---
title: "Zenject 는 기능을 다 갖춘 채 new 로 잇던 조립을 대신한다"
description: "싱글턴과 FindObjectOfType 은 의존을 숨기고, new 로 조립하면 MonoBehaviour 에 넘길 길이 없습니다. Zenject 는 유니티에 DI 가 없던 때 기능을 다 갖춘 쪽으로 나와, 설치 파일의 바인딩으로 씬 시작 때 조립과 주입을 끝냅니다."
author: "유니얼"
pubDate: 2026-09-27
updatedDate: 2026-09-30
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "DI"]
canonical: https://blog.unialgames.com/posts/unity-zenject-why/
---
```csharp
public class ScoreLabel : MonoBehaviour
{
    void Awake()
    {
        ScoreService.Instance.Add(0);   // Awake 순서가 어긋나면 Instance 가 아직 null
    }
}
```

점수 서비스 하나를 여러 곳에서 쓰려면 정적 필드에 걸거나, `FindObjectOfType` 으로 찾거나, 한 곳에서 `new` 로 만들어 생성자로 넘겼습니다. 정적 필드는 `Awake` 순서에 걸리고, `new` 로 조립하면 유니티가 만드는 `MonoBehaviour` 에는 넘길 길이 없습니다.

Zenject 는 **`new` 로 하던 조립을 설치 파일의 바인딩 문장으로 바꾸고, 씬이 시작될 때 컨테이너가 조립과 주입을 대신 끝내는** 유니티용 의존성 주입 프레임워크입니다. 유니티에 DI 프레임워크가 없던 때 나와서, 설계는 강요하지 않고 쓸 만한 기능을 전부 넣어 두는 쪽을 골랐습니다. 기준 판본은 master `31f06cf`(2021-04-06, 9.1.0)이고, 그 뒤로 새 커밋이 없습니다.

## 조립은 컨테이너가 하고 의존은 선언에 드러난다

같은 일을 네 방식으로 나란히 놓으면 차이는 "누가 잇느냐" 와 "의존이 어디에 보이느냐" 입니다.

<x-compare>
<script type="application/json">
{
  "label": "점수 서비스 하나를 두 곳에서 쓰기",
  "tabs": [
    {
      "label": "싱글턴 정적 필드",
      "code": "public class ScoreService : MonoBehaviour\n{\n    public static ScoreService Instance;\n    void Awake() => Instance = this;\n    public void Add(int v) { ... }\n}\n\n// 쓰는 쪽 어디서나\nScoreService.Instance.Add(10);",
      "stats": [["누가 잇나", "쓰는 쪽이 전역 필드를 직접 읽는다"], ["의존이 보이는 곳", "메서드 본문. 선언부만 봐서는 모른다"], ["테스트에서 바꾸기", "정적 필드를 갈아 끼워야 한다"], ["흔한 실패", "Awake 순서가 어긋나 Instance 가 아직 null"]],
      "tone": "bad"
    },
    {
      "label": "FindObjectOfType",
      "code": "public class ScoreLabel : MonoBehaviour\n{\n    ScoreService _score;\n\n    void Start()\n    {\n        _score = FindObjectOfType<ScoreService>();\n    }\n}",
      "stats": [["누가 잇나", "쓰는 쪽이 씬을 훑어서 찾는다"], ["찾을 수 있는 것", "씬에 올라간 UnityEngine.Object 파생 타입"], ["테스트에서 바꾸기", "가짜를 씬에 올려 두어야 한다"], ["흔한 실패", "같은 타입이 둘이면 어느 것이 나올지 코드에 드러나지 않는다"]],
      "tone": "bad"
    },
    {
      "label": "손으로 쓴 생성자 주입",
      "code": "// 조립하는 한 곳\nvar score = new ScoreService();\nvar hud   = new HudPresenter(score);\nvar flow  = new GameFlow(score, hud);\n\npublic class GameFlow\n{\n    public GameFlow(ScoreService score, HudPresenter hud) { ... }\n}",
      "stats": [["누가 잇나", "조립 코드 한 곳"], ["의존이 보이는 곳", "생성자 선언"], ["테스트에서 바꾸기", "가짜를 new 로 만들어 넘긴다"], ["막히는 곳", "MonoBehaviour 는 new 로 만들 수 없어 넘길 길을 따로 뚫어야 한다"]]
    },
    {
      "label": "Zenject",
      "code": "public class GameInstaller : MonoInstaller\n{\n    public override void InstallBindings()\n    {\n        Container.Bind<ScoreService>().AsSingle();\n        Container.Bind<HudPresenter>().AsSingle();\n        Container.Bind<GameFlow>().AsSingle().NonLazy();\n    }\n}\n\npublic class ScoreLabel : MonoBehaviour\n{\n    [Inject] ScoreService _score;\n}",
      "stats": [["누가 잇나", "컨테이너. 설치 파일은 무엇을 어떻게 만들지만 적는다"], ["만드는 순서", "생성자 인자를 읽고 컨테이너가 정한다"], ["MonoBehaviour", "씬에 놓인 것은 씬이 시작될 때 [Inject] 멤버가 채워진다"], ["흔한 실패", "빠진 바인딩은 처음 조회할 때 예외. 검증 메뉴로 실행 전에 볼 수 있다"]],
      "tone": "good"
    }
  ]
}
</script>
</x-compare>

