---
title: "Reflex 는 조립을 빠르고 작은 등록 표 하나로 바꾼다"
description: "싱글턴과 FindObjectOfType 은 Awake 순서에 묶이고, 손으로 new 하는 조립 코드는 클래스 수만큼 길어집니다. Reflex 는 빠르고 작지만 빠진 것 없는 컨테이너를 목표로, 조립을 등록 표로 바꾸고 객체를 만들어 넣는 데서 멈춥니다."
author: "유니얼"
pubDate: 2026-09-28
updatedDate: 2026-09-30
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "DI"]
canonical: https://blog.unialgames.com/posts/unity-reflex-why/
---
```csharp
public class Player : MonoBehaviour
{
    void OnHit() => ScoreService.Instance.Add(10);   // Instance 가 아직 null 이면?
}
```

`ScoreService` 의 `Awake` 가 `Player` 보다 늦게 돌면 `Instance` 는 비어 있습니다. `FindObjectOfType` 으로 바꿔도 부를 때마다 씬을 뒤지고, 같은 타입이 둘이면 어느 쪽이 잡힐지 모릅니다.

Reflex 는 **"무엇을 무엇으로 만들지" 를 등록 표로 굳혀 두고, 필요한 자리에 객체를 만들어 넣는 데서 멈추는** 유니티용 의존성 주입(DI) 컨테이너입니다. 빠르고 작게, 그래도 DI 에 필요한 것은 빠짐없이 갖추는 것이 목표입니다. 기준 판본은 14.3.1(커밋 `5542338`)입니다.

## 컨테이너 없이 잇는 세 방법은 조립 순서에 묶인다

같은 연결을 세 방식과 Reflex 로 나란히 놓았습니다.

<x-compare>
<script type="application/json">
{
  "label": "Player 가 점수 서비스를 쓰게 만들기",
  "tabs": [
    {
      "label": "싱글턴 정적 필드",
      "code": "public class ScoreService : MonoBehaviour\n{\n    public static ScoreService Instance { get; private set; }\n    void Awake() => Instance = this;\n    public void Add(int v) { ... }\n}\n\npublic class Player : MonoBehaviour\n{\n    void OnHit() => ScoreService.Instance.Add(10);\n}",
      "stats": [["누가 만드나", "씬에 놓인 컴포넌트가 Awake 에서 자기를 적는다"], ["Player 가 아는 것", "구체 클래스 이름과 정적 속성"], ["바꿔 끼우기", "정적 필드를 직접 덮어써야 한다"], ["순서", "Instance 를 읽는 쪽이 Awake 보다 먼저면 null"]],
      "tone": "bad"
    },
    {
      "label": "FindObjectOfType",
      "code": "public class Player : MonoBehaviour\n{\n    ScoreService _score;\n    void Start() => _score = FindObjectOfType<ScoreService>();\n    void OnHit() => _score.Add(10);\n}",
      "stats": [["누가 만드나", "씬에 놓인 컴포넌트"], ["찾는 방법", "부를 때 로드된 오브젝트를 뒤진다"], ["바꿔 끼우기", "같은 타입이 둘이면 어느 쪽이 잡힐지 모른다"], ["순수 C# 클래스", "찾을 수 없다. MonoBehaviour 만"]],
      "tone": "bad"
    },
    {
      "label": "손으로 쓴 생성자 주입",
      "code": "public class Player\n{\n    readonly IScoreService _score;\n    public Player(IScoreService score) => _score = score;\n}\n\n// 조립하는 곳 한 군데\nvar score = new ScoreService();\nvar player = new Player(score);",
      "stats": [["누가 만드나", "조립 코드 한 군데"], ["Player 가 아는 것", "인터페이스 하나"], ["바꿔 끼우기", "조립 코드에서 다른 구현을 넘긴다"], ["늘어나면", "조립 코드가 의존 그래프만큼 길어진다"]]
    },
    {
      "label": "Reflex",
      "code": "public class Player\n{\n    readonly IScoreService _score;\n    public Player(IScoreService score) => _score = score;\n}\n\n// 설치자\nbuilder.RegisterType(typeof(ScoreService),\n    new[] { typeof(IScoreService) },\n    Lifetime.Singleton, Resolution.Lazy);\nbuilder.RegisterType(typeof(Player),\n    Lifetime.Transient, Resolution.Lazy);",
      "stats": [["누가 만드나", "컨테이너. 생성자 인자를 표에서 찾아 채운다"], ["Player 가 아는 것", "인터페이스 하나"], ["바꿔 끼우기", "등록 한 줄을 바꾼다"], ["MonoBehaviour", "[Inject] 필드·속성·메서드로 받는다"]],
      "tone": "good"
    }
  ]
}
</script>
</x-compare>

