---
title: "VContainer 의 진입점은 PlayerLoop 에 꽂힌다"
description: "순수 C# 클래스가 MonoBehaviour 없이 매 프레임 호출을 받습니다. 스코프마다 디스패처가 인터페이스별로 진입점을 모아 플레이어 루프에 꽂은 러너에 올리고, 자식 스코프는 부모의 싱글턴 진입점만 걸러 냅니다."
author: "유니얼"
pubDate: 2026-09-16
updatedDate: 2026-09-30
category: "game-dev/unity/libraries"
tags: ["Unity", "DI", "라이브러리"]
canonical: https://blog.unialgames.com/posts/unity-vcontainer-entrypoint/
---
```csharp
public class BattleLoop : ITickable
{
    public void Tick() { /* 매 프레임 */ }
}
```

이 클래스는 `MonoBehaviour` 가 아니고 씬에 붙어 있지도 않은데 매 프레임 호출을 받습니다. 어디서 불리는지 모르면 `Update` 와의 순서를 틀리고, 자식 스코프에서 같은 진입점이 두 번 도는 것을 못 알아챕니다.

VContainer 는 **스코프마다 디스패처가 진입점을 인터페이스별로 모아, 유니티 플레이어 루프에 직접 꽂은 러너에 올립니다.** `Initialize` 만 스코프의 `Awake` 안에서 곧바로 돌고, 자식 스코프는 부모의 싱글턴 진입점만 걸러 냅니다.

## 구현한 인터페이스가 불리는 자리를 정한다

| 인터페이스 | 불리는 자리 | 횟수 |
| --- | --- | --- |
| `IInitializable` · `IPostInitializable` | 컨테이너 빌드 직후, 스코프 `Awake` 안에서 동기 | 한 번 |
| `IStartable` · `IAsyncStartable` | 첫 프레임, 모노비헤이비어 `Start` 앞 | 한 번 |
| `IPostStartable` | 첫 프레임, 모노비헤이비어 `Start` 뒤 | 한 번 |
| `IFixedTickable` · `IPostFixedTickable` | 물리 갱신 앞 · 뒤 | 매번 |
| `ITickable` · `IPostTickable` | `Update` 앞 · 뒤 | 매 프레임 |
| `ILateTickable` · `IPostLateTickable` | `LateUpdate` 앞 · 뒤 | 매 프레임 |

`Singleton` 이나 `Scoped` 로 등록된 `IDisposable` 은 스코프가 닫힐 때 불리므로 사실상 종료 진입점입니다.

## 꼭 RegisterEntryPoint 로 등록하지 않아도 불린다

진입점을 실제로 부르는 것은 스코프마다 하나씩 있는 **디스패처**입니다. 디스패처는 빌드가 끝나면 컨테이너에 "`ITickable` 로 꺼낼 수 있는 것 전부" 를 달라고 해서 그 목록을 부릅니다. 어떤 메서드로 등록했는지는 보지 않고, **그 인터페이스로 꺼낼 수 있는지**만 봅니다.

그리고 `LifetimeScope` 는 빌드하기 전에 디스패처를 늘 자동으로 등록해 둡니다. 그래서 `LifetimeScope` 안이라면 평범한 `Register` 로 등록해도 인터페이스로 꺼낼 수 있으면 불립니다. 두 가지만 다릅니다.

- **적은 인터페이스만 불린다.** `.As<ITickable>()` 처럼 하나만 적으면 `ITickable` 목록에만 들어가서, 같은 클래스가 `IInitializable` 을 구현했어도 `Initialize` 는 안 불립니다. `AsImplementedInterfaces()` 나 `RegisterEntryPoint` 는 구현한 인터페이스 전부로 등록합니다.
- **`LifetimeScope` 없이 `ContainerBuilder` 만 쓰면 디스패처가 없다.** 이때는 `RegisterEntryPoint` 가 디스패처까지 같이 등록하므로 이것을 써야 돕니다.

## Initialize 는 Awake 안에서 돌고 나머지는 루프로 간다

디스패처는 빌드 콜백에서 `Dispatch()` 를 부릅니다. 빌드가 스코프의 `Awake` 안에서 일어나므로 이것도 `Awake` 안입니다.

```csharp
// EntryPointDispatcher.Dispatch (줄임)
var initializables = container.Resolve<ContainerLocal<IReadOnlyList<IInitializable>>>().Value;
for (var i = 0; i < initializables.Count; i++)
    initializables[i].Initialize();                            // 즉시 동기 실행

var tickables = container.Resolve<ContainerLocal<IReadOnlyList<ITickable>>>().Value;
if (tickables.Count > 0)                                       // 없으면 루프 부담 0
    PlayerLoopHelper.Dispatch(PlayerLoopTiming.Update, new TickableLoopItem(tickables, exceptionHandler));
```

**`Initialize()` 는 플레이어 루프를 타지 않습니다.** 다른 게임오브젝트의 `Awake` 가 아직 안 돌았을 수 있는 시점입니다.

## 러너는 기존 루프에 끼워 넣어 Update 앞뒤에서 돈다

`PlayerLoopHelper.EnsureInitialized()` 는 앱 전체에서 한 번(`Interlocked.CompareExchange`), `PlayerLoop.GetCurrentPlayerLoop()` 로 받은 루프의 다섯 그룹에 앞뒤로 하나씩, 열 개의 러너를 꽂습니다. 기본 루프가 아니라 현재 루프를 고치므로 **다른 라이브러리가 먼저 꽂아 둔 단계를 날리지 않습니다.**

<x-wiring>
<script type="application/json">
{
  "label": "한 프레임 안에서 진입점이 끼어드는 자리",
  "width": 620,
  "height": 714,
  "nodes": [
    {
      "id": "init",
      "title": "Initialize · PostInitialize",
      "sub": "스코프 Awake 안, 동기",
      "x": 150,
      "y": 8,
      "w": 320,
      "h": 54
    },
    {
      "id": "awake",
      "title": "다른 스크립트 Awake",
      "sub": "모노비헤이비어",
      "x": 150,
      "y": 100,
      "w": 320,
      "h": 54,
      "aside": true
    },
    {
      "id": "start",
      "title": "IStartable.Start",
      "sub": "EarlyUpdate, Start 앞",
      "x": 150,
      "y": 192,
      "w": 320,
      "h": 54
    },
    {
      "id": "mstart",
      "title": "Start()",
      "sub": "모노비헤이비어",
      "x": 150,
      "y": 284,
      "w": 320,
      "h": 54,
      "aside": true
    },
    {
      "id": "pstart",
      "title": "IPostStartable.PostStart",
      "sub": "EarlyUpdate 그룹 끝",
      "x": 150,
      "y": 376,
      "w": 320,
      "h": 54
    },
    {
      "id": "tick",
      "title": "ITickable.Tick",
      "sub": "Update 그룹, Update 앞",
      "x": 150,
      "y": 468,
      "w": 320,
      "h": 54
    },
    {
      "id": "mupdate",
      "title": "Update() · 코루틴",
      "sub": "모노비헤이비어",
      "x": 150,
      "y": 560,
      "w": 320,
      "h": 54,
      "aside": true
    },
    {
      "id": "ptick",
      "title": "IPostTickable.PostTick",
      "sub": "Update 그룹 끝",
      "x": 150,
      "y": 652,
      "w": 320,
      "h": 54
    }
  ],
  "edges": [
    {
      "from": "init",
      "to": "awake",
      "bend": 0
    },
    {
      "from": "awake",
      "to": "start",
      "label": "첫 프레임",
      "bend": 0
    },
    {
      "from": "start",
      "to": "mstart",
      "bend": 0
    },
    {
      "from": "mstart",
      "to": "pstart",
      "bend": 0
    },
    {
      "from": "pstart",
      "to": "tick",
      "label": "물리 갱신을 지나",
      "bend": 0
    },
    {
      "from": "tick",
      "to": "mupdate",
      "bend": 0
    },
    {
      "from": "mupdate",
      "to": "ptick",
      "bend": 0
    }
  ],
  "steps": [
    {
      "node": "init",
      "why": "컨테이너 빌드가 끝나자마자 스코프의 Awake 안에서 곧바로 부릅니다. 루프를 타지 않습니다."
    },
    {
      "edge": 0,
      "why": "스코프의 실행 순서가 -5000 이라 다른 스크립트의 Awake 는 그 뒤입니다."
    },
    {
      "edge": 1,
      "why": "첫 프레임의 EarlyUpdate 구간입니다. 모노비헤이비어 Start 를 돌리는 단계 바로 앞에 꽂은 러너가 먼저 돕니다."
    },
    {
      "edge": 2,
      "why": "그다음이 유니티가 부르는 Start() 입니다."
    },
    {
      "edge": 3,
      "why": "같은 그룹의 맨 끝에 꽂은 러너가 PostStart 를 부릅니다."
    },
    {
      "edge": 4,
      "why": "Update 그룹에 들어서면 ScriptRunBehaviourUpdate 앞에 꽂은 러너가 Tick 을 부릅니다."
    },
    {
      "edge": 5,
      "why": "그다음 모노비헤이비어 Update 와 코루틴이 돕니다."
    },
    {
      "edge": 6,
      "why": "그룹 맨 끝의 러너가 PostTick 을 부릅니다. 모든 Update 와 코루틴이 끝난 뒤입니다."
    }
  ],
  "note": "실선 상자가 VContainer 가 꽂은 자리, 점선 상자가 유니티가 원래 부르는 자리입니다. 둘이 번갈아 돕니다."
}
</script>
</x-wiring>

