C#DictionaryList해시테이블EqualityComparerIEquatableGetHashCode캐시지역성SIMDILCLR성능

Dictionary가 List보다 1만 배 빠르다길래, 직접 재봤다

1. 문제 제기

면접에서 자주 나오는 질문이 있다.

"List에서 원소를 찾는 것과 Dictionary에서 찾는 것, 뭐가 다르죠?"

정답은 정해져 있다. "List는 O(n), Dictionary는 O(1)입니다." 여기까지는 누구나 안다.

그래서 계산을 해본다. 원소가 10,000개일 때 List는 평균 5,000번 비교하고, Dictionary는 1~2번 비교한다. 그러면 2,500배에서 5,000배쯤 빨라야 한다. 인터넷 글들은 여기서 한 발 더 나아가 "1만 배 빠르다"고도 쓴다.

재봤다.

       N |  List.Contains |  Dict.ContainsKey |   배수
------------------------------------------------------
    1000 |      19.82 ns  |          1.80 ns  |    11배
   10000 |     201.12 ns  |          1.98 ns  |   101배
  100000 |    1516.96 ns  |          2.60 ns  |   583배

101배였다. 5,000배가 아니라.

더 이상한 것도 나왔다.

     N |  List.Contains |  Dict.ContainsKey |   배수
-----------------------------------------------------
     2 |       2.03 ns  |          1.69 ns  |  1.20배
     4 |       1.88 ns  |          1.67 ns  |  1.13배
     8 |       1.96 ns  |          1.69 ns  |  1.16배

원소가 8개일 때, O(n)과 O(1)의 차이는 0.27나노초다. 사실상 없다.

이 글은 두 가지를 파고든다. 첫째, Dictionary는 대체 무슨 수로 N이 5만 배 늘어도 1.69ns에서 2.60ns밖에 안 느려지는가. 둘째, 그런데 왜 이론값(5,000배)과 실측(101배)이 이렇게 다른가. 두 번째 질문의 답이 이 글의 진짜 내용이고, 거기에 "O(1)이라서 빠르다"는 설명이 놓치는 것이 전부 들어 있다.

측정 조건: .NET 10.0.7 · win-x64 · 16코어 · Release 빌드 · Workstation GC · int 키 · 존재하는 키 조회 · Stopwatch 3회 최소값. BenchmarkDotNet이 아니라 직접 짠 마이크로벤치마크다. 절대값이 아니라 자릿수와 경향만 읽어야 한다. 원본 측정과 재현 방법은 research/il-analysis.md에 있다.

2. 개념 정의

비유 (Analogy)

도서관에서 책을 찾는다고 하자.

List는 서가를 처음부터 훑는 것이다. 1번 책장 1층부터 시작해서 한 권씩 제목을 확인한다. 찾는 책이 마지막에 있으면 전부 다 봐야 한다. 10만 권이면 평균 5만 권을 확인한다.

Dictionary는 청구기호를 쓰는 것이다. 책 제목을 계산해서 "813.7-김54ㅅ"이라는 번호를 뽑아내고, 그 번호가 가리키는 칸으로 곧장 간다. 서가가 10개든 10만 개든 상관없다. 번호 계산 한 번, 이동 한 번.

여기서 중요한 건 청구기호 방식이 공짜가 아니라는 점이다.

반면 서가를 훑는 방식은 계산이 없다. 그냥 눈이 옆으로 이동할 뿐이다. 그리고 사람의 눈은 옆 칸으로 이동하는 걸 아주 잘한다 — 이게 나중에 101배의 비밀이 된다.

다이어그램 (Diagram)

Dictionary는 배열 두 개다 버킷에는 Entry 참조가 아니라 entries 배열의 인덱스가 들어간다 — 그것도 1을 더한 값으로 _buckets int[] 해시로 고른 칸 0 0 3 1 0 [0] [1] [2] [3] [4] 0 = 비어 있음 int의 기본값을 그대로 쓴다. 초기화 비용도, null 표현도 필요 없다. int i = _buckets[b] - 1; // 1-based → 0-based _entries Entry[] — hashCode · next · key · value 를 한 칸에 h=1187 next=-1 "Alice" → S1 h=9043 next=2 "Bob" → S2 h=4471 next=-1 "Carol" → S3 h=9043 next=-1 "Dave" → S4 entries[0] entries[1] entries[2] entries[3] _buckets[3]=1 → entries[0] _buckets[2]=3 → entries[2] 해시가 같으면(h=9043) next로 체인을 잇는다. 노드 객체를 새로 만들지 않는다. 왜 참조 배열이 아니라 int 배열인가 충돌마다 노드 객체를 할당하지 않는다 → GC가 추적할 객체 그래프가 줄고, Entry들이 한 배열에 연속 저장된다. 삭제된 칸은 next 필드를 free list 링크로 재활용한다. 배열 두 개로 끝난다.
Dictionary의 내부 구조 — _buckets(int[])와 _entries(Entry[]) 두 배열, 그리고 1-based 인덱스 트릭

코드 (Example)

// 서가 훑기
var list = new List<int> { 7, 3, 9, 1, 4 };
bool found = list.Contains(9);          // 앞에서부터 하나씩 비교

// 청구기호
var dict = new Dictionary<int, Session> { [7] = s1, [3] = s2, [9] = s3 };
bool found2 = dict.ContainsKey(9);      // 9를 계산 → 그 칸으로 직행

정의 (Plain → Technical)

쉬운 말로: Dictionary는 "전부 확인"을 "후보 좁히기"로 바꾸는 자료구조다. 키를 정수로 변환해서 어느 칸에 있어야 하는지를 계산하고, 그 칸에 든 소수의 후보만 확인한다.

기술적으로, .NET의 Dictionary<TKey,TValue>는 분리 연결법(separate chaining) 해시 테이블이며 내부는 배열 두 개다.

private int[]? _buckets;        // 각 버킷이 가리키는 첫 Entry의 인덱스(+1)
private Entry[]? _entries;      // 실제 데이터
private ulong _fastModMultiplier;
private int _count, _freeList, _freeCount, _version;
private IEqualityComparer<TKey>? _comparer;

private struct Entry
{
    public uint hashCode;   // 저장 시점의 해시값
    public int next;        // 같은 버킷의 다음 Entry 인덱스 (-1이면 끝)
    public TKey key;
    public TValue value;
}

여기서 초보자가 예상과 다르다고 느끼는 지점이 둘 있다.

첫째, _buckets는 Entry 객체 배열이 아니라 int[]다. 버킷에는 참조가 아니라 _entries 배열의 인덱스가 들어간다. 충돌이 일어나도 new Node(...) 같은 객체를 만들지 않고, Entry.next에 다음 인덱스를 적는 것으로 연결 리스트를 흉내 낸다. 링크드 리스트의 구조를 쓰면서 노드 할당은 없앤 것이다.

둘째, 버킷에 저장되는 인덱스는 실제 인덱스 + 1이다.

int i = _buckets[bucketIndex] - 1;   // 꺼낼 때 -1

int 배열의 기본값은 0이다. 인덱스를 그대로 저장하면 "0번 엔트리를 가리킨다"와 "비어 있다"를 구분할 수 없다. 1을 더해 저장하면 0이 자동으로 "빈 버킷"이 된다. 배열을 새로 할당하면 전부 0이므로, 초기화 루프조차 필요 없다.

List<T>는 비교할 수 없을 만큼 단순하다.

private T[] _items;
private int _size;
private int _version;

배열 하나. 그게 전부다. 그리고 이 단순함이 나중에 무기가 된다.

3. 내부 동작

3-1. 조회 경로를 한 단계씩

TryGetValue, ContainsKey, 인덱서 getter는 모두 내부적으로 FindValue를 탄다. 단순화하면 이렇다.

ref TValue FindValue(TKey key)
{
    // ① 키 → 정수
    uint hashCode = (uint)_comparer.GetHashCode(key);

    // ② 정수 → 버킷 번호
    int bucketIndex = HashHelpers.FastMod(hashCode, (uint)_buckets.Length, _fastModMultiplier);

    // ③ 버킷 → 첫 엔트리 (1-based → 0-based)
    int i = _buckets[bucketIndex] - 1;

    // ④ 체인 순회
    while (i >= 0)
    {
        ref Entry entry = ref _entries[i];
        if (entry.hashCode == hashCode && _comparer.Equals(entry.key, key))
            return ref entry.value;
        i = entry.next;
    }
    return ref Unsafe.NullRef<TValue>();
}

④의 비교 순서를 눈여겨볼 만하다. hashCode를 먼저 비교하고, 같을 때만 Equals를 호출한다. uint 비교는 한 클럭이지만 Equals는 문자열 비교일 수도 있고 struct 필드 순회일 수도 있다. 싼 검사로 대부분을 걸러낸 뒤 비싼 검사를 한다.

②의 FastMod는 .NET Core 3.0에서 들어온 최적화다. 버킷 개수가 소수(prime)라서 hashCode & (length - 1) 같은 비트 마스크를 쓸 수 없고, 나눗셈 명령은 상대적으로 비싸다. 그래서 Daniel Lemire의 기법으로 미리 계산해둔 승수(_fastModMultiplier)와 64비트 곱셈·시프트 조합으로 나머지를 구한다.

이 최적화가 전체 조회 시간에서 몇 %를 차지하는지는 측정하지 않았다. "나눗셈을 없앴다"는 사실만 런타임 소스로 확인한 것이고, 성능 기여도를 단정하지는 않는다.

3-2. IL로 확인 — ContainsKey + 인덱서는 정말 두 번 조회한다

흔히 보는 두 패턴을 디컴파일해봤다.

public static string TwoLookups(Dictionary<int, string> dict, int key)
    => dict.ContainsKey(key) ? dict[key] : "none";

public static string OneLookup(Dictionary<int, string> dict, int key)
    => dict.TryGetValue(key, out var v) ? v : "none";
// TwoLookups — Code size: 23 (0x17)
IL_0002: callvirt instance bool Dictionary`2<int32, string>::ContainsKey(!0)
IL_0007: brtrue.s IL_000f
IL_0009: ldstr "none"
IL_000e: ret
IL_000f: ldarg.0
IL_0010: ldarg.1
IL_0011: callvirt instance !1 Dictionary`2<int32, string>::get_Item(!0)   // ← 두 번째 조회
IL_0016: ret
// OneLookup — Code size: 19 (0x13)
IL_0002: ldloca.s 0
IL_0004: callvirt instance bool Dictionary`2<int32, string>::TryGetValue(!0, !1&)  // ← 조회 1회
IL_0009: brtrue.s IL_0011

callvirt가 2회 대 1회. 해시 계산과 버킷 탐색을 통째로 두 번 한다. int 키에서는 무시할 만하지만, 키가 긴 문자열이거나 필드가 많은 struct라면 두 배의 해시 비용이 그대로 붙는다.

3-3. IL로 확인 — 제네릭이 없앤 박싱

비제네릭 Hashtable과 대조하면 제네릭의 효과가 IL에 직접 드러난다.

// Dictionary<Opcode, int>.ContainsKey — box 없음
IL_0002: callvirt instance bool Dictionary`2<valuetype Opcode, int32>::ContainsKey(!0)

// Hashtable.ContainsKey — box가 명시적으로 보인다
IL_0002: box [System.Runtime]System.Int32          // ← 힙 할당
IL_0007: callvirt instance bool Hashtable::ContainsKey(object)

Hashtable은 키를 object로 받으므로 int 하나 넣을 때마다 힙 객체가 생긴다. 제네릭 Dictionary는 TKey가 int로 특수화되어 박싱이 아예 없다.

3-4. IL이 똑같은데 성능이 다른 경우

여기서부터가 재미있다. struct 키 두 개를 준비했다.

public struct PlainKey            // IEquatable<T> 미구현
{
    public int X;
    public int Y;
}

public struct FastKey : IEquatable<FastKey>   // 구현
{
    public int X;
    public int Y;
    public bool Equals(FastKey other) => X == other.X && Y == other.Y;
    public override bool Equals(object? o) => o is FastKey k && Equals(k);
    public override int GetHashCode() => HashCode.Combine(X, Y);
}

호출부 IL은 이렇다.

// PlainKeyLookup — Code size: 8
IL_0002: callvirt instance bool Dictionary`2<valuetype PlainKey, int32>::ContainsKey(!0)

// FastKeyLookup — Code size: 8
IL_0002: callvirt instance bool Dictionary`2<valuetype FastKey, int32>::ContainsKey(!0)

바이트 단위로 동일하다. 그런데 성능은 다르다. 차이가 어디서 오는지 보려면 IL이 아니라 런타임을 봐야 한다.

// 여섯 개 타입에 대해 같은 것을 찍어봤다
void P<T>(string label) =>
    Console.WriteLine($"{label,-34} -> {EqualityComparer<T>.Default.GetType().Name}");

