Zenject 의 Provider 는 인스턴스를 먼저 내주고 주입은 나중에 한다
Esc

    Zenject 의 Provider 는 인스턴스를 먼저 내주고 주입은 나중에 한다

    –Unity라이브러리DI
    class A { [Inject] B b; }      class B { [Inject] A a; }      // 된다
    class C { public C(D d) {} }   class D { public D(C c) {} }   // Found circular dependency

    넷 다 AsSingle 로 바인딩했는데 필드로 서로를 가리키는 쪽은 만들어지고, 생성자로 가리키는 쪽은 예외가 납니다. 그리고 된다던 쪽도 [Inject] 메서드에서 상대의 필드를 읽으면 null 을 봅니다.

    Zenject 의 Provider 는 인스턴스를 먼저 내놓고 멤버 주입은 따로 돌려줍니다. 싱글턴 캐시는 그 사이에 인스턴스를 저장하므로 필드 순환은 풀리고, 생성자 순환은 저장할 인스턴스가 아직 없어 막힙니다.

    모든 바인딩은 Provider 하나가 되고, 주입은 따로 돌려준다

    확정된 바인딩은 전부 IProvider 하나가 됩니다. 핵심 메서드의 모양이 이 글의 전부입니다.

    // Providers/IProvider.cs
    void GetAllInstancesWithInjectSplit(
        InjectContext context, List<TypeValuePair> args, out Action injectAction, List<object> instances);

    인스턴스는 목록에 넣고, 그 인스턴스의 필드·프로퍼티·[Inject] 메서드를 채우는 일은 injectAction 람다로 따로 돌려줍니다. 보통 조회는 돌아오자마자 람다를 실행하므로 쓰는 쪽에서는 나뉜 것이 보이지 않습니다.

    바인딩 Provider 하는 일
    Bind<T>(), To<U>() TransientProvider 생성자까지 부르고 멤버 주입은 람다로
    FromInstance(x) InstanceProvider 받은 객체를 그대로
    FromMethod(...) MethodProvider<T> 메서드 결과를 그대로, 주입 없음
    FromComponentInNewPrefab(p) 프리팹 Provider 꺼진 복제본을 만들고, 주입 뒤 켠다
    위의 것 + AsSingle, AsCached CachedProvider 로 감쌈 첫 결과를 저장해 두고 계속 내줌

    TransientProvider 의 람다는 조회마다 새로 만들어지는 클로저입니다. 빈 클래스를 AsTransient 로 한 번 Resolve 하면 객체 16 B 에 더해 Mono 개발 빌드 플레이어에서 200 B, IL2CPP 에서 232 B 가 할당됐고, 캐시에 적중한 싱글턴은 0 B 였습니다.

    캐시에 먼저 넣어서 필드 순환은 풀린다

    AsSingle 과 AsCached 의 정체인 CachedProvider 는 안쪽 Provider 가 돌려준 injectAction 을 부르지 않고 위로 넘깁니다. 그 전에 인스턴스를 캐시에 넣고 “만드는 중” 표시를 끕니다.

    // Providers/CachedProvider.cs (줄임)
    if (_instances != null) { injectAction = null; buffer.AllocFreeAddRange(_instances); return; }
    if (_isCreatingInstance) throw Assert.CreateException("Found circular dependency when creating type '{0}'. ...");
    _isCreatingInstance = true;
    _creator.GetAllInstancesWithInjectSplit(context, args, out injectAction, instances);
    _instances = instances;            // 주입(injectAction)이 돌기 전에 저장
    _isCreatingInstance = false;

    생성자 순환은 설치와 확정이 조용히 지나간 뒤, 실제로 만드는 Resolve 에서 예외가 납니다.

    필드 순환의 상대는 아직 덜 채워져 있다

    필드 순환에서 b 가 주입되는 동안 b.a 가 가리키는 a 는 캐시에만 있고 b 를 받기 전입니다. A 와 B 에 서로를 가리키는 [Inject] 필드와 [Inject] void Init() 을 대칭으로 두고 재 봤습니다.

    먼저 꺼낸 것 먼저 불린 Init 그다음 Init 예외
    Resolve<A>() B.Init: a.b == null 이 True A.Init: b.a == null 이 False 없음
    Resolve<B>() A.Init: b.a == null 이 True B.Init: a.b == null 이 False 없음

    나중에 요구된 쪽의 [Inject] 메서드가 먼저 돌고, 그때 상대의 필드는 비어 있습니다. 어느 쪽을 먼저 꺼내느냐에 따라 null 을 보는 쪽이 바뀝니다.

    프리팹은 꺼진 채 만들고 주입한 뒤 켠다

    게임오브젝트를 만드는 Provider 는 주입 람다에 켜기를 하나 더 담습니다. 복제본을 꺼진 채 만들고, 주입이 끝난 뒤 켜서 Awake 와 OnEnable 이 주입 뒤에 돌게 합니다.

    컨테이너로 만든 프리팹의 컴포넌트는 자기 Awake 에서 이미 [Inject] 필드를 쓸 수 있습니다.

    주의할 점

    1. 생성자 순환은 그 경로를 처음 만들 때까지 조용하다

    안 지키면 생기는 문제. 생성자로 서로를 요구하는 두 클래스를 바인딩해도 설치는 통과합니다. NonLazy 가 아니고 특정 메뉴에서만 쓰는 객체라면, 그 메뉴를 처음 연 사용자가 Found circular dependency when creating type 을 만납니다.

    이렇게 쓴다. 실행 전에 Edit > Zenject > Validate Current Scenes 로 그래프를 한 번 풀어 봅니다. 순환이 설계상 필요하면 한쪽을 필드나 [Inject] 메서드 주입으로 바꾸거나, 사이에 인터페이스나 이벤트를 두어 방향을 하나로 만듭니다.

    2. 순환하는 두 객체의 [Inject] 메서드에서 상대의 상태를 읽지 않는다

    안 지키면 생기는 문제. 필드 순환은 풀리지만, 나중에 요구된 쪽의 [Inject] 메서드가 돌 때 상대는 캐시에만 있고 주입 전입니다. 어느 쪽을 먼저 조회하느냐에 따라 null 을 보는 쪽이 바뀌어, 씬 구성을 조금 바꿨을 뿐인데 초기화가 깨집니다.

    이렇게 쓴다. [Inject] 메서드에서는 참조를 저장만 합니다. 상대를 써야 하는 초기화는 IInitializable.Initialize 로 옮깁니다. 그 시점에는 주입이 모두 끝나 있습니다.

    3. FromInstance 로 넣은 객체는 알아서 주입되지 않는다

    안 지키면 생기는 문제. InstanceProvider 는 주입 대기열에 있는 객체만 주입합니다. 대기열에 들어가는 것은 Context 가 설치 전에 모은 씬 오브젝트와 QueueForInject 로 넣은 객체뿐이라, 코드에서 new 로 만든 객체를 BindInstance(obj) 로 넣으면 그 [Inject] 필드는 예외 없이 비어 있습니다.

    이렇게 쓴다. 주입이 필요한 객체는 Bind<T>().AsSingle() 로 컨테이너가 만들게 합니다. 이미 만든 객체를 넣어야 하면 설치 파일에서 Container.QueueForInject(obj) 를 함께 부르고, 설치가 끝난 뒤라면 Container.Inject(obj) 로 채웁니다.

    정리

    Zenject 의 Provider 는 인스턴스를 먼저 내놓고 멤버 주입은 injectAction 으로 따로 돌려줍니다.

    • CachedProvider 가 주입 전에 인스턴스를 저장하므로 필드 순환은 풀리고, 생성자 순환은 “만드는 중” 표시에 걸려 처음 만들 때 예외가 납니다.
    • 필드 순환에서 나중에 요구된 쪽의 [Inject] 메서드는 상대의 빈 필드를 봅니다.
    • 이 분리 덕에 프리팹은 꺼진 채 주입받고 켜지지만, Transient 조회마다 클로저가 할당되고(플레이어 200~232 B) FromInstance 객체는 대기열에 없으면 주입되지 않습니다.