아티클 목록으로 가기

Dagger 멀티바인딩 메커니즘: @IntoSet과 @IntoMap의 내부 동작 원리

skydovesJaewoong Eum (skydoves)||21분 소요

Dagger 멀티바인딩 메커니즘: @IntoSet과 @IntoMap의 내부 동작 원리

의존성 주입(dependency injection) 프레임워크는 개별 의존성을 연결하는 데 탁월합니다. 그런데 동일한 타입의 여러 구현체를 하나의 Set이나 Map으로 모아야 할 때는 어떻게 해야 할까요? 바로 이때 멀티바인딩(multibinding)이 필요합니다. 멀티바인딩은 여러 모듈에 분산된 기여(contribution)를 하나의 컬렉션으로 집계하는 메커니즘으로, API 자체는 단순해 보이지만, 내부적으로는 정교한 컴파일 타임 집계 로직, 선언과 기여 간의 명확한 분리, 그리고 메모리 효율성과 성능 모두를 최적화한 런타임 팩토리가 동작하고 있습니다.

이번 아티클에서는 @IntoSet@IntoMap의 내부 동작 원리를 깊이 있게 살펴보겠습니다. 어노테이션 프로세서가 기여와 선언을 어떻게 구분하는지, 바인딩 그래프가 분산된 바인딩을 어떻게 집계하는지, 런타임 팩토리가 컬렉션을 어떻게 즉시 생성하는지, 맵 키가 어떻게 처리되고 검증되는지, 그리고 Hilt가 ViewModel 인프라에 멀티바인딩을 어떻게 활용하는지까지 다루겠습니다. 멀티바인딩 사용법 가이드가 아니라, 분산 컬렉션 빌딩의 토대가 되는 컴파일러와 런타임 기계 장치(machinery)에 대한 탐구라고 보시면 됩니다.

근본적인 문제: 분산 컬렉션 빌딩

여러 모듈이 플러그인을 기여하는 플러그인 아키텍처를 생각해 봅시다.

// 모듈 A
@Module
class ModuleA {
  @Provides @IntoSet
  static Plugin providePluginA() {
    return new PluginA();
  }
}

// 모듈 B
@Module
class ModuleB {
  @Provides @IntoSet
  static Plugin providePluginB() {
    return new PluginB();
  }
}

// 애플리케이션
@Component(modules = {ModuleA.class, ModuleB.class})
interface AppComponent {
  Set<Plugin> plugins();  // {PluginA, PluginB}를 반환
}

여기서 핵심적인 질문이 생깁니다. Dagger는 이처럼 별도로 작성된 @Provides 메서드들을 어떻게 하나의 Set<Plugin> 바인딩으로 수집할까요? 각 모듈은 독립적이기 때문에, ModuleAModuleB의 존재를 전혀 알지 못합니다. 그럼에도 불구하고 컴포넌트는 모든 기여를 집계해야 합니다.

가장 단순한 접근법은 수동으로 컬렉션을 구성하는 것입니다.

@Module
class PluginCollectionModule {
  @Provides
  static Set<Plugin> providePlugins(
      PluginA a, PluginB b, PluginC c, ...) {
    Set<Plugin> plugins = new HashSet<>();
    plugins.add(a);
    plugins.add(b);
    plugins.add(c);
    return plugins;
  }
}

하지만 이 방식은 취약합니다. 플러그인을 추가할 때마다 이 중앙 모듈을 매번 수정해야 하기 때문입니다. 멀티바인딩은 각 모듈이 서로의 존재를 알 필요 없이 독립적으로 컬렉션에 기여할 수 있도록 하여 이 문제를 해결합니다.

어노테이션 분류 체계: 기여(Contribution)와 선언(Declaration)

멀티바인딩은 각각 고유한 역할을 가진 4가지 어노테이션을 도입합니다.

@IntoSet: 개별 요소 기여

@IntoSet 어노테이션은 하나의 요소를 Set에 기여하는 메서드를 표시합니다.

@Documented
@Target(METHOD)
@Retention(RUNTIME)
public @interface IntoSet {}

메서드의 반환 타입이 곧 요소 타입이 됩니다. Plugin을 반환하는 메서드라면, Set<Plugin>에 기여하게 됩니다.

@Provides @IntoSet
static Plugin providePlugin() {
  return new PluginImpl();
}

RUNTIME 리텐션(retention)은 중요한 의미를 갖습니다. Dagger가 이러한 어노테이션을 컴파일 타임에 처리하긴 하지만, RUNTIME 리텐션 덕분에 디버깅과 도구(tooling)에서 런타임 검사도 가능해집니다. 다만, 실제 멀티바인딩 로직 자체는 전적으로 컴파일 타임에 생성됩니다.

