---
title: "UniTask 는 풀에서 빌린 객체로 await 를 처리한다"
description: "UniTask 값은 풀에서 빌린 대기 객체를 가리키는 참조 하나와 번호 하나입니다. 복사는 공짜지만 await 가 끝나면 그 객체는 풀로 돌아가므로 한 번만 기다릴 수 있습니다. 번호 검사와 Preserve 를 봅니다."
author: "유니얼"
pubDate: 2026-09-24
updatedDate: 2026-10-04
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "동시성"]
canonical: https://blog.unialgames.com/posts/unity-unitask-struct/
---
```csharp
UniTask<Config> loading;   // Awake 에서 한 번 시작해 둔다

async UniTask InitHudAsync()  { var c = await loading; /* ... */ }
async UniTask InitShopAsync() { var c = await loading; /* ... */ }   // 둘 중 하나가 예외
```

`Task` 였다면 문제없는 코드입니다. UniTask 에서는 한쪽이 `InvalidOperationException` 을 받습니다.

**`UniTask` 는 결과를 담은 객체가 아니라, 풀에서 빌린 대기 객체를 가리키는 참조 하나와 번호 하나입니다.** await 가 끝나면 그 객체는 풀로 돌아가므로 한 번만 기다릴 수 있습니다. 기준 판본은 2.5.11 입니다.

## UniTask 값은 참조 하나와 번호 하나다

```csharp
// Runtime/UniTask.cs (줄임)
public readonly partial struct UniTask
{
    readonly IUniTaskSource source;   // 기다림을 들고 있는 대기 객체
    readonly short token;             // 그 객체의 몇 번째 사용분인가

    public UniTaskStatus Status
    {
        get
        {
            if (source == null) return UniTaskStatus.Succeeded;   // 객체가 없으면 끝난 것
            return source.GetStatus(token);
        }
    }
}
```

`source` 는 `DelayPromise` 나 async 메서드의 러너 같은 대기 객체입니다. `source` 가 `null` 이면 그 자체로 성공 완료입니다. 그래서 `UniTask.CompletedTask` 와 `UniTask.FromResult(42)` 는 객체를 하나도 만들지 않습니다(`UniTask<T>` 는 결과 칸이 하나 더 있습니다). `Task.FromResult(42)` 는 매번 `Task` 를 만듭니다.

두 필드뿐이라 복사하고 넘기는 데 비용이 없습니다. 문제는 `source` 가 있을 때입니다.

## await 가 끝나면 대기 객체는 풀로 돌아간다

`await t` 는 대기 객체에 `GetStatus`, `OnCompleted`, `GetResult` 를 차례로 부르고, 매번 토큰을 함께 넘깁니다. `t = UniTask.Delay(200)` 이 `(P, 5)` 일 때입니다.

