반응형

[PART9.컬렉션 기본 사용법(2/8)] Dictionary<TKey, TValue> — 키로 즉시 찾는 컬렉션

해시 테이블의 동작 원리 / TryGetValue가 빠른 이유 / GetHashCode·Equals 계약 / Unity에서 enum 키와 박싱 함정


1. 왜 Dictionary가 필요한가

Unity로 모바일 게임을 만들면 곧 이런 상황이 옵니다.

"플레이어 ID로 캐릭터 정보를 찾고 싶은데, 매 프레임 1만 명 중에서 검색해야 합니다."

List<Player>로 시작하면 처음 몇 명일 때는 잘 돌아가지만, 인원이 늘면 Find 한 번이 리스트 전체를 훑습니다. 1만 명을 매 프레임 4회 검색하면 4만 회 비교가 60fps에 60번씩 — 결국 프로파일러가 빨개집니다.

문제는 List<T>순서대로 늘어선 자료구조라는 점입니다. 특정 ID를 찾으려면 처음부터 끝까지 읽어 보는 수밖에 없고, 시간복잡도는 평균 O(n)입니다.

시간복잡도(Time complexity) 입력 크기 n에 따라 연산 횟수가 어떻게 늘어나는지를 표현하는 척도. O(n)은 데이터가 두 배가 되면 시간도 두 배, O(1)은 데이터 크기와 무관하게 일정 시간 안에 끝남을 뜻한다.

Dictionary<TKey, TValue>"키만 알면 데이터 위치를 곧바로 계산해서 가져온다"는 발상으로 이 문제를 해결합니다. 평균 O(1) — 데이터가 1만 개든 100만 개든 한 번의 조회는 거의 같은 시간이 걸립니다.

이 글에서 다루는 핵심은 단순한 사용법이 아니라 다음 네 가지입니다.

  1. 내부의 해시 테이블이 어떻게 키를 위치로 바꾸는가
  2. Add·ContainsKey·TryGetValue·인덱서 네 가지 패턴이 IL 레벨에서 어떻게 다른가
  3. 커스텀 클래스나 enum을 키로 쓸 때 무엇을 망치면 안 되는가
  4. 초기 용량과 비교자(comparer)가 성능에 얼마나 영향을 주는가

외워서 쓰는 컬렉션이 아니라 동작이 보이는 컬렉션으로 만드는 것이 목표입니다.


2. 개념 정의 — "사물함 번호로 짐 찾기"

2.1 키를 주소로 바꾸는 함수

Dictionary<TKey, TValue>를 한 줄로 요약하면 "키를 받아서 그 키를 저장할 배열 인덱스를 즉시 계산해 주는 자료구조" 입니다. 사물함을 떠올리면 빠릅니다. 학번을 사물함 번호로 변환하는 규칙(예: 학번 % 100)이 있다면, 학번 12345의 짐은 45번 사물함에 있다는 사실을 검색 없이 바로 압니다. 이 변환 규칙이 곧 해시 함수(hash function) 입니다.

해시 함수(Hash function) 임의의 입력값을 정수(해시 코드)로 변환하는 함수. 같은 입력은 항상 같은 정수를 내야 하고, 가능하면 서로 다른 입력은 다른 정수를 내도록 설계한다. C#에서는 object.GetHashCode() 메서드가 이 역할을 한다.
Dictionary<string, int> 내부 구조

핵심은 두 단계입니다. 키 → 해시 코드 → 버킷 인덱스. 이 변환은 키가 무엇이든 일정한 시간에 끝나기 때문에 데이터가 1만 개든 100만 개든 조회 시간이 거의 같습니다.

2.2 가장 짧은 사용 코드

C#
using System.Collections.Generic;

