---
title: "UniTask 의 취소는 예외로 끝난다"
description: "UniTask 의 취소는 토큰을 받은 대기가 OperationCanceledException 으로 끝나는 일입니다. finally 는 돌지만 한 번마다 수백 바이트가 들고, 즉시 취소나 SuppressCancellationThrow 로 끝나면 대기 객체가 풀로 돌아가지 않습니다."
author: "유니얼"
pubDate: 2026-09-24
updatedDate: 2026-10-04
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "동시성"]
canonical: https://blog.unialgames.com/posts/unity-unitask-cancel/
---
```csharp
var popup = Instantiate(popupPrefab);
try
{
    await UniTask.Delay(3000, cancellationToken: ct);   // 여기서 취소되면
}
finally
{
    Destroy(popup);                                      // 이 줄은 돈다
}
```

`StopCoroutine` 으로 멈춘 코루틴은 `finally` 가 돌지 않습니다. UniTask 로 옮기면 반대로 `finally` 는 도는데, 취소가 오류 로그로 찍히고, 할당 0 이라더니 취소마다 GC 가 생깁니다.

UniTask 의 취소는 **토큰을 받은 대기가 스스로 `OperationCanceledException` 을 던지며 끝나는 일**입니다. 끝난 길에 따라 대기 객체가 풀로 돌아가기도 하고 버려지기도 합니다.

## 취소는 대기가 예외를 던지며 끝나는 것이다

취소로 끝날 때 대기 객체는 **새 `OperationCanceledException`** 을 담아 두고, `await` 한 쪽의 `GetResult()` 에서 던집니다. 그래서 받는 쪽도 예외로 받습니다. `OperationCanceledException` 도 `Exception` 이라 순서가 바뀌면 취소가 오류 로그로 찍힙니다.

```csharp
try
{
    await ShowRewardAsync(ct);
}
catch (OperationCanceledException) { }        // 취소. 정상 흐름
catch (Exception ex) { Debug.LogException(ex); }
```

`Delay` 하나를 기본값으로 취소한 1회당 할당입니다.

| 구성 | Mono | IL2CPP |
| --- | --- | --- |
| `CancellationTokenSource` 만들고 취소하고 해제 | 64 B | 64 B |
| 예외를 만들고 던지고 받기 | 264 B | 336 B |
| 전체 | **352 B** | **464 B** |

할당 0 은 정상으로 끝나는 대기의 이야기입니다. 취소마다 생기는 GC 의 정체는 예외 객체입니다.

## 기본 취소는 다음 MoveNext 가 알아챈다

토큰을 넘겨도 누가 대기를 깨워 주지는 않습니다. **기본값에서는 대기 객체가 자기 차례에 토큰을 보고 스스로 끝납니다.** 대기 객체의 `MoveNext()` 는 모두 이렇게 시작합니다.

```csharp
if (cancellationToken.IsCancellationRequested)
{
    core.TrySetCanceled(cancellationToken);
    return false;
}
```

`Cancel()` 을 부른 순간에는 아무 일도 없고, 러너가 다음에 `MoveNext()` 를 부를 때 끝납니다. 토큰에 등록하는 것이 없어 등록 비용이 없고, 러너가 알아채니 이어지는 코드는 늘 메인 스레드에서 돕니다. 토큰을 받지 않은 대기는 볼 토큰이 없으니 끝까지 돕니다.

## 즉시 취소는 Cancel 을 부른 자리에서 끝난다

`cancelImmediately: true` 를 넘기면 `Create()` 가 토큰에 콜백을 겁니다. **`Cancel()` 을 부른 그 호출 안에서, 그 스레드에서** 대기가 끝납니다.

