---
title: "VContainer 는 자기가 처음 만든 것만 해제한다"
description: "Transient 로 등록한 IDisposable 은 요청마다 핸들을 흘립니다. VContainer 는 이번에 처음 만들었고, IDisposable 이고, 자기가 만든 것만 해제 목록에 올리고, 그 목록을 생성 역순으로 닫습니다."
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-dispose/
---
```csharp
public class LogWriter : ILogWriter, IDisposable
{
    StreamWriter writer = new StreamWriter("log.txt");
    public void Dispose() => writer?.Dispose();
}

builder.Register<ILogWriter, LogWriter>(Lifetime.Transient);
```

이 코드는 요청할 때마다 파일 핸들을 하나씩 흘립니다. `Dispose()` 는 한 번도 안 불립니다.

VContainer 는 **이번에 처음 만들었고, `IDisposable` 이고, 컨테이너가 만든 것만** 해제 목록에 올립니다. 목록은 스택이라 스코프가 닫힐 때 생성 역순으로 풀립니다.

## 해제는 생성의 역순이다

해제 목록은 `Stack<IDisposable>` 입니다. 만들 때 넣고, 해제할 때 위에서부터 꺼냅니다. `Presenter` 가 `Session` 을, `Session` 이 `Connection` 을 참조하는 스코프를 닫아 봅니다.

<x-flow>
<script type="application/json">
{
  "label": "해제 목록이 비워지는 순서",
  "lines": [
    "IDisposable disposable;",
    "do",
    "{",
    "    lock (disposables)",
    "    {",
    "        disposable = disposables.Count > 0 ? disposables.Pop() : null;",
    "    }",
    "    disposable?.Dispose();      // 락 밖에서 부른다",
    "} while (disposable != null);"
  ],
  "dwell": 1.6,
  "cases": [
    {
      "label": "셋을 만든 스코프",
      "state": "만든 순서 Connection → Session → Presenter · 스택 위부터 [Presenter, Session, Connection]",
      "path": [
        {"line": 0, "why": "스코프가 해제되면 해제 목록을 비우기 시작합니다."},
        {"line": 5, "why": "맨 위를 꺼냅니다. 마지막에 만든 Presenter 입니다.", "state": "스택 위부터 [Session, Connection]"},
        {"line": 7, "why": "Presenter 를 닫습니다. 이때 Session 은 아직 살아 있습니다.", "out": "Presenter.Dispose()"},
        {"line": 8, "why": "꺼낸 것이 있었으니 한 바퀴 더 돕니다.", "verdict": "true"},
        {"line": 5, "why": "다음 맨 위는 Session 입니다.", "state": "스택 위부터 [Connection]"},
        {"line": 7, "why": "Session 을 닫습니다. Connection 은 아직 살아 있습니다.", "out": "Session.Dispose()"},
        {"line": 8, "verdict": "true"},
        {"line": 5, "why": "마지막 남은 Connection 을 꺼냅니다.", "state": "스택 비어 있음"},
        {"line": 7, "out": "Connection.Dispose()"},
        {"line": 8, "verdict": "true"},
        {"line": 5, "why": "비었으니 null 입니다."},
        {"line": 7, "why": "null 이라 아무것도 안 합니다."},
        {"line": 8, "why": "꺼낸 것이 없으니 끝냅니다.", "verdict": "false"}
      ],
      "note": "의존하는 쪽이 먼저 닫히고, 의존받는 쪽이 끝까지 남습니다."
    }
  ]
}
</script>
</x-flow>

만든 순서대로 닫으면 `Connection` 이 먼저 죽어 `Session.Dispose()` 가 죽은 연결을 건드립니다. 역순이라 **의존하는 쪽이 먼저 닫히고 의존받는 쪽이 끝까지 남습니다.** 컨테이너 사이에서도 같은 원칙이라, 최상위 컨테이너는 품고 있는 `rootScope` 를 먼저 닫고 자기 싱글턴을 닫습니다. `Dispose()` 를 락 밖에서 부르므로 해제 도중에 새로 `Add` 가 일어나도 데드락이 없고, `do-while` 이라 그 항목도 처리됩니다.

## 해제 목록에 오르려면 조건 셋을 다 넘어야 한다

`Singleton` 과 `Scoped` 는 인스턴스를 처음 만드는 자리에서 목록에 넣을지를 정합니다. `IsValueCreated` 를 `Value` 앞에서 읽어 이번에 처음 만든 것인지 보고, `IDisposable` 인지, 밖에서 받은 객체(`ExistingInstanceProvider`)가 아닌지를 봅니다. 등록 방식별로 걸어 봅니다.

