---
title: "Zenject 의 수명은 Context 계층이 정한다"
description: "게임 전체에서 하나, 씬마다 하나, 적마다 하나는 Zenject 에서 Context 계층으로 정합니다. 자식마다 하나는 바인딩 문장을 자식에서 다시 확정해 만들고, 해제는 IDisposable 계약으로 건 것만 커널의 OnDestroy 에서 역순으로 합니다."
author: "유니얼"
pubDate: 2026-09-27
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "DI"]
canonical: https://blog.unialgames.com/posts/unity-zenject-context/
---
```csharp
public override void InstallBindings()
{
    Container.Bind<ScoreService>().AsSingle();
    Container.Bind<SaveFile>().AsSingle();      // SaveFile 은 IDisposable 을 구현한다
}
```

`ScoreService` 는 "그 씬에서" 하나인데, 그 범위는 코드에 안 보입니다. 게임 전체에서 하나인 서비스, 적마다 자기 것을 가져야 하는 서비스는 어디에 두는지, 씬이 내려갈 때 `SaveFile.Dispose()` 는 누가 부르는지도 마찬가지입니다. 위 코드에서는 아무도 부르지 않습니다.

Zenject 의 **수명은 컨테이너 계층이 정합니다.** Context 가 컨테이너를 부모에 잇고, "자식마다 하나" 는 바인딩 문장을 자식에서 다시 확정해 만들며, 해제는 `IDisposable` 계약으로 걸린 것만 그 Context 가 파괴될 때 역순으로 합니다.

## Context 가 컨테이너를 부모에 잇는다

Context 는 `DiContainer` 하나를 들고 있는 `MonoBehaviour` 이고, 종류마다 범위가 다릅니다.

| 클래스 | 범위 | 컨테이너의 부모 |
| --- | --- | --- |
| `ProjectContext` | 게임 전체. `DontDestroyOnLoad` | `StaticContext` 의 정적 컨테이너 |
| `SceneContext` | 씬 하나 | 기본은 `ProjectContext`. 계약 이름을 주면 이미 로드된 다른 씬의 `SceneContext` 들 |
| `GameObjectContext` | 게임오브젝트 하나와 그 자식 | 자기를 주입한 컨테이너 |

<x-wiring>
<script type="application/json">
{
  "label": "씬 하나가 열릴 때 컨테이너가 이어지는 순서",
  "width": 780,
  "height": 410,
  "nodes": [
    { "id": "st", "title": "StaticContext", "sub": "정적 DiContainer 하나", "x": 0, "y": 40, "w": 170 },
    { "id": "pc", "title": "ProjectContext", "sub": "DontDestroyOnLoad", "x": 305, "y": 40, "w": 170 },
    { "id": "res", "title": "Resources/ProjectContext", "sub": "프리팹", "x": 610, "y": 40, "w": 170, "aside": true },
    { "id": "sc", "title": "SceneContext", "sub": "Awake, 실행 순서 -9999", "x": 305, "y": 190, "w": 170 },
    { "id": "go", "title": "GameObjectContext", "sub": "씬 안의 적 하나", "x": 305, "y": 340, "w": 170 }
  ],
  "edges": [
    { "from": "sc", "to": "pc", "label": "ProjectContext.Instance", "bend": 40 },
    { "from": "pc", "to": "res", "label": "LoadAll", "bend": 22 },
    { "from": "res", "to": "pc", "label": "복제", "bend": 22 },
    { "from": "pc", "to": "st", "label": "부모로 잡는다", "bend": 0 },
    { "from": "sc", "to": "pc", "label": "부모로 잡는다", "bend": -40 },
    { "from": "sc", "to": "go", "label": "주입하며 Construct 호출", "bend": 30 },
    { "from": "go", "to": "sc", "label": "CreateSubContainer", "bend": 30 }
  ],
  "steps": [
    { "node": "sc", "why": "씬이 로드되면 SceneContext.Awake 가 Initialize 를 거쳐 Run 을 부릅니다. 실행 순서 -9999 라 기본 순서 스크립트보다 먼저입니다." },
    { "edge": 0, "why": "RunInternal 의 첫 줄이 ProjectContext.Instance 를 읽습니다. 아직 없으면 여기서 만들어집니다." },
    { "edge": 1, "why": "Resources 에서 ProjectContext 라는 이름의 프리팹을 찾습니다." },
    { "edge": 2, "why": "프리팹을 비활성 상태로 복제합니다. 주입이 끝나기 전에 그 안의 Awake 가 돌지 않게 하려는 것입니다." },
    { "edge": 3, "why": "ProjectContext 의 컨테이너는 StaticContext 의 컨테이너를 부모로 삼아 만들어지고, 그 자리에서 설치와 해석까지 끝냅니다." },
    { "edge": 4, "why": "이제 SceneContext 가 자기 컨테이너를 만듭니다. 계약 이름을 주지 않았으면 부모는 ProjectContext 의 컨테이너 하나입니다." },
    { "edge": 5, "why": "해석 단계에서 씬의 MonoBehaviour 들에 주입합니다. GameObjectContext 도 그중 하나라 [Inject] 가 붙은 Construct 가 불립니다." },
    { "edge": 6, "why": "Construct 가 받은 컨테이너에서 CreateSubContainer 로 자식을 만들고, 자기 설치와 해석을 그 자리에서 돌립니다." }
  ],
  "note": "GameObjectContext 는 자기 Awake 로 시작하지 않습니다. 부모가 주입해 줘야 시작합니다."
}
</script>
</x-wiring>

