1. 소개
너는 이미 async와 await에 익숙하지? 이건 단일 비동기 작업(예: 파일 하나 다운로드)에 아주 잘 맞아. 그런데 데이터가 스트림으로 들어오거나, 리소스 해제가 비동기여야 한다면 어떻게 할까?
비동기 "컬렉션"의 문제: 수백만 건의 레코드를 DB에서 가져와야 한다고 해보자. 메서드가 Task<List<T>>를 반환하면 메모리에 모든 데이터를 로드할 때까지 기다려야 해. 비효율적이고 지연이 커지지. 각 항목을 비동기적으로 받아야 한다면 동기적인 IEnumerable<T>도 맞지 않아.
비동기적 자원 해제의 문제: IDisposable와 using은 동기 해제에 잘 작동해. 그런데 네트워크 연결 종료나 버퍼를 파일에 플러시하는 작업 자체가 비동기라면? 동기 Dispose() 안에서 await를 쓸 수 없으니 스레드가 막히거나 해제가 제대로 안 될 수 있어.
이 문제들을 해결하기 위해 도입된 것이 IAsyncEnumerable<T>와 IAsyncDisposable야.
2. 비동기 데이터 스트림
IAsyncEnumerable<T>는 IEnumerable<T>의 비동기 버전이야. 이걸로 요소들을 하나씩 비동기적으로 생산할 수 있어서 모든 데이터를 한꺼번에 기다릴 필요가 없어.
언제 써야 해?
- 큰 파일을 비동기적으로 줄 단위로 읽을 때: 예를 들어 기가바이트 로그 같은 경우.
- 네트워크나 DB에서 데이터 스트리밍할 때: API 응답이 부분적으로 들어오는 경우.
- 서버의 스트리밍 API 구현: 예를 들어 gRPC Streaming.
- 데이터가 비동기적으로 생성되거나 들어오고 이를 점진적으로 처리해야 하는 모든 시나리오.
동작 원리
- IAsyncEnumerable<T>: GetAsyncEnumerator(CancellationToken cancellationToken) 메서드를 가진 인터페이스야. 취소 토큰이 중요해!
- IAsyncEnumerator<T>: ValueTask<bool>를 반환하는 MoveNextAsync()와 현재 요소를 나타내는 Current를 갖고 있어. 그리고 IAsyncDisposable도 상속해.
- await foreach: IAsyncEnumerable<T>를 편하게 순회하게 해줘. 컴파일러가 MoveNextAsync()와 Current를 알아서 호출해줘. 중요한 건, await foreach가 반복이 끝난 뒤(오류가 나도 포함해서) DisposeAsync()를 호출해 준다는 거야.
async와 yield return으로 IAsyncEnumerable<T> 만들기
async 메서드 안에서 yield return을 사용할 수 있어. 이렇게 하면 비동기 제너레이터를 만들 수 있지. 메서드 안에서 await로 비동기 작업을 기다렸다가 다시 생성을 이어갈 수 있어.
예: 간단한 비동기 제너레이터
async IAsyncEnumerable<int> GenerateNumbersAsync()
{
for (int i = 0; i < 3; i++)
{
Console.WriteLine($"생성 중: {i}");
await Task.Delay(100); // 비동기 작업 시뮬레이션
yield return i;
}
}
// 사용 예:
async Task ConsumeAsyncNumbers()
{
await foreach (var number in GenerateNumbersAsync())
{
Console.WriteLine($"받음: {number}");
}
}
// 호출: await ConsumeAsyncNumbers();
예: 파일을 줄 단위로 비동기 읽기
async IAsyncEnumerable<string> ReadFileLinesAsync(string filePath)
{
using var reader = new StreamReader(filePath); // 여기서 'using' (StreamReader는 IAsyncDisposable을 구현함)
string? line;
while ((line = await reader.ReadLineAsync()) != null)
{
yield return line;
}
}
// 사용 예:
async Task ProcessFileAsync()
{
await File.WriteAllLinesAsync("data.txt", new[] { "줄 1", "줄 2", "줄 3" });
await foreach (var line in ReadFileLinesAsync("data.txt"))
{
Console.WriteLine($"처리된 줄: {line}");
}
}
// 호출: await ProcessFileAsync();
예: 취소 가능한 비동기 제너레이터 (CancellationToken 사용)
async IAsyncEnumerable<int> GetCancelableSequence(
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token = default)
{
for (int i = 0; i < 10; i++)
{
token.ThrowIfCancellationRequested(); // 취소 확인
await Task.Delay(200, token); // Task.Delay도 token을 통해 취소를 지원함
yield return i;
}
}
// 사용 예:
async Task ConsumeAndCancel()
{
var cts = new CancellationTokenSource(500); // 500ms 후 취소
try
{
await foreach (var num in GetCancelableSequence(cts.Token))
{
Console.WriteLine($"받음: {num}");
}
}
catch (OperationCanceledException)
{
Console.WriteLine("생성 취소됨!");
}
}
// 호출: await ConsumeAndCancel();
[EnumeratorCancellation] 어트리뷰트는 CancellationToken을 비동기 제너레이터로 전달할 수 있게 해줘. 호출하는 코드가 CancellationTokenSource로 취소를 요청하면 반복을 중단할 수 있다는 뜻이야. 이 어트리뷰트가 없으면 토큰이 자동으로 GetAsyncEnumerator로 전달되지 않아.
3. 비동기 자원 관리
동기적 IDisposable의 문제점
Dispose()는 IDisposable에서 동기적으로 정의돼 있어 (void Dispose()). 그 안에서 await를 쓸 수 없어. DB 연결 종료나 버퍼 플러시 같은 작업이 오래 걸리고 비동기라면, 동기적인 Dispose()는 스레드를 블로킹해서 비동기 애플리케이션에 안 좋다.
해결책: IAsyncDisposable
IAsyncDisposable가 이 문제를 해결해. 이 인터페이스는 단 하나의 메서드를 갖고 있어: 비동기 정리를 위한 ValueTask DisposeAsync().
await using
이건 using의 비동기 버전이야. IAsyncDisposable를 구현한 객체에 쓰라고 만든 문법이지.
- await using은 블록이 끝나면(또는 예외로 빠져나가도) 자동으로 DisposeAsync()를 호출해 준다.
- 이렇게 하면 리소스를 비동기적으로 안전하게 해제해서 블로킹을 피할 수 있어.
예: 기본적인 IAsyncDisposable와 await using
class MyAsyncResource : IAsyncDisposable
{
public MyAsyncResource() => Console.WriteLine("리소스 열림.");
public async ValueTask DisposeAsync()
{
Console.WriteLine("비동기 정리 시작...");
await Task.Delay(200); // 비동기 정리 시뮬레이션
Console.WriteLine("비동기 정리 완료.");
}
}
// await using 사용 예
async Task UseAndDisposeResource()
{
await using var resource = new MyAsyncResource();
Console.WriteLine("리소스 사용 중...");
} // 여기서 자동으로 resource.DisposeAsync()가 호출됨
// 호출: await UseAndDisposeResource();
예: 여러 개의 await using 블록
async Task UseMultipleResources()
{
await using var res1 = new MyAsyncResource();
await using var res2 = new MyAsyncResource();
Console.WriteLine("두 리소스 사용 중...");
} // 리소스는 LIFO(Last In, First Out) 순서로 해제됨: res2.DisposeAsync()가 먼저 호출되고, 그 다음 res1.DisposeAsync()가 호출됨.
// 호출: await UseMultipleResources();
IAsyncEnumerable<T>와 IAsyncDisposable의 호환성
중요한 점: IAsyncEnumerator<T>는 자체적으로 IAsyncDisposable를 상속해. 즉, 네가 만든 비동기 제너레이터가 (위 예의 StreamReader처럼) 비동기적으로 해제해야 할 리소스를 사용한다면, await foreach가 알아서 처리해 줘. 반복이 끝나면 이터레이터의 DisposeAsync()를 호출해 줄 거야.
GO TO FULL VERSION