UniTask 의 에디터 할당은 코드 최적화가 만든다
Esc

    UniTask 의 에디터 할당은 코드 최적화가 만든다

    2026년 10월 4일 수정–Unity라이브러리동시성GC
    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 를 받는 오버로드로 넘깁니다.

    // 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 로 찾습니다.