개요
Unity 프로젝트에서 자주 사용되는 라이브러리인 UniTask에 대해 간단히 소개하고, Coroutine과 비교하여 왜 가능하면 UniTask를 사용하는 것이 좋은지 설명하려고 한다
우아한 스터디를 진행하면서 새로운 기술을 배우는 것도 중요하지만, 사용하는 기술을 제대로 이해하고 활용해야 부작용 없이 최상의 효과를 낼 수 있다고 생각했고, 그래서 이 글을 쓰게 되었다
이 글은 다음의 흐름으로 작성될 예정이다
- Coroutine과 UniTask 기술 소개
- 공통점과 차이점 비교
- UniTask를 사용해야하는 이유
- 검증하기 위한 테스트 코드 작성
- 결론
내용
1. Coroutine과 UniTask 기술 소개
Coroutine
Unity Documentation - Splitting tasks across frames
코루틴(Coroutine)은 Unity에서 제공하는 기능으로, 작업을 여러 프레임에 걸쳐 수행되도록 구현할 수 있는 메서드이다
이를 통해 한 번의 함수 호출로도 시간에 걸쳐 진행되는 애니메이션이나 이벤트 시퀀스를 구현할 수 있다
주요 특징은 다음과 같다
프레임 분할 실행
작업을 여러 프레임에 걸쳐 나누어 실행할 수 있다
한 프레임에서 과도한 연산을 피하여 게임의 성능 저하를 방지할 수 있고, 긴 작업도 게임 플레이를 방해하지 않고 처리할 수 있다.
메인 스레드에서 실행
모든 작업은 메인 스레드에서 실행된다. Multithreading이 아니라는 의미
스레드 관리나 동기화에 대한 복잡성을 줄일 수 있지만, 메인 스레드를 블로킹하는 작업을 수행하면 게임 전체가 멈출 수 있으므로 주의해야 한다
처리의 한계
코루틴은 실제로 단일 스레드에서 순차적으로 작업을 수행하므로, 동시에 여러 작업을 처리하는 데 한계가 있다
UniTask
유니태스크(UniTask)는 Cygames에서 개발한 라이브러리고, Unity 환경에서 C#의 async/await를 활용한 비동기 프로그래밍을 활용할 수 있게 해주는 라이브러리다
더욱 효율적이고 직관적인 비동기 코드를 작성할 수 있으며, 기존의 코루틴 방식에서 발생하는 여러 제약을 극복할 수 있다
주요 특징은 다음과 같다
async/await 지원
C#의 표준 비동기 패턴을 사용하여 코드의 가독성과 유지 보수성을 높일 수 있다
비동기 로직을 직관적으로 작성할 수 있어 코드의 흐름을 이해하기 쉽다
Multithreading 활용 가능
필요에 따라 멀티스레딩을 활용한 작업 분산이 가능하여 성능을 극대화할 수 있다
Zero-Allocation
UniTask는 구조체 기반으로 구현되어 메모리 할당을 최소화하였으며, 이는 GC 부담을 크게 줄여준다
PlayerLoop 기반 작업 스케줄링
UniTask는 Unity의 PlayerLoop 기반 작업 스케줄링을 활용하여 모든 코루틴 기능을 대체할 수 있다
2. 공통점과 차이점 비교
공통점
주요 공통점은 다음과 같다
작업 분할 실행
Coroutine과 UniTask는 모두 작업을 여러 프레임에 걸쳐 나누어 실행할 수 있다
이를 통해 한 프레임에서 과도한 연산을 피하고, 게임의 성능 저하를 방지할 수 있다
메인 스레드 사용
Coroutine과 UniTask는 기본적으로 Unity의 메인 스레드에서 실행된다
Multithreading을 진행할 경우 메인 스레드가 아닌 곳에서는 Unity API를 호출해서는 안된다
차이점
주요 차이점은 가독성을 위해 표로 작성하였다
특징 | Coroutine | UniTask |
성능 최적화 | 메모리 할당이 필요해 GC 부하가 증가, 이에 따라 성능 최적화에 한계가 있다 |
구조체 기반 구현, 메모리 할당 최소화 여러 최적화 기법 적용으로 성능 향상 |
예외 처리 | 예외 처리가 제한적 | async/await의 표준 예외 처리 방식을 지원 |
멀티스레딩 지원 | 멀티스레딩을 지원하지 않음 | 멀티스레딩 지원 |
제네릭 반환 타입 지원 | IEnumerator를 반환해야 하므로 사용 불가 | UniTask<T>와 같은 제네릭 타입을 지원 |
유연성 | 기본적인 프레임 분할 실행과 작업 일시 중단 제공 | 타임아웃, 취소 토큰, 비동기 LINQ 등 다양한 기능 제공 복잡한 비동기 시나리오를 지원 |
3. UniTask를 사용해야하는 이유
성능 최적화
Coroutine에서의 메모리 할당 이유
C#에서 반환값이 있는 함수를 호출할 때, 그 값을 어딘가에 명시적으로 할당하지 않더라도 반환값이 생성된다
코루틴은 시작할 때마다 IEnumerator 객체가 새로 생성되며, 이는 Reference Type이기 때문에 인스턴스 데이터는 힙에 할당된다
private IEnumerator MyCoroutine()
{
yield return new WaitForSeconds(1);
// 작업 수행
}
private void PlayCoroutine()
{
StartCoroutine(MyCoroutine());
}
위 예제에서 PlayCoroutine()을 호출할 때마다 새로운 IEnumerator 객체가 생성되며, 이로 인해 메모리 할당이 빈번하게 발생한다
실무에서의 영향
실제 프로젝트에서는 다음과 같은 상황에서 대량의 Coroutine이 동시에 사용될 수 있다
- 캐릭터의 이동, 공격, 스킬 사용
- 대규모 스폰 시스템
- 파티클 시스템 관리
이러한 상황에서는 각 Coroutine마다 메모리 할당이 급증하게 되며, 그 결과 다음과 같은 문제가 발생할 수 있다
- GC 부담 증가: 빈번한 메모리 할당은 Unity의 GC가 더 자주 실행되도록 만든다
- 프레임 드랍: GC가 실행되는 동안 메인 스레드가 일시적으로 멈추어 프레임 드롭이 발생할 수 있다
- 전체적인 게임 퍼포먼스 저하: 지속적인 GC 실행과 메모리 할당은 게임의 전반적인 성능 저하를 유발
- 메모리 파편화: 반복적인 메모리 할당과 해제로 인해 힙 Memory Fragmentation 발생
- 모바일 플랫폼 이슈: 메모리 자원이 제한된 모바일 플랫폼에서는 빈번한 메모리 할당이 게임의 안정성을 해치고, 심지어 게임이 강제로 종료되는 현상을 유발할 수 있다
자세한 기술적 이야기는 Unity Documentation - Managed memory와 inven - 이거 안하면 게임 터짐, 메모리 관리 링크를 남긴다
struct기반으로 구현된 UniTask
UniTask의 경우 호출시 구조체가 생성되는 형태이기 때문에 메인 스레드의 스택 프레임에 객체가 저장되며, 작업이 완료되고 반환되면 스택에서 메모리가 정리된다
따라서, 대부분의 작업에서 메모리 할당이 발생하지 않아 GC 실행이 최소화 된다
커스텀 AsyncMethodBuilder를 사용
C#에서 async/await 패턴을 사용하여 하나의 비동기 메서드가 실행될 때, 해당 메서드는 하나의 상태 머신으로 변환되고, 하나의 Task 객체가 생성된다. 이 과정에서 AsyncMethodBuilder는 비동기 작업의 상태를 관리하고 상태 머신을 제어하는 역할을 한다
상태 머신은 비동기 메서드의 내부 상태를 추적하고, await 구문에서 중단된 메서드를 다시 재개하는 역할을 한다. 이 상태를 관리하기 위해 Task가 사용되며, Task는 비동기 작업의 결과와 실행 상태를 외부로 노출하는 객체이다. 즉, Task는 상태 머신의 비동기 작업 결과를 나타내는 역할을 하며, 상태 머신은 비동기 작업의 진행 상황을 관리한다
기본적으로 상태 머신은 Task 객체를 통해 비동기 작업의 상태와 결과를 관리하는데, Task 객체는 참조 타입이므로 힙 메모리에 할당된다. 이로 인해 비동기 작업이 여러 차례 진행될 때마다 힙 메모리 할당과 관련된 CPU 오버헤드가 발생할 수 있다
UniTask는 이러한 문제를 해결하기 위해 AsyncMethodBuilder를 다음과 같은 핵심 아이디어를 통해 최적화하였다
- Task 객체 제거: Task 객체의 힙 메모리 할당을 제거하고, 값 타입 기반으로 즉시 반환이 가능하도록 변경
- 상태 머신의 구조체화: 상태 머신은 클래스 기반으로 힙에 할당되지만, UniTask에서는 struct로 상태 머신을 커스텀하여 Boxing 문제를 해결하고 추가적인 메모리 할당을 방지
- MoveNext 대리자 할당 최소화: 비동기 작업 재개 시 사용하는 MoveNext 대리자 할당을 줄이기 위해 필요할 때만 대리자를 생성하여 불필요한 힙 할당을 방지
- 풀링(Pooling): 상태 머신이나 비동기 작업에 필요한 객체를 풀링하여 재사용
- Boxing 방지: 상태 머신의 실행 흐름을 제어하는 Runner를 제약 조건을 이용, 타입을 지정하여 박싱 없이 상태를 관리한다
내용은 위와 같고, 추가적으로 시간이 된다면 별도의 포스팅을 통해 구체적으로 어떤 식으로 구현이 되었는지 분석하는 시간을 가지고 싶다
예외 처리
Coroutine에서의 예외 처리
코루틴 내부에서 예외가 발생하면, 그 예외를 코루틴 외부에서 잡을 수 없다. 즉, 메서드 콜러는 이를 처리할 수 없으며 내부에서만 예외 처리가 가능하다는 의미이다
private IEnumerator CoroutineWithException()
{
yield return new WaitForSeconds(1);
// 예외 발생
throw new Exception("Coroutine 내부에서 예외 발생!");
}
private void Start()
{
try
{
StartCoroutine(CoroutineWithException());
}
catch (Exception ex)
{
// 이 블록은 실행되지 않습니다.
Debug.Log("외부에서 예외 처리: " + ex.Message);
}
}
이러한 이유로 코루틴의 예외 처리는 제한적이다
UniTask에서의 예외 처리
UniTask에서는 async/await를 사용하기 때문에 호출자 측에서 예외를 잡아 처리할 수 있다
private async UniTask UniTaskWithException()
{
await UniTask.Delay(1000);
// 예외 발생
throw new Exception("UniTask 내부에서 예외 발생!");
}
private async void Start()
{
try
{
await UniTaskWithException();
}
catch (Exception ex)
{
// 이 블록에서 예외를 처리할 수 있습니다.
Debug.Log("외부에서 예외 처리: " + ex.Message);
}
}
멀티스레딩 지원
Unity의 Coroutine은 멀티스레딩을 지원하지 않는다
코루틴은 Unity 메인 스레드에서만 실행되기 때문에 실제로는 단일 스레드 기반에서 작동하여 멀티스레딩과 같은 병렬 처리 기능을 제공하지 않는다
실제로 Unity Manual에서는 멀티스레딩 코드를 사용하고 싶으면 C# Job System을 사용하라고 권장하고 있다
UniTask는 멀티스레딩을 지원한다
async/await 패턴을 기반으로 작성된 UniTask는 스레드 풀을 활용하여 작업을 병렬로 처리할 수 있다. 이를 통해 메인 스레드에서 실행되지 않는 백그라운드 작업이나 비동기 작업을 처리하는 것이 가능하다
private async UniTask TestTask()
{
// 스레드 풀로 전환하여 백그라운드에서 실행
await UniTask.SwitchToThreadPool();
// 단순한 계산 작업 (예: 1부터 1000까지의 합 계산)
int result = 0;
for (int i = 1; i <= 1000; i++)
{
result += i;
}
// 메인 스레드로 전환
await UniTask.SwitchToMainThread();
// 메인 스레드에서 결과를 출력 (예: UI 업데이트)
Debug.Log("계산 완료. 최종 결과: " + result);
}
private void Start()
{
TestTask().Forget(); // UniTask는 await을 사용하지 않는 경우 Forget을 사용해 실행
}
제네릭 반환 타입 지원
코루틴은 IEnumerator를 반환해야 하므로, 제네릭 타입을 직접 반환하는 방식으로 사용할 수 없다
C# 언어 차원에서 IEnumerator<T>를 지원하긴 하지만, Unity의 코루틴 시스템은 제네릭을 지원하지 않도록 설계되어 있다
이는 MonoBehaviour 클래스 코드를 참고하면 알 수 있다. 실제로 코드를 살펴보면, IEnumerator<T>를 인자로 받는 오버로드가 구현되어 있지 않는데, 이는 Unity의 코루틴 시스템이 제네릭 타입을 처리할 필요성을 고려하지 않은 것 같다
UniTask는 특별한 처리가 필요 없이, 단순하게 제네릭을 사용하면 된다
// 제네릭 타입을 반환하는 UniTask
private async UniTask<int> TestUniTask()
{
return 2024; // 제네릭 값 반환
}
private async void BeginTask()
{
int result = await TestUniTask();
Debug.Log("결과: " + result); // UniTask<T>를 통해 제네릭 값 반환
}
private void Start()
{
// BeginTask 실행
BeginTask();
}
유연성
코루틴의 유연성은 기본적으로 프레임 분할과 간단한 대기 작업에 한정된다
private IEnumerator TestCoroutine()
{
// 1초 대기
yield return new WaitForSeconds(1);
Debug.Log("1초 후 실행");
// 다음 프레임이 끝날 때까지 대기
yield return new WaitForEndOfFrame();
Debug.Log("End of Frame 이후 실행");
// 고정된 물리 시간 간격 동안 대기
yield return new WaitForFixedUpdate();
Debug.Log("FixedUpdate 후 실행");
// 실시간 경과 시간을 기준으로 대기 (게임 일시 정지와 무관)
yield return new WaitForSecondsRealtime(2);
Debug.Log("2초의 실시간 경과 후 실행");
// 특정 조건을 만족할 때까지 대기 (조건식이 true가 될 때까지)
yield return new WaitUntil(() => Time.time > 5);
Debug.Log("Time.time이 5초를 초과했을 때 실행");
// 특정 조건이 false가 될 때까지 대기
yield return new WaitWhile(() => Time.time < 10);
Debug.Log("Time.time이 10초에 도달할 때 실행");
}
UniTask는 단순한 비동기 처리 외에도, 다음과 같은 다양한 기능을 제공한다
private async UniTask TestUniTask(CancellationToken cancellationToken)
{
// 1. 타임아웃 사용 예제 (5초 작업에 대해 2초 타임아웃 설정)
try
{
Debug.Log("타임아웃 작업 시작...");
await UniTask.Delay(5000).Timeout(TimeSpan.FromSeconds(2));
Debug.Log("타임아웃 내 작업 완료");
}
catch (TimeoutException)
{
Debug.Log("타임아웃 발생 - 작업 중단");
}
// 2. 취소 토큰을 통한 작업 취소 예제
try
{
Debug.Log("취소 토큰 사용한 작업 시작...");
await UniTask.Delay(5000, cancellationToken: cancellationToken);
Debug.Log("취소되지 않고 작업 완료");
}
catch (OperationCanceledException)
{
Debug.Log("작업이 취소되었습니다");
}
}
private void Start()
{
// 취소 토큰 예시 (2초 후 작업 취소)
var cts = new CancellationTokenSource();
cts.CancelAfter(TimeSpan.FromSeconds(2));
// 타임아웃과 취소 토큰을 사용하는 작업 실행
TestUniTask(cts.Token).Forget();
}
이 외에도 비동기 LinQ를 지원하거나, WhenAll, WhenAny 등의 다양한 기능을 제공하는데, 사용법은 github를 참고하자
4. 검증을 위한 테스트 코드 작성
여러 유틸 기능이 제외하고, 핵심인 메모리 사용과 처리 속도 차이를 검증해야 한다고 생각한다
속도
속도 차이는 간단한 예제 코드를 작성하여 비교하였다
using System.Collections;
using System.Diagnostics;
using Cysharp.Threading.Tasks;
using UnityEngine;
using Debug = UnityEngine.Debug;
public class PerformanceTest : MonoBehaviour
{
private const long TestIterations = 5000000; // 테스트 반복 횟수
private async void Start()
{
Debug.Log("테스트 시작");
// UniTask 성능 측정
Stopwatch uniTaskStopwatch = new Stopwatch();
uniTaskStopwatch.Start();
await RunUniTaskTest(); // UniTask 완료를 기다림
uniTaskStopwatch.Stop();
Debug.Log($"UniTask 테스트 완료: {uniTaskStopwatch.ElapsedMilliseconds}ms");
// Coroutine 성능 측정 (UniTask 완료 후에 실행)
Stopwatch coroutineStopwatch = new Stopwatch();
coroutineStopwatch.Start();
StartCoroutine(CoroutineEndTest(coroutineStopwatch));
}
// 소수 찾기 함수
private bool IsPrime(long number)
{
if (number < 2) return false;
for (long i = 2; i * i <= number; i++)
{
if (number % i == 0) return false;
}
return true;
}
// UniTask 테스트
private async UniTask RunUniTaskTest()
{
long primeCount = 0;
for (long i = 2; i < TestIterations; i++)
{
if (IsPrime(i))
primeCount++; // 소수 찾기
}
Debug.Log($"UniTask 소수 개수: {primeCount}");
}
// Coroutine 테스트
private IEnumerator RunCoroutineTest()
{
long primeCount = 0;
for (long i = 2; i < TestIterations; i++)
{
if (IsPrime(i))
primeCount++; // 소수 찾기
}
Debug.Log($"Coroutine 소수 개수: {primeCount}");
yield return null;
}
// Coroutine 완료 시점에서 시간 측정 종료
private IEnumerator CoroutineEndTest(Stopwatch stopwatch)
{
yield return RunCoroutineTest(); // RunCoroutineTest()를 한 번만 호출
stopwatch.Stop();
Debug.Log($"Coroutine 테스트 완료: {stopwatch.ElapsedMilliseconds}ms");
}
}
혹시 캐싱 때문에 실험에 영향을 줄까봐 번갈아가면서 먼저 실행을 해보았고, 두 결과 모두 UniTask가 빠른 것으로 나왔다
메모리
메모리 사용은 GC.GetTotalMemory 메서드를 이용하여 테스트 하였다
using System;
using System.Collections;
using Cysharp.Threading.Tasks;
using UnityEngine;
using Debug = UnityEngine.Debug;
public class PerformanceTest : MonoBehaviour
{
private const int TestIterations = 10000000; // 테스트 반복 횟수
private long uniTaskResult = 0;
private long coroutineResult = 0;
private async void Start()
{
Debug.Log("테스트 시작");
// 시작할 테스트의 주석만 해제하기
// RunCoroutineTest(); // Coroutine 테스트 실행
// RunUniTaskTest(); // UniTask 테스트 실행
}
// UniTask를 반복 호출
private void RunUniTaskTest()
{
Debug.Log("RunUniTaskTest");
long initialMemory = GC.GetTotalMemory(true);
for (int i = 0; i < TestIterations; i++)
{
UniTaskDummy().Forget();
}
long finalMemory = GC.GetTotalMemory(true);
Debug.Log($"UniTask 메모리 사용량: {finalMemory - initialMemory:N0} bytes");
}
private async UniTaskVoid UniTaskDummy()
{
uniTaskResult += 1; // 단순 연산
}
// Coroutine을 반복 호출
private void RunCoroutineTest()
{
Debug.Log("RunCoroutineTest");
long initialMemory = GC.GetTotalMemory(true);
for (int i = 0; i < TestIterations; i++)
{
StartCoroutine(CoroutineDummy()); // StartCoroutine을 TestIterations만큼 호출
}
long finalMemory = GC.GetTotalMemory(true);
Debug.Log($"Coroutine 메모리 사용량: {finalMemory - initialMemory:N0} bytes");
}
// 단순 Coroutine 더미
private IEnumerator CoroutineDummy()
{
coroutineResult += 1; // 단순 연산
yield return null; // 실제 작업은 없음
}
}
압도적인 차이를 확인할 수 있었다
핵심은 '메모리 사용량이 더많다' 라는 결과를 눈으로 확인하기 위함이어서 굳이 프로파일러까지 사용하지는 않았다
또한, GC.GetTotalMemory 메서드의 경우 파편화되어있는 공간까지 return해주기 때문에 실무에서는 더 큰 차이가 있으리라 생각된다
5. 결론
가능하다면 무조건 UniTask를 사용하자!
이렇게 성능 차이가 나고, 코루틴에 있는 기능은 모두 존재하며, 추가 유틸 기능 까지 많은데 사용을 안할 이유가 있을까?
마무리
처음 스타트업에 입사했을 때 가장 먼저 한 일은 UniRx와 UniTask를 도입한 것이었는데, 이제 돌이켜보니 정말 잘한 선택이었다고 생각한다. 도입을 흔쾌히 지지해주신 사수님께도 감사드린다. 덕분에 Coroutine의 불편함을 겪지 않고 더욱 효율적인 코드를 짤 수 있었다
이번 글을 쓰면서 기술 부채를 줄이고, UniTask를 왜 사용해야 하는지에 대해 깊이 탐구할 수 있어 좋았다
이제 관련 질문이 들어오더라도 자신 있게 답변할 수 있을 것 같고, 근거를 바탕으로 UniTask의 도입을 더 적극적으로 권장할 수 있게 되었다
주요 레퍼런스는 다음과 같고, 사용하기 전 꼭 읽어보길 권한다
UniTask Github - Provides an efficient allocation free async/await integration for Unity
UniTask v2 — Zero Allocation async/await for Unity, with Asynchronous LINQ
'Unity' 카테고리의 다른 글
유니티 시스템 프로그래밍 Pt.1 - 7~8 주차 (3) | 2024.11.08 |
---|---|
유니티 시스템 프로그래밍 Pt.1 - 4주차 (0) | 2024.09.29 |
유니티 시스템 프로그래밍 Pt.1 - 3주차 (2) | 2024.09.16 |
유니티 시스템 프로그래밍 Pt.1 - 2주차 (0) | 2024.09.16 |
유니티 시스템 프로그래밍 Pt.1 - 1주차 (4) | 2024.09.11 |