---
title: "VContainer 스코프는 부모를 못 찾으면 조용히 기다린다"
description: "애디티브로 자식 씬을 먼저 열면 그 씬의 Awake 와 Start 가 주입 없이 돕니다. 스코프는 부모를 여섯 단계로 찾고, 못 찾으면 로그 없이 대기열에 누웠다가 부모가 빌드될 때 깨어나기 때문입니다."
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-boot/
---
```csharp
// Battle 스코프의 부모는 Common 씬에 있다
await SceneManager.LoadSceneAsync("Battle", LoadSceneMode.Additive);
await SceneManager.LoadSceneAsync("Common", LoadSceneMode.Additive);
```

로드 순서가 뒤집혔는데 예외도 경고도 없습니다. 결국 붙기는 하지만, 그 전에 Battle 씬 컴포넌트의 `Awake` 와 `Start` 가 주입 없이 지나갑니다.

VContainer 스코프는 **부모를 정해진 여섯 단계로 찾고, 못 찾으면 로그 없이 대기열에 누웠다가 부모가 빌드를 끝낼 때 깨어납니다.** 기다리는 동안 그 스코프의 컨테이너는 널입니다.

## 루트는 설정 자산이 씬보다 먼저 세운다

부모 탐색의 마지막 단계가 설정 자산의 루트입니다. `Assets > Create > VContainer > VContainer Settings` 로 만든 자산은 사전 로드 자산에 자동 등록되어 씬보다 먼저 로드되고, 그 `OnEnable` 이 부팅 훅이 됩니다.

```csharp
void OnEnable()
{
    if (Application.isPlaying)
    {
        Instance = this;
        var activeScene = SceneManager.GetActiveScene();
        if (activeScene.isLoaded)
            OnFirstSceneLoaded(activeScene, default);        // 이미 로드됐다
        else
            SceneManager.sceneLoaded += OnFirstSceneLoaded;  // 로드를 기다린다
    }
}
```

`Root LifetimeScope` 에 지정한 프리팹은 첫 씬 로드 때 꺼진 채로 복제되고 `DontDestroyOnLoad` 가 붙은 뒤 켜집니다. 복제 직후 도는 `Awake` 를 준비가 끝난 뒤로 미루려는 순서입니다.

## 부모는 여섯 단계 중 먼저 걸린 것이다

```csharp
LifetimeScope GetRuntimeParent()
{
    if (IsRoot) return null;                                               // 1. 루트
    if (parentReference.Object != null) return parentReference.Object;    // 2. 인스턴스 직접 지정
    var implParent = FindParent();                                         // 3. FindParent 재정의
    if (implParent != null) return implParent;
    if (parentReference.Type != null && parentReference.Type != GetType()) // 4. 인스펙터 타입
    {
        var found = Find(parentReference.Type);
        if (found != null && found.Container != null) return found;
        throw new VContainerParentTypeReferenceNotFound(parentReference.Type, ...);
    }
    if (GlobalOverrideParents.Count > 0) return GlobalOverrideParents.Peek(); // 5. EnqueueParent
    if (VContainerSettings.Instance != null)                               // 6. 설정 자산 루트
        return VContainerSettings.Instance.GetOrCreateRootLifetimeScopeInstance();
    return null;
}
```

가장 흔한 경로인 인스펙터 타입이 어떻게 판정되는지 걸어 봅니다.

<x-flow>
<script type="application/json">
{
  "label": "부모를 어디서 찾나",
  "lines": ["if (IsRoot) return null;", "if (parentReference.Object != null) return parentReference.Object;", "var implParent = FindParent();", "if (implParent != null) return implParent;", "if (parentReference.Type != null && parentReference.Type != GetType())", "{", "    var found = Find(parentReference.Type);", "    if (found != null && found.Container != null) return found;", "    throw new VContainerParentTypeReferenceNotFound(...);", "}", "if (GlobalOverrideParents.Count > 0) return GlobalOverrideParents.Peek();", "if (VContainerSettings.Instance != null) return 설정자산의_루트();", "return null;"],
  "cases": [
    {
      "label": "인스펙터에 Parent 지정",
      "state": "부모 스코프가 이미 빌드를 마쳤다",
      "path": [
        { "line": 0, "why": "루트가 아닙니다.", "verdict": "false" },
        { "line": 1, "why": "코드로 만든 자식이 아니라 인스턴스가 비어 있습니다.", "verdict": "false" },
        { "line": 3, "why": "FindParent 를 재정의하지 않았습니다.", "verdict": "false" },
        { "line": 4, "why": "인스펙터에서 타입을 지정했습니다.", "verdict": "true" },
        { "line": 6, "why": "씬에서 그 타입을 찾습니다." },
        { "line": 7, "why": "찾았고, 그 부모가 이미 컨테이너까지 만들었습니다.", "verdict": "true", "out": "부모 확정" }
      ],
      "skip": [8, 10, 11, 12]
    },
    {
      "label": "부모가 아직 안 깨어남",
      "state": "씬에는 있지만 Awake 전이다",
      "path": [
        { "line": 0, "verdict": "false" },
        { "line": 1, "verdict": "false" },
        { "line": 3, "verdict": "false" },
        { "line": 4, "why": "여기까지는 같습니다.", "verdict": "true" },
        { "line": 6, "why": "오브젝트는 찾았습니다." },
        { "line": 7, "why": "그런데 Container 가 아직 널입니다. 못 찾은 것으로 칩니다.", "verdict": "false" },
        { "line": 8, "why": "예외를 던집니다. 이게 대기열로 가는 신호입니다.", "out": "대기열 등록" }
      ],
      "skip": [10, 11, 12],
      "note": "오브젝트가 있느냐가 아니라 빌드가 끝났느냐를 봅니다."
    },
    {
      "label": "아무것도 지정 안 함",
      "state": "인스펙터도 비어 있다",
      "path": [
        { "line": 0, "verdict": "false" },
        { "line": 1, "verdict": "false" },
        { "line": 3, "verdict": "false" },
        { "line": 4, "why": "지정된 타입이 없습니다.", "verdict": "false" },
        { "line": 10, "why": "전역 부모 스택도 비어 있습니다.", "verdict": "false" },
        { "line": 11, "why": "설정 자산이 있으면 그 루트가 부모가 됩니다. 없으면 독립 컨테이너입니다.", "verdict": "true", "out": "설정 자산의 루트" }
      ],
      "skip": [6, 7, 8, 12]
    }
  ]
}
</script>
</x-flow>

4단계는 **오브젝트가 있느냐가 아니라 빌드가 끝났느냐**(`Container != null`)를 봅니다. 탐색은 `FindAnyObjectByType` 이라 **비활성 오브젝트는 아예 못 찾습니다.** 아무것도 지정하지 않으면 설정 자산의 루트가 부모가 되고, 설정 자산이 없으면 독립 컨테이너가 됩니다.

## 못 찾으면 대기열에서 로그 없이 기다린다

4단계의 예외는 `Awake` 가 받아서 대기열로 보냅니다.

```csharp
catch (VContainerParentTypeReferenceNotFound) when (!IsRoot)
{
    if (WaitingList.Contains(this)) throw;   // 대기열에 든 채로 또 실패하면 예외
    EnqueueAwake(this);                       // 아니면 대기열에 넣고 조용히 끝낸다
}
```

깨어나는 길은 둘입니다. **부모가 빌드를 끝냈을 때**(인스펙터 부모 타입이 부모의 실제 타입과 정확히 같은 대기자만)와 **자기 씬 로드가 끝났을 때**입니다. 두 길 모두 대기열에서 뺀 다음 `Awake` 를 다시 부르므로, 다시 실패해도 예외 대신 대기열로 돌아갑니다. 부모가 끝내 안 오면 끝까지 조용합니다.

맨 위 순서로 직접 로드했습니다. Battle 씬에는 `RegisterComponentInHierarchy` 로 주입받는 소비자를 두고, 스코프와 소비자가 불릴 때마다 로그를 찍었습니다.