싱글턴 정적 필드와 `FindObjectOfType` 은 쓰는 쪽을 구체 클래스와 `Awake` 순서에 묶고, `MonoBehaviour` 가 아닌 평범한 C# 클래스에는 쓸 수 없습니다. 손으로 쓴 생성자 주입은 이 문제를 다 풀지만, 클래스가 수십 개가 되면 순서를 맞춰 `new` 를 늘어놓는 조립 코드가 그 자체로 짐이 됩니다. 씬에 놓인 `MonoBehaviour` 는 유니티가 만들어서 생성자로 받을 수도 없습니다.

Reflex 는 그 조립을 등록 한 줄로 바꿉니다. 생성자 인자는 표에서 찾아 채우고, 씬의 `MonoBehaviour` 는 `[Inject]` 를 붙인 필드·속성·메서드로 받게 합니다.

## 부품은 넷이고 매 프레임 도는 것은 없다

등록 한 줄이 필드에 들어간 값이 되기까지 지나는 타입은 넷입니다.

| 부품 | 하는 일 |
| --- | --- |
| `ContainerBuilder` | 등록을 받는다. 호출 한 번마다 `Binding` 하나 |
| `IResolver` | 무엇을, 어떤 수명으로, 어떻게 만드는지 든 전략 객체. 구현이 일곱 개 |
| `Container` | 계약 → 리졸버 목록 표 |
| `ConstructorInjector`, `AttributeInjector` | 생성자를 부르고 `[Inject]` 멤버를 채운다 |

<x-wiring>
<script type="application/json">
{
  "label": "등록 한 줄이 주입된 필드가 되기까지",
  "width": 860,
  "height": 320,
  "nodes": [
    { "id": "inst", "title": "IInstaller", "sub": "InstallBindings(builder)", "x": 0, "y": 8, "w": 150, "h": 54 },
    { "id": "builder", "title": "ContainerBuilder", "sub": "Bindings 목록", "x": 245, "y": 8, "w": 150, "h": 54 },
    { "id": "resolver", "title": "IResolver", "sub": "수명과 만드는 법", "x": 620, "y": 8, "w": 150, "h": 54 },
    { "id": "container", "title": "Container", "sub": "계약 → 리졸버 목록", "x": 245, "y": 132, "w": 150, "h": 54 },
    { "id": "user", "title": "씬 주입 · 사용자 코드", "sub": "Resolve 를 부르는 쪽", "x": 0, "y": 258, "w": 170, "h": 54, "aside": true },
    { "id": "inj", "title": "주입기", "sub": "생성자 → [Inject] 멤버", "x": 620, "y": 258, "w": 150, "h": 54 }
  ],
  "edges": [
    { "from": "inst", "to": "builder", "label": "RegisterType(...)", "bend": 0 },
    { "from": "builder", "to": "resolver", "label": "그 자리에서 new", "bend": 0 },
    { "from": "builder", "to": "container", "label": "Build(): 표로 펼친다", "bend": 0 },
    { "from": "user", "to": "container", "label": "Resolve<IScoreService>()", "bend": 0 },
    { "from": "container", "to": "resolver", "label": "목록의 마지막.Resolve(this)", "bend": 30 },
    { "from": "resolver", "to": "inj", "label": "Construct(ScoreService)", "bend": 0 },
    { "from": "inj", "to": "container", "label": "생성자 인자마다 Resolve", "bend": 30 },
    { "from": "inj", "to": "user", "label": "채워진 인스턴스", "bend": 0 }
  ],
  "steps": [
    { "edge": 0, "why": "설치자가 등록 메서드를 부릅니다. 구현 타입, 계약 배열, 수명, 생성 시점을 한 번에 넘깁니다." },
    { "edge": 1, "why": "수명이 Singleton 이면 SingletonTypeResolver 를 이 자리에서 만듭니다. 계약이 구현에 맞는지도 이 줄에서 검사합니다." },
    { "edge": 2, "why": "Build 가 Binding 들을 계약별 목록으로 펼쳐 표를 만들고 빌더를 비웁니다. 이 뒤로 표는 바뀌지 않습니다." },
    { "edge": 3, "why": "씬 주입이나 사용자 코드가 계약을 달라고 합니다. 컨테이너는 자기 표 하나만 봅니다." },
    { "edge": 4, "why": "그 계약의 목록에서 마지막 리졸버를 꺼내, 부른 컨테이너를 넘깁니다." },
    { "edge": 5, "why": "아직 만든 적이 없으면 리졸버가 컨테이너의 Construct 를 부르고, 주입기가 생성자를 고릅니다." },
    { "edge": 6, "why": "생성자 인자마다 다시 Resolve 를 부릅니다. 컨테이너와 주입기가 서로를 재귀로 부르는 자리입니다." },
    { "edge": 7, "why": "[Inject] 멤버까지 채운 인스턴스가 돌아갑니다. 싱글턴이면 리졸버가 이것을 들고 있다가 다음부터 그대로 줍니다." }
  ],
  "note": "등록은 빌더와 리졸버 사이에서, 조회는 컨테이너와 주입기 사이에서 일어납니다. 매 프레임 도는 부분은 없습니다."
}
</script>
</x-wiring>

리졸버는 **등록하는 순간** 만들어지고, `Build()` 는 그것을 표로 옮겨 담기만 합니다. 조회는 **자기 표 하나만** 봅니다. 그리고 매 프레임 불리는 인터페이스, 시작할 때 한 번 불리는 진입점, 오브젝트 풀, 등록 검증 단계가 **없습니다.** 컨테이너는 객체를 만들어 넘기는 데서 손을 뗍니다.

## 빠르고 작지만 빠진 것 없는 쪽을 내건다

README 는 Reflex 를 "아주 빠르고, 작지만 빠진 것 없는 유니티용 DI 라이브러리" 라고 소개합니다. 목적으로는 읽기 쉬운 코드, 테스트하기 쉬운 구조, 커져도 버티는 구성을 들고, 가장 앞에 **속도와 할당**을 내세웁니다. 그 밖의 주장은 소스에서 이런 모양입니다.

| 주장 | 소스에서 나타난 모양 |
| --- | --- |
| Contract Table | 조회 표가 `Dictionary<Type, List<IResolver>>` 다. 계약 하나에 리졸버 목록이 붙는다 |
| Immutable Container | `Build()` 가 표를 한 번 만들고 빌더를 비운다. 컨테이너에는 등록 API 가 없다 |
| AOT Support | 생성자 호출기가 백엔드마다 다르다. IL2CPP 에서는 `ConstructorInfo.Invoke`, Mono 에서는 식 트리 컴파일 |
| Source Generated | `[SourceGeneratorInjectable]` 을 붙인 `partial` 클래스의 `[Inject]` 멤버 채우기만 대신 쓴다 |

"작다" 는 앞 절의 **없는 것**들입니다. 진입점, 풀, 검증 단계를 빼서 조회가 표 한 번과 리졸버 호출로 끝납니다. 실제로 이미 만든 싱글턴 조회는 컨테이너 깊이 0 이든 8 이든 Mono 163 ns, IL2CPP 243 ns 로 같았습니다.

계약 표의 키는 `Type` 하나이고 값은 **목록**입니다. `Resolve<T>()` 는 목록의 마지막을, `All<T>()` 는 전부를, `Single<T>()` 는 하나가 아니면 예외를 줍니다. 이름이나 키를 붙일 자리는 없습니다. "불변" 은 표의 구조까지이고, 싱글턴을 담는 리졸버 필드와 타입 캐시는 락 없이 바뀝니다.

## 가장 짧은 예제

