---
title: "VContainer 는 유니티 DI 의 네 제약을 빠르고 작게 푼다"
description: "직접 new 하고 싱글턴으로 꺼내 쓰는 코드는 바꾸지도 테스트하지도 못합니다. 유니티 DI 는 생성자, 씬 수명, Awake, Update 네 제약을 넘어야 하고, VContainer 는 이것을 빠른 Resolve 와 최소 할당을 목표로 풉니다."
author: "유니얼"
pubDate: 2026-09-16
updatedDate: 2026-09-30
category: "game-dev/unity/libraries"
tags: ["Unity", "DI", "라이브러리"]
canonical: https://blog.unialgames.com/posts/unity-di-why/
---
```csharp
public class GameManager : MonoBehaviour
{
    PlayerRepository repository;
    ScoreService scoreService;

    void Awake()
    {
        repository   = new PlayerRepository();        // 직접 만들고
        scoreService = new ScoreService(repository);  // 조립도 직접
    }
}
```

서버 저장소를 로컬 저장소로 바꾸려면 `GameManager` 를 고쳐야 하고, 테스트에 가짜 저장소를 넣을 자리가 없고, 누가 언제 해제하는지도 코드에 없습니다.

의존성 주입은 **필요한 것을 스스로 만들거나 찾지 않고 밖에서 받는 것**입니다. 유니티에서는 생성자, 씬 수명, `Awake`, `Update` 네 제약 때문에 받는 방법을 따로 마련해야 하고, **VContainer 는 이 넷을 풀면서 `Resolve` 를 빠르게, 할당은 거의 없게 만드는 쪽을 고른 컨테이너**입니다.

## 스스로 찾는 한 결합은 남는다

`new` 를 피하는 흔한 방법은 둘입니다.

| 방법 | 코드 | 한계 |
| --- | --- | --- |
| 정적 싱글턴 | `PlayerRepository.Instance` | 전역 상태입니다. 테스트 격리가 안 되고 초기화 순서 문제가 생깁니다 |
| 씬 탐색 | `FindObjectOfType<ScoreService>()` | 느리고 씬 구조에 묶입니다. 비활성 오브젝트를 놓칩니다 |

생김새는 달라도 **의존 대상을 자기가 찾는다**는 점이 `new` 와 같습니다. 찾는 주체가 자기 자신이면 결합은 그대로입니다.

## 받는 쪽으로 뒤집으면 의존 방향이 바뀐다

```csharp
public class GameManager
{
    readonly IPlayerRepository repository;   // 인터페이스에만 의존한다
    readonly IScoreService scoreService;

    public GameManager(IPlayerRepository repository, IScoreService scoreService)
    {
        this.repository   = repository;      // 밖에서 받는다
        this.scoreService = scoreService;
    }
}
```

구체 클래스가 아니라 인터페이스에 의존하게 되고(의존성 역전), 만들고 조립하는 주도권이 바깥으로 넘어갑니다(제어의 역전).