P<int>("int");
P<PlainKey>("PlainKey (IEquatable 미구현)");
P<FastKey>("FastKey  (IEquatable 구현)");
// ...
int                                -> GenericEqualityComparer`1
string                             -> StringEqualityComparer
enum Opcode                        -> EnumEqualityComparer`1
PlainKey (IEquatable 미구현)        -> ObjectEqualityComparer`1     ← 여기
FastKey  (IEquatable 구현)          -> GenericEqualityComparer`1
DateTime                           -> GenericEqualityComparer`1

IEquatable<T> 인터페이스 하나 붙였을 뿐인데 EqualityComparer<T>.Default가 다른 클래스를 반환한다.

컴파일 타임에는 아무 차이가 없고, 런타임 타입 시스템이 갈림길을 고른다. IL만 보고 "차이 없네"라고 결론 내리면 놓치는 지점이다.

enum이 EnumEqualityComparer로 특수화된 것도 확인해둘 만하다. .NET Framework 시절 "enum을 제네릭 컬렉션 키로 쓰면 박싱된다"는 이야기가 널리 퍼져 있었는데, 현대 .NET에는 해당하지 않는다.

3-5. IL로는 안 보이는 박싱 — foreach

static int SumStruct(Dictionary<int, int> d)
{
    int s = 0; foreach (var kv in d) s += kv.Value; return s;
}

static int SumIface(IEnumerable<KeyValuePair<int, int>> d)
{
    int s = 0; foreach (var kv in d) s += kv.Value; return s;
}

IL에서 결정적인 차이는 로컬 슬롯의 타입이다.

// SumStruct
.locals init (
    [1] valuetype Dictionary`2/Enumerator<int32, int32>,   // ← valuetype: 스택
    ...
)
IL_000d: call instance ... Enumerator::get_Current()       // call (가상 아님)
IL_001f: call instance bool Enumerator::MoveNext()
IL_002a: constrained. valuetype Dictionary`2/Enumerator<int32, int32>
IL_0030: callvirt instance void IDisposable::Dispose()     // constrained → 박싱 없음
// SumIface
.locals init (
    [1] class IEnumerator`1<valuetype KeyValuePair`2<int32, int32>>,  // ← class: 참조
    ...
)
IL_000c: callvirt instance !0 IEnumerator`1<...>::get_Current()       // 인터페이스 디스패치
IL_001d: callvirt instance bool IEnumerator::MoveNext()

여기서 정직하게 짚어야 할 것이 있다. 이 IL 어디에도 box 명령은 없다. 박싱은 호출부가 아니라 Dictionary의 명시적 인터페이스 구현(IEnumerable<KVP>.GetEnumerator()) 안에서 일어난다. 호출부 IL로 말할 수 있는 것은 "로컬 슬롯이 valuetype에서 class로 바뀌었고 call이 callvirt가 되었다"까지다.

실제 할당은 IL이 아니라 런타임 측정으로 확인했다.

long b0 = GC.GetAllocatedBytesForCurrentThread();
SumStruct(dict);
long b1 = GC.GetAllocatedBytesForCurrentThread();
SumIface(dict);
long b2 = GC.GetAllocatedBytesForCurrentThread();
foreach (var kv in dict)                      :    0 bytes
foreach (var kv in (IEnumerable<KVP>)dict)    :   48 bytes

48바이트는 박싱된 Dictionary<int,int>.Enumerator 하나다. 순회 길이와 무관하다 — 원소당 48바이트가 아니라 순회 1회당 48바이트다. N=1,000을 돌든 N=10을 돌든 똑같이 48바이트.

3-6. 그래서, 101배는 왜인가

이제 처음 질문으로 돌아온다. 비교 횟수로는 5,000배여야 하는데 실측은 101배였다.

계산을 하나 해보자. N=10,000에서 List.Contains는 201.12ns가 걸렸고, 평균 5,000개를 검사했다.

201.12ns ÷ 5,000회 = 0.04ns / 원소

CPU 1클럭이 약 0.3ns다. 원소 하나를 검사하는 데 0.04ns가 걸렸다는 건, 한 클럭에 7~8개씩 처리했다는 뜻이다. 순차 비교를 한 번에 하나씩 했다면 불가능한 수치다.

무슨 일이 벌어진 걸까.

메모리를 어떻게 읽는가 — 순차 vs 점프 List의 O(n)이 생각보다 강하고, Dictionary의 O(1)이 공짜가 아닌 이유 List<int>.Contains(x) T[] 하나를 앞에서부터 순서대로 7 3 9 1 4 8 2 5 연속 메모리를 한 방향으로만 훑는다 프리페처가 다음 캐시 라인을 미리 당겨온다 SIMD가 한 번에 여러 개를 비교한다 분기 예측이 거의 100% 적중한다 N=10,000 → 201.12 ns 원소 하나당 201.12 / 5,000 ≈ 0.04ns — CPU 1클럭(~0.3ns)보다 짧다 Dictionary<int,V>.ContainsKey(x) 배열 두 개를 건너뛰며 _buckets _entries ① 해시 ② fastmod ③ buckets → entries ④ 충돌이면 next로 또 점프 읽는 위치가 흩어진다 → 캐시 미스 N=10,000 → 1.98 ns N=2일 때 1.69ns, N=100,000일 때 2.60ns — 거의 변하지 않는다 그래서 실측은 101배였다 (이론 비교 횟수로는 수천 배) 교과서의 "비교 1회 = 시간 1단위" 모델은 현대 CPU에서 깨진다. List는 가장 빠른 종류의 O(n)이다 — 연속 메모리, 순차 접근, 벡터 비교. Dictionary는 N과 무관한 고정 비용을 내는 대신, 그 고정 비용이 0은 아니다. N이 커질수록 격차가 벌어지는 이유(11배 → 101배 → 583배)는 List가 캐시 밖으로 밀려나기 때문이다.
List의 순차 접근과 Dictionary의 점프 — 캐시 관점에서 본 두 자료구조

1. SIMD 벡터 비교. List<T>.Contains는 내부적으로 Array.IndexOf를 호출하고, int 같은 primitive 타입에 대해서는 벡터화된 경로를 탄다. 256비트 레지스터 하나에 int 8개가 들어가므로 한 명령에 8개를 동시에 비교한다.

2. 하드웨어 프리페처. T[]는 연속 메모리다. CPU는 "이 프로그램이 주소를 순차적으로 읽고 있다"를 감지하면 다음 캐시 라인을 미리 메모리에서 당겨온다. 실제 접근 시점에는 이미 L1에 있다.

3. 분기 예측. 루프의 "찾았는가?" 분기는 마지막 한 번을 빼면 전부 "아니오"다. 예측이 거의 100% 맞으므로 파이프라인이 멈추지 않는다.

즉 List의 O(n)은 "가장 빠른 종류의 O(n)"이다. 교과서의 복잡도 분석은 "비교 1회 = 시간 1단위"를 가정하는데, 현대 CPU에서는 이 가정이 성립하지 않는다. 어떤 비교는 다른 비교보다 훨씬 싸다.

반대편에서 Dictionary는 어떤가.

N=2      → 1.69 ns
N=1024   → 1.69 ns
N=10000  → 1.98 ns
N=100000 → 2.60 ns

N이 5만 배 늘어나는 동안 1.69ns → 2.60ns. 이것이 O(1)의 실체다. 동시에 이 1.69ns라는 바닥값이 "공짜가 아니다"의 실체이기도 하다. 해시 계산 + fastmod + _buckets 접근 + _entries 접근이라는 고정 비용이 있고, 그 비용은 N이 2일 때도 그대로 낸다.

그래서 N=8에서는 이렇게 된다.

List Dictionary
하는 일 8개를 벡터로 한두 번에 훑기 해시 계산 + 나머지 연산 + 배열 두 개 접근
시간 1.96 ns 1.69 ns

둘 다 "메모리 몇 번 읽기"로 끝난다. 알고리즘 복잡도가 개입할 여지가 없다.

N이 커지면서 배수가 11배 → 101배 → 583배로 벌어지는 이유도 이제 설명된다. List가 훑어야 할 배열이 L1(수십 KB) → L2(수백 KB) → L3, 그리고 결국 메인 메모리로 밀려나기 때문이다. 프리페처는 여전히 도와주지만, 메모리 대역폭 자체가 한계가 된다. Dictionary는 그동안 캐시 라인 두어 개만 건드린다.

직접 재본 조회 시간 (int 키, 존재하는 키) .NET 10 · win-x64 · Release · Stopwatch 3회 최소값 — BenchmarkDotNet이 아니므로 자릿수만 읽을 것 차이 없음 1 ns 10 100 1,000 2 8 32 128 1,024 10,000 100,000 원소 개수 N (로그 스케일) List.Contains 1,517 ns Dictionary 2.6 ns 15배 101배 583배 1.2배 Dictionary는 N이 5만 배로 늘어도 1.69ns → 2.60ns. List는 같은 구간에서 2.03ns → 1,517ns. N≤8에서 둘의 차이는 0.3ns 남짓 — 측정 잡음과 구분되지 않는다. "작으면 List가 이긴다"보다 "작으면 아무 상관 없다"가 정확하다. 이 그래프는 int 키 한 종류다. string·struct 키는 해시 계산 비용이 붙어 곡선이 달라진다.
직접 측정한 조회 시간 — N=8에서 1.2배, N=10,000에서 101배

4. 실전 적용

4-1. 손익분기점은 어디인가

실측표를 다시 보자.

     N |  List.Contains |  Dict.ContainsKey |    배수
------------------------------------------------------
     2 |       2.03 ns  |          1.69 ns  |  1.20배
     4 |       1.88 ns  |          1.67 ns  |  1.13배
     8 |       1.96 ns  |          1.69 ns  |  1.16배
    16 |       2.72 ns  |          1.65 ns  |  1.64배
    32 |       3.63 ns  |          1.70 ns  |  2.14배
    64 |       2.87 ns  |          1.70 ns  |  1.69배
   128 |       3.55 ns  |          1.71 ns  |  2.07배
   256 |       5.84 ns  |          1.67 ns  |  3.49배
  1024 |      25.74 ns  |          1.69 ns  | 15.24배

흔히 하는 말과 실측이 다른 지점이 있다. "원소가 적으면 List가 더 빠르다"는 조언을 자주 보는데, 이 측정에서 List가 이긴 구간은 없었다. N=2에서도 Dictionary가 1.20배 빨랐다.

다만 N≤8 구간의 차이는 0.3ns 남짓이고, N=32(3.63ns)와 N=64(2.87ns)가 역전된 걸 보면 이 정도는 측정 잡음과 구분되지 않는다. 정직한 결론은 이렇다.

N이 작을 때는 "List가 이긴다"가 아니라 "아무 상관 없다"가 맞다.

그러니 원소 열 개짜리 컬렉션을 Dictionary로 바꾸는 리팩터링은 성능 근거로는 정당화되지 않는다. 가독성과 의도 표현으로 고르면 된다. "ID로 찾는다"가 의미라면 Dictionary가 코드를 더 잘 설명한다.

반대로 N이 수천을 넘고 그 조회가 매 프레임 또는 매 패킷마다 일어난다면, 그때는 자료구조가 곧 성능이다.

그리고 이 표를 규칙으로 만들지 말 것. 키가 int일 때의 숫자다. 키가 문자열이면 해시 계산 비용이 붙어 Dictionary 쪽 바닥값이 올라가고, 손익분기점이 오른쪽으로 이동한다.

4-2. Unity — 매 프레임 선형 탐색

// 흔한 패턴
void Update()
{
    var target = enemies.Find(e => e.Id == targetId);
    target?.TakeDamage(damage);
}

이 코드에는 비용이 두 겹이다. 선형 탐색이 하나고, targetId를 캡처하는 람다가 또 하나다. 캡처가 있으면 클로저 객체가 생긴다.

// ID로 찾는 게 의미라면
Dictionary<int, EnemyController> _enemiesById;

void Update()
{
    if (_enemiesById.TryGetValue(targetId, out var enemy))
        enemy.TakeDamage(damage);
}

다만 적이 항상 5~10마리라면 이 교체의 성능 이득은 위 표대로 거의 없다. 삽입·삭제 빈도와 코드 명료성으로 판단하는 게 낫다.

4-3. 서버 — 세션 테이블과 핸들러 디스패치

Dictionary<long, Session> _sessionsByPlayerId;

if (_sessionsByPlayerId.TryGetValue(playerId, out var session))
    session.Send(packet);

long 키는 해시가 사실상 공짜고 비교도 한 명령이다. Dictionary가 가장 잘 맞는 사례다.

패킷 핸들러 테이블은 조금 다르다.

Dictionary<ushort, PacketHandler> _handlers;

opcode가 0~1023처럼 연속된 작은 정수라면 Dictionary보다 배열이 낫다.

PacketHandler?[] _handlersByOpcode = new PacketHandler?[MaxOpcode + 1];

var handler = _handlersByOpcode[packet.Opcode];
if (handler is not null) handler(session, packet);

해시 계산도, 버킷 탐색도, 체인 순회도 없다. 인덱싱 한 번. Dictionary의 O(1)보다 더 단순한 O(1)이다. opcode가 희소하거나 64비트 전역 ID라면 당연히 배열은 부적절하다.

4-4. 조회는 한 번만

// 나쁨 — FindValue를 두 번 탄다
if (sessions.ContainsKey(playerId))
    sessions[playerId].Send(packet);

// 좋음
if (sessions.TryGetValue(playerId, out var session))
    session.Send(packet);

§3-2의 IL이 근거다. int·long 키에서는 미미하지만, 문자열 키나 큰 struct 키에서는 해시 계산이 통째로 두 번 일어난다.

.NET 6+에는 조회와 갱신을 한 번에 끝내는 API도 있다.

using System.Runtime.InteropServices;

ref int count = ref CollectionsMarshal.GetValueRefOrAddDefault(dict, key, out bool exists);
if (!exists) count = 0;
count++;      // 다시 dict[key] = count를 하지 않는다

값이 큰 struct일 때 복사를 피할 수 있어 유용하다. 단 얻은 ref를 오래 들고 있으면 안 된다. Dictionary가 리사이즈되면 _entries 배열 자체가 교체되므로 그 참조는 옛 배열을 가리킨다.

4-5. 용량을 미리 준다

var sessions = new Dictionary<long, Session>(capacity: expectedPlayerCount);

용량 없이 계속 추가하면 리사이즈가 반복된다. 리사이즈마다 _buckets와 _entries 두 배열을 새로 할당하고 기존 엔트리를 옮긴다. 서버 시작 시 로딩이나 맵 데이터 테이블처럼 크기를 아는 경우에는 지정하는 편이 낫다.

한 가지 덧붙이면, 일반 리사이즈는 GetHashCode()를 다시 호출하지 않는다. Entry.hashCode에 저장해둔 값으로 버킷 체인만 재구성한다. 해시 계산이 비싼 키(긴 문자열)에서 특히 다행인 설계다.

5. 함정과 주의사항

5-1. GetHashCode를 빼먹으면 넣은 걸 못 찾는다

public sealed class PlayerKey
{
    public int Region;
    public int UserId;

    public override bool Equals(object? obj)
        => obj is PlayerKey o && Region == o.Region && UserId == o.UserId;

    // GetHashCode 미구현 ← 여기
}
var a = new PlayerKey { Region = 1, UserId = 100 };
var b = new PlayerKey { Region = 1, UserId = 100 };

dict.Add(a, session);
dict.TryGetValue(b, out _);   // false

a.Equals(b)는 true인데 못 찾는다. Equals 이전에 버킷을 먼저 고르기 때문이다. 해시가 다르면 애초에 다른 칸을 보고, a가 든 칸에는 가보지도 않는다.

컴파일러가 경고(CS0659)를 주지만 경고일 뿐이라 지나치기 쉽다. Equals를 재정의하면 GetHashCode도 반드시 함께 재정의한다.

public override int GetHashCode() => HashCode.Combine(Region, UserId);

5-2. 키를 넣은 뒤에 바꾸면 영원히 못 찾는다

var key = new PlayerKey { Region = 1, UserId = 100 };
dict[key] = session;

key.UserId = 200;             // ← 해시가 바뀐다

dict.TryGetValue(key, out _); // false

삽입 시점의 해시로 버킷이 정해졌는데, 지금 계산한 해시는 다른 버킷을 가리킨다. 그 엔트리는 Dictionary 안에 남아 있지만 접근할 수 없는 상태가 된다. 순회로는 보이는데 조회로는 안 나오는, 디버깅하기 고약한 버그다.

Dictionary 키는 불변이어야 한다.

public readonly record struct PlayerKey(int Region, int UserId);

readonly record struct는 불변성·IEquatable<T>·GetHashCode를 한 줄에 해결한다.

5-3. 나쁜 해시는 Dictionary를 List보다 못하게 만든다

public override int GetHashCode() => 1;   // 최악

모든 키가 한 버킷으로 몰리고 체인이 N개로 늘어난다. 재봤다.

GetHashCode() => 1  :      6979.62 ns/op
GetHashCode() => V  :         4.36 ns/op
퇴화 배수           :      1602.7x 느림

N=10,000에서 1,603배 느려진다.

표시된 두 값으로 직접 나누면 6,979.62 ÷ 4.36 = 1,600.8이 나와 배수와 어긋나 보인다. 배수는 반올림 전 원값으로 계산한 것이고(실제 4.355…), 위 세 줄은 프로그램이 각각 소수점 둘째 자리로 출력한 것이다.

더 눈여겨볼 건 이거다.

시간 (N=10,000)
정상 Dictionary (struct 키) 4.36 ns
List (int) 201.12 ns
해시가 퇴화한 Dictionary (struct 키) 6,979.62 ns

앞서 §3-6에서 Dictionary가 1.98ns였는데 여기서는 4.36ns인 것은 키 타입이 달라서다. 이 실험은 int 키가 아니라 IEquatable<T>를 구현한 사용자 정의 struct 키를 쓴다 — HashCode.Combine 호출과 필드 두 개 비교가 붙는다. 세 줄을 같은 열에 놓은 것은 자릿수 비교를 위한 것이고, 4.36 대 1.98을 직접 대조해서는 안 된다.

퇴화한 Dictionary는 같은 크기의 List보다 35배 느리다. 해시 테이블이 링크드 리스트로 전락하면 배열 순회보다 나쁘다 — 순차 접근이 아니라 next 인덱스를 따라 배열 안을 뛰어다니므로 프리페처도 SIMD도 못 쓴다.

GetHashCode() => 1을 일부러 쓰는 사람은 없지만, 필드 하나만 해시에 넣는 실수는 흔하다.

// 위험 — Region은 값이 몇 개 안 된다
public override int GetHashCode() => Region;

5-4. struct 키에 IEquatable<T>를 안 붙이면

§3-4에서 확인한 그대로다. EqualityComparer<PlainKey>.Default가 ObjectEqualityComparer<T>가 되고, 비교마다 object.Equals(object) 경로를 타며 박싱이 발생한다.

// 나쁨
public struct GridPos { public int X, Y; }

// 좋음
public readonly struct GridPos : IEquatable<GridPos>
{
    public readonly int X, Y;
    public GridPos(int x, int y) { X = x; Y = y; }
    public bool Equals(GridPos other) => X == other.X && Y == other.Y;
    public override bool Equals(object? o) => o is GridPos g && Equals(g);
    public override int GetHashCode() => HashCode.Combine(X, Y);
}

IL에는 아무 차이도 안 나타난다는 점을 기억해둘 만하다. 디컴파일해서 "박싱 없네"라고 확인해도 실제로는 런타임에 박싱이 일어난다. 이런 건 IL이 아니라 할당 프로파일러로 봐야 한다.

5-5. foreach를 인터페이스로 받으면 48바이트

// 0 bytes
foreach (var kv in _sessions) { ... }

// 48 bytes — 변수/파라미터 타입이 IEnumerable<T>이면
void Process(IEnumerable<KeyValuePair<long, Session>> items)
{
    foreach (var kv in items) { ... }
}

정적 타입이 결정한다. 매 프레임 도는 루프거나 매 틱 도는 서버 루프라면 파라미터 타입을 Dictionary<K,V>로 두는 것만으로 할당이 사라진다.

LINQ도 같은 이유로 할당한다. dict.Where(...), dict.Select(...)는 열거자 체인을 힙에 만든다.

한편 dict.Keys / dict.Values 자체는 매번 새로 할당되지 않는다. 재봤다.

dict.Keys   x1000 : 0 bytes
dict.Values x1000 : 0 bytes
ReferenceEquals(d.Keys,   d.Keys)   = True
ReferenceEquals(d.Values, d.Values) = True

첫 접근에서 만든 뷰 컬렉션을 필드에 캐시해두고 계속 돌려준다. 다만 dict.Keys.ToList()나 dict.Values.ToArray()는 당연히 새 컬렉션을 만든다.

5-6. 서버에서 동시 접근

Dictionary<TKey,TValue>는 스레드 안전하지 않다. 읽기만 여러 스레드에서 하는 것은 안전하지만, 한 스레드라도 쓰면 모든 접근이 위험해진다.

리사이즈 도중에 다른 스레드가 읽으면 반쯤 재구성된 체인을 따라가게 되고, .NET Framework 시절에는 이것이 무한 루프로 이어져 CPU가 100%에 고정되는 장애로 유명했다. 현대 .NET에는 일부 손상을 감지해 예외를 던지는 방어 코드가 있지만, 결론은 같다 — 동시 쓰기를 하면 안 된다.

상황 선택
여러 스레드가 읽고 쓴다 ConcurrentDictionary<K,V>
읽기 압도적, 쓰기 드묾 불변 스냅숏 교체, 또는 ReaderWriterLockSlim
초기화 후 읽기 전용 일반 Dictionary, 또는 FrozenDictionary (.NET 8+)
단일 스레드 이벤트 루프 일반 Dictionary
연속된 작은 정수 키 배열

ConcurrentDictionary가 항상 정답은 아니다. 락과 인터록 연산 비용이 있어 단일 스레드 상황에서는 일반 Dictionary보다 느리다. 게임서버가 세션별로 단일 스레드 액터 모델을 쓴다면 일반 Dictionary가 맞다.

5-7. 외부 입력을 키로 쓸 때

Dictionary<string, Player> _playersByNickname;

공격자가 의도적으로 해시가 충돌하는 문자열을 대량으로 만들어 보내면 체인이 길어지고 조회가 O(n)으로 퇴화한다(§5-3의 1,603배가 공격으로 재현되는 셈이다). 해시 충돌 DoS다.

.NET은 방어 장치를 갖고 있다. Dictionary<string, V>는 평소 성능을 위해 비랜덤화 문자열 비교자를 쓰다가, 한 체인의 충돌 수가 임계값을 넘으면 랜덤화 해시 비교자로 전환하고 재해시한다.

이 임계값은 런타임 소스상 100으로 알려져 있으나 구현 세부사항이다. 애플리케이션이 이 숫자에 의존해서는 안 되고, 필자도 이 동작을 직접 측정하지는 않았다.

런타임 방어에만 기대지 말고 설계로 막는 편이 낫다.

여기서 흔한 오해 하나를 짚고 가자. "비교자를 지정 안 하면 로케일 영향을 받으니 Ordinal을 명시해야 한다"는 말은 틀렸다. 재봤다.

EqualityComparer<string>.Default = StringEqualityComparer

  en-US  : ContainsKey("I")=True  ContainsKey("i")=False
  tr-TR  : ContainsKey("I")=True  ContainsKey("i")=False
  de-DE  : ContainsKey("I")=True  ContainsKey("i")=False

  tr-TR에서 string.Equals("I","i")                    = False
  tr-TR에서 "I".Equals("i", CurrentCultureIgnoreCase)  = False
  tr-TR에서 "I".Equals("i", OrdinalIgnoreCase)         = True

터키어 로케일은 i/I 매핑이 다른 것으로 유명하지만, Dictionary<string, V>의 기본 비교자는 이미 ordinal이라 로케일을 바꿔도 결과가 그대로다. 문화권 비교는 StringComparer.CurrentCulture를 명시적으로 넘겼을 때만 일어난다.

그러면 StringComparer.Ordinal을 왜 쓰나. 동작을 바꾸려는 게 아니라 의도를 코드에 남기려는 것이다. "이 키는 언어 규칙이 아니라 바이트로 비교한다"를 선언해두면, 나중에 누군가 OrdinalIgnoreCase나 CurrentCultureIgnoreCase로 바꿀 때 그게 의미 있는 변경임을 알아차린다. 대소문자 무시가 필요하면 StringComparer.OrdinalIgnoreCase를 쓰되, CurrentCultureIgnoreCase는 피한다 — 위 실측대로 로케일에 따라 결과가 달라진다.

6. 버전별 변화

버전 변화
.NET Framework Hashtable은 키를 object로 받아 값 타입마다 박싱(§3-3의 IL box). 제네릭 컬렉션에서 enum 키 박싱 이슈가 알려져 있었다. 동시 쓰기 시 무한 루프 장애 사례
.NET Core 2.1 Dictionary 조회 경로 리팩터링, ref 반환 기반 FindValue 도입
.NET Core 3.0 fastmod(Lemire) 도입 — _fastModMultiplier로 나눗셈 제거(64비트 타깃)
.NET 5+ comparer 특수화·devirtualization 개선. enum은 EnumEqualityComparer로 특수화되어 박싱 없음(§3-4 실측)
.NET 6 CollectionsMarshal.GetValueRefOrNullRef / GetValueRefOrAddDefault — 조회+갱신 1회
.NET 8 FrozenDictionary — 생성 후 불변인 읽기 전용 테이블. 생성 시 키 분포를 분석해 조회에 최적화된 레이아웃을 고른다. 생성 비용이 크므로 서버 시작 시 만드는 핸들러·설정 테이블에 적합
.NET 9+ Dictionary<K,V>.GetAlternateLookup<TAlternate>() → AlternateLookup<TAlternate> — string 키 테이블을 ReadOnlySpan<char>로 조회해 문자열 할당 없이 찾기

Unity에서는

이 글의 모든 실측은 .NET 10 / CoreCLR JIT 기준이다. Unity에서 그대로 재현된다고 가정하면 안 된다.

// 위험
Dictionary<Vector3, Tile> _tiles;

// 안전
public readonly record struct GridPos(int X, int Y);
Dictionary<GridPos, Tile> _tiles;

핫패스라면 타깃 플랫폼에서 직접 측정한다. 이 글의 숫자를 그대로 인용하지 말 것.

7. 정리


여기부터는 AI 에이전트용 규칙이다. 앞의 7개 절이 "왜 그런가"를 사람에게 설명했다면, 아래 두 절은 코드를 쓸 때 바로 적용할 판단 기준이다. 문체와 목적이 다르다 — 비유도 서사도 없고, DON'T / DO / 체크리스트만 있다. 각각 unity-skill.md, server-skill.md와 같은 내용이며, 그 파일들은 에이전트가 단독으로 참조한다.

8. Unity 실전 가이드펼치기

