Reflex 의 인스턴스는 컨테이너가 아니라 리졸버에 산다
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 는 매번 새로 만들고 부른 쪽이 해제합니다. 팩토리 싱글턴은 처음 부른 컨테이너로 만들어집니다.
- 해제는 자식부터, 스택은 만든 역순으로 갑니다. 컨테이너 사이에서는 첫 예외에서 멈춥니다.