---
title: "Zenject 의 Bind 문장은 다음 문장이 시작될 때 확정된다"
description: "Zenject 의 Container.Bind<T>().AsSingle() 한 줄은 그 자리에서 등록되지 않고 큐에 쌓입니다. 다음 Bind 가 앞 문장을 Provider 로 확정하므로, 스코프 누락 같은 실수는 잘못이 없는 다음 줄에서 터집니다."
author: "유니얼"
pubDate: 2026-09-27
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "DI"]
canonical: https://blog.unialgames.com/posts/unity-zenject-binding/
---
```csharp
Container.Bind<ScoreService>().AsSingle();
Container.BindInterfacesTo<Sword>();          // 스코프를 빠뜨렸다
Container.Bind<Bow>().AsTransient();          // 예외는 이 줄에서 난다
```

잘못은 둘째 줄에 있는데 스택 트레이스는 셋째 줄을 가리킵니다. 메시지는 `Scope must be set for the previous binding!` 입니다.

Zenject 의 **Bind 한 줄은 문장을 큐에 쌓을 뿐이고, 그 문장은 다음 문장이 시작될 때 Provider 로 확정됩니다.** 체인이 끝났다는 신호가 그것뿐이라서, 실수는 늘 한 줄 늦게 드러납니다.

## 한 줄은 큐에 문장을 쌓을 뿐이다

`Bind<T>()` 는 먼저 `StartBinding` 을 부릅니다.

```csharp
// Main/DiContainer.cs
public BindStatement StartBinding(bool flush = true)
{
    if (flush)
        FlushBindings();          // 앞 문장부터 확정한다
    var bindStatement = ZenPools.SpawnStatement();
    _currentBindings.Enqueue(bindStatement);
    return bindStatement;
}
```

문장 객체 `BindStatement` 와 설정 묶음 `BindInfo` 를 풀에서 빌려 큐에 넣고, 체인의 `To`, `AsSingle`, `NonLazy` 는 `BindInfo` 의 칸을 한두 개씩 바꿉니다. 체인의 메서드마다 돌려주는 타입이 좁아져서 `AsSingle().AsTransient()` 처럼 순서가 틀린 체인은 컴파일되지 않습니다. 줄이 끝나도 조회 테이블 `_providers` 는 비어 있습니다.

## 앞 문장은 다음 문장이 확정한다

`Bind<IWeapon>()` 이 불린 시점에는 뒤에 `.To<Sword>()` 가 올지 `.FromInstance(x)` 가 올지 모르고, 체인이 끝났다고 알려 주는 호출도 없습니다. 그래서 다음 문장이 시작되는 순간을 앞 문장이 끝난 신호로 씁니다.

<x-flow>
<script type="application/json">
{
  "label": "설치 파일 세 줄이 큐와 조회 테이블을 바꾸는 순서",
  "cases": [
    {
      "label": "스코프를 다 적었을 때",
      "lines": [
        "Container.Bind<ScoreService>().AsSingle();",
        "Container.BindInterfacesTo<Sword>().AsSingle();",
        "Container.Bind<Bow>().AsTransient();",
        "// 설치 끝. ResolveRoots 가 FlushBindings 를 부른다"
      ],
      "state": "큐 = [] · _providers = {}",
      "path": [
        { "line": 0, "why": "확정할 앞 문장이 없습니다. 새 문장이 큐에 들어갑니다.", "state": "큐 = [ScoreService] · _providers = {}" },
        { "line": 1, "why": "StartBinding 이 먼저 ScoreService 문장을 확정합니다. 그다음 Sword 문장이 큐에 들어갑니다.", "state": "큐 = [Sword] · _providers = {ScoreService}" },
        { "line": 2, "why": "Sword 문장이 확정되며 IWeapon 항목이 생깁니다.", "state": "큐 = [Bow] · _providers = {ScoreService, IWeapon}" },
        { "line": 3, "why": "마지막 문장은 설치가 끝난 뒤 ResolveRoots 가 확정합니다.", "state": "큐 = [] · _providers = {ScoreService, IWeapon, Bow}" }
      ]
    },
    {
      "label": "가운데 줄에서 빠뜨렸을 때",
      "lines": [
        "Container.Bind<ScoreService>().AsSingle();",
        "Container.BindInterfacesTo<Sword>();",
        "Container.Bind<Bow>().AsTransient();",
        "// 설치 끝. ResolveRoots 가 FlushBindings 를 부른다"
      ],
      "state": "큐 = [] · _providers = {}",
      "path": [
        { "line": 0, "state": "큐 = [ScoreService] · _providers = {}" },
        { "line": 1, "why": "스코프가 없지만 이 줄은 큐에 넣기만 하므로 아무 일도 없습니다.", "state": "큐 = [Sword, 스코프 없음] · _providers = {ScoreService}" },
        { "line": 2, "why": "StartBinding 이 Sword 문장을 확정하려다 스코프 필수 검사에 걸립니다. 예외의 스택은 이 줄을 가리킵니다.", "out": "Error while finalizing previous binding! Contract: IWeapon ... Scope must be set for the previous binding!" }
      ],
      "skip": [3],
      "note": "잘못은 둘째 줄에 있는데 예외는 셋째 줄에서 납니다."
    }
  ],
  "note": "큐에 남는 것은 늘 마지막 문장 하나입니다. 확정은 다음 문장, 조회, 설치 끝 중 먼저 오는 쪽이 합니다."
}
</script>
</x-flow>

