---
title: "Reflex 는 생성자와 [Inject] 멤버를 두 단계로 채운다"
description: "MonoBehaviour 는 생성자로 의존을 받을 수 없습니다. Reflex 는 생성자 단계와 [Inject] 멤버 단계를 나눠 채우고, 소스 제너레이터는 뒤쪽만 대신 씁니다. 두 단계의 규칙이 기본값, 할당, 순환 참조에서 차이를 만듭니다."
author: "유니얼"
pubDate: 2026-09-28
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "DI"]
canonical: https://blog.unialgames.com/posts/unity-reflex-inject/
---
```csharp
public class Player { public Player(IWeapon weapon, int maxHp = 100) { ... } }

public class PlayerView : MonoBehaviour
{
    [Inject] IWeapon _weapon;
    [Inject] void Construct(ILogger logger) { ... }
}
```

유니티가 만드는 `MonoBehaviour` 에는 생성자를 쓸 수 없으니 두 방법이 다 필요합니다. `maxHp` 가 등록되지 않았으면 어떻게 될지, 부모 클래스의 `[Inject]` 는 채워질지도 정해 둬야 합니다.

Reflex 는 객체 하나를 **생성자 단계(`ConstructorInjector`)와 `[Inject]` 멤버 단계(`AttributeInjector`)로 나눠** 채우고, 소스 제너레이터는 그중 멤버 단계만 대신 씁니다. 기준 판본은 14.3.1 입니다.

## 생성자는 public 중 인자가 가장 많은 것을 고른다

`RegisterType` 으로 등록한 타입은 생성자를 부른 뒤 `[Inject]` 멤버를 채웁니다. 팩토리 결과와 씬의 `MonoBehaviour` 는 멤버 단계만, 값 등록은 둘 다 거치지 않습니다.

생성자는 타입마다 한 번 골라 캐시합니다. `public` 생성자만 후보이고, `[ReflexConstructor]` 가 붙은 것이 있으면 그것, 없으면 **인자가 가장 많은 것**입니다. public 생성자가 없으면 백엔드마다 다른 기본 호출기로 떨어집니다. Mono 는 `null` 을 돌려주고, IL2CPP 는 생성자를 한 번도 돌리지 않은 객체를 돌려줍니다.

`class Hidden { public int X = 7; Hidden() {} [Inject] public IWeapon Weapon; }` 를 등록하고 꺼냈습니다.

| 조건 | 결과 |
| --- | --- |
| 에디터, Mono 개발 빌드 플레이어 | `NullReferenceException` (타입 이름 없는 맨 예외) |
| IL2CPP 개발 빌드 플레이어 | 예외 없이 객체가 나온다. `X` 는 **0**, `Weapon` 은 채워짐 |

구현 자리에 인터페이스를 적은 등록(`RegisterType(typeof(IWeapon), ...)`)도 같은 길로 갑니다. 에디터에서 바로 터지는 실수가 IL2CPP 빌드에서는 값이 틀린 객체로 조용히 주입됩니다.

## 없는 인자는 예외를 던지고 잡아서 기본값을 넣는다

인자마다 `Resolve` 하고, 그 계약이 표에 없어 `UnknownContractException` 이 나면 C# 기본값을 넣습니다. 선택 표시 옵션이 없는 대신 기본값 매개변수가 그 자리를 채웁니다.

<x-flow>
<script type="application/json">
{
  "label": "ConstructorInjector.Construct 가 Player(IWeapon weapon, int maxHp = 100) 를 채우는 길 (줄인 코드)",
  "lines": [
    "var args = ArrayPool.Rent(parameters.Length);",
    "try {",
    "    for (var i = 0; i < parameters.Length; i++)",
    "    {",
    "        try { args[i] = container.Resolve(parameters[i].ParameterType); }",
    "        catch (UnknownContractException)",
    "        {",
    "            if (parameters[i].HasDefaultValue) args[i] = parameters[i].DefaultValue;",
    "            else throw;",
    "        }",
    "    }",
    "    return info.ObjectActivator.Invoke(args);",
    "}",
    "catch (Exception e) { throw new ConstructorInjectorException(concrete, e, parameters); }",
    "finally { ArrayPool.Return(args); }"
  ],
  "cases": [
    {
      "label": "IWeapon 있음, int 없음",
      "state": "표: IWeapon = [Sword] · int 칸 없음 · args = [ _, _ ]",
      "path": [
        { "line": 0, "why": "인자 두 개짜리 배열을 풀에서 빌립니다." },
        { "line": 2, "state": "i = 0 (IWeapon)" },
        { "line": 4, "state": "args = [Sword, _ ]" },
        { "line": 2, "state": "i = 1 (int)" },
        { "line": 4, "why": "int 칸이 없어 Resolve 가 예외를 던집니다." },
        { "line": 5, "why": "직접 없는 경우만 여기서 잡힙니다." },
        { "line": 7, "verdict": "true", "state": "args = [Sword, 100]" },
        { "line": 11, "out": "Player(Sword, 100)" },
        { "line": 14, "why": "빌린 배열을 비워서 돌려놓습니다." }
      ],
      "skip": [8, 13]
    },
    {
      "label": "IWeapon 이 없음",
      "state": "표: IWeapon 칸 없음 · args = [ _, _ ]",
      "path": [
        { "line": 0 },
        { "line": 2, "state": "i = 0 (IWeapon)" },
        { "line": 4 },
        { "line": 5 },
        { "line": 7, "verdict": "false", "why": "IWeapon 에는 기본값이 없습니다." },
        { "line": 8 },
        { "line": 13, "why": "무슨 예외든 이 타입으로 감싸 다시 던집니다. 이 예외 타입은 internal 입니다.", "out": "ConstructorInjectorException: Cannot resolve contract 'IWeapon'. occurred while instantiating object type 'Player' ..." },
        { "line": 14 }
      ],
      "skip": [11]
    },
    {
      "label": "IWeapon 은 있는데 Sword 의 의존이 없음",
      "state": "표: IWeapon = [Sword] · Sword(IOre ore) 인데 IOre 칸 없음",
      "path": [
        { "line": 0 },
        { "line": 2, "state": "i = 0 (IWeapon)" },
        { "line": 4, "why": "Sword 를 만들다 안쪽 Construct 가 IOre 를 못 찾고, 그 예외를 ConstructorInjectorException 으로 감싸 올립니다." },
        { "line": 13, "why": "바깥 catch 는 UnknownContractException 만 잡으므로 여기로 곧장 옵니다. 기본값이 있는 인자였어도 마찬가지입니다.", "out": "ConstructorInjectorException (안에 또 ConstructorInjectorException)" },
        { "line": 14 }
      ],
      "skip": [5, 6, 7, 8, 11]
    }
  ],
  "note": "기본값은 '그 인자의 계약이 표에 없을 때' 만 쓰입니다. 계약은 있는데 그 아래가 비었으면 예외가 그대로 올라옵니다."
}
</script>
</x-flow>

흐름을 예외로 정하니 값이 붙습니다. `A(IDep d)` 와 `B(IDep d, int v = 42)`(`int` 미등록)를 Transient 로 꺼낸 1회 값입니다(이 Mac, 개발 빌드 플레이어).

| 조건 | `A` | `B` | 할당 `A` → `B` |
| --- | --- | --- | --- |
| Mono | 702 ns | 10,905 ns | 16 B → 432 B |
| IL2CPP | 1,407 ns | 48,961 ns | 16 B → 560 B |

기본값 하나가 조회를 Mono 에서 열다섯 배, IL2CPP 에서 서른다섯 배쯤 무겁게 합니다. 그리고 계약은 있는데 그 아래 의존이 빠졌으면, 감싼 예외가 올라와 기본값이 있어도 실패합니다.

## 인자 배열 풀은 여덟 겹까지만 버틴다

생성자 인자 `object[]` 는 스레드마다 둔 풀에서 빌립니다. 인자 수별로 배열을 4개씩 만들어 두고, 생성자 안에서 인자를 `Resolve` 하는 동안에는 빌린 채로 있습니다. 그래서 인자 수가 같은 생성자가 사슬로 이어지면 같은 칸에서 차례로 더 빌리는데, 넷을 넘길 때 칸을 키우는 코드가 **지역 변수만** 키웁니다.