AI 에이전트용 코딩 규칙. 이 절의 대상은 "컬렉션에서 하나를 찾는 코드" 하나다. boxing 일반론(1-4 주제), struct 복사(1-3 주제), 일반 Unity 최적화는 여기서 다루지 않는다. 판정 기준: 조회가 핫패스에 있는가 / 키 타입이 해시 계약을 지키는가 / 조회 1회에 할당이 붙는가.


0. 핵심 규칙 (한 줄 요약)

  • "O(1)이니까 Dictionary"는 근거가 아니다. 원소가 적으면 차이가 사라지고, 해시가 나쁘면 List보다 느려진다. 판단은 N · 조회 빈도 · 키 타입 세 가지로 한다.
  • 자료구조 교체보다 먼저 없앨 것은 조회에 붙은 할당이다 — 람다 클로저, LINQ 열거자, 인터페이스 열거자(순회 1회당 48바이트), ObjectEqualityComparer 경로의 박싱.
  • 키 타입이 성능과 정확성을 모두 결정한다. Vector3 키는 정확성 문제, IEquatable<T> 없는 struct 키는 할당 문제, GetHashCode 미구현/가변 키는 버그다.
  • 이 절의 수치는 전부 데스크톱 CoreCLR 측정이다. 인용 조건: .NET 10.0.7 / win-x64 / Release / int 키 / 존재하는 키 조회 / Stopwatch 3회 최소값 (result.md §1, research/il-analysis.md §2). Unity(Mono/IL2CPP)에서 재현된다는 보장이 없다. 핫패스면 타깃 디바이스에서 다시 잰다.

참고 기준선 (위 조건에서):

N List.Contains Dictionary.ContainsKey 배수
8 1.96 ns 1.69 ns 1.16x
1,024 25.74 ns 1.69 ns 15.2x
10,000 201.12 ns 1.98 ns 101x
100,000 1,516.96 ns 2.60 ns 583x

읽는 법: N이 작으면 "List가 빠르다"가 아니라 "아무 상관 없다" (N≤8 격차 약 0.3ns = 측정 잡음 수준). N이 수천을 넘고 매 프레임 조회면 그때 자료구조가 성능이 된다.


1. DON'T — 이 주제가 Unity에서 만드는 실수

1-1. Update에서 List.Find(람다) — 선형 탐색 + 클로저 할당

// ❌ BAD — 비용이 두 겹이다
public class CombatSystem : MonoBehaviour
{
    [SerializeField] List<EnemyController> _enemies = new();
    int _targetId;
    int _damage;

    void Update()
    {
        // ① 선형 탐색  ② _targetId를 캡처하는 클로저 → 매 프레임 힙 할당
        var target = _enemies.Find(e => e.Id == _targetId);
        if (target != null) target.TakeDamage(_damage);
    }
}

e => e.Id == _targetId는 this(또는 지역 변수)를 캡처하므로 컴파일러가 클로저를 만든다. 캡처가 있으면 델리게이트 인스턴스를 캐시할 수 없어 매 호출 할당이다. 자료구조를 안 바꾸더라도 이 할당은 반드시 없앤다.

1-2. Update에서 LINQ 조회

// ❌ BAD — 열거자 체인이 힙에 생긴다. 조회 1회에 여러 개
void Update()
{
    var target = _enemies.FirstOrDefault(e => e.Id == _targetId);
    var lowHp  = _enemies.Where(e => e.Hp < 10).ToList();   // 열거자 + List + 배열
}

Where/Select/OrderBy는 열거자 객체를 힙에 만든다(result.md §5-5). ToList()/ToArray()는 그 위에 컬렉션 하나를 더 얹는다.

1-3. ContainsKey + 인덱서 — 조회 2회

// ❌ BAD — FindValue 경로를 통째로 두 번 탄다
if (_enemiesById.ContainsKey(id))
    _enemiesById[id].TakeDamage(dmg);

IL에 callvirt 2회로 남는다(ContainsKey → get_Item, il-analysis.md §1-1). int 키에서는 미미하지만 문자열 키나 필드 많은 struct 키에서는 해시 계산이 두 배로 붙는다.

1-4. Vector3(또는 float를 품은 struct)를 키로 쓰기 — 정확성 버그

// ❌ BAD — Unity의 == 의미와 Dictionary의 비교 의미가 다르다
Dictionary<Vector3, Tile> _tiles = new();

void Place(Vector3 pos, Tile t) => _tiles[pos] = t;

void Query()
{
    var a = new Vector3(0.1f, 0, 0) * 3f;   // 0.30000001...
    var b = new Vector3(0.3f, 0, 0);

    bool byOperator = (a == b);              // Unity의 == 는 근사 비교 → true가 될 수 있다
    bool found = _tiles.TryGetValue(a, out _); // Dictionary는 == 를 쓰지 않는다 → 못 찾는다
}

핵심은 Dictionary가 operator==를 절대 호출하지 않는다는 것이다. EqualityComparer<Vector3>.Default → Vector3.Equals(성분별 정확 비교) → 그리고 그 전에 GetHashCode로 버킷부터 고른다. 근사 비교로 "같다"고 판정되는 두 벡터도 해시가 다르면 같은 칸에 가지도 않는다. 부동소수 누적 오차가 한 ULP만 생겨도 조회는 실패한다.

Quaternion, Vector2, Bounds, Color 등 float를 품은 모든 타입이 같은 문제를 갖는다.

1-5. struct 키에 IEquatable<T> 미구현 — 조회마다 박싱

// ❌ BAD — IEquatable<T>가 없다
public struct GridPos { public int X, Y; }

Dictionary<GridPos, Tile> _tiles = new();

void Update()
{
    // 조회마다 ObjectEqualityComparer 경로 → object.Equals(object) → 박싱
    if (_tiles.TryGetValue(new GridPos { X = _cx, Y = _cy }, out var tile))
        tile.Tick();
}

EqualityComparer<GridPos>.Default가 ObjectEqualityComparer<T>가 된다(il-analysis.md §2-1 실측). object.Equals(object) 경로라 값 타입 인자가 박싱되고, ValueType.Equals의 필드 단위 비교로 떨어진다.

이 차이는 IL에 전혀 안 나타난다. IEquatable<T> 구현 유무와 무관하게 호출부 IL은 바이트 단위로 동일하다(il-analysis.md §1-3). 디컴파일해서 "box 없네"라고 확인해도 실제로는 런타임에 박싱이 일어난다. IL 대신 Profiler의 GC Alloc으로 판정한다.

1-6. Equals만 재정의 / 가변 키 — 넣은 걸 못 찾는다

// ❌ BAD ① — GetHashCode 미구현 (CS0659 경고만 뜬다)
public sealed class ChunkKey
{
    public int Cx, Cz;
    public override bool Equals(object o) => o is ChunkKey k && Cx == k.Cx && Cz == k.Cz;
    // GetHashCode 없음 → 값이 같아도 다른 버킷을 본다 → TryGetValue 실패
}

// ❌ BAD ② — 키를 넣은 뒤에 필드를 바꾼다
var key = new ChunkKey { Cx = 1, Cz = 2 };
_chunks[key] = chunk;
key.Cx = 5;                        // 해시가 바뀐다
_chunks.TryGetValue(key, out _);   // false. 엔트리는 남아 있는데 접근 불가

// ❌ BAD ③ — MonoBehaviour/GameObject 자체를 키로 쓰면서 Destroy 후 방치
Dictionary<GameObject, EnemyState> _states = new();
void Kill(GameObject go) { Destroy(go); }   // 엔트리가 그대로 남는다 → 값은 fake-null

③은 Unity 고유다. Destroy 후에도 Dictionary 엔트리는 살아 있고(참조를 붙들고 있으므로 관리 래퍼가 GC되지도 않는다), 꺼낸 값은 == null이 참인 fake-null이 된다. Destroy와 Remove는 항상 짝으로 호출한다.

1-7. GetHashCode에 필드 하나만 넣기 — Dictionary가 List보다 느려진다

// ❌ BAD — Layer는 값이 몇 개 안 된다. 전부 같은 버킷으로 몰린다
public readonly struct SpawnKey : IEquatable<SpawnKey>
{
    public readonly int Layer, Index;
    public bool Equals(SpawnKey o) => Layer == o.Layer && Index == o.Index;
    public override int GetHashCode() => Layer;      // ← 퇴화
}

극단값(GetHashCode() => 1) 실측: N=10,000에서 6,979.62 ns/op vs 정상 4.36 ns/op = 1,603배 느림 (같은 조건, struct 키). 이 퇴화한 Dictionary는 같은 N의 List(201.12ns)보다도 35배 느리다 (result.md §5-3). 해시 테이블이 링크드 리스트로 전락하면 next 인덱스를 따라 배열 안을 뛰어다니므로 프리페처도 SIMD도 못 쓴다.

1-8. IEnumerable<T> 파라미터로 받아 foreach — 순회 1회당 48바이트

// ❌ BAD — 정적 타입이 IEnumerable<T>라 열거자가 박싱된다
void TickAll(IEnumerable<KeyValuePair<int, EnemyController>> items)
{
    foreach (var kv in items) kv.Value.Tick();
}

void Update() => TickAll(_enemiesById);   // 매 프레임 48 bytes

실측 48바이트 = 박싱된 Dictionary<K,V>.Enumerator 하나. 순회 길이와 무관하게 순회 1회당 1개다(il-analysis.md §2-2). 60fps면 초당 약 2.8KB, 순회 지점이 여럿이면 그만큼 곱해진다.

IList<T>/ICollection<T>로 받은 List도 마찬가지로 인터페이스 디스패치 경로를 탄다(il-analysis.md §1-5).

1-9. 매 프레임 문자열 키 조합

// ❌ BAD — string 할당 + 문자열 길이에 비례하는 해시 계산이 매 프레임
void Update()
{
    if (_effects.TryGetValue($"fx_{_enemyId}_hit", out var fx)) fx.Play();
}

int 키의 해시는 사실상 공짜지만 문자열 해시는 길이에 비례한다. 여기에 키 문자열 자체가 매 프레임 새 할당이다. Dictionary 쪽 바닥값이 올라가 손익분기점이 오른쪽으로 이동한다 (result.md §4-1).

1-10. 원소 5~10개짜리 컬렉션을 성능 이유로 Dictionary로 리팩터링

// ❌ BAD (판단 오류) — "O(1)이 빠르니까"가 유일한 근거인 교체
// 슬롯 4칸짜리 인벤토리, 파티원 4명, 스킬 6개 …

N≤8 구간의 실측 격차는 약 0.3ns로 측정 잡음과 구분되지 않는다(result.md §4-1). 이 구간의 자료구조 선택은 성능이 아니라 의도 표현과 삽입·삭제 빈도로 한다. 성능을 근거로 쓰면 잘못된 근거다.

1-11. 안전한 순회를 위해 매 프레임 ToList()

// ❌ BAD — 순회 중 제거를 피하려고 매 프레임 새 컬렉션을 만든다
void Update()
{
    foreach (var kv in _enemiesById.ToList())        // Dictionary 크기만큼 새 List
        if (kv.Value.IsDead) _enemiesById.Remove(kv.Key);
}

참고로 dict.Keys / dict.Values 접근 자체는 0바이트다 — 뷰 컬렉션을 필드에 캐시해두고 재사용한다(il-analysis.md §2-2b, ReferenceEquals(d.Keys, d.Keys) == true). 할당은 .ToList()/.ToArray()가 만든다.


2. DO — 대응 패턴

2-1. ID 조회는 TryGetValue 한 번

// ✅ GOOD — 조회 1회, 클로저 없음, 널 체크 통합
public class CombatSystem : MonoBehaviour
{
    readonly Dictionary<int, EnemyController> _enemiesById = new(capacity: 256);

    void Update()
    {
        if (_enemiesById.TryGetValue(_targetId, out var enemy))
            enemy.TakeDamage(_damage);
    }

    public void Register(EnemyController e)   => _enemiesById[e.Id] = e;
    public void Unregister(EnemyController e) => _enemiesById.Remove(e.Id);   // Destroy와 짝
}

단, 적이 항상 5~10마리라면 이 교체의 성능 이득은 거의 없다(§1-10). 정당화는 "등록/해제가 잦고 ID 조회가 코드의 의미"라는 쪽이다.

2-2. List를 유지해야 하면 최소한 클로저를 없앤다

// ✅ GOOD — N이 작아 List가 맞는 경우. 캡처 없는 인덱스 루프
EnemyController FindById(int id)
{
    for (int i = 0; i < _enemies.Count; i++)
        if (_enemies[i].Id == id) return _enemies[i];
    return null;
}

List<T>는 내부가 배열 하나(T[] _items)라 순차 접근이 프리페처·분기 예측을 그대로 탄다. N이 작을 때 이 경로는 충분히 빠르다. 없애야 할 것은 O(n)이 아니라 람다 할당이었다.

2-3. struct 키는 readonly record struct 또는 IEquatable<T> 수동 구현

// ✅ GOOD — 불변성 + IEquatable<T> + GetHashCode를 한 줄에
public readonly record struct GridPos(int X, int Y);

// ✅ GOOD — record를 못 쓰는 C# 버전이면 수동으로
public readonly struct ChunkKey : IEquatable<ChunkKey>
{
    public readonly int Cx, Cz;
    public ChunkKey(int cx, int cz) { Cx = cx; Cz = cz; }

    public bool Equals(ChunkKey o) => Cx == o.Cx && Cz == o.Cz;
    public override bool Equals(object o) => o is ChunkKey k && Equals(k);
    public override int GetHashCode() => (Cx * 397) ^ Cz;   // 모든 필드를 넣는다
}

