Zenject 의 진입점은 MonoKernel 의 Update 안에서 돈다
Esc

    Zenject 의 진입점은 MonoKernel 의 Update 안에서 돈다

    –Unity라이브러리DI
    public class Spawner : IInitializable, ITickable, IDisposable
    {
        public void Initialize() { /* 첫 웨이브 준비 */ }
        public void Tick()       { /* 매 프레임 타이머 */ }
        public void Dispose()    { /* 구독 해제 */ }
    }
    
    Container.BindInterfacesAndSelfTo<Spawner>().AsSingle();

    Spawner 는 MonoBehaviour 가 아닌데 매 프레임 Tick() 이 불립니다. 그러면 다른 MonoBehaviour.Update 보다 먼저일까, 나중일까요. 그리고 Tick 하나가 예외를 던지면 나머지는 어떻게 될까요.

    Zenject 의 진입점은 컨테이너마다 붙는 MonoKernel 이라는 MonoBehaviour 의 콜백 안에서 돕니다. 새 루프 단계가 아니라 평범한 Update 하나라서, 앞뒤는 스크립트 실행 순서가 정하고 예외는 그 Update 를 빠져나갑니다.

    커널은 유니티 콜백을 매니저에 넘긴다

    MonoKernel 의 본문은 유니티 콜백 다섯 개를 매니저에 넘기는 것이 전부입니다.

    커널의 콜백 부르는 것 넘겨받는 인터페이스
    Start InitializableManager.Initialize() IInitializable
    Update TickableManager.Update() ITickable
    FixedUpdate TickableManager.FixedUpdate() IFixedTickable
    LateUpdate TickableManager.LateUpdate() ILateTickable
    OnDestroy DisposableManager.Dispose(), LateDispose() IDisposable, ILateDisposable

    매니저는 List<ITickable> 같은 목록을 주입받고, 진입점 객체는 그 목록이 모일 때 만들어집니다. 그래서 IInitializable 이나 ITickable 을 계약으로 걸어야 목록에 들어갑니다. Bind<Spawner>().AsSingle() 처럼 구체 타입만 걸면 Tick() 도 Initialize() 도 불리지 않습니다.

    유니티 콜백은 커널 하나에 한 번이고 나머지는 인터페이스 호출이라, 진입점 하나는 Update 하나보다 쌉니다.

    하나당 Mono IL2CPP
    커널이 부르는 빈 Tick() 25 ns 22 ns
    유니티가 부르는 빈 Update() 261 ns 143 ns

    Tick 목록은 다음 프레임부터 우선순위 순으로 선다

    TickableManager 는 연결 리스트 하나와 대기 목록 하나를 둡니다. 새 진입점은 대기 목록에 섰다가 다음 프레임 처음에 우선순위 자리로 끼워지고, 지우기는 표시만 했다가 루프 뒤에 뺍니다.

    숫자는 Container.BindExecutionOrder<T>(숫자) 로 줍니다. 숫자 하나가 세 매니저의 순서를 함께 정하는데, 해제만 방향이 반대입니다.

    매니저 SaveSystem(-10), Spawner(0), Hud(10) 일 때
    InitializableManager SaveSystem → Spawner → Hud
    TickableManager SaveSystem → Spawner → Hud
    DisposableManager Hud → Spawner → SaveSystem

    작은 숫자는 “먼저 켜지고 먼저 돌고 나중에 꺼진다” 입니다. 등록 순서는 숫자 앞에서 아무 힘이 없습니다. IsPaused 는 그 컨테이너의 Tick 계열만 멈추고, 다른 컨테이너의 커널은 계속 돕니다.

    MonoBehaviour 와의 앞뒤는 .meta 의 실행 순서가 정한다

    Zenject 소스에는 [DefaultExecutionOrder] 가 없습니다. 커널의 실행 순서는 스크립트 옆 .meta 파일에 있습니다.

    # SceneContext.cs.meta            executionOrder: -9999
    # ProjectKernel.cs.meta           executionOrder: -9998
    # SceneKernel.cs.meta             executionOrder: -9997
    # DefaultGameObjectKernel.cs.meta executionOrder: -9996

    두 경우 모두 실제 SceneContext 씬에서 네 프레임을 적은 순서입니다(에디터, Mono·IL2CPP 개발 빌드 플레이어 모두 같음). 기본 설정에서는 진입점이 사용자 MonoBehaviour 를 감싸지만, 그 보장은 코드가 아니라 .meta 의 숫자에 있습니다.

    예외 하나가 뒤 순서를 멈춘다

    InitializableManager 는 하나가 던지면 타입 이름을 붙여 다시 던지고, 목록의 나머지 Initialize 는 불리지 않습니다. 커널은 이미 _hasInitialized = true 를 세웠으므로 다시 시도하지 않는데, Update 는 매니저가 있는지만 보므로 다음 프레임부터 모든 Tick 이 평소처럼 돕니다. 초기화를 못 받은 객체의 Tick 까지 포함해서입니다.

    Tick 쪽 루프에는 try 가 없습니다. A(-1), Boom(0), B(1) 을 세우고 Boom.Tick() 이 매 프레임 던지게 하자, 5프레임 동안 A.Tick 과 Boom.Tick 은 5번, B.Tick 은 한 번도 불리지 않았습니다(에디터, Mono·IL2CPP 개발 빌드 플레이어 모두). 콘솔에는 Boom 의 예외뿐이고 B 가 멈췄다는 표시는 없었습니다. LateTick 은 별도 루프라 셋 다 불렸습니다.

    주의할 점

    1. 커널의 실행 순서를 .meta 째로 지킨다

    안 지키면 생기는 문제. Zenject 를 옮기거나 .meta 를 커밋에서 빠뜨려 값이 0 이 되면, 기본 순서 0 인 스크립트와 숫자가 같아져 Tick 과 Update, Initialize 와 Start 의 앞뒤가 보장되지 않습니다. SceneKernel 을 0 으로 바꾼 이 Mac 의 Mono 빌드에서는 그래도 커널이 먼저였고, 시험용 커널로 잰 옛 Windows 측정에서는 뒤집혔습니다. 코드에는 아무 변화가 없어 원인을 찾기 어렵습니다.

    이렇게 쓴다. Zenject 폴더는 .meta 와 함께 버전 관리에 넣고, 업그레이드 뒤에는 Project Settings > Script Execution Order 에서 네 값(-9999, -9998, -9997, -9996)이 남아 있는지 봅니다. 순서가 필요한 MonoBehaviour 의 일은 ITickable 로 옮기고 BindExecutionOrder 로 세워, 순서를 정하는 수단을 하나로 모읍니다.

    2. Initialize 안의 실패는 스스로 잡는다

    안 지키면 생기는 문제. 한 진입점의 Initialize() 가 던지면 뒤 순서의 초기화는 건너뛰고 다시 시도되지 않는데, Tick() 은 다음 프레임부터 모두 돕니다. 초기화에서 채웠어야 할 필드가 null 인 객체들이 매 프레임 Tick 을 받아, 원래 예외 하나가 프레임마다 쏟아지는 널 참조 예외로 번집니다.

    이렇게 쓴다. Initialize() 본문에서 실패할 수 있는 일은 스스로 try/catch 로 감싸고, 실패했으면 자기 Tick() 이 아무것도 하지 않도록 상태를 남깁니다. Zenject 에는 진입점 예외를 받는 훅이 없습니다.

    3. Tick 안에서 던지지 않는다

    안 지키면 생기는 문제. Tick() 하나가 던지면 그 프레임에 뒤에 줄 선 모든 진입점이 건너뜁니다. 같은 오류가 매 프레임 나면 우선순위가 뒤인 진입점은 영영 돌지 않고, 콘솔에는 던진 쪽만 보입니다.

    이렇게 쓴다. 외부 입력이나 네트워크처럼 실패가 예상되는 Tick() 은 본문을 스스로 감쌉니다. 우선순위가 앞인 진입점일수록 뒤의 것을 모두 막으니 더 조심합니다.

    정리

    Zenject 의 진입점은 컨테이너마다 붙는 MonoKernel 의 Start, Update, OnDestroy 안에서 돕니다.

    • 진입점은 인터페이스를 계약으로 걸어야 매니저 목록에 들고, 하나를 부르는 값은 빈 Update 의 7분의 1에서 10분의 1이었습니다.
    • BindExecutionOrder 숫자가 초기화, Tick, 해제 순서를 함께 정하고, MonoBehaviour 와의 앞뒤는 .meta 의 실행 순서가 정합니다.
    • Initialize 예외는 뒤의 초기화만 막고 Tick 은 계속 돌리며, Tick 예외는 그 프레임의 뒤 순서를 모두 건너뜁니다.