최근 Compose Multiplatform을 활용하여 사이드 프로젝트를 진행중입니다.
Android, iOS, Flutter를 전부 실무에서 활용해본 경험으로 미루어 봤을때 꽤 좋은 선택지가 될 것이라는 기대 덕분인데요.
좋은 아키텍처가 무엇인지 고민하던 찰나, Slack에서 개발한 Circuit 아키텍처가 마음에 들어 사용해보고 있습니다.
현재 구글이 KMP를 적극 지원중이고 여러 Jetpack 라이브러리들이 KMP를 지원하도록 업데이트 되고 있으니 가까운 미래에 바뀔지도 모르지만요.
아키텍처를 구성한다는 것은 의존성 주입 또한 중요한 문제가 될 텐데요.
안드로이드 개발자라면 Jetpack Hilt를 사용하고 있을테지만 안타깝게도 KMP에서는 사용이 어렵습니다.
그래서 코틀린으로 작성된 Koin이 좋은 대안이 될 수 있습니다만, Koin은 Kotlin DSL을 활용해서 런타임에 주입해주는 방식이었기 때문에 의존성 주입을 잘 구성하지 않았을 때 Hilt와 달리 컴파일 타임에 발견이 안되고 런타임에 알 수 있기 때문에 크래쉬 발생 리스크가 있었습니다.
그런데 꽤 이전부터 Koin도 KSP와 어노테이션을 활용한 코드생성 방식을 지원하고 있었고 이러한 단점을 해소하고 있었습니다.
https://insert-koin.io/docs/reference/koin-annotations/start/
Starting with Koin Annotations | Koin
The goal of Koin Annotations project is to help declare Koin definition in a very fast and intuitive way, and generate all underlying Koin DSL for you. The goal is to help developer experience to scale and go fast 🚀, thanks to Kotlin Compilers.
insert-koin.io
그래서 제가 사이드 프로젝트에 활용하려고 만들고 있는 Circuit 템플릿 프로젝트를 마이그레이션 하였고, 이 과정에 대해 포스팅합니다.
내용은 굉장히 쉽습니다.
Starting with Koin Annotations
무엇이든 시작전에 공식문서를 한번 보는걸 습관화하는게 좋습니다. 공식 문서를 바탕으로 내용을 가볍게 이해해봅시다.
의존성 추가
https://insert-koin.io/docs/setup/annotations
Koin Annotations | Koin
Setup Koin Annotations for your project
insert-koin.io
위 링크를 참고해서 KSP와 Koin annotations의존성을 추가해줍시다.
KSP는 작성일 기준 2.0.20-1.0.25와 호환됩니다.
안드로이드 기준으로 다음과 같은 의존성 추가가 필요합니다.
plugins {
id("com.google.devtools.ksp") version "$ksp_version"
}
dependencies {
// Koin
implementation("io.insert-koin:koin-android:$koin_version")
// Koin Annotations
implementation("io.insert-koin:koin-annotations:$koin_annotations_version")
// Koin Annotations KSP Compiler
ksp("io.insert-koin:koin-ksp-compiler:$koin_annotations_version")
}
KMP의 경우 의존성 추가 방법이 따로 있습니다. 뒤에서 다시 다루겠습니다.
Koin Compile Safety
Koin의 Compile Safety 동작을 위해 활성화해야할 옵션이 있습니다.
// in build.gradle or build.gradle.kts
ksp {
arg("KOIN_CONFIG_CHECK","true")
}
위 코드를 gradle에 선언하면, Koin 어노테이션으로 선언된 의존성들이 올바른지 체크합니다.
이제 Koin을 사용할 때 런타임 크래쉬를 걱정하는 일이 없겠습니다.
Koin Annotations
Koin에서 제공하는 어노테이션들에 먼저 알아보겠습니다.
모두를 정리하진 않고, 핵심적인 부분만 정리하겠습니다. 더 자세히 알고자 하시는 분들은 공식문서 참고를 권장합니다.
@Module
Koin 모듈을 정의하기 위해 사용합니다. 예시 코드는 다음과 같습니다.
// 모듈 선언
@Module
@ComponentScan
class MyModule
// 생성된 코드를 import 해야 합니다.
import org.koin.ksp.generated.*
fun main() {
val koin = startKoin {
modules(
// KSP에 의해 코드가 생성됩니다.
MyModule().module
)
}
}
여기서 @ComponentScan 어노테이션은, 어노테이션이 달려있는 컴포넌트들을 모두 스캔하고 수집합니다. 스캔 대상은 현재 패키지와 모든 서브 패키지를 포함합니다.
만약 특정 패키지를 대상으로 하고 싶다면 @ComponentScan("com.my.package")와 같이 지정할 수 있습니다.
빌드를 하고나면 KSP에 의해 코드가 생성되고, 모듈 객체의 .module 속성으로 접근할 수 있습니다.
만약 여러 모듈이 있다면 다음과 같은 방법도 가능합니다.
@Module
class ModuleA
@Module(includes = [ModuleA::class])
class ModuleB
fun main() {
startKoin {
modules(
// 모듈 A와 모듈 B가 모두 생성됩니다.
ModuleB().module
)
}
}
모듈간의 의존관계를 정의해서 상위 모듈의 선언만으로 하위 모듈들을 모두 생성할 수 있습니다.
@Single
@Single 어노테이션은 DSL 방식 Koin의 single { ... } 와 동일합니다.
즉, 이 어노테이션은 컴포넌트를 Singletone으로 생성합니다.
다음과 같이 정의할 수 있습니다.
@Single
class MyComponent1(val myDependency : MyDependency)
@Single(binds = [MyBoundType::class])
class MyComponent2(val myDependency : MyDependency) : MyInterface
@Factory
@Factory 어노테이션은 DSL 방식 Koin의 factory { ... } 와 동일합니다.
즉, 이 어노테이션은 컴포넌트를 매번 필요할 때 마다 재생성합니다.
다음과 같이 정의할 수 있습니다.
@Factory
class MyComponent1(val myDependency : MyDependency)
@Factory(binds = [MyBoundType::class])
class MyComponent2(val myDependency : MyDependency) : MyInterface
모듈 내에서 컴포넌트 선언하기
만약 모듈 내에서 직접 컴포넌트를 선언하고 싶다면, 다음과 같이 할 수도 있습니다.
@Module
class MyModule {
@Single
fun myComponent(myDependency : MyDependency) = MyComponent(myDependency)
}
개인적으론 모듈에서 담당해야할 컴포넌트가 무엇인지 알 수 있어서 선호하는 방식입니다.
Hilt에서 마이그레이션 한다면 이 구조가 굉장히 효율적일 것 같습니다.
KMP에서 Koin Annotations 사용하기
https://insert-koin.io/docs/reference/koin-annotations/kmp
Annotations for Definitions and Modules in Kotlin Multiplatform App | Koin
KSP Setup
insert-koin.io
KMP에서 Koin annotations를 위한 설정은 위 문서에 나와있습니다. 문서를 참고하셔도 쉽게 할 수 있습니다.
다음과 같이 의존성을 추가합니다.
// build.gradle.kts
plugins {
...
alias(libs.plugins.ksp)
}
kotlin {
sourceSets {
commonMain.dependencies {
implementation(libs.koin.core)
api(libs.koin.annotations)
}
...
}
sourceSets.named("commonMain").configure {
kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin")
}
}
dependencies {
add("kspCommonMainMetadata", libs.koin.ksp.compiler)
add("kspAndroid", libs.koin.ksp.compiler)
add("kspIosX64", libs.koin.ksp.compiler)
add("kspIosArm64", libs.koin.ksp.compiler)
add("kspIosSimulatorArm64", libs.koin.ksp.compiler)
}
project.tasks.withType(KotlinCompilationTask::class.java).configureEach {
if(name != "kspCommonMainKotlinMetadata") {
dependsOn("kspCommonMainKotlinMetadata")
}
}
Circuit 모듈 마이그레이션
이제 기존의 Circuit 모듈을 마이그레이션 해 보겠습니다.
Circuit 모듈은 Presenter.Factory, Ui.Factory, Circuit 세 가지 컴포넌트가 있습니다.
Presenter.Factory, Ui.Factory
class CircuitPresenterFactory : Presenter.Factory {
override fun create(
screen: Screen,
navigator: Navigator,
context: CircuitContext
): Presenter<*> {
return when (screen) {
is MainScreen -> MainPresenter(screen, navigator)
else -> throw Exception("Invalid Screen Detected! :: $screen")
}
}
}
class CircuitUiFactory : Ui.Factory {
override fun create(screen: Screen, context: CircuitContext): Ui<*>? {
return ui<CircuitUiState> { state, modifier ->
when (state) {
is MainScreen.State -> Main(state, modifier)
}
}
}
}
Presenter.Factory, Ui.Factory 인터페이스를 구현하는 클래스를 정의했습니다.
이 클래스는 마이그레이션 하기 전과 다르지 않습니다.
Circuit Module
@Module
class CircuitModule {
@Factory(binds = [Presenter.Factory::class])
fun circuitPresenterFactoryComponent() = CircuitPresenterFactory()
@Factory(binds = [Ui.Factory::class])
fun circuitUiFactoryComponent() = CircuitUiFactory()
@Single
fun circuit(
presenterFactory: CircuitPresenterFactory,
uiFactory: CircuitUiFactory
) = Circuit.Builder()
.addPresenterFactory(presenterFactory)
.addUiFactory(uiFactory)
.build()
}
Circuit 모듈을 생성합니다.
@Module, @Factory, @Single 어노테이션을 사용하여 구현해줍니다.
Presenter.Factory, Ui.Factory는 @Factory를 사용하여 필요할 때 재생성하고, Circuit은 앱에서 단 하나의 인스턴스만 가지므로 @Single을 사용하여 Singletone으로 생성합니다.
App Module
앱의 전체 모듈을 포함하는 모듈을 정의합니다.
이는 앱의 동작동안 어떤 모듈들과 의존성들을 가지고 있는지 명확하게 하려는 의도를 가지고 있습니다.
@Module(
includes = [
CircuitModule::class
]
)
@ComponentScan
class AppModule
현재는 CircuitModule만을 포함하지만, 나중에 여러 레이어(Presentation, Domain, Data)가 추가된다면 다수의 모듈들이 앱 모듈에 포함될 수 있습니다.
생성한 모듈 사용
이제 Koin을 Android, iOS 플랫폼에서 각각 사용해주는 일만 남았습니다.
Koin을 초기화하는 공통 함수를 정의해서 AppModule을 포함할 수 있도록 코드를 작성하고, 각 플랫폼에서 호출해줍니다.
예시 코드는 다음과 같습니다.
fun initKoin(config: KoinAppDeclaration? = null) {
startKoin {
config?.invoke(this)
printLogger()
modules(
AppModule().module
)
}
}
// Android
class Application : Application() {
override fun onCreate() {
super.onCreate()
initKoin {
androidContext(this@Application)
}
}
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val koin = getKoin()
val circuit: Circuit = koin.get()
App(circuit)
}
}
}
// iOS
fun MainViewController() = ComposeUIViewController(
configure = {
initKoin()
}
) {
val koin = getKoin()
val circuit: Circuit = koin.get()
App(circuit)
}
마치며
저는 Android, iOS 네이티브를 둘 다 구현할 수 있어 크로스 플랫폼은 살짝 멀리 하는 경향이 있었습니다.
Flutter와 비교했을 때 생산성의 차이는 있겠습니다만, 저는 생산성이 높은 편이고 이슈 핸들링과 같은 부분들에 있어 결국 네이티브가 낫다는 개인적인 결론이 있었기 때문인데요.
하지만 최근 CMP의 등장과 발전으로 다시금 크로스 플랫폼에 관심을 두게 되고 여러가지 새로운 기술들과 사실들을 알게 되어 흥미진진합니다. 그리고 그동안 Koin의 런타임 리스크때문에 너무 멀리하고 있었나 하는 생각이 들어 반성하는 시간을 가지게 되었습니다.
이 포스팅의 전체 코드는 다음 Github에 공유되어 있습니다. (아직 템플릿 치고 많이 허접합니다.
업데이트 예정이니 의견을 주셔도 좋겠습니다. ㅋ.ㅋ)
https://github.com/duitddu/template-cmp-circuit
GitHub - duitddu/template-cmp-circuit: Kotlin Multiplatform Mobile(Android, iOS) + Compose + Circuit
Kotlin Multiplatform Mobile(Android, iOS) + Compose + Circuit - duitddu/template-cmp-circuit
github.com
제 포스팅이 저와 같이 CMP에 관심을 가지고 계신 분에게 도움이 되길 바라며 마칩니다.
'개발 > Android' 카테고리의 다른 글
[Android] 안드로이드 메모리 누수에 관하여 (0) | 2025.02.04 |
---|---|
[Android] Jetpack Compose - Layout으로 커스텀 레이아웃 만들기 (0) | 2025.02.02 |
Kotlin Coroutines, 에러 처리와 SupervisorJob (0) | 2025.01.29 |
Kotlin Coroutines, 구조화된 동시성 (Structed Concurrency) (0) | 2025.01.27 |
[Android] Retrofit Call Adapter를 활용해서 효과적인 에러 핸들링 하기 (0) | 2025.01.24 |