1. 소개
절대 안 터지는 프로그램을 만든다고 믿는 건 마치 버그들이 당신의 IDE를 무서워한다고 믿는 것과 같아. 실제로 코드가 복잡해질수록(특히 멀티스레드·비동기 환경에서는) 버그는 더 교활해지고, 예외도 더 교묘해져. 스레드나 작업에서 예외를 무시하면 리소스 누수, 교착, 데이터 유실 또는 실행 몇 시간 뒤의 예기치 않은 크래시를 얻기 쉬워.
이 강의에선 C#의 멀티스레드·비동기 프로그래밍에서의 모범적인 오류 처리 방법을 모아줄게. 예외를 어떻게 제대로 잡는지, 여러 동시 오류들(예: AggregateException)을 어떻게 분해하는지, '날려버린' 작업들에 대해 뭘 해야 하는지, 그리고 예외를 무시하는 게 왜 위험하고 무의미한지 알게 될 거야.
왜 이렇게 간단하지 않을까
- 예외가 발생한 스레드가 try-catch가 있는 스레드와 다를 수 있어.
- 비동기 작업은 예외를 즉시 던지지 않고 — 예외는 "포장"되어 await(또는 동기 대기)할 때 처리되길 기다려.
- 여러 작업을 동시에 다룰 때(예: Task.WhenAll) 여러 오류가 발생할 수 있고 — 그걸 고려해야 해.
- fire-and-forget 타입의 작업은 명시적 핸들러가 없으면 예외를 완전히 "잃어버릴" 수 있어.
이 특징은 바로 실행 컨텍스트 분리야. 프로그램을 여러 경기장이 있는 서커스라고 생각해봐: 한 경기장에서 불이 나도 다른 경기장에선 바로 보이지 않아. 그런 "불"들을 제대로 추적하고 끄는 법을 아는 게 중요해.
2. 작업의 예외: Task와 Task<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: 묶음 예외
await로 Task.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가 이상하게 동작할 수 있어.
GO TO FULL VERSION