---
title: "UniTask 는 완료와 이어 부르기를 딱 한 번씩만 한다"
description: "시간 만료와 취소가 겹치거나 완료가 워커 스레드에서 와도 await 뒤 코드는 정확히 한 번 돕니다. 대기 객체 안의 구조체 하나가 원자적 연산으로 이것을 지키고, 할당은 없습니다."
author: "유니얼"
pubDate: 2026-09-24
updatedDate: 2026-10-04
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "동시성"]
canonical: https://blog.unialgames.com/posts/unity-unitask-core/
---
```csharp
await UniTask.Delay(1000, cancellationToken: ct);   // 1초가 차는 그 프레임에 ct 도 취소되면?
```

완료가 두 번 들어올 수 있습니다. 완료가 워커 스레드에서 오는 순간 메인 스레드가 막 await 를 등록하고 있을 수도 있습니다. 그러면 await 뒤 코드가 두 번 돌거나, 한 번도 안 돌 수 있습니다.

UniTask 는 이 둘을 **완료 상태를 담은 구조체 하나**로 막습니다. 완료는 먼저 온 하나만 받고, 이어질 코드는 누가 먼저 오든 정확히 한 번 부릅니다. `lock` 없이 원자적 연산만 쓰고, 할당도 없습니다. 기준 판본은 2.5.11 입니다.

## 완료는 먼저 온 하나만 받는다

`TrySetResult`, `TrySetException`, `TrySetCanceled` 는 모두 같은 줄로 시작합니다.

```csharp
// Runtime/UniTaskCompletionSource.cs (줄임)
public bool TrySetResult(TResult result)
{
    if (Interlocked.Increment(ref completedCount) == 1)   // 동시에 불러도 1 은 한 쪽만 받는다
    {
        this.result = result;
        // 이어질 코드 처리 (다음 절)
        return true;
    }
    return false;                                          // 늦게 온 완료는 버린다
}
```

`Interlocked.Increment` 는 여러 스레드가 동시에 불러도 각자 다른 값을 돌려줍니다. 1 을 받은 쪽만 결과를 적습니다. 그래서 시간 만료와 취소가 같은 프레임에 겹쳐도 결과가 두 번 적히거나 성공과 취소가 섞이지 않습니다. 값도 쌉니다. 에디터에서 `Interlocked.Increment` 두 번이 3.1 ns, 빈 `lock` 한 번이 15.5 ns 였습니다.

## 이어질 코드는 누가 먼저 오든 한 번만 불린다

await 는 아직 안 끝났으면 "끝나면 이걸 불러 줘" 하고 이어질 코드를 `continuation` 칸에 맡깁니다. 완료하는 쪽은 그 칸에 무언가 있으면 부릅니다. 둘이 다른 스레드에서 동시에 오면 문제가 됩니다.

UniTask 는 둘 다 **칸이 비어 있을 때만** 자기 것을 꽂게 합니다(`Interlocked.CompareExchange`). 한 칸을 두고 겨루니 먼저 닿은 쪽만 성공하고, 진 쪽이 이긴 쪽의 값을 보고 이어질 코드를 부릅니다. 완료하는 쪽이 먼저 오면 "끝났는데 기다리는 사람이 없었다" 는 표시값(센티널)을 꽂아 둡니다.

기다리는 쪽은 아직 안 끝났을 때만 등록하러 갑니다.

```csharp
// 기다리는 쪽: await
if (!awaiter.IsCompleted)
    core.OnCompleted(cont, state, token);
```

등록은 칸이 비어 있을 때만 `cont` 를 꽂습니다. 이미 무언가 있으면 완료가 먼저 온 것이니 그 자리에서 직접 부릅니다.

```csharp
// OnCompleted 안
object old = this.continuation;

if (old == null)
    old = Interlocked.CompareExchange(ref this.continuation, cont, null);   // 비었으면 cont 를 꽂는다

if (old != null)
{
    // 센티널이 아니면 누가 이미 등록한 것이다 (두 번 await)
    if (old != s_sentinel)
        throw new InvalidOperationException(...);

    cont(state);   // 완료가 먼저 왔다. 등록한 쪽이 직접 부른다
}
```

완료하는 쪽도 칸이 비어 있을 때만 센티널을 꽂습니다. 이미 `cont` 가 있으면 그것을 부릅니다.

```csharp
// 완료하는 쪽: TrySetResult
if (Interlocked.Increment(ref completedCount) == 1)
{
    this.result = result;

    if (continuation != null ||
        Interlocked.CompareExchange(ref continuation, s_sentinel, null) != null)   // 비었으면 센티널을 꽂는다
    {
        continuation(continuationState);   // 등록이 먼저 왔다. 완료한 쪽이 부른다
    }
}
```

두 쪽이 오는 순서에 따라 칸에 남는 값과 `cont` 를 부르는 쪽이 정해집니다.

| 경우 | 칸에 먼저 꽂힌 것 | `cont` 를 부르는 쪽 | await 다음 줄이 도는 스레드 |
| --- | --- | --- | --- |
| A. 등록이 먼저 끝남 | `cont` | 완료하는 쪽 | 완료한 스레드 |
| B. 완료가 먼저 끝남 | 센티널 | 없음. `IsCompleted` 가 참이라 등록을 건너뜀 | 기다리던 스레드 |
| C.1 동시에 왔고 등록이 이김 | `cont` | 완료하는 쪽 | 완료한 스레드 |
| C.2 동시에 왔고 완료가 이김 | 센티널 | 등록하는 쪽 | 등록한 스레드 |

- 등록이 먼저면 완료하는 쪽이 부릅니다(A, C.1).
- 완료가 먼저면 등록하는 쪽이 센티널을 보고 그 자리에서 직접 부릅니다(B, C.2).

어느 쪽이든 한 번입니다. 다만 **누가 부르느냐에 따라 await 다음 줄이 도는 스레드가 달라집니다.** C.1 에서는 완료한 스레드에서 이어집니다.

## 이 모든 것이 구조체 하나라서 할당이 없다

완료 상태(결과, 예외, 완료 횟수, 이어질 코드)는 `UniTaskCompletionSourceCore<TResult>` 구조체 하나에 있습니다. `Delay`, `Yield`, async 메서드의 러너 같은 대기 객체가 모두 이것을 필드로 품습니다. 상태를 지키려고 따로 만드는 객체가 없습니다.

완료 한 번(만들고, 완료하고, 결과 꺼내기)을 백만 번씩 잰 중앙값입니다(에디터 Mono, Release 최적화).

| 한 주기 | 시간 | 할당 |
| --- | --- | --- |
| `AutoResetUniTaskCompletionSource` (풀 사용) | 71.1 ns | 0 B |
| `TaskCompletionSource<bool>` | 135.6 ns | 104 B |

개발 빌드 플레이어에서도 Mono 67.5 ns, IL2CPP 57.8 ns 에 0 B 였습니다.

단, 0 B 는 성공으로 끝날 때입니다. 취소나 예외로 끝내면 예외 객체를 만들고 던지는 값이 붙습니다.

| 완료 방식 | Mono | IL2CPP |
| --- | --- | --- |
| `TrySetResult` | 0 B | 0 B |
| `TrySetCanceled()` | 288 B | 368 B |
| `TrySetException(일반 예외)` | 약 9.3 KB | 약 9.7 KB |

## 주의할 점

### 1. 늦게 온 완료는 버려진다

**안 지키면 생기는 문제.** 시간 만료와 취소, 또는 두 콜백이 같은 소스를 완료하려 하면 먼저 온 하나만 남습니다. 늦은 쪽은 예외 없이 `false` 만 받고 끝나므로, 그쪽이 기록하려던 결과나 로그는 조용히 사라집니다.

**이렇게 쓴다.** 어느 쪽이 이겼는지 알아야 하면 `TrySet*` 의 반환값을 봅니다. `false` 면 이미 다른 쪽이 끝낸 것입니다.

### 2. 워커 스레드에서 완료하면 await 다음 줄이 워커에서 돌 수 있다

**안 지키면 생기는 문제.** `UniTaskCompletionSource` 를 워커 스레드에서 `TrySetResult` 하면, await 가 먼저 등록돼 있던 경우 이어질 코드를 그 워커가 부릅니다. await 다음 줄에서 유니티 API 를 부르면 메인 스레드가 아니라서 예외가 납니다.

**이렇게 쓴다.** 워커에서 완료하는 소스를 기다렸다면 await 다음에 `await UniTask.SwitchToMainThread();` 를 둡니다. 가능하면 완료 자체를 메인 스레드에서 합니다.

### 3. 자주 실패하는 흐름을 예외로 전하지 않는다

**안 지키면 생기는 문제.** 성공 완료는 0 B 지만 예외로 완료하면 한 번에 9 KB 남짓, 취소도 수백 B 가 듭니다. "없으면 실패" 같은 흔한 결과를 예외로 전하면 그만큼 매번 GC 에 쌓입니다.

**이렇게 쓴다.** 자주 일어나는 실패는 `UniTask<(bool ok, T value)>` 처럼 결과 값으로 돌려주고, 예외는 정말 예외적인 경우에만 씁니다.

## 정리

UniTask 는 **완료와 이어 부르기를 딱 한 번씩만** 합니다.

- 완료는 `Interlocked.Increment` 로 먼저 온 하나만 받습니다.
- 이어질 코드는 `continuation` 칸을 두고 CAS 로 겨뤄, 누가 먼저 오든 한 번만 불립니다. 대신 부르는 스레드는 경우에 따라 다릅니다.
- 이 상태가 구조체 하나라 성공 완료는 0 B 입니다. 예외로 끝내면 값이 붙습니다.