---
title: "Zenject 의 검증은 생성자를 부르지 않고 그래프만 따라간다"
description: "빠뜨린 바인딩은 Zenject 에서 처음 조회할 때 터집니다. 검증은 설치자를 돌리고 의존 그래프를 따라가되 생성자 대신 표식을 만듭니다. 기본 설정은 시작 때 닿는 것만 보고, 오류를 예외 대신 로그로 모읍니다."
author: "유니얼"
pubDate: 2026-09-27
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "DI"]
canonical: https://blog.unialgames.com/posts/unity-zenject-validation/
---
```csharp
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` 를 돌려준다는 점입니다.

<x-flow>
<script type="application/json">
{
  "label": "InstantiateInternal 이 모드에 따라 갈리는 자리 (줄인 코드)",
  "lines": [
    "allowDuringValidation = IsValidating && ShouldAllowDuringValidation(concreteType);",
    "for (생성자 인자마다)",
    "    value = Resolve(subContext);                 // 인자는 언제나 컨테이너에서 찾는다",
    "    paramValues[i] = (value == null || value is ValidationMarker) ? default : value;",
    "if (!IsValidating || allowDuringValidation)",
    "    newObj = typeInfo.InjectConstructor.Factory(paramValues);   // 사용자 생성자",
    "else",
    "    newObj = new ValidationMarker(concreteType);",
    "InjectExplicit(newObj, ...);                      // 필드와 [Inject] 메서드도 같은 방식",
    "if (IsValidating && newObj is IValidatable) QueueForValidate(newObj);"
  ],
  "cases": [
    {
      "label": "일반 모드",
      "state": "GoodConsumer(Present p) · Present 등록됨",
      "path": [
        { "line": 0, "verdict": "false" },
        { "line": 1 },
        { "line": 2, "why": "Present 를 꺼냅니다. 진짜 객체입니다.", "state": "p = Present 객체" },
        { "line": 3 },
        { "line": 4, "verdict": "true" },
        { "line": 5, "why": "생성자가 불립니다. 파일을 열든 네트워크를 붙잡든 여기서 일어납니다.", "out": "생성자 호출 1회" },
        { "line": 8, "why": "필드를 실제로 채웁니다." }
      ],
      "skip": [6, 7]
    },
    {
      "label": "검증 모드",
      "state": "GoodConsumer(Present p) · Present 등록됨",
      "path": [
        { "line": 0, "verdict": "false", "why": "설치자도, IValidatable 도, 표시된 타입도 아닙니다." },
        { "line": 1 },
        { "line": 2, "why": "Present 를 꺼내는 길은 똑같이 걷습니다. 돌아오는 것도 표식입니다.", "state": "p = ValidationMarker(Present)" },
        { "line": 3, "why": "표식은 인자 타입의 기본값으로 바꿔 넣습니다.", "state": "paramValues = [null]" },
        { "line": 4, "verdict": "false" },
        { "line": 7, "why": "생성자 대신 표식을 만듭니다.", "out": "생성자 호출 0회 · 결과 = ValidationMarker(GoodConsumer)" },
        { "line": 8, "why": "필드와 [Inject] 메서드도 값을 찾기만 하고 대입하거나 부르지 않습니다." }
      ],
      "skip": [5]
    },
    {
      "label": "검증 모드, 설치자",
      "state": "GameInstaller : MonoInstaller",
      "path": [
        { "line": 0, "verdict": "true", "why": "IInstaller 를 구현하므로 검증 중에도 진짜로 만듭니다." },
        { "line": 1 },
        { "line": 2 },
        { "line": 4, "verdict": "true" },
        { "line": 5, "why": "설치자를 실제로 돌려야 바인딩이 생깁니다.", "out": "설치자는 진짜 객체" },
        { "line": 8 }
      ],
      "skip": [7]
    }
  ],
  "note": "인자를 찾는 길은 두 모드가 같고, 마지막에 생성자를 부르느냐만 다릅니다. 그래서 빠진 등록은 찾되 사용자 코드는 돌리지 않습니다."
}
</script>
</x-flow>

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

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

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

<x-flow>
<script type="application/json">
{
  "label": "ResolveRoots 가 무엇을 꺼내 보는가 (줄인 코드)",
  "lines": [
    "public void ResolveRoots()",
    "    FlushBindings();",
    "    ResolveDependencyRoots();       // NonLazy 로 표시한 바인딩을 꺼낸다",
    "#if DEBUG",
    "    if (IsValidating && ValidationRootResolveMethod == All)",
    "        ValidateFullResolve();      // 등록된 계약을 전부 한 번씩",
    "#endif",
    "    _lazyInjector.LazyInjectAll();  // 주입 대기 중인 객체 (씬의 MonoBehaviour 등)",
    "    if (IsValidating) FlushValidationQueue();"
  ],
  "cases": [
    {
      "label": "검증 모드, 기본 (NonLazyOnly)",
      "state": "바인딩: Consumer(Missing) AsSingle · Missing 없음",
      "path": [
        { "line": 1 },
        { "line": 2, "why": "NonLazy 가 없으니 아무것도 꺼내지 않습니다." },
        { "line": 4, "verdict": "false", "why": "기본 설정은 NonLazyOnly 라서 전수 조회를 건너뜁니다." },
        { "line": 7, "why": "씬 객체들의 [Inject] 자리를 따라가지만 Consumer 에는 닿지 않습니다." },
        { "line": 8, "why": "팩토리와 풀 같은 IValidatable 을 검사합니다.", "out": "보고 없음. 빠진 등록을 못 찾았다" }
      ],
      "skip": [5]
    },
    {
      "label": "검증 모드, All",
      "state": "바인딩: Consumer(Missing) AsSingle · Missing 없음",
      "path": [
        { "line": 1 },
        { "line": 2 },
        { "line": 4, "verdict": "true" },
        { "line": 5, "why": "등록된 계약을 하나씩 선택적(Optional)으로 꺼내 봅니다. Consumer 를 꺼내려다 Missing 을 못 찾습니다.", "out": "Unable to resolve 'Missing' while building object with type 'Consumer'" },
        { "line": 7 },
        { "line": 8 }
      ]
    }
  ],
  "note": "기본 설정의 검증이 보는 범위는 일반 모드에서 ResolveRoots 가 닿는 범위에 IValidatable 검사를 더한 것입니다."
}
</script>
</x-flow>

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

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

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

```csharp
// 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` 람다, 게터 컴포넌트, 생성자 본문, 런타임 인자 값처럼 코드 안에서 정해지는 것은 검증 밖입니다.