본문 바로가기
자료 구조

Unity 개발자를 위한 C# Dictionary(딕셔너리) 구현

by DaebakGameDevLab 2025. 12. 8.

Dictionary(딕셔너리)는 키(Key)를 이용해 값(Value)을 빠르게 찾아오는 해시 기반 자료구조입니다. List처럼 인덱스로 접근하는 것이 아니라, 문자열·숫자·열거형 등 의미 있는 키를 사용해 값을 저장하고 조회할 수 있다는 점이 가장 큰 특징입니다.

 

값의 순서보다는 “이 키에 해당하는 값이 무엇인지”, “이 키가 존재하는지”를 빠르게 판별하는 데 특화되어 있으며, 같은 키를 다시 넣으면 기존 값을 덮어쓰는 방식으로 동작합니다. 내부적으로는 HashSet과 마찬가지로 해시(hash) 개념을 사용해, 키를 고정된 크기의 정수(해시코드)로 변환한 뒤 이를 이용해 어느 버킷에 저장할지를 결정합니다.

 

이번 글에서는 Dictionary의 개념과 특징을 정리하고, C# 배열과 연결 리스트를 이용해 직접 구현한 뒤, Unity에서 간단한 테스트 코드를 통해 동작을 확인해 보겠습니다.

 

※ 해시(hash)는 원래 “잘게 자르다, 뒤섞다”라는 뜻을 가진 단어로, 여러 데이터를 섞어서 전혀 다른 형태로 만드는 것을 의미합니다. 컴퓨터 과학에서는 이 개념이 확장되어, 크기가 제각각인 데이터(문자열, 파일 등)를 고정된 크기의 정수 값으로 변환하는 과정을 해시라고 부릅니다. 이때 사용되는 함수를 해시 함수(Hash Function)라 하고, 그 결과를 해시코드(Hash Code)라고 합니다. Dictionary에서는 이 해시코드를 이용해 “이 키-값 쌍이 어느 버킷에 들어갈지”를 빠르게 결정하여 탐색 속도를 높입니다.


1. Dictionary의 구조와 동작 방식

Dictionary는 중복되지 않는 키을 매핑하는 자료구조입니다. 핵심은 “키로 값을 빠르게 찾는다”는 점입니다. 특정 키에 해당하는 값을 알고 싶을 때, 매번 처음부터 끝까지 순회(List 탐색)를 하는 대신, 해시 함수를 사용해 바로 해당 키가 들어 있을 법한 위치를 찾아갑니다.

 

내부 구조는 크게 세 부분으로 나눌 수 있습니다.

  • 키에서 정수 해시코드를 만들어 주는 해시 함수 (key.GetHashCode())
  • 해시코드로 접근하는 버킷(bucket) 배열 (Entry[] _buckets)
  • 같은 버킷에 몰린 키-값 쌍들을 이어 붙이는 연결 리스트(Entry 체이닝)

먼저 키에서 해시코드를 계산한 뒤, 버킷 개수로 나눈 나머지를 사용해 인덱스를 구합니다. 이 인덱스가 “이 키-값 쌍이 들어 있을 가능성이 있는 자리”가 됩니다. 만약 그 자리에 여러 키-값 쌍이 모이면, 단일 연결 리스트로 이어서 관리합니다.

 

개념을 단순화하면 Dictionary의 주요 연산은 다음과 같이 볼 수 있습니다.

  • Add(추가): 키 → 해시코드 → 인덱스 계산 → 해당 버킷 리스트에 이미 같은 키가 있으면 값만 갱신, 없다면 새 Entry를 리스트 맨 앞에 삽입
  • TryGetValue / 인덱서 검색: 키 → 해시코드 → 인덱스 계산 → 해당 버킷 리스트만 순회하며 같은 키를 찾고, 찾으면 값을 반환
  • Remove(삭제): 키 → 해시코드 → 인덱스 계산 → 해당 버킷 리스트에서 키가 같은 노드를 찾아 연결을 끊어 제거