<figure class="diagram"><svg viewBox="0 0 620 272" role="img" aria-label="직접 만드는 구조와 받는 구조에서 화살표 방향이 달라지는 그림"><text x="30" y="32" font-size="11.5" fill="var(--text-muted)">직접 만든다</text><rect x="60" y="44" width="170" height="44" rx="6" fill="none" stroke="currentColor" stroke-opacity="0.32" /><text x="145" y="71" text-anchor="middle" font-size="12" fill="var(--text)">GameManager</text><line x1="230" y1="66" x2="370" y2="66" stroke="currentColor" stroke-opacity="0.32" /><path d="M380,66 L368,61 L368,71 Z" fill="currentColor" fill-opacity="0.32" /><text x="300" y="58" text-anchor="middle" font-size="11" fill="var(--text-muted)">new</text><rect x="380" y="44" width="190" height="44" rx="6" fill="none" stroke="currentColor" stroke-opacity="0.32" /><text x="475" y="71" text-anchor="middle" font-size="12" fill="var(--text)">PlayerRepository</text><text x="30" y="132" font-size="11.5" fill="var(--accent)">받는다</text><rect x="60" y="144" width="170" height="44" rx="6" fill="none" stroke="currentColor" stroke-opacity="0.32" /><text x="145" y="171" text-anchor="middle" font-size="12" fill="var(--text)">GameManager</text><line x1="230" y1="166" x2="330" y2="166" stroke="currentColor" stroke-opacity="0.32" /><path d="M340,166 L328,161 L328,171 Z" fill="currentColor" fill-opacity="0.32" /><text x="285" y="158" text-anchor="middle" font-size="11" fill="var(--text-muted)">의존</text><rect x="340" y="144" width="200" height="44" rx="6" fill="none" stroke="var(--accent)" stroke-opacity="0.55" /><text x="440" y="171" text-anchor="middle" font-size="12" fill="var(--accent)">IPlayerRepository</text><rect x="340" y="216" width="200" height="40" rx="6" fill="none" stroke="currentColor" stroke-opacity="0.24" /><text x="440" y="241" text-anchor="middle" font-size="11.5" fill="var(--text-muted)">PlayerRepository</text><line x1="440" y1="216" x2="440" y2="192" stroke="currentColor" stroke-opacity="0.28" stroke-dasharray="3 3" /><path d="M440,188 L435,199 L445,199 Z" fill="currentColor" fill-opacity="0.28" /><text x="500" y="207" text-anchor="middle" font-size="10.5" fill="var(--text-muted)">구현</text><rect x="60" y="216" width="170" height="40" rx="6" fill="none" stroke="currentColor" stroke-opacity="0.24" /><text x="145" y="241" text-anchor="middle" font-size="11.5" fill="var(--text-muted)">컨테이너</text><line x1="145" y1="216" x2="145" y2="192" stroke="currentColor" stroke-opacity="0.28" stroke-dasharray="3 3" /><path d="M145,188 L140,199 L150,199 Z" fill="currentColor" fill-opacity="0.28" /><text x="196" y="207" text-anchor="middle" font-size="10.5" fill="var(--text-muted)">주입</text></svg><figcaption>위에서는 화살표가 구체 클래스로 가고, 아래에서는 인터페이스로 갑니다. 구현은 아래에서 올라와 붙습니다.</figcaption></figure>

받는 자리는 생성자가 기본입니다. `readonly` 가 되고 빠뜨리면 컴파일이 안 됩니다. 생성자를 못 쓸 때는 `[Inject]` 메서드, 선택적인 것은 프로퍼티로 받고, `private` 필드에 강제로 쓰는 필드 주입은 최후 수단입니다.

## 컨테이너는 조립 코드를 규칙으로 대신 쓴다

주입 자체에는 프레임워크가 필요 없습니다. 조립 코드를 한 곳에 모으면 됩니다.

```csharp
var repository   = new PlayerRepository(new HttpClient(config));
var scoreService = new ScoreService(repository, new ScoreCalculator());
var gameManager  = new GameManager(repository, scoreService);
```

작은 프로젝트면 이게 정답입니다. 커지면 클래스가 늘 때마다 조립 코드가 같이 늘고, "앱 전체에 하나, 씬마다 하나, 매번 새로" 를 손으로 관리하고, `IDisposable` 을 누가 닫는지 추적이 안 됩니다.

