---
title: "UniTask 의 예외는 꺼내 가야 보고된다"
description: "WhenAll 로 묶은 로딩이 둘 실패해도 catch 에는 하나만 오고, 버린 UniTask 의 예외는 한참 뒤에 찍힙니다. 완료는 처음 한 번만 이기고, 예외는 누군가 GetResult 로 꺼내 가야 보고되기 때문입니다."
author: "유니얼"
pubDate: 2026-09-24
updatedDate: 2026-10-04
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "동시성"]
canonical: https://blog.unialgames.com/posts/unity-unitask-whenall/
---
```csharp
try
{
    await UniTask.WhenAll(LoadMapAsync(), LoadUnitsAsync(), LoadSoundAsync());
}
catch (Exception ex)
{
    Debug.LogException(ex);   // 둘이 실패해도 여기에는 하나만 온다
}
```

로딩 둘이 실패했는데 `catch` 에는 하나만 오고, 나머지 하나는 어디에도 찍히지 않습니다. 반대로 `await` 를 깜빡하고 버린 태스크의 예외는 몇 초 뒤 엉뚱한 때 찍힙니다.

규칙 둘 때문입니다. **완료는 처음 한 번만 이기고, 예외는 누군가 결과를 꺼내 가야(`GetResult`) 보고됩니다.**

## WhenAll 은 먼저 실패한 하나만 전한다

`WhenAll` 은 자식이 끝날 때마다 자식의 결과를 직접 꺼냅니다. 성공이면 자식 번호 칸에 넣고, 실패면 자기를 그 예외로 끝냅니다. 끝내기는 한 번만 성공하니 두 번째 실패는 갈 곳이 없습니다.

```csharp
static void TryInvokeContinuation(WhenAllPromise<T> self, in UniTask<T>.Awaiter awaiter, int i)
{
    try
    {
        self.result[i] = awaiter.GetResult();        // 자식의 결과를 자식 번호 칸에 넣는다. 실패면 여기서 던진다
    }
    catch (Exception ex)
    {
        self.core.TrySetException(ex);               // WhenAll 을 이 예외로 끝낸다. 먼저 온 하나만 성공한다
        return;                                      // 카운터를 올리지 않고 나간다
    }

    // 마지막으로 끝난 자식이 WhenAll 을 완료한다
    if (Interlocked.Increment(ref self.completeCount) == self.result.Length)
        self.core.TrySetResult(self.result);
}
```

셋 다 성공하면 끝나는 순서와 상관없이 결과가 자식 번호 칸에 들어갑니다. B, A, C 순서로 끝나도 `await` 결과는 `[10, 20, 30]` 입니다.

A 가 성공하고 B, C 가 차례로 실패하면 이렇게 됩니다.

1. A 가 끝납니다. 0 번 칸에 10 을 넣고 카운터가 1 이 됩니다.
2. B 의 `GetResult()` 가 예외를 던집니다. `TrySetException` 이 성공해 `WhenAll` 이 B 의 예외로 끝나고, 그 예외가 `await` 한 쪽으로 갑니다.
3. C 의 `GetResult()` 도 예외를 던집니다. `WhenAll` 은 이미 끝났으므로 `TrySetException` 이 `false` 를 돌려줍니다. C 의 예외는 여기서 사라집니다.

C 의 예외는 파이널라이저 보고로도 이어지지 않습니다. `WhenAll` 이 C 의 `GetResult()` 를 불렀으니 C 쪽에서는 관측된 예외이기 때문입니다. `UnobservedTaskException` 에 카운터를 걸고 `WhenAll(ok, throwA, throwB)` 를 받은 뒤 GC 를 두 번 돌렸더니, 보고는 **0** 번이었습니다.

`Task.WhenAll` 처럼 `AggregateException` 에 모아 주지 않습니다. `catch` 에 온 예외가 실패의 전부가 아닐 수 있습니다.

### 이렇게 쓴다

실패를 전부 알아야 하면 자식이 던지지 않게 감싸고, 예외를 결과로 받습니다. 아무도 던지지 않으니 `WhenAll` 은 셋이 다 끝난 뒤에 끝나고, 결과 배열에 각자의 예외가 들어 있습니다.

