Unity async/await vs 코루틴 내부 원리
1. 개요 — 두 메커니즘의 본질적 차이
Section titled “1. 개요 — 두 메커니즘의 본질적 차이”Unity 개발자라면 StartCoroutine과 async/await 모두 익숙하지만, “왜 이 경우엔 코루틴이고 저 경우엔 async인가?”를 정확히 설명하기는 어렵습니다. 두 메커니즘의 차이는 단순한 문법 선호 문제가 아니라 실행 컨텍스트의 근본적인 차이에서 비롯됩니다.
| 항목 | Unity 코루틴 | C# async/await |
|---|---|---|
| 실행 스케줄러 | Unity Engine PlayerLoop | .NET TaskScheduler + SynchronizationContext |
| 재개 트리거 | Unity yield instruction 처리 단계 | Task 완료 후 continuation 큐 |
| 스레드 보장 | 항상 메인 스레드 | 기본 메인 스레드 (UnitySynchronizationContext) |
| 예외 처리 | yield 주변 try-catch 불가 | try-catch/await 완전 지원 |
| 반환값 | 없음 (IEnumerator) | Task<T> / ValueTask<T> |
| 취소 메커니즘 | 코루틴 참조로 StopCoroutine | CancellationToken |
핵심 요약: 코루틴은 Unity 엔진이 관리하는 게임 루프 안에서 실행되고, async/await는 .NET 런타임이 관리하는 비동기 작업 모델에서 실행됩니다.
2. 내부 동작 원리
Section titled “2. 내부 동작 원리”2.1 Unity 코루틴: IEnumerator + 스케줄러
Section titled “2.1 Unity 코루틴: IEnumerator + 스케줄러”코루틴은 C# 컴파일러가 yield return을 기준으로 자동 생성하는 IEnumerator 상태 머신입니다.
// 작성 코드IEnumerator LoadDataCoroutine(){ yield return new WaitForSeconds(1f); Debug.Log("1초 경과"); yield return null; // 다음 프레임까지 대기 Debug.Log("1프레임 경과");}컴파일러는 이 메서드를 다음과 같은 상태 머신 클래스로 변환합니다(의사 코드):
// 컴파일러 생성 코드 (의사 코드)class LoadDataCoroutine_StateMachine : IEnumerator{ private int _state = 0; private WaitForSeconds _yieldInstruction;
public object Current => _yieldInstruction;
public bool MoveNext() { switch (_state) { case 0: _yieldInstruction = new WaitForSeconds(1f); _state = 1; return true; // Unity에게 "아직 끝나지 않았다"고 알림
case 1: Debug.Log("1초 경과"); _yieldInstruction = null; _state = 2; return true;
case 2: Debug.Log("1프레임 경과"); return false; // 완료 } return false; }}StartCoroutine()은 이 상태 머신 인스턴스를 Unity 내부 코루틴 테이블에 등록합니다. Unity는 매 프레임 PlayerLoop의 각 단계에서 등록된 코루틴을 순회하며 MoveNext()를 호출하고, Current가 반환하는 yield instruction을 해석해 재개 시점을 결정합니다.
PlayerLoop 실행 순서 (단순화)──────────────────────────────────EarlyUpdate↓FixedUpdate (WaitForFixedUpdate 재개)↓Update (null / WaitForEndOfFrame 외 대부분 재개)↓LateUpdate↓Rendering↓WaitForEndOfFrame 재개메모리 관점: 코루틴 상태 머신은 힙에 할당된 클래스 인스턴스입니다. WaitForSeconds도 매번 new로 생성되므로 GC 대상입니다.
// GC 압력 발생 — 매 호출마다 WaitForSeconds 인스턴스 생성IEnumerator BadLoop(){ while (true) { yield return new WaitForSeconds(0.1f); // 매 프레임 힙 할당 DoSomething(); }}
// 캐싱으로 GC 압력 감소private static readonly WaitForSeconds s_Wait01 = new WaitForSeconds(0.1f);
IEnumerator GoodLoop(){ while (true) { yield return s_Wait01; // 재사용 DoSomething(); }}2.2 C# async/await: 상태 머신 + SynchronizationContext
Section titled “2.2 C# async/await: 상태 머신 + SynchronizationContext”async 메서드 역시 컴파일러가 상태 머신으로 변환합니다. 그러나 코루틴과 달리 .NET의 Task 스케줄링 인프라를 활용합니다.
async Task LoadDataAsync(){ await Task.Delay(1000); Debug.Log("1초 경과"); string json = await FetchJsonAsync("https://api.example.com/data"); Debug.Log(json);}컴파일러 변환 의사 코드:
// 컴파일러 생성 구조체/클래스 (IL2CPP에서는 클래스로 heap 할당)struct LoadDataAsync_StateMachine : IAsyncStateMachine{ private int _state; private AsyncTaskMethodBuilder _builder;
public void MoveNext() { switch (_state) { case 0: var delayAwaiter = Task.Delay(1000).GetAwaiter(); if (!delayAwaiter.IsCompleted) { _state = 1; // continuation 등록: 완료되면 MoveNext를 다시 호출 delayAwaiter.OnCompleted(MoveNext_Continuation); return; // 현재 스레드 반환 — 블로킹 없음 } goto case 1;
case 1: Debug.Log("1초 경과"); // ... FetchJsonAsync awaiter 등록 break; } }}await 지점에서 현재 스레드를 즉시 반환하고, awaitable이 완료되면 **continuation(재개 콜백)**을 스케줄링합니다. 이 continuation이 어느 스레드에서 실행될지를 결정하는 것이 SynchronizationContext입니다.
Unity의 SynchronizationContext
Unity는 시작 시 UnitySynchronizationContext를 설치합니다. 이 컨텍스트는 Post된 continuation을 다음 프레임 메인 스레드 Update 단계에서 실행합니다.
스레드 흐름 (기본 동작)──────────────────────────────────────────메인 스레드: await Task.Delay(1000) 호출 → 현재 스레드 반환 (Update 계속 실행 가능) → 1초 후 ThreadPool 스레드에서 Task 완료 → UnitySynchronizationContext.Post(continuation) → 다음 프레임 메인 스레드에서 continuation 실행 → Debug.Log("1초 경과") 실행 — 메인 스레드 ✓IL2CPP에서의 변환
Mono 환경에서 async 상태 머신은 struct로 스택 할당될 수 있지만, IL2CPP로 빌드하면 클래스로 변환되어 힙에 할당됩니다. async 메서드가 많이 호출되는 경로에서 GC 압력이 발생하는 이유입니다.
// IL2CPP 환경: 이 호출마다 힙 할당 발생async Task HeavyCalledMethod(){ await Task.Yield(); // 구조체 상태머신도 클래스로 변환됨}3. 유니티 실전 적용
Section titled “3. 유니티 실전 적용”3.1 애니메이션/타이머 대기 → 코루틴
Section titled “3.1 애니메이션/타이머 대기 → 코루틴”Unity 전용 yield instruction을 사용하는 경우 코루틴이 자연스럽습니다.
IEnumerator PlaySequence(){ animator.SetTrigger("Attack"); yield return new WaitForSeconds(0.5f); // 애니메이션 중간 시점
SpawnHitEffect(); yield return new WaitUntil(() => animator.GetCurrentAnimatorStateInfo(0).normalizedTime >= 1f);
// 애니메이션 완전 종료 후 처리 EnableMovement();}3.2 HTTP / I-O / 외부 서비스 → async/await
Section titled “3.2 HTTP / I-O / 외부 서비스 → async/await”네트워크 요청처럼 Unity와 독립적인 비동기 I/O는 async/await가 훨씬 명확하고 예외 처리도 용이합니다.
// 코루틴으로 HTTP 요청 — 예외 처리 불가, 반환값 전달 어려움IEnumerator BadHttpRequest(Action<string> onComplete){ using var request = UnityWebRequest.Get("https://api.example.com/data"); yield return request.SendWebRequest();
if (request.result != UnityWebRequest.Result.Success) { Debug.LogError(request.error); // 예외 전파 불가 yield break; } onComplete(request.downloadHandler.text);}
// async/await — 예외 처리, 반환값, 취소 모두 지원async Task<string> GoodHttpRequest(CancellationToken ct = default){ using var request = UnityWebRequest.Get("https://api.example.com/data"); var operation = request.SendWebRequest();
// UnityWebRequestAsyncOperation을 Awaitable로 변환 while (!operation.isDone) { ct.ThrowIfCancellationRequested(); await Task.Yield(); }
if (request.result != UnityWebRequest.Result.Success) throw new HttpRequestException(request.error);
return request.downloadHandler.text;}3.3 씬 전환 흐름 관리
Section titled “3.3 씬 전환 흐름 관리”// 씬 로드 + 페이드 + 초기화를 async로 관리async Task TransitionToScene(string sceneName, CancellationToken ct){ try { await FadeOut(ct);
var loadOp = SceneManager.LoadSceneAsync(sceneName); loadOp.allowSceneActivation = false;
while (loadOp.progress < 0.9f) { ct.ThrowIfCancellationRequested(); await Task.Yield(); }
loadOp.allowSceneActivation = true; await Task.Yield(); // 씬 활성화 대기
await FadeIn(ct); } catch (OperationCanceledException) { Debug.Log("씬 전환 취소됨"); throw; }}4. 최적화 전략 & Best Practices
Section titled “4. 최적화 전략 & Best Practices”4.1 GC 압력 비교
Section titled “4.1 GC 압력 비교”| 패턴 | GC 원인 | 개선 방법 |
|---|---|---|
new WaitForSeconds(t) 반복 | 매 호출 힙 할당 | static readonly 캐싱 |
async Task 빈번 호출 | IL2CPP 클래스 할당 | UniTask 도입 |
Task.Delay 반복 | Task 객체 생성 | UniTask.Delay |
| lambda closure in coroutine | 클로저 클래스 힙 할당 | 클로저 최소화 |
4.2 UniTask — 제로 할당 async
Section titled “4.2 UniTask — 제로 할당 async”UniTask는 Unity 전용으로 최적화된 비동기 라이브러리로, ValueTask 기반의 구조체 상태 머신을 활용해 GC 압력을 거의 제거합니다.
using Cysharp.Threading.Tasks;
// Task 대신 UniTask — 힙 할당 없음async UniTask LoadWithUniTask(CancellationToken ct){ await UniTask.Delay(1000, cancellationToken: ct); await UniTask.WaitUntil(() => _isReady, cancellationToken: ct);
// Unity yield instruction을 await로 사용 가능 await UniTask.WaitForEndOfFrame(this);}
// UniTask.WhenAll — 병렬 로딩async UniTask LoadAssetsParallel(CancellationToken ct){ await UniTask.WhenAll( LoadTextureAsync("hero", ct), LoadAudioAsync("bgm", ct), LoadDataAsync("config", ct) );}4.3 ConfigureAwait(false) 주의
Section titled “4.3 ConfigureAwait(false) 주의”일반 .NET에서 ConfigureAwait(false)는 SynchronizationContext 캡처를 생략해 성능을 높이지만, Unity에서는 메인 스레드 복귀를 생략하는 결과를 초래합니다.
async Task DangerousMethod(){ // ThreadPool에서 실행 중이라면... string data = await File.ReadAllTextAsync("path").ConfigureAwait(false);
// 여기는 ThreadPool 스레드 — Unity API 접근 금지! // GetComponent, Instantiate 등 호출 시 크래시 transform.position = new Vector3(0, 0, 0); // ❌ 크래시}Unity 코드에서는 ConfigureAwait(false) 사용을 파일/네트워크 I/O 전용 유틸리티 레이어에만 제한하고, Unity API를 호출하는 코드에선 반드시 메인 스레드 컨텍스트를 유지해야 합니다.
5. 흔한 실수 & 안티패턴
Section titled “5. 흔한 실수 & 안티패턴”5.1 async void — 예외 블랙홀
Section titled “5.1 async void — 예외 블랙홀”// ❌ 절대 사용 금지: 예외가 삼켜지고 크래시로 이어짐async void LoadOnStart(){ await SomeFailingTask(); // 예외 발생 // UnhandledExceptionHandler로도 잡히지 않음}
// ✅ 반드시 async Task로 선언하고 예외 처리private async Task LoadOnStart(){ try { await SomeFailingTask(); } catch (Exception e) { Debug.LogException(e); }}
// MonoBehaviour에서 진입점으로 사용할 때private void Start() => LoadOnStart().Forget(); // UniTask의 Forget()5.2 코루틴의 try-catch 제약
Section titled “5.2 코루틴의 try-catch 제약”// ❌ 컴파일 오류: yield return은 try-catch 블록 안에 불가IEnumerator BadCoroutine(){ try { yield return new WaitForSeconds(1f); // CS1626 오류 } catch (Exception e) { }}
// ✅ yield 바깥에서 예외 처리IEnumerator GoodCoroutine(){ bool success = false; yield return new WaitForSeconds(1f);
try { // yield 없는 동기 코드는 try-catch 가능 success = ProcessData(); } catch (Exception e) { Debug.LogException(e); }}5.3 씬 언로드 후 댕글링 코루틴
Section titled “5.3 씬 언로드 후 댕글링 코루틴”// ❌ 씬 전환 후에도 코루틴이 살아있어 파괴된 오브젝트 접근IEnumerator LongRunningCoroutine(){ while (true) { yield return new WaitForSeconds(5f); UpdateUI(); // 씬 전환 후 UI 오브젝트가 null }}
// ✅ CancellationToken으로 코루틴 수명 관리private CancellationTokenSource _cts;
private void OnEnable(){ _cts = new CancellationTokenSource(); RunLoop(_cts.Token).Forget(); // UniTask}
private void OnDisable(){ _cts.Cancel(); _cts.Dispose();}
private async UniTaskVoid RunLoop(CancellationToken ct){ while (!ct.IsCancellationRequested) { await UniTask.Delay(5000, cancellationToken: ct); UpdateUI(); }}5.4 Task 미반환 — fire-and-forget 함정
Section titled “5.4 Task 미반환 — fire-and-forget 함정”// ❌ 반환된 Task를 무시 — 예외 손실, 완료 추적 불가private void OnButtonClick(){ LoadPlayerData(); // Task 반환값 버려짐 — 경고도 없이 실패}
// ✅ 명시적으로 처리private void OnButtonClick(){ LoadPlayerData() .ContinueWith(t => Debug.LogException(t.Exception), TaskContinuationOptions.OnlyOnFaulted);
// 또는 UniTask LoadPlayerDataAsync().Forget(e => Debug.LogException(e));}6. 심화 — UniTask & 커스텀 Awaiter
Section titled “6. 심화 — UniTask & 커스텀 Awaiter”6.1 PlayerLoopSystem 연동
Section titled “6.1 PlayerLoopSystem 연동”UniTask는 Unity의 PlayerLoopSystem에 직접 훅을 걸어 각 Unity 실행 단계를 awaitable로 노출합니다.
// UniTask 내부 동작 원리 (단순화)public static class PlayerLoopHelper{ // Unity PlayerLoop에 커스텀 시스템 삽입 static void Initialize() { var loop = PlayerLoop.GetCurrentPlayerLoop(); // loop.subSystemList에 UniTask 처리기 삽입 PlayerLoop.SetPlayerLoop(loop); }}
// 사용 예시 — Update 다음 프레임까지 정확히 대기await UniTask.NextFrame(); // Update 단계await UniTask.WaitForFixedUpdate(); // FixedUpdate 단계await UniTask.WaitForEndOfFrame(this); // 프레임 끝6.2 커스텀 Awaiter 패턴
Section titled “6.2 커스텀 Awaiter 패턴”Unity의 AsyncOperation을 직접 awaitable로 만드는 방법입니다.
// AsyncOperation 확장 — await 가능하게 만들기public static class AsyncOperationExtensions{ public static TaskAwaiter GetAwaiter(this AsyncOperation operation) { var tcs = new TaskCompletionSource<bool>();
if (operation.isDone) { tcs.SetResult(true); } else { operation.completed += _ => tcs.SetResult(true); }
return ((Task)tcs.Task).GetAwaiter(); }}
// 사용async Task LoadScene(){ // 이제 AsyncOperation을 직접 await 가능 await SceneManager.LoadSceneAsync("GameScene"); Debug.Log("씬 로드 완료");}7. 결론 & 핵심 요약
Section titled “7. 결론 & 핵심 요약”선택 기준 의사결정 트리
Section titled “선택 기준 의사결정 트리”비동기 작업이 필요한가? │ ├─ Unity yield instruction 필요? (WaitForSeconds, WaitUntil 등) │ └─ YES → 코루틴 │ ├─ 반환값이 필요한가? │ └─ YES → async Task<T> │ ├─ 예외를 전파해야 하는가? │ └─ YES → async Task │ ├─ 취소(Cancellation) 지원이 필요한가? │ └─ YES → async Task (CancellationToken) │ ├─ GC 압력이 극도로 민감한 경로인가? │ └─ YES → UniTask │ └─ 단순 타이밍/순서 제어 (씬 내부)? └─ 코루틴 (간단한 경우 더 적은 보일러플레이트)- 코루틴은 Unity PlayerLoop에 종속된 메인 스레드 전용 협력적 멀티태스킹입니다. 간단한 타이밍 제어에 적합하지만 예외 처리, 반환값, 취소 지원이 제한됩니다.
- async/await는 .NET Task 인프라를 기반으로 UnitySynchronizationContext를 통해 메인 스레드로 복귀합니다. 복잡한 비동기 흐름, 예외 처리, 취소 지원에 적합합니다.
- IL2CPP 환경에서는 async 상태 머신이 클래스로 힙 할당됩니다. GC에 민감한 경로에는 UniTask를 사용하세요.
async void는 사용하지 않습니다.ConfigureAwait(false)는 Unity API 호출이 없는 순수 I/O 유틸리티 레이어에만 사용합니다.