아티클 목록으로 가기

AssistedInject가 Hilt 내부에서 동작하는 방식

skydovesJaewoong Eum (skydoves)||19분 소요

AssistedInject가 Hilt 내부에서 동작하는 방식

의존성 주입(dependency injection)은 정적인 의존성 그래프를 연결하는 데 탁월하지만, 런타임 매개변수가 필요한 상황에서는 어떻게 해야 할까요? 이것이 바로 Assisted Injection이 해결하는 문제이며, 컴파일 타임 의존성 관리와 런타임 매개변수 전달 사이의 간극을 메워 줍니다. 표면적으로는 단순해 보일 수 있지만, 내부적으로는 정교한 컴파일 타임 코드 생성, 관심사 분리, 그리고 Hilt 컴포넌트 시스템과의 우아한 통합이 숨겨져 있습니다.

이번 아티클에서는 Assisted Injection이 내부적으로 어떻게 동작하는지 심층적으로 살펴봅니다. 어노테이션 프로세서가 assisted 매개변수와 주입 대상 의존성을 어떻게 구분하는지, 팩토리가 어떻게 생성되고 연결되는지, Hilt가 멀티바인딩 맵을 통해 ViewModel과 Assisted Injection을 어떻게 통합하는지, 그리고 런타임에서 이 모든 것을 하나로 묶는 메커니즘까지 다룹니다. 이 글은 @AssistedInject의 사용법 가이드가 아니라, 이를 가능하게 하는 컴파일러 내부 구조를 탐구하는 글입니다.

근본적인 문제: 컴파일 타임 프레임워크에서의 런타임 매개변수

본질적으로, 의존성 주입 프레임워크는 컴파일 타임에 동작합니다. 다음과 같은 코드를 작성한다고 가정해 보겠습니다.

class MyService {
  @Inject
  MyService(Database db, NetworkClient client) {
    // ...
  }
}

프레임워크는 의존성 그래프에서 DatabaseNetworkClient를 연결하는 코드를 생성합니다. 하지만 런타임 매개변수를 전달해야 하는 경우에는 어떻게 해야 할까요? 사용자 ID, 설정 객체, 사용자 입력 데이터 등은 런타임 이전에는 존재하지 않으므로 의존성 그래프에 넣을 수 없습니다.

가장 단순한 접근 방식은 팩토리를 주입받아 객체를 직접 생성하는 것입니다.

class MyService {
  private final Database db;
  private final NetworkClient client;
  private final String userId;

  MyService(Database db, NetworkClient client, String userId) {
    this.db = db;
    this.client = client;
    this.userId = userId;
  }
}

// 수동으로 생성한 팩토리
interface MyServiceFactory {
  MyService create(String userId);
}

// 수동 구현체
class MyServiceFactoryImpl implements MyServiceFactory {
  private final Database db;
  private final NetworkClient client;

  @Inject
  MyServiceFactoryImpl(Database db, NetworkClient client) {
    this.db = db;
    this.client = client;
  }

  @Override
  public MyService create(String userId) {
    return new MyService(db, client, userId);
  }
}

이 방법은 동작하지만, 반복적이고 실수가 발생하기 쉽습니다. 의존성을 추가하거나 제거할 때마다 생성자와 팩토리 구현체를 모두 업데이트해야 하기 때문입니다. Assisted Injection은 이 전체 패턴을 자동화해 줍니다.

어노테이션 분류 체계: assisted와 injected의 구분

Assisted Injection은 팩토리 생성 자동화를 위해 세 가지 어노테이션을 함께 사용합니다.

@AssistedInject: 생성자 표시

@AssistedInject 어노테이션은 주입 대상 의존성과 assisted 매개변수가 혼합된 생성자를 표시합니다.

@Retention(RUNTIME)
@Target(CONSTRUCTOR)
public @interface AssistedInject {}

