---
title: "Reflex 의 등록은 그 줄에서 리졸버로 굳는다"
description: "Reflex 는 RegisterType 을 부르는 줄에서 수명별 리졸버를 만들고 계약을 검사합니다. Build 는 부모 표를 복사해 그 뒤에 덧붙이고 Eager 를 만들 뿐이라, 같은 계약을 두 번 등록하면 조용히 목록에 쌓입니다."
author: "유니얼"
pubDate: 2026-09-28
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "DI"]
canonical: https://blog.unialgames.com/posts/unity-reflex-builder/
---
```csharp
builder.RegisterType(typeof(Sword), new[] { typeof(IWeapon) }, Lifetime.Singleton, Resolution.Lazy);
builder.RegisterType(typeof(Bow),   new[] { typeof(IWeapon) }, Lifetime.Singleton, Resolution.Lazy);
// 둘 중 무엇이 주입될까? 틀린 등록은 언제 드러날까?
```

설치자가 하는 일은 이런 줄을 늘어놓는 것이 전부입니다. 그런데 이 줄이 끝나는 순간 이미 많은 것이 정해집니다.

Reflex 의 등록은 **호출한 그 줄에서 리졸버 하나로 굳고**, `Build()` 는 그것을 부모 표 뒤에 덧붙일 뿐입니다. 기준 판본은 14.3.1 입니다.

## 등록 한 줄이 리졸버와 계약 검사를 끝낸다

등록 메서드는 `RegisterType`, `RegisterValue`, `RegisterFactory` 셋입니다. 수명은 `Lifetime`(`Singleton`, `Transient`, `Scoped`), 생성 시점은 `Resolution`(`Lazy`, `Eager`) 인자로 매번 적습니다. 값 등록만 늘 싱글턴이라 둘 다 받지 않습니다.

호출한 줄에서 수명별 리졸버를 `new` 하고, 계약마다 `IsAssignableFrom` 으로 구현이 맞는지 봅니다. 통과하면 **리졸버와 계약 배열 둘만** 담은 `Binding` 이 목록에 붙습니다.

<x-flow>
<script type="application/json">
{
  "label": "RegisterType(type, contracts, lifetime, resolution) 이 지나는 길 (줄인 코드)",
  "lines": [
    "Assert.IsNotNull(type);",
    "Assert.IsTrue(contracts != null && contracts.Length > 0);",
    "Assert.IsFalse(lifetime == Transient && resolution == Eager, \"... not allowed\");",
    "IResolver resolver = lifetime switch { Singleton => new SingletonTypeResolver(type, resolution), ... };",
    "// Add(type, contracts, resolver) → Binding.Validated",
    "foreach (var contract in contracts)",
    "    if (!contract.IsAssignableFrom(type))",
    "        throw new ContractDefinitionException(type, contract);",
    "Bindings.Add(new Binding(resolver, contracts));",
    "return this;"
  ],
  "cases": [
    {
      "label": "Sword → { IWeapon, Sword }",
      "state": "type = Sword · contracts = [IWeapon, Sword] · Singleton · Lazy · Bindings = []",
      "path": [
        { "line": 0 },
        { "line": 1, "why": "계약이 두 개라 통과합니다." },
        { "line": 2, "verdict": "통과", "why": "싱글턴이라 막을 이유가 없습니다." },
        { "line": 3, "why": "리졸버가 이 자리에서 만들어집니다. 아직 아무것도 만들지 않은, 설계도만 든 객체입니다.", "state": "resolver = SingletonTypeResolver(Sword, Lazy) · Bindings = []" },
        { "line": 4 },
        { "line": 5, "why": "첫 계약 IWeapon." },
        { "line": 6, "verdict": "false", "why": "Sword 는 IWeapon 을 구현하므로 대입할 수 있습니다." },
        { "line": 5, "why": "둘째 계약 Sword." },
        { "line": 6, "verdict": "false", "why": "자기 자신에게는 당연히 대입됩니다." },
        { "line": 8, "why": "리졸버 하나와 계약 배열 하나를 묶은 Binding 이 목록에 붙습니다.", "state": "Bindings = [ (SingletonTypeResolver(Sword), [IWeapon, Sword]) ]" },
        { "line": 9, "out": "빌더를 돌려준다. 다음 등록을 이어 쓸 수 있다" }
      ],
      "skip": [7]
    },
    {
      "label": "Sword → { IShield }",
      "state": "type = Sword · contracts = [IShield] · Singleton · Lazy · Bindings = []",
      "path": [
        { "line": 0 },
        { "line": 1 },
        { "line": 2, "verdict": "통과" },
        { "line": 3, "why": "리졸버는 검사보다 먼저 만들어집니다.", "state": "resolver = SingletonTypeResolver(Sword, Lazy) · Bindings = []" },
        { "line": 4 },
        { "line": 5 },
        { "line": 6, "verdict": "true", "why": "Sword 는 IShield 를 구현하지 않습니다." },
        { "line": 7, "why": "Build 까지 가지 않고 등록한 그 줄에서 멈춥니다. 만든 리졸버는 어디에도 붙지 않고 버려집니다.", "out": "ContractDefinitionException: Sword does not implement IShield contract" }
      ],
      "skip": [8, 9]
    },
    {
      "label": "Transient + Eager",
      "state": "type = Bullet · contracts = [Bullet] · Transient · Eager",
      "path": [
        { "line": 0 },
        { "line": 1 },
        { "line": 2, "verdict": "실패", "why": "매번 새로 만드는 것을 빌드 때 미리 만들 수는 없습니다.", "out": "AssertionException: Type registration Lifetime.Transient + Resolution.Eager not allowed" }
      ],
      "skip": [3, 4, 5, 6, 7, 8, 9]
    }
  ],
  "note": "등록이 틀렸으면 Build 를 기다리지 않고 그 줄에서 예외가 납니다. 대신 구현 타입이 실제로 만들 수 있는 타입인지는 여기서 보지 않습니다."
}
</script>
</x-flow>

