---
title: "VContainer 의 수명은 받는 쪽이 정한다"
description: "Singleton 이 Scoped 를 생성자로 받으면 첫 인스턴스가 필드에 박혀 전투가 바뀌어도 그대로입니다. 주는 쪽 수명을 바꾸거나 싱글턴 팩토리를 넣어도 안 풀리고, 받는 쪽을 Scoped 로 내려야 풀립니다."
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-lifetime/
---
```csharp
public class AudioService : IAudioService
{
    readonly IBattleSession session;
    public AudioService(IBattleSession session) => this.session = session;
}

builder.Register<IAudioService, AudioService>(Lifetime.Singleton);
builder.Register<IBattleSession, BattleSession>(Lifetime.Scoped);
```

전투 스코프를 새로 만들어도 `AudioService` 가 보는 세션은 안 바뀝니다. 예외도 로그도 없이 값만 계속 틀립니다.

수명은 인스턴스가 놓이는 자리이고, **의존성을 몇 번 다시 받느냐는 받는 쪽 수명이 정합니다.** 긴 수명이 짧은 수명을 생성자로 받으면 첫 인스턴스에 갇힙니다.

## 수명은 인스턴스가 놓이는 자리다

| | `Transient` | `Singleton` | `Scoped` |
| --- | :---: | :---: | :---: |
| 저장 위치 | 저장 안 함 | 등록이 있는 스코프 (보통 최상위) | 요청한 스코프의 컨테이너 |
| 생성 시점 | 매 요청 | 첫 요청 | 그 스코프의 첫 요청 |
| 자식 스코프에서 요청하면 | 그 자리에서 새로 | 등록이 있는 곳까지 올라가 공유 | 그 스코프가 자기 것을 만듦 |

최상위에 놓이면 앱이 끝날 때까지, 스코프에 놓이면 그 스코프가 죽을 때까지 삽니다. `Singleton` 은 "앱 전체에 하나" 가 아니라 **등록 1건당 하나**입니다.

`Resolve` 는 컨테이너에 "이 타입 하나 줘" 하고 인스턴스를 받는 일입니다. 받을 때 그 타입의 생성자가 요구하는 것까지 컨테이너가 찾아서 만들고 채워 줍니다. 그 요청을 받아 처리하는 쪽(최상위 컨테이너나 스코프, `IObjectResolver`)을 Resolver 라고 부릅니다.

저장하지 않는 `Transient` 는 `Resolve` 할 때마다 객체를 새로 만들고 생성자를 부릅니다. `Top(Mid, Leaf)`, `Mid(Leaf)`, `Leaf()` 세 타입을 같은 수명으로 등록해 `Resolve<Top>()` 1회를 쟀습니다.

| 수명 | Mono | IL2CPP | 할당 |
| --- | --- | --- | --- |
| `Transient` | 약 11.7 µs | 약 4.2 µs | 128 B |
| `Scoped` | 약 0.24 µs | 약 0.34 µs | 0 B |
| `Singleton` | 약 0.13 µs | 약 0.27 µs | 0 B |

`Transient` 는 `Singleton` 보다 Mono 에서 약 90배, IL2CPP 에서 약 16배 느렸습니다. 초기화 때 몇 번이면 문제가 안 되지만 반복해서 `Resolve` 하는 자리에서는 횟수만큼 쌓입니다.

## 루트가 만든 Singleton 은 루트의 Scoped 를 계속 쥐고 있다

맨 위 예제를 다시 봅니다.

```csharp
builder.Register<IAudioService, AudioService>(Lifetime.Singleton);   // 앱에 하나
builder.Register<IBattleSession, BattleSession>(Lifetime.Scoped);   // 스코프마다 하나

public AudioService(IBattleSession session) => this.session = session;
```

`IBattleSession` 은 전투 한 판 동안만 쓰는 상태를 담는 객체이고, `Lifetime.Scoped` 로 등록했습니다. `Scoped` 는 **스코프마다 인스턴스를 하나씩 따로 만든다**는 뜻이라, 전투 스코프를 열 때마다 새 `IBattleSession` 이 생기고 그 전투가 끝나면 같이 버려집니다. 아래에서 "세션" 은 이 `IBattleSession` 인스턴스를 말합니다.

