광고


[.NET] Async/Await - Best Practices in Asunchronous Programming #1 (번역) ASP.NET MVC


0. 들어가기에 앞서

이 글은 Async/Await - Best Practices in Asunchronous Programming 를 번역한 글이다.
글 자체의 내용이 상당히 괜찮다고 판단하여, 최대한 번역에만 집중했다.


1. 서론

최근 .NET Framework 4.5에 추가된 async와 await에 대한 정보들이 넘쳐난다.
이 글은 asynchronous 프로그래밍을 학습하는 "second step"에 초첨이 맞추어져 있다.
(이 글을 읽는 당신은 적어도 저들에 관련된 소개글은 읽어 봤으리라는 가정 하에...)

이 글은 async/await을 사용함에 있어 몇몇 best practice를 제공하는 데 집중한다.

이 글의 best practice들에 대해 규칙이라기 보다, 가이드라인이라고 대했으면 한다.
각 가이드라인에는 예외들(사용하기에 좋지 않은)이 존재하며, 그 이유에 대해 설명할 것이다.
각각의 가이드라인은 다음의 표에 정리되어 있으며, 개별 챕터에서 자세히 설명하겠다.

NameDescriptionExceptions
Avoid async voidPrefer async Task methods over async void methodsEvent handlers
Async all the wayDon’t mix blocking and async codeConsole main method
Configure contextUse ConfigureAwait(false) when you canMethods that require con­text


2. Avoid async void

async 메쏘드는 세 가지 리턴 타입을 가질 수 있다.
  • Task
  • Task<T>
  • void
그러나 async 메쏘드의 natural한 리턴 타입은 Task와 Task<T> 뿐이다.

동기 함수를 비동기 함수로 변환시킬 때, 리턴 타입은 다음과 같이 변환되어야 한다.
  • T를 반환했던 함수는 Task<T> 리턴
  • void를 리턴했던 함수는 Task 리턴
다음의 예제는 void를 반환하던 동기 함수와 동등한 비동기 함수를 보여준다.

  1. void MyMethod()
  2. {
  3.     // Do synchronous work.
  4.     Thread.Sleep(1000);
  5. }
  6.  
  7. async Task MyMethodAsync()
  8. {
  9.     // Do asynchronous work.
  10.     await Task.Delay(1000);
  11. }


1) Asynchronous EventHandler #1

void를 반환하는 async 메쏘드는 특별한 목적을 가진다 : 비동기 이벤트 핸들러를 만들 수 있다.
특정 타입을 반환하는 이벤트 핸들러는 무엇인가 어색하며, 이벤트 핸들러의 개념상 특정 타입을 반환한다는 자체가 말이 되지 않는다. 
이벤트 핸들러는 보통 void를 반환하기에, async 메쏘드가 void를 반환하게 하여, 비동기 이벤트 핸들러를 만들 수 있는 것이다.


2) Exception handling

async void 메쏘드는 error-handling 방식이 다르다.
async Task 또는 Task<T> 메쏘드에서 외부로 예외가 던져질 때, 예외는 Task 객체에 캡쳐된다.
반면, async void 메쏘드의 경우 Task 객체가 존재하지 않기에, async void 메쏘드가 호출되자마자 생성되는 SynchronizationContext까지 바로(directly) 예외가 던져지게 된다.

다음의 예제는 위에서 얘기한 async void 메쏘드에서 발생한 예외를 catch하지 못함을 보여준다.

  1. private async void ThrowExceptionAsync()
  2. {
  3.     throw new InvalidOperationException();
  4. }
  5.  
  6. public void AsyncVoidExceptions_CannotBeCaughtByCatch()
  7. {
  8.     try
  9.     {
  10.         ThrowExceptionAsync();
  11.     }
  12.     catch (Exception)
  13.     {
  14.         // The exception is never caught here!
  15.         throw;
  16.     }
  17. }


3) Composing await semantic

async void 메쏘드는 다른 조합 의미론을 가진다.
async Task 또는 Task<T> 메쏘드는 await, Task.WhenAny, Task.WhenAll, 그리도 기타 등등 들과 쉽게 조합될 수 있다.
하지만, async void 메쏘드는 호출 코드가 완료될 때까지 대기하라는 것을 명시하기 쉽지 않다.
async void 메쏘드를 시작시키긴 쉽지만, 호출 코드가 완료되었는지를 결정하기 쉽지 않다는 것이다.
async void 메쏘드는 시작과 끝을 SynchonizationContext에 알리겠지만, 커스텀화된 SynchonizationContext는 보통의 어플리케이션 코드에서 다루기가 상당히 복잡하다.


4) Testability

