1. 소개
아마 이미 눈치챘겠지만, C#의 여러 컬렉션들은 비슷한 점이 있어. 예를 들어, 거의 모든 컬렉션을 foreach 루프로 돌릴 수 있지:
var names = new List<string> { "아냐", "보리스", "비카" };
// 주의 — 이 코드는 List, 배열, 심지어 HashSet에도 잘 동작해!
foreach (var name in names)
{
Console.WriteLine(name);
}
이게 어떻게 가능할까? 그건 모든 최신 컬렉션들이 IEnumerable<T> 인터페이스를 구현하기 때문이야. 이건 일종의 "계약"이야. 즉, 컬렉션이 하나씩 순서대로 요소를 반환할 수 있다는 걸 보장해주는 거지.
회사에 있다고 상상해봐. 어떤 직원 컬렉션(부서, 프로젝트 팀, 회식 참가자 리스트 등)이든 "IEnumerable" 인터페이스를 구현하면, 그 안에 직원들이 어떻게 저장되어 있든 상관없이 항상 정해진 순서대로 전부 돌 수 있다는 규칙이 있는 거야.
2. IEnumerable<T> 인터페이스 — 내부는?
.NET에서 이 인터페이스가 어떻게 생겼는지 보자:
public interface IEnumerable<out T>
{
IEnumerator<T> GetEnumerator();
}
딱 한 개의 메서드만 있어 — GetEnumerator. 이 메서드는 IEnumerator<T> 타입의 객체를 반환해. 이 객체가 바로 "열거" 과정을 담당하지. 지금 어떤 요소에 있는지, 다음으로 어떻게 가는지, 끝났는지 등을 알고 있어.
요약: 만약 클래스(혹은 컬렉션)가 IEnumerable<T>를 구현하면, foreach 루프로 돌 수 있고, 요소를 하나씩 받을 수 있어. 내부적으로 어떻게 구현됐는지는 상관없어. 리스트든, 해시테이블이든, 심지어 하드디스크에 있든!
간단 도식
컬렉션 (예: List<T>)
|
v
IEnumerable<T>
|
v
IEnumerator<T> (진짜 "요소 순회자")
3. 실습과 코드의 범용성
이런 계약의 최고 장점은 바로 범용성이야. 함수나 메서드가 IEnumerable<T>를 받는다면, 어떤 컬렉션 타입이든 다 호환돼 — 배열, 리스트, 해시셋, 큐, 심지어 네가 만든 컬렉션까지!
예를 들어, 요소들의 합을 구하는 함수를 보자:
// IEnumerable<int>를 지원하는 어떤 int 집합이든 합을 구할 수 있어
int Sum(IEnumerable<int> numbers)
{
int result = 0;
foreach (var n in numbers)
result += n;
return result;
}
// List<int>에도 동작
var list = new List<int> { 1, 2, 3 };
Console.WriteLine(Sum(list));
// 배열에도 동작!
int[] array = { 4, 5, 6 };
Console.WriteLine(Sum(array));
// 필터링 메서드 결과에도 동작
Console.WriteLine(Sum(list.Where(x => x % 2 == 0))); // LINQ 사용
실생활 예시: 범용 메서드
예를 들어, 어떤 문자열 컬렉션에서 가장 긴 단어를 찾는 유틸리티를 만들어야 한다고 해보자. IEnumerable<string> 덕분에, 배열이든, 리스트든, 필터링 메서드 결과든 다 쓸 수 있어:
string FindLongest(IEnumerable<string> words)
{
string longest = "";
foreach (var word in words)
if (word.Length > longest.Length)
longest = word;
return longest;
}
아무 컬렉션이나 다 쓸 수 있어.
4. 왜 foreach 루프가 IEnumerable<T>에서 동작할까?
자주 나오는 질문: 왜 foreach 루프가 아무 컬렉션이나 "이해"할까? 답은 간단해: C# 컴파일러가 클래스에서 GetEnumerator 메서드를 찾고, MoveNext() 메서드와 Current 프로퍼티가 있는 객체를 기대하기 때문이야. 이게 바로 표준 인터페이스 — IEnumerator<T>야.
이런 구조 덕분에, 네가 직접 요소를 하나씩 "내놓는" 클래스를 만들어도(예: 피보나치 수열 생성기), IEnumerable<int>만 구현하면 foreach에서 리스트처럼 쓸 수 있어.
5. Enumerator란? 어떻게 동작할까?
IEnumerable<T>를 구현하는 모든 컬렉션 내부에는 특별한 “순회자” — Enumerator(공식적으로는 IEnumerator<T> 인터페이스 구현 객체)가 숨어 있어. 바로 이 녀석이 foreach 루프에서 요소를 하나씩 "꺼내" 주는 거지.
IEnumerator<T> 인터페이스
표준 Enumerator가 할 수 있는 일:
public interface IEnumerator<T> : IDisposable
{
T Current { get; } // 현재 요소
bool MoveNext(); // 다음 요소로 이동
void Reset(); // 처음으로 리셋 (거의 안 씀)
}
내부적으로 어떻게 동작할까?
- MoveNext() — "포인터"를 다음 요소로 옮기고, 요소가 있으면 true를 반환해. 더 이상 요소가 없으면 false를 반환.
- Current — 현재 요소(지금 순회자가 가리키는 것)를 반환.
- Reset() — Enumerator를 처음으로 리셋(거의 안 씀).
- Dispose() — 리소스 해제(파일이나 네트워크 등 외부 리소스를 쓰는 컬렉션에 필요).
예시: foreach가 "속에서" 어떻게 동작하는지
네가 이렇게 쓴다면:
var numbers = new List<int> { 1, 2, 3 };
foreach (var n in numbers)
Console.WriteLine(n);
사실 컴파일러는 이걸 대충 이렇게 바꿔:
var numbers = new List<int> { 1, 2, 3 };
// "순회자"를 얻어옴
var enumerator = numbers.GetEnumerator();
while (enumerator.MoveNext())
{
var n = enumerator.Current;
Console.WriteLine(n);
}
// enumerator가 IDisposable을 구현하면 컴파일러가 using 블록에서 Dispose()를 자동 호출해줌
중요: 컬렉션이 외부 리소스(예: 파일, DB 등)를 쓴다면, Enumerator가 순회 끝날 때 자동으로 해제해줄 수 있어.
시각적 도식
순회 시작 -> GetEnumerator() -> Enumerator
|
v
MoveNext() -> Current
|
v
MoveNext() -> Current
|
...
|
v
MoveNext() == false -> 순회 끝
6. IEnumerable과 배열, 리스트, 집합: 누가 누구 친척?
.NET의 표준 컨테이너 중 어떤 것들이 이 인터페이스를 구현하는지 보자:
| 컬렉션 타입 | IEnumerable<T> 구현? | foreach로 순회 가능? |
|---|---|---|
배열 () |
✅ | ✅ |
|
✅ | ✅ |
|
✅ (쌍, 키, 값 기준) | ✅ |
|
✅ | ✅ |
|
✅ | ✅ |
|
✅ | ✅ |
심지어 문자열(string)도 일반 IEnumerable을 구현해서, 문자열의 각 문자도 똑같이 순회할 수 있어.
7. 나만의 Enumerable 구현하기
재밌는 과제: 0부터 N까지 짝수만 저장하는 최소한의 컬렉션을 만들어서 IEnumerable<int>를 구현해보자. 그러면 foreach로 돌릴 수도 있고 LINQ도 쓸 수 있어.
// "순회" 가능한 컬렉션 클래스
class EvenNumbers : IEnumerable<int>
{
private int max;
public EvenNumbers(int max)
{
this.max = max;
}
public IEnumerator<int> GetEnumerator()
{
for (int i = 0; i <= max; i += 2)
yield return i; // enumerator 구현을 위한 특별한 마법
}
// 구버전 IEnumerable 인터페이스 명시적 구현 (호환성용)
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
}
// 사용 예시:
var evens = new EvenNumbers(10);
foreach(var e in evens)
Console.Write($"{e} "); // 0 2 4 6 8 10
중요 포인트: 네 클래스가 IEnumerable<T>를 구현하면, .NET의 대부분 도구들과 자동으로 호환돼: LINQ, foreach, enumerable을 받는 메서드 등등.
8. 흔한 실수와 주의점
초보 개발자들이 IEnumerable<T>가 뭔가 별도의 컬렉션이고, 요소가 있다고 착각하는 경우가 있어. 사실 이건 그냥 "약속"일 뿐이야. 순회를 시작하면 요소를 하나씩 "내놓겠다"는 거지.
인덱스로 임의 접근(myList[5])이나 Add, Remove 같은 메서드를 쓰고 싶으면 IEnumerable<T>로는 안 돼. 이건 오직 순차적 순회용이야!
실수: 순회 중에 컬렉션을 수정하려고 하는 것. 예를 들어:
foreach (var item in myList)
{
if (item < 0)
myList.Remove(item); // 위험! InvalidOperationException
}
더 안전한 방법은 삭제할 요소를 따로 리스트로 모으거나, 새로운 컬렉션을 만드는 거야:
// 안전한 방법 — 새 컬렉션을 수동으로 만듦
var newList = new List<int>();
foreach (var item in myList)
{
if (item >= 0)
newList.Add(item);
}
myList = newList;
// 또는 인덱스를 거꾸로 돌면서 삭제
for (int i = myList.Count - 1; i >= 0; i--)
{
if (myList[i] < 0)
myList.RemoveAt(i);
}
GO TO FULL VERSION