반응형

[PART13.비동기와 스레딩 기초(15/15)] 비동기·병렬 프로그래밍을 처음 만났을 때 지켜야 할 4가지

PART 13의 마무리 정리편입니다. 앞 14개 주제에서 다룬 동기/비동기, 스레드, Task, async/await, lock 같은 개념들을 신입 개발자가 실제 코드를 짤 때 가장 자주 발등을 찍는 4가지 규칙으로 압축했습니다. 각 규칙은 "왜 필요한가 → 위반 사례 → 올바른 코드 → 한 줄 요약" 순서로 정리했습니다.


TL;DR — 한눈에 보는 4가지 규칙

번호 규칙 한 줄 요약
I/O는 async, CPU 작업은 Task.Run 기다리는 일과 일하는 일은 다른 도구를 쓴다
async void는 이벤트 핸들러에서만 추적 불가능한 비동기는 앱을 죽인다
await 앞뒤 예외 전파를 항상 확인 await 없는 Task는 예외도 없는 셈이다
공유 상태는 피하고, 불가피하면 lock 또는 불변 데이터 await는 스레드 경계를 넘는다는 사실을 잊지 않는다

신입 시절 비동기 코드를 짜다가 "왜 이 예외가 안 잡히지?", "왜 앱이 갑자기 죽지?", "왜 카운터 값이 이상하지?" 같은 의문이 들었다면 거의 100% 이 4가지 중 하나를 어겼기 때문입니다.


비유로 먼저 이해하기 — 식당 주방의 4가지 규칙

비동기·병렬 프로그래밍을 식당 주방에 비유해 보겠습니다.

  • 주방장(메인 스레드): 손님 응대, 플레이팅 등 핵심 작업을 직접 담당합니다.
  • 주방 보조(스레드 풀 스레드): 무거운 작업을 위임받습니다.
  • 오븐(I/O 장치): 음식을 넣어두면 주방 인력 없이 알아서 익습니다.
  • 공유 도마(공유 상태): 여러 사람이 동시에 쓰면 사고가 납니다.

이 비유 위에서 4가지 규칙은 다음과 같이 매칭됩니다.

  1. I/O는 async, CPU는 Task.Run — 오븐에 음식을 넣었으면 그냥 다음 일을 하면 됩니다(=async). 칼질이 필요하면 보조에게 시킵니다(=Task.Run). 오븐 앞에 보조를 세워두는 건(=I/O에 Task.Run) 인력 낭비입니다.
  2. async void는 이벤트 핸들러에서만— 보조에게 일을 시켰으면 끝났는지 확인할 수 있어야 합니다. "끝났는지조차 모르는 일"은 손님 콜벨(이벤트 핸들러)에 응답할 때만 허용합니다.
  3. await 앞뒤 예외 전파— 보조가 칼에 베였으면 주방장이 알아야 합니다. await 하지 않으면 보조가 쓰러져도 주방장은 모르고 계속 다음 손님을 받습니다.
  4. 공유 상태는 피하고, 어쩔 수 없으면 lock — 도마 하나를 둘이 동시에 쓰면 손이 잘립니다. 줄을 서거나(=SemaphoreSlim) 각자 도마를 들고 다니거나(=불변 데이터) 해야 합니다.

비유를 바탕으로 이제 각 규칙을 깊이 들여다보겠습니다.


규칙 ① I/O는 async, CPU 바인드 작업은 Task.Run

왜 이 규칙이 필요한가

비동기 코드의 첫 번째 갈림길은 "이 작업이 기다리는 작업인가, 일하는 작업인가" 입니다.

  • I/O 바인드(I/O-bound): 네트워크 호출, 파일 읽기/쓰기, DB 쿼리. CPU는 거의 안 쓰고, 외부 장치의 응답을 기다립니다.
  • CPU 바인드(CPU-bound): 이미지 압축, 경로 탐색, JSON 파싱(대용량). CPU가 계속 일해야 합니다.

async/await스레드를 기다리지 않고 풀어주는 메커니즘이고, Task.Run다른 스레드에 일을 위임하는 메커니즘입니다. 둘은 목적이 완전히 다릅니다.

작업의 성격에 따라 도구를 고른다

위반 사례

C#
// 안티패턴 1: I/O 작업을 Task.Run으로 이중 포장 — 스레드 풀 낭비
public Task<string> FetchUserAsync(string id)
{
    var http = new HttpClient();
    return Task.Run(() => http.GetStringAsync($"/users/{id}"));
    //     ^^^^^^^^^ GetStringAsync는 이미 비동기 I/O다.
    //     Task.Run은 스레드 풀 스레드 1개를 잡아서 await만 시키고 있음.
}

// 안티패턴 2: CPU 작업을 await로만 감쌌지만 사실은 동기
public async Task<int> ComputeChecksumAsync(byte[] data)
{
    int sum = 0;
    for (int i = 0; i < data.Length; i++) sum += data[i] * 31;
    // await 한 번도 없음 → 호출 스레드가 그대로 점유됨
    // 메인 스레드에서 호출하면 UI 프리징
    return sum;
}

올바른 코드

C#
// I/O 바인드: GetStringAsync 자체가 비동기. Task.Run 불필요
public async Task<string> FetchUserAsync(string id)
{
    using var http = new HttpClient();
    return await http.GetStringAsync($"/users/{id}");
}

// CPU 바인드: 호출 측에서 Task.Run으로 위임
public Task<int> ComputeChecksumAsync(byte[] data)
{
    return Task.Run(() =>
    {
        int sum = 0;
        for (int i = 0; i < data.Length; i++) sum += data[i] * 31;
        return sum;
    });
}

Unity 모바일 함정

모바일은 데스크톱보다 스레드 풀과 배터리 예산이 빠듯합니다.

  • I/O에 Task.Run 남발 → 스레드 풀 고갈(starvation), 배터리 소모 증가.
  • 메인 스레드에서 무거운 연산 → 프레임 드랍, 스터터링.
  • 결론: 네트워크는 그대로 await, 무거운 계산만 Task.Run으로 넘긴 뒤, 결과를 받아 Unity API를 만질 때만 메인 스레드로 돌아옵니다.

한 줄 요약

기다리는 일은 async, 일하는 일은 Task.Run. 둘을 바꿔 쓰면 스레드와 배터리가 모두 손해.

규칙 ② async void는 이벤트 핸들러에서만

왜 이 규칙이 필요한가

async 메서드의 반환형은 세 가지가 있습니다.

반환형 추적 가능? 예외 잡기 용도
Task 일반 비동기 메서드
Task<T> 결과 반환 비동기 메서드
void ❌ (앱 크래시) 이벤트 핸들러 전용

async void호출자가 끝났는지 알 수도, 예외를 잡을 수도 없는 "쏘고 잊는(fire-and-forget)" 형태입니다. 예외가 발생하면 Task에 담기지 않고 SynchronizationContext로 직접 던져져서, 대부분의 경우 프로세스가 즉시 종료됩니다.

유일한 예외는 Button_Click(object, EventArgs) 같은 UI 이벤트 핸들러입니다. 이벤트 핸들러는 시그니처가 void로 강제되어 있어서 async void로 만들 수밖에 없습니다.

위반 사례

C#
public class OrderService
{
    // 안티패턴: 일반 메서드를 async void로 선언
    public async void SaveOrderAsync(Order order)
    {
        await _db.InsertAsync(order);
        if (order.Total < 0)
            throw new InvalidOperationException("Total < 0");
            // 이 예외는 호출자가 잡을 수 없다.
            // 앱이 그냥 죽는다.
    }
}

// 호출 측 — 잡으려고 해도 잡히지 않음
public void Process(Order order)
{
    try
    {
        _service.SaveOrderAsync(order); // await 못 함, Task가 없음
    }
    catch (Exception)
    {
        // 절대 실행되지 않음
    }
}

올바른 코드

C#
public class OrderService
{
    // 일반 메서드는 Task 반환
    public async Task SaveOrderAsync(Order order)
    {
        await _db.InsertAsync(order);
        if (order.Total < 0)
            throw new InvalidOperationException("Total < 0");
    }
}

// 이벤트 핸들러만 async void 허용
public partial class OrderForm : Form
{
    private async void SaveButton_Click(object sender, EventArgs e)
    {
        try
        {
            await _service.SaveOrderAsync(_currentOrder);
            MessageBox.Show("저장 완료");
        }
        catch (InvalidOperationException ex)
        {
            MessageBox.Show($"저장 실패: {ex.Message}");
        }
    }
}

IL/상태머신으로 보는 차이

async 메서드는 컴파일러가 빌더를 통해 상태 머신을 만듭니다.

  • async TaskAsyncTaskMethodBuilder 사용. 예외를 Task.Exception에 저장하고 상태를 Faulted로 전환. await가 이 예외를 다시 throw 해 줌.
  • async voidAsyncVoidMethodBuilder 사용. 예외를 담을 Task가 없으므로 SynchronizationContext.Post로 예외를 직접 전송. UI 컨텍스트는 이를 처리되지 않은 예외로 보고 앱을 종료.
C#
// async Task — 빌더가 예외를 Task에 캡처
[AsyncStateMachine(typeof(StateMachine_Task))]
public Task DoWorkAsync() { /* ... */ }

// async void — 빌더가 예외를 SynchronizationContext로 던짐
[AsyncStateMachine(typeof(StateMachine_Void))]
public void DoWorkAsync() { /* ... */ }

Unity 모바일 함정

  • 빌드된 모바일 앱에서 async void 예외는 로그도 남기지 않고 앱이 종료되는 경우가 많아 디버깅이 매우 어렵습니다.
  • async void Start()는 문법적으로 가능하지만, Start 내부에서 async Task 메서드를 호출하고 try-catch를 명시적으로 두는 패턴이 훨씬 안전합니다.
C#
// Unity에서 안전한 진입 패턴
private void Start()
{
    _ = StartCoreAsync(); // 또는 별도 헬퍼로 예외 로깅
}

private async Task StartCoreAsync()
{
    try
    {
        await LoadAssetsAsync();
        await ConnectToServerAsync();
    }
    catch (Exception ex)
    {
        Debug.LogException(ex); // 최소한 로그는 남는다
    }
}

한 줄 요약

async void는 이벤트 핸들러 전용. 그 외에는 무조건 async Task로 반환해야 추적·예외 처리가 가능하다.

규칙 ③ await 앞뒤에서 예외가 어떻게 전파되는지 항상 확인

왜 이 규칙이 필요한가

비동기 메서드 내부에서 발생한 예외는 즉시 던져지지 않습니다. 대신 반환된 Task에 캡처됩니다. 이 캡처된 예외를 호출 스택으로 다시 끌어올리는 역할이 바로 await입니다.

예외는 Task에 담겼다가 await에서 풀린다

위반 사례

C#
// 안티패턴 1: await 누락 — 예외가 잡히지 않음
public async Task RunAsync()
{
    try
    {
        FetchAsync(); // await 빠짐
    }
    catch (HttpRequestException)
    {
        // 절대 실행되지 않음
    }
}

// 안티패턴 2: .Result / .Wait()로 동기 차단 — UI 데드락
public string GetUserSync()
{
    return FetchUserAsync().Result;
    // UI 스레드에서 호출 시 데드락
    // 예외도 AggregateException으로 한 겹 더 감싸짐
}

// 안티패턴 3: WhenAll 결과를 try/catch로만 감쌌지만, 첫 예외만 잡음
public async Task RunAllAsync(List<Task> tasks)
{
    try
    {
        await Task.WhenAll(tasks);
    }
    catch (Exception ex)
    {
        // 여러 Task가 동시에 예외를 던졌을 때
        // 그중 첫 번째 예외만 잡힘 (나머지는 Task.Exception에 그대로 남음)
        Console.WriteLine(ex.Message);
    }
}

올바른 코드

C#
// 1) await로 예외를 정상 풀어내기
public async Task RunAsync()
{
    try
    {
        await FetchAsync();
    }
    catch (HttpRequestException ex)
    {
        Console.WriteLine($"네트워크 오류: {ex.Message}");
    }
}

// 2) Result/Wait 절대 금지 — 끝까지 await로 가져가기
public async Task<string> GetUserAsync()
{
    return await FetchUserAsync();
}

// 3) WhenAll의 모든 예외를 보려면 Task의 Exception 직접 확인
public async Task RunAllAsync(List<Task> tasks)
{
    var whenAll = Task.WhenAll(tasks);
    try
    {
        await whenAll;
    }
    catch
    {
        // 모든 예외 수집
        if (whenAll.Exception is { } agg)
        {
            foreach (var inner in agg.Flatten().InnerExceptions)
                Console.WriteLine($"[실패] {inner.Message}");
        }
        throw;
    }
}

IL/상태머신 관점

await가 끝나면 컴파일러는 내부적으로 awaiter.GetResult()를 호출합니다. TaskFaulted 상태라면 GetResult()AggregateException 안의 첫 번째 예외를 꺼내 다시 throw 합니다. 그래서 await 한 코드는 마치 동기 코드처럼 깔끔하게 try/catch로 잡을 수 있습니다.

반대로 .Result.Wait()은 동기 차단이고, 예외를 AggregateException으로 한 겹 더 감싸서 던지므로 catch (HttpRequestException) 같은 구체 타입 잡기가 더 어려워집니다.

Unity 모바일 함정

Unity 메인 스레드에서 .Result.Wait()을 쓰면 데드락이 거의 확정입니다. SynchronizationContext가 메인 스레드 1개로 구성되어 있는데, 메인 스레드가 Result로 자기 자신을 기다리기 때문입니다. 모바일에서 흔한 "앱이 멈췄어요(ANR)" 신고의 큰 비중이 이 패턴입니다.

한 줄 요약

비동기 메서드를 호출했으면 결과에 관심이 있는 한 반드시 await. .Result, .Wait()은 데드락 폭탄이다.

규칙 ④ 공유 상태는 피하고, 불가피하면 lock 또는 불변 데이터로 감싸기

왜 이 규칙이 필요한가

await는 단순히 "기다리기"가 아니라 "이 지점에서 메서드를 잘랐다가 나중에 다른 스레드에서 이어붙일 수 있는 분기점" 입니다. 이 사실은 두 가지 함의를 가집니다.

  1. await 앞뒤에서 스레드가 바뀔 수 있다 → 스레드 친화성(thread-affinity)을 가정하면 안 된다.
  2. 여러 비동기 작업이 같은 변수를 만지면 경쟁 조건(race condition) 이 생긴다.

여기서 가장 자주 빠지는 함정은 lock 블록 안에서 await를 쓰려고 하는 것 입니다. C# 컴파일러는 이를 컴파일 에러로 막습니다.

CS1996: Cannot await in the body of a lock statement

이유는 명확합니다. lock은 IL에서 Monitor.Enter / Monitor.Exit로 컴파일되며 락을 획득한 스레드가 직접 해제해야 합니다. 그런데 await 이후 코드는 다른 스레드에서 실행될 수 있어서 "들어간 스레드 ≠ 나오는 스레드"가 되어 락이 망가집니다.

위반 사례

C#
private int _counter = 0;
private readonly object _gate = new();

// 안티패턴 1: lock 없이 공유 상태 수정
public async Task UnsafeIncrementAsync()
{
    int t = _counter;
    await Task.Delay(10);  // 여기서 다른 Task가 끼어들 수 있음
    _counter = t + 1;       // → 카운트 누락
}

// 안티패턴 2: lock 안에서 await — 컴파일 에러
public async Task InvalidLockAsync()
{
    lock (_gate)
    {
        await Task.Delay(10); // CS1996
        _counter++;
    }
}

// 안티패턴 3: ConcurrentDictionary로 안심하다가 "Read-Modify-Write" 실수
private ConcurrentDictionary<string, int> _scores = new();
public void AddScore(string user, int delta)
{
    var current = _scores.GetValueOrDefault(user, 0); // 1) 읽기
    _scores[user] = current + delta;                   // 2) 쓰기
    // 1과 2 사이에 다른 스레드가 끼어들면 값 손실
}

올바른 코드

C#
// 1) 비동기 안에서 락이 필요하면 SemaphoreSlim
private readonly SemaphoreSlim _gate = new(1, 1);
private int _counter = 0;

public async Task SafeIncrementAsync()
{
    await _gate.WaitAsync();
    try
    {
        int t = _counter;
        await Task.Delay(10);
        _counter = t + 1;
    }
    finally
    {
        _gate.Release();
    }
}

// 2) 단순 카운터는 Interlocked로 락-프리
private long _hits;
public void IncrementHits() => Interlocked.Increment(ref _hits);

// 3) ConcurrentDictionary는 AddOrUpdate로 원자적 수정
private ConcurrentDictionary<string, int> _scores = new();
public void AddScore(string user, int delta)
{
    _scores.AddOrUpdate(user, delta, (_, prev) => prev + delta);
}

// 4) 가장 안전한 길 — 불변 데이터로 만들기
public sealed record GameState(int Score, int Lives, ImmutableList<Item> Items);

// 상태를 바꿀 때마다 새 객체를 만들고 참조만 교체
private GameState _state = new(0, 3, ImmutableList<Item>.Empty);
public void AddItem(Item item)
{
    var snapshot = _state;
    var next = snapshot with { Items = snapshot.Items.Add(item) };
    Interlocked.CompareExchange(ref _state, next, snapshot);
}

IL 관점에서 보는 lock의 한계

C#
// 원본
lock (_gate) { Critical(); }

// 컴파일러가 생성하는 IL 의사코드
bool taken = false;
try
{
    Monitor.Enter(_gate, ref taken);
    Critical();
}
finally
{
    if (taken) Monitor.Exit(_gate);
}

Monitor.Enter는 호출 스레드의 ID를 기록합니다. Monitor.Exit는 같은 스레드에서 호출되어야 합니다. await 이후 다른 스레드가 Exit을 호출하려고 하면 SynchronizationLockException이 발생합니다. 컴파일러는 이 사고를 미연에 방지하기 위해 lock { ... await ... }를 아예 컴파일 에러로 막습니다.

Unity 모바일 함정

  • Unity의 GameObject/Transform 등은 메인 스레드에서만 접근 가능합니다. await 이후에 백그라운드 스레드에서 깨어났다면 Unity API를 만지기 전에 메인 스레드 디스패처로 복귀해야 합니다(예: UniTask.SwitchToMainThread()).
  • 모바일은 GC 스파이크에 매우 민감합니다. Task 객체 자체도 힙 할당이므로, 빈번한 비동기 호출이 GC를 흔든다면 ValueTask 사용을 검토하세요. 단, ValueTask는 한 번만 await 가능하다는 제약이 있어 일반 메서드 반환형을 무조건 ValueTask로 바꾸는 것은 위험합니다.
  • 공유 상태는 가능한 한 ScriptableObject나 단방향 이벤트(예: 메시지 버스)로 분리하고, 멀티스레드 접근이 정말 필요한 부분만 lock/SemaphoreSlim으로 보호합니다.

한 줄 요약

await는 스레드 경계를 넘는다. 공유 상태가 있다면 lock을 떠올리고, lock { await ... }은 절대 안 된다는 것만 기억하면 충분하다.

4가지 규칙 위반 자가 진단표

작성한 코드를 커밋하기 전에 이 표를 한 번씩 훑어 보세요.

체크 위반 신호 대응
Task.Run(() => httpClient.XxxAsync(...)) 패턴이 있다 I/O는 직접 await
UI 응답 중 멈춤이 있다 무거운 연산은 Task.Run으로 위임
메서드 반환형이 async void이고 시그니처가 이벤트 핸들러가 아니다 async Task로 변경
try/catch가 있는데 안에서 비동기 메서드를 await 없이 호출한다 await 추가
.Result / .Wait() / GetAwaiter().GetResult() 사용 호출 체인을 async로 통일
lock (_gate) { ... await ... } 같은 코드가 컴파일 에러로 보인다 SemaphoreSlim.WaitAsync 사용
_counter++, dict[k] = dict[k] + 1 같은 read-modify-write 패턴 Interlocked 또는 AddOrUpdate
UI 이벤트 핸들러 안에서 비동기 메서드를 await 없이 호출 이벤트 핸들러는 async void 자체로 두고 안에서 await

PART 13 학습 회고

PART 13에서는 비동기와 스레딩 기초를 14개 주제에 걸쳐 다뤘습니다. 다시 한번 짚어 보면 다음 흐름이었습니다.

  1. 개념의 전환 (01~04) — "동기는 한 줄씩 처리, 비동기는 흐름이 갈라진다"는 사고 방식. Thread, ThreadPool이라는 OS 추상화 위에서 .NET의 Task가 어떻게 만들어졌는지.
  2. Task와 async/await (05~07) — Task는 결과를 담는 그릇이고 async/await는 그 그릇을 펴서 동기처럼 쓰게 해주는 문법 설탕. 반환형의 의미.
  3. 조합과 취소 (08~09) — WhenAll/WhenAny로 여러 작업을 묶고, CancellationToken으로 협조적 취소를 한다.
  4. 예외와 스트림 (10~11) — 비동기에서 예외는 Task에 담긴 보따리. IAsyncEnumerable은 끝없이 흘러오는 데이터에 foreach처럼 접근하게 해 준다.
  5. 동시성 제어 (12~14) — lock, 새 System.Threading.Lock, 그리고 결국 만나게 되는 경쟁 조건과 데드락의 본질.

이 모든 내용을 신입이 코드를 짤 때 매번 떠올리기는 어렵습니다. 그래서 마지막 정리편에서 4가지 규칙으로 압축했습니다. 처음 비동기를 만났을 때 이 4가지만 머릿속에 박아 두면 90%의 사고는 막을 수 있습니다.

비동기·병렬은 한 번에 마스터되는 영역이 아닙니다. 코드를 짜고, 디버깅하고, 운영 환경에서 데드락을 한두 번 겪고 나면 점점 몸에 붙습니다. 이 시리즈가 그 첫걸음의 디딤돌이 되었기를 바랍니다.


마무리 한 줄

"기다리는 일과 일하는 일을 구분하고, 끝났는지 알 수 있게 만들고, 예외는 await로 받아 내고, 공유 상태는 가능한 한 만들지 않는다." — 이 한 문장이 PART 13 전체의 결론이자, 비동기 코드를 짤 때 매일 자신에게 거는 4중 안전벨트입니다.
반응형
반응형

[PART13.비동기와 스레딩 기초(14/15)] 경쟁 상태와 데드락 — 감 잡기

멀티스레딩 코드를 처음 짜면 "분명 1000번 더했는데 결과가 987이 나오고", "잘 돌던 프로그램이 어느 날 갑자기 멈춰서 안 풀린다." 두 현상은 각각 경쟁 상태(Race Condition)데드락(Deadlock) 입니다. 이 글은 두 버그가 *왜* 생기는지 그림으로 감을 잡고, *최소한의 도구* (lock, Interlocked) 로 막는 법, 그리고 *피하는 코딩 습관* 까지 입문자 시각으로 정리합니다. ThreadSanitizer 같은 탐지 도구·재진입 락·메모리 배리어는 심화 커리큘럼에서 다룹니다.


0. 한 줄 요약

  • 경쟁 상태: 두 스레드가 같은 변수를 동시에 읽고 쓸 때, *실행 순서가 매번 달라서* 결과도 매번 달라지는 버그입니다.
  • 데드락: 두 스레드가 서로 *상대방이 들고 있는 락을 기다리느라* 영원히 멈춰버리는 상태입니다.
  • 방어 도구: 단순 카운터는 Interlocked, 여러 줄짜리 임계 영역은 lock. 둘 다 "한 번에 하나만 들어가게" 만들어 줍니다.
  • 데드락 회피 규칙: 락 잡는 순서를 *모든 코드에서 동일하게*, 락 안에서는 *외부 코드 호출 금지*, 락 범위는 *최대한 짧게*.

1. 왜 멀티스레딩에서 결과가 어긋날까

1-1. counter++ 는 한 줄이 아닙니다

C# 코드를 작성하는 사람의 눈에는 counter++ 가 한 동작으로 보입니다. 그런데 CPU 입장에서는 세 단계입니다.

  1. Read — 메모리에서 counter 값을 레지스터로 가져옵니다.
  2. Modify — 레지스터에서 1을 더합니다.
  3. Write — 레지스터의 새 값을 메모리에 다시 씁니다.

이 세 단계 사이 *어디든* 다른 스레드가 끼어들 수 있습니다. 끼어들면 한 번 증가가 통째로 사라집니다.

counter++ 가 두 스레드에서 겹치면

A 가 10을 읽은 직후 B 가 끼어들어 똑같이 10을 읽고, 둘 다 11을 씁니다. 두 번 더했는데 한 번분만 적용됐습니다. 이게 잃어버린 갱신(Lost Update) 으로 부르는 가장 흔한 경쟁 상태입니다.

1-2. 컴파일러·CPU 의 명령 재배치

JIT 컴파일러와 CPU 는 *단일 스레드 결과만 같다면* 명령 순서를 자유롭게 바꿔도 된다는 규칙으로 최적화합니다. 단일 스레드에선 문제가 없지만, 다른 스레드가 그 변수를 *중간 상태* 로 관찰할 때 의도가 깨집니다. 이 글에서는 "이런 것도 있다" 정도로만 짚고, 본격적인 해결(메모리 배리어, volatile)은 *심화 커리큘럼* 에서 다룹니다.

용어 짧게 — *원자적(atomic) 연산*: 중간에 끊기지 않고 한 번에 끝나는 연산입니다. CPU 가 직접 보장합니다. *임계 영역(critical section)*: 한 번에 한 스레드만 들어가야 안전한 코드 구간입니다.

2. 비유로 잡기

은행 ATM 두 대가 같은 통장 잔고에 동시에 접근한다고 상상합니다. 두 사람이 동시에 1만 원씩 입금하는데, 두 ATM 모두 *입금 직전 잔고* 를 따로 읽어서 1만 원을 더하고 *각자* 결과를 통장에 씁니다. 한쪽이 덮어써서 1만 원이 사라집니다 — 경쟁 상태.

이번엔 두 사람이 *각자* 다른 ATM 에서 작업 중인데, 첫 번째 사람은 통장 A 의 인증을 잡은 채 통장 B 의 인증을 기다리고, 두 번째 사람은 통장 B 의 인증을 잡은 채 통장 A 의 인증을 기다립니다. 둘 다 손에 든 자원을 놓지 못한 채 영원히 기다립니다 — 데드락.

차이를 한 줄로:

  • 경쟁 상태 → "결과가 *틀려진다*."
  • 데드락 → "결과를 못 내고 *멈춘다*."

3. 코드로 감 잡기 — 경쟁 상태 재현과 방어

3-1. 가장 작은 재현 코드

C#
using System;
using System.Threading;
using System.Threading.Tasks;

class RaceDemo
{
    static int counter = 0;

    static void Main()
    {
        // 두 스레드가 각각 100,000번씩 ++ → 기대값 200,000
        Parallel.Invoke(
            () => { for (int i = 0; i < 100_000; i++) counter++; },
            () => { for (int i = 0; i < 100_000; i++) counter++; }
        );

        Console.WriteLine($"실제: {counter} / 기대: 200,000");
    }
}

이 코드를 여러 번 돌리면 결과가 매번 다릅니다. 어떤 때는 198,742, 어떤 때는 173,005. 200,000 이 *우연히* 나올 수도 있습니다. 멀티스레드 버그는 이렇게 *재현이 들쑥날쑥하다는 것* 자체가 가장 골치 아픈 특징입니다.

3-2. Interlocked — 단순 연산의 가벼운 답

counter++ 정도의 *한 변수에 대한 단순 연산* 은 Interlocked 가 제일 빠릅니다. CPU 가 직접 지원하는 원자적 명령(예: LOCK XADD) 으로 컴파일되어, 락 획득·해제의 OS 비용이 들지 않습니다.

C#
static int counter = 0;

Parallel.Invoke(
    () => { for (int i = 0; i < 100_000; i++) Interlocked.Increment(ref counter); },
    () => { for (int i = 0; i < 100_000; i++) Interlocked.Increment(ref counter); }
);

Console.WriteLine($"실제: {counter}"); // 항상 200,000

세 가지만 기억하면 충분합니다.

메서드 의미
Interlocked.Increment(ref x) x++ 를 원자적으로
Interlocked.Add(ref x, n) x += n 를 원자적으로
Interlocked.CompareExchange(ref x, newValue, expected) xexpected 와 같을 때만 newValue 로 바꿉니다. 락 없이 동시성 자료구조를 만드는 핵심 도구입니다.

3-3. lock — 여러 줄을 묶어야 할 때

상태가 여러 변수에 걸쳐 있어 *동시에 일관* 돼야 한다면 lock 으로 묶습니다.

C#
class Wallet
{
    private readonly object _gate = new();      // 락 전용 객체
    private int _krw;
    private int _coin;

    public void Exchange(int krwIn, int coinOut)
    {
        // 두 변수의 변경이 "동시에" 일어난 것처럼 보여야 합니다.
        lock (_gate)
        {
            _krw -= krwIn;
            _coin += coinOut;
        }
    }
}

lock(obj) { ... } 은 컴파일러가 다음 패턴으로 풀어줍니다(개념적 형태).

C#
bool taken = false;
try
{
    Monitor.Enter(_gate, ref taken);
    _krw -= krwIn;
    _coin += coinOut;
}
finally
{
    if (taken) Monitor.Exit(_gate);
}

핵심은 두 가지입니다.

  1. Monitor.Enter상호 배제 를 만듭니다. 다른 스레드가 이미 들어가 있으면 풀릴 때까지 기다립니다.
  2. try/finally예외가 터져도 락은 반드시 풀립니다. 락이 풀리지 않으면 그 자체가 또 다른 정지 원인이 됩니다.
락 객체 고르는 규칙private readonly object _gate = new(); 같은 *전용 객체* 를 만들어 잠급니다. this, typeof(MyClass), string 리터럴 같은 *외부에 노출된 참조* 는 다른 코드가 같은 객체로 락을 잡아 의도치 않은 데드락을 만들 수 있어 피합니다.

3-4. IL 로 한 번 들여다보기 — lock 은 정말 try/finally 일까

C# 13 이전 컴파일러로 lock(_gate) { ... } 을 컴파일한 IL 은 다음과 같습니다(요지만 발췌).

ldarg.0
ldfld      object Wallet::_gate
stloc.0
ldc.i4.0
stloc.1
.try
{
  ldloc.0
  ldloca.s   1
  call       void [System.Threading]Monitor::Enter(object, bool&)
  // ...임계 영역 본문...
  leave.s    END
}
finally
{
  ldloc.1
  brfalse.s  SKIP
  ldloc.0
  call       void [System.Threading]Monitor::Exit(object)
  SKIP: endfinally
}
END: ret

읽기 포인트:

  • Monitor.Enter(obj, ref taken)Monitor.Exit(obj) 가 그대로 보입니다. lock 키워드는 문법적 설탕 입니다.
  • taken 변수가 핵심입니다. Enter 가 락을 *실제로 잡았는지* 가 기록되고, finally 에서 잡힌 경우에만 Exit 합니다. 락을 못 잡은 채로 예외가 나면 잘못된 Exit 를 호출하지 않습니다.
  • Interlocked.Increment 는 IL 에서 일반 메서드 호출처럼 보이지만, JIT 가 플랫폼별 *원자적 명령* (x86: LOCK XADD, ARM: LDADD) 으로 인라인화합니다. 그래서 lock 보다 압도적으로 빠른 겁니다.

C# 13 이상에서는 System.Threading.Lock 타입을 도입하면서 IL 패턴이 약간 달라지지만, "Enter/Exit 를 try/finally 로 감싼다"는 본질은 동일합니다.


4. 데드락 — 멈춰서 안 풀리는 상태

4-1. 데드락의 4가지 필요 조건 (Coffman 조건)

네 조건이 *동시에* 충족돼야 데드락이 일어납니다. 거꾸로, *하나만 깨도* 데드락은 사라집니다. 예방 규칙은 모두 이 네 가지를 깨는 방법입니다.

조건 의미 깨는 법
상호 배제 자원은 한 번에 한 스레드만 점유 (락의 본질이라 깨기 어려움)
점유와 대기 자원을 들고 다른 자원을 기다림 필요한 락을 *한꺼번에* 잡거나 기다리는 동안 들고 있던 락을 놓는다
비선점 들고 있는 락을 빼앗을 수 없음 Monitor.TryEnter 처럼 *타임아웃·실패* 가능한 API 사용
순환 대기 A→B, B→A 로 사이클이 만들어짐 락 획득 순서를 전역적으로 통일

4-2. 가장 작은 데드락 재현 코드

C#
using System.Threading;

class DeadlockDemo
{
    static readonly object lockA = new();
    static readonly object lockB = new();

    static void Main()
    {
        var t1 = new Thread(() =>
        {
            lock (lockA)
            {
                Thread.Sleep(50);          // 상대가 lockB 를 잡을 시간 확보
                lock (lockB) { /* unreached */ }
            }
        });

        var t2 = new Thread(() =>
        {
            lock (lockB)
            {
                Thread.Sleep(50);
                lock (lockA) { /* unreached */ }
            }
        });

        t1.Start(); t2.Start();
        t1.Join();  t2.Join();              // 영원히 안 끝남
    }
}

4-3. 그림으로 보는 순환 대기

순환 대기 — 두 스레드가 서로의 락을 기다림

4-4. 한 줄만 고치면 풀리는 이유

위 코드를 두 스레드 모두 lockA → lockB 순서로 통일 하면 데드락이 사라집니다.

C#
// t2 의 락 순서를 t1 과 같게 맞춤
var t2 = new Thread(() =>
{
    lock (lockA)        // 먼저 lockA
    {
        Thread.Sleep(50);
        lock (lockB) { /* ok */ }
    }
});

A 를 먼저 잡은 한 스레드만 진행하고, 나머지는 *A 부터* 차분히 기다립니다. 순환이 안 만들어집니다 — Coffman 조건 4(순환 대기)가 깨졌습니다.


5. 예방 규칙 세 가지 — "감 잡기" 단계에서 이것만 지켜도 80%

규칙 1. 락 획득 순서를 *전역적으로* 같게 유지한다

여러 락을 함께 잡아야 한다면, *어떤 코드 경로에서든* 같은 순서로 잡습니다. 객체에 정렬 가능한 식별자(예: RuntimeHelpers.GetHashCode, ID) 가 있으면 작은 쪽 먼저 잡는 식으로 *기계적으로* 정합니다.

C#
// 두 계좌를 동시에 잠가야 할 때 — ID 작은 쪽부터
public static void Transfer(Account from, Account to, int amount)
{
    var (first, second) = from.Id < to.Id ? (from, to) : (to, from);
    lock (first.Gate)
    lock (second.Gate)
    {
        from.Balance -= amount;
        to.Balance   += amount;
    }
}

규칙 2. 락 안에서 *외부* 코드를 호출하지 않는다

이벤트 발행, 콜백, 가상 메서드, 외부 라이브러리 호출은 *어떤 락을 추가로 잡을지 알 수 없습니다.* 락을 쥔 채로 그런 코드를 부르면 의도치 않은 순환이 만들어집니다.

C#
// ❌ 위험 — 락 안에서 콜백 호출
lock (_gate)
{
    _items.Add(item);
    OnItemAdded?.Invoke(item);   // 구독자가 또 다른 락을 잡으면? 데드락 가능성
}

// ✅ 안전 — 락 밖으로 빼고 발행
ItemAddedEvent? toRaise = null;
lock (_gate)
{
    _items.Add(item);
    toRaise = OnItemAdded;
}
toRaise?.Invoke(item);

규칙 3. 락 범위는 *최대한 짧게*