지금까지 살펴본 async void 메쏘드의 예외 처리, await 조합 방식 차이로 인해 unit 테스트 코드를 작성하는 것이 어려워져, 테스트 하기도 쉽지 않다.
MSTest 의 비동기 함수 테스팅은 async Task 또는 Task<T> 메쏘드만을 지원한다.
SynchonizationContext를 이용해 async void 메쏘드의 완료나 예외 발생을 알 수 있긴 하지만, async Task 또는 Task<T> 메쏘드를 사용하는 것이 훨씬 더 쉽다.


5) Asynchronous EventHandler #2

async void 메쏘드가 async Task 또는 Task<T> 메쏘드에 비해 (지금까지 알아본) 몇몇 disadvantages들이 있음은 명확하다.
하지만, 여전히 비동기 이벤트 핸들러를 만들 수 있다는 사실에선 확실히 유용하다.
또한 async void 메쏘드가 async Task 또는 Tassk<T> 메쏘드들과 다른 의미론을 가지는 것이 이벤트 핸들러의 의미론에선 말이 된다.
async void 메쏘드의 예외가 SynchonizationContext로 바로 던져지는 것은 동기 이벤트 핸들러의 그것과 유사하다.
동기 이벤트 핸들러들은 종종 private 하기에, 조합되거나 직접 테스팅되지 못한다.

내가 비동기 이벤트 핸들러 코드를 작성할 때 좋아하는 접근법은 비동기 이벤트 핸들러 함수의 코드를 최소화하는 것이다.
예를 들어, 실제 동작해야 하는 코드는 async Task 또는 Task<T> 메쏘드로 작성하고, async void 메쏘드가 그것을 호출하는 방식으로 작성한다. 

다음의 예제는 이 내용을 설명해 주며, async void 메쏘드를 사용하면서, 테스트 용이성을 잃지 않도록 한다.

  1. // event-handler
  2. private async void button1_Click(object sender, EventArgs e)
  3. {
  4.     // compose await
  5.     await Button1ClickAsync();
  6. }
  7.  
  8. // public access for Test
  9. public async Task Button1ClickAsync()
  10. {
  11.     // Do asynchronous work.
  12.     await Task.Delay(1000);
  13. }


6) Confusion of caller : sync or async?

async void 메쏘드는 메쏘드 호출자가 async임을 알거나 예측하지 못할 경우 혼란을 야기할 수 있다.
반환 타입이 Task 또는 Task<T> 일 경우 호출자는 async 임을 바로 알 수 있지만, 
반한 타입이 void 인 경우 호출자는 반환과 동시에 완료된 것, 즉 sync 함수라고 추정할 수도 있는 것이다.

이 문제는 많은 예기치 않은 방식으로 발생할 수 있다.
인터페이스 또는 기본 클래스에 async void 메쏘드의 구현(또는 오버라이드)을 제공하는 것은 대체적으로 잘못된 것이다.
또한, 일부 이벤트는 핸들러가 반환될 때 핸들러가 완료되었다고 가정한다. 즉, sync 메쏘드로 가정한다.

하나의 미묘한 트랩은 비동기 람다를 Action의 매개변수로 전달하는 것이다.
이 경우 비동기 람다는 void를 반환하고, async void 메쏘드의 모든 문제를 고스란히 상속받게 된다. (???)
일반적으로, 비동기 람다는 Task(Ex. Func<Task>)를 반환하는 delegate 타입으로 변환되는 경우에만 사용해야 한다.


7) Summary

첫 가이드라인을 정리하자면, async Task 또는 Task<T> 메쏘드 사용을 선호해야 한다는 것이다.
async Task 또는 Task<T> 메쏘드는 예외 처리, 조합, 그리고 테스트를 쉽게 할 수 있다.

유일한 예외가 비동기 이벤트 핸들러인데, 이벤트 핸들러는 void를 반환하는 것이 일반적이다.


3. Async all the way

동기식 코드를 비동기식으로 변환했을 때, 이 메쏘드가 비동기 코드를 호출하거나, 이 메쏘드를 비동기 코드가 호출했을 때 가장 이상적으로 동작함을 발견할 수 있다.
혹자는 비동기 프로그래밍의 확산성을 알아 차리고, 이를 "전염성이 있다"거나 좀비 바이러스와 비교하곤 한다.

비동기 프로그래밍은 주변 코드들을 비동기로 몰아가는 경향이 있고, 
이것은 비단 async/await 키워드를 떠나 전반적인 비동기 프로그래밍 모두에게 해당한다.


1) Deadlock possible