<x-wiring>
<script type="application/json">
{
  "label": "Battle 을 Common 보다 먼저 로드했을 때",
  "width": 620,
  "height": 898,
  "nodes": [
    {
      "id": "b1",
      "title": "Battle 스코프 Awake",
      "sub": "부모 Common 이 없다 → 대기열로, 컨테이너 없음",
      "x": 150,
      "y": 8,
      "w": 320,
      "h": 54
    },
    {
      "id": "c1",
      "title": "BattleConsumer.Awake",
      "sub": "주입 안 됨",
      "x": 150,
      "y": 100,
      "w": 320,
      "h": 54,
      "aside": true
    },
    {
      "id": "b2",
      "title": "Battle 스코프 Awake (다시)",
      "sub": "씬 로드 완료로 깨어남 → 또 실패, 대기열로",
      "x": 150,
      "y": 192,
      "w": 320,
      "h": 54
    },
    {
      "id": "c2",
      "title": "BattleConsumer.Start",
      "sub": "주입 안 됨",
      "x": 150,
      "y": 284,
      "w": 320,
      "h": 54,
      "aside": true
    },
    {
      "id": "m1",
      "title": "Common 스코프 Awake",
      "sub": "Configure → 빌드",
      "x": 150,
      "y": 376,
      "w": 320,
      "h": 54
    },
    {
      "id": "b3",
      "title": "Battle 스코프 Awake (세 번째)",
      "sub": "Common 빌드가 대기열을 깨움, Common 의 Awake 안",
      "x": 150,
      "y": 468,
      "w": 320,
      "h": 54
    },
    {
      "id": "b4",
      "title": "Battle.Configure",
      "sub": "부모 = Common",
      "x": 150,
      "y": 560,
      "w": 320,
      "h": 54
    },
    {
      "id": "c3",
      "title": "BattleConsumer 주입",
      "sub": "[Inject] 메서드가 이제서야 불림",
      "x": 150,
      "y": 652,
      "w": 320,
      "h": 54,
      "aside": true
    },
    {
      "id": "b5",
      "title": "Battle 컨테이너 완성",
      "sub": "Battle.Awake 끝, 컨테이너 있음",
      "x": 150,
      "y": 744,
      "w": 320,
      "h": 54
    },
    {
      "id": "m2",
      "title": "Common 스코프 Awake 끝",
      "sub": "컨테이너 있음",
      "x": 150,
      "y": 836,
      "w": 320,
      "h": 54
    }
  ],
  "edges": [
    {
      "from": "b1",
      "to": "c1",
      "bend": 0,
      "label": "Battle 씬 로드 중"
    },
    {
      "from": "c1",
      "to": "b2",
      "bend": 0,
      "label": "로드 완료"
    },
    {
      "from": "b2",
      "to": "c2",
      "bend": 0
    },
    {
      "from": "c2",
      "to": "m1",
      "bend": 0,
      "label": "Common 씬 로드"
    },
    {
      "from": "m1",
      "to": "b3",
      "bend": 0
    },
    {
      "from": "b3",
      "to": "b4",
      "bend": 0
    },
    {
      "from": "b4",
      "to": "c3",
      "bend": 0
    },
    {
      "from": "c3",
      "to": "b5",
      "bend": 0
    },
    {
      "from": "b5",
      "to": "m2",
      "bend": 0
    }
  ],
  "steps": [
    {
      "node": "b1",
      "why": "Battle 씬을 먼저 로드합니다. Battle 스코프의 Awake 가 부모 Common 을 찾지만 아직 없습니다. 예외를 받아 대기열에 넣고 조용히 끝냅니다. 컨테이너는 없습니다."
    },
    {
      "edge": 0,
      "why": "같은 씬의 소비자 Awake 가 돕니다. 주입할 컨테이너가 없어 필드는 비어 있습니다."
    },
    {
      "edge": 1,
      "why": "씬 로드가 끝나면 대기열의 스코프를 한 번 더 깨웁니다. 부모는 여전히 없으니 다시 대기열로 돌아갑니다. 로그는 없습니다."
    },
    {
      "edge": 2,
      "why": "소비자의 Start 도 빈손으로 지나갑니다. 이 상태가 Common 이 올 때까지 이어집니다."
    },
    {
      "edge": 3,
      "why": "Common 씬이 로드되고 Common 스코프가 Configure 와 빌드를 합니다."
    },
    {
      "edge": 4,
      "why": "Common 의 빌드가 끝나면서, 부모 타입이 Common 과 정확히 같은 대기자를 깨웁니다. Battle 의 Awake 가 Common 의 Awake 안에서 다시 돕니다."
    },
    {
      "edge": 5,
      "why": "이번에는 부모를 찾았습니다. Common 을 부모로 Configure 합니다."
    },
    {
      "edge": 6,
      "why": "빌드가 끝나며 RegisterComponentInHierarchy 로 등록한 소비자에 주입합니다. Awake 와 Start 는 이미 지나간 뒤입니다."
    },
    {
      "edge": 7,
      "why": "Battle 의 컨테이너가 섰습니다."
    },
    {
      "edge": 8,
      "why": "바깥의 Common Awake 도 끝납니다."
    }
  ],
  "note": "진한 상자가 스코프, 흐린 상자가 Battle 씬의 소비자입니다. 소비자는 Awake 와 Start 를 빈손으로 지나고 나서야 주입받습니다."
}
</script>
</x-wiring>

