주완 님의 블로그
Hilt 의 정의와 사용법 본문
사실 제가 Hilt 를 쓰면서도 헷갈리는 것도 많고.. 제대로 알고 쓰고 있나 할 때가 많아가지고 한 번 낋여와 봤습니다.
Hilt 를 사용하기 전 수동으로 의존성을 주입하는 것을 먼저 경험하시고 사용하는게 좋을 것 같습니다.
Hilt를 사용한 종속 항목 삽입 | App architecture | Android Developers
Hilt를 사용한 종속 항목 삽입 | App architecture | Android Developers
이 페이지는 Cloud Translation API를 통해 번역되었습니다. Hilt를 사용한 종속 항목 삽입 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Hilt는 프로젝트에서 종속
developer.android.com
일단 공식문서를 찾아보도록할까요?
- 문서상의 정의를 찾아보자면..
Hilt는 프로젝트에서 종속 항목 수동 삽입을 실행하는 상용구를 줄이는 Android용 종속 항목 삽입 라이브러리입니다. 종속 항목 수동 삽입을 실행하려면 모든 클래스와 종속 항목을 수동으로 구성하고 컨테이너를 사용하여 종속 항목을 재사용 및 관리해야 합니다.
Hilt는 프로젝트의 모든 Android 클래스에 컨테이너를 제공하고 수명 주기를 자동으로 관리함으로써 애플리케이션에서 DI를 사용하는 표준 방법을 제공합니다. Hilt는 Dagger가 제공하는 컴파일 시간 정확성, 런타임 성능, 확장성 및 Android 스튜디오 지원의 이점을 누리기 위해 인기 있는 DI 라이브러리인 Dagger를 기반으로 빌드되었습니다. 자세한 내용은 Hilt 및 Dagger를 참조하세요.
⇒ 라고 합니다. 말이 좀 어렵네요
해당 정의를 읽었을 때 직관적으로 이해하기 어려울 수 있어 키워드 별로 조금 분리를 하자면
- 종속 항목 수동 삽입을 실행하는 상용구를 줄이는 라이브러리
- 컨테이너를 제공하고 수명주기를 자동으로 관리함으로써 앱 DI를 사용하는 표준 방법
- 컴파일 시간 정확성 , 런타임 성능, 확장성 및 Android Studio 지원이 이점을 누르기 위함
이 정도로 나눠 볼 수 있는데요. 한 번 하나씩 알아봅시다.
일단 사용법 부터..
사용법은 워낙 잘 나온 글 들이 많기도 한데요. 저는 지인분의 자료를 좀 참고해서 작성해보겠습니다.
Hilt 세팅하기
이제 본격적으로 Hilt를 사용하기 위한 세팅을 해보겠습니다.
libs.versions.toml 버전 카탈로그에 버전 추가
[versions]
kotlin = "2.0.21"
hilt = "2.56.2"
hilt-navigation-compose = "1.2.0"
ksp = "2.1.20-2.0.0"
[libraries]
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
hilt-navigation-compose = {group="androidx.hilt", name="hilt-navigation-compose", version.ref = "hilt-navigation-compose"}
[plugins]
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
라이브러리와 플러그인을 쉽게 관리할 수 있는 버전 카탈로그를 사용해서 Hilt의 버전을 추가해줍니다.
Hilt는 어노테이션 기반으로 코드를 자동 생성하기 때문에 이 어노테이션을 처리하기 위한 도구가 필요합니다.
이 도구가 ksp 혹은 kapt입니다.
공식 문서에서도 ksp를 사용하고 있고, 최근 kapt보다 ksp가 떠오르는 추세이기 때문에 ksp를 추가해줍니다.
참고로 ksp 버전은 Kotlin 버전과 호환되는 버전을 선택해야합니다!
ksp, kapt는 공통적으로 Kotlin 코드에 붙인 어노테이션을 처리해서 자동으로 코드를 생성하는 도구입니다.
Hilt는 @Inject, @Module, @Provides 등의 어노테이션을 기반으로 의존성 주입용 클래스.팩토리 등을 자동 생성합니다. 이 자동 생성 작업을 처리해주는게 ksp(Kotlin Symbol Proccessing)또는 kapt(Kotlin Annotation Processing Tool)입니다.
- ksp랑 kapt는 또 뭔차이인데요?
- 그래서 Hilt도 이제 ksp를 공식 지원하고, 이를 통해 빌드 속도와 호환성을 개선할 수 있습니다.항목 KAPT (Kotlin Annotation Processing Tool) KSP (Kotlin Symbol Processing)
지원 방식 자바 기반의 어노테이션 프로세서 활용 코틀린 전용 어노테이션 프로세서 동작 시점 Kotlin → Java 변환 후 처리 Kotlin 원본 코드에서 직접 처리 속도 느림 (컴파일 단계에서 많은 리소스 사용) 빠름 (코틀린 AST 직접 사용) 오류 위치 추적 부정확 (자바 코드 기준) 정확 (Kotlin 코드 기준) Null Safety 보장 안 됨 Kotlin 타입 시스템을 그대로 사용 가능 현대적 지원 점점 축소 중 Jetpack/Google 라이브러리 점점 KSP로 이전 중 - 예전에는 kapt만 사용 가능했지만,Google이 Kotlin 전용의 더 빠르고 효율적인 ksp를 도입하였습니다.
https://developer.android.com/build/migrate-to-ksp?hl=ko
kapt에서 KSP로 이전 | Android Studio | Android Developers
주석 프로세서의 사용을 kapt에서 KSP로 이전합니다.
developer.android.com
(Project 수준) build.gradle.kts 종속 항목 추가
alias로 버전 카탈로그에서 정의한 키 이름을 사용
plugins {
...
alias(libs.plugins.hilt) apply false
alias(libs.plugins.ksp) apply false
}
(Module 수준) build.gradle.kts 종속 항목 추가
plugins {
...
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
}
dependencies {
implementation(libs.hilt.android)
implementation(libs.hilt.navigation.compose) // hiltViewModel() 사용을 위함
ksp(libs.hilt.compiler)
}
- 해당 라이브러리들은 무슨 역할이에요?
- hilt-navigation-compose는 composable 내부에서 hiltViewModel()을 호출하기 위해 필요합니다.
- hilt-compiler 는 무슨 역할일까요? 앞서 말씀 드렸듯이, Hilt는 어노테이션을 기반으로 코드를 생성합니다. 또한 Hilt는 컴파일 타임에 코드를 생성합니다. hilt-compiler는 Hilt의 의존성 주입 코드를 컴파일 타임에 자동 생성해주는 어노테이션 프로세서입니다. 따라서 코틀린에서 어노테이션 프로세서를 실행해주는 도구인 ksp에 hilt-compiler를 넣어주는 것이죠!
Hilt Application 클래스 추가 - @HiltAndroidApp
Hilt를 사용하는 모든 앱은 @HiltAndroidApp 으로 주석이 지정된 Application 클래스를 포함해야 합니다.
...Application class 가 뭐냐구요?
1. Application Class란?
- 안드로이드 앱 전체의 “최상위 객체(전역 관리자)”로 앱이 실행되는 순간 , 가장 먼저 만들어지는 객체이며 프로세스 안에서 단 하나만이 존재합니다.
앱 실행됨 → Application 객체 생성
↓
Activity / ViewModel / Service 등 생성
=> 모든 컴포넌트보다 먼저 생성되는 전역 클래스라고 보면 되는데요
2. Application Class의 역할
앱 전체에서 공유해야 하는 초기 설정
- Hilt DI 초기화 (그래서 @HiltAndroidApp 필요)
- Timber, Kakao SDK, Firebase 초기화
- DataStore, Room DB 초기화
- 글로벌 싱글톤 설정
앱 프로세스가 살아있는 동안 유지되는 전역 객체 제공
- Context 제공
- 전역 상태 관리
- 앱 단위의 라이프사이클 이벤트
프로세스 레벨에서 동작
Activity/Fragment가 사라져도 Application은 살아있음.
따라서 다음과 같이 Application 클래스를 상속받는 클래스 파일을 생성해주세요!
@HiltAndroidApp
class ExampleApplication : Application() {
override fun onCreate() {
super.onCreate() // 의존성 주입은 이 시점에서 발생합니다!
}
}
- @HiltAndroidApp이 뭔데요?애플리케이션 수준 종속 항목 컨테이너 역할을 하는 애플리케이션의 기본 클래스를 비롯하여 Hilt의 코드 생성을 트리거합니다.
- 라고 하는데요! 쉽게 말하면 “이 어플에서 Hilt 사용할게요!”라고 하는 것과 같습니다. 해당 어노테이션이 붙은 Application 클래스를 시작으로, Hilt는 앱의 모든 Hilt 사용 컴포넌트에 대한 의존성 그래프를 생성합니다.
AndroidManifest.xml 에 앞에서 만든 클래스 이름 추가
<application
android:name=".ExampleApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_uber"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_uber_foreground"
android:supportsRtl="true"
android:theme="@style/Theme.Uber"
android:usesCleartextTraffic="true"
tools:targetApi="31">
...
MainActivity에 @AndroidEntryPoint 붙여주기
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
...
}
}
}
@AndroidEntryPoint 는 Activity에만 붙일 수 있는 것은 아니에요.
먼저 Hilt가 지원하는 Android 클래스는 다음과 같습니다.
- Application(@HiltAndroidApp을 사용하여)
- ViewModel(@HiltViewModel을 사용하여)
- 이외에 @AndroidEntryPoint 를 사용하여 지원하는 클래스
- Activity
- Fragment
- View
- Service
- BroadcastReceiver
@AndroidEntryPoint는 프로젝트의 각 Android 클래스에 관한 개별 Hilt 컴포넌트를 생성합니다.
저는 동아리에서 실습을 해보며, Compose 앱을 만들때는 Single Activity Architecture(SAA)를 적용하곤하는데요! 그러면 제가 실제로 사용하는 Activity는 MainActivity 하나 입니. 그래서 Hilt를 사용하기 위해 MainActivity에 @AndroidEntryPoint 를 붙여줍니다. 이렇게 하면 MainActivity내부에서 사용하는 composable 함수들에서 뷰모델을 hiltViewMdel()로 자동 생성하고, 그 뷰모델에서 필요한 레포지터리도 주입받을 수 있게됩니다. 상위에서 Hilt가 먼저 적용이 되어야 하위에서도 의존성 주입이 가능해지는 것이지요.
여기까지 진행하면 사전 준비는 모두 끝이 납니다!
의존성 주입하기
이제 Hilt 세팅이 끝났으니, 실제로 의존성 주입을 해볼 차례입니다!
Hilt에서는 @Inject 어노테이션을 붙이는 방식으로 주입이 가능합니다.
@Inject를 활용한 주입 방식은 두가지가 있어요.
Field Injection
- Field Injection: @Inject 어노테이션을 활용하여 직접 의존성을 주입합니다. (주로 Activity, Service, BroadcastReceiver, Worker등 constructor()를 만들 수 없을 때 사용합니다.)
- 다음과 같이 AClass가 있다고 가정해볼게요.
class AClass(){ fun printAClass(){ println("AClass") } }
보통은 이 객체를 사용하기 위해서는 한번 생성한 뒤에 사용을 해야하죠!
class MainActivity : ComponentActivity() {
private val aClass = AClass()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
aClass.printAClass()
}
}
이제 이 AClass를 Field Injection으로 주입해보겠습니다.
먼저 주입 할 객체인 AClass의 생성자 앞에 @Inject 를 붙여줍니다.
class AClass @Inject constructor(){ //주입을 하겠다!
fun printAClass(){
println("AClass")
}
}
다음으로 주입 받을 프로퍼티(필드)에 @Inject를 붙여줍니다.
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject lateinit var aClass: AClass // 주입을 받겠다!
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
aClass.printAClass()
}
}
Constructor Injection
만약 생성자에 매개변수가 있다면 어떻게 될까요? 앞서 만든 AClass를 생성자 매개변수로 받는 BClass를 만들어보겠습니다.
class BClass @Inject constructor(
private val aClass: AClass
) {
fun printAClassWithBClass(){
aClass.printAClass()
println("BClass")
}
}
이때 BClass의 생성자는 @Injcet 어노테이션이 붙어 주입 될 객체로 선언하였습니다. BClass는 생성자의 매개변수로 AClass를 필요로합니다. 그렇기 때문에 AClass 인스턴스 제공방법 또한 Hilt에게 알려야 합니다.
저희는 아까 전 AClass의 생성자 앞에 @Inject를 붙여 인스턴스 제공방법을 알렸기때문에 그대로 사용하면 됩니다!
class AClass @Inject constructor(){
fun printAClass(){
println("AClass")
}
}
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject lateinit var bClass: BClass
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
bClass.printAClassWithBClass()
}
}
이렇게 의존성 주입 방법 두가지를 알아보았습니다. 그런데!!!!
@Inject로 주입이 안되는 경우
@Inject만으로는 주입이 안되는 경우도 있습니다 대표적으로 다음 두 가지입니다.
- 인터페이스를 생성자로 주입하려는 경우
- 외부 라이브러리 객체 (ex. Retrofit, OkHttp 등)를 생성자로 주입하려는 경우
이유는 간단합니다.
Hilt는 @Inject constructor()로 생성자를 명확하게 알 수 있는 경우에만 직접 인스턴스를 만들어서 주입해줄 수 있어요.
하지만 인터페이스는 구현체가 뭔지 모르고, 외부 라이브러리 객체는 우리가 @Inject를 붙이는 등 생성자를 직접 제어할 수 없기 때문에 주입이 되지 않아요.
그래서 등장한 게 바로 아래 두 가지 방식입니다!
Binds와 Provides
주입이 안 되는 경우엔 우리가 직접 Hilt에게 생성 방법을 알려줘야 해요.
그걸 도와주는 게 바로 @Binds와 @Provides 입니다.
먼저 Hilt에 알려주려면 Module이라는 공간이 필요합니다.
Module 만들기
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
abstract fun bindUserRepository(
impl: UserRepositoryImpl
): UserRepository
}
@Module은 특정 클래스의 인스턴스를 제공하는 방법을 Hilt에게 알리는 역할을 합니다.
@InstallIn은 해당 모듈의 적용 범위를 나타내는 것입니다. @Module사용시 반드시 붙여주어야 합니다. 위에서는SingletonComponent로 되어 있죠? 이는 하나의 어플리케이션의 하나의 모듈만 설치하겠다는 뜻입니다.
SingletonComponent와 같은 Hilt 컴포넌트의 종류는 마지막에 설명드릴게요.
@Binds 주석 사용하기
@Binds주석은 인터페이스의 인스턴스를 제공해야할 때, 구현방법을 Hilt에게 알리는 역할을 합니다.
→ 쉽게말해 인터페이스와 구현체를 연결합니다.
@Binds를 사용하는 방법은 다음과 같습니다.
- abstract fun을 만든다 (함수명은 임의로 지어도 됩니다!, 이 함수를 개발자가 직접 호출 할 일은 없습니다. 단지 이 인터페이스에는 이 구현체를 써라~ 라고 hilt에게 알려주는 용도에요.)
- 함수의 인자는 구현체
- 함수의 리턴 타입은 인터페이스
레포지터리 패턴을 사용할때 레포지터리 인터페이스와 구현체를 각각 만들죠? 그렇기에 위에 예제에서 abstract 함수의 인자는 UserRepositoryImpl, 리턴 타입은 UserRepository로 설정하여 바인딩 정보를 제공하는 것입니다.
@Binds
abstract fun bindUserRepository(
impl: UserRepositoryImpl
): UserRepository
interface로 모듈을 만들 수도 있다?
@bind 주석은 추상 함수를 만든 뒤에 붙여야한다고 했는데요, 그럼 추상 함수를 포함하는 모듈을 abstract class로만 만들어야할까요?
정답은 → 아닙니다.
인터페이스로도 구현이가능합니다. Kotlin에서는 interface 안의 fun 이 기본적으로 추상 함수이기 때문에 abstract class 대신 interface로도 모듈을 정의할 수 있습니다
따라서 @binds주석또한 인터페이스 내부 fun에 붙일 수 있습니다.
@Module
@InstallIn(SingletonComponent::class)
interface RepositoryModule {
@Binds
fun bindUserRepository(
impl: UserRepositoryImpl
): UserRepository
}
@Provides주석 사용하기
외부 라이브러리처럼 직접 객체 생성을 해야 하는 경우(ex. Retrofit, OkHttp 등)에는 @Provides를 사용합니다.
서버통신을 위한 Retrofit, OkHttpClient등을 주입하기 위한 예제입니다.
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun providesLoggingInterceptor() = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
@Provides
@Singleton
fun providesOkHttpClient(
loggingInterceptor: HttpLoggingInterceptor,
): OkHttpClient = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.build()
@Provides
@Singleton
fun providesConverterFactory(): Converter.Factory =
Json.asConverterFactory("application/json".toMediaType())
@Provides
@Singleton
fun providesRetrofit(
client: OkHttpClient,
converterFactory: Converter.Factory
): Retrofit = Retrofit.Builder()
.baseUrl(BuildConfig.BASE_URL)
.client(client)
.addConverterFactory(converterFactory)
.build()
}
이런 상황에서의 모듈은 대부분은 Object로 구현합니다.
- 함수의 리턴타입은 Hilt에게 "이 타입의 객체를 제공할 수 있어!"라고 알려줍니다.
- 함수의 파라미터로 Hilt에게 "이런 타입의 의존성을 사용할 거야!"라고 알려줍니다. → 즉 이 타입도 Hilt가 주입 할 수 있어야 합니다. (생성방법을 알려야함)
- 함수의 본문에는 이렇게 만들어줘!"라고 구체적인 생성 방법을 제공합니다. Hilt는 해당 유형의 인스턴스를 제공해야 할 때마다 함수 본문을 실행합니다.
- 그렇다면 Binds와 Provides 중 누가 더 효율적일까?
- @Binds승! ㅋㅋ @Provides 메서드는 런타임에 실제 메서드 호출을 통해 객체를 생성하지만, @Binds는 컴파일 타임에 Hilt가 해당 인터페이스 타입 요청 시 바로 지정된 구현체 클래스를 연결해주는 방식으로 작동해요. 따라서 불필요한 메서드 호출 오버헤드를 줄여줍니다.
HiltViewModel
아까 @Binds로 인터페이스와 구현체를 연결해주었죠? 레포지토리 인터페이스를 뷰모델에서 사용하는 예제를 보여드리겠습니다.
**@HiltViewModel**로 주석 처리하고 ViewModel 객체의 생성자에서 @Inject 주석을 사용하여 뷰모델을 제공합니다.
@HiltViewModel
class UserViewModel @Inject constructor(
private val repo: UserRepository
) : ViewModel(){
private val _uiState = MutableStateFlow(UserUiState())
val uiState = _uiState.asStateFlow()
private val _user = MutableStateFlow<User?>(null)
val user = _user.asStateFlow()
fun loadUser(userId: String) {
viewModelScope.launch {
try {
_uiState.value = _uiState.value.copy(isLoading = true)
val user = userRepository.getUser(userId)
_user.value = user
_uiState.value = _uiState.value.copy(isLoading = false)
} catch (e: Exception) {
_uiState.value = _uiState.value.copy(
isLoading = false,
error = e.message
)
}
}
}
}
만약 해당 ViewModel을 composable에서 불러오고 싶다면?
@Composable
fun UserScreen(
modifier: Modifier = Modifier,
**userViewModel: UserViewModel = hiltViewModel()**
) {
...
}
hiltViewModel() 함수로 간단하게 뷰모델 생성이 가능합니다!
@HiltViewModel은 Hilt가 이 ViewModel을 관리하게 한다는 의미예요.
hiltViewModel()은 @HiltViewModel로 등록된 뷰모델을 컴포저블에서 자동으로 불러오는 함수예요. 내부적으로는 NavBackStackEntry를 기반으로 ViewModel을 생성해주기 때문에, 화면마다 뷰모델을 자동으로 구분해줍니다. 따라서 Composable 함수에서 hiltViewModel()을 사용하려면 NavHostController와 연결된 NavBackStackEntry 안에 있어야 합니다. 그냥 단순한 @Composable에서 부르면 에러가 날 수 있어요!
Qualifier
동일한 타입의 의존성이 여러 개 존재할 경우, Hilt는 어떤 의존성을 주입해야 할지 알 수 없습니다. 이때 @**Qualifier**를 사용하여 특정 의존성을 식별하고 구분할 수 있습니다.
예를 들어, OkHttpClient를 여러 종류로 제공하고 싶을 때 각각 어떤 용도로 쓰이는지를 구분하지 않으면 Hilt는 어떤 것을 주입해야 할지 몰라 오류가 발생합니다. 이럴 때 사용하는 것이 바로 @Qualifier입니다.
예를 들어 OkHttpClient를 다음과 같이 두 개 만든다고 해봅시다:
- 로그인/회원가입 등 인증 요청을 처리하는 OkHttpClient
- 기타 요청에 사용하는 일반 요청용 OkHttpClient
두 객체의 타입은 동일하지만 내부 설정(인터셉터 등)이 다르기 때문에, 서로 다른 용도로 사용되어야 합니다. 이럴 때 @Qualifier를 붙여서 Hilt에게 "이건 인증용", "이건 일반용"이라고 구분해줘야 합니다.
1. 커스텀 어노테이션 정의
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class AuthInterceptorOkHttpClient
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class OtherInterceptorOkHttpClient
@Retention(AnnotationRetention.BINARY)이란?
어노테이션의 생명주기를 결정하는 설정입니다.
종류 설명
| SOURCE | 컴파일 시에만 존재. .class 파일에는 없음 |
| BINARY | .class 파일에 존재하지만 런타임에는 사용 불가 |
| RUNTIME | 런타임에서도 반영되어 Reflection 등을 통해 참조 가능 |
@Qualifier는 컴파일 타임에 구분만 하면 되기 때문에 BINARY면 충분합니다. 그래서 Hilt에서 @Qualifier 정의할 때 대부분 AnnotationRetention.BINARY를 사용합니다.
2. @Provides 메서드에 Qualifier 붙이기
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@AuthInterceptorOkHttpClient
@Provides
fun provideAuthInterceptorOkHttpClient(
authInterceptor: AuthInterceptor
): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.build()
}
@OtherInterceptorOkHttpClient
@Provides
fun provideOtherInterceptorOkHttpClient(
otherInterceptor: OtherInterceptor
): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(otherInterceptor)
.build()
}
}
두 함수 모두 OkHttpClient를 반환하지만 각각 @AuthInterceptorOkHttpClient, @OtherInterceptorOkHttpClient라는 서로 다른 Qualifier를 붙여서 명확하게 구분해줍니다.
3. 의존성을 주입할 때도 Qualifier 사용
@Provides
fun provideAnalyticsService(
@AuthInterceptorOkHttpClient okHttpClient: OkHttpClient
): AnalyticsService {
return Retrofit.Builder()
.baseUrl("<https://example.com>")
.client(okHttpClient)
.build()
.create(AnalyticsService::class.java)
}
여기서 아까 만든 커스텀 어노테이션 @AuthInterceptorOkHttpClient를 통해 인증용 OkHttpClient가 주입됩니다.
Hilt 컴포넌트
아까 @InstallIn 어노테이션 안에 SinglethonComponent라는게 있었죠? 이와 같이 Hilt의 컴포넌트종류는 아래와 같습니다.
Android 클래스용으로 생성된 구성요소

