---
title: "Reflex 의 씬 주입은 가장 먼저 도는 Awake 하나에서 끝난다"
description: "씬의 Awake 에서 [Inject] 필드를 바로 쓸 수 있는 것은 실행 순서 -10억인 ContainerScope.Awake 가 씬 전체를 먼저 주입하기 때문입니다. 그 한 번 밖에서 생긴 오브젝트와 씬 해제 순서는 쓰는 쪽이 챙겨야 합니다."
author: "유니얼"
pubDate: 2026-09-28
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "DI"]
canonical: https://blog.unialgames.com/posts/unity-reflex-unity/
---
```csharp
public class Player : MonoBehaviour
{
    [Inject] IInput _input;

    void Awake() => _input.Enable();   // Awake 에서 바로 쓴다
}
```

컨테이너 없이 이 모양을 만들려면 `Awake` 안에서 `FindObjectOfType<InputService>()` 로 찾거나 `InputService.Instance` 가 먼저 채워져 있기를 바라야 합니다. 둘 다 누가 먼저 `Awake` 를 도느냐에 기댑니다.

Reflex 는 **씬에서 가장 먼저 도는 `ContainerScope.Awake` 하나에서 컨테이너를 만들고 씬 전체를 주입**합니다. 그 뒤에 생기는 것은 스스로 챙기지 않습니다. 기준 판본은 14.3.1 입니다.

## 실행 순서 -10억의 Awake 가 컨테이너를 만든다

씬에 놓는 `ContainerScope` 는 `[DefaultExecutionOrder(-1_000_000_000)]` 입니다. 코드 속성이라 `.meta` 를 잃어도 유지되고, 사용자 스크립트가 이보다 앞설 일은 사실상 없습니다. 이 `Awake` 가 부르는 것이 씬 주입의 전부입니다.

<x-flow>
<script type="application/json">
{
  "label": "ContainerScope.Awake 가 부르는 InjectScene (두 함수를 합쳐 줄인 코드)",
  "lines": [
    "void InjectScene(Scene scene, ContainerScope scope)",
    "{",
    "    if (Container.RootContainer == null)",
    "        Container.RootContainer = CreateRootContainer();   // RootScopes 설치 후 Build",
    "    var sceneContainer = Container.RootContainer.Scope(b => {",
    "        scope.InstallBindings(b);                        // 씬 설치자",
    "        ContainerScope.OnSceneContainerBuilding?.Invoke(scene, b);",
    "    });                                                  // 여기서 Build",
    "    if (ContainersPerScene.TryAdd(scene, sceneContainer))",
    "        SceneInjector.Inject(scene, sceneContainer);     // 씬 전체에 [Inject]",
    "    else",
    "        throw new SceneHasMultipleSceneScopesException(scene);",
    "}"
  ],
  "cases": [
    {
      "label": "첫 씬 Boot",
      "state": "RootContainer = null · ContainersPerScene = {}",
      "path": [
        { "line": 2, "verdict": "true", "why": "아직 루트가 없습니다. 루트는 첫 SceneScope 가 깨어날 때 만들어집니다." },
        { "line": 3, "why": "설정 에셋을 꺼내고, 활성 RootScope 프리팹의 설치자를 돌린 뒤 Build 합니다.", "state": "RootContainer = 루트 · ContainersPerScene = {}" },
        { "line": 4, "why": "루트를 부모로 한 자식 컨테이너를 만듭니다." },
        { "line": 5, "why": "이 ContainerScope 와 그 자식에 붙은 IInstaller 를 차례로 부릅니다." },
        { "line": 6, "why": "정적 이벤트가 설치자 뒤에 불립니다. 같은 계약을 여기서 등록하면 이쪽이 이깁니다." },
        { "line": 7, "state": "RootContainer = 루트 · Boot 컨테이너 생성됨" },
        { "line": 8, "verdict": "true", "state": "ContainersPerScene = { Boot }" },
        { "line": 9, "out": "Boot 씬의 MonoBehaviour 전부에 주입" }
      ]
    },
    {
      "label": "두 번째 씬 Game",
      "state": "RootContainer = 루트 · ContainersPerScene = { Boot }",
      "path": [
        { "line": 2, "verdict": "false", "why": "루트는 이미 있습니다. 다시 만들지 않습니다." },
        { "line": 4, "why": "부모는 이번에도 루트입니다. Boot 가 아닙니다." },
        { "line": 5 },
        { "line": 6 },
        { "line": 7, "state": "Game 컨테이너 생성됨 (부모 = 루트)" },
        { "line": 8, "verdict": "true", "state": "ContainersPerScene = { Boot, Game }" },
        { "line": 9, "out": "Game 씬의 MonoBehaviour 전부에 주입" }
      ],
      "skip": [3]
    },
    {
      "label": "한 씬에 SceneScope 가 둘",
      "state": "Game 의 첫 ContainerScope 는 끝남 · ContainersPerScene = { Boot, Game }",
      "path": [
        { "line": 2, "verdict": "false", "why": "두 번째 ContainerScope 의 Awake 입니다." },
        { "line": 4 },
        { "line": 5, "why": "두 번째 스코프의 설치자가 모두 돕니다." },
        { "line": 6, "why": "정적 이벤트도 한 번 더 불립니다." },
        { "line": 7, "why": "Build 가 끝났습니다. Eager 싱글턴이 있었다면 이미 만들어졌고, 컨테이너는 루트의 자식 목록에 들어갔습니다.", "state": "Game 컨테이너 2 생성됨 · 루트의 자식 3개" },
        { "line": 8, "verdict": "false", "why": "같은 씬이 이미 있어 넣지 못합니다." },
        { "line": 11, "out": "SceneHasMultipleSceneScopesException. 만든 컨테이너는 치우지 않는다" }
      ],
      "skip": [3, 9]
    }
  ],
  "note": "씬 컨테이너의 부모는 언제나 루트이고, 한 씬에 하나인지는 컨테이너를 다 만든 뒤에야 검사합니다."
}
</script>
</x-flow>

