Menu

C# async await: Task, Task<T>, WhenAll과 흔한 함정

async와 await를 쓰면 C# 코드가 스레드를 막지 않고 느린 작업을 기다릴 수 있습니다. 비동기 메서드가 실행되는 방식, Task와 Task<T>, Task.WhenAll로 독립적인 작업을 동시에 실행하기, 비동기 코드의 예외, 그리고 async void와 .Result가 버그와 교착 상태를 일으키는 이유를 알아봅니다.

이 페이지에는 실행 가능한 에디터가 있습니다 - 편집하고 실행하면 결과를 바로 볼 수 있습니다.

프로그램의 느린 작업은 대부분 기다림입니다. 웹 서버의 응답, 데이터베이스가 행을 반환하는 것, 파일을 읽는 것을 기다립니다. async와 await는 이런 기다림 동안 스레드를 붙잡아 두지 않고 메서드를 일시 정지했다가 멈춘 곳에서 이어 가게 해 줍니다. 코드는 여전히 평범한 코드처럼 위에서 아래로 읽힙니다.

첫 비동기 메서드

async 메서드는 Task(결과 없음)나 Task<T>(T 타입의 결과)를 반환합니다. 그 안에서 await는 다른 태스크를 기다리고 결과를 줍니다.

출력:

Tea 2.50, cake 4.00, total 6.50

GetPriceAsync는 return 2.50m이라고 쓰고, 컴파일러가 이를 메서드가 반환하는 Task<decimal>로 감쌉니다. await가 다시 이를 풉니다. Task.Delay는 Thread.Sleep의 비동기 버전입니다. 그동안 스레드를 막지 않고, 시간이 지나면 완료됩니다.

C# 7.1부터는 Main 자체가 async일 수 있으며, 최신 .NET에서는 이 프로그램을 이렇게 씁니다:

static async Task Main()
{
    decimal tea = await GetPriceAsync("tea");
    Console.WriteLine(tea);
}

이 페이지의 실행 가능한 예제는 일반 Main에서 .GetAwaiter().GetResult()로 비동기 RunAsync 메서드를 호출하며, 이는 컴파일러가 async Main에 대해 생성하는 것과 같습니다. 콘솔 앱에서는 안전합니다. 같은 호출이 UI 코드에서 왜 위험한지는 교착 상태 절에서 설명합니다.

await에서 일어나는 일

비동기 메서드는 끝나지 않은 태스크에 대한 첫 await까지 동기적으로 실행됩니다. 그 지점에서 호출자에게 Task를 반환하고, 메서드의 나머지는 기다리던 태스크가 완료되면 실행되는 연속 작업이 됩니다.

출력:

Calling DownloadAsync
  Download: starting
DownloadAsync returned a task; doing other work
  Download: finished
Download awaited

첫 await까지의 모든 것이 호출자의 스레드에서 실행되므로, DownloadAsync가 반환되기 전에 "Download: starting"이 출력됩니다. 그다음 메서드는 끝나지 않은 태스크를 건네고, 호출자는 계속 진행하며, "Download: finished"는 지연이 끝났을 때에야 나타납니다. 비동기 메서드를 호출하면 시작되고, await하는 것이 결과를 기다리는 방법입니다.

async는 스레드를 만들지 않습니다. 메서드가 일시 정지된 동안에는 기다리는 스레드가 전혀 없으며, 그래서 서버가 소수의 스레드만으로 데이터베이스를 기다리는 수천 개의 요청을 가질 수 있습니다. 다른 스레드에서 실행해야 하는 CPU 중심 작업에는 태스크에서 다루는 Task.Run을 쓰세요.

Task.WhenAll로 작업을 동시에 실행하기

호출을 하나씩 await하는 것은 순차적입니다. 각 호출은 앞의 호출이 끝난 뒤에야 시작됩니다. 작업이 서로 의존하지 않는다면 모두 시작한 뒤 Task.WhenAll로 함께 await하세요:

출력 예:

Sequential: 160 units in ~900 ms
Concurrent: 160 units in ~300 ms

순차 버전은 세 번의 기다림의 합이 걸리고, 동시 버전은 가장 느린 것만큼 걸립니다. Task.WhenAll은 어느 것이 먼저 끝났든 넘긴 태스크와 같은 순서로 결과를 반환합니다. 리스트에도 동작하며, "모든 항목 가져오기"의 흔한 모양입니다: await Task.WhenAll(ids.Select(id => FetchAsync(id))).

