EveryDay.DevUp

[PART4.인터페이스(2/3)] IEnumerable vs ICollection vs IList — 계층의 의미 본문

C# 심화

[PART4.인터페이스(2/3)] IEnumerable vs ICollection vs IList — 계층의 의미

EveryDay.DevUp 2026. 4. 5. 17:32

IEnumerable vs ICollection vs IList — 계층의 의미

C#의 컬렉션 인터페이스는 왜 세 단계로 나뉘어 있을까? 각 계층이 약속하는 계약(Contract)을 이해하면, 매개변수와 반환 타입을 올바르게 선택할 수 있다.


1. 문제 제기

Unity 프로젝트에서 적 목록을 관리하는 메서드를 만든다고 하자.

C#
public void ProcessEnemies(List<Enemy> enemies)
{
    foreach (var enemy in enemies)
    {
        enemy.TakeDamage(10);
    }
}

이 메서드는 List<Enemy>만 받을 수 있다. 배열(Enemy[])이나 LINQ 결과(IEnumerable<Enemy>)는 전달할 수 없다. 매개변수 타입을 IEnumerable<Enemy>로 바꾸면 모든 컬렉션을 받을 수 있는데, 왜 굳이 List<T>로 제한했을까?

반대로, 내부에서 관리하는 적 리스트를 외부에 반환할 때 IList<Enemy>를 쓰면 어떤 일이 생길까?

C#
public class EnemyManager
{
    private List<Enemy> _enemies = new List<Enemy>();
    
    public IList<Enemy> GetEnemies() => _enemies;
}

// 외부 코드 — 내부 상태를 마음대로 조작
manager.GetEnemies().Clear(); // 적이 전부 사라진다!

이 두 가지 문제의 답은 컬렉션 인터페이스의 계층 구조에 있다. IEnumerable<T>, ICollection<T>, IList<T>가 각각 어떤 계약을 맺는지 알면, 매개변수는 가능한 한 약하게, 반환 타입은 필요한 만큼만 노출하는 원칙을 지킬 수 있다.


2. 개념 정의

세 인터페이스의 계층 관계

식당의 주방을 비유로 생각해 보자.

  • IEnumerable<T> — 컨베이어 벨트. 음식이 하나씩 나오고, 다 나올 때까지 기다리기만 하면 된다. 몇 개인지 미리 알 수 없고, 특정 위치의 음식을 골라 집을 수도 없다.
  • ICollection<T> — 주방 선반. 음식 총 몇 개인지 즉시 알 수 있고, 새 음식을 올리거나 뺄 수 있다. 하지만 "3번째 칸에 있는 것"을 바로 집을 수는 없다.
  • IList<T> — 번호표가 붙은 선반. 몇 번째 칸에 뭐가 있는지 바로 알 수 있고, 특정 번호에 끼워 넣거나 빼는 것도 가능하다.
IEnumerable<T>

각 계층이 요구하는 멤버

인터페이스 추가되는 멤버 한 줄 요약
IEnumerable<T> GetEnumerator() "나를 순회할 수 있다"
ICollection<T> Count, Add, Remove, Clear, Contains, CopyTo, IsReadOnly "크기를 알고, 항목을 추가/삭제할 수 있다"
IList<T> this[int], IndexOf, Insert, RemoveAt "인덱스로 임의 접근할 수 있다"

아래로 내려갈수록 기능이 많아지지만, 그만큼 구현체가 지켜야 할 약속도 많아진다. IEnumerable<T>를 구현하려면 GetEnumerator() 하나만 만들면 되지만, IList<T>를 구현하려면 인덱서, 삽입, 삭제까지 모두 구현해야 한다.


3. 내부 동작

IEnumerable<T>과 이터레이터 상태 머신

yield return — 이터레이터 생성 키워드 메서드 실행을 일시 중단하고 값을 하나 반환한다. 다음 호출 시 중단된 지점부터 실행을 재개한다. 컴파일러가 자동으로 상태 머신 클래스를 생성한다.
예시: yield return 1; 값 1을 반환하고 메서드 실행을 일시 정지

