---
title: "Zenject 의 Provider 는 인스턴스를 먼저 내주고 주입은 나중에 한다"
description: "Zenject 에서 필드끼리 서로를 가리키면 풀리고 생성자끼리 가리키면 예외가 나는 이유는 Provider 계약에 있습니다. 인스턴스를 캐시에 먼저 넣고 멤버 주입은 따로 돌려주므로, 필드 순환의 상대는 주입 도중 덜 채워져 있습니다."
author: "유니얼"
pubDate: 2026-09-27
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "DI"]
canonical: https://blog.unialgames.com/posts/unity-zenject-provider/
---
```csharp
class A { [Inject] B b; }      class B { [Inject] A a; }      // 된다
class C { public C(D d) {} }   class D { public D(C c) {} }   // Found circular dependency
```

넷 다 `AsSingle` 로 바인딩했는데 필드로 서로를 가리키는 쪽은 만들어지고, 생성자로 가리키는 쪽은 예외가 납니다. 그리고 된다던 쪽도 `[Inject]` 메서드에서 상대의 필드를 읽으면 `null` 을 봅니다.

Zenject 의 **Provider 는 인스턴스를 먼저 내놓고 멤버 주입은 따로 돌려줍니다.** 싱글턴 캐시는 그 사이에 인스턴스를 저장하므로 필드 순환은 풀리고, 생성자 순환은 저장할 인스턴스가 아직 없어 막힙니다.

## 모든 바인딩은 Provider 하나가 되고, 주입은 따로 돌려준다

확정된 바인딩은 전부 `IProvider` 하나가 됩니다. 핵심 메서드의 모양이 이 글의 전부입니다.

```csharp
// Providers/IProvider.cs
void GetAllInstancesWithInjectSplit(
    InjectContext context, List<TypeValuePair> args, out Action injectAction, List<object> instances);
```

인스턴스는 목록에 넣고, 그 인스턴스의 필드·프로퍼티·`[Inject]` 메서드를 채우는 일은 `injectAction` 람다로 따로 돌려줍니다. 보통 조회는 돌아오자마자 람다를 실행하므로 쓰는 쪽에서는 나뉜 것이 보이지 않습니다.

| 바인딩 | Provider | 하는 일 |
| --- | --- | --- |
| `Bind<T>()`, `To<U>()` | `TransientProvider` | 생성자까지 부르고 멤버 주입은 람다로 |
| `FromInstance(x)` | `InstanceProvider` | 받은 객체를 그대로 |
| `FromMethod(...)` | `MethodProvider<T>` | 메서드 결과를 그대로, 주입 없음 |
| `FromComponentInNewPrefab(p)` | 프리팹 Provider | 꺼진 복제본을 만들고, 주입 뒤 켠다 |
| 위의 것 + `AsSingle`, `AsCached` | `CachedProvider` 로 감쌈 | 첫 결과를 저장해 두고 계속 내줌 |

`TransientProvider` 의 람다는 조회마다 새로 만들어지는 클로저입니다. 빈 클래스를 `AsTransient` 로 한 번 `Resolve` 하면 객체 16 B 에 더해 Mono 개발 빌드 플레이어에서 200 B, IL2CPP 에서 232 B 가 할당됐고, 캐시에 적중한 싱글턴은 0 B 였습니다.

## 캐시에 먼저 넣어서 필드 순환은 풀린다

`AsSingle` 과 `AsCached` 의 정체인 `CachedProvider` 는 안쪽 Provider 가 돌려준 `injectAction` 을 부르지 않고 위로 넘깁니다. 그 전에 인스턴스를 캐시에 넣고 "만드는 중" 표시를 끕니다.

```csharp
// Providers/CachedProvider.cs (줄임)
if (_instances != null) { injectAction = null; buffer.AllocFreeAddRange(_instances); return; }
if (_isCreatingInstance) throw Assert.CreateException("Found circular dependency when creating type '{0}'. ...");
_isCreatingInstance = true;
_creator.GetAllInstancesWithInjectSplit(context, args, out injectAction, instances);
_instances = instances;            // 주입(injectAction)이 돌기 전에 저장
_isCreatingInstance = false;
```

