[MorphView 제작기 #9] Hilt DI 도입 — 레이어를 연결하는 의존성 주입

김보현·2026년 4월 23일

android

목록 보기
12/12
post-thumbnail

레이어는 다 만들었는데… 아무것도 연결이 안 됐다

Domain 레이어를 만들고, Data 레이어를 만들고, 나름 뿌듯했다.
그런데 잠깐 멈추고 생각해보니 문제가 보였다.

ObserveFaceDetectionUseCaseFaceDetectionRepository를 필요로 한다.
FaceDetectionRepositoryImpl이 그 구현체다.
그런데 누가 이 둘을 연결해주는가?

선언만 해놓고 아무것도 연결하지 않은 상태였다. 레이어 구조는 있는데 레이어끼리 이야기할 수 없는 상황. 여기서 DI(Dependency Injection)의 필요성이 실감났다.


왜 Hilt인가

Android에서 DI 프레임워크는 몇 가지 선택지가 있다.

  • Koin: 가볍고 DSL이 직관적이지만, 런타임에 의존성을 해석하므로 컴파일 타임 검증이 없다.
  • Dagger2: 컴파일 타임 검증, 빠른 성능. 그러나 보일러플레이트가 심각하다.
  • Hilt: Dagger2 기반이지만 Android에 특화된 추상화를 제공. Google 공식 권장.

Hilt를 선택한 이유는 단순하다. Android 공식 권장 DI 프레임워크고, Dagger2의 복잡한 Component 설정을 상당 부분 자동으로 처리해준다. Multi-Module 프로젝트에서도 @InstallIn 어노테이션만으로 모듈 간 의존성 범위를 깔끔하게 정의할 수 있다.


Convention Plugin에 Hilt 통합하기

Hilt를 쓰려면 각 모듈마다 두 가지 플러그인을 적용해야 한다:

  • com.google.dagger.hilt.android
  • com.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의 진가가 여기서 또 한 번 발휘됐다.


KSP vs KAPT — 왜 KSP를 선택했는가

Hilt는 원래 KAPT(Kotlin Annotation Processing Tool)를 사용했다. 그런데 현재는 KSP(Kotlin Symbol Processing)를 공식 지원하고 있고, 실제로 KSP를 권장하는 방향으로 가고 있다.

차이는 명확하다:

항목KAPTKSP
처리 방식Java AP 기반, Kotlin→Java 스텁 생성 후 처리Kotlin 코드 직접 처리
빌드 속도느림빠름
현재 권장레거시권장

Kotlin 2.0.x + KSP 조합이 현재 표준이다. 굳이 느린 KAPT를 쓸 이유가 없었다.


DataModule 설계 — @Binds vs @Provides

Hilt(Dagger)에서 인터페이스를 특정 구현체로 바인딩하는 방법은 두 가지다.

@Provides: 직접 인스턴스를 생성해서 반환한다. 외부 라이브러리처럼 생성자에 @Inject를 붙일 수 없을 때 사용한다.

@Provides
fun provideOkHttpClient(): OkHttpClient {
    return OkHttpClient.Builder().build()
}

@Binds: 인터페이스 → 구현체 바인딩만 선언한다. 추상 메서드로 작성하며, Dagger가 코드를 생성하지 않고 직접 위임하므로 더 효율적이다.

FaceDetectionRepositoryFaceDetectionRepositoryImpl@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이 맞다. 매번 새 인스턴스가 생성되면 상태가 초기화되어버린다.


domain에 Hilt 플러그인을 적용하지 않은 이유

각 모듈의 Hilt 적용 여부:

  • app 모듈: id("com.dantariun.buildlogic.hilt") 적용
  • data 모듈: id("com.dantariun.buildlogic.hilt") 적용
  • domain 모듈: 적용 안 함

domain에는 @Inject만 추가했다.

class ObserveFaceDetectionUseCase @Inject constructor(
    private val repository: FaceDetectionRepository
) {
    operator fun invoke(): Flow<List<DetectedFace>> = repository.detectedFaces
}

이유는 명확하다. @Injectjavax.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로 감지 결과를 실시간으로 렌더링하는 흐름을 만들 예정이다.

드디어 뭔가 눈에 보이기 시작할 것 같다.

profile
Android Developer

0개의 댓글