CodeGym /행동 /C# SELF /비동기 코드에서 예외 처리

비동기 코드에서 예외 처리

C# SELF
레벨 59 , 레슨 4
사용 가능

1. 비동기 메서드와 예외

코드에서 뭔가 잘못되면(예: 0으로 나누기나 존재하지 않는 파일에 접근하려고 할 때) 예외가 throw되고 우리는 try-catch로 잡을 수 있다는 것에 익숙합니다. 순차적이고 단일 스레드에서 실행될 때는 간단하죠. 하지만 비동기성이 생기면 세상이 우주처럼 열려버립니다: 예외가 우리가 기대한 위치에서 멀리 "발생"할 수 있고, 심지어 완전히 눈에 띄지 않을 수도 있습니다.

이유는 비동기 메서드가 종종 작업(Task)을 반환하고 그 작업의 실행이 메서드에서 나간 이후에도 계속된다는 점입니다. 예외는 메인 스레드가 실행을 "놓아준" 후에 발생할 수 있습니다. 그래서 비동기 메서드 호출 주변의 익숙한 try-catch가 동기 코드처럼 항상 동작하지 않습니다.

간단한 예제로 살펴보죠. 우리 미니 앱에 이런 비동기 메서드가 있다고 합시다:

// 우리 애플리케이션 조각: "리포트 전송" 비동기 계산
public async Task SendReportAsync()
{
    // 여기에는 네트워크 호출이나 파일 접근이 있을 수 있음
    await Task.Delay(100);
    throw new InvalidOperationException("리포트 전송 중 오류!");
}

이걸 호출하면 이렇게 될 수 있습니다:

SendReportAsync();
Console.WriteLine("계속 작업합니다...");

시각화

flowchart TD
    Start["메인 스레드"]
    Call[/"SendReportAsync() 호출"/]
    Continue["작업이 계속됨..."]
    Exception["Task에서 예외 발생"]
    Unhandled["예외 처리되지 않음!"]
    Start --> Call --> Continue
    Call -.- Exception --> Unhandled

결론: 비동기 메서드가 Task를 반환하고 당신이 그 작업이 끝날 때까지 기다리지 않으면(await 또는 .Wait()) 예외는 "눈에 띄지 않게" 됩니다. 최선의 경우 런타임이 "Task에서 처리되지 않은 예외" 같은 로그를 남기고, 최악의 경우 오류를 잃어버려서 원인을 찾느라 고생할 수 있습니다.

2. 비동기 코드에서 예외를 올바르게 잡는 방법

await + try-catch 사용

올바른 방법을 보겠습니다:

try
{
    await SendReportAsync(); // Task 완료를 기다림
    Console.WriteLine("리포트가 성공적으로 전송됨!");
}
catch (Exception ex)
{
    Console.WriteLine($"앗! 뭔가 잘못됐어요: {ex.Message}");
}

이게 어떻게 동작하나? 호출 앞에 await를 두면 C#은 당신의 메서드를 await 전과 후 두 부분으로 분해합니다. 비동기 부분에서 예외가 발생하면 그 예외는 await를 쓴 지점에서 튀어나오고, 고전적인 try-catch로 잡을 수 있습니다.

애플리케이션용 예제

데모에 리포트 전송 에러 처리를 추가해봅시다:

public async Task StartReportProcessAsync()
{
    try
    {
        await SendReportAsync();
        Console.WriteLine("리포트가 성공적으로 전송됨!");
    }
    catch (Exception ex)
    {
        Console.WriteLine($"리포트 전송 중 오류: {ex.Message}");
    }
}

그리고 호출:

await StartReportProcessAsync();

.Wait(), .Result — 콘솔에선 최선은 아니지만 동작하는 방법