이 구조 덕분에 한 번 Add / TryGetValue / Remove를 호출할 때 걸리는 시간은 전체 원소 수에 거의 비례하지 않고, 평균적으로 일정한 시간(O(1))에 가깝게 동작합니다. 다만 데이터가 많아질수록 특정 버킷에 키가 몰리거나, 버킷 배열을 확장(Resize) 해야 하는 시점이 오면 순간적으로 더 많은 비용이 들 수는 있습니다. 그럼에도 연산 하나가 List처럼 원소 수(n)에 비례하여 선형으로 증가하는 구조는 아닙니다.

 


2. Dictionary가 활용되는 대표적인 사례

Dictionary는 구조가 단순해 보이지만, “키-값 매핑을 자주 사용하고, 키 기반으로 빠르게 찾아야 하는 기능”의 핵심으로 매우 자주 등장합니다. 게임 개발 관점에서 대표적인 사례를 몇 가지 정리해 보겠습니다.

  • 아이템/스킬 테이블 관리
    아이템 ID, 스킬 ID를 키로 사용하여, 해당 설정 데이터(ScriptableObject, DTO 등)를 Dictionary에 저장해 두면 전투 중에도 O(1)에 가깝게 빠르게 조회할 수 있습니다.
  • 리소스/프리팹 캐시
    프리팹 이름이나 Addressables 키를 문자열 키로 사용해, 로드된 프리팹/리소스를 Dictionary에 캐싱해 두면 dictionary[key] 한 번으로 바로 참조를 가져올 수 있습니다.
  • 유저/세션 관리
    유저 ID나 세션 토큰을 키로 하여, 해당 유저의 상태 객체(위치, 스탯, 접속 정보 등)를 Dictionary로 관리하면 실시간으로 빠른 조회가 가능합니다. 서버·클라이언트 모두에서 자주 쓰이는 패턴입니다.
  • 버프/상태 효과 매핑
    버프 ID → 버프 정보, 상태 키 → 상태 객체 등을 Dictionary로 관리하면, 특정 버프가 활성화되어 있는지, 해당 버프의 수치가 얼마인지를 빠르게 조회할 수 있습니다.
  • 이벤트 핸들러/콜백 매핑
    이벤트 이름 또는 이벤트 타입을 키로 해서, 해당 이벤트를 처리할 델리게이트나 핸들러 객체를 Dictionary에 저장해 두면, 런타임에 if-else/switch를 길게 나열하지 않고도 적절한 핸들러를 바로 찾을 수 있습니다.

이런 패턴은 “배열이나 리스트로도 구현은 가능하지만, 데이터가 많아질수록 선형 탐색 때문에 성능이 떨어지는” 코드들을 Dictionary 하나로 깔끔하게 정리해 주기 때문에, 실제 프로젝트에서 가장 많이 등장하는 자료구조 중 하나입니다.


3. Dictionary가 제공하는 기능(구현 기준)

이번에 구현한 Dictionary<TKey, TValue>는 다음과 같은 기능을 제공합니다. 실제로 Dictionary를 직접 구현할 때 “최소 이 정도 기능은 있어야 한다”는 기준으로 보면 됩니다.

  • Count
    현재 Dictionary에 저장되어 있는 키-값 쌍의 개수를 반환합니다.
  • Capacity
    내부 버킷 배열(_buckets)의 길이, 즉 현재 Dictionary가 가지고 있는 내부 용량을 반환합니다. 로드 팩터를 넘으면 Resize를 통해 Capacity가 증가합니다.
  • Add(TKey key, TValue value)
    새로운 키-값 쌍을 Dictionary에 추가합니다. 이미 동일한 키가 존재하면, 예외를 던지지 않고 해당 키의 값을 새 값으로 갱신합니다. (학습용 구현이며, .NET 표준 Dictionary<TKey, TValue>Add는 중복 키에 대해 예외를 발생시킵니다.)
  • TryGetValue(TKey key, out TValue value)
    해당 키가 존재하는 경우 값을 out 파라미터로 반환하고 true를 반환합니다. 키가 없으면 false를 반환하며, out 파라미터에는 default 값이 들어갑니다. Dictionary를 사용할 때 가장 많이 쓰는 패턴 중 하나입니다.
  • this[TKey key] 인덱서
    get: 키에 해당하는 값을 반환합니다. 키가 없으면 KeyNotFoundException을 던집니다.
    set: 키에 값을 설정합니다. 키가 없으면 추가하고, 있으면 값을 갱신합니다. 내부적으로 Add를 호출하는 구조입니다.
  • ContainsKey(TKey key)
    해당 키가 Dictionary에 포함되어 있는지 확인합니다. 내부적으로 TryGetValue를 호출하여 값을 버리고 존재 여부만 확인합니다.
  • Remove(TKey key)
    해당 키에 대한 항목을 제거합니다. 실제로 삭제가 일어나면 true, 삭제할 항목이 없으면 false를 반환합니다.
  • Clear()
    모든 요소를 제거합니다. Array.Clear로 버킷 배열의 내용을 비우고 _count를 0으로 초기화합니다. 버킷 배열 자체는 재사용합니다.

실제 .NET의 Dictionary<TKey, TValue>는 이 외에도 IEqualityComparer 지원, Keys/Values 컬렉션, Enumerator, 직렬화 등 더 많은 기능을 제공합니다. 필요하다면 이번 구현을 기반으로 이런 기능을 하나씩 확장해 나가면 됩니다.


4. Dictionary의 내부 동작 방식

이제 Dictionary가 내부적으로 어떻게 동작하는지 단계별로 살펴보겠습니다. HashSet과 기본 구조는 매우 비슷하지만, 키와 값 두 가지 정보를 함께 저장한다는 점이 다릅니다.

 

4.1 Entry 구조

하나의 키-값 쌍과 다음 노드를 가리키는 연결 리스트 노드 구조입니다.

private class Entry
{
    public TKey Key;
    public TValue Value;
    public Entry Next;

    public Entry(TKey key, TValue value, Entry next = null)
    {
        Key = key;
        Value = value;
        Next = next;
    }
}

각 Entry는 다음과 같은 정보를 가집니다.

  • Key : 키 값
  • Value : 키에 매핑되는 실제 데이터
  • Next : 같은 버킷 내에서 다음 Entry를 가리키는 포인터

4.2 버킷 배열

Dictionary는 내부적으로 Entry 타입의 배열 하나를 가지고 있습니다.

private Entry[] _buckets;
private int _count;

 

버킷 배열은 아래처럼 “세로로 된 칸들”이라고 생각하면 이해가 쉽습니다. 각 칸은 연결 리스트의 시작 노드를 가리킵니다.

  • 여기서 사용한 ∅ 기호는 ‘비어 있음(null)’을 나타내는 공집합 기호입니다.
[0]  ∅
[1]  ∅
[2]  ∅
[3]  ∅
...
[n]  ∅

어떤 키가 “몇 번 버킷에 들어갈지”는 해시코드와 배열 길이에 의해 결정됩니다.

 

4.3 버킷 인덱스 계산

버킷 인덱스를 계산하는 코드는 다음과 같습니다.

private int GetIndex(TKey key)
{
    int hash = key.GetHashCode() & 0x7FFFFFFF;
    return hash % _buckets.Length;
}

 

이 코드는 다음과 같은 순서로 동작합니다.

  • 해시코드 계산: key.GetHashCode()로 키의 해시코드를 얻습니다. 동일한 키에 대해서는 항상 동일한 해시코드를 반환합니다.
  • 음수 제거: & 0x7FFFFFFF 연산으로 최상위 비트를 0으로 만들어 항상 양수 범위의 해시코드를 만듭니다.
  • 버킷 인덱스 계산: 버킷 길이(_buckets.Length)로 나눈 나머지를 사용해 0 ~ (_buckets.Length - 1) 범위의 인덱스를 얻습니다.

4.4 Add 흐름

Add 메서드는 다음과 같은 순서로 동작합니다. 이해를 돕기 위해 간단한 예시를 들어보겠습니다.

 

1) 초기 상태 – 모든 버킷이 비어 있음

[0]  ∅
[1]  ∅
[2]  ∅
[3]  ∅
[4]  ∅
[5]  ∅

 

2) 키 "Slime" / 값 10 추가 (예: 버킷 인덱스 2)

  • "Slime"의 GetHashCode를 가지고 버킷 인덱스를 구했을 때 2라고 가정하면, 2번 버킷에 새로운 Entry를 하나 만들어 저장합니다.
[0]  ∅
[1]  ∅
[2]  ("Slime", 10) → ∅
[3]  ∅
[4]  ∅
[5]  ∅

 

3) 키 "Orc" / 값 20 추가 (예: 버킷 인덱스 1)

[0]  ∅
[1]  ("Orc", 20) → ∅
[2]  ("Slime", 10) → ∅
[3]  ∅
[4]  ∅
[5]  ∅

 

4) 키 "Dragon" / 값 100 추가 (예: 버킷 인덱스 2, 충돌 발생)

  • "Dragon"의 버킷 인덱스도 2라고 가정하면, 이미 [2] 버킷에는 ("Slime", 10)이 있습니다. 이때 새 값을 연결 리스트의 앞쪽에 삽입합니다.
[0]  ∅
[1]  ("Orc", 20) → ∅
[2]  ("Dragon", 100) → ("Slime", 10) → ∅
[3]  ∅
[4]  ∅
[5]  ∅

 

Add 메서드의 실제 코드는 다음과 같습니다.

public void Add(TKey key, TValue value)
{
    if (key == null)
    {
        throw new ArgumentNullException(nameof(key));
    }

    int index = GetIndex(key);
    Entry entry = _buckets[index];

    // 같은 버킷의 연결 리스트를 순회하면서
    // 이미 존재하는 키가 있는지 확인
    while (entry != null)
    {
        if (entry.Key.Equals(key))
        {
            // 이미 존재하는 키면 값만 갱신
            entry.Value = value;
            return;
        }
        entry = entry.Next;
    }

    // 연결 리스트의 맨 앞에 새 노드 삽입
    Entry newEntry = new Entry(key, value, _buckets[index]);
    _buckets[index] = newEntry;
    ++_count;

    // 로드 팩터를 초과하면 버킷 배열 확장
    if (_count > _buckets.Length * LOAD_FACTOR)
    {
        Resize();
    }
}

 

버킷 인덱스를 계산한 후, 해당 버킷의 리스트를 한 번 훑어보면서 이미 같은 키가 있는지 검사하고, 있다면 그 Entry의 Value만 갱신합니다. 없으면 새 Entry를 리스트 맨 앞에 붙이는 구조입니다. 평균적으로 O(1)에 가깝지만, 특정 버킷에 키가 몰리면 그 버킷의 길이만큼 O(k) 비용이 추가됩니다.

 

4.5 TryGetValue / Remove 흐름

TryGetValue("Orc")를 호출한다고 가정해 보겠습니다. 위의 상태에서 버킷[1]에는 다음과 같은 연결 리스트가 있습니다.

[1]  ("Orc", 20) → ∅

 

TryGetValue는 인덱스를 계산한 뒤, 해당 버킷의 연결 리스트를 순회하면서 키가 같은지 비교합니다. 첫 노드가 바로 "Orc"이므로, 첫 비교에서 true를 반환하고 out 파라미터에 20을 넣어 줍니다.

public bool TryGetValue(TKey key, out TValue value)
{
    if (key == null)
    {
        throw new ArgumentNullException(nameof(key));
    }

    int index = GetIndex(key);
    Entry entry = _buckets[index];

    // 같은 버킷의 연결 리스트를 순회하며 키 검색
    while (entry != null)
    {
        if (entry.Key.Equals(key))
        {
            value = entry.Value;
            return true;
        }
        entry = entry.Next;
    }

    value = default;
    return false;
}

 

2) Remove("Slime")을 호출하는 경우를 보겠습니다.

 

Remove 전, 위의 상태에서 버킷 [2]에는 다음과 같은 연결이 있습니다.

[2]  ("Dragon", 100) → ("Slime", 10) → ∅

 

Remove 후

[2]  ("Dragon", 100) → ∅

 

Remove는 단일 연결 리스트에서 노드를 제거해야 하므로, 현재 노드를 가리키는 포인터(cur)와 이전 노드를 가리키는 포인터(prev)를 함께 사용합니다.

public bool Remove(TKey key)
{
    if (key == null)
    {
        throw new ArgumentNullException(nameof(key));
    }

    int index = GetIndex(key);
    Entry entry = _buckets[index];
    Entry prev = null;

    // 같은 버킷의 연결 리스트를 순회하면서 삭제 대상 탐색
    while (entry != null)
    {
        if (entry.Key.Equals(key))
        {
            // 연결 리스트에서 현재 노드 제거
            if (prev == null)
            {
                // 첫 노드를 제거하는 경우
                _buckets[index] = entry.Next;
            }
            else
            {
                prev.Next = entry.Next;
            }

            --_count;
            return true;
        }

        prev = entry;
        entry = entry.Next;
    }

    // 해당 키가 없으면 false
    return false;
}

평균적으로 TryGetValue/Remove도 O(1)에 가깝지만, 특정 버킷에 키가 몰리면 그 버킷의 리스트 길이에 비례하는 비용이 듭니다.

 

4.6 Resize와 GC/성능 관점

버킷 확장은 요소 개수가 늘어나 현재 버킷 배열로는 충분히 분산되지 않을 때 발생합니다. 이번 구현에서는 로드 팩터(LOAD_FACTOR)를 0.75로 잡고, _count > _buckets.Length * LOAD_FACTOR 조건을 만족하면 Resize를 수행합니다.

 

Resize 전후의 예시는 다음과 같습니다.

 

Resize 전 (버킷 4개)

[0]  ∅
[1]  ("Orc", 20) → ∅
[2]  ("Dragon", 100) → ("Slime", 10) → ∅
[3]  ∅

 

Resize 후 (버킷 8개, 다시 해싱해서 재배치 – 예시)

[0]  ∅
[1]  ("Orc", 20) → ∅
[2]  ("Slime", 10) → ∅
[3]  ∅
[4]  ("Dragon", 100) → ∅
[5]  ∅
[6]  ∅
[7]  ∅

 

실제 Resize 코드는 다음과 같습니다.

private void Resize()
{
    int newSize = _buckets.Length * 2;
    Entry[] newBuckets = new Entry[newSize];

    // 기존 버킷 배열을 순회하며 모든 엔트리를 새 배열로 재배치
    foreach (Entry head in _buckets)
    {
        Entry entry = head;
        while (entry != null)
        {
            // 새 배열 기준으로 인덱스 다시 계산
            int newIndex = (entry.Key.GetHashCode() & 0x7FFFFFFF) % newSize;

            // 현재 연결을 끊고 새 버킷의 맨 앞에 삽입
            Entry next = entry.Next;
            entry.Next = newBuckets[newIndex];
            newBuckets[newIndex] = entry;

            entry = next;
        }
    }

    _buckets = newBuckets;
}

 

중요한 점은 다음과 같습니다.

  • Resize 과정에서 버킷 배열만 새로 할당하고, Entry 인스턴스는 재사용합니다. 따라서 Node(Entry) 객체를 새로 만드는 데 따른 GC 부담은 없습니다.
  • 하지만 각 Entry에 대해 새 인덱스를 계산하고 연결 리스트에 다시 삽입하므로, Resize 자체는 O(n) 비용이 듭니다.
  • 게임처럼 프레임 타임이 민감한 환경에서는, 예상 최대 개수를 알고 있다면 생성 시 capacity를 넉넉히 잡아서 실시간 구간에서 Resize가 발생하지 않도록 설계하는 것이 좋습니다.

5. C# 기반 Dictionary 직접 구현 코드

아래는 이번 글에서 설명한 구조를 그대로 반영한 학습용 Dictionary<TKey, TValue> 전체 코드입니다. Separate Chaining 방식으로 충돌을 해결하고, Add, TryGetValue, 인덱서, ContainsKey, Remove, Clear, Resize 기능을 제공합니다.

using System;
using BCL = System.Collections.Generic; // BCL - Base Class Library

