반응형

[PART13.비동기와 스레딩 기초(8/15)] 여러 Task 조합 — Task.WhenAll · Task.WhenAny

여러 비동기 작업을 한 번에 묶는 두 도구 / WhenAll은 모두 끝날 때까지 / WhenAny는 가장 먼저 끝나는 하나만 / 타임아웃 패턴과 순차 대기의 함정


1. 문제 제기 — await 한 번에 하나씩만 기다리면 생기는 일

Unity 모바일 게임에서 게임이 시작되기 전 다음 세 가지가 모두 끝나야 한다고 해봅시다.

  • 캐릭터 모델 에셋 로드 (300ms)
  • 레벨 프리팹 로드 (250ms)
  • 서버에서 플레이어 데이터 조회 (400ms)

신입 개발자가 가장 먼저 떠올리는 코드는 보통 이렇습니다.

C#
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.WhenAllTask.WhenAny입니다.


2. 개념 정의 — 두 가지 조합기(Combinator)

2.1 비유: 음식 배달 주문

Task.WhenAll — 단체 회식 친구 다섯 명에게 각자 음식을 주문하라고 시키고, 모두가 자기 음식을 받을 때까지 기다렸다가 다같이 식사를 시작합니다. 한 명이라도 늦으면 그 사람이 도착할 때까지 기다립니다.
Task.WhenAny — 가장 빠른 배달 같은 메뉴를 세 가게에 주문하고, 가장 먼저 도착한 가게의 음식만 받습니다. 나머지 두 곳은 도착해도 무시(또는 취소)합니다.

이름 그대로입니다 — WhenAll은 "모두 끝날 때(when all)", WhenAny는 "어느 하나라도 끝날 때(when any)" 완료됩니다.

2.2 시각화

Task.WhenAll — 모두 끝날 때까지

WhenAll은 가장 느린 작업의 시간만큼, WhenAny는 가장 빠른 작업의 시간만큼 걸립니다.

2.3 가장 단순한 사용법

C#
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가 상태 머신을 만든다는 사실이 분명해집니다.

C#
private static async Task<int[]> ParallelAsync(List<Task<int>> tasks)
{
    return await Task.WhenAll(tasks);
}
IL
// 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에 GetAwaiterIsCompleted 체크 → AwaitUnsafeOnCompleted로 콜백 등록을 한다.

await Task.WhenAll(tasks)는 컴파일 관점에서 await someOrdinaryTask와 완전히 동일한 모양입니다. 차이는 단지 someOrdinaryTask가 "여러 Task가 모두 끝나면 완료되는 게이트키퍼 Task"라는 점뿐입니다.


3. 내부 동작 — 게이트키퍼 Task와 카운트다운

3.1 시각화

WhenAll 내부 — 카운트다운으로 모두 끝나기를 기다린다

3.2 동작 단계

Task.WhenAll을 호출하는 순간 일어나는 일을 단계별로 보면 다음과 같습니다.

  1. 게이트키퍼 Task 생성WhenAll이 우리에게 돌려주는 그 Task다. 아직 완료되지 않은 상태.
  2. 컨티뉴에이션(continuation) 등록 — 입력된 모든 Task에 "이 Task가 끝나면 게이트키퍼에게 알려달라"는 콜백을 ContinueWith로 단다.
  3. 카운트다운 — 내부 카운터를 입력 Task 개수로 초기화. Task가 하나 끝날 때마다 Interlocked.Decrement로 1씩 감소.
  4. 모두 완료 처리 — 카운터가 0이 되면 게이트키퍼의 ResultT[] 배열을 채우고 RanToCompletion 상태로 전환. 예외가 있었으면 모아서 AggregateException으로 묶어 Faulted 상태로 전환.

WhenAny는 더 단순합니다 — 컨티뉴에이션은 등록하지만, 첫 번째로 도착한 Task만 게이트키퍼의 Result로 설정합니다. 두 번째, 세 번째 Task가 끝나도 이미 완료된 게이트키퍼에 다시 쓰지 못하고 무시됩니다.

Interlocked — 원자적 연산 클래스 멀티스레드 환경에서 변수의 읽기·증감·교체를 인터럽트 없이 한 번에 수행한다. 락 없이도 안전한 카운트가 필요할 때 쓴다.
예시: Interlocked.Decrement(ref remaining) 여러 Task가 동시에 완료돼도 카운트가 꼬이지 않는다.

