[PART13.비동기와 스레딩 기초(8/15)] 여러 Task 조합 — Task.WhenAll · Task.WhenAny
여러 비동기 작업을 한 번에 묶는 두 도구 / WhenAll은 모두 끝날 때까지 / WhenAny는 가장 먼저 끝나는 하나만 / 타임아웃 패턴과 순차 대기의 함정
목차
1. 문제 제기 — await 한 번에 하나씩만 기다리면 생기는 일
Unity 모바일 게임에서 게임이 시작되기 전 다음 세 가지가 모두 끝나야 한다고 해봅시다.
- 캐릭터 모델 에셋 로드 (300ms)
- 레벨 프리팹 로드 (250ms)
- 서버에서 플레이어 데이터 조회 (400ms)
신입 개발자가 가장 먼저 떠올리는 코드는 보통 이렇습니다.
async Task LoadGameAsync()
{
var character = await LoadCharacterAsync(); // 300ms 대기
var level = await LoadLevelAsync(); // 250ms 대기
var player = await FetchPlayerDataAsync(); // 400ms 대기
// 총 950ms
}
각 줄은 정상 동작하지만, 세 작업은 서로 의존하지 않습니다. 그런데도 위 코드는 약 950ms가 걸립니다. 첫 번째 작업이 끝날 때까지 두 번째를 시작조차 하지 않고, 두 번째가 끝날 때까지 세 번째를 시작조차 하지 않기 때문입니다.
세 작업을 동시에 시작했다면 가장 오래 걸리는 작업의 시간(400ms)만큼만 기다리면 됩니다. 무려 550ms의 로딩 시간을 그냥 버린 셈입니다. 모바일 게임에서는 로딩 화면 1초가 사용자 이탈의 직접적인 원인이 됩니다.
또 다른 시나리오도 있습니다. 서버에 요청을 보냈는데 응답이 5초가 지나도 오지 않으면 타임아웃 처리하고 사용자에게 재시도 버튼을 보여줘야 합니다. await만으로는 이 "기다리는 시간 자체에 제한을 거는" 동작을 표현할 수 없습니다.
이 두 가지 문제 — 여러 작업을 병렬로 묶기와 타임아웃을 거는 가장 먼저 끝난 작업 고르기 — 를 동시에 해결하는 도구가 바로 Task.WhenAll과 Task.WhenAny입니다.
2. 개념 정의 — 두 가지 조합기(Combinator)
2.1 비유: 음식 배달 주문
Task.WhenAll— 단체 회식 친구 다섯 명에게 각자 음식을 주문하라고 시키고, 모두가 자기 음식을 받을 때까지 기다렸다가 다같이 식사를 시작합니다. 한 명이라도 늦으면 그 사람이 도착할 때까지 기다립니다.Task.WhenAny— 가장 빠른 배달 같은 메뉴를 세 가게에 주문하고, 가장 먼저 도착한 가게의 음식만 받습니다. 나머지 두 곳은 도착해도 무시(또는 취소)합니다.
이름 그대로입니다 — WhenAll은 "모두 끝날 때(when all)", WhenAny는 "어느 하나라도 끝날 때(when any)" 완료됩니다.
2.2 시각화

