---
title: "Reflex 는 실패를 예외로만 알린다"
description: "주입이 안 될 때 Reflex 의 로그에는 원인이 없습니다. 로그는 수명 이벤트만 찍고, 조회와 주입의 실패는 겹겹이 감싼 예외로만 나오며, 자세한 진단은 REFLEX_DEBUG 심볼로 컴파일할 때만 붙습니다."
author: "유니얼"
pubDate: 2026-09-28
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "DI"]
canonical: https://blog.unialgames.com/posts/unity-reflex-debug/
---
```csharp
[Test]
public void 빈_컨테이너를_만들고_버린다() => new ContainerBuilder().Build().Dispose();
```

```text
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` 를 읽어 레벨을 정합니다. 그래서 씬을 쓰지 않아도 컨테이너를 해제하는 순간 설정 에셋이 필요합니다.

<x-flow>
<script type="application/json">
{
  "label": "Dispose 의 로그 한 줄이 거치는 길 (여러 파일을 줄여 이은 코드)",
  "lines": [
    "container.Dispose();",
    "    ReflexLogger.Log($\"Container {Name} disposed\", LogLevel.Info);",
    "static ReflexLogger()                          // 이 타입을 처음 쓰는 순간 한 번",
    "    var settings = ReflexSettings.Instance;    // Resources/ReflexSettings",
    "    Assert.IsNotNull(_instance, \"ReflexSettings not found in Resources folder...\");",
    "    _logLevel = settings.LogLevel;",
    "    Log($\"Reflex LogLevel set to {_logLevel}\", LogLevel.Info);",
    "Log():  if (logLevel < _logLevel) return;  Debug.Log(message);"
  ],
  "cases": [
    {
      "label": "에셋 있음 · Info",
      "state": "에셋 LogLevel = Info (기본값)",
      "path": [
        { "line": 0 },
        { "line": 1, "why": "보간 문자열은 Log 를 부르기 전에 만들어집니다. 이 프로세스에서 ReflexLogger 를 처음 씁니다." },
        { "line": 2, "why": "그래서 정적 생성자가 먼저 돕니다." },
        { "line": 3, "state": "_instance = 에셋" },
        { "line": 4, "verdict": "통과" },
        { "line": 5, "state": "_logLevel = Info" },
        { "line": 6, "out": "Reflex LogLevel set to Info" },
        { "line": 7, "verdict": "찍는다", "why": "Info 는 Info 보다 작지 않습니다. 이름을 붙이지 않은 컨테이너라 이름 자리가 비어 있습니다.", "out": "Container  disposed" }
      ]
    },
    {
      "label": "에셋 있음 · Warning",
      "state": "에셋 LogLevel = Warning",
      "path": [
        { "line": 0 },
        { "line": 1, "why": "문자열은 여기서 이미 만들어졌습니다." },
        { "line": 2 },
        { "line": 3, "state": "_instance = 에셋" },
        { "line": 4, "verdict": "통과" },
        { "line": 5, "state": "_logLevel = Warning" },
        { "line": 6, "why": "이 줄도 Info 라서 걸러집니다." },
        { "line": 7, "verdict": "버린다", "why": "Info 가 Warning 보다 작아 돌아갑니다. 만든 문자열은 그대로 버려집니다." }
      ]
    },
    {
      "label": "에셋 없음",
      "state": "Resources 에 ReflexSettings 가 없다",
      "path": [
        { "line": 0 },
        { "line": 1 },
        { "line": 2 },
        { "line": 3, "why": "LoadAsync 의 결과가 null 입니다.", "state": "_instance = null" },
        { "line": 4, "verdict": "실패", "why": "AssertionException 이 정적 생성자 밖으로 나가면서 TypeInitializationException 으로 감싸집니다.", "out": "TypeInitializationException (안쪽 AssertionException)" }
      ],
      "skip": [5, 6, 7]
    }
  ],
  "note": "씬을 쓰지 않아도 컨테이너를 해제하는 순간 설정 에셋을 읽습니다. 로그 레벨로 걸러도 메시지 문자열은 만들어집니다."
}
</script>
</x-flow>

정적 생성자가 한 번 실패한 타입은 그 도메인에서 다시 초기화되지 않습니다. 에셋을 뒤늦게 만들어도 스크립트를 다시 컴파일하기 전까지 로그 경로가 계속 실패합니다. 씬에서는 첫 스코프가 먼저 에셋을 찾아서, 개발 빌드는 `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")]` 입니다. 심볼이 없으면 호출문과 인자 계산이 컴파일에서 통째로 사라집니다.

<x-compare>
<script type="application/json">
{
  "label": "SingletonTypeResolver.Resolve 가 컴파일되는 모양",
  "tabs": [
    {
      "label": "심볼 없음",
      "code": "public object Resolve(Container resolvingContainer)\n{\n    if (_instance == null)\n    {\n        _instance = DeclaringContainer.Construct(_concreteType);\n        DeclaringContainer.Disposables.TryAdd(_instance);\n    }\n    return _instance;\n}",
      "stats": [["조회마다 더해지는 일", "없음"], ["처음 만들 때 더해지는 일", "없음"], ["리졸버를 등록할 때", "없음"]],
      "tone": "good"
    },
    {
      "label": "REFLEX_DEBUG 있음",
      "code": "public object Resolve(Container resolvingContainer)\n{\n    Diagnosis.IncrementResolutions(this);\n    if (_instance == null)\n    {\n        _instance = DeclaringContainer.Construct(_concreteType);\n        DeclaringContainer.Disposables.TryAdd(_instance);\n        Diagnosis.RegisterInstance(this, _instance);\n    }\n    return _instance;\n}",
      "stats": [["조회마다 더해지는 일", "ConditionalWeakTable 조회 + 카운터 증가"], ["처음 만들 때 더해지는 일", "new StackTrace(3, true) 를 프레임별 문자열로 풀어 저장"], ["리졸버를 등록할 때", "new StackTrace(2, true) 저장"]],
      "tone": "bad"
    }
  ]
}
</script>
</x-compare>

켜면 조회마다 카운터가, 등록과 컨테이너 생성과 인스턴스 생성마다 파일 정보를 포함한 호출 스택 캡처가 붙습니다. 인스턴스 목록을 비우는 코드는 부르는 곳이 없습니다. 루트의 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, 에디터와 두 플레이어가 글자 하나 다르지 않았습니다).

```text
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` 한 겹을 더 벗깁니다.