Async/await 키워드 세 번째 글이네요. 앞서 두 글은 aysnc/await의 등장 배경을 설명했습니다. UI를 가진 어플리케이션에서 동기 프로그래밍의 문제점과 전통적인 APM(Asynchronous Programming Model) 비동기 프로그래밍 모델의 단점을 중점적으로 설명했는데요, 이번 글에서는 닷넷 프레임워크 4.5와 함께 등장한 C# 5.0의 async/await 키워드에 대해 본격적으로 살펴보도록 하겠습니다.

C#의 새로운 키워드 async/await

마이크로소프트는 APM 비동기 코드의 복잡성과 낮은 개발 생산성을 해결하기 위해 자신들의 컴파일러가 더 많은 작업을 하는 방법을 선택했다. 즉, 개발자는 동기 프로그래밍 모델과 유사하게 코드를 작성하더라도 컴파일러가 알아서 코드를 분리하여 적절한 완료 콜백(completion callback) 형태를 취하도록 코드를 생성해 준다는 것이다. 이 기능이 닷넷 프레임워크 4.5와 함께 변경된 C# 그리고 Visual Basic이 제공하는 기능이다. 여기에서는 C#에 대해서만 살펴볼 것이지만 Visual Basic 역시 동일한 메커니즘을 사용하므로 Visual Basic에 이 글의 내용을 적용하는데 크게 어렵지 않을 것이다.

Async/await 키워드 사용

Visual Studio 2012 버전 혹은 닷넷 프레임워크 4.5에 포함된 C# 컴파일러는 새로운 async 키워드와 await 키워드를 인식한다. 새로운 두 키워드를 한 두 마디로 설명하기 매우 어렵다. 그래서… 개발자들이 가장 잘 이해하는 의사 통신 수단인 예제 코드를 먼저 보는 것이 좋을 듯싶다. 리스트 1은 새로운 async/await 키워드를 사용하여 작성한 비동기 코드를 보여 주고 있다. 리스트 1 코드는 이 글의 첫 번째 시리즈에서 살펴본 동기 방식의 코드와 매우 흡사하다. 호출 완료를 기다리는 while 루프도 존재하지 않으며 비동기 호출에 대한 완료 콜백도 존재하지 않는다. 대신, button2_Click 메서드 앞에 Async 키워드가 삽입되었다. 그리고 GetResponse 메서드 호출 대신 닷넷 프레임워크 4.5에 새로이 추가된 비동기 메서드인 GetResponseAsync 메서드를 호출한다. 또 이 비동기 메서드를 호출할 때 await 키워드가 사용되었다. 이외의 부분은 동기 방식의 코드와 완전히 동일하다. 리스트 1의 코드는 UI를 블록시키지 않도록 비동기로 작동한다. 그럼에도 불구하고 코드는 직관적이며 가독성도 매우 높다. 또한 불필요하게 CPU 시간을 잡아먹는 Application.DoEvent 메서드를 호출도 없으며 콜백으로 인한 제어의 흐름이 분기되지도 않는다. 조쿠나~~~~

   1: private async void button2_Click(object sender, EventArgs e)
   2: {
   3:     progressBar1.Visible = true;
   4:     listBox1.Items.Clear();
   5:  
   6:     WebRequest request = WebRequest.Create(BlogUri);
   7:     WebResponse response = await request.GetResponseAsync();
   8:     Stream stream = response.GetResponseStream();
   9:  
  10:     var reader = XmlReader.Create(stream);
  11:     var feed = SyndicationFeed.Load(reader);
  12:     var items = new List<FeedItem>();
  13:  
  14:     foreach (var feedItem in feed.Items)
  15:     {
  16:         var item = new FeedItem(feedItem);
  17:         listBox1.Items.Add(item);
  18:     }
  19:  
  20:     progressBar1.Visible = false;
  21: }

[리스트 1] async/await 키워드를 이용한 비동기 코드