<x-flow>
<script type="application/json">
{
  "label": "해제 목록에 들어가는 길 (두 분기를 합쳐 줄인 코드)",
  "lines": [
    "switch (registration.Lifetime)",
    "{",
    "    case Lifetime.Singleton:",
    "    case Lifetime.Scoped:",
    "        var created = lazy.IsValueCreated;",
    "        var instance = lazy.Value;",
    "        if (!created &&",
    "            instance is IDisposable disposable &&",
    "            !(registration.Provider is ExistingInstanceProvider))",
    "            disposables.Add(disposable);",
    "        return instance;",
    "    default:                                   // Transient",
    "        return registration.SpawnInstance(this);",
    "}"
  ],
  "cases": [
    {
      "label": "Scoped 첫 요청",
      "state": "Register<LogWriter>(Lifetime.Scoped) · 이 스코프의 첫 요청",
      "path": [
        {"line": 0, "why": "수명부터 봅니다."},
        {"line": 3},
        {"line": 4, "why": "아직 만든 적이 없습니다."},
        {"line": 5, "why": "여기서 처음 만들어집니다."},
        {"line": 6, "why": "이번 호출에서 처음 만들었습니다.", "verdict": "true"},
        {"line": 7, "why": "IDisposable 을 구현했습니다.", "verdict": "true"},
        {"line": 8, "why": "컨테이너가 만든 객체입니다.", "verdict": "true"},
        {"line": 9, "why": "해제 목록에 올라갑니다.", "out": "해제 대상"},
        {"line": 10}
      ],
      "skip": [11, 12]
    },
    {
      "label": "Scoped 두 번째 요청",
      "state": "같은 스코프에서 한 번 더 요청",
      "path": [
        {"line": 0},
        {"line": 3},
        {"line": 4, "why": "이미 만들어져 있습니다."},
        {"line": 5, "why": "만들어 둔 것을 그대로 꺼냅니다."},
        {"line": 6, "why": "처음이 아니라 여기서 멈춥니다. 목록에 두 번 넣지 않습니다.", "verdict": "false"},
        {"line": 10, "out": "이미 목록에 있음"}
      ],
      "skip": [9, 11, 12]
    },
    {
      "label": "Transient",
      "state": "Register<LogWriter>(Lifetime.Transient)",
      "path": [
        {"line": 0, "why": "수명이 Transient 입니다."},
        {"line": 11},
        {"line": 12, "why": "만들어서 돌려주고 끝입니다. 저장도 추적도 없습니다.", "out": "해제 대상 아님"}
      ],
      "skip": [4, 5, 6, 7, 8, 9, 10]
    },
    {
      "label": "RegisterInstance",
      "state": "RegisterInstance(writer) · 싱글턴 고정",
      "path": [
        {"line": 0},
        {"line": 2},
        {"line": 4},
        {"line": 5, "why": "밖에서 넘겨받은 객체를 그대로 돌려줍니다."},
        {"line": 6, "verdict": "true"},
        {"line": 7, "verdict": "true"},
        {"line": 8, "why": "프로바이더가 ExistingInstanceProvider 라 여기서 걸립니다.", "verdict": "false"},
        {"line": 10, "out": "해제 대상 아님"}
      ],
      "skip": [9, 11, 12]
    },
    {
      "label": "컴포넌트 등록",
      "state": "RegisterComponent(view) · view 가 IDisposable 구현",
      "path": [
        {"line": 0},
        {"line": 2},
        {"line": 4},
        {"line": 5},
        {"line": 6, "verdict": "true"},
        {"line": 7, "verdict": "true"},
        {"line": 8, "why": "프로바이더가 ExistingComponentProvider 입니다. 이름은 비슷해도 다른 클래스라 안 걸립니다.", "verdict": "true"},
        {"line": 9, "why": "해제 목록에 올라갑니다. 게임오브젝트는 파괴하지 않습니다.", "out": "해제 대상"},
        {"line": 10}
      ],
      "skip": [11, 12]
    }
  ],
  "note": "조건 셋을 다 넘은 것만 해제 목록에 올라갑니다. Transient 는 조건을 보러 오지도 않습니다."
}
</script>
</x-flow>

그래서 이렇게 갈립니다.

| 등록 방식 | 해제되나 | 왜 |
| --- | --- | --- |
| `Register` + `Singleton` 또는 `Scoped` | 됩니다 | 컨테이너가 만들었으니 소유합니다 |
| `Register` + `Transient` | 안 됩니다 | 추적 자체를 안 합니다 |
| `RegisterInstance` | 안 됩니다 | 밖에서 만든 것은 밖의 책임입니다 |
| `RegisterFactory(func)` | 안 됩니다 | 내부적으로 같은 취급입니다 |
| 팩토리가 실행 중에 만든 객체 | 안 됩니다 | 컨테이너가 생성 시점을 모릅니다 |
| 컴포넌트 등록 | `Dispose()` 는 불립니다 | 제외 조건에 안 걸립니다 |