"Async all the way"의 의미는 동기와 비동기 코드를 섞지 말라는 것이다. (섞을 땐 매우 면밀하게 결과를 살펴봐야 한다)
특히나, Task.Wait 또는 Task.Result를 호출하여, 비동기 코드를 블록시키는 것은 일반적으로 잘못된 생각이다.

비동기 프로그래밍에 발버둥 치는 프로그래머에게 흔히 발생하는 문제이며,
응용 프로그램의 일부만을 동기식 API로 래핑하여, 응용 프로그램의 나머지 부분은 변경 사항으로부터 격리된다.

불행하게도 이들은 데드락에 노출될 것이다.
수없이 많은 MSDN 포험, StackOverflow, E-Mail의 비동기 관련된 질문들에 답변을 준 뒤에, 
나는 이것을 가장 빈번했던 질문으로 꼽을 수 있다 : "왜 비동기 코드 일부가 데드락에 빠지는가?"

다음 예제는 하나의 메쏘드가 비동기 메쏘드의 결과를 블록시키는 것을 보여준다.
이 코드는 콘솔 응용 프로그램에서는 제대로 동작하지만, GUI 또는 ASP.NET 컨텍스트에서 호출될 경우 데드락에 빠질 것이다.
특히 디버거를 단계별로 실행할 때 결코 완료되지 않을 것임을 염두에 두어야 한다.

데드락의 실제 원인은 Task.Wait이 호출될 때 쓰레드를 블록시키는 것이다.
Task.Wait은 동기적 블럭 방식이며, Task가 완료될 때까지 해당 쓰레드를 블록시키기 때문이다.

  1. public static class DeadlockDemo
  2. {
  3.     private static async Task DelayAsync()
  4.     {
  5.         await Task.Delay(1000);
  6.     }
  7.  
  8.     // This method causes a deadlock when called in a GUI or ASP.NET context.
  9.     public static void Test()
  10.     {
  11.         // Start the delay.
  12.         var delayTask = DelayAsync();

  13.         // Wait for the delay to complete.
  14.         delayTask.Wait();
  15.     }
  16. }

위 데드락의 근본 원인은 await이 context를 핸들링하는 방식 때문이다.

기본적으로 완료되지 않은 Task를 기다릴 때 현재 context는 캡쳐되어, Task가 완료될 때 비동기 메쏘드를 재개하는데 사용된다.
SynchronizationContext가 null이 아닐 경우, SynchronizationContext이 context이며, SynchronizationContext가 null인 경우는 TaskScheduler인 경우다.
GUI 및 ASP.NET 응용 프로그램에서는 한 번에 하나의 코드 묶음만 실행할 수 있는 SynchronizationContext를 가진다.

await이 완료될 때, 캡쳐된 context 내에서 async 메쏘드의 나머지 부분을 실행하려 시도하지만,
이미 context는 async 메쏘드를 완료시키기 위해 (동기적으로) 대기중인 쓰레드를 가진다.

Wait은 context가 가진 쓰레드를 블록시킨 뒤, 완료되길 대기하고 있고,
context는 Wait이 블록시킨 쓰레드가 깨어나, context가 complete 되기를 대기한다.
이런 교착 상태, 데드락인 것이다.

콘솔 어플리케이션에서는 이것이 데드락을 유발하지 않음을 주목하라.
콘솔 어플리케이션은 한 번에 하나의 코드 묶음만 실행할 수 있는 SynchronizationContext 대신 Thread pool SynchronizationContext를 가진다.
그렇기에 await이 완료될 때, async 메쏘드의 나머지 부분은 thread pool thread에 스케쥴링된다.

이 부분은 프로그래머에게 혼란을 주는 데, 콘솔 어플리케이션에서 테스트할 때 문제 없던 코드를 GUI 또는 ASP.NET 어플리케이션으로 옮겨갈 때 데드락이 발생하기 때문이다.

이 문제에 대한 최선의 해결책은 비동기 코드가 코드베이스를 통해 자연스럽게 성장하도록 허용하는 것이다.
이 해결책을 따르면, 비동기 코드가 자신의 엔트리 포인트(보통 이벤트 핸들러나 컨트롤러 액션)으로 확장되는 것을 확인할 수 있을 것이다.
콘솔 어플리케이션은 이 해결책을 완전히 따르지 않는데, 메인 함수가 비동기가 될 수 없기 때문이다.
만약, 메인 함수가 비동기였다면 완료되기 전에 반환될 수 있어 프로그램이 바로 종료되어 버릴 것이다.

