async와 await로 하는 비동기 프로그래밍

async와 await로 하는 비동기 프로그래밍

작업 비동기 프로그래밍(TAP) 모델은 전형적인 비동기 코딩 위에 추상화 계층을 제공해요. 이 모델에서는 평소처럼 코드를 문(statement)들의 연속으로 작성하죠. 차이점은, 문 사이의 실행 순서를 직접 제어하는 대신 Task 객체로 표현된 진행 중인 작업이 결과를 주는 시점을 기준으로 실행 흐름이 결정된다는 거예요. 컴파일러가 각 작업을 완성하기 위해 많은 변환을 수행해요.

작업 비동기 프로그래밍의 목표는 "문장의 연속처럼 읽히지만 더 복잡한 순서로 실행되는 코드"를 가능하게 하는 거예요. 실행 순서는 외부 자원 할당과 작업 완료 시점에 기반해요. 이 문서는 아침 식사를 준비하는 예시를 통해 asyncawait 키워드가 일련의 비동기 지시문을 포함한 코드를 어떻게 더 쉽게 추론하게 해 주는지 보여 줍니다.

출처: Asynchronous programming — Microsoft Learn

비동기 작업은 병렬 작업과 다르다

요리를 해본 적이 있다면 아침 식사 지시를 비동기적으로 완수할 수 있어요. 달걀용 팬을 예열하면서 해시 브라운을 요리하기 시작하고, 빵을 토스터에 넣은 다음 달걀을 요리하죠. 각 단계에서 작업을 시작하고, 관심이 필요한 다른 작업으로 전환해요.

아침 식사 조리는 병렬(parallel)이 아닌 비동기 작업의 좋은 예시예요. 한 사람(또는 스레드)이 모든 작업을 처리할 수 있거든요. 한 사람은 이전 작업이 끝나기 전에 다음 작업을 시작하는 방식으로 비동기적으로 아침 식사를 만들 수 있어요. 각 요리 작업은 누군가 적극적으로 지켜보지 않아도 진행돼요.

반면 병렬 알고리즘은 요리하는 사람 여러 명(또는 여러 스레드)이 필요해요. 한 사람은 달걀을, 다른 사람은 해시 브라운을 요리하는 식이죠. 그런데 각 사람(또는 스레드)은 현재 작업이 끝날 때까지 동기적으로 차단(block)됩니다.

같은 동기 지시 목록을 C# 코드 문으로 쓰면 컴퓨터는 각 문장이 끝날 때까지 차단하고 다음 문장으로 넘어가서, 아침 식사 준비에 개별 작업 시간의 합인 약 30분이 걸려요. 컴퓨터가 비동기적으로 실행되게 하려면 비동기 코드를 작성해야 해요. 클라이언트 프로그램에서는 UI가 사용자 입력에 반응해야 하고, 서버 프로그램에서는 다른 요청을 처리할 수 있는 스레드를 차단하고 싶지 않죠. 비동기 대안이 있는데 동기 코드를 쓰는 건 확장을 저렴하게 하지 못하는 데다 차단된 스레드에 대한 비용을 치르는 거예요.

언어 지원 없이 비동기 코드를 작성하려면 콜백이나 완료 이벤트 같은 수단에 의존해야 하고, 이는 코드의 원래 의도를 흐리게 만들어요. 동기 코드의 장점은 단계별 실행이라 훑어보고 이해하기 쉽다는 거예요. 전통적인 비동기 모델은 코드의 근본적인 동작이 아니라 비동기적인 특성에 집중하게 합니다.

차단하지 말고, await를 써라

위 코드는 안타까운 관행을 드러내요: 비동기 작업을 수행하면서 동기 코드를 쓰는 거죠. 그 코드는 현재 스레드를 다른 어떤 작업도 못 하게 차단해요. 토스터에 빵을 넣고 빵이 튀어나올 때까지 멍하니 바라보는 것과 비슷해요 — 중단을 무시하고, 빵이 나올 때까지 다른 작업을 시작하지 않는 거예요.

await 키워드는 작업을 시작한 뒤 작업이 완료되면 실행을 계속하는 비차단(nonblocking) 방식을 제공해요. await를 쓰면서도 다른 작업을 동시에 처리할 수 있게 되는 거죠.

Task 클래스와 관련 형식들을 사용하면 진행 중인 작업에 이런 사고 방식을 적용할 수 있어요. 작업을 시작하고 그 작업을 나타내는 Task 객체를 붙잡아 둔 뒤, 결과가 준비될 때까지 그 작업을 기다리죠.

Coffee cup = PourCoffee();
Console.WriteLine("Coffee is ready");

Task<Egg> eggsTask = FryEggsAsync(2);
Egg eggs = await eggsTask;
Console.WriteLine("Eggs are ready");

Task<HashBrown> hashBrownTask = FryHashBrownsAsync(3);
HashBrown hashBrown = await hashBrownTask;
Console.WriteLine("Hash browns are ready");