틀린 계약은 `Build()` 를 기다리지 않고 그 줄에서 멈춥니다. 대신 검사가 `IsAssignableFrom` 하나라서 **만들 수 있는 타입인지는 보지 않습니다.** 어느 생성자를 쓸지도 처음 만들 때 정합니다. `Binding` 에 이름, 조건, 생성자 인자를 적을 자리가 없으니 그런 기능도 없습니다.

## Build 는 부모 표를 복사하고 그 뒤에 덧붙인다

`Build()` 는 빈 표를 만들고, 부모가 있으면 부모 표의 계약 칸마다 **목록을 새로 복사**한 뒤, 자기 등록을 목록 **뒤에** 붙입니다. 그다음 컨테이너를 만들어 부모의 자식 목록에 넣고, Eager 를 만들고, 빌더를 비웁니다.

<x-flow>
<script type="application/json">
{
  "label": "ContainerBuilder.Build() 가 표를 채우는 순서 (줄인 코드)",
  "lines": [
    "var table = new Dictionary<Type, List<IResolver>>();",
    "if (Parent != null) foreach (var (c, rs) in Parent.ResolversByContract) table[c] = new List<IResolver>(rs);",
    "foreach (var b in Bindings) { disposables.Add(b.Resolver); foreach (var c in b.Contracts) table[c].Add(b.Resolver); }",
    "var container = new Container(Name, Parent, table, disposables);",
    "foreach (var b in Bindings) b.Resolver.DeclaringContainer = container;",
    "if (Parent != null) foreach (부모 표의 Scoped + Eager 리졸버) r.Resolve(container);",
    "foreach (자기 리졸버 중 (Singleton | Scoped) + Eager) r.Resolve(container);",
    "Bindings.Clear();",
    "OnContainerBuilt?.Invoke(container);",
    "return container;"
  ],
  "cases": [
    {
      "label": "루트: Config 값, Sword Singleton Eager, Clock Scoped Eager",
      "state": "Parent = null · Bindings = [Config 값 → Config] [Sword → IWeapon, Singleton Eager] [Clock → Clock, Scoped Eager]",
      "path": [
        { "line": 0, "state": "표 = { }" },
        { "line": 1, "verdict": "부모 없음" },
        { "line": 2, "why": "등록 순서대로 계약 칸에 붙습니다. 리졸버는 인스턴스보다 먼저 해제 목록에 들어갑니다.", "state": "표 = { Config: [값], IWeapon: [Sword], Clock: [Clock] } · 해제 목록 = [Config 값 리졸버, Sword 리졸버, Clock 리졸버]" },
        { "line": 3, "why": "Container 계약 칸이 이 컨테이너 자신으로 채워집니다.", "state": "표 = { Config, IWeapon, Clock, Container: [루트 자신] }" },
        { "line": 4, "why": "세 리졸버 모두 루트에서 선언됐다고 적힙니다." },
        { "line": 5, "verdict": "부모 없음" },
        { "line": 6, "why": "Sword 와 Clock 둘 다 Eager 라 지금 만듭니다.", "out": "Sword 생성자, Clock 생성자 (루트 몫)" },
        { "line": 7, "state": "빌더 Bindings = [ ] · 표는 이제 바뀌지 않는다" },
        { "line": 8 },
        { "line": 9, "out": "루트 컨테이너" }
      ]
    },
    {
      "label": "자식: Bow 를 IWeapon 으로 더 등록",
      "state": "Parent = 루트 · Bindings = [Bow → IWeapon, Singleton Lazy]",
      "path": [
        { "line": 0, "state": "표 = { }" },
        { "line": 1, "why": "부모의 목록을 칸마다 새 List 로 복사합니다. 리졸버 객체는 같은 것을 가리킵니다.", "state": "표 = { Config: [값], IWeapon: [Sword], Clock: [Clock], Container: [루트] }" },
        { "line": 2, "why": "Bow 는 IWeapon 칸의 뒤에 붙습니다. 부모의 Sword 를 지우지 않습니다.", "state": "표 = { ..., IWeapon: [Sword, Bow], ... } · 해제 목록 = [Bow 리졸버]" },
        { "line": 3, "why": "Container 칸을 자기 자신으로 덮어쓰고, 루트의 자식 목록에 들어갑니다.", "state": "표 = { ..., Container: [자식 자신] } · 루트.Children = [자식]" },
        { "line": 4, "why": "Bow 리졸버만 자식에서 선언됐다고 적힙니다. 복사해 온 Sword 리졸버는 여전히 루트 소속입니다." },
        { "line": 5, "why": "부모 표의 Clock 은 Scoped + Eager 라 이 자식 몫으로 하나 더 만듭니다. Sword 는 싱글턴이라 다시 부르지 않습니다.", "out": "Clock 생성자 (자식 몫)" },
        { "line": 6, "verdict": "Eager 없음", "why": "Bow 는 Lazy 라 처음 꺼낼 때 만듭니다." },
        { "line": 7 },
        { "line": 8 },
        { "line": 9, "out": "자식 컨테이너" }
      ]
    }
  ],
  "note": "자식의 표는 부모 표의 복사본 뒤에 자기 등록을 붙인 것입니다. 그래서 IWeapon 칸에는 Sword 와 Bow 가 둘 다 있습니다."
}
</script>
</x-flow>