`Tick` 이 `Update` 보다 먼저인 것은 러너를 `ScriptRunBehaviourUpdate` 앞에 꽂았기 때문이고, `PostTick` 이 코루틴보다 나중인 것은 그룹 맨 끝에 꽂았기 때문입니다. 러너는 항목의 `MoveNext()` 가 거짓을 돌려주면 목록에서 뺍니다. `Start` 계열은 한 번 돌고 거짓을 돌려 빠지고, `Tick` 계열은 해제될 때까지 참을 돌려줍니다. 스코프가 닫혀 디스패처가 해제되면 항목은 해제 표시만 되고 **다음 프레임의 `MoveNext()` 에서 빠집니다.**

## 자식 스코프는 부모의 싱글턴 진입점만 걸러 낸다

자식 스코프에도 자기 디스패처가 있어서, 빌드할 때 자기 `ITickable` 목록을 모아 매 프레임 부릅니다. 문제는 VContainer 에서 `IReadOnlyList<ITickable>` 같은 **목록을 `Resolve` 하면 자기 스코프뿐 아니라 부모 스코프의 등록까지 모은다**는 점입니다. 그대로 두면 루트의 진입점이 루트 디스패처에서 한 번, 자식 디스패처에서 또 한 번 불립니다.

그래서 디스패처는 목록을 그냥 달라고 하지 않고 `ContainerLocal<IReadOnlyList<ITickable>>` 로 감싸서 달라고 합니다.

`ContainerLocal<T>` 는 VContainer 내부(`VContainer.Internal`)의 포장 클래스입니다. 안에 든 것은 값 하나뿐입니다.

```csharp
public sealed class ContainerLocal<T>
{
    public readonly T Value;
    public ContainerLocal(T value) => Value = value;
}
```

아무도 이 타입을 등록하지 않습니다. `Resolve<ContainerLocal<X>>()` 를 부르면 레지스트리가 등록이 없는 것을 보고 그 자리에서 만들어 줍니다. 이때 **X 가 목록이면 부모 스코프의 `Singleton` 을 뺀 목록**을 모아 `Value` 에 담고, 목록이 아니면 X 를 평소처럼 꺼내 담습니다. 그러니까 `ContainerLocal` 은 기능이라기보다 **"이 스코프 몫만 달라" 는 표시**입니다. 부모의 싱글턴은 부모 디스패처가 이미 부르고 있으니 빼는 것입니다.

```csharp
// EntryPointDispatcher.Dispatch (줄임). 진입점 인터페이스마다 이렇게 받는다
var tickables = container.Resolve<ContainerLocal<IReadOnlyList<ITickable>>>().Value;
```

목록을 모으는 쪽에서는 이 표시가 `localScopeOnly` 로 넘어가 부모 등록을 거릅니다.

```csharp
// CollectionInstanceProvider, 부모 스코프의 등록을 모을 때 (줄임)
if (!localScopeOnly || x.Lifetime != Lifetime.Singleton)
    registrationsBuffer.Add(new RegistrationElement(x, scope));
```

<x-compare>
<script type="application/json">
{"label": "루트에 ITickable A, 자식에 B 가 있을 때 매 프레임 불리는 Tick", "tabs": [{"label": "A 가 Singleton", "code": "// 루트 스코프 (A : ITickable)\nbuilder.Register<A>(Lifetime.Singleton).AsImplementedInterfaces();\n\n// 자식 스코프 (B : ITickable)\nbuilder.Register<B>(Lifetime.Singleton).AsImplementedInterfaces();", "text": "루트 디스패처가 A 를, 자식 디스패처가 B 를 부릅니다. 자식 디스패처가 목록을 모을 때 부모의 싱글턴 A 는 뺐기 때문입니다.", "stats": [["루트 디스패처", "A"], ["자식 디스패처", "B"], ["A 의 Tick", "프레임마다 1번"]], "tone": "good"}, {"label": "A 가 Scoped", "code": "// 루트 스코프 (A : ITickable)\nbuilder.Register<A>(Lifetime.Scoped).AsImplementedInterfaces();\n\n// 자식 스코프 (B : ITickable)\nbuilder.Register<B>(Lifetime.Singleton).AsImplementedInterfaces();", "text": "필터는 싱글턴만 빼므로 Scoped 인 A 는 자식 목록에도 들어갑니다. Scoped 는 요청한 스코프가 만들기 때문에 자식에는 A 의 인스턴스가 하나 더 생기고, 루트 것과 자식 것이 각자 Tick 됩니다.", "stats": [["루트 디스패처", "A (루트 것)"], ["자식 디스패처", "B, A (자식 것)"], ["A 의 Tick", "인스턴스 둘이 1번씩"]]}, {"label": "필터가 없다면", "code": "// 디스패처가 ContainerLocal 없이 목록을 받는다고 하면\nvar tickables = container.Resolve<IReadOnlyList<ITickable>>();\n// 자식에서 받으면 B 와 루트의 A 가 같이 들어온다", "text": "목록 Resolve 는 원래 부모 스코프의 등록까지 모읍니다. 필터 없이 모으면 루트의 싱글턴 A 가 자식 목록에도 들어가서, 같은 A 인스턴스가 루트 디스패처와 자식 디스패처에서 한 번씩, 프레임마다 두 번 Tick 됩니다.", "stats": [["루트 디스패처", "A"], ["자식 디스패처", "B, A (같은 인스턴스)"], ["A 의 Tick", "프레임마다 2번"]], "tone": "bad"}]}
</script>
</x-compare>

