CodeGym /행동 /C# SELF /동기 및 비동기 제너레이터 in C# ( yield<...

동기 및 비동기 제너레이터 in C# ( yield)

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

1. 소개

수백만 줄의 로그가 들어있는 거대한 파일을 처리하거나 엄청나게 긴 수열을 생성해야 한다고 상상해봐. 제너레이터가 없다면 어떻게 할래?

전통적인 접근 방식은 대충 이렇게 생겼어:


// 문제: 컬렉션 전체를 한꺼번에 메모리에 생성함
List<int> GenerateAllNumbersSync(int count)
{
    List<int> numbers = new List<int>();
    for (int i = 0; i < count; i++)
    {
        numbers.Add(i);
    }
    return numbers; // 모든 것이 준비되었을 때 반환
}

// 사용:
var myNumbers = GenerateAllNumbersSync(1_000_000); // 한 번에 1백만 개 숫자가 메모리에!
foreach (var num in myNumbers) { /* 처리 */ }

문제가 뭘까?

  1. 메모리 소비: count가 매우 크면 전체 컬렉션이 메모리에 만들어져서 OutOfMemoryException이 날 수 있어.
  2. 지연: 사용자나 다음 처리 단계는 모든 데이터가 완전히 생성되어 메모리에 올라올 때까지 기다려야 해.
  3. 무한 시퀀스: 시퀀스가 잠재적으로 무한하면 이런 방식은 쓸모가 없어.

여기서 제너레이터가 등장해! 제너레이터는 지연 계산(Lazy Evaluation)과 스트리밍(Streaming) 개념을 구현해. 모든 데이터를 한꺼번에 만들지 않고, 필요할 때만 요소를 하나씩 생산해.

2. 제너레이터 기초

C#에서 제너레이터는 특별한 키워드 yield로 만들어져.

제너레이터란?

메서드, 속성의 get 블록 또는 연산자가 하나 이상인 yield return 표현식을 포함하는 것들이야.

yield return

이게 제너레이터의 핵심이야. 컴파일러가 yield return을 만나면:

  1. yield return 뒤에 있는 요소가 호출자에게 전달돼.
  2. 제너레이터 메서드의 실행이 일시중지되고, 그 시점의 상태(루프 위치, 지역 변수 값 등)가 저장돼.
  3. 다음 요소 요청(예: 다음 foreach 반복) 시, 메서드 실행은 중단된 지점부터 다시 시작돼.

반환 타입: 제너레이터 메서드는 IEnumerable<T> 또는 IEnumerator<T>를 반환해야 해. 컴파일러가 필요한 '마법'을 생성해줘.


// 예제 2.1: 단순 숫자 제너레이터
IEnumerable<int> GenerateNumbers(int count)
{
    Console.WriteLine("생성 시작...");
    for (int i = 0; i < count; i++)
    {
        Console.WriteLine($"생성 중: {i}");
        yield return i; // 일시중지하고 요소 반환
    }
    Console.WriteLine("생성 완료.");
}

// 사용:
// "생성 시작..."은 첫 번째 이터레이션에서만 출력될 거야!
// 그리고 "생성 중: X"는 각 이터레이션마다 출력돼.
foreach (var num in GenerateNumbers(3))
{
    Console.WriteLine($"foreach에서 받은 값: {num}");
}

yield break

이터레이션을 조기 종료할 때 사용해. yield break 이후에는 더 이상 요소가 반환되지 않아. 메서드 끝에 도달하면 굳이 yield break를 따로 쓸 필요는 없어.


// 예제 2.2: 종료 조건이 있는 제너레이터
IEnumerable<string> GetFirstNElements(List<string> source, int n)
{
    int count = 0;
    foreach (var item in source)
    {
        if (count >= n)
        {
            yield break; // 제너레이터에서 빠져나감
        }
        yield return item;
        count++;
    }
}

// 사용:
// var fruits = new List<string> { "애플", "바나나", "오렌지", "포도" };
// foreach (var fruit in GetFirstNElements(fruits, 2))
// {
//     Console.WriteLine(fruit); // "애플", "바나나" 출력
// }

3. 상태 기계

이게 내부적으로 어떻게 동작할까? 마법이 아니라 컴파일러의 똑똑한 변환이야.

yield가 있는 메서드를 쓰면 C# 컴파일러는 그 메서드를 IEnumerator<T>IEnumerable<T>를 구현하는 클래스로 변환해. 이 자동 생성된 클래스가 바로 상태 기계야.

  • 상태 저장: 기계는 현재 상태 번호(어디서 중단됐는지)와 중단 시의 모든 지역 변수 값을 저장해.
  • 이터레이션: foreach로 순회할 때 내부적으로 MoveNext()가 호출되고 Current 속성을 읽어. MoveNext()는 다음 yield return/yield break까지 실행을 재개하고, Current는 현재 요소를 반환해.

실제로 컴파일러가 Iterator 패턴을 대신 구현해 주는 거지.

4. 제너레이터 활용

예: 대용량 데이터 처리

파일을 한 번에 메모리에 올리지 않고 한 줄씩 읽기.


// 큰 파일 읽기 시뮬레이션
IEnumerable<string> ReadBigFileLines(string filePath)
{
    Console.WriteLine($"파일 열기: {filePath}");
    // 실제 애플리케이션에서는 StreamReader가 있을 거야
    yield return "데이터 줄 1";
    yield return "데이터 줄 2";
    yield return "데이터 줄 3";
    Console.WriteLine("파일 읽기 시뮬레이션 종료.");
}

// 사용:
Console.WriteLine("처리 시작.");
foreach (var line in ReadBigFileLines("my_huge_log.txt"))
{
    Console.WriteLine($"처리된 줄: {line}");
    if (line.Contains("2")) break; // 원하는 시점에 멈출 수 있어
}
Console.WriteLine("처리 완료.");

종료 메시지는 이터레이션이 완전히 끝난 후에야 나타난다는 점에 주의해.

예: 무한 시퀀스


IEnumerable<long> FibonacciSequence()
{
    long a = 0;
    long b = 1;
    while (true) // 잠재적으로 무한한 시퀀스
    {
        yield return a;
        long temp = a;
        a = b;
        b = temp + b;
    }
}

// 사용:
int count = 0;
foreach (var num in FibonacciSequence())
{
    Console.WriteLine(num);
    count++;
    if (count >= 10) break; // 멈추지 않으면 계속 실행돼
}

예: 데이터 파이프라인

각 단계가 "실시간"으로 데이터를 처리하는 메서드 체인 만들기.


IEnumerable<int> GetNumbers()
{
    yield return 1; yield return 2; yield return 3; yield return 4; yield return 5;
}

IEnumerable<int> FilterEven(IEnumerable<int> source)
{
    foreach (var num in source)
    {
        if (num % 2 == 0) yield return num;
    }
}

IEnumerable<int> Square(IEnumerable<int> source)
{
    foreach (var num in source)
    {
        yield return num * num;
    }
}

// 사용:
foreach (var result in Square(FilterEven(GetNumbers())))
{
    Console.WriteLine(result); // 4, 16
}

이건 LINQ의 많은 연산자들(예: Where, Select, Take, Skip)이 동작하는 방식과 매우 비슷해.

5. 비동기 제너레이터

동기 제너레이터는 훌륭하지만, 각 요소가 비동기 작업(예: 네트워크 요청)을 필요로 하면 어떻게 할까? C# 8.0 이전에는 구현이 까다로웠어.

문제: 비동기 데이터 스트림

동기 제너레이터 안에서는 await를 쓸 수 없어.


// 이건 컴파일되지 않아!
IEnumerable<string> GetStringsAsyncProblem()
{
    await Task.Delay(100); // 오류: await는 async 메서드에서만 가능
    yield return "Hello";
}

해결: IAsyncEnumerable<T>await foreach

  • IAsyncEnumerable<T>IEnumerable<T>의 비동기 대응물.
  • await foreach — 비동기 시퀀스를 순회하기 위한 편리한 문법(내부적으로는 MoveNextAsync()를 호출하고 비동기 상태를 처리함).

async yield return

이제 async 메서드 안에서 yield return을 사용할 수 있고, 반환 타입은 IAsyncEnumerable<T>여야 해. 컴파일러는 비동기 상태 기계를 만들어줘.


