React Native에서 사용하는 Javscript Bundler 아님 주의
운영 중인 사이드 프로젝트에 Metro를 적용해보면서, 기존에 사용했던 의존성 주입 라이브러리인 Hilt 와의 차이점과 Migration 과정에서 발생했던 문제를 해결한 방법을 공유하고자 한다.
Metro가 무엇인지, 왜 등장하게 되었는지는 이전 글에서 설명해두었기 때문에 빠르게 본론으로 넘어가도록 하겠다.
우선 Hilt에서 사용하는 어노테이션들과 Metro의 어노테이션을 비교해보도록 하겠다.
@HiltAndroidApp
class MyApplication : Application()
@AndroidEntryPoint
class MainActivity : ComponentActivity()
Hilt는 @HiltAndroidApp과 @AndroidEntryPoint 어노테이션으로 Android 컴포넌트에 자동으로 의존성을 주입한다.
이를 위해 Hilt는 Annotation Processing으로 코드를 생성하고, bytecode를 변환(transformation)을 통해 Android 각 컴포넌트의 생성 과정에 개입한다.
class MetroApp : Application(), Configuration.Provider {
/** Holder reference for the app graph for [MetroAppComponentFactory]. */
val appGraph by lazy { createGraphFactory<AppGraph.Factory>().create(this) }
}
/**
* An [AppComponentFactory] that uses Metro for constructor injection of Activities.
*
* If you have minSdk < 28, you can fall back to using member injection on Activities or (better)
* use an architecture that abstracts the Android framework components away.
*/
@Keep
class MetroAppComponentFactory : AppComponentFactory() {
private inline fun <reified T : Any> getInstance(
cl: ClassLoader,
className: String,
providers: Map<KClass<out T>, Provider<T>>,
): T? {
val clazz = Class.forName(className, false, cl).asSubclass(T::class.java)
val modelProvider = providers[clazz.kotlin] ?: return null
return modelProvider()
}
override fun instantiateActivityCompat(
cl: ClassLoader,
className: String,
intent: Intent?,
): Activity {
return getInstance(cl, className, activityProviders)
?: super.instantiateActivityCompat(cl, className, intent)
}
override fun instantiateApplicationCompat(cl: ClassLoader, className: String): Application {
val app = super.instantiateApplicationCompat(cl, className)
activityProviders = (app as MetroApp).appGraph.activityProviders
return app
}
// AppComponentFactory can be created multiple times
companion object {
private lateinit var activityProviders: Map<KClass<out Activity>, Provider<Activity>>
}
}
@DependencyGraph(AppScope::class)
interface AppGraph {
@Provides fun provideApplicationContext(application: Application): Context = application
/**
* A multibinding map of activity classes to their providers accessible for
* [MetroAppComponentFactory].
*/
@Multibinds val activityProviders: Map<KClass<out Activity>, Provider<Activity>>
@DependencyGraph.Factory
fun interface Factory {
fun create(@Provides application: Application): AppGraph
}
}
AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<application
android:name=".MetroApp"
android:appComponentFactory="dev.zacsweers.metro.sample.android.MetroAppComponentFactory"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="Metro Android Sample"
android:supportsRtl="true"
android:theme="@style/Theme.Material3.DayNight"
tools:replace="android:appComponentFactory"
>
// ...
</application>
</manifest>
Metro는 Android Framework의 AppComponentFactory를 활용한다.
AppComponentFactory는 Android 9 (API 28)부터 도입된 공식 API로, Activity, Service, BroadcastReceiver 등의 생성을 가로채서 커스텀 로직을 실행할 수 있다.
Metro는 이처럼 "magic" 이 적고 더 명시적이다. 개발자가 직접 Graph를 생성하고, AppComponentFactory에서 명시적으로 의존성을 주입하기 때문에 동작 과정을 이해하기 쉽다. 대신 Hilt에 비해 초기 설정에 더 많은 보일러플레이트 코드가 필요하다.
해당 프로젝트에선 ViewModel을 사용하지 않았으나, ViewModel을 사용하는 경우 별도의 ViewModelFactory를 만들어주어야 한다.
Hilt 최고
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient = OkHttpClient()
}
@DependencyGraph(AppScope::class)
interface AppGraph {
val okHttpClient: OkHttpClient
@DependencyGraph.Factory
fun interface Factory {
fun create(@Provides application: Application): AppGraph
}
}
@ContributesTo(AppScope::class)
interface NetworkGraph {
@Provides
fun provideOkHttpClient(): OkHttpClient = OkHttpClient()
}
Hilt의 @Module은 바인딩을 모아두는 컨테이너이고, @InstallIn으로 어떤 Component에 설치할지 지정한다.
반면 Metro는 @DependencyGraph가 실제 의존성 그래프를 정의하고, @ContributesTo로 해당 Scope에 바인딩을 기여한다.
@ContributesTo(AppScope::class)
interface NetworkGraph {
@Provides
fun provideRetrofit(): Retrofit
}
@ContributesTo는 Anvil에서 영감을 받은 기능으로, 특정 Scope에 바인딩을 기여(contribute)한다.
이를 통해 모듈화된 프로젝트에서 독립적으로 각각의 Graph 들을 생성하여 관리할 수 있다.
Hilt에서는 @InstallIn이 유사한 역할을 하지만, Metro의 @ContributesTo가 더 유연하다. 왜 더 유연한지는 이어서 설명하도록 하겠다.
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindAuthRepository(
impl: DefaultAuthRepository
): AuthRepository
}
@ContributesTo(AppScope::class)
interface DataGraph {
@Binds
@SingleIn(AppScope::class)
val DefaultAuthRepository.bind: AuthRepository
}
Hilt는 @SingletonComponent, @ActivityScoped 등 미리 정의된 Scope를 사용한다. Metro는 @SingleIn(SomeScope::class) 형태로 특정 Scope 내에서 싱글톤으로 관리할 수 있다.
Metro 는 Scope를 프로젝트 구조에 맞게 자유롭게 설계할 수 있다. 예를 들어
NetworkScope,DataStoreScope같은 도메인별 Scope를 만들 수 있다.
둘 다 동일하게 사용
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient = OkHttpClient()
@Provides
@Singleton
fun provideRetrofit(okHttp: OkHttpClient): Retrofit =
Retrofit.Builder().client(okHttp).build()
}
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindAuthRepository(
impl: DefaultAuthRepository
): AuthRepository
}
@ContributesTo(AppScope::class)
interface DataGraph {
// interface 하나에 모두 가능!
@Provides
@SingleIn(AppScope::class)
fun provideRetrofit(): Retrofit = Retrofit.Builder().build()
@Binds
val DefaultAuthRepository.bind: AuthRepository
@Binds
val DefaultBookRepository.bind: BookRepository
}
적용하는 방식은 함수, 변수 타입으로 차이가 있으나, 기존 처럼 @Provides 는 인스턴스를 제공하는 함수에, @Binds 는 구현체를 인터페이스에 바인딩할 때 사용한다.
class DefaultAuthRepository @Inject constructor(
private val service: ReedService,
private val tokenDataSource: TokenDataSource,
) : AuthRepository {}
class OnboardingPresenter @AssistedInject constructor(
@Assisted private val navigator: Navigator,
private val repository: UserRepository,
private val analyticsHelper: AnalyticsHelper,
) : Presenter<OnboardingUiState> {
@CircuitInject(OnboardingScreen::class, ActivityRetainedComponent::class)
@AssistedFactory
fun interface Factory {
fun create(navigator: Navigator): OnboardingPresenter
}
}
@SingleIn(DataScope::class)
@Inject
class DefaultAuthRepository(
private val service: ReedService,
private val tokenDataSource: TokenDataSource,
) : AuthRepository {}
@AssistedInject
class OnboardingPresenter(
@Assisted private val navigator: Navigator,
private val repository: UserRepository,
private val analyticsHelper: AnalyticsHelper,
) : Presenter<OnboardingUiState> {
@CircuitInject(OnboardingScreen::class, AppScope::class)
@AssistedFactory
fun interface Factory {
fun create(navigator: Navigator): OnboardingPresenter
}
}
둘 다 동일하게 사용
위와 마찬가지로 적용하는 방식은 constructor 앞에 @Inject/@AssistedInject, 클래스 앞에 @Inject/@AssistedInject 로 차이가 있으나, 동작 방식은 동일하다.
그 밖에
@Qualifier어노테이션을 통한 의존성 구분은 Hilt 와 Metro가 차이가 없기 때문에 그대로 사용하면 된다.Provider<T>,Lazy<T>등의 키워드도 마찬가지
Metro 적용을 어느정도 마무리 한 후에, 컴파일 단계에서 다음과 같은 에러가 발생하였다.
java.lang.ClassCastException: class org.jetbrains.kotlin.ir.types.impl.IrErrorTypeImpl
cannot be cast to class org.jetbrains.kotlin.ir.types.IrSimpleType
에러 메시지만 봐서는 도대체 코드 어디에, 무엇이 문제인지 파악하기 어려웠다. 전체 에러메세지를 확인해봐도 어떤 바인딩이 누락(missing)되었는지, 어떤 타입에서 문제가 발생했는지에 대한 정보를 전혀 알 수 없었다.
자력으로는 문제를 해결할 수 없다고 판단하여 Metro Github Discussion에 이슈를 제보했고, 다행히 Metro의 Maintainer 이신 Zac Sweers 님께서 빠르게 답변을 달아주셨다.
제보한 이슈 및 전체 에러 메세지는 아래 링크를 통해 확인할 수 있다.
https://github.com/ZacSweers/metro/discussions/1358
기존 프로젝트의 규모가 제법되고, 프로젝트의 빌드는 jks 파일을 가진 사람만 할 수 있기 때문에, 에러 재현을 위한 누구나 빌드가 가능한 최소한의 구현이 적용된 레포지토리를 구성해보았다.
https://github.com/easyhooon/MetroApplication
문제의 원인을 몰랐기 때문에, 에러를 재현하기 위해 거의 사이드 프로젝트 클론을 만들어버렸던 건 비밀...