Task<Toast> toastTask = ToastBreadAsync(2);
Toast toast = await toastTask;
ApplyButter(toast);
ApplyJam(toast);
Console.WriteLine("Toast is ready");

Juice oj = PourOJ();
Console.WriteLine("Oj is ready");
Console.WriteLine("Breakfast is ready!");

이 수정만으로는 아침 식사를 더 빨리 준비하지 못해요. await 식을 작업이 시작되자마자 모두 적용하고 있으니까요. 다음 단계는 해시 브라운과 달걀에 대한 await 식을 메서드 끝, 아침 식사를 서빙하기 직전으로 옮기는 거예요.

Coffee cup = PourCoffee();
Console.WriteLine("Coffee is ready");

Task<Egg> eggsTask = FryEggsAsync(2);
Task<HashBrown> hashBrownTask = FryHashBrownsAsync(3);
Task<Toast> toastTask = ToastBreadAsync(2);

Toast toast = await toastTask;
ApplyButter(toast);
ApplyJam(toast);
Console.WriteLine("Toast is ready");
Juice oj = PourOJ();
Console.WriteLine("Oj is ready");

Egg eggs = await eggsTask;
Console.WriteLine("Eggs are ready");
HashBrown hashBrown = await hashBrownTask;
Console.WriteLine("Hash browns are ready");

Console.WriteLine("Breakfast is ready!");

이제 아침 식사가 약 20분 만에 비동기적으로 준비돼요. 일부 작업이 동시에 실행되기 때문에 총 조리 시간이 줄었죠. 하지만 달걀과 해시 브라운이 타는 회귀가 생겨요 — 모든 비동기 작업을 한 번에 시작하고, 결과가 필요할 때만 각 작업을 기다리기 때문이에요. 이것은 웹 애플리케이션이 여러 마이크로서비스에 요청을 보내고 결과를 한 페이지로 합치는 것과 비슷한 패턴이에요.

작업으로 합성(composition) 지원하기

토스트를 만드는 과정은 비동기 작업(빵 굽기)과 동기 작업(버터·잼 바르기)의 합성이에요. 여기서 중요한 비동기 프로그래밍 개념이 나옵니다:

비동기 작업 뒤에 동기 작업이 이어지는 합성은 결국 비동기 작업이에요. 다시 말해, 작업의 어떤 부분이 비동기라면 전체 작업이 비동기입니다.

MakeToastWithButterAndJamAsync 메서드는 시그니처에 async 수식어를 넣어 컴파일러에게 이 메서드에 await 식과 비동기 연산이 포함돼 있음을 알려줘요. 이 메서드는 토스트를 구운 뒤 버터와 잼을 바르는 작업을 나타내고, 세 연산의 합성을 나타내는 Task<T> 객체를 반환해요.

이렇게 작업들을 새 메서드로 분리해 Task를 반환하게 만들면 작업을 합성할 수 있어요. 언제 그 작업을 기다릴지 선택할 수 있고, 다른 작업을 동시에 시작할 수도 있죠. 웹 페이지를 조합할 때처럼 모든 요청을 즉시 보낸 뒤 각 작업에 await 식을 적용하는 식으로요.

비동기 예외 처리하기

비동기 메서드도 동기 메서드처럼 예외를 던져요. 작업이 성공적으로 완료될 수 없으면 예외를 던지는데, 클라이언트 코드는 시작된 작업에 await 식을 적용했을 때 그 예외를 잡을 수 있어요. 아침 식사 예시에서 토스터가 빵을 굽는 동안 불이 났다고 가정해 볼게요. ToastBreadAsync 메서드를 수정해 시뮬레이션할 수 있어요.

private static async Task<Toast> ToastBreadAsync(int slices)
{
    for (int slice = 0; slice < slices; slice++)
    {
        Console.WriteLine("Putting a slice of bread in the toaster");
    }
    Console.WriteLine("Start toasting...");
    await Task.Delay(2000);
    Console.WriteLine("Fire! Toast is ruined!");
    throw new InvalidOperationException("The toaster is on fire");
    await Task.Delay(1000);
    Console.WriteLine("Remove toast from toaster");

    return new Toast();
}

비동기로 실행되는 작업이 예외를 던지면 그 작업은 실패(faulted) 상태가 돼요. Task 객체는 던져진 예외를 Task.Exception 속성에 담아요. 그리고 코드가 실패한 작업을 await하면 예외가 다시 던져집니다.

여기서 이해해야 할 메커니즘이 두 가지 있어요. 첫째, 예외가 어떻게 실패한 작업에 저장되는지. 둘째, 코드가 실패한 작업을 기다릴 때 예외가 어떻게 풀려서(unpackage) 다시 던져지는지.

비동기로 실행되는 코드가 예외를 던지면 예외는 Task 객체에 저장돼요. Task.Exception 속성은 AggregateException 객체인데, 비동기 작업 중에 여러 예외가 던져질 수 있기 때문이에요. 던져진 예외는 AggregateException.InnerExceptions 컬렉션에 추가되죠. Exception 속성이 null이면 새 AggregateException 객체가 만들어지고 던져진 예외가 그 컬렉션의 첫 항목이 됩니다.