`GameObjectContext` 에는 `Awake` 가 없고 부모가 주입하며 부르는 `Construct` 가 시작 신호입니다. `SceneContext` 가 없는 씬에 놓으면 아무 일도 하지 않습니다. `ProjectContext` 프리팹은 `Resources/ProjectContext` 라는 고정 경로에서 찾으므로, `Resources` 밖으로 옮기면 오류 없이 빈 `ProjectContext` 가 대신 섭니다.

## 자식마다 하나는 문장을 자식에서 다시 확정한다

수명 값(`ScopeTypes`)은 `Unset`, `Transient`, `Singleton` 셋뿐이고 "자식 컨테이너마다 하나" 는 없습니다. 그 일은 바인딩 끝에 붙이는 네 메서드가 합니다.

| 메서드 | 이 컨테이너에도 두나 | 어느 자식까지 |
| --- | --- | --- |
| `CopyIntoAllSubContainers()` | 둔다 | 모든 후손 |
| `CopyIntoDirectSubContainers()` | 둔다 | 직속 자식만 |
| `MoveIntoAllSubContainers()` | 두지 않는다 | 모든 후손 |
| `MoveIntoDirectSubContainers()` | 두지 않는다 | 직속 자식만 |

안에서는 수명을 바꾸지 않습니다. 확정한 바인딩 문장을 버리지 않고 보관했다가, 자식 컨테이너가 생길 때 **자식에 대고 한 번 더 확정**합니다. 확정할 때마다 `CachedProvider` 가 새로 생기니 자식마다 인스턴스가 하나씩입니다.