## 컴포넌트는 Dispose 만 당하고 살아남는다

씬에 이미 있는 컴포넌트를 넘겨 등록하는 것은 `RegisterInstance` 처럼 **밖에서 만든 것을 맡기는 일**입니다. 그런데 결과가 다릅니다. `IDisposable` 을 구현한 객체를 두 방식으로 등록하고 스코프를 닫으면 이렇게 됩니다.

| 등록 | 스코프가 닫힐 때 `Dispose()` | 게임오브젝트 |
| --- | --- | --- |
| `RegisterInstance(obj)` | 안 불림 | (해당 없음) |
| `RegisterComponent(component)` | **불림** | 씬에 그대로 남음 |
| `RegisterComponentInHierarchy`, `RegisterComponentInNewPrefab` | **불림** | 씬에 그대로 남음 |

차이는 "밖에서 만든 것은 해제하지 않는다" 는 검사가 **`RegisterInstance` 로 들어온 것만** 알아보기 때문입니다. 컨테이너는 등록마다 인스턴스를 꺼내 주는 프로바이더를 두는데, 검사는 그 프로바이더가 `ExistingInstanceProvider` 인지 하나만 봅니다.

```csharp
// Container.cs, 해제 목록에 넣을지 정하는 줄
if (!created && instance is IDisposable disposable && !(registration.Provider is ExistingInstanceProvider))
    disposables.Add(disposable);
```

컴포넌트 등록은 `ExistingComponentProvider`, `FindComponentProvider`, `PrefabComponentProvider` 같은 다른 프로바이더를 씁니다. 이름이 비슷해도 별개 클래스라 이 검사에 걸리지 않고, 해제 목록에 들어갑니다. 그리고 컨테이너가 하는 일은 `Dispose()` 를 부르는 것까지라 `Destroy(gameObject)` 는 하지 않습니다. 그래서 **오브젝트는 씬에 살아 있는데 `Dispose()` 는 이미 불린 상태**가 됩니다.

## 주의할 점

### 1. `IDisposable` 을 `Transient` 로 등록하지 않는다

**안 지키면 생기는 문제.** `Transient` 는 조건을 보러 오지도 않고 만들어 돌려주고 끝납니다. 맨 위의 `LogWriter` 처럼 요청할 때마다 닫히지 않는 객체가 하나씩 쌓입니다.

**이렇게 쓴다.** `Scoped` 로 바꿔 스코프가 닫게 합니다. 매번 새로 만들어야 하면 팩토리로 받고 호출부에서 `using` 으로 감쌉니다.

### 2. `RegisterInstance` 로 넣은 것은 직접 닫는다

**안 지키면 생기는 문제.** 밖에서 만든 객체는 밖의 책임이라 컨테이너가 닫지 않습니다. 연결 객체를 이렇게 넣으면 스코프가 죽어도 연결은 열려 있습니다.

**이렇게 쓴다.** 컨테이너가 만들게 넘기거나, 해제 콜백을 직접 답니다.

```csharp
builder.Register<IConnection>(_ => new TcpConnection(host, port), Lifetime.Singleton);
// 또는
builder.RegisterDisposeCallback(_ => connection.Dispose());
```

### 3. 컴포넌트에 `IDisposable` 을 구현하면 살아남는다고 계산한다

**안 지키면 생기는 문제.** 스코프가 닫힐 때 `Dispose()` 가 불리지만 게임오브젝트는 파괴되지 않습니다. `DontDestroyOnLoad` 로 살려 둔 오브젝트라면 해제된 채로 계속 돕니다.

**이렇게 쓴다.** `Dispose()` 에서 정리한 뒤에는 그 컴포넌트가 다시 쓰이지 않게 합니다. 오브젝트의 수명은 씬이나 직접 부르는 `Destroy` 가 정한다고 보고 짭니다.

## 정리

VContainer 는 **자기가 처음 만든 `IDisposable` 만 해제하고, 생성 역순으로 닫습니다.**

- 해제 목록이 스택이라 의존하는 쪽이 먼저 닫히고 의존받는 쪽이 끝까지 남습니다.
- `Transient`, `RegisterInstance`, 팩토리가 만든 객체는 해제 목록에 오르지 않습니다.
- 컴포넌트 등록은 `Dispose()` 가 불리지만 게임오브젝트는 그대로 남습니다.