<x-wiring>
<script type="application/json">
{
  "label": "await t 한 번, t = (source P, token 5)",
  "width": 740,
  "height": 420,
  "nodes": [
    { "id": "m", "title": "await 하는 메서드", "sub": "await t;", "x": 20, "y": 8, "w": 210, "h": 54 },
    { "id": "t", "title": "UniTask.Awaiter", "sub": "t 의 사본 (P, 5)", "x": 20, "y": 150, "w": 210, "h": 80 },
    { "id": "p", "title": "source P (DelayPromise)", "sub": "core.version = 5", "x": 420, "y": 150, "w": 230, "h": 80 },
    { "id": "loop", "title": "PlayerLoop", "sub": "매 프레임 MoveNext()", "x": 300, "y": 340, "w": 170, "h": 54 },
    { "id": "pool", "title": "TaskPool", "sub": "다음 Delay 가 꺼낸다", "x": 570, "y": 340, "w": 170, "h": 54 }
  ],
  "edges": [
    { "from": "m", "to": "t", "label": "GetAwaiter()", "bend": 0 },
    { "from": "t", "to": "p", "label": "GetStatus(5)", "bend": -30 },
    { "from": "t", "to": "p", "label": "OnCompleted(…, 5)", "bend": 0 },
    { "from": "loop", "to": "p", "label": "MoveNext()", "bend": 0 },
    { "from": "p", "to": "m", "label": "맡긴 것을 부른다", "bend": 0 },
    { "from": "t", "to": "p", "label": "GetResult(5)", "bend": 30 },
    { "from": "p", "to": "pool", "label": "Reset, version 6", "bend": 0 }
  ],
  "steps": [
    { "edge": 0, "why": "await t 는 먼저 t.GetAwaiter() 가 됩니다. 구조체 두 필드를 Awaiter 에 복사할 뿐 아무것도 만들지 않습니다." },
    { "edge": 1, "why": "IsCompleted 가 source 가 있는 것을 보고 P.GetStatus(5) 를 부릅니다. 토큰 5 가 P 의 버전 5 와 같아 통과하고, 아직 Pending 입니다." },
    { "edge": 2, "why": "안 끝났으니 이어서 실행할 델리게이트를 P 에 맡깁니다. 여기서도 토큰 5 를 함께 넘겨 검사받습니다. 메서드는 여기서 빠져나갑니다." },
    { "edge": 3, "why": "프레임마다 PlayerLoop 가 P 를 깨워 경과 시간을 셉니다. 200 ms 가 차면 결과를 채웁니다." },
    { "edge": 4, "why": "결과를 채우는 순간 P 가 맡아 둔 델리게이트를 부릅니다. 메서드가 await 다음 줄부터 이어집니다." },
    { "edge": 5, "why": "이어진 메서드가 가장 먼저 awaiter.GetResult() 를 부르고, 그것이 P.GetResult(5) 가 됩니다. 성공이면 그냥 돌아오고 실패나 취소면 여기서 예외가 다시 던져집니다." },
    { "edge": 6, "why": "GetResult 는 끝에서 P 를 되돌립니다. Reset 이 버전을 6 으로 올리고 P 는 풀로 들어갑니다. t 안의 토큰은 여전히 5 입니다." }
  ],
  "note": "구조체는 소스를 가리키는 쪽지일 뿐입니다. 상태는 전부 소스에 있고, 토큰은 그 쪽지가 몇 번째 사용분을 가리키는지 적은 번호입니다."
}
</script>
</x-wiring>

마지막 단계가 핵심입니다. `GetResult` 가 P 의 버전을 6 으로 올리고 풀로 돌려보내면, 다음 `Delay` 가 P 를 꺼내 씁니다. 그런데 `t` 는 구조체라 사본들이 여전히 `(P, 5)` 를 들고 있습니다.

## 번호가 낡은 사본을 막는다

대기 객체는 들어온 토큰을 자기 버전과 맞춰 보고, 다르면 예외를 던집니다.

```csharp
var t = UniTask.Delay(200);      // 풀에서 P 를 꺼냈다. P 의 버전이 5 라 t = (P, 5)
await t;                         // 5 와 5 가 같아 통과. 끝나면 P 는 버전 6 으로 풀에 돌아간다
var u = UniTask.Delay(500);      // 풀 맨 위의 P 를 그대로 꺼낸다. u = (P, 6)
await t;                         // t 는 아직 (P, 5). 5 와 6 이 달라 예외

// P 안에서 매번
void ValidateToken(short token)
{
    if (token != version)
        throw new InvalidOperationException("... can not await twice ...");
}
```

`t` 는 구조체라 첫 `await` 가 끝난 뒤에도 `(P, 5)` 를 그대로 들고 있습니다. 그사이 P 는 버전이 6 으로 올랐고, 세 번째 줄에서는 `u` 가 P 를 빌려 500 ms 를 세고 있습니다. 두 번째 `await t` 는 그 P 에 토큰 5 를 내밀고, 버전과 달라 `InvalidOperationException` 을 받습니다. `u` 의 상태는 건드려지지 않습니다.

번호가 없었다면 낡은 `t` 는 남의 500 ms 대기를 자기 것으로 읽었을 것입니다. 번호 덕에 낡은 쪽만 예외를 받습니다. 막히는 길은 둘입니다.

| 무엇을 했나 | 메시지 앞부분 |
| --- | --- |
| 끝난 뒤에 다시 await 하거나 `Status` 를 읽었다 | `Token version is not matched` |
| 끝나기 전에 두 곳에서 동시에 await 했다 | `Already continuation registered` |

두 번째는 버전이 같아 토큰 검사를 통과하지만, 대기 객체가 이어 부를 곳을 하나만 받아서 걸립니다. 맨 위 HUD 와 상점은 타이밍에 따라 둘 중 어느 것이든 받습니다.

