Reflex 는 생성자와 [Inject] 멤버를 두 단계로 채운다
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배 빨라지지만 순서와 기본값, 부모 멤버 규칙이 달라집니다.