<x-wiring>
<script type="application/json">
{
  "label": "cancelImmediately: true 인 Delay 가 취소되는 길",
  "width": 770,
  "height": 440,
  "nodes": [
    { "id": "caller", "title": "await 하는 쪽", "sub": "async 메서드의 상태 머신", "x": 20, "y": 8, "w": 190, "h": 54 },
    { "id": "cts", "title": "cts.Cancel()", "sub": "부른 스레드 그대로", "x": 560, "y": 8, "w": 190, "h": 54, "aside": true },
    { "id": "promise", "title": "DelayPromise", "sub": "풀에서 꺼낸 것", "x": 240, "y": 190, "w": 190, "h": 54 },
    { "id": "token", "title": "CancellationToken", "sub": "등록된 콜백 목록", "x": 560, "y": 190, "w": 190, "h": 54 },
    { "id": "runner", "title": "Update 러너", "sub": "매 프레임 MoveNext()", "x": 20, "y": 370, "w": 190, "h": 54 },
    { "id": "pool", "title": "TaskPool<DelayPromise>", "sub": "돌아갈 자리", "x": 560, "y": 370, "w": 190, "h": 54 }
  ],
  "edges": [
    { "from": "promise", "to": "runner", "label": "AddAction", "bend": 24 },
    { "from": "promise", "to": "token", "label": "콜백 등록", "bend": 14 },
    { "from": "cts", "to": "token", "label": "Cancel()", "bend": 0 },
    { "from": "token", "to": "promise", "label": "TrySetCanceled", "bend": 14 },
    { "from": "promise", "to": "caller", "label": "이어서 실행", "bend": 24 },
    { "from": "caller", "to": "promise", "label": "GetResult()", "bend": 24 },
    { "from": "runner", "to": "promise", "label": "다음 MoveNext()", "bend": 24 }
  ],
  "steps": [
    { "edge": 0, "why": "Create() 가 풀에서 Promise 를 꺼내 러너에 올립니다. 이 순간부터 시간을 셉니다." },
    { "edge": 1, "why": "cancelImmediately 가 참이라 토큰에 콜백을 겁니다. 이 등록 핸들은 Promise 필드에 보관됩니다." },
    { "edge": 2, "why": "어딘가에서 Cancel() 을 부릅니다. 워커 스레드일 수도 있습니다." },
    { "edge": 3, "why": "토큰이 그 자리에서 콜백을 부릅니다. 새 OperationCanceledException 을 담고 완료 표시를 합니다." },
    { "edge": 4, "why": "완료되자마자 기다리던 상태 머신이 같은 스레드에서 이어서 돕니다. 러너를 기다리지 않습니다." },
    { "edge": 5, "why": "상태 머신이 GetResult() 를 부르고 취소 예외를 받습니다. finally 에서 풀로 돌려보낼지 정합니다." },
    { "node": "pool", "why": "즉시 취소로 끝났으니 돌려보내지 않습니다. Reset 도 등록 해제도 하지 않고 추적만 끕니다." },
    { "edge": 6, "why": "러너는 아직 이 Promise 를 들고 있습니다. 다음 MoveNext() 에서 취소를 보고 false 를 돌려주며 목록에서 뺍니다. 이제 아무도 참조하지 않아 GC 가 가져갑니다." }
  ],
  "note": "기본값(false)이면 2번째와 4번째 단계가 없습니다. 러너의 다음 MoveNext() 가 취소를 알아채고, Promise 는 풀로 돌아갑니다."
}
</script>
</x-wiring>

반응은 빠르지만 대가가 둘입니다. 워커 스레드에서 취소하면 이어지는 코드가 워커에서 돕니다. 그리고 할당이 늘어납니다.

| `Delay` 1회를 취소 (개발 빌드 플레이어) | Mono | IL2CPP |
| --- | --- | --- |
| `cancelImmediately: false` | 352 B | 464 B |
| `cancelImmediately: true`, 풀이 차 있을 때 | 704 B | 816 B |
| `cancelImmediately: true`, 풀이 비었을 때 | 816 B | 936 B |

352 B 가 콜백 등록 몫이고, 풀이 비었을 때 더 드는 112 B(IL2CPP 120 B)는 대기 객체를 새로 만드는 값입니다. 왜 새로 만드는지가 다음 절입니다.

## 풀로 돌아가는 문은 GetResult 하나다

대기 객체는 `GetResult()` 의 `finally` 에서만 풀로 돌아갑니다. **즉시 취소로 끝났으면 그 문 앞에서 돌려보내지 않습니다.**

```csharp
public void GetResult(short token)
{
    try
    {
        core.GetResult(token);             // 취소로 끝났으면 여기서 OperationCanceledException 을 던진다
    }
    finally
    {
        // 즉시 취소 옵션이 켜져 있고 실제로 취소됐을 때만 풀로 돌려보내지 않는다
        if (!(cancelImmediately && cancellationToken.IsCancellationRequested))
            TryReturn();
        else
            TaskTracker.RemoveTracking(this);   // 추적만 끈다. 버전도 등록도 그대로다
    }
}

bool TryReturn()
{
    TaskTracker.RemoveTracking(this);
    core.Reset();                          // 버전을 올려 이전 UniTask 값으로는 다시 못 쓰게 한다
    cancellationToken = default;
    cancellationTokenRegistration.Dispose();   // 걸어 둔 취소 콜백을 푼다
    return pool.TryPush(this);             // 풀로 돌아간다
}
```

