VContainer 에서는 누가 만드느냐가 무엇을 받느냐를 정한다
// 루트 스코프. GameManager 는 생성자로 IAudioService 를 받는다
builder.Register<IAudioService, AudioService>(Lifetime.Singleton);
builder.Register<GameManager>(Lifetime.Singleton);
// 전투 스코프 (루트의 자식). IAudioService 를 전투용으로 하나 더 등록한다
// BattlePresenter 도 생성자로 IAudioService 를 받는다
builder.Register<IAudioService, AudioService>(Lifetime.Singleton);
builder.Register<BattlePresenter>(Lifetime.Scoped);
전투 스코프에 IAudioService 를 하나 더 등록하고, 전투 스코프에서 GameManager 와 BattlePresenter 를 꺼냈습니다. BattlePresenter 는 전투 스코프의 IAudioService 를 받는데, GameManager 는 루트의 것을 받습니다. 같은 스코프에서 꺼냈는데 결과가 다릅니다.
VContainer 에서는 그 객체를 어느 스코프가 만드느냐가 두 가지를 정합니다. 그 객체가 어느 스코프 것이 되어 언제 해제되는지, 그리고 생성자로 무엇을 받는지입니다. 만드는 스코프는 수명이 정하고, 생성자 인자는 그 만든 스코프에서 찾습니다.
등록은 자기 스코프부터 위로 찾는다
Resolve<T>() 를 부르면 먼저 T 의 등록을 찾습니다. 자기 스코프에 없으면 부모로, 그 위로 계속 올라갑니다.
// ScopedContainer.TryFindRegistration (줄임)
IScopedObjectResolver scope = this;
while (scope != null)
{
if (scope.TryGetRegistration(type, out registration, key)) return true;
scope = scope.Parent; // 없으면 부모로
}
내려가는 길은 없습니다. 부모는 자식의 등록을 볼 수 없습니다.
누가 만드는지는 수명이 정한다
등록을 찾으면 인스턴스를 만듭니다. 이때 어느 스코프가 만들어 들고 있을지가 수명마다 다릅니다. 전투 스코프(자식)에서 요청했을 때입니다.
| 수명 | 만드는 스코프 | 해제되는 때 |
|---|---|---|
Singleton |
그 등록이 들어 있는 스코프. 자식에 없으면 부모에게 넘긴다 | 만든 스코프가 닫힐 때 |
Scoped |
요청한 스코프. 등록이 부모에만 있어도 자식이 자기 것을 만든다 | 요청한 스코프가 닫힐 때 |
Transient |
요청한 스코프가 꺼낼 때마다 새로 | 아무도 해제하지 않는다 |
Singleton 은 이름과 달리 “앱 전체에 하나” 가 아닙니다. 공식 문서(Lifetime Overview)도 “같은 컨테이너에 같은 타입을 두 번 등록할 수 없다” 고만 하고, 부모와 자식이 둘 다 등록했으면 “가장 가까운 스코프의 인스턴스” 를 준다고 적습니다. 등록 한 건마다 하나입니다.
순수 C# 컨테이너에 자식 스코프 둘을 만들고, 인스턴스마다 만든 순서대로 번호를 붙여 확인했습니다.
| 구성 | 루트에서 | 자식1 에서 | 자식2 에서 | 자식1 을 닫으면 |
|---|---|---|---|---|
Singleton, 루트에만 등록 |
1번 | 1번 | 1번 | 아무것도 해제 안 됨 |
Singleton, 자식마다 다시 등록 |
1번 | 2번 | 3번 | 2번만 해제 |
Scoped, 루트에만 등록 |
1번 | 2번 | 3번 | 2번만 해제 |
Transient, 자식1 에서 두 번 |
1번, 2번 | 해제 안 됨 |
중간 스코프에 등록한 Singleton 을 손자 스코프에서 꺼내면 중간 스코프가 만든 하나가 나왔고, 손자를 닫을 때가 아니라 중간을 닫을 때 해제됐습니다.
소스로는 이 분기입니다.
// ScopedContainer.ResolveCore (줄임)
switch (registration.Lifetime)
{
case Lifetime.Singleton:
// 내 등록이 아니다: 부모가 만들고 부모가 들고 있다
if (!registry.Exists(registration.ImplementationType, registration.Key))
return Parent.Resolve(registration);
// 내 등록이다: 내가 만들고, 내가 닫힐 때 해제한다
return CreateTrackedInstance(registration);
case Lifetime.Scoped:
// 등록 위치와 상관없이 요청한 스코프가 만든다
return CreateTrackedInstance(registration);
default:
// Transient: 매번 새로 만들고 추적하지 않는다
return registration.SpawnInstance(this);
}
전투마다 새 점수판처럼 스코프마다 다른 것을 쓰고 싶으면, 자식 스코프에 다시 등록하거나 Scoped 로 등록합니다.
생성자 인자는 만든 스코프에서 찾는다
객체를 만들 때 생성자 인자도 Resolve 해야 합니다. 이 Resolve 는 그 객체를 만드는 스코프가 합니다. 만드는 스코프가 자기 자신을 넘기기 때문입니다.
// ScopedContainer 생성자 (줄임)
createInstance = registration =>
new Lazy<object>(() => registration.SpawnInstance(this)); // this = 만드는 스코프
맨 위 예제를 이 규칙으로 따라가면 결과가 갈린 이유가 보입니다.
| 꺼낸 것 | 수명과 등록 위치 | 누가 만드나 | 받는 IAudioService |
|---|---|---|---|
BattlePresenter |
Scoped, 전투 스코프 |
전투 스코프 | 전투 스코프 것 |
GameManager |
Singleton, 루트 |
루트 | 루트 것. 전투 등록은 보이지 않는다 |
GameManager 는 전투 스코프에서 꺼냈지만 등록이 루트에 있어 루트가 만들고, 루트는 자식의 등록을 못 보니 루트의 IAudioService 를 넣습니다. 실제로 루트 것이 1번, 전투 스코프 것이 2번일 때 GameManager 안에는 1번이, BattlePresenter 안에는 2번이 들어 있었습니다.
씬의 모노비헤이비어(RegisterComponentInHierarchy, 자동 주입)와 resolver.Instantiate 로 만든 프리팹도 같은 규칙입니다. 그 씬의 스코프가 주입하므로 그 스코프에서 가장 가까운 등록을 받습니다.
만드는 코드는 타입마다 한 번 정한다
인스턴스를 실제로 만드는 코드(인젝터)는 타입마다 첫 Resolve 에서 한 번 정하고 캐시합니다. 소스 제너레이터가 만든 코드가 있으면 그것을, 없으면 리플렉션을 씁니다.
return Injectors.GetOrAdd(type, key =>
{
var generatedType = key.Assembly.GetType($"{key.FullName}GeneratedInjector", false);
if (generatedType != null)
return (IInjector)Activator.CreateInstance(generatedType); // 소스 제너레이터가 만든 것
return ReflectionInjector.Build(key); // 리플렉션
});
생성된 인젝터는 new GameManager(...) 를 직접 부르고, 리플렉션 인젝터는 만들 때마다 ConstructorInfo.Invoke 를 부릅니다. 생성 코드는 대상 타입과 같은 어셈블리에서 찾으므로, 그 어셈블리가 VContainer 를 참조해야 하고 이름이 VContainer, UnityEngine., Unity. 로 시작하면 안 됩니다. [Inject] 생성자가 둘 이상이거나, private 생성자나 private [Inject] 메서드이거나, 제네릭 타입이면 그 클래스만 리플렉션으로 돌아가고 콘솔에 경고가 남습니다.
생성자는 이렇게 고릅니다.
| 상황 | 쓰는 생성자 |
|---|---|
[Inject] 생성자가 하나 |
그것 |
[Inject] 생성자가 둘 이상 |
예외 |
[Inject] 가 없음 |
파라미터가 가장 많은 것. private 도 후보 |
주의할 점
1. 부모가 만드는 객체에 자식 등록이 들어가길 기대하지 않는다
안 지키면 생기는 문제. 루트에 등록한 Singleton 은 전투 스코프에서 꺼내도 루트가 만들어서, 전투 스코프에 다시 등록한 구현을 받지 못합니다. 예외 없이 루트 것이 들어가 조용히 다르게 돕니다.
이렇게 쓴다. 스코프마다 달라야 하는 것을 받는 클래스는 그 스코프에 Scoped 로 등록합니다. 자식이 부모에게 알려야 할 일은 이벤트나 콜백으로 거꾸로 올립니다.
2. [Inject] 생성자는 하나만 둔다
안 지키면 생기는 문제. [Inject] 를 붙인 생성자가 둘 이상이면 예외입니다. 하나도 안 붙이면 파라미터가 가장 많은 생성자가 뽑히고, private 생성자도 후보라 의도하지 않은 생성자로 만들어질 수 있습니다.
이렇게 쓴다. 생성자가 여럿인 클래스는 컨테이너가 쓸 것 하나에만 [Inject] 를 붙이고 public 으로 둡니다.
3. 생성된 인젝터가 실제로 쓰이는지 확인한다
안 지키면 생기는 문제. 대상 타입이 있는 어셈블리가 VContainer 를 참조하지 않거나 위 조건에 걸리면 조용히 리플렉션으로 내려갑니다. Transient 를 많이 만드는 코드라면 ConstructorInfo.Invoke 비용이 매번 붙습니다.
이렇게 쓴다. 주입받는 클래스가 있는 모든 어셈블리 정의의 references 에 VContainer 를 적고, 소스 제너레이터가 남긴 콘솔 경고를 지우지 말고 읽습니다.
정리
VContainer 에서는 객체를 누가 만드느냐가 그 객체의 주인과 생성자로 받는 것을 함께 정합니다.
- 등록은 자기 스코프부터 위로 찾고, 부모는 자식의 등록을 못 봅니다.
Singleton은 등록이 있는 스코프가,Scoped는 요청한 스코프가 만들고, 그 스코프가 닫힐 때 해제됩니다.Transient는 아무도 해제하지 않습니다.- 생성자 인자는 만든 스코프에서 찾으므로, 루트가 만든
Singleton에는 자식의 등록이 들어가지 않습니다.