RUNTIME 보존 정책(retention)이 중요합니다. Hilt의 컴포넌트 스코프 어노테이션(CLASS 보존 정책 사용)과 달리, Assisted Injection은 유효성 검증과 디버깅을 위해 런타임 정보가 필요합니다. 다만, 실제 주입 로직은 전적으로 컴파일 타임에 생성됩니다.

한 가지 중요한 제약 사항이 있습니다. @AssistedInject 생성자를 가진 타입에는 스코프를 지정할 수 없습니다. 이는 합리적인 설계인데, 런타임 매개변수를 전달한다는 것은 팩토리 호출 시마다 새 인스턴스를 생성한다는 의미이기 때문입니다. 스코프를 적용하려면 assisted 매개변수를 기준으로 캐싱해야 하는데, 이는 복잡하면서도 실용적으로 거의 쓸모가 없습니다.

@Assisted: 런타임 매개변수 표시

@Assisted 어노테이션은 런타임 매개변수를 주입 대상 의존성과 구분하는 역할을 합니다.

@Retention(RUNTIME)
@Target(PARAMETER)
public @interface Assisted {
  String value() default "";
}

value() 매개변수는 동일한 타입의 assisted 매개변수가 여러 개 있을 때 식별자(discriminator) 역할을 합니다. 다음 예시를 살펴보겠습니다.

class DataService {
  @AssistedInject
  DataService(
      Database db,
      @Assisted String name,
      @Assisted("id") String id,
      @Assisted("repo") String repo) {
    // ...
  }
}

여기서 세 개의 String 매개변수가 assisted로 지정되어 있습니다. 식별자가 없으면 프레임워크가 이들을 구분할 수 없습니다. 식별자는 (타입, 식별자) 조합으로 고유 키를 생성합니다. 따라서 다음과 같이 구성됩니다.

  • (String, ""): name
  • (String, "id"): id
  • (String, "repo"): repo

프로세싱 과정에서 이러한 매개변수는 타입과 식별자 모두를 기반으로 동등성(equality)을 판단하는 AssistedParameter 클래스로 래핑됩니다. 덕분에 컴파일 타임에 고유성 검증이 이루어집니다.

@AssistedFactory: 사용자 측 API 정의

@AssistedFactory 어노테이션은 사용자가 실제로 사용할 인터페이스를 표시합니다.

@Retention(RUNTIME)
@Target(TYPE)
public @interface AssistedFactory {}

이 어노테이션이 적용된 타입은 엄격한 제약 조건을 따라야 합니다.

  • 반드시 추상(abstract) 타입이어야 합니다 (인터페이스 또는 추상 클래스)
  • 정확히 하나의 추상 메서드(default가 아닌)를 포함해야 합니다
  • 해당 메서드는 @AssistedInject가 적용된 타입을 반환해야 합니다
  • 해당 메서드의 매개변수는 assisted 매개변수(타입 + 식별자, 동일 순서)와 정확히 일치해야 합니다

이러한 제약 조건은 AssistedValidator가 컴파일 타임에 검증하며, 생성된 팩토리 구현체의 타입 안전성을 보장합니다.

2단계 코드 생성: 구현체 생성 후 어댑터 생성

Assisted Injection은 2단계에 걸쳐 코드를 생성하며, 각기 다른 역할을 가진 두 개의 별도 클래스를 만들어 냅니다.

1단계: 구현 팩토리(Implementation Factory)

모든 @AssistedInject 생성자에 대해 프로세서는 *_Factory 구현체를 생성합니다. 구체적인 예시를 살펴보겠습니다.

class Foo {
  private final Dep1 dep1;
  private final Dep2 dep2;
  private final AssistedDep1 assistedDep1;
  private final AssistedDep2 assistedDep2;
  private final int assistedInt;

  @AssistedInject
  Foo(
      Dep1 dep1,
      @Assisted AssistedDep1 assistedDep1,
      Dep2 dep2,
      @Assisted AssistedDep2 assistedDep2,
      @Assisted int assistedInt) {
    this.dep1 = dep1;
    this.dep2 = dep2;
    this.assistedDep1 = assistedDep1;
    this.assistedDep2 = assistedDep2;
    this.assistedInt = assistedInt;
  }
}

