---
title: "Reflex 의 인스턴스는 컨테이너가 아니라 리졸버에 산다"
description: "같은 등록인데 자식 컨테이너에서 꺼내면 다른 객체가 나오기도 합니다. Reflex 는 조회를 자기 표 한 번으로 끝내고, 인스턴스는 부모와 자식이 공유하는 리졸버 안에 두며, 해제는 자식부터 만든 역순으로 합니다."
author: "유니얼"
pubDate: 2026-09-28
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "DI"]
canonical: https://blog.unialgames.com/posts/unity-reflex-container/
---
```csharp
var child = root.Scope(b => b.RegisterType(typeof(Bow), new[] { typeof(IWeapon) }, Lifetime.Singleton, Resolution.Lazy));

child.Resolve<IWeapon>();                          // Bow
child.All<IWeapon>();                              // [Sword, Bow]
child.Resolve<Clock>() == root.Resolve<Clock>();   // Scoped 라서 false
```

자식에서 꺼내면 자식 등록이, `All` 로 꺼내면 부모 것까지 나옵니다. 같은 `Clock` 등록인데 루트와 자식이 다른 객체를 받습니다.

Reflex 의 컨테이너는 **표 하나로 조회만 하고, 인스턴스는 부모와 자식이 공유하는 리졸버 안에 둡니다.** 수명은 리졸버가 인스턴스를 어디에 담느냐로 정해집니다. 기준 판본은 14.3.1 입니다.

## 조회는 자기 표를 한 번 찾고 끝난다

컨테이너의 조회 표는 `Dictionary<Type, List<IResolver>>` 하나입니다. 자식은 빌드할 때 부모 표를 칸마다 복사해 두므로, 조회할 때 부모 쪽으로 올라가지 않습니다.

<x-flow>
<script type="application/json">
{
  "label": "Container.Resolve(type) 가 지나는 길 (GetResolvers 를 펼친 코드)",
  "lines": [
    "public object Resolve(Type type)",
    "{",
    "    if (type.IsEnumerable(out var elementType))",
    "        return All(elementType).CastDynamic(elementType);",
    "    if (!ResolversByContract.TryGetValue(type, out var resolvers))",
    "        throw new UnknownContractException(type);",
    "    var lastResolver = resolvers.Last();",
    "    return lastResolver.Resolve(this);",
    "}"
  ],
  "cases": [
    {
      "label": "IWeapon",
      "state": "자식의 표: IWeapon = [Sword 리졸버, Bow 리졸버]",
      "path": [
        { "line": 2, "verdict": "false", "why": "IEnumerable<T> 인지부터 봅니다. 아닙니다." },
        { "line": 4, "verdict": "있음", "why": "자기 표에서 한 번 찾습니다. 부모의 표는 보지 않습니다." },
        { "line": 6, "why": "목록의 마지막, 곧 가장 나중에 등록된 Bow 리졸버입니다. List 라 인덱스로 바로 꺼냅니다." },
        { "line": 7, "why": "리졸버에게 부른 컨테이너(this)를 넘깁니다. 무엇을 어느 컨테이너로 만들지는 리졸버가 정합니다.", "out": "Bow" }
      ],
      "skip": [3, 5]
    },
    {
      "label": "IEnumerable<IWeapon>",
      "state": "자식의 표: IWeapon = [Sword 리졸버, Bow 리졸버]",
      "path": [
        { "line": 2, "verdict": "true", "why": "IEnumerable<IWeapon> 이라 원소 타입 IWeapon 을 꺼냅니다." },
        { "line": 3, "why": "All(IWeapon) 이 리졸버마다 Resolve 해 object 배열을 만들고, Enumerable.Cast<IWeapon> 을 런타임에 만들어 감쌉니다.", "out": "[Sword, Bow]" }
      ],
      "skip": [4, 5, 6, 7]
    },
    {
      "label": "등록 안 된 IShield",
      "state": "자식의 표에 IShield 칸이 없다",
      "path": [
        { "line": 2, "verdict": "false" },
        { "line": 4, "verdict": "없음", "why": "부모에 있었다면 복사돼 와 있었을 것입니다. 여기 없으면 어디에도 없습니다." },
        { "line": 5, "out": "UnknownContractException: Cannot resolve contract 'IShield'." }
      ],
      "skip": [3, 6, 7]
    }
  ],
  "note": "조회는 자기 표를 한 번 찾는 것으로 끝납니다. 부모를 따라 올라가는 반복이 없습니다."
}
</script>
</x-flow>

