VContainer 는 유니티 DI 의 네 제약을 빠르고 작게 푼다
Esc

    VContainer 는 유니티 DI 의 네 제약을 빠르고 작게 푼다

    2026년 9월 30일 수정–UnityDI라이브러리
    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 와 같습니다. 찾는 주체가 자기 자신이면 결합은 그대로입니다.

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

    public class GameManager
    {
        readonly IPlayerRepository repository;   // 인터페이스에만 의존한다
        readonly IScoreService scoreService;
    
        public GameManager(IPlayerRepository repository, IScoreService scoreService)
        {
            this.repository   = repository;      // 밖에서 받는다
            this.scoreService = scoreService;
        }
    }

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

    직접 만든다GameManagernewPlayerRepository받는다GameManager의존IPlayerRepositoryPlayerRepository구현컨테이너주입
    위에서는 화살표가 구체 클래스로 가고, 아래에서는 인터페이스로 갑니다. 구현은 아래에서 올라와 붙습니다.

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

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

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

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

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

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

    책임 무엇 쓰는 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 와 최소 할당이고, 그 대가로 빌드 뒤에는 등록을 못 바꿉니다.