CodeGym /행동 /C# SELF /Parallel.For와 Parallel.ForE...

Parallel.For와 Parallel.ForEach에서의 예외 처리

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

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/ForEachtry-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이 발생하지 않습니다.

이제 여러 스레드에서 루프를 돌릴 때 발생할 수 있는 예외들을 잘 다룰 준비가 됐습니다! 항상 멀티스레딩에는 서프라이즈가 있기 마련이니, 예외를 꼼꼼히 처리하세요.

코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION