Unity - '한 우산 아래'

Unity - Dictionary

망고와플 2026. 4. 1. 01:54

C#의 Dictionary란 무엇인가

카메라 오클루전 처리를 하다 다음과 같은 코드를 쳤다..

private readonly Dictionary<Renderer, Material[]> materialCache = new();

이 코드를 보면 HashSet과는 또 다른 역할을 하고 있다는 것을 바로 느낄 수 있다.

따라서 해당 자료구조에 대해 정리해보고자 한다.

여기서는 단순히 어떤 렌더러가 집합에 포함되어 있는지 확인하는 것이 아니라, 특정 렌더러에 대응하는 머티리얼 배열을 저장하고 찾아야 한다.

자체 엔진 개발에서 unordered_map을 사용해 봤거나, 언리얼에서 TMap을 다뤄본 경험이 있다면 이 구조는 꽤 익숙하게 느껴진다.

결론부터 말하면 Dictionary<TKey, TValue>는 C#에서 사용하는 대표적인 맵 자료구조이고, 개념적으로는 C++의 std::unordered_map이나 언리얼의 TMap과 매우 비슷하다.

즉 Dictionary는 단순히 값을 나열해서 저장하는 구조가 아니라,

키를 통해 특정 값을 빠르게 찾아오기 위한 키-값 쌍 저장소라고 보면 된다.


왜 HashSet만으로는 부족한가

카메라 오클루전 처리에서는 HashSet<Renderer>를 사용해 현재 플레이어를 가리고 있는 렌더러 집합을 관리했다.

예를 들어 이런 역할이었다.

  • 이 렌더러가 이번 프레임에 플레이어를 가리고 있는가
  • 현재 가림 상태 집합에 포함되어 있는가

이 판단에는 HashSet이 잘 맞는다.

하지만 오클루전 처리는 여기서 끝나지 않는다.

우리는 가리고 있는 렌더러를 찾은 뒤, 그 렌더러에 연결된 머티리얼을 바꿔야 한다.

즉 이런 질문이 생긴다.

  • 이 Renderer에 대응하는 Material 배열은 무엇인가

이건 집합이 아니라 맵의 문제다.

단순히 존재 여부만 아는 것이 아니라, 어떤 키에 연결된 추가 정보를 저장하고 꺼내야 하기 때문이다.

그래서 HashSet<Renderer>와 별개로 Dictionary<Renderer, Material[]>가 필요한 것이다.


Dictionary는 어떤 자료구조인가

Dictionary<TKey, TValue>는 키와 값을 쌍으로 저장하는 자료구조다.

예를 들어:

  • 키 = Renderer
  • 값 = Material[]

이렇게 연결할 수 있다.

즉 특정 렌더러를 알고 있으면, 그 렌더러에 대응하는 머티리얼 배열을 빠르게 가져올 수 있다.

이 구조는 개념적으로 다음과 같다.

  • 어떤 대상을 식별할 수 있는 키가 있다
  • 그 키에 대응하는 값을 저장한다
  • 나중에 키를 주면 값을 다시 찾아온다

즉 Dictionary는

이 키에 연결된 값이 무엇인가를 다루는 자료구조다.


왜 Renderer를 키로 쓰는가

Dictionary<Renderer, Material[]> materialCache

오클루전 처리 코드를 처음 작성하면서 가장 먼저 들었던 의문 중 하나는 이 부분이었다.

Renderer를 키로 쓴다는 건 결국 객체 전체를 키로 쓰는 것처럼 보이는데,

이게 너무 무겁거나 느린 방식은 아닌가 하는 점이었다.

겉으로 보기에는 Renderer가 단순한 숫자나 문자열이 아니라 Unity 엔진 객체이기 때문에,

값 자체를 통째로 비교하면서 키를 찾는 것처럼 느껴질 수 있다.

하지만 실제로는 그렇게 동작한다고 보기 어렵다.

Renderer는 C#에서 구조체가 아니라 클래스이며, 참조형 타입이다.

즉 Dictionary<Renderer, Material[]>에서 Renderer를 키로 쓴다는 것은

거대한 객체를 값처럼 통째로 복사해서 키로 저장한다는 뜻이 아니라,

그 렌더러 객체를 가리키는 참조를 기준으로 키를 관리한다는 의미에 가깝다.

C++ 감각으로 바꾸면 다음과 유사하게 이해할 수 있다.

  • std::unordered_map<Renderer*, MaterialArray>
  • TMap<URendererComponent*, MaterialArray>

즉 포인터나 객체 참조를 키로 사용하는 상황과 크게 다르지 않다.

그래서 겉보기에는 복잡한 객체를 키로 쓰는 것 같아 보여도, 실제로는 특정 렌더러를 식별하는 참조를 키로 사용하는 셈이다.

이 구조가 자연스러운 이유는 오클루전 처리에서 우리가 실제로 추적하고 싶은 대상이 바로 Renderer이기 때문이다.

렌더러를 찾고, 그 렌더러가 어떤 머티리얼을 쓰는지 알고, 다시 그 렌더러를 복구해야 하는 흐름이라면,

중간에 별도의 ID를 뽑아내거나 다른 키 체계를 도입하는 것보다 렌더러 자체를 키로 사용하는 편이 더 직접적이고 단순하다.

즉 여기서 중요한 점은

Renderer를 키로 쓴다고 해서 복잡한 객체 전체를 비효율적으로 비교하는 것이 아니라,

실질적으로는 특정 Unity 객체 참조를 식별자처럼 사용하고 있다는 점이다.

그래서 이 경우 Renderer는 무거운 값 키라기보다, 오히려 가장 자연스럽고 직접적인 키라고 볼 수 있다.


왜 값이 Material이 아니라 Material[]인가

Unity의 Renderer는 머티리얼을 하나만 가지는 게 아니라 여러 개를 가질 수 있다.

예를 들어 하나의 메시에:

  • 몸체 머티리얼
  • 장식 머티리얼
  • 유리 머티리얼

처럼 여러 머티리얼 슬롯이 있을 수 있다.

그래서 renderer.materials를 가져오면 단일 Material이 아니라 Material[] 배열이 반환된다.

즉 값 타입이 Material[]인 이유는,

하나의 렌더러가 여러 머티리얼을 가질 가능성을 고려한 것이다.

이 구조 덕분에 오클루전 처리 시 해당 렌더러에 연결된 모든 머티리얼의 알파값을 같이 조절할 수 있다.


materialCache라는 이름이 뜻하는 것

이 변수 이름에서 중요한 단어는 Cache다.

캐시는 한 번 찾은 값을 다시 계산하지 않고 저장해두는 용도다.

즉 여기서 materialCache는 다음 역할을 한다.

  • 어떤 렌더러를 처음 감지했을 때
  • 그 렌더러의 머티리얼 인스턴스를 가져와 저장해둔다
  • 이후 같은 렌더러가 다시 감지되면
  • 새로 가져오지 않고 저장된 값을 재사용한다

이렇게 하면 매 프레임마다 같은 렌더러의 머티리얼을 반복해서 새로 찾는 비용을 줄일 수 있고,

동일한 렌더러에 대해 일관된 머티리얼 참조를 유지할 수 있다.

현재 문제점

materialCache는 한 번 감지한 렌더러와 그에 대응하는 머티리얼 인스턴스를 계속 보관하는 구조이기 때문에, 장기적으로는 메모리 사용량 증가 가능성을 함께 가진다.

특히 renderer.materials는 렌더러 전용 머티리얼 인스턴스를 생성할 수 있으므로, 씬 규모가 커지거나 감지 대상이 많아질 경우 캐시가 계속 누적되는 구조는 비효율로 이어질 수 있다.

다만 현재 단계에서는 프로토타입 규모가 작고, 우선 동작 검증이 목적이기 때문에 단순한 캐시 구조를 사용했다.

이후 실제 프로젝트 단계에서는 캐시 정리 시점, 사용 범위 제한, 머티리얼 인스턴스 관리 방식까지 함께 고려해 보완할 필요가 있다.

(맵이 넘어갈 경우 싹 비우는 방식을 생각 중이다…)


코드에서 실제로 어떻게 쓰이는가

예를 들어 이런 코드가 있다.

void CacheMaterials(Renderer renderer)
{
    if (materialCache.ContainsKey(renderer))
    {
        return;
    }

    materialCache[renderer] = renderer.materials;
}

이 코드는 다음 의미를 가진다.

  1. 이 렌더러가 이미 캐시에 있는가 확인
  2. 이미 있으면 아무것도 하지 않음
  3. 없으면 renderer.materials를 가져와 저장

즉 Dictionary는 여기서

렌더러를 키로 삼아 머티리얼 배열을 한 번만 저장하고 재사용하는 역할을 한다.

이후에는 이렇게 순회한다.

foreach (var pair in materialCache)
{
    Renderer renderer = pair.Key;
    Material[] materials = pair.Value;
}

즉:

  • pair.Key는 렌더러
  • pair.Value는 그 렌더러의 머티리얼 배열

이렇게 키-값 쌍을 함께 다룰 수 있다.


unordered_map과 어떤 점이 비슷한가

C++을 해봤다면 Dictionary<TKey, TValue>는 거의 std::unordered_map<K, V> 감각으로 이해하면 된다.

공통점은 다음과 같다.

  • 키와 값을 쌍으로 저장한다
  • 키를 통해 값을 빠르게 찾는다
  • 해시 기반 접근을 사용한다
  • 순서를 중심으로 쓰는 자료구조가 아니다

즉:

std::unordered_map<Renderer*, MaterialArray> materialCache;

같은 감각을 C#에서는 이렇게 쓰는 셈이다.

Dictionary<Renderer, Material[]> materialCache;

개념적으로는 거의 같은 역할을 한다.


TMap과 어떤 점이 비슷한가

언리얼의 TMap과도 매우 유사하다.

예를 들어 언리얼 스타일로 생각하면:

TMap<URendererComponent*, TArray<UMaterialInterface*>> MaterialCache;

비슷한 느낌으로 대응시킬 수 있다.

즉 C#의 Dictionary는

언리얼에서 TMap을 쓰던 감각으로 이해해도 큰 무리가 없다.

핵심은 다음과 같다.

  • 키 하나에 값 하나를 연결한다
  • 키를 알면 값을 빠르게 찾는다
  • 집합이 아니라 맵이다

HashSet과 Dictionary의 차이

오클루전 처리 코드에서는 둘이 같이 등장해서 헷갈릴 수 있다.

하지만 역할은 분명히 다르다.

HashSet<Renderer>

  • 현재 플레이어를 가리고 있는 렌더러 집합
  • 이 렌더러가 포함되어 있는가를 판단

Dictionary<Renderer, Material[]>

  • 특정 렌더러에 대응하는 머티리얼 배열 저장소
  • 이 렌더러의 머티리얼이 무엇인가를 찾음

즉 HashSet은 현재 상태 집합이고,

Dictionary는 추가 정보를 보관하는 맵이다.

하나는 포함 여부를 위한 구조이고,

다른 하나는 조회를 위한 구조다.


왜 굳이 캐시를 해야 하는가

카메라 오클루전은 매 프레임 돌 수 있는 로직이다.

즉 벽을 가릴 때마다 계속 머티리얼을 새로 찾거나, 매번 새 참조를 만들려고 하면 비효율적일 수 있다.

또한 renderer.materials는 경우에 따라 렌더러 전용 머티리얼 인스턴스를 만드는 동작과 연결될 수 있다.

이런 경우 매 프레임 반복 호출하면 관리가 번거로워질 수 있다.

그래서 한 번 감지한 렌더러의 머티리얼을 캐시에 저장해두고,

이후에는 그 캐시에 접근하는 방식이 더 안정적이다.

즉 Dictionary는 여기서 단순한 편의가 아니라,

렌더러별 머티리얼 상태를 기억하기 위한 핵심 저장소 역할을 한다.


초보자 관점에서 이해하면

유니티를 처음 쓰는 입장에서는 Dictionary를 이렇게 이해하면 쉽다.

  • 이름표가 붙은 서랍장 같은 구조
  • 이름표가 키
  • 서랍 안 내용물이 값

여기서는:

  • 이름표 = Renderer
  • 서랍 안 = Material[]

즉 어떤 렌더러가 들어오면,

그 렌더러에 해당하는 머티리얼 묶음을 바로 찾을 수 있는 구조다.


코드를 읽는 사람 관점에서 이해하면

코드를 읽는 입장에서는 Dictionary<Renderer, Material[]>를 다음처럼 해석하면 된다.

  • 키는 Renderer 객체 참조
  • 값은 해당 Renderer의 Material 배열
  • 렌더러별 머티리얼 인스턴스를 캐싱하기 위한 맵
  • 반복적인 조회와 상태 복구를 위해 유지되는 저장소

즉 이 맵은 단순히 데이터 하나를 넣어두는 용도가 아니라,

오클루전 처리 전체 흐름을 안정적으로 유지하기 위한 상태 저장소에 가깝다.


정리

Dictionary<TKey, TValue>는 C#의 대표적인 맵 자료구조다.

키와 값을 쌍으로 저장하고, 키를 통해 값을 빠르게 찾아오기 위한 구조다.

카메라 오클루전 코드에서

Dictionary<Renderer, Material[]> materialCache

를 사용하는 이유는 명확하다.

  • 어떤 렌더러가 감지되었는가만 아는 것으로는 부족하다
  • 그 렌더러에 연결된 머티리얼 배열도 함께 기억해야 한다
  • 나중에 알파를 조절하거나 원상복구할 때 필요하다
  • 같은 렌더러에 대해 반복적으로 머티리얼을 다시 찾지 않기 위해 캐시가 필요하다

핵심만 정리하면 다음과 같다.

  • Dictionary는 키-값 쌍 저장 구조다
  • Renderer를 키로, Material[]를 값으로 저장한다
  • C++의 std::unordered_map과 매우 비슷하다
  • 언리얼의 TMap과도 매우 비슷하다
  • HashSet은 집합이고, Dictionary는 맵이다
  • 오클루전 처리에서는 렌더러별 머티리얼 상태를 기억하는 저장소 역할을 한다

즉 Dictionary<Renderer, Material[]>는

가림 처리 대상 오브젝트의 머티리얼 상태를 보관하고 재사용하기 위한 렌더러-머티리얼 매핑 구조라고 이해하면 된다.