블로그로 돌아가기
C#

C# Task.WhenAll 예외 처리와 여러 오류 확인 방법

C#에서 Task.WhenAll로 실행한 여러 작업이 동시에 실패했을 때 await가 전달하는 예외와 실제로 저장되는 전체 예외의 차이를 설명합니다. 모든 오류를 수집하는 방법과 작업별 실패 원인을 구분하는 방법도 함께 정리합니다.

C#Task.WhenAllasyncawaitAggregateException예외 처리
C# Task.WhenAll 예외 처리와 여러 오류 확인 방법

C# Task.WhenAll 예외 처리와 여러 오류 확인 방법

Task.WhenAll로 여러 비동기 작업을 기다릴 때 두 개 이상의 작업이 실패해도 catch에서 예외 하나만 확인되는 경우가 있습니다. 예외가 사라진 것이 아니라 Task.WhenAll이 반환한 작업에 전체 예외가 저장되고, await가 그중 하나만 다시 던지기 때문에 발생하는 차이입니다.

일반적인 요청 처리에서는 await Task.WhenAll()try-catch로 감싸면 충분합니다. 모든 실패 원인을 기록해야 한다면 WhenAll이 반환한 작업을 변수에 보관한 뒤 Exception.InnerExceptions를 확인해야 합니다.

적용 범위

  • 언어: C#
  • 비동기 모델: Task 기반 비동기 패턴
  • 주요 API: Task.WhenAll, Task.Exception
  • 주요 문법: async, await, try-catch
  • 프레임워크 의존성: 없음
  • 버전 조건: 특정 C# 또는 .NET 버전을 전제로 하지 않습니다.
  • 검증 상태: 예제는 일반적인 C# 및 .NET 동작을 기준으로 작성했으며 실제 SDK 환경에서 실행 검증이 필요합니다.

문제 상황

다음 코드는 세 작업을 시작합니다. 두 작업은 서로 다른 예외를 발생시키고, 한 작업은 정상적으로 끝납니다.

최소 재현 코드

using System; using System.Threading.Tasks; public static class Program { public static async Task Main() { Task[] tasks = { FailAsync("사용자 조회 실패", 100), FailAsync("주문 조회 실패", 200), CompleteAsync(150) }; try { await Task.WhenAll(tasks); } catch (Exception exception) { Console.WriteLine( $"catch에서 확인한 예외: {exception.Message}" ); } } private static async Task FailAsync( string message, int delayMilliseconds) { await Task.Delay(delayMilliseconds); throw new InvalidOperationException(message); } private static async Task CompleteAsync(int delayMilliseconds) { await Task.Delay(delayMilliseconds); } }

두 작업이 실패하지만 catch 블록은 한 번만 실행됩니다. exception 변수에서도 일반적으로 실패 하나만 직접 확인합니다.

예외 두 개 중 어떤 예외가 await를 통해 전달되는지에 의존해서는 안 됩니다. 전체 실패 목록이 필요하다면 catch로 전달된 예외가 아니라 Task.WhenAll이 반환한 작업을 확인해야 합니다.

Task.WhenAll의 예외 처리 원리

Task.WhenAll은 전달받은 작업을 직접 실행하는 메서드가 아닙니다. 이미 시작된 여러 작업의 완료 상태를 하나의 작업으로 묶습니다.

Task allTasks = Task.WhenAll(tasks);

allTasks의 최종 상태는 내부 작업의 결과에 따라 결정됩니다.

내부 작업 상태WhenAll 작업의 최종 상태
모든 작업 성공RanToCompletion
하나 이상의 작업 실패Faulted
실패는 없고 하나 이상의 작업 취소Canceled
작업 목록이 비어 있음즉시 RanToCompletion

하나의 작업이 먼저 실패해도 Task.WhenAll은 즉시 완료되지 않습니다. 전달받은 작업이 모두 성공, 실패 또는 취소 상태에 도달한 뒤 최종 상태를 결정합니다.

전체 예외는 반환된 Task에 저장됩니다

둘 이상의 작업이 실패하면 Task.WhenAll이 반환한 작업의 Exception 프로퍼티에는 AggregateException이 저장됩니다.

Task allTasks = Task.WhenAll(tasks);

전체 예외는 다음 위치에서 확인할 수 있습니다.

allTasks.Exception.InnerExceptions

InnerExceptions에는 실패한 내부 작업에서 발생한 예외들이 포함됩니다.

await는 집계된 예외 중 하나를 전달합니다

다음 코드에서 awaitallTasks에 저장된 AggregateException 전체를 그대로 던지지 않습니다.

await allTasks;

await는 집계된 예외 중 하나를 호출자에게 전달합니다. 이 동작은 일반적인 비동기 메서드에서 원래 예외 형식을 유지해 처리할 수 있게 합니다.

따라서 다음 코드는 모든 오류를 수집하는 코드가 아닙니다.

try { await Task.WhenAll(tasks); } catch (Exception exception) { Console.WriteLine(exception.Message); }

이 코드는 실패 여부를 감지하고 대표 예외 하나를 처리하는 용도에 적합합니다.

해결 방법 1 가장 일반적인 try-catch 처리

이 방법이 적합한 경우

다음 조건에서는 일반적인 try-catch만으로 충분합니다.

  • 작업 하나라도 실패하면 전체 요청을 실패로 처리합니다.
  • 사용자에게 상세 오류 하나만 전달하면 됩니다.
  • 호출 계층에서 공통 예외 처리 미들웨어를 사용합니다.
  • 개별 작업의 모든 오류를 별도로 기록할 필요가 없습니다.
  • 실패한 작업별 복구 작업이 필요하지 않습니다.

수정 코드

public static async Task LoadAllAsync() { Task usersTask = LoadUsersAsync(); Task ordersTask = LoadOrdersAsync(); Task productsTask = LoadProductsAsync(); try { await Task.WhenAll( usersTask, ordersTask, productsTask ); } catch (InvalidOperationException exception) { Console.WriteLine(exception.Message); throw; } }

비동기 작업을 먼저 생성한 뒤 Task.WhenAll에 전달합니다.

Task usersTask = LoadUsersAsync(); Task ordersTask = LoadOrdersAsync(); Task productsTask = LoadProductsAsync();

세 작업은 순차적으로 await되지 않습니다. 각 비동기 메서드가 반환한 작업을 모은 뒤 Task.WhenAll에서 함께 기다립니다.

동작 원리

내부 작업 중 하나라도 실패하면 Task.WhenAll이 반환한 작업은 Faulted 상태가 됩니다. await는 해당 작업에 저장된 예외 중 하나를 다시 던집니다.

호출자는 일반 동기 코드와 비슷한 형태로 예외를 처리할 수 있습니다.

try { await LoadAllAsync(); } catch (InvalidOperationException exception) { Console.WriteLine(exception.Message); }

주의사항

복구할 수 없는 예외를 기록하기만 하고 숨기면 호출자는 작업이 성공한 것으로 오해할 수 있습니다.

catch (Exception exception) { Console.WriteLine(exception.Message); // 예외를 다시 던지지 않습니다. }

현재 메서드가 오류를 정상 상태로 변환할 책임이 없다면 throw;로 다시 전달합니다.

catch (Exception exception) { Console.WriteLine(exception.Message); throw; }

기존 예외를 다시 던질 때 다음처럼 작성하지 않습니다.

catch (Exception exception) { throw exception; }

throw exception;은 현재 위치를 기준으로 스택 추적을 다시 구성할 수 있습니다. 원래 예외 흐름을 유지하려면 throw;를 사용합니다.

해결 방법 2 WhenAll 작업에서 모든 예외 확인하기

이 방법이 적합한 경우

다음 조건에서는 모든 예외를 확인해야 합니다.

  • 여러 외부 API 요청의 실패 원인을 모두 기록합니다.
  • 배치 작업에서 실패한 항목 수를 집계합니다.
  • 병렬 작업마다 서로 다른 장애 원인이 발생할 수 있습니다.
  • 하나의 대표 예외만으로 장애 분석이 어렵습니다.
  • 실패한 모든 작업을 모니터링 시스템에 전송해야 합니다.

수정 코드

Task.WhenAll의 반환값을 바로 await하지 않고 변수에 저장합니다.

public static async Task RunAllAsync() { Task[] tasks = { FailAsync("사용자 조회 실패", 100), FailAsync("주문 조회 실패", 200), CompleteAsync(150) }; Task allTasks = Task.WhenAll(tasks); try { await allTasks; } catch (OperationCanceledException) when (allTasks.IsCanceled) { Console.WriteLine("하나 이상의 작업이 취소됐습니다."); throw; } catch { AggregateException aggregateException = allTasks.Exception!; foreach (Exception exception in aggregateException.InnerExceptions) { Console.WriteLine( $"{exception.GetType().Name}: " + exception.Message ); } throw; } }

catch 블록에 진입했을 때 allTasksFaulted 상태라면 Exception 프로퍼티에서 집계된 예외를 확인할 수 있습니다.

AggregateException aggregateException = allTasks.Exception!;

전체 예외는 InnerExceptions에서 순회합니다.

foreach (Exception exception in aggregateException.InnerExceptions) { Console.WriteLine(exception.Message); }

WhenAll 작업을 변수에 저장해야 하는 이유

다음 코드도 실패 여부를 처리할 수 있습니다.

try { await Task.WhenAll(tasks); } catch { }

하지만 Task.WhenAll(tasks)가 반환한 작업을 보관하지 않았으므로 catch 안에서 해당 작업의 Exception 프로퍼티에 접근할 수 없습니다.

전체 예외를 확인하려면 반환된 작업을 먼저 저장합니다.

Task allTasks = Task.WhenAll(tasks); try { await allTasks; } catch { AggregateException exceptions = allTasks.Exception!; }

취소와 실패를 구분해야 하는 이유

작업이 취소되면 allTasks.Exception은 전체 실패를 담는 용도로 사용할 수 없습니다. 취소 상태는 IsCanceled로 구분합니다.

catch (OperationCanceledException) when (allTasks.IsCanceled) { Console.WriteLine("작업이 취소됐습니다."); }

실패한 작업과 취소된 작업이 함께 있으면 실패가 우선합니다. 하나 이상의 작업이 실제 예외로 실패했다면 WhenAll 작업은 Faulted 상태가 됩니다.

실패한 작업이 하나도 없고 취소된 작업만 있을 때 Canceled 상태가 됩니다.

주의사항

다음 코드처럼 모든 예외를 기록한 뒤 아무 처리 없이 종료하면 호출자는 작업이 성공했다고 판단할 수 있습니다.

catch { foreach (Exception exception in allTasks.Exception!.InnerExceptions) { Console.WriteLine(exception.Message); } }

현재 메서드가 부분 실패를 정상 결과로 변환하는 역할이 아니라면 예외를 다시 던집니다.

catch { foreach (Exception exception in allTasks.Exception!.InnerExceptions) { Console.WriteLine(exception.Message); } throw; }

이때 throw;await가 전달한 예외를 다시 던집니다. 호출자에게 AggregateException 전체를 전달하는 계약이 필요하다면 메서드의 예외 정책을 별도로 설계해야 합니다.

해결 방법 3 실패한 작업과 작업 이름 연결하기

이 방법이 적합한 경우

전체 예외 목록만으로 어떤 작업이 실패했는지 구분하기 어려울 수 있습니다.

다음 조건에서는 작업 이름과 Task를 함께 보관합니다.

  • 여러 API나 저장소를 동시에 호출합니다.
  • 같은 예외 형식이 여러 작업에서 발생합니다.
  • 로그에서 실패한 기능 이름을 확인해야 합니다.
  • 성공한 작업과 실패한 작업을 따로 처리해야 합니다.
  • 작업별 재시도 정책을 적용해야 합니다.

수정 코드

using System; using System.Collections.Generic; using System.Threading.Tasks; public static class OperationRunner { public static async Task RunAsync() { var operations = new Dictionary<string, Task> { ["users"] = FailAsync("사용자 조회 실패", 100), ["orders"] = FailAsync("주문 조회 실패", 200), ["products"] = CompleteAsync(150) }; Task allTasks = Task.WhenAll(operations.Values); try { await allTasks; } catch { foreach ( KeyValuePair<string, Task> operation in operations) { Task task = operation.Value; if (!task.IsFaulted) { continue; } foreach ( Exception exception in task.Exception!.InnerExceptions) { Console.WriteLine( $"작업: {operation.Key}, " + $"오류: {exception.Message}" ); } } throw; } } private static async Task FailAsync( string message, int delayMilliseconds) { await Task.Delay(delayMilliseconds); throw new InvalidOperationException(message); } private static async Task CompleteAsync( int delayMilliseconds) { await Task.Delay(delayMilliseconds); } }

작업 이름을 키로 사용하면 로그에서 실패한 작업을 바로 확인할 수 있습니다.

예상되는 오류 정보는 다음과 같은 형태입니다.

작업: users, 오류: 사용자 조회 실패 작업: orders, 오류: 주문 조회 실패

오류의 출력 순서를 비즈니스 로직의 기준으로 사용해서는 안 됩니다. 비동기 작업의 완료 시점과 내부 예외 저장 순서는 실행 환경에 따라 달라질 수 있습니다.

동작 원리

Task.WhenAll은 모든 작업이 완료된 뒤 catch로 제어를 이동합니다. 따라서 catch 안에서는 각 작업의 최종 상태를 확인할 수 있습니다.

task.IsCompletedSuccessfully task.IsFaulted task.IsCanceled

실패한 작업에는 Exception이 저장됩니다.

if (task.IsFaulted) { AggregateException exception = task.Exception!; }

취소된 작업은 IsCanceled로 별도 처리할 수 있습니다.

if (task.IsCanceled) { Console.WriteLine( $"작업이 취소됐습니다: {operation.Key}" ); }

주의사항

작업 이름이나 입력값에 개인정보, 인증 토큰, 내부 주소를 그대로 넣지 않습니다.

다음과 같은 로그는 피해야 합니다.

Console.WriteLine( $"요청 실패: {accessToken}, {userEmail}" );

운영 로그에는 작업을 구분할 수 있는 안전한 식별자만 남깁니다.

지연 실행되는 작업 목록은 먼저 배열로 변환하기

LINQ로 작업 목록을 만들면 IEnumerable<Task>가 지연 실행될 수 있습니다.

IEnumerable<Task> tasks = ids.Select(id => ProcessAsync(id));

이 컬렉션을 여러 번 열거하면 ProcessAsync()가 다시 호출되어 새로운 작업이 생성될 수 있습니다.

다음처럼 실제 작업 배열을 한 번 생성합니다.

Task[] tasks = ids .Select(id => ProcessAsync(id)) .ToArray();

이후 같은 작업 배열을 Task.WhenAll과 오류 확인에 사용합니다.

Task allTasks = Task.WhenAll(tasks); try { await allTasks; } catch { foreach (Task task in tasks) { if (task.IsFaulted) { Console.WriteLine(task.Exception); } } throw; }

작업 목록을 다시 열거해 새 작업을 만들면 처음 실패한 작업과 검사 대상이 달라질 수 있습니다.

피해야 하는 예외 처리 방법

await 주위에서 AggregateException만 catch하기

다음 코드는 모든 오류를 처리할 것처럼 보입니다.

try { await Task.WhenAll(tasks); } catch (AggregateException exception) { Console.WriteLine(exception); }

하지만 await는 일반적으로 AggregateException 전체가 아니라 내부 예외 중 하나를 다시 던집니다. 내부 예외가 InvalidOperationException이라면 위 catch는 실행되지 않을 수 있습니다.

일반 예외를 잡은 뒤 WhenAll 작업의 Exception을 확인합니다.

Task allTasks = Task.WhenAll(tasks); try { await allTasks; } catch { foreach (Exception exception in allTasks.Exception!.InnerExceptions) { Console.WriteLine(exception.Message); } throw; }

WaitAll을 사용해 AggregateException 받기

다음 코드는 여러 예외를 AggregateException으로 받을 수 있지만 현재 스레드를 차단합니다.

try { Task.WaitAll(tasks); } catch (AggregateException exception) { Console.WriteLine(exception); }

비동기 메서드 안에서는 Task.WaitAll()보다 await Task.WhenAll()을 사용합니다.

await Task.WhenAll(tasks);

WaitAll, .Wait().Result는 스레드를 차단합니다. UI 애플리케이션과 특정 실행 컨텍스트에서는 응답 정지나 데드락 위험도 생길 수 있습니다.

첫 번째 실패가 나머지 작업을 중단한다고 가정하기

Task.WhenAll은 작업 하나가 실패해도 나머지 작업을 자동으로 취소하지 않습니다.

await Task.WhenAll(tasks);

catch가 실행되는 시점에는 전달한 작업이 모두 최종 상태에 도달한 상태입니다. catch 안에서 취소를 요청해도 이미 모든 작업이 끝난 뒤일 수 있습니다.

한 작업의 실패가 다른 작업의 중단으로 이어져야 한다면 작업들이 공유하는 CancellationToken과 별도의 실패 전파 구조를 설계해야 합니다. 각 작업도 해당 토큰을 실제로 확인해야 합니다.

예외를 기록하지 않고 무시하기

다음 코드는 실패한 작업을 성공한 것처럼 처리합니다.

try { await Task.WhenAll(tasks); } catch { }

오류를 정상 결과로 변환할 명확한 정책이 없다면 예외를 숨기지 않습니다.

최소한 실패 내용을 기록하고 호출자에게 다시 전달합니다.

catch (Exception exception) { Console.WriteLine(exception); throw; }

async void 작업을 WhenAll에 포함하려고 하기

async void 메서드는 호출자에게 Task를 반환하지 않습니다.

private static async void ProcessAsync() { await Task.Delay(100); throw new InvalidOperationException(); }

반환된 작업이 없으므로 Task.WhenAll이 완료 여부와 예외를 추적할 수 없습니다.

이벤트 처리기가 아니라면 Task를 반환합니다.

private static async Task ProcessAsync() { await Task.Delay(100); throw new InvalidOperationException(); }

해결 방법 비교

처리 방법적합한 상황확인 가능한 오류구현 복잡도주의사항
await와 일반 try-catch실패 하나로 전체 요청을 중단await가 전달한 예외 하나낮음모든 내부 오류를 확인할 수 없음
WhenAll 작업의 Exception 확인전체 실패 원인을 기록집계된 모든 내부 예외중간반환 작업을 변수에 보관해야 함
개별 작업 상태 확인작업 이름과 오류 연결작업별 전체 예외중간작업과 식별자를 함께 관리해야 함
Task.WaitAll동기 코드의 제한된 경계AggregateException낮음스레드 차단과 데드락 위험
예외를 결과 객체로 변환부분 성공이 정상인 배치 처리성공과 실패를 데이터로 확인높음예외 정책과 결과 형식을 별도 설계해야 함

어떤 방법을 선택해야 하는가

작업 하나라도 실패하면 전체 요청을 실패로 처리하고 대표 오류 하나만 필요하다면 일반적인 try-catch를 사용합니다.

try { await Task.WhenAll(tasks); } catch (Exception exception) { Log(exception); throw; }

실패한 모든 원인을 기록해야 한다면 Task.WhenAll의 반환 작업을 변수에 보관합니다.

Task allTasks = Task.WhenAll(tasks);

catch 안에서는 allTasks.Exception.InnerExceptions를 확인합니다.

foreach (Exception exception in allTasks.Exception!.InnerExceptions) { Log(exception); }

어떤 기능이나 입력이 실패했는지 구분해야 한다면 작업과 안전한 식별자를 함께 저장합니다.

Dictionary<string, Task> operations = CreateOperations();

일부 작업의 실패를 정상적인 부분 결과로 취급해야 한다면 예외를 단순히 숨기지 않습니다. 성공과 실패를 명시적으로 표현하는 결과 형식을 별도로 설계합니다.

자주 발생하는 추가 문제

Task<T> 결과 중 성공한 값만 사용할 수 있는가

Task.WhenAll<T>는 모든 작업이 성공하면 결과 배열을 반환합니다.

string[] results = await Task.WhenAll(tasks);

하나라도 실패하면 await에서 예외가 발생하므로 결과 배열을 받을 수 없습니다.

하지만 Task.WhenAll은 모든 작업이 끝날 때까지 기다립니다. catch 안에서 개별 작업을 검사하면 성공한 작업의 결과를 별도로 확인할 수 있습니다.

foreach (Task<string> task in tasks) { if (task.IsCompletedSuccessfully) { Console.WriteLine(task.Result); } }

이 위치에서는 IsCompletedSuccessfully를 먼저 확인했으므로 완료된 결과에 접근할 수 있습니다.

실패했거나 취소된 작업에서 무조건 .Result를 읽으면 다시 예외가 발생합니다.

예외 순서가 작업 배열 순서와 같은가

예외 목록의 순서를 비즈니스 규칙에 사용하지 않습니다.

다음과 같은 코드는 피해야 합니다.

Exception primary = allTasks.Exception!.InnerExceptions[0];

첫 번째 예외를 특정 작업의 대표 오류라고 가정하면 비동기 완료 순서에 따라 결과가 달라질 수 있습니다.

대표 오류가 필요하다면 예외 순서가 아니라 명시적인 우선순위를 사용합니다.

Exception? primary = allTasks.Exception! .InnerExceptions .FirstOrDefault( exception => exception is AuthenticationException );

실제 코드에서는 처리하려는 예외 형식과 업무 우선순위를 기준으로 선택해야 합니다.

예외와 취소가 함께 발생한 경우

일부 작업이 취소되고 다른 작업이 실패하면 Task.WhenAll이 반환한 작업은 Faulted 상태가 됩니다.

if (allTasks.IsFaulted) { // 실제 오류가 하나 이상 발생했습니다. } else if (allTasks.IsCanceled) { // 오류 없이 취소된 작업이 있습니다. }

취소와 실패를 같은 로그 수준이나 같은 응답 코드로 처리하지 않아야 한다면 두 상태를 구분합니다.

동작 확인

다음 명령으로 콘솔 프로젝트를 생성합니다.

dotnet new console -n WhenAllExceptionSample cd WhenAllExceptionSample

Program.cs를 다음 코드로 교체합니다.

using System; using System.Threading.Tasks; public static class Program { public static async Task Main() { Task[] tasks = { FailAsync("사용자 조회 실패", 100), FailAsync("주문 조회 실패", 200), CompleteAsync(150) }; Task allTasks = Task.WhenAll(tasks); try { await allTasks; } catch (Exception exception) { Console.WriteLine( "await에서 전달된 예외" ); Console.WriteLine( $"{exception.GetType().Name}: " + exception.Message ); if (allTasks.Exception is not null) { Console.WriteLine( "전체 예외 수: " + allTasks.Exception .InnerExceptions.Count ); foreach ( Exception innerException in allTasks.Exception .InnerExceptions) { Console.WriteLine( $"{innerException.GetType().Name}: " + innerException.Message ); } } } } private static async Task FailAsync( string message, int delayMilliseconds) { await Task.Delay(delayMilliseconds); throw new InvalidOperationException(message); } private static async Task CompleteAsync( int delayMilliseconds) { await Task.Delay(delayMilliseconds); } }

다음 명령으로 빌드하고 실행합니다.

dotnet build dotnet run

예상 결과에서는 await가 전달한 예외 하나와 allTasks.Exception에 저장된 예외 두 개를 확인할 수 있습니다.

await에서 전달된 예외 InvalidOperationException: [두 오류 중 하나] 전체 예외 수: 2 InvalidOperationException: 사용자 조회 실패 InvalidOperationException: 주문 조회 실패

예외가 표시되는 순서는 실행 환경에 따라 다를 수 있으므로 고정된 순서를 기대하지 않습니다.

현재 작성 환경에서는 .NET SDK를 사용할 수 없어 예제를 직접 빌드하거나 실행하지 못했습니다. 발행 전 실제 사용하는 SDK에서 dotnet builddotnet run을 실행해야 합니다.

[실행 검증 필요]

정리

Task.WhenAll에 전달한 작업 중 하나 이상이 실패하면 반환된 작업은 Faulted 상태가 됩니다. 여러 작업이 실패한 경우 전체 오류는 반환된 작업의 Exception.InnerExceptions에 저장됩니다.

대표 오류 하나만 처리하면 일반적인 try-catch를 사용합니다. 모든 오류가 필요하면 WhenAll 작업을 변수에 보관한 뒤 집계된 예외를 확인합니다.

실패한 기능까지 구분해야 한다면 작업과 식별자를 함께 관리합니다. Task.WaitAll로 바꾸거나 AggregateException만 직접 잡는 방식은 await Task.WhenAll의 기본 예외 처리 방법이 아닙니다.