namespace Daebak.Common.Collections
{
    /// <summary>
    /// 해시 기반의 키-값 저장 구조.
    /// Separate Chaining 방식으로 충돌을 해결하는 간단한 학습용 Dictionary 구현체
    /// </summary>
    public class Dictionary<TKey, TValue>
    {
        /// <summary>
        /// 하나의 키-값 쌍과 다음 노드를 가리키는 연결 리스트 노드
        /// </summary>
        private class Entry
        {
            public TKey Key;
            public TValue Value;
            public Entry Next;   // 같은 버킷 내에서 다음 엔트리를 가리킴 (체이닝)

            public Entry(TKey key, TValue value, Entry next = null)
            {
                Key = key;
                Value = value;
                Next = next;
            }
        }

        // 해시 버킷 배열 (각 칸은 연결 리스트의 헤드 노드를 가리킴)
        private Entry[] _buckets;

        // 실제 저장된 키-값 쌍의 개수
        private int _count;

        /// <summary>현재 저장된 키-값 쌍의 개수</summary>
        public int Count => _count;

        /// <summary>현재 버킷 배열 크기 (내부 용량)</summary>
        public int Capacity => _buckets.Length;

        // 버킷 확장 기준이 되는 로드 팩터
        private const float LOAD_FACTOR = 0.75f;

        public Dictionary(int capacity = 16)
        {
            if (capacity <= 0)
            {
                throw new ArgumentException("Capacity must be greater than 0.");
            }

            // 초기 버킷 배열 할당
            _buckets = new Entry[capacity];
            _count = 0;
        }

        /// <summary>
        /// 키의 해시코드를 이용해 버킷 인덱스 계산
        /// </summary>
        private int GetIndex(TKey key)
        {
            if (key == null)
            {
                throw new ArgumentNullException(nameof(key));
            }

            // & 0x7FFFFFFF 로 음수를 양수로 변환
            int hash = key.GetHashCode() & 0x7FFFFFFF;
            return hash % _buckets.Length;
        }

        /// <summary>
        /// 키-값 추가 (이미 존재하는 키라면 값만 갱신)
        /// </summary>
        public void Add(TKey key, TValue value)
        {
            if (key == null)
            {
                throw new ArgumentNullException(nameof(key));
            }

            int index = GetIndex(key);
            Entry entry = _buckets[index];

            // 같은 버킷의 연결 리스트를 순회하면서
            // 이미 존재하는 키가 있는지 확인
            while (entry != null)
            {
                if (entry.Key.Equals(key))
                {
                    // 이미 존재하는 키면 값만 갱신
                    entry.Value = value;
                    return;
                }
                entry = entry.Next;
            }

            // 연결 리스트의 맨 앞에 새 노드 삽입
            Entry newEntry = new Entry(key, value, _buckets[index]);
            _buckets[index] = newEntry;
            ++_count;

            // 로드 팩터를 초과하면 버킷 배열 확장
            if (_count > _buckets.Length * LOAD_FACTOR)
            {
                Resize();
            }
        }

        /// <summary>
        /// 키를 이용해 값 가져오기 (성공 시 true 반환)
        /// </summary>
        public bool TryGetValue(TKey key, out TValue value)
        {
            if (key == null)
            {
                throw new ArgumentNullException(nameof(key));
            }

            int index = GetIndex(key);
            Entry entry = _buckets[index];

            // 같은 버킷의 연결 리스트를 순회하며 키 검색
            while (entry != null)
            {
                if (entry.Key.Equals(key))
                {
                    value = entry.Value;
                    return true;
                }
                entry = entry.Next;
            }

            value = default;
            return false;
        }

        /// <summary>
        /// 인덱서 접근 지원
        /// - 읽기: 키가 없으면 예외 발생
        /// - 쓰기: Add와 동일하게 동작 (없으면 추가, 있으면 갱신)
        /// </summary>
        public TValue this[TKey key]
        {
            get
            {
                if (key == null)
                {
                    throw new ArgumentNullException(nameof(key));
                }

                if (TryGetValue(key, out TValue value))
                {
                    return value;
                }

                throw new BCL.KeyNotFoundException($"Key '{key}' not found.");
            }
            set
            {
                if (key == null)
                {
                    throw new ArgumentNullException(nameof(key));
                }

                Add(key, value);
            }
        }

