동기식(synchronous) C# 코드에서는 예외가 호출 스택을 따라 위쪽으로 전파되다가, 해당 예외를 처리할 수 있는 적절한 catch 블록을 만나면 잡히게 됩니다. 하지만 비동기(asynchronous) 메서드에서의 예외 처리 방식은 이보다 훨씬 복잡합니다.
비동기 메서드의 반환 타입과 예외 전파
C#의 비동기 메서드는 세 가지 반환 타입을 가질 수 있습니다.
- void
- Task
- Task<T>
반환 타입이 Task 또는 Task<T>인 비동기 메서드에서 예외가 발생하면, 예외 객체는 AggregateException 인스턴스로 감싸진 뒤 해당 Task 객체에 첨부됩니다. 호출자는 이 Task를 await할 때 예외를 받게 되며, 일반적인 try-catch 블록으로 처리할 수 있습니다.
또한 여러 개의 예외가 발생한 경우에는 모든 예외가 하나의 Task 객체 안에 저장됩니다.
예제 1: Task 반환 비동기 메서드의 예외 처리
static async Task Main(string[] args) {
await DoSomething();
Console.ReadLine();
}
public static async Task Foo() {
throw new ArgumentNullException();
}
public static async Task DoSomething(){
try{
await Foo();
}
catch (ArgumentNullException ex){
Console.WriteLine(ex);
}
}실행 결과
System.ArgumentNullException: Value cannot be null. at DemoApplication.Program.Foo() in C:\Users\Koushik\Desktop\Questions\ConsoleApp\Program.cs:line 37 at DemoApplication.Program.DoSomething() in C:\Users\Koushik\Desktop\Questions\ConsoleApp\Program.cs:line 44
위 예제에서 Foo() 메서드 내부에서 발생한 ArgumentNullException는 DoSomething() 메서드의 catch 블록에서 정상적으로 잡힌 것을 확인할 수 있습니다. 이는 await 키워드가 Task에 첨부된 예외를 다시 던져(throw) 주기 때문입니다.
그렇다면 async void 메서드의 예외는 어떻게 될까?
반환 타입이 void인 비동기 메서드(async void)는 상황이 완전히 다릅니다. async void 메서드는 Task 객체를 반환하지 않기 때문에, 발생한 예외를 Task에 첨부할 방법이 없습니다. 대신 예외는 현재 실행 중인 SynchronizationContext에 직접 게시(post)되며, 호출자가 try-catch로 잡을 수 없습니다.
이로 인해 처리되지 않은 예외는 프로세스 전체를 크래시(crash)시킬 수 있습니다. 따라서 async void 메서드를 사용할 때는 반드시 메서드 본문 내부에서 try-catch로 예외를 처리해야 합니다.
async void 예외 처리 예제
public static async void Foo(){
try{
// 작업 수행
throw new ArgumentNullException();
}
catch (ArgumentNullException ex){
Console.WriteLine($"예외 처리됨: {ex.Message}");
}
}정리 및 모범 사례
- Task / Task<T> 반환 비동기 메서드의 예외는
AggregateException으로 감싸져Task에 저장되며,await시점에 일반적인try-catch로 처리할 수 있습니다. - async void 메서드의 예외는 호출자가 잡을 수 없으므로, 반드시 메서드 내부에서 직접 처리해야 합니다.
async void는 이벤트 핸들러 등 어쩔 수 없는 경우를 제외하고는 사용을 피하고, 가능한 한 항상Task또는Task<T>를 반환하는 것이 좋습니다.