1. Parallel.For와 Parallel.ForEach에서의 예외 동작
일반적인 for-루프는 단순해요: 반복문 내부에서 예외가 던져지면 루프는 즉시 종료되고 예외가 밖으로 전파됩니다. 병렬 루프는 좀 다릅니다. 자세히 보죠.
모든 예외는 하나의 "가방"에 모입니다
병렬 반복(Parallel.For/ForEach)의 어떤 이터레이션에서 예외가 발생하면, 그 예외가 바로 밖으로 튀지 않고 포장됩니다. 그 후에도 다른 이터레이션들은 끝까지 실행되거나 또 예외를 던질 수 있습니다. 결과적으로 병렬 루프가 종료(또는 강제 중단)될 때, 발생한 모든 예외들이 모여 하나의 AggregateException 객체로 밖으로 던져집니다.
AggregateException는 병렬 이터레이션 도중 발생한 모든 예외들을 내부 컬렉션으로 보관하는 "컨테이너"예요. 장점은 명확하죠: 보통은 모든 오류(또는 루프가 끝날 때까지 모인 오류 전부)를 한 번에 받을 수 있다는 것.
실전에서는 어떻게 보이는가
예제: 때때로 예외를 던지는 병렬 처리
using System;
using System.Threading.Tasks;
class Program
{
static void Main()
{
int[] numbers = { 1, 2, 0, 4, 0, 6, 7, 8 };
try
{
Parallel.ForEach(numbers, number =>
{
// 일부러 숫자로 나눕니다, 가끔 0이 있어요!
// 이것은 DivideByZeroException을 발생시킵니다
int result = 100 / number;
Console.WriteLine($"100 / {number} = {result}");
});
}
catch (AggregateException ex)
{
Console.WriteLine("병렬 루프에서 오류가 발견되었습니다!");
// 발생한 모든 예외를 순회합니다
foreach (var inner in ex.InnerExceptions)
{
Console.WriteLine($"유형: {inner.GetType().Name} — 메시지: {inner.Message}");
}
}
}
}
무슨 일이 일어날까:
- 배열에 0이 있어서, 0으로 나누는 것은 C#에서도 금지된 연산입니다: DivideByZeroException이 발생합니다.
- 병렬 루프는 처리를 시작합니다. 어느 지점에서든 0으로 나누면 예외가 발생하지만 루프는 즉시 멈추지 않고 이미 시작된 이터레이션들은 계속 실행됩니다.
- 모든 스레드가 작업을 마치면(오류가 난 것도 있고 정상인 것도 있고), 밖으로 발생한 모든 예외를 담은 AggregateException이 던져집니다.
예외 처리 메커니즘 시각화
flowchart LR
A[스레드 1]
B[스레드 2]
C[스레드 3]
D[스레드 4]
E[Parallel.ForEach]
F[예외 1]
G[예외 2]
H[AggregateException]
subgraph 반복들
A --> F
B --> G
C --> E
D --> E
F --> H
G --> H
E --> H
end
다이어그램에서 보듯이: 서로 다른 스레드들이 서로 다른 오류를 만날 수 있고, 결국 모든 오류가 하나의 AggregateException으로 모입니다.
2. 실무에서의 오류 처리 특징
AggregateException을 어떻게 다룰까?
일반적으로 AggregateException을 잡으면 두 가지 시나리오가 있어요:
- 사용자나 로그에 모든 오류를 출력해서 원인을 분석한다.
- 어떤 오류는 치명적이고 어떤 건 무시해도 되는지 판단해서, 전체 작업을 실패로 볼지 일부 실패를 용인할지 결정한다.
자주 쓰이는 패턴: Handle로 처리하기
try
{
Parallel.For(0, 10, i =>
{
if (i == 3 || i == 7)
throw new InvalidOperationException($"이터레이션 {i}에서 오류");
Console.WriteLine($"처리됨: {i}");
});
}
catch (AggregateException ex)
{
ex.Handle(e =>
{
if (e is InvalidOperationException)
{
Console.WriteLine("잡힌 오류: " + e.Message);
// true = 오류를 처리된 것으로 간주
return true;
}
// false = 처리되지 않음, 다시 던져짐
return false;
});
}
이 방식은 여러분이 '정상적으로 받아들일 수 있는' 오류만 로컬에서 처리하고, 나머지 치명적인 오류는 다시 위로 올려서 처리하도록 할 수 있게 해줍니다.
구현상 흥미로운(그리고 위험한) 뉘앙스
루프는 언제 멈출까?
어떤 이터레이션에서 예외가 발생하면 Parallel.For/ForEach는 새로운 이터레이션을 시작하지 않으려 하지만, 이미 시작된 이터레이션들은 계속 실행됩니다. 모든 활성 이터레이션이 끝나면 AggregateException이 던져집니다. 스레드가 많으면 작업의 "꼬리"가 끝날 때까지 여전히 실행 중인 작업들이 있어서 예외가 여러 개 모일 수 있습니다.
예외를 잡지 않으면 애플리케이션이 종료된다.
Parallel.For/ForEach를 try-catch로 감싸지 않으면, 모든 이터레이션이 끝난 뒤 첫 번째로 발생한 (또는 처리되지 않은) 예외 때문에 애플리케이션이 비정상 종료될 수 있습니다 — 유저 입장에선 좋지 않죠.
예외를 루프 내부로 "넣어" 처리하기
때로는 개별 이터레이션의 실패가 전체 작업을 망치지 않게 하고 싶을 때가 있습니다. 그런 경우 예외를 이터레이션 내부에서 잡아 처리하면 됩니다:
Parallel.ForEach(numbers, number =>
{
try
{
int result = 100 / number;
Console.WriteLine(result);
}
catch (Exception ex)
{
Console.WriteLine($"숫자 {number}에서 오류 발생: {ex.Message}");
}
});
이 방식은 각 실패를 즉시 처리(예: 로그 기록)하고 싶을 때 유용합니다. 단점은 이런 방식으로 처리하면 AggregateException이 생성되지 않기 때문에 전체적으로 문제가 있었는지 일괄적으로 파악하기 어려워진다는 점이에요.
Break()나 Stop()가 호출되면?
이터레이션에서 ParallelLoopState.Break() 또는 ParallelLoopState.Stop()를 호출하면 루프는 새로운 이터레이션 시작을 중단하려고 시도합니다: Break()는 현재 인덱스 이후의 이터레이션을 중단하려 하고, Stop()는 가능한 모든 이터레이션을 중단시키려 합니다. 하지만 동시에 예외가 발생하면 그 예외는 모아서 모든 활성 이터레이션이 끝난 뒤 AggregateException로 던져집니다.
3. 유용한 팁들
일반 루프의 예외 vs 병렬 루프의 예외
일반 루프에서는 어떤 오류라도 즉시 전체 작업을 중단시킵니다: 예외가 밖으로 튀고 처리가 멈추죠.
반면에 C#의 병렬 루프는 좀 더 절충적인 방식을 사용합니다: 이미 시작된 작업들은 끝낼 수 있게 두고, 전체 프로세스가 끝난 다음에야 모든 오류를 한 번에 밖으로 내보냅니다. 이렇게 하면 오류를 하나도 놓치지 않고 모두 모아서 판단할 수 있습니다.
4. Parallel.For와 Parallel.ForEach 사용 시 흔한 실수
실수 #1: AggregateException을 무시하기.
AggregateException을 잡지 않으면 모든 이터레이션이 끝난 뒤 애플리케이션이 비정상 종료되어 데이터 손실이나 서버/GUI 앱의 장애를 초래할 수 있습니다.
실수 #2: .Wait()를 try-catch 없이 사용하기.
Parallel.For/ForEach에서 .Wait()를 호출하고 AggregateException를 처리하지 않으면 처리되지 않은 예외가 발생해 진단이 어려워집니다.
실수 #3: 반복되는 오류를 무시하기.
같은 오류가 여러 번 발생할 수 있습니다(예: 0으로 나누는 경우). InnerExceptions를 분석하지 않으면 근본 원인을 놓칠 수 있어요.
실수 #4: 모든 예외를 묵살해버리기.
루프 내부에서 catch (Exception) { /* 비워둠 */ } 같은 식으로 하면 중요한 정보를 숨기게 되어 원인 파악이 불가능한 '유령' 버그를 만들 수 있습니다.
다른 루프들에서의 오류 행동 비교
| 항목 | 일반 for/foreach | Parallel.For / ForEach |
|---|---|---|
| 예외 발생 시 처리 시점 | 즉시 | 모든 이터레이션 종료 후 |
| 오류 형식 | 단일 exception | 컬렉션을 가진 AggregateException |
| 다른 이터레이션 | 실행되지 않음 | 이미 시작된 것들은 마무리된다 |
| 루프 내부에서 오류 잡기 | 가능 | 가능 |
| 루프 외부에서 오류 잡기 | 가능 | 가능, 하지만 AggregateException으로 |
짧은 팁 & 면접용 체크포인트:
- AggregateException을 처리하지 않으면 어떻게 되나?
모든 이터레이션이 끝난 뒤 애플리케이션이 종료됩니다 — 오류가 언제 일어났든 상관없이요. - 어떤 이터레이션에서 오류가 났는지 알 수 있나?
예, 직접 예외 메시지나 예외에 인덱스/데이터 정보를 포함시키면 알 수 있습니다. 자동으로는 나오지 않습니다. - AggregateException이 비어 있을 수 있나?
아니요. 내부 예외가 최소 하나 이상 있어야 생성되어 던져집니다. 오류가 없으면 던져지지 않습니다. - 이터레이션 내부에서 예외를 잡으면 외부로 예외가 나가나?
아니요. 내부에서 처리하면 밖으로 아무 것도 던져지지 않아서 AggregateException이 발생하지 않습니다.
이제 여러 스레드에서 루프를 돌릴 때 발생할 수 있는 예외들을 잘 다룰 준비가 됐습니다! 항상 멀티스레딩에는 서프라이즈가 있기 마련이니, 예외를 꼼꼼히 처리하세요.
GO TO FULL VERSION