[PART9.컬렉션 기본 사용법(5/8)] IEnumerable<T> · ICollection<T> · IList<T> 개요 — 어디까지 보장되는가, 무엇을 매개변수로 받아야 하는가
인터페이스 계층 구조와 멤버 차이 / "받는 타입은 추상으로, 반환 타입은 구체적으로" / foreach 박싱 함정과 다중 열거의 정체
목차
1. 문제 제기 — 메서드 시그니처 하나로 GC가 갈린다
Unity 신입이 가장 자주 마주치는 컬렉션 관련 코드 리뷰 지적은 두 가지다.
// 케이스 A: 매개변수 타입을 List<T>로 못 박았다
public void DamageAll(List<Enemy> enemies) { ... }
// 케이스 B: 매개변수 타입을 IEnumerable<T>로 받아 매 프레임 foreach 한다
void Update()
{
foreach (var e in GetEnemies()) { ... } // GetEnemies() 반환은 IEnumerable<Enemy>
}
A는 List<Enemy>만 받을 수 있어서 Enemy[]나 LINQ 결과를 넘기는 호출자가 매번 .ToList()를 해야 한다 — 매번 힙(Heap, 동적 할당 메모리 영역) 할당이 추가로 발생한다. B는 반대로 너무 추상적인 타입으로 받아서 매 프레임 박싱이 발생한다.
핵심 질문 두 개
- 메서드가 컬렉션을 받을 때
List<T>·IList<T>·ICollection<T>·IEnumerable<T>중 무엇을 받아야 하는가? IEnumerable<T>로 받으면 아무 컬렉션이나 받을 수 있어 좋아 보이는데, 왜 Unity 핫패스에서는 위험한가?
이 두 질문의 답은 C# 컬렉션 인터페이스 계층 구조에 있다. 이 글은 IEnumerable<T> ⊂ ICollection<T> ⊂ IList<T>의 멤버 차이, 매개변수 선택 원칙, 그리고 IL 레벨에서 드러나는 박싱·다중 열거 함정을 다룬다.
앞선 게시글들에서 다룬List<T>·Dictionary<K,V>·HashSet<T>·Queue<T>·Stack<T>는 구체 타입(concrete type) 의 사용법이었다. 이번 글은 그들이 공통으로 구현하는 인터페이스 계약의 이야기다.
2. 개념 정의 — 권한이 점점 늘어나는 세 개의 레이어
2.1 비유: 도서관 카드의 등급
세 인터페이스를 도서관 회원 카드 등급에 비유하면 이해가 쉽다.
IEnumerable<T>(열람 카드) — 책을 한 권씩 처음부터 끝까지 훑어볼 수만 있다. 몇 권인지도, 특정 책을 바로 꺼낼 수도 없다.ICollection<T>(정회원 카드) — 책장에 몇 권이 있는지(Count) 알 수 있고, 책을 추가(Add)·반납(Remove)·검색(Contains)할 수 있다. 단, 몇 번째 칸에 있는지는 모른다.IList<T>(사서 권한 카드) — 책에 일련번호(인덱스)가 매겨져 있어 "3번 칸에 있는 책 줘"가 가능하다. 특정 위치에 끼워 넣기(Insert), 빼기(RemoveAt)도 된다.
상위 카드는 하위 카드의 모든 권한을 포함한다. IList<T> 카드를 가진 사람은 당연히 책을 훑어볼 수도(IEnumerable<T>) 있고 개수도 셀(ICollection<T>) 수 있다. 이것이 인터페이스 상속 관계다.
2.2 시각화: 계층 구조와 추가되는 멤버

2.3 가장 작은 코드로 확인
세 인터페이스가 어떤 멤버를 보장하는지 코드로 직접 확인한다.
using System.Collections.Generic;
class HierarchyDemo
{
public static void Main()
{
var list = new List<int> { 10, 20, 30 };
// IEnumerable<T>: 순회만 가능
IEnumerable<int> e = list;
foreach (var x in e) { /* OK */ }
// e.Count 는 컴파일 에러: IEnumerable<T>에는 Count가 없다
// e[0] 도 컴파일 에러: 인덱서가 없다
// ICollection<T>: 개수와 추가/삭제 가능
ICollection<int> c = list;
int n = c.Count; // OK
c.Add(40); // OK
c.Remove(10); // OK
bool has = c.Contains(20); // OK
// c[0] 는 컴파일 에러: 인덱서가 없다
// IList<T>: 인덱스 기반 접근 가능
IList<int> il = list;
int first = il[0]; // OK
il.Insert(0, 5); // OK
il.RemoveAt(0); // OK
int idx = il.IndexOf(20); // OK
}
}
이 코드는 세 인터페이스가 할 수 있는 일의 차이를 한눈에 보여준다. 같은 List<int> 객체지만 어느 인터페이스로 참조하느냐에 따라 호출 가능한 멤버가 달라진다. 이 차이가 곧 메서드 매개변수 타입을 고르는 기준이 된다.
3. 내부 동작 — foreach는 어떻게 작동하는가, 그리고 왜 박싱이 발생하는가
3.1 foreach의 내부 풀이
foreach는 마법이 아니다. 컴파일러가 다음 패턴으로 풀어쓰는 문법 설탕(syntactic sugar, 컴파일 시점에 더 단순한 코드로 변환되는 편의 문법)이다.
foreach— C#의 열거 문법 컬렉션의GetEnumerator()로 열거자(반복자)를 얻고,MoveNext()가false를 반환할 때까지Current를 꺼내 본문을 실행한다. 컬렉션이IEnumerable<T>를 구현하지 않더라도GetEnumerator()메서드만 있으면 동작하는 덕 타이핑(duck typing) 기반 패턴이다.
using System.Collections.Generic;
class ForeachDecomposed
{
public static int SumWithForeach(List<int> list)
{
int sum = 0;
foreach (var x in list)
sum += x;
return sum;
}
// 컴파일러가 위 foreach를 사실상 아래처럼 변환한다
public static int SumManually(List<int> list)
{
int sum = 0;
var enumerator = list.GetEnumerator(); // 열거자 획득
try
{
while (enumerator.MoveNext()) // 다음 항목으로 이동
sum += enumerator.Current; // 현재 값 사용
}
finally
{
enumerator.Dispose(); // 자원 해제 (using 패턴)
}
return sum;
}
}
이 두 메서드는 의미상 동일하다. foreach는 단지 코드를 짧게 쓰게 해주는 문법일 뿐이다. 핵심은 GetEnumerator()가 무엇을 반환하느냐이고, 그것이 박싱 발생 여부를 가른다.
3.2 시각화: List<T> 직접 vs IEnumerable<T> 캐스팅