- 복사하는 것은 목록이고 **리졸버 객체는 부모와 자식이 공유**합니다. 부모의 싱글턴을 자식에서 꺼내도 같은 인스턴스가 나오는 이유입니다.
- `Resolution` 을 읽는 곳은 `Build()` 뿐입니다. Eager 는 빌드가 대신 한 번 불러 주는 것이고, `Scoped + Eager` 는 **자손을 빌드할 때마다** 그 자손 몫이 하나씩 만들어집니다.
- 빌드가 끝나면 표는 바뀌지 않습니다. 같은 빌더에 등록을 더하고 다시 `Build()` 하면 새 컨테이너가 하나 더 생길 뿐입니다.

## 같은 계약을 두 번 등록하면 목록에 쌓인다

덧붙일 때 이미 있는지 보지 않습니다. 그래서 꺼내는 방법마다 결과가 갈립니다.

<x-compare>
<script type="application/json">
{
  "label": "IWeapon 칸 = [Sword, Bow] 일 때 꺼내는 방법마다",
  "tabs": [
    { "label": "Resolve", "code": "container.Resolve<IWeapon>();\n// resolvers.Last().Resolve(this)", "text": "목록의 마지막을 줍니다. 몇 개가 있는지 검사하지 않습니다. 생성자 주입과 [Inject] 멤버도 이 경로를 씁니다.", "stats": [["결과", "Bow"], ["경고", "없음"]] },
    { "label": "Single", "code": "container.Single<IWeapon>();\n// GetResolvers(type).Single().Resolve(this)", "text": "딱 하나가 아니면 LINQ 의 Single 이 던집니다. 메시지에 계약 이름이 없습니다.", "stats": [["결과", "InvalidOperationException"], ["메시지", "Sequence contains more than one element"]], "tone": "good" },
    { "label": "All", "code": "container.All<IWeapon>();\n// resolvers.Select(r => r.Resolve(this)).ToArray()", "text": "등록 순서대로 전부 만듭니다. 부모에서 복사해 온 것이 앞에, 자기 것이 뒤에 옵니다. 부를 때마다 배열을 새로 만듭니다.", "stats": [["결과", "[Sword, Bow]"]] },
    { "label": "TryGetResolver", "code": "container.TryGetResolver<IWeapon>(out var r);\n// resolvers.Single()", "text": "이름은 Try 지만 칸 안에서 Single 을 부릅니다. 둘 이상이면 false 가 아니라 예외입니다.", "stats": [["결과", "InvalidOperationException"]], "tone": "bad" }
  ]
}
</script>
</x-compare>

