Reflex 의 비용은 조회가 아니라 스코프 생성과 IL2CPP 생성에 있다
var enemyScope = sceneContainer.Scope(b => b.RegisterValue(enemyConfig)); // 적 하나마다 스코프?
var bullet = enemyScope.Resolve<Bullet>(); // 총알마다 Resolve?
컨테이너 없이 new 로 만들던 것을 컨테이너로 옮기면, 어느 호출이 비싼지부터 알아야 합니다.
Reflex 는 조회를 깊이와 상관없이 싸게 만든 값을 스코프 생성과 IL2CPP 의 객체 생성에서 치릅니다. 숫자는 이 Mac의 macOS 개발 빌드 플레이어(Mono, IL2CPP) 값이고, 로그 레벨은 Warning 입니다(워밍업 뒤 반복 평균, 3회 중앙값). 기준 판본은 14.3.1 입니다.
조회는 깊이와 무관하고 스코프 생성은 부모 크기를 따른다
자식 컨테이너는 빌드할 때 부모의 조회 표를 계약마다 목록째 복사해 둡니다. 그래서 조회는 자기 표를 한 번 찾고 끝나고, 복사 값은 스코프를 만들 때 한 번에 냅니다.
| 측정 | Mono | IL2CPP |
|---|---|---|
| 싱글턴 조회, 깊이 0 | 163 ns | 243 ns |
| 싱글턴 조회, 깊이 8 | 163 ns | 243 ns |
| 스코프 생성 + 해제, 부모 계약 10개 | 24.2 µs | 18.1 µs |
| 스코프 생성 + 해제, 부모 계약 160개 | 234.8 µs | 193.8 µs |
깊이에 따른 차이는 2% 안쪽이고, 부모를 16배로 늘리자 스코프 생성이 Mono 9.7배, IL2CPP 10.7배가 됐습니다. 부모에 Scoped + Eager 가 있으면 자식마다 그것도 새로 만들고, 그 목록을 찾으려고 부모 표 전체를 LINQ 로 펼칩니다. 확정 단계 자체는 검증이 없어 쌉니다. 부모 없는 빌더에 20종을 등록하고 Build() 까지 Mono 42.7 µs, IL2CPP 34.5 µs 였습니다.
씬마다 한 번 자식을 만드는 게임이면 유리한 모양이고, 스코프를 짧은 주기로 많이 만드는 구조면 불리합니다.
IL2CPP 는 객체를 만들 때마다 리플렉션을 부른다
새 인스턴스를 만드는 조회(처음 꺼내는 싱글턴, 매번 만드는 Transient)는 생성자 호출기를 거칩니다. 이 호출기가 백엔드마다 다릅니다.
손으로 쓴 new 와 같은 루프에서 견줬습니다.
| 타입 | Mono Resolve |
Mono new |
IL2CPP Resolve |
IL2CPP new |
|---|---|---|---|---|
인자 0개 T() |
484 ns | 136 ns | 1,144 ns | 98 ns |
인자 1개 T(U) |
714 ns | 172 ns | 1,393 ns | 131 ns |
new 대비 Mono 는 3.64.2배, IL2CPP 는 10.711.7배입니다. 첫 생성은 반대로 Mono 가 무겁습니다. 캐시에 없는 타입의 첫 Resolve 가 Mono 174 µs, IL2CPP 14.8 µs 였는데, Mono 는 여기서 식 트리를 컴파일하기 때문입니다. README 의 “런타임 Emit 없음” 은 IL2CPP 경로에서 맞고, 그 대가가 생성마다의 ConstructorInfo.Invoke 입니다.
할당은 조회 자체에서는 거의 없습니다. README 벤치마크와 같은 모양으로 Transient 다섯 개가 이어진 체인을 꺼내면 1회당 Mono 112 B, IL2CPP 144 B 로, 손 new 다섯 개(112 B)와 같거나 32 B 더 붙었습니다. 할당이 보이는 곳은 스코프 생성(빈 스코프 한 쌍에 Mono 1,380 B, IL2CPP 1,432 B), All<T>() 의 결과 배열, 컬렉션 주입입니다.
락이 없어서 첫 조회가 겹치면 싱글턴이 여럿 생긴다
README 의 “Immutable Container: 락 없는 스레드 안전” 에서 불변인 것은 등록 표입니다. 조회가 건드리는 싱글턴 리졸버의 _instance 와 정적 타입 캐시 Dictionary 는 락 없이 검사하고 씁니다.
생성자에 20 ms 대기를 넣은 싱글턴을 스레드 8개가 동시에 처음 Resolve 하게 했습니다. 칸은 생성자 호출 / 받은 서로 다른 인스턴스 / 예외 수입니다.
| 시도 | 에디터 | Mono 플레이어 | IL2CPP 플레이어 |
|---|---|---|---|
| 1 | 1 / 1 / 7 | 8 / 8 / 0 | 1 / 1 / 7 |
| 2 | 1 / 1 / 7 | 1 / 1 / 7 | 7 / 4 / 4 |
| 3 | 1 / 1 / 7 | 1 / 1 / 7 | 1 / 1 / 7 |
| 4 | 1 / 1 / 7 | 1 / 1 / 7 | 5 / 4 / 4 |
| 5 | 1 / 1 / 7 | 1 / 1 / 7 | 5 / 1 / 7 |
예외는 모두 타입 캐시에 같은 키를 동시에 Add 한 ArgumentException 이고, 캐시를 무사히 지난 스레드끼리는 싱글턴을 각자 만들었습니다. 같은 코드가 실행마다 다르게 나옵니다.
주의할 점
1. Resolve 는 메인 스레드에서만 한다
안 지키면 생기는 문제. Task.Run 이나 비동기 로딩 스레드에서 처음 조회하면 ArgumentException 으로 빈손이 되거나, 서로 다른 “싱글턴” 을 받습니다. 이 Mac 에서 8개까지 나왔습니다.
이렇게 쓴다. 조회는 메인 스레드에서 끝내고 백그라운드에는 꺼낸 객체를 넘깁니다. 백그라운드에서 쓸 싱글턴은 Resolution.Eager 로 등록해 빌드 때 만들어 둡니다.
2. 자식 스코프를 짧은 주기로 만들지 않는다
안 지키면 생기는 문제. 부모 등록 160개면 스코프 하나를 만들고 해제하는 데 Mono 235 µs, IL2CPP 194 µs 가 듭니다. 60 fps 한 프레임의 1% 를 넘고, 적이나 총알마다 만들면 그만큼이 매번 붙습니다.
이렇게 쓴다. 스코프는 씬이나 게임 모드처럼 드물게 바뀌는 경계에만 둡니다. 개체마다 달라지는 값은 팩토리 메서드의 인자로 넘깁니다.
3. IL2CPP 의 핫 경로에서 Transient 를 Resolve 로 만들지 않는다
안 지키면 생기는 문제. IL2CPP 의 Transient 조회는 new 의 약 11배입니다. 에디터(Mono)의 같은 조회는 컴파일된 식 트리라 465 ns 여서, 에디터에서 잰 값으로 IL2CPP 를 짐작하면 두 배 넘게 틀립니다.
이렇게 쓴다. 프레임마다 여러 개 만드는 것은 컨테이너에서 한 번 꺼낸 풀이나 팩토리가 new 로 만듭니다. 성능 판단은 IL2CPP 플레이어의 프로파일러로 합니다.
정리
Reflex 의 비용은 조회가 아니라 스코프 생성과 IL2CPP 의 생성에 있습니다.
- 조회는 깊이 0 에서 8 까지 Mono 163 ns, IL2CPP 243 ns 로 같고, 스코프 생성은 부모 계약 수에 거의 비례합니다.
- IL2CPP 는 생성마다
ConstructorInfo.Invoke를 불러 Transient 조회가new의 약 11배입니다. Mono 는 첫 생성에서 식 트리를 컴파일합니다. - 등록 표는 불변이지만 싱글턴 생성과 타입 캐시에는 락이 없어, 여러 스레드의 첫 조회가 싱글턴을 여럿 만들 수 있습니다.