---
title: "UniTask 는 코루틴의 빈칸을 메우고 Task 의 GC 를 없앤다"
description: "코루틴은 결과를 돌려주지 못하고, 멈추면 finally 가 돌지 않습니다. Task 로 옮기면 풀리지만 부를 때마다 GC 가 생깁니다. UniTask 는 async/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-why/
---
```csharp
IEnumerator ShowPopupCo()
{
    popup.SetActive(true);
    try { yield return new WaitForSeconds(3f); }
    finally { popup.SetActive(false); }   // 멈춰도 닫히겠지?
}
```

누가 `StopCoroutine` 을 부르면 팝업은 켜진 채 남습니다. async/await 와 `Task` 로 옮기면 풀리지만, 이번에는 기다릴 때마다 힙에 수백 바이트가 쌓입니다.

UniTask 는 **async/await 를 그대로 쓰면서 `Task` 의 힙 할당과 유니티에 필요 없는 컨텍스트를 버린** 라이브러리입니다. 기준 판본은 2.5.11 입니다.

## 코루틴은 결과, 예외, 취소를 다루지 못한다

- **반환값.** `IEnumerator` 에는 결과 자리가 없어 콜백을 넘깁니다.
- **예외.** `yield` 문법 탓에 `try-catch` 로 예외를 받을 수 없습니다.
- **취소.** `StopCoroutine` 은 이터레이터를 버릴 뿐 `Dispose` 를 부르지 않아 `finally` 가 돌지 않습니다.

맨 위 팝업을 두 방식으로 걸어 봅니다. 둘 다 기다리는 도중에 멈춥니다.

<x-compare>
<script type="application/json">
{
  "label": "기다리는 도중에 멈추면 finally 가 도는가",
  "tabs": [
    {
      "label": "코루틴 + StopCoroutine",
      "code": "IEnumerator ShowPopupCo()\n{\n    popup.SetActive(true);\n\n    try\n    {\n        yield return new WaitForSeconds(3f);   // 여기서 멈춰 있을 때 StopCoroutine 이 불린다\n    }\n    finally\n    {\n        popup.SetActive(false);                // 불리지 않는다\n    }\n}",
      "text": "StopCoroutine 은 이터레이터를 버리기만 합니다. Dispose 가 불리지 않으니 finally 로 들어갈 길이 없습니다.",
      "stats": [
        [
          "finally",
          "돌지 않음"
        ],
        [
          "popup",
          "켜진 채 남음"
        ]
      ],
      "tone": "bad"
    },
    {
      "label": "UniTask + CancellationToken",
      "code": "async UniTask ShowPopupAsync(CancellationToken ct)\n{\n    popup.SetActive(true);\n\n    try\n    {\n        await UniTask.Delay(3000, cancellationToken: ct);   // 취소되면 여기서 OperationCanceledException 을 던진다\n    }\n    finally\n    {\n        popup.SetActive(false);                // 예외가 빠져나가는 길에 돈다\n    }\n}",
      "text": "토큰을 취소하면 다음 프레임에 대기 객체가 그것을 보고 취소로 끝납니다. await 가 예외를 던지고, 예외는 finally 를 지나 호출한 쪽으로 올라갑니다.",
      "stats": [
        [
          "finally",
          "돎"
        ],
        [
          "popup",
          "꺼짐"
        ]
      ],
      "tone": "good"
    }
  ]
}
</script>
</x-compare>

async/await 는 취소를 `OperationCanceledException` 으로 바꿉니다. 예외는 `finally` 를 지나 올라가므로 정리 코드를 건너뛸 수 없습니다. 결과는 `UniTask<int>` 로, 실패는 `try-catch` 로 받습니다.

## Task 로 옮기면 부를 때마다 할당한다

`async Task` 도 세 빈칸을 모두 풉니다. 대신 기다림이 걸리면 `Task` 객체, 상태 머신 박싱, 러너, `MoveNext` 델리게이트를 힙에 만듭니다. 같은 일을 세 방식으로 쟀습니다.

<x-compare>
<script type="application/json">
{
  "label": "코루틴, Task, UniTask",
  "tabs": [
    {
      "label": "코루틴",
      "code": "IEnumerator LoadScoreCo(Action<int> onDone)\n{\n    yield return new WaitForSeconds(1f);\n    onDone(42);\n}\n\nStartCoroutine(LoadScoreCo(s => score = s));",
      "stats": [["결과", "콜백으로 받는다"], ["예외", "yield 탓에 try-catch 불가"], ["취소", "StopCoroutine, finally 안 돎"], ["재개 위치", "yield 한 명령이 정한 시점"], ["1회 할당", "76 B"]],
      "tone": "bad"
    },
    {
      "label": "Task",
      "code": "async Task<int> LoadScoreAsync()\n{\n    await Task.Yield();\n    return 42;\n}\n\nint score = await LoadScoreAsync();",
      "stats": [["결과", "Task<int>"], ["예외", "try-catch"], ["취소", "CancellationToken, 예외로 전달"], ["재개 위치", "UnitySynchronizationContext 로 Post"], ["1회 할당", "376 B"]],
      "tone": "bad"
    },
    {
      "label": "UniTask",
      "code": "async UniTask<int> LoadScoreAsync()\n{\n    await UniTask.Yield();\n    return 42;\n}\n\nint score = await LoadScoreAsync();",
      "stats": [["결과", "UniTask<int> (구조체)"], ["예외", "try-catch"], ["취소", "CancellationToken, 예외로 전달"], ["재개 위치", "지정한 PlayerLoopTiming"], ["1회 할당", "0.1 B"]],
      "tone": "good"
    }
  ]
}
</script>
</x-compare>

코드 모양은 거의 같은데 376 B 가 0.1 B 가 됩니다. await 없이 값만 돌려줘도 `Task` 는 80 B, UniTask 는 0.0 B 였고, IL2CPP 에서도 같은 모양이었습니다. UniTask 는 반환 타입을 구조체로 만들고, 기다리는 데 필요한 객체는 풀에서 빌려 씁니다.

## 컨텍스트를 버리고 PlayerLoop 에서 이어 간다

`Task` 의 await 는 `SynchronizationContext` 를 붙잡았다가, 끝나면 이어질 코드를 거기에 `Post` 합니다. 유니티의 비동기 작업은 끝났다는 알림이 이미 메인 스레드로 오므로 이 장치는 대부분 헛돕니다. UniTask 는 `SynchronizationContext` 도 `ExecutionContext` 도 붙잡지 않습니다. 그 자리는 PlayerLoop 가 채웁니다.

<x-wiring>
<script type="application/json">
{
  "label": "UniTask.Delay(200) 한 번이 돌아오는 길",
  "width": 740,
  "height": 300,
  "nodes": [
    { "id": "m", "title": "async 메서드", "sub": "await UniTask.Delay(200)", "x": 265, "y": 8, "w": 210, "h": 54 },
    { "id": "p", "title": "DelayPromise", "sub": "경과 시간을 세는 대기 객체", "x": 265, "y": 150, "w": 210, "h": 54 },
    { "id": "loop", "title": "PlayerLoop · Update", "sub": "매 프레임 한 번 부른다", "x": 0, "y": 238, "w": 170, "h": 54 },
    { "id": "pool", "title": "TaskPool", "sub": "다 쓴 DelayPromise", "x": 570, "y": 238, "w": 170, "h": 54 }
  ],
  "edges": [
    { "from": "pool", "to": "p", "label": "꺼낸다", "bend": 0 },
    { "from": "p", "to": "loop", "label": "AddAction", "bend": 30 },
    { "from": "m", "to": "p", "label": "이어 갈 곳을 맡긴다", "bend": 40 },
    { "from": "loop", "to": "p", "label": "MoveNext()", "bend": 30 },
    { "from": "p", "to": "m", "label": "완료, 맡긴 것을 부른다", "bend": 40 },
    { "from": "p", "to": "pool", "label": "GetResult 뒤 반납", "bend": 30 }
  ],
  "steps": [
    { "edge": 0, "why": "UniTask.Delay(200) 을 부르는 순간 풀에서 DelayPromise 하나를 꺼냅니다. 풀이 비어 있을 때만 new 합니다." },
    { "edge": 1, "why": "꺼낸 자리에서 곧바로 PlayerLoop 의 Update 지점에 등록합니다. await 하기 전부터 시간은 흐르기 시작합니다." },
    { "edge": 2, "why": "await 가 아직 안 끝난 것을 보고, 이어서 실행할 델리게이트를 promise 에 맡긴 뒤 메서드를 빠져나갑니다." },
    { "edge": 3, "why": "매 프레임 Update 에서 MoveNext 가 불려 경과 시간에 deltaTime 을 더합니다. 200 ms 가 안 찼으면 true 를 돌려주고 다음 프레임을 기다립니다." },
    { "edge": 4, "why": "시간이 차면 결과를 채우고 맡겨 둔 델리게이트를 그 자리에서 부릅니다. 메서드는 Update 도중, 메인 스레드에서 이어집니다. 어느 컨텍스트에도 Post 하지 않습니다." },
    { "edge": 5, "why": "메서드가 결과를 꺼내면 promise 는 버전을 하나 올리고 풀로 돌아갑니다. 다음 Delay 가 같은 객체를 씁니다." }
  ],
  "note": "시계는 SynchronizationContext 가 아니라 PlayerLoop 입니다. 어느 지점에서 깨어날지는 PlayerLoopTiming 인자로 고릅니다."
}
</script>
</x-wiring>