IEnumerable<T>의 핵심은 지연 실행(Deferred Execution)이다. yield return을 사용하면 컴파일러가 상태 머신 클래스를 자동 생성하여, MoveNext() 호출마다 다음 yield return 지점까지만 실행한다.

C#
using System;
using System.Collections.Generic;

public class YieldExample
{
    public static IEnumerable<int> GetNumbers()
    {
        yield return 1;
        yield return 2;
        yield return 3;
    }
}

public class Program
{
    public static void Main()
    {
        foreach (var n in YieldExample.GetNumbers())
        {
            Console.WriteLine(n);
        }
    }
}
IL
// YieldExample.GetNumbers() — 메서드 본체
.method public hidebysig static 
    class [System.Runtime]System.Collections.Generic.IEnumerable`1<int32> GetNumbers () cil managed 
{
    IL_0000: ldc.i4.s -2                    // 초기 상태값 -2
    IL_0002: newobj instance void YieldExample/'<GetNumbers>d__0'::.ctor(int32)  // 상태 머신 객체 생성
    IL_0007: ret                             // 상태 머신을 반환 — 아직 아무것도 실행하지 않음
}

// 컴파일러가 생성한 상태 머신의 MoveNext()
.method private final hidebysig newslot virtual 
    instance bool MoveNext () cil managed 
{
    .locals init ([0] int32)

    IL_0000: ldarg.0
    IL_0001: ldfld int32 '<>1__state'        // 현재 상태 읽기
    IL_0006: stloc.0
    IL_0007: ldloc.0
    IL_0008: switch (IL_001f, IL_0021, IL_0023, IL_0025)  // 상태별 분기

    IL_0029: ldarg.0
    IL_002a: ldc.i4.m1
    IL_002b: stfld int32 '<>1__state'        // 상태를 -1(실행 중)로 전환
    IL_0031: ldarg.0
    IL_0032: ldc.i4.1
    IL_0033: stfld int32 '<>2__current'      // Current = 1 (첫 번째 yield return)
    IL_0038: ldarg.0
    IL_0039: ldc.i4.1
    IL_003a: stfld int32 '<>1__state'        // 상태를 1로 전환 (다음 MoveNext 시 두 번째 분기로)
    IL_003f: ldc.i4.1
    IL_0040: ret                              // true 반환 — 아직 요소가 있음
}

핵심을 정리하면:

  1. GetNumbers()를 호출하면 실제 코드를 실행하지 않고, <GetNumbers>d__0라는 상태 머신 클래스의 인스턴스를 힙에 생성(newobj)하여 반환한다.
  2. foreachMoveNext()를 호출할 때마다 <>1__state 필드를 읽고, switch문으로 이전 yield return 이후 지점으로 점프한다.
  3. <>2__current 필드에 현재 값을 저장하고 true를 반환한다. 더 이상 값이 없으면 false를 반환한다.

이 구조 덕분에 데이터를 미리 메모리에 전부 올리지 않고, 필요할 때 하나씩 생성할 수 있다.

ICollection<T>.Count vs LINQ Count() — 타입 스니핑

LINQ의 Count() 확장 메서드는 내부적으로 타입 스니핑(Type Sniffing)을 수행한다. 전달받은 IEnumerable<T>가 실제로 ICollection<T>를 구현하고 있는지 런타임에 확인하여, 구현하고 있다면 O(1)인 Count 프로퍼티를 바로 사용한다.

C#
using System;
using System.Collections.Generic;
using System.Linq;

public class LinqTypeSniffing
{
    // LINQ Count() 확장 메서드 사용
    public static int CountViaEnumerable(IEnumerable<int> items)
    {
        return items.Count();
    }

    // ICollection<T>.Count 프로퍼티 직접 사용
    public static int CountViaCollection(ICollection<int> items)
    {
        return items.Count;
    }
}
IL
// CountViaEnumerable — LINQ Count() 확장 메서드 호출
.method public hidebysig static 
    int32 CountViaEnumerable (
        class [System.Runtime]System.Collections.Generic.IEnumerable`1<int32> items
    ) cil managed 
{
    IL_0001: ldarg.0
    IL_0002: call int32 [System.Linq]System.Linq.Enumerable::Count<int32>(...)  // 정적 메서드 호출
    IL_000b: ret
}