끝나는 방식마다 `finally` 의 조건이 이렇게 갈립니다.

- **정상 완료.** 취소 요청이 없으니 `TryReturn()` 을 부릅니다. 풀로 돌아갑니다.
- **기본 취소.** `MoveNext()` 가 취소를 보고 끝낸 경우입니다. `cancelImmediately` 가 거짓이라 `TryReturn()` 을 부릅니다. 예외는 던지지만 풀로 돌아갑니다.
- **즉시 취소.** 취소 콜백이 그 자리에서 끝낸 경우입니다. 두 조건이 모두 참이라 `TryReturn()` 을 건너뜁니다. 풀로 돌아가지 않습니다.

즉시 취소가 반복되면 풀이 줄고 결국 매번 새로 만듭니다. 위 표의 112 B 가 그 `DelayPromise` 하나입니다.

취소를 예외 대신 `bool` 로 받는 `SuppressCancellationThrow()` 는 문을 아예 거치지 않습니다. 취소로 끝나면 원본의 `GetResult()` 를 부르지 않기 때문입니다. `Delay(1000, ct)` 를 N 개 걸고 곧바로 취소해 받았습니다.

| 받는 방식 | 풀 상태 | Mono | IL2CPP | 풀 (전 → 후, N=2,000) |
| --- | --- | --- | --- | --- |
| `SuppressCancellationThrow()` | 채움 | 232 B | 232 B | 2,500 → 500 |
| try/catch | 채움 | 288 B | 368 B | 2,500 → 2,500 |
| `SuppressCancellationThrow()` | 비움 | 410 B | 418 B | 0 → 0 |
| try/catch | 비움 | 400 B | 488 B | 0 → 2,000 |

한 번만 보면 `SuppressCancellationThrow()` 가 싸지만 풀이 N 만큼 줄었습니다. 풀이 빈 뒤에는 Mono 에서 try/catch 와 비슷해집니다.

## 주의할 점

### 1. 모든 await 에 토큰을 넘긴다

**안 지키면 생기는 문제.** 바깥 메서드가 토큰을 받아도 안쪽 `Delay` 나 `WaitUntil` 에 넘기지 않으면 그 대기는 취소를 모릅니다. 오브젝트가 파괴된 뒤에 이어지는 코드가 파괴된 오브젝트를 건드립니다.

**이렇게 쓴다.** 진입점에서 `this.destroyCancellationToken` 을 받아 모든 `await` 에 끝까지 넘깁니다.

### 2. cancelImmediately 는 필요한 곳에만 켠다

**안 지키면 생기는 문제.** 켜면 등록 비용이 붙고, 취소된 대기 객체가 풀로 돌아가지 않습니다. 워커 스레드에서 취소하면 이어지는 코드가 그 스레드에서 돌아 유니티 API 호출이 실패합니다.

**이렇게 쓴다.** 기본값 `false` 로 둡니다. 다른 스레드에서 긴 대기를 그 자리에서 끊어야 할 때만 켜고, 이어지는 코드 앞에 `await UniTask.SwitchToMainThread()` 를 둡니다.

### 3. 자주 취소되는 대기에 SuppressCancellationThrow 를 쓸 때 풀을 본다

**안 지키면 생기는 문제.** 취소될 때마다 원본 대기 객체가 풀로 돌아가지 않아, 예외를 피하려다 할당이 다른 곳에서 생깁니다.

**이렇게 쓴다.** 드물게 취소되는 곳은 편한 쪽을 씁니다. 취소가 반복되는 곳은 try/catch 로 받거나 `TaskPool.GetCacheSizeInfo()` 로 풀 크기를 지켜봅니다.

## 정리

UniTask 의 취소는 **토큰을 받은 대기가 `OperationCanceledException` 을 던지며 끝나는 일**입니다.

- 취소 한 번은 예외 값을 냅니다(`Delay` 기준 Mono 352 B, IL2CPP 464 B). `catch (OperationCanceledException)` 을 먼저 둡니다.
- 기본값은 다음 `MoveNext()` 가 알아채 메인 스레드에서 끝나고, `cancelImmediately: true` 는 `Cancel()` 을 부른 스레드에서 그 자리에 끝납니다.
- 풀로 돌아가는 문은 `GetResult()` 하나라서, 즉시 취소와 `SuppressCancellationThrow()` 로 끝난 대기 객체는 돌아가지 않습니다.