모든 foreach 루프와 모든 LINQ 쿼리는 하나의 작은 인터페이스, IEnumerable<T>를 통해 동작합니다. 이를 이해하면 LINQ가 왜 지연 실행되는지, 왜 어떤 시퀀스는 한 번만 읽을 수 있는지, yield return이 어떻게 평범한 루프로 직접 시퀀스를 만들게 해 주는지 알 수 있습니다.
IEnumerable이란
IEnumerable<T>에는 GetEnumerator() 메서드 하나만 있습니다. 이것이 반환하는 열거자에는 MoveNext()(앞으로 이동하고 요소가 있는지 알려 줌)와 Current(요소)가 있습니다. foreach는 그 열거자를 움직이는 축약형입니다:
출력:
red green blue
red green blue
열거자는 첫 요소 앞에서 시작하므로 첫 MoveNext()가 첫 요소로 이동합니다. MoveNext()가 false를 반환하면 시퀀스가 끝난 것입니다. using 블록은 열거자를 dispose하며, 열린 파일 같은 자원을 붙잡고 있는 반복기에서 중요합니다.
IEnumerable과 ICollection과 List
컬렉션 인터페이스는 사다리를 이룹니다. 한 단계 올라갈수록 더 많은 것을 약속합니다:
| 타입 | 추가되는 것 | 전형적인 용도 |
|---|---|---|
IEnumerable<T> | foreach만 | 순회만 하는 매개변수, LINQ 결과, 지연 시퀀스 |
IReadOnlyCollection<T> | Count | 호출하는 쪽이 수정하면 안 되는 반환값 |
ICollection<T> | Count, Add, Remove, Contains | 어떤 종류든 컬렉션에 추가하는 코드 |
IReadOnlyList<T> | 인덱스 접근 [i] | 위치가 있는 읽기 전용 결과 |
IList<T> | 인덱스 접근, Insert, RemoveAt | 위치로 편집하는 코드 |
List<T> | 이 모두를 구현하는 구체 클래스 | 저장소 |
입력을 순회만 하는 메서드는 IEnumerable<T>를 받아야 호출하는 쪽이 변환 없이 배열, 리스트, 집합, LINQ 쿼리를 넘길 수 있습니다. 데이터를 반환하는 메서드는 보통 List<T>나 IReadOnlyList<T>처럼 "이미 메모리에 있다"고 말해 주는 것을 반환해야 합니다. IEnumerable<T> 반환값은 읽을 때마다 작업을 하는 쿼리일 수 있기 때문입니다.
출력:
30
15
4.0
30
yield return으로 반복기 작성하기
직접 시퀀스를 만들려면 IEnumerable<T>를 반환하는 메서드를 작성하고 각 요소마다 yield return을 쓰세요. 컴파일러가 메서드를 열거자를 구현하는 클래스로 바꿔 줍니다.
출력:
0, 2, 4, 6, 8, 10
Mon Tue Wed Thu Fri
EvenNumbers 어디에도 리스트는 없습니다. 각 yield return은 값 하나를 호출하는 쪽에 건네고 메서드를 일시 정지하며, 호출하는 쪽이 다음 값을 요청할 때까지 지역 변수(여기서는 i)를 살려 둡니다.
지연성: 실행 순서 지켜보기
메서드가 일시 정지하므로 메서드 본문과 호출하는 쪽의 루프가 번갈아 실행됩니다. 양쪽에서 출력해 보면 엇갈림이 보입니다:
출력:
Called Numbers(), nothing printed yet
start
yielding 1
Loop got 1
resumed after 1
yielding 2
Loop got 2
resumed after 2
yielding 3
Loop got 3
resumed after 3
end
Numbers()를 호출해도 "start"는 출력되지 않았습니다. 열거 가능한 객체만 만들었을 뿐입니다. 본문은 foreach 안의 첫 MoveNext()에서 시작해 첫 yield return까지 실행되고 멈췄습니다. 이후의 각 단계는 yield 다음 줄에서 다시 시작했습니다. LINQ의 Where와 Select도 같은 방식으로 동작하며(런타임은 속도를 위해 직접 작성한 열거자 클래스로 구현하지만 효과는 같습니다), 그래서 LINQ 쿼리가 지연 실행됩니다.
yield break
yield break는 시퀀스를 일찍 끝내며, 반복기 버전의 return입니다:
출력:
Subject: Invoice
From: shop@example.com
한 메서드 안에 return value;와 yield return을 섞을 수 없습니다. 메서드에 yield가 들어가면 반복기가 되며, 일찍 빠져나가는 유일한 방법은 yield break입니다.
무한 시퀀스
요청된 요소만 계산되므로, 반복기는 끝이 없는 시퀀스를 표현할 수 있습니다. 얼마나 가져갈지는 소비하는 쪽이 정합니다:
출력:
0, 1, 1, 2, 3, 5, 8, 13, 21, 34
1597
2026-09-28 Mon
2026-10-05 Mon
2026-10-12 Mon
Take와 First가 필요한 것을 얻은 뒤 요청을 멈추므로 여기서 while (true)는 안전합니다. 반면 무한 시퀀스에 ToList()나 Count()를 호출하면 절대 반환되지 않으며, 그 위의 OrderBy를 열거해도 마찬가지입니다. 정렬은 먼저 모든 요소가 필요하기 때문입니다.
지연 시퀀스의 함정
인수 검사가 늦게 실행됩니다. 반복기의 본문 전체가 지연되며, 맨 위의 검증도 마찬가지입니다. 해결책은 메서드를 나누는 것입니다. 일반 메서드가 인수를 검사하고 private 반복기의 결과를 반환합니다.
출력:
LazyRange(-1) returned without an error
...and threw only when enumerated
EagerRange(-1) threw at the call
열거할 때마다 메서드가 다시 실행됩니다. 반복기는 아무것도 캐시하지 않습니다. Numbers()를 두 번 순회하면 "start"가 두 번 출력되고, 파일을 두 번 다시 읽고, 웹 서비스를 두 번 호출합니다. IDE는 이를 "possible multiple enumeration of IEnumerable"로 표시합니다. 요소가 두 번 이상 필요하면 ToList()를 한 번 호출하세요.
소비하는 쪽이 멈추면 finally가 실행됩니다. 호출하는 쪽이 foreach를 일찍 빠져나가면 열거자가 dispose되고 반복기 안의 finally 블록이 실행됩니다. 파일을 읽는 반복기가 파일을 닫는 방법이 이것입니다:
출력:
open sensor
10
20
30
close sensor
done
컴파일러가 반복기에 강제하는 규칙이 몇 가지 있습니다. yield return은 catch가 있는 try 블록 안(try/finally는 괜찮습니다), 람다나 익명 메서드 안, ref나 out 매개변수가 있는 메서드 안에 올 수 없습니다.
비동기 스트림
요소가 비동기적으로 도착하는 시퀀스(API의 페이지, 데이터베이스의 행)에는, C# 8 이상(.NET Core 3.0 이상)에서 IAsyncEnumerable<T>를 통해 반복기와 await를 결합합니다:
static async IAsyncEnumerable<string> FetchPages()
{
for (int page = 1; page <= 3; page++)
{
await Task.Delay(100); // simulate a network call
yield return $"page {page}";
}
}
await foreach (string p in FetchPages())
{
Console.WriteLine(p);
}
소비하는 쪽은 await foreach를 쓰며, 각 요소는 만들어지는 대로 await됩니다.
흔한 실수
- 쿼리에서
IEnumerable<T>를 반환하고 여러 번 읽기. 읽을 때마다 다시 실행됩니다. 대신List<T>를 반환하세요. - 반복기 안에서 인수 검증하기. 검사가 지연됩니다. 래퍼와 반복기로 나누세요.
- 무한 반복기에
Count()나ToList()호출하기. 끝나지 않습니다. 먼저Take하세요. IEnumerable<T>매개변수에Count나[i]가 있으리라 기대하기. LINQ의Count()(열거할 수 있음)를 쓰거나IReadOnlyList<T>를 받으세요.
자주 묻는 질문
C#에서 IEnumerable이란 무엇인가요?
IEnumerable<T>는 foreach로 순회할 수 있는 모든 것을 위한 인터페이스입니다. 메서드는 GetEnumerator() 하나이며, 이것이 MoveNext()와 Current를 가진 IEnumerator<T>를 반환합니다. 배열, 리스트, 딕셔너리, 집합, LINQ 쿼리, 반복기 메서드가 모두 이를 구현합니다.
C#에서 IEnumerable과 List의 차이는 무엇인가요?
IEnumerable<T>는 순회할 수 있다는 것만 약속합니다. Count 속성도, 인덱서도, Add도 없고, 순회할 때마다 지연 계산될 수 있습니다. List<T>는 이 모든 것을 가진, 메모리에 저장된 구체적인 컬렉션입니다. 순회만 하는 매개변수는 IEnumerable<T>로 받고, 호출하는 쪽이 인덱싱하거나 개수를 세야 한다면 List<T>나 IReadOnlyList<T>를 반환하세요.
C#에서 yield return은 무엇을 하나요?
IEnumerable<T>를 반환하는 메서드 안의 yield return value;는 요소 하나를 호출하는 쪽에 건네고 그 지점에서 메서드를 일시 정지합니다. 호출하는 쪽이 다음 요소를 요청하면 메서드는 다음 줄부터 다시 시작합니다. 컴파일러가 메서드를 상태 기계로 다시 작성하므로, 루프를 쓰면 지연 시퀀스를 얻습니다.
C#에서 yield break는 무엇인가요?
yield break;는 시퀀스를 끝냅니다. 호출하는 쪽의 foreach는 이미 만들어진 요소들 뒤에 정상적으로 끝납니다. 반복기에서의 return에 해당하며, 한도에 도달했거나 입력이 비어 있을 때처럼 일찍 멈추는 데 쓰입니다.
반복기 메서드를 호출했는데 예외가 나지 않는 이유는 무엇인가요?
반복기 메서드의 본문은 시퀀스가 열거될 때까지 실행되지 않으므로, 맨 위의 인수 검사는 호출 시점이 아니라 첫 MoveNext에서 실행됩니다. 즉시 검증하려면 검사를 일반 public 메서드에 두고, 그 메서드가 yield 문을 담은 private 반복기 메서드를 호출하게 하세요.