반응형

[PART11.비동기와 동시성(8/12)] IAsyncEnumerable<T> — 비동기 스트림이란 무엇인가

데이터를 기다리면서도 스레드를 놓지 않는다 / await foreach가 만드는 pull 기반 스트림 / 상태 머신과 ValueTask<bool>로 GC를 줄이는 방법


1. 문제 제기 — 왜 스트림이 비동기여야 하는가

모바일 게임 클라이언트 개발을 하다 보면 "서버에서 플레이어 랭킹 1만 건을 가져와서 한 명씩 UI에 그려 달라", "200MB짜리 에셋 번들을 청크 단위로 다운로드 받으면서 진행률을 갱신해 달라" 같은 요구가 반복된다.

이때 지금까지 알고 있던 두 가지 선택지는 모두 아쉬운 구석이 있다.

  • IEnumerable<int> GetItems() 같은 동기 반복기 — 데이터 소스가 네트워크·파일이라면 MoveNext() 안에서 I/O가 끝날 때까지 스레드를 블로킹(Blocking, 스레드가 아무 일도 못 하고 결과를 기다리는 상태)한다. 메인 스레드가 막히면 곧바로 ANR(Application Not Responding)·프레임 드롭으로 이어진다.
  • Task<List<Player>> GetAllAsync() 같은 "비동기지만 한 번에 다" 반환 — 비동기는 맞지만 1만 건을 다 모을 때까지 첫 항목도 못 본다. 모바일 메모리 상황에서 10MB짜리 JSON을 통째로 파싱하고 리스트로 만드는 순간 GC(Garbage Collector, 힙 메모리를 자동으로 회수하는 런타임 구성요소) 스파이크가 치솟고, 사용자는 로딩 화면에서 한참을 기다린다.

필요한 건 "비동기로 데이터를 기다리면서, 도착한 것부터 하나씩 흘려 보내 주는" 방식이다. C# 8.0에서 등장한 IAsyncEnumerable<T>가 그 답이다.

asyncyield return동시에 쓸 수 있게 되었고, 소비자는 await foreach로 그 스트림을 한 항목씩 당겨 온다. 이 글에서는 그 내부 구조와 Unity에서의 실전 패턴을 IL 수준까지 따라가며 정리한다.


2. 개념 정의 — IAsyncEnumerable<T>란

비유 — 택배 창고(한 번에) vs 컨베이어 벨트(흐름)

Task<List<T>> 방식은 주문한 택배 1만 개가 전부 창고에 모일 때까지 기다렸다가 한 번에 받는 것과 같다. 다 모이기 전엔 단 하나도 열어볼 수 없고, 받자마자 거실이 상자로 가득 찬다.

IAsyncEnumerable<T> 방식은 공장에서 컨베이어 벨트로 물건이 한 개씩 흘러 들어오는 것과 같다. 물건이 아직 만들어지지 않았으면 벨트가 잠시 멈추지만, 그동안 나는 다른 일을 할 수 있다. 하나가 도착하면 바로 꺼내 쓰고, 또 다음 것이 올 때까지 기다린다. 거실에는 항상 한 개씩만 있다.

구조 한눈에 보기

IAsyncEnumerable<T>: pull 기반 비동기 스트림

기본 코드 — 가장 단순한 비동기 스트림

가장 작은 예시로 "1초에 하나씩 정수를 생산하는 스트림"을 만들어 본다.

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

public class BasicStream
{
    // async 한정자 + IAsyncEnumerable<T> 반환 타입 + yield return — 세 개가 함께 쓰인다
    public async IAsyncEnumerable<int> ProduceAsync()
    {
        for (int i = 0; i < 3; i++)
        {
            await Task.Delay(10);   // 비동기 대기 (스레드 블로킹 없음)
            yield return i;          // 생산자는 여기서 값을 뱉고 일시 중단
        }
    }