`AudioService` 는 `Singleton` 이고, 생성자로 세션을 받습니다. 기대하는 것은 전투마다 그 전투의 세션을 쓰는 것입니다. 이 구성(`Singleton` 하나가 `Scoped` 세션을 생성자로 받음)으로 전투 스코프를 둘 열고, 각 스코프에서 세션과 `Singleton` 을 꺼내 `Singleton` 이 들고 있는 세션이 몇 번인지 봤습니다(에디트 모드 테스트).

| 어디서 꺼냈나 | 그 스코프의 세션 | `Singleton` 이 쥔 세션 |
| --- | --- | --- |
| 루트 | 1번 | 1번 |
| 전투 스코프 1 | 2번 | **1번** |
| 전투 스코프 2 | 3번 | **1번** |

세션은 스코프마다 새로 만들어졌지만 `Singleton` 은 어디서 꺼내도 1번만 봅니다. 전투 코드는 2번, 3번 세션을 쓰는데 `AudioService` 는 1번 세션을 보고 있어서, 값이 서로 어긋납니다. 예외도 로그도 없습니다.

이렇게 되는 순서입니다.

1. `AudioService` 는 `Singleton` 이라 **딱 한 번**, 등록이 있는 **루트가** 만듭니다.
2. 만들면서 생성자 인자 `IBattleSession` 도 루트가 `Resolve` 합니다. 루트 쪽에서 만든 `Scoped` 세션, 즉 1번이 들어갑니다.
3. 그 참조가 필드에 들어간 뒤로는 생성자가 다시 불리지 않으니, 새 세션을 받을 길이 없습니다.

소스에서는 이 두 줄입니다.

```csharp
// Singleton 을 만드는 쪽은 최상위 Container
createInstance = registration =>
    new Lazy<object>(() => registration.SpawnInstance(this));   // this = 최상위 Container

// 최상위 Container 에 Scoped 를 요청하면 루트 쪽 스코프(rootScope)가 만든다
case Lifetime.Scoped:
    return rootScope.Resolve(registration);
```

누수는 프로파일러가 잡지만, **값이 갱신되지 않는 이 오동작은 아무것도 잡지 않습니다.**

## 주는 쪽을 고치면 안 풀리고 받는 쪽을 고쳐야 풀린다

<x-compare>
<script type="application/json">
{
  "label": "갇힘을 풀려는 세 시도",
  "tabs": [
    { "label": "세션을 Transient 로", "code": "builder.Register<ICache, Cache>(Lifetime.Singleton);\nbuilder.Register<ISession, Session>(Lifetime.Transient);\n\npublic class Cache : ICache\n{\n    readonly ISession session;\n    public Cache(ISession session) => this.session = session;\n}", "text": "캐시는 생애 전체에 한 번 생성됩니다. 그 한 번의 생성자 호출에서 받은 것을 필드에 박으므로, 세션이 무슨 수명이든 캐시는 다시 물어보지 않습니다. 오히려 더 나쁩니다. 스코프 안에서 공유도 안 되는 인스턴스 하나를 영원히 붙듭니다.", "tone": "bad" },
    { "label": "받는 쪽은 그대로, 팩토리만", "code": "builder.Register<ICache, Cache>(Lifetime.Singleton);\nbuilder.Register<Session>(Lifetime.Scoped);\nbuilder.RegisterFactory<Session>(\n    resolver => () => resolver.Resolve<Session>(),\n    Lifetime.Singleton);\n\npublic class Cache : ICache\n{\n    readonly Func<Session> factory;\n    public Cache(Func<Session> factory) => this.factory = factory;\n}", "text": "쓸 때마다 물어보게 바꿨는데도 안 풀립니다. 팩토리 자체가 싱글턴이라 최상위 컨테이너를 붙들고 있고, 그 팩토리가 부르는 resolver 는 언제나 루트입니다. 인스턴스 대신 루트 팩토리 하나에 갇혔을 뿐입니다.", "tone": "bad" },
    { "label": "받는 쪽을 Scoped 로", "code": "builder.Register<ICache, Cache>(Lifetime.Scoped);\nbuilder.Register<ISession, Session>(Lifetime.Scoped);\n\npublic class Cache : ICache\n{\n    readonly ISession session;\n    public Cache(ISession session) => this.session = session;\n}", "text": "캐시가 스코프마다 새로 만들어지므로 생성자도 스코프마다 다시 불립니다. 그때마다 그 스코프의 세션을 받습니다. 고칠 자리는 주는 쪽이 아니라 받는 쪽이었습니다.", "tone": "good" }
  ]
}
</script>
</x-compare>

