Zenject 의 수명은 Context 계층이 정한다
public override void InstallBindings()
{
Container.Bind<ScoreService>().AsSingle();
Container.Bind<SaveFile>().AsSingle(); // SaveFile 은 IDisposable 을 구현한다
}
ScoreService 는 “그 씬에서” 하나인데, 그 범위는 코드에 안 보입니다. 게임 전체에서 하나인 서비스, 적마다 자기 것을 가져야 하는 서비스는 어디에 두는지, 씬이 내려갈 때 SaveFile.Dispose() 는 누가 부르는지도 마찬가지입니다. 위 코드에서는 아무도 부르지 않습니다.
Zenject 의 수명은 컨테이너 계층이 정합니다. Context 가 컨테이너를 부모에 잇고, “자식마다 하나” 는 바인딩 문장을 자식에서 다시 확정해 만들며, 해제는 IDisposable 계약으로 걸린 것만 그 Context 가 파괴될 때 역순으로 합니다.
Context 가 컨테이너를 부모에 잇는다
Context 는 DiContainer 하나를 들고 있는 MonoBehaviour 이고, 종류마다 범위가 다릅니다.
| 클래스 | 범위 | 컨테이너의 부모 |
|---|---|---|
ProjectContext |
게임 전체. DontDestroyOnLoad |
StaticContext 의 정적 컨테이너 |
SceneContext |
씬 하나 | 기본은 ProjectContext. 계약 이름을 주면 이미 로드된 다른 씬의 SceneContext 들 |
GameObjectContext |
게임오브젝트 하나와 그 자식 | 자기를 주입한 컨테이너 |
GameObjectContext 에는 Awake 가 없고 부모가 주입하며 부르는 Construct 가 시작 신호입니다. SceneContext 가 없는 씬에 놓으면 아무 일도 하지 않습니다. ProjectContext 프리팹은 Resources/ProjectContext 라는 고정 경로에서 찾으므로, Resources 밖으로 옮기면 오류 없이 빈 ProjectContext 가 대신 섭니다.
자식마다 하나는 문장을 자식에서 다시 확정한다
수명 값(ScopeTypes)은 Unset, Transient, Singleton 셋뿐이고 “자식 컨테이너마다 하나” 는 없습니다. 그 일은 바인딩 끝에 붙이는 네 메서드가 합니다.
| 메서드 | 이 컨테이너에도 두나 | 어느 자식까지 |
|---|---|---|
CopyIntoAllSubContainers() |
둔다 | 모든 후손 |
CopyIntoDirectSubContainers() |
둔다 | 직속 자식만 |
MoveIntoAllSubContainers() |
두지 않는다 | 모든 후손 |
MoveIntoDirectSubContainers() |
두지 않는다 | 직속 자식만 |
안에서는 수명을 바꾸지 않습니다. 확정한 바인딩 문장을 버리지 않고 보관했다가, 자식 컨테이너가 생길 때 자식에 대고 한 번 더 확정합니다. 확정할 때마다 CachedProvider 가 새로 생기니 자식마다 인스턴스가 하나씩입니다.
부모 root, 자식 c1, c2, 손자 g 로 컨테이너 넷을 만들고 방식마다 네 곳에서 Resolve 해 생성자 호출 수를 셌습니다.
| 방식 | root 조회 |
g 가 받은 것 |
생성자 호출 수 |
|---|---|---|---|
CopyIntoAllSubContainers |
자기 인스턴스 | 자기 인스턴스 | 4 |
CopyIntoDirectSubContainers |
자기 인스턴스 | c1 과 같은 객체 |
3 |
MoveIntoAllSubContainers |
ZenjectException |
자기 인스턴스 | 3 |
MoveIntoDirectSubContainers |
ZenjectException |
c1 과 같은 객체 |
2 |
다시 확정하는 방식이라 값도 자식마다 다시 냅니다. CopyIntoAll 문장 하나가 CreateSubContainer() 한 번에 Mono 플레이어에서 약 3.5 µs, IL2CPP 에서 약 2.7 µs 와 약 630 B 를 더했습니다. 문장 10줄을 물려받는 적 100마리를 한 프레임에 만들면 자식 컨테이너만으로 Mono 에서 약 6.7 ms 입니다. IInitializable, ITickable, IDisposable 을 부르는 매니저 셋도 이 방식으로 컨테이너마다 한 벌씩 생깁니다.
해제는 IDisposable 계약으로 건 것만 역순으로 한다
DiContainer 는 IDisposable 이 아니고, 만든 객체를 추적하지도 않습니다. 해제는 DisposableManager 가 생성자에서 주입받은 List<IDisposable> 을 돌며 합니다. 그래서 IDisposable 이라는 계약으로 바인딩된 것만 목록에 들어옵니다.
(Disposed 는 Windows 11 에디터 EditMode 에서 DisposableManager.Dispose() 를 직접 부른 결과입니다.) 목록은 등록의 역순으로 해제하고, 하나가 던지면 멈춥니다.
해제를 부르는 것은 컨테이너마다 붙는 커널의 OnDestroy 입니다. 실제 SceneContext 씬에서 A, Boom, C 를 넣고 Boom.Dispose() 가 던지게 한 뒤 언로드하자, 불린 것은 C 와 Boom 뿐이었고 같은 OnDestroy 의 다음 줄인 LateDispose() 도 불리지 않았습니다. 콘솔에는 Boom 의 예외 한 건만 찍혔습니다(에디터, Mono·IL2CPP 개발 빌드 플레이어 모두).
씬이 여럿이면 해제 순서는 설정이 정한다
해제 시점은 커널이 붙은 게임오브젝트가 파괴되는 시점이고, 그 순서는 유니티가 정합니다. 씬 A, B 를 가산 로드하고 Application.Quit() 을 부른 새 프로세스를 설정마다 10번씩 띄웠습니다(Mono·IL2CPP 개발 빌드 플레이어).
EnsureDeterministicDestructionOrderOnApplicationQuit |
10회 결과 (Mono, IL2CPP 같음) |
|---|---|
| 끔 (기본) | 순서 5가지. ProjectContext 가 씬보다 먼저 파괴된 회차 4회 |
| 켬 | 10회 모두 B, A, ProjectContext |
켜면 ProjectKernel.OnApplicationQuit 이 로드한 역순으로 SceneContext 들을 파괴하고 마지막에 ProjectContext 를 파괴합니다. 씬 전환 때는 ZenjectSceneLoader.LoadScene 을 LoadSceneMode.Single 로 부르면 같은 역순을 보장합니다.
주의할 점
1. IDisposable 은 계약으로 바인딩한다
안 지키면 생기는 문제. Bind<SaveFile>().AsSingle() 처럼 구체 타입만 계약으로 걸면 IDisposable 을 구현해도 해제 목록에 들어가지 않습니다. 씬이 바뀔 때마다 파일 핸들이나 구독이 남고, 예외도 경고도 없습니다.
이렇게 쓴다. 수명이 있는 서비스는 BindInterfacesAndSelfTo<T>() 로 바인딩합니다. 구체 타입으로만 받고 싶으면 Bind(typeof(SaveFile), typeof(IDisposable)).To<SaveFile>().AsSingle() 처럼 계약에 함께 적습니다.
2. 자식마다 하나가 필요하면 CopyIntoAllSubContainers 를 붙인다
안 지키면 생기는 문제. ProjectContext 에 Bind<Health>().AsSingle() 만 해 두면 모든 GameObjectContext 가 부모로 올라가 같은 Health 하나를 받습니다. 적 하나가 맞으면 모든 적의 체력이 깎입니다. MoveInto 계열을 쓰면 부모에서는 그 타입이 없어져 부모 쪽 조회가 예외를 냅니다.
이렇게 쓴다. 컨테이너마다 자기 인스턴스가 필요하면 AsSingle().CopyIntoAllSubContainers() 를 쓰거나, 더 단순하게 그 타입을 GameObjectContext 쪽 설치 파일에서 바인딩합니다. 자식이 많으면 문장 수만큼 확정이 반복되니 물려주는 문장은 적게 둡니다.
3. Dispose 안에서 예외를 던지지 않는다
안 지키면 생기는 문제. 하나가 던지면 해제가 멈춥니다. 역순이라 먼저 등록한 것, 대개 가장 밑바닥 서비스가 정리되지 않고, 같은 컨테이너의 LateDispose 도 불리지 않습니다. 불리지 못한 쪽은 로그를 남기지 않습니다.
이렇게 쓴다. Dispose() 본문은 실패할 수 있는 일을 스스로 try/catch 로 감싸고 로그만 남깁니다. 실패가 흔한 정리는 별도의 종료 흐름에서 먼저 끝냅니다.
4. 씬이 여럿이면 해제 순서를 설정으로 고정한다
안 지키면 생기는 문제. 설정을 끈 채 종료하면 부모인 ProjectContext 가 씬보다 먼저 파괴되는 회차가 흔했습니다. 씬 서비스의 Dispose 가 전역 서비스를 쓰면 그 회차에만 이미 정리된 객체를 만지는, 재현이 들쭉날쭉한 종료 버그가 됩니다.
이렇게 쓴다. 씬 전환은 ZenjectSceneLoader 로 하고, ProjectContext 프리팹의 설정에서 Ensure Deterministic Destruction Order On Application Quit 을 켭니다. 그래도 Dispose 에서 다른 씬의 서비스를 부르는 구조는 피합니다.
정리
Zenject 의 수명은 Context 계층이 정하고, 해제는 계약으로 건 IDisposable 만 그 Context 가 파괴될 때 역순으로 합니다.
ProjectContext→SceneContext→GameObjectContext로 부모가 이어지고,GameObjectContext는 부모가 주입할 때 시작합니다.- “자식마다 하나” 는 바인딩 문장을 자식에서 다시 확정하는 것이라, 자식 하나를 만들 때마다 문장당 수 µs 를 다시 냅니다.
- 해제는 하나가 던지면 멈추고, 씬이 여럿일 때 순서는 설정을 켜야 고정됩니다.