손 조립이 가장 정직하지만 객체가 늘수록 조립 코드가 길어지고 `MonoBehaviour` 에서 막힙니다. Zenject 는 그 조립 코드를 바인딩 문장으로 바꿔 기계에 맡기고, 생성자로 못 받는 컴포넌트에는 `[Inject]` 멤버로 넣습니다.

## 유니티에 DI 가 없던 때 기능을 다 갖춘 쪽으로 나왔다

README 의 내력 절은 동기를 짧게 적습니다. 유니티가 권하는 방식은 크고 유연한 코드베이스를 짜기에 맞지 않고, 게임 컴포넌트 사이 의존을 다루는 기본 방법이 어색하고 틀리기 쉽다는 것. 그리고 **그때 유니티용 DI 프레임워크가 하나도 없어서** 유니티 밖에서 DI 프레임워크를 써 본 저자가 만들었다는 것입니다.

설계 철학은 **아무것도 강요하지 않는다**입니다. 가정은 객체 지향으로 짠다는 것 하나뿐이고, 기능은 많지만 전부 골라 쓰는 것입니다. 모든 클래스를 `MonoBehaviour` 로 짜고 `[SerializeField]` 자리에 `[Inject]` 만 써도 되고, `MonoBehaviour` 를 버리고 `ITickable`, `IInitializable` 같은 인터페이스로 흐름을 짜도 됩니다. README 의 기능 목록을 묶으면 이렇습니다.

| 묶음 | README 가 꼽는 기능 |
| --- | --- |
| 주입 | 일반 클래스와 `MonoBehaviour` 모두, 생성자 · 필드 · 속성 · 메서드 주입 |
| 바인딩 | 타입과 이름에 따른 조건부 바인딩, 선택적 의존, 규칙 기반 바인딩, 데코레이터, 열린 제네릭 |
| 컨테이너 구조 | 하위 컨테이너, 씬 부모, 프로젝트 전역 바인딩, 씬 사이 데이터 전달 |
| 실행 중 생성 | 팩토리, 메모리 풀, `ZenAutoInjecter` |
| 검증과 테스트 | 에디터에서 객체 그래프 검증, 단위 · 통합 · 씬 테스트, Moq 자동 목 |
| 비용 줄이기 | 빌드된 어셈블리를 고쳐 리플렉션을 없애는 리플렉션 베이킹 |

대가도 README 가 직접 적습니다. 정해진 길이 없으니 새로 온 개발자는 앞사람이 고른 구조부터 익혀야 합니다. 그리고 이 기능들을 받치는 단계를 쓰지 않는 조회도 매번 지나갑니다. 이미 만든 싱글턴 하나를 꺼내는 데 Mono 개발 빌드 플레이어 1,161 ns, IL2CPP 958 ns 가 들었고, 그중 딕셔너리 조회는 100 ns 안팎이었습니다.

## 설치와 주입은 SceneContext 의 Awake 안에서 끝난다

씬에는 `SceneContext` 가 하나 놓입니다. 이 컴포넌트의 실행 순서가 `.meta` 에 `-9999` 로 적혀 있어 같은 씬의 다른 스크립트보다 먼저 `Awake` 가 돌고, 그 한 번 안에서 컨테이너를 만들고 설치하고 주입까지 끝냅니다.