<x-compare>
<script type="application/json">
{
  "label": "같은 기능을 세 가지로",
  "tabs": [
    { "label": "정적 싱글턴", "code": "public class ScoreService\n{\n    public static ScoreService Instance { get; } = new ScoreService();\n}\n\npublic class StageClearPresenter : MonoBehaviour\n{\n    void OnClear() => ScoreService.Instance.Add(100);\n}", "text": "짧습니다. 대신 테스트에서 가짜로 못 바꾸고, 씬을 넘겨도 상태가 남고, 누가 이걸 쓰는지 코드에서 안 보입니다.", "tone": "bad" },
    { "label": "손으로 조립", "code": "public class CompositionRoot : MonoBehaviour\n{\n    [SerializeField] StageClearPresenter presenter;\n\n    void Awake()\n    {\n        var repository = new PlayerRepository();\n        var service    = new ScoreService(repository);\n        presenter.Construct(service);\n    }\n}", "text": "테스트가 되고 의존 관계가 드러납니다. 대신 대상이 늘수록 조립 코드가 같이 늘고 수명을 직접 관리해야 합니다." },
    { "label": "컨테이너", "code": "public class GameLifetimeScope : LifetimeScope\n{\n    protected override void Configure(IContainerBuilder builder)\n    {\n        builder.Register<IPlayerRepository, PlayerRepository>(Lifetime.Singleton);\n        builder.Register<IScoreService, ScoreService>(Lifetime.Singleton);\n        builder.RegisterComponentInHierarchy<StageClearPresenter>();\n    }\n}", "text": "조립 규칙이 한 곳에 모이고 수명이 단어 하나로 표현됩니다. 대신 언제 주입되는지를 알아야 합니다.", "tone": "good" }
  ]
}
</script>
</x-compare>

컨테이너가 대신 하는 일은 셋뿐입니다.

| 책임 | 무엇 | 쓰는 API |
| --- | --- | --- |
| 등록 | "이 타입을 요청하면 저걸 준다" 는 규칙을 모읍니다 | `builder.Register<IScoreService, ScoreService>(...)` |
| `Resolve` | 요청받은 타입의 의존 그래프를 따라가며 조립합니다 | `container.Resolve<IScoreService>()` |
| 수명 | 몇 개를 살릴지, 언제 해제할지를 정합니다 | `Lifetime.Singleton` 같은 값 |

손으로 짤 수 있는 일을 자동화한 것이라, 무슨 일이 일어나는지는 언제나 손으로 짠 조립 코드로 되돌려 이해할 수 있습니다.

## 유니티에는 제약이 넷 있고, VContainer 는 넷을 따로 푼다

| 제약 | 왜 문제인가 | VContainer 의 답 |
| --- | --- | --- |
| 모노비헤이비어는 생성자를 못 쓴다 | 유니티가 `AddComponent` 로 만드니 `new` 를 못 합니다 | `[Inject]` 메서드로 받고, 씬의 컴포넌트는 `RegisterComponentInHierarchy` 로 컨테이너에 알립니다 |
| 오브젝트 수명이 씬에 묶여 있다 | 씬을 언로드해도 컨테이너는 모릅니다 | 스코프 `LifetimeScope` 가 모노비헤이비어라 씬과 함께 파괴되며 컨테이너를 해제합니다 |
| 프리팹을 만들면 즉시 `Awake` 가 돈다 | 주입보다 `Awake` 가 먼저면 필드가 널입니다 | `resolver.Instantiate` 가 꺼 둔 채로 복제하고, 주입한 다음, 켭니다 |
| `Update` 가 모노비헤이비어 전용이다 | 순수 C# 클래스는 매 프레임 호출을 받을 길이 없습니다 | `PlayerLoop.SetPlayerLoop()` 로 엔진 루프에 자기 시스템을 꽂고, `ITickable` 같은 인터페이스를 구현한 클래스를 거기서 부릅니다 |

마지막 것 덕분에 게임 흐름을 모노비헤이비어 없이 순수 C# 클래스로 짤 수 있습니다. 모노비헤이비어는 화면에 보이는 쪽만 맡습니다.

## VContainer 는 `Resolve` 를 빠르고 할당 없이 하는 쪽을 골랐다

저자 hadashiA 는 README 첫 줄에서 VContainer 를 "유니티에서 도는 아주 빠른 DI 라이브러리" 라고 소개하고, 목표 넷을 내겁니다.

