VContainer 의 등록은 빌드할 때 굳는다
builder.Register<IWeapon, Sword>(Lifetime.Singleton);
// ...
container.Resolve<Sword>(); // 실패한다
Sword 를 등록했는데 Sword 로 못 꺼냅니다. 반대로 생성자가 등록하지 않은 타입을 받아도 빌드는 조용히 끝납니다.
VContainer 는 빌드할 때 등록을 조회 전용 불변 테이블로 굳히고, 그때 검사하는 것은 순환 참조뿐입니다. 어떤 타입으로 꺼낼 수 있는지, 수명, 생성 전략, 컬렉션 구성원이 이 순간 정해지고 이후로는 못 바꿉니다.
인터페이스로 등록하면 구현 타입 자리는 비어 있다
빌드는 인터페이스를 지정한 등록에 대해 구현 타입 자리에 “있긴 한데 조회는 안 된다” 는 널을 넣습니다.
for (var i = 0; i < interfaceTypes.Count; i++)
AddToBuildBuffer(buildBuffer, interfaceTypes[i], registration);
if (!buildBuffer.ContainsKey(key))
buildBuffer.Add(key, null); // 구현 타입 자리
조회는 값이 널이면 없는 것으로 칩니다. 직접 못 찾은 닫힌 제네릭은 열린 제네릭, 단일 요소 컬렉션, ContainerLocal 순으로 세 번 더 시도합니다.
같은 인터페이스를 여러 번 등록하면 빌드가 합성한 컬렉션의 원소가 됩니다. 구현 타입 자리의 널은 쓸모도 있습니다. 값을 보지 않는 Exists 는 이 자리에서 참을 돌려주므로, 자식 스코프가 “이 구현 타입을 다시 등록했는가” 를 판정하는 데 씁니다.
굳은 테이블에는 삽입이 없어 락도 없다
확정된 Registration 은 필드가 전부 readonly 이고, 조회 테이블 FixedTypeObjectKeyHashtable 에는 삽입 API 가 없습니다.
| 선택 | 효과 |
|---|---|
| 빌드 후 불변 | 락 없이 여러 스레드가 동시에 조회해도 안전합니다 |
RuntimeHelpers.GetHashCode(type) |
Type.GetHashCode() 가상 호출 대신 참조 해시를 씁니다 |
| 2의 거듭제곱 용량 | 나머지 연산을 비트 AND 로 바꿉니다 |
빌드는 씬을 열 때 한 번이고 Resolve 는 그 뒤로 계속이니, 런타임에 할 일을 빌드 시점으로 민 거래가 게임에 유리합니다. 대신 빌드 뒤에 등록을 끼워 넣을 길이 없습니다.
빌드는 순환만 잡고 Func 순환과 빠진 등록은 통과시킨다
순환 검사는 등록마다 깊이 우선으로 의존을 훑고, 같은 구현 타입을 다시 만나면 경로를 찍어 예외를 던집니다. 런타임 스택 오버플로 대신 빌드할 때 Circular dependency detected! 가 뜹니다. 다만 두 가지는 걸러지지 않습니다.
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에서 터집니다.