본문 바로가기

C#

C# Enumerator 커스터마이징과 상태 유지

커스텀 Enumerator는 반복의 규칙과 상태를 직접 통제할 수 있어 성능 최적화와 도메인 규칙 캡슐화에 유리합니다. 이 글에서는 IEnumerator/IEnumerable 직접 구현, yield 기반 상태 머신, 반복 간 상태를 공유하는 커서 스타일, 그리고 성능/주의사항을 간단히 정리합니다.

1. 기본기: IEnumerable/IEnumerator 직접 구현

직접 구현하면 반복 규칙과 상태 보관을 세밀하게 제어할 수 있습니다. 아래는 짝수만 내보내며 마지막으로 반환한 값을 상태로 유지하는 예시입니다.

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

public struct EvenOnlyEnumerator : IEnumerator<int>
{
    private readonly int _end;
    private int _current;

    public EvenOnlyEnumerator(int end)
    {
        _end = end;
        _current = -2; // MoveNext에서 +2 하여 0부터 시작
    }

    public int Current => _current;
    object IEnumerator.Current => Current;

    public bool MoveNext()
    {
        _current += 2;
        return _current <= _end;
    }

    public void Reset() => throw new NotSupportedException();
    public void Dispose() { /* 리소스 없음 */ }
}

public readonly struct EvenOnly : IEnumerable<int>
{
    private readonly int _end;
    public EvenOnly(int end) => _end = end;

    // 패턴 기반 foreach는 이 메서드를 우선 사용하며 박싱이 없습니다.
    public EvenOnlyEnumerator GetEnumerator() => new EvenOnlyEnumerator(_end);

    // 인터페이스 경로도 제공(호환성), 다만 이 경로를 사용하면 값형이 박싱될 수 있습니다.
    IEnumerator<int> IEnumerable<int>.GetEnumerator() => new EvenOnlyEnumerator(_end);
    IEnumerator IEnumerable.GetEnumerator() => new EvenOnlyEnumerator(_end);
}

// 사용
// var seq = new EvenOnly(10);
// foreach (var x in seq) Console.WriteLine(x); // 0,2,4,6,8,10

이 구현은 반복 상태(현재 값)를 Enumerator 내부에서 유지합니다. foreach가 끝나면 Enumerator도 수명이 끝나므로, Enumerator는 1회용이라는 점을 기억합니다.

2. yield return으로 상태 보관(컴파일러 상태 머신)

yield를 쓰면 컴파일러가 상태 머신을 생성해 지역 변수를 자동으로 보존합니다. 누적 평균처럼 상태가 자연스레 유지되는 스트림을 쉽게 만들 수 있습니다.

using System.Collections.Generic;

public static class Stats
{
    public static IEnumerable<double> RunningAverage(IEnumerable<int> source)
    {
        long sum = 0;
        int n = 0;
        foreach (var x in source)
        {
            sum += x;
            n++;
            yield return (double)sum / n; // sum, n은 MoveNext 사이에서 유지됩니다.
        }
    }
}

// 사용
// foreach (var avg in Stats.RunningAverage(new[]{1,2,3,4})) Console.WriteLine(avg);
// 1, 1.5, 2, 2.5

yield 기반 이터레이터는 간결하며 상태 유지가 명확합니다. 단, 마찬가지로 한 Enumerator 인스턴스는 1회 소비되며, 새 반복을 시작하면 새로운 Enumerator 인스턴스가 생성됩니다.

3. 반복 간 상태 유지(커서 스타일 Enumerable)

특정 시나리오에서는 여러 번 foreach를 돌 때 다음 위치부터 이어서 읽고 싶을 수 있습니다. 일반 Enumerable은 매번 처음부터 시작하도록 설계하는 것이 표준이지만, 의도적으로 커서를 공유해 "이어읽기"를 구현할 수도 있습니다. 아래는 내부 커서를 공유하는 예입니다.

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

