---
title: "VContainer 에서는 누가 만드느냐가 무엇을 받느냐를 정한다"
description: "전투 스코프에 다시 등록했는데 어떤 객체는 새 것을, 어떤 객체는 옛것을 받습니다. VContainer 는 수명에 따라 객체를 만드는 스코프가 정해지고, 생성자 인자도 그 만든 스코프에서 찾기 때문입니다."
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-resolve/
---
```csharp
// 루트 스코프. GameManager 는 생성자로 IAudioService 를 받는다
builder.Register<IAudioService, AudioService>(Lifetime.Singleton);
builder.Register<GameManager>(Lifetime.Singleton);

// 전투 스코프 (루트의 자식). IAudioService 를 전투용으로 하나 더 등록한다
// BattlePresenter 도 생성자로 IAudioService 를 받는다
builder.Register<IAudioService, AudioService>(Lifetime.Singleton);
builder.Register<BattlePresenter>(Lifetime.Scoped);
```

전투 스코프에 `IAudioService` 를 하나 더 등록하고, 전투 스코프에서 `GameManager` 와 `BattlePresenter` 를 꺼냈습니다. `BattlePresenter` 는 전투 스코프의 `IAudioService` 를 받는데, `GameManager` 는 루트의 것을 받습니다. 같은 스코프에서 꺼냈는데 결과가 다릅니다.

VContainer 에서는 **그 객체를 어느 스코프가 만드느냐**가 두 가지를 정합니다. 그 객체가 어느 스코프 것이 되어 언제 해제되는지, 그리고 생성자로 무엇을 받는지입니다. 만드는 스코프는 수명이 정하고, 생성자 인자는 그 만든 스코프에서 찾습니다.

## 등록은 자기 스코프부터 위로 찾는다

`Resolve<T>()` 를 부르면 먼저 `T` 의 등록을 찾습니다. 자기 스코프에 없으면 부모로, 그 위로 계속 올라갑니다.

```csharp
// ScopedContainer.TryFindRegistration (줄임)
IScopedObjectResolver scope = this;
while (scope != null)
{
    if (scope.TryGetRegistration(type, out registration, key)) return true;
    scope = scope.Parent;      // 없으면 부모로
}
```

내려가는 길은 없습니다. **부모는 자식의 등록을 볼 수 없습니다.**

## 누가 만드는지는 수명이 정한다

등록을 찾으면 인스턴스를 만듭니다. 이때 **어느 스코프가 만들어 들고 있을지**가 수명마다 다릅니다. 전투 스코프(자식)에서 요청했을 때입니다.

| 수명 | 만드는 스코프 | 해제되는 때 |
| --- | --- | --- |
| `Singleton` | 그 등록이 들어 있는 스코프. 자식에 없으면 부모에게 넘긴다 | 만든 스코프가 닫힐 때 |
| `Scoped` | 요청한 스코프. 등록이 부모에만 있어도 자식이 자기 것을 만든다 | 요청한 스코프가 닫힐 때 |
| `Transient` | 요청한 스코프가 꺼낼 때마다 새로 | 아무도 해제하지 않는다 |

`Singleton` 은 이름과 달리 "앱 전체에 하나" 가 아닙니다. 공식 문서(Lifetime Overview)도 "같은 컨테이너에 같은 타입을 두 번 등록할 수 없다" 고만 하고, 부모와 자식이 둘 다 등록했으면 "가장 가까운 스코프의 인스턴스" 를 준다고 적습니다. **등록 한 건마다 하나**입니다.

순수 C# 컨테이너에 자식 스코프 둘을 만들고, 인스턴스마다 만든 순서대로 번호를 붙여 확인했습니다.

| 구성 | 루트에서 | 자식1 에서 | 자식2 에서 | 자식1 을 닫으면 |
| --- | --- | --- | --- | --- |
| `Singleton`, 루트에만 등록 | 1번 | 1번 | 1번 | 아무것도 해제 안 됨 |
| `Singleton`, 자식마다 다시 등록 | 1번 | 2번 | 3번 | 2번만 해제 |
| `Scoped`, 루트에만 등록 | 1번 | 2번 | 3번 | 2번만 해제 |
| `Transient`, 자식1 에서 두 번 | | 1번, 2번 | | 해제 안 됨 |

중간 스코프에 등록한 `Singleton` 을 손자 스코프에서 꺼내면 중간 스코프가 만든 하나가 나왔고, 손자를 닫을 때가 아니라 중간을 닫을 때 해제됐습니다.

소스로는 이 분기입니다.

```csharp
// ScopedContainer.ResolveCore (줄임)
switch (registration.Lifetime)
{
    case Lifetime.Singleton:
        // 내 등록이 아니다: 부모가 만들고 부모가 들고 있다
        if (!registry.Exists(registration.ImplementationType, registration.Key))
            return Parent.Resolve(registration);
        // 내 등록이다: 내가 만들고, 내가 닫힐 때 해제한다
        return CreateTrackedInstance(registration);

    case Lifetime.Scoped:
        // 등록 위치와 상관없이 요청한 스코프가 만든다
        return CreateTrackedInstance(registration);

    default:
        // Transient: 매번 새로 만들고 추적하지 않는다
        return registration.SpawnInstance(this);
}
```

전투마다 새 점수판처럼 스코프마다 다른 것을 쓰고 싶으면, 자식 스코프에 다시 등록하거나 `Scoped` 로 등록합니다.

## 생성자 인자는 만든 스코프에서 찾는다

객체를 만들 때 생성자 인자도 `Resolve` 해야 합니다. 이 `Resolve` 는 **그 객체를 만드는 스코프**가 합니다. 만드는 스코프가 자기 자신을 넘기기 때문입니다.