        /// <summary>
        /// 키 존재 여부 확인
        /// </summary>
        public bool ContainsKey(TKey key)
        {
            if (key == null)
            {
                throw new ArgumentNullException(nameof(key));
            }

            // 값을 버리면서 존재 여부만 확인
            return TryGetValue(key, out _);
        }

        /// <summary>
        /// 키에 해당하는 항목 삭제
        /// </summary>
        public bool Remove(TKey key)
        {
            if (key == null)
            {
                throw new ArgumentNullException(nameof(key));
            }

            int index = GetIndex(key);
            Entry entry = _buckets[index];
            Entry prev = null;

            // 같은 버킷의 연결 리스트를 순회하면서 삭제 대상 탐색
            while (entry != null)
            {
                if (entry.Key.Equals(key))
                {
                    // 연결 리스트에서 현재 노드 제거
                    if (prev == null)
                    {
                        // 첫 노드를 제거하는 경우
                        _buckets[index] = entry.Next;
                    }
                    else
                    {
                        prev.Next = entry.Next;
                    }

                    --_count;
                    return true;
                }

                prev = entry;
                entry = entry.Next;
            }

            // 해당 키가 없으면 false
            return false;
        }

        /// <summary>
        /// 모든 항목 제거
        /// </summary>
        public void Clear()
        {
            // 버킷 배열만 비우면 나머지는 GC가 정리
            Array.Clear(_buckets, 0, _buckets.Length);
            _count = 0;
        }

        /// <summary>
        /// 버킷 크기 확장 및 모든 엔트리 재해시
        /// </summary>
        private void Resize()
        {
            int newSize = _buckets.Length * 2;
            Entry[] newBuckets = new Entry[newSize];

            // 기존 버킷 배열을 순회하며 모든 엔트리를 새 배열로 재배치
            foreach (Entry head in _buckets)
            {
                Entry entry = head;
                while (entry != null)
                {
                    // 새 배열 기준으로 인덱스 다시 계산
                    int newIndex = (entry.Key.GetHashCode() & 0x7FFFFFFF) % newSize;

                    // 현재 연결을 끊고 새 버킷의 맨 앞에 삽입
                    Entry next = entry.Next;
                    entry.Next = newBuckets[newIndex];
                    newBuckets[newIndex] = entry;

                    entry = next;
                }
            }

            _buckets = newBuckets;
        }
    }
}

6. 유니티(Unity) 환경에서 테스트해 보기

아래는 Unity에서 Dictionary<TKey, TValue>를 간단히 테스트하는 예제입니다. 몬스터 이름(문자열)을 키로, HP(정수)를 값으로 사용하는 Dictionary를 만들고, 추가/중복 처리/조회/삭제/초기화 흐름을 로그로 확인할 수 있습니다.

using UnityEngine;
using Daebak.Common.Collections;

public class Test_Dictionary : MonoBehaviour
{
    void Start()
    {
        Dictionary<string, int> monsterHp = new Dictionary<string, int>();

        Debug.Log("=== Dictionary 테스트 시작 ===");

        // 1) 기본 Add
        monsterHp.Add("Slime", 10);
        monsterHp.Add("Orc", 20);
        monsterHp.Add("Golem", 50);
        Debug.Log($"Count = {monsterHp.Count}, Capacity = {monsterHp.Capacity}");

        // 2) 동일 키 Add (값 갱신)
        monsterHp.Add("Orc", 25); // 기존 Orc HP 갱신
        Debug.Log($"Orc HP (갱신 후) = {monsterHp["Orc"]}");

        // 3) TryGetValue 테스트
        if (monsterHp.TryGetValue("Golem", out int golemHp))
        {
            Debug.Log($"Golem HP = {golemHp}");
        }

        // 4) 인덱서 테스트
        monsterHp["Dragon"] = 100; // 새 키 추가
        Debug.Log($"Dragon HP (indexer) = {monsterHp["Dragon"]}");
        Debug.Log($"Count = {monsterHp.Count}");

        // 5) ContainsKey 테스트
        Debug.Log($"ContainsKey(Slime) = {monsterHp.ContainsKey("Slime")}");
        Debug.Log($"ContainsKey(Unknown) = {monsterHp.ContainsKey("Unknown")}");

        // 6) Remove 테스트
        Debug.Log($"Remove(Slime) = {monsterHp.Remove("Slime")}");
        Debug.Log($"Remove(Slime) (이미 삭제됨) = {monsterHp.Remove("Slime")}");
        Debug.Log($"ContainsKey(Slime) = {monsterHp.ContainsKey("Slime")}");
        Debug.Log($"Count = {monsterHp.Count}");

        // 7) Clear 테스트
        monsterHp.Clear();
        Debug.Log("Clear() 호출");
        Debug.Log($"Count = {monsterHp.Count}");

        Debug.Log("=== Dictionary 테스트 종료 ===");
    }
}

실행 결과

위 테스트 코드를 실행하면 Unity 콘솔에는 대략 다음과 같은 로그가 출력됩니다. (Capacity 값은 초기 설정과 Resize 발생 여부에 따라 달라질 수 있습니다.)

=== Dictionary 테스트 시작 ===
Count = 3, Capacity = 16
Orc HP (갱신 후) = 25
Golem HP = 50
Dragon HP (indexer) = 100
Count = 4
ContainsKey(Slime) = True
ContainsKey(Unknown) = False
Remove(Slime) = True
Remove(Slime) (이미 삭제됨) = False
ContainsKey(Slime) = False
Count = 3
Clear() 호출
Count = 0
=== Dictionary 테스트 종료 ===

 

이 결과를 통해 다음 사항을 직관적으로 확인할 수 있습니다.

  • Add는 새로운 키를 추가하고, 동일 키에 대해서는 값을 갱신하는 방식으로 동작합니다.
  • TryGetValue는 키 존재 여부에 따라 true/false를 반환하며, 존재하는 경우 out 파라미터로 값을 전달합니다.
  • 인덱서 접근은 this[key] 형태로 직관적으로 값을 가져오고 설정할 수 있게 해 줍니다.
  • Remove는 실제 삭제가 일어날 때만 true를 반환하며, 없는 키에 대해서는 false입니다.
  • Clear 후에는 Count가 0으로 초기화되어, Dictionary가 비워진 것을 확인할 수 있습니다.

마무리

Dictionary는 키를 통해 값을 빠르게 찾는 자료구조로, 게임 개발을 포함한 대부분의 애플리케이션에서 필수적으로 사용되는 구조입니다. 해시 함수, 버킷 배열, 체이닝 구조, 로드 팩터와 Resize 개념을 이해하고 나면, 단순히 “편하니까 쓰는” 수준을 넘어서, 어떤 키를 사용해야 좋은지, 초기 용량을 어떻게 잡을지를 설계 단계에서부터 의식적으로 결정할 수 있습니다.

 

이번 글에서는 Dictionary의 핵심 구조, Add/TryGetValue/Remove 흐름, 충돌 처리 방식, Resize 동작까지 살펴보고, 실제로 C#으로 직접 구현한 코드와 Unity 테스트 예제까지 정리해 보았습니다. 게임 개발에서는 아이템 테이블, 스킬 테이블, 리소스 캐시, 세션 관리 등 “키-값 매핑이 필요한 거의 모든 곳”에 Dictionary가 등장하기 때문에, 내부 동작을 이해해 두면 성능 문제를 분석하거나 구조를 설계할 때 큰 도움이 됩니다.

 

HashSet, Dictionary까지 이해했다면, 해시 기반 자료구조의 기초는 어느 정도 정리된 것입니다. 이후에는 커스텀 비교기(IEqualityComparer) 도입, 집합 연산, 트리 기반 맵 구조(예: SortedDictionary) 등으로 응용 범위를 넓혀 보면서, 실제 프로젝트에서 상황에 맞는 자료구조를 선택하는 감각을 키우면 좋습니다.

 

다음 글에서는 PriorityQueue(우선순위 큐) 구조를 살펴보며, 키 기반 조회에 집중했던 Dictionary에서 나아가 데이터의 “중요도(우선순위)”에 따라 처리 순서를 제어하는 방식이 어떻게 구현되는지를 정리합니다.