var stage = new Dictionary<string, int>
{
    ["apple"] = 1,                // 컬렉션 초기화자 (인덱서 형식)
    ["banana"] = 2,
};
stage.Add("cherry", 3);            // 명시적 추가
int score = stage["apple"];        // 키로 즉시 조회 — O(1)
bool exists = stage.ContainsKey("durian");  // false
컬렉션 초기화자(Collection initializer) 컬렉션 생성과 동시에 초기 데이터를 채우는 문법. Dictionary["키"] = 값 인덱서 형식과 { "키", 값 } 쌍 형식 두 가지를 모두 지원한다. 내부적으로는 Add 호출로 변환된다.

stage["apple"]이 평균 O(1)인 것은 컴파일러가 마법을 부리는 게 아니라, 위 SVG에서 본 두 단계 변환을 IL 레벨에서 그대로 수행하기 때문입니다.


3. 내부 동작 — 해시 충돌과 체이닝

3.1 해시 코드가 같으면 어떻게 되는가

해시 함수가 아무리 좋아도 무한한 키를 유한한 인덱스에 매핑하므로 서로 다른 키가 같은 인덱스로 떨어지는 일은 반드시 생깁니다. 이를 해시 충돌(hash collision) 이라 부릅니다. Dictionary<TKey, TValue>는 충돌을 체이닝(chaining) 방식으로 처리합니다 — 같은 인덱스에 떨어진 항목들을 단방향 연결 리스트로 잇는 방법입니다.

해시 충돌 처리 — 체이닝

조회는 두 단계로 진행됩니다.

  1. GetHashCode()로 어느 버킷에 들어 있어야 하는지 계산 — 산술 한 번
  2. 그 버킷의 체인을 따라가며 Equals()로 같은 키인지 확인 — 충돌이 거의 없으면 한 번에 끝

따라서 Dictionary의 모든 동작은 키 타입의 GetHashCode()Equals()에 의존합니다. 이 두 메서드 사이에는 깨면 안 되는 계약이 있습니다.

GetHashCode/Equals 계약
  • a.Equals(b) == true 라면 a.GetHashCode() == b.GetHashCode()이어야 한다.
  • 같은 객체의 해시 코드는 그 객체가 살아 있는 동안 변하면 안 된다(키로 쓴 뒤 필드를 바꾸면 안 된다).
  • 같은 해시 코드라고 같은 객체일 필요는 없다(충돌 허용).

이 계약이 깨지면 Dictionary는 분명히 넣은 값을 못 찾습니다.

3.2 4가지 접근 패턴 IL 비교

같은 "키가 있으면 값을 가져오고 없으면 -1을 돌려준다"는 로직을 두 가지로 구현해 IL에서 어떤 차이가 나는지 봅니다.

C#
// Before: ContainsKey + 인덱서 — 두 번 조회
public static int LookupBefore(Dictionary<string, int> dict, string key)
{
    if (dict.ContainsKey(key))
    {
        return dict[key];
    }
    return -1;
}

// After: TryGetValue — 한 번 조회
public static int LookupAfter(Dictionary<string, int> dict, string key)
{
    if (dict.TryGetValue(key, out int value))
    {
        return value;
    }
    return -1;
}
out — 출력 매개변수 메서드가 결과를 두 개 이상 돌려줘야 할 때 쓰는 키워드. 호출 측에서 out int value 처럼 변수를 넘기면 메서드가 그 변수에 값을 채워준다. TryGetValue처럼 "키 존재 여부 + 값"을 한 번에 돌려줄 때 표준 패턴이다.
IL
// LookupBefore — ContainsKey + 인덱서 (두 번 조회)
.method public hidebysig static int32 LookupBefore (...) cil managed
{
    .maxstack 2
    .locals init ([0] bool, [1] int32)

    IL_0001: ldarg.0
    IL_0002: ldarg.1
    IL_0003: callvirt instance bool Dictionary`2::ContainsKey(!0)   // 조회 1: 해시 + 버킷 + 체인 순회
    IL_0008: stloc.0
    IL_000a: brfalse.s IL_0017                                       // false면 -1로 점프

    IL_000d: ldarg.0
    IL_000e: ldarg.1
    IL_000f: callvirt instance !1 Dictionary`2::get_Item(!0)         // 조회 2: 같은 작업을 또 수행
    IL_0014: stloc.1
    ...
}