컨테이너는 두 층입니다. 앱 전체에 하나인 **루트 컨테이너**와, `ContainerScope` 가 놓인 씬마다 하나씩 생기는 **씬 컨테이너**. 씬 컨테이너는 루트를 부모로 삼습니다.

```csharp
public interface IScoreService { int Score { get; } void Add(int value); }

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

// RootScope 프리팹에 붙인다
public class RootInstaller : MonoBehaviour, IInstaller
{
    public void InstallBindings(ContainerBuilder builder)
    {
        builder.RegisterType(typeof(ScoreService),
            new[] { typeof(IScoreService) },
            Lifetime.Singleton, Resolution.Lazy);
    }
}

public class ScoreView : MonoBehaviour
{
    [Inject] IScoreService _score;

    void Start()
    {
        _score.Add(10);
        Debug.Log(_score.Score);   // 10
    }
}
```

- **Create → Reflex → RootScope** 로 만든 프리팹에 `RootInstaller` 를 붙이고, `Resources/ReflexSettings` 에셋의 **RootScopes** 목록에 넣습니다
- 씬에는 **GameObject → Reflex → SceneScope** 로 씬 스코프를 하나 둡니다
- 씬이 로드되면 씬 스코프의 `Awake` 가 가장 먼저 돌아 컨테이너를 만들고, 씬의 모든 `MonoBehaviour` 에 `[Inject]` 를 채웁니다. 그래서 `ScoreView` 는 `Start` 에서 바로 씁니다

순수 C# 으로도 씁니다.

```csharp
using var container = new ContainerBuilder()
    .RegisterType(typeof(ScoreService), new[] { typeof(IScoreService) }, Lifetime.Singleton, Resolution.Lazy)
    .RegisterType(typeof(Player), Lifetime.Transient, Resolution.Lazy)
    .Build();

var player = container.Resolve<Player>();   // Player(IScoreService) 에 ScoreService 가 들어간다
```

등록 메서드는 `RegisterType`, `RegisterValue`, `RegisterFactory` 셋이고, 수명(`Lifetime`)과 생성 시점(`Resolution`)은 매번 인자로 적습니다. 기본값이 없습니다.

## 주의할 점

### 1. 이름으로 골라 받을 수 없다

**안 지키면 생기는 문제.** 같은 계약으로 두 번 등록하면 목록에 쌓이고, 생성자 주입과 `[Inject]` 는 목록의 마지막을 받습니다. 계약 옆에 이름이나 키를 붙이는 자리가 없어서 "첫 번째 것" 을 골라 받을 방법이 없습니다.

**이렇게 쓴다.** 구분해 받아야 하는 것은 README 안내대로 감싼 타입을 따로 만들어 서로 다른 계약으로 등록합니다.

### 2. 시작이나 매 프레임 할 일을 컨테이너에 맡길 수 없다

**안 지키면 생기는 문제.** Reflex 에는 컨테이너가 불러 주는 초기화 인터페이스나 `Tick` 같은 진입점이 없습니다. 서비스를 등록만 해 두면 누가 꺼내기 전까지 만들어지지도 않습니다.

**이렇게 쓴다.** 시작할 때 한 번 할 일은 `Resolution.Eager` 로 등록해 `Build()` 안에서 만들어지게 하고, 매 프레임 할 일은 씬의 `MonoBehaviour.Update` 에서 주입받은 서비스를 부릅니다.

## 정리

Reflex 는 **조립 코드를 등록 표 하나로 바꾸고, 객체를 만들어 넣는 데서 멈추는** 빠르고 작은 DI 컨테이너입니다.

- 싱글턴 정적 필드와 `FindObjectOfType` 은 `Awake` 순서에, 손으로 쓴 생성자 주입은 길어지는 조립 코드에 묶입니다. Reflex 는 그 조립을 등록 한 줄로 바꿉니다.
- 부품은 빌더, 리졸버, 컨테이너, 주입기 넷입니다. 진입점도 풀도 검증 단계도 없습니다.
- 계약 표는 `Type` → 리졸버 목록이라 `Resolve` 는 마지막, `All` 은 전부를 줍니다. 이름으로 고르는 기능은 없습니다.