VContainer 는 유니티 DI 의 네 제약을 빠르고 작게 푼다
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;
}
}
구체 클래스가 아니라 인터페이스에 의존하게 되고(의존성 역전), 만들고 조립하는 주도권이 바깥으로 넘어갑니다(제어의 역전).
받는 자리는 생성자가 기본입니다. 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와 최소 할당이고, 그 대가로 빌드 뒤에는 등록을 못 바꿉니다.