Reflex 는 생성자와 [Inject] 멤버를 두 단계로 채운다
Esc

    Reflex 는 생성자와 [Inject] 멤버를 두 단계로 채운다

    –Unity라이브러리DI
    public class Player { public Player(IWeapon weapon, int maxHp = 100) { ... } }
    
    public class PlayerView : MonoBehaviour
    {
        [Inject] IWeapon _weapon;
        [Inject] void Construct(ILogger logger) { ... }
    }

    유니티가 만드는 MonoBehaviour 에는 생성자를 쓸 수 없으니 두 방법이 다 필요합니다. maxHp 가 등록되지 않았으면 어떻게 될지, 부모 클래스의 [Inject] 는 채워질지도 정해 둬야 합니다.

    Reflex 는 객체 하나를 생성자 단계(ConstructorInjector)와 [Inject] 멤버 단계(AttributeInjector)로 나눠 채우고, 소스 제너레이터는 그중 멤버 단계만 대신 씁니다. 기준 판본은 14.3.1 입니다.

    생성자는 public 중 인자가 가장 많은 것을 고른다

    RegisterType 으로 등록한 타입은 생성자를 부른 뒤 [Inject] 멤버를 채웁니다. 팩토리 결과와 씬의 MonoBehaviour 는 멤버 단계만, 값 등록은 둘 다 거치지 않습니다.

    생성자는 타입마다 한 번 골라 캐시합니다. public 생성자만 후보이고, [ReflexConstructor] 가 붙은 것이 있으면 그것, 없으면 인자가 가장 많은 것입니다. public 생성자가 없으면 백엔드마다 다른 기본 호출기로 떨어집니다. Mono 는 null 을 돌려주고, IL2CPP 는 생성자를 한 번도 돌리지 않은 객체를 돌려줍니다.

    class Hidden { public int X = 7; Hidden() {} [Inject] public IWeapon Weapon; } 를 등록하고 꺼냈습니다.

    조건 결과
    에디터, Mono 개발 빌드 플레이어 NullReferenceException (타입 이름 없는 맨 예외)
    IL2CPP 개발 빌드 플레이어 예외 없이 객체가 나온다. X 는 0, Weapon 은 채워짐

    구현 자리에 인터페이스를 적은 등록(RegisterType(typeof(IWeapon), ...))도 같은 길로 갑니다. 에디터에서 바로 터지는 실수가 IL2CPP 빌드에서는 값이 틀린 객체로 조용히 주입됩니다.

    없는 인자는 예외를 던지고 잡아서 기본값을 넣는다

    인자마다 Resolve 하고, 그 계약이 표에 없어 UnknownContractException 이 나면 C# 기본값을 넣습니다. 선택 표시 옵션이 없는 대신 기본값 매개변수가 그 자리를 채웁니다.

    흐름을 예외로 정하니 값이 붙습니다. A(IDep d) 와 B(IDep d, int v = 42)(int 미등록)를 Transient 로 꺼낸 1회 값입니다(이 Mac, 개발 빌드 플레이어).

    조건 A B 할당 A → B
    Mono 702 ns 10,905 ns 16 B → 432 B
    IL2CPP 1,407 ns 48,961 ns 16 B → 560 B

    기본값 하나가 조회를 Mono 에서 열다섯 배, IL2CPP 에서 서른다섯 배쯤 무겁게 합니다. 그리고 계약은 있는데 그 아래 의존이 빠졌으면, 감싼 예외가 올라와 기본값이 있어도 실패합니다.

    인자 배열 풀은 여덟 겹까지만 버틴다

    생성자 인자 object[] 는 스레드마다 둔 풀에서 빌립니다. 인자 수별로 배열을 4개씩 만들어 두고, 생성자 안에서 인자를 Resolve 하는 동안에는 빌린 채로 있습니다. 그래서 인자 수가 같은 생성자가 사슬로 이어지면 같은 칸에서 차례로 더 빌리는데, 넷을 넘길 때 칸을 키우는 코드가 지역 변수만 키웁니다.

    인자 1개 생성자 사슬 C1(C2) → ... → Cn 을 Transient 로 두고 조회 1회당 할당에서 손 new 몫을 뺀 값입니다(이 Mac, 개발 빌드 플레이어).

    깊이 n Mono Reflex 몫 IL2CPP Reflex 몫
    4 0 B 32 B
    6 256 B 288 B
    8 648 B 680 B

    깊이 8 의 648 B 는 다섯째·여섯째·일곱째 대여가 만드는 버킷과 배열(256 + 216 + 176 B)과 바이트 단위로 같습니다.

    아홉째 대여의 예외가 생성자 순환을 멈추는 자리이기도 합니다. Reflex 에는 순환을 알아채는 코드가 없고, 싱글턴은 생성이 끝난 뒤에야 인스턴스를 담기 때문에 A(B), B(A) 는 서로를 계속 새로 만들며 내려갑니다. 필드 주입은 배열을 돌려준 뒤에 돌아서 이 풀에 걸리지 않습니다.

    순환 (이 Mac, 개발 빌드 플레이어) Mono IL2CPP
    생성자끼리, RegisterType 싱글턴 ConstructorInjectorException (Index was outside the bounds of the array. 를 여덟 겹 감쌈) 같음
    [Inject] 필드끼리, RegisterType 싱글턴 프로세스 종료, 코드 1 프로세스 종료, 코드 139
    [Inject] 필드끼리, RegisterFactory 싱글턴 정상, c.d.c == c 같음

    팩토리 싱글턴은 인스턴스를 먼저 담고 [Inject] 를 채우므로 한 바퀴에서 끝납니다.

    소스 제너레이터는 멤버 단계만 대신 쓴다

    AttributeInjector 는 객체가 IAttributeInjectionContract 를 구현하면 그 ReflexInject(Container) 만 부르고 리플렉션을 건너뜁니다. 이 메서드를 [SourceGeneratorInjectable] 을 붙인 public partial 클래스에 써 주는 것이 패키지의 소스 제너레이터입니다. 조건을 어기면 RFX1(partial 아님), RFX2(public 아님), RFX3(setter 없음), RFX5(readonly 필드) 컴파일 오류로 멈춥니다.

    [Inject] 필드 셋을 채우는 1회 값입니다(이 Mac, 개발 빌드 플레이어).

    조건 리플렉션 생성 코드 손 대입
    Mono 2,257 ns 510 ns (4.4배) 48 ns
    IL2CPP 1,840 ns 745 ns (2.5배) 37 ns

    생성 코드도 필드마다 container.Resolve 를 부르므로 손 대입보다 열 배 넘게 느립니다. 제너레이터가 없애는 것은 리플렉션 몫뿐입니다. 그리고 규칙이 조금 다릅니다.

    주의할 점

    1. 컨테이너가 만들 타입의 생성자는 public 으로 둔다

    안 지키면 생기는 문제. 후보는 public 생성자뿐입니다. 의존을 private 생성자로 받게 해 두면 인자가 적은 public 생성자가 불리거나, 기본 호출기로 떨어져 에디터에서는 예외, IL2CPP 빌드에서는 생성자를 거치지 않은 객체가 됩니다.

    이렇게 쓴다. RegisterType 으로 맡기는 타입은 public 생성자 하나만 둡니다. 여럿이면 쓸 것에 [ReflexConstructor] 를 붙이고, 생성자를 숨겨야 하면 RegisterFactory 로 등록합니다.

    2. 기본값 매개변수로 선택적 의존을 만들지 않는다

    안 지키면 생기는 문제. 기본값으로 떨어질 때마다 예외를 던지고 잡아서 Mono 약 10 µs, IL2CPP 약 48 µs 와 400~500 B 가 붙습니다. 계약 이름을 잘못 적은 등록도 기본값 뒤에 숨고, 한 단계 안쪽이 빠졌을 때는 기본값이 있어도 실패합니다.

    이렇게 쓴다. 선택적 서비스는 아무 일도 안 하는 구현을 등록해 둡니다. 기본값 매개변수는 설정값처럼 컨테이너가 채우지 않는 것에만 씁니다.

    3. 제너레이터를 붙이면 주입 규칙이 바뀐다고 본다

    안 지키면 생기는 문제. [SourceGeneratorInjectable] 하나로 [Inject] 메서드 순서가 부모 → 파생으로 뒤집히고, 메서드 인자의 기본값 처리가 사라지고, 대상이 아닌 부모 클래스의 [Inject] 멤버가 채워지지 않습니다.

    이렇게 쓴다. 상속 계층에는 맨 위 부모부터 전부 붙입니다. [Inject] 메서드끼리 순서에 기대지 않고, 순서가 필요하면 메서드 하나에 모읍니다.

    4. 순환 참조를 만들지 않는다

    안 지키면 생기는 문제. 필드 순환은 잡을 수 있는 예외 없이 프로세스를 끝내고, 생성자 순환은 “배열 범위를 벗어났다” 는 메시지로 멈춰 순환이라는 것을 알려 주지 않습니다.

    이렇게 쓴다. 두 서비스가 서로를 알아야 하면 공유 상태를 세 번째 클래스로 빼거나 한쪽을 이벤트로 바꿔 의존 방향을 한쪽으로 만듭니다. Index was outside the bounds of the array. 가 보이면 메시지에 되풀이되는 두 타입부터 봅니다.

    정리

    Reflex 는 객체를 생성자 단계와 [Inject] 멤버 단계로 나눠 채웁니다.

    • 생성자는 public 중 인자가 가장 많은 것이고, 없는 인자는 예외를 던지고 잡아 기본값을 넣습니다.
    • 인자 배열 풀은 같은 인자 수가 다섯 겹을 넘으면 매번 할당하고, 아홉 겹에서 예외로 멈춥니다. 순환 검사는 없습니다.
    • 소스 제너레이터는 멤버 단계만 대신 써서 Mono 4.4배, IL2CPP 2.5배 빨라지지만 순서와 기본값, 부모 멤버 규칙이 달라집니다.