`IEnumerable<T>` 검사가 표를 보기 **전에** 있어서, 생성자 인자를 `IEnumerable<IManager>` 로 적으면 그 계약의 등록을 전부 받습니다. 그리고 표에는 `Container` 칸이 늘 **자기 자신**으로 채워져 있어, 생성자나 `[Inject]` 로 `Container` 를 받으면 자기를 만든 그 컨테이너가 들어옵니다.

## 수명은 리졸버가 인스턴스를 담는 방식이다

부모와 자식이 같은 리졸버 객체를 공유하니, 리졸버가 인스턴스를 어떻게 담느냐가 곧 수명입니다.

- **Singleton**: 필드 `_instance` 하나. 누가 불러도 하나입니다.
- **Scoped**: `ConditionalWeakTable<Container, object>` 에 **부른 컨테이너를 키로** 담습니다. 공유된 리졸버 하나가 컨테이너마다 다른 인스턴스를 듭니다.
- **Transient**: 담지 않고 매번 만듭니다.

<x-flow>
<script type="application/json">
{
  "label": "세 리졸버의 Resolve 를 여러 번 부를 때 (Diagnosis 호출은 뺐다)",
  "cases": [
    {
      "label": "Scoped: 루트 → A → A → B",
      "lines": [
        "public object Resolve(Container resolvingContainer)",
        "{",
        "    if (!_instances.TryGetValue(resolvingContainer, out var instance))",
        "    {",
        "        instance = resolvingContainer.Construct(_concreteType);",
        "        _instances.Add(resolvingContainer, instance);",
        "        resolvingContainer.Disposables.TryAdd(instance);",
        "    }",
        "    return instance;",
        "}"
      ],
      "state": "호출 1: 루트가 부른다 · _instances = { }",
      "path": [
        { "line": 2, "verdict": "없음" },
        { "line": 4, "why": "부른 컨테이너로 만듭니다. 생성자 인자도 루트 표에서 찾습니다." },
        { "line": 5, "state": "_instances = { 루트: #1 }" },
        { "line": 6, "why": "IDisposable 이면 부른 컨테이너의 해제 목록에 들어갑니다." },
        { "line": 8, "out": "루트 → #1", "state": "호출 2: 자식 A 가 부른다 · _instances = { 루트: #1 }" },
        { "line": 2, "verdict": "없음", "why": "A 를 키로 한 칸이 없습니다." },
        { "line": 4 },
        { "line": 5, "state": "_instances = { 루트: #1, A: #2 }" },
        { "line": 6 },
        { "line": 8, "out": "A → #2", "state": "호출 3: A 가 한 번 더 · _instances = { 루트: #1, A: #2 }" },
        { "line": 2, "verdict": "있음", "why": "A 칸이 있으니 만들지 않습니다." },
        { "line": 8, "out": "A → #2 (같은 것)", "state": "호출 4: 자식 B 가 부른다" },
        { "line": 2, "verdict": "없음" },
        { "line": 4 },
        { "line": 5, "state": "_instances = { 루트: #1, A: #2, B: #3 }" },
        { "line": 6 },
        { "line": 8, "out": "B → #3" }
      ]
    },
    {
      "label": "Singleton: A → B → 루트",
      "lines": [
        "public object Resolve(Container resolvingContainer)",
        "{",
        "    if (_instance == null)",
        "    {",
        "        _instance = DeclaringContainer.Construct(_concreteType);",
        "        DeclaringContainer.Disposables.TryAdd(_instance);",
        "    }",
        "    return _instance;",
        "}"
      ],
      "state": "호출 1: 자식 A 가 먼저 부른다 · _instance = null",
      "path": [
        { "line": 2, "verdict": "null" },
        { "line": 4, "why": "A 가 불렀지만 만드는 것은 등록한 루트입니다. 생성자 인자도 루트 표에서 찾습니다.", "state": "_instance = #1 (루트가 만듦)" },
        { "line": 5, "why": "해제도 루트가 책임집니다. A 가 먼저 내려가도 #1 은 살아 있습니다." },
        { "line": 7, "out": "A → #1", "state": "호출 2: 자식 B" },
        { "line": 2, "verdict": "있음" },
        { "line": 7, "out": "B → #1", "state": "호출 3: 루트" },
        { "line": 2, "verdict": "있음" },
        { "line": 7, "out": "루트 → #1" }
      ]
    },
    {
      "label": "Transient: A → A",
      "lines": [
        "public object Resolve(Container resolvingContainer)",
        "{",
        "    var instance = DeclaringContainer.Construct(_concreteType);",
        "    resolvingContainer.Disposables.TryAdd(instance);",
        "    return instance;",
        "}"
      ],
      "state": "호출 1: 자식 A · A 의 해제 목록 = [ ]",
      "path": [
        { "line": 2, "why": "만들기는 등록한 컨테이너로 합니다." },
        { "line": 3, "why": "IDisposable 이면 부른 컨테이너의 해제 목록에 쌓습니다. 트랜지언트인데도 추적합니다.", "state": "A 의 해제 목록 = [#1]" },
        { "line": 4, "out": "A → #1", "state": "호출 2: 자식 A" },
        { "line": 2 },
        { "line": 3, "state": "A 의 해제 목록 = [#1, #2]" },
        { "line": 4, "out": "A → #2" }
      ]
    }
  ],
  "note": "Scoped 는 부른 컨테이너마다, Singleton 은 선언한 컨테이너에 하나, Transient 는 매번 새로 만들고 부른 쪽이 해제 책임을 진다."
}
</script>
</x-flow>

