---
title: "Zenject 의 주입은 리플렉션으로 읽은 타입 정보로 돈다"
description: "Zenject 는 타입마다 한 번 생성자와 [Inject] 멤버를 리플렉션으로 읽어 캐시하고, 그 정보대로 생성자, 필드, 메서드 순으로 채웁니다. 생성자가 여럿이면 인자가 가장 적은 것을 조용히 고르고, IL2CPP High 스트리핑에서는 필드가 예외 없이 비었습니다."
author: "유니얼"
pubDate: 2026-09-27
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "DI", "최적화"]
canonical: https://blog.unialgames.com/posts/unity-zenject-inject/
---
```csharp
public class Unit
{
    [Inject] ILog _log;
    [Inject] void InitUnit(IScore score) { ... }
}

public class Enemy : Unit
{
    [Inject] ISave _save;
    public Enemy(IClock clock) { ... }
    [Inject] void InitEnemy() { ... }
}
```

손으로 쓰면 `new Enemy(clock)` 한 줄과 대입 몇 줄입니다. 컨테이너는 `Enemy` 를 처음 보므로 어느 생성자를 부르고, 부모의 private 필드 `_log` 를 포함해 무엇을 어떤 순서로 채울지부터 알아내야 합니다.

Zenject 는 **타입마다 한 번 리플렉션으로 생성자와 `[Inject]` 멤버를 읽어 `InjectTypeInfo` 로 캐시하고, 그 정보대로 생성자, 부모부터 필드, 부모부터 메서드 순으로 채웁니다.** 이 정보가 리플렉션에서 오기 때문에 생성자 선택 규칙과 IL2CPP 스트리핑의 영향을 그대로 받습니다.

## 생성자가 여럿이면 [Inject], public 하나, 인자 최소 순으로 고른다

`MonoBehaviour` 같은 `Component` 와 추상 클래스는 생성자를 고르지 않습니다. 나머지는 이렇게 고릅니다.

<x-flow>
<script type="application/json">
{
  "label": "TryGetInjectConstructor 가 생성자를 고르는 순서 (줄인 코드)",
  "lines": [
    "if (type 이 Component 이거나 abstract) return null;",
    "var ctors = type.Constructors();          // public 과 non-public 모두",
    "if (ctors.Length > 1)",
    "{",
    "    var marked = ctors 중 [Inject] 가 붙은 것.SingleOrDefault();",
    "    if (marked != null) return marked;",
    "    if (전략 == InjectAttribute) return null;",
    "    var onlyPublic = ctors 중 public 인 것.OnlyOrDefault();",
    "    if (onlyPublic != null) return onlyPublic;",
    "    return ctors.OrderBy(인자 수).First();",
    "}",
    "return ctors[0];"
  ],
  "cases": [
    {
      "label": "public 둘, [Inject] 없음",
      "state": "public Enemy(IClock) · public Enemy(IClock, IScore)",
      "path": [
        { "line": 0, "verdict": "false" },
        { "line": 1 },
        { "line": 2, "verdict": "true" },
        { "line": 4, "why": "[Inject] 가 붙은 생성자가 없습니다." },
        { "line": 5, "verdict": "false" },
        { "line": 6, "verdict": "false", "why": "기본 전략은 InjectAttributeThenLeastArguments 입니다." },
        { "line": 7, "why": "public 이 둘이라 하나로 정해지지 않습니다." },
        { "line": 8, "verdict": "false" },
        { "line": 9, "why": "인자가 가장 적은 것을 고릅니다.", "out": "Enemy(IClock). IScore 는 받지 못한다" }
      ]
    },
    {
      "label": "둘째에 [Inject]",
      "state": "public Enemy(IClock) · [Inject] public Enemy(IClock, IScore)",
      "path": [
        { "line": 0, "verdict": "false" },
        { "line": 1 },
        { "line": 2, "verdict": "true" },
        { "line": 4 },
        { "line": 5, "verdict": "true", "out": "Enemy(IClock, IScore)" }
      ]
    },
    {
      "label": "public 하나, private 하나",
      "state": "public Enemy(IClock) · private Enemy(IClock, IScore, ISave)",
      "path": [
        { "line": 0, "verdict": "false" },
        { "line": 1, "why": "private 생성자도 후보에 들어옵니다." },
        { "line": 2, "verdict": "true" },
        { "line": 4 },
        { "line": 5, "verdict": "false" },
        { "line": 6, "verdict": "false" },
        { "line": 7, "why": "public 은 하나뿐입니다." },
        { "line": 8, "verdict": "true", "out": "Enemy(IClock)" }
      ]
    }
  ],
  "note": "생성자가 하나면 public 이든 private 이든 그것을 씁니다. 전략이 InjectAttribute 면 [Inject] 가 없는 한 null 입니다."
}
</script>
</x-flow>

