Zenject 의 Bind 문장은 다음 문장이 시작될 때 확정된다
Container.Bind<ScoreService>().AsSingle();
Container.BindInterfacesTo<Sword>(); // 스코프를 빠뜨렸다
Container.Bind<Bow>().AsTransient(); // 예외는 이 줄에서 난다
잘못은 둘째 줄에 있는데 스택 트레이스는 셋째 줄을 가리킵니다. 메시지는 Scope must be set for the previous binding! 입니다.
Zenject 의 Bind 한 줄은 문장을 큐에 쌓을 뿐이고, 그 문장은 다음 문장이 시작될 때 Provider 로 확정됩니다. 체인이 끝났다는 신호가 그것뿐이라서, 실수는 늘 한 줄 늦게 드러납니다.
한 줄은 큐에 문장을 쌓을 뿐이다
Bind<T>() 는 먼저 StartBinding 을 부릅니다.
// Main/DiContainer.cs
public BindStatement StartBinding(bool flush = true)
{
if (flush)
FlushBindings(); // 앞 문장부터 확정한다
var bindStatement = ZenPools.SpawnStatement();
_currentBindings.Enqueue(bindStatement);
return bindStatement;
}
문장 객체 BindStatement 와 설정 묶음 BindInfo 를 풀에서 빌려 큐에 넣고, 체인의 To, AsSingle, NonLazy 는 BindInfo 의 칸을 한두 개씩 바꿉니다. 체인의 메서드마다 돌려주는 타입이 좁아져서 AsSingle().AsTransient() 처럼 순서가 틀린 체인은 컴파일되지 않습니다. 줄이 끝나도 조회 테이블 _providers 는 비어 있습니다.
앞 문장은 다음 문장이 확정한다
Bind<IWeapon>() 이 불린 시점에는 뒤에 .To<Sword>() 가 올지 .FromInstance(x) 가 올지 모르고, 체인이 끝났다고 알려 주는 호출도 없습니다. 그래서 다음 문장이 시작되는 순간을 앞 문장이 끝난 신호로 씁니다.
Resolve, ResolveAll, HasBinding 도 들어오자마자 확정을 부르고, 마지막 문장은 설치가 끝난 뒤 ResolveRoots 가 확정합니다. 그래서 마지막 줄의 실수는 스택에 설치 파일이 아예 보이지 않습니다. 확정 중 예외는 모두 "Error while finalizing previous binding!" 으로 감싸여 앞 문장이라고 말합니다.
확정은 Provider 를 목록에 붙이고, AsSingle 은 유일성을 검사한다
확정은 문장의 finalizer 가 Provider 를 만들어 _providers[BindingId] 목록에 붙이는 일입니다. 스코프가 Singleton 이면 Provider 를 CachedProvider 로 한 번 감쌉니다. 목록이라서 같은 키로 두 번 등록해도 덮어쓰지 않고 하나가 더 붙습니다.
AsSingle 과 AsCached 는 둘 다 Singleton 입니다. 다른 점은 AsSingle 이 “이 구현을 만드는 바인딩은 이것 하나뿐” 이라고 컨테이너의 SingletonMarkRegistry 에 적는다는 것입니다. 같은 구현을 두 계약에 잇는 세 방법이 여기서 갈립니다(결과는 Windows 11 유니티 에디터 EditMode 에서 확인).
한 문장 안의 계약들은 Provider 하나를 공유하고, 문장이 나뉘면 Provider 도 나뉩니다. AsSingle 은 수명이 아니라 그 나뉨을 막는 선언입니다.
비싼 쪽은 확정이 아니라 쌓기다
새 DiContainer 에 인터페이스 20종을 Bind<IX>().To<X>().AsSingle() 로 넣고 확정한 한 회에서, 컨테이너 생성 몫을 뺀 값입니다.
| 20줄 | 에디터 플레이 모드 | Mono 개발 빌드 플레이어 | IL2CPP 개발 빌드 플레이어 |
|---|---|---|---|
| 쌓기와 확정 | 193.3 µs | 173.5 µs | 153.6 µs |
| 그중 큐에 쌓기만 한 몫 | 79% | 79% | 87% |
| 문장 하나의 새 할당 | 1,161 B | 1,156 B |
문장 하나가 플레이어에서 7.7~8.7 µs 이고, 대부분은 문장과 바인더 객체를 만들어 쌓는 데 듭니다. Bind<T>().AsSingle() 을 20, 80, 320줄로 늘리면 전체도 그에 비례했습니다. 씬 시작 때 한 번이면 문제가 되지 않지만, 바인딩이 많은 하위 컨테이너를 자주 만드는 구조에서는 그 수만큼 쌓입니다.
주의할 점
1. 설치 예외는 스택이 가리키는 줄의 위 문장을 본다
안 지키면 생기는 문제. 스코프 누락이나 AsSingle 중복의 예외는 잘못이 없는 다음 줄에서 납니다. 스택만 따라가면 멀쩡한 줄을 들여다보게 됩니다. 게다가 바인딩 설명을 넣는 SetContextInfo 는 [Conditional("UNITY_EDITOR")] 라서, 편집기에서는 붙던 Context: 'BindInterfacesTo(Sword)' 가 기기 로그에서는 빠집니다.
이렇게 쓴다. 메시지의 previous binding 과 Last binding: Contract: 뒤 계약 타입을 먼저 읽습니다. 실행 전에 Edit > Zenject > Validate Current Scenes 로 같은 예외를 플레이 전에 봅니다. 같은 계약을 여러 설치 파일에서 바인딩하지 않으면 기기 로그만으로도 좁힐 수 있습니다.
2. 같은 구현을 여러 계약에 줄 때는 한 문장으로 쓴다
안 지키면 생기는 문제. 인터페이스마다 AsSingle 문장을 따로 쓰면 두 번째 문장에서 예외가 납니다. 피하려고 AsCached 로 바꾸면 이번에는 조용히 Sword 가 두 개 생겨, 한쪽으로 바꾼 상태가 다른 쪽에서 안 보입니다.
이렇게 쓴다. Bind(typeof(IWeapon), typeof(IDamager)).To<Sword>().AsSingle() 이나 BindInterfacesAndSelfTo<Sword>().AsSingle() 처럼 계약을 한 문장에 모읍니다.
3. 설치 중에 Resolve 하지 않는다
안 지키면 생기는 문제. 설치 파일 안에서 Container.Resolve<T>() 를 부르면 그 시점까지 확정된 바인딩만 보입니다. 뒤의 설치 파일에서 넣을 바인딩은 아직 없고, 싱글턴이면 불완전한 그래프로 만든 객체가 끝까지 남습니다. 경고는 DEBUG 나 UNITY_EDITOR 빌드에서 한 번 찍힐 뿐입니다.
이렇게 쓴다. 설치 파일은 바인딩 문장만 적습니다. 설치 직후 할 일은 IInitializable 을 구현한 클래스의 Initialize 에 둡니다.
정리
Zenject 의 Bind 한 줄은 큐에 쌓이고, 다음 문장이 시작될 때 Provider 로 확정됩니다.
- 체인이 끝났다는 신호가 다음 문장뿐이라, 실수의 예외는 한 줄 늦게 “previous binding” 이라는 말과 함께 납니다.
- 확정은 Provider 를
_providers목록에 붙이는 일이고,AsSingle은 여기에 “이 구현의 생성 바인딩은 하나” 라는 검사를 더합니다. - 20종 등록이 플레이어에서 150~175 µs 였고 80% 안팎이 쌓기 몫, 문장 하나에 1.1 KB 남짓이 할당됩니다.