---
title: "UniTask 는 기다릴 때만 풀에서 빌려 GC 를 만들지 않는다"
description: "UniTask 의 async 메서드는 await 없이 끝나면 아무것도 만들지 않고, 정말 기다릴 때만 풀에서 러너를 빌렸다가 돌려줍니다. 이 0 은 최적화 컴파일에서만 성립하고, IL2CPP 에서는 반환이 프레임 끝으로 밀립니다."
author: "유니얼"
pubDate: 2026-09-24
updatedDate: 2026-10-04
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "동시성", "GC"]
canonical: https://blog.unialgames.com/posts/unity-unitask-builder/
---
```csharp
// 적 수만큼, 매 프레임 부른다
async UniTask<Sprite> GetIconAsync(string key)
{
    if (cache.TryGetValue(key, out var icon)) return icon;   // 대부분 여기서 끝난다
    icon = await LoadIconAsync(key);                          // 가끔만 기다린다
    cache[key] = icon;
    return icon;
}
```

같은 모양의 `async Task<int>` 는 await 없이 값만 돌려줘도 80 B, `Task.Yield()` 로 한 번 양보하면 376 B 를 만듭니다. UniTask 에서는 0.0 B 와 0.1 B 입니다.

**바로 끝나면 아무것도 만들지 않고, 정말 기다릴 때만 풀에서 러너를 빌렸다가 돌려주기** 때문입니다. 기준 판본은 2.5.11 입니다.

## 바로 끝나면 아무것도 만들지 않는다

`UniTask` 에는 `[AsyncMethodBuilder(typeof(AsyncUniTaskMethodBuilder))]` 가 붙어 있습니다. 컴파일러는 `async UniTask` 메서드를 상태 머신 구조체로 바꾸고, 이 빌더를 그 안의 필드로 넣습니다. 빌더도 구조체이고 `Create()` 는 `default` 입니다. 러너를 만드는 자리는 한 군데뿐입니다.

```csharp
// AsyncUniTaskMethodBuilder<T> (소스를 줄여 옮김)
public void Start<TStateMachine>(ref TStateMachine stateMachine)
{
    stateMachine.MoveNext();
}

public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(ref TAwaiter awaiter, ref TStateMachine stateMachine)
{
    // 러너를 만드는 유일한 자리. 첫 await 가 실제로 기다릴 때만 온다
    if (runnerPromise == null)
        AsyncUniTask<TStateMachine, T>.SetStateMachine(ref stateMachine, ref runnerPromise);

    awaiter.UnsafeOnCompleted(runnerPromise.MoveNext);
}

public void SetResult(T result)
{
    if (runnerPromise == null)
        this.result = result;
    else
        runnerPromise.SetResult(result);
}

public UniTask<T> Task => runnerPromise != null ? runnerPromise.Task
                        : ex != null ? UniTask.FromException<T>(ex)
                        : UniTask.FromResult(result);
```

두 경우에 값이 어떻게 달라지는지 견주면 이렇습니다.

<x-compare>
<script type="application/json">
{
  "label": "메서드가 끝났을 때 빌더의 값",
  "tabs": [
    {
      "label": "await 없이 끝난다",
      "text": "AwaitUnsafeOnCompleted 를 한 번도 거치지 않습니다. 결과는 빌더 필드에 있다가 값만 담은 UniTask<T> 로 나갑니다.",
      "stats": [
        ["runnerPromise", "null"],
        ["결과가 적히는 곳", "빌더의 result 필드"],
        ["Task 가 돌려주는 것", "UniTask.FromResult(result)"],
        ["만든 객체", "0개"]
      ]
    },
    {
      "label": "await 에서 기다린다",
      "text": "첫 await 에서 러너를 풀에서 빌려 상태 머신을 옮깁니다. 결과는 러너에 들어가고, 기다리던 쪽이 러너를 통해 받습니다.",
      "stats": [
        ["runnerPromise", "러너 (풀에서 빌림)"],
        ["결과가 적히는 곳", "러너"],
        ["Task 가 돌려주는 것", "runnerPromise.Task"],
        ["만든 객체", "러너 1개 (풀이 비었을 때만 new)"]
      ]
    }
  ]
}
</script>
</x-compare>

기다리지 않고 끝나면 러너 칸은 끝까지 `null` 이고, 결과는 빌더 필드에 있다가 값만 담은 `UniTask<T>` 로 나갑니다. 1회당 Mono·IL2CPP Development 플레이어에서 **0.0 B** 였고, `async Task<int>` 는 80 B, 88 B 였습니다.

## 기다릴 때만 러너를 풀에서 빌린다

기다림이 걸리면 스택의 상태 머신이 살아 있을 자리가 힙에 필요합니다. 빌더는 첫 await 에서 러너 `AsyncUniTask<TStateMachine>` 을 풀에서 꺼내 상태 머신을 옮깁니다.

- 러너는 풀에서 꺼내고, 비었을 때만 `new` 합니다.
- 상태 머신은 제네릭 필드 `TStateMachine` 에 값으로 담겨 박싱이 없습니다.
- `MoveNext` 델리게이트는 러너 생성자에서 한 번 만든 것을 재사용합니다.

<x-wiring>
<script type="application/json">
{
  "label": "첫 await 에서 상태 머신이 러너로 옮겨 가는 길",
  "width": 660,
  "height": 330,
  "nodes": [
    { "id": "sm", "title": "상태 머신 (스택)", "sub": "builder.runnerPromise = null", "x": 0, "y": 8, "w": 220, "h": 54 },
    { "id": "pool", "title": "TaskPool<AsyncUniTask<SM>>", "sub": "이 메서드 전용 풀", "x": 440, "y": 8, "w": 220, "h": 54 },
    { "id": "r", "title": "러너 AsyncUniTask<SM>", "sub": "stateMachine · MoveNext · core", "x": 220, "y": 138, "w": 220, "h": 54 },
    { "id": "aw", "title": "Awaiter", "sub": "UniTask.Yield() 등", "x": 220, "y": 268, "w": 220, "h": 54 }
  ],
  "edges": [
    { "from": "pool", "to": "r", "label": "TryPop, 비었으면 new", "bend": 0 },
    { "from": "r", "to": "sm", "label": "runnerPromise = R", "bend": 30 },
    { "from": "sm", "to": "r", "label": "통째로 값 복사", "bend": 30 },
    { "from": "r", "to": "aw", "label": "UnsafeOnCompleted(R.MoveNext)", "bend": 40 },
    { "from": "aw", "to": "r", "label": "끝나면 R.MoveNext()", "bend": 40 }
  ],
  "steps": [
    { "edge": 0, "why": "AwaitUnsafeOnCompleted 가 처음 불렸고 runnerPromise 가 비어 있습니다. 이 메서드의 상태 머신 타입 전용 풀에서 러너를 하나 꺼냅니다." },
    { "edge": 1, "why": "먼저 스택에 있는 원본 상태 머신 안의 빌더에 러너를 적습니다." },
    { "edge": 2, "why": "그다음 상태 머신 구조체를 러너의 제네릭 필드에 값으로 복사합니다. 사본 안의 빌더도 러너를 가리킵니다. 박싱은 없습니다." },
    { "edge": 3, "why": "생성자에서 한 번 만들어 둔 R.MoveNext 델리게이트를 awaiter 에 맡깁니다. 메서드는 여기서 빠져나가고 스택의 원본은 사라집니다." },
    { "edge": 4, "why": "기다림이 끝나면 awaiter 쪽이 R.MoveNext 를 부르고, 러너가 자기 필드의 상태 머신을 이어서 돌립니다." }
  ],
  "note": "풀이 워밍업된 뒤에는 이 과정 어디에도 new 가 없습니다."
}
</script>
</x-wiring>

풀은 상태 머신 타입마다, 곧 async 메서드마다 따로 있습니다. `await UniTask.Yield()` 한 번짜리 메서드가 워밍업 뒤 1회당 Mono 0.1 B, IL2CPP 0.2 B 였고, `Task.Yield()` 판은 376 B, 384 B 였습니다.

## IL2CPP 에서는 러너가 프레임 끝에 돌아온다

러너는 호출한 쪽이 `GetResult` 로 결과를 꺼낼 때 버전을 올리고 상태 머신을 비워 풀로 돌아갑니다. IL2CPP 에서는 VM 버그를 피하려고 이 반환을 그 프레임의 `LastPostLateUpdate` 로 미룹니다. 같은 메서드를 세 번 이어 기다려 봅니다.

```csharp
// StateMachineRunner.cs (소스를 줄여 옮김)
public static void SetStateMachine(ref TStateMachine stateMachine, ref IStateMachineRunnerPromise runnerPromiseFieldRef)
{
    // 풀에 있으면 꺼내고, 비었을 때만 새로 만든다
    if (!pool.TryPop(out var result))
        result = new AsyncUniTask<TStateMachine>();

    runnerPromiseFieldRef = result;
    result.stateMachine = stateMachine;
}

public void GetResult(short token)
{
    try
    {
        core.GetResult(token);
    }
    finally
    {
#if ENABLE_IL2CPP
        // 반환을 이 프레임의 LastPostLateUpdate 로 미룬다
        PlayerLoopHelper.AddContinuation(PlayerLoopTiming.LastPostLateUpdate, returnDelegate);
#else
        // 그 자리에서 비우고 풀에 넣는다
        TryReturn();
#endif
    }
}
```

호출마다 풀에서 꺼낸 러너와 풀의 상태는 이렇습니다.

<x-compare>
<script type="application/json">
{
  "label": "같은 메서드를 세 번 이어서 기다릴 때 러너 풀",
  "tabs": [
    {
      "label": "Mono",
      "text": "결과를 꺼내는 자리에서 러너가 곧바로 풀로 돌아옵니다. 다음 호출이 같은 러너를 다시 꺼냅니다.",
      "stats": [
        ["1번째 호출", "풀이 비어 R1 을 새로 만듦"],
        ["2번째 호출", "돌아온 R1 을 꺼냄"],
        ["3번째 호출", "돌아온 R1 을 꺼냄"],
        ["만든 러너", "1개"],
        ["끝난 뒤 풀", "[R1]"]
      ]
    },
    {
      "label": "IL2CPP",
      "text": "러너는 프레임 끝에야 풀로 돌아옵니다. 2번째 호출은 같은 Update 안에서 이어지므로 R1 이 아직 풀에 없습니다.",
      "stats": [
        ["1번째 호출", "풀이 비어 R1 을 새로 만듦"],
        ["2번째 호출", "R1 이 반환 대기 중이라 R2 를 새로 만듦"],
        ["3번째 호출", "프레임 끝에 돌아온 R1 을 꺼냄"],
        ["만든 러너", "2개"],
        ["끝난 뒤 풀", "[R1, R2]"]
      ]
    }
  ]
}
</script>
</x-compare>

순차 실행이어도 IL2CPP 는 반환이 한 박자 늦어 러너가 하나 더 필요합니다. 워밍업이 끝나면 둘 다 새로 만들지 않습니다.

실측도 같았습니다. 200 회 순차로 돌린 뒤 러너 풀은 **Mono 1, IL2CPP 2** 였고, 1회당 할당은 둘 다 0 이었습니다. 한 번만 드는 고정비는 다릅니다. 같은 메서드를 동시에 N 개 돌릴 때 Mono 16.5 KB, IL2CPP 32.7 KB 였습니다. IL2CPP 에서만 미뤄 둔 반환이 `LastPostLateUpdate` 대기열의 두 버퍼를 16,384 칸까지 키웠기 때문입니다.

## 이 0 은 최적화 컴파일에서만 성립한다

최적화를 끄고 컴파일하면 상태 머신이 **클래스**가 되어, 부를 때마다 그 객체가 하나 생깁니다. 기준은 `DEBUG` 심볼이나 Development 여부가 아니라 **최적화 컴파일이 켜졌는가** 입니다.

<x-compare>
<script type="application/json">
{
  "label": "워밍업 뒤 1회당 한계비용 (B). 1a async UniTask<int> await 없음 · 1b async Task<int> await 없음 · 2a async UniTask + await UniTask.Yield() · 2b async Task + await Task.Yield()",
  "tabs": [
    {
      "label": "에디터 Debug",
      "text": "에디터, Mono, Code Optimization 기본값 Debug. 상태 머신 IsValueType=False, 클래스입니다.",
      "stats": [
        ["1a UniTask 동기 완료", "56.0"],
        ["1b Task 동기 완료", "128.0"],
        ["2a UniTask + Yield", "48.1"],
        ["2b Task + Task.Yield", "368.0"]
      ],
      "tone": "bad"
    },
    {
      "label": "에디터 Release",
      "text": "같은 에디터를 -releaseCodeOptimization 으로 띄웠습니다. DEBUG 는 여전히 정의되어 있지만 상태 머신은 구조체입니다.",
      "stats": [
        ["1a UniTask 동기 완료", "0.0"],
        ["1b Task 동기 완료", "80.0"],
        ["2a UniTask + Yield", "0.1"],
        ["2b Task + Task.Yield", "376.0"]
      ],
      "tone": "good"
    },
    {
      "label": "Development Mono",
      "text": "Development 플레이어, Mono. DEBUG 와 DEVELOPMENT_BUILD 가 정의되어 있고 프로파일러도 붙습니다. 상태 머신은 구조체입니다.",
      "stats": [
        ["1a UniTask 동기 완료", "0.0"],
        ["1b Task 동기 완료", "80.0"],
        ["2a UniTask + Yield", "0.1"],
        ["2b Task + Task.Yield", "376.0"]
      ],
      "tone": "good"
    },
    {
      "label": "Development IL2CPP",
      "text": "Development 플레이어, IL2CPP. 상태 머신은 구조체입니다.",
      "stats": [
        ["1a UniTask 동기 완료", "0.0"],
        ["1b Task 동기 완료", "88.0"],
        ["2a UniTask + Yield", "0.2"],
        ["2b Task + Task.Yield", "384.0"]
      ],
      "tone": "good"
    },
    {
      "label": "Script Debugging 켬",
      "text": "Development 에 Script Debugging(AllowDebugging)을 더한 플레이어. 최적화가 꺼져 상태 머신이 다시 클래스가 됩니다. 값은 Mono / IL2CPP 이고, 2a 는 N=2,000 의 1회당 값입니다.",
      "stats": [
        ["1a UniTask 동기 완료", "56.0 / 64.0"],
        ["1b Task 동기 완료", "128.0 / 144.0"],
        ["2a UniTask + Yield", "48.2 / 56.2"],
        ["2b Task + Task.Yield", "368.0 / 384.0"]
      ],
      "tone": "bad"
    },
    {
      "label": "출하 빌드",
      "text": "Development 를 끈 플레이어. ProfilerRecorder 가 동작하지 않아 측정 창 동안 GC 를 끄고 GC.GetTotalMemory 차이로 쟀습니다. Mono 는 대조군(byte[16] 47.5, object 16.0)을 통과했고, IL2CPP 는 크기 등급으로 올린 점유량이라 0 인지 아닌지만 정확합니다. 값은 Mono / IL2CPP 입니다.",
      "stats": [
        ["1a UniTask 동기 완료", "0 / 0"],
        ["1b Task 동기 완료", "78~82 / 98"],
        ["2a UniTask + Yield (N=2,000)", "0 / 0"],
        ["2b Task + Task.Yield", "303~311 / 434~451"]
      ],
      "tone": "good"
    }
  ]
}
</script>
</x-compare>