Dictionary<GridPos, Tile> _tiles = new();

이러면 EqualityComparer<T>.Default가 GenericEqualityComparer<T>가 되어 Equals(T)를 직접 호출한다 — 박싱 없음(il-analysis.md §2-1).

HashCode.Combine은 Unity의 API 호환 레벨에 따라 없을 수 있다. 없으면 위의 (a * 397) ^ b 형태를 쓴다. 필드를 하나라도 빼면 §1-7의 퇴화로 간다.

2-4. Vector3는 격자 정수 키로 정규화

// ✅ GOOD — float를 키에서 완전히 제거한다
public readonly record struct GridPos(int X, int Z);

const float CellSize = 1f;

static GridPos ToGrid(Vector3 world) => new(
    Mathf.FloorToInt(world.x / CellSize),
    Mathf.FloorToInt(world.z / CellSize));

Dictionary<GridPos, Tile> _tiles = new();

void Query(Vector3 pos)
{
    if (_tiles.TryGetValue(ToGrid(pos), out var tile)) tile.Highlight();
}

정규화 함수는 한 곳에만 두고 삽입·조회가 같은 함수를 쓴다. 삽입 때 FloorToInt, 조회 때 RoundToInt를 쓰면 경계 셀에서 조용히 어긋난다.

2-5. 연속된 작은 정수 키는 Dictionary가 아니라 배열

// ✅ GOOD — 해시 계산도, 버킷 탐색도, 체인 순회도 없다. 인덱싱 한 번
// 예: 0~N으로 채번한 슬롯/레인/스폰 포인트/상태 enum
Tile[] _tilesByIndex = new Tile[Width * Height];

Tile Get(int x, int y) => _tilesByIndex[y * Width + x];

Dictionary의 O(1)보다 더 단순한 O(1)이다 (result.md §4-3). 단 키가 희소하거나(예: 서버가 준 64비트 전역 ID) 범위가 크면 배열은 부적절하다.

enum 키는 현대 .NET에서 EnumEqualityComparer<T>로 특수화되어 박싱이 없다(il-analysis.md §2-1). 그래도 값이 연속된 작은 정수라면 배열이 더 싸다.

2-6. 문자열 키는 로드 시점에 정수 ID로 바꾼다

// ✅ GOOD — Unity 자신이 Animator.StringToHash / Shader.PropertyToID로 쓰는 패턴
readonly Dictionary<int, EffectPlayer> _effectsById = new();
static readonly int HitFxId = Animator.StringToHash("fx_hit");   // 로드 시 1회

void Update()
{
    if (_effectsById.TryGetValue(HitFxId, out var fx)) fx.Play();   // 매 프레임 문자열 없음
}

// ✅ 문자열 키를 유지해야 한다면 비교자를 명시한다 (성능보다 정확성 이유)
readonly Dictionary<string, ItemDef> _items = new(StringComparer.Ordinal);

주의: "기본 비교자는 로케일 영향을 받는다"는 오해다. Dictionary<string, V>의 기본 비교자는 이미 ordinal이라, 터키어 로케일에서도 ContainsKey("i")는 "I" 키를 찾지 못한다(실측). 문화권 비교는 StringComparer.CurrentCulture를 명시적으로 넘겼을 때만 일어난다. 그래도 StringComparer.Ordinal을 쓰는 이유는 동작을 바꾸려는 게 아니라 의도를 남기려는 것이다 — 대소문자 무시가 필요할 때 OrdinalIgnoreCase를 고르고 CurrentCultureIgnoreCase를 피하게 만든다.

2-7. 순회는 구체 타입으로 받는다

// ✅ GOOD — 파라미터 타입만 바꾸면 48바이트가 0바이트가 된다
void TickAll(Dictionary<int, EnemyController> items)
{
    foreach (var kv in items) kv.Value.Tick();       // struct 열거자, 0 bytes
}

// ✅ GOOD — 제거는 재사용 버퍼로 (매 프레임 ToList 금지)
readonly List<int> _removeBuffer = new(64);

void SweepDead()
{
    _removeBuffer.Clear();
    foreach (var kv in _enemiesById)
        if (kv.Value.IsDead) _removeBuffer.Add(kv.Key);

    for (int i = 0; i < _removeBuffer.Count; i++)
        _enemiesById.Remove(_removeBuffer[i]);
}

정적 타입이 결정한다. 추상화를 위해 IEnumerable<T>로 받고 싶다면, 그 지점이 핫패스가 아닌지 먼저 확인한다.

2-8. 용량을 미리 준다

// ✅ GOOD — 리사이즈는 _buckets/_entries 두 배열을 새로 할당하고 옮긴다
readonly Dictionary<int, EnemyController> _enemiesById = new(capacity: MaxEnemies);
readonly List<EnemyController> _spawnBuffer = new(capacity: MaxEnemies);

웨이브 스폰처럼 상한을 아는 경우에는 반드시 준다. 참고로 리사이즈는 GetHashCode()를 다시 호출하지 않는다 — Entry.hashCode에 저장된 값으로 체인만 재구성한다(result.md §4-5).

2-9. 시작 시 고정되는 테이블은 읽기 전용으로 굳힌다

// ✅ GOOD — 로드 후 쓰기가 없는 마스터 데이터
Dictionary<int, ItemDef> _itemDefs;   // Awake에서 한 번 채우고 이후 읽기만

// FrozenDictionary(.NET 8+)는 Unity의 API 호환 레벨에 따라 없을 수 있다.
// 존재 여부를 먼저 확인하고, 없으면 일반 Dictionary + "쓰기 금지" 규약으로 간다.

3. 모바일 성능 함정

  • 조회 1회에 붙은 할당이 프레임당 수백 배로 곱해진다. 이 주제에서 나오는 할당원은 넷이다 — (a) 람다 클로저(§1-1), (b) LINQ 열거자(§1-2), (c) 인터페이스 열거자 48바이트(§1-8), (d) ObjectEqualityComparer 경로 박싱(§1-5). 전부 단명 객체라 Gen0을 채우고, Unity의 Incremental GC를 켜도 할당률 자체가 높으면 스파이크는 남는다.
  • Profiler 판독: Update의 GC Alloc이 0이 아닌데 소스에 new가 없다면, 이 주제에서는 1순위가 람다 캡처, 2순위가 IEnumerable<T> 파라미터 순회, 3순위가 IEquatable<T> 없는 struct 키다. (d)는 Deep Profile에서 ValueType.Equals 프레임으로 보인다.
  • 캐시 계층이 데스크톱보다 얕다. 위 표의 손익분기 경향은 win-x64 데스크톱 캐시 기준이다. 모바일 SoC는 L2/L3가 작아 List가 캐시를 벗어나는 N이 더 이르게 온다 — 즉 손익분기점이 왼쪽으로 이동할 개연성이 있다. 다만 이 절은 모바일에서 측정하지 않았다. 추정으로 쓰고, 수치가 필요하면 디바이스에서 잰다.
  • List의 O(n)이 데스크톱만큼 빠르지 않을 수 있다. 101배라는 실측이 나온 이유 중 하나는 Array.IndexOf의 primitive 경로가 SIMD를 타서 원소당 0.04ns까지 내려갔기 때문이다(result.md §3-6). 벡터 폭과 코드 생성이 다른 플랫폼에서 같은 비율이 나온다고 가정하면 안 된다. List 쪽 이점도, Dictionary 쪽 이점도 타깃에서 다시 측정한다.
  • 배터리 관점: 매 프레임 큰 List를 훑는 것은 DRAM 트래픽이다. 메인 메모리 접근은 캐시 히트보다 에너지가 훨씬 비싸고, 모바일에서는 발열 → 서멀 스로틀링 → 프레임 저하로 이어진다. N이 큰 컬렉션의 매 프레임 전수 탐색은 성능만이 아니라 전력 문제다.
  • 드로우콜과 직접 관계는 없다. 이 주제는 CPU 메인 스레드 문제다. 다만 GC 스톨이 프레임을 밀면 배칭 상태와 무관하게 체감 프레임이 떨어진다. "Dictionary로 바꿨더니 드로우콜이 줄었다" 같은 인과는 성립하지 않는다.
  • 큰 Dictionary의 리사이즈는 프레임 중에 하면 안 된다. _buckets(int[])와 _entries(Entry[]) 두 배열을 새로 할당하고 전부 옮긴다. 스폰 러시 도중에 이게 걸리면 그 프레임이 튄다 → §2-8의 capacity 사전 지정.

4. 플랫폼별 차이 (IL2CPP vs Mono)

대전제: 이 절의 모든 수치는 .NET 10 / CoreCLR JIT 측정이다. Unity에서 재현된다고 가정하지 않는다.

  • Unity의 BCL은 별도 포크다. Dictionary<TKey,TValue> 구현이 최신 .NET과 다를 수 있고, .NET Core 3.0에서 들어온 fastmod(나눗셈 제거) 최적화가 적용되지 않았을 수 있다. 바닥값(1.7ns 수준)을 Unity에 그대로 대입하지 않는다.
  • IL2CPP는 AOT다. JIT의 동적 devirtualization·인라이닝을 전제한 최적화가 그대로 나오지 않는다. EqualityComparer<T>.Default의 특수화 경로가 어떻게 코드 생성되는지는 빌드해서 확인할 사항이지 가정할 사항이 아니다. 다만 방향은 바뀌지 않는다 — IEquatable<T>를 구현하면 어느 백엔드에서도 손해가 없다. 미구현일 때만 백엔드에 따라 손해 폭이 달라진다.
  • 값 타입 키 제네릭은 IL2CPP에서 타입별로 특수화된다. Dictionary<GridPos, Tile>은 GridPos 전용 코드가 생성되어 박싱 없이 동작하지만, struct 키 종류를 남발하면 그만큼 코드가 복제되어 바이너리 크기와 빌드 시간이 늘어난다. 키 타입은 필요한 만큼만 만든다.
  • AOT 누락 주의: 정적 코드 경로에 등장하지 않고 리플렉션/동적 제네릭으로만 생성되는 Dictionary<TStruct, V> 인스턴스는 AOT 코드가 없어 런타임에 실패할 수 있다. 값 타입 키 Dictionary는 코드에서 직접 인스턴스화해 둔다. 스트리핑 문제가 의심되면 link.xml로 보존한다.
  • 최신 API 가용성부터 확인한다. CollectionsMarshal.GetValueRefOrAddDefault(.NET 6), FrozenDictionary(.NET 8), GetAlternateLookup(.NET 9)은 Unity의 API 호환 레벨(.NET Standard 2.1 등)에서 없을 수 있다. 존재를 가정하고 코드를 쓰지 말고, 없으면 일반 Dictionary로 간다.
  • 에디터(Mono)와 디바이스(IL2CPP)의 결과가 다를 수 있다. boxing 유무 같은 의미론은 동일하지만, 손익분기 N·상대 배수 같은 수치는 백엔드·CPU에 따라 달라진다. 에디터 프로파일로 자료구조 결정을 확정하지 않는다.
  • 정리하면: 백엔드와 무관하게 항상 참인 것은 ① TryGetValue 1회 조회, ② 키의 Equals/GetHashCode 계약, ③ 클로저·LINQ·인터페이스 열거자 할당 제거, ④ 불변 키. 수치로 정당화하는 결정만 디바이스 측정을 요구한다.

5. 코드 작성 전 체크리스트

컬렉션에서 원소를 찾는 코드를 쓸 때 확인한다.

정확성 (먼저 본다 — 여기가 깨지면 성능은 무의미)

  • [ ] 키에 float가 들어 있는가? (Vector3, Quaternion, Bounds, float 필드 struct) → 정수 격자/ID로 정규화. Dictionary는 Unity의 근사 ==를 쓰지 않는다.
  • [ ] 키 클래스/struct에서 Equals를 재정의했는데 GetHashCode는 빼먹지 않았는가? (CS0659는 경고일 뿐)
  • [ ] GetHashCode에 비교에 쓰는 모든 필드가 들어갔는가? 한 필드만 넣으면 퇴화한다(§1-7).
  • [ ] 키가 불변인가? 삽입 후 필드를 바꾸는 코드 경로가 없는가 → readonly record struct 우선.
  • [ ] Destroy한 오브젝트의 엔트리를 Remove하는가? (Destroy와 Remove는 짝)

할당 (자료구조 교체보다 먼저 한다)

  • [ ] 조회 람다가 바깥 변수를 캡처하는가? → 인덱스 루프 또는 Dictionary로 교체.
  • [ ] 핫패스에 LINQ(FirstOrDefault/Where/ToList)가 있는가? → 제거.
  • [ ] foreach 대상의 정적 타입이 IEnumerable<T>/IList<T>인가? → 구체 타입(Dictionary<K,V>/List<T>)으로 받는다. 순회 1회당 48바이트.
  • [ ] struct 키에 IEquatable<T>가 있는가? → 없으면 조회마다 박싱. IL로는 안 보인다.
  • [ ] 키 문자열을 매 프레임 만들고 있는가? → 로드 시 정수 ID로 변환.
  • [ ] 순회 중 제거를 ToList()로 회피하고 있는가? → 재사용 버퍼.