같은 테스트로 셋을 재면 이렇게 갈립니다. 전투 스코프를 둘 열고, 각 스코프에서 받는 쪽이 얻은 세션이 몇 번인지 봤습니다.

| 받는 쪽 수명 | 세션을 받는 법 | 전투 스코프 1 | 전투 스코프 2 | 전투마다 다른 세션인가 |
| --- | --- | --- | --- | --- |
| `Scoped` | 생성자 주입 | 1번 | 2번 | 예 |
| `Scoped` | 팩토리 | 1번 | 2번 | 예 |
| `Singleton` | 팩토리 | 1번 (루트의 세션) | 1번 (루트의 세션) | **아니오** |

세 번째 줄이 핵심입니다. **팩토리는 받는 쪽 수명을 바꿔 주지 않습니다.** 받는 쪽을 싱글턴으로 둬야 한다면 팩토리 자체를 `Scoped` 로 등록해서, 스코프마다 새 팩토리가 그 스코프의 `Resolver` 를 쥐게 합니다.

```csharp
builder.RegisterFactory<Session>(resolver => () => resolver.Resolve<Session>(), Lifetime.Scoped);
```

받는 쪽 기준으로 정리하면 규칙은 한 줄입니다.

| 받는 쪽 | 주는 쪽 | 결과 |
| --- | --- | --- |
| `Singleton` | `Scoped` | 갇힘. 루트 것을 영원히 붙듭니다 |
| `Singleton` | `Transient` | 사실상 싱글턴이 됩니다 |
| `Scoped` | `Transient` | 사실상 `Scoped` 가 됩니다 |
| 그 밖의 조합 | | 정상 |

**긴 수명이 짧은 수명을 직접 의존하면 안 되고**, 반대 방향은 짧은 쪽이 매번 다시 물어보므로 안전합니다.

## 주의할 점

### 1. 긴 수명이 짧은 수명을 생성자로 받지 않는다

**안 지키면 생기는 문제.** VContainer 는 갇힌 의존성을 검출하지 않습니다. 빌드할 때 순환 참조는 잡지만 수명 방향은 보지 않아서, 빌드와 `Resolve` 에 예외 없이 값만 틀립니다. 유니티에서도 자식 스코프 둘에서 꺼낸 싱글턴 안의 `Scoped` 는 루트 스코프의 것이었습니다.

**이렇게 쓴다.** 등록 목록을 직접 훑어 `Lifetime.Singleton` 으로 등록된 타입의 생성자 인자 중에 `Scoped` 로 등록된 것이 있는지 봅니다.

### 2. 주는 쪽 수명을 바꿔서 풀려고 하지 않는다

**안 지키면 생기는 문제.** 받는 쪽이 싱글턴이면 생성자는 한 번만 불리므로, 주는 쪽을 `Transient` 로 바꿔도 첫 인스턴스 하나에 갇힙니다. 싱글턴 팩토리를 넣어도 그 팩토리가 쥔 `Resolver` 가 루트라서 그대로입니다.

**이렇게 쓴다.** 받는 쪽을 `Scoped` 로 내립니다. 받는 쪽을 싱글턴으로 둬야 하면 팩토리를 `Scoped` 로 등록합니다.

### 3. 반복해서 `Resolve` 하는 자리에 `Transient` 를 두지 않는다

**안 지키면 생기는 문제.** `Transient` 는 `Resolve` 마다 생성자를 부르고 할당합니다. 세 타입 그래프 하나에 Mono 약 11.7 µs, 128 B 였고, 반복 횟수를 늘려도 1회 값이 그대로라 `Resolve` 횟수만큼 쌓입니다.

**이렇게 쓴다.** 상태가 없는 계산기처럼 가볍고 드물게 만드는 것만 `Transient` 로 두고, 자주 쓰는 것은 `Scoped` 나 `Singleton` 으로 한 번 만들어 재사용합니다.

## 정리

VContainer 의 수명은 **받는 쪽이 몇 번 다시 받느냐로 정해집니다.**

- `Singleton` 은 최상위, `Scoped` 는 그 스코프, `Transient` 는 아무 데도 놓이지 않습니다.
- 싱글턴이 생성자로 받은 `Scoped` 는 `rootScope` 것으로 고정되고, 증상은 값이 갱신되지 않는 조용한 오동작입니다.
- 주는 쪽 수명이나 싱글턴 팩토리로는 안 풀리고, 받는 쪽이나 팩토리를 `Scoped` 로 내려야 풀립니다.