UniTask 사용법 #Unity

Unity에서 UniTask는 Unity의 비동기 작업을 async/await 방식으로 다루기 위한 라이브러리다.

특히 Unity에서는 Coroutine과 UniTask의 역할이 겹치는 부분이 많지만, UniTask는 C#의 async/await 문법을 그대로 활용하면서 Unity의 PlayerLoop와 결합할 수 있다는 점이 핵심이다.


UniTask란?

UniTask는 Cysharp에서 만든 Unity용 비동기 라이브러리다.

기본적으로 이런 코드를 작성할 수 있게 해준다.

async UniTask StartGameAsync()
{
    await UniTask.Delay(1000);

    Debug.Log("1초 후 실행");
}

Coroutine으로 작성하면:

IEnumerator StartGame()
{
    yield return new WaitForSeconds(1f);

    Debug.Log("1초 후 실행");
}

둘의 목적은 비슷하지만 UniTask에서는 C#의 async/await를 사용할 수 있다.

즉,

Coroutine
    ↓
yield return

UniTask
    ↓
async / await

라고 생각하면 된다.


설치

Unity Package Manager에서 Git URL을 사용하거나 패키지 매니저를 통해 설치할 수 있다.

설치 후에는 보통 다음 네임스페이스를 사용한다.

using Cysharp.Threading.Tasks;

가장 기본적인 사용법

UniTask 반환

일반적인 비동기 메서드는 다음처럼 만든다.

async UniTask LoadGameAsync()
{
    await UniTask.Delay(1000);

    Debug.Log("게임 로드 완료");
}

호출:

await LoadGameAsync();

단, 호출하는 쪽 역시 async여야 한다.

async UniTask StartGameAsync()
{
    await LoadGameAsync();

    Debug.Log("게임 시작");
}

UniTask와 UniTaskVoid

여기서 중요한 차이가 있다.

UniTask

async UniTask LoadAsync()
{
    await UniTask.Delay(1000);
}

비동기 작업 자체를 반환한다.

따라서 호출자가 기다릴 수 있다.

await LoadAsync();

UniTaskVoid

async UniTaskVoid StartGame()
{
    await UniTask.Delay(1000);
}

반환값이 없는 비동기 작업이다.

주로 Unity 이벤트처럼 반환값을 요구할 수 없는 곳에서 사용한다.

async UniTaskVoid OnClick()
{
    await UniTask.Delay(1000);
}

다만 일반적인 비동기 메서드에서는 UniTaskVoid보다 UniTask를 우선 사용하는 것이 좋다.

async UniTask OnClickAsync()
{
    await ...
}

이렇게 만들어두고 필요할 때:

OnClickAsync().Forget();

하는 방식이 일반적이다.


Forget()

Unity에서 굉장히 자주 보게 되는 패턴이다.

async UniTask LoadAsync()
{
    await UniTask.Delay(1000);

    Debug.Log("완료");
}

이를 기다리지 않고 실행하려면:

LoadAsync().Forget();

즉,

await
→ 작업이 끝날 때까지 기다림

Forget()
→ 실행만 하고 기다리지 않음

이다.

예를 들어 버튼 클릭:

public void OnClick()
{
    LoadAsync().Forget();
}

UniTask.Delay

가장 기본적인 대기다.

await UniTask.Delay(1000);

1000ms = 1초다.

await UniTask.Delay(3000);

3초 대기.


프레임 단위 대기

await UniTask.DelayFrame(1);

다음 프레임까지 기다린다.

예:

Debug.Log("A");

await UniTask.DelayFrame(1);

Debug.Log("B");

Unity의 TimeScale과 관계

UniTask.Delay는 기본적으로 Unity의 PlayerLoop 기반으로 동작한다.

시간 기반 대기에서는 Time.timeScale의 영향을 받을지 여부를 지정할 수 있다.

예를 들어 게임이 일시정지되어도 실제 시간 기준으로 기다리고 싶다면:

await UniTask.Delay(
    1000,
    ignoreTimeScale: true
);

