1. 소개
상황을 상상해보자: 비동기 작업을 한 번에 여러 개 띄워서—사이트를 파싱하고, 마켓플레이스의 가격을 체크하거나 큰 데이터 배열을 계산한다고 하자. 전부 잘 시작되지만 몇몇 작업이 실패한다: 어떤 서버는 응답하지 않고, 어떤 곳은 데이터 문제가 있다. 동기 코드라면 하나의 예외만 보겠지만, 비동기 환경에서는 오류가 여러 개일 수 있다. 이럴 때 AggregateException가 등장해서 모든 오류를 하나의 "컨테이너"로 모아준다.
무엇이 AggregateException인가?
AggregateException는 .NET에서 병렬/비동기 작업들(예: Task.WhenAll, Parallel.For, TPL 등)에서 발생한 여러 오류를 집계하는 특별한 예외 타입이다. 둘 이상 작업이 에러로 끝나면 그 에러들이 하나의 AggregateException 객체로 "포장"된다.
AggregateException는 어디서 발생하나?
가장 흔한 시나리오는 여러 작업을 Task.WhenAll로 기다릴 때다:
// 예제 — 여러 작업을 병렬로 실행, 일부는 예외를 던진다
var tasks = new List<Task>
{
Task.Run(() => throw new InvalidOperationException("오류 #1")),
Task.Run(() => throw new ArgumentException("오류 #2")),
Task.Run(() => { Console.WriteLine("작업 3이 성공적으로 완료되었습니다!"); })
};
try
{
await Task.WhenAll(tasks); // await에서는 이 지점에서 AggregateException이 언패킹된다
}
catch (Exception ex)
{
Console.WriteLine($"오류 발생: {ex.GetType().Name} - {ex.Message}");
}
다만 한 가지 포인트: await할 때는 AggregateException이 언패킹되어 내부 예외 중 첫 번째가 그대로 던져진다. 반면 Result에 접근하거나 Wait()를 호출하면 실제로 AggregateException을 받게 된다.
2. AggregateException는 어떻게 생겼나?
이 예외는 모든 중첩된 오류들을 담은 InnerExceptions 컬렉션을 가지고 있다:
try
{
Task task = Task.WhenAll(tasks);
task.Wait(); // 오류가 있으면 여기서 실제로 AggregateException이 발생한다
}
catch (AggregateException aggEx)
{
Console.WriteLine($"총 오류 수: {aggEx.InnerExceptions.Count}");
foreach (var e in aggEx.InnerExceptions)
{
Console.WriteLine($"타입: {e.GetType().Name}, 메시지: {e.Message}");
}
}
출력 예시:
총 오류 수: 2
타입: InvalidOperationException, 메시지: 오류 #1
타입: ArgumentException, 메시지: 오류 #2
AggregateException는 어떻게 발생하나
+--------------------------+
| 여러 Task 실행 |
+-----+---------+----------+
| |
v v
Task1 Task2 ... TaskN
| |
| (예외 발생)
|-------------------+
| v
| Exception2
v
Exception1
|
v
+---------------------------------+
| Task.WhenAll 또는 Parallel.For |
| (모든 예외를 수집) |
+---------------------------------+
|
v
AggregateException
(InnerExceptions: 모든 오류)
왜 await하면 AggregateException가 보이지 않나?
await할 때 .NET은 AggregateException을 "언패킹"해서 내부 예외들 중 첫 번째(InnerExceptions[0])를 던진다.
// InvalidOperationException을 던지는 단일 작업 예제
try
{
await Task.Run(() => throw new InvalidOperationException("오류!"));
}
catch (Exception ex)
{
// 여기서 ex는 AggregateException이 아니라 InvalidOperationException이다
Console.WriteLine(ex.GetType().Name); // InvalidOperationException
}
모든 오류에 접근하고 싶다면 Wait()나 Result를 사용하라(단, 스레드 블로킹에 주의).
3. 모든 예외 잡아서 처리하기
이론을 실습에 연결해보자. 여러 작업이 사용자 데이터를 처리하고 있고, 모든 실패를 기록해야 한다고 가정하자.
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace MyApp
{
class Program
{
static async Task Main(string[] args)
{
var tasks = new List<Task>
{
Task.Run(() => throw new InvalidOperationException("잘못된 연산!")),
Task.Run(() => throw new DivideByZeroException("0으로 나누기!")),
Task.Run(() => Console.WriteLine("세 번째 작업이 성공했습니다"))
};
try
{
// 비동기적으로 작업들을 기다리면 첫 번째 내부 예외를 받는다
Task allTasks = Task.WhenAll(tasks);
await allTasks; // await에서 AggregateException이 언패킹된다
}
catch (Exception ex)
{
// await 시 첫 번째 내부 예외만 보인다
Console.WriteLine($"첫 번째 오류: {ex.GetType().Name} - {ex.Message}");
// 만약 ex가 AggregateException이면 모든 내부 예외를 순회할 수 있다
if (ex is AggregateException aggEx)
{
foreach (var inner in aggEx.InnerExceptions)
{
Console.WriteLine($"목록의 오류: {inner.GetType().Name} - {inner.Message}");
}
}
}
// 이제 동기적으로 모든 오류를 잡아보자
try
{
var allTasks = Task.WhenAll(tasks);
allTasks.Wait(); // 스레드를 블록한다 — UI에서는 사용하지 마라!
}
catch (AggregateException aggEx)
{
foreach (var e in aggEx.InnerExceptions)
{
Console.WriteLine($"[Wait] 오류: {e.GetType().Name} — {e.Message}");
}
}
}
}
}
await를 쓸 때는 try/catch 블록에서 첫 번째 오류만 보인다. Wait()를 쓰면 모든 내부 오류를 포함한 전형적인 AggregateException이 나온다.
4. 대량 오류 처리
실제 애플리케이션에서는 다양한 방식으로 많은 오류를 처리해야 할 때가 많다. 예를 들어 모든 사용자에게 이메일을 보내는데 일부 전송이 실패하는 경우:
var sendTasks = emailList.Select(email => Task.Run(() => SendEmail(email))).ToList();
try
{
Task.WaitAll(sendTasks.ToArray());
}
catch (AggregateException aggEx)
{
foreach (var ex in aggEx.InnerExceptions)
{
if (ex is SmtpException)
Console.WriteLine("이메일 전송 오류: " + ex.Message);
else
Console.WriteLine("알 수 없는 오류: " + ex.Message);
}
}
이렇게 하면 각 실패를 기록하고 개별적인 조치를 취할 수 있다.
5. 유용한 팁
이러한 동작의 이유
.NET은 편의성을 기준으로 설계되어 있다: UI에서는 일반적으로 첫 번째 오류면 충분한 경우가 많다(await 사용 시). 하지만 서버나 모든 실패를 처리해야 하는 시스템에서는 AggregateException이 필수적이다.
패턴: Flatten — 예외 리스트 펼치기
메서드 Flatten()는 중첩된 AggregateException들을 평탄한 리스트로 만든다:
catch (AggregateException aggEx)
{
foreach (var ex in aggEx.Flatten().InnerExceptions)
{
Console.WriteLine($"펼쳐진 오류: {ex.GetType().Name} — {ex.Message}");
}
}
다양한 대기 방식에 따른 Task의 동작
| 작업을 기다리는 방식 | 잡히는 예외 |
|---|---|
|
첫 번째 내부 오류 |
|
항상 AggregateException |
|
항상 AggregateException |
| 개별 await | 해당 Task가 던진 예외, 혹은 Task가 AggregateException으로 끝났다면 첫 번째 내부 오류 |
기억할 것
- AggregateException은 동시에 여러 오류가 발생할 수 있는 모든 시나리오에서 유용하다.
- await는 첫 번째 오류를 잡기에 편리하고, 동기 대기 방식은 모든 InnerExceptions을 순회하는 데 적합하다.
- 정교한 처리를 위해 Flatten()과 Handle()을 사용하라.
- UI에서 동기와 비동기 대기를 섞지 마라: 프리즈와 데드락이 발생한다.
7. AggregateException 처리 시 흔한 실수
오해: await Task.WhenAll은 항상 AggregateException을 던진다. 실제로는 아니다 — 예외가 언패킹되어 첫 번째 내부 예외가 보인다.
Result에 접근하거나 Wait()를 호출하면 작업이 끝날 때까지 스레드가 블록된다. UI에서는 인터페이스가 멈추는 결과를 낳는다.
일부 오류를 "조용히" 처리하고 싶다면 Handle()를 사용하라:
catch (AggregateException aggEx)
{
aggEx.Handle(ex =>
{
if (ex is InvalidOperationException)
{
// 이 오류는 처리했으니 더 이상 던지지 않아도 된다
return true;
}
return false; // 나머지 예외들은 다시 던져진다
});
}
GO TO FULL VERSION