// 예제 5.1: 비동기 숫자 제너레이터
async IAsyncEnumerable<int> GenerateNumbersAsync()
{
    Console.WriteLine("비동기 생성 시작...");
    for (int i = 0; i < 5; i++)
    {
        await Task.Delay(100); // 비동기 작업 시뮬레이션(예: 네트워크 요청)
        Console.WriteLine($"비동기 생성 중: {i}");
        yield return i; // 요소 반환
    }
    Console.WriteLine("비동기 생성 완료.");
}

// 사용:
async Task ConsumeAsyncNumbers()
{
    Console.WriteLine("비동기 처리 시작...");
    await foreach (var number in GenerateNumbersAsync())
    {
        Console.WriteLine($"비동기로 받은 값: {number}");
    }
    Console.WriteLine("비동기 처리 완료.");
}

// 실행:
await ConsumeAsyncNumbers(); // async Main 또는 유사한 컨텍스트에서 호출

IAsyncDisposableawait using (제너레이터 문맥)

제너레이터가 비동기적으로 해제해야 할 리소스를 열면(DisposeAsync()) await using을 사용해. await foreach가 끝날 때 내부 이터레이터가 IAsyncDisposable를 구현하면 자동으로 DisposeAsync()를 호출해.


// 예제 5.2: await using으로 비동기 파일 읽기
// 실제 StreamReader는 IAsyncDisposable을 구현함
async IAsyncEnumerable<string> ReadFileLinesAsync(string filePath)
{
    Console.WriteLine($"[제너레이터] 비동기적으로 파일 열기: {filePath}");
    // await using은 블록 종료 후 DisposeAsync()를 보장함
    await using var reader = new StreamReader(filePath); 
    
    string? line;
    while ((line = await reader.ReadLineAsync()) != null) // 비동기 한 줄 읽기
    {
        yield return line;
    }
    Console.WriteLine($"[제너레이터] 파일 읽기 완료: {filePath}");
}

// 사용:
async Task ProcessFileAsync()
{
    Console.WriteLine("[처리기] 파일 처리 시작.");
    await foreach (var line in ReadFileLinesAsync("path_to_some_file.txt")) // 실제 경로로 바꿔
    {
        Console.WriteLine($"[처리기] 받은 줄: {line}");
        // 각 줄에 대해 비동기 처리 가능
        await Task.Delay(50); 
    }
    Console.WriteLine("[처리기] 파일 처리 완료.");
}

// 실행:
await ProcessFileAsync(); // async Main에서 호출

비동기 제너레이터 취소: CancellationToken

비동기 제너레이터에 CancellationToken을 추가해서 호출자가 생성 작업을 취소할 수 있게 해.


// 예제 5.3: 취소 가능한 비동기 제너레이터
async IAsyncEnumerable<int> GenerateCancelableSequence(
    int start, int count, 
    [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token = default)
{
    for (int i = 0; i < count; i++)
    {
        token.ThrowIfCancellationRequested(); // 취소 토큰 확인
        await Task.Delay(100, token); // Task.Delay도 취소를 지원함
        yield return start + i;
    }
}

사용 예


var cts = new CancellationTokenSource();
Task.Run(async () =>
{
    await Task.Delay(300); // 제너레이터가 좀 돌아가게 둠
    cts.Cancel(); // 취소!
});

try
{
    await foreach (var num in GenerateCancelableSequence(0, 100, cts.Token))
    {
        Console.WriteLine($"받음: {num}");
    }
}
catch (OperationCanceledException)
{
    Console.WriteLine("생성이 취소되었음.");
}

6. 제약과 주의할 점

yield의 제약:

  • yield returntry 블록 안에서 쓰되, 같은 try에 속한 catchfinally에서도 yield를 쓰는 건 허용되지 않아.
  • yield가 있는 메서드는 unsafe일 수 없어.
  • yieldasync void 메서드에서 사용할 수 없어 (대신 async Task 또는 IAsyncEnumerable<T> 사용).

성능: 아주 작거나 고정된 컬렉션의 경우 상태 기계 오버헤드가 List<T>를 직접 반환하는 것보다 약간 클 수 있어. 하지만 큰 데이터에서는 지연성과 스트리밍의 이득이 보통 훨씬 중요해.

오류 처리: 제너레이터 내부에서 던져진 예외는 호출자에게 정상적으로 전달되고, 일반 메서드에서처럼 호출자가 예외를 잡을 수 있어.

코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION