본문 바로가기

C#

C# yield return을 이용한 지연 실행(Deferred Execution)

yield return은 컬렉션 전체를 미리 만들어 두지 않고, 필요한 순간에 하나씩 값을 생성해 내는 이터레이터를 쉽게 작성하게 해줍니다. 결과적으로 메모리 사용을 줄이고, 실제로 필요한 만큼만 계산하도록 지연 실행을 구현할 수 있습니다.

1. 기본 동작: 열거할 때마다 실행됩니다

아래 예제는 foreach가 진행될 때마다 값이 생성됩니다. 같은 시퀀스를 두 번 열거하면 생성 과정도 두 번 실행됩니다.

using System;
using System.Collections.Generic;
class Program
{
    static IEnumerable<int> Numbers()
    {
        Console.WriteLine("Generate 1");
        yield return 1;
        Console.WriteLine("Generate 2");
        yield return 2;
        Console.WriteLine("Generate 3");
        yield return 3;
    }
    
    static void Main()
    {
        Console.WriteLine("First foreach");
        foreach (var n in Numbers())
            Console.WriteLine($"Use {n}");
            
        Console.WriteLine("Second foreach");
        foreach (var n in Numbers())
            Console.WriteLine($"Use {n}");    
    }
}

포인트: IEnumerable<T>는 열거를 시작할 때마다 소스가 다시 실행됩니다. 부작용이 있거나 비용이 큰 생성 로직이라면 주의가 필요합니다.

2. 필요한 만큼만 계산: LINQ와 함께 쓰기

지연 실행은 Take, Where 같은 LINQ 연산자와 결합될 때 강력합니다. 아래는 무한 시퀀스에서 앞의 3개만 소비합니다.

using System;
using System.Collections.Generic;
using System.Linq;

static IEnumerable<int> Natural()
{    
    int i = 0;
    while (true)
        yield return ++i;
}

static void Main()
{
    foreach (var n in Natural().Take(3))
        Console.WriteLine(n); // 1,2,3만 생성/소비됨
}

포인트: Take(3)이 더 이상 필요하지 않을 때 열거를 중단하므로, 뒤의 값은 생성되지 않습니다.

3. 비용 큰 작업의 중복 실행 피하기

여러 번 열거하면 매번 로직이 재실행됩니다. 한 번만 실행하고 재사용하려면 물리화(예: ToList)하세요.

using System;
using System.Collections.Generic;
using System.Linq;

static IEnumerable<int> GetData()
{
    Console.WriteLine("Query...");    
    for (int i = 1; i <= 3; i++)
    {
        Console.WriteLine($"Fetch {i}");
        yield return i;    
    }
}

static void Main()
{
    var seq = GetData();
    Console.WriteLine(seq.Count());   // 첫 번째 열거(쿼리+페치 발생)    
    Console.WriteLine(seq.Count());   // 두 번째 열거(다시 발생)    
    var cached = seq.ToList();        // 한 번만 실행 후 캐시    
    Console.WriteLine(cached.Count);  // 이후 재사용 시 추가 실행 없음    
    Console.WriteLine(cached.Count);
}

포인트: UI 바인딩, 반복 계산, 로그 출력 등 부작용이 있거나 비용이 큰 경우 ToList/ToArray로 결과를 캐시합니다.

4. 리소스와 함께 쓰기: 파일을 줄 단위로 읽기

using과 yield를 함께 사용하면 열거 완료 시 안전하게 리소스를 해제할 수 있습니다.

using System.Collections.Generic;
using System.IO;
using System.Linq;

static IEnumerable<string> ReadLines(string path)
{
    using var sr = new StreamReader(path);
    string? line;
    while ((line = sr.ReadLine()) != null)
        yield return line;
}

static void Main()
{
    foreach (var line in ReadLines("log.txt").Take(5))
        System.Console.WriteLine(line);
}

포인트: 컴파일러가 상태 머신을 생성하여 열거가 끝나거나 중단될 때 StreamReader를 해제합니다.

5. 실전 가이드

- 시퀀스가 크거나 끝을 모를 때: yield return으로 스트리밍하세요.

- 앞부분만 필요할 때: Take/FirstOrDefault 등과 결합해 불필요한 계산을 막습니다.

- 동일 결과를 여러 번 사용할 때: ToList/ToArray로 한 번만 실행합니다.

- 부작용 로직(로그, 외부 호출)이 섞여 있을 때: 여러 번 열거되지 않도록 주의합니다.

정리: yield return은 값을 "나중에, 필요한 만큼" 생성하게 해 주어 성능과 메모리 사용을 최적화합니다. 다만 열거마다 실행된다는 특성을 이해하고, 필요 시 물리화로 제어하는 것이 핵심입니다.