Hilt는 Dagger 기반으로 컴파일 타임에 코드 생성 → 런타임 오버헤드 최소화, 타입 안정성을 확보한다.
Hilt vs Koin
| Hilt | Koin | |
| 분류 | Dagger 기반 컴파일 타임 DI 라이브러리 | Kotlin DSL 기반 런타임 DI 라이브러리 |
| 설정/문법 | 어노테이션(@Inject, @Module, @Provides, @HiltViewModel) | DSL 모듈(module { single { … } viewModel { … } }) |
| 성능 | 컴파일 시 그래프 생성 → 런타임 오버헤드↓ | 런타임 해석/해결 → 오버헤드↑(대부분 앱에선 체감 미미) |
| 안정성 | 타입 안전성↑(빌드 타임 검증) | 런타임 에러로 뒤늦게 발견될 수 있음 |
| 빌드 시간 | 어노테이션 처리로 느려질 수 있음 | 빠름(어노테이션 처리 없음) |
| 안드로이드 통합 | Jetpack과 완전한 통합(ViewModel, WorkManager 등) | 통합 가능하나 수동 설정이 더 필요 |
| 범위/스코프 | 컴포넌트/스코프 체계 표준화 | single, factory 등 단순 스코프 |
| 대규모/멀티모듈 | 적합(대규모 그래프, 멀티바인딩, Qualifier 체계적) | 단순·중소형에 적합(대규모에선 설계 관리가 필요) |
| 테스트 | 팩토리/바인딩 기반으로 엄격한 테스트 용이 | 모듈 교체로 쉽게 목킹 가능 |
| 커뮤니티/표준성 | Android 권장 DI 흐름의 표준 | 경량/속도 위주 팀에서 선호 |
Hilt의 핵심 명령어
진입점 & 대상
- @HiltAndroidApp: Application에 부착. DI 그래프의 시작점.
- @AndroidEntryPoint: Activity, Fragment, Service, BroadcastReceiver 등에 부착해 주입 가능하게 함.
- @HiltViewModel: ViewModel 생성자에 주입 사용.
의존성 선언/제공
- @Inject:
- 생성자 주입: class Repo @Inject constructor(api: Api)
- 필드 주입: @Inject lateinit var repo: Repo
- 메서드 주입: @Inject fun setX(x: X)
- @Module + @InstallIn(...): 모듈(제공자) 정의 및 어느 컴포넌트에 설치할지 지정.
- @Provides: 생성자 주입이 어려운 객체 생성 방법 제공.
- @Binds: 인터페이스 ↔ 구현체 바인딩(추상 함수, 구현 없이 시그니처만).
- @BindsInstance: 컴포넌트 빌드 시 런타임 값(예: String, Config) 주입.
같은 타입 구분자
- @Named("..."), @Qualifier(커스텀 애노테이션): 같은 타입 다수 바인딩 구분.
Android Context 구분
- @ApplicationContext, @ActivityContext: 적절한 컨텍스트 주입.
스코프(범위)
- @Singleton, @ActivityRetainedScoped, @ActivityScoped, @FragmentScoped,
- @ViewModelScoped, @ServiceScoped: 컴포넌트 생명주기에 맞춰 인스턴스 재사용.
멀티 바인딩
- @IntoSet, @IntoMap(+ @StringKey 등): 여러 바인딩을 Set/Map으로 합치기.
자동 주입이 어려운 곳
- @EntryPoint + EntryPointAccessors: Custom View, 3rd-party 콜백 등 수동 접근.
작동 순서
1. 코드 생성
Dagger 컴파일러(+ Hilt 컴파일러)와 Gradle 플러그인을 통해 어노테이션 빌드 타임에 @Inject, @Module, @AndroidEntryPoint 등을 분석해 컴포넌트 트리, 팩토리(*_Factory), 멤버 인젝터(*_MembersInjector) 등 코드 생성.
컴포넌트 구현체
- DaggerSingletonComponent, DaggerActivityRetainedComponent, DaggerActivityComponent, DaggerFragmentComponent, DaggerViewModelComponent 등
- 각 컴포넌트는 상위 컴포넌트로부터 필요한 바인딩을 상속받고, 자신의 스코프 캐시를 가진다
팩토리
- Foo_Factory @Inject constructor나 @Provides 메서드를 “호출 코드”로 풀어낸 클래스
- get()이 호출되면 내부에서 필요한 의존성의 Provider.get()을 호출해 실제 객체를 만든다. 스코프가 있으면 DoubleCheck 같은 래퍼로 캐싱
멤버 인젝터(MembersInjector)
- 인스턴스를 받은 뒤 instance.field = provider.get() 또는 instance.setX(provider.get())를 호출해 멤버에 값을 채워 넣는다.
- 상속 구조가 있으면 상위 타입 → 현재 타입 순서로 처리
안드로이드 컴포넌트용 베이스 클래스
- Hilt_MyActivity, Hilt_MyFragment, Hilt_MyApp 등 내부적으로 Hilt Gradle 플러그인이 바이트코드를 변환해 @AndroidEntryPoint 클래스가 실제로는 Hilt_* 베이스 클래스를 상속하도록 만든다. 개발자는 AppCompatActivity를 상속했다고 적지만, 빌드 결과물에선 Hilt_MyActivity가 중간에 껴서 주입 타이밍을 확보합니다.
- 이 베이스 클래스들은 생명주기에 맞춰 inject()를 호출하는 로직을 포함합니다.
ViewModel 팩토리 및 멀티바인딩 맵
- HiltViewModelFactory와 Map<Class<out ViewModel>, Provider<ViewModel>> 바인딩
- @HiltViewModel 클래스는 키-프로바이더 맵으로 등록되고, 팩토리가 해당 키로 올바른 뷰모델을 생성자로 주입한다. SavedStateHandle도 자동 연결
2. 런타임 초기화
Application 시작
- @HiltAndroidApp이 붙은 Application은 생성된 Hilt_MyApp을 통해 컴포넌트 매니저를 초기화하고, 최상위 그래프인 SingletonComponent를 구성
- 이때 @InstallIn(SingletonComponent::class) 모듈의 바인딩이 준비되고, @Singleton 스코프 캐시가 설정
스코프 컴포넌트 생성
- SingletonComponent: 앱 전체 수명
- ActivityRetainedComponent: 액티비티 재생성 사이에도 유지(주로 ViewModel 계층).
- ActivityComponent, FragmentComponent, ViewModelComponent, ServiceComponent: 각 생명주기 동안 유지.
- 각 컴포넌트는 필요해지는 시점에 생성되고, 수명이 끝날 때 정리
- 정책은 빌드타임에 정해지더라도 초기화는 런타임에 동작
3. 주입
@AndroidEntryPoint가 붙은 Activity/Fragment 등에서 생명주기 초기에 inject() 자동 호출
@HiltViewModel은 전용 Factory가 생성자 주입으로 만들고 관리
생성자 주입
// 개발 코드
class Engine @Inject constructor(logger: Logger)
// 생성 코드
class Engine_Factory(
private val loggerProvider: Provider<Logger>
) : Factory<Engine> {
override fun get(): Engine = Engine(loggerProvider.get())
}
필드 주입
// 개발 코드
class Car {
@Inject lateinit var engine: Engine
}
// 생성 코드
class Car_MembersInjector(
private val engineProvider: Provider<Engine>
) : MembersInjector<Car> {
override fun injectMembers(instance: Car) {
instance.engine = engineProvider.get()
}
}
메서드 주입
// 개발 코드
class Car {
private lateinit var engine: Engine
@Inject fun setEngine(engine: Engine) { this.engine = engine }
}
// 생성 코드
class Car_MembersInjector(
private val engineProvider: Provider<Engine>
) : MembersInjector<Car> {
override fun injectMembers(instance: Car) {
instance.setEngine(engineProvider.get())
}
}
주입 타이밍
- Activity: onCreate 직전
- Fragment: onAttach 시점
- ViewModel: ViewModelProvider가 @HiltViewModel 생성자 주입으로 생성
- Service/Receiver: 각 생명주기 초기 지점
- Custom View 등: EntryPoint로 직접 가져와 사용
++ 좀 더 파헤치기
의존성주입의 원리를 이해하기 위해선 Component, Provider, Factory 에 대해서 알아야 한다.
- Provider : 필요할 때 get()으로 T를 꺼내주는 지연 공급자 (캐시/지연을 담당)
- Component : 수명과 스코프를 가진 DI 컨테이너, 그래프를 보관 및 관리
- *_Factory : T를 “어떻게 만들지”를 아는 생성기 (캐시는 하지 않음)
Q. Provider란 무엇인가?
Provider<T>란 필요한 순간에 T를 꺼내는 지연 공급자로서, Hilt/Dagger는 컴파일 타임에 생성한 컴포넌트 구현체 안에 모든 바인딩을 Provider 필드로 보관하고, 실제 객체가 필요할 때 get()을 호출해 꺼내면서 생성 타이밍을 책임진다.
import javax.inject.Provider
interface Provider<T> {
fun get(): T
}
Q. 왜 Provider를 써야하는가?
- 지연 생성: 안 쓰면 만들지 않는다. 초기 부하↓
- 스코프 캐싱: @Singleton 등은 최초 1회 생성→캐시 재사용을 보장
- 초기화 순서/순환 의존 해결: 한쪽을 Provider<Foo>로 받아 생성 시점 지연으로 고리를 끊는다.
- 멀티바인딩 집계: @IntoSet/@IntoMap 바인딩을 Set/Map Provider로 모으기 쉽다.
Lazy<T> vs Provider<T>
Provider<T> : 필요할 때마다 만들거나(무스코프) 한 번만 만들어 재사용(스코프), 여러 번 get() 가능
Lazy<T> : 최초 한 번만 지연 생성하고 이후에는 그 하나를 돌려준다. 한 번만 늦게 만들고 계속 재사용이 목적일 때 적합
Q. Provider는 계속 get()을 요청하는데 어떻게 스코프 캐싱을 할까?
Dagger 내부 유틸에는 DoubleCheck 라는 Wrapper Provider가 존재한다. DoubleCheck는 Provider를 감싸 최초 1회만 생성하고 이후 같은 인스턴스를 반환한다. 주로 @Singleton, @ActivityScoped 등 스코프 바인딩에서 사용된다.
// 개념화한 형태 (실제 소스와 다를 수 있지만 아이디어는 동일)
class DoubleCheck<T>(
private var provider: Provider<T> // 원래 객체 만드는 방법
) : Provider<T> {
@Volatile private var instance: Any? = UNINITIALIZED
override fun get(): T {
var result = instance
if (result === UNINITIALIZED) { // 1차 체크 (락 없이 빠르게)
synchronized(this) { // 잠금
result = instance
if (result === UNINITIALIZED) { // 2차 체크
result = provider.get() // 실제 생성
instance = result // 캐시에 저장
provider = null // 원 Provider GC 가능
}
}
}
@Suppress("UNCHECKED_CAST")
return result as T
}
private object UNINITIALIZED
}
만약 스코프가 있으면 DoubleCheck가 첫 호출에만 factory.get() 을 호출하고, 이후에는 캐시된 인스턴스를 반환
Q. 컴포넌트란 무엇인가
특정 수명(lifecycle)을 가진 DI 컨테이너로서 역할은 다음과 같다.
- 모듈(@Module)과 바인딩을 장착한다. 즉, @InstallIn으로 어느 컴포넌트의 그래프에 이 바인딩들을 넣을지 지정한다
ex) @Singleton ↔ SingletonComponent , @ActivityRetainedScoped ↔ ActivityRetainedComponentt - 필요한 순간에 Provider.get() 을 통해 객체를 만들고(또는 캐시에서 꺼내) 주입한다.
- 스코프 캐시를 관리한다(예: @Singleton은 앱 프로세스 동안 1개).
부모 → 자식 방향으로 자식은 부모 바인딩을 참조할 수 있어서 자식 컴포넌트가 부모 Provider를 참조해 배선된다.
// 개념 스케치
class DaggerViewModelComponent(parent: DaggerActivityRetainedComponent) {
private val repoProvider: Provider<UserRepository> =
UserRepositoryImpl_Factory(parent.apiProvider, parent.userDaoProvider)
}
단계별 코드
1단계. 개발자가 작성하는 코드
// NetworkModule.kt
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides @Singleton
fun provideOkHttp(): OkHttpClient = OkHttpClient.Builder().build()
@Provides @Singleton
fun provideRetrofit(client: OkHttpClient): Retrofit =
Retrofit.Builder()
.baseUrl("https://api.example.com")
.client(client)
.build()
@Provides @Singleton
fun provideApiService(retrofit: Retrofit): ApiService =
retrofit.create(ApiService::class.java)
}
// DatabaseModule.kt
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides @Singleton
fun provideDb(@ApplicationContext ctx: Context): AppDatabase =
Room.databaseBuilder(ctx, AppDatabase::class.java, "app.db").build()
@Provides
fun provideUserDao(db: AppDatabase): UserDao = db.userDao()
}
// RepositoryModule.kt
interface UserRepository {
suspend fun loadUser(id: Long): User
}
class UserRepositoryImpl @Inject constructor(
private val api: ApiService,
private val dao: UserDao
) : UserRepository {
override suspend fun loadUser(id: Long): User {
val local = dao.find(id)
return local ?: api.getUser(id).also { dao.insert(it) }
}
}
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
}
@HiltViewModel
class MyViewModel @Inject constructor(
private val repo: UserRepository,
// SavedStateHandle은 Hilt가 자동 주입
private val savedStateHandle: SavedStateHandle
) : ViewModel() {
// ...
}
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
// Hilt가 제공하는 ViewModelFactory를 통해 생성자 주입된 VM 획득
private val vm: MyViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// ...
}
}
2단계. 빌드 타임에 생성되는 개념 코드
// UserRepositoryImpl_Factory (생성자 주입 대상)
class UserRepositoryImpl_Factory(
private val apiProvider: Provider<ApiService>,
private val daoProvider: Provider<UserDao>
) : Factory<UserRepositoryImpl> {
override fun get(): UserRepositoryImpl {
return UserRepositoryImpl(apiProvider.get(), daoProvider.get())
}
}
// NetworkModule_ProvideRetrofitFactory (Provides 변환)
class NetworkModule_ProvideRetrofitFactory(
private val clientProvider: Provider<OkHttpClient>
) : Factory<Retrofit> {
override fun get(): Retrofit {
return NetworkModule.provideRetrofit(clientProvider.get())
}
}
// MainActivity_MembersInjector (예시)
class MainActivity_MembersInjector(
private val someDepProvider: Provider<SomeDep>
) : MembersInjector<MainActivity> {
override fun injectMembers(instance: MainActivity) {
instance.someDep = someDepProvider.get()
}
}
// MyViewModel_Factory (개념)
class MyViewModel_Factory(
private val repoProvider: Provider<UserRepository>,
private val savedStateHandleProvider: Provider<SavedStateHandle>
) : Factory<MyViewModel> {
override fun get(): MyViewModel {
return MyViewModel(repoProvider.get(), savedStateHandleProvider.get())
}
}
3단계. 컴포넌트 내부의 Provider 배선
// DaggerSingletonComponent
class DaggerSingletonComponent : SingletonComponent {
// Provider 필드들
private val contextProvider: Provider<Context>
private val okHttpProvider: Provider<OkHttpClient>
private val retrofitProvider: Provider<Retrofit>
private val apiServiceProvider: Provider<ApiService>
private val dbProvider: Provider<AppDatabase>
private val userDaoProvider: Provider<UserDao>
private val userRepositoryProvider: Provider<UserRepository>
init {
// ApplicationContext 등은 별도 Entry로 제공
contextProvider = ApplicationContextModule_ProvideContextFactory.create()
okHttpProvider = DoubleCheck.provider(
NetworkModule_ProvideOkHttpFactory.create()
)
retrofitProvider = DoubleCheck.provider(
NetworkModule_ProvideRetrofitFactory(okHttpProvider)
)
apiServiceProvider = DoubleCheck.provider(
NetworkModule_ProvideApiServiceFactory(retrofitProvider)
)
dbProvider = DoubleCheck.provider(
DatabaseModule_ProvideDbFactory(contextProvider)
)
userDaoProvider = DatabaseModule_ProvideUserDaoFactory(dbProvider)
// @Binds 바인딩은 구현체 Factory를 인터페이스로 매핑
val repoImplProvider: Provider<UserRepositoryImpl> =
UserRepositoryImpl_Factory(apiServiceProvider, userDaoProvider)
userRepositoryProvider = DoubleCheck.provider(
RepositoryModule_BindUserRepositoryFactory(repoImplProvider)
)
}
// 하위 컴포넌트들이 참조할 수 있도록 getter 제공 가능
fun userRepository(): UserRepository = userRepositoryProvider.get()
}
// DaggerViewModelComponent
class DaggerViewModelComponent(
private val appComponent: DaggerSingletonComponent
) : ViewModelComponent {
private val savedStateHandleProvider: Provider<SavedStateHandle> =
SavedStateHandle_Factory.create()
private val myViewModelProvider: Provider<MyViewModel> =
MyViewModel_Factory(appComponent.userRepositoryProvider, savedStateHandleProvider)
fun getMyViewModel(): MyViewModel = myViewModelProvider.get()
}
'Android' 카테고리의 다른 글
| Navigation3 알아보기 (0) | 2025.10.21 |
|---|---|
| Android Build-logic & Version Catalog (0) | 2025.09.03 |
| Android DI(의존성 주입) - 개요 (1) | 2025.07.30 |
| Camera2 API 개념부터 구현까지 (0) | 2023.04.06 |
| Android Paging3 개념정리 및 사용기 (0) | 2022.07.06 |