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) { /* 처리 */ }
문제가 뭘까?
- 메모리 소비: count가 매우 크면 전체 컬렉션이 메모리에 만들어져서 OutOfMemoryException이 날 수 있어.
- 지연: 사용자나 다음 처리 단계는 모든 데이터가 완전히 생성되어 메모리에 올라올 때까지 기다려야 해.
- 무한 시퀀스: 시퀀스가 잠재적으로 무한하면 이런 방식은 쓸모가 없어.
여기서 제너레이터가 등장해! 제너레이터는 지연 계산(Lazy Evaluation)과 스트리밍(Streaming) 개념을 구현해. 모든 데이터를 한꺼번에 만들지 않고, 필요할 때만 요소를 하나씩 생산해.
2. 제너레이터 기초
C#에서 제너레이터는 특별한 키워드 yield로 만들어져.
제너레이터란?
메서드, 속성의 get 블록 또는 연산자가 하나 이상인 yield return 표현식을 포함하는 것들이야.
yield return
이게 제너레이터의 핵심이야. 컴파일러가 yield return을 만나면:
- yield return 뒤에 있는 요소가 호출자에게 전달돼.
- 제너레이터 메서드의 실행이 일시중지되고, 그 시점의 상태(루프 위치, 지역 변수 값 등)가 저장돼.
- 다음 요소 요청(예: 다음 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 또는 유사한 컨텍스트에서 호출
IAsyncDisposable 및 await 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 return을 try 블록 안에서 쓰되, 같은 try에 속한 catch나 finally에서도 yield를 쓰는 건 허용되지 않아.
- yield가 있는 메서드는 unsafe일 수 없어.
- yield는 async void 메서드에서 사용할 수 없어 (대신 async Task 또는 IAsyncEnumerable<T> 사용).
성능: 아주 작거나 고정된 컬렉션의 경우 상태 기계 오버헤드가 List<T>를 직접 반환하는 것보다 약간 클 수 있어. 하지만 큰 데이터에서는 지연성과 스트리밍의 이득이 보통 훨씬 중요해.
오류 처리: 제너레이터 내부에서 던져진 예외는 호출자에게 정상적으로 전달되고, 일반 메서드에서처럼 호출자가 예외를 잡을 수 있어.
GO TO FULL VERSION