Quick Flow
Task.WhenAll은 전달받은 task를 시작하지 않고 모두 끝날 때까지 기다리는 task를 만듭니다. 독립 작업을 실제로 시작하는 것은 API 호출이나 task factory이며, 그 결과 task를 먼저 모은 뒤 WhenAll로 await합니다. 한 task가 실패해도 나머지를 강제로 멈추지 않으므로 취소 token을 각 작업에 전달합니다.
// task를 먼저 시작해두고 한 번에 기다림
Task<User> userTask = LoadUserAsync();
Task<Order[]> orderTask = LoadOrdersAsync();
await Task.WhenAll(userTask, orderTask); // 이미 시작한 두 task가 모두 끝날 때까지 대기
User user = await userTask; // 이미 완료됨 — 바로 반환
Order[] orders = await orderTask;
// 컬렉션 버전
string[] results = await Task.WhenAll(
urls.Select(url => http.GetStringAsync(url)));문법
어떤 병렬 대기 형태가 있나
| 상황 | 먼저 떠올릴 것 |
|---|---|
| 여러 작업이 모두 끝나야 함 | Task.WhenAll |
| 가장 먼저 끝난 것만 필요함 | Task.WhenAny |
| 동시에 너무 많이 돌리면 안 됨 | SemaphoreSlim + WhenAll |
순차 await vs WhenAll — 핵심 차이
await A(); await B(); 는 A가 완료된 후에 B를 호출합니다. WhenAll은 이미 만들어진 task들이 모두 끝날 때까지 기다립니다. 일반적인 async API 호출은 호출 시 작업을 시작하지만, lazy task factory나 직접 만든 Task는 다를 수 있으므로 "task를 만들면 항상 시작한다"고 일반화하지 않습니다.
// ❌ 순차 — A(2초) + B(1초) = 3초
var user = await LoadUserAsync(); // 2초 기다린 후
var orders = await LoadOrdersAsync(); // 1초 기다림 → 총 3초
// ✅ 두 작업을 먼저 호출해 시작한 뒤 같이 대기 — 보통 max(A, B)에 가까움
var userTask = LoadUserAsync(); // 즉시 시작
var orderTask = LoadOrdersAsync(); // 즉시 시작
await Task.WhenAll(userTask, orderTask); // 가장 오래 걸리는 것 = 2초핵심은 WhenAll 자체가 아니라 "task를 먼저 만들어 두는 것" 입니다. task를 생성하는 순간 비동기 작업이 시작됩니다.
// ❌ Task.WhenAll 없이 순차 await만 하면 총 시간이 누적
var user = await LoadUserAsync();
var orders = await LoadOrdersAsync();
// ✅ task를 먼저 시작한 뒤 한 번에 기다림
var userTask = LoadUserAsync();
var orderTask = LoadOrdersAsync();
await Task.WhenAll(userTask, orderTask);예외 처리 — AggregateException
공급한 task 중 하나라도 faulted면 WhenAll이 돌려준 task도 faulted이며, 그 Exception에는 모든 task의 unwrapped 예외가 집계됩니다. await는 그중 하나를 던지지만 어느 예외가 관찰되는지 호출 순서로 가정하지 않습니다. 모든 실패를 기록해야 하면 aggregate task의 Exception을 검사합니다. fault가 없고 하나 이상 cancel되면 결과 task는 canceled입니다.
Task t1 = Task.FromException(new InvalidOperationException("A 실패"));
Task t2 = Task.FromException(new ArgumentException("B 실패"));
Task all = Task.WhenAll(t1, t2);
try
{
await all;
}
catch
{
foreach (Exception error in all.Exception!.Flatten().InnerExceptions)
Console.WriteLine(error.Message);
}WhenAny — 가장 빠른 것 먼저
Task.WhenAny 는 여러 task 중 하나라도 완료되면 반환합니다. 타임아웃 구현이나 경쟁 조건에서 유용합니다.
var fetchTask = FetchDataAsync();
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(5));
var completed = await Task.WhenAny(fetchTask, timeoutTask);
if (completed == timeoutTask)
throw new TimeoutException("5초 초과");
var result = await fetchTask; // 이미 완료됨동시성 제한 — SemaphoreSlim
WhenAll로 너무 많은 작업을 동시에 실행하면 외부 API나 DB에 과부하를 줄 수 있습니다. SemaphoreSlim 으로 동시 실행 수를 제한합니다.
using var cts = new CancellationTokenSource();
CancellationToken ct = cts.Token;
using var semaphore = new SemaphoreSlim(initialCount: 5, maxCount: 5);
var tasks = urls.Select(async url =>
{
await semaphore.WaitAsync(ct);
try
{
return await http.GetStringAsync(url, ct);
}
finally
{
semaphore.Release();
}
});
string[] results = await Task.WhenAll(tasks); // Select가 열거되는 이 시점에 task 생성.NET 6+의 Parallel.ForEachAsync는 CPU 수 또는 ParallelOptions.MaxDegreeOfParallelism을 기준으로 병렬 수를 제한할 수 있습니다. I/O 요청에 쓸 때도 callback token을 하위 API에 전달하고, 서버·상대 API의 rate limit에 맞춰 상한을 정합니다.
await Parallel.ForEachAsync(urls, new ParallelOptions
{
MaxDegreeOfParallelism = 5,
CancellationToken = ct,
}, async (url, token) =>
{
await http.GetStringAsync(url, token);
});Task 조합 선택
| 패턴 | 의미 |
|---|---|
| 먼저 task 생성 | 동시에 시작하게 만드는 핵심 |
Task.WhenAll(t1, t2) | 모두 완료까지 대기 |
Task.WhenAny(t1, t2) | 하나라도 완료되면 반환 |
AggregateException | 하나 실패 → 전체 실패, 모든 예외 포함 |
SemaphoreSlim | 동시 실행 수 제한 |
주의할 점
무제한 병렬화는 위험합니다. 1000개 URL을 동시에 요청하면 외부 API가 rate limit으로 차단하거나 DB 연결 풀이 고갈됩니다. SemaphoreSlim으로 동시 실행 수를 제한하거나, .NET 6+의 Parallel.ForEachAsync와 MaxDegreeOfParallelism을 활용하세요.
task들 중 하나가 예외를 던지면 WhenAll 자체는 실패하지만 나머지 task들은 계속 실행됩니다. task를 취소하려면 CancellationToken을 각 task에 전달해야 합니다.
참고 링크
3 sources