Hilt 라이브러리
DI(Dependency Injection)의 핵심은 클래스가 필요한 객체를 내부에서 직접 만들지 않고, 바깥에서 전달받는 방식이다. Hilt는 그 DI를 Android에서 자동화해주는 라이브러리다.
Dagger 위에 만들어져 있으며, Android 클래스와 생명주기에 맞는 표준 DI 설정을 자동으로 제공해 Dagger를 더 쉽게 사용할 수 있게 한다.
Hilt는 컴파일 타임에 Annotation을 해석해 DI에 필요한 코드를 생성한다. 이 생성 코드가 객체를 만들고 필요한 곳에 의존성을 연결한다. Hilt는 Application 클래스를 컴파일하는 Gradle 모듈이 볼 수 있는 binding 정보만 사용할 수 있다.
컴파일 타임에 만들어지는 것은 객체가 아니라 코드(생성 방법과 그래프 검증)뿐이다. 객체는 전부 런타임에 요청되는 순간 등록된 생성 방법이 실행되어 만들어진다.
즉 @HiltAndroidApp Application을 컴파일 하는 Gradle 모듈의 직접 의존성 또는 전이 의존성 안에 있는 binding 정보만 코드 생성에 사용될 수 있다. 연결되지 않은 Gradle 모듈에 있는 binding 정보는 Hilt가 사용할 수 없다.
binding정보란 @Module 안의 @Binds, @Provides 선언이나 @Inject constructor 처럼 Hilt가 객체 생성/연결 방법을 알 수 있게 해주는 정보를 말한다.
Hilt의 동작 방식
1
2
@HiltAndroidApp
class MeowApplication: Application()
Hilt는 @HiltAndroidApp이 붙은 Application을 기준으로 앱에서 필요한 객체들을 어떻게 만들고 서로 연결할지 정리한다. (이하 DI 그래프로 표현) 보통 이 Application은 :app 모듈에 있으며 :app 모듈이 Gradle 의존성으로 접근 할 수있는 모듈들의 binding 정보를 모아 사용한다.
:app이 직접 의존하는 모듈의 Hilt binding:app이 전이적으로 의존하는 모듈의 Hilt binding
반대로 Gradle 의존성으로 연결되지 않은 모듈에 binding이 있으면 Hilt는 그 binding을 DI 그래프를 만들때 사용할수 없다.
직접 의존성
app이 Gradle 설정에서 바로 의존하는 모듈이다.
1
2
3
/* app/build.gradle.kt 에 다음과 같이 선언되어있다면 직접 의존이다. */
implementation(project(":feature:feed"))
implementation(project(":feature:search"))
전이 의존성
app이 직접 의존하지는 않지만, 직접 의존한 모듈을 따라가면 연결되는 모듈이다.
1
2
3
4
5
6
:app
└─ :feature:feed
└─ :core:domain
└─ :core:data
└─ :core:network
└─ :core:model
실제 프로젝트에서는 :core:model처럼 여러 모듈이 공통으로 직접 의존하는 모듈도 있지만, 전이 관계를 보이기 위해 한 갈래로 단순화했다.
여기서 :app 기준으로 보면 다음과 같다.
:feature:feed는 직접 의존성:core:domain,:core:data,:core:network,:core:model은 전이적 의존성
의존성이 만드는 범위는 둘로 나뉜다. 소스 코드 직접 참조 범위는 이 모듈의 소스 코드에서 import해서 사용할 수 있는 범위이고, 앱 빌드 / Hilt binding 수집 범위는 최종 app 빌드에 포함되어 Hilt가 컴파일 시 binding으로 볼 수 있는 범위다.
직접 의존성은 내 코드가 직접 아는 범위이고, 전이 의존성은 앱을 빌드하기 위해 함께 따라오는 하위 범위다.
Q. 네트워크 통신은 :core:network가 Retrofit으로 구현하고 있고 :app은 이 모듈을 직접 의존하지 않아 Retrofit을 import할 수 없다. 그런데 전이 의존성 덕분에 Retrofit binding은 DI 그래프에 들어있다. 그럼 :app에서 주입을 요청하면 받을 수 있는 것 아닌가?
받을 수 없다. 주입을 요청하려면 타입을 코드에 써야 하기 때문이다.
1
@Inject lateinit var retrofit: Retrofit // Retrofit 타입을 참조해야 요청이 성립한다
:core:network가 Retrofit을 implementation으로 선언했다면 이 의존성은 전이되지 않는다. :app의 컴파일 클래스패스에 Retrofit이 없으니 타입 이름을 쓰는 순간 컴파일 에러가 난다. binding이 그래프에 있어도 요청할 방법 자체가 없는 것이다.
반대로 api로 선언하면 타입이 상위 모듈까지 노출되어 정말로 주입받을 수 있게 된다. Hilt는 이걸 막지 않는다.
SingletonComponent는 컴파일 범위 안 모든 Gradle 모듈에서 @InstallIn(SingletonComponent::class)로 선언된 Hilt Module들의 binding을 하나로 합친 전역 그래프라서 계층 규칙을 모른다.
이 수집은 Component마다 일어난다. ActivityComponent든 ViewModelComponent든 각 Component는 자기를 지정한 @InstallIn 대상을 앱 전역에서 모아 합친다. 그래서 Component 안에서는 binding이 어느 Gradle 모듈에서 왔는지 구분이 사라진다. 예외로 @Inject constructor binding은 @InstallIn 없이 수집되고 scope 어노테이션이 사용될 Component를 정한다.
정리하면 계층을 지키는 것은 DI 그래프가 아니라 Gradle이다.
라이브러리는 implementation으로 가두고 모듈 자신의 구현 클래스는 internal로 감춘다. 여기서 감추고 노출하는 대상은 함수가 아니라 타입이다. 모듈이 밖에 공개하는 타입은 public 인터페이스 하나고 그 인터페이스를 구현한 클래스는 internal이라 다른 모듈에서는 타입 이름조차 쓸 수 없다.
1
2
3
4
5
6
7
8
// :core:network
interface NiaNetworkDataSource { // public. 다른 모듈에서 참조 가능
suspend fun getTopics(): List<NetworkTopic>
}
internal class RetrofitNiaNetwork( // internal. 다른 모듈에서 참조 불가
... // Retrofit은 여기서만 사용한다
) : NiaNetworkDataSource { ... }
1
2
3
4
5
6
// :core:data (:core:network에 의존)
class TopicsRepository @Inject constructor(
private val network: NiaNetworkDataSource // OK
)
// RetrofitNiaNetwork 참조 → 컴파일 에러 (internal)
// Retrofit 참조 → 컴파일 에러 (implementation이라 전이가 안 됨)
결국 상위 모듈이 참조할 수 있는 타입은 public 인터페이스 하나만 남는다. 주입은 모듈 내부의 @Binds가 internal 구현체를 인터페이스에 연결해 주기 때문에 상위 모듈은 구현체 이름을 몰라도 된다. Now in Android도 이 패턴 그대로 Retrofit을 :core:network 안에 가두고 인터페이스 타입만 공개한다.
internal은 Kotlin 컴파일러 수준의 계약이다. 바이트코드에서는 public으로 컴파일되기 때문에 Java 코드에서는 접근이 가능하다. 클래스 멤버 함수는 이름 뒤에 모듈명이 붙어(secretHelper$core_network) 실수로 쓰기 어렵게 되어 있을 뿐이고 최상위 함수와 클래스 이름은 이 처리조차 없다. 전부 Kotlin으로 작성하는 프로젝트에서는 컴파일러가 막아주므로 사실상 강제된다.
Hilt DI : Component, Module, Scope
Hilt의 Component는 Android 생명주기와 연결된 DI 그래프, 또는 DI 컨테이너 단위로 볼 수 있다. Hilt는 이 Component안의 binding 정보를 바탕으로 필요한 의존성 객체를 생성하고 주입한다.
여기서 DI 그래프는 binding들이 직접/전이로 엮인 관계도(개념)이고, DI 컨테이너는 그 그래프를 들고 런타임에 실제로 동작하는 Component 인스턴스다. Hilt가 컴파일 타임에 생성해 둔 코드로 만들어진다.
Component
binding을 들고 객체를 생성/주입하는 DI 컨테이너, Android 생명주기에 연결된다. Component는 클래스가 아니라 인스턴스 단위로 생명주기 객체 하나당 Component 인스턴스 하나가 생성/소멸한다. Component 인스턴스는 바인딩(생성방법)과 Scope객체 캐시를 가진다.
1
2
Activity 2개 -> ActivityComponent 인스턴스 #1, #2
Fragment 2개 -> FragmentComponent 인스턴스 #1, #2 (각자 독립, 개별 인스턴스로 생성됨)
Module
@Provides, @Binds 함수 모음. 객체를 어떻게 만드는지 정의하며 생성/소멸의 주체가 아니라 생성 방법의 정의일 뿐이다. 이 Module의 함수가 실제로 만들어 내는 결과물을 provide된 객체라 표현한다. @Provides는 직접 소유하지 않은 클래스(Retrofit·Room 등)를 공급할 때 @Binds는 인터페이스를 구현체에 연결할 때 쓴다.
선언 방식에 따라 Module 인스턴스 생성 여부가 갈린다.
| 선언 | Module 인스턴스 | 용도 |
|---|---|---|
| object | 생성 안 함 (static 호출) | @Provides만 있을때 (기본) |
| abstract class / interface | 생성 안 함 (호출 없음, 생성 코드에 연결만 반영) | @Binds (인터페이스 → 구현 연결) |
| 일반 class | Dagger가 생성 | 인스턴스 @Provides가 필요한 특수 경우 |
@Binds는 왜 인스턴스도 호출도 필요 없나?
@Binds는 본문 없는 추상 함수라 “이 인터페이스에는 이 구현체”라는 연결 정보만 제공한다. 호출할 본문이 없으니 Module 인스턴스나 함수 호출 없이 생성코드에 연결만 반영된다. 반면@Provides는 본문이 있어 생성 코드가 그 함수를 호출해 객체를 얻는다.
DI 그래프의 등록은 두 종류로 나뉜다. 생성법의 등록(@Inject constructor, @Provides)과 이미 등록된 생성법에 요청 가능한 타입 이름을 더하는 연결(@Binds)이다.
이 구분은 빌드 산출물로 확인된다. Dagger는 생성법 하나당 *_Factory.java 파일을 build/generated에 만든다. @Inject constructor 클래스와 @Provides 함수에는 Factory가 하나씩 생기지만 @Binds에는 Factory가 생기지 않는다. 만드는 역할이 아니라서 만들 코드가 없는 것이다.
Hilt 프로젝트에서 직접 컴파일해 보면 삭제 실험으로도 이 사실을 확인할 수 있다.
@Binds만 지우면 인터페이스 타입 요청만[Dagger/MissingBinding] ... cannot be provided without an @Provides-annotated method로 죽는다. 구체 타입으로 직접 요청하면 여전히 생성된다. 연결(이름)만 사라졌고 생성법은 살아있기 때문이다.- 반대로 구현체의
@Inject constructor를 지우면@Binds가 있어도cannot be provided without an @Inject constructor or an @Provides-annotated method로 죽는다. 이 에러 메시지 자체가 생성법이 이 둘뿐임을 말해준다.
주입받는 쪽에서는 이 구분이 보이지 않는다. 어떤 등록으로 왔든 요청하면 객체가 도착한다. 공급 방식을 소비자가 모르게 하는 것이 DI의 목적이다. 구분은 공급 쪽에만 존재한다.
@Binds와 @Provides, 언제 무엇을 쓰나
공식 javadoc은 주입된 파라미터를 그대로 반환하기만 하는
@Provides라면@Binds로 바꾸라고 권한다: “Binds methods are a drop-in replacement for Provides methods that simply return an injected parameter. Prefer @Binds because the generated implementation is likely to be more efficient.” 호출할 본문이 없어 생성 구현이 더 효율적일 가능성이 높다는 이유다. 모든@Provides를 대체하라는 뜻이 아니라 단순 반환형에 한정된 권장이다.
어떤 선언 방식을 쓰든 Module 자체는 생성/소멸의 주체가 아니다. scope는 Module이 아니라 provide된 객체의 수명을 결정하므로 Module 인스턴스 생성 여부와 scope 동작은 무관하다. @Provides와 @Binds를 한 Module에 함께 둬야 하면 abstract에 @Binds를 그 안 companion object에 @Provides를 둔다.
Scope
scope는 provide된 객체를 Component 인스턴스 안에 하나만 만들어 재사용할지(scoped) 요청마다 새로 만들지(unscoped) 결정한다. scope를 안붙이면 기본 unscoped라 요청마다 새 인스턴스가 나오고 붙이면 그 Component가 살아있는 동안 같은 인스턴스를 재사용한다.
다만 다 붙이는것은 아니다. 공식 문서는 “scoped binding은 Component가 사라질 때까지 메모리에 남아 비용이 크니 최소화하라”고 권한다. 그래서 ① 만들기 비싼 객체(DB, Retrofit/OkHttp)거나 ② 같은 인스턴스를 공유해야 하는 객체(캐시·공유 상태)일 때만 scope를 주고 기본 unscoped로 둔다. (Now in Android도 DB·네트워크 소스만 scope, Repository는 unscoped)
scope는 @Singleton 아니면 unscoped 둘 중 하나가 아니다. 같은 인스턴스를 어느 범위까지 공유할지 고르는것이고 범위가 넓을수록 오래산다.
Hilt 기본 scope를 표로 정리하면 다음과 같다. 순서는 계층과 무관하다.
| Scope | Component | 공유 범위 / 수명 |
|---|---|---|
@Singleton | SingletonComponent | 앱 전체 (앱이 사는 동안 하나) |
@ActivityRetainedScoped | ActivityRetainedComponent | 구성 변경(회전)으로 Activity 재생성돼도 유지 |
@ViewModelScoped | ViewModelComponent | 한 ViewModel이 사는 동안 |
@ActivityScoped | ActivityComponent | 한 Activity가 사는 동안 (그 안 Fragment들이 공유) |
@FragmentScoped | FragmentComponent | 한 Fragment가 사는 동안 (다른 Fragment와는 공유 X) |
@ViewScoped | ViewComponent | 한 View가 사는 동안 |
@ServiceScoped | ServiceComponent | 한 Service가 사는 동안 |
여러 Fragment가 한 객체를 공유하려면 Fragment마다 생기는 @FragmentScoped가 아니라 한 단계 위 @ActivityScoped를 쓴다. (FragmentComponent가 ActivityComponent의 자식이라 그 인스턴스를 그대로 받는다.)
scope와 Component는 짝이 맞아야한다. binding이 어느 Component 그래프에 속할지는 Module의 @InstallIn이 정하므로, @InstallIn한 Component와 scope가 가리키는 Component가 다르면 모순이라 컴파일 에러다 (공식 : A binding’s scope must match the scope of the component where it is installed).
적용 위치는 @Provides/@Binds 함수, 또는 직접 소유한 구체 클래스라면 @Inject 생성자를 단 클래스 두 곳이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Module
@InstallIn(SingletonComponent::class)
// 이 모듈의 binding을 SingletonComponent 그래프에 설치
object NetworkModule {
// unscoped: 요청할 때 마다 새 OkHttpClient
@Provides
fun provideClient(): OkHttpClient = OkHttpClient()
//scoped : SingletonComponent 안에서 단 하나, 앱 전체 동안 재사용
@Provides
@Singleton
fun provideRetrofit(client: OkHttpClient): Retrofit =
Retrofit.Builder().client(client).baseUrl("...").build()
}
직접 만든 클래스면 Module 없이 클래스에 붙인다.
1
2
3
4
@Singleton
class UserRepository @Inject constructor(
private val retrofit: Retrofit
)
@InstallIn과 scope가 어긋난 경우 컴파일 에러 발생
1
2
3
4
5
6
7
8
@Module
@InstallIn(ActivityComponent::class) // ActivityComponent 그래프에 설치
object BadModule {
@Provides
@Singleton // SingletonComponent 전용 scope -> 모순으로 컴파일 에러
fun provideX(): X = X()
}
반대로 수요 지점에는 scope를 붙일 수 없다. 두 경우의 이유가 다르다.
@HiltViewModel클래스에 붙이면 컴파일 에러가 난다(@HiltViewModel classes should not be scoped). ViewModel의 수명은 Hilt가 아니라 ViewModelStore가 관리하고 ViewModelComponent는 ViewModel 인스턴스 하나당 하나 만들어지므로 scope 선언이 구조적으로 무의미해서 아예 막아둔 규칙이다.@AndroidEntryPoint클래스는 규칙 이전에 범주 밖이다. Activity/Fragment는 그래프가 제공하는 binding이 아니라 그래프에서 꺼내 쓰는 수요 지점이므로 scope가 다스릴 대상 자체가 없다.
Qualifiers
한 타입에 binding이 둘 이상이면 Hilt는 어느 걸 줄지 못정한다. 이때 @Qualifier Annotation을 꼬리표로 달아 구분한다. 꼬리표는 정의·공급·주입 세 곳 모두에 같은 것이 달려야 짝이 맞는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// ① qualifier annotation 정의 (같은 타입을 구분할 꼬리표)
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class DefaultDispatcher
// ② 공급 지점에 꼬리표 — 같은 CoroutineDispatcher 타입이지만 꼬리표로 구분
@Module
@InstallIn(SingletonComponent::class)
object CoroutinesModule {
@Provides
@IoDispatcher
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
@Provides
@DefaultDispatcher
fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
}
// ③ 주입 지점에 꼬리표 — 어느 binding을 받을지 Hilt에 알려줌
class NewsRepository @Inject constructor(
@IoDispatcher private val ioDispatcher: CoroutineDispatcher
)
꼬리표를 빼먹으면 CoroutineDispatcher 한 타입에 binding이 둘이라 “어느걸 줄지 모름”으로 컴파일 에러(DuplicateBinding)가 발생한다. 반대로 정의한 qualifier를 아무 binding에도 안달면 “이 꼬리표에 맞는 binding 없음”으로 에러가 발생한다. 때문에 정의·공급·주입이 한 세트다.
@Retention Annotation 역할
Hilt는 qualifier를 컴파일 타임에만 읽는다(런타임 reflection 안 씀).
retention 세 가지 비교
| Retention | .class에 남나 | 결과 |
|---|---|---|
| SOURCE | 안 남음 (컴파일 후 버려짐) | 다른 모듈의 Hilt가 이 꼬리표를 못 봄 → 멀티모듈에서 깨짐 |
| BINARY | 남음 (reflection 불가) | 컴파일 타임에 읽기 충분, 군더더기 없음 → 권장 |
| RUNTIME | 남음 (reflection 가능) | 동작은 하나 안 쓰는 런타임 메타데이터까지 붙음 |
Kotlin의 @Retention 기본값은 RUNTIME이라 생략하면 RUNTIME이 된다. 공식 예제가 굳이 BINARY를 명시하는 건 동작에 필요한 최소 수준이기 때문이다. SOURCE는 컴파일 후 버려져 모듈 경계를 넘으면 보이지 않으므로 쓸 수 없다.
Hilt가 미리 정의해 둔 qualifier
직접 만들지 않아도 Hilt가 제공하는 두개가 있다. Context는 App Context와 Activity Context 둘이 존재하므로 반드시 꼬리표로 구분해야한다.
| Qualifier | 할당 되는 것 | 받을 수 있는 Component |
|---|---|---|
@ApplicationContext | Application Context | 어디서나 (SingletonComponent 이하 전부) |
@ActivityContext | Activity Context | ActivityComponent 이하 |
1
2
3
class ImageLoader @Inject constructor(
@ApplicationContext private val context: Context
)
Component 의 종류
Component는 연결된 Android 클래스의 생명주기대로 생성·소멸하는 DI 컨테이너다. 공식 문서 표현으로 “Hilt는 대응하는 Android 클래스의 생명주기를 따라 Component 인스턴스를 자동으로 생성·소멸시킨다.” 그래서 binding을 어느 Component에 두느냐가 곧 그 객체가 살수 있는 범위가 된다.
Hilt의 기본 Component를 표로 정리하면 다음과 같다. 표의 순서는 계층 구조와 무관하며 계층은 아래 계층 구조 절에서 따로 정리한다.
| Component | 수명(생성→소멸) | 연결 클래스 |
|---|---|---|
| SingletonComponent | Application#onCreate → 앱 종료 | Application |
| ActivityRetainedComponent | Activity 최초 onCreate → 최후 onDestroy (구성 변경엔 유지) | 없음 (ViewModel·Activity의 부모) |
| ViewModelComponent | ViewModel 생성 → onCleared | ViewModel |
| ActivityComponent | Activity#onCreate → onDestroy | Activity |
| FragmentComponent | Fragment#onAttach → onDestroy | Fragment |
| ViewComponent | View 생성 → 소멸 | View |
| ViewWithFragmentComponent | View 생성 → 소멸 | @WithFragmentBindings를 단 View |
| ServiceComponent | Service#onCreate → onDestroy | Service |
ViewWithFragmentComponent는 계층상 FragmentComponent의 자식으로 Fragment의 binding까지 받아야하는 커스텀 View에 쓰인다. @AndroidEntryPoint + @WithFragmentBindings를 함께 선언한 경우로 거의 사용되지 않기에 참고만 한다.
각 Component에는 그 컴포넌트가 관리하는 Android 객체가 Hilt에 의해 미리 등록돼 있다. 그래서 @Provides·@Binds로 공급하지 않아도 주입받을 수 있다. 예를 들어 ActivityComponent에서는 Activity·Application을, ViewModelComponent에서는 SavedStateHandle을 바로 주입받는다.
ActivityRetainedComponent는 공식 문서에 따르면 필드 주입을 받을 대상 클래스가 없다. 이 컴포넌트의 수명은 구성 변경(화면 회전)을 넘겨 첫 Activity#onCreate부터 마지막 Activity#onDestroy까지 이어지는데 Activity는 회전 때마다 파괴·재생성되므로 이 수명과 일치하는 단일 Android 클래스가 없다. 그래서 필드 주입 진입점이 될 클래스가 없다. 대신 구성 변경에도 유지돼야 하는 공유 상태를 @ActivityRetainedScoped로 여기 설치해 두면 자식인 ViewModel이 그대로 받아 쓴다.
계층 구조
Component는 부모-자식 계층을 이룬다.
1
2
3
4
5
6
7
8
SingletonComponent
├─ ServiceComponent
└─ ActivityRetainedComponent
├─ ViewModelComponent
└─ ActivityComponent
├─ ViewComponent
└─ FragmentComponent
└─ ViewWithFragmentComponent
자식 Component는 부모 그래프의 binding을 그대로 쓸 수 있다 (반대는 불가). 그래서 어디서나 최상위 @Singleton 객체를 받고 Fragment가 한 단계 위 ActivityScoped 객체를 받는다.
Hilt Annotation의 세 역할
Hilt를 쓸 때 선언하는 Annotation의 역할은 세 가지다.
| 역할 | Annotation | 하는 일 | 방향 |
|---|---|---|---|
| 시동(앱당 1개·필수) | @HiltAndroidApp | Hilt 적용 진입점 + 루트 Component 생성 (SingletonComponent) | 그래프 자체를 생성 |
| 공급 (binding 등록) | @Module + @Provides/@Binds, @Inject constructor | 생성법(@Inject constructor, @Provides)과 타입 연결(@Binds)을 DI 그래프에 등록 | 그래프에 넣기(입구) |
| 수요 (주입 받기) | @AndroidEntryPoint, @HiltViewModel | 해당 클래스를 주입 받는 지점으로 만듦 | 그래프에서 꺼내기(출구) |
공급과 수요는 한 Component DI 그래프의 입구와 출구다. 여기서 입구·출구는 그래프의 위치가 아니라 데이터가 흐르는 방향이다. Module로 넣고(쓰기) Android 클래스로 꺼낸다(읽기). 시동은 넣지도 꺼내지도 않고 그 DI 그래프 자체를 만들어내는 별개의 역할이다.
수요 쪽 : 주입 방식
주입 방식은 그 클래스를 누가 생성하느냐로 결정된다. Android 프레임 워크가 직접 생성하는 클래스(=개발자가 생성자를 제어할 수 없는 클래스, Activity 등)는 생성자 필드를 건드릴수 없기 때문에 필드 주입을, 개발자가 생성을 맡길수 있는 클래스는 생성자 주입(@Inject constructor)을 쓴다. 공식은 가능한 경우 생성자 주입을 기본으로 권장하며 필드 주입은 생성자를 제어할 수 없는 프레임워크 클래스에 한정한다.
| 주입 대상 | Annotation | 주입 방식 |
|---|---|---|
| Activity/Fragment/Service/View | @AndroidEntryPoint | 필드 (@Inject lateinit var) |
| ViewModel | @HiltViewModel | 생성자 |
| 개발자가 만든 일반 클래스 | @Inject constructor | 생성자 |
@Inject는 붙는 위치에 따라 역할이 갈린다. 같은 애노테이션이지만 생성자에 붙으면 공급, 필드에 붙으면 수요다.
- 생성자
@Inject constructor: “이렇게 만든다”는 생성법을 그래프에 등록한다(공급, 입구). 세 역할 표에서 공급으로 분류되는 이유다. - 필드
@Inject lateinit var: 만들어진 객체를 그래프에서 꺼내 그 필드에 채운다(수요, 출구). 이때 필드는 private이면 안 된다. private 필드에 주입하면 컴파일 에러가 난다.
개발자가 생성을 제어하는 클래스는 생성자에서 만들며 인자도 함께 주입받기에 생성자 주입을 쓰고 Activity처럼 프레임워크가 생성하는 클래스는 생성자를 건드릴 수 없어 필드 주입으로만 받는다. Hilt가 생명주기에 맞춰 필드를 자동으로 채워주므로 Activity는 onCreate, Fragment는 onAttach 이후부터 바로 쓸 수 있다.
ViewModel도 개발자가 직접 생성하지 않는 관리 대상인데 생성자 주입이 되는 이유는 생성 경로에 ViewModelProvider.Factory라는 공식 확장 지점이 있어서다. @AndroidEntryPoint가 생성하는 부모 클래스가 기본 Factory를 Hilt Factory로 교체해 두고 by viewModels()가 기본 Factory를 요청하면 Hilt가 생성자 인자를 그래프에서 채워 ViewModel을 만들어 돌려준다. Activity/Fragment에는 Hilt가 쓰는 이런 틈이 없다 (Fragment의 FragmentFactory가 있지만 Hilt는 사용하지 않는다). 그래서 필드 주입만 남는다.
@AndroidEntryPoint가 지원하는 클래스 & 전용 어노테이션
공식 Hilt가 진입점으로 지원하는 Android 클래스는 정해져 있다. 다음 목록에 있으면 주입이 되고 없으면 아래 @EntryPoint로 직접 꺼내야 한다. @AndroidEntryPoint를 쓰는 클래스는 5종(Activity·Fragment·View·Service·BroadcastReceiver)이고 Application·ViewModel은 전용 어노테이션을 쓴다.
| Class | Annotation | 주입 방식 | 제약 / 비고 |
|---|---|---|---|
| Application | @HiltAndroidApp | (시동) | 시동 + SingletonComponent 생성 |
| ViewModel | @HiltViewModel | 생성자 | — |
| Activity | @AndroidEntryPoint | 필드 | ComponentActivity 하위만 (AppCompatActivity 포함) |
| Fragment | @AndroidEntryPoint | 필드 | androidx(Jetpack) Fragment만 지원 (구 android.app.Fragment 미지원, host Activity도 @AndroidEntryPoint여야 동작) |
| View | @AndroidEntryPoint | 필드 | host Activity/Fragment도 @AndroidEntryPoint여야 동작 |
| Service | @AndroidEntryPoint | 필드 | — |
| BroadcastReceiver | @AndroidEntryPoint | 필드 | 자체 Component 없음 → SingletonComponent에서 꺼냄 |
BroadcastReceiver는 @AndroidEntryPoint가 되지만 자기 Component가 없다. SingletonComponent에서 binding을 받는다.
수요 쪽 예외 : @EntryPoint (지원 안되는 클래스에서 그래프 꺼내기)
@AndroidEntryPoint 필드 주입과 생성자 주입은 Hilt가 자동으로 그래프에서 꺼내 꽂아주는 경로다. 위 5종 목록에 없는 클래스(대표적으로 ContentProvider, 또는 Hilt를 안 쓰는 외부 라이브러리가 생성하는 객체)는 이 자동 주입을 쓸 수 없다. 이때 직접 그래프에서 binding을 꺼내는 방법이 @EntryPoint다.
| 지원 안 되는 클래스 | 왜 안 되나 |
|---|---|
| ContentProvider | Application#onCreate 이전에 생성될 수 있어서 @AndroidEntryPoint 라이프사이클에 안 맞음 |
| 외부 라이브러리가 생성하는 객체 | 개발자가 생성을 제어 못 함 |
| 그 외 Hilt가 모르는 일반 클래스 | Hilt가 @AndroidEntryPoint 지원하지 않는 클래스 외 전부 |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// ⓪ 공급: Analytics를 SingletonComponent 그래프에 등록, 이게 없으면 ①·②가 컴파일 안 됨
class AnalyticsImpl @Inject constructor() : Analytics { /* ... */ }
@Module
@InstallIn(SingletonComponent::class)
abstract class AnalyticsModule {
@Binds
abstract fun bind(impl: AnalyticsImpl): Analytics
}
// ① 꺼내고 싶은 binding을 선언한 인터페이스에 @EntryPoint
// 이 인터페이스의 구현체는 Hilt가 컴파일 타임에 AnalyticsImpl을 사용해 자동 생성한다.
@EntryPoint
@InstallIn(SingletonComponent::class) // 어느 Component 그래프에서 꺼낼지 지정
interface AnalyticsEntryPoint { // ⓪ AnalyticsModule의 @InstallIn과 같거나 그 자식 Component여야 함
fun analytics(): Analytics
}
// ② EntryPointAccessors로 그 Component 인스턴스에서 직접 꺼냄
class MyContentProvider : ContentProvider() {
override fun onCreate(): Boolean {
val entryPoint = EntryPointAccessors.fromApplication(
context!!.applicationContext,
AnalyticsEntryPoint::class.java
)
val analytics = entryPoint.analytics() // Hilt가 생성한 구현체가 ⓪의 binding을 돌려줌
return true
}
}
자동 주입(@AndroidEntryPoint 필드 주입, 생성자 주입)은 Hilt가 알아서 그래프에서 꺼내 할당해준다. @EntryPoint는 그 자동화를 못 쓰는 곳에서 개발자가 직접 그래프에서 꺼내 할당하는 방식이다. 이때 진입점 구현체를 얻는 접근자가 EntryPointAccessors이고 @InstallIn으로 지정한 Component에 따라 접근자가 갈린다(fromApplication(Singleton), fromActivity(Activity), fromFragment(Fragment)).
공급 쪽 예외 : Assisted Injection (@AssistedInject)
생성자 주입은 모든 인자를 Hilt가 그래프에서 채울 수 있을 때만 된다. 그런데 일부 인자가 런타임에야 정해지는 값(화면에 넘어온 id, 사용자 입력 등)이면 그래프에 그 값의 binding이 없다. 이때 그래프에 있는 재료는 Hilt가 채우고 런타임 인자는 호출자가 채우는 게 Assisted Injection이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@HiltViewModel(assistedFactory = NewsDetailViewModel.Factory::class)
class NewsDetailViewModel @AssistedInject constructor(
private val newsRepository: NewsRepository, // DI가 채움 (그래프에 있음)
@Assisted private val newsId: String, // 호출자가 런타임에 넘김
) : ViewModel() {
val news = newsRepository.getNews(newsId) // 런타임 인자는 메서드 인자로 흘려보낸다
// ① 런타임 인자를 받아 완성 객체를 만들어 주는 Factory 인터페이스 (구현은 Hilt가 생성)
@AssistedFactory
interface Factory {
fun create(newsId: String): NewsDetailViewModel
}
}
// ② 화면에서 런타임 인자를 넘겨 생성
val viewModel = hiltViewModel<NewsDetailViewModel, NewsDetailViewModel.Factory>(
creationCallback = { it.create(newsId) }
)
Hilt가 컴파일 타임에 생성하는 Factory 구현체를 보면 구조가 명확해진다.
1
2
3
4
5
6
7
8
9
10
// Hilt가 생성하는 코드 (단순화)
class NewsDetailViewModel_Factory_Impl implements NewsDetailViewModel.Factory {
private final Provider<NewsRepository> repoProvider; // 그래프가 채울 수 있는 재료만 보유
@Override
public NewsDetailViewModel create(String newsId) {
// 그래프가 채운 재료 + 호출자가 지금 넘긴 값
return new NewsDetailViewModel(repoProvider.get(), newsId);
}
}
@AssistedInject: 이 생성자엔 그래프가 채울 수 없는 인자가 있다는 표시. 그래서 이 타입 자체는 그래프에 등록되지 않고 다른 곳에서 직접@Inject로 요구하면 missing binding 컴파일 에러가 난다.@Assisted: 그 런타임 인자, 즉 호출자가 채울 값. 같은 타입이 둘 이상이면@Assisted("name")으로 구분한다.@AssistedFactory: 런타임 인자를create()파라미터로 노출한 완성 레시피. 구현체는 Hilt가 만들어 일반 binding으로 등록하므로 평범하게@Inject로 주입받는다.
결국 Assisted Injection은 별개의 메커니즘이 아니다. @Inject와 똑같이 컴파일 타임에 팩토리 코드가 생성되고 객체는 런타임에 만들어진다. 달라지는 것은 두 가지, 생성을 트리거하는 주체(Hilt의 자동 주입 vs 호출자의 create() 호출)와 인자를 채우는 주체(그래프 전부 vs 그래프와 호출자의 분담)뿐이다.
@AssistedInject 타입은 scope도 붙일 수 없다. 그래프가 수명을 관리하는 공유 객체가 아니라 호출자가
create()로 직접 생성하는 객체이기 때문이다. 그래서 화면 인자를 받는 ViewModel, 시스템이 생성자를 호출하는 Worker(@HiltWorker)처럼 인스턴스 자체가 런타임 값에 묶이는 객체에 쓰인다.
Component별 사용 예제
Hilt는 scoped binding을 그 binding이 묶인 Component 인스턴스당 한 번만 생성하고 그 binding에 대한 모든 요청은 같은 인스턴스를 공유한다. 즉 기본은 unscoped(요청마다 새로), scope를 붙이면 선언된 Component 인스턴스 안에서 하나로 공유한다.
여기서 경로란 같은 객체를 요청하는 타입을 말한다. 아래 2번 ActivityComponent 예제처럼 인터페이스 binding이 있으면 한 구현체를 두 타입으로 요청할 수 있다.
- 경로 A : 구체 클래스로 직접 요청 (
AnalyticsImpl,@Inject constructorbinding) - 경로 B : 인터페이스로 요청 (
Analytics,@Bindsbinding)
scope를 @Binds 함수에만 붙이면 경로 B만 한 인스턴스로 공유된다. 어딘가에서 AnalyticsImpl을 직접 주입받으면 그 binding은 unscoped라 매번 새 인스턴스가 나와 두 경로가 서로 다른 객체를 보게 된다. 보통은 인터페이스로만 주입받게 설계해 경로가 하나뿐이므로 문제가 없다.
scope는 함수만이 아니라 @Inject 생성자를 가진 클래스에도 붙일 수 있다(공식 문서 Component scopes 예제가 이 방식이다).
1
2
@ActivityScoped // 클래스에 scope : 구체 클래스 binding 자체가 scoped
class AnalyticsImpl @Inject constructor() : Analytics
이러면 어느 타입으로 요청하든 같은 인스턴스로 모이므로 두 경로가 생길 수 있는 타입은 scope를 클래스에 붙이는 쪽이 안전하다.
1. SingletonComponent
앱 시작점(시동) + 앱 전역 객체 공급
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@HiltAndroidApp // 시동: Hilt 적용 진입점, SingletonComponent 생성
class MyApp : Application()
@Module
@InstallIn(SingletonComponent::class) // 공급①: @Provides — 외부 라이브러리(Room DB)
object AppModule {
@Provides
@Singleton
fun provideDB(@ApplicationContext ctx: Context): AppDatabase =
Room.databaseBuilder(ctx, AppDatabase::class.java, "app.db").build()
}
// 공급②: @Binds — 인터페이스→구현체 (앱 전역 Repository)
interface NewsRepository {
suspend fun news(query: String): List<News>
}
class OfflineFirstNewsRepository @Inject constructor(
private val db: AppDatabase // AppModule의 @Provides가 공급한 DB가 여기로 들어옴
) : NewsRepository {
override suspend fun news(query: String): List<News> = emptyList()
}
@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {
@Binds
abstract fun bindNewsRepository(impl: OfflineFirstNewsRepository): NewsRepository
}
// 수요: NewsRepository를 요청하면 @Binds로 연결된 OfflineFirstNewsRepository가 들어옴
@HiltViewModel
class NewsViewModel @Inject constructor(
private val repo: NewsRepository
) : ViewModel()
2. ActivityComponent
공급·수요 양쪽을 다 보여주는 기본형
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@AndroidEntryPoint // 수요: 이 Activity를 주입 지점으로
class MainActivity : AppCompatActivity() {
@Inject lateinit var analytics: Analytics // ActivityComponent DI 그래프에서 꺼냄
}
// 구현체: @Inject constructor라서 @Binds로 연결 가능
class AnalyticsImpl @Inject constructor() : Analytics { ... }
@Module
@InstallIn(ActivityComponent::class) // 공급: @Binds 인터페이스→구현체
abstract class AnalyticsModule {
@Binds
@ActivityScoped
abstract fun bind(impl: AnalyticsImpl): Analytics
}
3. FragmentComponent
부모(Activity) DI 그래프 binding도 그대로 받음
1
2
3
4
@AndroidEntryPoint
class HomeFragment : Fragment() {
@Inject lateinit var analytics: Analytics // 호스트 Activity가 받는 것과 같은 인스턴스
}
Fragment·View 주입은 호스트 Activity도 @AndroidEntryPoint여야 동작한다.
4. ViewModelComponent
@HiltViewModel + 생성자 주입
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 여러 곳에서 공유할 가변 상태 홀더
class FilterState {
var query: String = ""
}
// ViewModelComponent에 설치한 binding은 그 ViewModel이 사는동안 하나로 공유된다(@ViewModelScoped).
@Module
@InstallIn(ViewModelComponent::class)
object FilterModule {
@Provides
@ViewModelScoped
fun provideFilterState(): FilterState = FilterState()
}
// FilterState는 ViewModel 하나당 하나, 같은 ViewModel 그래프 안에서는 공유, 다른 ViewModel은 자기 것을 따로 가진다.
@HiltViewModel
class HomeViewModel @Inject constructor(
private val repo: NewsRepository,
private val filterState: FilterState //<- FilterModule 에서 옴
) : ViewModel()
// 사용처는 @AndroidEntryPoint Activity/Fragment에서 by viewModels()로 받는다(공식 권장 방식).
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private val viewModel: HomeViewModel by viewModels()
}
Q. FilterState를 ViewModel 생성자 한 곳에서만 주입 받으면 @ViewModelScoped 없이도 ViewModel 생성시 한 번만 만들어 진다. 그럼 scope를 굳이 붙여야 하나?
단일 주입이면 안 붙여도 된다. unscoped와 결과가 같다. 생성자는 ViewModel 생성 시 한 번만 호출되니 FilterState도 그때 한 번 만들어진다. scope의 차이는 “같은 DI 그래프 안에서 그 타입을 몇번 요청하느냐”에서만 드러난다. @ViewModelScoped가 실제로 필요한 건 같은 그래프에서 둘 이상이 FilterState를 주입받을 때다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class GetFilteredNewsUseCase @Inject constructor(
private val filterState: FilterState, // 소비처 ②
private val repo: NewsRepository,
) {
// 호출 시점의 query를 한 번 읽어 검색 (스냅샷, 실시간 감지 아님)
suspend operator fun invoke(): List<News> = repo.news(filterState.query)
}
@HiltViewModel
class HomeViewModel @Inject constructor(
private val filterState: FilterState, // 소비처 ①
private val getFilteredNews: GetFilteredNewsUseCase,
) : ViewModel() {
fun search(q: String) = viewModelScope.launch {
filterState.query = q // ① query를 변경하고
val news = getFilteredNews() // ② UseCase가 그 query를 읽어 검색
}
}
- scope 있음(
@ViewModelScoped) : ①과 ②가 같은 FilterState → invoke가 ①이 쓴 query를 읽음 - scope 없음(unscoped) : ①과 ②가 다른 FilterState → ②의 query는 여전히 “” → 검색이 무시됨
scope 지정이 적절한 경우
이 기준은 @ViewModelScoped, @Singleton, @ActivityScoped 등 모든 scope에 동일하게 적용된다. 공식 문서 Component scopes가 꼽는 적절한 경우는 다음 세 가지이다.
① 상태 공유 : 모두가 같은 인스턴스를 봐야 데이터가 일관 될 때(논리 문제)
② 스레드 동기화 : 여러 스레드의 동시 접근을 하나의 인스턴스에서 조율(락 등)할 때(동시성 문제)
③ 비싼 생성 : 생성 비용이 크다고 측정된 객체일 때
그 외엔 unscoped가 기본이다. 공식 권고도 “Minimize the use of scoped bindings in your application(앱에서 scoped binding 사용을 최소화 하라).” 그리고 어떤 scope든 같은 그래프에서 그 타입을 2번이상 요청할 때만 효과가 드러난다. 단일 주입이면 unscoped와 결과가 같다.
5. ServiceComponent
Service에 주입(수요) + 주입될 객체는 생성자 주입(공급)
1
2
3
4
5
6
7
8
9
10
// 공급: @Inject constructor라 Module 불필요
class Syncer @Inject constructor(
private val repo: NewsRepository
)
@AndroidEntryPoint
class SyncService : Service() { // 수요: 이 Service를 주입 지점으로
@Inject lateinit var syncer: Syncer // onCreate부터 사용 가능
override fun onBind(intent: Intent?): IBinder? = null // Service 추상 메서드, 구현 필수
}
6. ViewComponent
커스텀 View에 주입
1
2
3
4
5
6
7
8
9
10
// 공급: @Inject constructor
class ImageLoader @Inject constructor()
@AndroidEntryPoint // 수요: 커스텀 View를 주입 지점으로
class BannerView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null,
) : FrameLayout(context, attrs) {
@Inject lateinit var imageLoader: ImageLoader // View 생성 시 주입됨
}
// 주의: 이 View를 품는 Activity/Fragment도 @AndroidEntryPoint 여야 동작한다
7. ActivityRetainedComponent
직접 주입 대상은 없고 구성 변경(회전)에도 살아남아야 하는 공유 객체를 두면 자식인 ViewModel이 받는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 구성 변경에도 유지돼야 하는 공유 상태 (Hilt 애노테이션 없는 일반 클래스라 아래 @Provides로 공급)
class SessionCache {
val items = mutableListOf<String>()
}
// 공급: ActivityRetainedComponent DI 그래프에 등록 (@ActivityRetainedScoped)
@Module
@InstallIn(ActivityRetainedComponent::class)
object SessionModule {
@Provides
@ActivityRetainedScoped
fun provideSessionCache(): SessionCache = SessionCache()
}
// 수요: ViewModelComponent는 ActivityRetainedComponent의 자식이라 그대로 받는다
@HiltViewModel
class CartViewModel @Inject constructor(
private val sessionCache: SessionCache, // 회전해도 같은 인스턴스, 그 Activity의 ViewModel들이 공유
) : ViewModel()
마무리 : Hilt의 설계 목표
지금까지 정리한 Component 8종과 Scope 7종은 Hilt가 임의로 만든 API 목록이 아니다. Hilt 공식 사이트가 밝힌 설계 목표 그 자체다.
① Dagger 사용에 필요한 인프라를 단순화한다
② 표준 Component·Scope 세트를 제공한다. 설정과 가독성이 좋아지고 앱 간 코드 공유가 쉬워진다
③ 빌드 타입(테스트·디버그·릴리스)별로 다른 binding을 쉽게 공급하는 방법을 제공한다
Dagger에서는 Component 구조를 개발자가 직접 설계하기 때문에 프로젝트마다 그래프 모양이 달랐다. Hilt는 그 구조를 이 글에서 본 표준 세트로 고정했다. 그래서 처음 보는 Hilt 프로젝트도 구조를 예측할 수 있다. ③은 테스트에서 binding을 교체하는 내용이라 이 글에서는 다루지 않았다.
번외 : Compose에서 Hilt 사용
Compose는 Android 프레임 워크가 생성하는 클래스가 아니라 함수(@Composable)라서 @AndroidEntryPoint 필드 주입 모델이 그대로 안맞는다. 대신 ViewModel을 경유해 의존성을 받고 그 ViewModel을 꺼내는 전용 함수 hiltViewModel()을 쓴다.
1
2
// 의존성 (별도 아티팩트)
implementation("androidx.hilt:hilt-navigation-compose:<version>")
1
2
3
4
5
6
7
8
9
10
@HiltViewModel
class HomeViewModel @Inject constructor(
private val repo: NewsRepository
) : ViewModel()
// hiltViewModel()이 @HiltViewModel ViewModel을 DI 그래프에서 꺼내 준다
@Composable
fun HomeScreen(
viewModel: HomeViewModel = hiltViewModel()
) { /* viewModel.uiState 구독 등 */ }
by viewModels()(View 시스템) ↔ hiltViewModel()(Compose)가 대응 관계다. 둘 다 결국 @HiltViewModel + ViewModelComponent를 쓴다. 호스트 Activity는 여전히 @AndroidEntryPoint여야 동작한다.
scope는 어디에 묶이나
hiltViewModel()은 호출된 위치에서 가장 가까운 ViewModelStoreOwner, 즉 보통 그 화면의 NavBackStackEntry(navigation destination)에 ViewModel을 묶는다. 그래서 목적지(화면)마다 ViewModel 인스턴스가 따로 생긴다.
| 받는 방식 | 묶이는 범위 |
|---|---|
hiltViewModel() | 현재 navigation 목적지 (화면 단위, 기본) |
hiltViewModel(navBackStackEntry) | 지정한 back stack entry / 상위 nav graph |
여러 목적지가 한 ViewModel을 공유하려면(예: 마법사 흐름 전체) nav graph 범위의 back stack entry를 넘긴다.
1
2
3
4
5
6
7
8
// 여러 화면이 nav graph 단위로 같은 ViewModel 인스턴스를 공유
@Composable
fun StepScreen(navController: NavController, backStackEntry: NavBackStackEntry) {
val parentEntry = remember(backStackEntry) {
navController.getBackStackEntry("wizard_graph")
}
val sharedViewModel: WizardViewModel = hiltViewModel(parentEntry)
}