Quick Reference
IEnumerable<T>는 반복할 수 있다는 계약일 뿐, 지연 실행이나 스냅샷을 뜻하지 않습니다. List<T>도 IEnumerable<T>입니다. LINQ to Objects의 Where 같은 연산은 대체로 열거할 때 실행되고, ToList()와 ToArray()는 그 시점의 원소 목록을 새 컨테이너에 확정합니다.
var players = new List<Player> { new("Mina", 1200), new("Jin", 900) };
IEnumerable<Player> query = players.Where(p => p.Score >= 1000);
List<Player> snapshot = query.ToList();
players.Clear();
Console.WriteLine(snapshot.Count); // 1: 원소 목록은 유지- 한 번 흘려보낼 파이프라인이면
IEnumerable<T>로 두고 바로foreach합니다. - 결과를 여러 번 쓰거나 원본이 바뀌기 전의 목록을 보관해야 하면
ToList()또는ToArray()를 씁니다. ToList()는 컨테이너만 새로 만듭니다. 원소가 참조형이면 원소 객체 자체는 공유합니다.
열거는 언제 다시 실행되는가
LINQ to Objects의 Where, Select, Take 등은 보통 지연 실행입니다. 즉, 쿼리를 만드는 시점이 아니라 foreach, ToList, Count처럼 열거하는 시점에 원본을 읽습니다. 반면 OrderBy, GroupBy처럼 첫 결과를 만들기 전에 입력을 모아야 하는 연산도 있으며, 이들도 호출 시점이 아니라 열거 시점에 버퍼링합니다.
IEnumerable<int> evenNumbers = Enumerable.Range(1, 10)
.Where(number => number % 2 == 0);
foreach (int number in evenNumbers) { } // 첫 열거
int count = evenNumbers.Count(); // 다시 열거그러나 모든 IEnumerable<T>가 다시 계산되는 것은 아닙니다. List<T>처럼 이미 저장된 컬렉션일 수도 있고, 파일·네트워크·사용자 정의 반복자처럼 한 번 열거하는 비용이나 결과가 달라지는 소스일 수도 있습니다. 메서드가 받은 IEnumerable<T>의 성격을 모른다면, 두 번 열거하기 전에 materialization이 필요한지 판단합니다.
Any()와 Count()도 필요하면 열거합니다. ICollection<T>처럼 개수를 바로 제공하는 소스는 최적화될 수 있지만, 일반 IEnumerable<T>에 대해 그 최적화를 전제하지 않습니다.
스냅샷을 만드는 API
ToList()는 변경 가능한 List<T>, ToArray()는 길이가 고정된 배열을 만듭니다. 둘 다 원본의 원소 목록을 복사하며, 깊은 복사는 하지 않습니다.
var source = new List<Player> { new("Mina", 1200) };
var snapshot = source.ToList();
source.Add(new Player("Jin", 900));
snapshot[0].Score = 1300;
Console.WriteLine(snapshot.Count); // 1: 추가된 Jin은 없음
Console.WriteLine(source[0].Score); // 1300: Mina 객체는 공유다음 상황에서는 materialization이 특히 유용합니다.
- 비용이 큰 쿼리 결과를 같은 요청 안에서 여러 번 사용할 때
- 순회 중 원본이 바뀔 수 있어 일관된 목록이 필요할 때
- API가
IReadOnlyList<T>, 배열,List<T>처럼 인덱스나 개수를 요구할 때
반대로 한 번만 처리하고 끝나는 큰 시퀀스를 무조건 ToList()로 만들면 메모리와 초기 지연만 늘어납니다.
yield return으로 흐름 만들기
yield return은 호출자가 다음 원소를 요구할 때까지 실행을 멈추는 반복자 메서드를 만듭니다. yield break는 더 이상 원소가 없음을 나타냅니다.
static IEnumerable<int> PositiveNumbers(IEnumerable<int> values)
{
foreach (int value in values)
{
if (value <= 0)
continue;
yield return value;
}
}
foreach (int value in PositiveNumbers([3, -1, 5]).Take(1))
Console.WriteLine(value); // 3만 요청하므로 뒤는 읽지 않음반복자는 다시 열거하면 메서드 본문을 다시 실행합니다. 파일을 열거나 상태를 바꾸는 코드를 반복자에 넣었다면, 호출자가 몇 번 열거하는지까지 API 계약으로 생각해야 합니다. 비동기 요소 흐름은 IAsyncEnumerable<T>와 await foreach를 사용합니다.
자주 틀리는 부분
IEnumerable<T>라는 타입만 보고 지연 실행이라고 판단하지 않습니다.- 여러 번 쓰는 쿼리에
Count()와foreach를 각각 호출하면 실제 소스가 두 번 읽힐 수 있습니다. ToList()는 깊은 복사가 아닙니다. 참조형 원소의 속성 변경은 원본과 스냅샷 모두에 보입니다.- 컬렉션을 순회하는 중 같은 컬렉션을 수정하지 않습니다. 필요한 경우 스냅샷을 먼저 만듭니다.
참고 링크
3 sources