    public async Task ConsumeAsync()
    {
        // await foreach — 소비자는 한 항목을 기다리는 동안에도 스레드를 점유하지 않는다
        await foreach (var n in ProduceAsync())
        {
            Console.WriteLine(n);
        }
    }
}
async IAsyncEnumerable<T> — 비동기 반복기 (Async Iterator) async 한정자와 yield return을 같이 쓸 수 있는 특별한 메서드 형태다. 반환 타입은 반드시 IAsyncEnumerable<T> 또는 IAsyncEnumerator<T>여야 한다. 컴파일러가 비동기 흐름과 반복기 흐름을 하나의 상태 머신으로 합쳐 준다.
예시: public async IAsyncEnumerable<byte[]> ReadChunksAsync() 청크가 도착할 때마다 yield return buffer로 하나씩 호출자에게 건네준다.
await foreach — 비동기 스트림 소비 문법 일반 foreachGetEnumerator().MoveNext()를 반복 호출하듯, await foreachGetAsyncEnumerator().MoveNextAsync()await하며 반복 호출한다. 루프 종료 시 DisposeAsync()가 자동 호출된다.
예시: await foreach (var chunk in stream) ... 각 반복마다 MoveNextAsyncawait하고, 값이 있으면 chunk에 대입한다.

이 작은 코드에서 벌어지는 일은 몇 가지다.

  • ProduceAsync를 호출해도 아직 아무 것도 실행되지 않는다. IAsyncEnumerable<int>만 반환된다.
  • 소비자가 await foreach를 시작해야 그제서야 GetAsyncEnumerator가 호출되고 본문이 굴러가기 시작한다.
  • await Task.Delay(10)에서 일시 중단되면, 메서드는 그 자리에서 스택을 감아 올리고 스레드는 스레드 풀로 돌아간다. 모바일 GPU 드로우콜 준비 같은 다른 일에 그 스레드가 쓰일 수 있다.
  • Task.Delay가 완료되면 다시 상태 머신이 깨어나 yield return i로 값 하나를 넘기고 또 일시 중단된다.

3. 내부 동작 — 컴파일러가 만드는 비동기 반복기 상태 머신

async IAsyncEnumerable<T> 한 줄 뒤에는 async Task의 상태 머신과 IEnumerable<T> 반복기의 상태 머신을 합쳐 놓은 비동기 반복기 상태 머신(Async Iterator State Machine)이 숨어 있다. 눈으로 따라가기 위해 위에서 만든 BasicStream.ProduceAsync/il-analysis로 컴파일해 본다 (이름만 AfterPattern.FetchStreamAsync로 바뀐 동일 구조).

생산자 쪽 — 한 클래스가 네 개의 인터페이스를 모두 구현한다

C#
// Before 측 비교용
public class BeforePattern
{
    public async Task<List<int>> FetchAllAsync()
    {
        var result = new List<int>();
        for (int i = 0; i < 3; i++)
        {
            await Task.Delay(10);
            result.Add(i);
        }
        return result;
    }
}

// After: 비동기 반복기
public class AfterPattern
{
    public async IAsyncEnumerable<int> FetchStreamAsync(
        [EnumeratorCancellation] CancellationToken ct = default)
    {
        for (int i = 0; i < 3; i++)
        {
            await Task.Delay(10, ct);
            yield return i;
        }
    }
}

위 두 메서드를 컴파일하면 아래처럼 완전히 다른 구조의 상태 머신이 나온다. 핵심 부분만 발췌한다.