유니티 통합에서는 루트 하나와 씬마다 씬 컨테이너가 있으니, 흔한 경우는 이렇게 나뉩니다.

| 쓰임 | 수명 | 등록하는 곳 |
| --- | --- | --- |
| 저장, 오디오처럼 앱 내내 하나 | `Singleton` | 루트 |
| 씬마다 하나씩 있어야 하는 관리자 | `Scoped` | 루트에 한 번. 꺼낸 씬 컨테이너마다 하나 생긴다 |
| 그 씬에서만 쓰는 서비스 | `Singleton` | 씬 |
| 요청마다 새로 만드는 가벼운 객체 | `Transient` | 어디든 |

## 리졸버마다 쓰는 컨테이너가 다르다

리졸버는 부른 컨테이너와 자기가 선언된 컨테이너를 둘 다 압니다. 의존을 어느 쪽 표에서 채우고 해제를 어느 쪽에 맡기느냐가 리졸버마다 다릅니다.

| 리졸버 | 의존을 채우는 컨테이너 | 해제를 맡는 컨테이너 |
| --- | --- | --- |
| Singleton 타입 등록 | 선언 | 선언 |
| Singleton 팩토리 등록 | **처음 부른 쪽** | 선언 |
| Scoped (타입, 팩토리) | 부른 쪽 | 부른 쪽 |
| Transient 타입 등록 | 선언 | 부른 쪽 |
| Transient 팩토리 등록 | 부른 쪽 | 부른 쪽 |

어긋나는 것은 팩토리 싱글턴입니다. 루트에 `RegisterFactory<Holder>(c => new Holder(c.Resolve<IDep>()), Singleton, Lazy)` 와 `IDep` 의 루트 구현을, 자식 `scene` 에 `IDep` 의 씬 구현을 두고 `scene` 에서 먼저 꺼냈습니다.

| 루트의 Holder 등록 | 먼저 꺼낸 scene 의 Dep | 이어서 root 에서 꺼낸 Dep | `scene.Dispose()` 뒤 Dep |
| --- | --- | --- | --- |
| `RegisterFactory` 싱글턴 | 씬 구현 | **씬 구현** | **해제됨** |
| `RegisterType` 싱글턴 | 루트 구현 | 루트 구현 | 살아 있음 |

루트에 있는 싱글턴이 씬의 구현을 붙잡고, 씬이 내려간 뒤에도 해제된 객체를 계속 돌려줍니다.

## 해제는 자식부터, 만든 역순으로

`Dispose()` 는 자식을 나중에 만든 것부터 해제하고, 부모의 자식 목록에서 자기를 빼고, 표를 비우고, 해제 스택을 비웁니다. 해제 스택은 이름 그대로 스택이라 **인스턴스는 만든 역순으로, 리졸버(값 등록의 값 포함)는 맨 나중에** 해제됩니다. 한 항목이 던져도 멈추지 않습니다.