Task.WhenAny는 첫 태스크가 완료되자마자 완료되는 짝으로, 시간 제한과 "먼저 온 답이 이긴다"에 유용합니다.

비동기 코드의 예외

비동기 메서드 안에서 던져진 예외는 반환된 태스크에 저장되었다가 태스크를 await할 때 다시 던져지므로, await 주위의 평범한 try/catch가 동작합니다:

출력:

Task created, completed yet: False
Caught ArgumentOutOfRangeException for userId
WhenAll rethrew the first failure
All failures: 1
The other task still succeeded: profile 7

LoadProfileAsync(-1)을 호출해도 예외가 나지 않았습니다. 무언가가 await할 때까지 예외는 태스크 안에 있습니다. 아무도 await하지 않는 태스크는 예외를 완전히 숨기며, 시작한 것은 항상 await해야 하는 또 하나의 이유입니다. WhenAll에서 await는 첫 예외를 다시 던지고, 결합된 태스크의 Exception 속성(AggregateException)이 모두를 담습니다. ok는 이미 완료되었으므로 거기서 ok.Result를 읽는 것은 괜찮습니다.

async void: 이벤트 처리기에만

비동기 메서드는 void를 반환할 수도 있습니다. 이벤트 처리기 말고는 어디서든 피하세요:

// Bad: the caller cannot await it or catch its exceptions.
static async void SaveAsync(Order order)
{
    await db.InsertAsync(order);   // if this throws, the process may crash
}

// Good: return Task, so callers can await and handle errors.
static async Task SaveAsync(Order order)
{
    await db.InsertAsync(order);
}

// Acceptable: an event handler must return void.
private async void SaveButton_Click(object sender, EventArgs e)
{
    try { await SaveAsync(currentOrder); }
    catch (Exception ex) { ShowError(ex); }
}

async void에는 await할 태스크가 없으므로 호출하는 쪽은 작업이 끝나기 전에 계속 진행하고, 안에서 던져진 예외는 동기화 컨텍스트(또는 스레드 풀)에서 바로 발생해 호출하는 쪽의 어떤 try/catch도 닿을 수 없습니다. 콘솔이나 서버 앱에서는 보통 프로세스가 종료됩니다. async void 이벤트 처리기 안에서는 모든 것을 직접 잡으세요.

관련된 실수는 await 없이 비동기 메서드를 호출하는 것입니다. 컴파일러가 경고하고(CS4014), 메서드는 결과도 오류도 아무도 지켜보지 않는 채 백그라운드에서 실행됩니다.

.Result와 .Wait()로 인한 교착 상태

.Result나 .Wait()로 태스크를 막고 기다리는 것이 비동기 코드가 가장 자주 잘못되는 곳입니다. 동기화 컨텍스트가 있는 애플리케이션(WinForms, WPF, MAUI, 클래식 ASP.NET)에서 순서는 이렇습니다:

  1. UI 스레드가 GetDataAsync().Result를 호출하고 태스크를 기다리며 막힙니다.
  2. GetDataAsync 안에서 await가 끝납니다. 기본적으로 그 연속 작업은 시작된 컨텍스트, 즉 UI 스레드에서 실행되어야 합니다.
  3. UI 스레드는 1단계에서 막혀 있으므로 연속 작업이 절대 실행되지 않고, 그래서 태스크가 절대 완료되지 않고, 그래서 1단계가 절대 끝나지 않습니다.

앱은 예외 없이 멈춥니다. 콘솔 앱과 ASP.NET Core에는 그런 컨텍스트가 없으며, 그래서 같은 코드가 테스트 프로그램에서는 동작하고 데스크톱 앱에서는 멈춥니다. 해결책:

  • 막고 기다리는 대신 호출 체인 끝까지 await를 쓰세요("끝까지 async"). 이를 위해 이벤트 처리기는 async void일 수 있습니다.
  • 호출하는 쪽의 컨텍스트로 돌아갈 필요가 없는 라이브러리 코드에서는 await SomethingAsync().ConfigureAwait(false);라고 쓰세요. 그러면 연속 작업이 캡처된 컨텍스트 대신 스레드 풀에서 실행되고, 라이브러리는 불필요한 스레드 전환을 건너뜁니다. 체인의 모든 await가 똑같이 해야만 막고 기다리는 호출자를 보호하므로, .Result에 대한 해결책이 아니라 좋은 라이브러리 습관으로 여기세요.

