---
title: "VContainer 의 등록은 빌드할 때 굳는다"
description: "Register<IWeapon, Sword>() 뒤에 Resolve<Sword>() 가 실패하고, 빠진 등록은 빌드가 알려 주지 않습니다. VContainer 는 빌드 때 등록을 불변 테이블로 굳히고, 그때 검사하는 것은 순환 참조뿐입니다."
author: "유니얼"
pubDate: 2026-09-16
updatedDate: 2026-09-25
category: "game-dev/unity/libraries"
tags: ["Unity", "DI", "라이브러리"]
canonical: https://blog.unialgames.com/posts/unity-vcontainer-build/
---
```csharp
builder.Register<IWeapon, Sword>(Lifetime.Singleton);
// ...
container.Resolve<Sword>();   // 실패한다
```

`Sword` 를 등록했는데 `Sword` 로 못 꺼냅니다. 반대로 생성자가 등록하지 않은 타입을 받아도 빌드는 조용히 끝납니다.

VContainer 는 **빌드할 때 등록을 조회 전용 불변 테이블로 굳히고, 그때 검사하는 것은 순환 참조뿐입니다.** 어떤 타입으로 꺼낼 수 있는지, 수명, 생성 전략, 컬렉션 구성원이 이 순간 정해지고 이후로는 못 바꿉니다.

## 인터페이스로 등록하면 구현 타입 자리는 비어 있다

빌드는 인터페이스를 지정한 등록에 대해 **구현 타입 자리에 "있긴 한데 조회는 안 된다" 는 널**을 넣습니다.

```csharp
for (var i = 0; i < interfaceTypes.Count; i++)
    AddToBuildBuffer(buildBuffer, interfaceTypes[i], registration);
if (!buildBuffer.ContainsKey(key))
    buildBuffer.Add(key, null);          // 구현 타입 자리
```

조회는 값이 널이면 없는 것으로 칩니다. 직접 못 찾은 닫힌 제네릭은 열린 제네릭, 단일 요소 컬렉션, `ContainerLocal` 순으로 세 번 더 시도합니다.

<x-flow>
<script type="application/json">
{
  "label": "Resolve 가 등록을 찾는 길",
  "lines": ["if (hashTable.TryGet(interfaceType, key, out registration))", "    return registration != null;", "if (interfaceType.IsConstructedGenericType)", "{", "    return TryGetClosedGenericRegistration(...)   // 열린 제네릭", "        || TryFallbackToSingleElementCollection(...)  // 단일 요소 컬렉션", "        || TryFallbackToContainerLocal(...);          // ContainerLocal 래핑", "}", "return false;"],
  "cases": [
    {
      "label": "인터페이스로 조회",
      "state": "Register<IWeapon, Sword>() 후 Resolve<IWeapon>()",
      "path": [
        { "line": 0, "why": "해시테이블에 바로 있습니다.", "verdict": "true" },
        { "line": 1, "why": "값이 널이 아니라 그대로 돌려줍니다.", "out": "Sword 등록" }
      ],
      "skip": [4, 5, 6, 8]
    },
    {
      "label": "구현 타입으로 조회",
      "state": "같은 등록에 Resolve<Sword>()",
      "path": [
        { "line": 0, "why": "자리는 있습니다. 구현 타입도 색인돼 있으니까요.", "verdict": "true" },
        { "line": 1, "why": "그런데 값이 널입니다. 없는 것으로 칩니다.", "out": "Resolve 실패" }
      ],
      "skip": [4, 5, 6, 8],
      "note": "AsSelf() 를 붙이면 이 자리에 실제 등록이 들어갑니다."
    },
    {
      "label": "IEnumerable 로 조회",
      "state": "Resolve<IEnumerable<IWeapon>>()",
      "path": [
        { "line": 0, "why": "그 타입으로는 등록한 적이 없습니다.", "verdict": "false" },
        { "line": 2, "why": "닫힌 제네릭입니다.", "verdict": "true" },
        { "line": 4, "why": "열린 제네릭 등록을 먼저 찾아봅니다. 없습니다." },
        { "line": 5, "why": "컬렉션이라 단일 요소짜리를 합성해 줍니다.", "out": "합성된 컬렉션 등록" }
      ],
      "skip": [1, 8]
    }
  ]
}
</script>
</x-flow>

같은 인터페이스를 여러 번 등록하면 빌드가 합성한 컬렉션의 원소가 됩니다. 구현 타입 자리의 널은 쓸모도 있습니다. 값을 보지 않는 `Exists` 는 이 자리에서 참을 돌려주므로, 자식 스코프가 "이 구현 타입을 다시 등록했는가" 를 판정하는 데 씁니다.

## 굳은 테이블에는 삽입이 없어 락도 없다

확정된 `Registration` 은 필드가 전부 `readonly` 이고, 조회 테이블 `FixedTypeObjectKeyHashtable` 에는 삽입 API 가 없습니다.

| 선택 | 효과 |
| --- | --- |
| 빌드 후 불변 | 락 없이 여러 스레드가 동시에 조회해도 안전합니다 |
| `RuntimeHelpers.GetHashCode(type)` | `Type.GetHashCode()` 가상 호출 대신 참조 해시를 씁니다 |
| 2의 거듭제곱 용량 | 나머지 연산을 비트 AND 로 바꿉니다 |

빌드는 씬을 열 때 한 번이고 `Resolve` 는 그 뒤로 계속이니, **런타임에 할 일을 빌드 시점으로 민** 거래가 게임에 유리합니다. 대신 빌드 뒤에 등록을 끼워 넣을 길이 없습니다.

## 빌드는 순환만 잡고 Func 순환과 빠진 등록은 통과시킨다

순환 검사는 등록마다 깊이 우선으로 의존을 훑고, 같은 구현 타입을 다시 만나면 경로를 찍어 예외를 던집니다. 런타임 스택 오버플로 대신 빌드할 때 `Circular dependency detected!` 가 뜹니다. 다만 두 가지는 걸러지지 않습니다.

```csharp
if (current.Dependency.Provider is FuncInstanceProvider) return;   // Func 로 만드는 의존은 통과
```

`Func` 로 끊은 순환은 호출을 늦춰 피하라는 뜻으로 통과시킵니다. 그런데 `CycA` 가 `Func<CycB>` 를, `CycB` 가 `CycA` 를 받게 하고 A 생성자 안에서 팩토리를 곧바로 부르면 이렇게 됩니다.

| A 수명 | B 수명 | A 생성자 안에서 팩토리를 부르면 |
| --- | --- | --- |
| Singleton | Singleton 또는 Transient | `TargetInvocationException` 안에 `InvalidOperationException`: "ValueFactory attempted to access the Value property of this instance." |
| Transient | Singleton | 한 겹 더 싸인 같은 `InvalidOperationException` |
| Transient | Transient | A 와 B 를 끝없이 번갈아 만듭니다. 끊지 않으면 스택이 넘칩니다 |

싱글턴이 끼면 싱글턴을 한 번만 만들게 지키는 `Lazy` 가 막으면서 예외를 내는데, 메시지에 순환이라는 말도 타입 이름도 없습니다. 팩토리를 `Resolve` 가 끝난 뒤에 부르면 정상이었습니다.

순환 검사는 **등록된 의존만** 따라가므로 빠진 등록도 통과합니다. 생성자로 받든 `[Inject]` 메서드로 받든 `Build()` 는 끝나고, 처음 그 타입을 `Resolve` 하는 순간 `VContainerException` ("No such registration of type") 이 납니다.

## 주의할 점

### 1. 인터페이스로 등록한 것을 구현 타입으로 꺼내지 않는다

**안 지키면 생기는 문제.** `Register<IWeapon, Sword>()` 뒤의 `Resolve<Sword>()` 는 실패합니다. 구현 타입 자리가 널이라 조회 쪽이 없는 것으로 칩니다.

**이렇게 쓴다.** 두 타입으로 다 받아야 하면 `.As<IWeapon>().AsSelf()` 로 등록합니다. 받는 쪽은 가능하면 인터페이스로 받습니다.

### 2. 빌드가 끝난 뒤 등록을 바꾸려 하지 않는다

**안 지키면 생기는 문제.** 조회 가능한 타입, 수명, 생성 전략, 컬렉션 구성원이 빌드 때 굳고, 테이블에 삽입 API 가 없어 나중에 끼워 넣을 수 없습니다.

**이렇게 쓴다.** 상황에 따라 달라져야 하는 등록은 자식 스코프에 다시 등록합니다. 자식 스코프가 자기 등록을 먼저 보고, 없으면 부모로 올라갑니다.

### 3. `Func` 로 끊은 순환은 생성이 끝난 뒤 부른다

**안 지키면 생기는 문제.** 검사를 통과했다고 순환이 없어진 것이 아닙니다. 생성자 안에서 곧바로 부르면 싱글턴이 낄 때는 순환이라는 말이 없는 `InvalidOperationException`, 둘 다 Transient 이면 스택이 넘칠 때까지 재귀합니다.

**이렇게 쓴다.** 팩토리는 필드에 받아 두기만 하고, 생성이 끝난 뒤 실제로 필요할 때 부릅니다.

### 4. 빠진 등록은 빌드가 아니라 첫 `Resolve` 에서 드러난다

**안 지키면 생기는 문제.** `LifetimeScope` 에서는 빌드 콜백이 곧바로 `Resolve` 를 시작하므로 대개 그 스코프의 `Awake` 안에서 예외가 납니다. 하지만 어떤 진입점도 건드리지 않는 타입의 누락은 나중에 누군가 `Resolve` 할 때까지 숨어 있습니다.

**이렇게 쓴다.** 스코프의 `Awake` 에서 난 `VContainerException` 은 메시지의 타입 이름부터 봅니다. 늦게 `Resolve` 되는 타입은 테스트에서 `Resolve` 를 한 번씩 불러 누락을 미리 드러냅니다.

## 정리

VContainer 의 등록은 **빌드할 때 불변 테이블로 굳고, 빌드는 순환만 검사합니다.**

- 인터페이스로 등록하면 구현 타입 자리는 널이라 `.AsSelf()` 없이는 구현 타입으로 못 꺼냅니다.
- 굳은 테이블은 삽입이 없어 락 없이 조회하고, 빌드 뒤에는 자식 스코프로만 등록을 바꿀 수 있습니다.
- `Func` 로 끊은 순환과 빠진 등록은 빌드를 통과하고, 생성자 안의 호출이나 첫 `Resolve` 에서 터집니다.