IL
// ── Before: AsyncTaskMethodBuilder<List<int>> 기반 ─────────────
.class nested private auto ansi sealed beforefieldinit
    '<FetchAllAsync>d__0'
    extends [System.Runtime]System.ValueType
    implements [System.Runtime]System.Runtime.CompilerServices.IAsyncStateMachine
{
    // 힙이 아닌 struct로 먼저 뜬다 (상태 머신 할당 최적화)
    .field private valuetype AsyncTaskMethodBuilder`1<List`1<int32>> '<>t__builder'
    .field private class List`1<int32> '<result>5__2'

    .method instance void MoveNext() cil managed { ... }
}

// FetchAllAsync 진입점 — Task<List<int>> 하나를 돌려준다
.method public instance Task`1<List`1<int32>> FetchAllAsync() {
    IL_0000: ...
    IL_001d: call instance void
        AsyncTaskMethodBuilder`1<List`1<int32>>::Start<...>(!!0&)
    IL_0028: ret      // Task<List<int>> 하나 반환
}
IL
// ── After: AsyncIteratorMethodBuilder 기반 + 4개 인터페이스 구현 ──
.class nested private auto ansi sealed beforefieldinit
    '<FetchStreamAsync>d__0'
    extends [System.Runtime]System.Object                                 // ★ 참조 타입(class)
    implements class IAsyncEnumerable`1<int32>,                           // ★ 자기 자신이 enumerable
               class IAsyncEnumerator`1<int32>,                           // ★ 자기 자신이 enumerator
               IAsyncStateMachine,
               IAsyncDisposable                                           // ★ await foreach가 Dispose 호출 대상
{
    .field private valuetype AsyncIteratorMethodBuilder '<>t__builder'    // ★ Task가 아니라 iterator용 builder
    .field private int32 '<>1__state'
    .field private int32 '<>2__current'                                   // ★ yield 값을 담는 슬롯
    .field private bool '<>w__disposeMode'                                // ★ await foreach 중단 플래그
    .field public valuetype CancellationToken ct                          // [EnumeratorCancellation] 파라미터
    .field private class CancellationTokenSource '<>x__combinedTokens'    // 소비자·생산자 토큰 합성 저장
}

// GetAsyncEnumerator — 소비자가 await foreach를 시작하면 호출된다
.method instance IAsyncEnumerator`1<int32>
    'IAsyncEnumerable<Int32>.GetAsyncEnumerator'(CancellationToken) { ... }

// MoveNextAsync — 반환은 ValueTask<bool>
.method instance ValueTask`1<bool>
    'IAsyncEnumerator<Int32>.MoveNextAsync'() { ... }

// DisposeAsync — await foreach 종료 시 자동 호출
.method instance ValueTask 'System.IAsyncDisposable.DisposeAsync'() { ... }

IL 관점에서 두드러지는 차이는 세 가지다.

  • 빌더가 다르다. Task 메서드는 AsyncTaskMethodBuilder<T> 하나로 Task를 만드는 반면, 비동기 반복기는 AsyncIteratorMethodBuilder를 쓴다. 후자는 Task 인스턴스를 만들지 않는다 — 호출자에게 건네는 건 ValueTask<bool>이다.
  • 상태 머신이 struct가 아니라 class다. 일반 async Task는 hot path에서 struct로 떴다가 await 지점에서만 박싱하는데, 비동기 반복기는 여러 번 MoveNextAsync가 호출되므로 처음부터 참조 타입으로 만든다. 그리고 이 클래스 한 개가 enumerable·enumerator·state machine·async disposable을 동시에 구현한다 — GetAsyncEnumerator가 같은 인스턴스를 반환해도 되는 이유다.
  • <>2__current<>w__disposeMode가 생긴다. 전자는 yield return 값을 잠시 보관하는 필드(일반 반복기의 Current), 후자는 소비자가 루프를 일찍 벗어났을 때 생산자 쪽에 "정리 모드로 전환하라"고 알리는 플래그다.

소비자 쪽 — await foreach의 정체

await foreach도 그냥 문법 설탕이다. 다음 소비 코드를 IL로 내려 보면 try/finally로 감싼 while 루프로 바뀐다.

C#
await foreach (var item in after.FetchStreamAsync())
{
    Console.WriteLine(item);
}
IL
// GetAsyncEnumerator 호출 → wrap1 필드에 저장
IL_00a1: callvirt instance IAsyncEnumerator`1<!0>
    IAsyncEnumerable`1<int32>::GetAsyncEnumerator(CancellationToken)
IL_00a6: stfld class IAsyncEnumerator`1<int32> '<Main>d__0'::'<>7__wrap1'

// try 블록 — MoveNextAsync 반복 호출
IL_00d6: callvirt instance ValueTask`1<bool>
    IAsyncEnumerator`1<int32>::MoveNextAsync()
IL_0109: call void AsyncTaskMethodBuilder::AwaitUnsafeOnCompleted<
    ValueTaskAwaiter`1<bool>, '<Main>d__0'>(!!0&, !!1&)

// finally 블록 — DisposeAsync 자동 호출
IL_0155: callvirt instance ValueTask
    IAsyncDisposable::DisposeAsync()

세 줄이 핵심이다.

  • GetAsyncEnumerator(CancellationToken) 호출 — 일반 GetEnumerator와 달리 인자로 CancellationToken을 받을 수 있는 오버로드가 추가됐다. 소비자 쪽에서 .WithCancellation(ct)를 붙이면 이 자리로 전달된다.
  • MoveNextAsync 반환이 ValueTask<bool> — 호출 코드는 ValueTaskAwaiter<bool>await한다. 이 줄이 핵심 성능 최적화 지점이다. 값 타입(ValueTask)은 결과가 동기적으로 준비돼 있으면 힙 할당이 발생하지 않는다. 예를 들어 HTTP 청크가 이미 버퍼에 쌓여 있어서 다음 yield return이 즉시 가능하면, 이 MoveNextAsync 호출은 Task 객체 할당 없이 끝난다.
  • DisposeAsyncfinally에 자동 삽입 — 소비자가 루프를 break로 빠져나가든 예외로 벗어나든, 컴파일러가 짠 finally가 항상 DisposeAsync를 부른다. 이것이 "IAsyncEnumerator<T>IAsyncDisposable을 상속한다"는 말의 실제 효과다.

ValueTask<bool>인가

MoveNextAsync()가 매번 Task를 만든다면?

ValueTask<bool>는 구조체다. 다음 값이 이미 버퍼에 있어서 즉시 꺼낼 수 있으면 new ValueTask<bool>(true)처럼 스택에서 구조체 하나 만들고 끝이다. "한 네트워크 패킷에 100개 요소가 들어 있다 → 99번은 동기 완료 → Task 객체 0개"라는 시나리오가 가능해진다.

이 설계 덕분에 IAsyncEnumerable<T>초당 수만 항목을 스트리밍하는 핫패스에서도 GC 부담 없이 쓸 수 있다.


4. 실전 적용 — Before/After와 Unity 스트리밍

Before — 서버 랭킹 전체를 한 번에 받기

먼저 신입 개발자가 흔히 작성하는 "일단 리스트로 받아서 돌리자" 패턴이다.

C#
// Unity 상황: 랭킹 1만 건을 받아 하나씩 UI에 Instantiate
public class RankingBoardBefore : MonoBehaviour
{
    public RankRow rowPrefab;
    public Transform content;

    async void OnShow()
    {
        // ❌ 문제 1: 1만 건이 전부 도착해야 첫 행이 보인다 (TTFB가 나쁨)
        // ❌ 문제 2: List<RankDto> 하나가 메모리에 통째로 올라간다
        // ❌ 문제 3: Instantiate 1만 번을 한 프레임에 몰아넣어 GC 스파이크
        List<RankDto> all = await RankingApi.GetAllAsync();

        foreach (var r in all)
        {
            Instantiate(rowPrefab, content).Bind(r);
        }
    }
}

After — 도착 순서대로 스트리밍

IAsyncEnumerable<RankDto>로 바꾸면 서버가 페이지 단위로 응답을 끊어 보내고, 클라이언트는 도착 즉시 한 행씩 그린다.

C#
public class RankingApi
{
    // 한 페이지씩 비동기로 스트리밍 — 전체를 메모리에 들고 있지 않는다
    public static async IAsyncEnumerable<RankDto> StreamAsync(
        [EnumeratorCancellation] CancellationToken ct = default)
    {
        int page = 0;
        while (true)
        {
            ct.ThrowIfCancellationRequested();
            var chunk = await Http.GetPageAsync(page, ct); // ValueTask는 동기 완료 가능
            if (chunk.Count == 0) yield break;

            foreach (var r in chunk)
                yield return r;       // 한 행씩 소비자에게 즉시 전달

            page++;
        }
    }
}

public class RankingBoardAfter : MonoBehaviour
{
    public RankRow rowPrefab;
    public Transform content;
    CancellationTokenSource _cts;

    async void OnShow()
    {
        _cts = new CancellationTokenSource();
        try
        {
            int drawnThisFrame = 0;

            await foreach (var r in RankingApi.StreamAsync()
                                              .WithCancellation(_cts.Token))
            {
                Instantiate(rowPrefab, content).Bind(r);

                // ✅ 프레임당 N개로 나눠 그려 GC 스파이크 방지
                if (++drawnThisFrame >= 20)
                {
                    drawnThisFrame = 0;
                    await Awaitable.NextFrameAsync(); // Unity 6+의 비동기 프레임 대기
                }
            }
        }
        catch (OperationCanceledException)
        {
            // 사용자가 패널 닫음 — 조용히 종료
        }
    }

    void OnDisable() => _cts?.Cancel(); // await foreach의 finally에서 DisposeAsync 호출
}
[EnumeratorCancellation] — 열거자 취소 토큰 주입 특성 비동기 반복기 메서드의 CancellationToken 파라미터에 붙이는 특성. 소비자가 .WithCancellation(ct)로 넘긴 토큰이 이 파라미터에 자동 주입된다. 이 특성이 없으면 .WithCancellation은 아무 효과가 없다.
예시: public async IAsyncEnumerable<T> Stream([EnumeratorCancellation] CancellationToken ct) 컴파일러 경고 CS8425로 누락을 알려 준다.

이 변경으로 얻는 것:

  • 첫 행 표시 시간(TTFB) — 전체 다운로드를 기다리지 않고 첫 페이지만 오면 즉시 UI가 갱신된다.
  • 메모리 — 한 번에 들고 있는 행은 현재 페이지 하나뿐. List<RankDto> 1만 개를 통째로 들고 있지 않아도 된다.
  • GC 분산Awaitable.NextFrameAsync와 섞어 쓰면 Instantiate 호출을 여러 프레임에 나눠 분산할 수 있다. Unity Profiler에서 GC.Alloc 피크가 반으로 준다.
  • 취소 자연스러움OnDisable에서 Cancel() 한 번이면 StreamAsync 내부 Http.GetPageAsync(ct)까지 줄줄이 취소된다.

Unity 실전 — 에셋 청크 다운로드

두 번째 단골 사례는 "대용량 에셋 번들을 스트리밍 다운로드"다. 기존에는 코루틴 + 이벤트 콜백으로 조립했는데, IAsyncEnumerable<byte[]>로 쓰면 다운로드 루프와 진행률 갱신이 한 줄씩 떨어진다.

C#
public static async IAsyncEnumerable<(byte[] chunk, long totalRead)> DownloadAsync(
    string url,
    [EnumeratorCancellation] CancellationToken ct = default)
{
    using var req = UnityWebRequest.Get(url);
    var handler = new DownloadHandlerBuffer();
    req.downloadHandler = handler;

    var op = req.SendWebRequest();
    long read = 0;
    while (!op.isDone)
    {
        await Awaitable.NextFrameAsync(ct);
        var buf = handler.data;                       // 현재까지 받은 바이트
        if (buf != null && buf.Length > read)
        {
            var chunk = new byte[buf.Length - read];
            Array.Copy(buf, read, chunk, 0, chunk.Length);
            read = buf.Length;
            yield return (chunk, read);               // 프레임마다 새로 들어온 만큼만 방출
        }
    }
}

// 소비
await foreach (var (chunk, total) in DownloadAsync(url, ct))
{
    progressBar.value = total / (float)expectedSize;  // 진행률 UI
    diskStream.Write(chunk, 0, chunk.Length);         // 디스크에 즉시 기록
}
Unity 기본 Task의 SynchronizationContext 이슈와 프레임 정렬이 걱정된다면 UniTaskIUniTaskAsyncEnumerable<T>를 그대로 이 자리에 대입해도 된다 — 문법은 동일하고 할당이 더 줄어든다.

5. 함정과 주의사항 — 취소·예외·핫패스

함정 1 — [EnumeratorCancellation]을 빠뜨린다

신입이 가장 자주 만나는 버그다. 파라미터 이름만 ct로 둔다고 취소가 자동으로 흘러 들어가지 않는다.

C#
// ❌ 취소가 동작하지 않음
public async IAsyncEnumerable<int> BrokenStream(CancellationToken ct)
{
    while (true)
    {
        await Task.Delay(100, ct);
        yield return 1;
    }
}

// 소비
await foreach (var _ in BrokenStream(default).WithCancellation(cts.Token))
{
    // cts.Cancel() 불러도 Task.Delay의 ct는 default 그대로 — 영원히 안 멈춘다
}

컴파일러는 이 상황에서 CS8425 경고를 띄운다. 반드시 [EnumeratorCancellation]을 붙여 소비자 토큰이 주입되게 만들어야 한다.

C#
// ✅ 수정
public async IAsyncEnumerable<int> FixedStream(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    while (true)
    {
        await Task.Delay(100, ct);
        yield return 1;
    }
}

IL에서는 이 특성이 실제로 EnumeratorCancellationAttribute로 파라미터에 박힌다.

IL
.param [1]
    .custom instance void
        [System.Runtime]System.Runtime.CompilerServices.EnumeratorCancellationAttribute::.ctor()

이 마커가 있어야 컴파일러가 GetAsyncEnumerator(CancellationToken) 오버로드에서 받은 토큰을 ct 파라미터에 복사하는 코드를 생성한다. 실제로 IL의 <FetchStreamAsync>d__0 상태 머신에는 <>x__combinedTokens 필드가 있어서 "소비자가 넘긴 토큰 + 생산자 내부 토큰"을 CancellationTokenSource.CreateLinkedTokenSource로 합쳐 저장한다.

함정 2 — 예외 위치를 혼동한다

비동기 반복기에서 던진 예외는 yield return 시점이 아니라 MoveNextAsync의 Task가 완료될 때 소비자 쪽에서 튀어나온다.

C#
public async IAsyncEnumerable<int> MayThrow()
{
    yield return 1;
    await Task.Delay(10);
    throw new InvalidOperationException("boom");  // 이 예외는 언제 잡히나?
}

try
{
    await foreach (var n in MayThrow())
    {
        Console.WriteLine(n);      // 1 출력 → 다음 MoveNextAsync에서 예외 발생
    }
}
catch (InvalidOperationException ex)
{
    // ✅ 여기서 잡힌다 — 소비자 try/catch가 생산자 예외를 포착
}

await foreach가 IL 레벨에서 try/finally로 감싸져 있다는 것을 기억하면 이해가 쉽다. MoveNextAsync()의 awaiter가 예외를 다시 던지는 순간, 스택은 소비자 쪽으로 감겨 올라간다. 따라서 try/catch는 항상 await foreach를 감싸는 쪽에서 작성한다.

주의: yield return 다음 줄에서는 try/catchyield return 자체를 감쌀 수 없다는 컴파일러 제약이 있다 — yield return 문은 catch 블록 안쪽에 들어가지 못한다. 복잡한 예외 처리가 필요하면 async Task<TResult> TryProduceAsync() 같은 일반 async 메서드로 한 번 감싼 뒤 그 결과를 yield return 한다.

함정 3 — 스트림을 두 번 반복하려 한다

IAsyncEnumerable<T>IEnumerable<T>처럼 cold sequence다 — await foreach를 할 때마다 GetAsyncEnumerator가 다시 호출되고 본문이 처음부터 다시 실행된다.

C#
var stream = RankingApi.StreamAsync();

// ❌ 두 번 HTTP 요청이 나간다
await foreach (var r in stream) { ... }
await foreach (var r in stream) { ... }  // 또 다시 처음부터 페이징

// ✅ 결과를 보관하려면 명시적으로 수집
var snapshot = await stream.ToListAsync();   // System.Linq.Async
foreach (var r in snapshot) { ... }
foreach (var r in snapshot) { ... }

단, 소비할 때마다 비용이 든다는 건 관점에 따라 장점이기도 하다. 실시간 센서 데이터처럼 "최신 값을 매번 끌어오고 싶을 때"는 바로 이 특성이 이점이 된다.

함정 4 — 핫패스에서 LINQ 체인을 무겁게 쌓는다

System.Linq.AsyncWhere().Select().Take() 체인은 편하지만, 각 연산자가 새로운 IAsyncEnumerable 래퍼를 만든다. 프레임당 수천 항목을 흘려보내는 Unity 핫패스에서는 연산자 당 한 번씩 MoveNextAsync 호출 체인이 쌓여 오버헤드가 커진다.

C#
// ❌ 매 프레임 호출되는 입력 스트림에 5단 체인
await foreach (var ev in InputEvents()
    .Where(e => e.Type == InputType.Tap)
    .Select(e => e.Position)
    .Where(p => IsInsideUi(p))
    .Select(p => WorldToScreen(p))
    .Take(10))
{ ... }

// ✅ 한 번의 if로 분기 — 연산자 래퍼 할당 0건
await foreach (var e in InputEvents().WithCancellation(ct))
{
    if (e.Type != InputType.Tap) continue;
    if (!IsInsideUi(e.Position)) continue;
    var screen = WorldToScreen(e.Position);
    // ...
    if (--remaining <= 0) break;
}

원칙은 동기 LINQ와 같다 — 핫패스는 직접 루프, 핫패스가 아니면 LINQ.Async.


6. C# 버전별 변화

C# 7 이하 — 비동기 스트림 자체가 없다

asyncyield return동시에 쓸 수 없었다. 비슷한 효과를 내려면 아래처럼 Task<IEnumerable<T>>를 돌려주거나, 이벤트·콜백으로 직접 조립했다.

C#
// C# 7의 절충안 — 전체를 모은 뒤 한 번에 반환
public async Task<IEnumerable<int>> GetAllAsync()
{
    var list = new List<int>();
    for (int i = 0; i < 1000; i++)
    {
        await Task.Delay(1);
        list.Add(i);
    }
    return list; // 다 모일 때까지 아무 것도 방출 못 한다
}

IL 상으로는 평범한 AsyncTaskMethodBuilder<IEnumerable<int>> 메서드 하나가 전부였다.

C# 8.0 — IAsyncEnumerable<T>, await foreach, IAsyncDisposable 도입

C# 8.0(.NET Core 3.0 / .NET Standard 2.1) 시점에 세 가지가 한꺼번에 들어왔다.

  • IAsyncEnumerable<T> / IAsyncEnumerator<T> 인터페이스
  • await foreach 문법
  • 반복기 안에서 asyncyield return 공존 허용
  • IAsyncDisposableusing await으로도 해제 가능
C#
// C# 8부터 가능해진 코드
public async IAsyncEnumerable<int> ProduceAsync()
{
    for (int i = 0; i < 5; i++)
    {
        await Task.Delay(10);
        yield return i;
    }
}

IL 수준에서는 앞서 본 대로 AsyncIteratorMethodBuilder + 4개 인터페이스 구현 클래스가 컴파일러에 의해 자동 생성된다.

C# 8 동시 추가 — [EnumeratorCancellation] 특성

C# 8 스펙과 함께 System.Runtime.CompilerServices.EnumeratorCancellationAttribute도 추가됐다. 소비자가 WithCancellation으로 넘긴 토큰을 생산자 본문까지 전달하기 위한 다리다.

C#
public async IAsyncEnumerable<int> ProduceAsync(
    [EnumeratorCancellation] CancellationToken ct = default)
{
    for (int i = 0; i < 5; i++)
    {
        ct.ThrowIfCancellationRequested();
        await Task.Delay(10, ct);
        yield return i;
    }
}

IL에서는 파라미터에 아래와 같이 특성이 박힌다.

IL
.param [1]
    .custom instance void
        [System.Runtime]System.Runtime.CompilerServices.EnumeratorCancellationAttribute::.ctor()

C# 9+ — 언어 스펙의 큰 변화 없음

C# 9, 10, 11로 올라가도 IAsyncEnumerable<T> 자체의 문법 변화는 거의 없다. 대신 BCL 측면에서 System.Linq.Async가 안정화되고, ASP.NET Core·Entity Framework Core가 기본 반환 타입으로 받아들이기 시작하면서 생태계가 넓어졌다. Unity에서도 UniTask v2부터 IUniTaskAsyncEnumerable<T>가 안정화되어 모바일 환경에서 표준처럼 쓰인다.


7. 정리

IAsyncEnumerable<T>를 Unity 모바일 클라이언트에서 쓸 때 머릿속에 넣어 둘 핵심 7가지다.

  • IAsyncEnumerable<T>는 pull 기반 비동기 스트림 — 소비자가 MoveNextAsync를 부르면 생산자가 한 개 만들어 돌려준다. Push 중심의 Channel<T>, 전체 결과만 돌려주는 Task<List<T>>와 뚜렷이 구분된다.
  • async + yield return의 조합은 C# 8에서야 가능해졌다 — 컴파일러가 AsyncIteratorMethodBuilder 기반의 클래스(참조 타입) 하나를 만들어 enumerable·enumerator·state machine·async disposable 네 역할을 동시에 맡긴다.
  • MoveNextAsyncValueTask<bool>을 반환한다 — 동기 완료 시 힙 할당이 없다. 핫패스에 안전하다.
  • 취소는 [EnumeratorCancellation] + WithCancellation — 특성을 빠뜨리면 토큰이 전달되지 않는다. 컴파일러 CS8425 경고를 절대 무시하지 말 것.
  • await foreach는 try/finally로 자동 감싸지고 DisposeAsync를 호출한다 — 리소스 해제는 걱정하지 않아도 된다.
  • 예외는 소비자 쪽 try/catch로 전파된다yield return이 아니라 다음 MoveNextAsync 대기 지점에서 튀어나온다.
  • Unity에서는 프레임 분산 + UniTask 조합이 정석Awaitable.NextFrameAsync로 프레임마다 N개씩 처리, 또는 IUniTaskAsyncEnumerable<T>로 할당 추가 최소화.

Task<List<T>>로 모든 것을 "한 번에" 받아오던 습관을 버리면, 대용량·실시간·무한 스트림 시나리오가 훨씬 자연스러워진다. 다음에 "전부 다 받으면" 코드를 쓰려는 순간이 오면, "한 개씩 흘리면 어떨까?"를 먼저 떠올리면 된다.

반응형

+ Recent posts