// LookupAfter — TryGetValue (한 번 조회)
.method public hidebysig static int32 LookupAfter (...) cil managed
{
    .maxstack 3
    .locals init ([0] int32, [1] bool, [2] int32)

    IL_0001: ldarg.0
    IL_0002: ldarg.1
    IL_0003: ldloca.s 0                                              // 로컬 변수의 주소를 스택에 올림 (out 파라미터)
    IL_0005: callvirt instance bool Dictionary`2::TryGetValue(!0, !1&)  // 조회 1번으로 끝
    IL_000a: stloc.1
    ...
}

IL 분석 포인트

1. callvirt가 두 번 vs 한 번

LookupBeforeContainsKeyget_Item을 각각 callvirt로 호출합니다. 두 메서드 내부는 동일한 작업 — 키의 GetHashCode() 호출 → 모듈로 연산으로 버킷 인덱스 계산 → 체인 순회하며 Equals() 비교 — 를 처음부터 다시 합니다. 작은 사전이면 무시해도 되지만, 매 프레임 호출되는 Update 핫패스에서 수천 개 항목이 든 사전을 이렇게 두 번 조회하면 그 차이가 프레임 타임에 그대로 잡힙니다.

2. ldloca.sTryGetValue의 핵심

LookupAfterIL_0003: ldloca.s 0은 "로컬 변수 0번의 주소를 스택에 올린다"는 명령입니다. 이 주소가 out 파라미터로 들어가면, 사전 내부에서 키를 한 번 찾는 동안 값을 그 주소에 직접 써 넣습니다. 결과적으로 사전 탐색은 단 한 번이고, 추가 메서드 호출 없이 같은 메서드 안에서 "있는지 + 값이 무엇인지"를 모두 알아냅니다.

3. .maxstack의 차이

LookupBefore는 2, LookupAfter는 3입니다. TryGetValue가 인자를 하나 더 받기 때문이지만, 이 1의 차이는 무시할 수준입니다. 진짜 차이는 메서드 호출 횟수와 그에 따른 사전 내부 탐색 횟수에서 나옵니다.

따라서 "키 존재 여부 확인 + 값 사용"이 묶여 있는 모든 상황에서는 무조건 TryGetValue를 씁니다. ContainsKey는 "키가 있는지만 알고 값은 안 쓴다"는 드문 경우에만 사용합니다.


4. 실전 적용

4.1 인덱서 vs Add — 언제 무엇을 쓰나

네 가지 접근 방법은 의도가 다릅니다. 같은 일을 하지 않습니다.

패턴 키가 이미 있을 때 키가 없을 때 의도
dict.Add(key, val) ArgumentException 던짐 새로 추가 "이 키는 새로 들어가야 한다"
dict[key] = val (쓰기) 조용히 덮어씀 새로 추가 "있든 없든 이 값으로 만들겠다"
dict[key] (읽기) 값 반환 KeyNotFoundException 던짐 "반드시 있다고 확신할 때"
dict.TryGetValue(key, out v) true + 값 false + 기본값 "있을 수도 없을 수도 있다"

신입이 가장 많이 저지르는 실수는 두 가지입니다.

C#
// ❌ Bad: 매번 같은 키로 Add를 호출 — 두 번째에 ArgumentException
dict.Add("hp", 100);
dict.Add("hp", 90);  // 💥 ArgumentException: 키가 이미 있습니다

// ✅ Good: 덮어쓰기가 의도라면 인덱서
dict["hp"] = 100;
dict["hp"] = 90;  // 조용히 90으로 갱신

// ❌ Bad: 키가 있는지 모르면서 인덱서로 읽기 — KeyNotFoundException
int hp = dict["hp"];  // 💥 키가 없으면 예외

// ✅ Good: TryGetValue로 안전하게
if (dict.TryGetValue("hp", out int hp)) { /* 사용 */ }
KeyNotFoundException 인덱서 읽기(dict[key])가 존재하지 않는 키를 받았을 때 던지는 예외. 단순 조회 흐름에서 예외를 잡는 비용은 정상 호출의 수백 배에 달하므로, 키 존재가 보장되지 않는 곳에서 인덱서 읽기를 쓰면 절대 안 된다.

4.2 Unity 핫패스 — 매 프레임 enum 키로 조회

PlayerState(Idle/Walk/Run 같은 enum)를 키로 하는 사전은 게임에서 매우 흔합니다. 그런데 여기에 숨은 함정이 있습니다.

C#
using System;
using System.Collections.Generic;

public enum PlayerState { Idle, Walk, Run, Jump }

// ❌ Bad: 기본 비교자 — 일부 런타임/Unity IL2CPP 환경에서 박싱 발생 가능
var actions = new Dictionary<PlayerState, Action>();

// ✅ Good: 박싱 없는 커스텀 EnumComparer 주입
var actions2 = new Dictionary<PlayerState, Action>(new EnumComparer<PlayerState>());

public sealed class EnumComparer<TEnum> : IEqualityComparer<TEnum>
    where TEnum : struct, Enum
{
    public bool Equals(TEnum x, TEnum y) => Convert.ToInt32(x) == Convert.ToInt32(y);
    public int GetHashCode(TEnum obj) => Convert.ToInt32(obj);
}
박싱(Boxing) 값 타입(int, enum, struct)을 참조 타입(object 또는 인터페이스)으로 변환할 때 CLR이 힙에 새 객체를 할당하고 값을 그 안에 복사하는 작업. 박싱이 매 프레임 발생하면 GC(Garbage Collector, 사용 안 하는 객체를 자동 회수하는 런타임 구성요소) 부담이 누적되어 프레임 드롭의 원인이 된다.

Convert.ToInt32(x)가 정말로 박싱을 막는지 IL로 확인합니다.

IL
.method public final hidebysig newslot virtual
    instance bool Equals(!TEnum x, !TEnum y) cil managed
{
    IL_0000: ldarg.1
    IL_0001: box !TEnum                                          // ⚠ 박싱 발생!
    IL_0006: call int32 [System.Runtime]System.Convert::ToInt32(object)
    IL_000b: ldarg.2
    IL_000c: box !TEnum                                          // ⚠ 박싱 또 발생!
    IL_0011: call int32 [System.Runtime]System.Convert::ToInt32(object)
    IL_0016: ceq
    IL_0018: ret
}

IL 분석 포인트

1. box 명령이 두 번 — 박싱 흔적

Convert.ToInt32(object)는 인자가 object이므로 컴파일러가 box !TEnum을 강제로 끼워 넣습니다. 즉 위 EnumComparer는 박싱을 막기는커녕 매 비교마다 박싱을 두 번 일으킵니다. NotebookLM/Gemini에서 자주 추천되는 형태이지만 IL을 들여다보지 않으면 함정이 보이지 않습니다.

2. 진짜 박싱 없는 구현

Convert.ToInt32 대신 unsafe 캐스팅 또는 EqualityComparer<TEnum>.Default(.NET 5+에서 박싱 없이 최적화됨)를 사용해야 합니다.

C#
// ✅ 진짜 박싱 없는 EnumComparer (Unsafe 사용)
public sealed class FastEnumComparer<TEnum> : IEqualityComparer<TEnum>
    where TEnum : unmanaged, Enum
{
    public bool Equals(TEnum x, TEnum y) =>
        System.Runtime.CompilerServices.Unsafe.As<TEnum, int>(ref x) ==
        System.Runtime.CompilerServices.Unsafe.As<TEnum, int>(ref y);

    public int GetHashCode(TEnum obj) =>
        System.Runtime.CompilerServices.Unsafe.As<TEnum, int>(ref obj);
}

3. .NET 5 이상에서는 기본이 OK

.NET 5+ 런타임은 EqualityComparer<TEnum>.Default가 enum 타입을 인지하면 박싱 없이 비교하도록 JIT 단계에서 특수화합니다. 즉 "순정 .NET 5+" 에서는 new Dictionary<PlayerState, Action>()만 써도 박싱이 없습니다. 문제는 Unity의 IL2CPP — 빌드 환경에 따라 이 최적화가 적용되지 않을 수 있어 보수적으로는 Unsafe 기반 비교자를 직접 주입하는 것이 안전합니다.

4.3 초기 용량 지정 — 리사이징 비용 줄이기

사전이 가득 차면 더 큰 버킷 배열을 새로 할당하고, 모든 기존 항목의 해시를 다시 계산해서 재배치합니다. 이 과정의 비용은 O(n)이고, 1만 개 사전이라면 1만 번의 재해싱이 한 순간에 몰립니다. Unity Update 루프에서 이 일이 일어나면 그대로 프레임 스파이크입니다.

C#
// Before: 용량 미지정 — 데이터 추가 도중 여러 번 리사이징
public static Dictionary<int, string> WithoutCapacity()
{
    var dict = new Dictionary<int, string>();
    for (int i = 0; i < 1000; i++)
        dict.Add(i, "value");
    return dict;
}

// After: 용량 지정 — 한 번에 충분한 버킷 할당
public static Dictionary<int, string> WithCapacity()
{
    var dict = new Dictionary<int, string>(capacity: 1000);
    for (int i = 0; i < 1000; i++)
        dict.Add(i, "value");
    return dict;
}
IL
// WithoutCapacity
IL_0001: newobj instance void Dictionary`2::.ctor()                  // 인자 없는 생성자 — 기본 크기로 시작
IL_0006: stloc.0
... (루프에서 Add 1000번 호출, 그 사이에 내부적으로 리사이징 여러 번)

// WithCapacity
IL_0001: ldc.i4 1000                                                  // 1000을 스택에 올림
IL_0006: newobj instance void Dictionary`2::.ctor(int32)              // 용량 인자를 받는 생성자 호출
IL_000b: stloc.0
... (이후 1000번 Add — 리사이징 없음)

IL 분석 포인트

1. newobj의 생성자 시그니처

용량을 지정하면 Dictionary::.ctor(int32) 오버로드가 호출되어, 처음부터 1000개를 담을 수 있는 내부 배열을 한 번에 할당합니다. 미지정이면 기본 크기(보통 0 또는 작은 값)로 시작해 4 → 7 → 17 → 37 → 79 → ...처럼 소수(prime)로 늘어나며 매번 재해싱이 발생합니다.

2. Unity 실전 가이드

씬 시작 시점에 적 1000마리 데이터를 사전에 채워야 한다면 new Dictionary<int, EnemyData>(1000) 처럼 미리 용량을 지정합니다. 데이터 개수를 정확히 모르더라도 "최소 이 정도는 들어온다"는 추정치를 넣는 것이 무지정보다 항상 낫습니다. 불필요하게 큰 값(예: 1만인데 100만)을 넣으면 오히려 메모리만 낭비하므로 적정값을 찾습니다.

3. 정확히 같은 용량을 원할 때 — EnsureCapacity

.NET 5+에서는 dict.EnsureCapacity(1000)을 호출해 도중에라도 한 번에 늘릴 수 있습니다. 미리 용량을 모르고 일정 단계에서야 알게 되는 경우 유용합니다.


5. 함정과 주의사항

5.1 커스텀 클래스를 키로 쓸 때 — GetHashCode/Equals 오버라이드 누락

신입이 가장 자주 만나는 "넣은 값을 못 찾는" 버그입니다. 커스텀 클래스를 키로 쓰면서 GetHashCode/Equals를 오버라이드하지 않으면 object 기본 구현이 호출되어 참조 동등성으로 동작합니다.

C#
// ❌ Bad: GetHashCode/Equals 미오버라이드
public class BadKey
{
    public int Id;
    public BadKey(int id) { Id = id; }
}

var dict = new Dictionary<BadKey, int>();
dict[new BadKey(1)] = 100;
int v = dict[new BadKey(1)];  // 💥 KeyNotFoundException — 다른 인스턴스이므로 못 찾음
C#
// ✅ Good: IEquatable<T> 구현 + GetHashCode 오버라이드
public class GoodKey : IEquatable<GoodKey>
{
    public int Id;
    public GoodKey(int id) { Id = id; }

    public bool Equals(GoodKey other)
    {
        if (other is null) return false;
        return Id == other.Id;
    }

    public override bool Equals(object obj) => obj is GoodKey other && Equals(other);
    public override int GetHashCode() => Id.GetHashCode();
}
IEquatable<T> 같은 타입끼리 비교할 때 박싱 없이 호출되는 강타입 비교 인터페이스. Dictionary는 키 타입이 IEquatable<TKey>를 구현했는지 확인해 그쪽을 우선 호출하므로, 클래스 키에 이 인터페이스를 함께 구현하면 추가 비용을 줄일 수 있다.
IL
// GoodKey::GetHashCode — Id 필드 하나만 사용
IL_0000: ldarg.0
IL_0001: ldflda int32 GoodKey::Id                              // Id 필드의 주소를 가져옴
IL_0006: call instance int32 [System.Runtime]System.Int32::GetHashCode()  // int.GetHashCode() = Id 자체
IL_000b: ret

// GoodKey::Equals(GoodKey)
IL_000d: ldarg.0
IL_000e: ldfld int32 GoodKey::Id                               // this.Id 로드
IL_0013: ldarg.1
IL_0014: ldfld int32 GoodKey::Id                               // other.Id 로드
IL_0019: ceq                                                    // 같으면 1, 다르면 0

IL 분석 포인트

1. ldflda vs ldfld

GetHashCodeldflda(필드의 주소)를 사용해 int.GetHashCode() 인스턴스 메서드를 직접 호출합니다. 박싱이 발생하지 않습니다. 만약 ldfld(필드의 )였다면 Int32 박싱이 필요했을 것입니다 — 컴파일러가 Equals/GetHashCode 오버라이드 안에서 자주 하는 미세 최적화입니다.

2. 가변(mutable) 키는 절대 금지

Id 필드가 public 인 점을 유심히 봅니다. 누군가 key.Id = 999로 바꾸면 같은 객체가 사전에 넣을 때와 찾을 때 다른 해시 코드를 내고, 결국 영영 못 찾는 유령 항목이 됩니다. 키로 쓰는 클래스의 필드는 readonly 또는 init으로 만들어 변경 자체를 막는 것이 안전합니다.

3. record 타입은 자동 처리

C# 9의 record 키워드는 컴파일러가 Equals/GetHashCode/IEquatable<T>를 모두 자동 생성합니다. 키 타입이라면 class 대신 record를 우선 고려합니다.

C#
public record PlayerKey(int Id, string Region);  // Equals/GetHashCode/IEquatable 자동 구현

5.2 foreach 도중 수정하면 InvalidOperationException

Dictionary는 내부 변경 카운터(_version)를 들고 있어 열거 도중 추가·삭제·갱신이 일어나면 즉시 예외를 던집니다.

C#
// ❌ Bad: foreach 도중 직접 수정
foreach (var kv in scores)
{
    if (kv.Value < 0) scores.Remove(kv.Key);  // 💥 InvalidOperationException
}

// ✅ Good: 키 목록을 먼저 복사
foreach (var key in scores.Keys.ToList())
{
    if (scores[key] < 0) scores.Remove(key);
}

이 규칙은 List<T>와 같지만, Dictionary에서는 "값만 갱신"도 동일하게 막힌다는 점이 추가로 함정입니다(.NET 5+는 값 갱신은 허용하지만 보수적으로 작성하는 편이 안전).

5.3 멀티스레드에서 Dictionary는 안전하지 않다

Dictionary<TKey, TValue>단일 스레드에서만 안전합니다. 한 스레드가 쓰는 동안 다른 스레드가 읽기만 해도 내부 구조가 깨질 수 있습니다. Unity 클라이언트에서는 메인 스레드만 사용하면 문제없지만, 백그라운드 워커 스레드(예: Task.Run)에서 쓴다면 ConcurrentDictionary<TKey, TValue>로 바꿉니다.


6. C# 버전별 변화

6.1 C# 7.0 — out var 인라인 선언

TryGetValueout 파라미터를 같은 줄에서 선언할 수 있게 되어 가독성이 크게 개선되었습니다.

C#
// C# 6 이하
int score;
if (dict.TryGetValue("hp", out score)) { /* score 사용 */ }

// C# 7.0+
if (dict.TryGetValue("hp", out int score)) { /* score 사용 */ }

6.2 C# 7.0 — Deconstruct for KeyValuePair

foreach에서 키와 값을 분해해 받을 수 있습니다.

C#
// C# 7.0+
foreach (var (key, value) in dict)
{
    Debug.Log($"{key}: {value}");
}

6.3 .NET Core 2.0+ — TryAdd

"이미 있으면 무시, 없으면 추가" 패턴이 한 줄로 끝납니다.

C#
// Before
if (!dict.ContainsKey(key)) dict.Add(key, value);  // 두 번 조회

// After
dict.TryAdd(key, value);  // 한 번 조회

6.4 .NET Core 2.0+ — Remove(key, out value)

삭제하면서 값을 가져오는 오버로드. 캐시에서 꺼내 쓰기 좋습니다.

C#
if (cache.Remove(playerId, out var data))
{
    // data를 마지막으로 한 번 사용
}

6.5 .NET 5+ — EnsureCapacity / 박싱 없는 enum 비교

런타임 단위 변화로 두 가지가 중요합니다.

  • dict.EnsureCapacity(n)로 도중에 용량 확장 가능
  • EqualityComparer<TEnum>.Default가 박싱 없이 동작 — 단 Unity IL2CPP에서는 보수적으로 직접 주입 권장

7. 정리 — 이것만 기억하라

  • Dictionary<TKey, TValue> = 키 → 해시 → 버킷 인덱스로 즉시 점프하는 자료구조. 평균 O(1), 최악 O(n).
  • TryGetValue를 기본 도구로. ContainsKey + 인덱서 조합은 사전 탐색을 두 번 한다.
  • 인덱서 읽기는 KeyNotFoundException을 던진다. 키 존재가 보장되지 않으면 절대 쓰지 말 것.
  • 인덱서 쓰기는 덮어쓰기, Add는 중복 시 예외. 의도에 맞게 골라 쓴다.
  • 커스텀 클래스를 키로 쓸 땐 Equals+GetHashCode+IEquatable<T> 세 개를 함께 구현. 또는 record 사용.
  • 키는 불변(immutable)으로. 키로 쓴 객체의 필드를 나중에 바꾸면 절대 못 찾는다.
  • enum 키 + Unity IL2CPP에서는 박싱 위험. Convert.ToInt32는 박싱을 못 막으니 Unsafe.As나 .NET 5+ EqualityComparer<TEnum>.Default를 검증 후 사용.
  • 데이터 개수를 알면 new Dictionary<,>(capacity) 로 리사이징 비용 제거. Unity 씬 로딩 시점에 특히 유효.
  • foreach 도중 수정 금지Keys.ToList()로 한 번 복사한 뒤 순회.
  • 멀티스레드라면 ConcurrentDictionary. Dictionary는 단일 스레드 전용.
반응형

+ Recent posts