```csharp
// ScopedContainer 생성자 (줄임)
createInstance = registration =>
    new Lazy<object>(() => registration.SpawnInstance(this));   // this = 만드는 스코프
```

맨 위 예제를 이 규칙으로 따라가면 결과가 갈린 이유가 보입니다.

| 꺼낸 것 | 수명과 등록 위치 | 누가 만드나 | 받는 `IAudioService` |
| --- | --- | --- | --- |
| `BattlePresenter` | `Scoped`, 전투 스코프 | 전투 스코프 | 전투 스코프 것 |
| `GameManager` | `Singleton`, 루트 | 루트 | 루트 것. 전투 등록은 **보이지 않는다** |

`GameManager` 는 전투 스코프에서 꺼냈지만 등록이 루트에 있어 루트가 만들고, 루트는 자식의 등록을 못 보니 루트의 `IAudioService` 를 넣습니다. 실제로 루트 것이 1번, 전투 스코프 것이 2번일 때 `GameManager` 안에는 1번이, `BattlePresenter` 안에는 2번이 들어 있었습니다.

씬의 모노비헤이비어(`RegisterComponentInHierarchy`, 자동 주입)와 `resolver.Instantiate` 로 만든 프리팹도 같은 규칙입니다. 그 씬의 스코프가 주입하므로 그 스코프에서 가장 가까운 등록을 받습니다.

## 만드는 코드는 타입마다 한 번 정한다

인스턴스를 실제로 만드는 코드(인젝터)는 타입마다 첫 `Resolve` 에서 한 번 정하고 캐시합니다. 소스 제너레이터가 만든 코드가 있으면 그것을, 없으면 리플렉션을 씁니다.

```csharp
return Injectors.GetOrAdd(type, key =>
{
    var generatedType = key.Assembly.GetType($"{key.FullName}GeneratedInjector", false);
    if (generatedType != null)
        return (IInjector)Activator.CreateInstance(generatedType);   // 소스 제너레이터가 만든 것
    return ReflectionInjector.Build(key);                              // 리플렉션
});
```

생성된 인젝터는 `new GameManager(...)` 를 직접 부르고, 리플렉션 인젝터는 만들 때마다 `ConstructorInfo.Invoke` 를 부릅니다. 생성 코드는 **대상 타입과 같은 어셈블리**에서 찾으므로, 그 어셈블리가 `VContainer` 를 참조해야 하고 이름이 `VContainer`, `UnityEngine.`, `Unity.` 로 시작하면 안 됩니다. `[Inject]` 생성자가 둘 이상이거나, `private` 생성자나 `private` `[Inject]` 메서드이거나, 제네릭 타입이면 그 클래스만 리플렉션으로 돌아가고 콘솔에 경고가 남습니다.

생성자는 이렇게 고릅니다.

| 상황 | 쓰는 생성자 |
| --- | --- |
| `[Inject]` 생성자가 하나 | 그것 |
| `[Inject]` 생성자가 둘 이상 | 예외 |
| `[Inject]` 가 없음 | 파라미터가 가장 많은 것. `private` 도 후보 |

## 주의할 점

### 1. 부모가 만드는 객체에 자식 등록이 들어가길 기대하지 않는다

**안 지키면 생기는 문제.** 루트에 등록한 `Singleton` 은 전투 스코프에서 꺼내도 루트가 만들어서, 전투 스코프에 다시 등록한 구현을 받지 못합니다. 예외 없이 루트 것이 들어가 조용히 다르게 돕니다.

**이렇게 쓴다.** 스코프마다 달라야 하는 것을 받는 클래스는 그 스코프에 `Scoped` 로 등록합니다. 자식이 부모에게 알려야 할 일은 이벤트나 콜백으로 거꾸로 올립니다.

### 2. `[Inject]` 생성자는 하나만 둔다

**안 지키면 생기는 문제.** `[Inject]` 를 붙인 생성자가 둘 이상이면 예외입니다. 하나도 안 붙이면 파라미터가 가장 많은 생성자가 뽑히고, `private` 생성자도 후보라 의도하지 않은 생성자로 만들어질 수 있습니다.

**이렇게 쓴다.** 생성자가 여럿인 클래스는 컨테이너가 쓸 것 하나에만 `[Inject]` 를 붙이고 `public` 으로 둡니다.

### 3. 생성된 인젝터가 실제로 쓰이는지 확인한다

**안 지키면 생기는 문제.** 대상 타입이 있는 어셈블리가 `VContainer` 를 참조하지 않거나 위 조건에 걸리면 조용히 리플렉션으로 내려갑니다. `Transient` 를 많이 만드는 코드라면 `ConstructorInfo.Invoke` 비용이 매번 붙습니다.

**이렇게 쓴다.** 주입받는 클래스가 있는 모든 어셈블리 정의의 `references` 에 `VContainer` 를 적고, 소스 제너레이터가 남긴 콘솔 경고를 지우지 말고 읽습니다.

## 정리

VContainer 에서는 **객체를 누가 만드느냐가 그 객체의 주인과 생성자로 받는 것을 함께 정합니다.**

- 등록은 자기 스코프부터 위로 찾고, 부모는 자식의 등록을 못 봅니다.
- `Singleton` 은 등록이 있는 스코프가, `Scoped` 는 요청한 스코프가 만들고, 그 스코프가 닫힐 때 해제됩니다. `Transient` 는 아무도 해제하지 않습니다.
- 생성자 인자는 만든 스코프에서 찾으므로, 루트가 만든 `Singleton` 에는 자식의 등록이 들어가지 않습니다.