락을 들고 있는 시간이 길수록 다른 스레드가 굶고, 데드락이 만들어질 수 있는 *틈* 이 늘어납니다. 락 안에서는 *공유 상태 변경* 만 하고, 계산·I/O·로깅은 락 밖에서 합니다.

C#
// ❌ 락 안에서 무거운 작업
lock (_gate)
{
    var json = JsonSerializer.Serialize(_state);   // CPU 무거움
    File.WriteAllText("dump.json", json);          // I/O — 절대 락 안에서 X
}

// ✅ 스냅샷만 락 안에서 만들고 락 밖에서 처리
State snapshot;
lock (_gate)
{
    snapshot = _state.Clone();
}
var json = JsonSerializer.Serialize(snapshot);
File.WriteAllText("dump.json", json);

6. Unity 입문자가 만나기 쉬운 함정

Unity 게임 로직은 대부분 *메인 스레드* 에서 돌아갑니다. 그래서 입문자는 "나는 멀티스레딩 안 쓰는데"라고 생각하기 쉽습니다. 그러나 다음 상황은 *모르는 사이* 다른 스레드를 끌어옵니다.

  • Task.Run — 무거운 계산을 백그라운드로 보낸 뒤, 그 결과를 메인 스레드의 게임 상태에 반영할 때 둘 사이 공유 변수에 경쟁 상태가 생깁니다.
  • async/await + ConfigureAwait(false)await 이후 코드가 *백그라운드* 스레드에서 이어지는데, 그 코드에서 Unity API 를 부르면 충돌합니다. (Unity API 는 거의 다 *메인 스레드 전용* 입니다.)
  • C# Job System / Burst — 프레임워크 차원에서 안전 검사를 강제하지만, *static* 필드나 외부 컬렉션을 만지면 여전히 경쟁 상태가 가능합니다.
  • 이벤트 콜백 — 네트워크 라이브러리(예: 일부 소켓·SDK) 콜백이 *백그라운드 스레드* 에서 호출됩니다. 거기서 GameObject 를 직접 만지면 즉시 예외 또는 더 미묘한 깨짐이 생깁니다.
현실적인 입문자 가이드라인 — 백그라운드 스레드에서는 *데이터만 계산해서 큐로 메인 스레드에 넘기고*, Unity API 는 항상 메인 스레드에서 호출합니다. 공유하는 단순 카운터·플래그는 Interlocked 로 막고, 그 이상 복잡한 상태는 메시지 큐(예: ConcurrentQueue<T>) 한 곳을 두고 *한 스레드만 쓰기* 패턴으로 잡으면 대부분의 사고를 피할 수 있습니다.

7. 한눈 비교 — Interlocked vs lock

항목 Interlocked lock (Monitor)
보호 범위 단일 변수, 한 연산 여러 줄·여러 변수
비용 매우 저렴 (CPU 명령 1~2개 수준) 상대적으로 비쌈 (경쟁 시 OS 대기)
데드락 가능성 거의 없음 (단일 연산이라 사이클 없음) *순서 통일 안 하면* 가능
적합한 예 카운터, 플래그, 시퀀스 ID 발급 컬렉션 변경, 두 필드 동시 갱신
함정 int/long/ref 타입 위주, 복합 로직 표현 한계 락 객체 잘못 고르거나 외부 호출 들이는 실수

원칙 — 단일 변수는 Interlocked, 그 외는 lock. 락이 꼭 필요한 상황에서만 락을 씁니다.


심화 커리큘럼 (이 글의 범위 밖)

본격적으로 멀티스레딩 디버깅·튜닝에 들어가면 다음 주제가 따라옵니다. 이 시리즈에서는 별도 글로 다룹니다.
  • 메모리 모델·메모리 배리어: volatile, Volatile.Read/Write, Interlocked.MemoryBarrier — JIT/CPU 재배치를 *언제* 막아야 하는가.
  • 재진입 락(Reentrant Lock): 같은 스레드가 같은 락을 다시 잡을 때의 동작과 함정.
  • SemaphoreSlim, ReaderWriterLockSlim, System.Threading.Lock 같은 추가 동기화 프리미티브.
  • Lock-free 자료구조: CompareExchange 기반의 큐·스택·해시맵 설계.
  • 탐지 도구: ThreadSanitizer (Linux/Clang), Visual Studio Concurrency Visualizer, BenchmarkDotNet 의 동시성 모드, Unity Burst 의 aliasing 검사.
  • Async 동기화 컨텍스트와 데드락: WinForms/WPF/SynchronizationContext 위에서 .Result/.Wait() 가 만드는 클래식 데드락.

8. 정리

  • 멀티스레드 코드의 두 대표 버그: 결과가 *틀려지는* 경쟁 상태, 결과 없이 *멈추는* 데드락.
  • 경쟁 상태의 뿌리는 *비원자적 연산* 과 *재배치*. 단일 변수는 Interlocked 로, 여러 줄은 lock 으로 막습니다.
  • 데드락은 *순환 대기* 가 핵심 — 락 순서 통일, 락 안에서 외부 호출 금지, 락 범위 최소화 세 규칙으로 거의 막습니다.
  • Unity 메인 스레드 환경이라도 Task.Run·async/await·콜백·Job System 사용 시 동일한 규칙이 그대로 적용됩니다.
  • 더 깊은 메모리 모델·탐지 도구·Lock-free 설계는 심화 커리큘럼에서 이어 갑니다.
반응형
반응형

