[PART9.컬렉션 기본 사용법(8/8)] 배열 vs List<T> — 언제 무엇을 쓰는가
크기가 고정인가 변하는가 / Length vs Count / 인덱스 접근의 IL 차이로 보는 핫패스 선택
목차
1. [문제 제기] — 신입이 매일 마주치는 두 갈래 길
Unity에서 적 5마리를 관리하는 코드를 처음 짤 때, 누구나 한 번쯤 멈칫합니다.
Enemy[] enemies = new Enemy[5]; // (A)
List<Enemy> enemies = new List<Enemy>(); // (B)
기능적으로는 둘 다 적을 담을 수 있습니다. 그러나 다음 상황이 되면 차이가 드러납니다.
- 적이 죽고 새 적이 스폰되어서 개수가 매 프레임 변한다 → 배열은
Resize한 번에 새 배열을 통째로 만들어야 합니다. Update()에서 매 프레임enemies[i]로 좌표를 갱신한다 → 배열과List<T>의 IL이 달라서 미세하게 성능이 차이 납니다.GetComponentsInChildren<Collider>()를 호출했더니 매 프레임 GC가 튄다 → API에List<T>오버로드가 따로 있는 이유가 있습니다.- LINQ 결과를 받아서 저장하는데
.ToArray()와.ToList()중 뭐가 더 적합한지 모른다.
PART 9 의 마지막 글로, "크기가 고정인가, 변하는가" 라는 단순한 기준에서 출발해 두 자료구조의 내부 차이까지 한 번에 정리합니다. 이 글을 다 읽고 나면 코드 리뷰에서 "여긴 배열이 맞다"·"여긴 List<T> 가 맞다"를 근거로 말할 수 있게 됩니다.
선행 글: 이 글에서는List<T>자체의 내부 구조와IEnumerable/ICollection/IList인터페이스 계층은 이미 다뤘다고 가정합니다.foreach박싱·Enumerator 차이도 짧게만 언급합니다. 이 글의 초점은 "선택 기준" 입니다.
2. [개념 정의] — "고정 길이 메모리 블록" vs "늘어나는 박스"
비유로 먼저 잡기
- 배열: 미리 칸이 정해진 약 상자. 약사가 "30알짜리 통" 을 만들면 그 통에는 30알까지만 들어가고, 더 넣고 싶으면 새 통을 사야 합니다. 통의 크기를 늘릴 수는 없습니다.
List<T>: 안에 약 상자를 가지고 있는 약국 카운터. 카운터에 있는 통이 꽉 차면 점원(런타임)이 알아서 더 큰 통을 가져와 약을 옮겨 담습니다. 사용자는 "통을 바꿨다"는 사실을 모르고 그냥 계속 약을 추가합니다.
시각화 — 메모리 레이아웃 차이
![int[] arr = new int[5];](https://blog.kakaocdn.net/dna/oDo7M/dJMcaarEMyr/AAAAAAAAAAAAAAAAAAAAAPFMJUsVQYPg6OEcJ_cGX8A2a_YWMDBATlurOPKoNSW2/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&expires=1780239599&allow_ip=&allow_referer=&signature=79FrrEAMTMclp7jUscQX%2FVRP02o%3D)
배열은 메모리에 딱 5칸짜리 하나만 있습니다. List<int> 는 2개의 객체(List 인스턴스 + 내부 배열)로 구성되어 있고, 내부 배열의 길이가 곧 Capacity, 그중 채워진 칸 수가 Count 입니다. 이 분리가 가변의 비밀입니다.
기본 코드와 IL — 배열의 고정 길이 본질
using System;
class ArrayBasic
{
static void Main()
{
int[] arr = new int[3];
arr[0] = 10;
arr[1] = 20;
arr[2] = 30;
Console.WriteLine(arr.Length); // 3
// arr.Length = 5; // ❌ 컴파일 오류 — Length는 set 접근자가 없다
}
}
.method private hidebysig static void Main() cil managed
{
.entrypoint
.maxstack 3
.locals init (int32[] V_0)
ldc.i4.3
newarr [System.Runtime]System.Int32 // 길이 3짜리 int[] 인스턴스 생성
stloc.0
ldloc.0
ldc.i4.0
ldc.i4.s 10
stelem.i4 // arr[0] = 10 (배열 전용 IL)
ldloc.0
ldc.i4.1
ldc.i4.s 20
stelem.i4
ldloc.0
ldc.i4.2
ldc.i4.s 30
stelem.i4
ldloc.0
ldlen // arr.Length — 단일 IL 명령어
conv.i4
call void [System.Console]System.Console::WriteLine(int32)
ret
}
IL 해설:
newarr— 길이 3짜리int[]인스턴스를 힙에 할당. 이 명령어가 결정한 길이는 이후 변경 불가입니다.stelem.i4— 배열 요소 저장 전용 IL. 메서드 호출이 아니라 단일 명령어로 처리됩니다.ldlen— 배열 길이 조회 전용 IL. 단일 명령어로 끝납니다(메서드 호출 없음).int[]타입 자체에는 길이 정보가 박혀 있지 않지만(타입은int[]하나뿐), 인스턴스를 생성하는newarr가 길이를 함께 박아 넣고, 이후엔 변경할 방법이 IL 차원에서 제공되지 않습니다.
참고:arr.Length의 set 접근자는 존재하지 않으므로arr.Length = 5는 컴파일 오류입니다. 크기를 바꾸려면Array.Resize(ref arr, 5)를 호출해야 하는데, 이는 내부적으로 새 배열을 만들고 데이터를 복사한 뒤 참조를 갈아 끼우는 동작입니다. 인스턴스의 길이가 바뀐 게 아니라 변수가 가리키는 인스턴스가 바뀐 것입니다.
기본 코드와 IL — List<T> 의 가변 비밀
using System.Collections.Generic;
class ListBasic
{
static void Main()
{
List<int> list = new List<int>();
list.Add(10);
list.Add(20);
list.Add(30);
int first = list[0];
}
}
.method private hidebysig static void Main() cil managed
{
.entrypoint
.maxstack 2
.locals init (
class [System.Collections]System.Collections.Generic.List`1<int32> V_0,
int32 V_1
)
newobj instance void class [System.Collections]System.Collections.Generic.List`1<int32>::.ctor()
stloc.0
ldloc.0
ldc.i4.s 10
callvirt instance void class [System.Collections]System.Collections.Generic.List`1<int32>::Add(!0)
ldloc.0
ldc.i4.s 20
callvirt instance void class [System.Collections]System.Collections.Generic.List`1<int32>::Add(!0)
ldloc.0
ldc.i4.s 30
callvirt instance void class [System.Collections]System.Collections.Generic.List`1<int32>::Add(!0)
ldloc.0
ldc.i4.0
callvirt instance !0 class [System.Collections]System.Collections.Generic.List`1<int32>::get_Item(int32)
stloc.1
ret
}
IL 해설:
newobj ::.ctor()—List<int>인스턴스를 힙에 할당. 생성자 안에서 내부 배열은 아직 할당되지 않습니다 (.NET 6+ 기준_items = s_emptyArray).callvirt ... Add(!0)—Add는 메서드 호출입니다. 내부에서 Capacity 검사, 부족 시Grow()호출, 그렇지 않으면_items[_size++] = item.callvirt ... get_Item(int32)—list[0]은 단일 IL 명령어가 아니라 인덱서 메서드 호출로 컴파일됩니다. 이게 핵심 성능 차이의 원인입니다(섹션 3).
배열의 stelem.i4 · ldlen 은 단일 IL 명령어, List<T> 의 Add · 인덱서는 메서드 호출(callvirt) — 이 차이가 곧 핫패스 성능 차이로 이어집니다.
3. [내부 동작] — List<T> 가 늘어나는 메커니즘과 인덱서 호출 비용
Resize 메커니즘 — Capacity 2배 증가
List<T>.Add 가 내부에서 어떻게 동작하는지 시각화합니다.

List<T> 의 핵심 코드를 살펴봅니다 (.NET 8 BCL 발췌·요약).
public class List<T>
{
private const int DefaultCapacity = 4;
private T[] _items;
private int _size;
public void Add(T item)
{
T[] array = _items;
int size = _size;
if ((uint)size < (uint)array.Length)
{
// 빠른 경로: 빈 칸이 있으면 그냥 대입
_size = size + 1;
array[size] = item;
}
else
{
// 느린 경로: 용량 부족 → Grow + 추가
AddWithResize(item);
}
}
private void Grow(int capacity)
{
int newcapacity = _items.Length == 0 ? DefaultCapacity : 2 * _items.Length;
if ((uint)newcapacity > Array.MaxLength) newcapacity = Array.MaxLength;
if (newcapacity < capacity) newcapacity = capacity;
Capacity = newcapacity; // 내부적으로 새 배열 할당 + Array.Copy
}
public T this[int index]
{
get
{
if ((uint)index >= (uint)_size) ThrowHelper.ThrowArgumentOutOfRange_IndexException();
return _items[index];
}
}
}
해설:
Add의 빠른 경로는array[size] = item단순 대입 — 거의 배열 수준입니다.- 느린 경로(Capacity 부족)에서
Grow가 호출되어 2배 크기의 새 배열을 할당하고Array.Copy로 기존 데이터를 옮깁니다. 빈도는 낮지만 한 번 발생할 때 비용이 큽니다. - 처음
Add호출 시_items.Length == 0이므로 Capacity=4 부터 시작합니다 (4 → 8 → 16 → 32 → ...).
인덱스 접근의 IL 비교 — 배열 vs List<T>
같은 동작 vec.x += 0.1f 를 두 자료구조로 구현하고 IL 을 비교합니다.
using System.Collections.Generic;
class IndexCompare
{
static float SumArray(float[] arr)
{
float total = 0;
for (int i = 0; i < arr.Length; i++)
total += arr[i];
return total;
}
static float SumList(List<float> list)
{
float total = 0;
for (int i = 0; i < list.Count; i++)
total += list[i];
return total;
}
}
.method private hidebysig static float32 SumArray(float32[] arr) cil managed
{
// 루프 본문
ldloc.0 // total
ldarg.0 // arr
ldloc.1 // i
ldelem.r4 // arr[i] — 배열 요소 로드 전용 IL (단일 명령어)
add
stloc.0
ldloc.1
ldc.i4.1
add
stloc.1
// 루프 조건
ldloc.1
ldarg.0
ldlen // arr.Length — 단일 IL
conv.i4
blt.s // i < arr.Length 이면 점프
// ...
}
.method private hidebysig static float32 SumList(class [System.Collections]System.Collections.Generic.List`1<float32> list) cil managed
{
// 루프 본문
ldloc.0 // total
ldarg.0 // list
ldloc.1 // i
callvirt // list[i] — 인덱서는 가상 메서드 호출
instance !0 class [System.Collections]System.Collections.Generic.List`1<float32>::get_Item(int32)
add
stloc.0
ldloc.1
ldc.i4.1
add
stloc.1
// 루프 조건
ldloc.1
ldarg.0
callvirt // list.Count — 이것도 프로퍼티 호출
instance int32 class [System.Collections]System.Collections.Generic.List`1<float32>::get_Count()
blt.s
// ...
}
IL 해설 — 핫패스에서 결정적 차이:
- 배열:
ldelem.r4(단일 명령어로 요소 로드) +ldlen(단일 명령어로 길이 조회). List<T>:callvirt get_Item(가상 메서드 호출) +callvirt get_Count(또 다른 가상 메서드 호출). 한 번의 인덱스 접근마다 메서드 호출 오버헤드가 추가됩니다.- 게다가
get_Item내부에서 다시 한 번 경계 검사가 일어나고 그 안에서_items[index](즉ldelem)을 호출합니다. 즉 List 인덱스 접근은 배열 인덱스 접근 + 메서드 호출 오버헤드 입니다. - JIT 가 인라이닝하면 차이가 줄어들 수 있지만,
List<T>.this[]는 인터페이스 디스패치가 끼어들 수 있어 100% 인라이닝이 보장되진 않습니다.
추정: 단일 호출의 차이는 ns 수준이지만, UnityUpdate()에서 매 프레임 수만 번 반복되면 누적 차이가 커집니다. 정확한 측정은BenchmarkDotNet으로 확인해야 합니다 — 이 글에서는 IL 차이만 사실로 다룹니다.
Length(배열) vs Count(List) — 분리된 이유
언어 차원에서 두 멤버가 분리된 이유는 개념의 차이입니다.
| 멤버 | 자료구조 | 의미 | 가변? |
|---|---|---|---|
arr.Length |
배열 | 할당된 메모리 슬롯 전체 수 | 불변 (한 번 정해지면 고정) |
list.Count |
List<T> |
사용자가 채워 넣은 논리적 요소 수 | 가변 (Add/Remove로 변동) |
list.Capacity |
List<T> |
내부 배열의 길이 (재할당 없이 담을 수 있는 한도) | 가변 (Grow 시 증가) |
배열은 "할당한 만큼이 곧 유효한 칸"이므로 Length 하나면 충분합니다. List<T> 는 "할당된 칸(Capacity)" 과 "실제 채운 칸(Count)" 이 다를 수 있어 두 개념을 분리합니다.
숨겨진 사실: 배열도ICollection<T>를 구현하기 때문에Count가 존재하긴 합니다. 다만 명시적 인터페이스 구현으로 숨겨져 있어((ICollection<int>)arr).Count로 캐스팅해야 보입니다. 일반 사용에서는arr.Length만 쓰는 것이 자연스럽습니다.
4. [실전 적용] — Before/After와 Unity 핫패스
사례 1 — 매 프레임 컴포넌트 수집: 배열 반환 vs List 채우기
Unity 의 GetComponentsInChildren<T> 에는 두 가지 오버로드가 있습니다.
// (A) 매 호출마다 새 배열 할당 — GC 폭탄
T[] GetComponentsInChildren<T>();
// (B) 미리 만든 List<T>를 채워준다 — 무할당
void GetComponentsInChildren<T>(List<T> results);
❌ Before — 매 프레임 새 배열
using UnityEngine;
public class EnemyScanner : MonoBehaviour
{
void Update()
{
// 매 프레임 새 배열을 힙에 할당 → GC 압박
Collider[] cols = GetComponentsInChildren<Collider>();
for (int i = 0; i < cols.Length; i++)
{
// ...
}
}
}
✅ After — List 재사용
using System.Collections.Generic;
using UnityEngine;
public class EnemyScanner : MonoBehaviour
{
private readonly List<Collider> _buffer = new List<Collider>(32);
void Update()
{
_buffer.Clear(); // Count만 0으로, 내부 배열은 유지
GetComponentsInChildren<Collider>(_buffer); // 같은 List를 매번 채워 사용
for (int i = 0; i < _buffer.Count; i++)
{
// ...
}
}
}
// Before/After의 핵심 차이를 IL로 보고 싶다면 두 호출 부분만 추출
class HotPath
{
void BadFrame(GameObject go) => go.GetComponentsInChildren<Collider>();
void GoodFrame(GameObject go, List<Collider> buf) => go.GetComponentsInChildren<Collider>(buf);
}
.method instance void BadFrame(class [UnityEngine]UnityEngine.GameObject go) cil managed
{
ldarg.1
callvirt instance !!0[] [UnityEngine]UnityEngine.GameObject::GetComponentsInChildren<class [UnityEngine]UnityEngine.Collider>()
pop // 반환된 새 배열을 즉시 버려도 이미 힙에 할당됨
ret
}
.method instance void GoodFrame(class [UnityEngine]UnityEngine.GameObject go,
class [System.Collections]System.Collections.Generic.List`1<class [UnityEngine]UnityEngine.Collider> buf) cil managed
{
ldarg.1
ldarg.2
callvirt instance void [UnityEngine]UnityEngine.GameObject::GetComponentsInChildren<class [UnityEngine]UnityEngine.Collider>(class [System.Collections]System.Collections.Generic.List`1<!!0>)
ret
}
IL 해설:
- Before 의
GetComponentsInChildren<Collider>()호출은 반환 타입이Collider[]이므로 호출 시마다 새 배열을 힙에 할당합니다.pop으로 버려도 이미 할당은 끝난 뒤입니다. - After 의 List 오버로드는 반환값이
void— 새 객체를 만들지 않고 사용자가 넘긴 List 의 내부 배열만 재사용합니다. List 의Capacity가 이미 충분하면 단 한 바이트도 새로 할당하지 않습니다.
사례 2 — 인덱스 핫루프: 배열 우위
Unity 에서 적 좌표를 매 프레임 갱신하는 코드입니다.
❌ Before — List<T> 인덱서
public class EnemyMover : MonoBehaviour
{
public List<Transform> enemies; // 외부 주입
void Update()
{
for (int i = 0; i < enemies.Count; i++)
{
Transform t = enemies[i]; // callvirt get_Item
t.position += Vector3.forward * Time.deltaTime;
}
}
}
✅ After — 배열 + 길이 캐시
public class EnemyMover : MonoBehaviour
{
public Transform[] enemies; // 고정 개수면 배열 사용
void Update()
{
int len = enemies.Length; // ldlen 1회
for (int i = 0; i < len; i++)
{
Transform t = enemies[i]; // ldelem.ref 단일 IL
t.position += Vector3.forward * Time.deltaTime;
}
}
}
// After 의 인덱스 접근 부분만 발췌
ldarg.0
ldfld class [UnityEngine]UnityEngine.Transform[] EnemyMover::enemies
ldloc.0 // i
ldelem.ref // 배열에서 참조 요소 로드 — 단일 IL
stloc.1
IL 해설: ldelem.ref 한 줄로 끝나는 배열 인덱스 접근이, List<T> 의 callvirt get_Item 호출보다 짧고 인라이닝도 잘 됩니다. 적 개수가 라운드 시작 시점에 결정되어 변하지 않는다면 배열이 더 적합합니다.
단, 적이 죽고 새 적이 생성되어 개수가 자주 바뀐다면 List 가 맞습니다. "고정인지 가변인지" 가 첫 번째 기준입니다.
사례 3 — Span<T> 슬라이싱으로 GC 없이 부분 처리
배열의 일부만 처리하고 싶을 때, 새 배열을 만들지 않고 Span<T> 로 뷰만 잘라낼 수 있습니다.
using System;
class SpanSlice
{
static int SumRange(int[] data, int start, int count)
{
ReadOnlySpan<int> slice = data.AsSpan(start, count); // GC 없음
int sum = 0;
foreach (int v in slice)
sum += v;
return sum;
}
// ❌ 옛날 방식: 새 배열 할당 (GC 압박)
static int SumRangeBad(int[] data, int start, int count)
{
int[] copy = new int[count];
Array.Copy(data, start, copy, 0, count);
int sum = 0;
foreach (int v in copy)
sum += v;
return sum;
}
}
.method private hidebysig static int32 SumRange(int32[] data, int32 start, int32 count) cil managed
{
.locals init (
valuetype [System.Runtime]System.ReadOnlySpan`1<int32> V_0,
// ...
)
ldarg.0
ldarg.1
ldarg.2
call valuetype [System.Runtime]System.ReadOnlySpan`1<!!0> [System.Runtime]System.MemoryExtensions::AsSpan<int32>(!!0[], int32, int32)
stloc.0 // 힙 할당 없음 — Span은 ref struct (스택)
// ... foreach 루프 ...
ret
}
IL 해설:
AsSpan호출 결과는ReadOnlySpan<int>라는ref struct입니다. 힙에 할당되지 않고 스택에 머무릅니다.- 따라서 매 프레임 수십 번 호출해도 GC가 발생하지 않습니다.
Span은ref struct라서 클래스 필드, 비동기 메서드,yield return등에서는 사용할 수 없는 제약이 있지만, UnityUpdate()같은 동기 핫패스에서는 자유롭게 활용할 수 있습니다.
사례 4 — LINQ 결과 저장: .ToArray() vs .ToList()
using System.Collections.Generic;
using System.Linq;
class LinqResult
{
static void Snapshot(List<int> source)
{
// (A) 결과를 더 이상 수정하지 않을 때 — ToArray 권장
int[] evens = source.Where(x => x % 2 == 0).ToArray();
// (B) 결과에 추가/삭제가 필요할 때 — ToList
List<int> mutable = source.Where(x => x % 2 == 0).ToList();
mutable.Add(100);
}
}
선택 기준:
- 결과를 읽기만 한다 →
.ToArray(). 의도(불변 스냅샷)가 명확하고, 길이가 미리 알려진 경우 LINQ 가 정확한 크기로 한 번에 할당합니다. - 결과에 요소를 추가/삭제 한다 →
.ToList(). - 결과가 곧 다른 LINQ 의 입력이 된다 → 그대로
IEnumerable<T>로 두고ToArray/ToList를 미루는 것이 가장 좋습니다 (불필요한 구체화 방지).
Tip: 결과를 함수 반환값으로 외부에 노출할 때, 호출자가 수정하지 못하게 하려면 .ToArray() 가 더 안전합니다 — 길이를 바꾸려면 호출자도 새 배열을 만들어야 하므로 의도가 분명해집니다.
결정 트리 — "배열을 쓸까 List를 쓸까?"

선택 기준 표 — 한눈에 보는 비교
| 기준 | 배열 (T[]) |
List<T> |
|---|---|---|
| 크기 | 고정 (변경 불가) | 가변 (자동 확장) |
| 인덱스 접근 IL | ldelem (단일) |
callvirt get_Item (메서드) |
| 길이 조회 | ldlen (단일) |
callvirt get_Count (메서드) |
| 다차원 | T[,], T[][] 지원 |
직접 미지원 |
| Span 변환 | arr.AsSpan() 즉시 |
CollectionsMarshal.AsSpan(list) (위험) |
| 메모리 오버헤드 | 객체 1개 + 데이터 | 객체 2개 (List + 내부 배열) + 빈 칸 |
| 캐시 친화성 | 매우 높음 | 높음 (내부 배열 사용) |
| 추가/삭제 메서드 | 없음 (직접 새 배열) | Add, Remove, Insert 등 풍부 |
| Capacity 미리 지정 | 불가 (생성 시 길이가 곧 Capacity) | new List<T>(capacity) 가능 |
5. [함정과 주의사항] — 신입이 흔히 빠지는 5가지
함정 1 — List<T>.Clear() 는 메모리를 돌려주지 않는다
List<int> bigList = new List<int>();
for (int i = 0; i < 100_000; i++) bigList.Add(i);
// bigList.Capacity ≥ 131_072
bigList.Clear();
// bigList.Count = 0, but bigList.Capacity = 131_072 (그대로!)
❌ 잘못된 가정: Clear() 했으니 메모리가 풀렸다.
✅ 올바른 처리: 메모리를 회수하려면 명시적으로 TrimExcess() 또는 새 List 를 만든다.
bigList.TrimExcess(); // 내부 배열 길이를 Count에 맞춰 줄임 (또 다른 할당 발생)
// 또는
bigList = new List<int>();
함정 2 — 배열의 "크기 변경" 은 인스턴스 교체다
int[] arr = new int[3] { 1, 2, 3 };
int[] alias = arr; // 같은 인스턴스를 가리킴
Array.Resize(ref arr, 5); // 새 배열 생성 + 데이터 복사 + arr 참조 갱신
// alias는 여전히 옛 배열(길이 3)을 가리킴!
Console.WriteLine(arr.Length); // 5
Console.WriteLine(alias.Length); // 3
// Array.Resize 시그니처
.method public hidebysig static void Resize<T>(!!0[]& array, int32 newSize) cil managed
IL 해설: ref array 파라미터가 단서입니다. 인스턴스의 길이를 바꾸는 것이 아니라 호출자의 변수 참조를 새 배열로 교체합니다. 따라서 같은 배열을 가리키던 다른 변수(alias)는 옛 배열을 그대로 보고 있습니다.
함정 3 — List<T> 순회 중 변경
foreach (int x in list)
{
if (x % 2 == 0) list.Remove(x); // ❌ InvalidOperationException
}
List<T> 의 Enumerator 는 내부 _version 을 들고 있고, 컬렉션이 수정되면 _version 이 바뀌어 다음 MoveNext 에서 예외가 발생합니다.
✅ 올바른 처리:
list.RemoveAll(x => x % 2 == 0); // (a) 전용 메서드 사용
// 또는
for (int i = list.Count - 1; i >= 0; i--) // (b) 역방향 인덱스 루프
{
if (list[i] % 2 == 0) list.RemoveAt(i);
}
foreach 박싱·Enumerator 차이는 PART 9 의 다른 글에서 다뤘으므로 여기서는 짧게만 언급합니다.
함정 4 — CollectionsMarshal.AsSpan(list) 의 칼날
List<T> 의 내부 배열을 Span<T> 으로 직접 얻을 수 있는 API 가 있습니다.
using System.Runtime.InteropServices;
List<int> list = new List<int> { 1, 2, 3, 4 };
Span<int> span = CollectionsMarshal.AsSpan(list); // 내부 _items의 [0..Count) 뷰
span[0] = 100;
// list[0] == 100 (내부 배열을 직접 건드림)
❌ 위험: span 을 들고 있는 동안 list.Add(...) 를 호출하면 List 가 Grow 하면서 새 내부 배열을 할당하고, span 은 GC 대상이 된 옛 배열을 가리키게 됩니다. 일반 foreach 처럼 예외를 던지지 않으므로 더 위험합니다.
✅ 올바른 처리: 슬라이스가 사용되는 짧은 구간 안에서만 사용하고, 사용 중에는 List 를 변경하지 않는다.
함정 5 — LINQ 의 .ToList() 남용
// ❌ 매 프레임 List 생성
void Update()
{
var aliveEnemies = allEnemies.Where(e => e.hp > 0).ToList(); // GC 폭탄
foreach (var e in aliveEnemies) { /* ... */ }
}
✅ 올바른 처리: 핫패스에서는 .ToList()/.ToArray() 호출 자체를 없애거나, 미리 만든 버퍼에 채워 넣는다.
private readonly List<Enemy> _aliveBuffer = new List<Enemy>(64);
void Update()
{
_aliveBuffer.Clear();
for (int i = 0; i < allEnemies.Count; i++)
{
if (allEnemies[i].hp > 0) _aliveBuffer.Add(allEnemies[i]);
}
// _aliveBuffer 사용
}
6. [C# 버전별 변화] — 배열·List와 함께 진화한 슬라이싱
C# 1.0 — 배열만 존재
List<T> 는 없었고 ArrayList(object 박싱) 만 있었습니다. 이 시기에는 모든 가변 컬렉션이 박싱·언박싱 비용을 떠안았습니다.
C# 2.0 — 제네릭 + List<T> 등장
// C# 2.0 이후
List<int> list = new List<int>();
list.Add(42);
int x = list[0]; // 박싱 없음
List<T> 가 등장하면서 박싱 없는 가변 컬렉션이 가능해졌습니다. 배열과 List 의 역할 분담이 시작된 시점입니다.
C# 7.2 — Span<T> 도입
// C# 7.2 이후
int[] data = new int[1024];
Span<int> head = data.AsSpan(0, 16); // 새 배열 없음
배열의 일부만 다룰 때 새 배열을 만들지 않고 슬라이스로 처리할 수 있게 되었습니다. 배열의 활용도가 다시 높아진 분기점입니다.
C# 8.0 — Range/Index 연산자
// C# 8.0 이후
int[] data = { 1, 2, 3, 4, 5 };
int[] tail = data[2..]; // 새 배열 (할당 발생)
Span<int> tailSpan = data.AsSpan()[2..]; // 새 배열 없음
data[2..] 는 새 배열을 할당하지만 data.AsSpan()[2..] 는 새 배열을 만들지 않습니다. 같은 슬라이스 문법이라도 대상이 배열인지 Span인지에 따라 GC 발생 여부가 다릅니다.
C# 9.0 — CollectionsMarshal.AsSpan
// .NET 5 / C# 9 이후
List<int> list = new List<int> { 1, 2, 3 };
Span<int> span = CollectionsMarshal.AsSpan(list); // 내부 배열 직접 노출
List<T> 의 내부 배열을 직접 Span 으로 얻어 GC 없는 핫루프를 짤 수 있게 됐습니다(단, 함정 4 참조).
C# 12 — 컬렉션 식 [1, 2, 3]
// C# 12 이후 — 같은 식이 컨텍스트에 따라 배열 또는 List로
int[] arr = [1, 2, 3];
List<int> list = [1, 2, 3];
Span<int> span = [1, 2, 3]; // stackalloc과 결합
문법은 통일됐지만 타입에 따라 실제 IL 은 여전히 다르게 생성됩니다 — 배열은 newarr + stelem, List 는 newobj List + Add 반복. 즉 컬렉션 식이 등장해도 "배열인가 List 인가" 라는 선택은 여전히 유효합니다.
7. [정리] — 이것만 기억하라
- 첫 질문은 "크기가 변하는가?" — 변하지 않으면 배열, 변하면
List<T>. 이게 80% 의 결정을 끝냅니다. Length(배열, 불변) vsCount(List, 가변) — 두 멤버가 분리된 이유는 "할당된 칸" 과 "채워진 칸" 이 다른 개념이기 때문입니다.List<T>는 내부에 배열을 갖는 래퍼 —Capacity가 부족하면 2배로 새 배열을 할당하고Array.Copy로 옮깁니다. 추가 횟수를 미리 알면new List<T>(capacity)로 Resize 비용을 제거하세요.- 인덱스 접근 IL: 배열은
ldelem(단일), List 는callvirt get_Item(메서드) — UnityUpdate()핫패스에서 배열이 미세하게 빠른 이유입니다. - Unity API 패턴:
GetComponentsInChildren<T>(List<T>)처럼 List를 채워주는 오버로드가 있다면 그쪽이 무할당 경로입니다. 매 프레임 호출되는 코드라면 반드시 이 오버로드를 사용하세요. - Span 슬라이싱으로 GC 없이 부분 처리 —
arr.AsSpan(start, count)는 새 배열을 만들지 않습니다. 핫패스에서 부분 처리에 강력합니다. - LINQ 결과: 더 수정 안 한다 →
.ToArray(), 추가/삭제한다 →.ToList(). 핫패스에서는 둘 다 피하고 미리 만든 버퍼를 재사용하세요. Clear()는 메모리를 돌려주지 않는다 —Capacity는 그대로입니다. 메모리를 회수하려면TrimExcess()를 명시적으로 호출하세요.
PART 9 컬렉션 기본 사용법은 여기서 마무리됩니다. List·Dictionary·HashSet·Queue·Stack·인터페이스·초기화자·컬렉션 식·배열 vs List — 8 개 주제를 모두 익혔다면, 이제 컬렉션 위에서 동작하는 LINQ(다음 PART) 가 한결 자연스럽게 보일 것입니다.