`Resolve`, `ResolveAll`, `HasBinding` 도 들어오자마자 확정을 부르고, 마지막 문장은 설치가 끝난 뒤 `ResolveRoots` 가 확정합니다. 그래서 마지막 줄의 실수는 스택에 설치 파일이 아예 보이지 않습니다. 확정 중 예외는 모두 `"Error while finalizing previous binding!"` 으로 감싸여 **앞 문장**이라고 말합니다.

## 확정은 Provider 를 목록에 붙이고, AsSingle 은 유일성을 검사한다

확정은 문장의 finalizer 가 Provider 를 만들어 `_providers[BindingId]` 목록에 붙이는 일입니다. 스코프가 `Singleton` 이면 Provider 를 `CachedProvider` 로 한 번 감쌉니다. 목록이라서 같은 키로 두 번 등록해도 덮어쓰지 않고 하나가 더 붙습니다.

`AsSingle` 과 `AsCached` 는 둘 다 `Singleton` 입니다. 다른 점은 `AsSingle` 이 "이 구현을 만드는 바인딩은 이것 하나뿐" 이라고 컨테이너의 `SingletonMarkRegistry` 에 적는다는 것입니다. 같은 구현을 두 계약에 잇는 세 방법이 여기서 갈립니다(결과는 Windows 11 유니티 에디터 EditMode 에서 확인).

<x-compare>
<script type="application/json">
{
  "label": "Sword 하나를 IWeapon 과 IDamager 두 계약에 연결하기",
  "tabs": [
    {
      "label": "AsSingle 두 문장",
      "code": "Container.Bind<IWeapon>().To<Sword>().AsSingle();\nContainer.Bind<IDamager>().To<Sword>().AsSingle();",
      "text": "두 번째 문장이 확정될 때 SingletonMarkRegistry 에 Sword 가 이미 있어 예외가 납니다.",
      "stats": [["결과", "ZenjectException"], ["터지는 때", "두 번째 문장이 확정될 때 (다음 Bind 나 조회)"], ["메시지", "Error while finalizing previous binding! Contract: IDamager"]],
      "tone": "bad"
    },
    {
      "label": "AsCached 두 문장",
      "code": "Container.Bind<IWeapon>().To<Sword>().AsCached();\nContainer.Bind<IDamager>().To<Sword>().AsCached();",
      "text": "문장마다 CachedProvider 가 따로 생깁니다. 각 계약 안에서는 늘 같은 객체지만 두 계약이 받는 객체는 다릅니다.",
      "stats": [["결과", "예외 없음"], ["Sword 인스턴스", "2개"], ["IWeapon == IDamager", "False"]]
    },
    {
      "label": "한 문장에 두 계약",
      "code": "Container.Bind(typeof(IWeapon), typeof(IDamager))\n    .To<Sword>().AsSingle();\n\n// 또는 Sword 가 구현한 인터페이스 전부와 자신\nContainer.BindInterfacesAndSelfTo<Sword>().AsSingle();",
      "text": "finalizer 가 구체 타입마다 Provider 를 하나 만들고, 문장 안의 모든 계약을 그 하나에 연결합니다.",
      "stats": [["결과", "예외 없음"], ["Sword 인스턴스", "1개"], ["IWeapon == IDamager == Sword", "True"]],
      "tone": "good"
    }
  ]
}
</script>
</x-compare>