게임 시간 기준으로 동작시키고 싶다면:

await UniTask.Delay(
    1000,
    ignoreTimeScale: false
);

게임의 일시정지/시간 배속 시스템을 만들 때 상당히 중요하다.


UniTask.Yield

매 프레임 양보할 수도 있다.

await UniTask.Yield();

예:

async UniTask TestAsync()
{
    Debug.Log("A");

    await UniTask.Yield();

    Debug.Log("B");
}

현재 실행을 잠시 중단하고 다음 PlayerLoop에서 이어서 실행한다.


UniTask.NextFrame

다음 프레임을 명확하게 기다리고 싶다면:

await UniTask.NextFrame();

예:

await UniTask.NextFrame();

Debug.Log("다음 프레임");

비동기 로딩

Unity에서 UniTask를 사용하는 가장 큰 이유 중 하나다.

예를 들어 Addressables와 함께 사용할 수 있다.

async UniTask<GameObject> LoadAsync(string address)
{
    var handle = Addressables.LoadAssetAsync<GameObject>(address);

    return await handle.ToUniTask();
}

사용:

async UniTask StartGameAsync()
{
    GameObject player = await LoadAsync("Player");

    Debug.Log("Player Loaded");

    Instantiate(player);
}

이게 상당히 강력하다.

Coroutine 방식에서는:

Addressables
↓
AsyncOperationHandle
↓
yield return
↓
결과 확인

식으로 작성해야 하는 반면,

UniTask에서는:

await
↓
결과

라는 흐름으로 표현할 수 있다.


Unity AsyncOperation

Unity의 AsyncOperation도 ToUniTask()로 변환할 수 있다.

예:

async UniTask LoadSceneAsync(string sceneName)
{
    await SceneManager.LoadSceneAsync(sceneName).ToUniTask();
}

씬 로딩:

await LoadSceneAsync("Game");

Resources 로딩과는 다르다

Resources.Load() 자체는 동기 방식이다.

var prefab = Resources.Load<GameObject>("Player");

이걸 UniTask로 감싼다고 실제 로딩이 비동기가 되는 것은 아니다.

반면:

SceneManager.LoadSceneAsync(...)

같은 Unity의 비동기 API를 UniTask와 결합하면 의미가 있다.


CancellationToken

UniTask를 제대로 사용하려면 CancellationToken을 이해하는 것이 중요하다.

예를 들어:

async UniTask LoadAsync(CancellationToken cancellationToken)
{
    await UniTask.Delay(
        5000,
        cancellationToken: cancellationToken
    );

    Debug.Log("완료");
}

그런데 5초를 기다리는 도중 오브젝트가 파괴된다면?

GameObject Destroy
       ↓
작업은 계속 실행?
       ↓
잘못된 접근 가능

이 문제가 발생할 수 있다.

그래서 Unity에서는 객체의 생명주기와 비동기 작업을 연결하는 것이 중요하다.


GetCancellationTokenOnDestroy

UniTask가 Unity에서 강력한 이유 중 하나다.

async UniTask LoadAsync()
{
    var token = this.GetCancellationTokenOnDestroy();

    await UniTask.Delay(
        5000,
        cancellationToken: token
    );

    Debug.Log("완료");
}

GameObject가 Destroy되면 CancellationToken이 취소된다.

즉:

MonoBehaviour
    │
    ├── Async Task
    │
    └── Destroy
          ↓
    Cancellation
          ↓
    Async Task 종료

이런 구조를 만들 수 있다.


CancellationToken을 직접 관리하는 방법

private CancellationTokenSource _cts;

private void Awake()
{
    _cts = new CancellationTokenSource();
}

private void OnDestroy()
{
    _cts.Cancel();
    _cts.Dispose();
}

그리고:

async UniTask TestAsync(CancellationToken token)
{
    await UniTask.Delay(5000, cancellationToken: token);

    Debug.Log("완료");
}

호출:

TestAsync(_cts.Token).Forget();

