반응형

[PART9.컬렉션 기본 사용법(1/8)] List<T> — 가장 자주 쓰는 컬렉션

동적 배열의 동작 원리 / Add·Remove·인덱스 접근 / 박싱 없는 값 타입 저장 / Unity 핫패스에서의 함정


1. 왜 List<T>가 "국민 컬렉션"인가

Unity로 모바일 게임을 만들기 시작하면 가장 먼저 부딪히는 자료 구조 문제가 있습니다.

"지금 화면에 떠 있는 적이 몇 마리인지 모르겠는데, 어떻게 다 모아두지?"

처음에는 배열(Enemy[])부터 떠올리지만 곧 막힙니다. 배열은 한 번 만들면 크기를 바꿀 수 없고, 새 적이 등장할 때마다 new Enemy[oldArray.Length + 1]로 다시 만들고 복사해야 합니다. 적이 죽었을 때 중간에서 빼는 것도 직접 한 칸씩 당겨와야 합니다. 클라이언트 신입이라면 거의 100% 이 구간에서 한 번씩 시간을 잃습니다.

List<T>는 이 문제를 정확히 겨냥한 컬렉션입니다. 크기가 자동으로 늘어나고, Add·RemoveAt 한 줄로 추가·삭제가 끝나며, 인덱스 접근은 배열만큼 빠릅니다. .NET 표준 라이브러리에서 가장 자주 쓰이는 타입을 꼽으라면 string 다음으로 List<T>가 들어간다고 봐도 무리가 없습니다.

이 글에서는 List<T>내부적으로 무엇이고, 왜 빠르며, 언제 함정에 빠지는지를 IL 레벨까지 들여다봅니다. 외워서 쓰는 컬렉션이 아니라 동작이 보이는 컬렉션으로 만드는 것이 목표입니다.


2. 개념 정의 — "자라는 배열"이라는 비유

2.1 배열에 자동 리사이징 매니저를 붙인 것

List<T>를 한 줄로 요약하면 "내부에 배열을 들고 있다가, 가득 차면 더 큰 배열로 갈아타는 래퍼(wrapper) 클래스" 입니다. 마법이 아니라 단순한 동적 배열(dynamic array)입니다.

제네릭(<T>) — Generic 컴파일 시점에 컬렉션이 담을 타입을 고정하는 문법. List<int>는 정수만, List<Enemy>는 Enemy만 담는다. 타입을 고정하는 덕분에 캐스팅 오류가 없고, 값 타입을 박싱 없이 저장할 수 있다.
예시: List<int> nums = new List<int>(); nums에는 int 외에는 들어갈 수 없다.
List<int> 인스턴스 (힙)

List<T>는 자기가 가진 배열의 현재 크기(Capacity)실제로 쓰이는 칸의 개수(Count) 를 따로 관리합니다. 처음에 8칸짜리 배열을 받아두고 데이터는 3개만 들어 있어도 정상입니다. 9번째 데이터를 넣으려는 순간 비로소 16칸짜리 배열을 새로 받고 모두 옮겨 담습니다.

2.2 가장 짧은 사용 코드

C#
using System.Collections.Generic;

var enemies = new List<int> { 10, 20, 30 };  // 컬렉션 초기화자
enemies.Add(40);                              // 끝에 추가
int first = enemies[0];                       // 인덱스 읽기
enemies[2] = 99;                              // 인덱스 쓰기
enemies.RemoveAt(1);                          // 1번 인덱스 삭제
int n = enemies.Count;                        // 현재 개수

여섯 줄에 List<T> 사용의 90%가 들어 있습니다. 이 글의 나머지는 이 여섯 줄이 내부에서 무엇을 하는지에 대한 이야기입니다.


3. 내부 동작 — Capacity, Count, 그리고 자동 리사이징

3.1 Count vs Capacity의 차이