WhenAll은 가장 느린 작업의 시간만큼, WhenAny는 가장 빠른 작업의 시간만큼 걸립니다.
2.3 가장 단순한 사용법
using System.Threading.Tasks;
async Task<int[]> WhenAllExampleAsync()
{
Task<int> a = Task.FromResult(10);
Task<int> b = Task.FromResult(20);
Task<int> c = Task.FromResult(30);
int[] results = await Task.WhenAll(a, b, c);
// results = [10, 20, 30] — 입력 순서 그대로
return results;
}
async Task<int> WhenAnyExampleAsync()
{
Task<int> fast = Task.Delay(50).ContinueWith(_ => 1);
Task<int> slow = Task.Delay(500).ContinueWith(_ => 2);
Task<int> first = await Task.WhenAny(fast, slow); // Task<Task<int>> 를 await하면 Task<int>
// first 는 fast 와 같은 객체
return await first; // 1
}
await— 비동기 대기 키워드 비동기 작업이 끝날 때까지 현재 메서드를 일시 중지(suspend)하고, 호출 스레드는 다른 일을 할 수 있도록 반환한다. 작업이 끝나면 다시 이어서 실행한다.
예시:int[] r = await Task.WhenAll(tasks);tasks 안의 모든 Task가 끝날 때까지 메서드가 멈췄다가 결과 배열을 받는다.
WhenAll은 결과 배열(T[])을 돌려주고, WhenAny는 완료된 Task 자체를 돌려줍니다. WhenAny로 결과 값을 얻으려면 한 번 더 await해야 합니다 — 이 점이 처음 보면 가장 헷갈립니다.
2.4 반환 타입 정리
| 메서드 | 입력 | 반환 타입 | await 결과 |
|---|---|---|---|
Task.WhenAll(Task[]) |
Task 여러 개 |
Task |
void |
Task.WhenAll<T>(Task<T>[]) |
Task<T> 여러 개 |
Task<T[]> |
T[] 배열 |
Task.WhenAny(Task[]) |
Task 여러 개 |
Task<Task> |
완료된 Task 객체 |
Task.WhenAny<T>(Task<T>[]) |
Task<T> 여러 개 |
Task<Task<T>> |
완료된 Task<T> 객체 |
WhenAll이 입력 순서대로 결과를 묶어주는 점이 중요합니다. 가장 빨리 끝난 Task가 results[0]이 되는 게 아니라, 입력 리스트의 첫 번째 Task의 결과가 results[0]입니다.
2.5 IL 분석 — await Task.WhenAll(...)은 다른 await와 똑같다
ParallelAsync라는 메서드를 컴파일해 IL을 보면, Task.WhenAll은 그냥 평범한 메서드 호출이고 await가 상태 머신을 만든다는 사실이 분명해집니다.
private static async Task<int[]> ParallelAsync(List<Task<int>> tasks)
{
return await Task.WhenAll(tasks);
}
// ParallelAsync 의 컴파일된 메서드 본체
.method private hidebysig static
class [System.Runtime]System.Threading.Tasks.Task`1<int32[]>
ParallelAsync(class [...]List`1<class [...]Task`1<int32>> tasks) cil managed
{
// 1. 상태 머신 구조체를 스택에 만들고
.locals init ([0] valuetype Program/'<ParallelAsync>d__1' V_0)
ldloca.s V_0
call valuetype [...]AsyncTaskMethodBuilder`1<int32[]>::Create()
stfld ...t__builder // 빌더 초기화
ldloca.s V_0
ldarg.0
stfld ...tasks // 파라미터 저장
ldloca.s V_0
ldc.i4.m1
stfld ...<>1__state // state = -1 (시작 전)
// 2. 상태 머신 시작
ldloca.s V_0
ldflda ...t__builder
ldloca.s V_0
call AsyncTaskMethodBuilder`1::Start<...>(!!0&)
ldloca.s V_0
ldflda ...t__builder
call AsyncTaskMethodBuilder`1::get_Task()
ret
}
// 상태 머신 내부 MoveNext 의 핵심 (Task.WhenAll 호출과 await 처리)
IL_00ae: call class [...]Task`1<int32[]> System.Threading.Tasks.Task::WhenAll(...)
IL_00b3: callvirt instance valuetype TaskAwaiter`1<int32[]> Task`1::GetAwaiter()
IL_00b8: stloc.3
IL_00bb: call instance bool TaskAwaiter`1::get_IsCompleted()
IL_00c0: brtrue.s IL_0101 // 이미 완료면 동기 경로
// ↓ 미완료면 콜백 등록 + leave (호출자에게 제어권 반환)
IL_00d8: ldloca.s 3
IL_00db: call instance void AsyncTaskMethodBuilder::AwaitUnsafeOnCompleted<...>(...)
IL_00e0: leave IL_018d
// ↓ 재진입 시 결과 추출
IL_0103: call instance !0 TaskAwaiter`1::GetResult()
핵심은 두 줄입니다.
IL_00ae:Task.WhenAll(tasks)는 그저 정적 메서드 호출이다.Task<int[]>를 반환할 뿐 마법은 없다.IL_00bb~IL_00db:await가 만든 상태 머신이 그 Task에GetAwaiter→IsCompleted체크 →AwaitUnsafeOnCompleted로 콜백 등록을 한다.
즉 await Task.WhenAll(tasks)는 컴파일 관점에서 await someOrdinaryTask와 완전히 동일한 모양입니다. 차이는 단지 someOrdinaryTask가 "여러 Task가 모두 끝나면 완료되는 게이트키퍼 Task"라는 점뿐입니다.
3. 내부 동작 — 게이트키퍼 Task와 카운트다운
3.1 시각화

3.2 동작 단계
Task.WhenAll을 호출하는 순간 일어나는 일을 단계별로 보면 다음과 같습니다.
- 게이트키퍼 Task 생성 —
WhenAll이 우리에게 돌려주는 그 Task다. 아직 완료되지 않은 상태. - 컨티뉴에이션(continuation) 등록 — 입력된 모든 Task에 "이 Task가 끝나면 게이트키퍼에게 알려달라"는 콜백을
ContinueWith로 단다. - 카운트다운 — 내부 카운터를 입력 Task 개수로 초기화. Task가 하나 끝날 때마다
Interlocked.Decrement로 1씩 감소. - 모두 완료 처리 — 카운터가 0이 되면 게이트키퍼의
Result에T[]배열을 채우고RanToCompletion상태로 전환. 예외가 있었으면 모아서AggregateException으로 묶어Faulted상태로 전환.
WhenAny는 더 단순합니다 — 컨티뉴에이션은 등록하지만, 첫 번째로 도착한 Task만 게이트키퍼의 Result로 설정합니다. 두 번째, 세 번째 Task가 끝나도 이미 완료된 게이트키퍼에 다시 쓰지 못하고 무시됩니다.
Interlocked— 원자적 연산 클래스 멀티스레드 환경에서 변수의 읽기·증감·교체를 인터럽트 없이 한 번에 수행한다. 락 없이도 안전한 카운트가 필요할 때 쓴다.
예시:Interlocked.Decrement(ref remaining)여러 Task가 동시에 완료돼도 카운트가 꼬이지 않는다.
3.3 결과 배열의 순서 보장
WhenAll이 돌려주는 T[]는 입력 순서를 보장합니다 — 가장 빨리 끝난 Task가 [0]이 되는 게 아니라 입력 리스트의 첫 번째 Task의 결과가 [0]입니다. 이는 내부 구현이 입력 배열의 인덱스를 그대로 결과 배열의 인덱스로 사용하기 때문입니다.
async Task DemoOrderAsync()
{
var slow = Task.Run(async () => { await Task.Delay(300); return "slow"; });
var fast = Task.Run(async () => { await Task.Delay(50); return "fast"; });
string[] results = await Task.WhenAll(slow, fast);
// results[0] == "slow" ← 입력 순서대로
// results[1] == "fast"
}
3.4 IL 관점 — WhenAll은 평범한 메서드, 마법은 await에 있다
위 IL에서 본 것처럼 Task.WhenAll(...) 자체는 그냥 정적 메서드 호출입니다. 게이트키퍼 Task 객체를 만들어 돌려주는 일만 합니다. 그 Task를 비동기적으로 기다리는 메커니즘(상태 머신, MoveNext, AwaitUnsafeOnCompleted)은 모두 await 키워드가 컴파일러를 통해 만든 것입니다. 이 사실 덕분에 Task.WhenAll이 돌려준 Task를 변수에 담아 두었다가 나중에 await해도, 다른 메서드에 인자로 넘겨도 모두 자연스럽게 동작합니다.
4. 실전 적용 — 언제 어떻게 쓰는가
4.1 Before/After: 순차 대기 vs 병렬 대기
Before — foreach로 하나씩 await (병렬 효과 사라짐)
async Task<int[]> SequentialAsync(List<Task<int>> tasks)
{
var results = new int[tasks.Count];
for (int i = 0; i < tasks.Count; i++)
{
results[i] = await tasks[i]; // ← 한 개씩만 기다림
}
return results;
}
Unity 시나리오: 에셋 5개 로드. 각 작업이 200ms씩 걸리면 총 1000ms.
After — Task.WhenAll로 한 번에 (병렬 실행)
async Task<int[]> ParallelAsync(List<Task<int>> tasks)
{
return await Task.WhenAll(tasks); // ← 동시에 기다림
}
같은 시나리오: 5개가 동시에 진행되므로 총 약 200ms. 5배 빠름.
IL 분석 — 두 패턴의 결정적 차이
SequentialAsync의 IL을 보면 루프 한 바퀴마다 await 코드(상태 머신 분기 + AwaitUnsafeOnCompleted + leave)가 반복적으로 실행되도록 컴파일됩니다.
// SequentialAsync 의 MoveNext 일부 — 루프 안의 await
IL_004x: ldarg.0
IL_004y: ldloc.x // tasks[i]
IL_004z: callvirt Task`1::GetAwaiter()
IL_005x: stloc.x
IL_005y: call TaskAwaiter`1::get_IsCompleted()
IL_005z: brtrue.s ... // 동기 경로
IL_006x: call AwaitUnsafeOnCompleted // ← 루프마다 콜백 등록
IL_006y: leave ... // ← 매 반복마다 호출자에게 양보
// 다음 반복에서 GetResult 후 results[i] = ...
즉 IL 레벨에서 보면 SequentialAsync는 "Task를 하나 기다림 → 결과 저장 → 다음 Task를 기다림"이 N번 반복됩니다. 그래서 시간이 더해집니다.
반면 ParallelAsync의 IL은 단 한 번의 await만 있습니다.
// ParallelAsync 의 MoveNext 일부
IL_00ae: call Task::WhenAll(...) // 게이트키퍼 Task 생성 (즉시 반환)
IL_00b3: callvirt Task`1::GetAwaiter()
IL_00bb: call TaskAwaiter`1::get_IsCompleted()
IL_00c0: brtrue.s IL_0101
IL_00db: call AwaitUnsafeOnCompleted // ← 콜백 등록은 단 한 번
IL_00e0: leave IL_018d
IL_0103: call TaskAwaiter`1::GetResult()
Task.WhenAll(tasks) 호출은 게이트키퍼 Task를 즉시 반환합니다. 게이트키퍼는 카운트다운 카운터가 0이 될 때만 완료됩니다. 이 한 번의 await가 N개 작업의 동시 진행 시간을 측정합니다 — 그래서 Max(작업 시간들)만큼만 걸립니다.
4.2 타임아웃 패턴 — WhenAny + Task.Delay
WhenAny의 가장 흔한 용도는 타임아웃입니다.
async Task<string> FetchWithTimeoutAsync(int timeoutMs)
{
Task<string> work = FetchFromServerAsync();
Task delay = Task.Delay(timeoutMs);
Task first = await Task.WhenAny(work, delay);
if (first == delay)
throw new TimeoutException($"{timeoutMs}ms 초과");
return await work; // 서버 응답 결과
}
게이트키퍼 Task가 어느 쪽이 먼저 끝났는지 알려주므로 참조 비교(first == delay)로 분기합니다. .NET 6+에서는 task.WaitAsync(TimeSpan)라는 더 짧은 API도 있지만, 내부 동작 이해를 위해 WhenAny + Delay 패턴을 알아두는 게 좋습니다.
IL 분석 — WhenAny 호출과 참조 비교
private static async Task<int> WithTimeoutAsync(Task<int> work, int timeoutMs)
{
Task delay = Task.Delay(timeoutMs);
if (await Task.WhenAny(work, delay) == delay)
throw new TimeoutException();
return await work;
}
// MoveNext 핵심 부분 — WhenAny 호출과 참조 비교
ldarg.0
ldfld ...work
ldarg.0
ldfld ...delay
call class Task Task::WhenAny(class Task, class Task) // ← 두 인자 버전
callvirt TaskAwaiter`1::GetAwaiter()
// ... await 상태 머신 (생략) ...
call TaskAwaiter`1::GetResult() // ← 완료된 Task 반환
ldarg.0
ldfld ...delay
ceq // ← 참조 비교 (==)
brfalse.s ... (work 결과 반환 경로)
newobj instance void TimeoutException::.ctor()
throw
ceq가 핵심입니다 — WhenAny가 돌려준 Task와 delay Task의 참조를 비교합니다. WhenAny는 "방금 완료된 Task의 참조 그 자체"를 결과로 주기 때문에 어느 쪽이 먼저 끝났는지 알 수 있습니다.
4.3 Unity 실전 — 씬 로딩 병렬화
Unity 모바일 게임에서 씬 진입 시 자주 마주치는 패턴입니다.
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
public class SceneLoader : MonoBehaviour
{
public async Task EnterStageAsync(string stageId)
{
// 1. 모든 비동기 작업을 동시에 시작 (await 하지 않음)
Task<GameObject> characterTask = LoadAssetAsync<GameObject>("character");
Task<GameObject> stageTask = LoadAssetAsync<GameObject>($"stage_{stageId}");
Task<AudioClip> bgmTask = LoadAssetAsync<AudioClip>($"bgm_{stageId}");
Task<PlayerSaveData> saveTask = ServerApi.FetchSaveAsync();
// 2. 한 번에 기다림 — 가장 오래 걸리는 작업의 시간만큼만 걸림
await Task.WhenAll(characterTask, stageTask, bgmTask, saveTask);
// 3. 결과 사용 (이미 완료됐으므로 .Result 안전)
Instantiate(characterTask.Result);
Instantiate(stageTask.Result);
AudioSource.PlayClipAtPoint(bgmTask.Result, Vector3.zero);
ApplySaveData(saveTask.Result);
}
static Task<T> LoadAssetAsync<T>(string key) where T : Object
{
var op = Addressables.LoadAssetAsync<T>(key);
return op.Task; // Addressables는 Task로 변환 가능
}
}
Unity의 AsyncOperationHandle.Task 속성을 사용하면 Addressables 로딩을 표준 Task로 다룰 수 있습니다. 그 이후로는 일반 C# 비동기와 동일하게 WhenAll로 묶을 수 있습니다.
주의: Unity 메인 스레드 동기화 컨텍스트(SynchronizationContext)는 IL2CPP·플랫폼·Unity 버전에 따라 동작이 다릅니다. 라이브러리 코드라면await Task.WhenAll(...).ConfigureAwait(false)도 고려할 수 있지만, MonoBehaviour 코드에서 await 이후에Transform·Instantiate같은 메인 스레드 API를 호출해야 한다면ConfigureAwait(false)를 쓰면 안 됩니다 — 메인 스레드로 돌아오지 않을 수 있습니다.
4.4 Unity 실전 — 가장 빠른 서버 선택
WhenAny로 여러 후보 서버에 동시에 핑을 보내고 가장 먼저 응답한 곳을 선택할 수 있습니다.
async Task<string> PickFastestServerAsync(string[] candidates)
{
using var cts = new CancellationTokenSource();
var pingTasks = new Task<string>[candidates.Length];
for (int i = 0; i < candidates.Length; i++)
{
pingTasks[i] = PingAsync(candidates[i], cts.Token);
}
Task<string> winner = await Task.WhenAny(pingTasks);
cts.Cancel(); // 나머지 서버 ping은 취소 (네트워크 낭비 방지)
return await winner;
}
CancellationTokenSource.Cancel()을 호출해 진행 중인 나머지 ping을 정리하는 것이 모바일 환경에서 중요합니다 — 데이터 요금과 배터리를 아낍니다.
5. 함정과 주의사항
5.1 함정 1 — WhenAll 다음에 첫 예외만 잡으면 나머지 예외를 놓친다
❌ 잘못된 패턴
try
{
await Task.WhenAll(taskA, taskB, taskC);
}
catch (Exception ex)
{
// ex 는 첫 번째 예외 하나뿐!
// 나머지 두 개는 어디로?
Debug.LogError(ex);
}
await Task.WhenAll은 여러 Task가 실패해도 첫 번째 예외만 다시 던집니다(rethrow). 이는 await가 AggregateException을 unwrap해서 첫 번째 InnerException을 던지기 때문입니다. 나머지 예외는 게이트키퍼 Task의 Exception 속성에는 들어 있지만, 코드에서 명시적으로 꺼내지 않으면 사라집니다.
✅ 올바른 패턴
Task all = Task.WhenAll(taskA, taskB, taskC);
try
{
await all;
}
catch
{
// all.Exception 에는 모든 예외가 AggregateException 으로 담겨 있다
foreach (var inner in all.Exception!.InnerExceptions)
{
Debug.LogError(inner);
}
}
핵심은 WhenAll이 돌려준 Task를 변수에 담아두는 것입니다. await가 던지는 예외 객체는 첫 번째 것뿐이지만, Task 객체의 Exception 속성에는 전부 보존되어 있습니다.
IL 관점
// catch 블록 내부에서 변수에 저장된 Task에 접근
ldloc.0 // Task all
callvirt Task::get_Exception() // AggregateException? 반환
callvirt AggregateException::get_InnerExceptions()
// foreach 루프
get_Exception()이 핵심입니다. 이 속성이 모든 예외를 보존하고 있으므로, Task 변수만 살려두면 모든 InnerException을 순회할 수 있습니다.
5.2 함정 2 — WhenAny로 끝나도 나머지 Task는 계속 돈다
❌ 잘못된 인식
Task<string> work = FetchAsync();
Task delay = Task.Delay(2000);
await Task.WhenAny(work, delay);
// "타임아웃이니까 work는 자동으로 멈췄겠지?" — 아니다!
// work 는 계속 진행 중이며, 끝나면 결과를 버려도 그 사이 자원·돈을 쓴다
WhenAny는 어느 하나가 끝났음을 알려줄 뿐, 나머지 Task를 멈추지 않습니다. 네트워크 요청이라면 끝까지 응답을 받으며 그동안 데이터·배터리를 소모합니다.
✅ 올바른 패턴 — 취소 토큰 결합
async Task<string> FetchWithTimeoutAsync(int timeoutMs)
{
using var cts = new CancellationTokenSource(timeoutMs);
try
{
// 서버 호출이 cts.Token 을 협조적으로 확인하도록 만든다
return await FetchAsync(cts.Token);
}
catch (OperationCanceledException)
{
throw new TimeoutException($"{timeoutMs}ms 초과");
}
}
CancellationTokenSource가 시간을 추적하고, 시간이 지나면 토큰이 취소 신호를 보내며, FetchAsync 내부의 HttpClient·UnityWebRequest·내가 짠 코드가 이 토큰을 협조적으로 체크하면서 정리합니다. 이 방식이 자원 측면에서 훨씬 깔끔합니다.
5.3 함정 3 — Wait()/Result/WaitAll은 await가 아니다 (데드락 위험)
❌ 잘못된 패턴
// Unity UI 이벤트 핸들러
public void OnButtonClick()
{
var task = LoadAssetAsync();
task.Wait(); // ← UI 스레드 블로킹! 게임 화면 멈춤
// Unity SynchronizationContext에서는 데드락도 가능
}
Task.Wait·Task<T>.Result·Task.WaitAll·Task.WaitAny는 현재 스레드를 블로킹합니다. UI 스레드(또는 Unity 메인 스레드)에서 호출하면 화면이 멈추고, SynchronizationContext가 있는 환경에서는 데드락도 발생합니다.
✅ 올바른 패턴 — await Task.WhenAll/WhenAny만 사용
public async void OnButtonClick()
{
var task = LoadAssetAsync();
await task; // ← 메인 스레드 양보, 끝나면 다시 돌아옴
}
| 메서드 | 동작 | UI/Unity 메인 스레드 |
|---|---|---|
Task.WaitAll(tasks) |
동기·블로킹 | ❌ 절대 금지 |
Task.WaitAny(tasks) |
동기·블로킹 | ❌ 절대 금지 |
await Task.WhenAll(tasks) |
비동기·논블로킹 | ✅ 권장 |
await Task.WhenAny(tasks) |
비동기·논블로킹 | ✅ 권장 |
이름이 비슷해서 헷갈리지만 동작은 정반대입니다 — Wait*은 동기, When*은 비동기.
5.4 함정 4 — WhenAny를 루프로 돌려 "완료 순서대로 처리"하면 O(N²)
❌ 잘못된 패턴
async Task ProcessInOrderAsync(List<Task<int>> tasks)
{
while (tasks.Count > 0)
{
// N번 호출 × 매번 N개 Task에 콜백 등록 = O(N²)
Task<int> done = await Task.WhenAny(tasks);
tasks.Remove(done);
Process(await done);
}
}
매 루프마다 WhenAny가 남은 모든 Task에 새로운 컨티뉴에이션을 등록합니다. 작업이 100개면 컨티뉴에이션 등록·해제가 약 5000번 발생합니다.
✅ 올바른 패턴 — Task.WhenEach (.NET 9+)
async Task ProcessInOrderAsync(IEnumerable<Task<int>> tasks)
{
await foreach (Task<int> completed in Task.WhenEach(tasks))
{
Process(await completed);
}
}
.NET 9에서 도입된 Task.WhenEach는 완료되는 순서대로 Task를 비동기 스트림(IAsyncEnumerable<Task<T>>)으로 돌려줍니다. 내부적으로 IValueTaskSource를 활용해 O(N)으로 동작합니다. 구버전(.NET 8 이하·Unity 2022)에서는 TaskCompletionSource로 직접 구현해야 합니다.
5.5 함정 5 — Unity Profiler에서 GC 스파이크가 보일 때
Task.WhenAll(...)은 호출할 때마다 다음을 새로 할당합니다.
- 게이트키퍼 Task 객체 (참조 타입, 힙 할당)
- 입력 Task가
IEnumerable이면 내부적으로 배열로 변환 (할당) - 결과
T[]배열 (Task<T>버전) - 각 Task에 등록되는 컨티뉴에이션 콜백 객체
씬 전환·로딩 같이 한 번 일어나는 작업에서는 무시 가능하지만 Update 루프나 매 프레임 호출되는 핫패스에서 매번 Task.WhenAll을 만들면 GC 스파이크가 발생합니다.
❌ 잘못된 패턴 — Update 안에서 매 프레임 WhenAll
void Update()
{
// 매 프레임 WhenAll 호출 — GC 스파이크의 원인
_ = Task.WhenAll(SomeWorkAsync(), AnotherWorkAsync());
}
✅ 올바른 패턴 — 핫패스에서는 Task 자체를 피하거나 ValueTask 사용
// 핫패스에서는 동기 처리
void Update() { DoSyncWork(); }
// 비동기가 꼭 필요하면 한 번만 시작하고 상태 추적
Task? _pending;
void Update()
{
if (_pending == null || _pending.IsCompleted)
{
_pending = Task.WhenAll(SomeWorkAsync(), AnotherWorkAsync());
}
}
또는 Unity 전용 UniTask 라이브러리는 Task 대비 할당이 거의 없도록 설계돼 있어 핫패스에서 더 적합합니다.
6. C# 버전별 변화
| 버전 | 변화 | 의미 |
|---|---|---|
| .NET Framework 4.5 (2012) | Task.WhenAll / Task.WhenAny 도입 |
async/await와 함께 표준 비동기 조합기 등장 |
| .NET Core 3.0 (2019) | IAsyncEnumerable<T> 도입 |
비동기 스트림이 가능해지면서 WhenAny 루프의 대안 시작 |
| .NET 6 (2021) | Task.WaitAsync(TimeSpan) / Task.WaitAsync(CancellationToken) |
타임아웃 패턴이 더 짧아짐 — WhenAny + Delay 보일러플레이트 감소 |
| .NET 9 (2024) | Task.WhenEach(IEnumerable<Task>) 도입 |
"완료 순서대로 처리" 패턴이 O(N)으로, 비동기 스트림으로 표현됨 |
6.1 .NET 5 이하 — WhenAny + Delay 타임아웃
// Before — .NET 5 이전 (또는 Unity 2022 LTS, .NET Standard 2.1)
async Task<string> FetchWithTimeoutAsync(int timeoutMs)
{
Task<string> work = FetchAsync();
Task delay = Task.Delay(timeoutMs);
if (await Task.WhenAny(work, delay) == delay)
throw new TimeoutException();
return await work;
}
6.2 .NET 6+ — WaitAsync로 한 줄 단축
// After — .NET 6+
async Task<string> FetchWithTimeoutAsync(int timeoutMs)
{
return await FetchAsync().WaitAsync(TimeSpan.FromMilliseconds(timeoutMs));
}
IL 관점에서의 차이
WaitAsync는 내부적으로는 여전히 컨티뉴에이션 + 타이머를 사용하지만, IL 레벨에서 보면 호출자 코드에서는 WhenAny 호출과 Task.Delay 객체 할당이 사라집니다.
// Before — WhenAny 패턴
call class Task Task::Delay(int32) // delay Task 할당
call class Task Task::WhenAny(class Task, class Task) // 게이트키퍼 Task 할당
ceq // 참조 비교
// ... 분기 ...
// After — WaitAsync
call class Task`1<...> Task`1::WaitAsync(valuetype TimeSpan)
// 끝
호출자 IL이 짧아지고 할당이 줄어듭니다. Unity 2022 LTS는 .NET Standard 2.1 기반이라 아직 WaitAsync가 없을 수 있지만, Unity 6 / .NET 8+ 환경이라면 적극 사용을 권장합니다.
6.3 .NET 9 — WhenEach로 완료 순서대로 처리
// Before — WhenAny 루프 (O(N²))
async Task ProcessAsync(List<Task<int>> tasks)
{
while (tasks.Count > 0)
{
var done = await Task.WhenAny(tasks);
tasks.Remove(done);
Console.WriteLine(await done);
}
}
// After — WhenEach (O(N), 비동기 스트림)
async Task ProcessAsync(IEnumerable<Task<int>> tasks)
{
await foreach (Task<int> completed in Task.WhenEach(tasks))
{
Console.WriteLine(await completed);
}
}
WhenEach는 .NET 9 / C# 13 환경(서버·CLI)에서 사용 가능합니다. Unity는 아직 .NET Standard 2.1 기반이므로 직접 도입하지는 못하지만, 향후 이주 시 알아둘 가치가 있습니다.
7. 정리
이번 글에서 다룬 핵심을 한 줄씩 정리하면 다음과 같습니다.
| # | 항목 | 핵심 |
|---|---|---|
| 1 | WhenAll vs WhenAny |
모두 끝날 때까지 vs 가장 먼저 끝나는 하나 |
| 2 | 반환 타입 | Task<T[]> (입력 순서) vs Task<Task> (완료된 Task 자체) |
| 3 | 순차 vs 병렬 | foreach (var t in tasks) await t;는 합산 시간, await Task.WhenAll(tasks)는 최댓값 시간 |
| 4 | 타임아웃 | Task.WhenAny(work, Task.Delay(timeout)) 또는 .NET 6+의 WaitAsync |
| 5 | 예외 처리 | await Task.WhenAll은 첫 예외만 던진다 — 모든 예외는 Task 변수의 Exception.InnerExceptions에 |
| 6 | WhenAny 후 정리 |
나머지 Task는 자동으로 안 멈춘다 — CancellationToken으로 협조 취소 |
| 7 | Wait* 금지 |
WaitAll/WaitAny/Result/Wait은 블로킹 — Unity 메인 스레드 절대 금지 |
| 8 | Unity 핫패스 주의 | 매 프레임 WhenAll은 GC 스파이크 — Update 안에서 피하거나 UniTask 사용 |
체크리스트
- [ ] 여러 작업이 서로 의존하지 않으면
await Task.WhenAll(...)로 묶었는가? - [ ]
Task<T>[]결과 배열이 입력 순서를 따른다는 점을 인지하고 있는가? - [ ] 타임아웃이 필요하면
WhenAny + Delay또는WaitAsync를 사용하고 있는가? - [ ] 여러 Task가 동시에 실패할 가능성이 있으면
Task변수를 살려Exception.InnerExceptions를 순회하는가? - [ ]
WhenAny이후 나머지 Task를CancellationToken으로 정리하고 있는가? - [ ] Unity UI/Update 코드에서
Wait()·.Result·WaitAll을 사용하지 않는가? - [ ] Update 안에서 매 프레임
WhenAll을 호출하고 있지는 않은가?
WhenAll과 WhenAny는 비동기 코드의 "그리고/또는"입니다. await 한 번에 작업 한 개씩만 기다리던 한계를 넘어, 여러 비동기 작업을 자연스럽게 조합할 수 있게 해주는 가장 기본적인 도구입니다.
'C# 기초' 카테고리의 다른 글
| [PART13.비동기와 스레딩 기초(10/15)] Task의 예외 처리 (0) | 2026.05.09 |
|---|---|
| [PART13.비동기와 스레딩 기초(9/15)] 취소 — CancellationToken · CancellationTokenSource (0) | 2026.05.09 |
| [PART13.비동기와 스레딩 기초(7/15)] 비동기 메서드의 반환형 — Task vs Task<T> vs void (0) | 2026.05.08 |
| [PART13.비동기와 스레딩 기초(6/15)] async / await — 비동기 흐름을 동기처럼 쓰는 문법 (0) | 2026.05.08 |
| [PART13.비동기와 스레딩 기초(5/15)] Task와 Task<T> — "언젠가 끝날 작업" 객체 (0) | 2026.05.08 |