안드로이드 개발을 하다 KMP로 넘어오면 가장 먼저 부딪히는 벽 중 하나가 의존성 주입(DI)입니다. Hilt는 훌륭하지만, 안드로이드 전용 어노테이션 프로세싱 기반이라 iOS나 다른 플랫폼에서는 쓸 수 없습니다. 이 글에서는 KMP 프로젝트에서 Koin을 도입하는 과정을 실제 Todo 앱 예제로 정리합니다.
왜 Hilt가 아니라 Koin인가
Hilt는 컴파일 타임에 코드를 생성하는 방식(KAPT/KSP 기반)으로 동작합니다. 이 방식은 안드로이드의 Gradle 빌드 시스템과 강하게 결합되어 있어, iOS 타겟에서는 사용할 수 없습니다.
Koin은 런타임에 DI 그래프를 구성하는 순수 Kotlin 라이브러리입니다. 어노테이션 프로세싱이 없기 때문에 플랫폼에 종속되지 않고, commonMain에서 그대로 동작합니다. 대신 컴파일 타임 검증이 없다는 트레이드오프가 있으니, 모듈 등록을 빠뜨리면 런타임에야 에러를 발견하게 됩니다.
프로젝트 구조
composeApp/
├── commonMain/
│ └── di/
│ └── AppModule.kt // 공통 의존성
├── androidMain/
│ └── di/
│ └── PlatformModule.kt // 안드로이드 전용 (DataStore, HttpClient engine 등)
└── iosMain/
└── di/
└── PlatformModule.kt // iOS 전용
commonMain에 모듈 정의하기
// commonMain/di/AppModule.kt
val appModule = module {
single { TodoRepository(get()) }
factory { TodoViewModel(get()) }
}
Repository나 ViewModel처럼 플랫폼에 상관없이 동일하게 동작하는 클래스는 commonMain에 정의합니다.
플랫폼별 의존성 주입
DataStore처럼 플랫폼마다 초기화 방식이 다른 경우, expect/actual과 조합해서 처리합니다.
// androidMain/di/PlatformModule.kt
actual val platformModule = module {
single { createDataStore(androidContext()) }
}
// iosMain/di/PlatformModule.kt
actual val platformModule = module {
single { createDataStore() }
}
Compose Multiplatform에서 사용하기
@Composable
fun TodoScreen(viewModel: TodoViewModel = koinViewModel()) {
val todos by viewModel.todos.collectAsState()
// ...
}
koinViewModel()은 koin-compose-viewmodel 의존성을 추가하면 사용할 수 있습니다.
초기화
fun initKoin() {
startKoin {
modules(appModule, platformModule)
}
}
안드로이드는 Application.onCreate()에서, iOS는 MainViewController 진입점에서 각각 호출해줍니다.
자주 하는 실수
- 모듈 등록 누락:
startKoin에 모듈을 추가하지 않으면NoBeanDefFoundException이 런타임에 발생합니다. 컴파일 타임에 걸러지지 않으니 주의가 필요합니다. - 순환 의존성: A가 B를, B가 A를 요구하는 구조는 Koin에서도 여전히 문제가 됩니다. 생성자 주입 구조를 다시 점검해야 합니다.
- iOS 초기화 시점 실수:
MainViewController가 여러 번 생성되는 경우startKoin을 중복 호출하지 않도록 가드가 필요합니다.
마무리
Hilt의 컴파일 타임 안전성이 그립긴 하지만, Koin은 KMP 환경에서 사실상 표준처럼 자리잡았습니다. 작은 프로젝트일수록 러닝 커브도 낮아서, KMP를 처음 시작하는 분들께는 무난한 선택지입니다.