```csharp
// 예외를 던지는 대신 결과로 돌려준다
static async UniTask<Exception> Capture(UniTask task)
{
    try
    {
        await task;
        return null;
    }
    catch (Exception ex)
    {
        return ex;
    }
}

Exception[] errors = await UniTask.WhenAll(
    Capture(LoadMapAsync()),
    Capture(LoadUnitsAsync()),
    Capture(LoadSoundAsync()));

foreach (var ex in errors)
{
    if (ex != null)
        Debug.LogException(ex);   // 실패한 것이 모두 찍힌다
}
```

첫 실패만 알면 되는 곳은 처음 코드 그대로 둡니다.

## WhenAny 도 처음 끝난 하나가 이긴다

`WhenAny` 는 완료 조건이 "처음 끝난 하나" 일 뿐 같은 규칙입니다.

- 먼저 끝난 자식이 실패면 `WhenAny` 도 그 예외로 끝납니다. 실패도 "끝남" 입니다.
- 먼저 끝난 자식이 성공이면, 나중에 실패한 자식의 예외는 `WhenAll` 의 두 번째처럼 사라집니다.

그리고 어느 조합기든 **나머지 자식을 멈추지 않습니다.** 콜백을 걸어 둘 뿐 멈출 방법이 없어서, 실패 뒤에도, `WhenAny` 에서 진 뒤에도 끝까지 돕니다.

### 이렇게 쓴다

자식들에게 같은 `CancellationTokenSource` 의 토큰을 넘기고, 조합기가 끝난 자리에서 취소합니다.

```csharp
var cts = CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);

try
{
    await UniTask.WhenAll(
        LoadMapAsync(cts.Token),
        LoadUnitsAsync(cts.Token),
        LoadSoundAsync(cts.Token));
}
catch
{
    cts.Cancel();   // 하나가 실패하면 아직 도는 나머지를 멈춘다
    throw;
}
finally
{
    cts.Dispose();
}
```

`WhenAny` 는 성공으로 끝나도 진 쪽이 계속 돌므로, `await` 다음 줄에서 취소합니다.

```csharp
await UniTask.WhenAny(WaitClickAsync(cts.Token), UniTask.Delay(5000, cancellationToken: cts.Token));
cts.Cancel();       // 진 쪽을 멈춘다
```

## 아무도 꺼내 가지 않은 예외는 GC 때에야 보고된다

예외를 보고하는 쪽은 결과를 꺼내 가는 쪽입니다. `await` 가 꺼내 가면 `catch` 로 옵니다. `Forget()` 과 `async UniTaskVoid` 는 끝나는 순간 꺼내서 곧바로 `UniTaskScheduler` 에 넘깁니다. **아무도 꺼내 가지 않으면 예외를 감싼 `ExceptionHolder` 가 GC 에 수거될 때 파이널라이저에서 보고합니다.**