조건이 `!= Singleton` 이라 **부모의 싱글턴만 빠지고 `Scoped` 와 `Transient` 는 자식에서도 수집됩니다.** 이름과 달리 `ContainerLocal<T>` 은 값을 스코프에 붙잡아 두지도 않습니다. 요청할 때마다 새로 만들어서, `Transient` 를 감싸 두 번 요청하면 두 번 새로 만들어졌습니다.

## 주의할 점

### 1. `Initialize()` 에서 씬 오브젝트를 건드리지 않는다

**안 지키면 생기는 문제.** `Initialize()` 는 스코프의 `Awake` 안에서 빌드 직후 동기로 불립니다. 다른 게임오브젝트가 `Awake` 에서 채우는 값을 읽으면 비어 있을 수 있습니다.

**이렇게 쓴다.** `Initialize()` 에는 데이터 준비처럼 씬과 무관한 일만 둡니다. 씬 오브젝트가 필요하면 `IStartable` 로 옮깁니다.

### 2. 부모 스코프의 진입점은 `Singleton` 으로 둔다

**안 지키면 생기는 문제.** 부모에 `Scoped` 로 둔 진입점은 자식에서도 수집되어 한 프레임에 두 번 돌 수 있고, 자식 스코프가 늘수록 더 겹칩니다.

**이렇게 쓴다.** `RegisterEntryPoint` 의 기본값인 `Singleton` 을 그대로 씁니다. 수명을 바꿔야 하면 자식 스코프가 생기는 구조인지 먼저 봅니다.

### 3. 진입점은 `RegisterEntryPoint` 로 등록한다

**안 지키면 생기는 문제.** 스코프 안에서는 `Register` 로도 돌지만, 코드를 순수 `ContainerBuilder` 로 옮기는 순간 등록은 되는데 아무도 부르지 않습니다. `.As<ITickable>()` 로 하나만 적으면 같은 클래스가 구현한 다른 진입점 인터페이스는 불리지 않습니다. 어느 쪽도 경고가 없습니다.

**이렇게 쓴다.** `RegisterEntryPoint<T>()` 로 등록합니다. 디스패처를 챙기고 구현한 인터페이스 전부로 등록합니다.

### 4. 진입점 예외가 게임을 멈추지 않는다는 것을 알고 쓴다

**안 지키면 생기는 문제.** 디스패처를 등록할 때 `Debug.LogException` 을 부르는 기본 예외 핸들러가 늘 함께 등록됩니다. `Start` 나 `Tick` 에서 예외가 나도 로그만 남고 다음 프레임이 그대로 돌아, 깨진 상태로 계속 진행합니다.

**이렇게 쓴다.** 치명적으로 다뤄야 하면 핸들러를 직접 등록해 그 안에서 멈추거나 보고합니다.

```csharp
builder.RegisterEntryPointExceptionHandler(ex =>
{
    Debug.LogException(ex);
    // 여기서 게임을 멈추거나 리포트를 보낸다
});
```

## 정리

VContainer 의 진입점은 **디스패처가 모아 플레이어 루프에 꽂은 러너에서 불립니다.**

- 구현한 인터페이스가 자리를 정하고, 스코프 안에서는 `Register` 로도 돌지만 `RegisterEntryPoint` 가 안전합니다.
- `Initialize` 는 스코프 `Awake` 안에서 동기로, 나머지는 현재 루프에 끼워 넣은 러너에서 `Update` 앞뒤로 돕니다.
- 자식 스코프는 부모의 싱글턴 진입점만 거르므로, 부모의 `Scoped` 진입점은 두 번 돌 수 있습니다.