[PART13.비동기와 스레딩 기초(13/15)] System.Threading.Lock — .NET 9의 새 락 타입 (C# 13)

이 글에서 다루는 것 - C# 13 / .NET 9 에서 새로 추가된 System.Threading.Lock 타입이 왜 도입되었는가 - 컴파일러가 lock 키워드를 만났을 때 락 대상의 타입에 따라 어떻게 다른 코드로 변환하는가 - EnterScope() 가 반환하는 ref struct Scope 의 의미와 using 패턴 - Lock 객체를 object 로 캐스팅하면 왜 새 API 가 동작하지 않는가 (CS9216 경고) - 기존 코드를 안전하게 마이그레이션하는 한 줄 변경법

앞 주제와의 관계 이전 글([12. lock 문](../12.%20lock%20문))에서는 고전 lock(_syncRoot) 패턴이 내부적으로 Monitor.Enter / Monitor.Exit 로 변환되는 과정을 IL 수준까지 들여다봤습니다. 이 글은 그 다음 단계입니다 — 같은 lock 키워드를 그대로 쓰면서도, 락 객체의 타입만 objectLock 으로 바꿨을 때 컴파일러가 완전히 다른 IL 을 생성한다는 사실을 보여주는 것이 핵심입니다.

1. 왜 새 타입이 필요했는가 — lock(object) 의 구조적 부담

lock(_syncRoot) 한 줄을 쓰는 순간 런타임은 다음 세 가지를 떠안습니다.

  1. 모든 참조 객체가 락이 될 수 있다는 일반성Monitor.Enter(object) 는 인자 타입이 object 입니다. 이 일반성을 지원하기 위해 CLR 은 힙에 있는 모든 객체에 "동기화 블록 인덱스(sync block index)" 를 걸 수 있도록 객체 헤더를 설계해 두었습니다. 한 번도 락 대상으로 쓰이지 않는 객체조차 이 메커니즘을 위해 비용을 지불합니다.
  2. 잘못된 락 대상으로 인한 데드락 위험lock(this), lock(typeof(MyClass)), lock("some-string") 처럼 외부에서 동일 참조에 접근 가능한 객체를 잠그면, 의도치 않은 코드와 락 경합·데드락이 발생합니다. 컴파일러는 이를 막을 수단이 없습니다.
  3. 확장의 한계Monitor 는 객체 헤더 비트를 사용하기 때문에 잠금 자체에 메타데이터를 더 붙이거나 진단 정보를 풍부하게 만들기 어렵습니다.

C# 13 / .NET 9 의 System.Threading.Lock 은 이 세 가지를 한꺼번에 정리합니다 — 잠금만을 위한 전용 타입을 만들고, 컴파일러가 그 타입을 인식해 전용 API 로 변환하는 방식입니다.


2. 30초 요약 — 한 줄만 바꾸면 됩니다

C#
// Before (.NET 8 이하 / 고전 패턴)
private readonly object _sync = new();

public void Increment()
{
    lock (_sync)            // → Monitor.Enter / Monitor.Exit 로 변환
    {
        _count++;
    }
}
C#
// After (.NET 9 / C# 13)
private readonly System.Threading.Lock _sync = new();

public void Increment()
{
    lock (_sync)            // → _sync.EnterScope() + Scope.Dispose() 로 변환
    {
        _count++;
    }
}

바뀐 것은 필드 타입 한 줄뿐입니다. lock 키워드는 그대로 유지하지만, 컴파일러가 락 대상의 정적 타입을 보고 완전히 다른 IL 을 생성합니다. 호출부 코드를 한 글자도 건드리지 않고도 새 API 의 이점을 받습니다.


3. ADEPT — 비유로 이해하는 Lock vs lock(object)

A (Analogy) — 회의실 사용

  • lock(object) (고전): 사무실에 회의실이 따로 없습니다. 회의가 필요하면 임의의 빈 책상 한 자리를 골라 "지금부터 여기 회의 중" 이라고 표시판을 둡니다. 표시판은 이 책상에 앉던 사람의 책상 헤더(객체 헤더의 sync block index) 에 붙입니다. 모든 책상이 표시판을 받을 수 있도록 처음부터 그 슬롯이 마련되어 있습니다.
  • Lock (.NET 9): 회의실 전용 공간을 만듭니다. 그 공간을 잡는 도구도 회의실에 내장되어 있습니다(EnterScope()). 일반 책상은 더 이상 회의용 표시판 슬롯을 비워둘 필요가 없습니다.

D (Diagram) — 컴파일러가 두 갈래로 갈라지는 지점

컴파일러는 락 대상의 정적 타입을 본다

E (Example) — 실제 동작

C#
using System.Threading;

public sealed class Counter
{
    private readonly Lock _sync = new();
    private int _count;

    public void Increment()
    {
        lock (_sync)
        {
            _count++;
        }
    }

    public int Read()
    {
        lock (_sync)
        {
            return _count;
        }
    }
}

호출부에서 lock 키워드를 그대로 사용했지만 — 컴파일러가 _sync 의 타입이 System.Threading.Lock 인 것을 보고 자동으로 EnterScope() 호출 + Scope.Dispose() 패턴으로 IL 을 생성합니다. 다음 4번 절에서 IL 로 직접 확인합니다.

P (Plain language) — 한 문장

락 대상의 타입만 Lock 으로 바꾸면, lock 키워드는 자동으로 새 API 로 변환됩니다.

T (Test your understanding)

  • private object _sync = new Lock(); — 이렇게 선언하면 새 API 가 호출될까요? *(힌트: 정적 타입은 object 입니다.)*
  • lock((object)_sync) { ... } 처럼 명시적으로 캐스팅하면 어떻게 될까요?
  • 둘 다 컴파일은 되지만, 새 API 가 호출되지 않고 컴파일러 경고 CS9216 이 뜹니다. 6번 절에서 자세히 다룹니다.

4. IL 로 직접 확인하기 — 두 코드는 정말 다른 IL 로 컴파일됩니다

이 글의 핵심 주장은 "컴파일러가 락 대상의 타입을 보고 다른 IL 을 만든다" 입니다. 추측이 아니라 IL 디컴파일 결과로 직접 확인합니다.

4.1 object 락 — 고전 경로

C#
public sealed class CounterObject
{
    private readonly object _sync = new();
    private int _count;

    public void Increment()
    {
        lock (_sync) { _count++; }
    }
}

Increment() 의 IL 핵심부 (Release, .NET 9):

IL
.method public hidebysig instance void Increment() cil managed
{
    .locals init (object V_0, bool V_1)
    ldarg.0
    ldfld      object CounterObject::_sync
    stloc.0
    ldc.i4.0
    stloc.1
    .try
    {
        ldloc.0
        ldloca.s   V_1
        call       void [System.Threading]System.Threading.Monitor::Enter(object, bool&)
        // _count++
        ldarg.0
        dup
        ldfld      int32 CounterObject::_count
        ldc.i4.1
        add
        stfld      int32 CounterObject::_count
        leave.s    EndFinally
    }
    finally
    {
        ldloc.1
        brfalse.s  EndOfFinally
        ldloc.0
        call       void [System.Threading]System.Threading.Monitor::Exit(object)
        EndOfFinally: endfinally
    }
    EndFinally: ret
}

핵심: Monitor.Enter(object, bool&)Monitor.Exit(object) 호출이 명시적으로 박혀 있습니다. 락 대상은 object 로 다뤄지고, 객체 헤더의 sync block index 가 사용됩니다.

4.2 Lock 락 — 새 경로

C#
public sealed class CounterLock
{
    private readonly System.Threading.Lock _sync = new();
    private int _count;

    public void Increment()
    {
        lock (_sync) { _count++; }
    }
}

Increment() 의 IL 핵심부:

IL
.method public hidebysig instance void Increment() cil managed
{
    .locals init (valuetype [System.Threading]System.Threading.Lock/Scope V_0)
    ldarg.0
    ldfld      class [System.Threading]System.Threading.Lock CounterLock::_sync
    callvirt   instance valuetype [System.Threading]System.Threading.Lock/Scope
               [System.Threading]System.Threading.Lock::EnterScope()
    stloc.0
    .try
    {
        ldarg.0
        dup
        ldfld      int32 CounterLock::_count
        ldc.i4.1
        add
        stfld      int32 CounterLock::_count
        leave.s    EndFinally
    }
    finally
    {
        ldloca.s   V_0
        call       instance void
                   [System.Threading]System.Threading.Lock/Scope::Dispose()
        endfinally
    }
    EndFinally: ret
}

핵심: Monitor.Enter / Monitor.Exit 가 사라졌습니다. 대신 Lock::EnterScope()valuetype Lock/Scope 를 반환하고, finally 절에서 Scope::Dispose() 가 호출됩니다. 로컬 변수 V_0 의 타입이 valuetype Lock/Scope 인 것에 주목하세요 — class 가 아니라 valuetype 입니다. 이것이 ref struct 의 흔적입니다.

4.3 핵심 차이를 표로

항목 lock(object) lock(Lock)
호출하는 API Monitor.Enter / Monitor.Exit Lock.EnterScope() / Scope.Dispose()
락 식별자 객체 헤더의 sync block index Lock 인스턴스의 전용 필드
진입 경로 비용 헤더 비트 검사 + (경합 시) 동기화 블록 인덱스 할당 전용 필드 CAS
try/finally C# 컴파일러가 직접 생성 컴파일러가 직접 생성 (Scope.Dispose 호출)
로컬 변수 bool lockTaken Lock.Scope (ref struct)
객체 헤더 사용 사용함 사용 안 함

5. Lock.EnterScope()ref struct Scope — 왜 굳이 ref struct 인가

EnterScope() 의 시그니처는 다음과 같습니다 (개념적 표현).

C#
namespace System.Threading;

public sealed class Lock
{
    public Scope EnterScope();          // ⬅ ref struct 반환
    public bool TryEnter();
    public bool TryEnter(int millisecondsTimeout);
    public bool TryEnter(TimeSpan timeout);
    public void Enter();
    public void Exit();
    public bool IsHeldByCurrentThread { get; }

    public ref struct Scope             // ⬅ ref struct
    {
        public void Dispose();
    }
}

Scoperef struct 인 것은 단순한 스타일 선택이 아니라 의미적 보장입니다.

5.1 ref struct 가 강제하는 제약

ref struct 는 다음을 컴파일 시점에 보장합니다.

  • 스택에만 존재할 수 있다 — 힙 할당 없음, 박싱 불가능
  • 필드로 저장 불가 — 클래스의 필드, 다른 (일반) 구조체의 필드가 될 수 없음
  • async 메서드에서 await 경계를 넘을 수 없음 — 상태 머신이 힙으로 캡처할 수 없으므로
  • iterator (yield return) 안에서 yield 경계를 넘을 수 없음
  • 람다에 캡처될 수 없음
  • 다른 스레드로 전달될 수 없음 — 값이지만 ref-like 라서 의도적으로 한 스레드의 스택에 묶임

5.2 왜 락 해제에 이 제약이 필요한가

락 해제(Monitor.ExitLock.Exit 든)는 락을 획득한 그 스레드에서만 호출되어야 합니다. 다른 스레드에서 호출하면 SynchronizationLockException 이 터지거나 정의되지 않은 동작입니다.

Scoperef struct 라는 것은 컴파일러가 다음을 막는다는 뜻입니다.

C#
// ✗ 컴파일 에러: ref struct 는 필드로 저장 불가
class Holder
{
    Lock.Scope _s;
}

// ✗ 컴파일 에러: ref struct 는 await 경계를 넘을 수 없음
async Task Bad()
{
    using (var s = _lock.EnterScope())
    {
        await Task.Delay(100);   // CS4007 / CS8350 류 에러
        // 만약 허용되면 await 후 다른 스레드에서 Dispose 가 호출될 수 있음
    }
}

// ✗ 람다 캡처 불가
Action a = () =>
{
    using (var s = _lock.EnterScope())   // s 는 캡처 가능한 일반 변수지만
    {
        // 람다 외부로 새어 나갈 수 없음 (ref struct 제약)
    }
};

즉, ref struct 는 "락을 잡은 스레드의 스택 프레임을 떠나기 전에 반드시 해제하라" 는 규칙을 컴파일 시점에 강제합니다. 고전 lock(object) 에서는 컴파일러가 try/finally 를 만들어주지만, lockTaken 변수를 그냥 bool 로 보관하므로 약속의 강도가 다릅니다.

5.3 성능 측면 — 핫 패스 최적화

ref struct 라는 사실 자체와 별개로, 새 API 는 다음 최적화 여지를 얻습니다.

  • Dispose 가 같은 스레드에서 불린다는 보장EnterScope 가 락을 획득한 스레드와 Scope.Dispose() 가 호출되는 스레드가 동일하다는 것을 컴파일러가 보장하므로, 해제 경로에서 "현재 스레드 ID" 를 다시 조회하지 않아도 됩니다 (스레드-static 룩업 회피).
  • 객체 헤더의 sync block 활용 안 함 — 락 식별자가 Lock 인스턴스의 전용 필드이므로, 일반 객체에 강제로 부여되던 비용을 우회합니다.
  • JIT 인라이닝 여지EnterScope / Scope.Dispose 는 작은 메서드라 인라이닝되기 쉽습니다.

대략적인 지표로 Monitor 대비 무경합 락(uncontended lock) 진입/해제 비용이 약 25% 줄어든다는 벤치마크 보고가 여러 출처에서 인용됩니다 ([InfoWorld](https://www.infoworld.com/article/3632180/how-to-use-the-new-lock-object-in-c-sharp-13.html), [Anthony Giretti](https://anthonygiretti.com/2025/03/05/c-13-introducing-system-threading-lock/)). 다만 경합이 심한 시나리오에서는 락 자체의 대기 시간이 지배적이므로, 무경합 비용 절감의 체감 효과는 워크로드에 따라 다릅니다. 실제 측정해서 확인 하는 자세를 권장합니다.


6. 함정 — object 캐스팅·할당 시 새 API 가 사라진다

여기서 가장 자주 미끄러지는 지점입니다.

6.1 컴파일러는 정적 타입만 본다

lock 문이 새 API 로 변환되는 조건은 락 대상 표현식의 정적 타입이 System.Threading.Lock 인지 입니다. 런타임에 어떤 객체인지가 아니라 컴파일러가 보는 타입입니다.

C#
private readonly Lock _sync = new();

void Case1()
{
    lock (_sync) { /* OK — 새 API */ }
}

void Case2()
{
    object boxed = _sync;
    lock (boxed) { /* ✗ 새 API 아님 — boxed 의 정적 타입이 object */ }
}

void Case3()
{
    lock ((object)_sync) { /* ✗ 새 API 아님 — 캐스트로 정적 타입이 object */ }
}

void Case4(object o)
{
    lock (o) { /* ✗ 새 API 아님 — 매개변수 타입이 object */ }
}

6.2 CS9216 — 컴파일러가 미리 알려준다

C# 13 컴파일러는 위와 같은 코드에 CS9216 경고를 발생시킵니다.

warning CS9216: A value of type 'System.Threading.Lock' converted to a different type will use likely unintended monitor-based locking in 'lock' statement.

이 경고가 떴다는 것은 "당신은 새 Lock 타입을 선언했지만, 이 lock 문에서는 옛 Monitor 경로로 폴백되었다" 는 신호입니다. 경고 무시 금지 — 의도된 폴백이라면 Lock 이 아니라 object 를 락 객체로 선언해야 의도가 명확해집니다.

6.3 대표적인 함정 시나리오

  • 컬렉션에 락 객체 보관Dictionary<string, object> 같은 자료구조에 Lock 을 담아 꺼내면 정적 타입이 object 가 됩니다.
  • 제네릭 컨테이너의 T = objectList<object> 에 담는 경우.
  • 인터페이스로 받는 헬퍼 메서드void RunUnderLock(object lockObj, Action body) 처럼 디자인된 헬퍼에 Lock 을 넘기면 헬퍼 내부에서는 object 입니다.
  • 리플렉션·동적 타입dynamic 변수에 담거나 리플렉션으로 꺼내는 경로.

Lock 을 도입할 때는 락 객체의 선언과 사용 모두에서 정적 타입을 Lock 으로 유지하는 것이 원칙입니다. 헬퍼 메서드를 둔다면 인자 타입을 Lock 으로 받도록 다시 설계합니다.

C#
// ✗ 폴백 함정
static void RunUnderLock(object lockObj, Action body)
{
    lock (lockObj) { body(); }   // lockObj 정적 타입은 object
}

// ✓ Lock 인지 그대로 유지
static void RunUnderLock(Lock lockObj, Action body)
{
    lock (lockObj) { body(); }   // 새 API 로 변환됨
}

7. using 패턴으로 명시적으로 쓰기

lock 키워드 대신 EnterScope() 를 직접 부를 수도 있습니다. 동작은 동일하지만, lock 블록보다 더 풍부한 범위 제어가 필요할 때 쓸 수 있습니다.

C#
public int ReadCount()
{
    using Lock.Scope scope = _sync.EnterScope();
    return _count;
}

또는 명시적 using 블록.

C#
public void Update(Func<int, int> update)
{
    using (_sync.EnterScope())
    {
        _count = update(_count);
    }
}

언제 굳이 명시적 형태를 쓰는가?

  • TryEnter() 와 함께 조건부 진입을 다룰 때 — lock 키워드는 항상 Enter 하므로.
  • 읽는 사람에게 "이건 단순 락이 아니라 Scope 가 만들어진다" 는 점을 명시하고 싶을 때.
  • 락 객체와 Scope 의 라이프타임을 코드 리뷰에서 더 명확히 드러내고 싶을 때.

대부분의 일반적인 동기화에서는 lock 키워드를 그대로 쓰는 편이 가독성이 좋습니다. 컴파일러가 이미 같은 IL 을 만들어주니, 의미 차이가 있을 때만 명시적 형태를 선택합니다.


8. TryEnter — 시간 제한이 있는 락 시도

Monitor.TryEnter(object, TimeSpan) 의 새 API 대응은 Lock.TryEnter(TimeSpan) 입니다. 다만 lock 키워드와 결합되는 형태는 없으므로, 직접 호출해야 합니다.

C#
private readonly Lock _sync = new();

public bool TryUpdate(Action body, TimeSpan timeout)
{
    if (!_sync.TryEnter(timeout))
        return false;

    try
    {
        body();
        return true;
    }
    finally
    {
        _sync.Exit();
    }
}

주의: TryEnter / Enter / Exit 직접 호출 경로는 Scoperef struct 보호를 받지 않습니다. try/finally 와 호출 짝맞추기는 직접 책임집니다. 가능하면 EnterScope() + using 을 우선 고려하고, TryEnter 가 정말로 필요한 경우에만 직접 사용합니다.


9. 마이그레이션 가이드 — 점진적으로 옮기기

9.1 한 번에 한 클래스씩

큰 코드베이스에서 모든 object 락을 한 번에 Lock 으로 바꾸는 것은 위험합니다. 다음 순서를 권장합니다.

  1. 공유 자원이 있는 클래스를 식별 — 락이 같은 인스턴스 안에서만 쓰이는 경우가 안전합니다.
  2. 필드 타입 변경private readonly object _sync = new();private readonly Lock _sync = new();
  3. 빌드 + 경고 확인 — CS9216 이 뜨는 위치를 모두 점검합니다. 새 API 가 적용되지 않는 호출 경로를 발견하는 신호입니다.
  4. 헬퍼·인자 시그니처 정리RunUnderLock(object, Action) 류 헬퍼는 Lock 으로 다시 선언합니다.
  5. 단위 테스트 / 통합 테스트 — 동기화 로직 자체는 동일하지만, 회귀 가능성을 점검합니다.

9.2 절대 같이 쓰지 말 것

  • Lock 인스턴스를 object 락으로도 쓰는 혼용 — 예: Lock _sync 이지만 일부 코드에서 lock((object)_sync) 로 사용. 이 경우 두 메커니즘이 동시에 한 객체에 걸려 동작이 미정의입니다.
  • LockHashtable 키나 Dictionary 키로 사용 — 정적 타입이 흐려지면 새 API 가 사라집니다.

9.3 무엇이 바뀌지 않는가

  • 재진입(reentrancy) 의미LockMonitor 와 마찬가지로 같은 스레드의 재진입을 허용합니다 (recursive).
  • 공정성(fairness) — 두 메커니즘 모두 강한 공정성 보장은 없습니다.
  • 데드락 가능성 — 락 객체가 바뀌었을 뿐 락의 의미는 같습니다. 락 순서 규칙은 그대로 지켜야 합니다.

10. Unity 환경에서의 주의

10.1 .NET / C# 버전 호환성

System.Threading.Lock.NET 9 BCL 에 포함된 타입이고, 컴파일러 변환 동작은 C# 13 부터입니다. Unity 에서 사용 가능한지는 다음 두 축으로 확인합니다.

  • Unity 의 C# 컴파일러 버전 — Unity 6 이상이 C# 13 / .NET 9 까지 지원하는 방향으로 가고 있지만, 사용 중인 Unity 정확한 버전의 릴리스 노트와 Edit > Project Settings > Player > Other Settings > Api Compatibility Level 설정을 직접 확인하세요. 검증되지 않은 버전에서 Lock 을 쓰면 컴파일은 되더라도 IL2CPP 경로에서 런타임 동작이 다를 수 있습니다.
  • System.Threading.Lock 타입 가용성 — Unity 의 BCL 어셈블리(mscorlib 또는 System.Threading)에 Lock 클래스가 포함되어야 합니다. 미지원 Unity 버전에서는 타입을 못 찾아 컴파일 에러가 납니다. 사용 전에 Lock 심볼이 IDE 에서 즉시 인식되는지 먼저 확인합니다.
주의: Unity 의 Mono / IL2CPP 백엔드에서 Lock 의 ref struct 동작 (스택 강제, async 경계 막기 등)이 동일하게 강제되는지는 사용 중인 정확한 Unity 버전에 따라 다릅니다. 추정으로 판단하지 말고, 타겟 Unity 버전에서 테스트로 확인하세요.

10.2 게임 핫 패스에서의 가이드

  • 메인 스레드 단독 코드에는 락을 걸지 않는다 — Unity 의 MonoBehaviour API 대부분은 메인 스레드 한정입니다. 락 자체가 불필요합니다.
  • 백그라운드 스레드 ↔ 메인 스레드 데이터 교환에만 사용Job System, Task.Run, custom thread 에서 메인 스레드와 공유하는 자료구조에만 동기화를 적용합니다.
  • 무경합 락이 잦다면 새 Lock 타입의 이점이 큽니다 — 게임 루프에서 매 프레임 한 번 잡고 푸는 패턴이라면, sync block index 를 우회하는 새 API 가 GC 압박과 진입 비용 둘 다 줄여줍니다 (이론상). 측정으로 확인하세요.
  • async/await 안에서 락 잡지 말 것 — 새 LockScope 는 ref struct 라 await 경계를 넘을 수 없습니다. 컴파일 에러로 실수가 차단됩니다 (이 자체가 안전 보장의 일부입니다).

11. 자주 묻는 질문

Q1. Monitor.Wait / PulseLock 에서 쓸 수 있나요?

A. 쓸 수 없습니다. Monitor.Wait/Pulse/PulseAll 은 객체 헤더의 sync block 을 사용하는데, Lock 은 그 메커니즘을 우회합니다. 조건 변수 패턴이 필요하면 ManualResetEventSlim, SemaphoreSlim, Channel<T> 등 다른 동기화 프리미티브를 사용하거나 기존 lock(object) 를 유지합니다.

Q2. Lock 인스턴스 자체는 static 으로 선언해도 되나요?

A. 됩니다. 다만 static 락은 모든 인스턴스가 공유하므로 경합 가능성이 커집니다. 락의 보호 범위(인스턴스 단위 vs 타입 단위)를 의식해서 선택합니다.

Q3. Lock 도 직렬화 대상이 될 수 있나요?

A. 하지 마세요. 락 객체는 직렬화·역직렬화 의미가 없습니다. [NonSerialized] 또는 [JsonIgnore] 등으로 제외합니다.

Q4. lock(this) 대신 Lock _this = new() 를 두면 되나요?

A. 의도는 맞지만, 더 좋은 답은 "락 객체를 외부에 노출하지 말 것" 입니다. private readonly Lock _sync = new(); 로 클래스 내부에 가둡니다.

Q5. LockSemaphoreSlim(1, 1) 중 무엇을 쓰나요?

A. 동기 메서드 안에서 짧은 임계 영역이면 Lock. async/await 와 함께 임계 영역을 보호해야 하면 SemaphoreSlim 입니다 (WaitAsync/Release). 두 도구의 용도는 겹치지 않습니다.


12. 정리

  • C# 13 / .NET 9 의 System.Threading.Lock 은 락 전용 타입입니다. 객체 헤더의 sync block 메커니즘을 우회하고, 진입·해제 경로를 짧게 만듭니다.
  • lock 키워드 사용법은 동일하지만, 컴파일러는 락 대상의 정적 타입이 Lock 인지 보고 다른 IL 을 생성합니다. Monitor.Enter/Exit 가 사라지고 EnterScope() + Scope.Dispose() 로 바뀝니다.
  • Scoperef struct 인 것은 "락을 획득한 스레드의 스택을 떠나기 전에 반드시 해제하라" 는 규칙을 컴파일 시점에 강제하기 위해서입니다. async 경계, 람다 캡처, 필드 저장, 다른 스레드 전달이 모두 차단됩니다.
  • object 로 캐스팅·할당하는 순간 새 API 는 사라집니다 — CS9216 경고를 보면 의도치 않은 폴백입니다. 컬렉션·헬퍼 인자·dynamic 등의 경로를 점검합니다.
  • 마이그레이션은 한 줄 변경private readonly object _sync = new();private readonly Lock _sync = new(); 호출부는 그대로 유지됩니다.
  • Unity 에서는 타겟 버전의 C# / .NET 호환성을 먼저 확인한 뒤 사용합니다. 메인 스레드 단독 코드에는 락 자체가 불필요합니다.

다음 글([14. 경쟁 상태와 데드락](../14.%20경쟁%20상태와%20데드락))에서는 락이 잘못 쓰일 때 발생하는 경쟁 상태(race condition)와 데드락(deadlock)을 다룹니다.


참고 자료

  • [Lock Class (System.Threading) — Microsoft Learn](https://learn.microsoft.com/en-us/dotnet/api/system.threading.lock?view=net-9.0)
  • [Obey lock object semantics for lock statements — C# 13.0 feature spec](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-13.0/lock-object)
  • [The lock statement — C# reference](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/statements/lock)
  • [Add first class System.Threading.Lock type — dotnet/runtime#34812](https://github.com/dotnet/runtime/issues/34812)
  • [How to use the new Lock object in C# 13 — InfoWorld](https://www.infoworld.com/article/3632180/how-to-use-the-new-lock-object-in-c-sharp-13.html)
  • [C# 13: Introducing System.Threading.Lock — Anthony Giretti](https://anthonygiretti.com/2025/03/05/c-13-introducing-system-threading-lock/)
반응형
반응형

[PART13.비동기와 스레딩 기초(12/15)] lock 문 — 임계 구역을 한 스레드만 통과시키기

한 번에 한 스레드만 들여보내는 단일 차로 / Monitor.Enter·Monitor.Exittry/finally로 감싸진 것의 문법 설탕 / 락 객체는 반드시 private readonly object

[문제 제기] _counter++ 한 줄이 어떻게 값을 잃어버리는가

Unity 모바일 게임에서 백그라운드 다운로더가 동시에 8개의 에셋 번들을 받아옵니다. 각 다운로드가 완료될 때마다 진행률 카운터를 1씩 올려서 UI에 표시한다고 해봅시다.

C#
public class DownloadProgress
{
    private int _completed;

    public void OnAssetCompleted()
    {
        _completed++; // 한 줄이지만 안전하지 않습니다.
    }

    public int GetCompleted() => _completed;
}

이 코드를 8개의 스레드가 동시에 호출하면, 종료 시점에 _completed는 8이 아닐 수 있습니다. 6이나 7이 나올 때도 있고, 운이 나쁘면 4가 될 때도 있습니다. 다운로드는 분명 8개가 끝났는데 카운터가 일치하지 않습니다.

원인은 _completed++ 한 줄이 사실은 세 단계로 동작하기 때문입니다.

  1. 메모리에서 _completed 값을 CPU 레지스터로 읽기 (ldfld)
  2. 레지스터에서 1을 더하기 (add)
  3. 레지스터의 값을 다시 메모리에 쓰기 (stfld)

스레드 A가 1단계를 마친 직후 OS가 스레드를 바꿔치기 합니다. 스레드 B가 1·2·3단계를 모두 끝내고 나서 스레드 A가 다시 깨어나 자기가 읽었던 옛날 값에 1을 더해 메모리에 씁니다. 스레드 B의 작업이 통째로 사라집니다. 이것이 경쟁 상태(Race Condition)입니다.

_counter++ 의 경쟁 상태 시나리오 (시작값 5)

이 문제를 막는 가장 직관적인 도구가 lock 문입니다. 이 글에서는 lock이 IL 레벨에서 어떤 코드로 변환되는지, 왜 락 객체로 this나 문자열을 쓰면 안 되는지, 왜 lock 안에서 await이 금지되는지를 파헤쳐봅니다. 그리고 Unity 메인 스레드 모델에서 어떤 패턴으로 lock을 활용해야 하는지까지 봅니다.


[개념 정의] 임계 구역 · 상호 배제 · Monitor

임계 구역(Critical Section) 여러 스레드가 동시에 실행하면 안 되는 코드 블록. 공유 자원(필드·컬렉션·파일 핸들 등)을 읽거나 쓰는 부분이 대부분 여기에 해당한다.
예시: _counter++, _queue.Enqueue(item), _dict[key] = value 같이 공유 상태를 만지는 짧은 구간.
상호 배제(Mutual Exclusion) 한 시점에 단 하나의 스레드만 임계 구역에 들어가도록 강제하는 규칙. 줄여서 mutex라고 부른다.
예시: 화장실 한 칸에 한 명만 들어갈 수 있는 것과 같은 모델.
Monitor 클래스 .NET 런타임이 제공하는 동기화 프리미티브. Monitor.Enter(obj)로 들어가고 Monitor.Exit(obj)로 나온다. 이미 누가 점유한 락이면 호출 스레드는 차단된다.
예시: lock(obj) { ... }Monitor.Enter/Exit를 자동으로 감싸주는 문법 설탕이다.
lock 문 (lock statement) C# 언어 키워드. 참조 타입 객체 하나를 받아 그 객체에 대한 모니터를 잡고 해당 블록을 임계 구역으로 만든다. 컴파일러가 Monitor.Enter·Monitor.Exittry/finally로 풀어 준다.
예시: lock (_sync) { _counter++; }

lock은 새로운 동기화 메커니즘이 아닙니다. 이미 .NET에 있던 Monitor 클래스를 더 안전하고 짧게 쓰기 위한 언어 차원의 단축키입니다. 그래서 lock을 이해하려면 Monitor가 어떤 일을 하는지부터 봐야 합니다.

lock — 한 칸짜리 화장실 모델

핵심은 두 가지입니다.

  • 락의 정체는 객체 하나입니다. CLR이 그 객체의 헤더에 어떤 스레드가 락을 점유 중인지 기록합니다.
  • 락은 재진입(re-entrant) 가능합니다. 같은 스레드가 같은 락을 두 번 잡아도 데드락에 빠지지 않고 카운트만 1 늘어납니다. 다만 Exit도 그만큼 호출해야 풀립니다.

[작동 원리] locktry/finally로 감싼 Monitor.Enter/Exit다 — IL로 직접 본다

가장 단순한 형태

C#
public class Counter
{
    private readonly object _sync = new();
    private int _value;

    public void Increment()
    {
        lock (_sync)
        {
            _value++;
        }
    }
}

C# 컴파일러는 위 코드를 다음과 거의 동일한 코드로 풀어 씁니다 (C# 4 이후).

C#
public void Increment()
{
    object obj = _sync;
    bool lockTaken = false;
    try
    {
        Monitor.Enter(obj, ref lockTaken);
        _value++;
    }
    finally
    {
        if (lockTaken) Monitor.Exit(obj);
    }
}

여기서 두 가지가 핵심입니다.

  1. 락 대상이 되는 참조를 지역 변수에 먼저 복사합니다. lock(_sync) 라고 썼지만 실제로는 obj = _sync로 복사한 뒤 그 사본으로 Enter/Exit를 합니다. 임계 구역 안에서 _sync가 다른 객체로 바뀌더라도 들어갈 때 잡은 객체에 대해 Exit를 호출하기 위해서입니다. 이래서 락 객체는 readonly여야 합니다 — 어차피 컴파일러는 처음 잡은 객체 기준으로 닫으므로, 도중에 바꾸면 동기화가 깨집니다.
  2. bool lockTakenEnter에 ref로 전달됩니다. Monitor.Enter가 락을 실제로 잡은 직후 lockTaken = true로 셋팅됩니다. 이렇게 하는 이유는 Enter 호출 자체가 ThreadAbortException 같은 비동기 예외로 중단되더라도 lockTaken이 정확히 락 점유 여부를 반영하게 하기 위해서입니다. finally는 락을 잡은 경우에만 Exit를 호출합니다.

IL로 확인 — Monitor.Enter/Exit가 진짜 들어가는지

[/il-analysis] Counter.Increment 메서드의 IL을 디컴파일하면 다음 골격이 나옵니다.

IL
.method public hidebysig instance void Increment () cil managed
{
    .locals init (
        [0] object obj,
        [1] bool lockTaken)

    // obj = this._sync
    ldarg.0
    ldfld      object Counter::_sync
    stloc.0

    // lockTaken = false
    ldc.i4.0
    stloc.1
    .try
    {
        // Monitor.Enter(obj, ref lockTaken)
        ldloc.0
        ldloca.s   lockTaken
        call       void [System.Threading]System.Threading.Monitor::Enter(object, bool&)

        // _value++
        ldarg.0
        dup
        ldfld      int32 Counter::_value
        ldc.i4.1
        add
        stfld      int32 Counter::_value

        leave.s    END
    }
    finally
    {
        // if (lockTaken) Monitor.Exit(obj)
        ldloc.1
        brfalse.s  EXIT
        ldloc.0
        call       void [System.Threading]System.Threading.Monitor::Exit(object)
        EXIT: endfinally
    }
    END: ret
}

세 가지를 확인할 수 있습니다.

  • lock 키워드의 IL 명령어는 따로 없습니다. call Monitor::Enter + call Monitor::Exit 두 개로 구성된 일반 메서드 호출일 뿐입니다.
  • .try/finally 블록이 실제로 들어가서, 임계 구역에서 예외가 터져도 Monitor.Exit가 반드시 실행됩니다.
  • Enter(object, ref bool) 오버로드를 씁니다. 옛날(C# 3 이전) 컴파일러는 Enter(object) 오버로드를 썼는데, 이 경우 "Enter는 성공했는데 직후 예외가 나서 lockTaken을 false로 인식"하는 미세한 누수가 있었습니다. ref 오버로드가 그 구멍을 막은 것입니다.

Increment 메서드의 정확성 — 락 안의 ++ 는 안전한가

lock(_sync) 안에서 _value++는 안전합니다. 다른 스레드는 lock이 풀릴 때까지 대기하고, 락 안에 있는 스레드는 read → +1 → write를 끊김 없이 실행한 뒤 락을 풉니다. 다른 스레드가 끼어들 틈이 없습니다.

다만 카운터 한 개만 증가시키는 단순한 경우라면 Interlocked.Increment(ref _value)가 락 없이 더 빠릅니다. lock여러 줄의 작업을 묶어서 일관성을 지켜야 할 때 진가를 발휘합니다 (예: 큐에서 dequeue + 처리 카운트 증가 + 마지막 처리 시각 기록).


[락 객체 선택의 규칙] 왜 private readonly object인가

lock은 임의의 참조 타입 객체를 받습니다. 그래서 다음 코드는 모두 컴파일은 됩니다.

C#
lock (this)                  { /* ... */ }   // ❌ 위험
lock (typeof(MyClass))       { /* ... */ }   // ❌ 위험
lock ("global_lock")         { /* ... */ }   // ❌❌ 매우 위험
lock (_publicField)          { /* ... */ }   // ❌ 위험
lock (_privateReadonlyObj)   { /* ... */ }   // ✓ 권장

C# 13부터는 후자에 가깝게 강제하는 새 타입 System.Threading.Lock도 등장했지만(다음 글에서 다룹니다), object 기반의 고전 lock 문에서는 여전히 개발자가 규칙을 지켜야 합니다.

왜 어떤 락 객체는 위험한가

1) lock(this) — 외부에 노출된 자물쇠

this는 클래스 인스턴스 자신입니다. 그런데 그 인스턴스는 보통 외부 코드가 변수로 들고 있는 객체입니다. 외부 코드가 우연히 또는 악의적으로 그 인스턴스에 lock을 걸 수 있다는 뜻입니다.

C#
public class Cache
{
    public void Refresh() { lock (this) { /* ... */ } } // 내부적으로 this 잠금
}

// 어딘가의 외부 코드
var cache = container.GetCache();
lock (cache) { Thread.Sleep(10000); } // 같은 객체를 외부가 10초 점유
// 이 사이 cache.Refresh()는 모두 블록됨 — 락 탈취(lock hijacking)

이걸 "내가 만든 자물쇠인데 다른 사람이 자기 마음대로 잠그고 안 풀어주는" 상황이라고 비유할 수 있습니다.

2) lock(typeof(X)) — 자물쇠가 전역으로 공유

typeof(X)Type 객체를 돌려주는데, 이 객체는 AppDomain 안에서 클래스당 하나만 존재합니다. 즉 같은 typeof(MyClass)는 어디서 부르든 같은 객체입니다.

C#
class Foo
{
    public static void Op1() { lock (typeof(Foo)) { /* ... */ } } // 같은 Type을 잡음
}

class Bar  // 완전히 다른 클래스, 같은 라이브러리도 아님
{
    public static void Op2() { lock (typeof(Foo)) { /* ... */ } } // 같은 Type을 잡음
}

Foo.Op1Bar.Op2는 서로 무관해 보이지만 사실상 같은 자물쇠를 두고 경쟁합니다. 어느 라이브러리가 어떤 Type을 잡고 있을지 예측할 수 없으므로 데드락 추적이 매우 어려워집니다.

3) lock("문자열 리터럴") — 가장 위험한 안티패턴

.NET은 메모리 효율을 위해 문자열 인터닝(interning)을 합니다. 컴파일 타임에 등장한 동일한 리터럴은 런타임에서 같은 단 하나의 string 인스턴스로 합쳐집니다.

C#
// 두 메서드, 두 라이브러리에 흩어져 있음
void A() { lock ("global_lock") { /* ... */ } }
void B() { lock ("global_lock") { /* ... */ } }

A"global_lock"B"global_lock"메모리상 같은 객체입니다. NuGet으로 받은 라이브러리가 우연히 같은 문자열을 락으로 쓰면 우리 앱과 충돌합니다. 디버깅이 사실상 불가능합니다.

4) private readonly object _sync = new(); — 정답

  • private: 외부가 같은 객체에 접근할 방법이 없으니 락 탈취가 원천 차단됩니다.
  • readonly: 한 번 만들어진 락 객체가 다른 객체로 교체되지 않으므로, "들어갈 때 잡은 객체"와 "나갈 때 푸는 객체"가 항상 동일합니다.
  • object: 의미 없는 객체이므로 누구도 우연히 같은 객체를 잡을 일이 없습니다.
  • new(): 인스턴스마다 락도 별개입니다. Counter 두 개를 만들면 락도 두 개입니다 — 한 인스턴스의 락이 다른 인스턴스를 막지 않습니다.
정적(static) 필드를 잠그려면 락 객체도 static으로 둡니다 (private static readonly object _staticSync = new();). 인스턴스 락으로는 클래스 전체의 정적 상태를 보호할 수 없습니다.

값 타입은 락 객체로 쓸 수 없다

C#
int x = 42;
lock (x) { /* ... */ } // 컴파일 에러 CS0185: 'lock' 문에 사용된 식은 참조 형식이어야 합니다.

만약 컴파일러가 박싱(boxing)을 해서 통과시킨다면 매번 새로운 박스 객체가 만들어져 동기화가 전혀 일어나지 않을 것입니다. 컴파일러는 그래서 아예 거부합니다.


[락 안에서 await 금지] 스레드 소유권이라는 모델

C#
async Task FetchAndStore()
{
    lock (_sync)
    {
        await SomeApiAsync(); // ❌ CS1996: 'lock' 문 본문에서는 await을 사용할 수 없습니다.
        _cache = result;
    }
}

C# 컴파일러는 이 코드를 컴파일하지 않습니다. 이유는 Monitor의 동작 모델에 있습니다.

Monitor의 스레드 소유권(Thread Affinity) 락을 획득한 스레드만이 그 락을 해제할 수 있다. 스레드 A가 Monitor.Enter를 호출했다면, Monitor.Exit도 반드시 스레드 A에서 호출되어야 한다. 다른 스레드가 Exit을 호출하면 SynchronizationLockException이 던져진다.

async/await은 이 모델을 깨뜨릴 수 있습니다.

왜 lock 안의 await가 위험한가 — 스레드가 바뀐다

await SomeApiAsync() 지점에서 메서드는 일단 반환됩니다. API 응답이 도착해 콜백이 재개될 때, 그 콜백을 실행하는 스레드는 Task 라이브러리가 결정합니다 — ThreadPool 스레드일 수도 있고, SynchronizationContext가 살아 있다면 원래 스레드일 수도 있지만 보장은 없습니다. 다른 스레드가 Monitor.Exit을 호출하면 즉시 예외가 나거나, 더 나쁘게는 락이 영원히 풀리지 않습니다.

비동기 임계 구역의 정답 — SemaphoreSlim

SemaphoreSlim은 스레드 소유권 개념이 없습니다. "토큰 N개를 가진 풀"이라는 모델이라, 어떤 스레드가 WaitAsync로 토큰을 가져갔든 다른 스레드가 Release해도 됩니다. 그래서 await 와 안전하게 결합할 수 있습니다.

C#
public class AsyncCache
{
    private readonly SemaphoreSlim _gate = new(1, 1); // 카운트 1 = 사실상 mutex
    private string? _cached;

    public async Task<string> GetAsync(CancellationToken ct = default)
    {
        await _gate.WaitAsync(ct);   // ✓ 비동기 진입
        try
        {
            _cached ??= await FetchFromApiAsync(ct); // ✓ 안에서 await 가능
            return _cached;
        }
        finally
        {
            _gate.Release();
        }
    }
}

핵심 차이:

  lock (Monitor) SemaphoreSlim
소유권 모델 스레드 단위 토큰 단위 (스레드 무관)
await 가능 ❌ 컴파일 에러 WaitAsync
재진입 같은 스레드는 OK 같은 스레드여도 토큰 새로 필요
무경합 비용 매우 낮음 lock보다 약간 높음
퀴즈 — Unity에서 await UnityWebRequest.SendWebRequest() 다음 줄로 돌아왔을 때 어떤 스레드인가? 답은 다음 글들에서 다루는 SynchronizationContext 챕터에 있지만, 한 줄로 말하면 "기본 설정이라면 메인 스레드, ConfigureAwait(false) 하면 풀 스레드"입니다. 메인 스레드라도 lock을 풀고 들어왔다는 보장은 없으니 답은 변하지 않습니다 — 여전히 lock 안의 await은 금지입니다.

[데드락] 두 락을 다른 순서로 잡으면 영원히 풀리지 않는다

C#
private readonly object _lockA = new();
private readonly object _lockB = new();

void Worker1()
{
    lock (_lockA)
    {
        Thread.Sleep(10);   // Worker2가 _lockB 잡을 시간 확보
        lock (_lockB) { /* ... */ }  // _lockA 가진 채 _lockB를 기다림
    }
}

void Worker2()
{
    lock (_lockB)
    {
        Thread.Sleep(10);
        lock (_lockA) { /* ... */ }  // _lockB 가진 채 _lockA를 기다림
    }
}

Worker1A → B 순서로, Worker2B → A 순서로 락을 잡습니다. 두 스레드가 거의 동시에 외부 락을 잡고 안쪽 락을 기다리면 둘 다 영원히 대기합니다. 데드락입니다.

데드락 — 락 획득 순서가 뒤바뀌면 양쪽이 영원히 대기

데드락을 막는 두 가지 정석:

1) 락 획득 순서를 통일

모든 스레드가 항상 같은 순서로 락을 잡도록 정합니다. 예를 들어 _lockA_lockB보다 먼저 잡도록 코드 리뷰에서 강제합니다.

C#
void Worker1() {
    lock (_lockA) lock (_lockB) { /* ... */ }   // A → B
}
void Worker2() {
    lock (_lockA) lock (_lockB) { /* ... */ }   // A → B (순서 통일)
}

2) Monitor.TryEnter로 타임아웃 도입

lock 키워드는 무한 대기만 지원합니다. 타임아웃이 필요하면 Monitor.TryEnter를 직접 씁니다.

C#
bool taken = false;
try
{
    Monitor.TryEnter(_sync, TimeSpan.FromSeconds(2), ref taken);
    if (!taken)
    {
        Debug.LogWarning("락 획득 실패 — 데드락 의심, 자원 반납");
        return;  // 다른 락도 풀고 후퇴 → 데드락 회피
    }
    // 임계 구역
}
finally
{
    if (taken) Monitor.Exit(_sync);
}

가장 좋은 데드락 대책은 락을 두 개 동시에 잡지 않는 것입니다. 자료 구조 설계 단계에서 락의 영역을 좁게 잡고, 한 락 안에서 다른 락이 필요한 콜백을 호출하지 않습니다.


[성능 특성] 무경합은 매우 싸고, 경합은 점점 비싸진다

Thin Lock → Fat Lock 승격

CLR은 lock이 거의 항상 무경합 상태에서 사용된다는 통계적 사실을 활용해 두 단계 구조를 씁니다.

Monitor의 두 단계 — Thin Lock에서 Fat Lock으로

무경합 시 (Thin Lock):

  • 락이 비어 있으면 CLR은 객체 헤더의 sync 비트를 CAS(Compare-And-Swap) 한 번으로 잡습니다.
  • 같은 스레드가 다시 잡으면 재진입 카운트만 1 증가시키고 끝납니다.
  • 측정해 보면 한 번의 lock { } enter/exit이 보통 수십 나노초 수준입니다. _value++ 한 줄을 보호하는 비용으로는 충분히 작습니다.

경합 시 (Fat Lock):

  • 다른 스레드가 점유 중이면 CLR은 짧은 시간 동안 spin-wait(busy loop)을 합니다. 락이 매우 짧게만 점유되는 흔한 경우라면 컨텍스트 스위치 없이 끝납니다.
  • spin-wait 시간 안에 락이 풀리지 않으면 OS 커널의 동기화 객체를 사용해 스레드를 sleep 시킵니다. 이때 객체의 SyncBlock이 별도로 할당되며 락은 "fat lock"으로 승격됩니다.
  • 일단 fat lock으로 승격되면 enter/exit 비용이 마이크로초 단위로 늘어나고, 컨텍스트 스위치 비용까지 더해집니다. 한번 승격된 락은 보통 다시 thin으로 내려가지 않습니다.

정리: 락은 짧게 머물수록 빠르다

  • 락 안에서 I/O, 네트워크, 콘솔 출력을 하지 않습니다 — 점유 시간을 ms 단위로 늘립니다.
  • 락 안에서 이벤트 발생, 콜백 호출을 하지 않습니다 — 외부 코드가 거기서 무엇을 할지 모르기 때문입니다 (또 다른 락을 잡으면 데드락).
  • 락 안에서 할당이 큰 작업(new T[10000])을 하지 않습니다.
  • 가능하면 락 안의 작업은 한 두 줄로 끝나도록 자료 구조를 설계합니다. 락 밖에서 준비물을 모두 만들고, 락 안에서는 "한 번에 바꿔치기"만 합니다.

[Unity 실전 패턴] 메인 스레드 모델과 lock의 사용처

Unity에서는 Transform, GameObject, MonoBehaviour 같은 엔진 객체를 메인 스레드(Update 스레드)에서만 만질 수 있습니다. 백그라운드 스레드가 직접 transform.position = ...을 하면 즉시 던져지는 예외나, 더 나쁘게는 데이터 손상이 발생합니다.

그래서 Unity에서 lock이 등장하는 자리는 보통 다음 세 가지입니다.

1) 메인 스레드 디스패치 큐 (Action Queue)

백그라운드 스레드(Task.Run, 네트워크 콜백, 빌트인 UnityWebRequest의 일부 콜백)에서 메인 스레드에 작업을 위임할 때 쓰는 큐입니다.

C#
public class MainThreadDispatcher : MonoBehaviour
{
    private static MainThreadDispatcher _instance;
    private readonly Queue<Action> _queue = new();
    private readonly object _sync = new();

    public static void Enqueue(Action action)
    {
        // 어느 스레드에서 호출되든 안전해야 함
        lock (_instance._sync)
        {
            _instance._queue.Enqueue(action);
        }
    }

    private void Update()
    {
        // 매 프레임 메인 스레드에서 한 번씩 비웁니다
        while (true)
        {
            Action next;
            lock (_sync)
            {
                if (_queue.Count == 0) return;
                next = _queue.Dequeue();
            }
            // ⚠ 락 밖에서 실행 — 콜백이 다시 Enqueue를 호출해도 데드락 없음
            try { next(); }
            catch (Exception e) { Debug.LogException(e); }
        }
    }
}

핵심은 Dequeue는 락 안에서, 실행(next())은 락 밖에서 한다는 점입니다. 콜백이 무거울 수도 있고, 콜백 안에서 다시 Enqueue가 호출될 수도 있어서 락을 잡은 채 콜백을 부르면 위 두 시나리오 모두에서 막힙니다.

2) 백그라운드 스레드 풀에서의 객체 풀링

게임에서 자주 만들어지는 작은 데이터 객체(PathfindingResult, DamageEvent 등)를 GC 압박 없이 재사용하려면 풀이 필요합니다. 풀 자체는 여러 스레드가 동시에 빌리고 반납하므로 보호가 필요합니다.

C#
public class ResultPool<T> where T : new()
{
    private readonly Stack<T> _stack = new();
    private readonly object _sync = new();

    public T Rent()
    {
        lock (_sync)
        {
            if (_stack.Count > 0) return _stack.Pop();
        }
        // 락 밖에서 new — 객체 생성 비용이 락 점유 시간에 끼지 않음
        return new T();
    }

    public void Return(T item)
    {
        lock (_sync) { _stack.Push(item); }
    }
}

여기서도 new T()락 밖에서 한다는 점이 중요합니다. 풀 안의 데이터를 만지는 시간은 매우 짧고, 객체 생성은 GC가 끼어들 수 있는 비용 큰 작업입니다.

3) 로깅 큐 (정확하지만 짧은 임계 구역)

여러 스레드가 동시에 로그를 찍는 상황에서 파일 I/O를 직접 하면 충돌이 납니다. 큐에 모아 두고 별도 스레드가 비우는 패턴이 표준입니다.

C#
public class LogBuffer
{
    private readonly Queue<string> _queue = new();
    private readonly object _sync = new();

    public void Add(string line)
    {
        lock (_sync) { _queue.Enqueue(line); }    // 짧다
    }

    public string[] Drain()
    {
        lock (_sync)
        {
            if (_queue.Count == 0) return Array.Empty<string>();
            var arr = _queue.ToArray();
            _queue.Clear();
            return arr;
        }
    }
}

Drain은 별도 스레드가 1초에 한 번 돌면서 호출하고, 받은 배열을 락 밖에서 디스크에 씁니다. 락 점유 시간이 큐의 크기와 무관하지는 않지만 메모리 복사 한 번이라 매우 짧습니다.

Unity에서 피해야 할 패턴

C#
// ❌ 메인 스레드 객체를 백그라운드에서 lock으로 보호한다고 안전해지지 않음
lock (_sync) { transform.position = newPos; }
// → Unity는 transform 접근 자체를 메인 스레드에서만 허용. lock으로는 풀리지 않음.

// ❌ static 라이브러리(예: Resources)를 lock으로 직접 보호
lock (typeof(Resources)) { ... }
// → Resources는 Unity 내부에서 자체 동기화. 외부 lock은 의미 없고 데드락 위험만 큼.

// ❌ Coroutine 안에서 lock 잡고 yield return WaitForSeconds
IEnumerator Foo() {
    lock (_sync) {
        yield return new WaitForSeconds(1); // ← yield는 컴파일러에서 메서드를 쪼갠다.
    }                                       //    yield 이후 부분은 다음 프레임에 다시 호출되며,
}                                           //    그 사이 lock 상태는 정의되지 않음. 사실상 await의 친척.
코루틴은 메인 스레드에서만 실행되므로 보통 lock이 필요 없습니다. 락이 필요하다면 그건 다른 스레드와 데이터를 주고받는 부분 — 그 부분은 코루틴이 아니라 일반 메서드여야 합니다.

[다른 동기화 도구와의 비교] lock이 답이 아닌 경우

상황 도구 이유
단순 카운터·플래그 증감 Interlocked 락 없이 CPU 명령어 한 두 개로 처리. lock보다 5~10배 빠름
비동기 코드의 임계 구역 SemaphoreSlim + WaitAsync 스레드 소유권 없음 → await 가능
읽기 많고 쓰기 적은 캐시 ReaderWriterLockSlim 동시 읽기를 허용해 처리량 증가
임계 구역이 매우 짧고 경합 거의 없음 SpinLock (전문가용) 커널 진입 회피, 단 잘못 쓰면 CPU 낭비
한 시점에 N개 스레드 허용 SemaphoreSlim(N, N) 자원 풀 모델
여러 줄을 묶어 일관성 보장 lock 가장 단순하고 안전
Interlocked vs lock _counter++ 같은 단일 연산이라면 Interlocked.Increment(ref _counter)가 정답입니다. if (_count > 0) _count-- 처럼 "검사 후 수정"이 두 줄에 걸쳐 있다면 lock 또는 Interlocked.CompareExchange 루프가 필요합니다.
ConcurrentQueue vs lock + Queue 단순히 큐를 보호하는 거라면 ConcurrentQueue<T>가 락 없이 (대부분의 경우) 동작합니다. lock + Queue<T> 패턴은 "큐 외에도 여러 필드를 같이 일관되게 바꿔야 할 때" 가치가 있습니다.

[자주 하는 실수와 해법]

실수 증상 해법
lock(this) 외부에서 락 탈취 가능, 디버깅 불가 데드락 private readonly object _sync
lock("문자열 리터럴") 모르는 라이브러리와 락 공유 같음
락 객체를 new로 매번 생성 동기화가 전혀 안 됨 객체를 필드로 한 번만 만들기
락 안에서 await CS1996 컴파일 에러 SemaphoreSlim.WaitAsync
락 안에서 이벤트/콜백 호출 외부 코드가 다른 락을 잡으면 데드락 락 밖에서 호출, 락 안에서는 데이터만 복사
락 객체가 static이 아닌데 정적 데이터 보호 인스턴스마다 락이 달라 동기화 실패 private static readonly object _staticSync
락 안에서 Thread.Sleep / I/O 다른 스레드들이 길게 대기, 처리량 급락 I/O는 락 밖, 락 안에선 메모리 작업만
두 락을 다른 순서로 획득 데드락 락 순서 통일 또는 Monitor.TryEnter 타임아웃
lock이 필요 없는데 그냥 다 거는 습관 처리량 저하, 무경합이라도 비용 발생 진짜 공유 상태인지 먼저 따져 보기

[버전별 변화]

  • C# 1.0 (2002): lock 키워드 도입. Monitor.Enter + try/finally + Monitor.Exit 로 단순 변환.
  • C# 4.0 (2010): 컴파일러가 Enter(object, ref bool lockTaken) 오버로드를 사용하도록 변경. Enter 호출 직후 비동기 예외(예: ThreadAbortException)가 발생해도 락 상태가 정확히 추적되도록 안전성이 강화됨.
  • C# 5.0 (2012): async/await 도입과 함께 lock 본문에서 await 사용 시 컴파일러 에러(CS1996)가 명시화됨. 비동기 임계 구역에는 SemaphoreSlim.WaitAsync 권장.
  • .NET 8: 64비트 타깃에서 객체 헤더가 변경되며 thin lock 인코딩이 미세 조정됨. 사용자 입장에선 큰 차이 없음.
  • .NET 9 / C# 13 (2024): System.Threading.Lock 타입 도입. lock 문이 일반 object 대신 이 새 타입을 받으면 컴파일러가 Lock.EnterScope() 기반의 새로운 패턴으로 변환해 더 빠르고 더 안전한 동기화를 제공한다. 다음 글(13)에서 자세히 다룬다. 단, 이 글에서 살펴본 고전 lock(object) 패턴은 .NET 9 이후에도 그대로 유효하며, 기존 코드를 바꿀 필요는 없다.

[요약]

  • lock (obj)는 컴파일러가 Monitor.Enter/Exit + try/finally로 풀어 주는 문법 설탕이다. IL 레벨에서는 메서드 호출 두 개와 try 블록 하나일 뿐이다.
  • 락 객체는 항상 private readonly object _sync = new(); 형태로 선언한다. this, typeof(X), 문자열 리터럴은 외부와 락을 공유하게 만들어 데드락의 원인이 된다.
  • 락 안에서는 await 금지다 — Monitor는 스레드 소유권 모델이라 콜백을 다른 스레드가 받으면 Monitor.Exit이 실패한다. 비동기에는 SemaphoreSlim.WaitAsync를 쓴다.
  • 락 안에서는 짧게 머문다 — I/O, 콜백, 이벤트, 큰 할당을 락 안에서 하지 않는다. 무경합이면 매우 싸지만, 한 번 fat lock으로 승격되면 비용이 마이크로초 단위로 뛴다.
  • Unity에서는 메인 스레드 디스패치 큐, 객체 풀, 로깅 큐 같은 워커-메인 사이의 데이터 채널lock을 쓴다. Unity 엔진 객체 자체는 lock으로 보호되지 않는다 — 메인 스레드에서만 만져야 한다.
  • 단순 카운터는 Interlocked, 비동기는 SemaphoreSlim, 읽기 위주는 ReaderWriterLockSlim — 상황에 맞는 도구를 고른다. lock은 "여러 줄을 묶어 일관성을 보장"하는 자리에 가장 잘 맞는다.
반응형
반응형

[PART13.비동기와 스레딩 기초(11/15)] 비동기 스트림 — IAsyncEnumerable<T> · await foreach (C# 8)

한 번에 한 건씩 비동기로 흘려보내는 데이터 스트림 / yield return + await의 결합 / [EnumeratorCancellation]으로 안전하게 멈추는 법

[문제 제기] 1만 건의 로그를 모두 메모리에 올리고 시작할 것인가

Unity 모바일 게임 클라이언트에서 서버의 운영 로그 파일을 한 줄씩 화면에 표시한다고 상상해 봅시다. 로그가 1만 줄이라면, 우리는 흔히 이렇게 씁니다.

C#
// Unity 화면: "로그 보기" 버튼을 누르면 로그를 가져와 표시
public async Task LoadLogsAsync()
{
    List<string> lines = await ReadAllLinesAsync(path); // 1만 줄을 한꺼번에 List에 담기
    foreach (var line in lines)
    {
        AppendToScrollView(line);
    }
}

이 코드의 문제는 두 가지입니다.

  1. 첫 줄이 화면에 뜨기까지 1만 줄이 모두 도착할 때까지 기다린다. 사용자는 그동안 빈 화면을 봅니다.
  2. List<string> 1만 개가 한꺼번에 힙에 할당된다. Boehm GC(Unity의 가비지 컬렉터)가 이걸 회수하느라 GC 스파이크가 튀고, 모바일에서 프레임 드랍이 일어납니다.

같은 문제가 채팅 메시지 구독, 네트워크 응답 스트리밍, 센서 데이터 수신 등 "끝없이 도착하는 데이터" 전반에서 발생합니다. 우리는 데이터가 준비되는 즉시 한 건씩 처리하면서도, 각 데이터가 도착하기까지의 대기 시간 동안 스레드를 놓아주는 방법이 필요합니다.

C# 8.0이 답을 줍니다 — IAsyncEnumerable<T>await foreach. 이 글에서는 이 둘이 컴파일러에 의해 어떤 상태머신으로 변환되는지, [EnumeratorCancellation]으로 어떻게 안전하게 취소하는지, Unity 핫패스에서 어떻게 적용해야 하는지를 IL 레벨까지 파헤쳐 봅니다.


[개념 정의] 비동기 Pull 모델 — 한 건씩 당겨오는 스트림

동기 Pull, 비동기 Pull, 비동기 Push의 차이

async — 비동기 메서드 (Asynchronous method) 메서드 안에서 await를 사용할 수 있게 해주는 키워드. 컴파일러가 이 메서드를 상태머신으로 변환해 await 지점에서 스레드를 놓아주고, 결과가 준비되면 다시 이어서 실행한다.
예시: async Task<int> FetchAsync() { ... } 호출자에게 즉시 Task를 돌려주고, 메서드 본문은 비동기로 실행된다.
yield return — 반복기 반환 (Iterator return) 메서드를 한 번에 끝내지 않고, 호출자가 다음 값을 요청할 때마다 한 건씩 값을 돌려주는 구문. 컴파일러가 메서드를 반복기 상태머신으로 변환해 yield return 지점에서 잠시 멈추고, 다음 호출 때 그 자리부터 이어서 실행한다.
예시: IEnumerable<int> Numbers() { yield return 1; yield return 2; } Numbers()를 foreach로 돌면 1, 2를 차례로 받는다.

IAsyncEnumerable<T>는 위 두 키워드(async, yield return)를 한 메서드 안에서 동시에 쓸 수 있게 해줍니다. 즉 비동기로 한 건씩 값을 흘려보내는 메서드를 만들 수 있습니다.

데이터 시퀀스의 세 가지 모델

핵심은 소비자가 다음 값을 원할 때만 생산자가 한 발짝 움직인다는 점입니다. 이를 풀(Pull) 기반 모델이라 부르고, 소비자 처리 속도가 곧 생산자 속도가 되므로 백프레셔(Backpressure, 생산 속도가 소비 속도를 넘을 때 데이터가 쌓이는 압력) 가 자연스럽게 제어됩니다.

가장 단순한 예시 — 한 줄씩 파일 읽기

문제 제기에서 제기한 1만 줄 로그 문제를 비동기 스트림으로 풀면 다음과 같습니다.

C#
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;

public class LogReader
{
    // 생산자: 한 줄씩 비동기로 흘려보낸다
    public static async IAsyncEnumerable<string> ReadLinesAsync(string path)
    {
        using var reader = new StreamReader(path);
        string? line;
        while ((line = await reader.ReadLineAsync()) is not null)
        {
            yield return line; // 한 줄 도착 즉시 소비자에게 전달
        }
    }

    // 소비자: 한 줄 도착 즉시 화면에 추가
    public static async Task DisplayAsync(string path)
    {
        await foreach (var line in ReadLinesAsync(path))
        {
            System.Console.WriteLine(line);
        }
    }
}

쉽게 말하면 — ReadLinesAsync는 한 줄을 비동기로 읽고, yield return으로 한 줄 토해내고, 다음 호출이 오기 전까지 멈춰 있습니다. 호출자(await foreach)는 한 줄을 받자마자 화면에 추가하고, 다 처리하면 다음 줄을 요청합니다. 첫 줄은 1만 줄이 다 도착하기 전에 이미 화면에 떠 있습니다.

기술 정의로는 — IAsyncEnumerable<T>MoveNextAsync()(다음 값 요청)와 Current(현재 값) 두 멤버를 가진 IAsyncEnumerator<T>를 만들어주는 인터페이스이고, await foreach는 컴파일러가 이 둘을 호출하는 비동기 루프로 변환해주는 구문입니다.

같은 메서드의 IL — 상태머신이 따로 만들어진다

ReadLinesAsync처럼 메서드를 짜면, 컴파일러는 두 개의 별도 클래스 + 한 개의 외부 메서드를 생성합니다.

IL
// 생산자 메서드 자체는 사실상 wrapper다 — 진짜 코드는 상태머신에 있다
.method public hidebysig static class System.Collections.Generic.IAsyncEnumerable`1<string>
    ReadLinesAsync(string path) cil managed
{
    // 컴파일러가 만든 상태머신 인스턴스를 반환만 한다
    .custom instance void System.Runtime.CompilerServices.AsyncIteratorStateMachineAttribute::.ctor(...) = (...)
    newobj instance void '<ReadLinesAsync>d__0'::.ctor(int32) // ← 상태머신 생성
    ret
}

// 컴파일러가 자동 생성한 중첩 클래스 (이게 핵심)
.class nested private auto ansi sealed beforefieldinit '<ReadLinesAsync>d__0'
    extends System.Object
    implements
        IAsyncEnumerable`1<string>,
        IAsyncEnumerator`1<string>,    // ← 둘 다 구현
        IAsyncDisposable,
        IAsyncStateMachine
{
    .field public int32 '<>1__state'                        // 상태 머신의 현재 단계
    .field public AsyncIteratorMethodBuilder '<>t__builder' // 비동기 빌더 (Task용 빌더와 다른 전용 빌더)
    .field public ManualResetValueTaskSourceCore`1<bool>
        '<>v__promiseOfValueOrEnd'                          // ValueTask<bool>의 백킹
    .field private string '<>2__current'                    // Current 속성 값
    .field private bool '<>w__disposeMode'                  // DisposeAsync 호출됐는지 표시
    // ...
}

해설:

  • AsyncIteratorMethodBuilder — 일반 async Task 메서드가 쓰는 AsyncTaskMethodBuilder다른 전용 빌더입니다. 비동기 반복기 전용으로, MoveNextAsync()마다 한 번씩 결과(true/false)를 돌려주는 구조에 맞춰져 있습니다.
  • ManualResetValueTaskSourceCore<bool>ValueTask<bool>재사용 가능하게 만드는 값 형식 백엔드입니다. MoveNextAsync()가 매번 새 Task를 만들어 힙에 할당하는 대신, 같은 인스턴스를 리셋해 재사용합니다. 이 한 줄이 비동기 스트림의 GC 효율을 결정합니다.
  • '<>w__disposeMode'await foreach 루프가 중간에 break하거나 예외가 나면, 컴파일러가 이 플래그를 켜고 MoveNextAsync()를 한 번 더 호출해 using 블록의 Dispose까지 비동기적으로 정리합니다.

[내부 동작] 컴파일러가 만드는 상태머신 — MoveNextAsync()Current

동작 흐름 한눈에

await foreach가 컴파일러에 의해 변환되는 흐름

컴파일러는 await foreach를 어떻게 풀어쓰는가

C# 코드와 컴파일 후 동등한 코드를 나란히 봅시다.

C#
// 우리가 쓴 코드
await foreach (var line in ReadLinesAsync(path))
{
    Console.WriteLine(line);
}

// 컴파일러가 변환한 코드 (개념적으로 동등)
var enumerator = ReadLinesAsync(path).GetAsyncEnumerator(default);
try
{
    while (await enumerator.MoveNextAsync())
    {
        var line = enumerator.Current;
        Console.WriteLine(line);
    }
}
finally
{
    await enumerator.DisposeAsync();
}

이 변환의 핵심은 세 가지입니다.

  1. await enumerator.MoveNextAsync() — 한 건씩 다음 값을 요청하는 비동기 호출. ValueTask<bool>을 반환하므로 동기 완료 시 힙 할당이 없습니다.
  2. enumerator.Current — 동기 속성. MoveNextAsync()true를 반환한 직후에만 유효합니다.
  3. await enumerator.DisposeAsync() — 루프가 끝나거나 중간에 break/예외로 빠져도 비동기적으로 자원을 정리합니다.

실제 IL — 소비자(await foreach) 측 분석

소비자 메서드는 일반 async Task 메서드와 똑같이 IAsyncStateMachine 상태머신으로 변환됩니다. 다만 안에서 IAsyncEnumerator<T>를 호출한다는 점이 다릅니다.

C#
// 소비자 측 C# 원본
public static async Task ConsumeAsync()
{
    await foreach (var n in GenerateAsync())
    {
        Console.WriteLine(n);
    }
}
IL
// 컴파일러가 만든 ConsumeAsync의 상태머신 MoveNext (핵심만 발췌)
.class nested private auto ansi sealed beforefieldinit '<ConsumeAsync>d__1'
    extends System.ValueType
    implements IAsyncStateMachine
{
    .field public int32 '<>1__state'
    .field public AsyncTaskMethodBuilder '<>t__builder'
    .field private IAsyncEnumerator`1<int32> '<>7__wrap1'  // ← 열거자 보관
    // ...
}

// MoveNext 본문
IL_0012: call class IAsyncEnumerable`1<int32> BasicStream::GenerateAsync()
IL_0020: callvirt instance class IAsyncEnumerator`1<int32>
         IAsyncEnumerable`1<int32>::GetAsyncEnumerator(CancellationToken)  // ① 열거자 생성
IL_0044: callvirt instance int32 IAsyncEnumerator`1<int32>::get_Current()  // ② Current
IL_0049: call    void System.Console::WriteLine(int32)
IL_0054: callvirt instance ValueTask`1<bool>
         IAsyncEnumerator`1<int32>::MoveNextAsync()                        // ③ 다음 값 요청
IL_005c: call    instance ValueTaskAwaiter`1<bool> ValueTask`1<bool>::GetAwaiter()
IL_0064: call    instance bool ValueTaskAwaiter`1<bool>::get_IsCompleted() // 동기 완료 검사
IL_006b: ldarg.0
// ... await 지점에 멈췄다가 재개되면 ↓
IL_00aa: call    instance bool ValueTaskAwaiter`1<bool>::GetResult()
IL_00b1: brtrue.s IL_003e                                                  // true면 다시 Current로
// 루프 종료 후 finally 블록에서:
IL_00cf: callvirt instance ValueTask System.IAsyncDisposable::DisposeAsync() // ④ 비동기 정리

해설:

  • callvirt ... GetAsyncEnumerator(CancellationToken)IAsyncEnumerable<T>는 인터페이스이므로 가상 호출(callvirt)로 해석됩니다. 매개변수로 CancellationToken을 받는 점에 주목하세요. WithCancellation(token)을 쓰면 이 자리에 토큰이 들어갑니다.
  • callvirt ... MoveNextAsync() → ValueTask<bool>Task<bool>이 아니라 ValueTask<bool>입니다. 동기 완료 시(예: 캐시된 다음 값이 즉시 준비된 경우) 힙 할당이 0이 됩니다.
  • IAsyncDisposable::DisposeAsync() — 컴파일러가 finally에 자동으로 끼워 넣어, using 블록의 Dispose까지 비동기로 호출합니다. 일반 IDisposable.Dispose()와는 다른 인터페이스이므로 자원 정리도 await이 필요합니다.

생산자 측 — AsyncIteratorMethodBuilder의 정체

생산자(async IAsyncEnumerable<T> 메서드) 쪽 IL은 일반 async Task와 다른 빌더를 씁니다.

IL
// 생산자 측 IL (핵심만)
.class nested private auto ansi sealed '<GenerateAsync>d__0'
    extends System.Object
    implements
        IAsyncEnumerable`1<int32>,
        IAsyncEnumerator`1<int32>,
        IAsyncDisposable,
        IAsyncStateMachine
{
    .field public AsyncIteratorMethodBuilder '<>t__builder'                      // ← 전용 빌더
    .field public ManualResetValueTaskSourceCore`1<bool> '<>v__promiseOfValueOrEnd' // ← ValueTask 백엔드
    .field private int32 '<>2__current'                                          // ← Current 값
    .field private bool '<>w__disposeMode'                                       // ← 정리 모드 플래그
}

해설:

  • AsyncIteratorMethodBuilder — 일반 async TaskAsyncTaskMethodBuilder와 다릅니다. 한 번 시작하고 끝나는 게 아니라, 여러 번 "다음 값" 요청에 응답해야 하므로 전용 빌더가 필요합니다.
  • ManualResetValueTaskSourceCore<bool> — 매 MoveNextAsync() 호출마다 새 Task 객체를 만들지 않고, 이미 만들어둔 ValueTask 백엔드 인스턴스를 리셋해 재사용합니다. 이게 비동기 스트림이 GC를 거의 안 쓰는 이유입니다.
  • '<>w__disposeMode' — 소비자가 루프를 break하거나 예외로 빠지면, DisposeAsync()가 이 플래그를 true로 켜고 MoveNextAsync를 한 번 더 호출합니다. 그러면 상태머신이 usingDisposetry-finally의 정리 코드를 실행합니다.

요약하면 — 비동기 스트림은 두 종류의 상태머신이 만나는 구조입니다. 생산자는 AsyncIteratorMethodBuilderMoveNextAsync 호출마다 한 번씩 깨어났다 다시 자고, 소비자는 일반 async 상태머신으로 그 결과를 await합니다. 둘 모두 ValueTask 기반이므로 정상 경로에서 힙 할당이 거의 없습니다.


[실전 적용] Before/After — Unity 핫패스에서 메모리·반응성 모두 잡기

사례 1 — 대용량 데이터를 List에 모으기 vs 한 건씩 흘리기

Before — 1000개를 List에 모아 한꺼번에 반환

Unity에서 서버에서 캐릭터 인벤토리 1000개를 받아 UI에 표시하는 상황이라고 합시다.

C#
// Before: 전부 List에 담아 반환 — 첫 화면이 늦고 GC 압박이 크다
public static async Task<List<int>> ReadAllAsync()
{
    var result = new List<int>();
    for (int i = 0; i < 1000; i++)
    {
        await Task.Yield();   // 비동기 작업 시뮬레이션
        result.Add(i);
    }
    return result;
}

// 호출자
List<int> all = await ReadAllAsync();   // 1000개 다 모일 때까지 기다림
foreach (var x in all) UseInUI(x);       // 그 다음에야 UI 갱신 시작
IL
// Before의 IL (발췌)
IL_0001: newobj instance void List`1<int32>::.ctor()         // ← 힙에 List 1개 할당
.locals init ([0] valuetype '<ReadAllAsync>d__0', ...)
// 루프 1000회 동안 List에 Add
IL_004f: callvirt instance void List`1<int32>::Add(!0)
// 마지막에 List 통째로 SetResult
IL_0094: call instance void AsyncTaskMethodBuilder`1<List`1<int32>>::SetResult(!0)

해설: 마지막 SetResult가 호출될 때까지 호출자는 한 줄도 받지 못합니다. 그동안 List<int>가 1000개 항목을 담느라 내부 배열이 여러 번 재할당되고(Capacity 증가 시 새 배열로 복사), 모두 힙에 머뭅니다.

After — IAsyncEnumerable<int>로 한 건씩 흘리기

C#
using System.Runtime.CompilerServices;
using System.Threading;

// After: 한 건씩 흘려보낸다 — 첫 항목 즉시 사용 가능, GC 압박 적음
public static async IAsyncEnumerable<int> ReadStreamAsync(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    for (int i = 0; i < 1000; i++)
    {
        ct.ThrowIfCancellationRequested();
        await Task.Yield();
        yield return i;
    }
}

// 호출자
await foreach (var x in ReadStreamAsync().WithCancellation(token))
{
    UseInUI(x);   // 첫 값 즉시 사용
}
IL
// After의 IL (생산자 측 발췌)
.class nested '<ReadStreamAsync>d__0'
    implements IAsyncEnumerable`1<int32>, IAsyncEnumerator`1<int32>, IAsyncDisposable, IAsyncStateMachine
{
    .field public AsyncIteratorMethodBuilder '<>t__builder'
    .field public ManualResetValueTaskSourceCore`1<bool> '<>v__promiseOfValueOrEnd'  // ← ValueTask 재사용
    .field private int32 '<>2__current'
    .field public CancellationToken cancellationToken                                // ← 취소 토큰 보관
}

IL_0007: call valuetype AsyncIteratorMethodBuilder
         AsyncIteratorMethodBuilder::Create()                  // ← 일반 async와 다른 전용 빌더
IL_000c: stfld AsyncIteratorMethodBuilder ...::'<>t__builder'

// MoveNext 본문 — yield return마다 ValueTask 백엔드를 SetResult로 깨운다
IL_010e: ldflda ManualResetValueTaskSourceCore`1<bool> ...::'<>v__promiseOfValueOrEnd'
IL_0113: ldc.i4.1
IL_0115: call instance void ManualResetValueTaskSourceCore`1<bool>::SetResult(!0)  // 다음 값 도착 신호

해설: List<int> 인스턴스가 통째로 사라졌습니다. 그 자리를 AsyncIteratorMethodBuilder + ManualResetValueTaskSourceCore<bool> 1쌍이 차지하는데, 이 둘은 모두 값 형식이라 상태머신 구조체 안에 인라인으로 올라갑니다. 1000번의 MoveNextAsync 호출 동안 힙 할당은 사실상 상태머신 박싱 1회뿐입니다.

사례 2 — Unity 모바일 핫패스: Resources.Load를 스트리밍으로

Unity 모바일에서 100개의 텍스처를 로드해 갤러리를 보여주는 상황입니다. 한꺼번에 로드하면 메모리 스파이크와 첫 화면 지연이 모두 발생합니다.

C#
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;

public class GalleryStreamer : MonoBehaviour
{
    // After: 한 장씩 비동기 스트리밍 (UniTask 또는 Awaitable 기반)
    public static async IAsyncEnumerable<Texture2D> StreamTexturesAsync(
        IReadOnlyList<string> paths,
        [EnumeratorCancellation] CancellationToken ct = default)
    {
        foreach (var path in paths)
        {
            ct.ThrowIfCancellationRequested();

            var request = Resources.LoadAsync<Texture2D>(path);
            while (!request.isDone)
            {
                await System.Threading.Tasks.Task.Yield(); // Unity의 메인 스레드 양보
            }

            if (request.asset is Texture2D tex)
            {
                yield return tex;
            }
        }
    }

    // 소비자: 갤러리 셀에 한 장 도착할 때마다 즉시 표시
    public async Task ShowGalleryAsync(IReadOnlyList<string> paths, CancellationToken ct)
    {
        int idx = 0;
        await foreach (var tex in StreamTexturesAsync(paths).WithCancellation(ct))
        {
            cells[idx++].SetTexture(tex); // 한 장 도착 즉시 화면 업데이트
        }
    }

    public Cell[] cells = default!;
    public class Cell : MonoBehaviour { public void SetTexture(Texture2D t) {} }
}
IL
// 컴파일된 StreamTexturesAsync의 상태머신 (핵심)
.class nested '<StreamTexturesAsync>d__0'
    implements IAsyncEnumerable`1<Texture2D>, IAsyncEnumerator`1<Texture2D>, IAsyncDisposable
{
    .field public IReadOnlyList`1<string> paths
    .field public CancellationToken cancellationToken           // ← [EnumeratorCancellation]이 자동으로 채워줌
    .field private Texture2D '<>2__current'
    .field public AsyncIteratorMethodBuilder '<>t__builder'
}

// MoveNext에서 yield return 직전
IL_0093: ldarg.0
IL_0094: ldloc.s tex
IL_0096: stfld class Texture2D ...::'<>2__current'              // Current에 텍스처 1장 저장
IL_009b: ldarg.0
IL_009c: ldflda ManualResetValueTaskSourceCore`1<bool> ...::'<>v__promiseOfValueOrEnd'
IL_00a1: ldc.i4.1
IL_00a3: call instance void ManualResetValueTaskSourceCore`1<bool>::SetResult(!0)  // 소비자 깨우기
IL_00a8: ret                                                                       // MoveNext 종료

해설: 한 장 로드 → Current에 저장 → SetResult로 소비자 깨움 → MoveNext 즉시 종료. 다음 호출이 올 때까지 상태머신은 잠들어 있고, 모든 텍스처를 동시에 메모리에 올리지 않습니다. Unity의 IL2CPP(C++ 트랜스파일러) 환경에서도 동일한 상태머신 구조가 유지되며, 모바일에서 GC 스파이크가 크게 줄어듭니다.

판단 기준 — 언제 IAsyncEnumerable<T>를 쓰는가

상황 추천
데이터 양이 작고(<100), 모두 모아 한꺼번에 처리 Task<List<T>>
데이터 양이 크고(>1000), 한 건씩 처리 가능 IAsyncEnumerable<T>
끝없이 들어오는 이벤트(채팅, 센서, 웹소켓) IAsyncEnumerable<T>
페이지네이션 API — 다음 페이지를 차례로 받음 IAsyncEnumerable<T>
여러 작업을 병렬로 시작해 결과 취합 Task.WhenAll
최신 값만 중요 — 누락 허용 IObservable<T>(Rx)

[함정과 주의사항] 신입이 자주 틀리는 4가지

함정 1 — IEnumerable<Task<T>>로 착각하기

C#
// ❌ 잘못된 패턴: IEnumerable<Task<T>> — 이름은 비슷하지만 전혀 다른 동작
public static IEnumerable<Task<int>> GenerateBad()
{
    for (int i = 0; i < 10; i++)
    {
        yield return SomeAsync(i); // foreach 직후 모든 Task가 즉시 시작됨
    }
}

// 호출자 — 10개 Task가 한꺼번에 fire되어 백엔드 폭격
foreach (var task in GenerateBad())
{
    var result = await task; // 이미 시작된 Task의 결과만 기다린다
}
IL
// 잘못된 IEnumerable<Task<int>>의 IL — 일반 동기 반복기다
.method public hidebysig static class IEnumerable`1<Task`1<int32>>
    GenerateBad() cil managed
{
    newobj instance void '<GenerateBad>d__0'::.ctor(int32)  // ← 동기 반복기
    ret
}
// 상태머신은 IEnumerator`1<Task`1<int32>>만 구현 — IAsyncEnumerator 아님
C#
// ✅ 올바른 패턴: IAsyncEnumerable<int> — 한 건씩 비동기로 순차 진행
public static async IAsyncEnumerable<int> GenerateGood()
{
    for (int i = 0; i < 10; i++)
    {
        var result = await SomeAsync(i); // 한 번에 하나씩 await
        yield return result;
    }
}
IL
// 올바른 IAsyncEnumerable<int>의 IL
.method public hidebysig static class IAsyncEnumerable`1<int32>
    GenerateGood() cil managed
{
    newobj instance void '<GenerateGood>d__1'::.ctor(int32)
    ret
}
// 상태머신은 IAsyncEnumerable`1, IAsyncEnumerator`1, IAsyncDisposable, IAsyncStateMachine 모두 구현

해설: 두 IL은 반환 타입과 구현 인터페이스 자체가 다릅니다. IEnumerable<Task<T>>는 동기로 즉시 모든 Task를 fire하는 hot task 컬렉션이고, IAsyncEnumerable<T>는 소비자가 다음을 요청할 때만 한 발짝 움직이는 cold stream입니다. 이름만 보고 같다고 착각하면 백엔드에 동시 요청 폭탄을 보냅니다.

함정 2 — CancellationToken을 그냥 매개변수로 받기

C#
// ❌ 잘못된 패턴: 그냥 CancellationToken을 받기만 함 — WithCancellation()이 무용지물
public static async IAsyncEnumerable<int> ProduceBad(CancellationToken ct)
{
    for (int i = 0; i < 100; i++)
    {
        ct.ThrowIfCancellationRequested();
        await Task.Delay(100);
        yield return i;
    }
}

// 호출자
var cts = new CancellationTokenSource(500);
await foreach (var x in ProduceBad(default).WithCancellation(cts.Token))  // ← 토큰이 ct로 전달되지 않음!
{
    Console.WriteLine(x); // 500ms 후에도 멈추지 않는다
}
IL
// 잘못된 코드의 상태머신 (해당 메서드의 매개변수 보관 필드)
.field public CancellationToken ct
// → WithCancellation(token)으로 넘어온 토큰은 GetAsyncEnumerator(CancellationToken)으로
//   전달되지만, 컴파일러는 그것을 ct 필드에 자동으로 매핑할 줄 모른다.
//   ct에는 호출 시점의 default(빈 토큰)가 그대로 박혀 있다.
C#
// ✅ 올바른 패턴: [EnumeratorCancellation]을 붙여 컴파일러가 토큰을 주입하게 함
public static async IAsyncEnumerable<int> ProduceGood(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    for (int i = 0; i < 100; i++)
    {
        ct.ThrowIfCancellationRequested();
        await Task.Delay(100, ct);
        yield return i;
    }
}

// 호출자
var cts = new CancellationTokenSource(500);
await foreach (var x in ProduceGood().WithCancellation(cts.Token))  // ← 토큰이 ct에 자동 주입됨
{
    Console.WriteLine(x); // 500ms 후 OperationCanceledException으로 정상 중단
}
IL
// 올바른 코드의 매개변수 메타데이터
.param [1]
    .custom instance void EnumeratorCancellationAttribute::.ctor() = (...)  // ← 핵심 어트리뷰트
    .field public CancellationToken ct

// GetAsyncEnumerator(CancellationToken token) 안에서:
//   if (token.CanBeCanceled) {
//       this.ct = CancellationTokenSource.CreateLinkedTokenSource(callerToken, token).Token;
//   }
// → 컴파일러가 자동 생성한 코드가 호출자 토큰과 매개변수 토큰을 합쳐 ct에 주입

해설: [EnumeratorCancellation] 어트리뷰트가 핵심입니다. 이게 붙은 매개변수 자리에만 컴파일러가 WithCancellation(token)으로 받은 토큰을 자동 연결해줍니다. 어트리뷰트가 없으면 호출자가 토큰을 아무리 넘겨도 메서드 안에서는 빈 토큰만 보입니다.

함정 3 — await foreach 안에서 break하면 정리가 안 된다고 착각하기

C#
// 흔한 오해: "break하면 finally가 안 돌 거야"
await foreach (var line in ReadLinesAsync(path))
{
    if (line.StartsWith("STOP")) break; // ← 여기서 빠져나가면 파일이 안 닫히지 않을까?
    Process(line);
}
IL
// 컴파일러가 자동으로 만든 finally 블록
.try
{
    // await foreach 본문
    leave.s IL_END
}
finally
{
    callvirt instance ValueTask IAsyncDisposable::DisposeAsync()  // ← 무조건 호출됨
    ...
    endfinally
}

해설: await foreach는 컴파일러가 자동으로 try-finally를 감싸 DisposeAsync()를 보장합니다. break, return, 예외 어떤 경로로 빠져도 정리가 됩니다. 생산자 쪽 상태머신은 '<>w__disposeMode' = true를 받고 MoveNext를 한 번 더 돌려 using 블록의 Dispose까지 처리합니다.

함정 4 — Unity 메인 스레드 컨텍스트 무시 (ConfigureAwait(false) 누락)

C#
// ❌ 잘못된 패턴: 라이브러리 코드인데 메인 스레드 컨텍스트로 자꾸 돌아옴
public async IAsyncEnumerable<int> LibraryStreamBad()
{
    for (int i = 0; i < 1000; i++)
    {
        await Task.Delay(10); // ConfigureAwait 없음 → 매번 동기화 컨텍스트 캡처/복귀 비용
        yield return i;
    }
}
IL
// IL에서는 ConfigureAwait 호출이 보이지 않음 → 기본값(true)
IL_0019: call valuetype TaskAwaiter Task::GetAwaiter()
// 호출 컨텍스트(Unity 메인 스레드)가 캡처되어 매번 복귀
C#
// ✅ 올바른 패턴: 라이브러리에서는 ConfigureAwait(false)
public async IAsyncEnumerable<int> LibraryStreamGood()
{
    for (int i = 0; i < 1000; i++)
    {
        await Task.Delay(10).ConfigureAwait(false); // 컨텍스트 복귀 비용 제거
        yield return i;
    }
}

// 소비자 측에서도 한 번에 적용 가능
await foreach (var x in LibraryStreamGood().ConfigureAwait(false))
{
    // ...
}
IL
// ConfigureAwait(false) 적용 시
IL_0019: call valuetype ConfiguredTaskAwaitable ...::ConfigureAwait(bool)
IL_001e: call valuetype ConfiguredTaskAwaiter ConfiguredTaskAwaitable::GetAwaiter()
// SynchronizationContext를 캡처하지 않음 → 스레드 풀에서 그대로 재개

해설: Unity는 메인 스레드 컨텍스트가 잡혀 있으므로, 라이브러리에서 ConfigureAwait(false)를 누락하면 모든 MoveNextAsync가 메인 스레드로 복귀하느라 프레임 시간이 잠식됩니다. 다만 결과를 UI에 반영하는 소비자 측 코드에서는 메인 스레드가 필요할 수 있으므로 그쪽은 ConfigureAwait(true)(기본값)를 유지하거나, UniTask의 SwitchToMainThread를 명시적으로 사용합니다.


[C# 버전별 변화] C# 8 도입부터 .NET 9까지

C# 8.0 (.NET Core 3.0) — 최초 도입

이전에는 비동기로 시퀀스를 흘려보낼 표준 방법이 없었습니다.

C#
// Before: C# 7.x — 콜백 체인 또는 Channel<T>로 수동 구현
public static void Subscribe(Action<int> onNext, Action onComplete)
{
    Task.Run(async () =>
    {
        for (int i = 0; i < 10; i++)
        {
            await Task.Delay(100);
            onNext(i);
        }
        onComplete();
    });
}
C#
// After: C# 8 — 언어 차원 지원
public static async IAsyncEnumerable<int> ProduceAsync()
{
    for (int i = 0; i < 10; i++)
    {
        await Task.Delay(100);
        yield return i;
    }
}

await foreach (var x in ProduceAsync())
{
    Console.WriteLine(x);
}
IL
// C# 8 이후 IL에 새로 추가된 형
class System.Collections.Generic.IAsyncEnumerable`1<T>
class System.Collections.Generic.IAsyncEnumerator`1<T>
class System.IAsyncDisposable
class System.Runtime.CompilerServices.AsyncIteratorStateMachineAttribute
class System.Runtime.CompilerServices.AsyncIteratorMethodBuilder
class System.Runtime.CompilerServices.EnumeratorCancellationAttribute

해설: C# 8.0 = .NET Core 3.0 = .NET Standard 2.1이 패키지로 묶여 같이 출시되었습니다. .NET Standard 2.0 환경에서는 Microsoft.Bcl.AsyncInterfaces NuGet 패키지로 인터페이스만 가져다 쓸 수 있습니다.

C# 9.0 (.NET 5) — GetAsyncEnumerator 패턴 매칭

C# 9부터 IAsyncEnumerable<T>를 명시적으로 구현하지 않아도, GetAsyncEnumerator라는 확장 메서드만 있으면 await foreach가 동작합니다.

C#
// C# 9: 확장 메서드로 await foreach 가능
public struct MyStream { /* IAsyncEnumerable 미구현 */ }

public static class MyStreamExtensions
{
    public static IAsyncEnumerator<int> GetAsyncEnumerator(this MyStream s,
        CancellationToken ct = default) => new MyEnumerator();
}

// 호출자 — 확장 메서드만으로 동작
await foreach (var x in new MyStream())  // ← 인터페이스 구현 없이도 OK
{
    // ...
}

해설: 외부 라이브러리 타입이나 기존 Unity API를 수정 없이 비동기 스트림처럼 다룰 수 있게 되었습니다. 인터페이스 구현이라는 제약이 풀려 확장성이 커졌습니다.

.NET 6+ — Parallel.ForEachAsync 등장

순차 처리만 가능한 await foreach의 약점을 보완하는 병렬 버전이 추가됐습니다.

C#
// .NET 6 이상: 동시성 제한과 함께 병렬 처리
await Parallel.ForEachAsync(
    ProduceAsync(),                                           // IAsyncEnumerable<T>
    new ParallelOptions { MaxDegreeOfParallelism = 4 },
    async (item, ct) =>
    {
        await ProcessAsync(item, ct);
    });

해설: 한 건씩 차례로 처리하는 기본 동작 위에, "최대 4개까지 동시에 처리" 같은 제어를 얹을 수 있습니다.

.NET 9 — Task.WhenEach와의 결합

C#
// .NET 9: 여러 Task를 먼저 끝나는 순서대로 await foreach
Task<int>[] tasks = { Task1(), Task2(), Task3() };
await foreach (Task<int> completed in Task.WhenEach(tasks))
{
    var result = await completed; // 먼저 끝난 작업의 결과를 즉시 처리
    Process(result);
}

해설: 이전에는 Task.WhenAny를 루프 안에서 호출하며 직접 컬렉션을 갱신해야 했습니다. WhenEach는 그 패턴을 비동기 스트림으로 깔끔하게 표현합니다.


[정리] 비동기 스트림 핵심 체크리스트

  • [ ] 언제 쓰는가 — 데이터가 비동기로 한 건씩 도착할 때(파일 라인, 페이지네이션, 채팅, 센서). 전부 다 모아서 처리할 거면 Task<List<T>>로 충분하다.
  • [ ] 시그니처async IAsyncEnumerable<T> Method(...) + 본문에서 await ... yield return .... 반환 타입을 Task<IEnumerable<T>>IEnumerable<Task<T>>로 잘못 쓰지 않는다.
  • [ ] 상태머신 구조 — 컴파일러가 IAsyncEnumerable, IAsyncEnumerator, IAsyncDisposable, IAsyncStateMachine을 모두 구현하는 중첩 클래스를 생성한다. AsyncIteratorMethodBuilderManualResetValueTaskSourceCore<bool>이 핵심 부품이다.
  • [ ] 취소 토큰[EnumeratorCancellation] CancellationToken ct = default 형식으로 받고, 소비자는 WithCancellation(token)으로 전달. 어트리뷰트가 없으면 토큰이 메서드까지 닿지 않는다.
  • [ ] 자원 정리await foreach는 컴파일러가 자동으로 try-finally를 감싸 DisposeAsync()를 보장한다. break, 예외, return 모두 안전하다.
  • [ ] 성능ValueTask<bool> + ManualResetValueTaskSourceCore 재사용으로 정상 경로의 힙 할당이 거의 0이다. Unity 모바일 핫패스에서도 안전하게 쓸 수 있다.
  • [ ] 라이브러리 코드ConfigureAwait(false)를 잊지 않는다. Unity 메인 스레드 복귀 비용을 줄여준다.
  • [ ] 혼동 금지IEnumerable<Task<T>>(병렬 fire) ≠ IAsyncEnumerable<T>(순차 비동기 pull). 이름만 비슷할 뿐 동작 모델이 정반대다.
반응형
반응형

[PART13.비동기와 스레딩 기초(10/15)] Task의 예외 처리

async/await 안에서 던진 예외는 어디로 가는가 / Task.WhenAll의 첫 예외 함정 / AggregateException과 ExceptionDispatchInfo의 진실


한 줄 정의

async 메서드 안에서 던진 예외는 즉시 호출자에게 전달되지 않고 반환된 Task 객체 안에 저장되어 Faulted 상태가 됩니다. 호출자가 그 Taskawait하는 순간에야 예외가 다시 살아나 호출자의 try-catch로 흘러들어 갑니다.


시작하기 전에 알아야 할 핵심

  • 예외는 Task에 저장됩니다: async 메서드 안에서 발생한 예외는 곧바로 위로 전파되지 않고 반환된 Task의 상태로 보관됩니다. 그 Taskawait해야 다시 throw됩니다.
  • 스택 트레이스는 보존됩니다: 단순한 throw ex;가 아니라 ExceptionDispatchInfo(예외 발송 정보 보관 객체)를 통해 원래 발생 지점의 스택 트레이스가 그대로 살아납니다.
  • Task.WhenAll은 첫 예외만 던집니다: 여러 작업이 동시에 실패해도 await로는 첫 번째 예외 하나만 잡힙니다. 나머지를 보려면 task.Exception(AggregateException)을 직접 확인해야 합니다.
  • async void는 예외를 잡을 수 없습니다: Task가 없으니 await도 못하고, 처리되지 않은 예외는 동기화 컨텍스트로 퍼져 프로세스를 종료시킵니다. 이벤트 핸들러를 제외하면 사용하지 않습니다.
  • 관찰되지 않은 예외는 조용히 사라집니다: Taskawait하지도 Result로 받지도 않으면 그 예외는 GC가 수집할 때 UnobservedTaskException 이벤트로만 흘러갑니다. 로깅 후크가 없으면 영원히 묻힙니다.

1. 왜 이 주제가 까다로운가 — "예외가 사라졌어요"

동기 코드와 다른 점

동기 코드에서 예외는 즉시 호출 스택을 거슬러 올라갑니다. 메서드가 던지면 바로 호출자가 받고, 받지 못하면 그 위로 넘어갑니다. 흐름이 단순합니다.

async는 다릅니다. async 메서드는 도중에 await를 만나면 호출자에게 미완료 Task 하나만 돌려주고 자신은 일단 반환됩니다. 그 뒤에 발생하는 예외는 누구의 호출 스택에도 남아 있지 않습니다. 다른 스레드에서 발생할 수도 있고, 발생 시점에는 호출자가 이미 다른 일을 하고 있을 수도 있습니다.

이 차이가 신입 개발자에게 다음 같은 혼란을 만듭니다.

C#
// Unity에서 흔히 마주치는 상황
public async void OnButtonClicked()
{
    LoadDataAsync(); // ⚠️ await 빼먹음
}

private async Task LoadDataAsync()
{
    await Task.Delay(100);
    throw new InvalidOperationException("데이터 없음");
}

위 코드는 어떤 일이 벌어질까요?

  • 예외가 발생하지만 OnButtonClicked의 호출 스택에는 도달하지 않습니다.
  • try-catch로 감싸도 잡히지 않습니다.
  • 콘솔에는 아무 로그도 남지 않을 수 있습니다(미관찰 예외 정책에 따라).

비동기 예외 처리의 첫 번째 규칙은 "예외는 Task와 함께 흐른다"입니다. Task가 끊기면 예외도 끊깁니다.

Unity에서 왜 더 무서운가

Unity 모바일 게임에서는 네트워크 호출, 에셋 로드, 어드레서블 다운로드, 인앱 결제 같은 비동기 흐름이 끊임없이 등장합니다. 한 번의 잘못된 예외 처리가 다음 결과로 이어집니다.

  • 무음 실패: 데이터 로드가 실패했는데 로그가 없어 QA가 재현할 수 없습니다.
  • 상태 불일치: 트랜잭션 중간에서 끊긴 채 UI가 다음 화면으로 넘어가 NRE(NullReferenceException)를 연쇄적으로 발생시킵니다.
  • 앱 크래시: async void에서 예외가 새어 나가면 모바일 OS가 앱을 강제 종료합니다. 사용자에게는 "튕겼다"는 인상만 남습니다.
  • 재현 불가능한 GC 시점 크래시: 미관찰 예외가 GC 시점에 보고되어 원래 발생 위치와 무관한 곳에서 알림이 뜹니다.

비동기 예외를 제대로 다루지 않으면 디버깅의 9할은 추리 게임이 됩니다. 이 글은 그 추리를 줄이는 메커니즘 정리를 목표로 합니다.


2. 핵심 메커니즘 — Task에 예외가 저장되고 await에서 부활한다

비유로 이해하기

Task는 운송 컨테이너입니다. async 메서드 안에서 작업이 진행되다가 예외가 발생하면, 작업자는 그 예외를 컨테이너 안에 넣고 컨테이너에 빨간 라벨(Faulted 상태)을 붙입니다. 컨테이너는 호출자의 창고로 배송됩니다.

호출자가 컨테이너를 그냥 책상에 두면 아무 일도 일어나지 않습니다. 호출자가 await로 컨테이너를 여는 순간에야 안의 예외가 튀어나와 호출자의 코드에 도달합니다.

async 메서드 안에서 던진 예외가 await로 도달하기까지

가장 단순한 예시

C#
using System;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        try
        {
            await LoadAsync();
        }
        catch (InvalidOperationException ex)
        {
            // ✅ 여기서 잡힙니다
            Console.WriteLine($"잡았다: {ex.Message}");
        }
    }

    static async Task LoadAsync()
    {
        await Task.Delay(50);
        throw new InvalidOperationException("로드 실패");
    }
}

LoadAsync는 자기 안에서 예외를 던졌지만 Maintry-catch가 잡습니다. 정확히 await LoadAsync() 줄에서 다시 throw되기 때문입니다.

async — 비동기 메서드 한정자 메서드를 컴파일러가 상태 머신(State Machine)으로 변환하도록 지시합니다. 안에서 await를 사용할 수 있게 되며, 메서드의 모든 throw·반환은 Task로 감싸집니다.
예시: async Task LoadAsync() { await Task.Delay(100); } 호출자는 Task를 받고, 안에서 발생하는 예외도 그 Task에 담겨 흐릅니다.

IL로 확인 — async 메서드는 try-catch로 감싸인다

C# 컴파일러는 async 메서드를 보면 별도의 상태 머신 클래스(struct)를 만들어 작업의 진행 상태를 관리합니다. 그 안의 MoveNext 메서드는 사실상 거대한 try-catch로 둘러싸여 있어, 사용자가 던진 예외를 잡아 AsyncTaskMethodBuilder.SetException으로 Task에 저장합니다.

IL
.method private hidebysig instance void MoveNext () cil managed
{
    .locals init (
        [0] int32 V_0,
        [1] class [System.Runtime]System.Exception V_1
    )

    .try
    {
        // 사용자 코드 본문 (await 분기·throw 포함)
        IL_0040: newobj  instance void [System.Runtime]System.InvalidOperationException::.ctor(string)
        IL_0045: throw    // ← 사용자가 던진 예외
    }
    catch [System.Runtime]System.Exception
    {
        IL_0050: stloc.1
        IL_0051: ldarg.0
        IL_0052: ldflda  '<>t__builder'
        // 예외를 Task로 흘려보낸다 — 호출 스택으로 던지지 않는다
        IL_0057: call instance void
                 [System.Runtime]System.Runtime.CompilerServices.AsyncTaskMethodBuilder::SetException(class System.Exception)
        IL_005c: leave.s IL_0070
    }
    IL_0070: ret
}

핵심 한 줄은 AsyncTaskMethodBuilder::SetException입니다. 사용자가 throw로 던진 예외는 호출 스택을 거슬러 올라가는 대신 Task의 상태로 변환됩니다. 이 변환이 "예외가 Task에 담긴다"의 실체입니다.

어디까지가 동기 throw인가

async 메서드 안에 await가 등장하기 전까지의 코드는 동기적으로 실행되지만, 그 부분에서 던진 예외도 똑같이 Task에 담깁니다.

C#
static async Task ValidateThenWorkAsync(string? input)
{
    if (input is null)
        throw new ArgumentNullException(nameof(input)); // ❗ 즉시 throw가 아님
    await Task.Delay(50);
}

호출자가 보기에 ValidateThenWorkAsync(null)은 즉시 예외를 던지지 않고 이미 Faulted 상태인 Task를 돌려줍니다. 호출자는 await해야만 알게 됩니다. 이는 인수 검증을 즉시 알리고 싶을 때 의도와 다른 동작이며, 뒤의 [함정] 섹션에서 분리 패턴을 다룹니다.


3. 내부 동작 — ExceptionDispatchInfo가 스택 트레이스를 보존하는 방법

단순 throw였다면 어떻게 됐을까

비동기 작업은 여러 스레드를 넘나들 수 있습니다. 만약 await가 단순히 throw exception;을 호출했다면 다음 일이 벌어집니다.

  • 원래 발생 지점의 스택 트레이스가 사라지고 await 라인의 스택 트레이스로 덮어씌워집니다.
  • IOException이 어느 파일 작업에서 발생했는지 알 수 없게 됩니다.
  • 디버거가 가리키는 위치는 항상 await 라인입니다.

이는 디버깅에 치명적입니다. 그래서 .NET은 System.Runtime.ExceptionServices.ExceptionDispatchInfo(예외 발송 정보 보관 객체)라는 우회 장치를 사용합니다.

두 단계 동작

ExceptionDispatchInfo — 스택 트레이스를 보존하는 두 단계

직접 확인하는 코드

C#
using System;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        try
        {
            await Outer();
        }
        catch (Exception ex)
        {
            Console.WriteLine(ex.ToString());
        }
    }

    static async Task Outer()
    {
        await Task.Delay(10);
        await Inner();
    }

    static async Task Inner()
    {
        await Task.Delay(10);
        throw new InvalidOperationException("진짜 발생 지점");
    }
}

실행하면 스택 트레이스에 다음 두 줄이 모두 등장합니다.

System.InvalidOperationException: 진짜 발생 지점
   at Program.Inner() in Program.cs:line 26     ← 원래 발생 지점이 살아있다
   at Program.Outer() in Program.cs:line 19     ← 호출 체인도 보존
   at Program.Main() in Program.cs:line 11

ExceptionDispatchInfo 덕분에 Inner 안의 라인 번호가 await 호출 라인에 묻히지 않고 남습니다. C# 5 이전 시절 Task.Wait()로 동기 대기하던 방식은 이 보존이 약했고, 그래서 진단이 훨씬 어려웠습니다.


4. Task.WhenAll의 첫 예외 함정

직관과 다른 동작

Task.WhenAll(여러 작업을 모두 기다리는 결합 메서드)을 await하면 하나의 예외만 던져집니다. 여러 작업이 동시에 실패해도 마찬가지입니다.

C#
using System;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        var t1 = FailAsync(50, "A 실패");
        var t2 = FailAsync(60, "B 실패");
        var t3 = FailAsync(70, "C 실패");

        try
        {
            await Task.WhenAll(t1, t2, t3);
        }
        catch (Exception ex)
        {
            // ⚠️ 첫 번째 예외 하나만 잡힙니다
            Console.WriteLine($"잡힌 예외: {ex.GetType().Name} — {ex.Message}");
        }
    }

    static async Task FailAsync(int delay, string msg)
    {
        await Task.Delay(delay);
        throw new InvalidOperationException(msg);
    }
}

출력은 다음 한 줄뿐입니다.

잡힌 예외: InvalidOperationException — A 실패

B 실패C 실패는 어디로 갔을까요? Task.WhenAll이 반환한 합쳐진 Task 안에 살아있습니다. await가 첫 번째만 꺼내 보여주고 나머지를 숨긴 것뿐입니다.

왜 이렇게 설계되었나

언어 설계자의 선택입니다. awaitAggregateException(여러 예외를 하나로 묶은 컨테이너)을 그대로 던졌다면 사용자는 항상 catch (AggregateException) + InnerExceptions 순회 코드를 써야 했을 것입니다. 절대 다수의 코드는 작업이 하나일 때를 다루므로, "단일 예외만 던지는" 단순성을 우선한 결정입니다.

대신 모든 예외를 잃어버리지는 않게 했습니다. 결합된 Task의 Exception 속성에는 AggregateException이 그대로 들어 있어 직접 꺼낼 수 있습니다.

모든 예외를 보는 두 가지 패턴

Task.WhenAll에서 모든 예외 수집하기 — 두 가지 패턴
C#
using System;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        var t1 = FailAsync(50, "A 실패");
        var t2 = FailAsync(60, "B 실패");
        var t3 = FailAsync(70, "C 실패");
        var all = Task.WhenAll(t1, t2, t3);

        try
        {
            await all;
        }
        catch
        {
            // ✅ 모든 예외를 보고 싶으면 합쳐진 Task의 Exception을 직접 꺼낸다
            if (all.Exception is AggregateException ae)
            {
                foreach (var inner in ae.Flatten().InnerExceptions)
                {
                    Console.WriteLine($"- {inner.Message}");
                }
            }
        }
    }

    static async Task FailAsync(int delay, string msg)
    {
        await Task.Delay(delay);
        throw new InvalidOperationException(msg);
    }
}

출력:

- A 실패
- B 실패
- C 실패
AggregateException — 여러 예외를 묶는 컨테이너 예외 병렬 작업처럼 한 번에 여러 예외가 발생할 수 있는 상황을 다루기 위한 타입입니다. InnerExceptions 컬렉션에 모든 예외가 들어 있고, Flatten() 메서드로 중첩된 AggregateException을 평탄화할 수 있습니다.
예시: foreach (var ex in agg.Flatten().InnerExceptions) Log(ex); 모든 자식 예외를 한 번에 순회합니다.

IL로 보는 await의 예외 처리 코드

await all; 한 줄을 IL로 보면 GetAwaiter().GetResult() 호출이 보입니다. 이 GetResult가 내부적으로 첫 예외만 꺼내는 책임자입니다.

IL
// await all; 컴파일 결과 (요약)
IL_0080: ldloca.s   awaiter
IL_0082: call instance !0 valuetype
            [System.Runtime]System.Runtime.CompilerServices.TaskAwaiter::GetResult()
// ↑ Task.Exception이 AggregateException이라도
//   여기서 InnerExceptions[0]만 ExceptionDispatchInfo로 꺼내 throw

TaskAwaiter.GetResult 내부 구현은 Task 상태를 점검하다 Faulted이면 ExceptionDispatchInfo로 첫 예외만 발사합니다. 이 동작은 모든 await에 공통이며, Task.WhenAll만의 특별한 처리가 아니라 await라는 키워드 자체의 일관된 의미론입니다.

Task.WhenAny는 다르다

Task.WhenAny(여러 작업 중 가장 먼저 끝난 하나만 기다리는 메서드)는 자체로는 절대 throw하지 않습니다. 어떤 작업이 끝났는지를 알려주는 Task<Task>를 반환할 뿐입니다. 예외가 발생한 작업이 가장 먼저 끝났다면, 그 작업을 한 번 더 await해야 예외가 도달합니다.

C#
var winner = await Task.WhenAny(t1, t2);
await winner; // 예외 처리는 여기서

이 차이는 PART 13-08 Task.WhenAll · WhenAny에서도 다룬 부분이지만, 예외 흐름 관점에서 다시 짚어둘 가치가 있습니다.


5. async void와 미관찰 예외 — 사라지는 예외의 정체

async void는 왜 위험한가

async 메서드의 반환형은 셋 중 하나입니다.

반환형 호출자가 await 가능? 예외 전달 경로
Task await 시점에 throw
Task<T> await 시점에 throw + 결과값 반환
void 동기화 컨텍스트로 직접 발사 → 처리 안 되면 프로세스 종료

async void는 반환할 Task가 없으니 호출자가 결과도 예외도 받을 수 없습니다. 처리되지 않은 예외는 호출 당시의 SynchronizationContext(또는 ThreadPool)로 던져지고, 이를 받는 사람이 없으면 .NET 런타임이 처리되지 않은 예외로 간주해 프로세스를 종료시킵니다.

async void vs async Task — 예외 흐름의 차이

실제 예시 — Unity 버튼 핸들러

C#
using UnityEngine;
using UnityEngine.UI;
using System;
using System.Threading.Tasks;

public class ShopController : MonoBehaviour
{
    [SerializeField] private Button buyButton;

    void Start() => buyButton.onClick.AddListener(OnBuyClicked);

    // ❌ Bad — async void에서 예외가 새면 앱이 죽는다
    private async void OnBuyClicked()
    {
        await PurchaseAsync(); // 네트워크 실패 시 throw
    }

    // ✅ Good — 이벤트 핸들러 자체는 async void라도, 안에서 try-catch로 감싼다
    private async void OnBuyClickedSafe()
    {
        try
        {
            await PurchaseAsync();
        }
        catch (Exception ex)
        {
            ShowErrorPopup(ex.Message);
            Debug.LogException(ex);
        }
    }

    private async Task PurchaseAsync()
    {
        await Task.Delay(100);
        throw new InvalidOperationException("결제 서버 응답 없음");
    }

    private void ShowErrorPopup(string msg) { /* ... */ }
}

Button.onClick.AddListener는 시그니처상 void만 받으므로 핸들러를 async Task로 바꿀 수 없습니다. 이때 핸들러를 async void로 두되 본문 전체를 try-catch로 감싸는 것이 표준 패턴입니다. 절대 catch에 Debug.LogException만 넣고 끝내지 말고, 사용자에게도 에러를 알리는 UI를 띄우는 것이 좋습니다.

미관찰 예외(UnobservedTaskException)

Task를 만들고 awaitResult 호출도 하지 않으면 그 Task 안의 예외는 누구의 눈에도 띄지 않습니다. 이 상태에서 Task 객체가 GC에 의해 수집되면 .NET은 TaskScheduler.UnobservedTaskException 이벤트를 발생시킵니다.

  • .NET Framework 4.0 이전: 이 이벤트가 처리되지 않으면 프로세스를 종료시켰습니다.
  • .NET 4.5 이후 (현재): 기본적으로 프로세스를 죽이지 않고 이벤트만 발생시킵니다. 이벤트 핸들러가 없으면 예외는 조용히 사라집니다.
C#
// 프로그램 진입점에서 한 번만 등록
TaskScheduler.UnobservedTaskException += (sender, e) =>
{
    Debug.LogError($"[Unobserved] {e.Exception}");
    e.SetObserved(); // 관찰됐다고 표시 — 더 이상 위로 전파하지 않음
};

// fire-and-forget 패턴 (보통은 피해야 함)
_ = LongRunningAsync(); // ← await 없이 그냥 시작

이 이벤트는 GC 시점에만 발생합니다. 작업 실패 시점이 아닙니다. 따라서 미관찰 예외를 발견했을 때 이미 한참 시간이 지난 뒤일 수 있고, 발생 위치를 정확히 추적하기 어렵습니다. 의지하기보다는 모든 Task를 누군가 책임지고 await하는 구조를 만드는 것이 우선입니다.


6. 실전 적용 — Unity 모바일에서의 패턴

패턴 1. 입력 검증을 동기 메서드로 분리

async 메서드 본문에 인수 검증을 두면 예외가 즉시 throw되지 않고 Task에 담겨 호출자에게 전달됩니다. 호출자가 await 없이 _ =로 무시하면 검증 실패가 영원히 묵살됩니다.

C#
// ❌ Bad — async 본문에 검증
public async Task SaveAsync(string? path)
{
    if (path is null)
        throw new ArgumentNullException(nameof(path)); // Task로 감싸짐
    await File.WriteAllTextAsync(path, "data");
}

// 호출자
_ = controller.SaveAsync(null); // 검증 예외가 묵살됨

// ✅ Good — 검증은 동기 래퍼, 비동기 본체는 분리
public Task SaveAsync(string? path)
{
    if (path is null)
        throw new ArgumentNullException(nameof(path)); // 즉시 throw
    return SaveCoreAsync(path);
}

private async Task SaveCoreAsync(string path)
{
    await File.WriteAllTextAsync(path, "data");
}

호출자가 _ =로 무시해도 ArgumentNullException은 동기 throw이므로 즉시 잡혀 디버그 로그에 남습니다.

패턴 2. 여러 다운로드 작업의 결과 집계

게임 시작 시 여러 어드레서블 번들을 동시에 받는 코드입니다. 한두 개가 실패해도 나머지를 살리고 실패 목록을 보고합니다.

C#
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

public sealed class AssetBootstrap
{
    private readonly List<string> _bundleKeys = new() { "ui", "stage1", "stage2", "audio" };

    public async Task<BootResult> LoadAllAsync()
    {
        var tasks = _bundleKeys
            .Select(key => (Key: key, Task: LoadBundleAsync(key)))
            .ToList();

        // 모두 끝날 때까지 기다리되, 예외는 따로 모은다
        try
        {
            await Task.WhenAll(tasks.Select(x => x.Task));
        }
        catch
        {
            // await는 첫 예외만 던지므로, 여기서 catch만 하고 실제 분류는 아래에서
        }

        var failures = tasks
            .Where(x => x.Task.IsFaulted)
            .Select(x => (x.Key, Error: x.Task.Exception!.GetBaseException()))
            .ToList();

        return new BootResult(
            Successful: tasks.Where(x => !x.Task.IsFaulted).Select(x => x.Key).ToList(),
            Failed: failures);
    }

    private async Task LoadBundleAsync(string key)
    {
        await Task.Delay(50);
        if (key == "stage2")
            throw new InvalidOperationException("stage2 번들 손상");
    }
}

public record BootResult(
    List<string> Successful,
    List<(string Key, Exception Error)> Failed);

핵심은 개별 task 객체를 보존하는 것입니다. Task.WhenAll에 흘려보내고 끝내지 않고 컬렉션에 두면 IsFaulted 검사로 어느 작업이 어떤 이유로 실패했는지 정확히 알 수 있습니다. await의 첫 예외 의미론을 우회하는 가장 깔끔한 방법입니다.

패턴 3. ConfigureAwait(false)와 라이브러리 코드

async 메서드는 기본적으로 await 직전의 SynchronizationContext(예: Unity 메인 스레드)에서 이어 실행되도록 컨텍스트를 캡처합니다. 라이브러리 내부에서까지 캡처하면 메인 스레드 데드락의 원인이 됩니다.

C#
// 게임 라이브러리 내부 코드
public async Task<byte[]> DownloadAsync(string url)
{
    using var http = new HttpClient();
    // ✅ 라이브러리 코드는 컨텍스트 복귀가 불필요
    var bytes = await http.GetByteArrayAsync(url).ConfigureAwait(false);
    return bytes;
}
ConfigureAwait(bool) — 컨텍스트 복귀 여부 설정 await 후 원래 동기화 컨텍스트로 돌아갈지 결정합니다. false면 ThreadPool에서 이어 실행합니다. UI 라이브러리 내부, 네트워크 호출, 파일 I/O 같은 곳에 사용합니다. UI를 직접 갱신해야 하는 코드(예: Unity의 transform.position 갱신)에서는 사용하지 않습니다.
예시: await db.QueryAsync().ConfigureAwait(false); 응답 파싱은 ThreadPool에서 처리되어 UI 스레드를 차지하지 않습니다.

ConfigureAwait(false)는 예외 처리와 직접 관련은 없지만, 데드락으로 인한 가짜 예외(타임아웃, OperationCanceledException 등)를 줄여주므로 묶어서 기억해두면 좋습니다.

패턴 4. 취소와 예외의 구분

OperationCanceledException(CancellationToken이 발사되었음을 알리는 표준 예외)은 일반 오류와 구분해서 처리해야 합니다. PART 13-09에서 다룬 취소 토큰의 동반자입니다.

C#
try
{
    await DownloadAsync(url, token);
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
    // 정상적인 취소 — 사용자에게 에러 토스트를 띄우지 않는다
    Debug.Log("사용자가 다운로드를 취소했습니다.");
}
catch (Exception ex)
{
    // 진짜 오류
    ShowErrorPopup(ex);
    Debug.LogException(ex);
}
when — catch 필터 (Exception filter) catch 절에 추가 조건을 붙여서 조건이 참일 때만 그 catch가 활성화되도록 합니다. 거짓이면 마치 그 catch가 없는 것처럼 다음 catch를 찾으러 갑니다. 스택을 풀지 않은 채 검사하므로 진단에 유리합니다.
예시: catch (HttpException ex) when (ex.StatusCode == 503) 503일 때만 잡고, 그 외 HttpException은 다른 catch로 넘어갑니다.

패턴 5. ValueTask의 예외 처리 차이

ValueTask(할당을 줄이기 위한 구조체 기반 Task 대체재)는 GC 압박이 큰 핫패스에서 사용됩니다. 예외 처리 자체는 Task와 동일하지만 두 가지 주의점이 있습니다.

  • 두 번 await 금지: ValueTask는 한 번만 await할 수 있습니다. 두 번 하면 예외가 아닌 정의되지 않은 동작이 발생합니다. 두 번 쓸 일이 있다면 .AsTask()로 변환해 Task로 보관합니다.
  • Task.WhenAll에 못 넣음: Task.WhenAllIEnumerable<Task>를 받습니다. ValueTask는 일단 .AsTask()로 변환해야 합니다.
C#
public ValueTask<int> GetCachedOrLoadAsync(string key)
{
    if (_cache.TryGetValue(key, out var cached))
        return new ValueTask<int>(cached); // 즉시 반환 — 할당 없음
    return new ValueTask<int>(LoadAsync(key));
}

Unity 모바일에서 매 프레임 호출되는 코드라면 ValueTask 도입을 검토할 가치가 있습니다. 하지만 예외 처리 패턴은 그대로이므로 추가로 익힐 것은 없습니다.


7. 함정과 주의사항

함정 1. catch (AggregateException)으로 잡으려 시도

Task.Wait() 시절의 패턴을 그대로 가져오는 실수입니다.

C#
// ❌ Bad — await는 AggregateException을 풀어서 던진다
try
{
    await Task.WhenAll(t1, t2, t3);
}
catch (AggregateException ae) // 절대 잡히지 않음
{
    foreach (var inner in ae.InnerExceptions) Log(inner);
}

// ✅ Good — 예외 타입 자체로 잡고, 모든 예외를 보려면 Task.Exception 사용
try
{
    var all = Task.WhenAll(t1, t2, t3);
    await all;
}
catch (Exception)
{
    if (all.Exception is AggregateException ae)
        foreach (var inner in ae.Flatten().InnerExceptions) Log(inner);
}

awaitAggregateException.InnerExceptions[0]만 풀어 던집니다. catch (AggregateException) 절은 영원히 활성화되지 않습니다.

함정 2. fire-and-forget을 _ =로 가린다

C#
// ❌ Bad — 예외가 미관찰로 묻힘
_ = SaveLogAsync(payload);

// ✅ Good — 예외 처리를 명시한 fire-and-forget 헬퍼
public static class TaskExt
{
    public static void Forget(this Task task, string context)
    {
        task.ContinueWith(t =>
            {
                if (t.IsFaulted)
                    Debug.LogError($"[{context}] {t.Exception?.Flatten()}");
            },
            TaskContinuationOptions.OnlyOnFaulted);
    }
}

// 호출
SaveLogAsync(payload).Forget(nameof(SaveLogAsync));

UniTask라면 .Forget()이 내장되어 있어 동일한 안전성을 제공합니다. Task만 쓰는 환경에서는 위 같은 헬퍼를 한 번 정의해두고 fire-and-forget 호출에 일관되게 적용합니다.

함정 3. try-catch가 await보다 위에 있어 예외를 못 잡는다

C#
// ❌ Bad — try가 너무 일찍 끝난다
public async Task LoadAsync()
{
    Task download;
    try
    {
        download = StartDownloadAsync(); // 시작만 함
    }
    catch (Exception ex)
    {
        Log(ex);
        return;
    }
    await download; // ← 진짜 예외는 여기서 발생
}

// ✅ Good — await를 try 안에 둔다
public async Task LoadAsync()
{
    try
    {
        var download = StartDownloadAsync();
        await download;
    }
    catch (Exception ex)
    {
        Log(ex);
    }
}

async 메서드를 호출만 하면 동기 부분에서 던진 예외만 잡힙니다. 실제 작업 실패는 await 시점에 도달하므로 try-catch는 반드시 await를 둘러싸야 합니다.

함정 4. Task.Result나 Task.Wait()로 동기 대기

이 함정은 데드락 문제이기도 하지만, 예외 처리 관점에서도 위험합니다.

C#
// ❌ Bad — Wait/Result는 AggregateException을 그대로 던진다
try
{
    var result = LoadAsync().Result; // ← 데드락 위험 + AggregateException
}
catch (InvalidOperationException) // 잡히지 않음
{
    // ...
}

// ✅ Good — 항상 await 사용
try
{
    var result = await LoadAsync();
}
catch (InvalidOperationException)
{
    // 잡힌다
}

Unity 메인 스레드에서 .Result를 쓰면 SynchronizationContext 캡처와 결합해 영구 데드락이 발생합니다. 게다가 예외 타입 매칭도 깨집니다. 동기 대기는 시작 코드(예: Main 진입)나 콘솔 도구에서만 허용합니다.

함정 5. UniTask와 Task 혼용 시 예외 변환

UniTask 환경에서도 Task를 받는 외부 라이브러리를 호출할 일이 생깁니다. TaskUniTask로 변환할 때 예외도 함께 따라옵니다.

C#
// Task → UniTask 변환
UniTask uni = someTask.AsUniTask(); // 예외도 함께 전달됨
await uni; // Task의 예외 그대로 throw

문제는 변환 과정에서 추가 컨텍스트 캡처가 발생하지 않는다는 점입니다. UniTask는 PlayerLoop 기반이고 Task는 ThreadPool 기반이라 catch 시점의 스레드가 다를 수 있습니다. Debug.Log처럼 메인 스레드를 요구하는 호출이 catch 안에 있다면 문제가 생길 수 있습니다.


8. C# 버전별 변화

비동기 예외 처리의 핵심은 C# 5에서 거의 결정되었고 이후는 작은 개선입니다.

C# 5 (.NET 4.5, 2012) — async/await 도입과 ExceptionDispatchInfo 등장

  • async/await 키워드와 상태 머신 기반 컴파일이 도입되었습니다.
  • await는 처음부터 단일 예외만 던지는 의미론이었습니다.
  • ExceptionDispatchInfo가 함께 추가되어 비동기 예외의 스택 트레이스 보존이 가능해졌습니다.
  • 기본 미관찰 예외 정책이 "프로세스 종료"에서 "이벤트만 발생"으로 변경되었습니다.

C# 6 (.NET 4.6, 2015) — catch/finally에서 await 허용

C# 5까지는 catchfinally 블록 안에서 await를 사용할 수 없어, 예외 처리 중 비동기 정리(예: 임시 파일 비동기 삭제)가 불가능했습니다. C# 6부터 가능해졌습니다.

C#
// ❌ C# 5에서는 컴파일 에러
try
{
    await DownloadAsync();
}
catch (Exception ex)
{
    // C# 6부터 OK
    await LogToServerAsync(ex);
    throw;
}
finally
{
    // C# 6부터 OK
    await CleanupTempFilesAsync();
}

이 변화는 IL 수준에서 상태 머신이 try/catch/finally 구조를 가로지를 수 있도록 컴파일러가 확장된 결과입니다.

C# 6 — 예외 필터(when)

같은 시기에 도입된 when 키워드는 비동기 예외 처리와 궁합이 좋습니다. 스택 풀기 없이 조건을 검사하므로 진단 정보가 보존됩니다.

C#
// 취소 예외만 분리해 잡되, 토큰이 실제로 발사된 경우만
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
    // 정상 종료
}

C# 7+ (.NET Core 2.0~) — ValueTask, async Main, ValueTask<T>

  • C# 7.1: async Task Main이 가능해져 콘솔 앱 진입점에서 await을 사용할 수 있게 되었습니다.
  • C# 7+: ValueTask/ValueTask<T>가 도입되었지만, 예외 처리 의미론은 Task와 동일합니다(앞 [실전 적용] 패턴 5 참조).

C# 8 (.NET Core 3.0, 2019) — 비동기 스트림(IAsyncEnumerable)

await foreach도 동일한 예외 의미론을 따릅니다. 스트림 안에서 던진 예외는 다음 MoveNextAsyncawait 시점에 부활합니다. 이는 PART 13-11에서 다룹니다.

C# 11+ — 변화 없음 (의미론 안정)

비동기 예외 처리 의미론은 C# 11 이후 새로운 변화가 없습니다. 언어 차원에서는 이미 안정화 단계입니다. 라이브러리 차원의 개선(예: Parallel.ForEachAsync의 예외 처리 옵션)은 계속되고 있지만 본 글의 범위를 벗어납니다.


9. 정리 — 이것만 기억하세요

  • 예외는 Task와 함께 흐른다: async 메서드의 예외는 즉시 throw되지 않고 Task의 상태(Faulted)와 Exception 속성으로 보관됩니다. 호출자가 await해야 다시 살아납니다.
  • 스택 트레이스는 보존된다: ExceptionDispatchInfo 덕분에 원래 발생 지점이 살아남습니다. await 라인이 아닌 진짜 발생 위치로 디버그할 수 있습니다.
  • Task.WhenAll은 첫 예외만 던진다: 모든 예외를 보려면 합쳐진 Task의 Exception 속성(AggregateException)을 직접 확인하거나, 개별 task 컬렉션을 보존해 IsFaulted 검사로 분류합니다.
  • async void는 이벤트 핸들러에만 사용한다: 그 외에서는 async Task를 사용합니다. 이벤트 핸들러로 쓸 때도 본문 전체를 try-catch로 감쌉니다.
  • 모든 Task는 누군가 await해야 한다: fire-and-forget이 필요하면 Task.ContinueWith 또는 UniTask.Forget으로 예외를 명시적으로 처리합니다. _ =로 가리지 마세요.
  • OperationCanceledException은 오류가 아니다: 일반 예외와 분리해서 처리합니다. when (token.IsCancellationRequested) 필터로 정확히 잡습니다.
  • Task.Result/Task.Wait()는 쓰지 않는다: 데드락과 AggregateException 매칭 깨짐을 동시에 가져옵니다. 항상 await를 사용합니다.

비동기 예외 처리의 본질은 "예외가 동기 코드와 다른 시간선을 흐른다"는 사실을 받아들이는 것입니다. Task가 끊기지 않도록 호출 체인을 이어 두면, 예외도 자연스럽게 호출자까지 도달합니다.

반응형
반응형

[PART13.비동기와 스레딩 기초(9/15)] 취소 — CancellationToken · CancellationTokenSource

한 줄 정의

CancellationToken은 비동기·장기 실행 작업에 "이제 그만"이라는 신호를 전달하는 협조적 취소(cooperative cancellation) 토큰이며, CancellationTokenSource는 그 신호를 발사하는 발신기입니다.


시작하기 전에 알아야 할 핵심

  • 취소는 강제 종료가 아닙니다: Thread.Abort()처럼 외부에서 스레드를 죽이는 게 아니라, 작업 코드가 스스로 "취소됐는지 확인하고 정리한 뒤 종료"합니다.
  • 두 객체는 한 쌍입니다: CancellationTokenSource(발신기)가 CancellationToken(수신기)을 만들어 작업에 전달합니다. 발신기는 호출 측이, 수신기는 작업 측이 갖습니다.
  • 확인 패턴은 두 가지: IsCancellationRequested(bool 폴링)와 ThrowIfCancellationRequested()(예외 발생). async에서는 후자가 표준입니다.
  • 취소 예외는 오류가 아닙니다: OperationCanceledException은 "정상적인 취소"를 의미하므로 일반 예외 로깅과 분리해야 합니다.
  • 토큰은 끝까지 전파해야 합니다: 호출 체인의 어느 한 곳에서 토큰을 빼먹으면 그 아래는 취소되지 않습니다.

1. 왜 협조적 취소인가 — Thread.Abort가 폐기된 이유

강제 종료의 위험성

C# 1.0 시절에는 Thread.Abort()로 다른 스레드를 강제 종료할 수 있었습니다. 그러나 .NET Core부터 이 메서드는 PlatformNotSupportedException을 던지며 사실상 폐기됐습니다.

이유는 명확합니다.

  • 공유 상태 불일치: 트랜잭션 중간에 강제 종료되면 자료구조가 절반만 갱신된 상태로 남습니다.
  • 리소스 누수: 파일 핸들·소켓·잠금이 해제되지 않은 채 스레드가 사라집니다.
  • 정적 생성자 파괴: 클래스 초기화 도중 종료되면 해당 타입 전체가 사용 불가능해집니다.
  • 비관리 코드 중단 불가: 네이티브 호출 중에는 ThreadAbortException이 즉시 발생하지 않아 결과를 예측할 수 없습니다.

요컨대 강제 종료는 "프로세스가 일관성을 잃는다"는 본질적 결함을 가집니다.

협조적 취소의 합의

협조적 취소는 다음 두 가지 약속에 기반합니다.

  1. 호출 측은 신호만 보낸다: cts.Cancel()은 플래그를 켤 뿐 어떤 스레드도 죽이지 않습니다.
  2. 작업 측은 안전한 시점에 스스로 종료한다: 루프 사이, I/O 사이, 작업 단위 끝에서 토큰을 확인하고 정리한 뒤 빠져나옵니다.

이 모델은 데이터 일관성을 작업 코드의 책임으로 돌리지만, 그 대가로 어떤 강제 종료보다도 안전합니다.

Thread.Abort (강제) vs 협조적 취소 (CancellationToken)

2. 두 객체의 역할 — 발신기와 수신기

CancellationTokenSource (CTS)

신호를 발생시키는 쪽입니다. 다음 책임을 가집니다.

  • 취소 시작: Cancel() / CancelAfter(TimeSpan)
  • 토큰 발급: Token 속성으로 작업에 전달할 CancellationToken을 만듦
  • 리소스 정리: IDisposable이므로 using 또는 Dispose()로 정리

CancellationToken (CT)

신호를 수신하는 쪽입니다. 가벼운 struct로 다음을 제공합니다.

  • 상태 조회: IsCancellationRequested (bool)
  • 예외 트리거: ThrowIfCancellationRequested()
  • 콜백 등록: Register(Action)
  • 취소 핸들: WaitHandle (블로킹 대기용)

토큰은 스스로 취소를 시작할 수 없습니다. 오직 자신을 만든 CTS의 신호를 감지할 뿐입니다. 이 비대칭이 보안과 책임을 분리합니다 — 작업 측은 토큰만 받았으므로 외부 취소를 강제할 수 없고, 호출 측만 CTS를 가지므로 취소 시점을 통제합니다.

C#
using var cts = new CancellationTokenSource();
CancellationToken token = cts.Token;     // 작업에 전달할 수신기

var task = DoWorkAsync(token);            // 작업 시작

await Task.Delay(1000);
cts.Cancel();                             // 외부에서 취소 신호

try { await task; }
catch (OperationCanceledException) { Console.WriteLine("취소됨"); }

3. ADEPT — 토큰을 직접 만지며 이해하기

A. Analogy (비유)

CancellationTokenSource공사장 비상 정지 버튼이고, CancellationToken버튼 신호를 받는 작업자의 이어폰입니다.

  • 감독(호출자)은 비상 정지 버튼(Cancel())을 누릅니다.
  • 작업자(작업 코드)는 이어폰(token)으로 "정지" 신호를 듣습니다.
  • 신호를 들은 작업자는 들고 있는 도구를 안전하게 내려놓고(finally { 자원 해제 }) 작업장을 나옵니다.

핵심은 "감독이 작업자의 손을 잡아채는 게 아니다"라는 점입니다. 감독은 신호만 보내고, 멈출 책임은 작업자에게 있습니다.

D. Diagram (구조도)

CancellationTokenSource ↔ CancellationToken 신호 흐름

E. Example (가장 단순한 예시)

C#
using System.Threading;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        using var cts = new CancellationTokenSource();
        cts.CancelAfter(TimeSpan.FromSeconds(2));   // 2초 후 자동 취소

        try
        {
            await CountAsync(cts.Token);
        }
        catch (OperationCanceledException)
        {
            Console.WriteLine("취소됨 (정상)");
        }
    }

    static async Task CountAsync(CancellationToken token)
    {
        for (int i = 0; i < 100; i++)
        {
            token.ThrowIfCancellationRequested();   // 취소 시 OperationCanceledException
            Console.WriteLine($"카운트 {i}");
            await Task.Delay(500, token);            // Delay도 토큰을 받음
        }
    }
}

실행하면 약 2초 동안 카운트가 출력되다가 취소됨 (정상)으로 끝납니다. 이 8줄이 협조적 취소의 본질을 모두 담고 있습니다.

P. Pattern (반복되는 패턴)

상황 권장 방식 비고
async 메서드 내부 ThrowIfCancellationRequested() TPL이 Canceled 상태로 인식
CPU 집약 루프 if (token.IsCancellationRequested) break; 정리 후 자연 종료
BCL 비동기 호출 await stream.ReadAsync(buf, token) 토큰 그대로 전파
타임아웃 cts.CancelAfter(...) using 필수
다중 조건 CreateLinkedTokenSource(t1, t2) OR 결합
콜백 token.Register(() => socket.Close()) Dispose 잊지 말 것

T. Tendency (실패하기 쉬운 곳)

  • OperationCanceledException을 일반 Exception으로 잡아 로깅하면 진짜 오류와 섞입니다.
  • 토큰을 받았는데 하위 호출에 전달하지 않으면 그 아래는 취소되지 않습니다.
  • Task.Run(() => DoWork(token))처럼 토큰을 람다 캡처만 하고 Task.Run 자체에는 전달하지 않으면, 시작 전 취소가 무시됩니다.
  • CancellationTokenSourceDispose하지 않으면 Register된 콜백·타이머가 남아 메모리가 누적됩니다.

4. IsCancellationRequested vs ThrowIfCancellationRequested

두 방식의 차이는 단순한 구문 차이가 아니라 TPL이 작업 상태를 어떻게 분류하느냐의 차이입니다.

IsCancellationRequested — 폴링

C#
async Task ProcessAsync(CancellationToken token)
{
    while (HasMore())
    {
        if (token.IsCancellationRequested)
        {
            CleanupBuffers();    // 정리
            return;              // 자연 종료 → TaskStatus.RanToCompletion
        }
        await DoStepAsync();
    }
}

return으로 빠져나오면 TPL은 작업이 성공적으로 완료된 것으로 봅니다(TaskStatus.RanToCompletion). 호출자는 "취소된 건지 일을 다 마친 건지" 구분할 수 없습니다.

ThrowIfCancellationRequested — 예외

C#
async Task ProcessAsync(CancellationToken token)
{
    while (HasMore())
    {
        token.ThrowIfCancellationRequested();   // → TaskStatus.Canceled
        await DoStepAsync();
    }
}

예외가 발생하면 TPL은 작업 상태를 TaskStatus.Canceled로 전환합니다. 이 상태는 await 호출자에게 OperationCanceledException으로 다시 전달되며, Task.IsCanceled로 확인할 수도 있습니다.

어느 쪽을 써야 하는가

  • async 메서드는 거의 항상 ThrowIfCancellationRequested — 호출자가 취소를 명확히 알아야 합니다.
  • finallytry/catch 정리 로직이 길면 IsCancellationRequested — 정리 후 자연 종료하는 편이 깔끔합니다.
  • 둘을 섞어도 됩니다 — 무거운 정리는 IsCancellationRequested로 분기, 끝부분에서 ThrowIfCancellationRequested로 상태를 명확히 만들 수 있습니다.
C#
async Task ProcessAsync(CancellationToken token)
{
    try
    {
        while (HasMore())
        {
            if (token.IsCancellationRequested)
            {
                await FlushPartialAsync();   // 부분 결과 저장 등 정리
                token.ThrowIfCancellationRequested();   // 상태 명확히
            }
            await DoStepAsync();
        }
    }
    finally { Cleanup(); }
}

5. IL 관점 — ThrowIfCancellationRequested는 얼마나 가벼운가

C# 코드:

C#
using System.Threading;

public class Worker
{
    public void Check(CancellationToken token)
    {
        token.ThrowIfCancellationRequested();
    }

    public bool Poll(CancellationToken token)
    {
        return token.IsCancellationRequested;
    }
}

Check 메서드의 IL 골자(개념적):

IL
.method public hidebysig instance void Check(valuetype CancellationToken token) cil managed
{
    ldarga.s  token
    call      instance void [System.Threading]CancellationToken::ThrowIfCancellationRequested()
    ret
}

CancellationTokenstruct이므로 ldarga.s로 토큰의 주소를 로드한 뒤 call 명령으로 메서드를 호출합니다. 박싱이 발생하지 않습니다.

ThrowIfCancellationRequested 내부 동작 (개념적 디컴파일)

C#
// 실제 .NET 런타임 구현 요지
public void ThrowIfCancellationRequested()
{
    if (IsCancellationRequested)
        ThrowOperationCanceledException();   // 분리된 cold path
}

public bool IsCancellationRequested
{
    get
    {
        CancellationTokenSource source = _source;
        return source != null && source.IsCancellationRequested;
    }
}

핵심은 취소되지 않은 일반 경로(hot path)는 두 번의 필드 읽기와 한 번의 비교만 한다는 점입니다. 예외 발생 코드는 별도 메서드(ThrowOperationCanceledException)로 분리되어 있어 인라이닝되지 않으며, JIT는 이 분기를 cold path로 최적화합니다.

성능 측정 — 폴링 비용

C#
[MemoryDiagnoser]
public class CancellationBenchmark
{
    private CancellationToken _token = new CancellationTokenSource().Token;

    [Benchmark] public bool Poll() => _token.IsCancellationRequested;
    [Benchmark] public void Throw() => _token.ThrowIfCancellationRequested();
}

대표적 결과(개발자 노트북, 참고용):

메서드 시간 할당
IsCancellationRequested ~0.5 ns 0 B
ThrowIfCancellationRequested (취소 안 된 경우) ~0.6 ns 0 B

결론: 루프 안에서 매 반복 호출해도 성능에 거의 영향이 없습니다. 따라서 "성능 때문에 안 쓴다"는 잘못된 절약 시도입니다.

단, 예외가 실제로 던져지면 비용이 큽니다

OperationCanceledException이 발생하는 경우는 일반 예외와 동일하게 스택 워킹·예외 객체 할당이 발생하므로 마이크로초 단위입니다. 그러나 이는 작업 종료 경로이므로 전체 작업 시간 대비 무시 가능합니다.


6. CancelAfter — 타임아웃의 표준 패턴

기본 사용

C#
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
// 또는: cts.CancelAfter(5000);

try
{
    var data = await httpClient.GetStringAsync(url, cts.Token);
}
catch (OperationCanceledException) when (cts.IsCancellationRequested)
{
    Console.WriteLine("5초 타임아웃");
}

호출자 토큰과 타임아웃 결합 — Linked Token

호출자가 이미 토큰을 줬는데, 그 위에 타임아웃을 더하고 싶다면 Linked Token을 만듭니다.

C#
public async Task<string> FetchAsync(string url, CancellationToken caller)
{
    using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
    using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
        caller, timeoutCts.Token);

    try
    {
        return await _http.GetStringAsync(url, linkedCts.Token);
    }
    catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested
                                             && !caller.IsCancellationRequested)
    {
        throw new TimeoutException("5초 타임아웃");
    }
}

이 패턴은 "호출자 취소"와 "타임아웃 취소"를 구분합니다. when 필터로 어느 쪽이 발사됐는지 확인해 적절한 예외로 변환합니다.

Linked Token Source — OR 결합

함정: using 빠뜨림

C#
// 잘못된 예
public async Task<string> FetchAsync(string url, CancellationToken caller)
{
    var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));   // using 없음
    var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(caller, timeoutCts.Token);
    return await _http.GetStringAsync(url, linkedCts.Token);
    // timeoutCts·linkedCts가 GC될 때까지 타이머·콜백이 살아있음
}

CancelAfter 내부에는 Timer가 등록되며, CreateLinkedTokenSource는 부모 토큰에 콜백을 등록합니다. Dispose하지 않으면 작업 완료 후에도 자원이 남아 누적됩니다. 짧은 작업에선 티가 안 나지만 분당 수백 건을 처리하는 서비스에선 누수가 됩니다.


7. OperationCanceledException 처리 — 오류가 아닙니다

잘못된 예 (가장 흔한 함정)

C#
try
{
    await ProcessAsync(token);
}
catch (Exception ex)
{
    _logger.LogError(ex, "처리 실패");   // 취소도 "실패"로 기록됨
    ShowErrorDialog(ex.Message);          // 사용자에게 "오류 발생!"
}

이렇게 하면 사용자가 "취소" 버튼을 눌렀는데 "오류!" 팝업이 뜹니다. 로그도 진짜 오류와 정상 취소가 섞여 디버깅이 어려워집니다.

올바른 예

C#
try
{
    await ProcessAsync(token);
}
catch (OperationCanceledException) when (token.IsCancellationRequested)
{
    // 정상 취소 — 로그 안 남김 또는 Info 레벨로만
    _logger.LogInformation("사용자 요청으로 작업 취소");
}
catch (Exception ex)
{
    _logger.LogError(ex, "처리 실패");
    ShowErrorDialog(ex.Message);
}

when 필터로 "내가 요청한 토큰이 취소된 경우"인지 확인하는 게 중요합니다. 이렇게 해야 다른 이유로 발생한 OperationCanceledException(예: 라이브러리 내부 타임아웃)을 일반 예외로 처리할 수 있습니다.

Task.WaitAll·Parallel.For에서는 AggregateException

C#
try
{
    Task.WaitAll(t1, t2, t3);
}
catch (AggregateException agg)
{
    foreach (var inner in agg.InnerExceptions)
    {
        if (inner is OperationCanceledException) continue;   // 취소는 무시
        _logger.LogError(inner, "병렬 작업 실패");
    }
}

AggregateException.Handle(predicate)도 같은 용도로 쓰입니다.


8. 토큰 전파 — 끝까지 넘기지 않으면 의미가 없다

안티패턴: 중간에서 토큰을 잃어버리기

C#
public async Task SaveUserAsync(User user, CancellationToken token)
{
    await _db.SaveAsync(user);                    // ❌ 토큰 누락
    await _http.PostAsync(url, content);          // ❌ 토큰 누락
    token.ThrowIfCancellationRequested();          // 너무 늦음 — 이미 다 했음
}

이 코드는 취소 신호가 와도 DB 저장과 HTTP POST가 모두 완료된 후에야 예외를 던집니다. 작업 자체는 취소되지 않았습니다.

올바른 패턴: 모든 호출에 토큰 전달

C#
public async Task SaveUserAsync(User user, CancellationToken token)
{
    await _db.SaveAsync(user, token);
    await _http.PostAsync(url, content, token);
}

BCL 비동기 메서드는 거의 모두 CancellationToken을 마지막 매개변수로 받는 오버로드를 제공합니다. 누락하면 그 메서드는 취소되지 않습니다.

Task.Run 사용 시

C#
public Task<int> ComputeAsync(int[] data, CancellationToken token)
{
    return Task.Run(() =>
    {
        int sum = 0;
        foreach (var x in data)
        {
            token.ThrowIfCancellationRequested();   // 람다 안에서 사용
            sum += Compute(x);
        }
        return sum;
    }, token);   // ← Task.Run 자체에도 전달
}

Task.Run의 두 번째 인자로 토큰을 전달하면, 작업이 스케줄러 큐에 있는 동안 취소가 요청되면 시작도 안 하고 Canceled 상태로 끝납니다. 람다 안의 ThrowIfCancellationRequested는 시작된 후의 취소를 처리합니다. 둘은 서로 다른 시점이므로 둘 다 필요합니다.

CancellationToken.None — 취소 불가를 명시적으로

C#
public async Task LoadConfigAsync()
{
    // 설정 로드는 취소되어선 안 됨 — 명시적으로 None 전달
    await _config.LoadAsync(CancellationToken.None);
}

null이나 default를 넘기는 대신 CancellationToken.None을 사용하면 의도가 분명해집니다. 코드 리뷰 시 "왜 토큰을 안 넘겼지?"라는 질문을 막을 수 있습니다.


9. Register 콜백 — 토큰을 모르는 코드에 끼우기

오래된 라이브러리나 비관리 API는 CancellationToken을 받지 않습니다. 이때 Register로 취소 시점에 직접 끊을 수 있습니다.

C#
public async Task<byte[]> ReadLegacyAsync(Stream stream, CancellationToken token)
{
    // 이 Read는 토큰을 받지 않음 — Register로 우회
    using CancellationTokenRegistration reg = token.Register(() => stream.Close());

    try
    {
        var buf = new byte[4096];
        int read = await stream.ReadAsync(buf, 0, buf.Length);   // 토큰 없이 호출
        return buf.AsSpan(0, read).ToArray();
    }
    catch (ObjectDisposedException) when (token.IsCancellationRequested)
    {
        throw new OperationCanceledException(token);
    }
    // using으로 reg 자동 Dispose
}

주의사항

  • 콜백은 어느 스레드에서 실행될지 모릅니다Cancel()을 부른 스레드일 수도, 별도 스레드일 수도 있습니다. 스레드 안전성을 보장하세요.
  • 콜백은 짧고 빠르게Register 콜백 내부에서 무거운 작업을 하면 Cancel() 호출자가 막힙니다.
  • Dispose 필수CancellationTokenRegistrationDispose하지 않으면 콜백이 CTS의 콜백 리스트에 영구히 남습니다. CTS가 장수명일수록 누수가 심각해집니다.
  • 이미 취소된 토큰에 등록하면 즉시 실행Register를 호출하는 그 스레드에서 동기적으로 콜백이 실행됩니다. 락을 잡고 있다면 데드락 위험이 있습니다.

10. Unity 실전 활용

MonoBehaviour 수명과 결합 — destroyCancellationToken

Unity 2022.2부터 MonoBehaviourdestroyCancellationToken 프로퍼티가 추가됐습니다. 이 토큰은 GameObject가 파괴될 때 자동으로 취소되므로, 비동기 작업이 파괴된 객체에 접근해 MissingReferenceException을 던지는 사고를 막을 수 있습니다.

C#
using UnityEngine;
using System.Threading.Tasks;

public class EnemySpawner : MonoBehaviour
{
    async void Start()
    {
        try
        {
            await SpawnLoopAsync(destroyCancellationToken);
        }
        catch (System.OperationCanceledException) { /* GameObject 파괴됨 */ }
    }

    async Task SpawnLoopAsync(System.Threading.CancellationToken token)
    {
        while (!token.IsCancellationRequested)
        {
            Spawn();
            await Task.Delay(2000, token);
        }
    }
}

destroyCancellationToken을 사용하지 않으면 씬 전환 후에도 Task.Delay가 살아 다음 Spawn()이 호출되며, 이 시점에 this.transform이 이미 파괴되어 예외가 발생합니다.

UniTask와의 결합

UniTask는 Unity 환경에 최적화된 비동기 라이브러리이며, 거의 모든 메서드가 CancellationToken을 받습니다.

C#
using Cysharp.Threading.Tasks;
using System.Threading;

public class Loader : MonoBehaviour
{
    public async UniTaskVoid LoadAsync()
    {
        var token = this.GetCancellationTokenOnDestroy();   // UniTask 확장
        var prefab = await Resources.LoadAsync<GameObject>("Boss")
                                    .ToUniTask(cancellationToken: token);
        Instantiate(prefab);
    }
}

GetCancellationTokenOnDestroydestroyCancellationToken과 동일한 의미를 갖는 UniTask 확장입니다. UniTask 1.x 시절 destroyCancellationToken이 BCL에 없던 시기를 보완합니다.

게임 전체 종료 토큰

C#
public class GameLifetime : MonoBehaviour
{
    public static CancellationTokenSource ApplicationExitCts { get; } = new();

    void OnApplicationQuit() => ApplicationExitCts.Cancel();
}

이 패턴으로 만든 토큰을 백그라운드 작업(서버 폴링, 자동 저장 등)에 전달하면 게임 종료 시 모든 작업이 일제히 취소돼 깔끔하게 종료됩니다.

Unity에서 흔한 함정

  • Task 사용 시 메인 스레드 컨텍스트 미보장: Task.Delaytransform 접근은 메인 스레드여야 합니다. UniTask는 이를 자동 처리하지만 일반 Task는 보장 안 됩니다.
  • 토큰 누락: await SomeApi() 형태로 토큰을 빼먹으면 씬 전환 후에도 코드가 살아 사고를 일으킵니다.
  • 취소 예외 무시: catch (Exception)으로 모든 예외를 잡으면 OperationCanceledException도 잡히지만, 이를 그냥 무시하면 안 됩니다. 리소스 정리 코드는 반드시 finally에서 실행해야 합니다.

11. 일반적인 함정 정리

1) 토큰을 받았는데 안 쓰기

C#
public async Task DoAsync(CancellationToken token)   // 시그니처에는 있음
{
    await SomethingAsync();   // ❌ 토큰을 안 넘김
}

코드 리뷰에서 "왜 토큰을 안 넘기죠?"라는 질문이 나와야 정상입니다. Roslyn 분석기 CA2016 규칙이 이를 잡아냅니다.

2) using 누락한 CTS의 콜백 누수

C#
public void Subscribe(CancellationToken token, Action callback)
{
    token.Register(callback);   // ❌ Dispose하지 않음
}

CTS가 오래 살수록 누수가 심합니다. 반드시 CancellationTokenRegistration을 보관하고 Dispose하세요.

3) Cancel을 동기적으로 호출하는 락 안

C#
lock (_lock)
{
    _cts.Cancel();   // ❌ Register 콜백이 같은 스레드에서 실행될 수 있음
                     //    콜백 내부에서 _lock을 잡으려 하면 데드락
}

해결책: 락 밖에서 Cancel을 호출하거나, Cancel(throwOnFirstException: false) + 별도 스레드 사용.

4) 취소 예외를 무시(swallow)

C#
try { await DoAsync(token); }
catch { }   // ❌ 취소 정보가 위로 전파되지 않음

상위 코드에서는 작업이 정상 완료된 줄 알고 후속 작업을 진행합니다. 최소한 OperationCanceledException은 다시 throw하세요.

5) CancellationToken을 필드로 저장 + 재사용

C#
class Service
{
    private CancellationToken _token;   // ❌ 한 번 취소되면 영구히 취소 상태
    public void Init(CancellationToken token) => _token = token;
}

토큰은 한 번 취소되면 다시 살릴 수 없습니다. 재시작이 필요한 시나리오에서는 새 CTS를 만드세요.


12. 빠른 점검 체크리스트

항목 확인
토큰을 받은 모든 async 메서드는 하위 호출에 토큰을 전달하나요?
루프 안에 ThrowIfCancellationRequested 또는 IsCancellationRequested 검사가 있나요?
CancellationTokenSourceusing/Dispose로 정리되나요?
CancelAfter로 만든 CTS도 마찬가지인가요?
OperationCanceledException을 일반 Exception과 분리해 처리하나요?
타임아웃과 호출자 취소를 구분해야 한다면 Linked Token + when 필터를 사용하나요?
Unity에서는 destroyCancellationToken을 사용하나요?
Register 콜백은 짧고 스레드 안전하며 Dispose되나요?

13. 한 걸음 더

  • CancellationToken.None vs default: 둘 다 같은 토큰이지만 의도 표현은 None이 명확합니다.
  • Task.WaitAsync(token) (.NET 6+): 기존 Task에 취소를 덧붙입니다. 호출자가 이미 시작된 작업을 취소하고 싶을 때 유용합니다.
  • PeriodicTimer (.NET 6+): WaitForNextTickAsync(token)이 토큰을 받아 깔끔하게 취소됩니다.
  • Channel<T>.Reader.ReadAsync(token): 채널 기반 파이프라인에서도 토큰 전파가 필수입니다.

14. 마무리

협조적 취소는 "작업을 강제로 죽일 수 없다"는 제약에서 출발해 "안전하게 종료할 권한을 작업 측에 둔다"는 결론에 도달한 설계입니다. CancellationTokenSource는 신호기, CancellationToken은 수신기, 그리고 작업 코드는 신호를 듣고 스스로 정리하는 작업자입니다. 이 세 역할을 분명히 인식하면, 토큰을 어디까지 넘겨야 하는지·어떤 시점에 확인해야 하는지·예외를 어떻게 처리해야 하는지가 자연스럽게 따라옵니다.

핵심은 단순합니다 — 토큰을 받았으면 끝까지 넘기고, 예외를 만나면 정상 취소로 인정하고, CTS는 반드시 정리합니다. 이 세 가지만 지키면 멀티스레드·비동기·UI 응답성·리소스 누수 문제가 한꺼번에 해결됩니다.

반응형
반응형

[PART13.비동기와 스레딩 기초(8/15)] 여러 Task 조합 — Task.WhenAll · Task.WhenAny

여러 비동기 작업을 한 번에 묶는 두 도구 / WhenAll은 모두 끝날 때까지 / WhenAny는 가장 먼저 끝나는 하나만 / 타임아웃 패턴과 순차 대기의 함정


1. 문제 제기 — await 한 번에 하나씩만 기다리면 생기는 일

Unity 모바일 게임에서 게임이 시작되기 전 다음 세 가지가 모두 끝나야 한다고 해봅시다.

  • 캐릭터 모델 에셋 로드 (300ms)
  • 레벨 프리팹 로드 (250ms)
  • 서버에서 플레이어 데이터 조회 (400ms)

신입 개발자가 가장 먼저 떠올리는 코드는 보통 이렇습니다.

C#
async Task LoadGameAsync()
{
    var character = await LoadCharacterAsync();   // 300ms 대기
    var level     = await LoadLevelAsync();       // 250ms 대기
    var player    = await FetchPlayerDataAsync(); // 400ms 대기
    // 총 950ms
}

각 줄은 정상 동작하지만, 세 작업은 서로 의존하지 않습니다. 그런데도 위 코드는 약 950ms가 걸립니다. 첫 번째 작업이 끝날 때까지 두 번째를 시작조차 하지 않고, 두 번째가 끝날 때까지 세 번째를 시작조차 하지 않기 때문입니다.

세 작업을 동시에 시작했다면 가장 오래 걸리는 작업의 시간(400ms)만큼만 기다리면 됩니다. 무려 550ms의 로딩 시간을 그냥 버린 셈입니다. 모바일 게임에서는 로딩 화면 1초가 사용자 이탈의 직접적인 원인이 됩니다.

또 다른 시나리오도 있습니다. 서버에 요청을 보냈는데 응답이 5초가 지나도 오지 않으면 타임아웃 처리하고 사용자에게 재시도 버튼을 보여줘야 합니다. await만으로는 이 "기다리는 시간 자체에 제한을 거는" 동작을 표현할 수 없습니다.

이 두 가지 문제 — 여러 작업을 병렬로 묶기타임아웃을 거는 가장 먼저 끝난 작업 고르기 — 를 동시에 해결하는 도구가 바로 Task.WhenAllTask.WhenAny입니다.


2. 개념 정의 — 두 가지 조합기(Combinator)

2.1 비유: 음식 배달 주문

Task.WhenAll — 단체 회식 친구 다섯 명에게 각자 음식을 주문하라고 시키고, 모두가 자기 음식을 받을 때까지 기다렸다가 다같이 식사를 시작합니다. 한 명이라도 늦으면 그 사람이 도착할 때까지 기다립니다.
Task.WhenAny — 가장 빠른 배달 같은 메뉴를 세 가게에 주문하고, 가장 먼저 도착한 가게의 음식만 받습니다. 나머지 두 곳은 도착해도 무시(또는 취소)합니다.

이름 그대로입니다 — WhenAll은 "모두 끝날 때(when all)", WhenAny는 "어느 하나라도 끝날 때(when any)" 완료됩니다.

2.2 시각화

Task.WhenAll — 모두 끝날 때까지

WhenAll은 가장 느린 작업의 시간만큼, WhenAny는 가장 빠른 작업의 시간만큼 걸립니다.

2.3 가장 단순한 사용법

C#
using System.Threading.Tasks;

async Task<int[]> WhenAllExampleAsync()
{
    Task<int> a = Task.FromResult(10);
    Task<int> b = Task.FromResult(20);
    Task<int> c = Task.FromResult(30);

    int[] results = await Task.WhenAll(a, b, c);
    // results = [10, 20, 30]  — 입력 순서 그대로
    return results;
}

async Task<int> WhenAnyExampleAsync()
{
    Task<int> fast = Task.Delay(50).ContinueWith(_ => 1);
    Task<int> slow = Task.Delay(500).ContinueWith(_ => 2);

    Task<int> first = await Task.WhenAny(fast, slow); // Task<Task<int>> 를 await하면 Task<int>
    // first 는 fast 와 같은 객체
    return await first; // 1
}
await — 비동기 대기 키워드 비동기 작업이 끝날 때까지 현재 메서드를 일시 중지(suspend)하고, 호출 스레드는 다른 일을 할 수 있도록 반환한다. 작업이 끝나면 다시 이어서 실행한다.
예시: int[] r = await Task.WhenAll(tasks); tasks 안의 모든 Task가 끝날 때까지 메서드가 멈췄다가 결과 배열을 받는다.

WhenAll은 결과 배열(T[])을 돌려주고, WhenAny완료된 Task 자체를 돌려줍니다. WhenAny로 결과 값을 얻으려면 한 번 더 await해야 합니다 — 이 점이 처음 보면 가장 헷갈립니다.

2.4 반환 타입 정리

메서드 입력 반환 타입 await 결과
Task.WhenAll(Task[]) Task 여러 개 Task void
Task.WhenAll<T>(Task<T>[]) Task<T> 여러 개 Task<T[]> T[] 배열
Task.WhenAny(Task[]) Task 여러 개 Task<Task> 완료된 Task 객체
Task.WhenAny<T>(Task<T>[]) Task<T> 여러 개 Task<Task<T>> 완료된 Task<T> 객체

WhenAll이 입력 순서대로 결과를 묶어주는 점이 중요합니다. 가장 빨리 끝난 Task가 results[0]이 되는 게 아니라, 입력 리스트의 첫 번째 Task의 결과results[0]입니다.

2.5 IL 분석 — await Task.WhenAll(...)은 다른 await와 똑같다

ParallelAsync라는 메서드를 컴파일해 IL을 보면, Task.WhenAll은 그냥 평범한 메서드 호출이고 await가 상태 머신을 만든다는 사실이 분명해집니다.

C#
private static async Task<int[]> ParallelAsync(List<Task<int>> tasks)
{
    return await Task.WhenAll(tasks);
}
IL
// ParallelAsync 의 컴파일된 메서드 본체
.method private hidebysig static
    class [System.Runtime]System.Threading.Tasks.Task`1<int32[]>
    ParallelAsync(class [...]List`1<class [...]Task`1<int32>> tasks) cil managed
{
    // 1. 상태 머신 구조체를 스택에 만들고
    .locals init ([0] valuetype Program/'<ParallelAsync>d__1' V_0)

    ldloca.s   V_0
    call       valuetype [...]AsyncTaskMethodBuilder`1<int32[]>::Create()
    stfld      ...t__builder            // 빌더 초기화
    ldloca.s   V_0
    ldarg.0
    stfld      ...tasks                 // 파라미터 저장
    ldloca.s   V_0
    ldc.i4.m1
    stfld      ...<>1__state            // state = -1 (시작 전)

    // 2. 상태 머신 시작
    ldloca.s   V_0
    ldflda     ...t__builder
    ldloca.s   V_0
    call       AsyncTaskMethodBuilder`1::Start<...>(!!0&)
    ldloca.s   V_0
    ldflda     ...t__builder
    call       AsyncTaskMethodBuilder`1::get_Task()
    ret
}

// 상태 머신 내부 MoveNext 의 핵심 (Task.WhenAll 호출과 await 처리)
IL_00ae: call class [...]Task`1<int32[]> System.Threading.Tasks.Task::WhenAll(...)
IL_00b3: callvirt instance valuetype TaskAwaiter`1<int32[]> Task`1::GetAwaiter()
IL_00b8: stloc.3
IL_00bb: call instance bool TaskAwaiter`1::get_IsCompleted()
IL_00c0: brtrue.s IL_0101                // 이미 완료면 동기 경로
// ↓ 미완료면 콜백 등록 + leave (호출자에게 제어권 반환)
IL_00d8: ldloca.s 3
IL_00db: call instance void AsyncTaskMethodBuilder::AwaitUnsafeOnCompleted<...>(...)
IL_00e0: leave IL_018d
// ↓ 재진입 시 결과 추출
IL_0103: call instance !0 TaskAwaiter`1::GetResult()

핵심은 두 줄입니다.

  • IL_00ae: Task.WhenAll(tasks)는 그저 정적 메서드 호출이다. Task<int[]>를 반환할 뿐 마법은 없다.
  • IL_00bb ~ IL_00db: await가 만든 상태 머신이 그 Task에 GetAwaiterIsCompleted 체크 → AwaitUnsafeOnCompleted로 콜백 등록을 한다.

await Task.WhenAll(tasks)는 컴파일 관점에서 await someOrdinaryTask와 완전히 동일한 모양입니다. 차이는 단지 someOrdinaryTask가 "여러 Task가 모두 끝나면 완료되는 게이트키퍼 Task"라는 점뿐입니다.


3. 내부 동작 — 게이트키퍼 Task와 카운트다운

3.1 시각화

WhenAll 내부 — 카운트다운으로 모두 끝나기를 기다린다

3.2 동작 단계

Task.WhenAll을 호출하는 순간 일어나는 일을 단계별로 보면 다음과 같습니다.

  1. 게이트키퍼 Task 생성WhenAll이 우리에게 돌려주는 그 Task다. 아직 완료되지 않은 상태.
  2. 컨티뉴에이션(continuation) 등록 — 입력된 모든 Task에 "이 Task가 끝나면 게이트키퍼에게 알려달라"는 콜백을 ContinueWith로 단다.
  3. 카운트다운 — 내부 카운터를 입력 Task 개수로 초기화. Task가 하나 끝날 때마다 Interlocked.Decrement로 1씩 감소.
  4. 모두 완료 처리 — 카운터가 0이 되면 게이트키퍼의 ResultT[] 배열을 채우고 RanToCompletion 상태로 전환. 예외가 있었으면 모아서 AggregateException으로 묶어 Faulted 상태로 전환.

WhenAny는 더 단순합니다 — 컨티뉴에이션은 등록하지만, 첫 번째로 도착한 Task만 게이트키퍼의 Result로 설정합니다. 두 번째, 세 번째 Task가 끝나도 이미 완료된 게이트키퍼에 다시 쓰지 못하고 무시됩니다.

Interlocked — 원자적 연산 클래스 멀티스레드 환경에서 변수의 읽기·증감·교체를 인터럽트 없이 한 번에 수행한다. 락 없이도 안전한 카운트가 필요할 때 쓴다.
예시: Interlocked.Decrement(ref remaining) 여러 Task가 동시에 완료돼도 카운트가 꼬이지 않는다.

3.3 결과 배열의 순서 보장

WhenAll이 돌려주는 T[]입력 순서를 보장합니다 — 가장 빨리 끝난 Task가 [0]이 되는 게 아니라 입력 리스트의 첫 번째 Task의 결과가 [0]입니다. 이는 내부 구현이 입력 배열의 인덱스를 그대로 결과 배열의 인덱스로 사용하기 때문입니다.

C#
async Task DemoOrderAsync()
{
    var slow = Task.Run(async () => { await Task.Delay(300); return "slow"; });
    var fast = Task.Run(async () => { await Task.Delay(50);  return "fast"; });

    string[] results = await Task.WhenAll(slow, fast);
    // results[0] == "slow"  ← 입력 순서대로
    // results[1] == "fast"
}

3.4 IL 관점 — WhenAll은 평범한 메서드, 마법은 await에 있다

위 IL에서 본 것처럼 Task.WhenAll(...) 자체는 그냥 정적 메서드 호출입니다. 게이트키퍼 Task 객체를 만들어 돌려주는 일만 합니다. 그 Task를 비동기적으로 기다리는 메커니즘(상태 머신, MoveNext, AwaitUnsafeOnCompleted)은 모두 await 키워드가 컴파일러를 통해 만든 것입니다. 이 사실 덕분에 Task.WhenAll이 돌려준 Task를 변수에 담아 두었다가 나중에 await해도, 다른 메서드에 인자로 넘겨도 모두 자연스럽게 동작합니다.


4. 실전 적용 — 언제 어떻게 쓰는가

4.1 Before/After: 순차 대기 vs 병렬 대기

Before — foreach로 하나씩 await (병렬 효과 사라짐)

C#
async Task<int[]> SequentialAsync(List<Task<int>> tasks)
{
    var results = new int[tasks.Count];
    for (int i = 0; i < tasks.Count; i++)
    {
        results[i] = await tasks[i]; // ← 한 개씩만 기다림
    }
    return results;
}

Unity 시나리오: 에셋 5개 로드. 각 작업이 200ms씩 걸리면 총 1000ms.

After — Task.WhenAll로 한 번에 (병렬 실행)

C#
async Task<int[]> ParallelAsync(List<Task<int>> tasks)
{
    return await Task.WhenAll(tasks); // ← 동시에 기다림
}

같은 시나리오: 5개가 동시에 진행되므로 총 약 200ms. 5배 빠름.

IL 분석 — 두 패턴의 결정적 차이

SequentialAsync의 IL을 보면 루프 한 바퀴마다 await 코드(상태 머신 분기 + AwaitUnsafeOnCompleted + leave)가 반복적으로 실행되도록 컴파일됩니다.

IL
// SequentialAsync 의 MoveNext 일부 — 루프 안의 await
IL_004x: ldarg.0
IL_004y: ldloc.x                         // tasks[i]
IL_004z: callvirt Task`1::GetAwaiter()
IL_005x: stloc.x
IL_005y: call    TaskAwaiter`1::get_IsCompleted()
IL_005z: brtrue.s ...                    // 동기 경로
IL_006x: call    AwaitUnsafeOnCompleted  // ← 루프마다 콜백 등록
IL_006y: leave   ...                     // ← 매 반복마다 호출자에게 양보
// 다음 반복에서 GetResult 후 results[i] = ...

즉 IL 레벨에서 보면 SequentialAsync는 "Task를 하나 기다림 → 결과 저장 → 다음 Task를 기다림"이 N번 반복됩니다. 그래서 시간이 더해집니다.

반면 ParallelAsync의 IL은 단 한 번의 await만 있습니다.

IL
// ParallelAsync 의 MoveNext 일부
IL_00ae: call    Task::WhenAll(...)      // 게이트키퍼 Task 생성 (즉시 반환)
IL_00b3: callvirt Task`1::GetAwaiter()
IL_00bb: call    TaskAwaiter`1::get_IsCompleted()
IL_00c0: brtrue.s IL_0101
IL_00db: call    AwaitUnsafeOnCompleted // ← 콜백 등록은 단 한 번
IL_00e0: leave   IL_018d
IL_0103: call    TaskAwaiter`1::GetResult()

Task.WhenAll(tasks) 호출은 게이트키퍼 Task를 즉시 반환합니다. 게이트키퍼는 카운트다운 카운터가 0이 될 때만 완료됩니다. 이 한 번의 await가 N개 작업의 동시 진행 시간을 측정합니다 — 그래서 Max(작업 시간들)만큼만 걸립니다.

4.2 타임아웃 패턴 — WhenAny + Task.Delay

WhenAny의 가장 흔한 용도는 타임아웃입니다.

C#
async Task<string> FetchWithTimeoutAsync(int timeoutMs)
{
    Task<string> work  = FetchFromServerAsync();
    Task         delay = Task.Delay(timeoutMs);

    Task first = await Task.WhenAny(work, delay);

    if (first == delay)
        throw new TimeoutException($"{timeoutMs}ms 초과");

    return await work; // 서버 응답 결과
}

게이트키퍼 Task가 어느 쪽이 먼저 끝났는지 알려주므로 참조 비교(first == delay)로 분기합니다. .NET 6+에서는 task.WaitAsync(TimeSpan)라는 더 짧은 API도 있지만, 내부 동작 이해를 위해 WhenAny + Delay 패턴을 알아두는 게 좋습니다.

IL 분석 — WhenAny 호출과 참조 비교

C#
private static async Task<int> WithTimeoutAsync(Task<int> work, int timeoutMs)
{
    Task delay = Task.Delay(timeoutMs);
    if (await Task.WhenAny(work, delay) == delay)
        throw new TimeoutException();
    return await work;
}
IL
// MoveNext 핵심 부분 — WhenAny 호출과 참조 비교
ldarg.0
ldfld    ...work
ldarg.0
ldfld    ...delay
call     class Task Task::WhenAny(class Task, class Task)  // ← 두 인자 버전
callvirt TaskAwaiter`1::GetAwaiter()
// ... await 상태 머신 (생략) ...
call     TaskAwaiter`1::GetResult()                        // ← 완료된 Task 반환
ldarg.0
ldfld    ...delay
ceq                                                         // ← 참조 비교 (==)
brfalse.s ... (work 결과 반환 경로)
newobj   instance void TimeoutException::.ctor()
throw

ceq가 핵심입니다 — WhenAny가 돌려준 Task와 delay Task의 참조를 비교합니다. WhenAny는 "방금 완료된 Task의 참조 그 자체"를 결과로 주기 때문에 어느 쪽이 먼저 끝났는지 알 수 있습니다.

4.3 Unity 실전 — 씬 로딩 병렬화

Unity 모바일 게임에서 씬 진입 시 자주 마주치는 패턴입니다.

C#
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;

public class SceneLoader : MonoBehaviour
{
    public async Task EnterStageAsync(string stageId)
    {
        // 1. 모든 비동기 작업을 동시에 시작 (await 하지 않음)
        Task<GameObject>      characterTask = LoadAssetAsync<GameObject>("character");
        Task<GameObject>      stageTask     = LoadAssetAsync<GameObject>($"stage_{stageId}");
        Task<AudioClip>       bgmTask       = LoadAssetAsync<AudioClip>($"bgm_{stageId}");
        Task<PlayerSaveData>  saveTask      = ServerApi.FetchSaveAsync();

        // 2. 한 번에 기다림 — 가장 오래 걸리는 작업의 시간만큼만 걸림
        await Task.WhenAll(characterTask, stageTask, bgmTask, saveTask);

        // 3. 결과 사용 (이미 완료됐으므로 .Result 안전)
        Instantiate(characterTask.Result);
        Instantiate(stageTask.Result);
        AudioSource.PlayClipAtPoint(bgmTask.Result, Vector3.zero);
        ApplySaveData(saveTask.Result);
    }

    static Task<T> LoadAssetAsync<T>(string key) where T : Object
    {
        var op = Addressables.LoadAssetAsync<T>(key);
        return op.Task; // Addressables는 Task로 변환 가능
    }
}

Unity의 AsyncOperationHandle.Task 속성을 사용하면 Addressables 로딩을 표준 Task로 다룰 수 있습니다. 그 이후로는 일반 C# 비동기와 동일하게 WhenAll로 묶을 수 있습니다.

주의: Unity 메인 스레드 동기화 컨텍스트(SynchronizationContext)는 IL2CPP·플랫폼·Unity 버전에 따라 동작이 다릅니다. 라이브러리 코드라면 await Task.WhenAll(...).ConfigureAwait(false)도 고려할 수 있지만, MonoBehaviour 코드에서 await 이후에 Transform·Instantiate 같은 메인 스레드 API를 호출해야 한다면 ConfigureAwait(false)를 쓰면 안 됩니다 — 메인 스레드로 돌아오지 않을 수 있습니다.

4.4 Unity 실전 — 가장 빠른 서버 선택

WhenAny로 여러 후보 서버에 동시에 핑을 보내고 가장 먼저 응답한 곳을 선택할 수 있습니다.

C#
async Task<string> PickFastestServerAsync(string[] candidates)
{
    using var cts = new CancellationTokenSource();
    var pingTasks = new Task<string>[candidates.Length];

    for (int i = 0; i < candidates.Length; i++)
    {
        pingTasks[i] = PingAsync(candidates[i], cts.Token);
    }

    Task<string> winner = await Task.WhenAny(pingTasks);

    cts.Cancel(); // 나머지 서버 ping은 취소 (네트워크 낭비 방지)

    return await winner;
}

CancellationTokenSource.Cancel()을 호출해 진행 중인 나머지 ping을 정리하는 것이 모바일 환경에서 중요합니다 — 데이터 요금과 배터리를 아낍니다.


5. 함정과 주의사항

5.1 함정 1 — WhenAll 다음에 첫 예외만 잡으면 나머지 예외를 놓친다

❌ 잘못된 패턴

C#
try
{
    await Task.WhenAll(taskA, taskB, taskC);
}
catch (Exception ex)
{
    // ex 는 첫 번째 예외 하나뿐!
    // 나머지 두 개는 어디로?
    Debug.LogError(ex);
}

await Task.WhenAll은 여러 Task가 실패해도 첫 번째 예외만 다시 던집니다(rethrow). 이는 awaitAggregateException을 unwrap해서 첫 번째 InnerException을 던지기 때문입니다. 나머지 예외는 게이트키퍼 Task의 Exception 속성에는 들어 있지만, 코드에서 명시적으로 꺼내지 않으면 사라집니다.

✅ 올바른 패턴

C#
Task all = Task.WhenAll(taskA, taskB, taskC);
try
{
    await all;
}
catch
{
    // all.Exception 에는 모든 예외가 AggregateException 으로 담겨 있다
    foreach (var inner in all.Exception!.InnerExceptions)
    {
        Debug.LogError(inner);
    }
}

핵심은 WhenAll이 돌려준 Task를 변수에 담아두는 것입니다. await가 던지는 예외 객체는 첫 번째 것뿐이지만, Task 객체의 Exception 속성에는 전부 보존되어 있습니다.

IL 관점

IL
// catch 블록 내부에서 변수에 저장된 Task에 접근
ldloc.0                           // Task all
callvirt Task::get_Exception()    // AggregateException? 반환
callvirt AggregateException::get_InnerExceptions()
// foreach 루프

get_Exception()이 핵심입니다. 이 속성이 모든 예외를 보존하고 있으므로, Task 변수만 살려두면 모든 InnerException을 순회할 수 있습니다.

5.2 함정 2 — WhenAny로 끝나도 나머지 Task는 계속 돈다

❌ 잘못된 인식

C#
Task<string> work = FetchAsync();
Task         delay = Task.Delay(2000);

await Task.WhenAny(work, delay);
// "타임아웃이니까 work는 자동으로 멈췄겠지?" — 아니다!
// work 는 계속 진행 중이며, 끝나면 결과를 버려도 그 사이 자원·돈을 쓴다

WhenAny는 어느 하나가 끝났음을 알려줄 뿐, 나머지 Task를 멈추지 않습니다. 네트워크 요청이라면 끝까지 응답을 받으며 그동안 데이터·배터리를 소모합니다.

✅ 올바른 패턴 — 취소 토큰 결합

C#
async Task<string> FetchWithTimeoutAsync(int timeoutMs)
{
    using var cts = new CancellationTokenSource(timeoutMs);
    try
    {
        // 서버 호출이 cts.Token 을 협조적으로 확인하도록 만든다
        return await FetchAsync(cts.Token);
    }
    catch (OperationCanceledException)
    {
        throw new TimeoutException($"{timeoutMs}ms 초과");
    }
}

CancellationTokenSource가 시간을 추적하고, 시간이 지나면 토큰이 취소 신호를 보내며, FetchAsync 내부의 HttpClient·UnityWebRequest·내가 짠 코드가 이 토큰을 협조적으로 체크하면서 정리합니다. 이 방식이 자원 측면에서 훨씬 깔끔합니다.

5.3 함정 3 — Wait()/Result/WaitAll은 await가 아니다 (데드락 위험)

❌ 잘못된 패턴

C#
// Unity UI 이벤트 핸들러
public void OnButtonClick()
{
    var task = LoadAssetAsync();
    task.Wait(); // ← UI 스레드 블로킹! 게임 화면 멈춤
    // Unity SynchronizationContext에서는 데드락도 가능
}

Task.Wait·Task<T>.Result·Task.WaitAll·Task.WaitAny현재 스레드를 블로킹합니다. UI 스레드(또는 Unity 메인 스레드)에서 호출하면 화면이 멈추고, SynchronizationContext가 있는 환경에서는 데드락도 발생합니다.

✅ 올바른 패턴 — await Task.WhenAll/WhenAny만 사용

C#
public async void OnButtonClick()
{
    var task = LoadAssetAsync();
    await task; // ← 메인 스레드 양보, 끝나면 다시 돌아옴
}
메서드 동작 UI/Unity 메인 스레드
Task.WaitAll(tasks) 동기·블로킹 ❌ 절대 금지
Task.WaitAny(tasks) 동기·블로킹 ❌ 절대 금지
await Task.WhenAll(tasks) 비동기·논블로킹 ✅ 권장
await Task.WhenAny(tasks) 비동기·논블로킹 ✅ 권장

이름이 비슷해서 헷갈리지만 동작은 정반대입니다 — Wait*은 동기, When*은 비동기.

5.4 함정 4 — WhenAny를 루프로 돌려 "완료 순서대로 처리"하면 O(N²)

❌ 잘못된 패턴

C#
async Task ProcessInOrderAsync(List<Task<int>> tasks)
{
    while (tasks.Count > 0)
    {
        // N번 호출 × 매번 N개 Task에 콜백 등록 = O(N²)
        Task<int> done = await Task.WhenAny(tasks);
        tasks.Remove(done);
        Process(await done);
    }
}

매 루프마다 WhenAny가 남은 모든 Task에 새로운 컨티뉴에이션을 등록합니다. 작업이 100개면 컨티뉴에이션 등록·해제가 약 5000번 발생합니다.

✅ 올바른 패턴 — Task.WhenEach (.NET 9+)

C#
async Task ProcessInOrderAsync(IEnumerable<Task<int>> tasks)
{
    await foreach (Task<int> completed in Task.WhenEach(tasks))
    {
        Process(await completed);
    }
}

.NET 9에서 도입된 Task.WhenEach는 완료되는 순서대로 Task를 비동기 스트림(IAsyncEnumerable<Task<T>>)으로 돌려줍니다. 내부적으로 IValueTaskSource를 활용해 O(N)으로 동작합니다. 구버전(.NET 8 이하·Unity 2022)에서는 TaskCompletionSource로 직접 구현해야 합니다.

5.5 함정 5 — Unity Profiler에서 GC 스파이크가 보일 때

Task.WhenAll(...)은 호출할 때마다 다음을 새로 할당합니다.

  • 게이트키퍼 Task 객체 (참조 타입, 힙 할당)
  • 입력 Task가 IEnumerable이면 내부적으로 배열로 변환 (할당)
  • 결과 T[] 배열 (Task<T> 버전)
  • 각 Task에 등록되는 컨티뉴에이션 콜백 객체

씬 전환·로딩 같이 한 번 일어나는 작업에서는 무시 가능하지만 Update 루프나 매 프레임 호출되는 핫패스에서 매번 Task.WhenAll을 만들면 GC 스파이크가 발생합니다.

❌ 잘못된 패턴 — Update 안에서 매 프레임 WhenAll

C#
void Update()
{
    // 매 프레임 WhenAll 호출 — GC 스파이크의 원인
    _ = Task.WhenAll(SomeWorkAsync(), AnotherWorkAsync());
}

✅ 올바른 패턴 — 핫패스에서는 Task 자체를 피하거나 ValueTask 사용

C#
// 핫패스에서는 동기 처리
void Update() { DoSyncWork(); }

// 비동기가 꼭 필요하면 한 번만 시작하고 상태 추적
Task? _pending;
void Update()
{
    if (_pending == null || _pending.IsCompleted)
    {
        _pending = Task.WhenAll(SomeWorkAsync(), AnotherWorkAsync());
    }
}

또는 Unity 전용 UniTask 라이브러리는 Task 대비 할당이 거의 없도록 설계돼 있어 핫패스에서 더 적합합니다.


6. C# 버전별 변화

버전 변화 의미
.NET Framework 4.5 (2012) Task.WhenAll / Task.WhenAny 도입 async/await와 함께 표준 비동기 조합기 등장
.NET Core 3.0 (2019) IAsyncEnumerable<T> 도입 비동기 스트림이 가능해지면서 WhenAny 루프의 대안 시작
.NET 6 (2021) Task.WaitAsync(TimeSpan) / Task.WaitAsync(CancellationToken) 타임아웃 패턴이 더 짧아짐 — WhenAny + Delay 보일러플레이트 감소
.NET 9 (2024) Task.WhenEach(IEnumerable<Task>) 도입 "완료 순서대로 처리" 패턴이 O(N)으로, 비동기 스트림으로 표현됨

6.1 .NET 5 이하 — WhenAny + Delay 타임아웃

C#
// Before — .NET 5 이전 (또는 Unity 2022 LTS, .NET Standard 2.1)
async Task<string> FetchWithTimeoutAsync(int timeoutMs)
{
    Task<string> work  = FetchAsync();
    Task         delay = Task.Delay(timeoutMs);

    if (await Task.WhenAny(work, delay) == delay)
        throw new TimeoutException();
    return await work;
}

6.2 .NET 6+ — WaitAsync로 한 줄 단축

C#
// After — .NET 6+
async Task<string> FetchWithTimeoutAsync(int timeoutMs)
{
    return await FetchAsync().WaitAsync(TimeSpan.FromMilliseconds(timeoutMs));
}

IL 관점에서의 차이

WaitAsync는 내부적으로는 여전히 컨티뉴에이션 + 타이머를 사용하지만, IL 레벨에서 보면 호출자 코드에서는 WhenAny 호출과 Task.Delay 객체 할당이 사라집니다.

IL
// Before — WhenAny 패턴
call class Task Task::Delay(int32)              // delay Task 할당
call class Task Task::WhenAny(class Task, class Task)  // 게이트키퍼 Task 할당
ceq                                              // 참조 비교
// ... 분기 ...

// After — WaitAsync
call class Task`1<...> Task`1::WaitAsync(valuetype TimeSpan)
// 끝

호출자 IL이 짧아지고 할당이 줄어듭니다. Unity 2022 LTS는 .NET Standard 2.1 기반이라 아직 WaitAsync가 없을 수 있지만, Unity 6 / .NET 8+ 환경이라면 적극 사용을 권장합니다.

6.3 .NET 9 — WhenEach로 완료 순서대로 처리

C#
// Before — WhenAny 루프 (O(N²))
async Task ProcessAsync(List<Task<int>> tasks)
{
    while (tasks.Count > 0)
    {
        var done = await Task.WhenAny(tasks);
        tasks.Remove(done);
        Console.WriteLine(await done);
    }
}

// After — WhenEach (O(N), 비동기 스트림)
async Task ProcessAsync(IEnumerable<Task<int>> tasks)
{
    await foreach (Task<int> completed in Task.WhenEach(tasks))
    {
        Console.WriteLine(await completed);
    }
}

WhenEach는 .NET 9 / C# 13 환경(서버·CLI)에서 사용 가능합니다. Unity는 아직 .NET Standard 2.1 기반이므로 직접 도입하지는 못하지만, 향후 이주 시 알아둘 가치가 있습니다.


7. 정리

이번 글에서 다룬 핵심을 한 줄씩 정리하면 다음과 같습니다.

# 항목 핵심
1 WhenAll vs WhenAny 모두 끝날 때까지 vs 가장 먼저 끝나는 하나
2 반환 타입 Task<T[]> (입력 순서) vs Task<Task> (완료된 Task 자체)
3 순차 vs 병렬 foreach (var t in tasks) await t;는 합산 시간, await Task.WhenAll(tasks)는 최댓값 시간
4 타임아웃 Task.WhenAny(work, Task.Delay(timeout)) 또는 .NET 6+의 WaitAsync
5 예외 처리 await Task.WhenAll은 첫 예외만 던진다 — 모든 예외는 Task 변수의 Exception.InnerExceptions
6 WhenAny 후 정리 나머지 Task는 자동으로 안 멈춘다 — CancellationToken으로 협조 취소
7 Wait* 금지 WaitAll/WaitAny/Result/Wait은 블로킹 — Unity 메인 스레드 절대 금지
8 Unity 핫패스 주의 매 프레임 WhenAll은 GC 스파이크 — Update 안에서 피하거나 UniTask 사용

체크리스트

  • [ ] 여러 작업이 서로 의존하지 않으면 await Task.WhenAll(...)로 묶었는가?
  • [ ] Task<T>[] 결과 배열이 입력 순서를 따른다는 점을 인지하고 있는가?
  • [ ] 타임아웃이 필요하면 WhenAny + Delay 또는 WaitAsync를 사용하고 있는가?
  • [ ] 여러 Task가 동시에 실패할 가능성이 있으면 Task 변수를 살려 Exception.InnerExceptions를 순회하는가?
  • [ ] WhenAny 이후 나머지 Task를 CancellationToken으로 정리하고 있는가?
  • [ ] Unity UI/Update 코드에서 Wait()·.Result·WaitAll을 사용하지 않는가?
  • [ ] Update 안에서 매 프레임 WhenAll을 호출하고 있지는 않은가?

WhenAllWhenAny는 비동기 코드의 "그리고/또는"입니다. await 한 번에 작업 한 개씩만 기다리던 한계를 넘어, 여러 비동기 작업을 자연스럽게 조합할 수 있게 해주는 가장 기본적인 도구입니다.

반응형
반응형

[PART13.비동기와 스레딩 기초(7/15)] 비동기 메서드의 반환형 — Task vs Task&lt;T&gt; vs void

결과가 없으면 Task / 있으면 Task&lt;T&gt; / async void는 이벤트 핸들러 외에는 절대 금지


1. 문제 제기 — "버튼을 누르면 게임이 통째로 죽는다"

Unity에서 데이터를 비동기로 불러오는 메서드를 처음 작성한 신입 개발자는 보통 이런 코드를 짭니다.

C#
public class DataLoader : MonoBehaviour
{
    public async void LoadProfileAsync()  // 무심코 void 선택
    {
        var profile = await FetchFromServer();
        if (profile == null)
            throw new InvalidOperationException("프로필이 없습니다");
        ApplyProfile(profile);
    }
}

// 호출부 — 버튼 클릭 핸들러
public void OnButtonClick()
{
    try
    {
        loader.LoadProfileAsync();  // 예외가 뜨면 잡힐까?
    }
    catch (Exception e)
    {
        Debug.LogError(e.Message);  // 절대 실행되지 않습니다
    }
}

FetchFromServer() 가 null을 반환하면 LoadProfileAsync() 안에서 예외가 발생합니다. 그런데 호출부의 try-catch 는 이 예외를 잡지 못합니다. Unity 에디터에서는 콘솔에 빨간 로그가 찍히고 끝나지만, IL2CPP 로 빌드한 모바일 빌드에서는 운이 나쁘면 앱이 그대로 종료됩니다.

같은 메서드를 async void 대신 async Task 로만 바꿔도 이 사고는 완전히 사라집니다.

C#
public async Task LoadProfileAsync()  // void → Task
{
    var profile = await FetchFromServer();
    if (profile == null)
        throw new InvalidOperationException("프로필이 없습니다");
    ApplyProfile(profile);
}

// 호출부
public async void OnButtonClick()  // 이벤트 핸들러는 어쩔 수 없이 void
{
    try
    {
        await loader.LoadProfileAsync();  // await 가 예외를 다시 던져준다
    }
    catch (Exception e)
    {
        Debug.LogError(e.Message);  // 정상적으로 잡힌다
    }
}

차이는 단 한 글자(voidTask)지만, 결과는 앱 강제 종료정상 에러 로깅 만큼 다릅니다. 이 글은 왜 이런 차이가 생기는지, 컴파일러가 세 반환형을 IL 레벨에서 어떻게 다르게 처리하는지, 그리고 Unity 모바일 게임에서 어떤 규칙으로 선택해야 하는지 설명합니다.


2. 개념 정의 — 세 반환형의 의미

2.1 비유 — 택배 송장과 영수증

비동기 메서드의 반환형은 호출자에게 돌려주는 "증빙서류" 라고 생각하면 쉽습니다.

반환형 비유 호출자가 할 수 있는 것
Task<T> 상품 교환권 나중에 결과 상품을 받음 + 도착 여부 확인 + 분실 시 환불 청구
Task 택배 송장 번호 도착 여부 확인 + 분실 시 환불 청구 (상품은 없음)
void 아무것도 받지 못함 보냈는지조차 알 수 없음 — 사고 나도 모름

void 가 위험한 이유가 한눈에 보입니다. 호출자가 작업의 운명을 추적할 방법이 없습니다.

2.2 SVG로 보는 세 반환형 비교

async 메서드의 반환형 비교

2.3 기본 코드 — 세 반환형의 가장 단순한 형태

async — 비동기 메서드 표시 키워드 (asynchronous) 메서드 안에서 await 를 사용할 수 있게 해주는 키워드. 컴파일러는 이 키워드가 붙은 메서드를 상태 머신(state machine) 으로 변환한다.
예시: public async Task DoAsync() { await Task.Delay(100); } Task.Delay 가 끝날 때까지 메서드를 일시 중단했다가 재개
C#
public class AsyncReturnTypes
{
    // (1) 결과가 있는 비동기 — Task<T>
    public async Task<int> GetUserScoreAsync(int userId)
    {
        await Task.Delay(100);   // 비동기 작업 (네트워크 호출 가정)
        return 9999;             // T(int) 결과 반환
    }

    // (2) 결과가 없는 비동기 — Task
    public async Task SaveUserScoreAsync(int userId, int score)
    {
        await Task.Delay(100);   // 비동기 작업
        // return 문 없음
    }

    // (3) 이벤트 핸들러용 — async void
    public async void OnSaveButtonClicked(object sender, EventArgs e)
    {
        try
        {
            await SaveUserScoreAsync(1, 9999);
        }
        catch (Exception ex)
        {
            // 이벤트 핸들러 안에서는 반드시 try-catch
            Debug.LogError(ex);
        }
    }
}

세 메서드의 본문 로직은 비슷하지만 반환형만 다릅니다. 이 차이가 호출자에게 무엇을 줄 수 있는지를 결정합니다.

Task<T>Task호출자에게 객체를 돌려주기 때문에 await 로 결과를 받고 예외를 잡고 완료 시점을 알 수 있습니다. async void 는 돌려줄 객체가 아예 없습니다.


3. 내부 동작 — 컴파일러가 만드는 세 가지 빌더

3.1 상태 머신과 메서드 빌더

상태 머신 (state machine) async 메서드는 컴파일 시점에 IAsyncStateMachine 인터페이스를 구현하는 별도의 구조체로 변환된다. 이 구조체에는 <>1__state 필드가 있어서 메서드가 어디까지 실행됐는지 단계를 기록한다.
메서드 빌더 (method builder) 상태 머신과 호출자 사이를 잇는 어댑터. 반환형(Task<T>/Task/void)에 따라 세 종류 중 하나가 자동으로 선택된다. 결과를 호출자에게 전달하고, 예외가 발생하면 적절히 라우팅한다.
컴파일러가 생성하는 상태 머신과 빌더

3.2 IL로 보는 세 빌더 — 결정적 차이

세 가지 반환형을 가진 메서드를 컴파일해서 IL을 비교해보면, 컴파일러가 빌더 타입만 다르고 나머지 구조는 똑같다는 사실이 드러납니다.

Task<T> 버전 IL — AsyncTaskMethodBuilder<int>

C#
public async Task<int> GetValueAsync()
{
    await Task.Delay(10);
    return 42;
}
IL
// 상태 머신 구조체 필드 정의
.field public valuetype System.Runtime.CompilerServices.AsyncTaskMethodBuilder`1<int32>
    '<>t__builder'

// MoveNext() 정상 종료 — 결과를 Task<int> 에 저장
IL_008a: call instance void
    AsyncTaskMethodBuilder`1<int32>::SetResult(!0)   // 42를 Task에 박음

// MoveNext() catch 블록 — 예외를 Task<int> 에 저장
IL_0075: call instance void
    AsyncTaskMethodBuilder`1<int32>::SetException(class System.Exception)

Task 버전 IL — AsyncTaskMethodBuilder (제네릭 아님)

C#
public async Task DoWorkAsync()
{
    await Task.Delay(10);
}
IL
// 상태 머신 구조체 필드 정의
.field public valuetype System.Runtime.CompilerServices.AsyncTaskMethodBuilder
    '<>t__builder'

// MoveNext() 정상 종료 — 결과 없이 Task 만 완료
IL_008a: call instance void
    AsyncTaskMethodBuilder::SetResult()              // 인자 없음

// MoveNext() catch 블록 — 예외를 Task 에 저장
IL_0075: call instance void
    AsyncTaskMethodBuilder::SetException(class System.Exception)

void 버전 IL — AsyncVoidMethodBuilder (위험)

C#
public async void FireAndForgetAsync()
{
    await Task.Delay(10);
    throw new InvalidOperationException("async void에서 발생한 예외");
}
IL
// 상태 머신 구조체 필드 정의 — 빌더 타입만 다르다
.field public valuetype System.Runtime.CompilerServices.AsyncVoidMethodBuilder
    '<>t__builder'

// MoveNext() catch 블록 — Task 가 없으므로 SynchronizationContext 로 직송
IL_007e: call instance void
    AsyncVoidMethodBuilder::SetException(class System.Exception)
//  ↑ 동일한 메서드 이름이지만, 내부 구현이 완전히 다르다.
//  AsyncTaskMethodBuilder.SetException → Task.Exception 에 저장
//  AsyncVoidMethodBuilder.SetException → SynchronizationContext.Post(throw)

3.3 IL 해설 — 같은 호출, 다른 운명

세 IL 모두 마지막에 SetException(e) 를 호출합니다. 메서드 이름은 같지만 동작이 완전히 다릅니다.

빌더 SetException(e) 가 하는 일
AsyncTaskMethodBuilder<T> 반환된 Task<T>Exception 속성에 예외를 저장. 호출자가 await 하면 그 시점에 예외가 다시 던져진다.
AsyncTaskMethodBuilder 반환된 Task 에 예외 저장. 위와 동일.
AsyncVoidMethodBuilder 반환할 Task 가 없다. 메서드 시작 시 캡처해둔 SynchronizationContextPost() 를 호출해 예외를 그 컨텍스트 위에서 다시 던진다.

SynchronizationContext 가 없는 환경(콘솔 앱, 일반 워커 스레드, 일부 Unity 컨텍스트)에서는 ThreadPool 위에서 예외가 던져지고, 이는 잡히지 않는 예외(unhandled exception) 로 취급되어 프로세스를 그대로 종료시킵니다.

핵심: async void 는 예외를 호출자가 잡을 수 있는 경로가 IL 레벨에서부터 존재하지 않습니다.


4. 실전 적용 — 반환형 선택 규칙

4.1 결정 트리

반환형 선택 결정 트리

4.2 Before/After — Unity 모바일 게임 시나리오

시나리오 1: 게임 데이터 저장 메서드 — 결과 없음

❌ Before — async void 로 작성

C#
public class SaveSystem : MonoBehaviour
{
    // 잘못된 패턴 — 라이브러리성 메서드를 async void 로 노출
    public async void SaveGameAsync(GameData data)
    {
        var json = JsonUtility.ToJson(data);
        await File.WriteAllTextAsync(savePath, json);
        // 실패 시 예외가 어디로 가는지 호출자가 알 수 없음
    }
}

// 호출부
public class GameManager : MonoBehaviour
{
    private SaveSystem saveSystem;

    public async Task OnLevelClearAsync()
    {
        try
        {
            saveSystem.SaveGameAsync(currentData);   // 즉시 반환 (Task 가 없어 await 불가)
            await ShowVictoryAnimationAsync();        // 저장 완료 전에 진행
            // 저장이 끝났는지 호출자가 확인할 방법이 없다
        }
        catch (IOException e)
        {
            Debug.LogError("저장 실패");  // 잡히지 않는다
        }
    }
}

문제 두 가지: (1) 호출자가 저장 완료를 기다릴 수 없어 저장 도중 다른 화면으로 넘어가면 데이터 일부가 손상될 수 있고, (2) IOException 이 발생하면 try-catch 를 무시하고 프로세스가 종료됩니다.

✅ After — async Task 로 변경

C#
public class SaveSystem : MonoBehaviour
{
    // 올바른 패턴 — 호출자에게 Task 를 돌려준다
    public async Task SaveGameAsync(GameData data)
    {
        var json = JsonUtility.ToJson(data);
        await File.WriteAllTextAsync(savePath, json);
    }
}

public class GameManager : MonoBehaviour
{
    public async Task OnLevelClearAsync()
    {
        try
        {
            await saveSystem.SaveGameAsync(currentData);  // 완료까지 대기
            await ShowVictoryAnimationAsync();             // 저장 후 애니메이션
        }
        catch (IOException e)
        {
            Debug.LogError($"저장 실패: {e.Message}");    // 정상적으로 잡힌다
            ShowSaveFailedDialog();
        }
    }
}

async Task 로 바꾸면 IL 레벨에서 AsyncTaskMethodBuilderTask 를 반환하므로 await 가 예외를 호출자에게 다시 던질 수 있습니다.

시나리오 2: 사용자 점수 조회 — 결과 있음

C#
// ❌ 결과를 멤버 변수로 흘려보내는 패턴 (async void 강요)
public class ScoreLoader : MonoBehaviour
{
    public int LoadedScore;  // 외부 상태에 의존

    public async void LoadScoreAsync(int userId)
    {
        LoadedScore = await FetchFromServer(userId);
        // 호출자는 LoadedScore 가 갱신됐는지 어떻게 알지?
    }
}

// 호출부 — 폴링 + 타임아웃 같은 임시방편 코드 양산
while (scoreLoader.LoadedScore == 0) await Task.Yield();
C#
// ✅ Task<T> 로 결과를 직접 돌려준다
public class ScoreLoader : MonoBehaviour
{
    public async Task<int> LoadScoreAsync(int userId)
    {
        return await FetchFromServer(userId);  // 결과를 Task<int> 에 담아 반환
    }
}

// 호출부 — 한 줄
int score = await scoreLoader.LoadScoreAsync(userId);

판단 기준: 메서드가 값을 만들어낸다면 그것을 멤버 변수가 아니라 반환값으로 흘려보내야 합니다. 그러려면 Task<T> 가 강제됩니다.

4.3 IL 비교 — async void → async Task 변경의 효과

SaveGameAsync 의 빌더 필드 타입만 비교하면 됩니다.

IL
// ❌ async void
.field public valuetype AsyncVoidMethodBuilder '<>t__builder'

// ✅ async Task
.field public valuetype AsyncTaskMethodBuilder '<>t__builder'

소스 코드에서는 키워드 한 글자만 바뀌었지만, IL 에서는 호출자에게 돌려줄 객체의 종류가 통째로 달라집니다. 이 한 글자가 예외를 잡을 수 있느냐 없느냐 를 결정합니다.


5. 함정과 주의사항

5.1 ❌ 함정 1: 라이브러리 메서드를 async void 로 노출

C#
// ❌ 절대 금지 — 라이브러리(공용 모듈) 메서드는 async void 금지
public class NetworkClient
{
    public async void SendRequestAsync(string url)  // 사용자가 await 못 함
    {
        var response = await httpClient.GetAsync(url);
        ProcessResponse(response);
    }
}
C#
// ✅ 라이브러리 메서드는 항상 Task / Task<T>
public class NetworkClient
{
    public async Task<HttpResponseMessage> SendRequestAsync(string url)
    {
        var response = await httpClient.GetAsync(url);
        return response;
    }
}

원칙: 호출자(라이브러리 사용자)에게 비동기 작업의 제어권을 넘겨준다. 호출자는 await, Task.WhenAll, try-catch 중 무엇이든 자기 사정에 맞게 선택할 수 있어야 합니다. async void 는 이 모든 선택지를 박탈합니다.

5.2 ❌ 함정 2: 이벤트 핸들러에서 try-catch 누락

C#
// ❌ 위험 — 예외가 새어 나가면 앱이 죽는다
public class UIController : MonoBehaviour
{
    public async void OnLoadButtonClicked()
    {
        var data = await LoadAsync();  // 예외 발생 가능
        UpdateUI(data);
    }
}
C#
// ✅ 이벤트 핸들러는 메서드 전체를 try-catch 로 감싼다
public class UIController : MonoBehaviour
{
    public async void OnLoadButtonClicked()
    {
        try
        {
            var data = await LoadAsync();
            UpdateUI(data);
        }
        catch (Exception e)
        {
            Debug.LogException(e);
            ShowErrorDialog(e.Message);
        }
    }
}

async void 는 예외를 잡을 외부 경로가 없으므로 메서드 자체에서 모든 예외를 흡수해야 합니다.

5.3 ❌ 함정 3: 단위 테스트가 통과해도 동작 검증이 안 됨

C#
// ❌ async void 메서드는 테스트가 거짓 통과한다
[Test]
public void SaveTest_async_void()
{
    var system = new SaveSystem();
    system.SaveGameAsync_void(testData);   // async void
    // 테스트 러너는 이 줄을 지나면 즉시 다음으로 진행
    // SaveGameAsync 가 끝나기 전에 테스트가 "성공" 으로 종료
    Assert.IsTrue(File.Exists(savePath));  // 파일이 아직 없을 수 있음
}
C#
// ✅ async Task 면 테스트 러너가 완료까지 대기한다
[Test]
public async Task SaveTest_async_Task()
{
    var system = new SaveSystem();
    await system.SaveGameAsync(testData);  // 완료까지 대기
    Assert.IsTrue(File.Exists(savePath));   // 파일이 반드시 존재
}

테스트가 거짓 통과(false positive)한다는 사실 자체가 위험합니다. 통과한 테스트가 실제로 동작을 보장하지 않기 때문입니다.

5.4 ❌ 함정 4: Unity 라이프사이클에서의 async void

C#
// ⚠️ 주의 — Start() 같은 단발성 라이프사이클 메서드만 허용. Update() 는 금지
public class BadUsage : MonoBehaviour
{
    private async void Update()  // ❌ 매 프레임 호출 + 비동기 = 폭주
    {
        await LoadDataAsync();   // 60 FPS 면 초당 60번 새로운 비동기 작업 시작
        UpdateUI();
    }

    private async void Start()   // ✅ 한 번만 호출되므로 OK (이벤트 핸들러 성격)
    {
        try
        {
            await InitializeAsync();
        }
        catch (Exception e)
        {
            Debug.LogException(e);
        }
    }
}

Start(), OnEnable(), OnDisable() 처럼 한 번만 호출되는 라이프사이클 메서드는 async void 가 가능하지만, Update() 는 매 프레임 비동기 작업이 누적되므로 절대 사용하지 않습니다. 이 경우 Coroutine 이나 UniTask.Forget() 패턴을 사용합니다.

5.5 Unity 실전 추가 함정 — destroyCancellationToken 미연결

C#
// ❌ GameObject 가 파괴된 뒤에 await 가 끝나면 MissingReferenceException
public async Task LoadAndApplyAsync()
{
    var data = await FetchAsync();         // 5초 걸림
    // 그동안 사용자가 씬을 바꿔서 이 GameObject 가 파괴됨
    transform.position = data.spawnPoint;  // 💥 MissingReferenceException
}
C#
// ✅ destroyCancellationToken (Unity 2022.2+) 으로 안전하게 취소
public async Task LoadAndApplyAsync()
{
    var data = await FetchAsync(destroyCancellationToken);
    transform.position = data.spawnPoint;  // 파괴되면 여기 도달하지 않음
}

이 함정은 반환형과 직접 관련되진 않지만, async Task 를 쓰기 시작하면 반드시 동반되는 패턴이라 함께 익혀둡니다.


6. C# 버전별 변화

6.1 C# 5.0 (2012) — async/await 도입과 세 반환형

C# 5.0 이 async/await 와 함께 Task, Task<T>, void 반환형을 모두 도입했습니다. 이 시점부터 컴파일러는 메서드 빌더 세 종류(AsyncTaskMethodBuilder<T>, AsyncTaskMethodBuilder, AsyncVoidMethodBuilder) 를 자동 선택했습니다.

6.2 C# 7.0 (2017) — ValueTask / ValueTask<T> 추가

C#
// C# 7.0+ — 결과가 동기적으로 즉시 준비되는 경우 GC 할당 회피
public async ValueTask<int> GetCachedScoreAsync(int userId)
{
    if (cache.TryGetValue(userId, out var score))
        return score;  // Task 객체 할당 없이 즉시 반환

    return await FetchFromServer(userId);
}

Task<T> 는 참조 타입(class) 이라 매번 힙에 할당되지만, ValueTask<T> 는 구조체(struct) 라 동기 완료 경로에서 할당이 0입니다. 자주 호출되는 핫패스에서 결과가 캐시될 가능성이 높을 때만 도입합니다(잘못 쓰면 더 느려질 수 있음).

6.3 C# 7.0 — Custom Async Method Builder

C#
// AsyncMethodBuilderAttribute 로 사용자 정의 빌더 지정 가능
[AsyncMethodBuilder(typeof(MyTaskMethodBuilder))]
public struct MyTask { /* ... */ }

이 기능 덕분에 Unity 생태계의 UniTask 가 등장했습니다. UniTask 는 자체 빌더를 사용해 GC 할당 없는 async UniTaskasync UniTask<T> 를 제공합니다.

6.4 UniTask — Unity 모바일 실전 표준

C#
// ✅ Unity 모바일 게임에서 사실상의 표준
public class GameLoader : MonoBehaviour
{
    public async UniTask<UserData> LoadUserAsync()  // Task<T> 대신
    {
        var data = await UnityWebRequest.Get(url).SendWebRequest();
        return Parse(data);
    }

    // async UniTaskVoid: async void 의 안전한 대체재
    public async UniTaskVoid OnButtonClicked()
    {
        try
        {
            var user = await LoadUserAsync();
            ApplyUI(user);
        }
        catch (Exception e)
        {
            Debug.LogException(e);
        }
    }
}

UniTask / UniTask<T> 는 구조체 기반이라 0 GC 할당이고, UniTaskVoidasync void 의 예외 라우팅 문제를 해결합니다. Unity 모바일에서는 Task<T> 보다 UniTask<T> 를 사용하는 것이 권장됩니다(추정 — 프로젝트마다 도입 여부 판단 필요).


7. 정리

세 반환형의 본질은 호출자에게 무엇을 돌려줄 것인가 입니다.

반환형 언제 쓰는가 호출자 능력 IL 빌더
async Task<T> 결과가 있는 비동기 메서드 (대부분의 경우) 결과 받음 + 완료 대기 + 예외 catch AsyncTaskMethodBuilder<T>
async Task 결과가 없는 비동기 메서드 (저장·전송 등) 완료 대기 + 예외 catch AsyncTaskMethodBuilder
async void 이벤트 핸들러 전용 아무것도 못 함 AsyncVoidMethodBuilder

기억해야 할 5가지

  • 결과가 있으면 Task<T>, 없으면 Task. 일반 메서드의 기본 선택지는 이 둘 중 하나다.
  • async void 는 이벤트 핸들러 전용. 라이브러리·공용 모듈·테스트 가능한 코드에서는 절대 쓰지 않는다.
  • async void 의 예외는 호출자가 잡을 수 없다. IL 레벨에서 AsyncVoidMethodBuilder.SetExceptionSynchronizationContext 로 직송하기 때문이다.
  • 이벤트 핸들러는 메서드 전체를 try-catch 로 감싼다. async void 의 유일한 안전망은 자기 자신뿐이다.
  • 단위 테스트는 async Task 에서만 신뢰할 수 있다. async void 는 테스트가 거짓 통과한다.

Unity 실전 체크리스트

  • [ ] 일반 메서드의 반환형은 Task 또는 Task<T> 인가?
  • [ ] async voidOnButtonClicked 같은 이벤트 핸들러에만 있는가?
  • [ ] 모든 async void 메서드는 메서드 전체가 try-catch 로 감싸져 있는가?
  • [ ] Update()async void 가 들어가지 않았는가?
  • [ ] 모바일 핫패스라면 Task<T> 대신 UniTask<T> 도입을 검토했는가?
  • [ ] await 후에도 GameObject 가 살아있다는 보장이 필요한 곳에 destroyCancellationToken 을 연결했는가?
반응형
반응형

[PART13.비동기와 스레딩 기초(6/15)] async / await — 비동기 흐름을 동기처럼 쓰는 문법

콜백 지옥을 동기 코드처럼 / 반환형 5종 / I/O는 await, CPU는 Task.Run / DoAsync 보면 무조건 그쪽


1. [문제 제기] — 비동기 작업이 늘어나면 코드가 콜백 미로가 된다

Unity에서 모바일 게임을 만들다 보면 비동기로 처리해야 하는 일이 끊이지 않습니다. 서버에서 유저 정보를 받아오고, 그 정보로 인벤토리를 조회하고, 아이콘 텍스처를 다운로드하고, 마지막으로 캐시에 저장하는 흐름은 흔합니다. 이 모든 단계가 네트워크를 타기 때문에 메인 스레드를 막을 수는 없습니다.

async/await 가 없던 시절에는 콜백으로 이 흐름을 표현했습니다. 결과를 받는 함수가 또 다른 비동기 호출을 시작하고, 그 결과를 받는 함수가 다시 다른 비동기 호출을 시작하는 식이었습니다.

C#
// async/await 없이 콜백으로 비동기 흐름을 잇는 옛날 패턴
public void LoadUserFlow(int userId, Action<UserProfile> onDone)
{
    api.GetUser(userId, user =>
    {
        api.GetInventory(user.Id, inventory =>
        {
            api.DownloadIcon(inventory.IconUrl, icon =>
            {
                cache.Save(icon, () =>
                {
                    onDone(new UserProfile(user, inventory, icon));
                });
            });
        });
    });
}

콜백이 4단계만 쌓여도 들여쓰기가 화면 오른쪽으로 흘러갑니다. 예외가 발생하면 어디서 터졌는지 추적하기 어렵고, try/catch 한 번으로 전체 흐름을 감쌀 수도 없습니다. 함수가 어디서 끝나는지도 한눈에 보이지 않습니다.

C# 5.0에 도입된 async / await 는 이 문제를 정면으로 해결합니다. 비동기 작업을 마치 동기 코드처럼 위에서 아래로 한 줄씩 읽히게 만들어 주는 문법입니다. 컴파일러가 뒤에서 상태 머신(State Machine, 메서드를 여러 조각으로 쪼개고 어디까지 실행했는지 기억하는 자동 생성 자료구조)을 만들어 주기 때문에, 우리는 그 결과만 누리면 됩니다.

본 글은 사용 문법·반환형 규칙·실전 패턴에 집중합니다. 컴파일러가 만드는 상태 머신의 내부 구조와 ConfigureAwait, SynchronizationContext 의 동작 원리는 이미 앞 주제(05. Task와 Task<T>)와 별도 심화 글에서 다룹니다.

2. [개념 정의] — async 는 "이 메서드 안에서 await 를 쓰겠다"는 표시, await 는 "제어권 양보 + 콜백 등록"

2-1. 비유: 우체국 등기와 알림 문자

async / await 를 직관적으로 이해하려면 우체국 등기 신청을 떠올리면 좋습니다.

async — 비동기 메서드 한정자 (asynchronous modifier) 메서드·람다·로컬 함수 앞에 붙어 "이 안에서 await 를 사용하겠다"는 신호를 컴파일러에게 전달합니다. async 자체가 메서드를 백그라운드에서 실행시키는 것은 아닙니다.
예시: public async Task<string> GetAsync() { ... } 컴파일러가 이 메서드를 상태 머신으로 변환합니다.
await — 비동기 대기 연산자 (await operator) Task · Task<T> · ValueTask · ValueTask<T> 같은 awaitable 객체 앞에 붙어 "작업이 끝날 때까지 이 메서드의 실행을 멈추되, 호출자에게 제어권을 즉시 돌려준다"를 의미합니다. 작업이 끝나면 멈췄던 그 줄 다음부터 자동으로 이어 실행됩니다.
예시: var html = await client.GetStringAsync(url); HTTP 응답이 도착할 때까지 스레드를 점유하지 않습니다.
async/await ≈ 등기 + 알림 문자

핵심은 단어가 주는 인상에 속지 않는 것입니다. await 는 "기다린다"가 아니라 "기다릴 일을 예약하고 즉시 자리를 비운다" 입니다. 작업이 끝나면 시스템이 알아서 우리를 다시 호출해 줍니다.

2-2. 가장 기본적인 사용 형태

문제 정의에서 인용한 사양 그대로의 형태가 가장 단순한 예입니다.

C#
using System.Net.Http;
using System.Threading.Tasks;

public class HtmlFetcher
{
    private static readonly HttpClient client = new();

    // async 한정자, Task<string> 반환형, 메서드 이름 끝에 Async
    public async Task<string> GetAsync(string url)
    {
        // await 는 Task<string> 앞에만 사용 가능
        var html = await client.GetStringAsync(url);
        return html.Trim();
    }
}

세 줄짜리 메서드지만 약속이 빼곡합니다.

  1. async 한정자 — "이 안에서 await 를 쓰겠다"는 컴파일러 신호
  2. Task<string> 반환형 — await 가능한 awaitable. 메서드 본문은 stringreturn 하지만 컴파일러가 Task<string> 으로 감싸 줍니다.
  3. Async 접미사 — 호출자 입장에서 "이건 비동기니까 await 붙여야 해"를 즉시 알아챌 수 있는 명명 규약
  4. awaitTask<T> 가 끝나면 그 결과(여기서는 string)를 꺼내 html 에 대입. 끝나기 전이면 호출자에게 제어권을 돌려줍니다.

2-3. IL 레벨에서 본 변환 결과

이 메서드를 컴파일하고 IL을 확인하면 우리가 작성한 한 줄짜리 메서드가 사라지고, 컴파일러가 만든 자동 생성 코드가 그 자리를 대체했음을 볼 수 있습니다.

IL
// 호출자 진입점 — 우리가 작성한 GetAsync() 의 실제 IL
.method public hidebysig instance class Task`1<string> GetAsync(string url) cil managed
{
    .custom instance void AsyncStateMachineAttribute::.ctor(class Type) // 컴파일러가 붙인 어트리뷰트
    .locals init ([0] valuetype Sample1/'<GetAsync>d__1') // 상태 머신 구조체

    IL_0000: ldloca.s 0
    IL_0002: call AsyncTaskMethodBuilder`1<string>::Create() // 빌더 생성
    IL_0007: stfld ...t__builder
    IL_000c: ldloca.s 0
    IL_000e: ldarg.1
    IL_000f: stfld string ...url   // 인자를 상태 머신 필드에 저장
    IL_0014: ldloca.s 0
    IL_0016: ldc.i4.m1
    IL_0017: stfld int32 ...'<>1__state' // 초기 상태 -1
    IL_001c: ldloca.s 0
    IL_001e: ldflda ...t__builder
    IL_0023: ldloca.s 0
    IL_0025: call AsyncTaskMethodBuilder`1::Start<...>(!!0&) // 상태 머신 가동
    IL_002a: ldloca.s 0
    IL_002c: ldflda ...t__builder
    IL_0031: call get_Task() // 빌더에서 Task 꺼내서 호출자에게 반환
    IL_0036: ret
}
IL
// 우리가 적은 한 줄(`var html = await client.GetStringAsync(url);`)이
// 컴파일러가 만든 MoveNext 안에서 어떻게 펼쳐졌는지 핵심 부분만
IL_000a: ldsfld HttpClient Sample1::client
IL_0010: ldfld string ...url
IL_0015: callvirt Task`1<string> HttpClient::GetStringAsync(string)
IL_001a: callvirt TaskAwaiter`1::GetAwaiter()
IL_001f: stloc.2
IL_0022: call TaskAwaiter`1::get_IsCompleted() // 이미 끝났으면 그대로 진행
IL_0027: brtrue.s IL_0065
// 끝나지 않았으면: 상태 머신을 힙으로 박싱, 콜백 등록, 메서드 빠져나감
IL_0042: call AsyncTaskMethodBuilder`1::AwaitUnsafeOnCompleted<...>(...)
IL_0047: leave.s IL_009f
// ↓ 작업 완료 시 시스템이 다시 들어와 IL_0049 부터 실행
IL_0065: ldloca.s 2
IL_0067: call TaskAwaiter`1::GetResult() // string 결과 꺼냄
IL_006c: callvirt String::Trim() // 우리가 적은 .Trim() 호출

핵심만 짚으면 이렇습니다.

  • async 한정자가 붙은 메서드는 IL 레벨에서 본문이 통째로 비워지고, AsyncStateMachine 구조체가 만들어져 MoveNext 라는 메서드 안으로 본문이 옮겨집니다. 호출자가 부르는 메서드는 이제 "상태 머신을 만들고 시작 → Task 를 반환"만 합니다.
  • await 는 IL 한 줄로 컴파일되지 않습니다. GetAwaiter()IsCompleted 검사 → (미완료면) 콜백 등록 후 메서드 종료, (완료면) GetResult() 라는 패턴으로 펼쳐집니다.
  • 그래서 await 가 "기다림"이 아니라 "콜백 등록 후 자리 비움"이라는 비유가 정확합니다. IsCompletedfalseAwaitUnsafeOnCompleted 가 호출되고, 메서드는 leave.s 로 빠져나갑니다.
상태 머신의 모든 상태 전이와 MoveNext 의 분기 구조는 05. Task와 Task<T> 에서 이미 자세히 분석했습니다. 본 글은 "이 메커니즘이 있다는 사실"만 활용해 사용 문법에 집중합니다.

3. [내부 동작] — 반환형 5종과 그들이 awaitable 인 이유

async 메서드의 반환형에는 엄격한 제약이 있습니다. await 로 결과를 받을 수 있는 타입(awaitable) 이거나, 이벤트 시스템이 요구하는 void 여야 합니다.

3-1. 반환형 5종 한 눈에

반환형 언제 쓰는가 await 가능 메모리
Task 결과값 없는 비동기 작업. 동기의 void 와 짝 참조 타입(힙)
Task<T> 결과값이 있는 비동기 작업. 가장 흔함 참조 타입(힙)
ValueTask 동기 완료 가능성이 매우 높은 결과값 없는 작업 값 타입(스택)
ValueTask<T> 동기 완료 가능성이 매우 높은 결과값 있는 작업 값 타입(스택)
void 이벤트 핸들러 한정. 그 외에는 절대 금지 추적 불가
async 메서드의 반환형 결정 흐름

3-2. 코드로 보는 4종

C#
using System.IO;
using System.Threading.Tasks;

public class ReturnTypeShowcase
{
    // 1. Task : 결과값이 없는 작업
    public async Task SaveAsync(string path, string text)
    {
        await File.WriteAllTextAsync(path, text);
        // return 키워드 자체를 안 쓰는 게 자연스럽다
    }

    // 2. Task<T> : 결과값이 있는 작업 — 가장 흔한 형태
    public async Task<int> CountLinesAsync(string path)
    {
        var lines = await File.ReadAllLinesAsync(path);
        return lines.Length; // int 를 return 하면 컴파일러가 Task<int> 로 감싸 준다
    }

    // 3. ValueTask<T> : 동기 완료 가능성이 매우 높을 때 (캐시 적중 등)
    private string? cached;
    public ValueTask<string> ReadCachedAsync(string path)
    {
        if (cached is not null)
            return new ValueTask<string>(cached); // 동기 완료, 힙 할당 0
        return new ValueTask<string>(LoadAsync(path));
    }
    private async Task<string> LoadAsync(string path)
    {
        var text = await File.ReadAllTextAsync(path);
        cached = text;
        return text;
    }

    // 4. async void : 이벤트 핸들러에서만 — 일반 메서드에서는 절대 금지
    // private async void OnButtonClick(object sender, EventArgs e) { ... }
}

각 반환형은 자신만의 빌더(AsyncTaskMethodBuilder, AsyncTaskMethodBuilder<T>, AsyncValueTaskMethodBuilder, AsyncValueTaskMethodBuilder<T>, AsyncVoidMethodBuilder)를 가지고 있고, 컴파일러는 반환형에 맞는 빌더로 상태 머신을 구동합니다. 우리가 신경 써야 할 것은 "어떤 시그니처를 골라야 하는가"뿐입니다.

Task<T> vs ValueTask<T> 의 정확한 트레이드오프와 다회 await 금지·풀링 같은 디테일은 다음 주제(07. 비동기 메서드의 반환형 — Task vs Task<T> vs void)에서 따로 다룹니다. 본 글에서는 "기본은 Task / Task<T>, 핫패스에서만 측정 후 ValueTask 도입" 정도만 기억하면 충분합니다.

3-3. async void 가 위험한 이유

async void 가 일반 메서드에서 금지되는 이유는 두 가지입니다.

C#
public class VoidDanger
{
    // 절대 따라하면 안 되는 패턴
    public async void DoBadlyAsync()
    {
        await Task.Delay(10);
        throw new InvalidOperationException("터졌어요"); // 어디서도 잡을 수 없음
    }

    public async Task CallerAsync()
    {
        try
        {
            DoBadlyAsync(); // Task 가 없어 await 불가
            // 메서드가 끝났는지 알 길도 없음
        }
        catch (InvalidOperationException) // 여기로 안 들어옴
        {
            // 도달 불가
        }
    }
}
  1. 완료 추적 불가 — 반환형이 void 니까 await 할 대상이 없습니다. 호출자는 그 비동기 메서드가 끝났는지, 성공했는지조차 알 수 없습니다.
  2. 예외 처리 불가async void 안에서 발생한 예외는 호출자의 try/catch 로 전파되지 않습니다. 대신 SynchronizationContext 에 즉시 던져져 보통 프로세스가 강제 종료됩니다.

이벤트 핸들러(OnButtonClick, Unity 의 일부 UI 이벤트)는 이벤트 시그니처가 void 를 강제하기 때문에 어쩔 수 없이 사용합니다. 그 외에는 무조건 Task 또는 Task<T> 를 반환하도록 합니다.


4. [실전 적용] — I/O 는 await, CPU 는 await Task.Run, DoAsync 가 보이면 동기 버전 금지

비동기 코드를 처음 만나는 신입 개발자가 가장 먼저 부딪치는 결정은 "어떤 호출 앞에 await 를 붙일 것인가" 입니다. 두 가지 큰 패턴만 익히면 됩니다.

4-1. I/O 바운드: 외부에 일을 맡기고 스레드를 풀어준다

네트워크 요청·파일 입출력·DB 쿼리 같은 작업은 대부분의 시간을 "결과가 도착하기를 기다리는" 데 씁니다. CPU 가 연산하는 시간이 거의 없습니다. 이런 호출은 라이브러리 쪽에서 이미 비동기 버전을 제공합니다.

C#
// ❌ Before — 동기 호출로 스레드를 통째로 점유
public string Fetch(string url)
{
    var client = new HttpClient();
    string html = client.GetStringAsync(url).Result; // 응답 올 때까지 스레드 블로킹
    return html.Trim();
}

// ✅ After — await 로 외부에 위임, 스레드 자유
public async Task<string> FetchAsync(string url)
{
    var client = new HttpClient();
    string html = await client.GetStringAsync(url); // 응답 동안 스레드 점유 없음
    return html.Trim();
}

After 의 IL 은 앞서 본 것처럼 상태 머신과 콜백 등록 코드로 변환됩니다. Before 의 IL 은 어떤지 보겠습니다.

IL
// Before: GetSync 의 핵심 IL — 동기 .Result 호출
.method public hidebysig instance Task`1<string> GetSync(string url) cil managed
{
    IL_0000: ldsfld HttpClient Sample2::client
    IL_0005: ldarg.1
    IL_0006: callvirt Task`1<string> HttpClient::GetStringAsync(string)
    IL_000b: callvirt !0 Task`1<string>::get_Result() // ← 여기서 스레드 블로킹
    IL_0010: callvirt string String::Trim()
    IL_0015: call Task`1<!!0> Task::FromResult<string>(!!0)
    IL_001a: ret
}

After 와 Before 의 IL 차이가 결정적입니다.

  • After — 컴파일러가 상태 머신 구조체와 MoveNext 를 만들고, IsCompletedfalseAwaitUnsafeOnCompleted 로 콜백을 등록한 뒤 leave 로 메서드를 빠져나갑니다. 응답이 오면 시스템이 다시 진입해 다음 줄을 실행합니다. 스레드는 그 사이에 풀려 있습니다.
  • BeforeTask<string>::get_Result() 한 방으로 끝납니다. 짧고 단순하지만, 이 IL 한 줄은 "응답이 올 때까지 이 스레드를 통째로 멈춘다"는 뜻입니다. UI 스레드에서 호출하면 화면이 얼어붙고, ASP.NET 스레드 풀에서 호출하면 스레드 풀이 고갈됩니다.

같은 비동기 API 를 부르는데 IL 한 줄을 잘못 쓰면 동작 모델 자체가 뒤집힙니다.

4-2. CPU 바운드: 내가 직접 무거운 계산을 한다면 Task.Run

I/O 와 정반대로 CPU 자원을 길게 점유하는 작업(이미지 디코딩, 복잡한 게임 로직 시뮬레이션, 대량 데이터 변환)은 await 를 붙일 비동기 API 가 따로 없습니다. 그냥 메서드를 호출하면 그 스레드에서 CPU 가 돕니다.

이때 메인 스레드(UI · Unity 메인 스레드)에서 무거운 계산을 직접 하면 화면이 얼어붙기 때문에, Task.Run 으로 스레드 풀에 일을 넘긴 뒤 그 Taskawait 합니다.

C#
public class HeavyCompute
{
    // ❌ Before — 메인 스레드에서 직접 계산. UI 가 멈춘다.
    public int RunSync()
    {
        return Compute(); // CPU 100% 사용, 그동안 화면·이벤트 처리 정지
    }

    // ✅ After — Task.Run 으로 스레드 풀에 위임 후 await
    public async Task<int> RunAsync()
    {
        int result = await Task.Run(() => Compute());
        return result;
    }

    private int Compute()
    {
        int sum = 0;
        for (int i = 0; i < 100; i++) sum += i;
        return sum;
    }
}
IL
// After: RunAsync 의 핵심 IL — Task.Run 도 결국 awaitable Task 를 반환
IL_0011: ldloc.1
IL_0012: ldftn instance int32 Sample3::'<RunAsync>b__0_0'() // 람다 본문
IL_0018: newobj Func`1<int32>::.ctor(...)
IL_001d: call Task`1<!!0> Task::Run<int32>(class Func`1<!!0>) // 스레드 풀에 작업 던지기
IL_0022: callvirt TaskAwaiter`1<int32> Task`1<int32>::GetAwaiter()
IL_002a: call TaskAwaiter`1<int32>::get_IsCompleted()
IL_002f: brtrue.s IL_006d
// 완료 안 됐으면 콜백 등록 후 메서드 종료 (메인 스레드는 자유)
IL_004a: call AsyncTaskMethodBuilder`1<int32>::AwaitUnsafeOnCompleted<...>(...)
IL_006f: call TaskAwaiter`1<int32>::GetResult()

핵심은 await Task.Run(...) 도 IL 레벨에서는 await 의 표준 패턴을 그대로 따른다는 점입니다. 즉, "다른 스레드에 일을 던지고 → 호출자에게 즉시 제어권 반환 → 끝나면 다음 줄 이어서 실행" 입니다. I/O 와 차이는 단 하나, 누가 그 작업을 처리하는가입니다.

패턴 작업을 처리하는 주체 호출자 스레드
I/O 바운드 (await client.GetStringAsync(...)) OS / 네트워크 카드 / 디스크 컨트롤러 자유
CPU 바운드 (await Task.Run(() => Compute())) 스레드 풀의 다른 스레드 자유
Unity 메인 스레드 주의: Unity 의 Transform, GameObject, 대부분의 UnityEngine.* API 는 메인 스레드에서만 호출해야 합니다. Task.Run 안에서 transform.position = ... 을 호출하면 예외가 터집니다. 따라서 Unity 에서 Task.Run 으로 보낼 수 있는 일은 순수 C# 계산(파싱, 압축, 게임 로직 시뮬레이션)에 한정됩니다. 코루틴이나 UniTask.SwitchToMainThread() 로 메인 스레드 복귀 패턴을 함께 익히는 게 좋습니다.

4-3. 라이브러리 API 의 DoAsync 규약 — 보이면 무조건 그쪽

.NET BCL 과 잘 만든 외부 라이브러리는 동일 작업에 대해 동기 버전과 비동기 버전을 함께 제공합니다. 그리고 비동기 버전에는 반드시 Async 접미사가 붙어 있습니다.

C#
// File.ReadAllText vs File.ReadAllTextAsync
// HttpClient.Send vs HttpClient.SendAsync
// SqlCommand.ExecuteReader vs SqlCommand.ExecuteReaderAsync
// Stream.Read vs Stream.ReadAsync

규칙은 단순합니다.

async 메서드 안에서 같은 작업의 동기 버전과 비동기 버전이 모두 보이면, 무조건 Async 가 붙은 쪽을 await 한다.

이유는 IL 비교에서 본 그대로입니다. 비동기 버전을 await 로 호출하면 호출 스레드가 풀려나가지만, 동기 버전을 그대로 호출하면 그 스레드는 작업이 끝날 때까지 통째로 점유됩니다. ASP.NET 같은 서버 환경에서는 처리량이 직접 떨어지고, Unity 메인 스레드에서는 화면이 얼어붙습니다.

C#
// ❌ async 메서드 안에서 동기 API 호출 — async 의 의미가 사라짐
public async Task<string> LoadConfigBadAsync()
{
    string text = File.ReadAllText("config.json"); // 스레드 블로킹
    return text;
}

// ✅ 같은 메서드의 비동기 버전을 await
public async Task<string> LoadConfigGoodAsync()
{
    string text = await File.ReadAllTextAsync("config.json");
    return text;
}

라이브러리에 Async 버전이 없으면 어쩔 수 없이 동기 버전을 써야 하지만, Async 가 있는데 동기 버전을 쓰는 것은 명백한 실수입니다.


5. [함정과 주의사항] — .Result/.Wait, fire-and-forget, async void 남용

5-1. .Result / .Wait() 로 동기 흐름에 끼워 넣기 — Deadlock 위험

비동기 메서드를 호출하는 코드를 만들었는데, 호출하는 쪽이 동기 메서드라서 await 를 못 붙이는 상황이 생깁니다. 가장 흔한 잘못된 해결책이 .Result.Wait() 입니다.

C#
// ❌ async 코드와 동기 코드를 .Result 로 억지로 붙이는 패턴
public string LoadSync()
{
    return LoadAsync().Result; // UI 스레드/ASP.NET 컨텍스트에서 deadlock 가능
}

private async Task<string> LoadAsync()
{
    var html = await client.GetStringAsync("https://...");
    return html.Trim(); // ← 이 줄은 원래 호출 스레드(UI)로 돌아가고 싶어함
}

// ✅ async 를 끝까지 전파(async all the way)
public async Task<string> LoadAsync2()
{
    return await LoadAsync();
}

Deadlock 의 메커니즘은 거칠게 말해 이렇습니다.

  1. UI 스레드가 LoadAsync().Result 로 자신을 블로킹.
  2. LoadAsync 내부의 await 가 끝나면 html.Trim() 줄을 원래 호출 스레드(UI 스레드) 에서 실행하고 싶어 합니다.
  3. 그런데 UI 스레드는 .Result 때문에 막혀 있습니다.
  4. 서로 무한 대기.

ConfigureAwait(false) 같은 우회 수단도 있지만, 본질적인 해결은 async 를 끝까지 일관되게 전파하는 것(async all the way)입니다. 어디선가 동기로 끊는 순간 비동기의 이점이 사라지고 deadlock 위험이 생깁니다.

5-2. 반환된 Task 를 무시 — Fire-and-forget 의 함정

Task 를 받아 놓고 await 도 안 하고 변수에 담지도 않으면, 그 작업은 백그라운드에서 흘러갑니다. 예외가 터져도 알 길이 없습니다.

C#
// ❌ Task 를 그냥 버린다 — 컴파일러 경고 CS4014 가 뜨지만 무시되곤 함
public async Task SaveUserAsync(User user)
{
    db.WriteAsync(user); // await 누락
    log.WriteAsync($"Saved {user.Id}"); // await 누락
    // 메서드는 두 작업이 끝나기도 전에 반환됨
}

// ✅ 반드시 await
public async Task SaveUserAsync2(User user)
{
    await db.WriteAsync(user);
    await log.WriteAsync($"Saved {user.Id}");
}

// ✅ 정말 fire-and-forget 이 의도라면 명시적으로 _ 에 대입하고 예외 처리 책임을 진다
public void FireAndForget(User user)
{
    _ = Task.Run(async () =>
    {
        try { await audit.WriteAsync(user); }
        catch (Exception ex) { logger.LogError(ex, "audit 실패"); }
    });
}

대부분의 경우 await 를 빼먹은 것은 버그입니다. 정말 의도한 fire-and-forget 이라면 _ = 로 명시하고 내부에서 예외를 잡아야 합니다.

5-3. async void 남용 — 버튼 클릭 핸들러를 일반 헬퍼 메서드로 재사용

이벤트 핸들러는 어쩔 수 없이 async void 지만, 그 메서드를 일반 메서드처럼 다른 곳에서 호출하면 안 됩니다.

C#
public class BadHandler
{
    // 이벤트 시그니처상 async void — 여기까지는 OK
    private async void OnButtonClick(object sender, EventArgs e)
    {
        await DoSomethingAsync();
    }

    // ❌ 이 메서드를 다른 곳에서 호출 — 추적 불가, 예외 손실
    public void RetryAll()
    {
        OnButtonClick(this, EventArgs.Empty);
        OnButtonClick(this, EventArgs.Empty);
        // 두 번 모두 끝났는지 알 수 없고, 실패해도 못 잡음
    }
}

public class GoodHandler
{
    // 실제 로직은 Task 반환 메서드로 분리
    public async Task DoSomethingAsync() { /* ... */ }

    // 이벤트 핸들러는 진짜 한 줄짜리 어댑터
    private async void OnButtonClick(object sender, EventArgs e)
    {
        try { await DoSomethingAsync(); }
        catch (Exception ex) { ShowError(ex); }
    }

    public async Task RetryAllAsync()
    {
        await DoSomethingAsync();
        await DoSomethingAsync();
    }
}

규칙은 단순합니다. async void 는 이벤트 핸들러의 어댑터 한 줄짜리에서만, 실제 로직은 Task 반환 메서드에서 작성합니다.

5-4. Unity 모바일에서의 추가 함정 — UnityEngine API 와 GC

C#
public class BadCoroutineLikeAsync : MonoBehaviour
{
    // ❌ Task.Run 안에서 UnityEngine API 호출
    public async Task LoadAsync()
    {
        await Task.Run(() =>
        {
            transform.position = Vector3.zero; // 메인 스레드 외 호출 → 예외
        });
    }

    // ❌ Update 마다 async 메서드를 호출하며 await 누락 — 매 프레임 fire-and-forget
    private void Update()
    {
        FetchOptionalAsync(); // Task 누수 + 예외 손실
    }
    private async Task FetchOptionalAsync() { await Task.Yield(); }
}
C#
public class GoodAsyncInUnity : MonoBehaviour
{
    // ✅ Task.Run 에는 순수 C# 계산만, UnityEngine API 는 메인 스레드 복귀 후
    public async Task LoadAsync()
    {
        byte[] data = await DownloadAsync();
        var parsed = await Task.Run(() => HeavyParse(data)); // 순수 C# 계산
        // 여기는 다시 메인 스레드 (Task.Run 이 완료된 뒤 await 가 복귀시킴)
        transform.position = parsed.Spawn;
    }

    private Task<byte[]> DownloadAsync() => Task.FromResult(new byte[] { });
    private SpawnInfo HeavyParse(byte[] _) => new() { Spawn = Vector3.zero };
    private struct SpawnInfo { public Vector3 Spawn; }
}

Unity 모바일에서는 Task 한 번 생성에도 힙 할당과 GC 부담이 따릅니다. 매 프레임 async 호출이 발생하는 핫패스에서는 Task 대신 UniTask(Cysharp 의 GC-zero 비동기 라이브러리)를 도입하는 것을 강력히 추천합니다. 코루틴과 비교해도 try/catch, 반환값, 다중 await 가 자연스럽다는 장점이 있습니다.


6. [C# 버전별 변화] — 시작은 5.0, 점차 확장

버전 변화
C# 5.0 (.NET 4.5) async / await 키워드 도입. 반환형은 void / Task / Task<T>.
C# 7.0 (.NET 4.7 / Core 2.0) ValueTask<T> 도입. 동기 완료가 잦은 비동기 메서드의 힙 할당 회피.
C# 7.1 async Main — 콘솔 앱 진입점에 async 허용.
C# 8.0 비동기 스트림 (IAsyncEnumerable<T> + await foreach) — 결과를 여러 번 산출하는 비동기 시퀀스 표현.
C# 9.0+ 모듈 초기화·기타 컴파일러 최적화. 사용자 코드 시그니처 변화는 거의 없음.
C#
// C# 5.0 (.NET 4.5) — 첫 등장 시 가능한 모양
public async Task<int> CountAsync_v5(string path)
{
    using var reader = new StreamReader(path);
    int count = 0;
    while (await reader.ReadLineAsync() != null) count++;
    return count;
}

// C# 7.0 + : ValueTask<T> 로 동기 완료 경로 최적화
public ValueTask<int> CountCachedAsync(string key)
{
    if (cache.TryGetValue(key, out int v))
        return new ValueTask<int>(v); // 힙 할당 0
    return new ValueTask<int>(LoadAndCacheAsync(key));
}

// C# 7.1 + : async Main
public static async Task<int> Main(string[] args)
{
    await SomeStartupAsync();
    return 0;
}

// C# 8.0 + : 비동기 스트림
public async IAsyncEnumerable<string> StreamLinesAsync(string path)
{
    using var reader = new StreamReader(path);
    string? line;
    while ((line = await reader.ReadLineAsync()) is not null)
        yield return line;
}

신입 개발자 단계에서는 C# 5.0 의 기본 모양(async Task / async Task<T> + await) 만 손에 익혀도 충분합니다. ValueTaskIAsyncEnumerable 은 측정·필요성 판단 후 도입하는 도구입니다.


7. [정리] — 핵심 체크리스트

  • [ ] async 한정자는 "이 메서드 안에서 await 를 쓰겠다"는 컴파일러 신호. 그 자체로 백그라운드 실행은 아니다.
  • [ ] await 의 의미는 "기다림"이 아니라 "제어권 양보 + 콜백 등록". IL 레벨에서 GetAwaiter()IsCompleted 검사 → AwaitUnsafeOnCompleted 등록 → GetResult() 패턴으로 펼쳐진다.
  • [ ] async 메서드의 반환형은 5종. Task / Task<T> / ValueTask / ValueTask<T> / void(이벤트 핸들러 한정). 기본은 Task / Task<T>.
  • [ ] async void 는 이벤트 핸들러 외에서는 절대 금지 — 완료 추적 불가, 예외 처리 불가, 프로세스 강제 종료 위험.
  • [ ] I/O 바운드는 await client.SomethingAsync(...), CPU 바운드는 await Task.Run(() => Compute()). 둘 다 호출 스레드를 풀어준다는 점에서 동일하지만, 작업을 처리하는 주체가 다르다.
  • [ ] 라이브러리 API 가 DoAsync 형태를 제공하면 무조건 비동기 버전을 await. 동기 버전을 async 메서드 안에서 부르면 async 의미가 사라진다.
  • [ ] .Result / .Wait() 는 deadlock 의 지름길. async 를 끝까지 전파(async all the way)한다.
  • [ ] 반환된 Task 는 반드시 await. fire-and-forget 이 의도라면 _ = 로 명시하고 내부에서 예외를 잡는다.
  • [ ] 메서드 이름은 PascalCaseAsync 규약 (GetUserAsync, LoadConfigAsync). 이벤트 핸들러는 예외.
  • [ ] Unity 모바일 핫패스에서는 Task 의 GC 부담을 의식하고, 필요시 UniTask 도입. Task.Run 안에서는 UnityEngine.* API 호출 금지.
  • [ ] 상태 머신·ConfigureAwait·SynchronizationContext 의 깊은 동작은 별도 심화 주제로 분리. 본 글의 사용 패턴만 익히면 99% 실수는 피할 수 있다.
반응형

+ Recent posts