Async 키워드는 메서드 혹은 delegate 블록(람다 수식 및 익명 메서드)에만 사용할 수 있으며 await 키워드는 async 키워드가 붙어 있는 메서드 혹은 delegate 블록 내부에서만 사용할 수 있다. 다시 말해 두 키워드는 한 쌍으로 사용되어야 하며 어느 하나만을 사용할 수 없다. 비록 await 키워드 없이 async 키워드를 사용할 수도 있지만 잠시 후에 async 키워드가 어떤 역할을 하는지 살펴보면 이것이 아무런 의미도 없다는 것도 이해할 것이다. Async 키워드는 컴파일러에게 이 메서드의 내부에 await가 사용되고 있으므로 코드를 비동기 호출 형태로 변환하라는 지시 정도로 이해하면 된다.

Async 키워드의 다른 제약 사항으로, async 가 사용된 메서드는 리턴 타입으로 void 혹은 Task 혹은 Task<T> 만을 허용하며 매개변수로 ref, out 키워드를 사용할 수 없다. Await 키워드를 사용할 수 있는 메서드 호출은 그 메서드가 반드시 Task 혹은 Task<T>를 반환하는 메서드에 대해서만 사용이 가능하다. 또, await 키워드는 try~catch~finally 블록의 catch 및 finally 블록에서 사용할 수 없으며 lock, unsafe 키워드의 내부에서도 사용할 수 없다. 아놔 이 시키들은 뭐 길래 이렇게 많은 제약이 있는가? 이 두 키워드는 매우 특별한 코드를 생성하기 때문인데, 잠시 후에 async/await 키워드의 작동 방식을 설명할 때 다시 언급하도록 하겠다.

리스트 1의 await 키워드가 사용된 메서드 호출을 살펴보자. 비동기 프로그래밍을 다루었던 이전 글에서 살펴보았던 비동기 코드들은 BeginGetResponse 메서드를 호출했었다. BeginGetResponse 메서드는 전통적인 비동기 프로그래밍 모델을 위한 메서드이며 async/await에 사용할 수 없다. 왜냐하면 Task 혹은 Task<T> 타입을 반환하지 않기 때문이다. 이런 이유에서 닷넷 프레임워크 4.5의 클래스 라이브러리들은 대부분 ~Async 접미사를 사용하는 메서드를 제공한다. 이 메서드들은 모두 Task 혹은 Task<T> 타입을 반환하는 비동기 버전의 메서드로써 async/await 키워드와 함께 사용이 가능한 것이다. 만약 여러분이 기존에 작성한 동기 코드를 비동기적으로 처리하고 싶다면 동기 작업을 수행하는 코드를 Task 혹은 Task<T>를 통해 별도의 스레드에서 작동하도록 만들면 된다. 리스트 2는 이와 같은 예를 보여주고 있다.

   1: private async void YourEventHandler(object sender, EventArgs e)
   2: {
   3:     var result = await YourWorkAsync();
   4:  
   5:     UpdateUI(result);
   6: }
   7:  
   8: private Task<T> YourWorkAsync()
   9: {
  10:     Func<T> func = delegate()
  11:     {
  12:         return YourExistingSynchronousWork();
  13:     };
  14:     Task<T> task = new Task<T>(func);
  15:     task.Start();
  16:     return task;
  17: }

[리스트 2] Async/await 적용이 가능한 사용자 정의 메서드 예제(의사 코드)

Async/await 키워드 사용 시 제어의 흐름

Async/await 키워드가 사용되었을 때 코드의 수행 순서에 대한 독자들의 이해를 돕기 위해 리스트 3의 코드를 살펴보자.

   1: TestMethod();
   2: Console.WriteLine("TestMethod() return...");
   3: Console.ReadKey(true);
   4:  
   5: static async void TestMethod()
   6: {
   7:     Console.WriteLine("Code Block #1");
   8:     await Test1Async();
   9:     Console.WriteLine("Code Block #2");
  10:     await Test2Async();
  11:     Console.WriteLine("Code Block #3");
  12: }
  13:  
  14: static Task Test1Async()
  15: {
  16:     Action action = delegate()
  17:     {
  18:         Console.WriteLine("Task #1");
  19:         System.Threading.Thread.Sleep(1000);
  20:         Console.WriteLine("End of Task #1");
  21:     };
  22:     Task task = Task.Factory.StartNew(action);
  23:     return task;
  24: }
  25:  
  26: static Task Test2Async()
  27: {
  28:     Action action = delegate()
  29:     {
  30:         Console.WriteLine("Task #2");
  31:         System.Threading.Thread.Sleep(1000);
  32:         Console.WriteLine("End of Task #2");
  33:     };
  34:     Task task = Task.Factory.StartNew(action);
  35:     return task;
  36: }

