[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>가 그 답이다.
async와 yield return을 동시에 쓸 수 있게 되었고, 소비자는 await foreach로 그 스트림을 한 항목씩 당겨 온다. 이 글에서는 그 내부 구조와 Unity에서의 실전 패턴을 IL 수준까지 따라가며 정리한다.
2. 개념 정의 — IAsyncEnumerable<T>란
비유 — 택배 창고(한 번에) vs 컨베이어 벨트(흐름)
Task<List<T>> 방식은 주문한 택배 1만 개가 전부 창고에 모일 때까지 기다렸다가 한 번에 받는 것과 같다. 다 모이기 전엔 단 하나도 열어볼 수 없고, 받자마자 거실이 상자로 가득 찬다.
IAsyncEnumerable<T> 방식은 공장에서 컨베이어 벨트로 물건이 한 개씩 흘러 들어오는 것과 같다. 물건이 아직 만들어지지 않았으면 벨트가 잠시 멈추지만, 그동안 나는 다른 일을 할 수 있다. 하나가 도착하면 바로 꺼내 쓰고, 또 다음 것이 올 때까지 기다린다. 거실에는 항상 한 개씩만 있다.
구조 한눈에 보기
기본 코드 — 가장 단순한 비동기 스트림
가장 작은 예시로 "1초에 하나씩 정수를 생산하는 스트림"을 만들어 본다.
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— 비동기 스트림 소비 문법 일반foreach가GetEnumerator().MoveNext()를 반복 호출하듯,await foreach는GetAsyncEnumerator().MoveNextAsync()를await하며 반복 호출한다. 루프 종료 시DisposeAsync()가 자동 호출된다.
예시:await foreach (var chunk in stream) ...각 반복마다MoveNextAsync를await하고, 값이 있으면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로 바뀐 동일 구조).
생산자 쪽 — 한 클래스가 네 개의 인터페이스를 모두 구현한다
// 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;
}
}
}
위 두 메서드를 컴파일하면 아래처럼 완전히 다른 구조의 상태 머신이 나온다. 핵심 부분만 발췌한다.
// ── 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>> 하나 반환
}
// ── 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 루프로 바뀐다.
await foreach (var item in after.FetchStreamAsync())
{
Console.WriteLine(item);
}
// 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객체 할당 없이 끝난다.DisposeAsync가finally에 자동 삽입 — 소비자가 루프를break로 빠져나가든 예외로 벗어나든, 컴파일러가 짠 finally가 항상DisposeAsync를 부른다. 이것이 "IAsyncEnumerator<T>가IAsyncDisposable을 상속한다"는 말의 실제 효과다.
왜 ValueTask<bool>인가
ValueTask<bool>는 구조체다. 다음 값이 이미 버퍼에 있어서 즉시 꺼낼 수 있으면 new ValueTask<bool>(true)처럼 스택에서 구조체 하나 만들고 끝이다. "한 네트워크 패킷에 100개 요소가 들어 있다 → 99번은 동기 완료 → Task 객체 0개"라는 시나리오가 가능해진다.
이 설계 덕분에 IAsyncEnumerable<T>는 초당 수만 항목을 스트리밍하는 핫패스에서도 GC 부담 없이 쓸 수 있다.
4. 실전 적용 — Before/After와 Unity 스트리밍
Before — 서버 랭킹 전체를 한 번에 받기
먼저 신입 개발자가 흔히 작성하는 "일단 리스트로 받아서 돌리자" 패턴이다.
// 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>로 바꾸면 서버가 페이지 단위로 응답을 끊어 보내고, 클라이언트는 도착 즉시 한 행씩 그린다.
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[]>로 쓰면 다운로드 루프와 진행률 갱신이 한 줄씩 떨어진다.
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 이슈와 프레임 정렬이 걱정된다면UniTask의IUniTaskAsyncEnumerable<T>를 그대로 이 자리에 대입해도 된다 — 문법은 동일하고 할당이 더 줄어든다.
5. 함정과 주의사항 — 취소·예외·핫패스
함정 1 — [EnumeratorCancellation]을 빠뜨린다
신입이 가장 자주 만나는 버그다. 파라미터 이름만 ct로 둔다고 취소가 자동으로 흘러 들어가지 않는다.
// ❌ 취소가 동작하지 않음
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]을 붙여 소비자 토큰이 주입되게 만들어야 한다.
// ✅ 수정
public async IAsyncEnumerable<int> FixedStream(
[EnumeratorCancellation] CancellationToken ct = default)
{
while (true)
{
await Task.Delay(100, ct);
yield return 1;
}
}
IL에서는 이 특성이 실제로 EnumeratorCancellationAttribute로 파라미터에 박힌다.
.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가 완료될 때 소비자 쪽에서 튀어나온다.
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/catch로yield return자체를 감쌀 수 없다는 컴파일러 제약이 있다 —yield return문은 catch 블록 안쪽에 들어가지 못한다. 복잡한 예외 처리가 필요하면async Task<TResult> TryProduceAsync()같은 일반 async 메서드로 한 번 감싼 뒤 그 결과를yield return한다.
함정 3 — 스트림을 두 번 반복하려 한다
IAsyncEnumerable<T>도 IEnumerable<T>처럼 cold sequence다 — await foreach를 할 때마다 GetAsyncEnumerator가 다시 호출되고 본문이 처음부터 다시 실행된다.
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.Async의 Where().Select().Take() 체인은 편하지만, 각 연산자가 새로운 IAsyncEnumerable 래퍼를 만든다. 프레임당 수천 항목을 흘려보내는 Unity 핫패스에서는 연산자 당 한 번씩 MoveNextAsync 호출 체인이 쌓여 오버헤드가 커진다.
// ❌ 매 프레임 호출되는 입력 스트림에 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 이하 — 비동기 스트림 자체가 없다
async와 yield return을 동시에 쓸 수 없었다. 비슷한 효과를 내려면 아래처럼 Task<IEnumerable<T>>를 돌려주거나, 이벤트·콜백으로 직접 조립했다.
// 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문법- 반복기 안에서
async와yield return공존 허용 IAsyncDisposable—using await으로도 해제 가능
// 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으로 넘긴 토큰을 생산자 본문까지 전달하기 위한 다리다.
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에서는 파라미터에 아래와 같이 특성이 박힌다.
.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 네 역할을 동시에 맡긴다.MoveNextAsync는ValueTask<bool>을 반환한다 — 동기 완료 시 힙 할당이 없다. 핫패스에 안전하다.- 취소는
[EnumeratorCancellation]+WithCancellation— 특성을 빠뜨리면 토큰이 전달되지 않는다. 컴파일러CS8425경고를 절대 무시하지 말 것. await foreach는 try/finally로 자동 감싸지고DisposeAsync를 호출한다 — 리소스 해제는 걱정하지 않아도 된다.- 예외는 소비자 쪽 try/catch로 전파된다 —
yield return이 아니라 다음MoveNextAsync대기 지점에서 튀어나온다. - Unity에서는 프레임 분산 + UniTask 조합이 정석 —
Awaitable.NextFrameAsync로 프레임마다 N개씩 처리, 또는IUniTaskAsyncEnumerable<T>로 할당 추가 최소화.
Task<List<T>>로 모든 것을 "한 번에" 받아오던 습관을 버리면, 대용량·실시간·무한 스트림 시나리오가 훨씬 자연스러워진다. 다음에 "전부 다 받으면" 코드를 쓰려는 순간이 오면, "한 개씩 흘리면 어떨까?"를 먼저 떠올리면 된다.
'C# 심화' 카테고리의 다른 글
| [PART11.비동기와 동시성(10/12)] Mutex vs SemaphoreSlim vs lock — 동기화 도구 선택 기준 (0) | 2026.04.14 |
|---|---|
| [PART11.비동기와 동시성(9/12)] lock — 임계 구역을 보호하는 원리 (0) | 2026.04.14 |
| [PART11.비동기와 동시성(7/12)] ValueTask — 언제 Task 대신 쓰는가 (0) | 2026.04.14 |
| [PART11.비동기와 동시성(6/12)] Task.WhenAll vs Task.WhenAny — 언제 무엇을 쓰는가 (0) | 2026.04.14 |
| [PART11.비동기와 동시성(5/12)] CancellationToken — 비동기 작업을 취소하는 올바른 방법 (0) | 2026.04.14 |