때때로 특히 콘솔 앱에서 최상위에서 await를 사용할 수 없는 경우(구버전 C#, Main 메서드)가 있습니다. 이럴 땐 .Wait()이나 .Result로 동기적으로 작업을 기다려야 할 때가 있습니다.

try
{
    SendReportAsync().Wait();
}
catch (AggregateException aggEx)
{
    foreach (var ex in aggEx.InnerExceptions)
        Console.WriteLine($"오류: {ex.Message}");
}

왜 이러나? .Wait().Result 호출은 원래의 예외를 항상 AggregateException으로 래핑합니다. 이 컨테이너는 하나 이상(여러 개)의 예외를 담을 수 있습니다. 내부 예외가 하나이든 여러 개이든 반복문으로 풀어야 합니다. AggregateException에 대한 자세한 내용은 공식 문서를 참고하세요.

중요!

최신 .NET 버전(C# 7.1 이상)에서는 비동기 Main을 선언해서 진입점에서 바로 await를 사용할 수 있습니다:

static async Task Main(string[] args)
{
    await StartReportProcessAsync();
}

3. "fire-and-forget" 작업에서의 예외

비동기 메서드를 호출하고 완료를 기다리지도 않고 Task 참조도 저장하지 않는다면 무슨 일이 일어날까요?

SendReportAsync(); // Task를 "잊어버림"

이런 상황에서는 문제가 생깁니다: 작업에서 발생한 예외를 아무도 처리하지 않습니다. 환경과 설정에 따라 앱이 크래시할 수도 있고, 단순히 경고가 로그에 남을 수도 있습니다. 이건 C#의 버그가 아니라 Task 동작의 결과입니다.

올바른 방식은?

  • 이상적으로는: 작업이 치명적으로 실패할 가능성이 있다면 "fire-and-forget"를 사용하지 마세요.
  • 비동기 메서드가 정말로 "fire-and-forget"로 동작해야 한다면, 메서드 내부에서 명시적으로 예외를 처리하세요.
public async Task SendReportSafeAsync()
{
    try
    {
        await Task.Delay(100);
        throw new InvalidOperationException("전송 중 오류!");
    }
    catch (Exception ex)
    {
        // 로그하거나 처리
        Console.WriteLine($"[로그] 예외: {ex.Message}");
    }
}

// 호출
SendReportSafeAsync();

일반 권장사항: 작업을 추적하지 않고 await도 사용하지 않을 경우, 비동기 메서드 본문을 반드시 try-catch로 감싸 예외를 잃지 말고 최소한 로깅이라도 하세요.

4. 예외와 병렬 작업: Task.WhenAll

실제 앱에서는 독립적인 비동기 작업들을 동시에 여러 개 실행하고 그 완료를 기다려야 할 때가 많습니다. 예를 들어 여러 수신자에게 병렬로 리포트를 보낼 때:

var tasks = new List<Task>
{
    SendReportAsync(),
    SendReportAsync(),
    SendReportAsync()
};

await Task.WhenAll(tasks);

하나 혹은 여러 작업이 예외를 던지면 무슨 일이 일어나나?

이런 오류를 어떻게 잡나?

awaitTask.WhenAll(tasks)을 사용할 때 — 만약 적어도 하나의 작업이 에러로 끝나면 await은 가장 먼저 실패한 작업에서 나온 예외를 던집니다(이 경우 대체로 예외는 AggregateException으로 래핑되지 않습니다).
하지만 주의할 점: 여러 작업이 실패했다면 AggregateException이 내부 예외들의 집합으로 던져질 수 있습니다.

try
{
    await Task.WhenAll(tasks);
}
catch (Exception ex)
{
    // AggregateException이면 풀어봄
    if (ex is AggregateException agg)
    {
        foreach (var inner in agg.InnerExceptions)
            Console.WriteLine($"작업 오류: {inner.Message}");
    }
    else
    {
        Console.WriteLine($"오류: {ex.Message}");
    }
}

단일 Task에 대한 await의 경우 예외는 보통 AggregateException으로 래핑되지 않습니다. 하지만 WhenAll을 쓸 때는 그럴 가능성이 충분히 있습니다!

5. 비동기 대리자(delegate)와 예외 처리

WPF, WinForms, ASP.NET 같은 UI 앱에서는 이벤트 핸들러를 비동기 람다로 작성하는 일이 잦습니다. 그런 핸들러에서 예외가 밖으로 나가면 결과는 UI 프레임워크에 따라 달라집니다: 앱이 크래시할 수도 있고 예외를 무시할 수도 있습니다.

권장사항

비동기 대리자 안에서는 항상 try-catch를 사용하세요:

button.Click += async (sender, args) =>
{
    try
    {
        await SendReportAsync();
    }
    catch (Exception ex)
    {
        MessageBox.Show($"오류: {ex.Message}");
    }
};
2
과제
C# SELF, 레벨 59, 레슨 4
잠금
비동기 예외 처리 핸들러에서
비동기 예외 처리 핸들러에서
1
설문조사/퀴즈
비동기 프로그래밍, 레벨 59, 레슨 4
사용 불가능
비동기 프로그래밍
비동기 vs. 멀티스레딩
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION