UniTask 는 코루틴의 빈칸을 메우고 Task 의 GC 를 없앤다
Esc

    UniTask 는 코루틴의 빈칸을 메우고 Task 의 GC 를 없앤다

    2026년 10월 4일 수정–Unity라이브러리동시성
    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 가 돌지 않습니다.

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

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

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

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

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

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

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

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

    가장 짧은 예제

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

    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 를 씁니다.

    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 로는 한 번에 80384 B, UniTask 로는 0.00.2 B 였습니다.
    • 컨텍스트 대신 PlayerLoop 가 이어 주고, 깨어날 지점은 PlayerLoopTiming 으로 고릅니다.