@IntoMap: 키-값 쌍 기여

@IntoMap 어노테이션은 Map에 엔트리를 기여하는 메서드를 표시합니다.

@Documented
@Target(METHOD)
@Retention(RUNTIME)
public @interface IntoMap {}

@IntoSet과 달리, @IntoMap은 키를 지정하기 위한 @MapKey 어노테이션을 함께 사용해야 합니다.

@Provides @IntoMap
@StringKey("key1")
static Plugin providePlugin() {
  return new PluginImpl();
}

이 메서드는 Map<String, Provider<Plugin>>에 기여합니다. 맵의 값 타입이 Plugin이 아니라 Provider<Plugin>이라는 점에 주목하세요. 맵은 기본적으로 지연 평가(lazy evaluation)를 사용합니다.

@ElementsIntoSet: 컬렉션 단위 기여

@ElementsIntoSet 어노테이션은 여러 요소를 한꺼번에 기여합니다.

@Provides @ElementsIntoSet
static Set<Plugin> provideDefaultPlugins() {
  return ImmutableSet.of(new PluginA(), new PluginB());
}

기본(default) 기여나 대량 추가를 할 때 유용합니다. 메서드가 Set<T>를 반환하면, 모든 요소가 멀티바인딩에 추가됩니다.

@Multibinds: 빈 멀티바인딩 선언

@Multibinds 어노테이션은 멀티바인딩이 존재한다는 사실을 선언하며, 기여가 하나도 없어도 동작합니다.

@Module
abstract class MyModule {
  @Multibinds
  abstract Set<Plugin> plugins();

  @Multibinds
  abstract Map<String, Plugin> pluginMap();
}

이 선언은 멀티바인딩이 비어 있을 가능성이 있을 때만 필요합니다. 하나 이상의 기여가 존재하면 선언은 암시적으로 처리됩니다. 하지만 컴포넌트가 Set<Plugin>을 요청하는데 기여가 하나도 없고 @Multibinds 선언도 없다면, 컴파일 오류가 발생합니다.

핵심적인 통찰은, Dagger가 @Multibinds 메서드를 실제로 구현하거나 호출하지 않는다는 점입니다. 이 메서드들은 순수한 메타데이터이며, 해당 멀티바인딩이 존재해야 한다는 컴파일 타임 신호 역할만 합니다.

ContributionType 열거형: 컴파일 타임 분류

어노테이션 처리 과정에서 Dagger는 각 바인딩을 기여 타입별로 분류합니다.

public enum ContributionType {
  UNIQUE,       // 일반(비-멀티바인딩) 바인딩
  SET,          // @IntoSet 기여
  SET_VALUES,   // @ElementsIntoSet 기여
  MAP,          // @IntoMap 기여
}

public static ContributionType fromBindingElement(XElement element) {
  if (element.hasAnnotation(XTypeNames.INTO_MAP)) {
    return ContributionType.MAP;
  } else if (element.hasAnnotation(XTypeNames.INTO_SET)) {
    return ContributionType.SET;
  } else if (element.hasAnnotation(XTypeNames.ELEMENTS_INTO_SET)) {
    return ContributionType.SET_VALUES;
  }
  return ContributionType.UNIQUE;
}

이 분류가 전체 멀티바인딩 기계 장치를 주도합니다. 프로세서가 @Provides 메서드를 만나면, 해당 어노테이션을 확인하고 적절한 바인딩 타입을 생성합니다.

바인딩 표현: 기여에서 집계로

멀티바인딩은 2단계 바인딩 구조를 사용합니다. 개별 기여(individual contribution)와 집계된 바인딩(aggregated binding)입니다.

개별 기여: ProvisionBinding

@Provides @IntoSet 또는 @Provides @IntoMap 메서드는 ProvisionBinding을 생성합니다.

public abstract class ProvisionBinding extends ContributionBinding {
  @Override
  public BindingKind kind() {
    return BindingKind.PROVISION;
  }

  @Memoized
  @Override
  public ContributionType contributionType() {
    return ContributionType.fromBindingElement(bindingElement().get());
  }
}

키는 기여 타입에 따라 다르게 저장됩니다.

  • @IntoSet의 경우: 키는 Set<T> (T는 반환 타입)
  • @IntoMap의 경우: 키는 Map<K, Provider<V>> (K는 맵 키 타입, V는 반환 타입)
  • @ElementsIntoSet의 경우: 키는 Set<T> (T는 반환된 Set<T>의 요소 타입)