하지만 MonoBehaviour 생명주기와 연결하는 경우라면 대부분:

this.GetCancellationTokenOnDestroy()

가 훨씬 간단하다.


Cancellation 예외 처리

Cancellation은 일반적인 오류와 다르다.

try
{
    await UniTask.Delay(
        5000,
        cancellationToken: token
    );
}
catch (OperationCanceledException)
{
    Debug.Log("취소됨");
}

이렇게 처리할 수 있다.

또는 UniTask에서는:

await UniTask.Delay(
    5000,
    cancellationToken: token
);

처럼 취소 가능한 작업을 구성하고 상위에서 cancellation을 관리하는 방식도 많이 사용한다.


여러 작업을 동시에 실행

UniTask의 또 다른 핵심 기능이다.

예를 들어:

await LoadPlayerAsync();
await LoadEnemyAsync();
await LoadMapAsync();

이렇게 하면 순차 실행이다.

Player
 ↓
Enemy
 ↓
Map

각각 2초라면 대략 6초가 걸린다.

반면 동시에 실행하려면:

await UniTask.WhenAll(
    LoadPlayerAsync(),
    LoadEnemyAsync(),
    LoadMapAsync()
);

이렇게 할 수 있다.

Player ────────┐
Enemy  ────────┼──→ 완료
Map    ────────┘

가장 오래 걸리는 작업만큼 시간이 걸린다.


결과를 받을 수도 있다

async UniTask<int> GetPlayerLevelAsync()
{
    await UniTask.Delay(1000);

    return 50;
}

사용:

int level = await GetPlayerLevelAsync();

Debug.Log(level);

즉 일반적인 C#의 Task<T>처럼 사용할 수 있다.


여러 결과 받기

var (player, enemy, map) = await UniTask.WhenAll(
    LoadPlayerAsync(),
    LoadEnemyAsync(),
    LoadMapAsync()
);

각각 결과를 반환하도록 만들면 된다.

UniTask<Player> LoadPlayerAsync()
UniTask<Enemy> LoadEnemyAsync()
UniTask<Map> LoadMapAsync()

그러면:

var (player, enemy, map) = await UniTask.WhenAll(
    LoadPlayerAsync(),
    LoadEnemyAsync(),
    LoadMapAsync()
);

이런 형태가 가능하다.


UniTask.WaitUntil

특정 조건이 만족될 때까지 기다릴 수도 있다.

await UniTask.WaitUntil(() => player.IsReady);

즉:

player.IsReady == false
        ↓
계속 대기
        ↓
player.IsReady == true
        ↓
다음 코드 실행

예를 들어:

await UniTask.WaitUntil(() => GameManager.Instance.IsInitialized);

StartGame();

게임 초기화 시스템에서 상당히 유용하다.


WaitWhile

반대로 조건이 true인 동안 기다린다.

await UniTask.WaitWhile(() => player.IsLoading);

조건 + CancellationToken

실전에서는 이런 형태가 좋다.

async UniTask WaitForGameAsync(CancellationToken token)
{
    await UniTask.WaitUntil(
        () => GameManager.Instance.IsReady,
        cancellationToken: token
    );
}

UniTask를 이용한 반복

Coroutine에서는:

IEnumerator UpdateLoop()
{
    while (true)
    {
        yield return null;

        UpdateSomething();
    }
}

UniTask에서는:

async UniTask UpdateLoopAsync()
{
    while (true)
    {
        await UniTask.Yield();

        UpdateSomething();
    }
}

다만 이것은 반드시 Cancellation을 고려해야 한다.

async UniTask UpdateLoopAsync(CancellationToken token)
{
    while (!token.IsCancellationRequested)
    {
        await UniTask.Yield(token);

        UpdateSomething();
    }
}

또는:

async UniTask UpdateLoopAsync(CancellationToken token)
{
    while (true)
    {
        await UniTask.Yield(token);

        UpdateSomething();
    }
}

UniTask를 Coroutine처럼 사용할 수도 있다