같은 List<int> 객체를 같은 foreach로 돌리는데 매개변수 타입이 다르면 결과가 정반대다. 이 차이는 IL을 보면 명확해진다.
3.3 IL 분석: 결정적 증거
다음 두 메서드를 컴파일해서 IL을 비교한다.
using System.Collections.Generic;
class IlContrast
{
static int SumList(List<int> list)
{
int sum = 0;
foreach (var x in list) // List<T>로 직접 받음
sum += x;
return sum;
}
static int SumEnumerable(IEnumerable<int> source)
{
int sum = 0;
foreach (var x in source) // IEnumerable<T>로 받음
sum += x;
return sum;
}
}
SumList의 IL (핵심만 발췌, 한국어 주석은 추가)
.method private hidebysig static int32 SumList(class List`1<int32> list)
{
.locals init (
[0] int32,
[1] valuetype List`1/Enumerator<int32>, // 값 타입 Enumerator를 로컬에 저장
[2] int32
)
IL_0002: ldarg.0
IL_0003: callvirt instance valuetype List`1/Enumerator<!0>
List`1<int32>::GetEnumerator() // struct Enumerator 반환
IL_0008: stloc.1
.try
{
IL_000b: ldloca.s 1 // 로컬의 주소 로드 (박싱 없이 struct에 직접 접근)
IL_000d: call instance !0
List`1/Enumerator<int32>::get_Current()
...
IL_0017: ldloca.s 1
IL_0019: call instance bool
List`1/Enumerator<int32>::MoveNext() // call (가상 디스패치 없음)
...
}
finally
{
IL_0022: ldloca.s 1
IL_0024: constrained. valuetype List`1/Enumerator<int32> // struct 그대로 호출
IL_002a: callvirt instance void IDisposable::Dispose()
}
}
SumEnumerable의 IL
.method private hidebysig static int32 SumEnumerable(class IEnumerable`1<int32> source)
{
.locals init (
[0] int32,
[1] class IEnumerator`1<int32>, // 참조 타입 (인터페이스) 로컬
[2] int32
)
IL_0002: ldarg.0
IL_0003: callvirt instance class IEnumerator`1<!0>
IEnumerable`1<int32>::GetEnumerator() // 인터페이스 호출
IL_0008: stloc.1 // ← 여기서 List<T>.Enumerator(struct)가
// IEnumerator<int32>(class)로 박싱됨
.try
{
IL_000b: ldloc.1 // 참조를 로드 (스택에서 객체 참조 사용)
IL_000c: callvirt instance !0
IEnumerator`1<int32>::get_Current() // 가상 디스패치
...
IL_0016: ldloc.1
IL_0017: callvirt instance bool
IEnumerator::MoveNext() // 가상 디스패치
}
}
핵심 차이 정리
| 항목 | SumList (List<T> 직접) | SumEnumerable (IEnumerable<T>) |
|---|---|---|
| GetEnumerator 반환 타입 | valuetype Enumerator (struct) |
class IEnumerator<T> (interface) |
| 로컬 변수 | valuetype (스택) |
class 참조 (힙 박스) |
| MoveNext/Current 호출 | call + constrained. (직접) |
callvirt (가상 디스패치) |
| 힙 할당 | 0 byte | 박싱된 Enumerator 객체 1개 |
| Dispose | constrained. 통해 struct에서 직접 |
박싱된 객체에서 호출 |
List<T>.Enumerator는 struct라서 스택에 그대로 잡힌다. 그러나 IEnumerable<T>.GetEnumerator()의 반환 타입은 인터페이스 IEnumerator<T> 라서, struct를 그 자리에 끼워 넣으려면 박싱(boxing, 값 타입을 힙에 객체로 감싸 참조 타입처럼 다루는 변환) 이 일어난다. 매 foreach마다 작은 객체 하나가 힙에 새로 생긴다.
박싱(Boxing)이란 CLR(Common Language Runtime, .NET 코드를 실제로 실행하는 가상 머신)이 값 타입(int, struct 등)을 object 또는 인터페이스 참조로 다룰 때, 그 값을 힙에 새 객체로 복사해 감싸는 동작이다. 작은 객체라도 매 프레임 발생하면 GC(Garbage Collector, 힙 메모리를 자동 회수하는 런타임 구성요소)에 부담이 쌓인다.
이 한 줄짜리 IL 차이가 Unity 핫패스에서 GC 스파이크의 원인이 된다.
4. 실전 적용 — "받는 타입은 추상으로, 반환 타입은 구체적으로"
4.1 매개변수 타입 선택의 단순한 기준
판단은 사실 단순하다. 메서드 본문이 그 컬렉션으로 무엇을 하는가만 보면 된다.

