Zenject 의 주입은 리플렉션으로 읽은 타입 정보로 돈다
Esc

    Zenject 의 주입은 리플렉션으로 읽은 타입 정보로 돈다

    –Unity라이브러리DI최적화
    public class Unit
    {
        [Inject] ILog _log;
        [Inject] void InitUnit(IScore score) { ... }
    }
    
    public class Enemy : Unit
    {
        [Inject] ISave _save;
        public Enemy(IClock clock) { ... }
        [Inject] void InitEnemy() { ... }
    }

    손으로 쓰면 new Enemy(clock) 한 줄과 대입 몇 줄입니다. 컨테이너는 Enemy 를 처음 보므로 어느 생성자를 부르고, 부모의 private 필드 _log 를 포함해 무엇을 어떤 순서로 채울지부터 알아내야 합니다.

    Zenject 는 타입마다 한 번 리플렉션으로 생성자와 [Inject] 멤버를 읽어 InjectTypeInfo 로 캐시하고, 그 정보대로 생성자, 부모부터 필드, 부모부터 메서드 순으로 채웁니다. 이 정보가 리플렉션에서 오기 때문에 생성자 선택 규칙과 IL2CPP 스트리핑의 영향을 그대로 받습니다.

    생성자가 여럿이면 [Inject], public 하나, 인자 최소 순으로 고른다

    MonoBehaviour 같은 Component 와 추상 클래스는 생성자를 고르지 않습니다. 나머지는 이렇게 고릅니다.

    첫 경우가 함정입니다. 의존을 더 받는 생성자가 아니라 인자가 가장 적은 생성자가 조용히 불립니다. 소스 주석은 Zenject 를 모르는 라이브러리의 클래스도 만들 수 있게 하려고 널 참조 예외의 위험을 감수한 선택이라고 적어 둡니다.

    생성자, 부모 필드, 자기 필드, 부모 메서드, 자기 메서드 순으로 채운다

    타입 정보는 자기가 선언한 멤버만 담고, 부모 클래스는 BaseTypeInfo 로 따라갑니다. 부모를 따로 분석하므로 부모의 private 필드도 주입됩니다.

    생성자 안에서 [Inject] 필드를 읽으면 null 이고, 메서드는 필드가 모두 찬 뒤 부모부터 불립니다. [InjectOptional] float _speed = 5f; 처럼 선택 주입 필드는 바인딩이 없으면 대입을 건너뛰어 초기화식 값이 남습니다. MonoBehaviour 는 생성자 단계 없이 필드와 메서드만 이 순서로 채웁니다.

    타입 정보는 기본이 리플렉션이고, 베이킹이 빌드 때 대신 만든다

    타입 정보는 캐시에 없을 때 한 번 만들어집니다. 타입 안에 __zenCreateInjectTypeInfo 메서드가 있으면 그것을 부르고, 없으면 리플렉션으로 만듭니다. 그 메서드를 넣는 것이 리플렉션 베이킹입니다. Assets > Create > Zenject > Reflection Baking Settings 로 설정 에셋을 만들면 어셈블리가 컴파일될 때마다 DLL 에 IL 메서드를 끼워 넣습니다. 에셋이 없으면 아무 일도 없어서, 설치만 한 프로젝트는 전부 리플렉션입니다.

    리플렉션으로 만든 대입 함수도 환경마다 다릅니다. 에디터와 Mono 는 식 트리를 컴파일한 대리자를 쓰고, IL2CPP 는 FieldInfo.SetValue 를 씁니다. 베이킹 설정 에셋이 있는 빌드와 없는 빌드를 비교했습니다.

    빌드 첫 Instantiate 20종 합 Inject 1회
    Mono, 베이킹 끔 17.2 ms 6.3 µs
    Mono, 베이킹 켬 10.6 ms 6.3 µs
    IL2CPP 스트리핑 Low, 베이킹 끔 1.7 ms 7.8 µs
    IL2CPP 스트리핑 High, 베이킹 켬 1.7 ms 6.6 µs

    Mono 는 리플렉션 분석과 식 트리 컴파일이 빠진 첫 주입이 약 40% 줄었고(JIT 몫 포함), 반복 주입은 그대로였습니다. 반복 주입의 대부분은 필드마다 하는 조회라 대입 방식을 바꿔도 크게 달라지지 않습니다. 베이킹의 이득은 속도보다 스트리핑 쪽에서 컸습니다.

    High 스트리핑은 [Inject] 특성을 지워 필드를 조용히 비운다

    [Inject] 는 PreserveAttribute 를 상속해 스트리핑을 막으려 하고, link.xml 은 Zenject-usage 어셈블리를 보존합니다. 그래도 High 에서는 막히지 않았습니다(IL2CPP 개발 빌드 플레이어, [Inject] 필드 셋을 가진 최상위와 중첩 클래스, link.xml 보존 여부를 함께 확인).

    스트리핑 베이킹 필드 셋 필드에 남은 [Inject] 특성 예외
    Minimal 끔 모두 채워짐 있음 없음
    Low 끔 모두 채워짐 있음 없음
    High 끔 모두 null 없음 없음
    High 켬 모두 채워짐 없음 없음

    필드는 남고 그 위의 특성만 지워졌습니다. 리플렉션은 특성이 붙은 멤버만 주입 대상으로 모으니, 특성이 없으면 주입할 것이 없는 타입으로 보고 지나갑니다. 베이킹은 주입할 필드 목록을 빌드 때 IL 로 넣어 두어서 특성이 지워져도 채웠습니다. 같은 High 빌드(베이킹 끔)에서 BindMemoryPool 을 넣은 SceneContext 씬은 로드 중 NullReferenceException 으로 서지 못했고, 베이킹을 켜자 풀이 정상으로 만들어졌습니다.

    주의할 점

    1. 생성자가 둘 이상이면 [Inject] 를 붙인다

    안 지키면 생기는 문제. [Inject] 없이 public 생성자가 둘이면 인자가 가장 적은 쪽이 불립니다. 의존을 더 받는 생성자를 새로 추가했는데 예전 생성자가 남아 있으면, 새 의존은 들어오지 않고 그 필드를 처음 쓰는 곳에서 널 참조 예외가 납니다. 경고는 없습니다.

    이렇게 쓴다. 주입에 쓸 생성자에 [Inject] 를 붙입니다. ZenjectSettings 의 Constructor Choice Strategy 를 InjectAttribute 로 바꿔 강제할 수도 있는데, 그러면 생성자를 쓰지 않은 클래스가 IL2CPP 에서만 More than one (or zero) constructors found 로 실패했습니다(에디터와 Mono 는 통과). 이 전략을 쓰면 인자 없는 생성자도 직접 적고 [Inject] 를 붙입니다.

    2. 생성자에서 필드 주입을, 부모 메서드에서 파생의 준비를 기대하지 않는다

    안 지키면 생기는 문제. 생성자에서 [Inject] 필드를 쓰면 null 이고, 부모의 [Inject] 메서드가 파생의 [Inject] 메서드에서 채울 값을 쓰면 아직 비어 있습니다. 상속 계층을 바꾸는 순간 조용히 깨집니다.

    이렇게 쓴다. 의존은 생성자 인자로 받아 생성자 안에서 씁니다. MonoBehaviour 는 [Inject] 메서드 하나에서 인자로 받고, 그 메서드는 자기 클래스의 필드만 준비합니다. 부모와 파생이 협력하는 초기화는 IInitializable.Initialize() 로 옮깁니다.

    3. IL2CPP 빌드에서는 주입 결과를 한 번 확인한다

    안 지키면 생기는 문제. 스트리핑은 빌드에만 있습니다. High 로 올린 IL2CPP 빌드에서 모든 [Inject] 필드가 예외 없이 null 로 남았고, link.xml 로 보존한 어셈블리도 같았습니다. 에디터만 보고 출시하면 빌드에서만 나는 널 참조를 만납니다.

    이렇게 쓴다. High 스트리핑을 쓰려면 베이킹 설정 에셋을 만들어 빌드에서 켜고, 네임스페이스 패턴에 자기 네임스페이스를 넣습니다(비우면 Zenject 타입만 굽습니다). 스트리핑 수준을 바꿀 때마다 개발 빌드에서 주요 서비스의 필드가 채워졌는지 로그로 확인합니다.

    정리

    Zenject 는 타입마다 한 번 리플렉션으로 읽은 InjectTypeInfo 로 주입합니다.

    • 생성자는 [Inject] 표시, public 하나, 인자 최소 순으로 고르고, 마지막 규칙은 경고 없이 적은 쪽을 부릅니다.
    • 채우는 순서는 생성자, 부모부터 필드, 부모부터 [Inject] 메서드입니다.
    • 리플렉션이 기본이라 High 스트리핑에서 특성이 지워지면 필드가 예외 없이 비고, 리플렉션 베이킹을 켜면 채워집니다.