ASP.NET Core의 애플리케이션 코드에는 돌아갈 컨텍스트가 없으므로 ConfigureAwait(false)가 필요 없습니다.

흔한 실수

  • 독립적인 호출을 하나씩 await하기. 시작한 뒤 await Task.WhenAll(...)을 쓰세요.
  • async void 메서드. Task를 반환하고, async void는 이벤트 처리기에만 쓰세요.
  • UI나 클래식 ASP.NET 코드의 .Result와 .Wait(). 교착 상태가 됩니다. 대신 await하세요.
  • await를 잊기. 작업이 지켜보는 이 없이 실행되고 예외가 사라집니다.
  • I/O를 Task.Run으로 감싸기. File.ReadAllTextAsync나 HttpClient.GetStringAsync 같은 비동기 API는 이미 스레드를 풀어 줍니다. Task.Run은 스레드 풀 이동만 더합니다.
  • async가 "다른 스레드에서 실행된다"는 뜻이라고 가정하기. "막지 않고 일시 정지할 수 있다"는 뜻입니다. CPU 중심 작업에는 Task.Run을 쓰세요.

자주 묻는 질문

C#에서 async와 await는 어떻게 동작하나요?

메서드를 async로 표시하면 await를 쓸 수 있습니다. 메서드가 끝나지 않은 태스크에 대한 await에 도달하면, 작업의 나머지를 나타내는 Task를 건네며 즉시 호출자에게 반환합니다. 기다리던 작업이 완료되면 메서드는 await 뒤에서 다시 시작합니다. 기다리는 동안 막혀 있는 스레드는 없습니다.

Task와 Task<T>의 차이는 무엇인가요?

Task는 값을 만들지 않는 작업으로, void 메서드의 비동기 버전입니다. Task<T>는 T를 만드는 작업입니다. Task<int>를 await하면 int를 얻습니다. async Task<int>로 선언한 비동기 메서드는 그냥 return 42;라고 쓰고, 컴파일러가 이를 태스크로 감쌉니다.

여러 비동기 작업을 동시에 실행하려면 어떻게 하나요?

먼저 모두 시작한 뒤 함께 await하세요: var a = GetUserAsync(); var b = GetOrdersAsync(); await Task.WhenAll(a, b);. await GetUserAsync(); await GetOrdersAsync();라고 쓰면 차례로 실행되므로, 총 시간이 가장 긴 것이 아니라 합계가 됩니다.

C#에서 async void가 나쁜 이유는 무엇인가요?

async void 메서드는 await할 수 없으므로 호출하는 쪽이 언제 끝나는지 모르고, 그 안에서 던져진 예외를 호출하는 쪽이 잡을 수 없습니다. 예외는 동기화 컨텍스트에서 발생하며 보통 프로세스를 죽입니다. 대신 Task를 반환하세요. async void는 시그니처가 void를 요구하는 이벤트 처리기를 위해서만 존재합니다.

.Result나 .Wait()가 교착 상태를 일으키는 이유는 무엇인가요?

동기화 컨텍스트가 있는 앱(WinForms, WPF, 클래식 ASP.NET)에서 .Result는 UI나 요청 스레드를 막고, await한 메서드의 연속 작업은 바로 그 스레드에서 실행되기를 기다립니다. 서로가 서로를 영원히 기다립니다. 막지 말고 끝까지 await를 쓰고, 라이브러리 코드 안에서는 ConfigureAwait(false)를 쓰세요.

C#에서 Main을 async로 만들 수 있나요?

네, C# 7.1부터 가능합니다: static async Task Main()이나 static async Task<int> Main(). 최상위 문(C# 9)에서는 await를 바로 쓸 수 있습니다. 예전 코드에서는 일반 Main에서 MainAsync().GetAwaiter().GetResult()를 호출하는 것이 같은 방법이며, 콘솔 앱에는 동기화 컨텍스트가 없으므로 거기서는 안전합니다.

Coddy programming languages illustration

Coddy로 코딩 배우기

시작하기