Android 개발/Kotlin

공유 UI 말고 공유 코어부터, KMP 도입 한 주 일지

stackD 2026. 6. 24. 18:00
반응형

 

공유 UI는 안 만들었습니다. 그런데도 KMP 도입은 남는 장사였어요. 코어만 떼어내도 테스트 속도가 7배 빨라지는 일이 벌어지거든요.

 

iOS 앱은 아직 계획도 없어요. 그런데도 안드로이드 사이드 프로젝트 하나에 코틀린 멀티플랫폼(Kotlin Multiplatform)을 한 주 동안 발라봤습니다. 네트워킹과 도메인 모델만 commonMain 으로 떼어내는, 딱 거기까지요.

 

넷플릭스가 KMP 도입으로 기능 개발 시간을 40% 줄였다거나, 에어비앤비가 95% 코드 공유까지 갔다는 얘기가 자주 인용됩니다. 그게 전부 공유 UI 까지 간 사례는 아니라고 해요. 비즈니스 로직만 공유해도 충분히 의미가 있다는 입장이 업계에서 더 흔하게 보이는 편입니다.

 

2026년 KMP 시작, 공유 코어부터가 정석인 이유

2026년 시점에서 KMP 는 안드로이드·iOS 타깃 기준으로 프로덕션 단계라는 평가입니다. 컴포즈 멀티플랫폼(Compose Multiplatform) for iOS 도 2025년 5월 1.8.0 부터 Stable 로 올라와, 올해 1.10·1.11 까지 빠르게 굴러가고 있어요. 그렇다고 모든 팀이 UI 부터 공유하느냐 하면 그건 별개입니다.

 

오히려 권장 패턴은 비즈니스·데이터·네트워킹 로직만 shared 모듈로 가져가고, UI 는 각 플랫폼 네이티브로 두는 형태로 알려져 있어요. 에어비앤비도 맥도날드도 코어 공유에서 출발했다는 얘기가 자주 들립니다.

 

여기서 한 가지 함정이 있는데요. 올해 1월에 정식 출시된 AGP 9.0 이후로는 com.android.application 플러그인과 KMP 플러그인이 같은 모듈에 공존할 수 없습니다. 그러니까 기존 단일 모듈 앱이라면 :app (안드로이드 앱) 과 :shared (KMP 공통 로직) 로 모듈을 가르고, :shared 쪽은 새 KMP 전용 플러그인 com.android.kotlin.multiplatform.library 로 옮기는 일이 필수 전제예요. 이걸 모르고 그냥 플러그인부터 붙이면 빌드가 첫 줄부터 깨지더라고요. 급하면 android.enableLegacyVariantApi=true 로 잠깐 비빌 수는 있지만, 이건 AGP 10 에서 사라질 예정이라 미루는 만큼 빚이 늘어나는 느낌이었습니다.

 

저는 이번에 :shared 한 덩어리로 두고 그 안에서 domain, data, network 패키지로만 나눴어요. 장기적으로는 :sharedLogic:sharedUI 까지 가르는 게 권장 구조이지만, 한 주 일정에 그 정도 큰 수술까지는 무리였습니다.

 

 

Ktor 와 kotlinx.serialization 으로 네트워킹 계층부터 떼어내기

가장 먼저 옮긴 건 네트워킹입니다. 이유는 단순해요. 네트워킹을 옮기면 DTO 와 도메인 모델이 자연스럽게 따라 올라오거든요.

 

JVM 에서 잘 쓰던 Retrofit 은 KMP 에 안 맞아서, Ktor Client 와 kotlinx.serialization 으로 갈아끼웠습니다. 응답 모델마다 @Serializable 어노테이션을 달아주는 작업이 의외로 시간을 잡아먹더라고요. Moshi 의 @JsonClass 를 기계적으로 바꾸면 끝날 줄 알았는데, 커스텀 어댑터 쓰던 자리에서 한 번씩 막혔어요.

 

대신 이 작업의 부수효과가 컸습니다. commonMain 은 안드로이드 Context 를 알지 못해요. 그러니까 거기 올라가는 코드는 자동으로 프레임워크 의존성이 없는 순수 로직이 되어야 합니다. Repository 인터페이스만 commonMain 에 두고, Context 가 필요한 구현체는 androidMain 에 남기는 식으로 정리하다 보니까, 그동안 흐릿했던 레이어 경계가 컴파일러 힘으로 강제됐습니다.

 

expect/actual 로 DataStore 와 Keychain 가르기

여기서 KMP 의 시그니처 문법이 등장합니다. expect / actual 인데요. 플랫폼별 구현이 다른 부분에 쓰는 장치예요.

 

권장 방식은 클래스 전체를 expect class 로 잡지 않는 겁니다. 그보다는 commonMaininterface Storageexpect fun createStorage(): Storage 같이 인터페이스 + 팩토리 함수를 두고, 안드로이드는 DataStore 로, iOS 는 Keychain 으로 actual 을 채우는 쪽이 훨씬 안정적이라는 의견이 많습니다. 클래스 단위로 expect 를 잡으면 생성자 변경이 양쪽 플랫폼에 다 번지면서 빌드가 자주 깨지더라고요.

 