이 결정 트리 외에 한 가지 보조 원칙이 있다.
API 설계 원칙: "Be liberal in what you accept, be conservative in what you produce." 매개변수는 가능한 한 추상적인 타입(IEnumerable<T>)으로 받아 호출자에게 유연성을 주고, 반환 타입은 구체적인 타입(List<T>·T[])으로 돌려주어 호출자가 추가 형변환·.ToList()없이 바로 사용할 수 있게 한다.
4.2 Before/After: PrintNames
Before — 매개변수가 너무 구체적
using System.Collections.Generic;
class BadApi
{
// List<string>만 받을 수 있다
public static void PrintNames(List<string> names)
{
foreach (var n in names)
System.Console.WriteLine(n);
}
}
// 호출자: 배열을 가지고 있다면 매번 ToList() 필요
class Caller
{
static void Demo()
{
string[] arr = { "Alice", "Bob" };
BadApi.PrintNames(arr.ToList()); // ❌ 새 List 할당
}
}
After — 매개변수는 추상적으로
using System.Collections.Generic;
class GoodApi
{
// 어떤 컬렉션이든 받을 수 있다
public static void PrintNames(IEnumerable<string> names)
{
foreach (var n in names)
System.Console.WriteLine(n);
}
}
class Caller
{
static void Demo()
{
string[] arr = { "Alice", "Bob" };
GoodApi.PrintNames(arr); // ✓ 그대로 전달
GoodApi.PrintNames(new List<string>()); // ✓
GoodApi.PrintNames(new HashSet<string>()); // ✓
}
}
IL로 본 차이: BadApi.PrintNames의 매개변수 타입은 class List1<string> 으로 못 박혀 있어서, 호출자가 string[]을 가지고 있다면 ToList() 호출(newobj + 복사 루프)이 추가로 들어간다. GoodApi.PrintNames는 매개변수가 class IEnumerable1<string> 이라 호출 시점에 별다른 변환 없이 그대로 전달된다.
4.3 핫패스에서는 거꾸로: Unity 매 프레임 코드
위의 "추상으로 받기" 원칙은 일반 비즈니스 로직에서는 옳다. 하지만 Unity의 매 프레임 호출되는 핫패스(Update·FixedUpdate)에서는 박싱 비용이 더 크다.
Before — IEnumerable<T>로 받아 매 프레임 박싱
using System.Collections.Generic;
using UnityEngine;
public class EnemyManager : MonoBehaviour
{
private List<Enemy> enemies = new();
void Update()
{
DamageAll(enemies, 1f); // List를 IEnumerable로 받음 → 박싱
}
// ❌ 핫패스에서는 List<T>로 받았어야 했다
private void DamageAll(IEnumerable<Enemy> targets, float damage)
{
foreach (var e in targets) // List<T>.Enumerator → IEnumerator<Enemy> 박싱
e.TakeDamage(damage);
}
}
public class Enemy : MonoBehaviour { public void TakeDamage(float d) { } }
매 프레임 IEnumerable<Enemy>.GetEnumerator() 호출 시 List<Enemy>.Enumerator struct가 IEnumerator<Enemy>로 박싱된다. Unity의 Boehm GC(Boehm-Demers-Weiser GC, Mono 런타임이 사용하는 비-제너레이셔널 보수적 GC) 환경에서 이런 작은 할당이 누적되면 GC 스파이크를 유발한다.
After — 핫패스에서는 구체 타입으로 받기
using System.Collections.Generic;
using UnityEngine;
public class EnemyManager : MonoBehaviour
{
private List<Enemy> enemies = new();
void Update()
{
DamageAll(enemies, 1f);
}
// ✓ List<T>로 받으면 List<T>.Enumerator(struct)가 그대로 사용됨
private void DamageAll(List<Enemy> targets, float damage)
{
foreach (var e in targets) // 박싱 없음
e.TakeDamage(damage);
}
}
IL 증거: 앞 3.3절에서 본 그대로다. 매개변수가 class List1<Enemy>이면 GetEnumerator는 valuetype Enumerator를 반환하고 ldloca.s + constrained. 로 박싱 없이 동작한다. class IEnumerable1<Enemy>이면 class IEnumerator1` 참조에 담기면서 박싱이 일어난다.
원칙 보정 "추상으로 받기"는 일반 코드의 기본값이다. 단, Unity의 핫패스에서는 박싱·가상 디스패치 비용이 우선이므로 구체 타입(List<T>·T[])을 받는 것이 옳다. 한 프레임에 한 번만 호출되는 비-핫패스 메서드는 원칙을 따라가도 된다.
4.4 Unity API의 GetComponentsInChildren — 왜 List<T>를 받는가
Unity 엔진이 제공하는 두 가지 오버로드를 비교한다.
using System.Collections.Generic;
using UnityEngine;
public class ComponentScan : MonoBehaviour
{
// 캐시: 한 번만 할당하고 재사용
private readonly List<Collider> cache = new();
void Update()
{
// ❌ 매 프레임 새 배열 할당 — GC 압박
Collider[] alloc = GetComponentsInChildren<Collider>();
// ✓ 미리 만든 List<T>를 재사용 — 할당 0
cache.Clear(); // 내부적으로도 동일하게 호출됨
GetComponentsInChildren(cache); // 결과를 cache에 채움
}
}
GetComponentsInChildren<T>() (반환형) 오버로드는 호출마다 새 T[]를 힙에 할당한다. 반면 GetComponentsInChildren<T>(List<T> results) 오버로드는 호출자가 미리 준비한 List<T>를 비우고 결과로 채워주므로 할당이 0이다. 매개변수 타입이 List<T>인 이유는 (1) Clear()·Add()가 필요하고 (2) 박싱 없이 인덱스로 채우려면 구체 타입이 효율적이기 때문이다.
이는 앞서 본 박싱 함정과 같은 원리의 다른 표현이다. 핫패스에서는 인터페이스 추상화보다 할당 회피가 우선이다.
4.5 반환 타입은 구체적으로
매개변수 원칙의 거울 짝이다. 메서드 내부에서 List<T>를 만들어 반환할 때, 반환 타입까지 IEnumerable<T>로 좁히면 호출자가 Count도 인덱스 접근도 못 한다.
using System.Collections.Generic;
class ReturnTypeDemo
{
// ❌ 호출자가 .Count를 보려면 또 .ToList()가 필요할 수도
static IEnumerable<int> GetEvenNumbersWeak(int n)
{
var result = new List<int>();
for (int i = 0; i < n; i++)
if (i % 2 == 0) result.Add(i);
return result;
}
// ✓ 호출자가 바로 Count·인덱스 접근 가능
static List<int> GetEvenNumbersStrong(int n)
{
var result = new List<int>();
for (int i = 0; i < n; i++)
if (i % 2 == 0) result.Add(i);
return result;
}
}
호출자 입장에서 GetEvenNumbersWeak(10).Count는 LINQ의 Count() 확장 메서드로 풀려 전체를 다시 순회한다. 이미 메모리에 있는 리스트의 개수를 다시 세는 셈이다. List<T>로 반환하면 List<T>.Count가 O(1)로 즉시 반환된다.
예외 — yield return / 진정한 지연 시퀀스yield return으로 만든 시퀀스나 LINQ 체인 결과는 본질적으로 지연 실행이다. 이때는 의도가 "느슨한 시퀀스"이므로IEnumerable<T>반환이 자연스럽다. 단, 호출자가 여러 번 순회하면 [5.2의 다중 열거 함정](#5-함정과-주의사항)을 만난다.
4.6 IReadOnlyCollection<T> · IReadOnlyList<T> — 변경 의도 차단
내부 컬렉션을 외부에 노출하면서 수정은 막고 싶다면 IReadOnlyList<T> 또는 IReadOnlyCollection<T>로 반환한다. .NET 4.5에서 도입된 인터페이스 쌍이다.
using System.Collections.Generic;
public class Inventory
{
private readonly List<string> _items = new() { "Sword", "Shield" };
// ✓ 외부는 읽기만 가능 — Add/Remove를 호출할 수 없다
public IReadOnlyList<string> Items => _items;
public void AddItem(string item) => _items.Add(item);
public void RemoveItem(string item) => _items.Remove(item);
}
class Caller
{
static void Demo()
{
var inv = new Inventory();
var items = inv.Items;
// items.Add("Hack"); // 컴파일 에러: IReadOnlyList<T>에는 Add가 없다
foreach (var x in items) { /* 읽기만 OK */ }
}
}
이 패턴은 캡슐화(encapsulation, 외부에서 내부 구현을 직접 만지지 못하게 막는 객체지향 원칙)를 컴파일러가 검사하게 만든다. 단, 원본 컬렉션 자체가 불변이 되는 것은 아니다 — Inventory 내부의 _items.Add()는 여전히 가능하므로, 외부에서 inv.Items를 두 번 읽는 사이에도 내용은 바뀔 수 있다. "수정 메서드를 호출하지 못하게 한다"는 컴파일러 차원의 보호일 뿐이다.
5. 함정과 주의사항
5.1 다중 열거(Multiple Enumeration) 함정
IEnumerable<T>의 가장 큰 함정이다. LINQ의 지연 실행과 결합되면 같은 쿼리가 의도치 않게 여러 번 실행된다.
using System.Collections.Generic;
using System.Linq;
class MultiEnumDemo
{
// ❌ 같은 IEnumerable을 두 번 순회 → 쿼리 두 번 실행
static void PrintReport(IEnumerable<int> source)
{
if (source.Any()) // 1차 열거
{
int count = source.Count(); // 2차 열거 — 전체 재순회
int max = source.Max(); // 3차 열거 — 전체 재순회
System.Console.WriteLine($"Count={count}, Max={max}");
foreach (var x in source) // 4차 열거
System.Console.WriteLine(x);
}
}
// ✓ 한 번만 실행 → 결과를 List에 캐싱
static void PrintReportFixed(IEnumerable<int> source)
{
var data = source.ToList(); // 단 한 번 열거
if (data.Count > 0)
{
System.Console.WriteLine($"Count={data.Count}, Max={data.Max()}");
foreach (var x in data)
System.Console.WriteLine(x);
}
}
}
원본이 단순한 List<int>라면 4번 순회해도 메모리 접근일 뿐 큰 문제가 없다. 그러나 원본이 다음과 같으면 즉시 큰 비용으로 돌아온다.
- LINQ 체인 결과 —
Where·Select람다가 매 열거마다 모든 항목에 다시 실행된다. - DB 쿼리 (EF Core 등) — 같은 SQL이 매 열거마다 새로 실행된다.
yield return메서드 — 메서드 본문이 매번 처음부터 다시 실행된다.- 파일 스트림 — 두 번째 순회에서 빈 결과만 나오거나 예외가 발생한다.
IL로 본 다중 열거
static void MultiEnumerate(IEnumerable<int> source)
{
int count = source.Count();
int max = source.Max();
System.Console.WriteLine($"{count} {max}");
}
.method private hidebysig static void MultiEnumerate(class IEnumerable`1<int32> source)
{
IL_0000: ldarg.0
IL_0001: call int32 [System.Linq]Enumerable::Count<int32>(class IEnumerable`1<!!0>)
// ↑ Enumerable.Count: 내부에서 GetEnumerator() 호출, 모든 항목 순회
IL_0006: stloc.0
IL_0007: ldarg.0
IL_0008: call int32 [System.Linq]Enumerable::Max(class IEnumerable`1<int32>)
// ↑ Enumerable.Max: 또다시 GetEnumerator() 호출, 또 모든 항목 순회
IL_000d: stloc.1
...
}
call ... Enumerable::Count와 call ... Enumerable::Max가 각각 source를 받아 내부에서 GetEnumerator() → MoveNext() 루프를 돈다. 같은 시퀀스를 두 번 펼친다. 디버거에서 source.Count()에 람다 함수를 연결한 LINQ 결과를 넘기면 람다가 두 번 호출되는 것이 명확히 보인다.
해결 원칙
- 메서드 본문에서 같은
IEnumerable<T>를 두 번 이상 사용해야 하면, 메서드 진입 직후var data = source.ToList();로 즉시 실행 후data를 사용한다.- 메서드가 매개변수를 한 번만 순회한다는 것이 명확하면
ToList()불필요 — 추가 할당이 오히려 손해다.
5.2 ICollection<T>로 받는데 IsReadOnly를 무시
ICollection<T>는 Add·Remove를 보장하지만 동시에 IsReadOnly 속성도 가진다. 읽기 전용 구현체에서 Add를 호출하면 NotSupportedException이 던져진다.
using System.Collections.Generic;
class ReadOnlyTrap
{
// ❌ IsReadOnly 검사 없이 Add 시도 → 런타임 예외 가능
static void AddSentinel(ICollection<int> col)
{
col.Add(-1); // 만약 col이 배열이라면 NotSupportedException
}
static void Demo()
{
int[] arr = { 1, 2, 3 };
AddSentinel(arr); // 배열은 ICollection<int>를 구현하지만 IsReadOnly == true
}
}
배열(T[])은 IList<T>와 ICollection<T>를 구현하지만 길이를 바꾸는 연산은 모두 NotSupportedException을 던진다. IsReadOnly가 true이기 때문이다.
// ✓ 변경이 필요한 메서드는 IList<T>도 ICollection<T>도 아닌, 변경 가능 여부가 명확한 타입을 받는다
static void AddSentinel(List<int> col) { col.Add(-1); }
매개변수가 변경 가능해야 한다면 List<T>처럼 변경 가능성이 보장된 구체 타입을 받거나, 들어온 후 IsReadOnly를 명시적으로 검사한다.
5.3 IList<T> vs IReadOnlyList<T> — 상속 관계가 없다
직관과 다르게 IList<T>는 IReadOnlyList<T>를 상속하지 않는다. .NET 설계 시점에 IList<T>가 먼저 만들어졌고, 후에 IReadOnlyList<T>가 추가될 때 호환성을 위해 별도 계층으로 분리되었기 때문이다.
using System.Collections.Generic;
class HierarchyPitfall
{
static void Demo()
{
IList<int> list = new List<int> { 1, 2, 3 };
// IReadOnlyList<int> ro = list; // 컴파일 에러: 명시적 변환 없음
// List<T>는 둘 다 직접 구현하므로 List<T> 변수는 양쪽으로 캐스팅 가능
List<int> concrete = new() { 1, 2, 3 };
IList<int> il = concrete; // OK
IReadOnlyList<int> rol = concrete; // OK
}
}
매개변수 시그니처를 짤 때, "읽기 전용 인터페이스로 받고 싶다"는 이유로 무작정 IReadOnlyList<T>로 바꾸면 호출자가 IList<T>만 갖고 있을 때 컴파일이 깨진다. 호출자가 어떤 타입을 가지고 있을지를 먼저 생각한다.
5.4 인터페이스 인덱서로 매 프레임 접근 — 가상 디스패치 비용
IList<T>.this[int]는 인터페이스 멤버라 callvirt로 호출된다. JIT(Just-In-Time, 실행 직전에 IL을 기계어로 변환하는 컴파일 단계)이 일부 인라인을 시도하지만, 매 프레임 수천 번 호출되는 루프라면 구체 타입이 더 빠르다.
using System.Collections.Generic;
class HotIndexer
{
// ❌ 핫패스에서 인터페이스 인덱서 — callvirt 비용
static int SumViaInterface(IList<int> list)
{
int sum = 0;
for (int i = 0; i < list.Count; i++) // ICollection<T>::get_Count callvirt
sum += list[i]; // IList<T>::get_Item callvirt
return sum;
}
// ✓ 구체 타입 — call (직접 호출), JIT 인라인 가능성 ↑
static int SumViaList(List<int> list)
{
int sum = 0;
for (int i = 0; i < list.Count; i++)
sum += list[i];
return sum;
}
}
IL 차이
// SumViaInterface
IL_0009: callvirt instance !0 IList`1<int32>::get_Item(int32) // 가상 디스패치
IL_0016: callvirt instance int32 ICollection`1<int32>::get_Count() // ICollection 멤버
// SumViaList
IL_0009: callvirt instance !0 List`1<int32>::get_Item(int32) // 같은 callvirt지만,
// List<T> 구체 타입이라 JIT이 단형성을 활용하기 쉬움
SumViaInterface의 Count는 ICollection<T>::get_Count()로 라우팅된다. IList<T>가 ICollection<T>를 상속하기 때문이다. 두 번 모두 인터페이스 디스패치라 메서드 테이블 조회가 끼어든다. 단순 List<T>로 받으면 JIT이 호출 사이트를 단형(monomorphic)으로 인식해 최적화하기 더 쉽다.
핫패스에서 차이가 측정 가능할 정도는 아닐 수 있지만, "받는 타입을 추상으로"라는 일반 원칙을 핫패스에 적용하면 박싱 + 가상 디스패치가 함께 따라온다는 것을 기억한다.
6. C# 버전별 변화 — 인터페이스 자체보다 주변 기능
IEnumerable<T> 인터페이스 자체는 .NET Framework 2.0(C# 2.0)에 제네릭과 함께 도입된 이후 시그니처가 거의 변하지 않았다. 그러나 그 위에서 동작하는 언어 기능과 형제 인터페이스가 꾸준히 추가되었다.
6.1 C# 2.0 — yield return으로 반복자 직접 만들기
using System.Collections.Generic;
class IteratorDemo
{
// C# 2.0 이전: IEnumerator<T>를 직접 클래스로 구현해야 했음
// C# 2.0+: yield return으로 한 줄
static IEnumerable<int> Range(int start, int count)
{
for (int i = 0; i < count; i++)
yield return start + i; // 컴파일러가 상태 머신 클래스 자동 생성
}
}
yield return— 반복자 메서드 컴파일러가 메서드를 IEnumerable<T>를 구현하는 자동 생성 클래스로 변환한다. 호출 시점에는 아무 일도 일어나지 않고, 호출자가MoveNext()를 호출할 때마다 다음yield return까지 본문이 실행된다. 지연 실행의 핵심 빌딩 블록이다.
yield return은 IEnumerable<T>를 직접 구현하는 부담을 없앴다. 동시에 다중 열거 함정의 빈도도 늘렸다 — yield 메서드를 두 번 순회하면 본문이 두 번 실행된다.
6.2 C# 3.0 — LINQ와 확장 메서드
LINQ는 IEnumerable<T>를 받는 확장 메서드의 거대한 집합이다.
using System.Collections.Generic;
using System.Linq;
class LinqOnEnumerable
{
static void Demo()
{
var nums = new List<int> { 1, 2, 3, 4, 5 };
// Where·Select·OrderBy 모두 IEnumerable<T> 확장 메서드
var query = nums.Where(n => n % 2 == 0).Select(n => n * 10);
// 이 시점에는 아무것도 실행되지 않음 (지연 실행)
foreach (var x in query) System.Console.WriteLine(x);
}
}
LINQ의 설계 원칙: 입력은 모두 IEnumerable<T>로 받는다 — "추상으로 받기"의 모범 사례다. 결과 또한 IEnumerable<T>라 체이닝이 가능하다. 호출자가 즉시 결과가 필요하면 .ToList() / .ToArray()로 구체화한다.
6.3 .NET Standard 2.1 / C# 8.0 — IAsyncEnumerable<T>
비동기 스트림을 foreach처럼 다루기 위한 인터페이스다.
using System.Collections.Generic;
using System.Threading.Tasks;
class AsyncStreamDemo
{
static async IAsyncEnumerable<int> ReadChunksAsync()
{
for (int i = 0; i < 5; i++)
{
await Task.Delay(100); // 네트워크/DB 청크 대기 시뮬레이션
yield return i;
}
}
static async Task ConsumeAsync()
{
await foreach (var chunk in ReadChunksAsync()) // C# 8.0+ await foreach
System.Console.WriteLine(chunk);
}
}
await foreach— 비동기 열거 문법IAsyncEnumerable<T>.GetAsyncEnumerator()를 호출해 비동기 열거자를 얻고, 매 항목마다MoveNextAsync()를await한다. DB 결과를 청크 단위로 받거나 SignalR 스트림을 처리할 때 동기 코드처럼 깔끔하게 작성할 수 있다.
IEnumerable<T> 계층의 비동기 형제다. Unity의 Awaitable(2023.1+) 또는 UniTask와 함께 쓰면 비동기 흐름에서도 컬렉션 인터페이스 추상화의 이점을 유지할 수 있다.
6.4 .NET 4.5 (C# 5.0) — IReadOnlyList<T> · IReadOnlyCollection<T> 도입
앞 4.6절에서 다룬 인터페이스 쌍이다. C# 4.5 이전에는 컬렉션을 외부 노출할 때 ReadOnlyCollection<T> 클래스(System.Collections.ObjectModel)로 감싸거나 직접 복사본을 만드는 수밖에 없었다.
using System.Collections.Generic;
using System.Collections.ObjectModel;
class ReadOnlyEvolution
{
private readonly List<int> _data = new() { 1, 2, 3 };
// .NET 4.5 이전: ReadOnlyCollection<T> 래퍼 클래스
public ReadOnlyCollection<int> DataOld => _data.AsReadOnly();
// .NET 4.5+: 인터페이스로 노출
public IReadOnlyList<int> DataNew => _data;
}
후자가 더 가볍고(래퍼 객체 할당 없음) 일반화된 표현이다.
7. 정리 — 매개변수 타입 결정 체크리스트
빠른 판단표
| 메서드가 하는 일 | 매개변수 타입 |
|---|---|
| 한 번만 훑어본다 (비-핫패스) | IEnumerable<T> |
| 한 번만 훑어본다 (Unity 핫패스) | List<T> 또는 구체 타입 |
| Count + Add/Remove 필요 | ICollection<T> |
| 인덱스로 접근 | IList<T> (변경 함) / IReadOnlyList<T> (변경 안 함) |
| 결과를 채워준다 (Unity 풀링 패턴) | List<T> |
| 두 번 이상 순회한다 | 진입 즉시 .ToList() 또는 List<T>로 받기 |
반환 타입은 거꾸로
- 일반적으로 구체적인 타입(
List<T>·T[]·IReadOnlyList<T>) 으로 반환한다. yield return/ LINQ 체인이라 본질적으로 지연 시퀀스라면IEnumerable<T>반환이 자연스럽다.
핵심 체크리스트
- [ ] 추상 vs 구체 — 비-핫패스는 추상으로 받고, 핫패스는 구체로 받는가?
- [ ] 박싱 회피 — Unity
Update/FixedUpdate에서IEnumerable<T>매개변수로List<T>를 받지 않는가? - [ ] 다중 열거 — 메서드 본문에서 같은
IEnumerable<T>를 두 번 이상 순회하지 않는가? 그래야 한다면ToList()로 캐싱했는가? - [ ] 변경 차단 — 외부에 컬렉션을 노출할 때
IReadOnlyList<T>/IReadOnlyCollection<T>를 사용했는가? - [ ] IsReadOnly —
ICollection<T>매개변수에서Add/Remove를 호출하기 전에IsReadOnly가능성을 고려했는가? - [ ] 반환 타입 — 호출자가
Count나 인덱스 접근이 필요할 가능성이 있다면IEnumerable<T>대신List<T>/IReadOnlyList<T>를 반환했는가?
한 줄 요약
IEnumerable<T>⊂ICollection<T>⊂IList<T>는 권한이 점점 늘어나는 계약이다. 매개변수는 메서드가 실제로 필요로 하는 만큼만 받고, 반환은 호출자가 활용할 수 있는 만큼 풀어준다. 단, Unity 핫패스에서는 박싱·가상 디스패치 비용이 우선이므로 구체 타입을 직접 받는다.