한 문장 안의 계약들은 Provider 하나를 공유하고, 문장이 나뉘면 Provider 도 나뉩니다. `AsSingle` 은 수명이 아니라 그 나뉨을 막는 선언입니다.

## 비싼 쪽은 확정이 아니라 쌓기다

새 `DiContainer` 에 인터페이스 20종을 `Bind<IX>().To<X>().AsSingle()` 로 넣고 확정한 한 회에서, 컨테이너 생성 몫을 뺀 값입니다.

| 20줄 | 에디터 플레이 모드 | Mono 개발 빌드 플레이어 | IL2CPP 개발 빌드 플레이어 |
| --- | --- | --- | --- |
| 쌓기와 확정 | 193.3 µs | 173.5 µs | 153.6 µs |
| 그중 큐에 쌓기만 한 몫 | 79% | 79% | 87% |
| 문장 하나의 새 할당 | | 1,161 B | 1,156 B |

문장 하나가 플레이어에서 7.7~8.7 µs 이고, 대부분은 문장과 바인더 객체를 만들어 쌓는 데 듭니다. `Bind<T>().AsSingle()` 을 20, 80, 320줄로 늘리면 전체도 그에 비례했습니다. 씬 시작 때 한 번이면 문제가 되지 않지만, 바인딩이 많은 하위 컨테이너를 자주 만드는 구조에서는 그 수만큼 쌓입니다.

## 주의할 점

### 1. 설치 예외는 스택이 가리키는 줄의 위 문장을 본다

**안 지키면 생기는 문제.** 스코프 누락이나 `AsSingle` 중복의 예외는 잘못이 없는 다음 줄에서 납니다. 스택만 따라가면 멀쩡한 줄을 들여다보게 됩니다. 게다가 바인딩 설명을 넣는 `SetContextInfo` 는 `[Conditional("UNITY_EDITOR")]` 라서, 편집기에서는 붙던 `Context: 'BindInterfacesTo(Sword)'` 가 기기 로그에서는 빠집니다.

**이렇게 쓴다.** 메시지의 `previous binding` 과 `Last binding: Contract:` 뒤 계약 타입을 먼저 읽습니다. 실행 전에 **Edit > Zenject > Validate Current Scenes** 로 같은 예외를 플레이 전에 봅니다. 같은 계약을 여러 설치 파일에서 바인딩하지 않으면 기기 로그만으로도 좁힐 수 있습니다.

### 2. 같은 구현을 여러 계약에 줄 때는 한 문장으로 쓴다

**안 지키면 생기는 문제.** 인터페이스마다 `AsSingle` 문장을 따로 쓰면 두 번째 문장에서 예외가 납니다. 피하려고 `AsCached` 로 바꾸면 이번에는 조용히 `Sword` 가 두 개 생겨, 한쪽으로 바꾼 상태가 다른 쪽에서 안 보입니다.

**이렇게 쓴다.** `Bind(typeof(IWeapon), typeof(IDamager)).To<Sword>().AsSingle()` 이나 `BindInterfacesAndSelfTo<Sword>().AsSingle()` 처럼 계약을 한 문장에 모읍니다.

### 3. 설치 중에 Resolve 하지 않는다

**안 지키면 생기는 문제.** 설치 파일 안에서 `Container.Resolve<T>()` 를 부르면 그 시점까지 확정된 바인딩만 보입니다. 뒤의 설치 파일에서 넣을 바인딩은 아직 없고, 싱글턴이면 불완전한 그래프로 만든 객체가 끝까지 남습니다. 경고는 `DEBUG` 나 `UNITY_EDITOR` 빌드에서 한 번 찍힐 뿐입니다.

**이렇게 쓴다.** 설치 파일은 바인딩 문장만 적습니다. 설치 직후 할 일은 `IInitializable` 을 구현한 클래스의 `Initialize` 에 둡니다.

## 정리

Zenject 의 **Bind 한 줄은 큐에 쌓이고, 다음 문장이 시작될 때 Provider 로 확정됩니다.**

- 체인이 끝났다는 신호가 다음 문장뿐이라, 실수의 예외는 한 줄 늦게 "previous binding" 이라는 말과 함께 납니다.
- 확정은 Provider 를 `_providers` 목록에 붙이는 일이고, `AsSingle` 은 여기에 "이 구현의 생성 바인딩은 하나" 라는 검사를 더합니다.
- 20종 등록이 플레이어에서 150~175 µs 였고 80% 안팎이 쌓기 몫, 문장 하나에 1.1 KB 남짓이 할당됩니다.