---
title: "Zenject 의 진입점은 MonoKernel 의 Update 안에서 돈다"
description: "MonoBehaviour 가 아닌 객체의 Tick 을 Zenject 는 컨테이너마다 붙는 MonoKernel 의 Update 에서 부릅니다. 그래서 순서는 .meta 의 실행 순서와 BindExecutionOrder 숫자가 정하고, Tick 하나가 던지면 그 프레임의 뒤 순서가 전부 건너뜁니다."
author: "유니얼"
pubDate: 2026-09-27
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "DI"]
canonical: https://blog.unialgames.com/posts/unity-zenject-kernel/
---
```csharp
public class Spawner : IInitializable, ITickable, IDisposable
{
    public void Initialize() { /* 첫 웨이브 준비 */ }
    public void Tick()       { /* 매 프레임 타이머 */ }
    public void Dispose()    { /* 구독 해제 */ }
}

Container.BindInterfacesAndSelfTo<Spawner>().AsSingle();
```

`Spawner` 는 `MonoBehaviour` 가 아닌데 매 프레임 `Tick()` 이 불립니다. 그러면 다른 `MonoBehaviour.Update` 보다 먼저일까, 나중일까요. 그리고 `Tick` 하나가 예외를 던지면 나머지는 어떻게 될까요.

Zenject 의 진입점은 **컨테이너마다 붙는 `MonoKernel` 이라는 `MonoBehaviour` 의 콜백 안에서 돕니다.** 새 루프 단계가 아니라 평범한 `Update` 하나라서, 앞뒤는 스크립트 실행 순서가 정하고 예외는 그 `Update` 를 빠져나갑니다.

## 커널은 유니티 콜백을 매니저에 넘긴다

`MonoKernel` 의 본문은 유니티 콜백 다섯 개를 매니저에 넘기는 것이 전부입니다.

| 커널의 콜백 | 부르는 것 | 넘겨받는 인터페이스 |
| --- | --- | --- |
| `Start` | `InitializableManager.Initialize()` | `IInitializable` |
| `Update` | `TickableManager.Update()` | `ITickable` |
| `FixedUpdate` | `TickableManager.FixedUpdate()` | `IFixedTickable` |
| `LateUpdate` | `TickableManager.LateUpdate()` | `ILateTickable` |
| `OnDestroy` | `DisposableManager.Dispose()`, `LateDispose()` | `IDisposable`, `ILateDisposable` |

매니저는 `List<ITickable>` 같은 목록을 주입받고, 진입점 객체는 그 목록이 모일 때 만들어집니다. 그래서 `IInitializable` 이나 `ITickable` 을 **계약으로** 걸어야 목록에 들어갑니다. `Bind<Spawner>().AsSingle()` 처럼 구체 타입만 걸면 `Tick()` 도 `Initialize()` 도 불리지 않습니다.

유니티 콜백은 커널 하나에 한 번이고 나머지는 인터페이스 호출이라, 진입점 하나는 `Update` 하나보다 쌉니다.

| 하나당 | Mono | IL2CPP |
| --- | --- | --- |
| 커널이 부르는 빈 `Tick()` | 25 ns | 22 ns |
| 유니티가 부르는 빈 `Update()` | 261 ns | 143 ns |

## Tick 목록은 다음 프레임부터 우선순위 순으로 선다

`TickableManager` 는 연결 리스트 하나와 대기 목록 하나를 둡니다. 새 진입점은 대기 목록에 섰다가 다음 프레임 처음에 우선순위 자리로 끼워지고, 지우기는 표시만 했다가 루프 뒤에 뺍니다.

