Zenject 의 검증은 생성자를 부르지 않고 그래프만 따라간다
Esc

    Zenject 의 검증은 생성자를 부르지 않고 그래프만 따라간다

    –Unity라이브러리DI
    public class Consumer
    {
        public Consumer(Missing m) { }
    }
    
    Container.Bind<Consumer>().AsSingle();   // Missing 은 등록하지 않았다

    컴파일도 되고 씬도 뜹니다. 누군가 Consumer 를 처음 달라고 하는 순간 Unable to resolve 'Missing' while building object with type 'Consumer' 가 납니다. 그 순간이 게임을 켜고 30분 뒤라면 한 줄을 찾는 데 30분이 듭니다.

    Zenject 의 검증은 설치자를 실제로 돌리고 의존 그래프를 따라가되, 생성자 대신 표식을 만들어 빠진 등록을 게임 없이 찾습니다. 다만 기본 설정은 시작할 때 닿는 것만 보고, 오류를 예외가 아니라 로그로 모읍니다.

    생성자 대신 표식을 만든다

    검증 모드 컨테이너(new DiContainer(true))는 생성자 인자를 일반 모드와 똑같이 컨테이너에서 찾습니다. 다른 것은 마지막에 생성자를 부르는 대신 ValidationMarker 를 돌려준다는 점입니다.

    검증 중에도 진짜로 만드는 것은 설치자, Context, IValidatable, [ZenjectAllowDuringValidation] 이 붙은 타입뿐입니다. 팩토리와 풀은 IValidatable 이라, 런타임에만 만들 객체를 인자 기본값(0, null)으로 한 번 만들어 봅니다. 생성자를 빼니 비용도 생성자 몫만큼 빠집니다. 1 ms 동안 바쁜 대기를 하는 생성자 20개를 NonLazy 로 두면 일반 모드는 한 회 20.421.0 ms, 검증 모드는 0.170.23 ms 였습니다.

    기본 설정은 시작할 때 꺼낼 것이 정해진 것만 본다

    ResolveRoots() 가 무엇을 꺼내 보느냐가 검증의 범위입니다. 기본값 NonLazyOnly 는 일반 모드와 같은 범위를 따라갑니다.

    Lazy 인 Consumer 도 주입 대기 중인 객체의 필드에서 닿으면 검사됩니다. 그 객체를 빼면 NonLazyOnly 는 빠진 등록을 보지 못했습니다(QueueForInject 한 경우 오류 1건, 안 한 경우 0건. 에디터, Mono·IL2CPP 개발 빌드 플레이어 모두).

    오류는 예외가 아니라 로그로 모인다

    ZenjectSettings.ValidationErrorResponse 의 기본값은 Log 입니다. 검증 모드에서 객체를 만드는 입구가 예외를 잡아 로그로 남기고 계속합니다.

    // Main/DiContainer.cs (InstantiateExplicit)
    if (IsValidating)
    {
        if (_settings.ValidationErrorResponse == ValidationErrorResponses.Throw)
            return InstantiateInternal(...);
    
        try { return InstantiateInternal(...); }
        catch (Exception e)
        {
            Log.ErrorException(e);
            return new ValidationMarker(concreteType, true);   // 실패했다고 표시만 하고 계속
        }
    }

    오류를 한꺼번에 보여 주려는 설계입니다. 그래서 Consumer 를 .NonLazy() 로 두고 검증 모드에서 ResolveRoots() 를 부르면, 일반 모드에서는 예외가 나던 것이 예외 없이 끝나고 오류 로그만 1건 남습니다. 메뉴 Edit > Zenject > Validate Current Scenes(Shift+Alt+V)와 ZenUnityEditorUtil.ValidateAllActiveScenes() 는 Application.logMessageReceived 로 오류 로그를 세어 Zenject Validation Failed! 를 던지므로 이 설정에서도 실패를 잡습니다.

    설정 기본값 다른 값
    ValidationRootResolveMethod NonLazyOnly: 시작할 때 꺼낼 것이 정해진 것만 All: 등록된 계약 전부
    ValidationErrorResponse Log: 전부 로그로 모으고 계속 Throw: 첫 오류에서 예외

    사용자 코드 안에서 정해지는 것은 못 본다

    검증은 그래프를 따라갈 뿐 사용자 코드를 돌리지 않습니다.

    경우 검증 모드에서 일어나는 일 그래서
    FromMethod(ctx => ...) 람다를 부르지 않고 표식을 돌려준다 람다 안에서 무엇을 꺼내든 검사 밖
    FromComponentOn(ctx => 게임오브젝트), FromComponentOnRoot() GetComponent 하지 않고 표식 컴포넌트가 없어도 통과
    생성자 본문 부르지 않는다 생성자 안의 예외는 모른다
    팩토리의 런타임 인자 기본값으로 한 번 만들어 본다 인자 값에 따른 차이는 모른다
    코드에서 직접 부르는 container.Resolve<T>() 그 코드가 돌지 않는다 게임 도중의 조회는 모른다

    생성자끼리의 순환은 인자를 찾는 길에서 걸려 Found circular dependency 가 보고됩니다. 필드 순환은 일반 모드에서도 동작하므로 통과합니다.

    주의할 점

    1. 예외가 없다는 것을 통과로 읽지 않는다

    안 지키면 생기는 문제. 기본값 Log 에서 검증 모드 컨테이너는 오류를 로그로만 남깁니다. 테스트에서 new DiContainer(true) 로 만들고 ResolveRoots() 가 예외 없이 끝났다고 초록불을 켜면, 빠진 등록이 있어도 CI 가 통과합니다.

    이렇게 쓴다. 코드로 검증하는 테스트는 container.Settings = new ZenjectSettings(ValidationErrorResponses.Throw, RootResolveMethods.All) 로 시작합니다. Log 를 유지한다면 Application.logMessageReceived 로 오류 로그를 셉니다. CI 에서는 메뉴와 같은 판정을 하는 ZenUnityEditorUtil.ValidateAllActiveScenes() 를 -executeMethod 로 부릅니다. 실패하면 던지므로 종료 코드가 1 이 됩니다.

    2. Lazy 바인딩까지 보려면 All 을 켠다

    안 지키면 생기는 문제. NonLazyOnly 는 NonLazy 바인딩과 주입 대기 객체에서 닿는 것만 봅니다. 메뉴 하나 뒤에서만 쓰는 서비스처럼 시작할 때 아무도 요구하지 않는 바인딩은 빠진 의존성이 있어도 통과하고, 그 화면을 처음 여는 순간 터집니다.

    이렇게 쓴다. ProjectContext 프리팹의 Settings 에서 ValidationRootResolveMethod 를 All 로 둡니다. 등록된 계약 수만큼 한 번씩 꺼내 보는 값이라 에디터에서 돌리는 검증에서는 문제가 되지 않습니다.

    3. FromMethod 와 게터로 찾는 컴포넌트는 따로 확인한다

    안 지키면 생기는 문제. FromMethod 의 람다와 게터로 구하는 컴포넌트는 검증이 들여다보지 않습니다. 람다 안에서 등록 안 된 타입을 꺼내거나 루트에 컴포넌트를 빠뜨려도 초록불이고, 게임 도중에 터집니다.

    이렇게 쓴다. 할 수 있으면 FromNew, FromInstance, 오브젝트를 직접 넘기는 FromComponentOn(gameObject) 처럼 검증이 보는 쪽으로 씁니다. 람다가 꼭 필요하면 그 람다가 꺼내는 것을 IValidatable 을 구현한 작은 클래스의 Validate() 에서 한 번 Resolve 해 둡니다.

    정리

    Zenject 의 검증은 인자를 찾는 길은 그대로 걷고 생성자만 부르지 않아서, 빠진 등록은 찾되 사용자 코드는 돌리지 않습니다.

    • 진짜로 만드는 것은 설치자, Context, IValidatable, 표시한 타입뿐이고, 무거운 생성자가 있으면 오히려 가볍습니다(20 ms 대 0.2 ms).
    • 기본 설정은 NonLazyOnly 와 Log 라서 시작 때 닿는 것만 보고, 오류는 예외 없이 로그로 남습니다.
    • FromMethod 람다, 게터 컴포넌트, 생성자 본문, 런타임 인자 값처럼 코드 안에서 정해지는 것은 검증 밖입니다.