반응형

[PART9.컬렉션 기본 사용법(8/8)] 배열 vs List<T> — 언제 무엇을 쓰는가

크기가 고정인가 변하는가 / Length vs Count / 인덱스 접근의 IL 차이로 보는 핫패스 선택


1. [문제 제기] — 신입이 매일 마주치는 두 갈래 길

Unity에서 적 5마리를 관리하는 코드를 처음 짤 때, 누구나 한 번쯤 멈칫합니다.

C#
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];

배열은 메모리에 딱 5칸짜리 하나만 있습니다. List<int>2개의 객체(List 인스턴스 + 내부 배열)로 구성되어 있고, 내부 배열의 길이가 곧 Capacity, 그중 채워진 칸 수가 Count 입니다. 이 분리가 가변의 비밀입니다.

기본 코드와 IL — 배열의 고정 길이 본질

C#
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 접근자가 없다
    }
}
IL
.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> 의 가변 비밀

C#
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];
    }
}
IL
.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 가 내부에서 어떻게 동작하는지 시각화합니다.

Step 1. Capacity = 4, Count = 4 (가득 참)

List<T> 의 핵심 코드를 살펴봅니다 (.NET 8 BCL 발췌·요약).

C#
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 을 비교합니다.

C#
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;
    }
}
IL
.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 이면 점프
    // ...
}
IL
.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 수준이지만, Unity Update() 에서 매 프레임 수만 번 반복되면 누적 차이가 커집니다. 정확한 측정은 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> 에는 두 가지 오버로드가 있습니다.

C#
// (A) 매 호출마다 새 배열 할당 — GC 폭탄
T[] GetComponentsInChildren<T>();

// (B) 미리 만든 List<T>를 채워준다 — 무할당
void GetComponentsInChildren<T>(List<T> results);

❌ Before — 매 프레임 새 배열

C#
using UnityEngine;

public class EnemyScanner : MonoBehaviour
{
    void Update()
    {
        // 매 프레임 새 배열을 힙에 할당 → GC 압박
        Collider[] cols = GetComponentsInChildren<Collider>();
        for (int i = 0; i < cols.Length; i++)
        {
            // ...
        }
    }
}

✅ After — List 재사용

C#
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++)
        {
            // ...
        }
    }
}
C#
// Before/After의 핵심 차이를 IL로 보고 싶다면 두 호출 부분만 추출
class HotPath
{
    void BadFrame(GameObject go) => go.GetComponentsInChildren<Collider>();
    void GoodFrame(GameObject go, List<Collider> buf) => go.GetComponentsInChildren<Collider>(buf);
}
IL
.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> 인덱서

C#
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 — 배열 + 길이 캐시

C#
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;
        }
    }
}
IL
// 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> 로 뷰만 잘라낼 수 있습니다.

C#
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;
    }
}
IL
.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가 발생하지 않습니다.
  • Spanref struct 라서 클래스 필드, 비동기 메서드, yield return 등에서는 사용할 수 없는 제약이 있지만, Unity Update() 같은 동기 핫패스에서는 자유롭게 활용할 수 있습니다.

사례 4 — LINQ 결과 저장: .ToArray() vs .ToList()

C#
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() 는 메모리를 돌려주지 않는다

C#
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 를 만든다.

C#
bigList.TrimExcess();   // 내부 배열 길이를 Count에 맞춰 줄임 (또 다른 할당 발생)
// 또는
bigList = new List<int>();

함정 2 — 배열의 "크기 변경" 은 인스턴스 교체다

C#
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
IL
// Array.Resize 시그니처
.method public hidebysig static void Resize<T>(!!0[]& array, int32 newSize) cil managed

IL 해설: ref array 파라미터가 단서입니다. 인스턴스의 길이를 바꾸는 것이 아니라 호출자의 변수 참조를 새 배열로 교체합니다. 따라서 같은 배열을 가리키던 다른 변수(alias)는 옛 배열을 그대로 보고 있습니다.

함정 3 — List<T> 순회 중 변경

C#
foreach (int x in list)
{
    if (x % 2 == 0) list.Remove(x); // ❌ InvalidOperationException
}

List<T> 의 Enumerator 는 내부 _version 을 들고 있고, 컬렉션이 수정되면 _version 이 바뀌어 다음 MoveNext 에서 예외가 발생합니다.

✅ 올바른 처리:

C#
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 가 있습니다.

C#
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 하면서 새 내부 배열을 할당하고, spanGC 대상이 된 옛 배열을 가리키게 됩니다. 일반 foreach 처럼 예외를 던지지 않으므로 더 위험합니다.

✅ 올바른 처리: 슬라이스가 사용되는 짧은 구간 안에서만 사용하고, 사용 중에는 List 를 변경하지 않는다.

함정 5 — LINQ 의 .ToList() 남용

C#
// ❌ 매 프레임 List 생성
void Update()
{
    var aliveEnemies = allEnemies.Where(e => e.hp > 0).ToList(); // GC 폭탄
    foreach (var e in aliveEnemies) { /* ... */ }
}

✅ 올바른 처리: 핫패스에서는 .ToList()/.ToArray() 호출 자체를 없애거나, 미리 만든 버퍼에 채워 넣는다.

C#
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#
// 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#
// C# 7.2 이후
int[] data = new int[1024];
Span<int> head = data.AsSpan(0, 16);   // 새 배열 없음

배열의 일부만 다룰 때 새 배열을 만들지 않고 슬라이스로 처리할 수 있게 되었습니다. 배열의 활용도가 다시 높아진 분기점입니다.

C# 8.0 — Range/Index 연산자

C#
// 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

C#
// .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#
// 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 (배열, 불변) vs Count (List, 가변) — 두 멤버가 분리된 이유는 "할당된 칸" 과 "채워진 칸" 이 다른 개념이기 때문입니다.
  • List<T> 는 내부에 배열을 갖는 래퍼Capacity 가 부족하면 2배로 새 배열을 할당하고 Array.Copy 로 옮깁니다. 추가 횟수를 미리 알면 new List<T>(capacity) 로 Resize 비용을 제거하세요.
  • 인덱스 접근 IL: 배열은 ldelem (단일), List 는 callvirt get_Item (메서드) — Unity Update() 핫패스에서 배열이 미세하게 빠른 이유입니다.
  • 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) 가 한결 자연스럽게 보일 것입니다.

반응형

+ Recent posts