
Domain 레이어를 만들고, Data 레이어를 만들고, 나름 뿌듯했다.
그런데 잠깐 멈추고 생각해보니 문제가 보였다.
ObserveFaceDetectionUseCase는 FaceDetectionRepository를 필요로 한다.
FaceDetectionRepositoryImpl이 그 구현체다.
그런데 누가 이 둘을 연결해주는가?
선언만 해놓고 아무것도 연결하지 않은 상태였다. 레이어 구조는 있는데 레이어끼리 이야기할 수 없는 상황. 여기서 DI(Dependency Injection)의 필요성이 실감났다.
Android에서 DI 프레임워크는 몇 가지 선택지가 있다.
Hilt를 선택한 이유는 단순하다. Android 공식 권장 DI 프레임워크고, Dagger2의 복잡한 Component 설정을 상당 부분 자동으로 처리해준다. Multi-Module 프로젝트에서도 @InstallIn 어노테이션만으로 모듈 간 의존성 범위를 깔끔하게 정의할 수 있다.
Hilt를 쓰려면 각 모듈마다 두 가지 플러그인을 적용해야 한다:
com.google.dagger.hilt.androidcom.google.devtools.ksp그리고 두 가지 의존성을 추가해야 한다:
hilt-android (implementation)hilt-compiler (ksp)모듈이 늘어날수록 이 설정이 반복된다. 이게 불편했기 때문에 Convention Plugin으로 감싸기로 했다.
libs.versions.toml에 먼저 버전과 라이브러리를 등록:
[versions]
hilt = "2.51.1"
ksp = "2.0.21-1.0.28"
[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" }
[plugins]
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
AndroidHiltConventionPlugin.kt 신규 작성:
class AndroidHiltConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
with(pluginManager) {
apply("com.google.dagger.hilt.android")
apply("com.google.devtools.ksp")
}
dependencies {
add("implementation", libs.findLibrary("hilt-android").get())
add("ksp", libs.findLibrary("hilt-compiler").get())
}
}
}
}
Plugin ID는 com.dantariun.buildlogic.hilt로 등록했다.
이제 Hilt가 필요한 모듈의 build.gradle.kts에 한 줄만 추가하면 된다:
id("com.dantariun.buildlogic.hilt")
반복 설정이 한 줄로 줄어든다. Convention Plugin의 진가가 여기서 또 한 번 발휘됐다.
Hilt는 원래 KAPT(Kotlin Annotation Processing Tool)를 사용했다. 그런데 현재는 KSP(Kotlin Symbol Processing)를 공식 지원하고 있고, 실제로 KSP를 권장하는 방향으로 가고 있다.
차이는 명확하다:
| 항목 | KAPT | KSP |
|---|---|---|
| 처리 방식 | Java AP 기반, Kotlin→Java 스텁 생성 후 처리 | Kotlin 코드 직접 처리 |
| 빌드 속도 | 느림 | 빠름 |
| 현재 권장 | 레거시 | 권장 |
Kotlin 2.0.x + KSP 조합이 현재 표준이다. 굳이 느린 KAPT를 쓸 이유가 없었다.
Hilt(Dagger)에서 인터페이스를 특정 구현체로 바인딩하는 방법은 두 가지다.
@Provides: 직접 인스턴스를 생성해서 반환한다. 외부 라이브러리처럼 생성자에 @Inject를 붙일 수 없을 때 사용한다.
@Provides
fun provideOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder().build()
}
@Binds: 인터페이스 → 구현체 바인딩만 선언한다. 추상 메서드로 작성하며, Dagger가 코드를 생성하지 않고 직접 위임하므로 더 효율적이다.
FaceDetectionRepository와 FaceDetectionRepositoryImpl은 @Binds가 정확히 맞는 케이스다. 구현체는 이미 @Inject constructor()가 있으므로 Dagger가 직접 생성할 수 있다. 추가로 인스턴스를 만들어줄 필요가 없다.
DataModule.kt:
@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {
@Binds
@Singleton
abstract fun bindFaceDetectionRepository(
impl: FaceDetectionRepositoryImpl
): FaceDetectionRepository
}
@InstallIn(SingletonComponent::class)으로 앱 생명주기와 동일한 Singleton 스코프를 지정했다. FaceDetectionRepositoryImpl은 내부에 StateFlow를 가지고 있다. 상태를 유지해야 하므로 Singleton이 맞다. 매번 새 인스턴스가 생성되면 상태가 초기화되어버린다.
각 모듈의 Hilt 적용 여부:
id("com.dantariun.buildlogic.hilt") 적용id("com.dantariun.buildlogic.hilt") 적용domain에는 @Inject만 추가했다.
class ObserveFaceDetectionUseCase @Inject constructor(
private val repository: FaceDetectionRepository
) {
operator fun invoke(): Flow<List<DetectedFace>> = repository.detectedFaces
}
이유는 명확하다. @Inject는 javax.inject 표준 스펙이다. Android 프레임워크에 의존하지 않는다. Hilt 플러그인은 Android 전용 Dagger Component 코드를 생성하는 도구다. domain은 순수 Kotlin 계층이어야 한다는 원칙에서 Android 코드 생성 도구를 굳이 끌어들일 이유가 없다.
domain은 가능한 한 Android로부터 독립적이어야 한다. @Inject 하나로 DI 프레임워크에 "나는 주입받을 수 있다"는 신호를 보내는 것으로 충분하다.
ObserveFaceDetectionUseCase (@Inject constructor)
└── FaceDetectionRepository (interface)
└── FaceDetectionRepositoryImpl (@Inject constructor)
← DataModule에서 @Binds로 바인딩
Hilt가 앱을 시작할 때 이 그래프를 컴파일 타임에 분석하고, 런타임에 올바른 의존성을 주입한다. ObserveFaceDetectionUseCase를 사용하는 쪽은 FaceDetectionRepository가 어떤 구현체인지 알 필요가 없다.
앱 진입점에는 @HiltAndroidApp을 붙인 Application 클래스가 필요하다:
@HiltAndroidApp
class MorphViewApplication : Application()
AndroidManifest.xml에도 android:name=".MorphViewApplication" 등록을 잊지 않았다.
설정 후 ./gradlew assembleDebug를 실행했다.
KSP가 Hilt Component를 생성하고, Dagger가 의존성 그래프를 검증한다. 빌드가 통과되면 의존성 연결에 오류가 없다는 뜻이다.
처음 도입 시 @Module이 붙은 클래스에 abstract를 빠뜨려서 에러가 났었다. @Binds는 반드시 추상 클래스 안에 추상 메서드로 선언해야 한다는 점을 놓쳤다. 이 부분은 에러 메시지가 명확해서 금방 잡을 수 있었다.
레이어 구조가 완성됐고, 의존성도 연결됐다.
이제 실제로 화면에 뭔가를 그려야 한다. 다음 편은 Presentation 레이어 — CameraX 프리뷰를 화면에 띄우고, 얼굴 감지 결과를 Canvas로 오버레이하는 작업이다.
ViewModel이 UseCase를 주입받아 StateFlow를 collect하고, Compose 또는 커스텀 View로 감지 결과를 실시간으로 렌더링하는 흐름을 만들 예정이다.
드디어 뭔가 눈에 보이기 시작할 것 같다.