<x-flow>
<script type="application/json">
{
  "label": "바인딩 문장이 부모에서 확정되고 자식에서 다시 확정되는 길 (줄인 코드)",
  "lines": [
    "// 부모: FlushBindings 가 문장 하나를 꺼냈을 때",
    "if (상속 != MoveDirectOnly && 상속 != MoveIntoAll)",
    "    FinalizeBinding(문장);        // 이 컨테이너에 Provider 를 단다",
    "if (상속 != None) _childBindings.Add(문장);",
    "else 문장.Dispose();",
    "// 자식: DiContainer 생성자에서",
    "foreach (var 조상 in 조상들.Distinct())",
    "    foreach (var 문장 in 조상._childBindings)",
    "        if (ShouldInheritBinding(문장, 조상))",
    "            FinalizeBinding(문장);  // 자식에 새 Provider 를 단다"
  ],
  "cases": [
    {
      "label": "AsSingle()",
      "state": "부모: Provider 없음 · 자식: 아직 없음",
      "path": [
        { "line": 1, "verdict": "true" },
        { "line": 2, "why": "부모에 CachedProvider 가 하나 생깁니다.", "state": "부모: CachedProvider A · 자식: 아직 없음" },
        { "line": 3, "verdict": "false", "why": "상속 방식이 None 입니다." },
        { "line": 4, "why": "문장은 여기서 버려집니다. 자식이 다시 볼 방법이 없습니다." },
        { "line": 6, "why": "자식을 만듭니다. 조상은 부모 하나입니다." },
        { "line": 7, "why": "부모의 _childBindings 가 비어 있어 돌 것이 없습니다.", "state": "부모: CachedProvider A · 자식: Provider 없음", "out": "자식에서 조회하면 부모로 올라가 A 가 만든 인스턴스를 받는다" }
      ]
    },
    {
      "label": "AsSingle().CopyIntoAllSubContainers()",
      "state": "부모: Provider 없음 · 자식: 아직 없음",
      "path": [
        { "line": 1, "verdict": "true" },
        { "line": 2, "state": "부모: CachedProvider A · 자식: 아직 없음" },
        { "line": 3, "verdict": "true", "why": "확정한 문장을 버리지 않고 _childBindings 에 남깁니다." },
        { "line": 6, "why": "자식을 만듭니다." },
        { "line": 7, "why": "부모에 남겨 둔 문장 하나가 나옵니다." },
        { "line": 8, "verdict": "true", "why": "CopyIntoAll 은 조상이 누구든 물려받습니다." },
        { "line": 9, "why": "같은 문장을 자식 컨테이너에 대고 다시 확정합니다. CachedProvider 가 새로 생깁니다.", "state": "부모: CachedProvider A · 자식: CachedProvider B", "out": "부모와 자식이 서로 다른 인스턴스를 가진다" }
      ]
    },
    {
      "label": "MoveIntoAllSubContainers()",
      "state": "부모: Provider 없음 · 자식: 아직 없음",
      "path": [
        { "line": 1, "verdict": "false", "why": "Move 는 이 컨테이너에서 확정하지 않습니다.", "state": "부모: Provider 없음 · 자식: 아직 없음" },
        { "line": 3, "verdict": "true", "why": "문장은 자식에게 넘기려고 남깁니다." },
        { "line": 6 },
        { "line": 7 },
        { "line": 8, "verdict": "true" },
        { "line": 9, "state": "부모: Provider 없음 · 자식: CachedProvider B", "out": "부모에서 조회하면 예외, 자식에서는 성공" }
      ]
    }
  ],
  "note": "손자가 생길 때도 조상 전체를 훑으므로 CopyIntoAll 문장은 손자에서 또 한 번 확정됩니다. 컨테이너 넷이면 인스턴스도 넷입니다."
}
</script>
</x-flow>

부모 `root`, 자식 `c1`, `c2`, 손자 `g` 로 컨테이너 넷을 만들고 방식마다 네 곳에서 `Resolve` 해 생성자 호출 수를 셌습니다.

| 방식 | `root` 조회 | `g` 가 받은 것 | 생성자 호출 수 |
| --- | --- | --- | --- |
| `CopyIntoAllSubContainers` | 자기 인스턴스 | 자기 인스턴스 | 4 |
| `CopyIntoDirectSubContainers` | 자기 인스턴스 | `c1` 과 같은 객체 | 3 |
| `MoveIntoAllSubContainers` | `ZenjectException` | 자기 인스턴스 | 3 |
| `MoveIntoDirectSubContainers` | `ZenjectException` | `c1` 과 같은 객체 | 2 |

다시 확정하는 방식이라 값도 자식마다 다시 냅니다. `CopyIntoAll` 문장 하나가 `CreateSubContainer()` 한 번에 Mono 플레이어에서 약 3.5 µs, IL2CPP 에서 약 2.7 µs 와 약 630 B 를 더했습니다. 문장 10줄을 물려받는 적 100마리를 한 프레임에 만들면 자식 컨테이너만으로 Mono 에서 약 6.7 ms 입니다. `IInitializable`, `ITickable`, `IDisposable` 을 부르는 매니저 셋도 이 방식으로 컨테이너마다 한 벌씩 생깁니다.

## 해제는 IDisposable 계약으로 건 것만 역순으로 한다

`DiContainer` 는 `IDisposable` 이 아니고, 만든 객체를 추적하지도 않습니다. 해제는 `DisposableManager` 가 생성자에서 주입받은 `List<IDisposable>` 을 돌며 합니다. 그래서 **`IDisposable` 이라는 계약으로 바인딩된 것만** 목록에 들어옵니다.