UniTask에는 Unity의 PlayerLoop와 연결된 다양한 대기 방법이 있다.

await UniTask.NextFrame();
await UniTask.DelayFrame(10);
await UniTask.WaitForSeconds(1f);
await UniTask.WaitUntil(...);
await UniTask.WaitWhile(...);

따라서 Coroutine에서 하던 작업 대부분을 async/await 형태로 표현할 수 있다.


Coroutine과 UniTask의 차이

핵심만 비교하면 이렇다.

CoroutineUniTask
문법yieldasync/await
반환값제한적가능
예외 처리불편try/catch
Cancellation직접 구현CancellationToken
여러 작업 조합불편WhenAll 등
C# 비동기 코드와 결합제한적좋음
Unity API와 결합매우 좋음매우 좋음
코드 흐름분산되기 쉬움일반적인 코드처럼 표현 가능

중요한 점: UniTask는 “백그라운드 스레드”가 아니다

이 부분을 잘못 이해하면 안 된다.

await UniTask.Delay(1000);

한다고 CPU 작업이 별도의 스레드에서 실행되는 것이 아니다.

UniTask의 기본적인 Unity 작업은 Unity PlayerLoop를 기반으로 실행된다.

따라서:

await UniTask.Delay(1000);

이것과

await Task.Run(() => HeavyCalculation());

은 완전히 다른 개념이다.


CPU 작업을 별도 스레드에서 실행

정말 CPU 작업을 다른 스레드에서 처리하고 싶다면:

await UniTask.RunOnThreadPool(() =>
{
    HeavyCalculation();
});

같은 방법을 사용할 수 있다.

예:

async UniTask CalculateAsync()
{
    int result = await UniTask.RunOnThreadPool(() =>
    {
        return HeavyCalculation();
    });

    Debug.Log(result);
}

주의해야 할 것은 Unity API 대부분은 메인 스레드에서 호출해야 한다는 것이다.

따라서:

await UniTask.RunOnThreadPool(() =>
{
    Instantiate(prefab); // 위험
});

이런 코드를 무작정 작성하면 안 된다.


실전 구조

게임 개발에서는 보통 이런 구조로 가져가면 깔끔하다.

public class GameLoader : MonoBehaviour
{
    async UniTaskVoid Start()
    {
        await InitializeAsync();
    }

    private async UniTask InitializeAsync()
    {
        await LoadDataAsync();
        await LoadAssetsAsync();
        await InitializeSystemsAsync();

        StartGame();
    }

    private async UniTask LoadDataAsync()
    {
        await UniTask.Delay(500);
    }

    private async UniTask LoadAssetsAsync()
    {
        await UniTask.Delay(1000);
    }

    private async UniTask InitializeSystemsAsync()
    {
        await UniTask.Delay(500);
    }

    private void StartGame()
    {
        Debug.Log("Game Start");
    }
}

흐름이 매우 명확하다.

Initialize
   ↓
Load Data
   ↓
Load Assets
   ↓
Initialize Systems
   ↓
Start Game

Coroutine을 여러 개 연결하는 것보다 구조를 읽기가 쉽다.


개인적으로 가장 중요한 UniTask 사용 원칙

Unity 프로젝트에서 UniTask를 쓴다면 다음 정도를 기준으로 잡으면 된다.

비동기 메서드
        ↓
    UniTask 반환
        ↓
    필요한 곳에서 await
        ↓
기다릴 필요 없으면 Forget()
        ↓
Unity 객체 생명주기
        ↓
CancellationToken 연결

특히 이것이 핵심이다.

async UniTask LoadAsync(CancellationToken token)
{
    await SomethingAsync(token);
}

그리고 MonoBehaviour라면:

var token = this.GetCancellationTokenOnDestroy();

를 연결한다.

결국 UniTask의 핵심은

async/await + Unity PlayerLoop + CancellationToken + 비동기 Unity API + 여러 비동기 작업 조합

을 하나의 코드 구조로 묶는 데 있다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다