Reflex 의 인스턴스는 컨테이너가 아니라 리졸버에 산다
Esc

    Reflex 의 인스턴스는 컨테이너가 아니라 리졸버에 산다

    –Unity라이브러리DI
    var child = root.Scope(b => b.RegisterType(typeof(Bow), new[] { typeof(IWeapon) }, Lifetime.Singleton, Resolution.Lazy));
    
    child.Resolve<IWeapon>();                          // Bow
    child.All<IWeapon>();                              // [Sword, Bow]
    child.Resolve<Clock>() == root.Resolve<Clock>();   // Scoped 라서 false

    자식에서 꺼내면 자식 등록이, All 로 꺼내면 부모 것까지 나옵니다. 같은 Clock 등록인데 루트와 자식이 다른 객체를 받습니다.

    Reflex 의 컨테이너는 표 하나로 조회만 하고, 인스턴스는 부모와 자식이 공유하는 리졸버 안에 둡니다. 수명은 리졸버가 인스턴스를 어디에 담느냐로 정해집니다. 기준 판본은 14.3.1 입니다.

    조회는 자기 표를 한 번 찾고 끝난다

    컨테이너의 조회 표는 Dictionary<Type, List<IResolver>> 하나입니다. 자식은 빌드할 때 부모 표를 칸마다 복사해 두므로, 조회할 때 부모 쪽으로 올라가지 않습니다.

    IEnumerable<T> 검사가 표를 보기 전에 있어서, 생성자 인자를 IEnumerable<IManager> 로 적으면 그 계약의 등록을 전부 받습니다. 그리고 표에는 Container 칸이 늘 자기 자신으로 채워져 있어, 생성자나 [Inject] 로 Container 를 받으면 자기를 만든 그 컨테이너가 들어옵니다.

    수명은 리졸버가 인스턴스를 담는 방식이다

    부모와 자식이 같은 리졸버 객체를 공유하니, 리졸버가 인스턴스를 어떻게 담느냐가 곧 수명입니다.

    • Singleton: 필드 _instance 하나. 누가 불러도 하나입니다.
    • Scoped: ConditionalWeakTable<Container, object> 에 부른 컨테이너를 키로 담습니다. 공유된 리졸버 하나가 컨테이너마다 다른 인스턴스를 듭니다.
    • Transient: 담지 않고 매번 만듭니다.

    유니티 통합에서는 루트 하나와 씬마다 씬 컨테이너가 있으니, 흔한 경우는 이렇게 나뉩니다.

    쓰임 수명 등록하는 곳
    저장, 오디오처럼 앱 내내 하나 Singleton 루트
    씬마다 하나씩 있어야 하는 관리자 Scoped 루트에 한 번. 꺼낸 씬 컨테이너마다 하나 생긴다
    그 씬에서만 쓰는 서비스 Singleton 씬
    요청마다 새로 만드는 가벼운 객체 Transient 어디든

    리졸버마다 쓰는 컨테이너가 다르다

    리졸버는 부른 컨테이너와 자기가 선언된 컨테이너를 둘 다 압니다. 의존을 어느 쪽 표에서 채우고 해제를 어느 쪽에 맡기느냐가 리졸버마다 다릅니다.

    리졸버 의존을 채우는 컨테이너 해제를 맡는 컨테이너
    Singleton 타입 등록 선언 선언
    Singleton 팩토리 등록 처음 부른 쪽 선언
    Scoped (타입, 팩토리) 부른 쪽 부른 쪽
    Transient 타입 등록 선언 부른 쪽
    Transient 팩토리 등록 부른 쪽 부른 쪽

    어긋나는 것은 팩토리 싱글턴입니다. 루트에 RegisterFactory<Holder>(c => new Holder(c.Resolve<IDep>()), Singleton, Lazy) 와 IDep 의 루트 구현을, 자식 scene 에 IDep 의 씬 구현을 두고 scene 에서 먼저 꺼냈습니다.

    루트의 Holder 등록 먼저 꺼낸 scene 의 Dep 이어서 root 에서 꺼낸 Dep scene.Dispose() 뒤 Dep
    RegisterFactory 싱글턴 씬 구현 씬 구현 해제됨
    RegisterType 싱글턴 루트 구현 루트 구현 살아 있음

    루트에 있는 싱글턴이 씬의 구현을 붙잡고, 씬이 내려간 뒤에도 해제된 객체를 계속 돌려줍니다.

    해제는 자식부터, 만든 역순으로

    Dispose() 는 자식을 나중에 만든 것부터 해제하고, 부모의 자식 목록에서 자기를 빼고, 표를 비우고, 해제 스택을 비웁니다. 해제 스택은 이름 그대로 스택이라 인스턴스는 만든 역순으로, 리졸버(값 등록의 값 포함)는 맨 나중에 해제됩니다. 한 항목이 던져도 멈추지 않습니다.

    해제가 끝난 컨테이너는 표가 비어 있어서, 거기서 Resolve 하면 ObjectDisposedException 이 아니라 등록이 없을 때와 같은 UnknownContractException 이 납니다.

    주의할 점

    1. 오래 사는 컨테이너에서 Transient IDisposable 을 반복해 꺼내지 않는다

    안 지키면 생기는 문제. Transient 라도 IDisposable 이면 부른 컨테이너의 해제 스택에 쌓이고, 그 컨테이너가 해제될 때까지 빠지지 않습니다. 한 컨테이너에서 1만 번 꺼내자 붙잡힌 개수가 1 에서 10,001 이 됐습니다(Windows 11 에디터 EditMode). 루트라면 앱이 끝날 때까지 GC 가 거두지 못합니다.

    이렇게 쓴다. 자주 만들고 버리는 IDisposable 은 팩토리 객체 하나를 싱글턴으로 등록해 그것이 new 하게 하고, 쓰는 쪽이 using 으로 해제합니다.

    2. 루트의 팩토리 싱글턴이 자식 쪽 등록을 꺼내게 두지 않는다

    안 지키면 생기는 문제. 팩토리 싱글턴은 처음 부른 컨테이너를 팩토리에 넘깁니다. 어느 씬이 먼저 꺼냈느냐에 따라 앱 전체의 싱글턴이 달라지고, 그 씬이 내려간 뒤에는 해제된 객체를 씁니다.

    이렇게 쓴다. 루트의 싱글턴은 가능하면 RegisterType 으로 등록합니다. 팩토리가 꼭 필요하면 Resolution.Eager 로 두어 루트를 빌드할 때 루트 컨테이너로 만들어지게 합니다.

    3. Dispose 에서 예외를 던지지 않는다

    안 지키면 생기는 문제. 한 컨테이너 안에서는 예외를 모아 끝까지 해제한 뒤 AggregateException 을 던집니다. 그런데 부모의 자식 해제 루프에는 try 가 없어서, 자식 하나가 던지면 남은 형제와 부모 자신의 해제가 건너뛰어집니다. 루트 아래 C1, C2 를 두고 C2 의 서비스가 던지게 하자 C1 의 서비스도 루트의 서비스도 해제되지 않았습니다(이 Mac, 세 조건 모두).

    이렇게 쓴다. 컨테이너가 해제하는 서비스의 Dispose 는 안에서 예외를 잡아 로그로 남깁니다. 닫는 순서가 중요한 자원은 명시적인 종료 메서드로 먼저 닫습니다.

    정리

    Reflex 의 인스턴스는 컨테이너가 아니라 부모와 자식이 공유하는 리졸버에 삽니다.

    • 조회는 IEnumerable<T> 인지 본 뒤 자기 표에서 한 번 찾아 목록의 마지막 리졸버에 부른 컨테이너를 넘깁니다.
    • Singleton 은 필드 하나, Scoped 는 부른 컨테이너를 키로 한 약한 참조 표, Transient 는 매번 새로 만들고 부른 쪽이 해제합니다. 팩토리 싱글턴은 처음 부른 컨테이너로 만들어집니다.
    • 해제는 자식부터, 스택은 만든 역순으로 갑니다. 컨테이너 사이에서는 첫 예외에서 멈춥니다.