| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |
- 암호화
- 게임개발
- sha
- 최적화
- AES
- 패스트캠퍼스
- base64
- 환급챌린지
- 직장인자기계발
- RSA
- 가이드
- Job 시스템
- adfit
- Framework
- DotsTween
- 오공완
- 패스트캠퍼스후기
- Custom Package
- C#
- 샘플
- 2D Camera
- Tween
- job
- 직장인공부
- Unity Editor
- ui
- unity
- TextMeshPro
- Dots
- 프레임워크
- Today
- Total
EveryDay.DevUp
[PART8.컬렉션과 LINQ(1/6)] List<T> vs Array — 언제 무엇을 선택하는가 본문
List<T> vs Array — 언제 무엇을 선택하는가
Unity 모바일 게임에서 데이터를 담을 그릇을 고를 때, 배열과 리스트의 내부 동작을 이해하면 GC 스파이크 없는 안정적인 프레임을 유지할 수 있다.
문제 제기
적을 처치하면 드롭 아이템 목록을 만들어야 한다. 당신은 int[]를 쓸 것인가, List<int>를 쓸 것인가?
"둘 다 되니까 아무거나" — 이렇게 생각하면 Unity 모바일 환경에서 매 프레임 GC(Garbage Collector, 메모리를 자동으로 회수하는 런타임 구성요소) 스파이크가 터지거나, 고정 크기 배열에 무리하게 요소를 추가하려다 버그가 생긴다.
배열은 크기가 고정이고, List<T>는 크기가 동적이다. 이 한 줄이 전부처럼 보이지만, 실제로는 메모리 레이아웃, IL(Intermediate Language, .NET 컴파일러가 생성하는 중간 언어) 명령어, JIT(Just-In-Time, 실행 시점에 IL을 기계어로 변환하는 컴파일러) 최적화까지 차이가 있다.
이 글에서는 배열과 List<T>의 내부 구조를 IL 수준까지 파고들어, 언제 무엇을 선택해야 하는지 명확한 판단 기준을 세운다.
개념 정의
배열 — 사물함
배열은 학교 복도에 나란히 붙어 있는 사물함과 같다. 사물함 수는 설치할 때 정해지고, 나중에 한 칸을 더 붙이거나 뺄 수 없다. 3번 사물함을 열려면 복도 시작점에서 3칸만 걸어가면 된다 — 이것이 인덱스 접근 O(1)이다.
List<T>는 이 사물함에 관리인이 붙어 있는 구조다. 사물함이 꽉 차면 관리인이 더 큰 복도를 빌려서 기존 짐을 모두 옮겨 담는다. 편리하지만, 이사 비용이 든다.
![배열 (T[]) — 고정 크기](https://blog.kakaocdn.net/dna/dvj4D7/dJMcaakwDeP/AAAAAAAAAAAAAAAAAAAAAD72njzWmanAOEGGDblTCS3cP5xuLSTu--NfFl8MbnwS/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&expires=1777561199&allow_ip=&allow_referer=&signature=TZVCys1nfdbg%2FIjd5Lbw8B7BNvY%3D)
배열: IL이 증명하는 직접 접근
배열의 요소 접근은 CLR(Common Language Runtime, .NET 프로그램을 실행하는 가상 머신)이 전용 IL 명령어를 제공할 만큼 중요한 연산이다.
int[] numbers = new int[5];
numbers[0] = 10;
numbers[1] = 20;
int value = numbers[0];
IL_0000: ldc.i4.5
IL_0001: newarr [System.Runtime]System.Int32 // 힙에 int[5] 배열 할당
IL_0006: dup
IL_0007: ldc.i4.0
IL_0008: ldc.i4.s 10
IL_000a: stelem.i4 // 배열[0]에 10 저장 — 전용 IL 명령어
IL_000b: dup
IL_000c: ldc.i4.1
IL_000d: ldc.i4.s 20
IL_000f: stelem.i4 // 배열[1]에 20 저장
IL_0010: ldc.i4.0
IL_0011: ldelem.i4 // 배열[0] 값 로드 — 전용 IL 명령어
핵심: stelem.i4와 ldelem.i4는 배열 전용 IL 명령어다. 메서드 호출 없이 CLR이 직접 메모리 주소를 계산해서 읽고 쓴다.
List<T>: 메서드 호출을 거치는 접근
List<int> numbers = new List<int>();
numbers.Add(10);
numbers.Add(20);
int value = numbers[0];
IL_0000: newobj instance void class List`1<int32>::.ctor()
IL_0005: dup
IL_0006: ldc.i4.s 10
IL_0008: callvirt instance void class List`1<int32>::Add(!0) // 메서드 호출
IL_000d: dup
IL_000e: ldc.i4.s 20
IL_0010: callvirt instance void class List`1<int32>::Add(!0) // 메서드 호출
IL_0015: ldc.i4.0
IL_0016: callvirt instance !0 class List`1<int32>::get_Item(int32) // 인덱서도 메서드 호출
핵심: List<T>의 모든 연산은 callvirt(가상 메서드 호출)를 거친다. 배열의 ldelem/stelem과 달리, 인덱서 접근조차 메서드 호출이다. JIT가 이를 인라인(inline, 메서드 호출을 제거하고 본문을 직접 삽입하는 최적화)할 수 있지만, 배열만큼의 최적화 보장은 없다.
배열은 CLR이 직접 제어하는 1급 시민이고, List<T>는 배열 위에 올라탄 래퍼(wrapper) 클래스다.
내부 동작
List<T>의 Grow 전략 — 이사의 비용
List<T>가 편리한 이유는 Add만 호출하면 알아서 커지기 때문이다. 하지만 그 "알아서"의 대가를 정확히 알아야 한다.

List<T> 생성자에 용량(capacity)을 지정하면 첫 내부 배열의 크기를 미리 잡는다. 지정하지 않으면 기본 capacity는 0이고, 첫 Add 시 4로 시작한 뒤 부족할 때마다 2배로 늘린다.
IL에서 두 방식의 차이를 확인해 보자.
❌ Capacity 미지정 — 재할당 반복
var list = new List<int>(); // Capacity = 0
for (int i = 0; i < 100; i++)
list.Add(i); // 4 → 8 → 16 → 32 → 64 → 128 총 6번 Grow
IL_0000: newobj instance void class List`1<int32>::.ctor() // 기본 생성자, Capacity=0
IL_0005: stloc.0
// ... 루프 내 Add 호출
IL_000a: ldloc.0
IL_000b: ldloc.1
IL_000c: callvirt instance void class List`1<int32>::Add(!0) // 내부에서 Grow 판단
✅ Capacity 지정 — 재할당 없음
var list = new List<int>(100); // Capacity = 100, 단 한 번의 배열 할당
for (int i = 0; i < 100; i++)
list.Add(i); // Grow 발생하지 않음
IL_0000: ldc.i4.s 100
IL_0002: newobj instance void class List`1<int32>::.ctor(int32) // int 인자 생성자
IL_0007: stloc.0
// ... 동일한 루프
IL 구조는 거의 동일하지만, .ctor() vs .ctor(int32) 차이가 런타임에서 Grow 횟수를 결정한다. 100개를 넣을 때 Capacity 미지정은 6번의 배열 재할당과 5개의 가비지 배열을 만든다. Capacity를 지정하면 이 모든 비용이 사라진다.
배열 vs List — for 루프 순회의 IL 차이
성능에 민감한 Unity 핫패스(hot path, 매 프레임 반복 실행되는 코드 경로)에서 순회 방식의 차이가 누적될 수 있다.
// 배열 순회
public static int ArrayForLoop(int[] arr)
{
int sum = 0;
for (int i = 0; i < arr.Length; i++)
sum += arr[i];
return sum;
}
// 배열 for 루프 IL
IL_0006: ldloc.0 // sum
IL_0007: ldarg.0 // arr
IL_0008: ldloc.1 // i
IL_0009: ldelem.i4 // arr[i] — 전용 IL 명령어, 메서드 호출 없음
IL_000a: add
// ...
IL_0012: ldlen // arr.Length — 필드 직접 접근
IL_0013: conv.i4
IL_0014: blt.s IL_0006
// List 순회
public static int ListForLoop(List<int> list)
{
int sum = 0;
for (int i = 0; i < list.Count; i++)
sum += list[i];
return sum;
}
// List for 루프 IL
IL_0006: ldloc.0 // sum
IL_0007: ldarg.0 // list
IL_0008: ldloc.1 // i
IL_0009: callvirt instance !0 class List`1<int32>::get_Item(int32) // 메서드 호출
IL_000e: add
// ...
IL_0016: callvirt instance int32 class List`1<int32>::get_Count() // 메서드 호출
IL_001b: blt.s IL_0006
차이 분석:
- 배열:
ldelem.i4(전용 명령어) +ldlen(필드 직접 읽기). JIT가 루프의 범위 검사(bounds check)를 루프 밖으로 호이스팅(hoisting, 반복되는 검사를 루프 시작 전에 한 번만 수행하도록 옮기는 최적화)한다. - List:
callvirt get_Item+callvirt get_Count. 매 반복마다 두 번의 가상 메서드 호출. JIT가 인라인할 수 있지만 보장되지 않는다.
대부분의 상황에서 이 차이는 무시할 수 있다. 하지만 수만 개의 요소를 매 프레임 순회하는 핫패스에서는 배열이 유리하다.
실전 적용
판단 기준 — 언제 배열, 언제 List

Before/After 1 — 매 프레임 new List 생성
Unity의 Update()에서 매 프레임 새 List를 만드는 것은 가장 흔한 실수 중 하나다.
❌ Before: 매 프레임 new List — GC 폭탄
// Unity Update에서 매 프레임 호출된다고 가정
public static List<int> BadNewEveryFrame()
{
var enemies = new List<int>(); // 매 프레임 new → 힙 할당
enemies.Add(1);
enemies.Add(2);
return enemies;
}
IL_0000: newobj instance void class List`1<int32>::.ctor() // 매번 새 객체 + 내부 배열 할당
IL_0005: dup
IL_0006: ldc.i4.1
IL_0007: callvirt instance void class List`1<int32>::Add(!0)
IL_000c: dup
IL_000d: ldc.i4.2
IL_000e: callvirt instance void class List`1<int32>::Add(!0)
IL_0013: ret
✅ After: 필드에 선언하고 Clear 재사용 — GC 제로
private static readonly List<int> _enemies = new List<int>();
public static List<int> GoodClearAndReuse()
{
_enemies.Clear(); // 내부 배열 유지, Count만 0으로
_enemies.Add(1);
_enemies.Add(2);
return _enemies;
}
IL_0000: ldsfld class List`1<int32> ListReuse::_enemies // 정적 필드에서 기존 객체 로드
IL_0005: callvirt instance void class List`1<int32>::Clear() // Count=0, 배열은 유지
IL_000a: ldsfld class List`1<int32> ListReuse::_enemies
IL_000f: ldc.i4.1
IL_0010: callvirt instance void class List`1<int32>::Add(!0)
IL_0015: ldsfld class List`1<int32> ListReuse::_enemies
IL_001a: ldc.i4.2
IL_001b: callvirt instance void class List`1<int32>::Add(!0)
IL_0020: ldsfld class List`1<int32> ListReuse::_enemies
IL_0025: ret
IL 비교: Before는 newobj로 매번 힙에 새 객체를 만든다. After는 ldsfld로 이미 존재하는 정적 필드를 가져오므로 힙 할당이 0이다. Clear()는 내부 배열을 버리지 않고 Count만 리셋한다.
Before/After 2 — 빈 배열 반환
"결과가 없음"을 표현할 때 빈 배열을 반환하는 패턴이 있다.
❌ Before: new int[0] — 매번 새 객체
public static int[] BadEmptyArray()
{
return new int[0]; // 크기 0이지만 매번 힙에 새 객체 할당
}
IL_0000: ldc.i4.0
IL_0001: newarr [System.Runtime]System.Int32 // 매 호출마다 새 빈 배열 할당
IL_0006: ret
✅ After: Array.Empty<T>() — 캐시된 싱글턴
public static int[] GoodEmptyArray()
{
return Array.Empty<int>(); // 타입별로 한 번만 할당된 정적 인스턴스 반환
}
IL_0000: call !!0[] [System.Runtime]System.Array::Empty<int32>() // 정적 캐시 반환
IL_0005: ret
IL 비교: newarr는 매번 힙 할당을 발생시킨다. Array::Empty<int32>()는 제네릭 타입별로 단 한 번만 생성된 정적 배열을 반환하므로 GC 부하가 없다.
함정과 주의사항
함정 1 — List 중간 삭제의 숨겨진 O(n) 비용
게임에서 적이 죽으면 목록에서 제거한다. RemoveAt(index)를 쓰면 그 뒤의 모든 요소가 한 칸씩 앞으로 이동한다. 적이 1000마리면 최악의 경우 999번 복사가 발생한다.
❌ 단순 RemoveAt — O(n) 이동
public static void NaiveRemove(List<int> list, int index)
{
list.RemoveAt(index); // 뒤의 요소를 모두 한 칸씩 앞으로 이동
}
IL_0000: ldarg.0
IL_0001: ldarg.1
IL_0002: callvirt instance void class List`1<int32>::RemoveAt(int32) // 내부에서 Array.Copy
IL_0007: ret
✅ SwapBack 패턴 — O(1) 삭제 (순서가 중요하지 않을 때)
public static void SwapBackRemove(List<int> list, int index)
{
list[index] = list[list.Count - 1]; // 마지막 요소를 삭제할 위치로 복사
list.RemoveAt(list.Count - 1); // 마지막 요소 제거 — 이동 없음
}
IL_0000: ldarg.0
IL_0001: ldarg.1
IL_0002: ldarg.0
IL_0003: ldarg.0
IL_0004: callvirt instance int32 class List`1<int32>::get_Count()
IL_0009: ldc.i4.1
IL_000a: sub
IL_000b: callvirt instance !0 class List`1<int32>::get_Item(int32) // 마지막 요소 읽기
IL_0010: callvirt instance void class List`1<int32>::set_Item(int32, !0) // 삭제 위치에 덮어쓰기
IL_0015: ldarg.0
IL_0016: ldarg.0
IL_0017: callvirt instance int32 class List`1<int32>::get_Count()
IL_001c: ldc.i4.1
IL_001d: sub
IL_001e: callvirt instance void class List`1<int32>::RemoveAt(int32) // 마지막 위치 제거
IL_0023: ret
SwapBack은 순서를 보장하지 않지만, 적 목록처럼 순서가 중요하지 않은 경우에 RemoveAt의 O(n)을 O(1)로 줄인다. Unity에서 수백 개의 오브젝트를 관리할 때 이 차이는 프레임 드랍을 막는다.
함정 2 — foreach와 배열/List의 IL 차이
foreach는 편리하지만, 배열과 List<T>에서 컴파일러가 생성하는 코드가 다르다.
// 배열의 foreach
public static int ForeachArray(int[] arr)
{
int sum = 0;
foreach (var n in arr)
sum += n;
return sum;
}
// 배열 foreach — 컴파일러가 for 루프로 변환
IL_0002: ldarg.0
IL_0003: stloc.1 // 배열 참조를 로컬에 저장
IL_0004: ldc.i4.0
IL_0005: stloc.2 // 인덱스 0
IL_0006: br.s IL_0014
// loop
IL_0008: ldloc.1
IL_0009: ldloc.2
IL_000a: ldelem.i4 // 배열 전용 명령어, Enumerator 없음
IL_000b: stloc.3
// ...
IL_0016: ldlen // 배열 길이 직접 접근
// List의 foreach
public static int ForeachList(List<int> list)
{
int sum = 0;
foreach (var n in list)
sum += n;
return sum;
}
// List foreach — Enumerator 사용 + try/finally
IL_0002: ldarg.0
IL_0003: callvirt instance valuetype List`1/Enumerator<!0> class List`1<int32>::GetEnumerator()
IL_0008: stloc.1
.try
{
IL_0009: br.s IL_0017
IL_000b: ldloca.s 1
IL_000d: call instance !0 valuetype List`1/Enumerator<int32>::get_Current()
IL_0012: stloc.2
// ...
IL_0019: call instance bool valuetype List`1/Enumerator<int32>::MoveNext()
IL_001e: brtrue.s IL_000b
IL_0020: leave.s IL_0030
}
finally
{
IL_0022: ldloca.s 1
IL_0024: constrained. valuetype List`1/Enumerator<int32>
IL_002a: callvirt instance void System.IDisposable::Dispose()
IL_002f: endfinally
}
핵심 차이:
- 배열 foreach: 컴파일러가 인덱스 기반 for 루프로 변환한다.
Enumerator객체가 생성되지 않는다.ldelem.i4로 직접 접근한다. - List foreach:
GetEnumerator()로Enumerator구조체를 생성하고,try/finally블록으로 감싼다.List<T>.Enumerator는struct이므로 힙 할당은 없지만, 메서드 호출 오버헤드가 있다.
대부분의 상황에서 이 차이는 무시할 수 있다. 하지만 매 프레임 수만 번 순회하는 핫패스에서는 배열 + for 루프가 가장 빠르다.
함정 3 — ArrayList의 박싱/언박싱 (역사적 함정)
.NET 2.0 이전에는 제네릭(generic, 타입을 매개변수로 받아 재사용 가능한 코드를 작성하는 기능)이 없었다. ArrayList는 모든 것을 object로 저장했다.
❌ ArrayList — 박싱 발생
박싱(Boxing): 값 타입(int,float등)을object로 변환할 때, 힙에 새 객체를 할당하고 값을 복사하는 과정. 반대로object에서 값 타입으로 되돌리는 것을 언박싱(Unboxing)이라 한다.
var list = new ArrayList();
list.Add(42); // int → object 박싱
int value = (int)list[0]; // object → int 언박싱
IL_0006: ldc.i4.s 42
IL_0008: box [System.Runtime]System.Int32 // 힙에 새 객체 할당!
IL_000d: callvirt instance int32 ArrayList::Add(object)
IL_0013: ldc.i4.0
IL_0014: callvirt instance object ArrayList::get_Item(int32)
IL_0019: unbox.any [System.Runtime]System.Int32 // 언박싱
✅ List<T> — 박싱 없음
var list = new List<int>();
list.Add(42); // int 그대로 저장
int value = list[0]; // int 그대로 반환
IL_0006: ldc.i4.s 42
IL_0008: callvirt instance void class List`1<int32>::Add(!0) // box 없음!
IL_000d: ldc.i4.0
IL_000e: callvirt instance !0 class List`1<int32>::get_Item(int32) // unbox 없음!
IL 비교: ArrayList는 box와 unbox.any 명령어가 나타난다. 값을 넣을 때마다 힙에 새 객체가 생기고, 꺼낼 때마다 타입 검사 + 복사가 발생한다. List<T>는 제네릭 덕분에 !0 (타입 매개변수 T)를 직접 사용하므로 박싱이 완전히 사라진다.
오래된 코드에서ArrayList를 발견하면List<T>로 교체하라. 이것은 선택이 아니라 의무다.
C# 버전별 변화
.NET 1.0 — ArrayList 시대
제네릭이 없던 시절, 동적 컬렉션은 ArrayList뿐이었다. 위에서 본 것처럼 값 타입을 넣으면 무조건 박싱이 발생했다. 배열(int[])은 타입 안전했지만 크기가 고정이었다.
.NET 2.0 — List<T> 등장 (2005)
제네릭의 도입으로 List<T>가 탄생했다. 박싱/언박싱 문제가 해결되고, 컴파일 타임 타입 안전성이 확보되었다. 이때부터 ArrayList는 사실상 사용되지 않는다.
.NET Core 2.1 — Span<T> 도입 (2018)
Span<T> — 연속된 메모리 영역에 대한 타입 안전한 뷰(view). 배열, 스택 메모리, 네이티브 메모리 위에 복사 없이 슬라이싱할 수 있다. 힙 할당 없이 배열의 일부분을 다룰 수 있어 고성능 코드에서 핵심이다.
Span<T>의 등장으로 배열과 List 모두에서 복사 없는 슬라이싱이 가능해졌다.
int[] numbers = { 10, 20, 30, 40, 50 };
Span<int> slice = numbers.AsSpan(1, 3); // [20, 30, 40] — 복사 없이 원본 참조
.NET 5 — CollectionsMarshal.AsSpan (2020)
List<T>의 내부 배열에 직접 Span으로 접근할 수 있는 API가 추가되었다. List<T>의 편의성과 배열의 성능을 동시에 얻을 수 있다.
var list = new List<int> { 1, 2, 3 };
Span<int> span = CollectionsMarshal.AsSpan(list); // 내부 배열에 직접 접근
span[0] = 99; // list[0]도 99로 변경됨 — 복사 없음
.NET 6 — EnsureCapacity (2021)
List<T>.EnsureCapacity(int capacity) 메서드가 공식 API로 추가되어, 이미 생성된 List의 용량을 사전에 확보할 수 있게 되었다.
var list = new List<int>();
list.EnsureCapacity(1000); // 이미 생성된 List에 사후적으로 용량 확보
C# 12 — 컬렉션 표현식 (2023)
// Before (C# 11 이전)
int[] arr = new int[] { 1, 2, 3 };
List<int> list = new List<int> { 1, 2, 3 };
// After (C# 12)
int[] arr = [1, 2, 3];
List<int> list = [1, 2, 3];
[] 리터럴 하나로 배열, List, Span 등 다양한 컬렉션 타입을 생성할 수 있다. 컴파일러가 대상 타입에 따라 가장 효율적인 코드를 생성한다. 컬렉션 표현식은 문법 단축(syntactic sugar)이므로 IL 수준의 변화는 없다 — 대상 타입에 맞는 기존 IL이 그대로 생성된다.
정리
| 기준 | 배열 (T[]) |
List<T> |
|---|---|---|
| 크기 | 고정 — 생성 시 결정, 변경 불가 | 동적 — Add/Remove로 자동 조절 |
| IL 접근 | ldelem/stelem 전용 명령어 |
callvirt 메서드 호출 |
| 메모리 | 정확히 필요한 만큼 사용 | Capacity > Count일 때 여유 공간 존재 |
| JIT 최적화 | bounds check 호이스팅 보장 | 인라인 가능하나 보장 없음 |
| GC 영향 | 없음 (재할당 없으므로) | Grow 시 이전 배열이 가비지 |
| API | 기본만 (Length, Copy, Sort) | 풍부 (Add, Remove, Find, Sort, LINQ) |
| Unity 직렬화 | Inspector에서 직접 편집 가능 | Inspector에서 직접 편집 가능 |
핵심 체크리스트:
- [ ] 크기가 고정이면 → 배열
- [ ] 크기가 변하면 →
List<T>, 반드시 Capacity 지정 - [ ] 매 프레임 new List 금지 → 필드에 선언하고 Clear 재사용
- [ ] 빈 배열 반환 →
Array.Empty<T>()사용 - [ ] 중간 삭제가 빈번하고 순서 무관 → SwapBack 패턴
- [ ] 핫패스 대량 순회 → 배열 + for 루프
- [ ] 오래된 코드의
ArrayList발견 →List<T>로 즉시 교체
'C# 심화' 카테고리의 다른 글
| [PART7.제네릭(3/3)] 공변성과 반공변성 — in/out이 필요한 이유 (0) | 2026.04.05 |
|---|---|
| [PART7.제네릭(2/3)] 제네릭 제약 조건 — where T : 의 의미 (0) | 2026.04.05 |
| [PART7.제네릭(1/3)] 제네릭 — <T>가 해결하는 문제 (0) | 2026.04.05 |
| [PART6.문자열(3/3)] 문자열 포맷팅 — String.Format·보간·Span 기반 포맷 (0) | 2026.04.05 |
| [PART6.문자열(2/3)] string vs StringBuilder vs Span<char> — 문자열 처리 도구 선택 (0) | 2026.04.05 |