<x-compare>
<script type="application/json">
{
  "label": "SaveFile 이 IDisposable 을 구현할 때, 바인딩 한 줄에 따라",
  "tabs": [
    { "label": "Bind<SaveFile>()", "code": "Container.Bind<SaveFile>().AsSingle();", "text": "계약이 SaveFile 하나뿐입니다. List<IDisposable> 을 조회해도 이 바인딩은 걸리지 않습니다. 씬이 내려가도 Dispose 가 불리지 않고 오류도 없습니다.", "stats": [["DisposableManager 목록", "비어 있음"], ["Disposed", "False"]], "tone": "bad" },
    { "label": "BindInterfacesAndSelfTo<SaveFile>()", "code": "Container.BindInterfacesAndSelfTo<SaveFile>().AsSingle();", "text": "SaveFile 이 구현한 인터페이스 전부와 자기 타입이 계약이 됩니다. IDisposable 도 그중 하나라 목록에 들어갑니다. 같은 CachedProvider 를 공유하므로 인스턴스는 하나입니다.", "stats": [["DisposableManager 목록", "SaveFile"], ["Disposed", "True"]], "tone": "good" }
  ]
}
</script>
</x-compare>

(Disposed 는 Windows 11 에디터 EditMode 에서 `DisposableManager.Dispose()` 를 직접 부른 결과입니다.) 목록은 등록의 역순으로 해제하고, 하나가 던지면 멈춥니다.

<x-flow>
<script type="application/json">
{
  "label": "DisposableManager.Dispose 가 목록을 도는 순서 (줄인 코드)",
  "lines": [
    "var ordered = _disposables.OrderBy(x => x.Priority).Reverse().ToList();",
    "foreach (var d in ordered)",
    "{",
    "    try { d.Disposable.Dispose(); }",
    "    catch (Exception e) { throw Assert.CreateException(e, \"... '{0}'\", 타입); }",
    "}"
  ],
  "cases": [
    {
      "label": "모두 정상",
      "state": "등록 순서 A, B, C · 우선순위 모두 0",
      "path": [
        { "line": 0, "why": "우선순위가 같으면 OrderBy 는 원래 순서를 지킵니다. 그것을 뒤집으니 등록의 역순이 됩니다.", "state": "ordered = [C, B, A]" },
        { "line": 1 },
        { "line": 3, "out": "C.Dispose()" },
        { "line": 1 },
        { "line": 3, "out": "B.Dispose()" },
        { "line": 1 },
        { "line": 3, "out": "A.Dispose()" }
      ]
    },
    {
      "label": "B 가 던진다",
      "state": "등록 순서 A, B, C · 우선순위 모두 0",
      "path": [
        { "line": 0, "state": "ordered = [C, B, A]" },
        { "line": 1 },
        { "line": 3, "out": "C.Dispose()" },
        { "line": 1 },
        { "line": 3, "why": "B 의 Dispose 가 예외를 던집니다." },
        { "line": 4, "why": "어느 타입에서 터졌는지 적은 ZenjectException 으로 감싸 다시 던집니다. 루프가 여기서 끝납니다.", "out": "ZenjectException: Error occurred while disposing IDisposable with type 'B'" }
      ],
      "note": "A 의 Dispose 는 불리지 않습니다."
    }
  ],
  "note": "우선순위를 주면 큰 숫자부터 해제됩니다. 초기화는 작은 숫자부터라서, 숫자 하나로 먼저 켜진 것이 나중에 꺼집니다."
}
</script>
</x-flow>

해제를 부르는 것은 컨테이너마다 붙는 커널의 `OnDestroy` 입니다. 실제 `SceneContext` 씬에서 `A`, `Boom`, `C` 를 넣고 `Boom.Dispose()` 가 던지게 한 뒤 언로드하자, 불린 것은 `C` 와 `Boom` 뿐이었고 같은 `OnDestroy` 의 다음 줄인 `LateDispose()` 도 불리지 않았습니다. 콘솔에는 `Boom` 의 예외 한 건만 찍혔습니다(에디터, Mono·IL2CPP 개발 빌드 플레이어 모두).

## 씬이 여럿이면 해제 순서는 설정이 정한다

해제 시점은 커널이 붙은 게임오브젝트가 파괴되는 시점이고, 그 순서는 유니티가 정합니다. 씬 A, B 를 가산 로드하고 `Application.Quit()` 을 부른 새 프로세스를 설정마다 10번씩 띄웠습니다(Mono·IL2CPP 개발 빌드 플레이어).

