VContainer 에서는 누가 만드느냐가 무엇을 받느냐를 정한다
Esc

    VContainer 에서는 누가 만드느냐가 무엇을 받느냐를 정한다

    2026년 9월 30일 수정–UnityDI라이브러리
    // 루트 스코프. 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 에는 자식의 등록이 들어가지 않습니다.