속성 의미 값을 바꾸는 시점
Count 실제로 들어 있는 요소 개수 Add/Insert/Remove 등으로 변경
Capacity 내부 배열 _items의 길이 Add로 가득 찼을 때 자동 2배, EnsureCapacity 호출, 또는 직접 대입

Count는 항상 Capacity 이하입니다. Capacity - Count 만큼이 "지금 비어 있는 여유 공간"이고, 이 여유가 있는 동안의 Add는 그냥 배열의 빈 칸에 값을 쓰는 작업이라 매우 빠릅니다.

3.2 자동 2배 증가 메커니즘

Capacity가 가득 찼을 때 Add(40) 호출 — 2배 증가

핵심은 이전 배열이 GC(Garbage Collector, 메모리를 자동으로 회수하는 런타임 구성요소) 대상이 된다는 점입니다. 리사이징은 새 배열 할당 + 기존 데이터 전체 복사 + 옛 배열 폐기를 한 번에 수행하므로 평소 Add보다 한참 비쌉니다.

이 동작을 직접 관찰해 봅시다.

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

var list = new List<int>();
for (int i = 0; i < 5; i++)
{
    list.Add(i);
    Console.WriteLine($"Count={list.Count}, Capacity={list.Capacity}");
}

실행 결과(.NET 8 기준):

Count=1, Capacity=4
Count=2, Capacity=4
Count=3, Capacity=4
Count=4, Capacity=4
Count=5, Capacity=8

처음 Add 한 번에 Capacity가 0에서 4로 점프하고, 다섯 번째 Add 직전에 4 → 8로 다시 점프합니다. 6, 7, 8번째 추가는 또 8 한도 안에서 일어나고, 9번째에 16으로 갑니다. 2배 증가는 "꽉 찼을 때만" 발생한다는 점을 기억해 둡니다.

이 코드의 IL을 보면 Add 호출이 어떻게 디스패치되는지 확인할 수 있습니다.

IL
.method public hidebysig static int32 GrowthDemo () cil managed
{
    .locals init (
        [0] class [System.Collections]System.Collections.Generic.List`1<int32>,
        [1] int32, [2] bool, [3] int32
    )

    IL_0001: newobj instance void class List`1<int32>::.ctor()         // 빈 리스트 생성 — 힙 1회 할당
    IL_0006: stloc.0
    IL_0007: ldc.i4.0
    IL_0008: stloc.1                                                    // i = 0
    IL_0009: br.s IL_0019                                               // 루프 조건으로 점프

    IL_000b: nop
    IL_000c: ldloc.0
    IL_000d: ldloc.1
    IL_000e: callvirt instance void class List`1<int32>::Add(!0)        // list.Add(i) — 가상 디스패치
    ...
    IL_0021: ldloc.0
    IL_0022: callvirt instance int32 class List`1<int32>::get_Count()   // list.Count 프로퍼티
    ...
}

IL 분석 포인트

1. newobj로 List 인스턴스 1회만 생성

new List<int>()newobj instance void List<int>::.ctor()로 컴파일됩니다. 이 호출에서 _items는 비어 있는 공유 배열(Array.Empty<T>())을 가리키므로 첫 번째 Add 전까지는 추가 힙 할당이 없습니다. 모바일에서 자주 쓰는 임시 리스트가 결국 비어 있는 채로 끝나는 코드 패턴은 GC 부담이 거의 없다는 뜻입니다.

2. Addcallvirt로 디스패치되는 이유

List<T>IList<T>를 구현하는 가상(virtual) 메서드 디스패치 대상이므로 callvirt 명령어가 사용됩니다. callvirt호출 직전에 인스턴스가 null인지 검사하는 점이 call과의 차이입니다. JIT(Just-In-Time, 실행 직전에 IL을 기계어로 변환하는 컴파일러)는 호출 대상이 봉인된 클래스이거나 정확한 타입을 알 수 있으면 이 가상 디스패치를 정적 호출로 디버추얼화(devirtualize)하여 사실상의 비용을 0에 가깝게 만듭니다.

3. Add 안에 숨어 있는 분기

IL에는 안 보이지만 Add 메서드 내부는 대략 다음과 같은 형태입니다.

C#
public void Add(T item) {
    T[] array = _items;
    int size = _size;
    if ((uint)size < (uint)array.Length) {   // 빠른 경로
        _size = size + 1;
        array[size] = item;
    } else {
        AddWithResize(item);                  // 느린 경로 — 새 배열 할당 + 복사
    }
    _version++;                                // foreach 안전성 검사용 카운터
}

Update() 매 프레임마다 Add를 호출하는 핫패스(hot path, 자주 실행되는 코드 경로)에서 이 빠른 경로는 거의 무료지만, 느린 경로가 한 번 걸리면 그 프레임에서만 GC 압박이 갑자기 튄다는 점이 중요합니다.


4. 핵심 멤버 — Add, RemoveAt, 인덱서, Count

4.1 컬렉션 초기화자는 결국 Add 시퀀스다

new List<int> { 1, 2, 3 } 한 줄은 신입 개발자가 가장 많이 쓰는 표현이지만, 컴파일러가 이걸 어떻게 풀어내는지를 정확히 아는 사람은 의외로 적습니다.

C#
public static int InitDemo()
{
    var nums = new List<int> { 1, 2, 3 };
    return nums.Count;
}

이 함수를 IL로 디컴파일하면 다음과 같습니다.

IL
.method public hidebysig static int32 InitDemo () cil managed
{
    .locals init (
        [0] class [System.Collections]System.Collections.Generic.List`1<int32>,
        [1] int32
    )

    IL_0001: newobj instance void class List`1<int32>::.ctor()           // 빈 리스트 생성
    IL_0006: dup                                                          // 스택 위 참조 복제 (Add 호출용)
    IL_0007: ldc.i4.1                                                     // 정수 1 푸시
    IL_0008: callvirt instance void class List`1<int32>::Add(!0)          // Add(1)
    IL_000e: dup
    IL_000f: ldc.i4.2
    IL_0010: callvirt instance void class List`1<int32>::Add(!0)          // Add(2)
    IL_0016: dup
    IL_0017: ldc.i4.3
    IL_0018: callvirt instance void class List`1<int32>::Add(!0)          // Add(3)
    IL_001e: stloc.0                                                      // 결과를 nums 변수에 저장
    IL_001f: ldloc.0
    IL_0020: callvirt instance int32 class List`1<int32>::get_Count()     // nums.Count
    IL_0025: stloc.1
    ...
}

IL 분석 포인트

1. 컬렉션 초기화자 = newobj + dup + Add 시퀀스

{ 1, 2, 3 } 문법은 컴파일 후 사라지고 Add(1) → Add(2) → Add(3) 호출로 풀어집니다. 즉 컬렉션 초기화자를 쓰려면 그 타입에 Add 메서드가 있어야 한다는 규칙은 우연이 아닙니다. 컴파일러가 이름 그대로 Add를 호출하기 때문입니다.

2. dup 명령어 — 스택 최상위 복제

newobj로 생성된 List 참조는 Add마다 한 번씩 인자로 필요하므로, 매번 변수에 저장했다 꺼내는 대신 dup으로 스택 위 참조를 복제합니다. 변수 슬롯 사용이 줄어 IL이 짧아집니다.

3. 박싱이 한 번도 등장하지 않는다

ldc.i4.1 (정수 상수 1을 스택에 푸시) 직후 곧장 Add(!0) (제네릭 파라미터 T인 int 자체)로 들어갑니다. box 명령어가 어디에도 없습니다. 만약 옛 컬렉션 ArrayList를 썼다면 Add(object) 시그니처에 맞추기 위해 box int32가 끼어들어 매번 힙 할당이 발생했을 자리입니다. 이게 List<T>의 가장 큰 성능 우위입니다.

Capacity 미지정 + 컬렉션 초기화자 사용은 .NET 7 이후 컴파일러 최적화에 따라 미리 정확한 Capacity를 잡고 시작하는 경우가 있습니다. 다만 Add 시퀀스로 풀어내는 큰 그림은 동일합니다.

4.2 인덱서 — list[i]는 무엇을 호출하는가

C#
public static int IndexerDemo()
{
    var list = new List<int> { 10, 20, 30 };
    int x = list[1];     // get_Item(1)
    list[2] = 99;        // set_Item(2, 99)
    return x + list[2];
}
IL
IL_0022: ldloc.0
IL_0023: ldc.i4.1
IL_0024: callvirt instance !0 class List`1<int32>::get_Item(int32)        // list[1] 읽기
IL_0029: stloc.1
IL_002a: ldloc.0
IL_002b: ldc.i4.2
IL_002c: ldc.i4.s 99
IL_002e: callvirt instance void class List`1<int32>::set_Item(int32, !0)  // list[2] = 99 쓰기

IL 분석 포인트

1. 인덱서는 평범한 메서드 호출이다

C#의 [...] 문법은 마법이 아니라 get_Item / set_Item 메서드 호출의 문법 설탕(syntactic sugar)입니다. 속도는 배열 직접 접근(ldelem)보다 한 단계 더 들어간 메서드 호출 비용 만큼 느립니다. 다만 JIT이 인덱서 안의 경계 검사와 본문을 인라이닝(inlining, 호출을 본문 그 자리에 붙여 넣는 최적화)하는 경우가 많아, 실측에서는 배열과 거의 같은 속도가 나옵니다. 그래도 타이트한 수치 루프에서는 미세하게 차이가 날 수 있다는 사실은 알아두는 편이 좋습니다.

2. 경계 검사는 인덱서 안에서 일어난다

list[100] 같은 잘못된 접근은 get_Item 안에서 ArgumentOutOfRangeException을 던집니다. IL에는 보이지 않지만, 인덱서가 단순 배열 접근보다 약간의 추가 안전 비용을 가진다는 의미입니다.

4.3 RemoveAt — 뒤 요소가 한 칸씩 앞으로 당겨진다

C#
public static int RemoveAtDemo()
{
    var list = new List<int> { 1, 2, 3, 4, 5 };
    list.RemoveAt(1);            // 인덱스 1(값 2) 제거
    int sum = 0;
    foreach (var n in list) sum += n;
    return sum;
}
RemoveAt(1) — 인덱스 1 제거 시 뒤 요소가 한 칸씩 앞으로 당겨짐

RemoveAt(1)은 단순히 칸을 비우는 게 아니라 그 뒤의 모든 요소를 한 칸씩 앞으로 이동시킵니다. n개짜리 리스트에서 앞쪽 인덱스를 지우면 거의 n번의 복사가 발생합니다. 이게 Remove/RemoveAtO(n) 시간 복잡도(time complexity, 입력 크기 n에 따라 연산 횟수가 어떻게 늘어나는지를 표기하는 척도)를 갖는 이유입니다.

IL
IL_002f: ldloc.0
IL_0030: ldc.i4.1
IL_0031: callvirt instance void class List`1<int32>::RemoveAt(int32)        // RemoveAt(1)
...
IL_003a: ldloc.0
IL_003b: callvirt instance valuetype List`1/Enumerator<!0>
                          class List`1<int32>::GetEnumerator()              // foreach 진입
.try
{
    IL_0043: ldloca.s 2                                                      // ★ ldloca — Enumerator 주소 직접 참조
    IL_0045: call instance !0 valuetype List`1/Enumerator<int32>::get_Current()
    ...
    IL_0051: call instance bool valuetype List`1/Enumerator<int32>::MoveNext()
}
finally
{
    IL_005a: ldloca.s 2
    IL_005c: constrained. valuetype List`1/Enumerator<int32>
    IL_0062: callvirt instance void [System.Runtime]System.IDisposable::Dispose()
}

IL 분석 포인트

1. RemoveAtcallvirt — 그러나 본문은 Array.Copy다

callvirt RemoveAt은 메서드 호출 한 번이지만, 실제 메서드 본문은 내부적으로 Array.Copy(_items, index + 1, _items, index, count - index - 1)을 수행합니다. 호출 비용은 작지만 복사 비용은 리스트 길이에 비례 합니다. 1만 개짜리 리스트에서 매 프레임 앞쪽을 제거하면 핫패스에서 곧장 병목이 됩니다.

2. valuetype Enumerator<int32> — foreach가 GC를 일으키지 않는 결정적 증거

foreach (var n in list)는 IL에서 GetEnumerator()를 호출해 열거자를 받는데, 그 타입이 valuetype List1/Enumerator<int32> (값 타입 struct)임에 주목합니다. 게다가 호출 방식은 ldloca.s 2 + call로 **주소 기반 정적 호출**입니다. callvirt도 박싱도 없습니다. 이게 .NET 4.x 이후 Unity에서 List<T>foreach`가 GC 제로(zero allocation)인 이유입니다.

3. constrained. — 박싱 없이 인터페이스 메서드 호출

finally에서 Enumerator(struct)의 Dispose()를 호출할 때 constrained. 접두사를 사용합니다. 이 접두사는 값 타입에 대해 인터페이스 메서드를 박싱 없이 호출하라는 지시입니다. 옛 Mono(과거 Unity 런타임)에서 foreach가 가비지를 만들었던 결정적 원인이 여기 박싱이 끼어들었기 때문이었고, 현재 constrained.로 그 비용이 사라졌습니다.


5. 시간 복잡도 — 어디가 빠르고 어디가 느린가

연산 복잡도 이유
list[i] 읽기/쓰기 O(1) 내부 배열의 i번째 칸에 직접 접근
Count O(1) _size 필드 값 그대로 반환
Add(item) 분할 상환 O(1) 보통 빈 칸에 쓰기. 가끔 리사이징(O(n))이 끼지만 평균 내면 O(1)
Insert(index, item) O(n) 삽입 위치 뒤 요소를 모두 한 칸씩 뒤로 이동
RemoveAt(index) O(n) 제거 위치 뒤 요소를 모두 한 칸씩 앞으로 이동
Remove(item) O(n) 선형 탐색으로 위치 찾고 + RemoveAt 동작
Contains(item) / IndexOf(item) O(n) 처음부터 순차 비교
Clear() O(n) 참조 타입은 null 채움. 값 타입은 사실상 O(1) 처리
분할 상환(amortized) 시간 한 번씩 큰 비용이 끼어도 전체 호출 횟수로 나누면 평균 비용이 일정하다는 분석. Add는 대부분 O(1)이지만 가끔 리사이징(O(n))이 발생하는데, 리사이징은 매번이 아니라 Capacity가 두 배로 커질 때만 발생하므로 누적 평균이 O(1)에 수렴한다.

여기서 신입이 자주 빠지는 함정은 "List<T>Contains / Remove / IndexOf는 모두 O(n)" 이라는 사실입니다. 1만 개 리스트에서 매 프레임 bullets.Contains(b) 같은 확인을 하면 1만 번의 비교가 매 프레임 일어납니다. 이런 용도는 다음 글에서 다룰 HashSet<T>(검색 O(1))이 정답입니다.


6. 실전 적용 — Unity 핫패스에서의 List<T>

6.1 Before/After: 적 풀(pool) 구현

Unity 신입이 가장 먼저 만나는 패턴이 오브젝트 풀(object pool, 자주 만들고 부수는 오브젝트를 미리 만들어 두고 재활용하는 기법)입니다. 잘못 짜면 매 프레임 Capacity 리사이징이 발생합니다.

C#
// ❌ Before — 적이 등장할 때마다 List가 리사이징됨
public class EnemySpawnerBad : MonoBehaviour
{
    private List<Enemy> activeEnemies = new List<Enemy>();   // Capacity = 0

    public void SpawnWave(int count)
    {
        for (int i = 0; i < count; i++)
        {
            var enemy = Instantiate(enemyPrefab);
            activeEnemies.Add(enemy);  // 0 → 4 → 8 → 16 → 32 → 64 ... 매번 배열 새로 할당
        }
    }
}

SpawnWave(100) 한 번에 0 → 4 → 8 → 16 → 32 → 64 → 128로 7번의 새 배열 할당과 데이터 복사가 일어납니다. 이전 6개의 배열은 모두 GC 대상이 됩니다. 모바일 환경에서 이게 한 프레임에 몰리면 곧장 GC 스파이크(GC spike, 가비지 수집이 갑자기 길어져 프레임이 끊기는 현상)로 이어집니다.

C#
// ✅ After — Capacity를 미리 잡아 리사이징을 0회로 만듦
public class EnemySpawnerGood : MonoBehaviour
{
    [SerializeField] private int maxConcurrentEnemies = 128;
    private List<Enemy> activeEnemies;

    private void Awake()
    {
        activeEnemies = new List<Enemy>(maxConcurrentEnemies);  // Capacity = 128
    }

    public void SpawnWave(int count)
    {
        for (int i = 0; i < count; i++)
        {
            var enemy = Instantiate(enemyPrefab);
            activeEnemies.Add(enemy);  // 128 한도 내에서는 리사이징 없음
        }
    }
}

new List<T>(capacity) 생성자 한 글자 추가로 리사이징 7번 → 0번입니다. 정확한 상한을 모르면 예상치보다 약간 크게 잡고, 안 쓰는 공간은 게임 종료 시 회수되니 큰 문제가 안 됩니다.

경험적 기준: 리스트 크기가 사전에 어느 정도 예측되면 new List<T>(예상치)로 시작합니다. 8 이하 작은 임시 리스트는 굳이 신경 쓰지 않아도 됩니다 — 첫 Add에서 잡히는 Capacity 4 자체가 충분히 작습니다.

6.2 RemoveAll로 조건부 일괄 제거

체력이 0인 적을 한꺼번에 제거하는 코드를 신입은 보통 이렇게 씁니다.

C#
// ❌ Before — foreach 안에서 Remove → InvalidOperationException
foreach (var enemy in activeEnemies)
{
    if (enemy.Health <= 0)
        activeEnemies.Remove(enemy);   // 컬렉션 변경 감지 → 예외
}

foreach는 진입 시점의 _version을 기억해 두고 매 단계에서 비교하는데, Remove_version을 증가시켜 버리므로 MoveNextInvalidOperationException을 던집니다.

C#
// ✅ After 방법 1 — RemoveAll (가장 간단하고 효율적)
activeEnemies.RemoveAll(e => e.Health <= 0);
C#
// ✅ After 방법 2 — 역순 for 루프 (RemoveAll의 람다 캡처 비용도 피하고 싶을 때)
for (int i = activeEnemies.Count - 1; i >= 0; i--)
{
    if (activeEnemies[i].Health <= 0)
        activeEnemies.RemoveAt(i);
}

RemoveAll은 내부적으로 한 번의 순회로 살아남을 요소를 앞으로 모은 뒤 뒤쪽을 잘라내므로 여러 번의 RemoveAt을 호출하는 것보다 훨씬 효율적입니다. 다만 람다(e => e.Health <= 0)가 캡처 변수를 가지면 매 호출마다 클로저 객체가 할당될 수 있으니, 정말 타이트한 핫패스에서는 역순 for를 씁니다.

6.3 foreach vs for — 현대 .NET에서는 자유롭게

환경 foreach 가비지
Unity 5.x 이전 (구 Mono) 발생 (Enumerator 박싱)
Unity 2018.3+ (.NET 4.x equivalent) 없음 (struct Enumerator + constrained.)
.NET Core/5+ 없음

옛 자료를 보고 "List<T> foreach는 GC를 만든다"라고 외우는 분이 있는데, 현재 Unity 환경에서는 사실이 아닙니다. 가독성을 위해 foreach를 쓰셔도 좋고, 인덱스가 필요하면 for를 쓰면 됩니다.

C#
// 둘 다 GC를 만들지 않음 (현대 Unity)
foreach (var e in activeEnemies) e.Tick();          // 가독성 우선
for (int i = 0; i < activeEnemies.Count; i++)        // 인덱스가 필요할 때
    activeEnemies[i].Tick();

다만 IList<T> / IEnumerable<T> 인터페이스 변수에 담은 뒤 foreach를 돌리는 건 다릅니다. 이때는 인터페이스 디스패치 때문에 enumerator가 박싱돼 가비지가 발생합니다. 선언 타입을 그대로 List<T>로 유지하는 것이 핵심입니다.


7. 함정과 주의사항

7.1 인덱스 범위 초과 — [Count]는 존재하지 않는다

C#
// ❌ Before — ArgumentOutOfRangeException
var list = new List<int> { 1, 2, 3 };  // Count = 3, 유효 인덱스 0~2
list[3] = 99;       // Count(3)는 유효 인덱스가 아님 — 예외
C#
// ✅ After — Add를 사용해야 함
var list = new List<int> { 1, 2, 3 };
list.Add(99);       // 인덱스 3에 99가 추가됨 (Count = 4)

list[i] = v는 이미 존재하는 인덱스만 덮어씁니다. 새 자리를 만들고 싶으면 반드시 AddInsert를 써야 합니다. C# 신입의 단골 실수입니다.

7.2 foreach 도중 수정 — _version 검사

C#
// ❌ Before — InvalidOperationException
foreach (var e in activeEnemies)
{
    if (e.IsDead)
        activeEnemies.RemoveAt(activeEnemies.IndexOf(e));   // 순회 중 변경
}

이미 6.2에서 다뤘지만, 핵심은 List<T> Enumerator가 시작 시점의 _version 스냅샷을 저장해 두고, 매 MoveNext마다 현재 _version과 비교한다는 점입니다. 일치하지 않으면 즉시 예외를 던지므로 미묘한 버그로 발전하지 않고 즉시 발견됩니다.

C#
// ✅ After — 역순 for 또는 RemoveAll
activeEnemies.RemoveAll(e => e.IsDead);

7.3 List 대입은 참조 복사 — 사본을 원하면 명시해야 한다

C#
// ❌ Before — 같은 리스트를 두 변수가 가리킴
var original = new List<int> { 1, 2, 3 };
var copy = original;
copy.Add(4);
Console.WriteLine(original.Count);  // 4 (의도와 다름!)

List<T>는 참조 타입(reference type, 변수가 객체의 메모리 주소를 담는 타입)이므로 =는 주소만 복사합니다. 둘이 같은 인스턴스를 가리키므로 한쪽 변경이 다른 쪽에도 보입니다.

C#
// ✅ After — 새 리스트 생성으로 명시적 복사
var original = new List<int> { 1, 2, 3 };
var copy = new List<int>(original);   // 또는 original.ToList();
copy.Add(4);
Console.WriteLine(original.Count);    // 3 (변경 없음)

new List<T>(원본)은 내부에서 Array.Copy로 요소를 복제하므로 요소가 많을수록 비용이 큽니다. 매 프레임 큰 리스트를 복제하는 코드는 의심해야 합니다.

7.4 Clear 후에도 Capacity는 유지된다

C#
var list = new List<int>();
for (int i = 0; i < 10000; i++) list.Add(i);
Console.WriteLine($"Count={list.Count}, Capacity={list.Capacity}"); // 10000, 16384

list.Clear();
Console.WriteLine($"Count={list.Count}, Capacity={list.Capacity}"); // 0, 16384 — 메모리는 그대로

Clear()_size를 0으로 만들 뿐 내부 배열을 버리지 않습니다. 재사용을 위한 의도된 설계 지만, 메모리를 정말 회수하고 싶다면 list.Capacity = 0 또는 list.TrimExcess()를 호출해야 합니다. 풀(pool) 구현에서는 오히려 Capacity가 유지되는 것이 이득이므로, 이 동작을 알고 의식적으로 활용합니다.


8. C# 버전별 변화

C# 버전 변화
C# 1.0 (.NET 1.x) 제네릭 없음. ArrayList(object 기반)만 존재 — 박싱·언박싱 발생
C# 2.0 (2005) 제네릭 도입과 함께 System.Collections.Generic.List<T> 등장
C# 3.0 (2007) 컬렉션 초기화자 도입 — new List<int> { 1, 2, 3 } 문법 가능
C# 6.0 using static으로 LINQ 임포트 간소화 (List<T> 활용성 ↑)
.NET Core 2.1 Span<T> 도입으로 List<T> 내부 배열을 복사 없이 다루는 길 열림
.NET 5+ EnsureCapacity(int) 메서드 정식 추가 (이전엔 Capacity 직접 대입)
C# 12 (2023) 컬렉션 식(collection expression) List<int> nums = [1, 2, 3]; 도입 — 기존 new List<int> { ... }와 동등하게 컴파일

신입 개발자가 가장 많이 섞어 쓰는 두 문법을 비교해 둡니다.

C#
// C# 3.0 컬렉션 초기화자 (2007~)
List<int> a = new List<int> { 1, 2, 3 };

// C# 12 컬렉션 식 (2023~)
List<int> b = [1, 2, 3];

두 코드는 IL이 거의 같습니다. new List<int>() + Add(1) + Add(2) + Add(3) 패턴으로 풀어진다는 점에서 동일합니다. 컬렉션 식 쪽이 짧고 배열·리스트·Span 등 여러 타깃에 같은 문법을 쓸 수 있다는 게 장점이며, 본 시리즈의 PART 9-7 글에서 단독으로 다룹니다.


9. 정리 — 이것만 기억하면 됩니다

  • List<T>는 내부에 T[]를 들고 있는 동적 배열입니다. _items, _size, _version 세 필드가 핵심입니다.
  • Count는 실제 요소 수, Capacity는 내부 배열 길이. 가득 차면 Capacity가 자동으로 2배로 증가하고 이전 배열은 GC 대상이 됩니다.
  • 컬렉션 초기화자 new List<int> { 1, 2, 3 } 은 IL 레벨에서 Add(1) Add(2) Add(3) 호출로 풀립니다.
  • 인덱스 접근·Count·Add는 빠르고(O(1) 또는 분할 상환 O(1)), Remove/Insert/Contains는 느립니다(O(n)). 검색이 잦은 용도에는 HashSet<T>Dictionary<TKey, TValue>를 씁니다.
  • List<T>는 값 타입을 박싱 없이 저장합니다. IL에서 box 명령어가 등장하지 않는 이유이며, 이게 옛 ArrayList 대비 가장 큰 성능 우위입니다.
  • Unity 핫패스에서는 new List<T>(예상 크기)로 Capacity를 미리 잡아 리사이징을 0회로 만듭니다.
  • foreach 도중 Add/RemoveInvalidOperationException 입니다. 일괄 삭제는 RemoveAll 또는 역순 for를 씁니다.
  • 현대 Unity에서 List<T>foreach는 GC를 만들지 않습니다valuetype Enumerator + constrained. 호출 덕분입니다. 단, IList<T> 인터페이스 변수로 받아서 foreach를 돌리면 박싱이 발생합니다.
  • Clear()는 Capacity를 유지합니다. 메모리를 회수하고 싶으면 TrimExcess() 또는 Capacity = 0을 호출합니다.

다음 글(PART 9-2)에서는 List<T>로는 풀리지 않는 "키 기반 빠른 조회" 문제를 Dictionary<TKey, TValue>로 해결하는 방법을 다룹니다.

반응형

+ Recent posts