자료구조 선택 (마지막에 판단한다)

  • [ ] N이 대략 얼마인가? 수십 이하면 성능을 근거로 교체하지 않는다. 의도 표현과 삽입·삭제 빈도로 고른다.
  • [ ] 키가 연속된 작은 정수인가? → Dictionary가 아니라 배열.
  • [ ] 조회가 매 프레임/매 틱인가, 초기화 1회인가?
  • [ ] 조회는 ContainsKey + 인덱서가 아니라 TryGetValue 1회인가?
  • [ ] 크기 상한을 아는가? → capacity 지정.
  • [ ] 성능을 근거로 교체한다면, 타깃 디바이스에서 측정했는가? 이 절의 데스크톱 수치를 근거로 쓰지 않는다.

6. 빠른 판정표

코드 패턴 문제 근거 교체
list.Find(e => e.Id == id) in Update 선형 탐색 + 클로저 할당 result.md §4-2 TryGetValue 또는 캡처 없는 인덱스 루프
list.FirstOrDefault(...) in Update 열거자 할당 result.md §5-5 인덱스 루프 / Dictionary
ContainsKey(k) + dict[k] 조회 2회 (callvirt ×2) il-analysis.md §1-1 TryGetValue
Dictionary<Vector3, T> ==(근사)와 Equals(정확)의 의미 불일치 → 조회 실패 result.md §6 readonly record struct GridPos(int,int)
struct Key { int X, Y; } (IEquatable 없음) ObjectEqualityComparer → 조회마다 박싱 il-analysis.md §2-1 IEquatable<T> 구현 / record struct
Equals 재정의 + GetHashCode 미구현 버그 — 넣은 걸 못 찾음 result.md §5-1 GetHashCode 함께 재정의
삽입 후 키 필드 변경 버그 — 영구 미조회 엔트리 result.md §5-2 불변 키
GetHashCode() => oneField 체인 퇴화 (극단값 실측 1,603배, List보다도 35배 느림) result.md §5-3 모든 비교 필드 결합
void F(IEnumerable<T> x) { foreach ... } 순회 1회당 48 bytes il-analysis.md §2-2 파라미터를 구체 타입으로
dict.ToList() 매 프레임 컬렉션 전체 복제 result.md §5-5 재사용 List 버퍼
$"fx_{id}" 키 조합 매 프레임 string 할당 + 길이 비례 해시 result.md §4-1 StringToHash 류 정수 ID
N≈8 컬렉션을 성능 이유로 Dictionary화 근거 없음 (격차 약 0.3ns = 잡음) result.md §4-1 판단 근거를 의도/삽입빈도로 교체
연속 정수 키 Dictionary<int,T> 불필요한 해시·버킷 비용 result.md §4-3 배열 인덱싱
dict.Keys / dict.Values 접근 문제 없음 (0 bytes, 캐시됨) il-analysis.md §2-2b 그대로 OK
Dictionary<EnumKey, T> 문제 없음 (EnumEqualityComparer 특수화) il-analysis.md §2-1 그대로 OK (단 연속값이면 배열이 더 쌈)

9. 게임서버 실전 가이드펼치기

AI 에이전트가 게임서버 코드를 쓸 때 참조하는 규칙이다. 사람용 해설이 아니다. 핵심 전제: Dictionary 조회는 **고정 비용이 있는 O(1)**이고, List 순회는 **가장 빠른 종류의 O(n)**이다. 그래서 "O(1)이니까 무조건 Dictionary"도, "작으면 List"도 둘 다 틀린 규칙이다. 서버에서 진짜로 갈리는 지점은 조회 횟수(1회냐 2회냐), 키 타입(해시 비용), 동시성, 리사이즈 스파이크, 그리고 해시 퇴화다.

이 절이 인용하는 모든 수치의 측정 조건 (research/il-analysis.md): .NET 10.0.7 · win-x64 · 16코어 · Release · Workstation GC · int 키 · 존재하는 키 조회 · Stopwatch 3회 최소값. BenchmarkDotNet이 아니다. 자릿수와 경향만 인용하고 절대값을 규칙으로 만들지 않는다.

핫패스 정의(이 절 전체): 패킷 수신 디스패치 · 세션 조회 · 틱 루프의 엔티티/공간 조회 · 브로드캐스트 팬아웃 · 존 이동 처리.


1. DON'T — 이 주제가 서버에서 만드는 구체적 실수

DON'T ①: 세션 조회를 ContainsKey + 인덱서로 (조회 2회)

IL에 callvirt 2회로 남는다(il-analysis.md §1-1: TwoLookups=23바이트/ContainsKey+get_Item, OneLookup=19바이트/TryGetValue 1회). 해시 계산 + fastmod + _buckets 접근 + _entries 체인 순회를 통째로 두 번 한다.

// ❌ FindValue 경로를 2회 탄다. 초당 수만 패킷 × 2배 해시 비용
if (_sessionsByPlayerId.ContainsKey(playerId))
    _sessionsByPlayerId[playerId].Send(packet);

// ❌ 더 나쁨 — 문자열/큰 struct 키에서는 해시 계산 전체가 두 배
if (_playersByAccount.ContainsKey(accountName))
    _playersByAccount[accountName].Kick();

long 키에서는 미미하지만, 계정명·길드명 같은 문자열 키나 필드가 여럿인 struct 키에서는 두 배가 그대로 붙는다.

DON'T ②: opcode가 연속된 작은 정수인데 Dictionary로 디스패치

// ❌ opcode가 0~1023 연속인데 해시 테이블
Dictionary<ushort, PacketHandler> _handlers = new();

void OnPacket(Session s, Packet p)
{
    if (_handlers.TryGetValue(p.Opcode, out var h)) h(s, p);
}

Dictionary 바닥값은 N과 무관하게 약 1.7ns다(측정 조건 위 참조, N=2에서 1.69ns, N=1024에서 1.69ns). 해시 계산·fastmod·배열 2개 접근이라는 고정 비용을 매 패킷 낸다. 연속 정수 키라면 이 비용 전부가 불필요하다 — 배열 인덱싱 한 번이면 된다.

DON'T ③: IO 스레드 여러 개가 같은 Dictionary에 쓴다

// ❌ 접속/종료가 IO 스레드에서 동시에 일어난다
private readonly Dictionary<long, Session> _sessions = new();

void OnAccept(Session s)     => _sessions[s.PlayerId] = s;   // 다른 스레드
void OnDisconnect(long id)   => _sessions.Remove(id);        // 또 다른 스레드
Session? Find(long id)       => _sessions.GetValueOrDefault(id); // 워커 스레드

리사이즈 도중 다른 스레드가 읽으면 반쯤 재구성된 체인을 따라간다. .NET Framework 시절 이것이 Entry.next 순환으로 무한 루프 → CPU 100% 고정 장애로 유명했다. 현대 .NET에는 일부 손상을 감지해 예외를 던지는 방어가 있지만 감지 보장은 없다. 검출되지 않은 손상은 "세션이 사라졌다가 다시 나타나는" 재현 불가 버그가 된다.

DON'T ④: 반대로, 단일 스레드 액터인데 전부 ConcurrentDictionary

// ❌ 존 로직이 단일 스레드 이벤트 루프인데 습관적으로 Concurrent
private readonly ConcurrentDictionary<int, Monster> _monsters = new();

void Tick()
{
    foreach (var kv in _monsters) kv.Value.Update();   // 경합 없는데 인터록 비용만 냄
}

ConcurrentDictionary는 버킷 단위 락과 Volatile/인터록 연산 비용이 있어 경합이 없는 단일 스레드에서는 일반 Dictionary보다 느리다. 존/룸이 단일 스레드 액터 모델이면 일반 Dictionary가 맞다.

추가로 두 가지 함정이 있다.

// ❌ valueFactory는 락 밖에서 실행되며 여러 번 호출될 수 있다
var session = _sessions.GetOrAdd(playerId, id => CreateSessionAndRegisterSocket(id));
// → 소켓이 두 개 만들어지고 하나가 누수될 수 있다

// ❌ Count는 모든 락을 잡는다. 틱마다 부르면 전체 직렬화
if (_sessions.Count > Capacity) Reject();

DON'T ⑤: 외부 입력 문자열을 그대로 키로 쓴다 (해시 충돌 DoS)

// ❌ 로그인 패킷의 닉네임이 그대로 키가 된다
private readonly Dictionary<string, Player> _playersByNickname = new();

void OnLogin(LoginPacket p) => _playersByNickname[p.Nickname] = new Player(p);

공격자가 해시가 충돌하는 문자열을 대량 생성해 보내면 한 체인이 길어지고 조회가 O(n)으로 퇴화한다. §5-3의 퇴화 수치(1,603배)가 공격으로 재현되는 셈이다.

.NET은 한 체인의 충돌이 임계값을 넘으면 비랜덤 문자열 비교자를 랜덤화 비교자로 전환하고 재해시하는 방어가 있다. 하지만 이 임계값(런타임 소스상 100)은 구현 세부사항이고, 필자도 직접 측정하지 않았다. 런타임 방어에만 의존하는 설계는 금지한다. 전환이 일어나는 순간의 전체 재해시 비용 자체도 요청 처리 중에 발생하는 지연 스파이크다.

DON'T ⑥: 가변 클래스/struct를 키로 쓰고 넣은 뒤 필드를 바꾼다

// ❌ 가변 키
public sealed class RoomKey { public int ChannelId; public int RoomNo;
    public override bool Equals(object? o) => o is RoomKey k && ChannelId == k.ChannelId && RoomNo == k.RoomNo;
    public override int GetHashCode() => HashCode.Combine(ChannelId, RoomNo);
}

var key = new RoomKey { ChannelId = 1, RoomNo = 10 };
_rooms[key] = room;

key.ChannelId = 2;          // 채널 이전 처리에서 무심코 변경
_rooms.TryGetValue(key, out _);   // false — 영원히 못 찾는다

삽입 시점 해시로 버킷이 정해졌는데 지금 해시는 다른 버킷을 가리킨다. 그 방은 테이블 안에 남아 있지만 접근 불가다. 순회(관리자 목록)로는 보이는데 조회로는 안 나오는, 서버에서 가장 디버깅하기 고약한 부류의 누수다.

Equals만 재정의하고 GetHashCode를 빼먹으면(CS0659 경고) 넣자마자 못 찾는다. 이건 성능이 아니라 정확성 버그다.

DON'T ⑦: 저카디널리티 필드 하나만 해시에 넣는다

// ❌ ChannelId는 값이 8개뿐 → 버킷 8개에 전부 몰린다
public readonly struct EntityKey
{
    public readonly int ChannelId;   // 0~7
    public readonly long EntityId;
    public override int GetHashCode() => ChannelId;   // ← 퇴화
}

실측(N=10,000, IEquatable<T> 구현 struct 키, 측정 조건 위 참조):

시간
정상 해시 Dictionary 4.36 ns
List<int> 선형 탐색 (같은 N) 201.12 ns
퇴화한 Dictionary 6,979.62 ns

1,602.7배 느려지고, 같은 크기의 List보다도 약 35배 느리다. 해시 테이블이 링크드 리스트로 전락하면 next 인덱스를 따라 배열 안을 뛰어다니므로 프리페처도 SIMD도 못 쓴다 — 순차 배열 순회보다 나쁘다.

주의: 4.36ns는 struct 키 기준이고 §3-6의 1.98ns는 int 키 기준이다. 두 숫자를 직접 대조하지 말 것. 자릿수 비교용이다.

DON'T ⑧: 공간 해시/엔티티 키 struct에 IEquatable<T> 미구현

// ❌ IEquatable 없음 → EqualityComparer<GridPos>.Default가 ObjectEqualityComparer'1
public struct GridPos { public int X, Y; }

Dictionary<GridPos, List<Entity>> _spatialHash = new();

실측 확인(il-analysis.md §2-1): IEquatable<T> 미구현 struct는 ObjectEqualityComparer<T>, 구현하면 GenericEqualityComparer<T>가 선택된다. 전자는 object.Equals(object) 경로 → ValueType.Equals 필드 단위 비교 → 비교마다 박싱.

호출부 IL은 바이트 단위로 동일하다(il-analysis.md §1-3, 둘 다 Code size 8). 디컴파일해서 "박싱 없네" 확인해도 소용없다. 이건 런타임 타입 시스템이 고르는 갈림길이고 할당 프로파일러로만 보인다. 공간 해시는 틱마다 수천 번 조회되므로 그대로 alloc rate가 된다.

DON'T ⑨: 틱 루프에서 IEnumerable<T>로 받아 순회 / LINQ

// ❌ 파라미터 타입이 인터페이스 → 순회 1회당 48바이트
void Broadcast(IEnumerable<KeyValuePair<long, Session>> sessions, Packet p)
{
    foreach (var kv in sessions) kv.Value.Send(p);
}

// ❌ 틱마다 열거자 체인을 힙에 만든다
var nearby = _entities.Values.Where(e => e.Zone == zone).ToList();

실측: foreach (var kv in dict) = 0 bytes, foreach (var kv in (IEnumerable<KVP>)dict) = 48 bytes. 순회 길이와 무관한 열거자 1개분이다. 원소당이 아니라 순회 1회당 48바이트. 20Hz 틱 × 존 100개면 초당 96KB가 순수 쓰레기로 쌓인다.

DON'T ⑩: 세션 테이블을 용량 없이 만들고 접속 폭주를 받는다

// ❌ 예상 동접 8,000인데 capacity 없음
private readonly Dictionary<long, Session> _sessions = new();

