Reflex 의 비용은 조회가 아니라 스코프 생성과 IL2CPP 생성에 있다
Esc

    Reflex 의 비용은 조회가 아니라 스코프 생성과 IL2CPP 생성에 있다

    –Unity라이브러리DI최적화
    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 는 첫 생성에서 식 트리를 컴파일합니다.
    • 등록 표는 불변이지만 싱글턴 생성과 타입 캐시에는 락이 없어, 여러 스레드의 첫 조회가 싱글턴을 여럿 만들 수 있습니다.