Zenject 의 수명은 Context 계층이 정한다
Esc

    Zenject 의 수명은 Context 계층이 정한다

    –Unity라이브러리DI
    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 를 다시 냅니다.
    • 해제는 하나가 던지면 멈추고, 씬이 여럿일 때 순서는 설정을 켜야 고정됩니다.