구성요소 전체 기간
각 구성요소가 생성되는 위치와 소멸 위치는 다음과 같아요.
Application, Activity, ViewModel 등등... @HiltAndroidApp이나, @AndroidEntryPoint를 사용할 수 있는 곳들에서 생성이 됩니다. 이 수명주기에 따라 구성요소 클래스의 인스턴스가 자동으로 만들어지고 제거됩니다.

구성요소 범위

@Singleton, @ActivityScoped 같은 범위 어노테이션을 붙이면, 같은 컴포넌트 안에서는 객체가 한 번만 생성돼요. 이걸 Scope 라고 합니다.
예를 들어 @Singleton을 SingletonComponent에 설치하면 → 앱 전체에서 같은 인스턴스 사용합니다.
@ActivityScoped를 붙이면 액티비티 내에서 하나만 만들고 공유하는 것이지요.
구성요소 계층 구조
컴포넌트는 계층 구조를 가집니다. 컴포넌트는 부모 → 자식 구조로 연결되기때문에 상위 컴포넌트에 있는 객체는 하위에서도 접근 가능해요.
예: SingletonComponent → ActivityComponent → ViewModelComponent
즉, 전역 객체를 ViewModel이나 Activity에서도 그대로 쓸 수 있다라는 뜻입니다.

