[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<T>는 자기가 가진 배열의 현재 크기(Capacity) 와 실제로 쓰이는 칸의 개수(Count) 를 따로 관리합니다. 처음에 8칸짜리 배열을 받아두고 데이터는 3개만 들어 있어도 정상입니다. 9번째 데이터를 넣으려는 순간 비로소 16칸짜리 배열을 새로 받고 모두 옮겨 담습니다.
2.2 가장 짧은 사용 코드
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배 증가 메커니즘

핵심은 이전 배열이 GC(Garbage Collector, 메모리를 자동으로 회수하는 런타임 구성요소) 대상이 된다는 점입니다. 리사이징은 새 배열 할당 + 기존 데이터 전체 복사 + 옛 배열 폐기를 한 번에 수행하므로 평소 Add보다 한참 비쌉니다.
이 동작을 직접 관찰해 봅시다.
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 호출이 어떻게 디스패치되는지 확인할 수 있습니다.
.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. Add가 callvirt로 디스패치되는 이유
List<T>는 IList<T>를 구현하는 가상(virtual) 메서드 디스패치 대상이므로 callvirt 명령어가 사용됩니다. callvirt는 호출 직전에 인스턴스가 null인지 검사하는 점이 call과의 차이입니다. JIT(Just-In-Time, 실행 직전에 IL을 기계어로 변환하는 컴파일러)는 호출 대상이 봉인된 클래스이거나 정확한 타입을 알 수 있으면 이 가상 디스패치를 정적 호출로 디버추얼화(devirtualize)하여 사실상의 비용을 0에 가깝게 만듭니다.
3. Add 안에 숨어 있는 분기
IL에는 안 보이지만 Add 메서드 내부는 대략 다음과 같은 형태입니다.
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 } 한 줄은 신입 개발자가 가장 많이 쓰는 표현이지만, 컴파일러가 이걸 어떻게 풀어내는지를 정확히 아는 사람은 의외로 적습니다.
public static int InitDemo()
{
var nums = new List<int> { 1, 2, 3 };
return nums.Count;
}
이 함수를 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]는 무엇을 호출하는가
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_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 — 뒤 요소가 한 칸씩 앞으로 당겨진다
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)은 단순히 칸을 비우는 게 아니라 그 뒤의 모든 요소를 한 칸씩 앞으로 이동시킵니다. n개짜리 리스트에서 앞쪽 인덱스를 지우면 거의 n번의 복사가 발생합니다. 이게 Remove/RemoveAt이 O(n) 시간 복잡도(time complexity, 입력 크기 n에 따라 연산 횟수가 어떻게 늘어나는지를 표기하는 척도)를 갖는 이유입니다.
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. RemoveAt도 callvirt — 그러나 본문은 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 리사이징이 발생합니다.
// ❌ 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, 가비지 수집이 갑자기 길어져 프레임이 끊기는 현상)로 이어집니다.
// ✅ 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인 적을 한꺼번에 제거하는 코드를 신입은 보통 이렇게 씁니다.
// ❌ Before — foreach 안에서 Remove → InvalidOperationException
foreach (var enemy in activeEnemies)
{
if (enemy.Health <= 0)
activeEnemies.Remove(enemy); // 컬렉션 변경 감지 → 예외
}
foreach는 진입 시점의 _version을 기억해 두고 매 단계에서 비교하는데, Remove가 _version을 증가시켜 버리므로 MoveNext가 InvalidOperationException을 던집니다.
// ✅ After 방법 1 — RemoveAll (가장 간단하고 효율적)
activeEnemies.RemoveAll(e => e.Health <= 0);
// ✅ 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를 쓰면 됩니다.
// 둘 다 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]는 존재하지 않는다
// ❌ Before — ArgumentOutOfRangeException
var list = new List<int> { 1, 2, 3 }; // Count = 3, 유효 인덱스 0~2
list[3] = 99; // Count(3)는 유효 인덱스가 아님 — 예외
// ✅ After — Add를 사용해야 함
var list = new List<int> { 1, 2, 3 };
list.Add(99); // 인덱스 3에 99가 추가됨 (Count = 4)
list[i] = v는 이미 존재하는 인덱스만 덮어씁니다. 새 자리를 만들고 싶으면 반드시 Add나 Insert를 써야 합니다. C# 신입의 단골 실수입니다.
7.2 foreach 도중 수정 — _version 검사
// ❌ Before — InvalidOperationException
foreach (var e in activeEnemies)
{
if (e.IsDead)
activeEnemies.RemoveAt(activeEnemies.IndexOf(e)); // 순회 중 변경
}
이미 6.2에서 다뤘지만, 핵심은 List<T> Enumerator가 시작 시점의 _version 스냅샷을 저장해 두고, 매 MoveNext마다 현재 _version과 비교한다는 점입니다. 일치하지 않으면 즉시 예외를 던지므로 미묘한 버그로 발전하지 않고 즉시 발견됩니다.
// ✅ After — 역순 for 또는 RemoveAll
activeEnemies.RemoveAll(e => e.IsDead);
7.3 List 대입은 참조 복사 — 사본을 원하면 명시해야 한다
// ❌ Before — 같은 리스트를 두 변수가 가리킴
var original = new List<int> { 1, 2, 3 };
var copy = original;
copy.Add(4);
Console.WriteLine(original.Count); // 4 (의도와 다름!)
List<T>는 참조 타입(reference type, 변수가 객체의 메모리 주소를 담는 타입)이므로 =는 주소만 복사합니다. 둘이 같은 인스턴스를 가리키므로 한쪽 변경이 다른 쪽에도 보입니다.
// ✅ 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는 유지된다
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# 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/Remove는InvalidOperationException입니다. 일괄 삭제는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>로 해결하는 방법을 다룹니다.
'C# 기초' 카테고리의 다른 글
| [PART9.컬렉션 기본 사용법(3/8)] HashSet<T> — 중복 없는 집합, Add 한 번으로 끝내는 이유 (0) | 2026.05.04 |
|---|---|
| [PART9.컬렉션 기본 사용법(2/8)] Dictionary<TKey, TValue> — 키로 즉시 찾는 컬렉션 (0) | 2026.05.04 |
| [PART8.상속과 인터페이스 사용법(11/11)] 업캐스트와 다운캐스트 — 안전한 타입 변환 (0) | 2026.05.03 |
| [PART8.상속과 인터페이스 사용법(10/11)] is · as · 타입 패턴 — 안전한 타입 검사 세 가지 방법 (0) | 2026.05.03 |
| [PART8.상속과 인터페이스 사용법(9/11)] 명시적 인터페이스 구현 — `IFoo.Method()` (1) | 2026.05.03 |