리사이즈마다 _buckets(int[])와 _entries(Entry[]) 두 배열을 새로 할당하고 전부 옮긴다. 접속 폭주(패치 직후, 서버 오픈) 구간에서 리사이즈가 반복되며 지연 스파이크 + LOH 쓰레기를 만든다(§3-2 참조).

DON'T ⑪: 순회 중 추가 / ref를 들고 있다가 리사이즈

// ❌ 순회 중 Add → _version 증가 → InvalidOperationException
foreach (var kv in _sessions)
    if (kv.Value.NeedsProxy) _sessions[kv.Key + ProxyOffset] = MakeProxy(kv.Value);

// ❌ ref를 들고 있는 사이에 다른 키를 추가 → _entries 배열이 통째로 교체됨
ref int dmg = ref CollectionsMarshal.GetValueRefOrAddDefault(_damageByPlayer, id, out _);
_damageByPlayer[otherId] = 0;     // ← 리사이즈 가능
dmg += amount;                    // ← 옛 배열에 쓴다. 조용히 사라진다

두 번째가 특히 위험하다. 예외도 안 나고 값만 사라진다.

DON'T ⑫: 원소 5~10개짜리 컬렉션을 "성능 때문에" Dictionary로 바꾼다

실측(측정 조건 위 참조):

     N |  List.Contains |  Dict.ContainsKey |    배수
------------------------------------------------------
     2 |       2.03 ns  |          1.69 ns  |  1.20배
     8 |       1.96 ns  |          1.69 ns  |  1.16배
    32 |       3.63 ns  |          1.70 ns  |  2.14배
    64 |       2.87 ns  |          1.70 ns  |  1.69배   ← N=32와 역전(측정 잡음)

N≤8 구간의 차이는 0.3ns 남짓으로 측정 잡음과 구분되지 않는다. 파티 멤버 5명, 장착 슬롯 8개 같은 컬렉션의 자료구조 교체는 성능 근거로 정당화되지 않는다. 가독성·의도 표현으로 고른다. (반대로 "작으면 List가 빠르다"도 틀렸다 — 이 측정에서 List가 이긴 구간은 없었다.)


2. DO — 올바른 서버 패턴

DO ①: 조회는 항상 1회 — TryGetValue

// ✅ FindValue 1회 (il-analysis.md §1-1: callvirt 1회)
if (_sessionsByPlayerId.TryGetValue(playerId, out var session))
    session.Send(packet);

값을 만들며 갱신하는 카운터류는 .NET 6+의 ref API로 조회+갱신을 1회로 줄인다.

using System.Runtime.InteropServices;

// ✅ 조회 1회로 누적. 큰 struct 값이면 복사도 피한다
ref DamageStat stat = ref CollectionsMarshal.GetValueRefOrAddDefault(_damage, attackerId, out bool existed);
if (!existed) stat = default;
stat.Total += amount;
stat.Hits++;
// ← 이 ref는 여기서 즉시 소비하고 버린다. 다음 Add/Remove 전까지만 유효.

규칙: GetValueRefOrAddDefault/GetValueRefOrNullRef로 얻은 ref는 같은 문장 블록 안에서 소비하고 끝낸다. 필드·로컬로 보관하지 않는다.

DO ②: 연속 opcode는 배열 인덱싱

// ✅ 해시 계산·버킷 탐색·체인 순회 전부 없음. 인덱싱 한 번
public sealed class PacketRouter
{
    private const int MaxOpcode = 1023;
    private readonly PacketHandler?[] _byOpcode = new PacketHandler?[MaxOpcode + 1];

    public void Register(ushort opcode, PacketHandler handler) => _byOpcode[opcode] = handler;

    public void Dispatch(Session s, in Packet p)
    {
        // 경계 검사는 필수 — opcode는 외부 입력이다
        if (p.Opcode > MaxOpcode) { s.Disconnect(DisconnectReason.BadOpcode); return; }

        var handler = _byOpcode[p.Opcode];
        if (handler is null) { s.Disconnect(DisconnectReason.UnknownOpcode); return; }
        handler(s, in p);
    }
}

적용 조건: opcode가 조밀한 작은 정수일 때만. 희소하거나(0x0001, 0x8000, 0xF00D) 64비트 전역 ID면 배열은 부적절하다 — 그때는 Dictionary/FrozenDictionary.

DO ③: 시작 시 만들고 안 바꾸는 테이블은 FrozenDictionary (.NET 8+)

opcode가 희소해서 배열을 못 쓰는 경우, 그리고 아이템·스킬·설정 테이블에 쓴다.

using System.Collections.Frozen;

// ✅ 서버 부팅 시 1회 생성. 이후 읽기 전용 → 락도 필요 없다
private static readonly FrozenDictionary<ushort, PacketHandler> Handlers =
    BuildHandlerMap().ToFrozenDictionary();

private static readonly FrozenDictionary<int, ItemTemplate> Items =
    LoadItems().ToFrozenDictionary(i => i.Id);

생성 시 키 분포를 분석해 조회에 최적화된 레이아웃을 고른다. 생성 비용이 크므로 런타임에 반복 생성하면 안 된다 — 부팅/핫리로드 시점에만.

DO ④: 읽기 압도적 · 쓰기 드묾 → 불변 스냅숏 교체

접속/종료는 초당 수십 번인데 조회는 초당 수만 번인 세션 테이블에 맞는다. 읽기 경로에 락이 전혀 없다.

public sealed class SessionRegistry
{
    private Dictionary<long, Session> _map = new(capacity: 8192);
    private readonly object _writeGate = new();

    // 읽기: 락 없음. 참조 하나 읽고 일반 Dictionary 조회
    public bool TryGet(long playerId, out Session session)
        => Volatile.Read(ref _map).TryGetValue(playerId, out session!);

    public void Add(long playerId, Session session)
    {
        lock (_writeGate)
        {
            var next = new Dictionary<long, Session>(_map, _map.Comparer); // 복사
            next[playerId] = session;
            Volatile.Write(ref _map, next);   // 원자적 교체
        }
    }

    public void Remove(long playerId)
    {
        lock (_writeGate)
        {
            var next = new Dictionary<long, Session>(_map, _map.Comparer);
            if (!next.Remove(playerId)) return;
            Volatile.Write(ref _map, next);
        }
    }
}

트레이드오프를 명시한다: 쓰기마다 전체 복사(O(N))이므로 접속률이 높으면 부적합하다. 동접 8,000 기준 복사 1회가 수백 μs 규모이고 그동안 LOH 할당이 발생한다. 초당 접속이 수백 건 이상이면 ConcurrentDictionary나 샤딩으로 간다.

DO ⑤: 진짜 다중 쓰기면 ConcurrentDictionary, 단 사용법을 지킨다

private readonly ConcurrentDictionary<long, Session> _sessions = new(
    concurrencyLevel: Environment.ProcessorCount, capacity: 8192);

// ✅ valueFactory에 부작용을 넣지 않는다 (여러 번 호출될 수 있음)
var session = new Session(playerId, socket);          // 생성은 밖에서
if (!_sessions.TryAdd(playerId, session))
{
    session.Dispose();                                // 경쟁에서 졌으면 정리
    return;
}

// ✅ 조건부 제거 — 다른 스레드가 재접속으로 덮어쓴 세션을 지우지 않는다
_sessions.TryRemove(new KeyValuePair<long, Session>(playerId, session));  // .NET Core 3.0+

// ✅ 개수는 Count(전체 락) 대신 별도 카운터
Interlocked.Increment(ref _sessionCount);

TryRemove(key, out _)는 "내가 아는 그 세션"이 아니라 현재 값을 지운다. 재접속 레이스에서 새 세션을 날린다. KeyValuePair 오버로드를 쓴다.

DO ⑥: 브로드캐스트는 스냅숏 위에서, 락 안에서 Send 하지 않는다

// ✅ 스냅숏 참조를 잡고 순회. 순회 중 등록/해제와 무관
var map = Volatile.Read(ref _map);
foreach (var kv in map)          // 정적 타입이 Dictionary → 0 bytes
    kv.Value.EnqueueSend(packet); // 논블로킹 큐잉. 소켓 Send를 여기서 하지 않는다
  • 정적 타입을 Dictionary<K,V>로 유지한다(인터페이스로 받으면 48바이트).
  • 순회 중 실제 소켓 I/O를 하지 않는다. 느린 클라이언트 하나가 전체 순회를 붙잡는다. 세션별 송신 큐에 넣고 반환한다.
  • 일반 Dictionary를 순회하며 지워야 한다면 제거 대상을 모았다가 순회 후 처리한다.
// ✅ 순회 중 구조 변경 금지 — 수집 후 처리 (버퍼는 재사용)
_deadBuffer.Clear();
foreach (var kv in _monsters)
    if (kv.Value.IsDead) _deadBuffer.Add(kv.Key);
foreach (var id in _deadBuffer) _monsters.Remove(id);

DO ⑦: 키는 불변 + IEquatable<T> + 전 필드 해시

// ✅ 불변성 · IEquatable<T> · GetHashCode를 한 줄로
public readonly record struct GridPos(int X, int Y);
public readonly record struct EntityKey(int ChannelId, long EntityId);

Dictionary<GridPos, List<Entity>> _spatialHash = new(capacity: 4096);

record struct를 쓸 수 없는 환경이면 수동으로 전부 구현한다.

// ✅ 수동 구현 — EqualityComparer<T>.Default가 GenericEqualityComparer를 고르게 된다
public readonly struct GridPos : IEquatable<GridPos>
{
    public readonly int X, Y;
    public GridPos(int x, int y) { X = x; Y = y; }

    public bool Equals(GridPos other) => X == other.X && Y == other.Y;
    public override bool Equals(object? o) => o is GridPos g && Equals(g);
    public override int GetHashCode() => HashCode.Combine(X, Y);   // 두 필드 모두
}

엔티티 키에 enum을 써도 된다. .NET 5+에서 EnumEqualityComparer<T>로 특수화되어 박싱이 없다(il-analysis.md §2-1 실측). ".NET Framework 시절 enum 키는 박싱된다"는 조언은 현대 런타임에 해당하지 않는다.

DO ⑧: 외부 문자열은 경계에서 정수 ID로 바꾼다

// ✅ 권위 있는 정수 ID를 키로. 닉네임 → ID 변환은 로그인 시 1회, DB/캐시 계층에서
private readonly Dictionary<long, Player> _playersById = new(capacity: 8192);

// ✅ 그래도 문자열 키가 필요하면 비교자를 명시하고 입력을 제한한다
private readonly Dictionary<string, GuildInfo> _guildsByName =
    new(capacity: 1024, StringComparer.Ordinal);

static bool IsValidName(ReadOnlySpan<char> n)
    => n.Length is >= 2 and <= 16 && !n.ContainsAnyExcept(AllowedNameChars);

StringComparer.Ordinal은 동작을 바꾸려고 쓰는 게 아니라 의도를 남기려고 명시한다. Dictionary<string, V>의 기본 비교자는 이미 ordinal이므로 서버 로케일을 바꿔도 조회 결과는 그대로다(실측: tr-TR에서도 ContainsKey("i")가 "I" 키를 못 찾음). 명시의 값어치는 나중에 대소문자 무시가 필요해졌을 때 OrdinalIgnoreCase를 고르고 CurrentCultureIgnoreCase를 피하게 만드는 데 있다. 프로토콜 식별자는 애초에 문자열 대신 enum/ushort.

DO ⑨: .NET 9+ — 문자열 할당 없이 조회 (GetAlternateLookup)

수신 버퍼에서 이름을 잘라 조회할 때, 조회만 하려고 string을 만들지 않는다.

// ✅ ReadOnlySpan<char>로 직접 조회 → 조회 실패해도 string 할당 0
private readonly Dictionary<string, CommandHandler> _commands = new(StringComparer.Ordinal);

public void OnChatCommand(ReadOnlySpan<char> name, Session s)
{
    var lookup = _commands.GetAlternateLookup<ReadOnlySpan<char>>();
    if (lookup.TryGetValue(name, out var handler)) handler(s);
}

채팅 명령·치트 방어 필터처럼 대부분 실패하는 조회에서 특히 크다. 실패 조회를 위해 문자열을 만드는 게 순수 낭비였기 때문이다.

DO ⑩: 용량을 미리 준다

// ✅ 예상 동접 기준으로 지정 — 리사이즈 반복과 그 스파이크를 없앤다
private readonly Dictionary<long, Session> _sessions = new(capacity: 8192);

// ✅ 대량 로드 직전에는 EnsureCapacity
_items.EnsureCapacity(templateCount);
foreach (var row in rows) _items[row.Id] = row.ToTemplate();

// ✅ 틱마다 만드는 임시 테이블은 새로 만들지 말고 Clear로 재사용 (용량 유지)
_perTickBuckets.Clear();

참고로 일반 리사이즈는 GetHashCode()를 다시 호출하지 않는다 — Entry.hashCode에 저장된 값으로 체인만 재구성한다. 해시가 비싼 키(긴 문자열)에서 다행인 설계지만, 두 배열의 재할당·복사 비용은 그대로 든다.


3. 서버 성능 함정

3-1. 조회 2회는 "키 타입에 비례해" 비싸진다

ContainsKey + 인덱서의 추가 비용은 long 키에서는 ~1.7ns지만, 키가 길이 16 문자열이면 해시 계산 전체가 한 번 더 일어난다. 세션 조회가 패킷당 1~3회, 초당 수만 패킷이면 이 차이가 코어 하나를 갉아먹는다. TryGetValue 외의 조회 패턴을 코드에 남기지 않는다.

