EveryDay.DevUp

[PART8.컬렉션과 LINQ(1/6)] List<T> vs Array — 언제 무엇을 선택하는가 본문

C# 심화

[PART8.컬렉션과 LINQ(1/6)] List<T> vs Array — 언제 무엇을 선택하는가

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

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[]) — 고정 크기

배열: IL이 증명하는 직접 접근

배열의 요소 접근은 CLR(Common Language Runtime, .NET 프로그램을 실행하는 가상 머신)이 전용 IL 명령어를 제공할 만큼 중요한 연산이다.

C#
int[] numbers = new int[5];
numbers[0] = 10;
numbers[1] = 20;
int value = numbers[0];
IL
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.i4ldelem.i4는 배열 전용 IL 명령어다. 메서드 호출 없이 CLR이 직접 메모리 주소를 계산해서 읽고 쓴다.

List<T>: 메서드 호출을 거치는 접근

C#
List<int> numbers = new List<int>();
numbers.Add(10);
numbers.Add(20);
int value = numbers[0];
IL
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> Grow 과정 — Add 호출 시 내부 배열 확장

List<T> 생성자에 용량(capacity)을 지정하면 첫 내부 배열의 크기를 미리 잡는다. 지정하지 않으면 기본 capacity는 0이고, 첫 Add 시 4로 시작한 뒤 부족할 때마다 2배로 늘린다.

IL에서 두 방식의 차이를 확인해 보자.

❌ Capacity 미지정 — 재할당 반복

C#
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
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 지정 — 재할당 없음

C#
var list = new List<int>(100);        // Capacity = 100, 단 한 번의 배열 할당
for (int i = 0; i < 100; i++)
    list.Add(i);                      // Grow 발생하지 않음
IL
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, 매 프레임 반복 실행되는 코드 경로)에서 순회 방식의 차이가 누적될 수 있다.

C#
// 배열 순회
public static int ArrayForLoop(int[] arr)
{
    int sum = 0;
    for (int i = 0; i < arr.Length; i++)
        sum += arr[i];
    return sum;
}
IL
// 배열 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
C#
// List 순회
public static int ListForLoop(List<int> list)
{
    int sum = 0;
    for (int i = 0; i < list.Count; i++)
        sum += list[i];
    return sum;
}
IL
// 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 폭탄

C#
// Unity Update에서 매 프레임 호출된다고 가정
public static List<int> BadNewEveryFrame()
{
    var enemies = new List<int>();  // 매 프레임 new → 힙 할당
    enemies.Add(1);
    enemies.Add(2);
    return enemies;
}
IL
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 제로

C#
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
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] — 매번 새 객체

C#
public static int[] BadEmptyArray()
{
    return new int[0];    // 크기 0이지만 매번 힙에 새 객체 할당
}
IL
IL_0000: ldc.i4.0
IL_0001: newarr     [System.Runtime]System.Int32    // 매 호출마다 새 빈 배열 할당
IL_0006: ret

✅ After: Array.Empty<T>() — 캐시된 싱글턴

C#
public static int[] GoodEmptyArray()
{
    return Array.Empty<int>();  // 타입별로 한 번만 할당된 정적 인스턴스 반환
}
IL
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) 이동

C#
public static void NaiveRemove(List<int> list, int index)
{
    list.RemoveAt(index);   // 뒤의 요소를 모두 한 칸씩 앞으로 이동
}
IL
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) 삭제 (순서가 중요하지 않을 때)

C#
public static void SwapBackRemove(List<int> list, int index)
{
    list[index] = list[list.Count - 1];   // 마지막 요소를 삭제할 위치로 복사
    list.RemoveAt(list.Count - 1);        // 마지막 요소 제거 — 이동 없음
}
IL
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>에서 컴파일러가 생성하는 코드가 다르다.

C#
// 배열의 foreach
public static int ForeachArray(int[] arr)
{
    int sum = 0;
    foreach (var n in arr)
        sum += n;
    return sum;
}
IL
// 배열 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         // 배열 길이 직접 접근
C#
// List의 foreach
public static int ForeachList(List<int> list)
{
    int sum = 0;
    foreach (var n in list)
        sum += n;
    return sum;
}
IL
// 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>.Enumeratorstruct이므로 힙 할당은 없지만, 메서드 호출 오버헤드가 있다.
대부분의 상황에서 이 차이는 무시할 수 있다. 하지만 매 프레임 수만 번 순회하는 핫패스에서는 배열 + for 루프가 가장 빠르다.

함정 3 — ArrayList의 박싱/언박싱 (역사적 함정)

.NET 2.0 이전에는 제네릭(generic, 타입을 매개변수로 받아 재사용 가능한 코드를 작성하는 기능)이 없었다. ArrayList는 모든 것을 object로 저장했다.

❌ ArrayList — 박싱 발생

박싱(Boxing): 값 타입(int, float 등)을 object로 변환할 때, 힙에 새 객체를 할당하고 값을 복사하는 과정. 반대로 object에서 값 타입으로 되돌리는 것을 언박싱(Unboxing)이라 한다.
C#
var list = new ArrayList();
list.Add(42);                // int → object 박싱
int value = (int)list[0];   // object → int 언박싱
IL
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> — 박싱 없음

C#
var list = new List<int>();
list.Add(42);                // int 그대로 저장
int value = list[0];         // int 그대로 반환
IL
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 비교: ArrayListboxunbox.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 모두에서 복사 없는 슬라이싱이 가능해졌다.

C#
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>의 편의성과 배열의 성능을 동시에 얻을 수 있다.

C#
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의 용량을 사전에 확보할 수 있게 되었다.

C#
var list = new List<int>();
list.EnsureCapacity(1000);  // 이미 생성된 List에 사후적으로 용량 확보

C# 12 — 컬렉션 표현식 (2023)

C#
// 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>로 즉시 교체