---
title: "UniTask 의 에디터 할당은 코드 최적화가 만든다"
description: "에디터에서 UniTask 호출마다 찍히는 48 B 는 꺼진 코드 최적화가 상태 머신을 클래스로 만들어 생깁니다. 최적화를 켜면 0 이고, 그 뒤에도 남는 할당은 첫 대량 실행과 정상 경로 밖의 호출입니다."
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-alloc/
---
```csharp
async UniTask OneYield()
{
    await UniTask.Yield();
}
```

UniTask 로 옮겼는데 에디터 프로파일러에는 이 메서드를 부를 때마다 **48 B** 가 찍힙니다. 같은 코드를 빌드해서 재면 0.1 B 입니다.

**에디터에서 보이는 할당은 대부분 UniTask 가 아니라 꺼진 코드 최적화가 만든 것입니다.** 최적화를 켜고 재면 0 이고, 그 뒤에도 남는 할당은 두 가지입니다.

아래 숫자는 모두 1회당 할당입니다. N=1,000 과 2,000 의 차이를 1,000 으로 나눴습니다.

## 에디터의 할당은 코드 최적화가 꺼져서 생긴다

### 원인

C# 컴파일러는 `async` 메서드를 상태 머신으로 바꿉니다. 코드 최적화가 켜져 있으면 구조체로, 꺼져 있으면 클래스로 만듭니다. 클래스면 부를 때마다 `new` 가 되고, 이것은 컴파일러가 만든 코드라 UniTask 의 풀로 막을 수 없습니다.

유니티 에디터는 스크립트를 컴파일할 때 코드 최적화 설정을 따릅니다. 기본값이 Debug 라 최적화가 꺼져 있습니다.

| 환경 | 코드 최적화 | `OneYield()` 1회 |
| --- | --- | --- |
| 에디터, 코드 최적화 Debug (기본) | 꺼짐 | 48.1 B |
| 에디터, 코드 최적화 Release | 켜짐 | 0.1 B |
| Development 빌드 | 켜짐 | 0.1~0.2 B |
| Development 빌드 + Script Debugging | 꺼짐 | 48~56 B |
| 출하 빌드 | 켜짐 | 0 B |

기준은 Development 빌드인지나 `DEBUG` 심볼이 아닙니다. Development 빌드는 `DEBUG` 가 정의돼 있어도 0 이고, Script Debugging 을 켜면 최적화가 꺼져 다시 할당합니다.

`async` 메서드를 거치지 않으면 최적화가 꺼져 있어도 0 입니다. 48 B 가 UniTask 의 것이 아니라는 증거입니다.

| 작업 | 에디터 Debug | 에디터 Release |
| --- | --- | --- |
| `async UniTask<int>`, await 없음 | 56.0 B | 0.0 B |
| `async UniTask` + `await UniTask.Yield()` | 48.1 B | 0.1 B |
| `UniTask.Delay(1)` 을 직접 부름 | 0.0 B | 0.0 B |
| `async Task<int>`, await 없음 | 128.0 B | 80.0 B |
| `async Task` + `await Task.Yield()` | 368.0 B | 376.0 B |

`Task` 는 최적화를 켜도 80 B, 376 B 가 남습니다.

### 이렇게 쓴다

에디터 기본 설정에서 찍힌 할당은 출하 빌드에 없습니다. 그 숫자로 UniTask 코드를 고치지 않습니다. 할당을 잴 때는 최적화가 켜진 곳에서 잽니다.

| 재는 곳 | 설정 |
| --- | --- |
| 에디터 | 코드 최적화를 Release 로 바꿈 |
| 플레이어 | Development Build 켬, Script Debugging 끔 |

에디터의 코드 최적화는 에디터를 `-releaseCodeOptimization` 인자로 띄우면 Release 로 시작합니다. Release 에서는 디버거를 붙여 중단점을 걸 수 없으니, 잴 때만 바꿉니다.

## 최적화를 켜도 남는 할당 1: 처음 많이 돌릴 때 한 번

### 원인

첫 실행은 풀이 비어 러너를 새로 만듭니다. 풀을 미리 채워도 첫 대량 실행에는 값이 남습니다. `OneYield()` 를 한 번에 1,000개 돌린 첫 측정은 1회당 16.6 B 였습니다.

이것은 PlayerLoop 의 continuation 큐가 처음 커지는 값입니다. 큐는 배열 두 개를 번갈아 쓰고, 16칸에서 두 배씩 자랍니다. 미리 돌린 한 바퀴가 한쪽만 키웠고, 본 측정에서 다른 쪽이 커졌습니다.

| 계산 | 값 |
| --- | --- |
| 16칸에서 1,024칸까지 새로 만든 배열 합 × 8 B | 16,128 B |
| 실측 (N=1,000) | 16,648 B |

두 배열이 다 커진 뒤에는 0.3 B 였습니다. 한 번 내고 끝나는 값입니다.

### 이렇게 쓴다

- 잴 때는 같은 개수로 미리 돌려 풀과 큐를 키운 뒤에 잽니다. 첫 측정에 섞인 값을 매번 드는 값으로 읽지 않습니다.
- 게임에서는 대부분 무시해도 됩니다. 전투 시작처럼 한 프레임에 수백 개가 몰리는 자리가 있고 그 프레임의 GC 가 문제라면, 로딩 중에 같은 개수를 미리 돌려 둡니다.

## 최적화를 켜도 남는 할당 2: 정상 경로를 벗어날 때

### 원인

UniTask 가 0 인 것은 대기 객체를 풀에서 빌려 정상으로 끝낼 때입니다. 아래는 그 밖의 경로라 부를 때마다 할당합니다.

| 대상 | Mono | IL2CPP |
| --- | --- | --- |
| `WhenAll(3개)` | 110.8 B | 143.5 B |
| `Delay` + 취소 | 352 B | 448 B |
| `WaitUntil(() => flag && local >= 0)` | 148.2 B | 152.2 B |
| `WaitUntil(state, s => s.flag)` | 0.2 B | 0.2 B |

- `WhenAll` 은 결과 배열과 조합기 객체를 만듭니다.
- 취소는 예외 객체를 만들어 던집니다.
- 바깥 변수를 캡처한 람다는 부를 때마다 클로저와 델리게이트가 생깁니다. 이것은 UniTask 가 아니라 호출하는 코드의 할당입니다.

### 이렇게 쓴다

매 호출 도는 람다는 바깥 변수를 캡처하지 않고, `state` 를 받는 오버로드로 넘깁니다.

```csharp
// 148~152 B. this 를 캡처해 부를 때마다 클로저와 델리게이트가 생긴다
await UniTask.WaitUntil(() => this.hp <= 0);

// 0.2 B. 필요한 것을 state 로 넘기고 람다는 캡처하지 않는다
await UniTask.WaitUntil(this, self => self.hp <= 0);
```

`WhenAll` 과 취소의 할당은 없앨 수 없습니다. 로딩이나 화면 전환처럼 가끔 도는 곳에서는 그대로 쓰고, 매 프레임 도는 경로에는 두지 않습니다.

## 끝나지 않는 태스크는 UniTask Tracker 로 찾는다

할당보다 흔한 문제는 **끝나지 않는 태스크**입니다. 끝나지 않으면 대기 객체와 러너가 풀로 돌아가지 않습니다. 에디터 메뉴 **Window → UniTask Tracker** 가 이것을 봅니다.

**Enable Tracking 을 켜야 목록에 오릅니다.** 기본은 꺼져 있고, 켜기 전에 시작된 태스크는 안 잡힙니다. 추적 호출은 `[Conditional("UNITY_EDITOR")]` 라 플레이어 빌드에는 없습니다.

`UniTask.Delay(1)` 하나를 만드는 비용입니다(에디터 PlayMode).

| 설정 | 1개당 할당 | 1,000개 만드는 시간 (Release / Debug) |
| --- | --- | --- |
| 전부 끔 | 0.2 B | 0.16 / 0.29 ms |
| Enable Tracking | 158 B | 6.3 / 12.2 ms |
| Tracking + StackTrace | **약 78 KB** | **349 / 576 ms** |

찾는 법은 이렇습니다.

1. Tracking 을 켜고 플레이를 다시 시작합니다.
2. 씬 전환이나 창 열고 닫기처럼 의심 가는 동작을 되풀이합니다.
3. 되풀이할 때마다 같은 TaskType 이 늘면 끝나지 않는 태스크입니다.
4. 그때만 StackTrace 를 켜고 재현해 만든 자리를 봅니다.

`async UniTaskVoid` 의 러너는 추적용으로 늘 `Pending` 을 돌려줍니다. Pending 이라고 새는 것이 아니고, 목록에서 사라지는지를 봅니다.

StackTrace 는 찾은 뒤에 바로 끕니다. 설정이 `EditorPrefs` 에 저장돼 다음 날에도 켜져 있고, 태스크 하나마다 78 KB 와 0.35~0.58 ms 가 들어 에디터 플레이가 느려집니다.

## 정리

에디터에서 보이는 UniTask 의 할당은 대부분 **꺼진 코드 최적화가 만든 클래스 상태 머신**입니다.

- 에디터 기본(Debug)과 Script Debugging 을 켠 빌드는 `async` 호출마다 48~64 B 를 만들고, 최적화가 켜진 곳에서는 0 입니다.
- 최적화를 켜도 처음 많이 돌릴 때는 continuation 큐 배열이 커지느라 한 번 할당합니다.
- `WhenAll`, 취소, 캡처 람다는 부를 때마다 할당합니다.

그래서 할당은 최적화가 켜진 곳에서 풀을 채운 뒤에 재고, 매 호출 도는 람다는 `state` 오버로드로 바꿉니다. 끝나지 않는 태스크는 Tracker 로 찾습니다.