아래 예제는 이 가이드라인에 대한 예외를 보여준다.
콘솔 어플리케이션의 메인 함수는 비동기 메쏘드를 블록해도 되는 몇 안 되는 상황이다.

  1. class Program
  2. {
  3.     static void Main()
  4.     {
  5.         MainAsync().Wait();
  6.     }
  7.  
  8.     static async Task MainAsync()
  9.     {
  10.         try
  11.         {
  12.             // Asynchronous implementation.
  13.             await Task.Delay(1000);
  14.         }
  15.         catch (Exception ex)
  16.         {
  17.             // Handle exceptions.
  18.         }
  19.     }
  20. }

비동기 코드가 코드베이스를 통해 자라나는 것이 최선의 해결책이지만, 
비동기 코드를 통해 어플리케이션이 실질적 이득을 얼마나 얻을 수 있는지 면밀이 살펴 보아야 한다는 것을 의미하기도 한다.
점진적으로 방대한 코드베이스를 비동기 코드로 변환하는 몇몇 테크닉이 있으나, 이 글의 범위를 벗어나기에 설명은 하지 않는다.

몇몇 경우 Task.Wait이나 Task.Result를 사용하는 것이 부분적 변환에 도움이 되긴 하나, 데드락 문제 그리고 예외 처리 문제에 대해 인지하고 있어야 한다.
지금부터 예외처리 문제에 대해 설명할 것이며, 데드락을 회피하는 방법에 대해서는 이 글 후반에 다시 설명하겠다.


2) Complex exception handling

모든 Task는 예외 목록을 저장한다. Task를 await 할 때, 첫번째 예외가 다시 던져지면 특정 예외 타입에 대해 catch가 가능하다.
(예를 들어, InvalidOperationException 같은...)
그러나, Task.Wait이나 Task.Result를 통해 Task를 블록시킬 때, 모든 예외는 AggregationException으로 래핑되어 던져진다.

  1. class Program
  2. {
  3.     static void Main()
  4.     {
  5.         MainAsync().Wait();
  6.     }
  7.  
  8.     static async Task MainAsync()
  9.     {
  10.         try
  11.         {
  12.             // Asynchronous implementation.
  13.             await Task.Delay(1000);
  14.         }
  15.         catch (Exception ex)
  16.         {
  17.             // Handle exceptions.
  18.         }
  19.     }
  20. }

다시 위의 예제에서 MainAsync 함수의 try-catch는 특정 예외 타입을 잘 캐치해 내겠지만,
만약, try~catch 문이 Main() 함수에 있다면, 늘 AggregationException을 캐치할 것이다.
AggregationException이 발생하지 않아야 예외 처리가 쉽기에, MainAsync 함수에 try-catch르르 건 것이다.

지금까지 비동기 코드를 블록시킬 때 나타다는 두 가지 문제를 살펴 보았다
  • 데드락 가능성
  • 복잡한 예외 처리

3) Synchronous blocking within async code

또한, 비동기 메쏘드 내에서 블로킹 코드를 사용하는 문제도 있는데, 다음의 예제를 살펴보자.

  1. public static class NotFullyAsynchronousDemo
  2. {
  3.     // This method synchronously blocks a thread.
  4.     public static async Task TestNotFullyAsync()
  5.     {
  6.         await Task.Yield();
  7.         // blocking
  8.         Thread.Sleep(5000);
  9.     }
  10. }

위 함수는 온전히 비동기로 동작하지 않는다. 
그것은 즉시 yield 할 것이며, 완료되지 않은 Task가 반환될 것이다.
하지만, 함수가 재개될 때 동기적으로 재개될 때, 실행중이던 쓰레드가 블록된다.

만약 이 함수가 GUI context에서 호출되었다면, GUI 쓰레드를 블록시킬 것이고,
ASP.NET request context에서 호출되었다면, 현재 ASP.NET의 request 쓰레드를 블록시킬 것이다.
비동기 코드는 동기적 블로킹이 없어야 최상으로 동작한다.

아래 표는 동기 오퍼레이션을 대체할 수 있는 비동기 cheat sheet이다.
  • Retrieve the result of a background task
     Task.Wait or Task.Result -> await

  • Wait for any task to complete
    Task.WaitAny -> await Task.WhenAny

  • Retrieve the results of multiple tasks
    Task.WaitAll -> await Task.WhenAll

  • Wait a period time
    Thread.Sleep -> await Task.Delay

4) Summary

두 번째 가이드라인을 요약하자면, async and blocking 코드를 섞어 쓰지 말라는 것이다.
뒤섞인 async and blocking 코드는...
  • 데드락을 유발할 수 있고
  • 훨씬 더 복잡한 예외 처리
  • 예기치 않은 쓰레드 블로킹을 유발한다.
이 가이드라인의 유일한 예외는 콘솔 어플리케이션의 메인 함수이다.




1 2 3 4 5 6 7 8 9 10 다음