3.3 결과 배열의 순서 보장

WhenAll이 돌려주는 T[]입력 순서를 보장합니다 — 가장 빨리 끝난 Task가 [0]이 되는 게 아니라 입력 리스트의 첫 번째 Task의 결과가 [0]입니다. 이는 내부 구현이 입력 배열의 인덱스를 그대로 결과 배열의 인덱스로 사용하기 때문입니다.

C#
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 (병렬 효과 사라짐)

C#
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로 한 번에 (병렬 실행)

C#
async Task<int[]> ParallelAsync(List<Task<int>> tasks)
{
    return await Task.WhenAll(tasks); // ← 동시에 기다림
}

같은 시나리오: 5개가 동시에 진행되므로 총 약 200ms. 5배 빠름.

IL 분석 — 두 패턴의 결정적 차이

SequentialAsync의 IL을 보면 루프 한 바퀴마다 await 코드(상태 머신 분기 + AwaitUnsafeOnCompleted + leave)가 반복적으로 실행되도록 컴파일됩니다.

IL
// 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만 있습니다.

IL
// 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의 가장 흔한 용도는 타임아웃입니다.

C#
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 호출과 참조 비교

C#
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;
}
IL
// 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 모바일 게임에서 씬 진입 시 자주 마주치는 패턴입니다.

C#
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로 여러 후보 서버에 동시에 핑을 보내고 가장 먼저 응답한 곳을 선택할 수 있습니다.

C#
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 다음에 첫 예외만 잡으면 나머지 예외를 놓친다

❌ 잘못된 패턴

C#
try
{
    await Task.WhenAll(taskA, taskB, taskC);
}
catch (Exception ex)
{
    // ex 는 첫 번째 예외 하나뿐!
    // 나머지 두 개는 어디로?
    Debug.LogError(ex);
}

await Task.WhenAll은 여러 Task가 실패해도 첫 번째 예외만 다시 던집니다(rethrow). 이는 awaitAggregateException을 unwrap해서 첫 번째 InnerException을 던지기 때문입니다. 나머지 예외는 게이트키퍼 Task의 Exception 속성에는 들어 있지만, 코드에서 명시적으로 꺼내지 않으면 사라집니다.

✅ 올바른 패턴

C#
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 관점

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는 계속 돈다

❌ 잘못된 인식

C#
Task<string> work = FetchAsync();
Task         delay = Task.Delay(2000);

await Task.WhenAny(work, delay);
// "타임아웃이니까 work는 자동으로 멈췄겠지?" — 아니다!
// work 는 계속 진행 중이며, 끝나면 결과를 버려도 그 사이 자원·돈을 쓴다

WhenAny는 어느 하나가 끝났음을 알려줄 뿐, 나머지 Task를 멈추지 않습니다. 네트워크 요청이라면 끝까지 응답을 받으며 그동안 데이터·배터리를 소모합니다.

✅ 올바른 패턴 — 취소 토큰 결합

C#
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가 아니다 (데드락 위험)

❌ 잘못된 패턴

C#
// 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만 사용

C#
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²)

❌ 잘못된 패턴

C#
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+)

C#
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

C#
void Update()
{
    // 매 프레임 WhenAll 호출 — GC 스파이크의 원인
    _ = Task.WhenAll(SomeWorkAsync(), AnotherWorkAsync());
}

✅ 올바른 패턴 — 핫패스에서는 Task 자체를 피하거나 ValueTask 사용

C#
// 핫패스에서는 동기 처리
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 타임아웃

C#
// 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로 한 줄 단축

C#
// After — .NET 6+
async Task<string> FetchWithTimeoutAsync(int timeoutMs)
{
    return await FetchAsync().WaitAsync(TimeSpan.FromMilliseconds(timeoutMs));
}

IL 관점에서의 차이

WaitAsync는 내부적으로는 여전히 컨티뉴에이션 + 타이머를 사용하지만, IL 레벨에서 보면 호출자 코드에서는 WhenAny 호출과 Task.Delay 객체 할당이 사라집니다.

IL
// 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로 완료 순서대로 처리

C#
// 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을 호출하고 있지는 않은가?

WhenAllWhenAny는 비동기 코드의 "그리고/또는"입니다. await 한 번에 작업 한 개씩만 기다리던 한계를 넘어, 여러 비동기 작업을 자연스럽게 조합할 수 있게 해주는 가장 기본적인 도구입니다.

반응형

+ Recent posts