Battle 로드 30프레임 뒤에도 컨테이너는 널이었고, 그사이 경고나 예외는 한 줄도 없었습니다. Common 을 먼저 로드한 대조군은 대기열을 거치지 않고 소비자가 자기 `Awake` 전에 주입받았습니다. **붙기는 하지만, 붙기 전에 소비자의 `Awake` 와 `Start` 가 빈손으로 지나갑니다.**

## 주의할 점

### 1. 애디티브 씬은 `EnqueueParent` 로 부모를 명시한다

**안 지키면 생기는 문제.** 인스펙터 Parent 에만 기대면 로드 순서에 따라 자식이 대기열을 거치고, 그사이 자식 씬 컴포넌트가 주입 없이 돕니다. 한 씬에 부모를 지정하지 않은 스코프를 둘 두면 둘 다 설정 자산 루트나 독립 컨테이너 밑에 서서 형제가 되고, 등록이 갈라져 서로 못 봅니다.

**이렇게 쓴다.** 자식 씬을 로드하는 코드를 감쌉니다. 5단계에서 바로 걸려 씬 탐색을 건너뜁니다.

```csharp
using (LifetimeScope.EnqueueParent(commonScope))
{
    await SceneManager.LoadSceneAsync("Battle", LoadSceneMode.Additive);
}
```

### 2. 부모 스코프를 꺼 두지 않는다

**안 지키면 생기는 문제.** 인스펙터 타입 탐색은 비활성 오브젝트를 못 찾습니다. 부모가 꺼져 있으면 자식은 대기열로 가서 조용히 기다립니다.

**이렇게 쓴다.** 부모 스코프가 붙은 게임오브젝트는 켜 둔 채 둡니다. 부모를 늦게 켜야 한다면 자식도 그 뒤에 만듭니다.

### 3. `FindParent()` 재정의만으로 찾는 스코프는 부모 빌드가 깨워 주지 않는다

**안 지키면 생기는 문제.** 부모 빌드가 대기열을 깨우는 기준은 인스펙터 부모 타입의 정확한 일치뿐입니다. `FindParent()` 만 재정의하고 인스펙터 타입을 비워 두면, 부모가 없을 때 널이 돌아와도 예외가 없어 대기열에 들어가지 않고 부모 없이(설정 자산 루트가 있으면 그 밑에) 빌드됩니다. 나중에 부모가 로드돼도 다시 붙지 않습니다. 인스펙터에 부모의 기반 클래스를 적어도 깨워지지 않습니다.

**이렇게 쓴다.** 부모가 먼저 떠 있는 순서로 로드하거나, 인스펙터에 부모의 실제 타입을 정확히 적거나, `EnqueueParent` 로 명시합니다.

### 4. 대기 중인 스코프는 직접 찾아낸다

**안 지키면 생기는 문제.** 대기열은 정적 컬렉션이라 부모를 영영 안 만들면 그 스코프는 `Container` 가 널인 채 방치됩니다. 진입점이 안 돌고 주입받을 코드는 빈손인데, 프레임워크는 경고를 내지 않습니다.

**이렇게 쓴다.** 인스펙터에 부모 타입을 지정했다면 개발 중에 `Container` 가 널인 스코프가 없는지 검사하는 코드를 둡니다.

## 정리

VContainer 스코프는 **부모를 여섯 단계로 찾고, 못 찾으면 조용히 기다립니다.**

- 설정 자산이 사전 로드 자산으로 부팅 훅이 되어, 지정이 없을 때의 부모인 루트를 씬보다 먼저 세웁니다.
- 인스펙터 타입 탐색은 비활성 오브젝트를 못 찾고, 빌드가 끝난 부모만 인정합니다.
- 대기 중에는 로그도 예외도 없고, 자식 씬 컴포넌트는 주입 없이 돕니다. 애디티브 씬은 `EnqueueParent` 로 명시합니다.