CodeGym /행동 /C# SELF /오류 처리 종합 전략: Task, ...

오류 처리 종합 전략: Task, async/ await, AggregateException

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

1. 소개

절대 안 터지는 프로그램을 만든다고 믿는 건 마치 버그들이 당신의 IDE를 무서워한다고 믿는 것과 같아. 실제로 코드가 복잡해질수록(특히 멀티스레드·비동기 환경에서는) 버그는 더 교활해지고, 예외도 더 교묘해져. 스레드나 작업에서 예외를 무시하면 리소스 누수, 교착, 데이터 유실 또는 실행 몇 시간 뒤의 예기치 않은 크래시를 얻기 쉬워.

이 강의에선 C#의 멀티스레드·비동기 프로그래밍에서의 모범적인 오류 처리 방법을 모아줄게. 예외를 어떻게 제대로 잡는지, 여러 동시 오류들(예: AggregateException)을 어떻게 분해하는지, '날려버린' 작업들에 대해 뭘 해야 하는지, 그리고 예외를 무시하는 게 왜 위험하고 무의미한지 알게 될 거야.

왜 이렇게 간단하지 않을까

  • 예외가 발생한 스레드가 try-catch가 있는 스레드와 다를 수 있어.
  • 비동기 작업은 예외를 즉시 던지지 않고 — 예외는 "포장"되어 await(또는 동기 대기)할 때 처리되길 기다려.
  • 여러 작업을 동시에 다룰 때(예: Task.WhenAll) 여러 오류가 발생할 수 있고 — 그걸 고려해야 해.
  • fire-and-forget 타입의 작업은 명시적 핸들러가 없으면 예외를 완전히 "잃어버릴" 수 있어.

이 특징은 바로 실행 컨텍스트 분리야. 프로그램을 여러 경기장이 있는 서커스라고 생각해봐: 한 경기장에서 불이 나도 다른 경기장에선 바로 보이지 않아. 그런 "불"들을 제대로 추적하고 끄는 법을 아는 게 중요해.

2. 작업의 예외: TaskTask<TResult>

작업이 오류를 알리는 방법

작업에서 처리되지 않은 예외가 발생하면, 그 예외가 바로 밖으로 튀어나가진 않아. 작업은 Faulted 상태가 되고, 예외는 내부에 저장돼. 예외를 얻는 방법은:

  • await로 완료를 기다리기(또는 task.Wait()/task.Result로 동기 대기 — 하지만 이건 권장하지 않아);
  • 속성 task.Exception을 확인하기 — 거기엔 AggregateException이 들어있어.

예제


// 오류 있는 비동기 메서드
async Task FailAsync()
{
    await Task.Delay(100);
    throw new InvalidOperationException("뭔가 잘못됐어");
}

async Task MainAsync()
{
    try
    {
        await FailAsync();
    }
    catch (Exception ex)
    {
        Console.WriteLine($"오류: {ex.Message}");
    }
}

만약 try-catch를 두지 않으면 프로그램이 터질 거야. 넣어두면 — 예외는 다른 작업에서 발생했더라도 정상적으로 잡혀.

AggregateException: 묶음 예외

awaitTask.WhenAll(tasks)를 할 때, 여러 작업의 오류들이 하나의 AggregateException으로 모여(그 안의 InnerExceptions) 담겨.


async Task MultiFailAsync()
{
    Task t1 = Task.Run(() => throw new InvalidOperationException("오류 1"));
    Task t2 = Task.Run(() => throw new ArgumentException("오류 2"));
    try
    {
        await Task.WhenAll(t1, t2);
    }
    catch (Exception ex)
    {
        if (ex is AggregateException agg)
        {
            foreach (var e in agg.InnerExceptions)
                Console.WriteLine($"예외: {e.Message}");
        }
        else
        {
            Console.WriteLine($"단일 오류: {ex.Message}");
        }
    }
}

주의: await Task.WhenAll(tasks)를 사용하면 .NET이 AggregateException을 "풀어"주기도 해서, catch에서는 첫 번째 예외만 잡힐 수 있어. 전체 목록은 작업이 오류로 끝났다면 task.Exception.InnerExceptions를 통해 확인할 수 있어.

3. «Fire and Forget»: 그냥 잊어버리면 안 되는 이유

"실행하고 잊기"는 숨겨진 실패로 이어지기 쉬워. 예:


Task.Run(() => { throw new Exception("펑!"); }); // 오류가 공중으로 날아감.

현대 런타임은 오류를 작업에 저장하고, 처리되지 않은 예외 때문에 프로세스가 종료될 수도 있어. 최선의 방법은 작업 참조를 보관하거나 TaskScheduler.UnobservedTaskException 이벤트를 구독하는 거야.

어떻게 하는 게 맞나?

  • 작업을 보관해서 완료를 기다리고 오류를 처리해라;
  • fire‑and‑forget의 경우에는 델리게이트 안에서 바로 처리기를 둬라.

Task.Run(() => {
    try 
    {
        // 예외를 던질 수 있는 코드
    }
    catch (Exception ex)
    {
        // 로그 남기고, 알리고, 오류를 밖으로 내보내진 않음
        Console.WriteLine($"fire-and-forget에서의 오류: {ex.Message}");
    }
});

4. 스레드(Thread)의 오류: 밖에서 못 잡을까?