<x-flow>
<script type="application/json">
{
  "label": "TickableManager.Update 한 번 (줄인 코드)",
  "lines": [
    "if (IsPaused) return;",
    "AddQueuedTasks();             // 대기 목록을 우선순위 자리에 끼운다",
    "for (var node = _tasks.First; node != null; node = next)",
    "    if (!node.Value.IsRemoved) node.Value.Task.Tick();",
    "ClearRemovedTasks(_tasks);    // 표시만 해 둔 것을 이제 뺀다"
  ],
  "cases": [
    {
      "label": "A 가 Tick 안에서 C 를 더하고 B 를 뺀다",
      "state": "프레임 1 · 목록 = [A(-10), B(0)] · 대기 = [ ]",
      "path": [
        { "line": 0, "verdict": "false" },
        { "line": 1, "why": "대기 목록이 비어 있습니다." },
        { "line": 2 },
        { "line": 3, "why": "A.Tick 안에서 tickableManager.Add(C, -5) 와 Remove(B) 를 부릅니다. C 는 대기 목록으로, B 는 IsRemoved 표시만 받습니다.", "state": "프레임 1 · 목록 = [A(-10), B(0) 지움표시] · 대기 = [C(-5)]", "out": "프레임 1: A.Tick" },
        { "line": 2 },
        { "line": 3, "why": "B 는 표시가 있어 건너뜁니다." },
        { "line": 4, "why": "루프가 끝난 뒤 B 를 리스트에서 뺍니다.", "state": "프레임 1 · 목록 = [A(-10)] · 대기 = [C(-5)]" },
        { "line": 0, "verdict": "false", "state": "프레임 2 · 목록 = [A(-10)] · 대기 = [C(-5)]" },
        { "line": 1, "why": "C 를 넣을 자리를 앞에서부터 찾습니다. C 보다 숫자가 큰 첫 칸 앞에 끼우는데, 없으니 맨 뒤입니다.", "state": "프레임 2 · 목록 = [A(-10), C(-5)] · 대기 = [ ]" },
        { "line": 2 },
        { "line": 3, "out": "프레임 2: A.Tick" },
        { "line": 2 },
        { "line": 3, "out": "프레임 2: C.Tick" },
        { "line": 4 }
      ]
    },
    {
      "label": "IsPaused = true",
      "state": "목록 = [A(-10), C(-5)] · 대기 = [D(0)]",
      "path": [
        { "line": 0, "verdict": "true", "why": "첫 줄에서 돌아갑니다. 대기 목록의 D 도 들어가지 않은 채 기다립니다.", "out": "이번 프레임 Tick 없음" }
      ],
      "skip": [1, 2, 3, 4]
    }
  ],
  "note": "더한 것은 다음 프레임부터, 뺀 것은 이번 루프에서 바로 건너뜁니다. 같은 숫자끼리는 먼저 들어온 쪽이 앞에 섭니다."
}
</script>
</x-flow>

숫자는 `Container.BindExecutionOrder<T>(숫자)` 로 줍니다. 숫자 하나가 세 매니저의 순서를 함께 정하는데, 해제만 방향이 반대입니다.

| 매니저 | `SaveSystem(-10)`, `Spawner(0)`, `Hud(10)` 일 때 |
| --- | --- |
| `InitializableManager` | `SaveSystem` → `Spawner` → `Hud` |
| `TickableManager` | `SaveSystem` → `Spawner` → `Hud` |
| `DisposableManager` | `Hud` → `Spawner` → `SaveSystem` |

작은 숫자는 "먼저 켜지고 먼저 돌고 나중에 꺼진다" 입니다. 등록 순서는 숫자 앞에서 아무 힘이 없습니다. `IsPaused` 는 그 컨테이너의 Tick 계열만 멈추고, 다른 컨테이너의 커널은 계속 돕니다.

## MonoBehaviour 와의 앞뒤는 .meta 의 실행 순서가 정한다

Zenject 소스에는 `[DefaultExecutionOrder]` 가 없습니다. 커널의 실행 순서는 스크립트 옆 `.meta` 파일에 있습니다.

```yaml
# SceneContext.cs.meta            executionOrder: -9999
# ProjectKernel.cs.meta           executionOrder: -9998
# SceneKernel.cs.meta             executionOrder: -9997
# DefaultGameObjectKernel.cs.meta executionOrder: -9996
```

<x-flow>
<script type="application/json">
{
  "label": "한 프레임 안에서 진입점과 MonoBehaviour 가 불리는 순서",
  "lines": [
    "SceneKernel.Update()       → ITickable.Tick()",
    "MonoBehaviour.Update()",
    "SceneKernel.LateUpdate()   → ILateTickable.LateTick()",
    "MonoBehaviour.LateUpdate()"
  ],
  "cases": [
    {
      "label": "커널 -9997, MB 0 (기본)",
      "state": "커널 -9997 · MonoBehaviour 0",
      "path": [
        { "line": 0, "why": "숫자가 작은 커널이 먼저 Update 를 받습니다.", "out": "Tick" },
        { "line": 1, "out": "MB.Update" },
        { "line": 2, "out": "LateTick" },
        { "line": 3, "out": "MB.LateUpdate" }
      ],
      "note": "Initialize 도 커널의 Start 라서 MB.Start 보다 먼저입니다."
    },
    {
      "label": "커널 -9997, MB -9998",
      "state": "커널 -9997 · MonoBehaviour -9998",
      "path": [
        { "line": 1, "why": "커널보다 작은 숫자를 받은 MonoBehaviour 는 진입점보다 먼저 돕니다.", "out": "MB.Update" },
        { "line": 0, "out": "Tick" },
        { "line": 3, "out": "MB.LateUpdate" },
        { "line": 2, "out": "LateTick" }
      ],
      "note": "Update 와 LateUpdate 는 숫자대로 MB 가 먼저지만, 측정에서 Initialize 는 이 MB 의 Start 보다도 먼저 불렸습니다."
    }
  ],
  "note": "진입점은 MonoBehaviour 와 같은 Update 단계 안에 있습니다. 앞뒤는 구조가 아니라 숫자가 정합니다."
}
</script>
</x-flow>