- **루트 컨테이너는 늦게 생깁니다.** 첫 `ContainerScope` 가 깨어날 때 만들어지므로, 그 전인 `BeforeSceneLoad` 에서 `Container.RootContainer` 는 `null` 입니다. 씬 스코프가 없는 씬은 주입을 통째로 건너뜁니다.
- **씬 컨테이너의 부모는 기본이 루트입니다.** 씬끼리는 부모 자식이 되지 않습니다.
- **한 씬에 하나인지는 빌드 뒤에 검사합니다.** 둘째 스코프는 설치자와 Eager 를 다 돌린 뒤 예외를 내고, 그 컨테이너는 씬을 내려도 루트에 남습니다. 이 Mac의 세 조건 모두 씬을 내린 뒤 루트의 자식이 1개 남았습니다.

## 씬 주입은 꺼진 것까지 모든 MonoBehaviour 를 훑는다

`SceneInjector` 는 씬의 루트 오브젝트마다 `GetComponentsInChildren<MonoBehaviour>(true)` 로 **비활성까지** 모아 `AttributeInjector.Inject` 를 부릅니다. 아직 `Awake` 가 불리지 않은 오브젝트도 이때 주입되니, 씬에 놓인 오브젝트는 자기 `Awake` 에서 `[Inject]` 필드를 씁니다.

`[Inject]` 가 없는 컴포넌트도 한 번씩 거치므로 비용은 **씬의 `MonoBehaviour` 수**를 따릅니다. `[Inject]` 없는 컴포넌트 N 개를 채운 씬에서 스코프를 켜는 한 줄을 쟀습니다(이 Mac, 개발 빌드 플레이어, 5회 중앙값).

| 씬 | Mono | IL2CPP |
| --- | --- | --- |
| 1종 1,000개 | 0.460 ms | 0.619 ms |
| 1종 10,000개 | 5.19 ms | 6.86 ms |
| 100종 각 1개, 첫 회 | 22.8 ms | 17.3 ms |
| 100종 각 1개, 캐시가 찬 뒤 | 0.094 ms | 0.121 ms |

캐시된 타입은 컴포넌트 하나에 0.4~0.7 µs 이고, 처음 보는 타입은 리플렉션으로 멤버를 훑느라 한 종에 0.2 ms 안팎입니다. 이 값은 전부 `ContainerScope.Awake` 한 칸에 몰립니다.

## 실행 중에 만든 오브젝트는 주입이 활성화보다 먼저여야 한다

Reflex 는 실행 중의 `Instantiate` 를 알 방법이 없습니다. 직접 `GameObjectInjector.InjectRecursive(go, container)` 를 부르는데, 활성 프리팹은 `Instantiate` 가 돌아오기 전에 이미 `Awake` 를 돕니다.

<x-flow>
<script type="application/json">
{
  "label": "Player 의 Awake 가 [Inject] 보다 먼저인가",
  "cases": [
    {
      "label": "씬에 놓인 오브젝트",
      "lines": [
        "// 씬 로드. Awake 는 실행 순서대로",
        "ContainerScope.Awake()          // 순서 -1,000,000,000",
        "    SceneInjector.Inject(...)   // 씬 전체에 [Inject]",
        "Player.Awake()                  // 순서 0",
        "// 첫 프레임",
        "Player.Start()"
      ],
      "path": [
        { "line": 1, "why": "실행 순서가 가장 작아 씬에서 제일 먼저 깨어납니다." },
        { "line": 2, "why": "Player 는 아직 Awake 전이지만 비활성 포함으로 훑기 때문에 이미 대상입니다.", "out": "Inject" },
        { "line": 3, "why": "_input 이 채워진 채로 들어옵니다.", "out": "Awake" },
        { "line": 5, "out": "Start" }
      ]
    },
    {
      "label": "Instantiate 한 뒤 주입",
      "lines": [
        "var p = Instantiate(playerPrefab);             // 활성 프리팹",
        "    // 이 줄 안에서 p.Awake(), p.OnEnable() 이 돈다",
        "GameObjectInjector.InjectRecursive(p.gameObject, container);",
        "// 다음 프레임",
        "p.Start()"
      ],
      "path": [
        { "line": 0, "why": "활성 오브젝트는 Instantiate 가 돌아오기 전에 Awake 와 OnEnable 을 부릅니다." },
        { "line": 1, "why": "Reflex 는 이 생성을 알 방법이 없습니다. _input 은 null 입니다.", "out": "Awake (_input == null)" },
        { "line": 2, "out": "Inject" },
        { "line": 4, "out": "Start" }
      ]
    },
    {
      "label": "비활성 부모 아래 만들고 주입한 뒤 옮기기",
      "lines": [
        "var p = Instantiate(playerPrefab, inactiveHolder);   // 계층상 비활성",
        "GameObjectInjector.InjectRecursive(p.gameObject, container);",
        "p.transform.SetParent(null);                        // 여기서 활성이 된다",
        "    // 이 줄 안에서 p.Awake(), p.OnEnable() 이 돈다",
        "// 다음 프레임",
        "p.Start()"
      ],
      "path": [
        { "line": 0, "why": "부모가 꺼져 있으면 계층상 비활성이라 Awake 가 불리지 않습니다." },
        { "line": 1, "why": "비활성 포함으로 훑으므로 주입은 됩니다.", "out": "Inject" },
        { "line": 2 },
        { "line": 3, "out": "Awake" },
        { "line": 5, "out": "Start" }
      ]
    }
  ],
  "note": "Reflex 는 씬 로드 한 번에만 스스로 주입합니다. 그 뒤에 생기는 것은 부르는 쪽이 주입과 활성화의 순서를 정합니다."
}
</script>
</x-flow>

이 Mac 의 에디터와 Mono·IL2CPP 개발 빌드 플레이어에서 세 경우 모두 탭과 같은 순서였습니다. 부모 없이 만든 오브젝트는 **활성 씬**에 들어가므로, 그 씬에 스코프가 없거나 `DontDestroyOnLoad` 로 옮긴 오브젝트면 `gameObject.scene.GetSceneContainer()` 가 예외를 던집니다. 스포너가 컨테이너를 주입받아 들고 있다가 넘기는 편이 안전합니다.

## 부모를 바꾼 씬은 부모와 함께 비워진다

씬 컨테이너는 `sceneUnloaded` 에서, 루트는 `Application.quitting` 에서 해제됩니다. `ContainerScope.OnSceneContainerBuilding` 에서 `SetParent(Boot 컨테이너)` 로 부모를 바꿀 수 있는데, 컨테이너의 `Dispose` 는 자식부터 해제하고 표를 비웁니다.

<x-wiring>
<script type="application/json">
{
  "label": "부모 씬 Boot 를 자식 씬 Lobby 보다 먼저 내렸을 때",
  "width": 760,
  "height": 300,
  "nodes": [
    { "id": "sm", "title": "SceneManager", "sub": "sceneUnloaded(Boot)", "x": 0, "y": 120, "w": 150, "h": 54, "aside": true },
    { "id": "root", "title": "RootContainer", "sub": "앱이 끝날 때까지", "x": 235, "y": 8, "w": 150, "h": 54 },
    { "id": "boot", "title": "Boot 컨테이너", "sub": "먼저 내린 씬", "x": 235, "y": 120, "w": 150, "h": 54 },
    { "id": "lobby", "title": "Lobby 컨테이너", "sub": "부모가 Boot", "x": 235, "y": 232, "w": 150, "h": 54 },
    { "id": "obj", "title": "Lobby 의 스크립트", "sub": "씬은 살아 있다", "x": 610, "y": 232, "w": 150, "h": 54, "aside": true }
  ],
  "edges": [
    { "from": "sm", "to": "boot", "label": "Dispose()", "bend": 0 },
    { "from": "boot", "to": "lobby", "label": "자식 먼저 Dispose", "bend": 0 },
    { "from": "boot", "to": "root", "label": "Children 에서 빠진다", "bend": 0 },
    { "from": "obj", "to": "lobby", "label": "GetSceneContainer().Resolve<T>()", "bend": 0 }
  ],
  "steps": [
    { "edge": 0, "why": "Boot 씬이 내려가면 Reflex 가 Boot 컨테이너의 Dispose 를 부릅니다." },
    { "edge": 1, "why": "Dispose 는 자기 자식부터 해제합니다. Lobby 씬은 아직 떠 있는데 그 컨테이너가 먼저 해제됩니다." },
    { "node": "lobby", "why": "Lobby 컨테이너의 조회 테이블이 비고, 들고 있던 IDisposable 이 모두 Dispose 됩니다. ContainersPerScene 에서는 빠지지 않습니다." },
    { "edge": 2, "why": "Boot 자신도 루트의 자식 목록에서 빠지고 해제를 마칩니다." },
    { "edge": 3, "why": "Lobby 의 스크립트가 컨테이너를 꺼내면 받기는 합니다. 하지만 테이블이 비어 있어 UnknownContractException 이 납니다. 이미 주입된 필드는 해제된 객체를 가리킵니다." }
  ],
  "note": "README 의 경고는 '예상하지 못한 동작' 까지입니다. 소스와 실측으로 보면 증상은 해제된 서비스 사용과 빈 컨테이너 조회 예외 두 가지입니다."
}
</script>
</x-wiring>

Boot 를 올리고 부모를 Boot 로 건 Lobby 를 추가로 올린 뒤 Boot 만 내렸습니다. 이 Mac 의 세 조건 모두 Lobby 컨테이너에서 Lobby 자신의 서비스를 꺼내도 `UnknownContractException` 이었고, Lobby 스크립트가 미리 받아 둔 Boot 서비스와 Lobby 서비스가 둘 다 해제돼 있었습니다.