새로운 Thread에서 발생한 예외는 바깥쪽의 try-catch로 잡을 수 없어 — 스레드 몸통 안에서만 잡을 수 있어.


var thread = new Thread(() =>
{
    try
    {
        throw new Exception("스레드에서 오류");
    }
    catch (Exception ex)
    {
        Console.WriteLine($"스레드 내부에서 오류를 잡음: {ex.Message}");
    }
});
thread.Start();

만약 처리기를 두지 않으면 그 예외는 그 스레드만 종료시킬 뿐이야(그 스레드가 백그라운드이면: thread.IsBackground = true). 포그라운드 스레드에서 처리되지 않은 예외는 전체 프로세스를 종료시킬 수 있어. 항상 스레드 내부에 try-catch를 두자.

스레드에서 결과와 오류를 어떻게 반환할까?

  • 결과/오류 전달을 위해 큐나 컬렉션을 사용해라;
  • 이벤트 기반 모델을 사용해라;
  • 가능하면 Task로 전환해라 — Task가 오류를 다루기 더 편해.

5. 병렬 루프: 특수한 방식으로 오류 잡기

병렬 루프에서는 서로 다른 분기에서 발생한 오류들이 AggregateException으로 묶여.


try
{
    Parallel.For(0, 5, i =>
    {
        if (i % 2 == 0)
            throw new Exception($"반복 {i}에서 오류");
    });
}
catch (AggregateException ex)
{
    foreach (var e in ex.InnerExceptions)
        Console.WriteLine($"[병렬 루프] 오류: {e.Message}");
}

로컬 실패를 로깅하고 다른 분기들을 계속 실행하려면, 각 분기 안에 내부 try-catch를 두어라.

6. 취소된 작업의 오류 처리

CancellationToken을 통한 취소 시 관례적으로 OperationCanceledException를 던지는데 — 이건 오류가 아니라 정상적인 정지 신호야.


async Task DoWorkAsync(CancellationToken token)
{
    for (int i = 0; i < 10; i++)
    {
        token.ThrowIfCancellationRequested();
        await Task.Delay(100);
    }
}

// 어딘가에서:
var cts = new CancellationTokenSource();
var task = DoWorkAsync(cts.Token);
cts.Cancel(); // 대략 200ms 후

try
{
    await task;
}
catch (OperationCanceledException)
{
    Console.WriteLine("작업이 취소되었어!");
}

ThrowIfCancellationRequested() 외에도 토큰을 지원하는 많은 메서드들(예: Task.Delay, HttpClient.GetAsync)이 스스로 OperationCanceledException을 던질 거야. 취소 지원 여부를 확인하자.

7. 유용한 팁

모든 경우에 적용할 접근법

  • 비동기 메서드의 최상위 레벨에 try-catch를 두어라 — 이렇게 하면 "달아난" 오류들을 잡을 수 있어.
  • 작업을 무시하지 마: 참조를 보관하고, 완료를 기다리고, 오류를 로깅해라.
  • 병렬 작업(Task.WhenAll / Parallel.ForEach)에서는 AggregateException을 고려해라.
  • 취소와 실패를 구분해라: OperationCanceledException은 따로 잡아라.
  • 특히 "조용한" 오류들은 꼭 로깅해라 — 앱을 죽이지 않아도 나중에 큰 문제를 만들 수 있어.
  • 세부 정보를 보존해라: 중첩된 모든 예외들을 로깅하되 첫 번째만 남기지 마.
  • 가능하면 그 자리에서 처리해라: 뭘 해야 할지 모르겠다면 적어도 로그는 남겨.

멀티스레드·비동기 코드에서 어디서 예외를 잡아야 할까

flowchart TD
    A[메인 스레드 / UI] -->|작업 실행| B[Task/async]
    B -->|작업 내부| C[비동기 메서드 안의 try/catch]
    B -->|메인에서 await| D[await 주변의 try/catch]
    A -->|Thread 실행| E[Thread]
    E -->|내부| F[스레드 내부의 try/catch]
    B -->|여러 작업| G[Task.WhenAll / Parallel.ForEach]
    G -->|오류| H[AggregateException]

8. 흔한 실수와 함정

실수 №1: .Result.Wait()로 작업을 기다리는 것. deadlock이나 예상치 못한 AggregateException가 발생할 수 있어.

실수 №2: 내부 try-catch 없는 fire‑and‑forget 실행 — 작업이 조용히 실패해서 진단이 불가능해진다.

실수 №3: 병렬 루프에서 놓친 오류들 — 일부 작업이 완료되지 않았는데도 아무 알림이 없을 수 있어.

실수 №4: 취소(OperationCanceledException)와 실제 오류를 구분하지 못함.

실수 №5: 여러 작업 중 첫 번째 오류만 로깅하고 나머지는 "그림자"로 남겨두는 것.

실수 №6: 모든 작업에 같은 Exception 인스턴스를 재사용하는 것 — 각 오류는 자기 인스턴스를 가져야 해.

실수 №7: UI 스레드에서 예외 처리 누락 — 백그라운드 오류는 감지되지 않고 UI가 이상하게 동작할 수 있어.

1
설문조사/퀴즈
클래식 Thread에서의 예외, 레벨 61, 레슨 4
사용 불가능
클래식 Thread에서의 예외
비동기 코드에서의 에러 처리
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION