Reflex 는 실패를 예외로만 알린다
Esc

    Reflex 는 실패를 예외로만 알린다

    –Unity라이브러리DI
    [Test]
    public void 빈_컨테이너를_만들고_버린다() => new ContainerBuilder().Build().Dispose();
    TypeInitializationException : The type initializer for 'Reflex.Logging.ReflexLogger' threw an exception.
      ----> AssertionException : ReflexSettings not found in Resources folder.

    아무것도 등록하지 않은 컨테이너를 버렸는데 설정 에셋을 찾다가 터집니다. 반대로 주입이 실패했을 때는 로그 레벨을 아무리 내려도 “무엇이 없어서” 가 찍히지 않습니다.

    Reflex 는 로그로는 수명만 알리고, 실패는 예외로만 알리며, 진단은 REFLEX_DEBUG 심볼로 컴파일할 때만 붙입니다. 기준 판본은 14.3.1 입니다.

    로그는 수명 이벤트만 찍고 설정 에셋에서 레벨을 읽는다

    Container.Dispose 의 마지막 줄이 로그이고, 로거는 정적 생성자에서 Resources/ReflexSettings 를 읽어 레벨을 정합니다. 그래서 씬을 쓰지 않아도 컨테이너를 해제하는 순간 설정 에셋이 필요합니다.

    정적 생성자가 한 번 실패한 타입은 그 도메인에서 다시 초기화되지 않습니다. 에셋을 뒤늦게 만들어도 스크립트를 다시 컴파일하기 전까지 로그 경로가 계속 실패합니다. 씬에서는 첫 스코프가 먼저 에셋을 찾아서, 개발 빌드는 AssertionException 을, 비개발 빌드는 Assert 가 빠져 CreateRootContainer 의 NullReferenceException 을 냅니다(이 Mac, Mono·IL2CPP 플레이어).

    찍히는 것은 씬 로드와 언로드, 설치, 컨테이너 해제 같은 수명 이벤트뿐입니다. 기본값 Info 에서는 컨테이너를 해제할 때마다 Debug.Log 가 한 번 찍힙니다. 빈 스코프를 만들고 버리는 1회 값입니다(이 Mac, 개발 빌드 플레이어).

    레벨 Mono IL2CPP
    Warning 7.8 µs 7.0 µs
    Info 22.1 µs 21.3 µs

    Info 가 해제 한 번에 약 14 µs 를 더 씁니다. 에디터에서는 콘솔까지 거쳐 116 µs 로, Warning 의 열두 배였습니다.

    REFLEX_DEBUG 는 켜면 비싸고 끄면 흔적이 없다

    진단 계측은 Diagnosis 의 메서드 다섯 개이고, 모두 [Conditional("REFLEX_DEBUG")] 입니다. 심볼이 없으면 호출문과 인자 계산이 컴파일에서 통째로 사라집니다.

    켜면 조회마다 카운터가, 등록과 컨테이너 생성과 인스턴스 생성마다 파일 정보를 포함한 호출 스택 캡처가 붙습니다. 인스턴스 목록을 비우는 코드는 부르는 곳이 없습니다. 루트의 Transient 하나를 1만 번 조회했습니다(이 Mac, Mono 개발 빌드 플레이어).

    항목 심볼 없음 REFLEX_DEBUG
    조회 1회 0.50 µs 69.9 µs
    인스턴스 목록 0 10,000

    디버거 창(Window › Analysis › Reflex Debugger)은 이 데이터를 읽어 컨테이너, 바인딩, 인스턴스를 각각 누가 어디서 만들었는지 보여줍니다. 창의 벌레 버튼은 지금 선택된 빌드 타깃의 Player Settings 심볼을 직접 고칩니다.

    실패는 겹겹이 감싼 예외로 온다

    Reflex 의 예외 일곱 가지 중 다섯은 internal 이라 타입으로 잡을 수 없습니다. 생성자 주입은 어떤 예외든 ConstructorInjectorException 으로 감싸고, 의존의 의존이 빠지면 단계마다 한 겹씩 더 감쌉니다. A(B), B(C) 에 C 만 등록하지 않았을 때 받은 메시지입니다(이 Mac, 에디터와 두 플레이어가 글자 하나 다르지 않았습니다).

    Cannot resolve contract 'C'. occurred while instantiating object type 'B' using constructor B (C) occurred while instantiating object type 'A' using constructor A (B)

    왼쪽이 가장 안쪽입니다. 맨 앞의 계약이 실제로 없는 것이고, 뒤로 갈수록 그것을 필요로 한 쪽입니다. 어느 컨테이너에서 찾았는지는 나오지 않습니다.

    생성자 본문이 던지면 백엔드에 따라 한 겹이 더 붙습니다.

    실행 e.Message 앞머리 예외 사슬 (바깥 → 안)
    에디터, Mono 생성자가 던진 메시지 그대로 ConstructorInjectorException → 원래 예외
    IL2CPP Exception has been thrown by the target of an invocation. ConstructorInjectorException → TargetInvocationException → 원래 예외

    IL2CPP 는 생성자를 ConstructorInfo.Invoke 로 부르기 때문입니다.

    주의할 점

    1. REFLEX_DEBUG 를 켠 채 빌드하지 않는다

    안 지키면 생기는 문제. 벌레 버튼은 Player Settings 를 고치므로 켠 채 빌드하면 플레이어에도 계측이 들어갑니다. 조회가 140배쯤 느려지고 인스턴스 목록이 조회 수만큼 늘어납니다. 값은 ProjectSettings.asset 에 저장되어 버전 관리로 번집니다.

    이렇게 쓴다. 조사가 끝나면 같은 버튼으로 끄고 그 변경을 커밋하지 않습니다. 플레이어 로그에 Symbol REFLEX_DEBUG are defined, performance impacted! 경고가 없는지 봅니다.

    2. 설정 에셋을 먼저 만들고 로그 레벨은 Warning 으로 둔다

    안 지키면 생기는 문제. 에셋이 없으면 첫 Dispose 에서 TypeInitializationException 이 나고, 한 번 실패한 뒤에는 에셋을 만들어도 다시 컴파일할 때까지 계속 실패합니다. 기본값 Info 는 스코프를 자주 만드는 코드에 해제마다 Debug.Log 를 더합니다.

    이렇게 쓴다. Assets/Resources 바로 아래에 Create → Reflex → Settings 로 에셋을 만들고 LogLevel 을 Warning 으로 둡니다. 씬 로드 순서를 따라갈 때만 Development 로 내립니다.

    3. 콘솔에서 가장 먼저 난 예외부터 읽는다

    안 지키면 생기는 문제. 씬 주입 루프에는 try 가 없어서 한 오브젝트의 [Inject] 가 실패하면 거기서 멈추고, 아직 주입받지 못한 오브젝트들이 빈 필드로 NullReferenceException 을 쏟아 냅니다. 스코프, X, Y(없는 계약), Z 를 둔 씬에서 Mono 는 X 만 채워졌고, IL2CPP 는 하이어라키에서 앞인 X 도 비었습니다(이 Mac). 뒤따르는 NRE 부터 고치면 원인이 아닌 곳을 고칩니다.

    이렇게 쓴다. 콘솔을 시간순으로 두고 첫 예외를 찾습니다. 유니티 콘솔은 가장 안쪽 예외를 제목으로 보여 주므로 대개 UnknownContractException: Cannot resolve contract '...' 이고, 그 계약이 빠진 것입니다. 있을 수도 없을 수도 있는 의존은 예외 타입으로 분기하지 말고 HasBinding<T>() 로 먼저 확인합니다.

    정리

    Reflex 는 실패를 예외로만 알립니다.

    • 로그는 수명 이벤트만 찍고, 로거가 설정 에셋에서 레벨을 읽어서 에셋이 없으면 컨테이너 해제에서 터집니다.
    • 진단은 REFLEX_DEBUG 로 컴파일할 때만 붙습니다. 끄면 흔적이 없고, 켜면 조회가 0.5 µs 에서 70 µs 가 됩니다.
    • 예외 메시지는 왼쪽이 가장 안쪽입니다. IL2CPP 에서는 TargetInvocationException 한 겹을 더 벗깁니다.