## 주의할 점

### 1. ContainerScope 는 씬마다 하나, 켜진 오브젝트에 둔다

**안 지키면 생기는 문제.** 스코프가 꺼진 오브젝트에 있으면 `Awake` 가 없어 씬 전체가 주입되지 않고, 경고도 없습니다. 둘을 두면 둘째가 설치자와 Eager 를 다 돌린 뒤 예외를 내고, 그 컨테이너는 루트에 남습니다.

**이렇게 쓴다.** 씬마다 **GameObject › Reflex › SceneScope** 로 만든 오브젝트 하나를 켜 둡니다. 첫 씬에도 빈 스코프를 두어 루트를 가장 먼저 세웁니다.

### 2. 런타임에 만든 오브젝트는 꺼 둔 채로 주입한다

**안 지키면 생기는 문제.** 활성 프리팹을 `Instantiate` 한 뒤 주입하면 `Awake` 와 `OnEnable` 은 이미 `null` 필드로 지나갔습니다. 반대로 씬에 놓인 오브젝트에 `GameObjectSelfInjector` 를 붙이면 씬 주입과 합쳐 `[Inject]` 메서드가 두 번 불립니다(이 Mac, 세 조건 모두 2회, 예외나 로그 없음).

**이렇게 쓴다.** 꺼진 부모 아래 만들고 `InjectRecursive` 로 주입한 뒤 옮깁니다. `GameObjectSelfInjector` 는 실행 중에만 생기는 프리팹에 붙이고 모드는 기본값 `Recursive` 로 둡니다.

### 3. 정적 훅은 BeforeSceneLoad 에서 += 로 걸고 쓰고 나면 뺀다

**안 지키면 생기는 문제.** `OnRootContainerBuilding` 과 `OnSceneContainerBuilding` 은 공개 `Action` 필드라 `=` 로 대입하면 다른 구독이 사라집니다. 에디터는 `AfterAssembliesLoaded` 에서 두 훅을 비우므로, `SubsystemRegistration` 에서 건 훅은 빌드에서만 살아남습니다(이 Mac, 플레이어 `True`, 에디터 `False`). 부모를 바꾸는 씬 훅을 풀지 않으면 그 뒤 모든 씬이 같은 부모를 받습니다.

**이렇게 쓴다.** 루트 훅은 `[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]` 에서 `+=` 로 겁니다. 씬 훅은 로드 직전에 `+=`, 그 로드의 `completed` 에서 `-=` 합니다.

### 4. 부모를 바꾼 씬은 자식부터 내린다

**안 지키면 생기는 문제.** 부모 씬을 먼저 내리면 떠 있는 자식 씬의 컨테이너가 함께 비워지고 서비스가 해제됩니다. 새 조회는 등록을 빠뜨렸을 때와 같은 `UnknownContractException` 이라 엉뚱한 곳을 찾게 됩니다.

**이렇게 쓴다.** 추가로 쌓은 씬은 쌓은 역순으로 내립니다. 부모를 바꿀 필요가 없으면 기본값인 루트로 두고, 씬끼리 나눌 서비스는 루트에 올립니다.

## 정리

Reflex 의 씬 주입은 **가장 먼저 도는 `ContainerScope.Awake` 하나에서** 끝납니다.

- 첫 스코프가 루트를 늦게 만들고, 루트를 부모로 씬 컨테이너를 만든 뒤 씬 전체를 주입합니다.
- 비활성까지 모든 `MonoBehaviour` 를 훑어서 씬 오브젝트는 `Awake` 에서 필드를 쓰고, 비용은 컴포넌트 수를 따릅니다.
- 실행 중에 만든 오브젝트와 씬 해제 순서는 쓰는 쪽 몫입니다. 꺼 둔 채 주입하고, 부모를 바꾼 씬은 자식부터 내립니다.