반응형

[PART9.컬렉션 기본 사용법(5/8)] IEnumerable<T> · ICollection<T> · IList<T> 개요 — 어디까지 보장되는가, 무엇을 매개변수로 받아야 하는가

인터페이스 계층 구조와 멤버 차이 / "받는 타입은 추상으로, 반환 타입은 구체적으로" / foreach 박싱 함정과 다중 열거의 정체


1. 문제 제기 — 메서드 시그니처 하나로 GC가 갈린다

Unity 신입이 가장 자주 마주치는 컬렉션 관련 코드 리뷰 지적은 두 가지다.

C#
// 케이스 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는 반대로 너무 추상적인 타입으로 받아서 매 프레임 박싱이 발생한다.

핵심 질문 두 개

  1. 메서드가 컬렉션을 받을 때 List<T>·IList<T>·ICollection<T>·IEnumerable<T> 중 무엇을 받아야 하는가?
  2. 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 시각화: 계층 구조와 추가되는 멤버

IEnumerable<T>

2.3 가장 작은 코드로 확인

세 인터페이스가 어떤 멤버를 보장하는지 코드로 직접 확인한다.

C#
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) 기반 패턴이다.
C#
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> 캐스팅

foreach (var x in list)

같은 List<int> 객체를 같은 foreach로 돌리는데 매개변수 타입이 다르면 결과가 정반대다. 이 차이는 IL을 보면 명확해진다.

3.3 IL 분석: 결정적 증거

다음 두 메서드를 컴파일해서 IL을 비교한다.

C#
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 (핵심만 발췌, 한국어 주석은 추가)

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

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>.Enumeratorstruct라서 스택에 그대로 잡힌다. 그러나 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 — 매개변수가 너무 구체적

C#
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 — 매개변수는 추상적으로

C#
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>로 받아 매 프레임 박싱

C#
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 — 핫패스에서는 구체 타입으로 받기

C#
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 엔진이 제공하는 두 가지 오버로드를 비교한다.

C#
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도 인덱스 접근도 못 한다.

C#
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에서 도입된 인터페이스 쌍이다.

C#
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의 지연 실행과 결합되면 같은 쿼리가 의도치 않게 여러 번 실행된다.

C#
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로 본 다중 열거

C#
static void MultiEnumerate(IEnumerable<int> source)
{
    int count = source.Count();
    int max = source.Max();
    System.Console.WriteLine($"{count} {max}");
}
IL
.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::Countcall ... 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이 던져진다.

C#
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을 던진다. IsReadOnlytrue이기 때문이다.

C#
// ✓ 변경이 필요한 메서드는 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>가 추가될 때 호환성을 위해 별도 계층으로 분리되었기 때문이다.

C#
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을 기계어로 변환하는 컴파일 단계)이 일부 인라인을 시도하지만, 매 프레임 수천 번 호출되는 루프라면 구체 타입이 더 빠르다.

C#
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 차이

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이 단형성을 활용하기 쉬움

SumViaInterfaceCountICollection<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으로 반복자 직접 만들기

C#
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 returnIEnumerable<T>를 직접 구현하는 부담을 없앴다. 동시에 다중 열거 함정의 빈도도 늘렸다 — yield 메서드를 두 번 순회하면 본문이 두 번 실행된다.

6.2 C# 3.0 — LINQ와 확장 메서드

LINQ는 IEnumerable<T>를 받는 확장 메서드의 거대한 집합이다.

C#
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처럼 다루기 위한 인터페이스다.

C#
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)로 감싸거나 직접 복사본을 만드는 수밖에 없었다.

C#
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>를 사용했는가?
  • [ ] IsReadOnlyICollection<T> 매개변수에서 Add/Remove를 호출하기 전에 IsReadOnly 가능성을 고려했는가?
  • [ ] 반환 타입 — 호출자가 Count나 인덱스 접근이 필요할 가능성이 있다면 IEnumerable<T> 대신 List<T> / IReadOnlyList<T>를 반환했는가?

한 줄 요약

IEnumerable<T>ICollection<T>IList<T>권한이 점점 늘어나는 계약이다. 매개변수는 메서드가 실제로 필요로 하는 만큼만 받고, 반환은 호출자가 활용할 수 있는 만큼 풀어준다. 단, Unity 핫패스에서는 박싱·가상 디스패치 비용이 우선이므로 구체 타입을 직접 받는다.
반응형

+ Recent posts