| 목표 | README 의 설명 |
| --- | --- |
| 빠른 `Resolve` | `Resolve` 를 빠르게 |
| 최소 GC 할당 | 새로 만드는 인스턴스가 없으면 `Resolve` 할당 0 |
| 작은 코드 | 내부 타입과 가상 호출이 적다 |
| 올바른 DI 쓰기를 돕는다 | 단순하고 투명한 API, 기능을 골라서 넣는다 |

게임은 로딩 중에만 `Resolve` 하지 않습니다. 풀에서 꺼낼 때, 스테이지를 바꿀 때, 전투 스코프를 열 때마다 `Resolve` 가 돌고, 그때 생기는 할당은 모바일에서 GC 멈춤으로 돌아옵니다. 그래서 VContainer 는 빌드할 때 등록을 조회 전용 불변 테이블로 굳혀 `Resolve` 를 표 조회와 생성만으로 줄이고, 원하면 소스 제너레이터로 리플렉션도 뺍니다. 불변이라 여러 스레드가 락 없이 조회해도 안전합니다.

실제로도 이미 만든 인스턴스를 돌려주는 `Resolve` 는 할당이 없었습니다. 세 타입 그래프를 `Resolve` 한 번 하면 `Singleton` 과 `Scoped` 는 0 B, 매번 새로 만드는 `Transient` 만 128 B 였습니다.

기능을 골라 넣는다는 목표는 반대로 **없는 것**으로 드러납니다. 빌드한 뒤에는 등록을 바꿀 수 없고, 빌드가 검사하는 것은 순환 참조뿐입니다. 빠르고 단순한 대신 규칙을 아는 쪽이 사용자입니다.

## 주의할 점

### 1. 필요한 것을 컨테이너에서 꺼내 쓰지 않는다

**안 지키면 생기는 문제.** 컨테이너를 들고 다니며 `Resolve` 로 꺼내 쓰면 정적 싱글턴과 같은 문제로 돌아갑니다. 싱글턴의 문제는 하나라는 것이 아니라 아무 데서나 꺼낼 수 있다는 것이었습니다. 클래스가 무엇에 의존하는지 밖에서 안 보이고, 매 프레임 꺼내면 `Resolve` 비용도 프레임마다 붙습니다.

**이렇게 쓴다.** 필요한 것은 생성자로 한 번 받아 필드에 둡니다. 실행 중에 새로 만들어야 하는 것은 팩토리를 받습니다.

### 2. 전부 인터페이스로 빼지 않는다

**안 지키면 생기는 문제.** 교체할 일도 테스트할 일도 없는 클래스까지 인터페이스를 두면 파일이 늘고, 정의로 이동해도 구현이 안 보여 추적이 어려워집니다.

**이렇게 쓴다.** 바꿔 끼울 이유가 있거나 테스트에서 가짜를 넣어야 하는 경계에만 인터페이스를 둡니다. 나머지는 구체 클래스로 등록합니다.

### 3. 모든 객체를 컨테이너에 넣지 않는다

**안 지키면 생기는 문제.** 값 객체나 잠깐 쓰고 버리는 데이터까지 등록하면 등록 목록만 길어지고 얻는 것이 없습니다.

**이렇게 쓴다.** 컨테이너에는 수명이 길고 바뀔 수 있는 협력 객체를 둡니다. 값과 짧은 데이터는 그냥 `new` 합니다.

## 정리

의존성 주입은 **필요한 것을 찾지 않고 받는 것**이고, VContainer 는 유니티의 네 제약을 **빠르고 작게** 넘는 장치입니다.

- `new`, 정적 싱글턴, `FindObjectOfType` 은 모두 자기가 찾는 방식이라 결합이 남습니다.
- 컨테이너는 등록, `Resolve`, 수명 셋을 규칙으로 대신 하는 조립 코드입니다.
- VContainer 는 스코프를 모노비헤이비어로 만들고, 프리팹을 꺼 둔 채 주입하고, 플레이어 루프에 직접 꽂아 네 제약을 풉니다.
- 목표는 빠른 `Resolve` 와 최소 할당이고, 그 대가로 빌드 뒤에는 등록을 못 바꿉니다.