<x-flow>
<script type="application/json">
{
  "label": "DisposableCollection.Dispose() (생성 순서 D1 → D2 → D3)",
  "lines": [
    "List<Exception> exceptions = null;",
    "while (_stack.TryPop(out var disposable))",
    "{",
    "    try { disposable.Dispose(); }",
    "    catch (Exception e) { (exceptions ??= new()).Add(e); }",
    "}",
    "if (exceptions is not null)",
    "    throw new AggregateException(exceptions);"
  ],
  "cases": [
    {
      "label": "모두 정상",
      "state": "스택(위 → 아래) = [D3, D2, D1, 리졸버들]",
      "path": [
        { "line": 0 },
        { "line": 1, "state": "꺼낸 것 = D3 · 스택 = [D2, D1, 리졸버들]" },
        { "line": 3, "out": "D3 해제" },
        { "line": 1, "state": "꺼낸 것 = D2 · 스택 = [D1, 리졸버들]" },
        { "line": 3, "out": "D2 해제" },
        { "line": 1, "state": "꺼낸 것 = D1 · 스택 = [리졸버들]" },
        { "line": 3, "out": "D1 해제" },
        { "line": 1, "why": "리졸버 차례입니다. 값 리졸버는 이때 값을 해제하고, 나머지 리졸버의 Dispose 는 비어 있습니다.", "state": "스택 = [ ]" },
        { "line": 6, "verdict": "false" }
      ],
      "skip": [4, 7]
    },
    {
      "label": "D2 가 던진다",
      "state": "스택(위 → 아래) = [D3, D2, D1, 리졸버들]",
      "path": [
        { "line": 0 },
        { "line": 1, "state": "꺼낸 것 = D3" },
        { "line": 3, "out": "D3 해제" },
        { "line": 1, "state": "꺼낸 것 = D2" },
        { "line": 3, "why": "D2.Dispose() 가 예외를 던집니다." },
        { "line": 4, "why": "잡아서 목록에 모으고 계속 돕니다.", "state": "exceptions = [D2 의 예외]" },
        { "line": 1, "state": "꺼낸 것 = D1" },
        { "line": 3, "out": "D1 해제 (멈추지 않았다)" },
        { "line": 1, "state": "스택 = [ ]" },
        { "line": 6, "verdict": "true" },
        { "line": 7, "out": "AggregateException (D2 의 예외 하나)" }
      ]
    }
  ],
  "note": "한 컨테이너 안에서는 끝까지 해제하고, 모은 예외를 마지막에 한 번 던진다."
}
</script>
</x-flow>

해제가 끝난 컨테이너는 표가 비어 있어서, 거기서 `Resolve` 하면 `ObjectDisposedException` 이 아니라 등록이 없을 때와 같은 `UnknownContractException` 이 납니다.

## 주의할 점

### 1. 오래 사는 컨테이너에서 Transient IDisposable 을 반복해 꺼내지 않는다

**안 지키면 생기는 문제.** Transient 라도 `IDisposable` 이면 **부른 컨테이너의** 해제 스택에 쌓이고, 그 컨테이너가 해제될 때까지 빠지지 않습니다. 한 컨테이너에서 1만 번 꺼내자 붙잡힌 개수가 1 에서 10,001 이 됐습니다(Windows 11 에디터 EditMode). 루트라면 앱이 끝날 때까지 GC 가 거두지 못합니다.

**이렇게 쓴다.** 자주 만들고 버리는 `IDisposable` 은 팩토리 객체 하나를 싱글턴으로 등록해 그것이 `new` 하게 하고, 쓰는 쪽이 `using` 으로 해제합니다.

### 2. 루트의 팩토리 싱글턴이 자식 쪽 등록을 꺼내게 두지 않는다

**안 지키면 생기는 문제.** 팩토리 싱글턴은 처음 부른 컨테이너를 팩토리에 넘깁니다. 어느 씬이 먼저 꺼냈느냐에 따라 앱 전체의 싱글턴이 달라지고, 그 씬이 내려간 뒤에는 해제된 객체를 씁니다.

**이렇게 쓴다.** 루트의 싱글턴은 가능하면 `RegisterType` 으로 등록합니다. 팩토리가 꼭 필요하면 `Resolution.Eager` 로 두어 루트를 빌드할 때 루트 컨테이너로 만들어지게 합니다.

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

**안 지키면 생기는 문제.** 한 컨테이너 안에서는 예외를 모아 끝까지 해제한 뒤 `AggregateException` 을 던집니다. 그런데 부모의 자식 해제 루프에는 `try` 가 없어서, 자식 하나가 던지면 남은 형제와 부모 자신의 해제가 건너뛰어집니다. 루트 아래 C1, C2 를 두고 C2 의 서비스가 던지게 하자 C1 의 서비스도 루트의 서비스도 해제되지 않았습니다(이 Mac, 세 조건 모두).

**이렇게 쓴다.** 컨테이너가 해제하는 서비스의 `Dispose` 는 안에서 예외를 잡아 로그로 남깁니다. 닫는 순서가 중요한 자원은 명시적인 종료 메서드로 먼저 닫습니다.

## 정리

Reflex 의 인스턴스는 **컨테이너가 아니라 부모와 자식이 공유하는 리졸버에** 삽니다.

- 조회는 `IEnumerable<T>` 인지 본 뒤 자기 표에서 한 번 찾아 목록의 마지막 리졸버에 부른 컨테이너를 넘깁니다.
- Singleton 은 필드 하나, Scoped 는 부른 컨테이너를 키로 한 약한 참조 표, Transient 는 매번 새로 만들고 부른 쪽이 해제합니다. 팩토리 싱글턴은 처음 부른 컨테이너로 만들어집니다.
- 해제는 자식부터, 스택은 만든 역순으로 갑니다. 컨테이너 사이에서는 첫 예외에서 멈춥니다.