실패한 작업의 가장 흔한 시나리오는 Exception 속성에 예외가 정확히 하나 들어 있는 경우예요. 코드가 실패한 작업을 기다릴 때 컬렉션의 첫 InnerExceptions 예외를 다시 던져요. 그래서 예제 출력이 AggregateException이 아니라 InvalidOperationException 객체로 보이는 거예요. 첫 번째 내부 예외를 꺼내면 비동기 메서드를 동기 메서드와 최대한 비슷하게 다룰 수 있죠. 여러 예외가 생길 수 있는 시나리오에서는 코드에서 Exception 속성을 직접 검사할 수 있어요.

권장 사항: 작업-반환 메서드에서 인자 검증 예외는 동기적으로 나타나는 게 좋아요.

await 식을 효율적으로 적용하기

Task 클래스의 메서드로 일련의 await 식을 개선할 수 있어요. Task.WhenAll 메서드는 인자 목록의 모든 작업이 완료되면 완료되는 Task 객체를 반환해요.

await Task.WhenAll(eggsTask, hashBrownTask, toastTask);
Console.WriteLine("Eggs are ready");
Console.WriteLine("Hash browns are ready");
Console.WriteLine("Toast is ready");
Console.WriteLine("Breakfast is ready!");

또 다른 방법은 Task.WhenAny 메서드인데, 인자 중 하나라도 완료되면 완료되는 Task<Task> 객체를 반환해요. 반환된 작업이 끝났다는 걸 알기 때문에 그 작업을 기다릴 수 있죠. 먼저 끝난 작업을 기다려 결과를 처리하고, 처리한 작업을 WhenAny에 전달한 작업 목록에서 제거해요.

var breakfastTasks = new List<Task> { eggsTask, hashBrownTask, toastTask };
while (breakfastTasks.Count > 0)
{
    Task finishedTask = await Task.WhenAny(breakfastTasks);
    if (finishedTask == eggsTask)
    {
        Console.WriteLine("Eggs are ready");
    }
    else if (finishedTask == hashBrownTask)
    {
        Console.WriteLine("Hash browns are ready");
    }
    else if (finishedTask == toastTask)
    {
        Console.WriteLine("Toast is ready");
    }
    await finishedTask;
    breakfastTasks.Remove(finishedTask);
}

코드 끝부분의 await finishedTask; 식이 중요한데요. Task.WhenAnyTask<Task> — 완료된 작업을 담고 있는 래퍼 작업 — 을 반환하기 때문이에요. Task.WhenAnyawait하면 래퍼 작업의 완료를 기다리는 것이고, 결과는 실제로 먼저 끝난 작업이에요. 그 작업의 결과를 가져오거나 예외를 제대로 던지려면 완료된 작업 자체(finishedTask에 저장된)를 다시 await해야 해요. 작업이 끝났다는 걸 알더라도 다시 기다려야 결과에 접근하거나 실패를 일으킨 예외를 처리할 수 있습니다.

이 최종 비동기 코드는 아침 식사 작업을 약 15분 만에 완료해요. 여러 작업을 동시에 모니터링하면서 필요할 때만 조치를 취하니까요. 핵심 동작은 여전히 코드를 읽어서 파악할 수 있어요. asyncawait 키워드가 제공하는 언어 기능이 바로 "가능한 한 작업을 시작하고, 작업이 완료되기를 기다리는 동안 차단하지 말 것"을 사람이 지시문을 따르듯 가능하게 해 줍니다.

async/await vs ContinueWith

asyncawait 키워드는 Task.ContinueWith를 직접 쓰는 것에 비해 구문적으로 단순해요. async/awaitContinueWith는 비동기 연산을 다루는 의미가 비슷하지만, 컴파일러가 await 식을 반드시 ContinueWith 메서드 호출로 번역하지는 않아요. 대신 같은 논리적 동작을 제공하는 최적화된 상태 머신 코드를 생성하죠. 이 변환은 특히 여러 비동기 연산을 이어붙일 때 가독성과 유지보수성 면에서 큰 이점을 줍니다.

ContinueWith를 쓰면 일련의 비동기 연산의 각 단계마다 중첩된 연속(continuation)이 필요해져요. 같은 순서의 연산을 async/await로 쓰면 훨씬 자연스럽게 읽힙니다. async/await 방식은 코드가 동기 코드처럼 읽혀 흐름을 이해하기 쉽고, 시퀀스에 단계를 추가·제거할 때 코드 변경이 최소화되며, try/catch로 자연스럽게 예외를 처리할 수 있고, 디버깅 경험도 좋죠. 연쇄 작업 수가 늘어날수록 그 이점이 커져요. 기능 프로그래밍에서 "모나딕 do-표기"라고 알려진 이 패턴은 여러 비동기 연산을 순차적이고 읽기 쉬운 방식으로 합성하게 해 줍니다.

더 알아보기 (Learn more)