3-2. 리사이즈 = 두 배열 재할당 + LOH 진입

Dictionary<long, Session>의 Entry는 hashCode(4) + next(4) + key(8) + value(참조 8) ≈ 24바이트다(x64). LOH 임계값 85,000바이트를 넘는 지점은 대략 엔트리 3,500개 근처다.

즉 동접 수천 규모의 세션 테이블은 _entries 배열이 LOH에 산다. 결과:

  • 리사이즈마다 LOH 할당 + 옛 배열이 LOH 쓰레기가 된다. LOH는 기본적으로 압축되지 않아 단편화가 누적된다.
  • LOH 할당은 Gen2 수집을 유발할 수 있다 → 접속 폭주 구간에 Gen2 GC가 겹치는 최악의 조합.
  • 대응: capacity를 예상 최대 동접 기준으로 처음부터 준다. 성장 여지를 두되 실측 없이 무리하게 크게 잡지 않는다.

3-3. 큰 장수(long-lived) 테이블은 GC 스캔 비용이다

세션/엔티티 테이블은 서버 수명 내내 살아 Gen2로 간다. 값이 참조 타입이면 _entries 배열은 참조를 가득 담은 Gen2 객체이고, 여기에 참조를 쓸 때마다 write barrier + 카드 마킹이 발생한다. 카드가 더럽혀진 영역은 Gen0/Gen1 수집 때도 스캔된다.

  • 값에 불필요한 참조 필드를 늘리지 않는다. 세션 객체가 참조를 많이 물면 마크 비용이 커진다.
  • 틱마다 값 전체를 갈아끼우는 패턴(_map[id] = newObj)은 카드 마킹을 계속 유발한다. 가능하면 객체를 재사용하고 내부 필드만 갱신한다.
  • 반대로 Dictionary<int, int>처럼 참조가 없는 테이블은 GC 스캔 대상이 아니다 — 키/값을 참조 없는 형태로 유지할 수 있으면 유리하다.

3-4. 해시 퇴화는 "성능 저하"가 아니라 "가용성 사고"다

실측: 퇴화 시 1,602.7배, 같은 N의 List보다도 35배 느림(N=10,000, struct 키, 측정 조건 위 참조). 세션 조회가 μs 단위로 늘어나면 틱이 밀리고 → 큐가 쌓이고 → 타임아웃 → 대량 접속 종료로 이어진다. 성능 문제가 장애로 번지는 전형적 경로다.

  • 키 struct의 GetHashCode에 모든 식별 필드를 넣는다(HashCode.Combine).
  • 외부 입력 문자열을 키로 두지 않는다(§DON'T ⑤).
  • 운영 지표: 조회 지연 p99를 재는 것보다, 키 분포 히스토그램을 부팅 시 샘플링해 두는 편이 조기 발견에 낫다.

3-5. 순회 경로의 할당 — 48바이트 × 틱레이트

IEnumerable<KVP>로 받으면 순회 1회당 48바이트(실측). 원소당이 아니므로 개별로는 작지만, 틱 × 존 × 시스템 수로 곱해진다. LINQ(Where/Select/GroupBy)는 열거자 체인을 힙에 만들어 이보다 훨씬 크다. 핫패스에서 LINQ 금지, 순회 파라미터는 구체 타입.

dict.Keys / dict.Values 접근 자체는 캐시되어 할당이 없다(실측 0 bytes, ReferenceEquals(d.Keys, d.Keys) == True). 다만 .ToList() / .ToArray()는 당연히 새 컬렉션이다 — 브로드캐스트마다 부르지 않는다.

3-6. 락 유지 시간이 조회 비용보다 훨씬 크다

lock 안에서 Dictionary를 조회하는 것 자체는 ~2ns지만, 락 안에서 Send/await/로그를 하면 임계 구역이 수십 μs가 된다. 자료구조 선택보다 임계 구역 길이가 지배적이다. 락 안에서는 참조만 꺼내고 나온다.

// ✅ 락은 조회까지만
Session? s;
lock (_gate) { _map.TryGetValue(id, out s); }
s?.EnqueueSend(packet);   // 락 밖

3-7. 실패 조회는 List에 더 불리하다

측정은 존재하는 키만 조회했다. 실패 조회에서 List.Contains는 항상 N개를 전부 훑으므로 배수가 더 커진다. 중복 로그인 검사·차단 목록 조회처럼 대부분 실패하는 조회가 서버에는 많다 — 이 경우 손익분기점이 왼쪽으로 이동한다.


4. 런타임별 차이 (.NET 버전 · Workstation vs Server GC)

4-1. .NET 버전별로 쓸 수 있는 수단이 다르다

버전 변화 서버에서의 의미
.NET Framework Hashtable은 키가 object → 값 타입마다 box(IL에 직접 보임). 동시 쓰기 시 무한 루프 / CPU 100% 장애 사례 레거시 서버 코드에서 Hashtable 발견 시 교체 대상
.NET Core 2.1 조회 경로 리팩터링, ref 반환 기반 FindValue —
.NET Core 3.0 fastmod(Lemire) 로 버킷 계산에서 나눗셈 제거. Dictionary.Remove가 순회 중에도 열거자를 무효화하지 않도록 보장 순회하며 제거하는 코드는 이 버전 이상에서만 동작. Framework/Unity Mono로 포팅하면 깨진다 → §DO ⑥의 수집 후 제거 패턴을 기본으로 쓴다
.NET 5+ comparer 특수화·devirtualization 개선. enum은 EnumEqualityComparer로 특수화 → enum 키 박싱 없음(실측) opcode/상태 enum을 키로 써도 안전
.NET 6 CollectionsMarshal.GetValueRefOrNullRef / GetValueRefOrAddDefault 조회+갱신 1회. 단 ref 무효화 규칙 준수(§DON'T ⑪)
.NET 8 FrozenDictionary 부팅 시 만드는 핸들러·아이템·설정 테이블
.NET 9+ GetAlternateLookup<ReadOnlySpan<char>>() 수신 버퍼 문자열 조회에서 string 할당 제거

fastmod의 성능 기여도는 측정하지 않았다. 도입 사실만 런타임 소스로 확인한 것이다.

4-2. Workstation GC vs Server GC — 이 주제에서 실제로 갈리는 지점

이 절이 인용한 수치는 전부 Workstation GC에서 측정되었다. 조회 자체는 GC 모드와 무관하지만, Dictionary가 만드는 GC 압박은 모드에 따라 다르게 나타난다.

  • Server GC(<ServerGarbageCollection>true, 게임서버 기본 권장)는 코어마다 힙과 GC 스레드를 둔다. 세션 테이블 리사이즈 같은 큰 배열 할당의 처리량은 좋아지지만, LOH 할당 자체가 사라지지는 않는다. 큰 _entries 배열의 재할당은 여전히 Gen2/LOH 이벤트다.
  • Server GC는 힙 개수만큼 Gen0 예산이 커서 48바이트짜리 열거자 박싱 같은 소량 할당은 잘 흡수한다. 반대로 한 번의 수집이 훑는 총량은 커진다 — 큰 장수 Dictionary가 많으면 마크 단계가 길어지고 p99가 튄다.
  • Workstation GC(기본, 클라이언트/개발 머신)에서는 리사이즈 스파이크가 더 직접적으로 지연에 나타난다. 개발 머신에서 잰 수치를 프로덕션 Server GC에 그대로 대입하지 않는다.
  • 어느 모드든 결론은 같다: capacity를 미리 줘서 리사이즈를 없애는 것이 GC 모드 튜닝보다 우선이다.
  • <ConcurrentGarbageCollection>(백그라운드 GC)이 켜져 있어도 LOH 압축은 기본적으로 일어나지 않는다. 세션 테이블을 반복 재생성하는 설계(§DO ④의 스냅숏 교체)는 접속률이 높으면 LOH 단편화를 만든다 — 트레이드오프를 실측으로 확인한다.

4-3. 컨테이너 / 코어 수

ConcurrentDictionary의 기본 concurrencyLevel은 프로세서 수 기반이다. 컨테이너에 CPU 제한(--cpus)을 걸면 런타임이 보는 코어 수가 달라져 락 배열 크기와 경합 특성이 바뀐다. 명시적으로 지정하고, 배포 환경에서 재측정한다.

4-4. 이 절의 수치를 다른 런타임에 대입하지 말 것

  • 측정은 int 키 / CoreCLR JIT / win-x64 기준이다. 키가 문자열이면 Dictionary 쪽 바닥값이 올라가 손익분기점이 오른쪽으로 이동한다.
  • Linux x64 / arm64에서 캐시 크기와 SIMD 폭이 다르면 List 쪽 곡선이 바뀐다. 실제 배포 대상에서 잰다.
  • BenchmarkDotNet이 아니다. 이상치 제거·오버헤드 보정이 없다. 자릿수와 경향만 인용한다.

5. 코드 작성 전 체크리스트

조회 테이블(세션·엔티티·핸들러·공간 해시)을 만들거나 고치기 전에 확인한다.

자료구조 선택

  • [ ] 키가 연속된 작은 정수(opcode, 슬롯 인덱스)인가? → 배열. 경계 검사 필수(외부 입력).
  • [ ] 부팅 시 만들고 이후 안 바꾸는가? → FrozenDictionary (.NET 8+). 런타임 재생성 금지.
  • [ ] 원소가 10개 미만인가? → 성능 근거로 자료구조를 바꾸지 않는다(N≤8에서 차이 0.3ns, 잡음 수준). 의도 표현으로 고른다.
  • [ ] 조회가 대부분 실패하는가? → List 쪽이 더 불리하다. Dictionary/HashSet 쪽으로.

정확성 (성능보다 먼저)

  • [ ] 키가 불변인가? 넣은 뒤 필드가 바뀔 여지가 있으면 readonly record struct로 바꾼다.
  • [ ] Equals를 재정의했으면 GetHashCode도 했는가? (CS0659는 경고일 뿐이다)
  • [ ] GetHashCode에 모든 식별 필드가 들어갔는가? 저카디널리티 필드 하나만 넣으면 퇴화(1,603배, List보다도 35배 느림).
  • [ ] 키가 struct면 IEquatable<T> 구현했는가? 없으면 ObjectEqualityComparer → 비교마다 박싱. IL로는 안 보인다. 확인은 EqualityComparer<T>.Default.GetType().Name 출력 또는 할당 프로파일러로.
  • [ ] 문자열 키면 StringComparer.Ordinal을 명시했는가? (정확성 이유)

동시성

  • [ ] 이 테이블에 쓰는 스레드가 둘 이상인가? 하나라도 있으면 일반 Dictionary 공유 금지.
  • [ ] 단일 스레드 액터/이벤트 루프인가? → ConcurrentDictionary를 쓰지 않는다. 인터록 비용만 낸다.
  • [ ] 읽기 압도적·쓰기 드묾인가? → 불변 스냅숏 교체(Volatile.Read/Write). 쓰기가 잦으면 O(N) 복사 때문에 부적합 — 접속률로 판단.
  • [ ] ConcurrentDictionary를 쓴다면: GetOrAdd 팩토리에 부작용 없음? TryRemove에 KeyValuePair 오버로드 사용? Count를 핫패스에서 안 부름?
  • [ ] 락 안에서 Send/await/로그를 하고 있지 않은가? 락은 참조 꺼내기까지.

할당

  • [ ] capacity를 지정했는가? 특히 세션 테이블 — 엔트리 ~3,500개 근처부터 _entries가 LOH다.
  • [ ] 조회를 ContainsKey + 인덱서로 하고 있지 않은가? → TryGetValue. IL callvirt 2회 → 1회.
  • [ ] 순회 파라미터/변수의 **정적 타입이 Dictionary<K,V>**인가? IEnumerable<T>면 순회당 48바이트.
  • [ ] 핫패스에 LINQ가 있는가? → 제거.
  • [ ] 틱마다 만드는 임시 테이블을 Clear()로 재사용하는가?
  • [ ] CollectionsMarshal의 ref를 보관하고 있지 않은가? 리사이즈 후 조용히 값이 사라진다.

순회

  • [ ] 순회 중 Add가 없는가? (_version 증가 → 예외)
  • [ ] 순회 중 제거가 필요하면 수집 후 처리 패턴인가? (Remove 중 순회 허용은 .NET Core 3.0+ 한정 — Framework/Mono 포팅 시 깨짐)

보안

  • [ ] 외부 입력 문자열(닉네임, 채팅, 커스텀 필드)이 키인가? → 내부 정수 ID로 변환. 길이·문자셋 제한. 런타임의 랜덤화 comparer 전환에만 의존하지 않는다(임계값은 구현 세부사항).

검증

  • [ ] 자료구조를 바꿨다면 배포 대상 플랫폼에서 직접 측정했는가? 이 문서 수치(.NET 10 / win-x64 / int 키 / Stopwatch)를 근거로 쓰지 않는다.
  • [ ] 할당 검증은 dotnet-counters의 alloc-rate / gen-0-gc-count / gen-2-gc-count, LOH는 loh-size로.

리서치 소스

소스 파일 상태
NotebookLM (서적 기반) research/notebooklm.md 미수행 — Google 세션 만료
GPT (gpt-5.6-terra) research/gpt.md 완료
Claude 통합 분석 research/claude.md 완료
IL 디컴파일 + 런타임 실측 research/il-analysis.md 완료 — 이 글 수치의 1차 근거

주요 참조: