VContainer 는 모노비헤이비어를 꺼 둔 채로 주입하고 켠다
Esc

    VContainer 는 모노비헤이비어를 꺼 둔 채로 주입하고 켠다

    2026년 9월 30일 수정–UnityDI라이브러리
    public class Enemy : MonoBehaviour
    {
        [Inject] IHealthService health;
        void Awake() => health.Init(100);
    }

    이 적 프리팹을 두 가지로 복제했습니다(VContainer 1.19.0, 에디터 PlayMode 테스트).

    복제 방법 Awake 안에서 health == null
    Object.Instantiate(prefab) 다음에 주입 True. Init 에서 널 참조
    resolver.Instantiate(prefab) False. Init(100) 성공

    모노비헤이비어는 new 를 못 하니 이미 만들어진 뒤에 [Inject] 멤버로만 받습니다. 그래서 VContainer 는 새로 만드는 것은 꺼진 채 복제해 주입한 뒤 켜서 Awake 를 주입 뒤로 미루고, 씬에 있는 것은 스코프의 Awake 안에서 빌드 직후 찾아 채웁니다.

    모노비헤이비어는 [Inject] 가 붙은 멤버로 받는다

    생성자를 못 쓰니 받을 자리를 [Inject] 로 표시합니다. 자리는 셋입니다.

    public class Enemy : MonoBehaviour
    {
        [Inject] IHealthService health;                 // 필드
    
        [Inject] public ScoreService Score { get; set; } // 속성
    
        IAudioService audio;
    
        [Inject]                                         // 메서드. 이름은 아무거나 된다
        public void Construct(IAudioService audio)
        {
            this.audio = audio;
        }
    }

    Construct 는 VContainer 의 API 가 아니라 흔히 쓰는 이름일 뿐입니다. 표시는 [Inject] 이고, 메서드 이름이 무엇이든 불립니다.

    주입은 “이미 만들어진 객체에 [Inject] 자리를 채워 넣는 일” 입니다. 인젝터는 그 타입의 [Inject] 멤버 목록을 한 번 읽어 두고, 객체가 오면 필드, 속성, 메서드 순으로 채웁니다.

    // ReflectionInjector.Inject
    InjectFields(instance, resolver, parameters);      // [Inject] 필드마다 타입으로 Resolve 해서 대입
    InjectProperties(instance, resolver, parameters);  // [Inject] 속성도 같은 방식
    InjectMethods(instance, resolver, parameters);     // [Inject] 메서드의 인자마다 Resolve 한 뒤 메서드를 부른다

    그래서 Construct(IAudioService audio) 는 컨테이너가 IAudioService 를 꺼내 인자로 넣어 부르는 메서드입니다. 필드보다 메서드로 받으면 무엇에 의존하는지 한 자리에 보이고, 테스트에서 직접 불러 가짜를 넣을 수도 있습니다. 아래에서 말하는 “주입” 은 모두 이 채워 넣기를 언제 하느냐의 문제입니다.

    새로 만드는 것은 꺼진 채 복제하고 주입한 뒤 켠다

    유니티는 켜진 오브젝트를 복제하면 Instantiate 호출 안에서 바로 Awake 를 부르고, 꺼진 오브젝트는 켤 때 부릅니다. VContainer 는 이 규칙을 씁니다.

    꺼진 오브젝트에 주입한 뒤 켜도 멀쩡했습니다. 꺼 두는 목적은 주입이 아니라 Awake 를 미루는 것입니다. 원본을 되돌리는 두 줄은 finally 라 주입 중 예외가 나도 원본 프리팹이 꺼진 채 남지 않고, 되돌리는 값이 true 가 아니라 wasActive 라서 원래 꺼져 있던 프리팹은 주입은 끝났지만 꺼진 채로 돌아옵니다.

    RegisterComponentInNewPrefab 과 RegisterComponentOnNewGameObject 의 프로바이더도 같은 순서를 씁니다. 새 게임오브젝트는 AddComponent 전에 끕니다. 실행 순서 -5000 은 씬에 놓인 오브젝트 사이의 이야기라 런타임 복제에는 통하지 않고, 이 순서만이 Awake 를 뒤로 미룹니다.

    씬에 놓인 것은 스코프의 Awake 안에서 채워진다

    RegisterComponent 와 RegisterComponentInHierarchy 는 등록하면서 자기를 Resolve 하는 빌드 콜백을 답니다. 아무도 생성자로 받지 않아도 빌드 직후 주입이 일어나고, 그 빌드는 스코프의 Awake 안입니다.

    그래서 같은 씬에서 함께 로드되고 실행 순서가 -5000 보다 뒤인 컴포넌트는 주입이 자기 Awake 보다 먼저입니다. 이 조건은 쉽게 깨집니다. 실행 순서 0 과 -6000 인 컴포넌트를 둘 다 RegisterComponentInHierarchy 로 등록하고, 불릴 때마다 로그를 찍었습니다.

    -6000 쪽은 Awake 때 널이었고 Start 때는 둘 다 채워져 있었습니다. 실행 순서 말고도 스코프가 부모를 기다리며 대기열에 들어가거나 Auto Run 을 끄고 직접 Build() 하면 주입이 Awake 뒤로 밀립니다.

    같은 참조라도 RegisterInstance(hudView) 로 넣으면 인젝터를 만들지 않아 [Inject] 멤버가 끝까지 비어 있습니다.

    씬 탐색은 스코프의 씬에서 처음 찾은 하나를 가져온다

    RegisterComponentInHierarchy 는 ApplicationOrigin, 즉 빌드를 시작한 스코프의 씬만 뒤집니다. 루트 오브젝트를 하이어라키 순서로, 루트 안은 깊이 우선으로, 꺼진 것까지 봅니다.

    순수 ContainerBuilder 에서는 ApplicationOrigin 이 널이라 VContainerException 이 아니라 NullReferenceException 이 납니다. 찾을 컴포넌트를 마지막 루트의 마지막 자식에 두고, 전체 탐색과 .UnderTransform(root) 로 좁힌 탐색이 스코프 빌드에 더한 시간입니다.

    씬 오브젝트 수 전체 (Mono) 전체 (IL2CPP) 좁힘 (두 런타임)
    약 1,000 0.058 ms 0.056 ms 0.01 ms 미만
    약 10,000 1.04 ms 0.43 ms 0.01 ms 미만
    약 30,000 3.33 ms 2.12 ms 0.01 ms 미만

    전체 탐색은 씬 크기에 따라 늘고, 등록이 여럿이면 등록마다 처음부터 다시 훑습니다.

    등록으로 만드는 프리팹은 등록한 컴포넌트 하나만 주입한다

    프리팹을 복제해 주입하는 길은 셋입니다. 어디까지 채우느냐와 Awake 전에 채우느냐가 다릅니다.

    방법 등록한 컴포넌트 같은 오브젝트의 다른 컴포넌트 · 자식 Awake 전에 주입
    RegisterComponentInNewPrefab 채워짐 비어 있음 예
    resolver.Instantiate 채워짐 채워짐 예
    Object.Instantiate 뒤 InjectGameObject 채워짐 채워짐 아니오

    RegisterComponentInNewPrefab 은 꺼 둔 채 복제하고 주입한 뒤 켜지만, 복제 뒤 부르는 것이 injector.Inject(component, ...) 한 줄이라 등록한 타입의 컴포넌트 하나만 채웁니다. Object.Instantiate 는 켜진 채 복제되어 Awake 가 먼저 돌고 주입은 그 뒤입니다.

    여러 컴포넌트를 Awake 전에 채우려면 resolver.Instantiate 를 씁니다. 등록으로 받고 싶으면 팩토리로 감쌉니다.

    builder.RegisterFactory<Enemy>(
        resolver => () => resolver.Instantiate(enemyPrefab), Lifetime.Scoped);
    
    // 쓰는 쪽
    public EnemySpawner(Func<Enemy> createEnemy) => this.createEnemy = createEnemy;

    루트와 자식에 [Inject] 메서드가 있는 프리팹으로 확인했고, 프리팹 에셋이든 실행 중 만든 템플릿이든, 켜져 있든 꺼져 있든 결과가 같았습니다. 트리 전체 주입은 노드 수에 비례해 노드당 Mono 약 5 µs, IL2CPP 약 2.4 µs, 약 40 B 가 들었습니다.

    주의할 점

    1. Object.Instantiate 대신 resolver.Instantiate 를 쓴다

    안 지키면 생기는 문제. 켜진 프리팹을 유니티 API 로 복제하면 복제 호출 안에서 Awake 가 돌고, 그 뒤의 InjectGameObject 는 늦습니다. Awake 에서 주입 필드를 쓰면 널 참조입니다.

    이렇게 쓴다. 프리팹을 만드는 클래스가 IObjectResolver 를 받아 resolver.Instantiate(prefab) 로 복제합니다. IObjectResolver 를 받는 것은 이런 생성 자리로만 한정합니다.

    2. 씬에 놓인 컴포넌트는 Awake 에서 주입 필드를 쓰지 않는다

    안 지키면 생기는 문제. 스코프가 먼저 깨어나는 동안에는 문제가 안 보이다가, 더 앞선 실행 순서, 부모 대기, Auto Run 끄기 중 하나로 Awake 가 주입보다 앞으로 옵니다. -6000 을 준 컴포넌트는 Awake 때 널이었습니다.

    이렇게 쓴다. [Inject] 메서드는 받은 것을 대입만 하고, 그걸로 하는 초기화는 Start() 로 옮깁니다.

    3. RegisterComponentInHierarchy 는 스코프와 같은 씬을 대상으로 쓴다

    안 지키면 생기는 문제. 애디티브로 연 다른 씬의 컴포넌트는 못 찾고 예외가 납니다. 같은 타입이 둘이면 꺼진 쪽이라도 먼저 걸린 것이 뽑히고, 3만 개 씬에서는 등록 하나에 빌드가 2~3 ms 더 듭니다.

    이렇게 쓴다. LifetimeScope 의 Configure 안에서만 쓰고, 찾을 컴포넌트는 스코프와 같은 씬에 둡니다. 위치가 정해져 있으면 .UnderTransform(root) 로 좁힙니다.

    4. 프리팹 자식까지 채우려면 RegisterComponentInNewPrefab 을 쓰지 않는다

    안 지키면 생기는 문제. 등록한 컴포넌트 하나만 주입되므로 프리팹 자식이나 같은 오브젝트의 다른 컴포넌트는 [Inject] 가 널로 남습니다. 원래 꺼진 프리팹을 넘기면 복제본도 꺼진 채 돌아와 Awake 와 Start 가 켜질 때까지 돌지 않습니다.

    이렇게 쓴다. 트리 전체가 주입받아야 하면 resolver.Instantiate 를 씁니다. 등록에 넘기는 프리팹은 켜 둔 채 저장하고, 일부러 꺼 둔 것이면 받은 쪽에서 SetActive(true) 를 부릅니다.

    정리

    VContainer 는 모노비헤이비어를 꺼 둔 채 주입하고 켜서 Awake 를 주입 뒤로 미룹니다.

    • 새로 만드는 경로는 모두 꺼진 채 복제하고, 주입하고, 원래 상태대로 켭니다.
    • 씬에 놓인 컴포넌트는 스코프 Awake 안의 빌드 콜백에서 채워지므로, 스코프보다 늦게 깨어날 때만 Awake 전에 주입됩니다.
    • RegisterComponentInHierarchy 는 스코프의 씬에서 처음 찾은 하나, RegisterComponentInNewPrefab 은 등록한 컴포넌트 하나만 채웁니다.