최근에 게임을 협업하면서 Unitask를 통해 비동기 로직을 구현하는 상황을 보았다. 평소에 .NET 기반 Task을 통해 비동기 작업을 처리했는데, 협업을 통해 Unitask를 사용해보니 매력적인 라이브러리라고 생각했습니다. 그래서 오늘은 비동기 프로그래밍을 쉽게 처리해주는 Unitask에 대해 알아보도록 하겠습니다.
Unitask 등장
① Unity 코루틴(Coroutine)의 한계
코루틴은 IEnumerator를 사용해 중단과 실행을 반복하는 구조입니다. 하지만 이러한 구조는 복잡한 비동기 시스템에서 코드가 난잡해지고 가독성이 떨어집니다. 또한, 대량의 코루틴을 사용하면 많은 메모리 할당과 GC 문제로 성능 문제가 있습니다.
GC가 자주 발생하는 이유: new WaitForSeconds 처럼 new 생성자가 반복적으로 호출되기 때문
② async/await 도입
C# 5.0 이후 async/await가 도입되면서 비동기 로직을 작업하기 용이해졌습니다.
③ Unitask의 등장
Unitask는 일본 개발자들이 개발한 오픈 소스 프로젝트입니다. Unity의 코루틴이 가진 단점들을 보완하였습니다.
- Unity에 최적화된 비동기 처리: async/await 비동기를 처리합니다. 여기에 WaitForSeconds와 같은 yieldInstruction에 대한 기능을 내포하여 지원하기 때문에 Unity에 친화적입니다.
- 결과값 반환 가능: 코루틴에서 값을 직접적으로 반환하지 못하는 기능을 Unitask에선 지원합니다.
- 예외 처리 용이: 코루틴에서 불가능한 try-catch 기능을 제공합니다.
💡 Unitask는 병렬 프로그래밍인가?
기본적으로 Unitask은 코루틴과 마찬가지로 메인 스레드에서 동작합니다. 그래서 일반적인 호출은 async/await을 사용한 비동기적 처리를 지원할 뿐, 병렬성(Parallelism)은 없습니다.
💡 그렇다면 병렬적으로 처리하려면 어떻게 해야 하나요?
Task.Run 혹은 Unitask.Run 함수를 사용하면 됩니다. 혹은 Unity의 Job System에 대해 알아보시면 좋습니다.
Unitask 사용 방법
Unitask 설치
https://github.com/Cysharp/UniTask
Unitask를 사용해보니 코드가 훨씬 직관적이고 편리했습니다. 하지만 async/await에 기반한 로직이기 때문에 Task의 흐름을 관리하기 힘들 수 있습니다. 먼저 기본 사용법에 대해 살펴보겠습니다.
① 시간 경과 처리 (Time.timeScale 영향 받음)
// 코루틴
IEnumerator WaitFor1Second()
{
yield return new WaitForSeconds(1.0f);
Debug.Log("1 second later");
}
// UniTask
async UniTaskVoid WaitFor1Second()
{
await UniTask.Delay(TimeSpan.FromSeconds(1.0f));
Debug.Log("1 second later");
}
② 시간 경과 처리 (Time.timeScale 영향 받지 않음)
// 코루틴
IEnumerator WaitFor1Second()
{
yield return new WaitForSecondsRealtime(1.0f);
Debug.Log("1 second later");
}
// UniTask
async UniTaskVoid ExampleUnitTask()
{
await UniTask.Delay(TimeSpan.FromSeconds(1.0f), DelayType.UnscaledDeltaTime);
Debug.Log("1 second later");
}
③ 특정 조건(Condition) 만족
// 코루틴
IEnumerator Wait3Count()
{
yield return new WaitUntil(() => count == 3);
Debug.Log("3 count");
}
// UniTask
async UniTaskVoid Wait3Count()
{
await UniTask.WaitUntil(() => count == 3);
Debug.Log("3 count");
}
④ Web 이미지 불러오기
// 코루틴
IEnumerator WaitGetWebTexture(UnityAction<Texture2D> action)
{
UnityWebRequest request = UnityWebRequestTexture.GetTexture(imagePath);
yield return request.SendWebRequest();
if (request.result == UnityWebRequest.Result.Success)
{
Texture2D texture = DownloadHandlerTexture.GetContent(request);
action?.Invoke(texture);
}
else
{
Debug.Log("Error : " + request.error);
}
}
StartCoroutine(WaitGetWebTexture((texture) =>
{
image.texture = texture;
image.GetComponent<RectTransform>().sizeDelta = new Vector2(texture.width, texture.height);
}));
// Unitask
async UniTask<Texture2D> WaitGetWebTextureAsync()
{
UnityWebRequest request = UnityWebRequestTexture.GetTexture(imagePath);
await request.SendWebRequest();
if (request.result == UnityWebRequest.Result.Success)
{
return DownloadHandlerTexture.GetContent(request);
}
else
{
Debug.Log("Error : " + request.error);
return null;
}
}
async UniTaskVoid GetImageAsync()
{
Texture2D texture = await WaitGetWebTextureAsync();
image.texture = texture;
image.GetComponent<RectTransform>().sizeDelta = new Vector2(texture.width, texture.height);
}
💡 Coroutine과 Unitask의 차이점
Coroutine은 직접 값을 반환할 수 없어 Action을 통해 처리합니다. (혹은 내부에서 처리) Unitask은 Unitask<T> 형태로 반환할 수 있어 반환값을 통해 처리합니다. 따라서 Unitask를 사용하면 코드의 흐름이 간결해집니다.
⑤ 종료 및 취소
// 코루틴
private Coroutine coroutine;
private void Start()
{
coroutine = StartCoroutine(nameof(Wait3Seconds));
}
private void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
StopCoroutine(coroutine);
}
}
IEnumerator Wait3Seconds()
{
yield return new WaitForSeconds(3f);
Debug.Log("3초가 지났습니다.");
}
// Unitask
private CancellationTokenSource source = new();
private void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
source.Cancel();
}
}
async UniTaskVoid Wait3SecondsUniTask()
{
await UniTask.Delay(TimeSpan.FromSeconds(3f), cancellationToken: source.Token);
Debug.Log("3초가 지났습니다.");
}
💡 Token 관련 주의 사항
Unitask는 CancellationToken 이라는 .NET 비동기 작업을 취소를 위한 토큰을 사용합니다. CancellationTokenSource를 통해 토큰을 생성하고 취소 요청을 할 수 있습니다.
이때 주의해야 할 점은 CancellationToken은 Unity와 연동되지 않기 때문에 Object가 파괴돼도 Source가 Dispose 되지 않습니다. 그래서 Unity Event 함수에 Dispose를 처리하는 로직이 필요합니다.
private void OnDestroy()
{
source.Cancle();
source.Dispose();
}
private void OnEnable()
{
if (source != null)
source.Dispose();
source = new();
}
private void OnDisable()
{
source.Dispose();
}
// 오브젝트 파괴의 경우 간단하게 처리 가능
private async UniTaskVoid Wait3Second()
{
await UniTask.Delay(TimeSpan.FromSeconds(3), cancellationToken: this.GetCancellationTokenOnDestroy());
Debug.Log("3초가 지났다.");
}
⑥ Unitask Tracker을 통한 Task 추적
UniTask Tracker를 사용하여 현재 실행 중인 UniTask를 확인할 수 있습니다.
⑦ DoTween 호환
Unitask는 DoTween과 호환이 됩니다. 그러기 위해선 아래와 같은 처리가 필요합니다.
[Project Settings] - [Player] - [Other Settings] - [Scripting Define Symbols]
위 경로에 UNITASK_DOTWEEN_SUPPORT을 추가합니다.
DoTween 예시 코드
private async UniTaskVoid WaitMove()
{
await transform.DOMove(new vector3(0, 3, 0), 3.0f);
Debug.Log("XYZ(0, 3, 0) 위치로 이동 완료");
}
정리
코루틴만 사용하던 개발자들에게는 Unitask가 익숙하지 않을 수 있습니다. 하지만 Task와 async/await에 대해 공부하고 싶거나, 혹은 이미 알고 계신 분들이라면 Unitask을 이용하여 Unity 비동기 처리를 하는 것이 좋다고 생각합니다.
참고 자료
https://www.youtube.com/watch?v=j3hpuVB2cLk
'유니티(Unity) > 이론 정리' 카테고리의 다른 글
[Unity] Mono & IL2CPP 컴파일 방식의 차이 (0) | 2024.11.18 |
---|---|
[Unity] .NET이란? (4) | 2024.11.17 |
코루틴(Coroutine)의 개념과 동작 원리 (4) | 2024.11.13 |
[Unity Korea] 객체 지향 설계 원칙 (SOLID) (0) | 2024.11.12 |
클래스 이름 짓기 [ 2편 ] + MVC 패턴 (2) | 2024.11.11 |