첫 경우가 함정입니다. 의존을 더 받는 생성자가 아니라 **인자가 가장 적은 생성자**가 조용히 불립니다. 소스 주석은 Zenject 를 모르는 라이브러리의 클래스도 만들 수 있게 하려고 널 참조 예외의 위험을 감수한 선택이라고 적어 둡니다.

## 생성자, 부모 필드, 자기 필드, 부모 메서드, 자기 메서드 순으로 채운다

타입 정보는 자기가 선언한 멤버만 담고, 부모 클래스는 `BaseTypeInfo` 로 따라갑니다. 부모를 따로 분석하므로 부모의 private 필드도 주입됩니다.

<x-flow>
<script type="application/json">
{
  "label": "container.Instantiate<Enemy>() 가 값을 넣는 순서 (줄인 코드)",
  "lines": [
    "var typeInfo = TypeAnalyzer.TryGetInfo(typeof(Enemy));",
    "newObj = typeInfo.InjectConstructor.Factory(생성자 인자);",
    "InjectMembersTopDown(typeInfo.BaseTypeInfo);    // 부모 필드",
    "InjectMembersTopDown(typeInfo);                 // 자기 필드",
    "CallInjectMethodsTopDown(typeInfo.BaseTypeInfo); // 부모 [Inject] 메서드",
    "CallInjectMethodsTopDown(typeInfo);             // 자기 [Inject] 메서드",
    "if (extraArgs.Count > 0) throw \"Passed unnecessary parameters ...\";"
  ],
  "cases": [
    {
      "label": "Instantiate<Enemy>()",
      "state": "extraArgs = [ ]",
      "path": [
        { "line": 0, "why": "캐시에서 Enemy 정보를 꺼냅니다. 처음이면 여기서 리플렉션으로 만들어 캐시합니다." },
        { "line": 1, "why": "IClock 을 조회해 생성자를 부릅니다. 이때 필드는 아직 전부 null 입니다.", "out": "Enemy(IClock) 생성자" },
        { "line": 2, "why": "재귀가 부모 쪽을 먼저 끝냅니다.", "out": "_log 채움 (Unit 의 private)" },
        { "line": 3, "out": "_save 채움" },
        { "line": 4, "why": "필드가 다 찬 뒤에 메서드가 불립니다. InitUnit 안에서 _save 도 이미 쓸 수 있습니다.", "out": "InitUnit(IScore)" },
        { "line": 5, "out": "InitEnemy()" },
        { "line": 6, "verdict": "false" }
      ]
    },
    {
      "label": "Instantiate<Enemy>(new object[] { clock, \"boss\" })",
      "state": "extraArgs = [ IClock, string ]",
      "path": [
        { "line": 0 },
        { "line": 1, "why": "IClock 은 컨테이너보다 extraArgs 에서 먼저 꺼냅니다. 꺼낸 값은 목록에서 빠집니다.", "state": "extraArgs = [ string ]", "out": "Enemy(clock) 생성자" },
        { "line": 2, "why": "필드 타입이 string 이 아니라 extraArgs 에서 꺼낼 것이 없습니다. 컨테이너에서 조회합니다." },
        { "line": 3 },
        { "line": 4 },
        { "line": 5 },
        { "line": 6, "verdict": "true", "why": "어디에도 쓰이지 않은 string 이 남았습니다.", "out": "ZenjectException: Passed unnecessary parameters when injecting into type 'Enemy'" }
      ]
    }
  ],
  "note": "extraArgs 는 이름이 아니라 타입으로 짝을 찾습니다. 같은 타입 인자가 둘이면 순서대로 소비됩니다."
}
</script>
</x-flow>

생성자 안에서 `[Inject]` 필드를 읽으면 `null` 이고, 메서드는 필드가 모두 찬 뒤 부모부터 불립니다. `[InjectOptional] float _speed = 5f;` 처럼 선택 주입 필드는 바인딩이 없으면 대입을 건너뛰어 초기화식 값이 남습니다. `MonoBehaviour` 는 생성자 단계 없이 필드와 메서드만 이 순서로 채웁니다.

## 타입 정보는 기본이 리플렉션이고, 베이킹이 빌드 때 대신 만든다

타입 정보는 캐시에 없을 때 한 번 만들어집니다. 타입 안에 `__zenCreateInjectTypeInfo` 메서드가 있으면 그것을 부르고, 없으면 리플렉션으로 만듭니다. 그 메서드를 넣는 것이 **리플렉션 베이킹**입니다. `Assets > Create > Zenject > Reflection Baking Settings` 로 설정 에셋을 만들면 어셈블리가 컴파일될 때마다 DLL 에 IL 메서드를 끼워 넣습니다. 에셋이 없으면 아무 일도 없어서, 설치만 한 프로젝트는 전부 리플렉션입니다.

리플렉션으로 만든 대입 함수도 환경마다 다릅니다. 에디터와 Mono 는 식 트리를 컴파일한 대리자를 쓰고, IL2CPP 는 `FieldInfo.SetValue` 를 씁니다. 베이킹 설정 에셋이 있는 빌드와 없는 빌드를 비교했습니다.

| 빌드 | 첫 Instantiate 20종 합 | Inject 1회 |
| --- | --- | --- |
| Mono, 베이킹 끔 | 17.2 ms | 6.3 µs |
| Mono, 베이킹 켬 | 10.6 ms | 6.3 µs |
| IL2CPP 스트리핑 Low, 베이킹 끔 | 1.7 ms | 7.8 µs |
| IL2CPP 스트리핑 High, 베이킹 켬 | 1.7 ms | 6.6 µs |

Mono 는 리플렉션 분석과 식 트리 컴파일이 빠진 첫 주입이 약 40% 줄었고(JIT 몫 포함), 반복 주입은 그대로였습니다. 반복 주입의 대부분은 필드마다 하는 조회라 대입 방식을 바꿔도 크게 달라지지 않습니다. 베이킹의 이득은 속도보다 스트리핑 쪽에서 컸습니다.

## High 스트리핑은 [Inject] 특성을 지워 필드를 조용히 비운다

`[Inject]` 는 `PreserveAttribute` 를 상속해 스트리핑을 막으려 하고, `link.xml` 은 `Zenject-usage` 어셈블리를 보존합니다. 그래도 High 에서는 막히지 않았습니다(IL2CPP 개발 빌드 플레이어, `[Inject]` 필드 셋을 가진 최상위와 중첩 클래스, `link.xml` 보존 여부를 함께 확인).

| 스트리핑 | 베이킹 | 필드 셋 | 필드에 남은 `[Inject]` 특성 | 예외 |
| --- | --- | --- | --- | --- |
| Minimal | 끔 | 모두 채워짐 | 있음 | 없음 |
| Low | 끔 | 모두 채워짐 | 있음 | 없음 |
| High | 끔 | **모두 `null`** | **없음** | **없음** |
| High | 켬 | 모두 채워짐 | 없음 | 없음 |