값은 N = 1,000 과 2,000 을 동시에 돌린 차이를 N 차이로 나눈 한계비용이고, `ProfilerRecorder` 의 "GC Allocated In Frame" 으로 쟀습니다. Debug 쪽 56 B, 48 B 는 상태 머신 몫이라 최적화를 켜면 0 이 됩니다. `Task` 는 어느 조건에서도 줄지 않습니다. 출하 빌드는 최적화 컴파일이므로, 플레이어가 받는 코드에서 0 이 성립합니다.

## 주의할 점

### 1. 할당은 최적화 컴파일에서 잰다

**안 지키면 생기는 문제.** 에디터의 기본 Code Optimization 은 Debug 입니다. 여기서 프로파일러를 보면 async 메서드마다 48~64 B 가 찍혀 "UniTask 도 할당한다" 는 잘못된 결론에 닿습니다. Development 빌드에 Script Debugging 을 켜도 같습니다.

**이렇게 쓴다.** 에디터 Code Optimization 을 Release 로 바꾸거나, Script Debugging 을 끈 Development 플레이어에서 잽니다.

### 2. 끝나지 않는 await 는 지역 변수를 붙잡는다

**안 지키면 생기는 문제.** 기다리는 동안 상태 머신은 러너 안에 있고, 러너는 대기 객체에 맡긴 `MoveNext` 로 붙잡혀 있습니다. `WaitUntil(() => ready)` 처럼 영영 끝나지 않고 토큰도 없으면, 그 메서드의 지역 변수가 가리키는 텍스처나 오브젝트가 풀려나지 못합니다.

**이렇게 쓴다.** 오래 기다릴 수 있는 await 에는 `destroyCancellationToken` 같은 토큰을 넘깁니다. 취소로 끝나도 러너는 비워져 풀로 돌아갑니다.

### 3. IL2CPP 에서는 두 번 await 가 예외 없이 러너를 망가뜨린다

**안 지키면 생기는 문제.** IL2CPP 에서는 버전을 올리는 `Reset` 도 프레임 끝으로 밀립니다. `await UniTask.Yield()` 한 번짜리 async 메서드가 돌려준 `t` 를 `await t; await t;` 하자 두 번째가 **예외 없이 통과**했고, 프레임이 지난 뒤 러너 풀은 2 였습니다. 러너 하나가 두 번 들어간 것입니다. 이어서 같은 메서드를 두 번 부르자 두 호출이 같은 러너와 같은 토큰을 받았습니다. Mono 와 에디터에서는 두 번째 await 가 곧바로 `InvalidOperationException` 입니다.

**이렇게 쓴다.** 에디터에서 예외가 안 났다고 안심하지 말고, 두 번 await 자체를 코드에서 없앱니다.

## 정리

UniTask 의 async 메서드는 **바로 끝나면 아무것도 만들지 않고, 기다릴 때만 풀에서 러너를 빌렸다가 돌려주기** 때문에 반복해서 불러도 GC 가 생기지 않습니다.

- 바로 끝나면 결과를 빌더 필드에 두고 값만 돌려줍니다(0.0 B, `Task` 80~88 B).
- 기다리면 첫 await 에서만 러너를 풀에서 꺼내고 박싱과 델리게이트 할당 없이 씁니다(0.1~0.2 B, `Task` 376~384 B). IL2CPP 는 반환이 프레임 끝이라 러너가 하나 더 듭니다.
- 이 0 은 최적화 컴파일에서만 성립합니다. 에디터 Debug 와 Script Debugging 빌드에서는 48~64 B 가 생깁니다.