| `EnsureDeterministicDestructionOrderOnApplicationQuit` | 10회 결과 (Mono, IL2CPP 같음) |
| --- | --- |
| 끔 (기본) | 순서 5가지. `ProjectContext` 가 씬보다 먼저 파괴된 회차 4회 |
| 켬 | 10회 모두 B, A, `ProjectContext` |

켜면 `ProjectKernel.OnApplicationQuit` 이 로드한 역순으로 `SceneContext` 들을 파괴하고 마지막에 `ProjectContext` 를 파괴합니다. 씬 전환 때는 `ZenjectSceneLoader.LoadScene` 을 `LoadSceneMode.Single` 로 부르면 같은 역순을 보장합니다.

## 주의할 점

### 1. IDisposable 은 계약으로 바인딩한다

**안 지키면 생기는 문제.** `Bind<SaveFile>().AsSingle()` 처럼 구체 타입만 계약으로 걸면 `IDisposable` 을 구현해도 해제 목록에 들어가지 않습니다. 씬이 바뀔 때마다 파일 핸들이나 구독이 남고, 예외도 경고도 없습니다.

**이렇게 쓴다.** 수명이 있는 서비스는 `BindInterfacesAndSelfTo<T>()` 로 바인딩합니다. 구체 타입으로만 받고 싶으면 `Bind(typeof(SaveFile), typeof(IDisposable)).To<SaveFile>().AsSingle()` 처럼 계약에 함께 적습니다.

### 2. 자식마다 하나가 필요하면 CopyIntoAllSubContainers 를 붙인다

**안 지키면 생기는 문제.** `ProjectContext` 에 `Bind<Health>().AsSingle()` 만 해 두면 모든 `GameObjectContext` 가 부모로 올라가 같은 `Health` 하나를 받습니다. 적 하나가 맞으면 모든 적의 체력이 깎입니다. `MoveInto` 계열을 쓰면 부모에서는 그 타입이 없어져 부모 쪽 조회가 예외를 냅니다.

**이렇게 쓴다.** 컨테이너마다 자기 인스턴스가 필요하면 `AsSingle().CopyIntoAllSubContainers()` 를 쓰거나, 더 단순하게 그 타입을 `GameObjectContext` 쪽 설치 파일에서 바인딩합니다. 자식이 많으면 문장 수만큼 확정이 반복되니 물려주는 문장은 적게 둡니다.

### 3. Dispose 안에서 예외를 던지지 않는다

**안 지키면 생기는 문제.** 하나가 던지면 해제가 멈춥니다. 역순이라 먼저 등록한 것, 대개 가장 밑바닥 서비스가 정리되지 않고, 같은 컨테이너의 `LateDispose` 도 불리지 않습니다. 불리지 못한 쪽은 로그를 남기지 않습니다.

**이렇게 쓴다.** `Dispose()` 본문은 실패할 수 있는 일을 스스로 `try/catch` 로 감싸고 로그만 남깁니다. 실패가 흔한 정리는 별도의 종료 흐름에서 먼저 끝냅니다.

### 4. 씬이 여럿이면 해제 순서를 설정으로 고정한다

**안 지키면 생기는 문제.** 설정을 끈 채 종료하면 부모인 `ProjectContext` 가 씬보다 먼저 파괴되는 회차가 흔했습니다. 씬 서비스의 `Dispose` 가 전역 서비스를 쓰면 그 회차에만 이미 정리된 객체를 만지는, 재현이 들쭉날쭉한 종료 버그가 됩니다.

**이렇게 쓴다.** 씬 전환은 `ZenjectSceneLoader` 로 하고, `ProjectContext` 프리팹의 설정에서 `Ensure Deterministic Destruction Order On Application Quit` 을 켭니다. 그래도 `Dispose` 에서 다른 씬의 서비스를 부르는 구조는 피합니다.

## 정리

Zenject 의 **수명은 Context 계층이 정하고, 해제는 계약으로 건 `IDisposable` 만 그 Context 가 파괴될 때 역순으로 합니다.**

- `ProjectContext` → `SceneContext` → `GameObjectContext` 로 부모가 이어지고, `GameObjectContext` 는 부모가 주입할 때 시작합니다.
- "자식마다 하나" 는 바인딩 문장을 자식에서 다시 확정하는 것이라, 자식 하나를 만들 때마다 문장당 수 µs 를 다시 냅니다.
- 해제는 하나가 던지면 멈추고, 씬이 여럿일 때 순서는 설정을 켜야 고정됩니다.