<x-flow>
<script type="application/json">
{
  "label": "CachedProvider 가 A 와 B 의 순환을 만나는 순서 (줄인 코드)",
  "lines": [
    "CachedProvider.GetAllInstancesWithInjectSplit(...)",
    "    if (_instances != null) return 캐시;",
    "    if (_isCreatingInstance) throw \"Found circular dependency\";",
    "    _isCreatingInstance = true;",
    "    TransientProvider: 생성자 호출 (생성자 인자는 여기서 조회)",
    "    _instances = [새 객체];  _isCreatingInstance = false;",
    "호출한 쪽: injectAction()   // 필드 · 프로퍼티 · [Inject] 메서드"
  ],
  "dwell": 1.8,
  "cases": [
    {
      "label": "필드끼리 가리킬 때",
      "state": "class A { [Inject] B b; } · class B { [Inject] A a; } · 둘 다 AsSingle",
      "path": [
        { "line": 0, "why": "누군가 A 를 요구합니다. A 의 CachedProvider 로 들어옵니다.", "frames": ["Resolve<A>"], "state": "A 캐시 = 없음 · B 캐시 = 없음" },
        { "line": 1, "verdict": "false" },
        { "line": 2, "verdict": "false" },
        { "line": 3, "state": "A = 만드는 중 · B 캐시 = 없음" },
        { "line": 4, "why": "A 의 생성자는 인자가 없습니다. 객체만 생깁니다." },
        { "line": 5, "why": "주입하기 전에 a 를 캐시에 넣습니다. 필드 b 는 아직 null 입니다.", "state": "A 캐시 = [a] (a.b = null) · B 캐시 = 없음" },
        { "line": 6, "why": "a 의 필드 b 를 채우려고 B 를 조회합니다.", "frames": ["Resolve<A>", "a 주입", "Resolve<B>"] },
        { "line": 0, "why": "이번에는 B 의 CachedProvider 입니다." },
        { "line": 1, "verdict": "false" },
        { "line": 2, "verdict": "false" },
        { "line": 3, "state": "A 캐시 = [a] · B = 만드는 중" },
        { "line": 4 },
        { "line": 5, "state": "A 캐시 = [a] · B 캐시 = [b] (b.a = null)" },
        { "line": 6, "why": "b 의 필드 a 를 채우려고 A 를 다시 조회합니다.", "frames": ["Resolve<A>", "a 주입", "Resolve<B>", "b 주입", "Resolve<A>"] },
        { "line": 0 },
        { "line": 1, "verdict": "true", "why": "a 는 이미 캐시에 있습니다. 주입이 덜 끝난 a 를 그대로 돌려줍니다.", "out": "b.a = a" },
        { "line": 6, "why": "b 의 주입이 끝나고 a 의 주입으로 돌아와 필드를 채웁니다.", "frames": ["Resolve<A>"], "state": "a.b = b · b.a = a", "out": "a.b = b" }
      ],
      "note": "b 가 주입되는 동안 b.a 가 가리키는 a 는 아직 b 를 받기 전입니다."
    },
    {
      "label": "생성자끼리 가리킬 때",
      "state": "class A { A(B b) } · class B { B(A a) } · 둘 다 AsSingle",
      "path": [
        { "line": 0, "frames": ["Resolve<A>"], "state": "A 캐시 = 없음 · B 캐시 = 없음" },
        { "line": 1, "verdict": "false" },
        { "line": 2, "verdict": "false" },
        { "line": 3, "state": "A = 만드는 중" },
        { "line": 4, "why": "A 의 생성자를 부르기 전에 인자 B 부터 조회합니다.", "frames": ["Resolve<A>", "new A 의 인자", "Resolve<B>"] },
        { "line": 0, "why": "B 의 CachedProvider 입니다." },
        { "line": 1, "verdict": "false" },
        { "line": 2, "verdict": "false" },
        { "line": 3, "state": "A = 만드는 중 · B = 만드는 중" },
        { "line": 4, "why": "B 의 생성자 인자 A 를 조회합니다.", "frames": ["Resolve<A>", "new A 의 인자", "Resolve<B>", "new B 의 인자", "Resolve<A>"] },
        { "line": 0 },
        { "line": 1, "verdict": "false", "why": "A 는 아직 생성자 안에 있어 캐시에 없습니다." },
        { "line": 2, "verdict": "true", "out": "Found circular dependency when creating type 'A'" }
      ],
      "skip": [5, 6],
      "note": "생성자는 인자가 다 모여야 불리므로, 캐시에 먼저 넣을 틈이 없습니다."
    }
  ],
  "note": "캐시에 넣는 줄이 주입 람다보다 앞에 있어서 필드 순환은 풀리고, 생성자 순환은 만드는 중 표시에 걸립니다."
}
</script>
</x-flow>