[리스트 3] Async/await 키워드 사용시 제어 흐름 예제

리스트 3에서 TestMethod 메서드는 2개의 await 키워드를 사용하고 있는 비동기 메서드이다. 리스트 3의 코드를 수행했을 때 수행 결과는 다음과 같다.

Code Block #1
TestMethod() return...
Task #1
End of Task #1
Code Block #2
Task #2
End of Task #2
Code Block #3

코드 블록 #1이 수행된 후 곧바로 TestMethod 메서드가 제어를 반환하고 있음에 주목하자(Task #1 출력과 return 출력의 순서는 스레드 스케줄링에 의해 바뀔 수 있다). 그리고 비동기 적으로 Task #1 이 수행되며 Task #1 이 작업을 완료하면 코드 블록 #2가 수행된다. 그리고 다시 Task #2 가 비동기적으로 수행되고 Task #2가 완료된 후에야 코드 블록 #3가 수행되고 있다.

분명 리스트 3의 TestMethod 메서드는 하나처럼 보이는데 메서드 수행 도중 제어를 반환(return)하는 것일까? 이 질문에 대한 답은 Async/await 키워드가 C# 컴파일러에 의해 특별하게 처리되며 소스와는 다른 코드를 생성하기 때문이다.

Async/await 작동 방식

리스트 3의 수행 결과에 대한 비밀을 풀기 위해 async/await 키워드가 어떤 원리로 작동하는지 살펴보자. 지난 글에서 살펴보았던 완료 콜백 기반의 비동기 코드는 매우 복잡하며 UI 스레드 동기화까지 필요로 한다고 했었다. Async/await 키워드 역시 비슷한 방식의 완료 콜백 기반의 코드를 사용하도록 되어있다. 단지 코드를 분리하는 작업을 컴파일러가 해준다는 것만이 다르다. 즉, Async 키워드가 적용된 메서드 내에서 await 호출 이전과 이후가 별도의 메서드처럼 분리된다는 것이며 await 호출이 완료되면 await 이후 코드가 수행되도록 컴파일러가 코드를 생성해 준다는 것이다. 컴파일러에 의해 async/await가 처리된다는 점에 주목하자. CLR(Common Language Runtime)은 이들 키워드에 대해 전혀 알지 못한다. 닷넷 프레임워크 4.5의 컴파일러와 클래스 라이브러리에서 async/await를 위한 기능을 제공할 뿐이다.

예제 코드를 들어 보자. 리스트 4와 같이 async/await 호출을 수행하는 메서드를 개발자가 작성했다고 가정해 보자. 이 코드는 await 키워드를 기준으로 두 부분으로 나누어 볼 수 있다(코드 블록 1 및 코드 블록 2).

   1: private async void MyMethodAsync(string arg)
   2: {
   3:     // 코드 블럭 1
   4:  
   5:     T result = await ServerCallAsync(arg);
   6:  
   7:     // 코드 블럭 2
   8: }

[리스트 4] 간단한 async/await 비동기 메서드 예제

위 코드를 C# 컴파일러가 컴파일 하여 생성하는 코드는 리스트 5와 비슷하다(논리적인 수준에서… 실제 생성되는 코드는 비슷하지도 않다!).

   1: private void MyMethodAsync(string arg)
   2: {
   3:     // 코드 블럭 1
   4:  
   5:     Task<T> task = ServerCallAsync(arg);
   6:     TaskAwaiter<T> awaiter = task.GetAwaiter();
   7:     Action action = delegate()
   8:     {
   9:         T result = awaiter.GetResult();
  10:  
  11:         // 코드 블럭 2
  12:     }
  13:     awaiter.OnCompleted(action);
  14: }

[리스트 5] 리스트 4에 대해 C# 컴파일러가 생성하는 제어 흐름

