Task와 await를 사용하던 도중 문득
- Task.Run은 스레드를 새로 만드는 것인지
- await가 붙으면 정확히 뭐가 일어나는 건지
- Task 대신 UniTask를 쓰는게 좋다는데, 뭐가 다른지
등이 궁금해져서 C#의 Task가 실제로 어떻게 작동하는지, 그리고 왜 Unity에서는 UniTask가 더 적합한지 원리를 파헤쳐보려고 한다.
Task는 간단히 말하면 "미래에 완료될 작업을 표현하는 객체"이다.
C#에서는 비동기 실행을 위해 두 축을 사용한다.
- async / await (언어 문법)
- Task (비동기 작업 컨테이너)
Task는 개발자가 따로 스레드를 관리할 필요 없이 .NET 런타임이 적절한 시점에 스레드 풀에서 작업을 꺼내 실행하도록 설계되어 있다.
대표적인 Task 기반 비동기는 다음과 같다.
ex)
그런데 여기서 첫 번째 의문이 발생한다.
Task.Run은 스레드를 새로 만드는 걸까?
Task는 실제로 스레드를 직접 만들지 않는다.
대신 ThreadPool이라는 .NET 공용 스레드 저장소에 작업을 등록한다.
즉, 구조는 다음과 같다.
Task.Run -> ThreadPool에 작업 요청 -> 대기 중인 스레드가 그 일을 처리
왜 이렇게 만들었을까? 이유는 당연하게도 성능 때문이다.
스레드를 직접 생성하는 비용이 매우 크고, 이미 만들어진 스레드를 재사용하면 효율적이기 때문이다.
그래서 Task는 CPU-bound 연산(압축, 암호화, 경로 탐색, 수학 계산 등)에 매우 적합하다.
그런데 await를 붙이면 무슨 일이 일어날까?
C# 컴파일러는 async 메서드에 대해 다음 두 가지 일을 자동으로 처리한다.
1) 메서드를 '상태 머신'으로 변환
코드의 흐름이 단계별(state)로 나뉘고, await 지점마다 "중단 -> 다시 재개"가 가능하도록 바뀐다.
2) await 이후 실행할 코드를 Task의 continuation(후속 작업)으로 등록
즉,
await SomethingAsync();
이 continuation은 어떤 스레드에서 실행될까?
바로 이 부분에서 Unity 환경과 일반 .NET 환경이 갈린다.
Task는 아주 강력한 시스템이지만, Unity에서는 단점이 명확하게 존재한다.
<1. await 이후 자동으로 Unity 메인 스레드로 돌아오지 않는다>
WPF나 WinForms 같은 UI 프레임워크는 SynchronizationContext가 있어서 await 이후 UI 스레드로 자동 복귀하지만,
Unity는 SynchronizationContext가 없다.
그래서 다음과 같은 코드가 문제가 생긴다.
await Task.Delay(1000); text.text = "Hello" // 메인 스레드가 아니라 오류 발생 가능
<2. GC Alloc이 크다>
Task는 클래스 기반이며 내부에 여러 관리용 객체를 할당한다.
특히 모바일에서는 이 GC가 눈에 띌 정도이다.
<3. Delay. Wait 같은 기능이 프레임 기반이 아니다>
await Task.Delay(1000); // 실제로 OS 타이머 기반
Unity의 코루틴처럼 프레임과 동기화되지 않는다.
연출이나 애니메이션 타이밍을 맞추는 데 적합하지 않다.
UniTask는 Unity에 최적화된 비동기 처리 시스템이다.
개발자는 async/await 문법 그대로 사용하지만, 내부 동작은 Task와 완전히 다르다.
<1. struct 기반 -> GC Alloc이 거의 없음>
UniTask는 클래스가 아닌 구조체 기반이다.
- Task = class -> 힙 메모리
- UniTask = struct -> 스택 또는 최적화된 경량 메모리
<2. Unity PlayerLoop에 직접 훅킹>
Delay, NextFrame, WaitUntil 등이 모두 Unity 메인 스레드를 기준으로 동작한다.
await UniTask.Delay(1000);
예를 들어 위 코드는 Update 루프와 완전히 동기화되며, 메인 스레드에서 안전하게 재개한다는 의미이다.
<3. await후 강제로 메인 스레드 복귀 가능>
await UniTask.SwitchToMainThread();
이건 Task에서는 불가능한 기능이다. 앞서 말했듯이 Unity는 SynchronizationContext가 없기 때문이다.
<4. 코루틴보다 빠르고, Task보다 가볍다>
- 코루틴보다 GC 적음
- Task보다 핸들·상태 관리가 단순
- PlayerLoop 기반이라 로직 흐름이 자연스러움
마지막으로 어떤 차이가 있는지 표로 정리해보면 다음과 같다.
| 구분 | Task | UniTask |
|---|---|---|
| 구조 | class(참조 타입) | struct(값 타입) |
| GC발생 | 많음 | 매우 적음 |
| await 복귀 스레드 | 불확실(메인 스레드가 아님) | Unity 메인 스레드 |
| Delay 작동 방식 | OS 타이머 기반 | PlayerLoop기반 |
| CPU 연산 | 매우 적합 | 비권장 |
| Unity API 접근 | 위험 | 안전 |
| 목적 | 범용 비동기 시스템 | Unity 특화 비동기 |
Unity에서 비동기를 사용할 때는 이렇게 정리할 수 있다.
CPU연산 -> Task
압축, AI 경로 탐색, JSON 파싱 등 외부 API에 의존하지 않는 순수 계산
Unity 오브젝트와 관련된 모든 작업 -> UniTask
로딩, Addressables, 시각 효과, UI 애니메이션, 코루틴 대체 등
Task는 스레드 중심
UniTask는 프레임 중심
이라는 점이 둘의 가장 큰 차이라고 할 수 있다.
엔진 내부의 동작 방식을 이해하면 단순히 "Task보다 UniTask가 더 빠르다더라" 같은 얕은 정보가 아니라,
왜 그런 선택을 해야 하는지
어떤 상황에서 어떤 방식을 선택해야 하는지
명확하게 판단할 수 있게 되는 것 같다.
C#의 Task는 스레드 풀 기반의 범용적인 비동기 시스템이고, UniTask는 Unity의 PlayerLoop와 메인 스레드를 고려한 최적화된 솔루션이다.
Unity 개발을 하며 비동기 로직을 구현할 때 둘의 원리를 이해하고 구분하여 사용한다면 성능과 구조 두 측면 모두에서 큰 이점을 얻을 수 있을 것 같다.