생성자 순환은 설치와 확정이 조용히 지나간 뒤, 실제로 만드는 `Resolve` 에서 예외가 납니다.

## 필드 순환의 상대는 아직 덜 채워져 있다

필드 순환에서 `b` 가 주입되는 동안 `b.a` 가 가리키는 `a` 는 캐시에만 있고 `b` 를 받기 전입니다. `A` 와 `B` 에 서로를 가리키는 `[Inject]` 필드와 `[Inject] void Init()` 을 대칭으로 두고 재 봤습니다.

| 먼저 꺼낸 것 | 먼저 불린 `Init` | 그다음 `Init` | 예외 |
| --- | --- | --- | --- |
| `Resolve<A>()` | `B.Init`: `a.b == null` 이 `True` | `A.Init`: `b.a == null` 이 `False` | 없음 |
| `Resolve<B>()` | `A.Init`: `b.a == null` 이 `True` | `B.Init`: `a.b == null` 이 `False` | 없음 |

나중에 요구된 쪽의 `[Inject]` 메서드가 먼저 돌고, 그때 상대의 필드는 비어 있습니다. 어느 쪽을 먼저 꺼내느냐에 따라 `null` 을 보는 쪽이 바뀝니다.

## 프리팹은 꺼진 채 만들고 주입한 뒤 켠다

게임오브젝트를 만드는 Provider 는 주입 람다에 켜기를 하나 더 담습니다. 복제본을 꺼진 채 만들고, 주입이 끝난 뒤 켜서 `Awake` 와 `OnEnable` 이 주입 뒤에 돌게 합니다.

<x-flow>
<script type="application/json">
{
  "label": "프리팹 Provider 가 게임오브젝트를 내주는 순서 (줄인 코드)",
  "lines": [
    "var go = CreateAndParentPrefab(prefab, ..., out shouldMakeActive);",
    "injectAction = () => {",
    "    InjectGameObject(go);                 // 모든 컴포넌트에 주입",
    "    if (shouldMakeActive) go.SetActive(true);",
    "};",
    "return go;",
    "buffer.Add(go.GetComponentInChildren(type, includeInactive: true));"
  ],
  "cases": [
    {
      "label": "켜져 있는 프리팹",
      "state": "prefab.activeSelf = true",
      "path": [
        { "line": 0, "why": "원본이 켜져 있었다는 것을 기억해 두고 꺼진 복제본을 만듭니다. 이 복제에서는 Awake 가 돌지 않습니다.", "state": "go.activeSelf = false · shouldMakeActive = true" },
        { "line": 1, "why": "주입과 켜기는 람다에 담아 둡니다." },
        { "line": 5 },
        { "line": 6, "why": "꺼진 오브젝트에서 찾아야 하므로 includeInactive 를 켜고 컴포넌트를 찾습니다." },
        { "line": 2, "why": "조회한 쪽이 람다를 부릅니다. 복제본의 모든 컴포넌트에 [Inject] 멤버를 채웁니다." },
        { "line": 3, "verdict": "true", "state": "go.activeSelf = true", "out": "Awake, OnEnable 이 주입 뒤에 돈다" }
      ]
    },
    {
      "label": "꺼져 있는 프리팹",
      "state": "prefab.activeSelf = false",
      "path": [
        { "line": 0, "state": "go.activeSelf = false · shouldMakeActive = false" },
        { "line": 1 },
        { "line": 5 },
        { "line": 6 },
        { "line": 2 },
        { "line": 3, "verdict": "false", "state": "go.activeSelf = false", "out": "주입은 끝났지만 꺼진 채로 남는다" }
      ]
    }
  ],
  "note": "원본의 켜짐 상태를 그대로 따릅니다. 켜진 프리팹은 주입이 끝난 뒤에야 켜집니다."
}
</script>
</x-flow>