문제의 근본 원인은 Metro 어노테이션을 사용법과 다르게 잘못 사용해서 발생한 것이 아닌, api를 사용하지 않고 implementation을 사용하여 모듈 의존성을 주입한 것이었다.
정확히는 :core:datastore:impl 모듈이 Preferences 타입을 public API에 노출하면서도 implementation으로만 선언했다는 것이다.
implementation:
api:
클래스패스(Classpath)란 컴파일러가 타입 정보를 찾기 위해 참조하는 경로의 집합이다.
예시)
:core:datastore:impl 모듈의 컴파일 클래스패스:
- :core:datastore:impl의 소스 코드
- androidx.datastore:datastore-preferences (implementation으로 선언)
→ 이 모듈 안에서는 Preferences 타입을 볼 수 있음
// core/data/impl/DefaultUserRepository.kt
@SingleIn(DataScope::class)
@Inject
class DefaultUserRepository(
// Uncomment this line to reproduce Metro bug: -> Resolve!
private val notificationDataSource: NotificationDataSource,
) : UserRepository {
override suspend fun getUserProfile(): Result<String> {
return Result.success("User Profile")
}
}
// core/data/impl/build.gradle.kts
dependencies {
implementations(
projects.core.di,
projects.core.data.api,
projects.core.datastore.api,
)
}
// core/datastore/api/NotificationDataSource
interface NotificationDataSource {
// ...
}
// core/datastore/impl/DataStoreGraph.kt
@ContributesTo(DataScope::class)
interface DataStoreGraph {
@NotificationDataStore
@Provides
fun provideNotificationDataStore(
@ApplicationContext context: Context,
): DataStore<Preferences> = context.notificationDataStore
@Binds
val DefaultNotificationDataSource.bind: NotificationDataSource
}
// core/datastore/impl/build.gradle.kts
dependencies {
implementation(projects.core.di)
implementation(projects.core.datastore.api)
// API because DataStore<Preferences> is exposed in public API (DataStoreGraph)
// Metro compiler needs to resolve Preferences type across modules
// See: https://github.com/ZacSweers/metro/discussions/1358#discussioncomment-15020091
// implementation -> api
// implementation(libs.androidx.datastore.preferences)
api(libs.androidx.datastore.preferences)
}
core:datastore:impl 모듈내 datastore-preferences 의존성을 api로 추가함으로써, 의존성이 전이되어 :app 모듈도 Preferences 타입을 볼 수 있게 하여 문제를 해결할 수 있었고, 정상적으로 컴파일이 완료되었다.
그동안 멀티 모듈기반의 프로젝트를 수도 없이 구성해왔지만, api는 정말 특정한 상황에서만 사용해야하고, 그렇지 않은 경우 사용을 지양해야한다고, 사용하지 않는 것이 좋다고 학습하였기 때문에 되도록이면 implementation 을 사용하려고 하였다.
하지만 왜 api 사용을 지양해야 하는지에 대해 깊이 생각해본 적은 없던 것같다.
그저 "선생님이 쓰지 말라고 했으니 쓰지 말아야지" 라는 식으로, 착한 학생처럼 규칙만을 따랐을 뿐이었다.
이번 이슈를 해결하면서 비로소 api와 implementation의 차이를 제대로 이해하게 된 것 같다.
implementation: 의존성을 내부 구현으로 숨김 → 빌드 속도 향상, 불필요한 재컴파일 방지api: 의존성을 외부에 노출 → 타입이 public API에 포함되어야 할 때 필요규칙의 이유를 이해하지 못한 채 맹목적으로 따르기만 한다면, 예외 상황에서 적절히 대응할 수 없다는 걸 깨달았다. 앞으로는 "왜?"를 먼저 묻고, 원리를 이해한 후 규칙을 적용해야겠다.
@Module
@InstallIn(SingletonComponent::class)
object DataStoreModule {
@Provides
@Singleton
fun provideDataStore(
@ApplicationContext context: Context
): DataStore<Preferences> = context.dataStore
}
Hilt는 Annotation Processor를 통해 컴파일 타임에 코드를 생성한다. 이때 Hilt는 각 모듈의 컴파일 클래스패스를 개별적으로 확인하기 때문에, datastore-preferences가 implementation으로 선언되어 있어도 해당 모듈 내에서는 타입을 볼 수 있다.
@ContributesTo(DataScope::class)
interface DataStoreGraph {
@Provides
fun provideDataSource(): DefaultDataSource
}
@DependencyGraph(
scope = AppScope::class,
additionalScopes = [DataScope::class]
)
interface AppGraph
Metro는 Kotlin Compiler Plugin으로 동작하며, 앱 모듈에서 전체 의존성 그래프를 한 번에 분석한다. 따라서 :app 모듈이 DefaultDataSource의 Preferences 타입을 보려면 반드시 :app의 컴파일 클래스패스에 datastore-preferences가 있어야 한다.
문제의 원인 파악에 있어, 각 모듈의 주입 형태는 Hilt 사용할 때에 문제가 없었기에 당연히 Metro를 사용해도 문제가 없을 것이라 생각해 가능성을 후보지에서 제외하였고... 그렇게 문제를 찾고 에러를 재현해내는데 대략 일주일이 넘는 시간을 허비하였다...내 시간...
Hilt 에서 Metro 로 성공적으로 Migration 하여 PR 을 올릴 수 있었다.
https://github.com/YAPP-Github/Reed-Android/pull/227
Metro를 적용해보면서 오히려 Hilt에 대해 더 많이 알게된 것 같다. 그동안 Hilt가 정말 많은 것들을 대신 해주고 있음을 깨달을 수 있었다. 고맙다 Hilt야
이처럼, 무언가를 학습할 땐 A 하나만을 Deep Dive하는 것이 아닌, A와 B를 비교하면서 공부하는게 A와 B의 특징, 차이점을 보다 또렷하게 알게되는 것 같다.
그동안 많이 활용해왔던 학습 방법인데, 앞으로도 계속 활용 해야겠다.
Metro를 적용한 코드를 보면 다음과 같이 Activity에 생성자 주입 방식을 통해 의존성을 주입하는 방식을 확인할 수 있다.
@ContributesIntoMap(AppScope::class, binding = binding<Activity>())
@ActivityKey(MainActivity::class)
@Inject
class MainActivity(
private val circuit: Circuit,
) : ComponentActivity() {
// 기존에 Hilt를 사용할 때는 lateinit var 을 통한 필드 주입의 방법을 사용했엇음
// @Inject lateinit var circuit: Circuit
}
Android 개발자라면 눈쌀이 찌부려질 구현 방식인데, Activity에 파라미터가 있는 생성자를 만드는 것은 지양하는 방식이기 때문이다.
configuration change가 발생하여 Activity가 재생성될 경우 빈 생성자만 호출하기 때문에 인자가 반드시 필요한 경우 문제가 발생할 수 있다.
이러한 문제를 Metro는 어떻게 해결하고 있는 것인지 추가적인 확인이 필요하다.
그래서 Discussion에 질문을 올려두었고, 답변을 받을 수 있었다.
Fragment도 마찬가지로 FragmentFactory를 구현하여 Fragment의 생성자에 ViewModelFactory를 주입하는 코드를 확인할 수 있었는데, 이 또한 문제가 없는지 확인해봐야겠다...
아직 Metro를 적용하기 전과 후의 성능적인 차이점을 직접 benchmark 등을 통해 측정해보지 않았기 때문에 유의미한 차이가 있는지 측정을 해봐야겠다.

