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의 차이
핵심만 비교하면 이렇다.
| Coroutine | UniTask | |
|---|---|---|
| 문법 | yield | async/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 + 여러 비동기 작업 조합
을 하나의 코드 구조로 묶는 데 있다.