[PART13.비동기와 스레딩 기초(11/15)] 비동기 스트림 — IAsyncEnumerable<T> · await foreach (C# 8)
한 번에 한 건씩 비동기로 흘려보내는 데이터 스트림 / yield return + await의 결합 / [EnumeratorCancellation]으로 안전하게 멈추는 법
목차
[문제 제기] 1만 건의 로그를 모두 메모리에 올리고 시작할 것인가
Unity 모바일 게임 클라이언트에서 서버의 운영 로그 파일을 한 줄씩 화면에 표시한다고 상상해 봅시다. 로그가 1만 줄이라면, 우리는 흔히 이렇게 씁니다.
// Unity 화면: "로그 보기" 버튼을 누르면 로그를 가져와 표시
public async Task LoadLogsAsync()
{
List<string> lines = await ReadAllLinesAsync(path); // 1만 줄을 한꺼번에 List에 담기
foreach (var line in lines)
{
AppendToScrollView(line);
}
}
이 코드의 문제는 두 가지입니다.
- 첫 줄이 화면에 뜨기까지 1만 줄이 모두 도착할 때까지 기다린다. 사용자는 그동안 빈 화면을 봅니다.
- 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만 줄 로그 문제를 비동기 스트림으로 풀면 다음과 같습니다.
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처럼 메서드를 짜면, 컴파일러는 두 개의 별도 클래스 + 한 개의 외부 메서드를 생성합니다.
// 생산자 메서드 자체는 사실상 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를 어떻게 풀어쓰는가
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();
}
이 변환의 핵심은 세 가지입니다.
await enumerator.MoveNextAsync()— 한 건씩 다음 값을 요청하는 비동기 호출.ValueTask<bool>을 반환하므로 동기 완료 시 힙 할당이 없습니다.enumerator.Current— 동기 속성.MoveNextAsync()가true를 반환한 직후에만 유효합니다.await enumerator.DisposeAsync()— 루프가 끝나거나 중간에break/예외로 빠져도 비동기적으로 자원을 정리합니다.
실제 IL — 소비자(await foreach) 측 분석
소비자 메서드는 일반 async Task 메서드와 똑같이 IAsyncStateMachine 상태머신으로 변환됩니다. 다만 안에서 IAsyncEnumerator<T>를 호출한다는 점이 다릅니다.
// 소비자 측 C# 원본
public static async Task ConsumeAsync()
{
await foreach (var n in GenerateAsync())
{
Console.WriteLine(n);
}
}
// 컴파일러가 만든 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 (핵심만)
.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 Task의AsyncTaskMethodBuilder와 다릅니다. 한 번 시작하고 끝나는 게 아니라, 여러 번 "다음 값" 요청에 응답해야 하므로 전용 빌더가 필요합니다.ManualResetValueTaskSourceCore<bool>— 매MoveNextAsync()호출마다 새Task객체를 만들지 않고, 이미 만들어둔 ValueTask 백엔드 인스턴스를 리셋해 재사용합니다. 이게 비동기 스트림이 GC를 거의 안 쓰는 이유입니다.'<>w__disposeMode'— 소비자가 루프를break하거나 예외로 빠지면,DisposeAsync()가 이 플래그를true로 켜고MoveNextAsync를 한 번 더 호출합니다. 그러면 상태머신이using의Dispose나try-finally의 정리 코드를 실행합니다.
요약하면 — 비동기 스트림은 두 종류의 상태머신이 만나는 구조입니다. 생산자는 AsyncIteratorMethodBuilder로 MoveNextAsync 호출마다 한 번씩 깨어났다 다시 자고, 소비자는 일반 async 상태머신으로 그 결과를 await합니다. 둘 모두 ValueTask 기반이므로 정상 경로에서 힙 할당이 거의 없습니다.
[실전 적용] Before/After — Unity 핫패스에서 메모리·반응성 모두 잡기
사례 1 — 대용량 데이터를 List에 모으기 vs 한 건씩 흘리기
Before — 1000개를 List에 모아 한꺼번에 반환
Unity에서 서버에서 캐릭터 인벤토리 1000개를 받아 UI에 표시하는 상황이라고 합시다.
// 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 갱신 시작
// 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>로 한 건씩 흘리기
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); // 첫 값 즉시 사용
}
// 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개의 텍스처를 로드해 갤러리를 보여주는 상황입니다. 한꺼번에 로드하면 메모리 스파이크와 첫 화면 지연이 모두 발생합니다.
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) {} }
}
// 컴파일된 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>>로 착각하기
// ❌ 잘못된 패턴: 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의 결과만 기다린다
}
// 잘못된 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 아님
// ✅ 올바른 패턴: IAsyncEnumerable<int> — 한 건씩 비동기로 순차 진행
public static async IAsyncEnumerable<int> GenerateGood()
{
for (int i = 0; i < 10; i++)
{
var result = await SomeAsync(i); // 한 번에 하나씩 await
yield return result;
}
}
// 올바른 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을 그냥 매개변수로 받기
// ❌ 잘못된 패턴: 그냥 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 후에도 멈추지 않는다
}
// 잘못된 코드의 상태머신 (해당 메서드의 매개변수 보관 필드)
.field public CancellationToken ct
// → WithCancellation(token)으로 넘어온 토큰은 GetAsyncEnumerator(CancellationToken)으로
// 전달되지만, 컴파일러는 그것을 ct 필드에 자동으로 매핑할 줄 모른다.
// ct에는 호출 시점의 default(빈 토큰)가 그대로 박혀 있다.
// ✅ 올바른 패턴: [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으로 정상 중단
}
// 올바른 코드의 매개변수 메타데이터
.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하면 정리가 안 된다고 착각하기
// 흔한 오해: "break하면 finally가 안 돌 거야"
await foreach (var line in ReadLinesAsync(path))
{
if (line.StartsWith("STOP")) break; // ← 여기서 빠져나가면 파일이 안 닫히지 않을까?
Process(line);
}
// 컴파일러가 자동으로 만든 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) 누락)
// ❌ 잘못된 패턴: 라이브러리 코드인데 메인 스레드 컨텍스트로 자꾸 돌아옴
public async IAsyncEnumerable<int> LibraryStreamBad()
{
for (int i = 0; i < 1000; i++)
{
await Task.Delay(10); // ConfigureAwait 없음 → 매번 동기화 컨텍스트 캡처/복귀 비용
yield return i;
}
}
// IL에서는 ConfigureAwait 호출이 보이지 않음 → 기본값(true)
IL_0019: call valuetype TaskAwaiter Task::GetAwaiter()
// 호출 컨텍스트(Unity 메인 스레드)가 캡처되어 매번 복귀
// ✅ 올바른 패턴: 라이브러리에서는 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))
{
// ...
}
// 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) — 최초 도입
이전에는 비동기로 시퀀스를 흘려보낼 표준 방법이 없었습니다.
// 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();
});
}
// 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);
}
// 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# 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의 약점을 보완하는 병렬 버전이 추가됐습니다.
// .NET 6 이상: 동시성 제한과 함께 병렬 처리
await Parallel.ForEachAsync(
ProduceAsync(), // IAsyncEnumerable<T>
new ParallelOptions { MaxDegreeOfParallelism = 4 },
async (item, ct) =>
{
await ProcessAsync(item, ct);
});
해설: 한 건씩 차례로 처리하는 기본 동작 위에, "최대 4개까지 동시에 처리" 같은 제어를 얹을 수 있습니다.
.NET 9 — Task.WhenEach와의 결합
// .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을 모두 구현하는 중첩 클래스를 생성한다.AsyncIteratorMethodBuilder와ManualResetValueTaskSourceCore<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). 이름만 비슷할 뿐 동작 모델이 정반대다.
'C# 기초' 카테고리의 다른 글
| [PART13.비동기와 스레딩 기초(13/15)] System.Threading.Lock — .NET 9의 새 락 타입 (C# 13) (0) | 2026.05.09 |
|---|---|
| [PART13.비동기와 스레딩 기초(12/15)] lock 문 — 임계 구역을 한 스레드만 통과시키기 (1) | 2026.05.09 |
| [PART13.비동기와 스레딩 기초(10/15)] Task의 예외 처리 (0) | 2026.05.09 |
| [PART13.비동기와 스레딩 기초(9/15)] 취소 — CancellationToken · CancellationTokenSource (0) | 2026.05.09 |
| [PART13.비동기와 스레딩 기초(8/15)] 여러 Task 조합 — Task.WhenAll · Task.WhenAny (1) | 2026.05.09 |