Android Hilt 의존성 주입 완벽 가이드 — MVVM, Repository, Retrofit, SQLite와 테스트 환경 구성
Android 앱의 규모가 커지면 Activity, Fragment, ViewModel, Repository 사이의 의존성을 관리하는 일이 점점 복잡해집니다.
예를 들어 조류 도감 앱에서 새 이름을 검색한다고 가정하겠습니다.
검색 화면에서는 ViewModel이 필요하고, ViewModel은 Repository를 사용합니다. Repository는 SQLite 데이터베이스와 Retrofit API를 사용합니다.
처음에는 필요한 객체를 직접 생성해도 문제가 없어 보입니다.
하지만 앱에 화면과 기능이 늘어나면 동일한 객체 생성 코드가 여러 곳에 반복됩니다.
특히 다음과 같은 문제가 발생하기 쉽습니다.
Activity와 Fragment에서 Repository를 직접 생성
ViewModel마다 별도의 Factory 작성
SQLiteOpenHelper 생성 코드 중복
Retrofit과 OkHttpClient를 여러 곳에서 생성
Application Context와 Activity Context 혼용
테스트에서 실제 데이터베이스를 분리하기 어려움
Product Flavor별 구현체 교체가 복잡함
이러한 문제를 해결하기 위한 방법 중 하나가 **의존성 주입(Dependency Injection, DI)**입니다.
Android에서는 Google이 권장하는 의존성 주입 라이브러리인 Hilt를 사용할 수 있습니다.
이번 글에서는 Hilt의 기본 개념부터 기존 MVVM 프로젝트에 적용하는 방법까지 살펴보겠습니다.
개발 환경: Kotlin, XML ViewBinding, MVVM, Retrofit2, OkHttp, SQLiteOpenHelper, Kotlin Coroutines, AndroidX ViewModel, minSdk 24 이상. Hilt·Kotlin·AGP 버전은 프로젝트별 호환성을 확인해야 하며, 아래 코드는 구조를 설명하기 위한 예제입니다.
1. 의존성 주입이란?
의존성 주입은 클래스가 필요한 객체를 직접 생성하지 않고 외부에서 전달받도록 만드는 설계 방식입니다.
예를 들어 다음 코드를 살펴보겠습니다.
class BirdRepository {
private val api =
Retrofit.Builder()
.baseUrl("https://api.example.com/")
.build()
.create(BirdApiService::class.java)
}이 구조에서는 Repository가 Retrofit 객체를 직접 생성합니다.
따라서 테스트 환경에서 다른 API 구현체를 사용하거나 네트워크 설정을 변경하려면 Repository 코드를 수정해야 할 수 있습니다.
의존성 주입을 적용하면 다음과 같이 변경할 수 있습니다.
class BirdRepository(
private val api: BirdApiService
)Repository는 API 객체를 어떻게 생성하는지 알 필요가 없습니다.
필요한 객체를 외부에서 전달받기만 하면 됩니다.
이것이 의존성 주입의 핵심입니다.
2. Hilt를 사용하지 않는 경우
기존 MVVM 프로젝트에서는 다음과 같은 방식으로 의존성을 전달할 수 있습니다.
val databaseHelper =
BirdDatabaseHelper(applicationContext)
val repository =
BirdRepositoryImpl(databaseHelper)
val factory =
BirdListViewModelFactory(repository)이 방식 자체가 잘못된 것은 아닙니다.
작은 프로젝트에서는 수동 의존성 주입만으로도 충분할 수 있습니다.
하지만 앱의 화면과 객체가 많아지면 생성 코드를 유지보수하는 부담이 커집니다.
Hilt는 이러한 객체 생성과 전달 과정을 자동화하는 데 도움을 줍니다.
3. Hilt를 적용한 구조
MainActivity
│
▼
BirdListFragment
│
▼
BirdListViewModel
│
▼
BirdRepository
│
├── BirdDatabaseHelper
│
└── BirdApiService
│
▼
Retrofit
│
▼
OkHttpClient개발자는 각 클래스가 어떤 의존성을 필요로 하는지 선언합니다.
Hilt는 등록된 생성 방법과 객체 범위를 바탕으로 필요한 객체를 제공합니다.
Hilt 설치
4. Gradle 플러그인 추가
Hilt를 사용하려면 Gradle 플러그인과 라이브러리를 추가해야 합니다.
프로젝트의 build.gradle.kts에서 플러그인을 선언합니다.
plugins {
id("com.google.dagger.hilt.android") version "<hilt-version>" apply false
}앱 모듈에서는 다음과 같이 적용합니다.
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.google.dagger.hilt.android")
id("com.google.devtools.ksp")
}Hilt는 어노테이션 처리가 필요합니다.
기존 프로젝트에서는 KAPT를 사용할 수도 있지만, 여기서는 KSP를 사용하는 구성을 예로 들겠습니다.
KSP 플러그인 버전은 Kotlin 버전과 호환되어야 합니다. 특히 Kotlin 또는 AGP를 업그레이드할 때는 Hilt, Dagger, KSP의 지원 조합을 함께 확인해야 합니다.
5. Hilt 의존성 추가
dependencies {
implementation(
"com.google.dagger:hilt-android:<hilt-version>"
)
ksp(
"com.google.dagger:hilt-android-compiler:<hilt-version>"
)
}<hilt-version>에는 공식 문서에서 확인한 안정 버전을 사용합니다.
KSP를 사용할 때는 다음 사항을 확인해야 합니다.
Kotlin 버전과 KSP 버전의 호환성
Hilt의 KSP 지원 여부
AGP 버전과의 호환성
기존 KAPT 프로세서와의 혼용 여부
프로젝트에 다른 어노테이션 프로세서가 있다면 KSP 전환 범위를 별도로 검토해야 합니다.
Application 설정
6. @HiltAndroidApp 추가
Hilt를 사용하는 앱에서는 Application 클래스에 @HiltAndroidApp을 선언합니다.
@HiltAndroidApp
class BirdApplication : Application()그리고 AndroidManifest.xml에 등록합니다.
<application
android:name=".BirdApplication"
android:allowBackup="true"
android:theme="@style/Theme.BirdApp">
<!-- Activity 등 기존 구성 -->
</application>이미 Application 클래스를 사용하고 있다면 새 클래스를 만들기보다 기존 클래스에 @HiltAndroidApp을 추가하면 됩니다.
다만 Hilt를 도입하면 Application 및 Android 컴포넌트의 생성 과정에 코드 생성이 추가되므로 빌드 설정을 정확하게 구성해야 합니다.
Activity와 Fragment에 Hilt 적용
7. @AndroidEntryPoint
Hilt가 Activity 또는 Fragment에 의존성을 주입하도록 하려면 @AndroidEntryPoint를 사용합니다.
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
// 기존 코드
}Fragment에도 적용할 수 있습니다.
@AndroidEntryPoint
class BirdListFragment : Fragment() {
// 기존 코드
}Hilt를 사용하는 Fragment는 Hilt가 지원하는 Activity 안에 배치해야 합니다.
따라서 Fragment에 @AndroidEntryPoint를 적용했다면 해당 Fragment를 호스팅하는 Activity의 설정도 확인해야 합니다.
ViewModel에 Hilt 적용
8. @HiltViewModel
기존에는 ViewModel Factory를 직접 작성해야 했습니다.
Hilt를 사용하면 다음과 같이 ViewModel을 선언할 수 있습니다.
@HiltViewModel
class BirdListViewModel @Inject constructor(
private val repository: BirdRepository
) : ViewModel() {
// UI 상태 관리
}여기서 중요한 어노테이션은 두 가지입니다.
| 어노테이션 | 역할 |
|---|---|
@HiltViewModel | Hilt가 관리하는 ViewModel 선언 |
@Inject | 생성자 주입 지정 |
이렇게 하면 Hilt가 Repository를 생성해 ViewModel에 전달할 수 있습니다.
단, BirdRepository도 Hilt가 생성할 수 있도록 등록되어 있어야 합니다.
9. Fragment에서 ViewModel 사용
@AndroidEntryPoint
class BirdListFragment : Fragment() {
private val viewModel:
BirdListViewModel by viewModels()
}기존에 직접 작성했던 BirdListViewModelFactory를 제거할 수 있습니다.
이 방식은 AndroidX의 viewModels() 확장 함수와 Hilt를 함께 사용하는 구조입니다.
Repository 주입
10. 생성자 주입 사용하기
Repository가 구체적인 클래스라면 생성자에 @Inject를 사용할 수 있습니다.
class BirdRepository @Inject constructor(
private val localDataSource: BirdLocalDataSource,
private val remoteDataSource: BirdRemoteDataSource
) {
suspend fun searchBirds(
keyword: String
): List<Bird> {
return localDataSource.searchBirds(keyword)
}
}Hilt는 BirdLocalDataSource와 BirdRemoteDataSource를 생성할 수 있다면 Repository도 생성할 수 있습니다.
하지만 인터페이스를 사용하는 경우에는 구현체를 연결하는 설정이 필요합니다.
11. 인터페이스와 구현체 분리
다음과 같이 Repository 인터페이스를 정의합니다.
interface BirdRepository {
suspend fun searchBirds(
keyword: String
): List<Bird>
suspend fun setFavorite(
birdId: String,
favorite: Boolean
)
}구현체는 다음과 같이 작성합니다.
class BirdRepositoryImpl @Inject constructor(
private val localDataSource: BirdLocalDataSource
) : BirdRepository {
override suspend fun searchBirds(
keyword: String
): List<Bird> {
return localDataSource.searchBirds(keyword)
}
override suspend fun setFavorite(
birdId: String,
favorite: Boolean
) {
localDataSource.setFavorite(
birdId,
favorite
)
}
}이제 Hilt에 인터페이스와 구현체의 관계를 알려줘야 합니다.
@Binds와 @Provides
12. @Binds 사용하기
인터페이스에 구현체를 연결할 때는 @Binds를 사용할 수 있습니다.
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
abstract fun bindBirdRepository(
implementation: BirdRepositoryImpl
): BirdRepository
}이렇게 하면 ViewModel에서 BirdRepository 인터페이스를 요청할 때 BirdRepositoryImpl을 사용할 수 있습니다.
@Binds는 이미 생성 방법이 정의된 구현체를 인터페이스에 연결하는 용도로 적합합니다.
13. @Provides 사용하기
반면 직접 생성자를 수정할 수 없는 외부 라이브러리 객체에는 @Provides를 사용할 수 있습니다.
대표적인 예가 다음과 같습니다.
Retrofit
OkHttpClient
SQLiteOpenHelper의 생성 설정
외부 SDK 객체
특정 설정이 필요한 객체
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
fun provideBaseUrl(): String {
return "https://api.example.com/"
}
}다만 위 예제처럼 String을 직접 제공하면 다른 문자열 의존성과 충돌하거나 의미가 모호해질 수 있습니다.
실무에서는 @Named 또는 커스텀 Qualifier를 사용해 구분하는 것이 좋습니다.
Retrofit2 주입
14. OkHttpClient 제공
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.connectTimeout(
15,
TimeUnit.SECONDS
)
.readTimeout(
30,
TimeUnit.SECONDS
)
.build()
}
}@Singleton을 사용하면 해당 컴포넌트 범위에서 동일한 인스턴스를 재사용하도록 구성할 수 있습니다.
OkHttpClient는 연결 풀과 스레드 자원을 관리하므로 요청마다 새로 생성하기보다 적절하게 재사용하는 것이 좋습니다.
15. Retrofit 제공
@Provides
@Singleton
fun provideRetrofit(
okHttpClient: OkHttpClient
): Retrofit {
return Retrofit.Builder()
.baseUrl("https://api.example.com/")
.client(okHttpClient)
.addConverterFactory(
GsonConverterFactory.create()
)
.build()
}위 코드는 앞서 작성한 NetworkModule 안에 추가합니다.
Hilt는 OkHttpClient가 필요하다는 사실을 확인하고 등록된 생성 함수를 이용합니다.
16. API Service 제공
@Provides
@Singleton
fun provideBirdApiService(
retrofit: Retrofit
): BirdApiService {
return retrofit.create(
BirdApiService::class.java
)
}이제 Repository에서는 Retrofit 생성 코드를 작성할 필요가 없습니다.
class BirdRemoteDataSource @Inject constructor(
private val api: BirdApiService
)이 구조에서는 네트워크 설정을 한곳에서 관리할 수 있습니다.
SQLiteOpenHelper 주입
17. 기존 SQLite 구조 유지하기
Hilt를 도입하기 위해 Room으로 전환할 필요는 없습니다.
기존 SQLiteOpenHelper도 Hilt에서 사용할 수 있습니다.
예를 들어 다음과 같은 데이터베이스 헬퍼가 있다고 가정하겠습니다.
class BirdDatabaseHelper(
context: Context
) : SQLiteOpenHelper(
context,
"birds.db",
null,
1
) {
override fun onCreate(
db: SQLiteDatabase
) {
// 테이블 생성
}
override fun onUpgrade(
db: SQLiteDatabase,
oldVersion: Int,
newVersion: Int
) {
// DB 마이그레이션
}
}이 클래스는 생성할 때 Context가 필요합니다.
Hilt에서는 Application Context를 주입해 사용할 수 있습니다.
18. @ApplicationContext 사용하기
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideBirdDatabaseHelper(
@ApplicationContext context: Context
): BirdDatabaseHelper {
return BirdDatabaseHelper(context)
}
}@ApplicationContext는 Application 범위의 Context를 요청한다는 의미입니다.
데이터베이스처럼 앱 전반에서 사용하는 객체에는 Activity Context보다 Application Context가 적합합니다.
Activity Context를 Singleton 객체에 보관하면 메모리 누수 문제가 발생할 수 있으므로 주의해야 합니다.
19. SQLite DataSource 작성
class BirdLocalDataSource @Inject constructor(
private val dbHelper: BirdDatabaseHelper,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher
) {
suspend fun searchBirds(
keyword: String
): List<Bird> =
withContext(ioDispatcher) {
val result = mutableListOf<Bird>()
dbHelper.readableDatabase.rawQuery(
"""
SELECT
KTSN,
KoreanName,
ScientificName,
FamilyKor
FROM korean_birds
WHERE KoreanName LIKE ?
LIMIT 100
""".trimIndent(),
arrayOf("%$keyword%")
).use { cursor ->
val idIndex =
cursor.getColumnIndexOrThrow("KTSN")
val nameIndex =
cursor.getColumnIndexOrThrow("KoreanName")
val scientificIndex =
cursor.getColumnIndexOrThrow("ScientificName")
val familyIndex =
cursor.getColumnIndexOrThrow("FamilyKor")
while (cursor.moveToNext()) {
result.add(
Bird(
id = cursor.getString(idIndex),
koreanName = cursor.getString(nameIndex),
scientificName = cursor.getString(scientificIndex),
familyName = cursor.getString(familyIndex)
)
)
}
}
result
}
suspend fun setFavorite(
birdId: String,
favorite: Boolean
) = withContext(ioDispatcher) {
// bird_memos 테이블에
// 좋아요 상태 저장
Unit
}
}이 예제에서는 SQLite 조회를 Repository가 아닌 LocalDataSource에서 처리합니다.
이렇게 하면 Repository는 데이터 소스를 조합하고 정책을 결정하는 역할에 집중할 수 있습니다.
다만 실제 프로젝트의 SQLite 컬럼 이름과 테이블 구조에 맞춰 SQL을 조정해야 합니다.
Coroutine Dispatcher 주입
20. Dispatchers.IO를 직접 사용하면 안 될까?
다음과 같은 코드는 일반적인 앱에서 사용할 수 있습니다.
withContext(Dispatchers.IO) {
// 데이터베이스 작업
}하지만 테스트 환경에서는 Dispatcher를 교체하기 어려울 수 있습니다.
따라서 Dispatcher도 의존성으로 주입하면 테스트하기 편리합니다.
21. Qualifier 정의
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher이 어노테이션은 특정 CoroutineDispatcher를 구분하는 역할을 합니다.
22. Dispatcher 제공
@Module
@InstallIn(SingletonComponent::class)
object DispatcherModule {
@Provides
@IoDispatcher
fun provideIoDispatcher():
CoroutineDispatcher {
return Dispatchers.IO
}
}이제 DataSource에서 다음과 같이 사용할 수 있습니다.
class BirdLocalDataSource @Inject constructor(
private val dbHelper: BirdDatabaseHelper,
@IoDispatcher private val dispatcher: CoroutineDispatcher
)테스트에서는 다른 Dispatcher를 주입해 비동기 작업을 제어할 수 있습니다.
Hilt의 Scope 이해하기
23. Scope란?
Scope는 객체를 어느 범위에서 재사용할지 결정하는 개념입니다.
대표적인 Scope는 다음과 같습니다.
| Scope | 주요 범위 |
|---|---|
@Singleton | SingletonComponent |
@ActivityRetainedScoped | 구성 변경을 넘어 유지되는 Activity 관련 범위 |
@ViewModelScoped | 특정 ViewModel |
@ActivityScoped | Activity 인스턴스 |
@FragmentScoped | Fragment 인스턴스 |
모든 객체에 @Singleton을 붙이는 것은 좋지 않습니다.
객체가 실제로 공유되어야 하는 범위를 기준으로 선택해야 합니다.
24. SQLiteOpenHelper는 Singleton이 적합할까?
하나의 데이터베이스 파일을 앱 전체에서 사용하는 경우에는 SQLiteOpenHelper를 Singleton으로 관리하는 방식을 고려할 수 있습니다.
@Provides
@Singleton
fun provideBirdDatabaseHelper(
@ApplicationContext context: Context
): BirdDatabaseHelper {
return BirdDatabaseHelper(context)
}이렇게 하면 여러 화면에서 불필요하게 데이터베이스 헬퍼를 반복 생성하는 일을 줄일 수 있습니다.
하지만 Singleton으로 관리한다고 해서 모든 데이터베이스 동시성 문제가 자동으로 해결되는 것은 아닙니다.
SQLite 트랜잭션, 동시 접근, 데이터베이스 종료 시점은 별도로 고려해야 합니다.
ViewModelScoped 활용
25. ViewModel별로 다른 객체가 필요한 경우
특정 ViewModel 안에서만 공유해야 하는 객체라면 @ViewModelScoped를 사용할 수 있습니다.
@ViewModelScoped
class BirdSearchSession @Inject constructor() {
var lastKeyword: String = ""
}이 객체는 해당 ViewModel 컴포넌트 범위에서 재사용됩니다.
다만 ViewModel이 제거되면 관련 범위도 종료됩니다.
영구 저장해야 하는 사용자 데이터는 SQLite나 DataStore에 보관해야 합니다.
Product Flavor와 Hilt
26. Flavor별 구현체 분리
Android 앱에서 Product Flavor를 사용하는 경우 Hilt와 함께 활용할 수 있습니다.
예를 들어 다음과 같은 Flavor가 있다고 가정하겠습니다.
flavorDimensions += "animal"
productFlavors {
create("bird") {
dimension = "animal"
}
create("fish") {
dimension = "animal"
}
}조류 앱과 어류 앱에서 서로 다른 데이터 소스를 사용할 수 있습니다.
27. Flavor별 Module 구성
다음과 같이 파일을 배치할 수 있습니다.
src/
├── main/
│ └── java/
│ └── DataRepository.kt
│
├── bird/
│ └── java/
│ └── RepositoryModule.kt
│
└── fish/
└── java/
└── RepositoryModule.kt각 Flavor에서 동일한 인터페이스에 서로 다른 구현체를 연결할 수 있습니다.
예를 들어 bird Flavor에서는 다음과 같이 작성합니다.
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
abstract fun bindRepository(
implementation: BirdRepositoryImpl
): DataRepository
}fish Flavor에서는 다른 구현체를 연결합니다.
단, 공통 소스셋에 동일한 바인딩을 중복 등록하지 않도록 주의해야 합니다.
Hilt는 빌드 변형에 포함된 Module을 기준으로 의존성 그래프를 구성합니다.
Hilt 테스트
28. 테스트에서 실제 Repository를 사용하지 않기
ViewModel 테스트에서는 실제 SQLite 파일이나 서버 API가 필요하지 않은 경우가 많습니다.
이때 Fake Repository를 사용할 수 있습니다.
class FakeBirdRepository : BirdRepository {
override suspend fun searchBirds(
keyword: String
): List<Bird> {
return listOf(
Bird(
id = "1",
koreanName = "참새",
scientificName = "Passer montanus",
familyName = "참새과"
)
)
}
override suspend fun setFavorite(
birdId: String,
favorite: Boolean
) {
// 테스트용 구현
}
}순수 JVM ViewModel 단위 테스트에서는 반드시 Hilt를 실행할 필요는 없습니다.
생성자에 Fake Repository를 직접 전달하면 됩니다.
Hilt 테스트는 실제 Android 컴포넌트에 의존성이 올바르게 주입되는지 검증할 때 유용합니다.
29. Hilt Android 테스트 의존성
dependencies {
androidTestImplementation(
"com.google.dagger:hilt-android-testing:<hilt-version>"
)
kspAndroidTest(
"com.google.dagger:hilt-android-compiler:<hilt-version>"
)
}Android Instrumentation 테스트에서 Hilt를 사용하려면 테스트 Application 설정도 필요합니다.
Hilt에서는 HiltTestApplication을 제공하며, 테스트 러너에서 이를 사용하도록 구성할 수 있습니다.
30. 테스트 러너 구성
class HiltTestRunner : AndroidJUnitRunner() {
override fun newApplication(
cl: ClassLoader,
className: String,
context: Context
): Application {
return super.newApplication(
cl,
HiltTestApplication::class.java.name,
context
)
}
}앱 모듈의 테스트 설정에 러너를 등록합니다.
android {
defaultConfig {
testInstrumentationRunner =
"com.example.birdapp.HiltTestRunner"
}
}이렇게 하면 Hilt 기반 Android 테스트 환경을 구성할 수 있습니다.
31. @HiltAndroidTest 사용하기
@HiltAndroidTest
class BirdRepositoryInjectionTest {
@get:Rule
val hiltRule =
HiltAndroidRule(this)
@Inject
lateinit var repository: BirdRepository
@Before
fun setup() {
hiltRule.inject()
}
@Test
fun repositoryIsInjected() {
assertNotNull(repository)
}
}이 테스트는 Repository가 Hilt를 통해 주입되는지 확인하는 기본 예제입니다.
실제 테스트에서는 데이터 조회 결과와 오류 처리까지 검증하는 것이 좋습니다.
테스트용 구현체 교체
32. @TestInstallIn 활용
Hilt에서는 테스트 환경에서 기존 Module을 교체할 수 있습니다.
예를 들어 프로덕션 Repository Module 대신 테스트용 Module을 사용할 수 있습니다.
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [RepositoryModule::class]
)
abstract class FakeRepositoryModule {
@Binds
abstract fun bindRepository(
implementation: FakeBirdRepository
): BirdRepository
}이 예제를 사용하려면 FakeBirdRepository를 Hilt가 생성할 수 있어야 합니다.
예를 들어 생성자에 @Inject를 추가하거나 @Provides를 이용해 인스턴스를 제공할 수 있습니다.
또한 테스트용 Module과 기존 Module의 바인딩이 충돌하지 않도록 확인해야 합니다.
Hilt 도입 시 자주 발생하는 오류
33. MissingBinding 오류
Hilt를 처음 사용할 때 자주 발생하는 오류입니다.
[Dagger/MissingBinding]
BirdRepository cannot be provided
without an @Provides-annotated method.이 오류는 Hilt가 필요한 객체를 생성하는 방법을 찾지 못했다는 의미입니다.
다음 사항을 확인합니다.
생성자에
@Inject가 있는지인터페이스에
@Binds가 등록되어 있는지외부 라이브러리 객체에
@Provides가 있는지Module의
@InstallIn범위가 올바른지필요한 의존성이 모두 등록되어 있는지
34. DuplicateBindings 오류
동일한 타입을 여러 Module에서 제공하면 충돌이 발생할 수 있습니다.
예를 들어 BirdRepository에 대한 바인딩을 두 곳에서 등록한 경우입니다.
[Dagger/DuplicateBindings]
BirdRepository is bound multiple times이때는 중복 Module을 제거하거나 Qualifier를 사용해 구분해야 합니다.
특히 Product Flavor를 사용하는 프로젝트에서는 공통 Module과 Flavor별 Module이 중복되지 않도록 확인해야 합니다.
35. @AndroidEntryPoint 누락
Fragment에서 Hilt ViewModel을 사용하려는데 Activity 또는 Fragment의 Hilt 설정이 빠져 있으면 문제가 발생할 수 있습니다.
@AndroidEntryPoint
class BirdListFragment : Fragment()또한 해당 Fragment를 호스팅하는 Activity도 Hilt를 지원하도록 구성해야 합니다.
36. Scope 충돌
Scope가 잘못 설정되면 컴파일 오류가 발생할 수 있습니다.
예를 들어 Singleton 범위의 객체가 더 짧은 생명주기의 객체에 직접 의존하는 구조는 문제가 될 수 있습니다.
따라서 다음 원칙을 기억하는 것이 좋습니다.
긴 생명주기를 가진 객체가 짧은 생명주기의 UI 객체를 직접 보관하지 않도록 설계합니다.
특히 Singleton Repository에 Activity나 Fragment를 전달하는 방식은 피해야 합니다.
Hilt와 앱 시작 속도
37. Hilt를 사용하면 앱이 느려질까?
Hilt는 컴파일 시점에 의존성 그래프를 생성하는 방식입니다.
하지만 Hilt를 사용한다고 해서 모든 객체 생성 비용이 사라지는 것은 아닙니다.
예를 들어 Application 시작 시점에 무거운 객체를 불필요하게 초기화하면 앱 시작 속도에 영향을 줄 수 있습니다.
특히 다음 작업을 주의해야 합니다.
대용량 SQLite 파일 복사
네트워크 요청
이미지 캐시 초기화
복잡한 데이터 파싱
불필요한 Singleton 객체 초기화
Hilt는 객체 생성 구조를 관리하는 도구이지, 무거운 초기화 작업을 자동으로 최적화하는 도구는 아닙니다.
기존 프로젝트에 Hilt 적용하기
38. 점진적 도입 전략
이미 출시된 Android 앱이라면 모든 클래스를 한 번에 변경할 필요는 없습니다.
다음 순서로 도입하는 것이 좋습니다.
1단계: Hilt 설정
Gradle 플러그인과 의존성을 추가하고 Application에 @HiltAndroidApp을 적용합니다.
2단계: Activity와 Fragment 설정
Hilt를 사용할 화면에 @AndroidEntryPoint를 적용합니다.
3단계: Repository 주입
기존 Repository의 생성자를 @Inject로 변경하거나 Module을 작성합니다.
4단계: Retrofit과 OkHttpClient Module 구성
네트워크 객체 생성 코드를 공통 Module로 이동합니다.
5단계: SQLiteOpenHelper 주입
Application Context를 이용해 데이터베이스 헬퍼를 제공합니다.
6단계: ViewModel Factory 제거
ViewModel에 @HiltViewModel을 적용하고 직접 작성한 Factory를 단계적으로 제거합니다.
7단계: 테스트 코드 추가
Fake Repository와 Hilt 테스트 환경을 구성합니다.
이렇게 하면 기존 기능을 유지하면서 의존성 관리 구조를 개선할 수 있습니다.
39. 추천 프로젝트 구조
com.example.birdapp
│
├── BirdApplication.kt
│
├── di/
│ ├── NetworkModule.kt
│ ├── DatabaseModule.kt
│ ├── RepositoryModule.kt
│ └── DispatcherModule.kt
│
├── data/
│ ├── local/
│ │ ├── BirdDatabaseHelper.kt
│ │ └── BirdLocalDataSource.kt
│ │
│ ├── remote/
│ │ ├── BirdApiService.kt
│ │ └── BirdRemoteDataSource.kt
│ │
│ └── repository/
│ └── BirdRepositoryImpl.kt
│
├── domain/
│ ├── model/
│ │ └── Bird.kt
│ │
│ └── repository/
│ └── BirdRepository.kt
│
└── ui/
└── birdlist/
├── BirdListFragment.kt
├── BirdListViewModel.kt
├── BirdListUiState.kt
└── BirdListAdapter.kt이 구조에서는 의존성 생성 설정을 di 패키지에 모아 관리할 수 있습니다.
단, 모든 Module을 반드시 하나의 패키지에 배치해야 하는 것은 아닙니다.
프로젝트 규모에 따라 기능별로 분리해도 됩니다.
40. Hilt 적용 체크리스트
Hilt Gradle 플러그인 추가
Kotlin·KSP·Hilt 버전 호환성 확인
@HiltAndroidApp적용Manifest의 Application 클래스 확인
Activity에
@AndroidEntryPoint적용Fragment에
@AndroidEntryPoint적용ViewModel에
@HiltViewModel적용생성자에
@Inject적용Repository 인터페이스와 구현체 연결
@Binds와@Provides역할 구분Retrofit Module 구성
OkHttpClient 재사용
SQLiteOpenHelper 주입
Application Context 사용
CoroutineDispatcher 주입 검토
Scope를 필요한 범위로 제한
Product Flavor별 바인딩 충돌 확인
ViewModel Factory 제거 여부 확인
Fake Repository 테스트
Hilt Android 테스트 환경 구성
MissingBinding 오류 확인
DuplicateBindings 오류 확인
앱 시작 속도와 초기화 비용 점검
41. 마무리
Hilt는 Android 앱의 의존성 관리 코드를 줄이고, MVVM 아키텍처를 유지보수하기 쉽게 만드는 도구입니다.
특히 ViewModel, Repository, Retrofit, SQLite처럼 여러 계층에서 객체를 공유하는 앱에서 유용합니다.
하지만 Hilt를 사용한다고 해서 앱 아키텍처가 자동으로 좋아지는 것은 아닙니다.
각 클래스의 책임을 올바르게 분리하고, 객체의 생명주기를 이해하는 것이 먼저입니다.
이번 글의 핵심을 정리하면 다음과 같습니다.
의존성 주입은 객체 생성 책임을 외부로 분리하는 설계 방식입니다.
Hilt는 Android에서 의존성 주입을 쉽게 구현하도록 돕습니다.
@HiltAndroidApp으로 Application을 설정합니다.@AndroidEntryPoint로 Activity와 Fragment를 Hilt에 연결합니다.@HiltViewModel과@Inject로 ViewModel 의존성을 주입합니다.@Binds는 인터페이스와 구현체를 연결할 때 사용합니다.@Provides는 Retrofit과 같은 외부 객체를 생성할 때 사용합니다.SQLiteOpenHelper도 Hilt로 관리할 수 있습니다.
Scope는 실제 객체의 사용 범위에 맞게 선택해야 합니다.
테스트에서는 Fake Repository로 구현체를 교체할 수 있습니다.
기존 Kotlin + XML 기반 앱에서도 Hilt를 충분히 활용할 수 있습니다.
Jetpack Compose나 Room으로 전환할 필요는 없습니다.
이미 MVVM 구조를 사용하고 있다면 Repository와 ViewModel의 의존성부터 Hilt로 전환해 보는 것이 좋습니다.
Hilt의 핵심은 어노테이션을 많이 사용하는 것이 아니라, 객체 생성과 의존성 관리를 일관된 방식으로 구성하는 것입니다.
참고 자료
Android Developers — Dependency Injection with Hilt
https://developer.android.com/training/dependency-injection/hilt-androidAndroid Developers — Hilt and Jetpack Integrations
https://developer.android.com/training/dependency-injection/hilt-jetpackAndroid Developers — Dependency Injection Overview
https://developer.android.com/training/dependency-injectionDagger Hilt Documentation
https://dagger.dev/hilt/Dagger Hilt Testing
https://dagger.dev/hilt/testing.htmlAndroid Developers — Guide to App Architecture
https://developer.android.com/topic/architectureAndroid Developers — ViewModel
https://developer.android.com/topic/libraries/architecture/viewmodelAndroid Developers — Kotlin Coroutines
https://developer.android.com/kotlin/coroutines
태그: Android, Kotlin, Hilt, Dagger, Dependency Injection, MVVM, Repository, ViewModel, Retrofit2, OkHttp, SQLiteOpenHelper, KSP, Android Architecture, Unit Test, Product Flavor, Android 앱 개발