반응형

[PART11.비동기와 동시성(1/12)] async-await — 상태 머신으로 변환되는 원리

컴파일러가 생성하는 상태 머신 구조 / MoveNext와 awaiter 패턴 / 스레드를 차단하지 않는 방법


1. 문제 제기 — 왜 이 메서드는 중간에 멈췄다가 다시 실행되는가

Unity 모바일 게임에서 서버 통신을 할 때 이런 코드를 자연스럽게 쓰게 된다.

C#
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 메서드에서 던진 예외가 왜 앱을 강제 종료시키는가
  • .Resultawait를 피하려 했더니 왜 데드락(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 필드), 어떤 지역 변수들이 있었는지(호이스팅된 필드)를 전부 보관
async 메서드가 상태 머신으로 변환되는 구조

상태 머신의 3가지 구성 요소

요소 역할
<>1__state 필드 지금 어느 await 지점에 있는지. -1(시작 전), 0(1번째 await 대기), 1(2번째 await 대기), -2(완료)
<>t__builder 필드 AsyncTaskMethodBuilder. 호출자에게 돌려줄 Task 객체를 만들고, 최종 결과·예외를 설정
호이스팅된 지역 변수 원래 메서드의 지역 변수·매개변수가 struct 필드로 승격. await로 스레드가 떠나도 값이 살아남음

쉽게 말하면: 지역 변수를 구조체 필드로 옮겨서, 메서드가 중간에 떠났다 돌아와도 변수 값이 유지되게 만든 것. 스택에 있던 변수가 사라지지 않도록 기록해 두는 것이다.

기본 C# 코드

C#
public class AsyncDemo
{
    public async Task<int> FetchAsync()
    {
        int local = 10;
        await Task.Delay(100);
        return local + 1;
    }
}

이 10줄짜리 코드가 컴파일되면 어떻게 변할까? IL을 뜯어보면 비밀이 드러난다.

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회. IsCompletedfalse일 때만 힙으로 박싱(boxing)되어 영구 저장된다. 최적화를 위한 설계다.


3. 내부 동작 — MoveNext의 switch와 goto, 그리고 콜백 등록

메서드 진입점은 MoveNext() 하나뿐

상태 머신의 모든 실행은 MoveNext() 메서드 안에서 벌어진다. 원본 async 메서드의 코드는 전부 MoveNext() 안으로 옮겨지고, await 지점마다 switch/goto로 분기가 걸린다.

MoveNext() 1회 호출 흐름

핵심 흐름을 한 문장으로: await 지점에서 작업이 아직 안 끝났으면, 상태를 저장하고 콜백을 등록한 뒤 return으로 빠져나온다. 나중에 작업이 끝나면 같은 MoveNext()가 다시 호출되고, switch(state)로 멈췄던 자리로 점프해서 이어 실행한다.

실제 IL로 확인

앞서 본 FetchAsyncMoveNext()를 IL 레벨에서 따라가 보자. 압축해서 핵심만 남겼다.

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_0048state == 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를 호출할 뿐이다.

IL
.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 메서드 호출 흐름은 이렇다.

  1. 상태 머신 struct를 스택에 생성 (.locals init)
  2. 빌더 생성 (AsyncTaskMethodBuilder.Create())
  3. state = -1 (아직 한 번도 실행 안 함)
  4. builder.Start() 호출 → 이 안에서 MoveNext()호출자 스레드에서 동기 실행된다.
  5. MoveNext() 안에서 첫 await 도달:
    • 이미 완료 → 계속 진행하여 결과까지 만들고 SetResult → Task는 이미 완료된 상태로 반환
    • 미완료 → 상태 머신 힙 박싱 + 콜백 등록 후 탈출 → 아직 미완료 Task 반환
  6. 호출자는 반환된 Task로 await하거나 .ContinueWith한다.

Awaiter 패턴 — awaitTask 전용이 아니다

컴파일러는 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할 수 있는 것도 바로 이 패턴 덕분이다.

C#
// 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로 동기 대기 (데드락 + 프레임 드랍)

C#
// 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 + 예외 핸들링

C#
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 반환

C#
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

C#
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을 보면 newobjTask가 아닌 ValueTask struct 생성자에 걸린다:

IL
// 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] 특성으로 교체할 뿐이다.

C#
// UniTask는 컴파일러가 만든 상태 머신을 그대로 쓰되, 빌더만 교체
public async UniTask<int> LoadAsync(CancellationToken ct)
{
    await UniTask.Delay(100, cancellationToken: ct);   // PlayerLoop에 등록 (할당 없음)
    return 42;
}

5. 함정과 주의사항

함정 1 — async void는 이벤트 핸들러 외에 절대 쓰지 말 것

C#
// ❌ 나쁜 예: 일반 메서드를 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 컨텍스트를 벗어나 앱을 크래시시킬 수 있다.

C#
// ✅ 좋은 예: 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 호출할 때 메인 스레드 확인

C#
// ❌ 나쁜 예: 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이 발생한다.

C#
// ✅ 좋은 예: 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 전까지는 동기 실행

C#
// ❌ 오해: "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 블록 안에서 예외가 발생하면 catchbuilder.SetException() 호출.
  • SetException은 Task를 Faulted 상태로 만들고 호출자에게 반환한다.
  • 예외는 await 시점에야 throw된다.

이 때문에 fire-and-forget(_ = DivideAsync(...))은 매우 위험하다. Faulted Task를 아무도 await하지 않으면 TaskScheduler.UnobservedTaskException으로 조용히 묻힌다.

C#
// ✅ 올바른 fire-and-forget — 예외를 삼키지 않도록 반드시 로깅
_ = DivideAsync(10, 0).ContinueWith(
    t => Debug.LogError(t.Exception),
    TaskContinuationOptions.OnlyOnFaulted);

함정 4 — 상태 머신 힙 박싱으로 인한 GC 스파이크

매 프레임 실행되는 Update 루프에서 async Task 메서드를 호출하면, await할 때마다 상태 머신이 힙으로 박싱된다.

C#
// ❌ 나쁜 예: 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 스파이크.

C#
// ✅ 좋은 예: 동기 처리로 충분 / 또는 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로 콜백 지옥을 만들어야 했다.

C#
// 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이 표준 라이브러리에 추가됐다.

C#
// 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#
// C# 7.1+
public static async Task Main(string[] args)
{
    await RunAsync();
}

C# 8.0 (2019) — IAsyncEnumerable<T> + await foreach

비동기 스트림이 도입됐다. 내부적으로는 async의 상태 머신 + yield return의 반복기 상태 머신을 합친 코드가 생성된다.

C#
// 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#
// 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/gotoawait 지점마다 재진입 포인트를 만든다.
  • awaitAwaiter 패턴(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 래퍼로 감싼다.
반응형

+ Recent posts