반응형

[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). 이름만 비슷할 뿐 동작 모델이 정반대다.
반응형

+ Recent posts