생성자 주입과 `[Inject]` 는 `Resolve` 를 쓰므로 **경고 없이 마지막 등록**이 들어갑니다. 반대로 등록 하나에 계약을 여러 개 적으면 그 계약들이 리졸버 하나를 나눠 써서, `Sword` 를 `{ IWeapon, IDamager }` 싱글턴으로 등록하면 두 계약이 같은 인스턴스를 줍니다.

## 주의할 점

### 1. 같은 계약을 두 설치자에 흩어 두지 않는다

**안 지키면 생기는 문제.** 한 스코프 안의 설치자는 하이어라키 순서대로 불립니다. 두 설치자가 같은 계약을 등록하면 나중 것이 조용히 이기니, 씬에서 오브젝트 순서만 바꿔도 다른 구현이 주입됩니다. 부모에 있는 계약을 자식이 다시 등록해도 같습니다.

**이렇게 쓴다.** 하나만 있어야 하는 서비스는 `Single<T>()` 로 꺼내 둘째 등록을 바로 잡습니다. 덮어쓰는 것이 의도라면 그 등록을 한 설치자에 모읍니다.

### 2. 등록 검사가 릴리스 빌드에도 남는다고 보지 않는다

**안 지키면 생기는 문제.** 빈 계약 배열과 `Transient + Eager` 를 막는 검사는 `UnityEngine.Assertions.Assert` 라서 비개발 빌드에서 빠집니다. 에디터에서 `AssertionException` 이던 실수가 릴리스에서는 조용히 지나가, 빈 계약 배열로 등록한 타입은 `UnknownContractException` 으로 끝나고 `Transient + Eager` 는 Lazy 로 돕니다. 계약 불일치 검사는 평범한 `throw` 라 남습니다.

**이렇게 쓴다.** 등록 코드는 에디터 플레이나 EditMode 테스트에서 한 번씩 다 돌려 봅니다. 계약 배열을 코드로 만들어 넘기면 넘기기 전에 직접 비었는지 봅니다.

### 3. Eager 생성자에서 실패할 수 있는 일을 하지 않는다

**안 지키면 생기는 문제.** `Build()` 는 컨테이너를 부모의 자식 목록에 넣은 **뒤에** Eager 를 만듭니다. Eager 생성자가 던지면 호출한 쪽은 컨테이너를 받지 못하는데, 부모에는 반쯤 만든 컨테이너가 매달린 채 남습니다. 에디터와 Mono·IL2CPP 개발 빌드 플레이어 모두 그랬고, 그 컨테이너는 부모가 해제될 때에야 치워졌습니다.

**이렇게 쓴다.** Eager 타입의 생성자는 받은 의존을 필드에 두는 데서 그치고, 파일이나 네트워크처럼 실패할 수 있는 일은 따로 부르는 초기화 메서드로 옮깁니다.

## 정리

Reflex 의 등록은 **그 줄에서 리졸버로 굳고**, `Build()` 는 부모 표 뒤에 덧붙입니다.

- 등록 한 줄이 수명별 리졸버를 만들고 계약을 검사합니다. 만들 수 있는 타입인지는 보지 않습니다.
- `Build()` 는 부모 표를 칸마다 복사하고 자기 등록을 뒤에 붙입니다. 리졸버는 부모와 자식이 공유하고, Eager 는 빌드가 대신 부르는 것입니다.
- 중복 등록은 목록에 쌓여 `Resolve` 는 조용히 마지막, `Single` 은 예외, `All` 은 전부입니다.