<x-flow>
<script type="application/json">
{
  "label": "SizeSpecificArrayPool.Rent(1) 이 중첩된 생성자에서 불릴 때",
  "lines": [
    "var bucket = _buckets[length];",
    "var rentalIndex = _rentals[length];",
    "if (rentalIndex >= bucket.Length)",
    "{",
    "    Array.Resize(ref bucket, bucket.Length * 2);",
    "    for (var i = rentalIndex; i < bucket.Length; i++) bucket[i] = new T[length];",
    "}",
    "var array = bucket[rentalIndex];",
    "_rentals[length]++;",
    "return array;"
  ],
  "cases": [
    {
      "label": "첫 번째 (A 를 만들 때)",
      "state": "_buckets[1] = [a0, a1, a2, a3] · _rentals[1] = 0",
      "path": [
        {
          "line": 0
        },
        {
          "line": 1,
          "state": "rentalIndex = 0"
        },
        {
          "line": 2,
          "verdict": "false"
        },
        {
          "line": 7,
          "state": "빌려 줄 것 = a0"
        },
        {
          "line": 8,
          "state": "_rentals[1] = 1"
        },
        {
          "line": 9,
          "out": "a0 을 빌려 준다. 할당 없음"
        }
      ],
      "skip": [
        4,
        5
      ]
    },
    {
      "label": "다섯 번째 (A→B→C→D→E 의 E)",
      "state": "_buckets[1] = [a0, a1, a2, a3] · _rentals[1] = 4 (a0~a3 모두 빌려 간 상태)",
      "path": [
        {
          "line": 0,
          "why": "필드에서 칸을 꺼내 지역 변수에 둡니다."
        },
        {
          "line": 1,
          "state": "rentalIndex = 4"
        },
        {
          "line": 2,
          "verdict": "true"
        },
        {
          "line": 4,
          "why": "8칸짜리 새 배열을 만들어 지역 변수 bucket 에만 담습니다. _buckets[1] 은 그대로 4칸입니다.",
          "state": "지역 bucket = [a0, a1, a2, a3, _, _, _, _] · _buckets[1] 은 여전히 4칸"
        },
        {
          "line": 5,
          "why": "4번부터 7번 칸에 길이 1 배열을 새로 넷 만듭니다.",
          "state": "지역 bucket = [a0, a1, a2, a3, n4, n5, n6, n7]"
        },
        {
          "line": 7,
          "state": "빌려 줄 것 = n4"
        },
        {
          "line": 8,
          "state": "_rentals[1] = 5"
        },
        {
          "line": 9,
          "out": "n4 를 빌려 준다. 8칸 버킷 배열 1개(96 B) + object[1] 4개(40 B씩) = 256 B 할당, 나머지는 버려진다"
        }
      ]
    },
    {
      "label": "아홉 번째 (여덟 겹을 넘을 때)",
      "state": "_buckets[1] = [a0, a1, a2, a3] · _rentals[1] = 8",
      "path": [
        {
          "line": 0,
          "why": "필드의 칸은 여전히 4칸입니다."
        },
        {
          "line": 1,
          "state": "rentalIndex = 8"
        },
        {
          "line": 2,
          "verdict": "true"
        },
        {
          "line": 4,
          "why": "새 크기는 필드 칸의 두 배라 몇 번째든 8칸입니다.",
          "state": "지역 bucket = [a0, a1, a2, a3, _, _, _, _]"
        },
        {
          "line": 5,
          "why": "i = 8 에서 시작하는데 8칸이라 한 칸도 만들지 않습니다."
        },
        {
          "line": 7,
          "why": "8칸 배열의 아홉째 칸을 읽습니다.",
          "out": "IndexOutOfRangeException: Index was outside the bounds of the array."
        }
      ],
      "skip": [
        8,
        9
      ]
    }
  ],
  "note": "같은 인자 수의 생성자가 네 겹을 넘게 이어지면 그 깊이부터 풀이 새 배열을 만들고 버리고, 여덟 겹을 넘으면 아홉째에서 예외가 납니다."
}
</script>
</x-flow>

인자 1개 생성자 사슬 `C1(C2) → ... → Cn` 을 Transient 로 두고 조회 1회당 할당에서 손 `new` 몫을 뺀 값입니다(이 Mac, 개발 빌드 플레이어).

| 깊이 n | Mono Reflex 몫 | IL2CPP Reflex 몫 |
| --- | --- | --- |
| 4 | 0 B | 32 B |
| 6 | 256 B | 288 B |
| 8 | 648 B | 680 B |

깊이 8 의 648 B 는 다섯째·여섯째·일곱째 대여가 만드는 버킷과 배열(256 + 216 + 176 B)과 바이트 단위로 같습니다.

아홉째 대여의 예외가 **생성자 순환을 멈추는 자리**이기도 합니다. Reflex 에는 순환을 알아채는 코드가 없고, 싱글턴은 생성이 끝난 뒤에야 인스턴스를 담기 때문에 `A(B)`, `B(A)` 는 서로를 계속 새로 만들며 내려갑니다. 필드 주입은 배열을 돌려준 뒤에 돌아서 이 풀에 걸리지 않습니다.

| 순환 (이 Mac, 개발 빌드 플레이어) | Mono | IL2CPP |
| --- | --- | --- |
| 생성자끼리, `RegisterType` 싱글턴 | `ConstructorInjectorException` (`Index was outside the bounds of the array.` 를 여덟 겹 감쌈) | 같음 |
| `[Inject]` 필드끼리, `RegisterType` 싱글턴 | **프로세스 종료**, 코드 1 | **프로세스 종료**, 코드 139 |
| `[Inject]` 필드끼리, `RegisterFactory` 싱글턴 | 정상, `c.d.c == c` | 같음 |

팩토리 싱글턴은 인스턴스를 먼저 담고 `[Inject]` 를 채우므로 한 바퀴에서 끝납니다.

## 소스 제너레이터는 멤버 단계만 대신 쓴다

`AttributeInjector` 는 객체가 `IAttributeInjectionContract` 를 구현하면 그 `ReflexInject(Container)` 만 부르고 리플렉션을 건너뜁니다. 이 메서드를 `[SourceGeneratorInjectable]` 을 붙인 `public partial` 클래스에 써 주는 것이 패키지의 소스 제너레이터입니다. 조건을 어기면 `RFX1`(partial 아님), `RFX2`(public 아님), `RFX3`(setter 없음), `RFX5`(`readonly` 필드) 컴파일 오류로 멈춥니다.

`[Inject]` 필드 셋을 채우는 1회 값입니다(이 Mac, 개발 빌드 플레이어).

| 조건 | 리플렉션 | 생성 코드 | 손 대입 |
| --- | --- | --- | --- |
| Mono | 2,257 ns | 510 ns (4.4배) | 48 ns |
| IL2CPP | 1,840 ns | 745 ns (2.5배) | 37 ns |

생성 코드도 필드마다 `container.Resolve` 를 부르므로 손 대입보다 열 배 넘게 느립니다. 제너레이터가 없애는 것은 리플렉션 몫뿐입니다. 그리고 **규칙이 조금 다릅니다.**

<x-flow>
<script type="application/json">
{
  "label": "class Hero : Unit 에 [Inject] 메서드가 하나씩 있을 때",
  "cases": [
    {
      "label": "리플렉션 경로",
      "state": "Unit: [Inject] void InitUnit(IClock c, int lv = 1) · Hero: [Inject] void InitHero(IWeapon w) · int 미등록",
      "lines": [
        "var info = TypeInfoCache.Get(typeof(Hero));",
        "// info.InjectableMethods = [Hero.InitHero, Unit.InitUnit]",
        "MethodInjector.Inject(Hero.InitHero, obj, container);",
        "MethodInjector.Inject(Unit.InitUnit, obj, container);"
      ],
      "path": [
        { "line": 0, "why": "Hero 에서 시작해 Unit 쪽으로 올라가며 모읍니다." },
        { "line": 1, "why": "파생 것이 앞에 옵니다." },
        { "line": 2, "out": "InitHero(Sword)" },
        { "line": 3, "why": "int 가 없으니 기본값 1 을 넣습니다.", "out": "InitUnit(clock, 1)" }
      ]
    },
    {
      "label": "생성 코드 경로 (둘 다 [SourceGeneratorInjectable])",
      "state": "같은 두 클래스, 둘 다 public partial",
      "lines": [
        "public override void ReflexInject(Container container)   // Hero",
        "{",
        "    base.ReflexInject(container);   // Unit 의 것",
        "        InitUnit(container.Resolve<IClock>(), container.Resolve<int>());",
        "    InitHero(container.Resolve<IWeapon>());",
        "}"
      ],
      "path": [
        { "line": 0, "why": "부모도 대상이라 Hero 쪽은 override 로 생성됩니다." },
        { "line": 2, "why": "부모 것을 먼저 부릅니다. 순서가 뒤집힙니다." },
        { "line": 3, "why": "생성 코드는 인자마다 Resolve 만 씁니다. 기본값으로 대신하는 코드가 없습니다.", "out": "UnknownContractException: int" }
      ],
      "skip": [4]
    },
    {
      "label": "생성 코드 경로 (Hero 에만 붙임)",
      "state": "Unit 은 평범한 클래스 · Hero 만 [SourceGeneratorInjectable] public partial",
      "lines": [
        "public virtual void ReflexInject(Container container)   // Hero",
        "{",
        "    InitHero(container.Resolve<IWeapon>());",
        "}",
        "// AttributeInjector 는 여기서 return. Unit 의 [Inject] 는 보지 않는다"
      ],
      "path": [
        { "line": 0, "why": "부모가 대상이 아니라 virtual 로 생성됩니다. base 호출이 없습니다." },
        { "line": 2, "out": "InitHero(Sword)" },
        { "line": 4, "why": "ReflexInject 가 있으니 리플렉션 경로로 넘어가지 않습니다.", "out": "InitUnit 은 불리지 않는다" }
      ]
    }
  ],
  "note": "같은 클래스에 속성 하나를 붙이는 것만으로 호출 순서, 기본값 처리, 부모 멤버의 주입 여부가 바뀝니다."
}
</script>
</x-flow>