public sealed class CursorList<T> : IEnumerable<T>
{
    private readonly IReadOnlyList<T> _data;
    private int _cursor = 0; // 공유 커서(상태)

    public CursorList(IReadOnlyList<T> data) => _data = data;

    public IEnumerator<T> GetEnumerator() => new CursorEnumerator(this);
    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

    private sealed class CursorEnumerator : IEnumerator<T>
    {
        private readonly CursorList<T> _owner;
        public CursorEnumerator(CursorList<T> owner) => _owner = owner;

        public T Current { get; private set; } = default!;
        object IEnumerator.Current => Current!;

        public bool MoveNext()
        {
            if (_owner._cursor >= _owner._data.Count) return false;
            Current = _owner._data[_owner._cursor++];
            return true;
        }

        public void Reset() => throw new NotSupportedException();
        public void Dispose() { }
    }

    public void Rewind() => _cursor = 0; // 수동으로 되감기
}

// 사용
// var c = new CursorList<int>(new[]{1,2,3,4});
// foreach (var x in c) { Console.Write(x); if (x==2) break; } // 12
// foreach (var x in c) Console.Write(x); // 34 (이어읽기)
// c.Rewind();
// foreach (var x in c) Console.Write(x); // 1234

주의: 이런 공유 상태 설계는 표준 Enumerable의 기대(매 반복은 처음부터)를 깨뜨립니다. 병렬/중첩 반복에 취약하고, 스레드 세이프하지 않습니다. 외부에 의도를 명확히 문서화하거나 Reset/Dispose 정책을 분명히 하는 것이 좋습니다.

4. 성능 팁과 주의사항

- 값 형식(struct) Enumerator: GetEnumerator가 struct를 반환하면 foreach에서 박싱 없이 동작합니다. 다만 IEnumerable 인터페이스로 캐스팅하면 박싱될 수 있어 성능에 민감한 구간에서는 인터페이스 경로를 피합니다.

- Reset은 구현하지 않습니다: 대부분의 .NET 컬렉션처럼 Reset은 NotSupportedException을 던지도록 합니다.

- Dispose와 리소스: 파일, 네트워크 등 외부 리소스를 다룬다면 Enumerator.Dispose에서 해제를 보장합니다. foreach는 Dispose를 자동 호출합니다.

- 단일 사용 원칙: Enumerator는 1회용입니다. 재사용을 시도하지 말고, 새로운 반복이 필요하면 새 Enumerator를 생성합니다.

- yield와 캡처: yield 메서드의 지역 변수는 상태로 안전하게 유지됩니다. 다만 외부 가변 변수(클로저)를 캡처해 변경하면 예측하기 어려운 부작용이 생길 수 있으므로 주의합니다.

- LINQ와 값 형식 Enumerator: LINQ 연산은 IEnumerable 경로를 거치며 값 형식 Enumerator를 박싱할 수 있습니다. 극한 최적화가 필요하다면 전용 연산자 구현(예: 전용 Select/Where)이나 소스-전용 반복을 고려합니다.

5. 보너스: 비동기 스트림에서도 상태 유지

IAsyncEnumerable에서도 MoveNextAsync 사이에 상태가 유지됩니다.

using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;

public static class AsyncGen
{
    public static async IAsyncEnumerable<int> PollUntilAsync(
        Func<Task<int>> fetch,
        [EnumeratorCancellation] System.Threading.CancellationToken ct = default)
    {
        int last = 0;
        while (!ct.IsCancellationRequested)
        {
            var v = await fetch();
            if (v != last) { last = v; yield return v; } // last 상태 유지
            await Task.Delay(100, ct);
        }
    }
}

정리하면, 간단한 규칙과 상태는 yield로, 미세한 제어나 성능 최적화가 필요하면 직접 Enumerator를, 특수한 이어읽기 요구에는 커서 스타일을 고려합니다. 상태의 수명과 공개 범위를 명확히 설계하는 것이 핵심입니다.