Skip to content

Unity async/await vs 코루틴 내부 원리

1. 개요 — 두 메커니즘의 본질적 차이

Section titled “1. 개요 — 두 메커니즘의 본질적 차이”

Unity 개발자라면 StartCoroutineasync/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>
취소 메커니즘코루틴 참조로 StopCoroutineCancellationToken

핵심 요약: 코루틴은 Unity 엔진이 관리하는 게임 루프 안에서 실행되고, async/await는 .NET 런타임이 관리하는 비동기 작업 모델에서 실행됩니다.


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.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;
}
// 씬 로드 + 페이드 + 초기화를 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;
}
}

패턴GC 원인개선 방법
new WaitForSeconds(t) 반복매 호출 힙 할당static readonly 캐싱
async Task 빈번 호출IL2CPP 클래스 할당UniTask 도입
Task.Delay 반복Task 객체 생성UniTask.Delay
lambda closure in coroutine클로저 클래스 힙 할당클로저 최소화

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)
);
}

일반 .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를 호출하는 코드에선 반드시 메인 스레드 컨텍스트를 유지해야 합니다.


// ❌ 절대 사용 금지: 예외가 삼켜지고 크래시로 이어짐
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()
// ❌ 컴파일 오류: 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);
}
}
// ❌ 씬 전환 후에도 코루틴이 살아있어 파괴된 오브젝트 접근
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));
}

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); // 프레임 끝

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("씬 로드 완료");
}

비동기 작업이 필요한가?
├─ 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 유틸리티 레이어에만 사용합니다.