// CountViaCollection — ICollection<T>.Count 프로퍼티 직접 호출
.method public hidebysig static 
    int32 CountViaCollection (
        class [System.Runtime]System.Collections.Generic.ICollection`1<int32> items
    ) cil managed 
{
    IL_0001: ldarg.0
    IL_0002: callvirt instance int32 class ICollection`1<int32>::get_Count()  // 인터페이스 가상 호출
    IL_000b: ret
}

CountViaEnumerableEnumerable.Count<int32>() 정적 메서드를 호출한다. 이 메서드 내부에서 ICollection<T>로의 캐스팅 시도가 일어난다. 반면 CountViaCollectionICollection<T>.get_Count()를 직접 callvirt로 호출하여 즉시 O(1)로 결과를 얻는다.

ElementAt()도 같은 원리다. 내부적으로 IList<T>인지 확인한 뒤, 맞으면 인덱서로 O(1) 접근하고, 아니면 처음부터 순회한다.

결론: 매개변수 타입이 ICollection<T>IList<T>이면, LINQ가 내부에서 하는 타입 스니핑 비용 없이 바로 최적 경로를 탈 수 있다.

foreach의 두 얼굴 — 인터페이스 디스패치 vs 구조체 열거자

같은 foreach라도 변수의 선언 타입에 따라 IL이 완전히 달라진다.

C#
using System;
using System.Collections.Generic;

public class EnumerableForEach
{
    // IEnumerable<T>로 받으면 — 인터페이스 가상 디스패치
    public static int SumViaEnumerable(IEnumerable<int> items)
    {
        int sum = 0;
        foreach (var item in items) { sum += item; }
        return sum;
    }
}