Context가 필요한 경우
안드로이드 개발에서는 Context를 직접 생성할 수 없기 때문에 의존성 주입으로 받아야 할 때가 많습니다.
Hilt는 Context를 구분해서 제공할 수 있게 아래 두 가지 어노테이션을 제공합니다:
@ApplicationContext : 앱 전체에 해당하는 Context
@ActivityContext : 해당 액티비티에만 해당하는 Context
예를들어 SharedPreference를 생성하기 위해 @ApplicationContext를 이용하는 예제입니다.
class PreferenceUtil @Inject constructor(
@ApplicationContext private val context: Context
) {
private val sharedPreferences =
context.getSharedPreferences("tiving_prefs", Context.MODE_PRIVATE)
}
굉장히 잘 정리되어 있네요 암튼, 이렇게 사용법에 대해서 알아봤는데요, 그럼 이제 이걸 왜 쓰냐.. 한다면
1. 종속 항목을 수동으로 삽입 한다.
종속 항목 = 의존성 = 내가 일하려면 필요한 것들로
예시로 보자면, Class 내부에 필요한 기능에 대한 인스턴스를 직접적으로 전부 만들어주어야 합니다.
class Barista {
fun makeCoffee() {
val coffeeMaker = CoffeeMaker() *// 커피 머신 직접 만들기*
val grinder = Grinder() *// 그라인더 직접 만들기*
val milk = Milk() *// 우유 직접 만들기*
*// 커피 만들기...*
}
}
인스턴스 : 클래스로부터 만들어진 실제 객체 (메모리에 할당된 실체)
비유로 들자면..
*// 클래스 = 붕어빵 틀*
class CoffeeMaker {
fun brew() {
println("커피 추출 중...")
}
}
*// 인스턴스 = 붕어빵 틀로 찍어낸 실제 붕어빵*
val coffeeMaker1 = CoffeeMaker() *// 붕어빵 1개*
val coffeeMaker2 = CoffeeMaker() *// 붕어빵 2개*
val coffeeMaker3 = CoffeeMaker() *// 붕어빵 3개*
클래스: 설계도, 틀, 레시피
인스턴스: 설계도로 만든 실제 물건
class Barista {
fun makeCoffee() {
val coffeeMaker = CoffeeMaker() *// 인스턴스 직접 생성*
val grinder = Grinder() *// 인스턴스 직접 생성*
val milk = Milk() *// 인스턴스 직접 생성*
*// 커피 만들기...*
}
}
이게 뭘 의미하냐면:
val coffeeMaker = CoffeeMaker()
*//그럼 새로운 인스턴스마다 메모리에 하나씩 할당을 해줘야 겠죠?*
메모리에서 일어나는 일
makeCoffee() 호출
↓
메모리 할당 시작:
[메모리]
┌─────────────────┐
│ CoffeeMaker 객체 │ ← 새로 생성
│ - 주소: 0x1234 │
│ - 메서드: brew()│
└─────────────────┘
┌─────────────────┐
│ Grinder 객체 │ ← 새로 생성
│ - 주소: 0x5678 │
│ - 메서드: grind()│
└─────────────────┘
┌─────────────────┐
│ Milk 객체 │ ← 새로 생성
│ - 주소: 0x9abc │
│ - 데이터: milk()│
└─────────────────┘
매번 makeCoffee() 호출할 때마다 새로운 인스턴스 3개씩 생성됨!
실제 예시로 본다면?
class LoginActivity {
fun login() {
val api = ApiService() // 인스턴스 생성
api.login()
}
}
class SignupActivity {
fun signup() {
val api = ApiService() // 또 새로 생성
api.signup()
}
}
class ProfileActivity {
fun loadProfile() {
val api = ApiService() // 또또 새로 생성
api.getProfile()
}
}
이렇게 되면 뭐가 안좋을까요?
- ApiService() 라는 인스턴스 자체는 1개만 존재해도 되지만 불필요하게 3개씩 만들어버림
- 결합도가 높아짐
- 테스트 불가능
수동 삽입 = 필요한 걸 직접 손으로 만들어서 넣어주는 것
수동 삽입 (반복 작업)
*// 1. API 객체 만들기*
val retrofit = Retrofit.Builder()
.baseUrl("<https://api.example.com>")
.addConverterFactory(GsonConverterFactory.create())
.build()
val api = retrofit.create(ApiService::class.java)
*// 2. Database 객체 만들기*
val database = Room.databaseBuilder(
context,
AppDatabase::class.java,
"app_database"
).build()
*// 3. Preferences 객체 만들기*
val preferences = PreferenceManager(context)
*// 4. 이제 ViewModel 만들기*
val loginViewModel = LoginViewModel(
api = api, *// 수동으로 넣어주기*
database = database, *// 수동으로 넣어주기*
preferences = preferences *// 수동으로 넣어주기*
)
*// SignupViewModel도 필요하면?*
val signupViewModel = SignupViewModel(
api = api, *// 또 수동으로 넣어주기*
database = database, *// 또 수동으로 넣어주기*
preferences = preferences *// 또 수동으로 넣어주기*
)
*// ProfileViewModel도 필요하면?*
val profileViewModel = ProfileViewModel(
api = api, *// 또또 수동으로...*
database = database,
preferences = preferences
)
문제점:
- 같은 코드 계속 반복
- 객체 생성 순서 틀리면 크래시
- 새로운 종속성 추가하면 모든 곳 수정
- 코드 양이 엄청 많아짐 (상용구 코드)
2. 컨테이너를 제공하고 수명주기를 자동으로 관리함으로써 앱 DI를 사용하는 표준 방법
// 각자 알아서 만들어야 함
class LoginViewModel {
private val api = Retrofit.Builder()
.baseUrl("[<https://api.example.com>](<https://api.example.com/>)")
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(ApiService::class.java)
}
class SignupViewModel {
private val api = Retrofit.Builder()
.baseUrl("[<https://api.example.com>](<https://api.example.com/>)")
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(ApiService::class.java)
}
class ProfileViewModel {
private val api = Retrofit.Builder()
.baseUrl("[<https://api.example.com>](<https://api.example.com/>)")
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(ApiService::class.java)
}
// 1. 컨테이너에 넣을 것 정의 (1번만)
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideApiService(): ApiService {
return Retrofit.Builder()
.baseUrl("<https://api.example.com>")
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(ApiService::class.java)
}
}
// 2. 컨테이너에서 꺼내 쓰기
@HiltViewModel
class LoginViewModel @Inject constructor(
private val api: ApiService // 컨테이너에서 가져옴
) : ViewModel()
@HiltViewModel
class SignupViewModel @Inject constructor(
private val api: ApiService // 같은 인스턴스 재사용
) : ViewModel()
@HiltViewModel
class ProfileViewModel @Inject constructor(
private val api: ApiService // 같은 인스턴스 재사용
) : ViewModel()
[Hilt 컨테이너]
ApiService 인스턴스 ← 모두가 공유
LoginViewModel ───┐
SignupViewModel ───┼──→ 같은 ApiService 사용
ProfileViewModel ──┘
Hilt 자동 수명주기 관리
Hilt의 수명주기 스코프
@Singleton *// 앱 전체 수명 (앱 시작~종료)*
@ActivityRetainedScoped *// 화면 회전에도 유지*
@ActivityScoped *// Activity 수명 (onCreate~onDestroy)*
@ViewModelScoped *// ViewModel 수명*
@FragmentScoped *// Fragment 수명*
*// 1. 앱 전체에서 1개만 (Singleton)*
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton *// 앱 시작~종료까지 1개*
fun provideApiService(): ApiService {
return Retrofit.Builder()
.baseUrl("<https://api.example.com>")
.build()
.create(ApiService::class.java)
}
}
*// 2. ViewModel마다 새로 만들기*
@Module
@InstallIn(ViewModelComponent::class)
object ViewModelModule {
@Provides
fun provideUseCase(api: ApiService): LoginUseCase {
return LoginUseCase(api)
}
}
*// 3. 사용*
@HiltViewModel
class LoginViewModel @Inject constructor(
private val api: ApiService, *// Singleton - 앱 전체에서 1개*
private val useCase: LoginUseCase *// ViewModel마다 새로 만듦*
) : ViewModel()
Hilt가 자동으로 해주는 것을 보자면
앱 시작
↓
[Hilt] ApiService 생성 (Singleton)
↓
LoginActivity 시작
↓
[Hilt] LoginViewModel 생성
↓
[Hilt] LoginUseCase 생성 (ViewModel용)
↓
[Hilt] ApiService 주입 (기존 것 재사용)
↓
LoginActivity 화면 회전
↓
[Hilt] LoginViewModel 유지 (데이터 안 날아감!)
↓
LoginActivity 종료
↓
[Hilt] LoginViewModel 삭제
↓
[Hilt] LoginUseCase 삭제
↓
앱 종료
↓
[Hilt] ApiService 삭제
// 1. 앱 설정 (1줄)
@HiltAndroidApp
class MyApp : Application()
// 2. 컨테이너 정의 (명확함)
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton // 수명주기 명시
fun provideApi(): ApiService = ...
@Provides
@Singleton // 수명주기 명시
fun provideDatabase(): Database = ...
// 3. 사용 (1줄)
@AndroidEntryPoint
class LoginActivity : AppCompatActivity() {
private val viewModel: LoginViewModel by viewModels()
// 끝!
@HiltViewModel
class LoginViewModel @Inject constructor(
private val api: ApiService,
private val database: Database
) : ViewModel()
3. 컴파일 시간 정확성 , 런타임 성능, 확장성 및 Android Studio 지원이 이점을 누르기 위함
1. 컴파일 시간 정확성(Compile-time correctness)
잘못된 DI 코드(주입할 수 없는 타입, 순환 의존성 등)를 앱을 실행하기도 전에, 즉 컴파일 단계에서 에러로 알려준다는 의미.
- 다른 DI 방식(Koin 등)은 런타임에서 에러가 발생하는 편이 많음
- → 앱 실행 후 crash 로 알게 됨
- Hilt/Dagger는 코드를 분석해서 컴파일 단계에 반드시 체크함
- → “앱 실행 전에 문제를 잡을 수 있음”
아래처럼 제공하지 않은 의존성을 쓰면:
@Inject lateinit var repository: UserRepository
// UserRepository를 제공하는 @Provides 또는 @Inject constructor가 없다면?
- Hilt는 컴파일 오류 발생
- 다른 DI는 앱 실행 시 crash
- 빌드 시점에 에러를 잡아주니 안정성 ↑
- 런타임 crash ↓
2. 런타임 성능(Run-time performance)
Hilt는 의존성 그래프를 "컴파일 시점"에 코드로 생성해 놓기 때문에
"런타임에는 매우 빠르게 동작"한다는 의미.
무슨 소리냐?
Koin / reflection 기반 DI는
→ 실행하면서 의존성을 찾고 주입하느라 느릴 수 있음
반면 Hilt는
→ 미리 "주입할 코드를 자동 생성"해 둠 (=코드 생성 based)
장점
- Activity, ViewModel 생성 시 속도가 훨씬 빠름
- DI가 복잡해져도 앱 실행 속도를 해치지 않음
- 대규모 앱에서 성능 이슈가 안 생김
3. 확장성(Scalability)
규모가 커져도 쉽게 확장될 수 있는 구조를 제공한다는 뜻.
왜 Hilt는 확장성이 좋을까?
- Component 구조(ActivityRetainedComponent, SingletonComponent 등)가 잘 정의됨
- 의존성 생명주기를 자동 관리
- 여러 모듈(Feature Module)로 분리해도 DI 그래프를 합쳐줌
예시: ViewModel 의존성
Hilt는 다음만 붙이면 자동 주입됨:
@HiltViewModel
class MyViewModel @Inject constructor(
private val repo: UserRepository
) : ViewModel()
ViewModel에 의존성이 많아져도
모듈 여러 개로 분리해도
컴포넌트가 많아져도
Hilt가 알아서 그래프를 유지해줌.
팀이 커져도 확장 가능
모듈이 많아져도 충돌 적음
이렇게 해서 Hilt 에 대해서 정리를 해보았습니다
'Android' 카테고리의 다른 글
| [Kotlin] Stable 과 Immutable 톺아보기 (0) | 2025.12.20 |
|---|---|
| 결제 요청 시 collectLatest 와 collect 의 사용 (0) | 2025.12.17 |
| Android에서 Domain Layer와 UseCase: 언제 필요하고 언제 생략할까? (0) | 2025.12.03 |
| Android - jetpack navigation 사용시 중첩 네비게이션에 대하여 (0) | 2025.11.11 |
| android- 이미지 라이브러리 Coil (1) | 2025.11.07 |