## 주의할 점

### 1. 컨테이너가 만들 타입의 생성자는 public 으로 둔다

**안 지키면 생기는 문제.** 후보는 `public` 생성자뿐입니다. 의존을 `private` 생성자로 받게 해 두면 인자가 적은 public 생성자가 불리거나, 기본 호출기로 떨어져 에디터에서는 예외, IL2CPP 빌드에서는 생성자를 거치지 않은 객체가 됩니다.

**이렇게 쓴다.** `RegisterType` 으로 맡기는 타입은 public 생성자 하나만 둡니다. 여럿이면 쓸 것에 `[ReflexConstructor]` 를 붙이고, 생성자를 숨겨야 하면 `RegisterFactory` 로 등록합니다.

### 2. 기본값 매개변수로 선택적 의존을 만들지 않는다

**안 지키면 생기는 문제.** 기본값으로 떨어질 때마다 예외를 던지고 잡아서 Mono 약 10 µs, IL2CPP 약 48 µs 와 400~500 B 가 붙습니다. 계약 이름을 잘못 적은 등록도 기본값 뒤에 숨고, 한 단계 안쪽이 빠졌을 때는 기본값이 있어도 실패합니다.

**이렇게 쓴다.** 선택적 서비스는 아무 일도 안 하는 구현을 등록해 둡니다. 기본값 매개변수는 설정값처럼 컨테이너가 채우지 않는 것에만 씁니다.

### 3. 제너레이터를 붙이면 주입 규칙이 바뀐다고 본다

**안 지키면 생기는 문제.** `[SourceGeneratorInjectable]` 하나로 `[Inject]` 메서드 순서가 부모 → 파생으로 뒤집히고, 메서드 인자의 기본값 처리가 사라지고, 대상이 아닌 부모 클래스의 `[Inject]` 멤버가 채워지지 않습니다.

**이렇게 쓴다.** 상속 계층에는 맨 위 부모부터 전부 붙입니다. `[Inject]` 메서드끼리 순서에 기대지 않고, 순서가 필요하면 메서드 하나에 모읍니다.

### 4. 순환 참조를 만들지 않는다

**안 지키면 생기는 문제.** 필드 순환은 잡을 수 있는 예외 없이 프로세스를 끝내고, 생성자 순환은 "배열 범위를 벗어났다" 는 메시지로 멈춰 순환이라는 것을 알려 주지 않습니다.

**이렇게 쓴다.** 두 서비스가 서로를 알아야 하면 공유 상태를 세 번째 클래스로 빼거나 한쪽을 이벤트로 바꿔 의존 방향을 한쪽으로 만듭니다. `Index was outside the bounds of the array.` 가 보이면 메시지에 되풀이되는 두 타입부터 봅니다.

## 정리

Reflex 는 객체를 **생성자 단계와 `[Inject]` 멤버 단계로 나눠** 채웁니다.

- 생성자는 public 중 인자가 가장 많은 것이고, 없는 인자는 예외를 던지고 잡아 기본값을 넣습니다.
- 인자 배열 풀은 같은 인자 수가 다섯 겹을 넘으면 매번 할당하고, 아홉 겹에서 예외로 멈춥니다. 순환 검사는 없습니다.
- 소스 제너레이터는 멤버 단계만 대신 써서 Mono 4.4배, IL2CPP 2.5배 빨라지지만 순서와 기본값, 부모 멤버 규칙이 달라집니다.