<x-wiring>
<script type="application/json">
{
  "label": "아무도 받지 않은 예외가 보고되기까지",
  "width": 800,
  "height": 430,
  "nodes": [
    { "id": "task", "title": "버려진 UniTask", "sub": "core 에 예외가 있다", "x": 0, "y": 8, "w": 190, "h": 54 },
    { "id": "caller", "title": "호출한 쪽", "sub": "반환값을 버렸다", "x": 610, "y": 8, "w": 190, "h": 54, "aside": true },
    { "id": "holder", "title": "ExceptionHolder", "sub": "calledGet = false", "x": 0, "y": 180, "w": 190, "h": 54 },
    { "id": "gc", "title": "GC 파이널라이저", "sub": "수거될 때 한 번", "x": 300, "y": 180, "w": 190, "h": 54 },
    { "id": "sched", "title": "UniTaskScheduler", "sub": "PublishUnobservedTaskException", "x": 610, "y": 180, "w": 190, "h": 54 },
    { "id": "handler", "title": "UnobservedTaskException", "sub": "등록한 핸들러", "x": 300, "y": 360, "w": 190, "h": 54 },
    { "id": "log", "title": "Debug.LogException", "sub": "핸들러가 없을 때", "x": 610, "y": 360, "w": 190, "h": 54 }
  ],
  "edges": [
    { "from": "task", "to": "holder", "label": "TrySetException", "bend": 0 },
    { "from": "holder", "to": "gc", "label": "참조가 끊긴다", "bend": 0 },
    { "from": "gc", "to": "sched", "label": "~ExceptionHolder()", "bend": 0 },
    { "from": "sched", "to": "handler", "label": "핸들러 있음", "bend": 0 },
    { "from": "sched", "to": "log", "label": "핸들러 없음", "bend": 0 }
  ],
  "steps": [
    { "edge": 0, "why": "메서드가 첫 await 뒤에 던집니다. 빌더가 예외를 ExceptionHolder 로 감싸 core 에 넣습니다." },
    { "node": "caller", "why": "호출한 쪽은 반환값을 버렸습니다. 아무도 GetResult() 를 부르지 않으니 calledGet 은 계속 거짓이고, 보고할 사람이 없습니다." },
    { "edge": 1, "why": "보고는 아직 없습니다. 참조가 끊긴 뒤 GC 가 이 객체를 수거할 때까지 기다립니다. 언제일지는 GC 가 정합니다." },
    { "edge": 2, "why": "파이널라이저가 calledGet 이 거짓인 것을 보고 그제야 스케줄러에 넘깁니다." },
    { "node": "sched", "why": "OperationCanceledException 이면 기본 설정에서 여기서 조용히 버립니다. 그 밖의 예외만 다음으로 갑니다." },
    { "edge": 3, "why": "핸들러가 있으면 부릅니다. 메인 스레드가 아니고 DispatchUnityMainThread 가 참이면 유니티 동기화 컨텍스트로 Post 해서 메인 스레드에서 부릅니다." },
    { "edge": 4, "why": "핸들러가 없으면 Debug.LogException 으로 찍습니다. 로그 종류는 UnobservedExceptionWriteLogType 으로 바꿀 수 있습니다." }
  ],
  "note": "Forget() 과 UniTaskVoid 는 끝나는 순간 GetResult() 를 불러 가운데 두 단계를 건너뛰고 스케줄러로 곧장 갑니다."
}
</script>
</x-wiring>

예외를 던지는 작업 10개로 쟀습니다.

| 경로 | 10 프레임 안에 보고 | `GC.Collect()` + `WaitForPendingFinalizers()` 뒤 |
| --- | --- | --- |
| 반환값을 버림 | 0 / 10 | 10 / 10 |
| `.Forget()` | 10 / 10 | |
| `async UniTaskVoid` | 10 / 10 | |

버린 쪽은 GC 가 돌 때까지 하나도 보고되지 않았습니다. 실제 게임에서는 몇 초 뒤나 씬을 옮길 때라, 찍힌 로그가 예외가 난 자리와 동떨어집니다.

### 이렇게 쓴다

반환된 `UniTask` 는 셋 중 하나로 받습니다. 어느 쪽이든 예외가 끝나는 순간 보고되고, 대기 객체도 풀로 돌아갑니다.

```csharp
await SaveAsync();              // 기다릴 것. 예외가 catch 로 온다

SaveAsync().Forget();           // 기다리지 않을 것. 예외가 끝나는 순간 보고된다

async UniTaskVoid Start()       // 진입점. 예외가 끝나는 순간 보고된다
{
    await SaveAsync();
}
```

`Forget()` 과 `UniTaskVoid` 의 예외는 `catch` 로 오지 않고 `UniTaskScheduler` 로 갑니다. 출시 빌드에서도 남기려면 핸들러를 겁니다.

```csharp
UniTaskScheduler.UnobservedTaskException += ex =>
{
    Debug.LogException(ex);     // 크래시 리포트로 보내는 코드를 여기에 둔다
};
```

## 정리

UniTask 의 완료는 **처음 한 번만 이기고**, 예외는 **누군가 `GetResult()` 로 꺼내 가야** 보고됩니다.

- `WhenAll` 은 먼저 실패한 하나만 전하고, 두 번째 예외는 보고 없이 사라집니다.
- `WhenAny` 는 실패도 "끝남" 으로 치고, 어느 조합기든 나머지 자식을 멈추지 않습니다.
- 버린 태스크의 예외는 GC 가 `ExceptionHolder` 를 치울 때에야 보고되고, `Forget()` 과 `async UniTaskVoid` 는 곧바로 보고합니다.

그래서 실패를 전부 알아야 하면 예외를 결과로 받고, 남은 자식은 토큰으로 직접 멈추고, 반환된 `UniTask` 는 `await` 나 `Forget()` 으로 받습니다.