컨테이너로 만든 프리팹의 컴포넌트는 자기 `Awake` 에서 이미 `[Inject]` 필드를 쓸 수 있습니다.

## 주의할 점

### 1. 생성자 순환은 그 경로를 처음 만들 때까지 조용하다

**안 지키면 생기는 문제.** 생성자로 서로를 요구하는 두 클래스를 바인딩해도 설치는 통과합니다. `NonLazy` 가 아니고 특정 메뉴에서만 쓰는 객체라면, 그 메뉴를 처음 연 사용자가 `Found circular dependency when creating type` 을 만납니다.

**이렇게 쓴다.** 실행 전에 **Edit > Zenject > Validate Current Scenes** 로 그래프를 한 번 풀어 봅니다. 순환이 설계상 필요하면 한쪽을 필드나 `[Inject]` 메서드 주입으로 바꾸거나, 사이에 인터페이스나 이벤트를 두어 방향을 하나로 만듭니다.

### 2. 순환하는 두 객체의 [Inject] 메서드에서 상대의 상태를 읽지 않는다

**안 지키면 생기는 문제.** 필드 순환은 풀리지만, 나중에 요구된 쪽의 `[Inject]` 메서드가 돌 때 상대는 캐시에만 있고 주입 전입니다. 어느 쪽을 먼저 조회하느냐에 따라 `null` 을 보는 쪽이 바뀌어, 씬 구성을 조금 바꿨을 뿐인데 초기화가 깨집니다.

**이렇게 쓴다.** `[Inject]` 메서드에서는 참조를 저장만 합니다. 상대를 써야 하는 초기화는 `IInitializable.Initialize` 로 옮깁니다. 그 시점에는 주입이 모두 끝나 있습니다.

### 3. FromInstance 로 넣은 객체는 알아서 주입되지 않는다

**안 지키면 생기는 문제.** `InstanceProvider` 는 주입 대기열에 있는 객체만 주입합니다. 대기열에 들어가는 것은 Context 가 설치 전에 모은 씬 오브젝트와 `QueueForInject` 로 넣은 객체뿐이라, 코드에서 `new` 로 만든 객체를 `BindInstance(obj)` 로 넣으면 그 `[Inject]` 필드는 예외 없이 비어 있습니다.

**이렇게 쓴다.** 주입이 필요한 객체는 `Bind<T>().AsSingle()` 로 컨테이너가 만들게 합니다. 이미 만든 객체를 넣어야 하면 설치 파일에서 `Container.QueueForInject(obj)` 를 함께 부르고, 설치가 끝난 뒤라면 `Container.Inject(obj)` 로 채웁니다.

## 정리

Zenject 의 **Provider 는 인스턴스를 먼저 내놓고 멤버 주입은 `injectAction` 으로 따로 돌려줍니다.**

- `CachedProvider` 가 주입 전에 인스턴스를 저장하므로 필드 순환은 풀리고, 생성자 순환은 "만드는 중" 표시에 걸려 처음 만들 때 예외가 납니다.
- 필드 순환에서 나중에 요구된 쪽의 `[Inject]` 메서드는 상대의 빈 필드를 봅니다.
- 이 분리 덕에 프리팹은 꺼진 채 주입받고 켜지지만, `Transient` 조회마다 클로저가 할당되고(플레이어 200~232 B) `FromInstance` 객체는 대기열에 없으면 주입되지 않습니다.