여러 ProvisionBinding 객체가 동일한 키를 공유할 수 있으며, Dagger는 이를 통해 동일한 멀티바인딩에 대한 기여임을 파악합니다.

집계된 바인딩: MultiboundSetBinding과 MultiboundMapBinding

Dagger가 Set<T> 또는 Map<K, V> 요청을 리졸브(resolve)할 때, 합성된(synthetic) 집계 바인딩을 생성합니다.

public abstract class MultiboundSetBinding extends ContributionBinding {
  @Override
  public BindingKind kind() {
    return BindingKind.MULTIBOUND_SET;
  }

  // 참고: SET이 아니라 UNIQUE입니다!
  // 이것은 집계 바인딩이지, 기여가 아닙니다
  @Override
  public ContributionType contributionType() {
    return ContributionType.UNIQUE;
  }
}

이 바인딩의 contributionType()UNIQUE를 반환하는 이유가 중요합니다. 집계된 바인딩 자체는 멀티바인딩 기여가 아니라, 기여들을 집계하는 일반 바인딩이기 때문입니다.

집계된 바인딩의 의존성은 모든 개별 기여를 가리킵니다.

public MultiboundSetBinding multiboundSet(
    Key key, Iterable<ContributionBinding> multibindingContributions) {
  return MultiboundSetBinding.builder()
      .key(key)
      .dependencies(
          dependencyRequestFactory.forMultibindingContributions(key, multibindingContributions))
      .build();
}

구체적인 예제를 통해 전체 흐름을 추적해 보겠습니다.

@Module
class MyModule {
  @Provides @IntoSet static String provideFoo() { return "foo"; }
  @Provides @IntoSet static String provideBar() { return "bar"; }
}

@Component(modules = MyModule.class)
interface MyComponent {
  Set<String> strings();
}

처리 흐름

  1. 프로세서가 provideFoo()를 만남

    • 키가 Set<String>ProvisionBinding 생성
    • contributionType()SET 반환
  2. 프로세서가 provideBar()를 만남

    • 키가 Set<String>ProvisionBinding 생성 (동일한 키!)
    • contributionType()SET 반환
  3. 컴포넌트가 Set<String>을 요청

    • 바인딩 그래프가 키 Set<String>에 해당하는 바인딩을 검색
    • contributionType() == SET인 두 개의 ProvisionBinding 객체를 발견
    • 다음 속성을 가진 MultiboundSetBinding 생성
      • 키: Set<String>
      • 의존성: [provideFoo(), provideBar()]
      • contributionType()UNIQUE 반환
  4. 코드 생성기가 MultiboundSetBinding.dependencies()를 사용하여 팩토리를 생성

이 2단계 구조가 핵심입니다. 기여 메타데이터(개별 바인딩)와 집계 로직(합성 바인딩)을 명확하게 분리하기 때문입니다.

런타임 팩토리: SetFactory와 MapFactory

생성된 컴포넌트 코드는 런타임 팩토리 클래스를 사용하여 필요 시(on demand) 컬렉션을 실체화합니다.

SetFactory: Provider로부터 Set 구성

SetFactory 클래스는 Set 멀티바인딩의 런타임 핵심 구현체입니다.

public final class SetFactory<T> implements Factory<Set<T>> {
  private final List<Provider<T>> individualProviders;
  private final List<Provider<Collection<T>>> collectionProviders;

  @Override
  public Set<T> get() {
    int size = individualProviders.size();

    // 먼저 모든 컬렉션을 가져와서 전체 크기 계산
    List<Collection<T>> providedCollections = new ArrayList<>(collectionProviders.size());
    for (int i = 0, c = collectionProviders.size(); i < c; i++) {
      Collection<T> providedCollection = collectionProviders.get(i).get();
      size += providedCollection.size();
      providedCollections.add(providedCollection);
    }

    // 효율성을 위해 Set을 미리 크기 지정하여 생성
    Set<T> providedValues = newHashSetWithExpectedSize(size);

    // 개별 기여 추가
    for (int i = 0, c = individualProviders.size(); i < c; i++) {
      providedValues.add(checkNotNull(individualProviders.get(i).get()));
    }

    // 컬렉션 기여 추가
    for (int i = 0, c = providedCollections.size(); i < c; i++) {
      for (T element : providedCollections.get(i)) {
        providedValues.add(checkNotNull(element));
      }
    }

    return unmodifiableSet(providedValues);
  }
}

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

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

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