`Delay` 는 Update 도중 메인 스레드에서 이어지므로, 컨텍스트 없이도 유니티 API 를 부를 수 있는 자리로 돌아옵니다. 어느 지점에서 깨어날지는 `PlayerLoopTiming` 인자로 고릅니다.

## 가장 짧은 예제

오브젝트를 세 번 깜빡이다가 파괴되면 멈춥니다. 네임스페이스는 `Cysharp.Threading.Tasks` 입니다.

```csharp
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;

public class Blinker : MonoBehaviour
{
    [SerializeField] GameObject target;

    void Start() => BlinkAsync(3, destroyCancellationToken).Forget();

    async UniTask BlinkAsync(int count, CancellationToken ct)
    {
        for (int i = 0; i < count; i++)
        {
            target.SetActive(false);
            await UniTask.Delay(200, cancellationToken: ct);   // 밀리초, timeScale 을 따른다
            target.SetActive(true);
            await UniTask.Delay(200, cancellationToken: ct);
        }
    }
}
```

- `async UniTask` 로 쓰면 UniTask 의 빌더로 컴파일됩니다. 값을 돌려주려면 `async UniTask<int>` 입니다.
- `destroyCancellationToken` 은 유니티 2022.2 부터 있는 토큰으로, 오브젝트가 파괴될 때 취소됩니다. 그 전 버전에서는 `this.GetCancellationTokenOnDestroy()` 를 씁니다.
- `Forget()` 은 기다리지 않을 `UniTask` 를 끝까지 지켜보다가 예외가 나면 `UniTaskScheduler.UnobservedTaskException` 으로 넘깁니다.

결과와 예외는 `Task` 와 똑같이 받습니다. `async void` 자리에는 `async UniTaskVoid` 를 씁니다.

```csharp
async UniTask<int> LoadScoreAsync(CancellationToken ct)
{
    await UniTask.Delay(1000, cancellationToken: ct);
    return 42;
}

async UniTaskVoid OnClickAsync()
{
    try { label.text = (await LoadScoreAsync(destroyCancellationToken)).ToString(); }
    catch (OperationCanceledException) { }            // 파괴되었다
    catch (Exception ex) { Debug.LogException(ex); }
}
```

## 주의할 점

### 1. 토큰을 넘기지 않으면 멈추지 않는다

**안 지키면 생기는 문제.** `StopCoroutine` 같은 바깥 스위치가 없습니다. `UniTask.Delay` 에 토큰을 넘기지 않으면 오브젝트가 파괴되어도 대기는 끝까지 돌고, 다음 줄이 파괴된 오브젝트를 만집니다.

**이렇게 쓴다.** 기다리는 메서드는 `CancellationToken` 을 받아 모든 await 에 넘깁니다. `MonoBehaviour` 안이면 `destroyCancellationToken` 을 씁니다.

### 2. 반환 타입을 Task 로 남기면 그 할당도 남는다

**안 지키면 생기는 문제.** 안에서 UniTask 를 await 해도 메서드가 `async Task` 면 `Task` 의 빌더로 컴파일됩니다. 기다릴 때마다 `Task` 쪽 할당이 그대로 생깁니다.

**이렇게 쓴다.** 게임 루프에서 도는 async 메서드는 반환 타입을 `UniTask`, `UniTask<T>`, `UniTaskVoid` 로 바꿉니다.

### 3. AsyncLocal 에 기대지 않는다

**안 지키면 생기는 문제.** UniTask 는 `ExecutionContext` 를 붙잡지 않으므로 `AsyncLocal<T>` 에 넣은 값이 await 너머로 따라오지 않습니다.

**이렇게 쓴다.** await 너머로 넘길 값은 메서드 인자나 필드로 넘깁니다.

## 정리

UniTask 는 **async/await 로 코루틴의 빈칸을 메우면서 `Task` 의 할당과 컨텍스트를 버린** 라이브러리입니다.

- 코루틴은 결과, 예외, 취소를 다루지 못하고, `StopCoroutine` 은 `finally` 를 건너뜁니다. async/await 는 취소를 예외로 바꿔 정리 코드를 보장합니다.
- 같은 코드가 `Task` 로는 한 번에 80~384 B, UniTask 로는 0.0~0.2 B 였습니다.
- 컨텍스트 대신 PlayerLoop 가 이어 주고, 깨어날 지점은 `PlayerLoopTiming` 으로 고릅니다.