VContainer 는 모노비헤이비어를 꺼 둔 채로 주입하고 켠다
public class Enemy : MonoBehaviour
{
[Inject] IHealthService health;
void Awake() => health.Init(100);
}
이 적 프리팹을 두 가지로 복제했습니다(VContainer 1.19.0, 에디터 PlayMode 테스트).
| 복제 방법 | Awake 안에서 health == null |
|---|---|
Object.Instantiate(prefab) 다음에 주입 |
True. Init 에서 널 참조 |
resolver.Instantiate(prefab) |
False. Init(100) 성공 |
모노비헤이비어는 new 를 못 하니 이미 만들어진 뒤에 [Inject] 멤버로만 받습니다. 그래서 VContainer 는 새로 만드는 것은 꺼진 채 복제해 주입한 뒤 켜서 Awake 를 주입 뒤로 미루고, 씬에 있는 것은 스코프의 Awake 안에서 빌드 직후 찾아 채웁니다.
모노비헤이비어는 [Inject] 가 붙은 멤버로 받는다
생성자를 못 쓰니 받을 자리를 [Inject] 로 표시합니다. 자리는 셋입니다.
public class Enemy : MonoBehaviour
{
[Inject] IHealthService health; // 필드
[Inject] public ScoreService Score { get; set; } // 속성
IAudioService audio;
[Inject] // 메서드. 이름은 아무거나 된다
public void Construct(IAudioService audio)
{
this.audio = audio;
}
}
Construct 는 VContainer 의 API 가 아니라 흔히 쓰는 이름일 뿐입니다. 표시는 [Inject] 이고, 메서드 이름이 무엇이든 불립니다.
주입은 “이미 만들어진 객체에 [Inject] 자리를 채워 넣는 일” 입니다. 인젝터는 그 타입의 [Inject] 멤버 목록을 한 번 읽어 두고, 객체가 오면 필드, 속성, 메서드 순으로 채웁니다.
// ReflectionInjector.Inject
InjectFields(instance, resolver, parameters); // [Inject] 필드마다 타입으로 Resolve 해서 대입
InjectProperties(instance, resolver, parameters); // [Inject] 속성도 같은 방식
InjectMethods(instance, resolver, parameters); // [Inject] 메서드의 인자마다 Resolve 한 뒤 메서드를 부른다
그래서 Construct(IAudioService audio) 는 컨테이너가 IAudioService 를 꺼내 인자로 넣어 부르는 메서드입니다. 필드보다 메서드로 받으면 무엇에 의존하는지 한 자리에 보이고, 테스트에서 직접 불러 가짜를 넣을 수도 있습니다. 아래에서 말하는 “주입” 은 모두 이 채워 넣기를 언제 하느냐의 문제입니다.
새로 만드는 것은 꺼진 채 복제하고 주입한 뒤 켠다
유니티는 켜진 오브젝트를 복제하면 Instantiate 호출 안에서 바로 Awake 를 부르고, 꺼진 오브젝트는 켤 때 부릅니다. VContainer 는 이 규칙을 씁니다.
꺼진 오브젝트에 주입한 뒤 켜도 멀쩡했습니다. 꺼 두는 목적은 주입이 아니라 Awake 를 미루는 것입니다. 원본을 되돌리는 두 줄은 finally 라 주입 중 예외가 나도 원본 프리팹이 꺼진 채 남지 않고, 되돌리는 값이 true 가 아니라 wasActive 라서 원래 꺼져 있던 프리팹은 주입은 끝났지만 꺼진 채로 돌아옵니다.
RegisterComponentInNewPrefab 과 RegisterComponentOnNewGameObject 의 프로바이더도 같은 순서를 씁니다. 새 게임오브젝트는 AddComponent 전에 끕니다. 실행 순서 -5000 은 씬에 놓인 오브젝트 사이의 이야기라 런타임 복제에는 통하지 않고, 이 순서만이 Awake 를 뒤로 미룹니다.
씬에 놓인 것은 스코프의 Awake 안에서 채워진다
RegisterComponent 와 RegisterComponentInHierarchy 는 등록하면서 자기를 Resolve 하는 빌드 콜백을 답니다. 아무도 생성자로 받지 않아도 빌드 직후 주입이 일어나고, 그 빌드는 스코프의 Awake 안입니다.
그래서 같은 씬에서 함께 로드되고 실행 순서가 -5000 보다 뒤인 컴포넌트는 주입이 자기 Awake 보다 먼저입니다. 이 조건은 쉽게 깨집니다. 실행 순서 0 과 -6000 인 컴포넌트를 둘 다 RegisterComponentInHierarchy 로 등록하고, 불릴 때마다 로그를 찍었습니다.
-6000 쪽은 Awake 때 널이었고 Start 때는 둘 다 채워져 있었습니다. 실행 순서 말고도 스코프가 부모를 기다리며 대기열에 들어가거나 Auto Run 을 끄고 직접 Build() 하면 주입이 Awake 뒤로 밀립니다.
같은 참조라도 RegisterInstance(hudView) 로 넣으면 인젝터를 만들지 않아 [Inject] 멤버가 끝까지 비어 있습니다.
씬 탐색은 스코프의 씬에서 처음 찾은 하나를 가져온다
RegisterComponentInHierarchy 는 ApplicationOrigin, 즉 빌드를 시작한 스코프의 씬만 뒤집니다. 루트 오브젝트를 하이어라키 순서로, 루트 안은 깊이 우선으로, 꺼진 것까지 봅니다.
순수 ContainerBuilder 에서는 ApplicationOrigin 이 널이라 VContainerException 이 아니라 NullReferenceException 이 납니다. 찾을 컴포넌트를 마지막 루트의 마지막 자식에 두고, 전체 탐색과 .UnderTransform(root) 로 좁힌 탐색이 스코프 빌드에 더한 시간입니다.
| 씬 오브젝트 수 | 전체 (Mono) | 전체 (IL2CPP) | 좁힘 (두 런타임) |
|---|---|---|---|
| 약 1,000 | 0.058 ms | 0.056 ms | 0.01 ms 미만 |
| 약 10,000 | 1.04 ms | 0.43 ms | 0.01 ms 미만 |
| 약 30,000 | 3.33 ms | 2.12 ms | 0.01 ms 미만 |
전체 탐색은 씬 크기에 따라 늘고, 등록이 여럿이면 등록마다 처음부터 다시 훑습니다.
등록으로 만드는 프리팹은 등록한 컴포넌트 하나만 주입한다
프리팹을 복제해 주입하는 길은 셋입니다. 어디까지 채우느냐와 Awake 전에 채우느냐가 다릅니다.
| 방법 | 등록한 컴포넌트 | 같은 오브젝트의 다른 컴포넌트 · 자식 | Awake 전에 주입 |
|---|---|---|---|
RegisterComponentInNewPrefab |
채워짐 | 비어 있음 | 예 |
resolver.Instantiate |
채워짐 | 채워짐 | 예 |
Object.Instantiate 뒤 InjectGameObject |
채워짐 | 채워짐 | 아니오 |
RegisterComponentInNewPrefab 은 꺼 둔 채 복제하고 주입한 뒤 켜지만, 복제 뒤 부르는 것이 injector.Inject(component, ...) 한 줄이라 등록한 타입의 컴포넌트 하나만 채웁니다. Object.Instantiate 는 켜진 채 복제되어 Awake 가 먼저 돌고 주입은 그 뒤입니다.
여러 컴포넌트를 Awake 전에 채우려면 resolver.Instantiate 를 씁니다. 등록으로 받고 싶으면 팩토리로 감쌉니다.
builder.RegisterFactory<Enemy>(
resolver => () => resolver.Instantiate(enemyPrefab), Lifetime.Scoped);
// 쓰는 쪽
public EnemySpawner(Func<Enemy> createEnemy) => this.createEnemy = createEnemy;
루트와 자식에 [Inject] 메서드가 있는 프리팹으로 확인했고, 프리팹 에셋이든 실행 중 만든 템플릿이든, 켜져 있든 꺼져 있든 결과가 같았습니다. 트리 전체 주입은 노드 수에 비례해 노드당 Mono 약 5 µs, IL2CPP 약 2.4 µs, 약 40 B 가 들었습니다.
주의할 점
1. Object.Instantiate 대신 resolver.Instantiate 를 쓴다
안 지키면 생기는 문제. 켜진 프리팹을 유니티 API 로 복제하면 복제 호출 안에서 Awake 가 돌고, 그 뒤의 InjectGameObject 는 늦습니다. Awake 에서 주입 필드를 쓰면 널 참조입니다.
이렇게 쓴다. 프리팹을 만드는 클래스가 IObjectResolver 를 받아 resolver.Instantiate(prefab) 로 복제합니다. IObjectResolver 를 받는 것은 이런 생성 자리로만 한정합니다.
2. 씬에 놓인 컴포넌트는 Awake 에서 주입 필드를 쓰지 않는다
안 지키면 생기는 문제. 스코프가 먼저 깨어나는 동안에는 문제가 안 보이다가, 더 앞선 실행 순서, 부모 대기, Auto Run 끄기 중 하나로 Awake 가 주입보다 앞으로 옵니다. -6000 을 준 컴포넌트는 Awake 때 널이었습니다.
이렇게 쓴다. [Inject] 메서드는 받은 것을 대입만 하고, 그걸로 하는 초기화는 Start() 로 옮깁니다.
3. RegisterComponentInHierarchy 는 스코프와 같은 씬을 대상으로 쓴다
안 지키면 생기는 문제. 애디티브로 연 다른 씬의 컴포넌트는 못 찾고 예외가 납니다. 같은 타입이 둘이면 꺼진 쪽이라도 먼저 걸린 것이 뽑히고, 3만 개 씬에서는 등록 하나에 빌드가 2~3 ms 더 듭니다.
이렇게 쓴다. LifetimeScope 의 Configure 안에서만 쓰고, 찾을 컴포넌트는 스코프와 같은 씬에 둡니다. 위치가 정해져 있으면 .UnderTransform(root) 로 좁힙니다.
4. 프리팹 자식까지 채우려면 RegisterComponentInNewPrefab 을 쓰지 않는다
안 지키면 생기는 문제. 등록한 컴포넌트 하나만 주입되므로 프리팹 자식이나 같은 오브젝트의 다른 컴포넌트는 [Inject] 가 널로 남습니다. 원래 꺼진 프리팹을 넘기면 복제본도 꺼진 채 돌아와 Awake 와 Start 가 켜질 때까지 돌지 않습니다.
이렇게 쓴다. 트리 전체가 주입받아야 하면 resolver.Instantiate 를 씁니다. 등록에 넘기는 프리팹은 켜 둔 채 저장하고, 일부러 꺼 둔 것이면 받은 쪽에서 SetActive(true) 를 부릅니다.
정리
VContainer 는 모노비헤이비어를 꺼 둔 채 주입하고 켜서 Awake 를 주입 뒤로 미룹니다.
- 새로 만드는 경로는 모두 꺼진 채 복제하고, 주입하고, 원래 상태대로 켭니다.
- 씬에 놓인 컴포넌트는 스코프
Awake안의 빌드 콜백에서 채워지므로, 스코프보다 늦게 깨어날 때만Awake전에 주입됩니다. RegisterComponentInHierarchy는 스코프의 씬에서 처음 찾은 하나,RegisterComponentInNewPrefab은 등록한 컴포넌트 하나만 채웁니다.