<x-wiring>
<script type="application/json">
{
  "label": "SceneContext 의 Awake 한 번에 일어나는 일",
  "width": 720,
  "height": 340,
  "nodes": [
    { "id": "scene", "title": "씬 로드", "sub": "유니티가 Awake 를 부른다", "x": 0, "y": 8, "aside": true },
    { "id": "sc", "title": "SceneContext", "sub": "실행 순서 -9999", "x": 270, "y": 8 },
    { "id": "pc", "title": "ProjectContext", "sub": "Resources 의 프리팹", "x": 570, "y": 8 },
    { "id": "inst", "title": "MonoInstaller", "sub": "InstallBindings()", "x": 0, "y": 140 },
    { "id": "cont", "title": "DiContainer", "sub": "이 씬의 컨테이너", "x": 270, "y": 140 },
    { "id": "mb", "title": "씬의 MonoBehaviour", "sub": "[Inject] 멤버", "x": 570, "y": 272 },
    { "id": "kernel", "title": "SceneKernel", "sub": "실행 순서 -9997", "x": 270, "y": 272 }
  ],
  "edges": [
    { "from": "scene", "to": "sc", "label": "Awake", "bend": 0 },
    { "from": "sc", "to": "pc", "label": "ProjectContext.Instance", "bend": 0 },
    { "from": "pc", "to": "cont", "label": "부모 컨테이너", "bend": 0 },
    { "from": "sc", "to": "mb", "label": "주입 대기열에 넣기", "bend": -30 },
    { "from": "sc", "to": "inst", "label": "InstallBindings", "bend": 0 },
    { "from": "inst", "to": "cont", "label": "Bind 문장", "bend": 0 },
    { "from": "sc", "to": "cont", "label": "ResolveRoots", "bend": 0 },
    { "from": "cont", "to": "mb", "label": "필드 · 메서드 주입", "bend": 0 },
    { "from": "cont", "to": "kernel", "label": "NonLazy 로 생성", "bend": 0 }
  ],
  "steps": [
    { "edge": 0, "why": "씬이 로드되면 실행 순서가 가장 앞인 SceneContext 의 Awake 가 먼저 돕니다." },
    { "edge": 1, "why": "ProjectContext.Instance 를 부릅니다. 처음이면 Resources 폴더의 ProjectContext 프리팹을 만들고 그쪽 설치부터 끝냅니다." },
    { "edge": 2, "why": "프로젝트 컨테이너를 부모로 삼아 이 씬의 컨테이너를 새로 만듭니다. 부모의 바인딩은 자식에서 보입니다." },
    { "edge": 3, "why": "설치하기 전에 씬 안의 주입 대상 MonoBehaviour 를 전부 모아 대기열에 넣습니다. 아직 주입하지 않습니다." },
    { "edge": 4, "why": "인스펙터의 Installers 목록에 넣은 MonoInstaller 마다 InstallBindings 를 부릅니다." },
    { "edge": 5, "why": "Bind 문장은 컨테이너의 큐에 쌓이고, 다음 문장이 시작될 때 하나씩 확정됩니다. 객체는 아직 하나도 만들어지지 않습니다." },
    { "edge": 6, "why": "설치가 끝나면 ResolveRoots 를 부릅니다. 남은 문장을 확정하고 NonLazy 가 붙은 바인딩부터 실제로 만듭니다." },
    { "edge": 7, "why": "대기열의 MonoBehaviour 에 필드, 프로퍼티, [Inject] 메서드를 채웁니다." },
    { "edge": 8, "why": "NonLazy 로 붙어 있던 SceneKernel 컴포넌트가 이 단계에서 만들어집니다. 이 커널이 Start 에서 IInitializable 을, Update 에서 ITickable 을 부릅니다." }
  ],
  "note": "설치와 주입이 전부 SceneContext.Awake 안에서 끝납니다. 진입점 인터페이스를 부르는 것만 커널의 Start 와 Update 입니다."
}
</script>
</x-wiring>

그래서 실행 순서 0 인 컴포넌트는 자기 `Awake` 에서 이미 주입된 필드를 씁니다. 아래 예제의 `ScoreLabel` 에 `Awake` 를 더해 `_score == null` 을 찍자 `False` 였습니다.

## 가장 짧은 예제

생성자로 받는 일반 클래스와 필드로 받는 씬 컴포넌트를 함께 둡니다.

```csharp
using UnityEngine;
using Zenject;

public class ScoreService
{
    public int Score { get; private set; }
    public void Add(int v) => Score += v;
}

public class GameFlow : IInitializable
{
    readonly ScoreService _score;
    public GameFlow(ScoreService score) => _score = score;   // 생성자 주입
    public void Initialize() => _score.Add(10);              // SceneKernel 의 Start 에서 불린다
}

public class ScoreLabel : MonoBehaviour
{
    [Inject] ScoreService _score;                             // 필드 주입
    void Start() => Debug.Log($"점수 {_score.Score}");
}

public class GameInstaller : MonoInstaller
{
    public override void InstallBindings()
    {
        Container.Bind<ScoreService>().AsSingle();
        Container.BindInterfacesAndSelfTo<GameFlow>().AsSingle();
    }
}
```