젯팩 데이터스토어(Jetpack DataStore) 는 1.1.0 부터 KMP Preferences 한정으로 지원이 들어왔다는 점은 알고 있었어요. 다만 이번엔 안드로이드 단독 시점이라 의존성을 한 줄이라도 줄이려고, commonMainKeyValueStore 인터페이스를 따로 두고 안드로이드 구현체에서만 DataStore 를 부르도록 단순화했습니다. iOS 가 붙는 시점에 DataStore KMP (Preferences 한정) 를 commonMain 으로 끌어올릴 예정입니다.

 

DI 는 더 골치 아팠는데요. 메인 앱은 힐트(Hilt) 를 쓰고 있는데 힐트가 KMP 를 지원하지 않습니다. 결국 :shared 안쪽은 코인(Koin) 으로 묶고, :app 쪽은 힐트를 유지한 채 두 컨테이너를 어댑터 한 줄로 연결하는 방식으로 갔어요.

 

 

안드로이드 KMP 도입 후 테스트 속도 7배, 빌드 시간은 +50초

가장 체감이 컸던 부분입니다. commonMain 에 올라온 도메인 로직은 commonTest 에서 JVM 으로 바로 돕니다. 로보일렉트릭(Robolectric) 도, 에뮬레이터도 필요 없어요.

 

기존에 로보일렉트릭으로 굴리던 도메인 테스트 묶음이 22초 걸리던 게, JVM 단순 실행으로 옮긴 뒤 3초로 줄었습니다. 7배 정도 단축된 모습인데, 이게 사이드 프로젝트의 TDD 사이클 자체를 바꿔놓더라고요.

 

대신 빌드 시간은 손해를 봤습니다. 클린 빌드 기준으로 안드로이드 단독일 때 1분 40초였던 게, KMP 플러그인 붙이고 2분 30초로 늘었어요. 증분 빌드는 비슷한 수준이라 일상 개발에서는 큰 차이가 없습니다. 첫 iOS 프레임워크 빌드는 5~10분 걸린다는 얘기도 들리니, iOS 까지 갈 거라면 이쪽도 미리 각오해두시는 게 좋겠습니다.

 

체감으로 가장 좋았던 건 빌드 시간이 아니라, commonMain 이 프레임워크 의존을 컴파일 타임에 잘라준다는 점이었어요. 그동안 도메인 레이어에 슬쩍 Log.d 하나 박아두고 잊어버리는 식의 게으름이 많았는데, KMP 모듈에서는 그게 빨간 줄로 즉시 보여요. 클린 아키텍처를 말로만 외치다가, 컴파일러가 강제하는 자리에 처음 서본 느낌이네요.

 

 

컴포즈 멀티플랫폼·내비게이션을 굳이 안 옮긴 자리

이번 한 주 동안 의도적으로 안 옮긴 자리도 분명히 있습니다. 솔직히 말하면 UI 가 가장 컸어요.

 

iOS 타깃이 없는 프로젝트에서 컴포즈 멀티플랫폼까지 끌어다 쓸 ROI 가 약하다고 봅니다. 젯팩 컴포즈 코드를 그대로 공유할 수 있다는 매력은 있지만, 그걸로 얻는 게 결국 "있지도 않은 iOS 앱의 미래 UI" 라서요. 미래 보험이라기엔 지금 빌드 시간을 더 깎아먹는 비용이 더 크게 느껴졌습니다.

 

내비게이션도 비슷한 결정을 했어요. 화면 이동의 트리거가 되는 UiStateEvent 클래스만 commonMain 에 올리고, 실제 화면 전환은 안드로이드 쪽 Navigation Compose 가 그대로 처리하도록 뒀습니다.

 

개인적으로는, iOS 출시 계획이 정말 없다면 멀티모듈만 잘 쪼개도 비슷한 효과를 얻을 수 있다고 봅니다. :domain, :data, :app 으로 자른 순수 안드로이드 멀티모듈도 레이어 강제 효과는 비슷한 편이에요. KMP 의 진짜 값어치는 "지금 당장 iOS 가 없어도, 그쪽으로 가는 문을 열어둔다"는 옵션 값에 더 가깝다고 봅니다.

 

한 주 정리하면 commonMain 으로 옮긴 코드 약 1,800줄, 도메인 테스트 22초 → 3초, 클린 빌드 +50초, 공유 UI 0줄입니다. 숫자만 보면 손익이 애매해 보이는데, 손으로 굴려본 입장에서는 분명히 남는 장사였어요. iOS 가 없는 사이드 프로젝트라도 코어부터 떼어내는 쪽과, 안드로이드 멀티모듈로만 깔끔하게 가는 쪽 둘 중에서 저는 전자를 골랐습니다. 옵션 값을 한 주에 1,800줄로 사두는 거래라고 보면, 사이드 프로젝트 단계에서 이만한 가성비가 흔치 않거든요. 다음 한 주는 이 :shared 위에 SQLDelight 를 얹어서, 로컬 캐시까지 같은 자리로 끌어올려볼 생각입니다.

 

반응형
LIST