필드는 남고 그 위의 특성만 지워졌습니다. 리플렉션은 특성이 붙은 멤버만 주입 대상으로 모으니, 특성이 없으면 주입할 것이 없는 타입으로 보고 지나갑니다. 베이킹은 주입할 필드 목록을 빌드 때 IL 로 넣어 두어서 특성이 지워져도 채웠습니다. 같은 High 빌드(베이킹 끔)에서 `BindMemoryPool` 을 넣은 `SceneContext` 씬은 로드 중 `NullReferenceException` 으로 서지 못했고, 베이킹을 켜자 풀이 정상으로 만들어졌습니다.

## 주의할 점

### 1. 생성자가 둘 이상이면 [Inject] 를 붙인다

**안 지키면 생기는 문제.** `[Inject]` 없이 public 생성자가 둘이면 인자가 가장 적은 쪽이 불립니다. 의존을 더 받는 생성자를 새로 추가했는데 예전 생성자가 남아 있으면, 새 의존은 들어오지 않고 그 필드를 처음 쓰는 곳에서 널 참조 예외가 납니다. 경고는 없습니다.

**이렇게 쓴다.** 주입에 쓸 생성자에 `[Inject]` 를 붙입니다. `ZenjectSettings` 의 `Constructor Choice Strategy` 를 `InjectAttribute` 로 바꿔 강제할 수도 있는데, 그러면 생성자를 쓰지 않은 클래스가 IL2CPP 에서만 `More than one (or zero) constructors found` 로 실패했습니다(에디터와 Mono 는 통과). 이 전략을 쓰면 인자 없는 생성자도 직접 적고 `[Inject]` 를 붙입니다.

### 2. 생성자에서 필드 주입을, 부모 메서드에서 파생의 준비를 기대하지 않는다

**안 지키면 생기는 문제.** 생성자에서 `[Inject]` 필드를 쓰면 `null` 이고, 부모의 `[Inject]` 메서드가 파생의 `[Inject]` 메서드에서 채울 값을 쓰면 아직 비어 있습니다. 상속 계층을 바꾸는 순간 조용히 깨집니다.

**이렇게 쓴다.** 의존은 생성자 인자로 받아 생성자 안에서 씁니다. `MonoBehaviour` 는 `[Inject]` 메서드 하나에서 인자로 받고, 그 메서드는 자기 클래스의 필드만 준비합니다. 부모와 파생이 협력하는 초기화는 `IInitializable.Initialize()` 로 옮깁니다.

### 3. IL2CPP 빌드에서는 주입 결과를 한 번 확인한다

**안 지키면 생기는 문제.** 스트리핑은 빌드에만 있습니다. High 로 올린 IL2CPP 빌드에서 모든 `[Inject]` 필드가 예외 없이 `null` 로 남았고, `link.xml` 로 보존한 어셈블리도 같았습니다. 에디터만 보고 출시하면 빌드에서만 나는 널 참조를 만납니다.

**이렇게 쓴다.** High 스트리핑을 쓰려면 베이킹 설정 에셋을 만들어 빌드에서 켜고, 네임스페이스 패턴에 자기 네임스페이스를 넣습니다(비우면 `Zenject` 타입만 굽습니다). 스트리핑 수준을 바꿀 때마다 개발 빌드에서 주요 서비스의 필드가 채워졌는지 로그로 확인합니다.

## 정리

Zenject 는 **타입마다 한 번 리플렉션으로 읽은 `InjectTypeInfo` 로 주입합니다.**

- 생성자는 `[Inject]` 표시, public 하나, 인자 최소 순으로 고르고, 마지막 규칙은 경고 없이 적은 쪽을 부릅니다.
- 채우는 순서는 생성자, 부모부터 필드, 부모부터 `[Inject]` 메서드입니다.
- 리플렉션이 기본이라 High 스트리핑에서 특성이 지워지면 필드가 예외 없이 비고, 리플렉션 베이킹을 켜면 채워집니다.