CodeGym /행동 /C# SELF /비동기 데이터 스트림

비동기 데이터 스트림

C# SELF
레벨 62, 레슨 0
사용 가능

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.
  • 데이터가 비동기적으로 생성되거나 들어오고 이를 점진적으로 처리해야 하는 모든 시나리오.

동작 원리

  1. IAsyncEnumerable<T>: GetAsyncEnumerator(CancellationToken cancellationToken) 메서드를 가진 인터페이스야. 취소 토큰이 중요해!
  2. IAsyncEnumerator<T>: ValueTask<bool>를 반환하는 MoveNextAsync()와 현재 요소를 나타내는 Current를 갖고 있어. 그리고 IAsyncDisposable도 상속해.
  3. 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()를 호출해 줄 거야.

2
과제
C# SELF, 레벨 62, 레슨 0
잠금
비동기 파일 읽기
비동기 파일 읽기
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION