Zenject 의 조회 비용은 딕셔너리가 아니라 파이프라인에 있다
Container.Bind<IScoreService>().To<ScoreService>().AsSingle();
var score = container.Resolve<IScoreService>(); // 이미 만들어져 있다
두 번째부터의 Resolve 는 저장해 둔 객체 하나를 돌려주면 끝입니다. 그런데 한 번에 에디터 플레이 모드 1,403 ns, Mono 개발 빌드 플레이어 1,161 ns, IL2CPP 개발 빌드 플레이어 958 ns 가 듭니다. 조회 키로 딕셔너리를 찾는 것만 떼면 100 ns 안팎입니다.
Zenject 의 조회 비용은 자료구조가 아니라 파이프라인에 있습니다. 다중 부모, 조건부 바인딩, 순환 감지를 위해 둔 단계를 그 기능을 쓰지 않는 조회도 매번 지나갑니다.
싱글턴 하나를 꺼내도 파이프라인 전체를 지난다
씬 컨테이너에서 ProjectContext 에 등록한 싱글턴을 꺼내는 길입니다.
셀 수 있는 조각을 따로 재 더해도 전체의 21% 였습니다(Windows 11 에디터 EditMode, 이 회차 Resolve 1,493 ns).
| 조각 | 한 번 |
|---|---|
InjectContext 빌리고 돌려주기 |
120.0 ns |
List 빌리고 돌려주기 |
79.8 ns |
BindingId 와 Dictionary 조회 |
약 104 ns |
Assert 두 번 |
7.2 ns |
| 합 | 약 311 ns (21%) |
나머지 79% 는 컨테이너 루프, 거리 재귀, 조건 평가, 순환 감지, Provider 호출에 흩어져 있어 한 곳을 고쳐서는 줄지 않습니다. 할당을 피하려는 풀 대여가 딕셔너리 조회보다 비쌉니다.
등록 수가 아니라 컨테이너 깊이에 비례한다
등록 수를 25개에서 400개로 늘려도 딕셔너리 조회는 93.5 ns 에서 98.9 ns 였습니다(Windows 에디터). 대신 조상마다 확정, 거리 재귀, 딕셔너리 조회가 하나씩 늘어납니다. 루트에 싱글턴 하나를 두고 CreateSubContainer() 로 단을 쌓은 뒤 맨 아래에서 Resolve 했습니다(20만 회 평균의 5회 중앙값).
| 깊이 | 에디터 플레이 모드 | Mono 개발 빌드 플레이어 | IL2CPP 개발 빌드 플레이어 |
|---|---|---|---|
| 0 | 1,403 ns | 1,167 ns | 967 ns |
| 2 | 1,658 ns | 1,436 ns | 1,154 ns |
| 8 | 2,993 ns | 2,670 ns | 1,942 ns |
한 단계에 에디터와 Mono 는 120200 ns, IL2CPP 는 90120 ns 씩 붙었습니다. 그래도 할당은 0 B 였습니다. InjectContext, List, LookupId 가 전부 풀에서 나오기 때문입니다.
새로 만들면 생성과 클로저가 붙는다
트랜지언트는 이 길 끝에서 객체를 만들고, 생성자 인자마다 조회를 다시 하고, 멤버 주입을 람다로 미룹니다. 그 람다가 생성마다 할당됩니다(워밍업 뒤 20만 회, 할당은 한 프레임 1,000 회를 GC Allocated In Frame 으로).
| 한 회 | Mono 플레이어 | IL2CPP 플레이어 |
|---|---|---|
| 싱글턴 조회 | 1,161 ns, 0 B | 958 ns, 0 B |
| 빈 클래스 트랜지언트 조회 (객체 16 B 포함) | 2,862 ns, 200 B | 3,587 ns, 232 B |
PlaceholderFactory.Create() (객체 24 B 포함) |
248 B | 248 B |
트랜지언트가 싱글턴의 2.5배(Mono), 3.7배(IL2CPP)였고, 객체 자신의 열 배가 넘는 바이트를 남겼습니다.
에디터 숫자는 빌드로 옮겨 가지 않는다
같은 코드를 에디터와 플레이어에서 돌려 나란히 놓으면, 비율부터 환경마다 다릅니다.
Windows 에서는 에디터에서 IL2CPP 플레이어로 가며 싱글턴 조회가 약 4.5배 빨라졌는데, 이 Mac 개발 빌드에서는 1.5배였고 트랜지언트는 IL2CPP 가 Mono 플레이어보다 느렸습니다. 기계, 개발 빌드 여부, 스트리핑이 모두 달라 원인은 가를 수 없지만, 에디터의 비율이 빌드로 그대로 옮겨 간다는 가정은 두 측정 모두에서 틀렸습니다.
주의할 점
1. Resolve 를 프레임마다 부르지 않는다
안 지키면 생기는 문제. 이미 만든 싱글턴이라도 Resolve 는 파이프라인 전체를 매번 지나갑니다. 이 Mac 의 플레이어에서 한 번 1 µs 안팎이고 컨테이너가 깊을수록 더 듭니다. Update 에서 대상마다 부르면 오브젝트 수 곱하기 그 값이 매 프레임 나갑니다.
이렇게 쓴다. 필요한 것은 생성자 인자나 [Inject] 필드로 한 번 받아 둡니다. 게임 도중 새로 만드는 것은 팩토리를, 프레임마다 여럿 만드는 것은 MemoryPool 을 씁니다. DiContainer 를 주입받아 아무 데서나 Resolve 하는 코드는 싱글턴 정적 필드를 컨테이너로 바꿔 부르는 것과 같습니다.
2. 에디터에서 잰 숫자로 빌드 성능을 판단하지 않는다
안 지키면 생기는 문제. 에디터 숫자만 보고 “DI 조회가 병목이다” 라고 결론 내리면 필요 없는 캐시 계층을 만들고, 에디터에서 괜찮던 비율이 빌드에서도 같을 거라고 믿어도 틀립니다. 같은 코드의 트랜지언트 조회가 이 Mac 에서는 IL2CPP 플레이어 쪽이 더 느렸습니다.
이렇게 쓴다. 조회나 주입 비용이 의심되면 목표 플랫폼의 IL2CPP 개발 빌드에서 Profiler 를 붙입니다. 에디터에서는 절대값보다 두 방식의 차이만 보고, 결론은 빌드에서 확인한 뒤 냅니다.
3. #if 로 감싼 코드는 켜져 있는지 확인하고 말한다
안 지키면 생기는 문제. 소스만 읽고 “조회마다 lock 을 건다” 거나 “Assert 는 에디터에서만 돈다” 고 믿으면 비용을 반대로 짚습니다. 기본 설정에서 lock 은 ZEN_MULTITHREADING 안에만 있어 없고, Assert 는 ZEN_STRIP_ASSERTS_IN_BUILDS 가 없으면 빌드에도 남습니다.
이렇게 쓴다. 출시 빌드에는 ZEN_STRIP_ASSERTS_IN_BUILDS 를 넣고, ZEN_MULTITHREADING 은 켜지 않습니다(켜면 CachedProvider 가 매번 lock 을 겁니다). 비용을 말하기 전에 Player Settings 의 Scripting Define Symbols 에서 그 기호가 정의돼 있는지 봅니다.
정리
Zenject 의 조회 비용은 딕셔너리가 아니라 그 앞뒤의 파이프라인에 있습니다.
- 이미 만든 싱글턴 하나에 이 Mac 플레이어에서 1 µs 안팎이고, 셀 수 있는 조각을 다 더해도 21% 입니다. 할당은 0 B 입니다.
- 등록 수와는 무관하고 컨테이너 깊이 한 단계에 90~200 ns 가 붙으며, 트랜지언트는 조회마다 200 B 안팎을 할당합니다.
- 에디터 숫자는 빌드로 옮겨 가지 않으니, 조회는 프레임 밖으로 빼고 판단은 목표 빌드에서 합니다.