두 경우 모두 실제 `SceneContext` 씬에서 네 프레임을 적은 순서입니다(에디터, Mono·IL2CPP 개발 빌드 플레이어 모두 같음). 기본 설정에서는 진입점이 사용자 `MonoBehaviour` 를 감싸지만, 그 보장은 코드가 아니라 `.meta` 의 숫자에 있습니다.

## 예외 하나가 뒤 순서를 멈춘다

`InitializableManager` 는 하나가 던지면 타입 이름을 붙여 다시 던지고, 목록의 나머지 `Initialize` 는 불리지 않습니다. 커널은 이미 `_hasInitialized = true` 를 세웠으므로 다시 시도하지 않는데, `Update` 는 매니저가 있는지만 보므로 다음 프레임부터 **모든 `Tick` 이 평소처럼 돕니다.** 초기화를 못 받은 객체의 `Tick` 까지 포함해서입니다.

`Tick` 쪽 루프에는 `try` 가 없습니다. `A`(-1), `Boom`(0), `B`(1) 을 세우고 `Boom.Tick()` 이 매 프레임 던지게 하자, 5프레임 동안 `A.Tick` 과 `Boom.Tick` 은 5번, `B.Tick` 은 한 번도 불리지 않았습니다(에디터, Mono·IL2CPP 개발 빌드 플레이어 모두). 콘솔에는 `Boom` 의 예외뿐이고 `B` 가 멈췄다는 표시는 없었습니다. `LateTick` 은 별도 루프라 셋 다 불렸습니다.

## 주의할 점

### 1. 커널의 실행 순서를 .meta 째로 지킨다

**안 지키면 생기는 문제.** Zenject 를 옮기거나 `.meta` 를 커밋에서 빠뜨려 값이 0 이 되면, 기본 순서 0 인 스크립트와 숫자가 같아져 `Tick` 과 `Update`, `Initialize` 와 `Start` 의 앞뒤가 보장되지 않습니다. `SceneKernel` 을 0 으로 바꾼 이 Mac 의 Mono 빌드에서는 그래도 커널이 먼저였고, 시험용 커널로 잰 옛 Windows 측정에서는 뒤집혔습니다. 코드에는 아무 변화가 없어 원인을 찾기 어렵습니다.

**이렇게 쓴다.** Zenject 폴더는 `.meta` 와 함께 버전 관리에 넣고, 업그레이드 뒤에는 **Project Settings > Script Execution Order** 에서 네 값(-9999, -9998, -9997, -9996)이 남아 있는지 봅니다. 순서가 필요한 `MonoBehaviour` 의 일은 `ITickable` 로 옮기고 `BindExecutionOrder` 로 세워, 순서를 정하는 수단을 하나로 모읍니다.

### 2. Initialize 안의 실패는 스스로 잡는다

**안 지키면 생기는 문제.** 한 진입점의 `Initialize()` 가 던지면 뒤 순서의 초기화는 건너뛰고 다시 시도되지 않는데, `Tick()` 은 다음 프레임부터 모두 돕니다. 초기화에서 채웠어야 할 필드가 `null` 인 객체들이 매 프레임 `Tick` 을 받아, 원래 예외 하나가 프레임마다 쏟아지는 널 참조 예외로 번집니다.

**이렇게 쓴다.** `Initialize()` 본문에서 실패할 수 있는 일은 스스로 `try/catch` 로 감싸고, 실패했으면 자기 `Tick()` 이 아무것도 하지 않도록 상태를 남깁니다. Zenject 에는 진입점 예외를 받는 훅이 없습니다.

### 3. Tick 안에서 던지지 않는다

**안 지키면 생기는 문제.** `Tick()` 하나가 던지면 그 프레임에 뒤에 줄 선 모든 진입점이 건너뜁니다. 같은 오류가 매 프레임 나면 우선순위가 뒤인 진입점은 영영 돌지 않고, 콘솔에는 던진 쪽만 보입니다.

**이렇게 쓴다.** 외부 입력이나 네트워크처럼 실패가 예상되는 `Tick()` 은 본문을 스스로 감쌉니다. 우선순위가 앞인 진입점일수록 뒤의 것을 모두 막으니 더 조심합니다.

## 정리

Zenject 의 **진입점은 컨테이너마다 붙는 `MonoKernel` 의 `Start`, `Update`, `OnDestroy` 안에서 돕니다.**

- 진입점은 인터페이스를 계약으로 걸어야 매니저 목록에 들고, 하나를 부르는 값은 빈 `Update` 의 7분의 1에서 10분의 1이었습니다.
- `BindExecutionOrder` 숫자가 초기화, Tick, 해제 순서를 함께 정하고, `MonoBehaviour` 와의 앞뒤는 `.meta` 의 실행 순서가 정합니다.
- `Initialize` 예외는 뒤의 초기화만 막고 `Tick` 은 계속 돌리며, `Tick` 예외는 그 프레임의 뒤 순서를 모두 건너뜁니다.