리스트 5의 코드는 코드 블록 1(await 사용 전)과 서버 호출을 수행하는 부분은 동일하게 진행된다. 하지만 코드 블록 2(await 사용 후)의 코드는 별도의 익명 메서드로 묶고 서버 호출 메서드(위 경우, ServerCallAsync 메서드)가 반환하는 Task 객체의 완료 시 호출하는 완료 콜백으로 사용하는 것이다. 비동기 호출의 완료를 기다리고 호출 완료 시 완료 콜백을 호출해 주는 TaskAwaiter<T> 클래스는 닷넷 프레임워크 4.5에 새로이 추가된 클래스이다.

컴파일러가 생성해 주는 리스트 5의 코드는 완료 콜백을 사용하는 전통적인 비동기 코드과 매우 비슷하다. 리스트 5의 코드와 전통적인 비동기 코드는 모두 비동기 서버 호출 수행 후 이벤트 핸들러가 곧바로 제어를 반환하여 메시지 루프(혹은 디스패처)가 UI를 처리하도록 한다는 점에서 두 코드의 제어의 흐름은 같다고 할 수 있다. 비록 완료 콜백을 설정하는 코드가 존재하지만 이 코드들은 매우 간단한 작업만을 수행하므로 수행이 중단(block)되지 않는다. 이런 관점에서 보면 async/await 키워드는 UI 어플리케이션에서 비동기 프로그래밍을 쉽게 해주기 위해 등장했다고 보아도 과언이 아닐 정도이다.

리스트 3에서는 별도의 메서드를 사용하여 콜백 메서드를 구현했지만 리스트 7은 람다 식을 사용했다는 점이 다르지만 이는 소스 코드인 리스트 6의 문맥 상 MyMethodAsync 메서드의 매개변수인 arg를 코드 블록 2에서 사용할 수 있어야 하기 때문이다. 또, 리스트 3의 코드와 다른 부분으로는 UI 스레드 동기화 관련 코드의 유무이다. Async/await 키워드를 사용하면 TaskAwaiter 클래스가 자동으로 UI 스레드 동기화를 수행해주므로 코드 블록 2에 UI 변경 코드가 존재하는지 고민할 필요가 없다. 이런 관점을 리스트 4의 코드에 적용해 보면 리스트 3의 코드와 동등한 작업을 수행한다고 볼 수 있는 것이다. 다시 말해 개발자는 동기 서버 호출하는 것처럼 코드를 작성하지만 생성되는 코드는 비동기 호출과 호출 완료 콜백이 사용되며 CPU 자원을 낭비하지 않을 뿐 더러 UI 스레드 동기화를 수행해 준다는 것이다. 훌륭하지 않은가?

다음 글에는…

Async/await 를 사용한 코드를 실제로 C# 컴파일하고 컴파일 된 결과를 Reflector 와 같은 역 컴파일 도구로 살펴보면 코드는 매우 복잡하며 가독성도 매우 떨어진다. 그리고 리스트 5과 같이 익명 메서드를 사용하지도 않는다. 실제 C# 컴파일러가 생성하는 코드는 async 가 적용된 메서드마다 별도의 구조체(struct)를 생성하고 이 구조체의 메서드를 반복 호출 하는 방식으로 작동된다. 그리고 메서드 내부 구현은 상태 관리(state machine)와 goto 문장을 조합하여 코드 블록 1과 코드 블록2가 수행되도록 구성되어 있다. 실제로 이 코드를 독자들에게 설명하면 내용이 너무 길어지기 독자들의 이해를 돕는 수준의 의사 코드로서 리스트 5를 보여 주었을 뿐이다. 이렇게 C# 컴파일러가 생성하는 실제 코드가 복잡한 이유는 await 키워드가 반복문 내부에 존재하거나 try~catch 문장의 try 블록 내부에 존재하는 경우, 익명 메서드로 처리하기 매우 어려우며, 코드 블록 2에서는 여전히 메서드에서 선언된 로컬변수 등을 액세스 할 수 있어야 하기 때문이다. 다음 글에서 C# 컴파일러가 생성하는 코드에 대해 상세히 살펴보도록 하겠다.


경고 : 이 글을 무단으로 복제/스크랩하여 타 게시판, 블로그에 게시하는 것은 허용하지 않습니다.