씬에 **GameObject > Zenject > Scene Context** 로 `SceneContext` 를 만들고, `GameInstaller` 를 붙인 뒤 인스펙터의 **Installers** 목록에 넣고, 아무 오브젝트에 `ScoreLabel` 을 붙입니다.

- `Bind<ScoreService>().AsSingle()` 은 "요구하면 하나를 만들어 두고 계속 그것을 준다" 입니다.
- `BindInterfacesAndSelfTo<GameFlow>()` 는 `GameFlow` 와 그것이 구현한 `IInitializable` 을 한 문장에 묶습니다. 커널이 `IInitializable` 목록을 모을 때 `GameFlow` 가 만들어집니다.
- `ScoreLabel` 은 설치 전에 대기열에 들어가 `ResolveRoots` 에서 채워집니다.

로그는 `점수 10` 입니다. `SceneKernel` 의 실행 순서가 `-9997` 이라 `Initialize` 를 부르는 커널의 `Start` 가 `ScoreLabel.Start` 보다 먼저 돕니다.

## 주의할 점

### 1. 고쳐 줄 원본이 없다는 것을 전제로 둔다

**안 지키면 생기는 문제.** master 의 마지막 커밋은 2021-04-06 입니다. 새 유니티 판에서 버그를 만나도 원본에서 고친 판이 나오지 않습니다. 소스를 복사해 넣은 프로젝트에서 누군가 기록 없이 한 줄을 고치면 프로젝트의 Zenject 가 9.1.0 이 아닌 무언가가 됩니다.

**이렇게 쓴다.** 넣을 때 기준 커밋(`31f06cf`)과 뺀 `OptionalExtras` 폴더를 기록합니다. 사본을 고쳐야 하면 그 수정만 따로 커밋해 원본과의 차이를 언제든 볼 수 있게 합니다.

### 2. 실행 중에 Instantiate 한 프리팹은 주입되지 않는다

**안 지키면 생기는 문제.** 씬 시작 때 주입되는 것은 `SceneContext` 가 설치 전에 모아 둔 씬 오브젝트뿐입니다. 실행 중에 `GameObject.Instantiate` 로 만든 프리팹의 `[Inject]` 필드는 아무도 채우지 않고, 예외 없이 `null` 로 돌다가 나중에 `NullReferenceException` 으로 드러납니다.

**이렇게 쓴다.** 실행 중에 만드는 프리팹은 주입받은 `DiContainer` 의 `InstantiatePrefab` 이나 팩토리로 만듭니다. 프리팹에 `ZenAutoInjecter` 컴포넌트를 붙이는 방법도 있습니다.

### 3. 아무도 요구하지 않는 바인딩은 만들어지지 않는다

**안 지키면 생기는 문제.** `ResolveRoots` 가 미리 만드는 것은 `NonLazy` 바인딩뿐입니다. 시작할 때 일을 하려고 `Bind<Greeter>().AsSingle()` 만 적으면, 아무도 `Greeter` 를 요구하지 않는 한 생성자가 한 번도 불리지 않습니다.

**이렇게 쓴다.** 시작할 때 할 일은 `IInitializable.Initialize` 에 두고 `BindInterfacesAndSelfTo` 로 바인딩합니다. 생성 자체가 목적이면 `.NonLazy()` 를 붙입니다.

## 정리

Zenject 는 **`new` 로 하던 조립을 바인딩 문장으로 바꾸고, `SceneContext.Awake` 안에서 컨테이너가 조립과 주입을 끝내는** 프레임워크이고, 필요할 만한 기능을 전부 갖춰 두었습니다.

- 싱글턴 정적 필드와 `FindObjectOfType` 이 숨기던 의존이 생성자와 `[Inject]` 멤버로 드러나고, `new` 로 못 만드는 컴포넌트에도 넣을 길이 생깁니다.
- 설계는 강요하지 않고 조건부 바인딩, 하위 컨테이너, 풀, 검증까지 다 넣었습니다. 그 대가로 쓰지 않는 기능의 단계도 조회마다 지나갑니다.
- 씬 하나의 설치와 주입은 실행 순서 `-9999` 인 `SceneContext.Awake` 에서 끝나고, 진입점은 `SceneKernel` 의 `Start` 와 `Update` 가 부릅니다.
- 실행 중에 만든 프리팹과 아무도 요구하지 않는 바인딩은 저절로 채워지거나 만들어지지 않습니다.