프로세서가 생성하는 Foo_Factory는 다음과 같습니다.

public final class Foo_Factory {
  private final Provider<Dep1> dep1Provider;
  private final Provider<Dep2> dep2Provider;

  public Foo_Factory(Provider<Dep1> dep1Provider, Provider<Dep2> dep2Provider) {
    this.dep1Provider = dep1Provider;
    this.dep2Provider = dep2Provider;
  }

  public Foo get(AssistedDep1 assistedDep1, AssistedDep2 assistedDep2, int assistedInt) {
    return newInstance(
        dep1Provider.get(),
        assistedDep1,
        dep2Provider.get(),
        assistedDep2,
        assistedInt);
  }

  public static Foo newInstance(
      Dep1 dep1,
      AssistedDep1 assistedDep1,
      Dep2 dep2,
      AssistedDep2 assistedDep2,
      int assistedInt) {
    return new Foo(dep1, assistedDep1, dep2, assistedDep2, assistedInt);
  }

  public static Foo_Factory create(Provider<Dep1> dep1Provider, Provider<Dep2> dep2Provider) {
    return new Foo_Factory(dep1Provider, dep2Provider);
  }
}

생성된 코드의 구조를 자세히 분석해 보면 다음과 같은 특징이 있습니다.

  1. 주입 대상 의존성을 위한 Provider 필드: dep1Providerdep2Provider가 필드로 저장됩니다. 이들은 의존성 그래프로부터 주입받습니다.

  2. assisted 매개변수를 받는 get\(\) 메서드: get() 메서드는 assisted 매개변수를 메서드 인자로 받습니다. 이것이 표준 Factory<T>와의 핵심적인 차이점인데, 표준 팩토리는 인자가 없는 get() 메서드를 가지지만, assisted 팩토리는 매개변수가 있는 get(...) 메서드를 가집니다.

  3. newInstance\(\) 정적 메서드: 객체를 직접 생성하는 편의 메서드로, 테스트 시에 유용하며, 팩토리 자체를 주입할 필요가 없는 경우에도 활용할 수 있습니다.

  4. create\(\) 정적 팩토리 메서드: 팩토리 인스턴스를 생성합니다. Dagger 컴포넌트가 초기화 시점에 이 메서드를 호출합니다.

newInstance()의 매개변수 순서는 생성자 시그니처와 정확히 일치합니다. 당연해 보일 수 있지만, 프로세서가 주입 대상 매개변수와 assisted 매개변수를 인터리빙(interleaving)할 때 매개변수 순서를 정확히 유지해야 하므로 매우 중요합니다.

2단계: 어댑터 팩토리(Adapter Factory)

모든 @AssistedFactory 인터페이스에 대해 프로세서는 사용자 정의 인터페이스를 구현하고 구현 팩토리에 위임하는 *_Impl 어댑터를 생성합니다.

@AssistedFactory
interface FooFactory {
  Foo createFoo(AssistedDep1 dep1, AssistedDep2 dep2, int value);
}

생성되는 어댑터는 다음과 같습니다.

public final class FooFactory_Impl implements FooFactory {
  private final Foo_Factory delegateFactory;

  FooFactory_Impl(Foo_Factory delegateFactory) {
    this.delegateFactory = delegateFactory;
  }

  @Override
  public Foo createFoo(AssistedDep1 dep1, AssistedDep2 dep2, int value) {
    return delegateFactory.get(dep1, dep2, value);
  }

  public static Provider<FooFactory> createFactoryProvider(Foo_Factory delegateFactory) {
    return InstanceFactory.create(new FooFactory_Impl(delegateFactory));
  }
}

이 아티클은 구독자 전용입니다

Dove Letter를 구독하시면 안드로이드, 코틀린 개발 관련 독점 아티클의 전체 내용을 볼 수 있습니다.

구독하기
아티클 목록으로 가기