번호는 `short` 라 65,536 번 재사용되면 같은 번호가 돌아옵니다. 풀 크기를 1 로 줄이고 첫 번호를 들고 있었더니 65,535 번째까지는 예외가 났고, 65,536 번째에는 옛 번호로 새 작업의 결과를 꺼내 갔습니다. 옛 값을 그만큼 오래 들고 있을 때만 생기는 일입니다.

## 다시 읽으려면 Preserve 로 결과를 기억시킨다

`Preserve()` 는 대기 객체를 `MemoizeSource` 로 감쌉니다. 첫 `GetResult` 에서 결과(또는 예외)를 자기 필드에 적고 원래 객체는 풀로 보낸 뒤, 그다음부터는 자기 필드에서 답합니다. 이미 끝난 값이면 그대로 돌려줍니다.

<x-compare>
<script type="application/json">
{
  "label": "같은 UniTask 를 두 번 기다리기",
  "tabs": [
    {
      "label": "그대로",
      "code": "var t = UniTask.Delay(200);\nawait t;\nawait t;   // 버전이 올라가 있다",
      "stats": [["두 번째 await", "InvalidOperationException"], ["새로 만드는 객체", "없음"]],
      "tone": "bad"
    },
    {
      "label": "Preserve 뒤 차례로",
      "code": "var t = UniTask.Delay(200).Preserve();\nawait t;\nawait t;   // 기억해 둔 결과로 답한다",
      "stats": [["두 번째 await", "통과"], ["새로 만드는 객체", "MemoizeSource 1개"]],
      "tone": "good"
    },
    {
      "label": "Preserve 뒤 동시에",
      "code": "var t = UniTask.Delay(200).Preserve();\nvar a = WaitAndLog(t);\nvar b = WaitAndLog(t);   // 둘 다 끝나기 전에 맡긴다",
      "stats": [["두 번째 등록", "Already continuation registered"], ["왜", "끝나기 전에는 원래 소스에 그대로 맡긴다"]],
      "tone": "bad"
    }
  ]
}
</script>
</x-compare>

`Preserve()` 한 번의 값은 `MemoizeSource` 하나, **40 B** 였습니다. 결과를 기억하기 전에는 등록을 원래 객체에 넘기므로, 끝나기 전에 두 곳에서 기다리면 세 번째 탭처럼 예외입니다.

## 주의할 점

### 1. UniTask 를 필드에 담아 여러 곳에서 await 하지 않는다

**안 지키면 생기는 문제.** 맨 위 `loading` 처럼 여러 컴포넌트가 같은 값을 기다리면, 먼저 끝난 쪽이 대기 객체를 풀로 돌려보내고 나머지는 예외를 받습니다. 어느 쪽이 터질지는 타이밍에 따라 달라집니다.

**이렇게 쓴다.** 여러 곳에서 기다릴 결과는 `UniTaskCompletionSource<T>` 의 `Task` 를 나눠 주거나 `AsyncLazy<T>` 로 감쌉니다.

### 2. await 한 뒤에 Status 를 읽지 않는다

**안 지키면 생기는 문제.** `await t;` 뒤에 로그를 남기려고 `t.Status` 를 읽으면 버전이 이미 올라가 있어 예외가 납니다.

**이렇게 쓴다.** 성공, 실패, 취소는 await 결과로 받습니다. 취소를 예외 없이 받으려면 `await t.SuppressCancellationThrow()` 로 `bool` 을 받습니다.

### 3. Preserve 를 동시 대기용으로 쓰지 않는다

**안 지키면 생기는 문제.** `Preserve()` 는 끝난 결과를 되풀이해 읽는 것만 허락합니다. 끝나기 전에 두 곳에서 await 하면 `Already continuation registered` 가 납니다.

**이렇게 쓴다.** 차례로 다시 읽을 때만 `Preserve()` 를 쓰고, 동시에 기다릴 곳이 여럿이면 1번의 방법을 씁니다.

## 정리

`UniTask` 는 **풀에서 빌린 대기 객체를 가리키는 참조와 번호**라서, 복사는 공짜지만 한 번만 await 할 수 있습니다.

- `source == null` 이 곧 성공 완료라 끝난 값에는 객체가 필요 없습니다.
- await 가 끝나면 대기 객체는 버전을 올리고 풀로 돌아가고, 토큰이 낡은 사본의 접근을 예외로 막습니다.
- 다시 읽으려면 `Preserve()`(40 B), 동시에 기다리려면 `UniTaskCompletionSource` 나 `AsyncLazy` 를 씁니다.