[PART11.비동기와 동시성(1/12)] async-await — 상태 머신으로 변환되는 원리
컴파일러가 생성하는 상태 머신 구조 / MoveNext와 awaiter 패턴 / 스레드를 차단하지 않는 방법
목차
1. 문제 제기 — 왜 이 메서드는 중간에 멈췄다가 다시 실행되는가
Unity 모바일 게임에서 서버 통신을 할 때 이런 코드를 자연스럽게 쓰게 된다.
public async Task<UserProfile> LoadProfileAsync(string userId)
{
Debug.Log("프로필 요청 시작");
var response = await httpClient.GetAsync($"/users/{userId}"); // (1) 여기서 멈춤
var json = await response.Content.ReadAsStringAsync(); // (2) 여기서도 멈춤
Debug.Log("프로필 수신 완료");
return JsonUtility.FromJson<UserProfile>(json);
}
await를 만나는 순간 메서드가 "멈추고", 서버 응답이 오면 다시 "이어서" 실행된다. 일반적인 동기 메서드는 호출되면 return까지 달려가는 게 당연한데, 왜 이 메서드는 중간에 제어권을 놓았다가 다시 받아오는가?
이 동작을 이해하지 못하면 다음과 같은 버그를 추적할 수 없다.
await이후 코드가 왜 다른 스레드에서 실행되는가 (Unity API 호출 시 크래시)async void메서드에서 던진 예외가 왜 앱을 강제 종료시키는가.Result로await를 피하려 했더니 왜 데드락(Deadlock)이 발생하는가- 매 프레임
await Task.Yield()를 썼더니 왜 GC(Garbage Collector, 더 이상 사용하지 않는 메모리를 자동으로 회수하는 런타임 구성요소) 스파이크가 튀는가
정답은 하나다. 컴파일러가 async 메서드를 상태 머신(State Machine)이라는 별도의 구조체로 분해·재조립한다. await 키워드는 단순한 문법 설탕이 아니라, 컴파일러 지시문이다. 이 글은 그 변환 과정을 IL(Intermediate Language, C# 소스가 컴파일되어 만들어지는 CLR 중간 언어) 레벨까지 파헤친다.
async/await— 비동기 메서드 키워드async는 메서드를 상태 머신으로 변환하도록 컴파일러에 지시하는 한정자(modifier)다.await는 비동기 작업의 완료를 기다리는 연산자로, 대기 중에는 호출자에게 제어권을 돌려준다.
예시:await Task.Delay(100);100ms 동안 스레드를 점유하지 않고 대기, 이후 다시 이어서 실행
2. 개념 정의 — 드라마 촬영장의 "컷" 사인
비유: 감독이 "컷" 하면 잠시 멈추고 세팅 후 다시 촬영
드라마 촬영장을 생각해 보자. 감독은 긴 장면을 한 번에 찍지 않는다. 배우가 문을 열고 나가는 장면까지 찍으면 "컷"을 외친다. 스태프가 조명을 재배치하는 동안 배우는 대기실로 간다. 배치가 끝나면 감독이 다시 "액션"을 외치고 배우는 아까 나간 문 앞에서 이어서 촬영을 시작한다.
중요한 건 배우가 어디서 멈췄고, 어떤 감정 상태였는지를 기록해 두는 대본(스크립트 슈퍼바이저의 노트)이다. 이 노트가 없으면 배우는 어디서 이어가야 할지 모른다.
C# 컴파일러도 똑같이 한다.
- "컷" =
await— 현재 실행을 멈추고 제어권을 반환 - "액션" = 비동기 작업 완료 콜백 — 멈춘 자리에서 재개
- 대본 노트 = 상태 머신 구조체 — 어디서 멈췄고(state 필드), 어떤 지역 변수들이 있었는지(호이스팅된 필드)를 전부 보관
상태 머신의 3가지 구성 요소
| 요소 | 역할 |
|---|---|
<>1__state 필드 |
지금 어느 await 지점에 있는지. -1(시작 전), 0(1번째 await 대기), 1(2번째 await 대기), -2(완료) |
<>t__builder 필드 |
AsyncTaskMethodBuilder. 호출자에게 돌려줄 Task 객체를 만들고, 최종 결과·예외를 설정 |
| 호이스팅된 지역 변수 | 원래 메서드의 지역 변수·매개변수가 struct 필드로 승격. await로 스레드가 떠나도 값이 살아남음 |
쉽게 말하면: 지역 변수를 구조체 필드로 옮겨서, 메서드가 중간에 떠났다 돌아와도 변수 값이 유지되게 만든 것. 스택에 있던 변수가 사라지지 않도록 기록해 두는 것이다.
기본 C# 코드
public class AsyncDemo
{
public async Task<int> FetchAsync()
{
int local = 10;
await Task.Delay(100);
return local + 1;
}
}
이 10줄짜리 코드가 컴파일되면 어떻게 변할까? IL을 뜯어보면 비밀이 드러난다.
// <FetchAsync>d__0 — 컴파일러가 자동 생성한 상태 머신 struct
.class nested private auto ansi sealed beforefieldinit '<FetchAsync>d__0'
extends [System.Runtime]System.ValueType // struct (ValueType 상속)
implements [System.Runtime]System.Runtime.CompilerServices.IAsyncStateMachine
{
// 필드: 원본 메서드의 지역 변수 + 상태 추적용 변수가 전부 여기 모인다
.field public int32 '<>1__state' // 현재 상태 (-1/0/-2 등)
.field public valuetype AsyncTaskMethodBuilder`1<int32> '<>t__builder' // Task 빌더
.field private int32 '<local>5__2' // 원본의 local 변수가 필드로 승격
.field private valuetype TaskAwaiter '<>u__1' // await 중인 awaiter 저장소
}
상태 머신 struct의 IL 핵심
extends System.ValueType→ 참조 타입(class)이 아닌 struct로 생성된다. 첫 await 전까지는 스택에만 있으므로 힙 할당이 없다.IAsyncStateMachine인터페이스 구현 →MoveNext()와SetStateMachine()메서드를 반드시 가진다.<local>5__2→ 원본의local지역 변수가 호이스팅되어 struct 필드가 되었다. await 후에도local값을 읽으려면 스택이 아닌 이 필드에서 읽어야 하기 때문이다.
struct로 생성되는 이유가 핵심이다. 만약 await가 호출된 작업이 동기적으로 완료되면(예: 캐시 히트) 상태 머신은 스택에만 있다가 사라진다 — 힙 할당 0회. IsCompleted가 false일 때만 힙으로 박싱(boxing)되어 영구 저장된다. 최적화를 위한 설계다.
3. 내부 동작 — MoveNext의 switch와 goto, 그리고 콜백 등록
메서드 진입점은 MoveNext() 하나뿐
상태 머신의 모든 실행은 MoveNext() 메서드 안에서 벌어진다. 원본 async 메서드의 코드는 전부 MoveNext() 안으로 옮겨지고, await 지점마다 switch/goto로 분기가 걸린다.
핵심 흐름을 한 문장으로: await 지점에서 작업이 아직 안 끝났으면, 상태를 저장하고 콜백을 등록한 뒤 return으로 빠져나온다. 나중에 작업이 끝나면 같은 MoveNext()가 다시 호출되고, switch(state)로 멈췄던 자리로 점프해서 이어 실행한다.
실제 IL로 확인
앞서 본 FetchAsync의 MoveNext()를 IL 레벨에서 따라가 보자. 압축해서 핵심만 남겼다.
.method private final hidebysig newslot virtual
instance void MoveNext () cil managed
{
IL_0000: ldarg.0
IL_0001: ldfld int32 '<FetchAsync>d__0'::'<>1__state' // 현재 state 읽기
IL_0006: stloc.0
.try
{
IL_0007: ldloc.0
IL_0008: brfalse.s IL_0048 // state == 0 이면 재진입 경로로 점프
// ── 첫 진입 (state == -1) 경로 ──
IL_000a: ldarg.0
IL_000b: ldc.i4.s 10
IL_000d: stfld int32 '<FetchAsync>d__0'::'<local>5__2' // local = 10 (필드에 저장)
IL_0012: ldc.i4.s 100
IL_0014: call Task Task::Delay(int32) // Task.Delay(100)
IL_0019: callvirt TaskAwaiter Task::GetAwaiter() // awaiter 획득
IL_001e: stloc.2
IL_001f: ldloca.s 2
IL_0021: call bool TaskAwaiter::get_IsCompleted() // 이미 완료?
IL_0026: brtrue.s IL_0064 // 그렇다 → GetResult로 점프
// ── 아직 미완료: 상태 저장 + 콜백 등록 + 탈출 ──
IL_0028: ldarg.0
IL_0029: ldc.i4.0
IL_002b: stloc.0
IL_002c: stfld int32 '<FetchAsync>d__0'::'<>1__state' // state = 0
IL_0031: ldarg.0
IL_0032: ldloc.2
IL_0033: stfld TaskAwaiter '<FetchAsync>d__0'::'<>u__1' // awaiter 저장
IL_0038: ldarg.0
IL_0039: ldflda AsyncTaskMethodBuilder`1<int32> '<>t__builder'
IL_003e: ldloca.s 2
IL_0040: ldarg.0
IL_0041: call void AsyncTaskMethodBuilder::AwaitUnsafeOnCompleted<...>(...)
// "작업 끝나면 MoveNext 다시 호출해줘"
IL_0046: leave.s IL_00a1 // try 블록 탈출 → 호출자에게 반환
// ── 재진입 (state == 0) 경로 ──
IL_0048: ldarg.0
IL_0049: ldfld TaskAwaiter '<>u__1' // 저장해둔 awaiter 꺼내기
IL_004e: stloc.2
IL_0055: initobj TaskAwaiter // 필드 초기화 (GC 압박 감소)
IL_005c: ldc.i4.m1
IL_005f: stfld int32 '<>1__state' // state = -1 로 되돌림
// ── await 뒤 본문 실행 ──
IL_0064: ldloca.s 2
IL_0066: call void TaskAwaiter::GetResult() // 결과/예외 꺼내기
IL_006b: ldarg.0
IL_006c: ldfld int32 '<local>5__2' // local 값 읽기 (필드에서!)
IL_0071: ldc.i4.1
IL_0072: add // local + 1
IL_0074: leave.s IL_008d
}
catch [System.Runtime]System.Exception
{
IL_0077: stloc.3
IL_0078: ldc.i4.s -2
IL_007a: stfld int32 '<>1__state' // state = -2 (완료)
IL_0086: call void AsyncTaskMethodBuilder::SetException(Exception)
// Task를 Faulted 상태로
}
IL_008e: ldc.i4.s -2
IL_0090: stfld int32 '<>1__state' // state = -2
IL_009c: call void AsyncTaskMethodBuilder::SetResult(!0) // Task에 결과 세팅
}
IL 해설 — Unity 신입이 꼭 알아야 할 명령어만:
brfalse.s IL_0048—state == 0분기. 상태 머신 전체는 이switch역할의 점프로 구현된다. await가 2개면switch, 3개 이상이면switch테이블 또는 여러brtrue/brfalse체인이 된다.callvirt Task::GetAwaiter()— await 할 때 가장 먼저 호출. 모든 awaitable은GetAwaiter()를 노출해야 한다.get_IsCompleted()가 true이면AwaitUnsafeOnCompleted를 건너뛴다 — 이게 동기 완료 경로(fast path)다. 이미 완료된 Task(Task.CompletedTask, 캐시 히트)라면 힙 할당 없이 그대로 진행한다.ValueTask가 노리는 최적화가 바로 이것.AwaitUnsafeOnCompleted— "이 awaiter가 끝나면 내MoveNext()를 다시 호출해줘"라고 등록. 이 호출에서 상태 머신 struct가 처음으로 힙에 박싱되며, 바로 여기서 GC 할당이 발생한다.leave.s IL_00a1— try 블록을 벗어나 메서드 반환.return이 아니라leave인 이유는 try 안쪽이기 때문(finally 처리 위함).SetException/SetResult— Task의 최종 상태(Faulted/Ran to completion)를 결정하는 마지막 호출.
외부에서 본 FetchAsync()의 역할은 상태 머신 한 번 돌리는 것뿐
원본 FetchAsync 메서드 자체의 IL은 놀라울 만큼 단순하다. 상태 머신 struct를 만들고 Start를 호출할 뿐이다.
.method public hidebysig instance Task`1<int32> FetchAsync () cil managed
{
.locals init ([0] valuetype '<FetchAsync>d__0') // 상태 머신을 스택에 생성
IL_0000: ldloca.s 0
IL_0002: call AsyncTaskMethodBuilder`1<int32>::Create()
IL_0007: stfld AsyncTaskMethodBuilder`1<int32> '<>t__builder'
IL_000c: ldloca.s 0
IL_000e: ldc.i4.m1
IL_000f: stfld int32 '<>1__state' // state = -1 (시작 전)
IL_0014: ldloca.s 0
IL_0016: ldflda AsyncTaskMethodBuilder`1<int32> '<>t__builder'
IL_001b: ldloca.s 0
IL_001d: call void AsyncTaskMethodBuilder::Start<...>(...) // 첫 MoveNext() 동기 호출
IL_0022: ldloca.s 0
IL_0024: ldflda AsyncTaskMethodBuilder`1<int32> '<>t__builder'
IL_0029: call Task`1<!0> AsyncTaskMethodBuilder::get_Task()
IL_002e: ret // 호출자에게 Task 반환
}
요약하면 async 메서드 호출 흐름은 이렇다.
- 상태 머신 struct를 스택에 생성 (
.locals init) - 빌더 생성 (
AsyncTaskMethodBuilder.Create()) - state = -1 (아직 한 번도 실행 안 함)
builder.Start()호출 → 이 안에서MoveNext()가 호출자 스레드에서 동기 실행된다.MoveNext()안에서 첫 await 도달:- 이미 완료 → 계속 진행하여 결과까지 만들고
SetResult→ Task는 이미 완료된 상태로 반환 - 미완료 → 상태 머신 힙 박싱 + 콜백 등록 후 탈출 → 아직 미완료 Task 반환
- 이미 완료 → 계속 진행하여 결과까지 만들고
- 호출자는 반환된 Task로
await하거나.ContinueWith한다.
Awaiter 패턴 — await는 Task 전용이 아니다
컴파일러는 await x를 만나면 x의 타입을 보는 게 아니라 Awaiter 패턴을 따르는지 본다.
| 요구사항 | 역할 |
|---|---|
x.GetAwaiter() |
awaiter 객체 반환 (메서드 또는 확장 메서드) |
awaiter.IsCompleted (bool 속성) |
동기 완료 최적화용. true면 콜백 등록 안 함 |
awaiter.OnCompleted(Action) 또는 UnsafeOnCompleted(Action) |
완료 시 실행할 continuation 등록 (INotifyCompletion / ICriticalNotifyCompletion) |
awaiter.GetResult() |
결과 반환 또는 예외 throw |
그래서 Task, Task<T>, ValueTask<T>는 물론이고 아무 타입이나 이 4가지만 만족하면 await 가능하다. UniTask가 Unity에 특화된 UniTask 타입으로 await할 수 있는 것도 바로 이 패턴 덕분이다.
// Unity 코루틴 대체용 간단한 awaiter 예시 — PlayerLoop에 continuation을 등록하는 패턴
public struct FrameAwaiter : INotifyCompletion
{
public bool IsCompleted => false; // 항상 한 프레임 기다림
public void OnCompleted(Action continuation)
=> UnityEngine.LowLevel.PlayerLoopHelper.Enqueue(continuation); // 다음 프레임에 실행 요청
public void GetResult() { }
public FrameAwaiter GetAwaiter() => this;
}
// 사용: await new FrameAwaiter();
이 구조가 UniTask의 핵심이다.
4. 실전 적용 — Unity에서 async-await를 제대로 쓰기
상황: 버튼 클릭으로 서버에서 유저 데이터를 받아와 UI를 갱신
❌ Before — .Result로 동기 대기 (데드락 + 프레임 드랍)
// Unity UI 버튼 클릭 핸들러
public void OnClickLoadProfile()
{
// async Task를 .Result로 동기 대기 시도 → UI 스레드 멈춤
UserProfile profile = LoadProfileAsync("user-123").Result;
nameText.text = profile.Name;
}
public async Task<UserProfile> LoadProfileAsync(string id)
{
var json = await httpClient.GetStringAsync($"/users/{id}");
return JsonUtility.FromJson<UserProfile>(json);
}
문제점:
.Result는 Task가 끝날 때까지 현재 스레드를 차단(block)한다. Unity 메인 스레드가 멈춘다 → 게임이 프리즈된다.- Unity 에디터에서는
SynchronizationContext구성에 따라 데드락까지 간다 (await이후 continuation이 UI 스레드로 돌아가려 하는데 UI 스레드는.Result로 대기 중).
✅ After — async void 없이 async Task + 예외 핸들링
public void OnClickLoadProfile()
{
// fire-and-forget 대신 안전한 래퍼로 실행
_ = LoadProfileSafe();
}
private async Task LoadProfileSafe()
{
try
{
var profile = await LoadProfileAsync("user-123").ConfigureAwait(false);
// await 뒤 Unity API 호출은 반드시 메인 스레드에서!
await UniTask.SwitchToMainThread();
nameText.text = profile.Name;
}
catch (System.Exception e)
{
Debug.LogError($"프로필 로드 실패: {e.Message}");
}
}
해설:
.Result제거 → 메인 스레드 차단 없음async void대신async Task로 예외를 잡을 수 있게 함 (async void의 예외는SynchronizationContext나 프로세스 레벨로 전파되어 앱을 크래시시킨다)ConfigureAwait(false)로 불필요한 UI 스레드 복귀를 생략 → 데드락 방지. 단, UI를 건드리는nameText.text는 다시 메인 스레드로 돌아와서 해야 한다.
상황: 매 프레임 Task를 반환 — ValueTask로 GC 제거
Unity 모바일 게임의 핫패스(매 프레임 실행되는 코드)에서 Task를 반환하면 매 프레임 힙 할당이 발생한다.
❌ Before — 항상 Task 반환
private readonly Dictionary<string, int> _cache = new();
public async Task<int> GetStatAsync(string key)
{
if (_cache.TryGetValue(key, out var cached))
return cached; // 이미 있어도 Task<int>가 힙에 할당됨
var v = await LoadFromServerAsync(key);
_cache[key] = v;
return v;
}
✅ After — ValueTask로 캐시 히트 시 힙 할당 0
public ValueTask<int> GetStatAsync(string key)
{
if (_cache.TryGetValue(key, out var cached))
return new ValueTask<int>(cached); // 힙 할당 없음!
return new ValueTask<int>(LoadAndCacheAsync(key)); // 실제 비동기 경로만 Task 사용
}
private async Task<int> LoadAndCacheAsync(string key)
{
var v = await LoadFromServerAsync(key);
_cache[key] = v;
return v;
}
ValueTask<T> 버전의 IL을 보면 newobj가 Task가 아닌 ValueTask struct 생성자에 걸린다:
// GetValueAsync 메서드 — ValueTask는 struct라 스택에 생성
IL_0000: ldarg.0
IL_0001: ldfld int32 ValueTaskDemo::_cache
IL_0006: brfalse.s IL_0014
// 캐시 히트 경로 — Task 할당 없음
IL_000e: newobj instance void ValueTask`1<int32>::.ctor(!0) // struct ctor, 스택 생성
IL_0013: ret
// 캐시 미스 경로 — 실제 Task를 ValueTask로 래핑
IL_0014: call Task`1<int32> ValueTaskDemo::LoadAsync()
IL_001a: newobj instance void ValueTask`1<int32>::.ctor(Task`1<!0>)
IL_001f: ret
IL 해설:
newobj ValueTask::.ctor(!0)—ValueTask는 struct이므로newobj를 하더라도 스택에 생성된다. 힙 할당 없음.newobj Task가 IL 어디에도 없다 — 캐시 히트 경로에서 Task 객체 자체를 만들지 않는다는 뜻.- Unity 프로파일러에서
Task<int>할당이 사라지는 것을 직접 확인 가능.
주의:ValueTask는 한 번만await해야 한다. 같은ValueTask를 두 번await하면 내부IValueTaskSource가 재사용되어 미정의 동작으로 이어질 수 있다. 결과를 여러 곳에서 쓰려면.AsTask()로 Task로 변환하거나, 값을 로컬 변수에 저장해 두고 그것을 사용해야 한다.
Unity에서는 UniTask가 사실상 표준
Unity 환경에서는 기본 Task보다 UniTask를 쓰는 게 맞다. 이유는 상태 머신 관점에서 명확하다:
| 항목 | 표준 Task |
UniTask |
|---|---|---|
| 상태 머신 힙 박싱 | 첫 await 시 발생 | 대부분 경우 struct 풀링으로 회피 |
| continuation 재개 스레드 | SynchronizationContext.Post (Unity에서는 메인 스레드 재진입이 무거움) |
Unity PlayerLoop에 직접 등록 (프레임 타이밍 정확, 할당 0) |
| GC 압박 | 매 await 할당 존재 | 제로 할당 설계 |
GameObject 수명 연동 |
수동 CancellationToken | this.GetCancellationTokenOnDestroy() 제공 |
상태 머신 구조 자체는 컴파일러가 만드는 코드 그대로 재사용된다. UniTask는 AsyncTaskMethodBuilder 대신 AsyncUniTaskMethodBuilder를 쓰도록 [AsyncMethodBuilder] 특성으로 교체할 뿐이다.
// UniTask는 컴파일러가 만든 상태 머신을 그대로 쓰되, 빌더만 교체
public async UniTask<int> LoadAsync(CancellationToken ct)
{
await UniTask.Delay(100, cancellationToken: ct); // PlayerLoop에 등록 (할당 없음)
return 42;
}
5. 함정과 주의사항
함정 1 — async void는 이벤트 핸들러 외에 절대 쓰지 말 것
// ❌ 나쁜 예: 일반 메서드를 async void로
public async void SaveAsync()
{
await File.WriteAllTextAsync("save.json", data);
throw new System.IO.IOException("저장 실패!"); // 이 예외는 어디로?
}
컴파일러는 async void 메서드도 상태 머신으로 만들지만, 빌더를 AsyncVoidMethodBuilder로 쓴다. 이 빌더는 SetException을 호출할 때 예외를 현재 SynchronizationContext에 바로 post한다. 호출자에게 Task를 돌려주지 않으므로 try/catch로 잡을 수 없다.
Unity에서 이 예외는 MonoBehaviour 컨텍스트를 벗어나 앱을 크래시시킬 수 있다.
// ✅ 좋은 예: async Task로 바꾸고 호출부에서 예외 처리
public async Task SaveAsync()
{
await File.WriteAllTextAsync("save.json", data);
throw new System.IO.IOException("저장 실패!");
}
// 호출부
try { await SaveAsync(); }
catch (System.IO.IOException e) { Debug.LogError(e); }
예외: UI 이벤트 핸들러(Button.onClick, OnPointerDown 등)는 void 서명을 강제하므로 async void가 불가피하다. 이 경우에는 반드시 메서드 본문 전체를 try/catch로 감싼다.
함정 2 — await 뒤에서 Unity API 호출할 때 메인 스레드 확인
// ❌ 나쁜 예: ConfigureAwait(false) 후 Unity API 직접 호출
public async Task DownloadAsync()
{
var data = await www.SendWebRequest().ConfigureAwait(false);
// 여기는 워커 스레드 — Unity API 호출하면 크래시
transform.position = Vector3.zero; // ❌ 메인 스레드 외에서 호출
}
ConfigureAwait(false)는 continuation을 캡처된 SynchronizationContext로 복귀시키지 않는다. Unity 메인 스레드 외에서 Transform, GameObject API를 건드리면 UnityException이 발생한다.
// ✅ 좋은 예: UI 조작 직전 메인 스레드 복귀
public async Task DownloadAsync()
{
var data = await www.SendWebRequest(); // 기본 ConfigureAwait(true) — Unity sync context 복귀
transform.position = Vector3.zero; // 메인 스레드에서 호출 OK
}
// 또는 UniTask로 명시적 전환
public async UniTask DownloadWithUniTaskAsync()
{
var data = await UniTask.RunOnThreadPool(HeavyWork).ConfigureAwait(false);
await UniTask.SwitchToMainThread(); // 메인 스레드로 전환
transform.position = Vector3.zero;
}
함정 3 — async Task의 첫 줄부터 예외가 나도 await 전까지는 동기 실행
// ❌ 오해: "async 메서드는 무조건 비동기로 실행된다"
public async Task<int> DivideAsync(int a, int b)
{
if (b == 0) throw new System.DivideByZeroException(); // (★) await 이전 예외
await Task.Delay(10);
return a / b;
}
// 호출 시
var task = DivideAsync(10, 0);
// ★ 이 시점에 이미 예외 발생? 아니다 — Task가 Faulted 상태로 반환됨
await task; // 여기서 예외 throw
상태 머신 IL을 다시 보면:
builder.Start()내부에서MoveNext()가 호출자 스레드에서 동기 실행된다.MoveNext()의try블록 안에서 예외가 발생하면catch가builder.SetException()호출.SetException은 Task를 Faulted 상태로 만들고 호출자에게 반환한다.- 예외는
await시점에야 throw된다.
이 때문에 fire-and-forget(_ = DivideAsync(...))은 매우 위험하다. Faulted Task를 아무도 await하지 않으면 TaskScheduler.UnobservedTaskException으로 조용히 묻힌다.
// ✅ 올바른 fire-and-forget — 예외를 삼키지 않도록 반드시 로깅
_ = DivideAsync(10, 0).ContinueWith(
t => Debug.LogError(t.Exception),
TaskContinuationOptions.OnlyOnFaulted);
함정 4 — 상태 머신 힙 박싱으로 인한 GC 스파이크
매 프레임 실행되는 Update 루프에서 async Task 메서드를 호출하면, await할 때마다 상태 머신이 힙으로 박싱된다.
// ❌ 나쁜 예: Update에서 매 프레임 async Task 호출
private void Update()
{
_ = CheckCooldownAsync(); // 매 프레임 상태 머신 힙 할당 + Task 객체 할당
}
private async Task CheckCooldownAsync()
{
await Task.Yield(); // 상태 머신 박싱 확정
cooldown -= Time.deltaTime;
}
Unity 프로파일러에서 <CheckCooldownAsync>d__0 박싱이 매 프레임 보인다. 60fps 기준 초당 60회 할당 → GC 스파이크.
// ✅ 좋은 예: 동기 처리로 충분 / 또는 UniTask 사용
private void Update()
{
cooldown -= Time.deltaTime; // 동기 처리로 충분하면 async 쓰지 말 것
}
// 정말 프레임 지연이 필요하면 UniTask
private async UniTaskVoid CheckCooldownLoop(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
await UniTask.Yield(); // PlayerLoop 기반, 할당 없음
cooldown -= Time.deltaTime;
}
}
원칙: 동기로 할 수 있는 일에 async를 남발하지 말 것. 상태 머신은 공짜가 아니다.
6. C# 버전별 변화
C# 5.0 (2012) — async/await 최초 도입
이전에는 Task.ContinueWith로 콜백 지옥을 만들어야 했다.
// Before — ContinueWith 체이닝 (C# 4 이하)
public Task<string> LoadAsync()
{
return httpClient.GetStringAsync("/api/profile")
.ContinueWith(t =>
{
var json = t.Result;
return Parse(json);
});
}
// After — async/await (C# 5.0+)
public async Task<string> LoadAsync()
{
var json = await httpClient.GetStringAsync("/api/profile");
return Parse(json);
}
IL 관점에서는 ContinueWith 버전은 람다 캡처 클래스가 생성되는 반면, async 버전은 IAsyncStateMachine 구조체가 생성된다. 예외 처리·디버깅·성능 모두 후자가 우월하다.
C# 7.0 (2017) — ValueTask<T> 도입
동기 완료가 잦은 hot path에서 Task<T> 할당을 제거하기 위해 struct 기반 awaitable이 표준 라이브러리에 추가됐다.
// Before (C# 5~6) — 항상 Task 할당
public async Task<int> GetOrLoadAsync(string key)
{
if (cache.TryGetValue(key, out var v)) return v; // Task<int> 할당됨
return await LoadAsync(key);
}
// After (C# 7+) — 캐시 히트는 ValueTask로 할당 제거
public ValueTask<int> GetOrLoadAsync(string key)
{
if (cache.TryGetValue(key, out var v))
return new ValueTask<int>(v); // struct — 할당 없음
return new ValueTask<int>(LoadAsync(key));
}
C# 7.1 (2017) — async Main 지원
콘솔 앱의 Main 메서드가 async Task 반환을 허용하게 됐다. 런타임이 자동으로 .GetAwaiter().GetResult()를 호출하는 Main 래퍼를 생성한다.
// C# 7.1+
public static async Task Main(string[] args)
{
await RunAsync();
}
C# 8.0 (2019) — IAsyncEnumerable<T> + await foreach
비동기 스트림이 도입됐다. 내부적으로는 async의 상태 머신 + yield return의 반복기 상태 머신을 합친 코드가 생성된다.
// C# 8.0+
public async IAsyncEnumerable<string> ReadLinesAsync(string path)
{
using var reader = new StreamReader(path);
string line;
while ((line = await reader.ReadLineAsync()) != null)
yield return line;
}
// 사용
await foreach (var line in ReadLinesAsync("log.txt"))
Console.WriteLine(line);
C# 10 (2021) — AsyncMethodBuilderAttribute를 메서드 단위로 지정
이전에는 타입 단위로만 커스텀 빌더를 지정할 수 있었다. C# 10부터는 메서드마다 빌더를 바꿀 수 있어, 같은 Task<T> 반환 메서드라도 특정 메서드는 PooledTaskMethodBuilder를 쓰는 식의 최적화가 가능해졌다. UniTask 같은 라이브러리에도 유용하다.
// C# 10+
[AsyncMethodBuilder(typeof(PoolingAsyncValueTaskMethodBuilder<>))]
public async ValueTask<int> HotPathAsync() { ... }
C# 13 / .NET 9+ — 런타임 비동기 개선 논의
async 키워드를 런타임 레벨(IL·JIT)에서 처리하는 "Runtime Async" 프로젝트가 진행 중이다. 상태 머신을 컴파일 타임이 아니라 런타임에서 생성하여 트램펄린 없이 성능을 끌어올리려는 시도다. 글 작성 시점(2026-04)에는 아직 preview 수준으로, 프로덕션에서는 기존 컴파일러 변환 방식을 그대로 쓴다.
7. 정리
이것만 기억하라:
async메서드는 컴파일러에 의해IAsyncStateMachine인터페이스를 구현하는 struct로 변환된다. 원본 지역 변수는 전부 이 struct의 필드로 호이스팅된다.- 모든 실행은
MoveNext()하나로 수렴한다.state필드 +switch/goto로await지점마다 재진입 포인트를 만든다. await는 Awaiter 패턴(GetAwaiter/IsCompleted/OnCompleted/GetResult)만 만족하면Task가 아니어도 사용 가능. UniTask가 이 패턴을 활용한다.IsCompleted가 true면 상태 머신 힙 박싱 없이 동기 경로로 진행.ValueTask의 핵심 최적화.- 첫
await에서 작업이 미완료일 때 상태 머신이 힙으로 박싱된다. 이때 GC 할당이 생긴다. AsyncTaskMethodBuilder가 Task 객체 생성·결과 세팅·예외 세팅을 전담. 빌더를[AsyncMethodBuilder]로 교체하면 UniTask 같은 커스텀 비동기 모델을 쓸 수 있다.
Unity 실전 체크리스트:
- [ ]
async void는 UI 이벤트 핸들러에만 쓴다. 나머지는async Task. - [ ]
.Result/.Wait()로await를 동기 대기하지 않는다 — 데드락 + 프레임 프리즈. - [ ]
await뒤에서 Unity API를 호출할 때는 반드시 메인 스레드인지 확인.ConfigureAwait(false)후엔UniTask.SwitchToMainThread()등으로 복귀. - [ ] 동기 처리로 충분한 로직에
async남발 금지 (상태 머신 할당 비용). - [ ] 캐시 히트가 잦은 핫패스는
ValueTask<T>사용. 단, 두 번 await 금지. - [ ] Unity 프로젝트에서는 UniTask를 기본으로 검토 —
PlayerLoop연동 + 제로 할당. - [ ]
fire-and-forget(_ = SomeAsync())은 예외가 삼켜진다.ContinueWith로 로깅하거나 별도async Task래퍼로 감싼다.
'C# 심화' 카테고리의 다른 글
| [PART11.비동기와 동시성(3/12)] async deadlock — 왜 일어나고 어떻게 피하는가 (1) | 2026.04.14 |
|---|---|
| [PART11.비동기와 동시성(2/12)] Task vs Thread — 무엇이 다른가 (1) | 2026.04.14 |
| [PART10.예외 처리(5/5)] 사용자 정의 예외 — 올바르게 만드는 방법 (0) | 2026.04.14 |
| [PART10.예외 처리(4/5)] 예외 vs 반환값 — 오류를 어떻게 표현하는가 (1) | 2026.04.12 |
| [PART10.예외 처리(3/5)] 예외 필터(when) — catch를 더 정밀하게 쓰는 방법 (1) | 2026.04.12 |