public class ListForEach
{
    // List<T>로 받으면 — 구조체 열거자 직접 호출
    public static int SumViaList(List<int> items)
    {
        int sum = 0;
        foreach (var item in items) { sum += item; }
        return sum;
    }
}
IL
// SumViaEnumerable — IEnumerable<int>로 순회
.method public hidebysig static 
    int32 SumViaEnumerable (
        class IEnumerable`1<int32> items
    ) cil managed 
{
    .locals init (
        [0] int32,
        [1] class IEnumerator`1<int32>,   // 참조 타입(인터페이스)으로 저장
        [2] int32, [3] int32
    )
    IL_0004: ldarg.0
    IL_0005: callvirt instance class IEnumerator`1<!0> IEnumerable`1<int32>::GetEnumerator()  // 가상 호출
    IL_000a: stloc.1
    // 루프 내부
    IL_000d: ldloc.1
    IL_000e: callvirt instance !0 IEnumerator`1<int32>::get_Current()  // 매번 가상 디스패치
    IL_001a: ldloc.1
    IL_001b: callvirt instance bool IEnumerator::MoveNext()            // 매번 가상 디스패치
}

// SumViaList — List<int>로 순회
.method public hidebysig static 
    int32 SumViaList (
        class List`1<int32> items
    ) cil managed 
{
    .locals init (
        [0] int32,
        [1] valuetype List`1/Enumerator<int32>,  // 값 타입(구조체)으로 저장 — 힙 할당 없음
        [2] int32, [3] int32
    )
    IL_0004: ldarg.0
    IL_0005: callvirt instance valuetype List`1/Enumerator<!0> List`1<int32>::GetEnumerator()
    IL_000a: stloc.1
    // 루프 내부
    IL_000d: ldloca.s 1                                              // 주소를 로드 (값 타입이므로)
    IL_000f: call instance !0 List`1/Enumerator<int32>::get_Current()  // 직접 호출 (call)
    IL_001b: ldloca.s 1
    IL_001d: call instance bool List`1/Enumerator<int32>::MoveNext()   // 직접 호출 (call)
}

차이가 극명하다:

  IEnumerable<T> List<T>
열거자 타입 class IEnumerator(참조 타입) valuetype List.Enumerator(구조체)
호출 방식 callvirt(가상 디스패치) call(직접 호출)
힙 할당 List<T>.Enumerator 구조체가 인터페이스로 박싱됨 스택에 저장, 힙 할당 없음

Unity의 Update() 같은 핫패스에서 매 프레임 수천 개의 요소를 순회한다면, 이 차이가 GC(Garbage Collector, 메모리를 자동으로 회수하는 런타임 구성요소) 부담으로 누적된다.


4. 실전 적용

매개변수는 가능한 한 약한 타입으로 받기

메서드가 컬렉션을 순회만 한다면, IEnumerable<T>로 받는 것이 가장 유연하다.

C#
// ❌ 나쁜 예: List<T>만 받을 수 있다
public void ApplyDamageToAll(List<Enemy> enemies)
{
    foreach (var enemy in enemies) { enemy.TakeDamage(10); }
}

// ✅ 좋은 예: 배열, List, LINQ 결과 모두 받을 수 있다
public void ApplyDamageToAll(IEnumerable<Enemy> enemies)
{
    foreach (var enemy in enemies) { enemy.TakeDamage(10); }
}

다만, 메서드 내부에서 Count가 필요하면 ICollection<T>, 인덱스 접근이 필요하면 IList<T>로 올린다. 필요한 최소한의 계약만 요구하는 것이 원칙이다.

반환 타입은 내부 상태를 보호할 만큼만 노출하기

C#
using System;
using System.Collections.Generic;

public class EnemyManager
{
    private List<string> enemies = new List<string>();

    // ❌ 위험: 외부에서 내부 리스트를 직접 수정 가능
    public IList<string> GetEnemiesDangerous()
    {
        return enemies;
    }

    // ⚠️ 느슨한 보호: IEnumerable로 반환하지만 캐스팅하면 뚫린다
    public IEnumerable<string> GetEnemiesSafe()
    {
        return enemies;
    }

    // ✅ 확실한 보호: ReadOnlyCollection 래퍼
    public IReadOnlyList<string> GetEnemiesReadOnly()
    {
        return enemies.AsReadOnly();
    }
}
IL
// GetEnemiesDangerous — List<string>을 IList<string>으로 반환
.method public hidebysig instance class IList`1<string> GetEnemiesDangerous ()
{
    IL_0002: ldfld class List`1<string> EnemyManager::enemies  // 내부 필드를 그대로 반환
    IL_000b: ret
}

// GetEnemiesSafe — List<string>을 IEnumerable<string>으로 반환
.method public hidebysig instance class IEnumerable`1<string> GetEnemiesSafe ()
{
    IL_0002: ldfld class List`1<string> EnemyManager::enemies  // 역시 같은 참조를 반환
    IL_000b: ret
}

// GetEnemiesReadOnly — AsReadOnly()로 래핑
.method public hidebysig instance class IReadOnlyList`1<string> GetEnemiesReadOnly ()
{
    IL_0002: ldfld class List`1<string> EnemyManager::enemies
    IL_0007: callvirt instance class ReadOnlyCollection`1<!0> List`1<string>::AsReadOnly()  // 래퍼 객체 생성
    IL_0010: ret
}

IL을 보면 GetEnemiesDangerousGetEnemiesSafe동일하게 내부 필드의 참조를 그대로 반환한다. IEnumerable<string>으로 반환해도 (IList<string>)manager.GetEnemiesSafe()로 캐스팅하면 Add, Remove가 가능하다. AsReadOnly()만이 ReadOnlyCollection<T> 래퍼 객체를 생성하여 진정한 읽기 전용 보호를 제공한다.

Unity 핫패스에서의 인터페이스 선택

C#
using System;
using System.Collections.Generic;
using System.Linq;

// ❌ Before: Update에서 LINQ 사용 — 매 프레임 이터레이터 + 델리게이트 객체 생성
public class BadUpdate
{
    private List<string> enemies = new List<string>();
    
    public void Update()
    {
        var alive = enemies.Where(e => e != null);
        foreach (var e in alive) { /* 처리 */ }
    }
}

// ✅ After: for 루프 + 캐시된 리스트 — 힙 할당 없음
public class GoodUpdate
{
    private List<string> enemies = new List<string>();
    private List<string> aliveCache = new List<string>();
    
    public void Update()
    {
        aliveCache.Clear();
        for (int i = 0; i < enemies.Count; i++)
        {
            if (enemies[i] != null) { aliveCache.Add(enemies[i]); }
        }
    }
}
IL
// BadUpdate.Update() — LINQ Where
IL_0002: ldfld class List`1<string> BadUpdate::enemies
IL_0007: ldsfld class Func`2<string, bool> '<>c'::'<>9__1_0'   // 캐싱된 델리게이트 확인
IL_000c: dup
IL_000d: brtrue.s IL_0026                                        // 캐싱되어 있으면 스킵
IL_000f: pop
IL_0010: ldsfld class '<>c' '<>c'::'<>9'
IL_0015: ldftn instance bool '<>c'::'<Update>b__1_0'(string)
IL_001b: newobj instance void Func`2<string, bool>::.ctor(...)   // 델리게이트 최초 1회 생성
IL_0026: call class IEnumerable`1<!!0> Enumerable::Where<string>(...)  // Where 이터레이터 객체 생성
IL_002e: callvirt instance class IEnumerator`1<!0> IEnumerable`1<string>::GetEnumerator()  // 열거자 생성

// GoodUpdate.Update() — for 루프
IL_0007: callvirt instance void List`1<string>::Clear()       // 기존 캐시 비우기
IL_0019: callvirt instance !0 List`1<string>::get_Item(int32) // 인덱서 접근 O(1)
IL_0038: callvirt instance void List`1<string>::Add(!0)       // 캐시에 추가
IL_004b: callvirt instance int32 List`1<string>::get_Count()  // Count O(1) 확인

BadUpdateWhere()가 호출될 때마다 내부적으로 WhereEnumerableIterator 객체를 힙에 생성한다. 델리게이트 자체는 정적 필드에 캐싱되지만(ldsfld), 이터레이터 객체와 GetEnumerator() 호출로 인한 열거자는 매 프레임 새로 만들어진다.

GoodUpdateList<T>Count 프로퍼티(O(1))와 인덱서(O(1))를 직접 사용하므로, 추가 힙 할당이 전혀 없다.


5. 함정과 주의사항

함정 1: IEnumerable<T>을 여러 번 순회하면 매번 다시 실행된다

C#
// ❌ 잘못된 패턴: IEnumerable을 두 번 순회
public void ProcessTwice(IEnumerable<int> items)
{
    Console.WriteLine($"총 {items.Count()}개");  // 첫 번째 전체 순회
    foreach (var item in items)                   // 두 번째 전체 순회
    {
        Console.WriteLine(item);
    }
}

items가 DB 쿼리나 yield return 기반 시퀀스라면, Count()에서 한 번, foreach에서 한 번, 총 두 번 실행된다. 네트워크 요청이면 두 번 호출되고, yield return이면 부작용이 두 번 발생할 수 있다.

C#
// ✅ 올바른 패턴: 먼저 구체화한 뒤 재사용
public void ProcessTwice(IEnumerable<int> items)
{
    var materialized = items.ToList();            // 한 번만 순회하여 메모리에 올림
    Console.WriteLine($"총 {materialized.Count}개"); // O(1) — List<T>.Count 프로퍼티
    foreach (var item in materialized)
    {
        Console.WriteLine(item);
    }
}

함정 2: 배열의 IList<T> 구현은 불완전하다

T[](배열)은 IList<T>를 구현하지만, 크기가 고정이므로 Add, Remove, Insert를 호출하면 NotSupportedException이 발생한다.

C#
// ❌ 런타임 예외 발생
IList<int> list = new int[] { 1, 2, 3 };
list.Add(4); // NotSupportedException!

// ✅ 배열인지 확인하거나, IsReadOnly 체크
if (!list.IsReadOnly)
{
    list.Add(4);
}

이런 이유로 매개변수에서 "읽기만 할 것"이라면 IList<T> 대신 IReadOnlyList<T>를 사용하는 것이 의도를 명확히 전달한다.

함정 3: Unity 코루틴의 IEnumerator와 IEnumerable<T>은 다르다

Unity의 코루틴은 IEnumerator(비제네릭)를 반환한다. 이것은 IEnumerable<T> 계열이 아니라, 열거자(IEnumerator) 자체다.

C#
// Unity 코루틴 — IEnumerator 반환
IEnumerator SpawnWave()
{
    for (int i = 0; i < 10; i++)
    {
        Instantiate(enemyPrefab, GetSpawnPoint(), Quaternion.identity);
        yield return new WaitForSeconds(0.5f);
    }
}

코루틴이 IEnumerator를 반환하는 이유는 Unity 엔진이 직접 MoveNext()를 호출하여 프레임 단위로 실행을 제어하기 때문이다. IEnumerable<T>처럼 foreach로 순회하는 것이 아니라, Unity 런타임이 매 프레임 MoveNext()를 호출하고 Current에 담긴 YieldInstruction(WaitForSeconds, WaitForEndOfFrame 등)을 해석하여 다음 실행 시점을 결정한다.


6. C# 버전별 변화

C# 2.0 — 제네릭 인터페이스 도입

C# 1.0의 IEnumerable, ICollection, IList는 모두 비제네릭이었다. object 타입으로 요소를 주고받아야 했기 때문에 값 타입에서 박싱이 발생했다.

C#
// C# 1.0 — 비제네릭 IList
System.Collections.IList list = new System.Collections.ArrayList();
list.Add(42);         // int → object 박싱
int value = (int)list[0]; // object → int 언박싱
C#
// C# 2.0+ — 제네릭 IList<T>
IList<int> list = new List<int>();
list.Add(42);         // 박싱 없음
int value = list[0];  // 캐스팅 불필요

C# 2.0 — yield return과 이터레이터

yield return이 도입되면서 IEnumerable<T>의 지연 실행이 언어 차원에서 지원되었다. 상태 머신을 직접 구현할 필요가 없어졌다.

C# 3.0 — LINQ와 IEnumerable<T>의 전성기

LINQ가 도입되면서 IEnumerable<T>가 컬렉션 조작의 중심이 되었다. Where, Select, OrderBy 등 모든 LINQ 확장 메서드가 IEnumerable<T>를 기반으로 동작한다.

.NET 4.5 — IReadOnlyCollection<T>과 IReadOnlyList<T>

C#
// .NET 4.5 이전 — 읽기 전용 의도를 표현하기 어려움
public IEnumerable<Enemy> GetEnemies() => _enemies;
// 문제: 호출자가 캐스팅하면 수정 가능

// .NET 4.5 이후 — 읽기 전용 인터페이스 등장
public IReadOnlyList<Enemy> GetEnemies() => _enemies.AsReadOnly();
// Count와 인덱서는 사용 가능하되, Add/Remove는 불가능

IReadOnlyCollection<T>Count + GetEnumerator()만, IReadOnlyList<T>는 여기에 읽기 전용 인덱서를 추가한다. 반환 타입에서 "이 컬렉션은 수정하지 마라"는 의도를 명확히 전달할 수 있게 되었다.


7. 정리

  • IEnumerable<T>: "순회만 가능" — 가장 약한 계약. 매개변수의 기본 선택지. 지연 실행을 지원하며, yield return으로 상태 머신을 자동 생성한다.
  • ICollection<T>: "크기를 알고, 추가/삭제 가능" — Count O(1)이 필요하면 이 계층.
  • IList<T>: "인덱스 임의 접근 가능" — 인덱서가 필요할 때만 사용. 기능이 가장 많지만 구현 부담도 가장 크다.

체크리스트

  • [ ] 매개변수 타입은 메서드가 실제로 필요한 최소 계층인가? (순회만 한다면 IEnumerable<T>)
  • [ ] 반환 타입이 내부 상태를 불필요하게 노출하지 않는가? (IReadOnlyList<T> 또는 AsReadOnly() 고려)
  • [ ] IEnumerable<T>을 여러 번 순회하고 있지 않은가? (필요하면 .ToList()로 구체화)
  • [ ] Unity 핫패스(Update, FixedUpdate)에서 LINQ를 사용하고 있지 않은가? (for 루프 + 캐시된 리스트로 대체)
  • [ ] List<T> 타입으로 직접 foreach를 돌리고 있는가? (인터페이스 타입이면 박싱 발생 가능)
  • [ ] 배열을 IList<T>로 받은 뒤 Add/Remove를 호출하지 않는가? (NotSupportedException 주의)