https://news.hada.io/topic?id=21111
나는 DI 를 직접 수동으로 구현해본 적이 없다...
Hilt를 처음 적용할 때는 정말 어려웠지만, 사용하는데 익숙해지고 나선 Hilt 없이는 개발에 엄두가 나지 않을 정도로 편하게 이용해왔다.
이번 글을 작성하면서 "바퀴를 다시 발명하지 말라" 는 업계의 격언이 있지만, 때로는 바퀴가 어떻게 굴러가는지 이해하기 위해 직접 만들어보거나, 다른 바퀴를 굴려보는 것이 중요하다는 걸 깨달았다.
DI 에 대한 본질적인 이해가 부족하다고 느끼던 시점에, 우아한 테크코스에서 android-di 미션을 통해 DI 를 직접 구현해보는 미션을 수행하는 것을 알 수 있었다. 역시 업계 최고의 교육 기관은 다르구나
우테코를 수료하여 이제 본격적으로 취업을 준비하는 학생분들은 나보다 훨씬 DI 에 대한 이해도가 높겠구나 생각하면서, 반성도 하게된다.
나도 DI 에 대한 더 깊이 이해를 위해, 일부러라도 DI를 직접 구현해보는 시간을 가져봐야겠다.
바퀴의 재발명 관련 양질의 레퍼런스들의 링크를 남기며 글을 마무리하도록 하겠다.
바퀴를 다시 발명하라
안드로이드 DI 직접 구현하기
우테코 android-di 미션
[7기] 모바일 안드로이드 레벨4 바퀴의 재발명
reference)
https://zacsweers.github.io/metro/latest/
https://github.com/ZacSweers/metro/tree/main/samples/android-app
https://github.com/ZacSweers/metro/tree/main/samples/circuit-app
https://github.com/ZacSweers/CatchUp
https://github.com/DroidKaigi/conference-app-2025
https://github.com/l2hyunwoo/Kudos
https://jtm0609.tistory.com/296
https://developer.android.com/reference/android/app/AppComponentFactory
https://github.com/easyhooon/MetroApplication
https://github.com/woowacourse/retrospective/discussions/73
https://github.com/woowacourse/android-di
https://mangkyu.tistory.com/296
https://velog.io/@plz_no_anr/Android-Hilt%EC%9D%98-Provider%EC%99%80-Lazy
https://stackoverflow.com/questions/65953637/what-is-bytecode-transformation
https://github.com/woowacourse/retrospective/discussions/73