안녕하세요. 스포카 FE팀의 Android 개발자 김진우입니다.
“Kotlin/Compose Multiplatform(이하 KMP/CMP) 마이그레이션, 정말 프로덕션에서 가능할까?”
이 질문에 대한 답을 찾기 위해 저희 팀은 키친보드 Android 앱을 KMP/CMP로 마이그레이션하는 도전을 시작했습니다.
그 과정에서 가장 큰 난관은 Compose Navigation의 한계로 인한 WebView 화면 상태 유실 문제였습니다.
이로 인해 마이그레이션 자체를 포기해야 하나 고민했지만, 포기하지 않고 커스텀 네비게이션 아키텍처를 설계하여 문제를 해결할 수 있었습니다.
이 글에서는 위 문제를 어떻게 해결했는지, 그리고 그 과정에서 얻은 인사이트를 공유드리고자 합니다.
마이그레이션 배경
키친보드는 요식업 매장과 식자재 유통사의 주문·결제·커뮤니케이션 관리를 통합 지원하는 B2B SaaS 플랫폼으로, Android와 iOS 네이티브 앱을 각각 운영해 왔습니다.
서비스가 성장하면서 양쪽 플랫폼의 기능 격차를 최소화하고, 더 빠른 기능 배포가 필요한 상황이었습니다.
기술 스택 선택 과정
멀티플랫폼 전략을 수립하면서 여러 선택지를 검토했습니다:
| 기술 스택 | 장점 | 단점 | 결론 |
|---|---|---|---|
| 현행 유지 | 안정적 운영 | 플랫폼별 개발 비용 2배 | ❌ 장기적 효율성 부족 |
| React Native | 성숙한 생태계 및 웹 개발자 활용 가능 | 기존 Android 코드 재활용 불가 | ❌ 전면 재작성 필요 |
| Flutter | 성숙한 생태계 | 기존 Android 코드 재활용 불가 | ❌ 전면 재작성 필요 |
| KMP/CMP | 기존 Android 코드 70%+ 재활용 | 비교적 미성숙한 생태계 | ✅ 채택 |
KMP/CMP를 선택한 핵심 이유:
- 기존 코드 재활용: 이미 Kotlin으로 작성된 Business Logic과 Compose로 작성된 UI 재활용 가능
- Kotlin/Compose 기반 개발: 기존 Android 개발 경험과 노하우를 그대로 활용
- 네이티브 성능 보장: 100% 네이티브 성능 유지 (WebView 방식과 달리)
- 플랫폼 특화 기능 지원: DeepLink, Push, MLKit 등 네이티브 기능 완전 지원
네이티브 기능 호환성 검증
마이그레이션 전, 현재 서비스의 핵심 네이티브 기능들이 KMP/CMP에서 지원 가능한지 검증했습니다:
| 기능 | KMP 지원 여부 | 비고 |
|---|---|---|
| DeepLink | ✅ 완전 지원 | Platform-specific 코드 구현 |
| Push Notification | ✅ 완전 지원 | Platform-specific 코드 구현 |
| MLKit / VisionKit | ✅ 완전 지원 | Platform-specific 화면 구현 |
| 커스텀 카메라 | ✅ 완전 지원 | Platform-specific 화면 구현 |
| 파일 시스템 접근 | ✅ 완전 지원 | expect/actual 연동 |
| Third-Party SDKs (Sendbird SDK 등) |
✅ 일부 지원 | 미지원 시, 각 Platform SDK를 expect/actual 연동 |
검증 결과, 모든 핵심 기능이 KMP/CMP에서 구현 가능함을 확인했습니다.
마이그레이션 목표
- 기존 사용자 경험 100% 유지: 기존 프로덕션 앱의 모든 기능과 UX를 그대로 유지
- 플랫폼 간 코드 공유 극대화: Business Logic, UI 70% 이상 공유
- 장기적 유지보수 효율성 확보: 한 번의 개발로 양쪽 플랫폼 배포
해결해야 했던 핵심 과제
키친보드 앱의 핵심 화면은 WebView 기반의 주문서 작성 화면입니다.
사용자가 주문서를 작성하는 중에 다른 화면(예: 상품 상세, 상품 검색)으로 이동했다가 다시 돌아왔을 때, 작성 중이던 내용이 그대로 유지되어야 합니다.
Compose Navigation의 한계
초기에는 CMP를 위해 Compose Navigation을 도입(기존엔 Multi-Activity 구조)하려 했습니다. 하지만 다음과 같은 문제가 발생했습니다.
// Compose Navigation 방식
NavHost(navController, startDestination = "home") {
composable("webview") {
WebViewScreen() // 백스택에서 복귀 시 재구성(Recomposition)됨
}
composable("detail") {
DetailScreen()
}
}
Compose Navigation의 NavHost는 화면 전환 시 destination을 재구성(Recomposition)하는 특성이 있습니다.
이로 인해 WebView 객체가 초기화되어, 백스택에서 WebView 화면으로 복귀할 때 리로딩되는 문제가 발생했고, 이는 다음과 같은 사용자 경험 저하로 이어졌습니다.
- 작성 중이던 폼 데이터 손실
- 스크롤 위치 초기화
- 불필요한 네트워크 요청 재발생
이러한 문제를 근본적으로 해결(Recomposition 회피)하기 위해, Compose Navigation 대신 FragmentManager(Android) 및 UINavigationController(iOS) 기반의 커스텀 네비게이션 아키텍처를 구축하게
되었습니다.
커스텀 네비게이션 아키텍처 설계

1. Type Safety 화면 정의 - NavDestination & NavScreen
네비게이션의 타입 안전성을 보장하기 위해 NavDestination abstract class와 NavScreen enum을 설계했습니다.
NavDestination - 화면 라우트 정의
각 화면의 Route 패턴과 파라미터를 Type Safety하게 정의합니다.
// NavDestination.kt - Navigator Module
abstract class NavDestination<D>(val host: String) {
open val pathParams: List<PathParam> = emptyList()
open val queryParams: List<QueryParam> = emptyList()
// Type Safety Route 생성
fun createRoute(
pathArgs: List<Any> = emptyList(),
queryArgs: Map<NavParam, Any?> = emptyMap()
): String
// Route에서 Type Safety 데이터 파싱
abstract fun getData(arguments: SavedState): D
}
실제 사용 예시:
// WebViewDestination.kt
object WebViewDestination : NavDestination<WebViewDestination.Data>(host = "webview") {
enum class QueryParams(...) : QueryParam {
URL(key = "url", type = NavType.StringType),
TRANSITION(key = "transition", type = NavType.StringType),
RESULT_KEY(key = "resultKey", type = NavType.StringType),
// ...
}
fun createRoute(
url: String,
transition: NavTransition = NavTransition.HORIZONTAL,
resultKey: String? = null
): String = createRoute(
queryArgs = mapOf(
QueryParams.URL to url,
QueryParams.TRANSITION to transition.webViewParam,
QueryParams.RESULT_KEY to resultKey,
// ...
)
)
data class Data(
val url: String,
val transition: NavTransition,
val resultKey: String?,
// ...
)
override fun getData(arguments: SavedState) = with(arguments) {
Data(
url = getString(QueryParams.URL.key) ?: "about:blank",
transition = getString(QueryParams.TRANSITION.key).let(NavTransition::fromWebViewParam),
resultKey = getString(QueryParams.RESULT_KEY.key),
// ...
)
}
}
// 🔑 Type Safety 데이터 전달
navigator.navigate(
route = WebViewDestination.createRoute(
url = "https://kitchenboard.co.kr/order",
title = "주문서 작성",
transition = NavTransition.VERTICAL
)
)
// 🔑 Type Safety 데이터 접근
@Composable
fun WebViewScreen(data: WebViewDestination.Data) {
AndroidView { WebView(it).loadUrl(data.url) }
}
NavScreen - 화면 메타데이터 관리
모든 화면을 Enum으로 정의하여 모든 화면 메타데이터를 한 곳에서 관리합니다.
// NavScreen.kt
enum class NavScreen(
val destination: NavDestination<*>,
val transition: NavTransition = NavTransition.HORIZONTAL,
val launchMode: NavLaunchMode = NavLaunchMode.STANDARD,
val content: @Composable (Any?) -> Unit,
) {
SPLASH(
destination = SplashDestination,
content = SplashDestination.Screen { SplashScreen() },
transition = NavTransition.NONE,
launchMode = NavLaunchMode.SINGLE_TASK,
),
HOME(
destination = HomeDestination,
content = HomeDestination.Screen { HomeScreen() },
transition = NavTransition.VERTICAL,
launchMode = NavLaunchMode.SINGLE_TASK,
),
WEBVIEW(
destination = WebViewDestination,
content = WebViewDestination.Screen { WebViewScreen(it) },
);
}
2. 플랫폼 추상화 - Navigator 인터페이스
CMP에서 플랫폼 독립적인 네비게이션을 구현하기 위해 Navigator Module에 Navigator 인터페이스를 정의했습니다.
interface Navigator {
fun navigate(route: String, navOptions: NavOptions? = null)
fun navigateUp()
}
expect fun getNavigator(): Navigator
이를 통해 비즈니스 로직 레이어에서는 플랫폼에 독립적으로 네비게이션 API를 사용할 수 있게 되었습니다.
// Android Implementation
actual fun getNavigator(): Navigator = AndroidNavigator
// iOS Implementation
actual fun getNavigator(): Navigator = IOSNavigator
Android 구현 - FragmentManager 기반
1. Fragment 화면 단위 - ScreenFragment
각 화면을 독립적인 Fragment로 관리합니다.
// ScreenFragment.kt
class ScreenFragment : Fragment() {
var currentRoute by mutableStateOf<String?>(null)
val transition: NavTransition? by lazy {
arguments?.getString(ARG_TRANSITION)?.let(NavTransition::safeValueOf)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
) = ComposeView(requireContext()).apply {
setContent {
// 각 Fragment가 독립적인 Compose 트리 소유
Screen(route = currentRoute ?: arguments?.getString(ARG_ROUTE))
}
}
// 동적으로 Route 변경 가능 (SINGLE_TOP, SINGLE_TASK 모드에서 활용)
fun updateRoute(route: String) {
arguments?.putString(ARG_ROUTE, route)
currentRoute = route // Recomposition 트리거
}
}
핵심 포인트:
- 각 Fragment = 하나의 독립적인 Compose 트리
- Fragment가 백스택에 유지되면 Compose 상태도 함께 유지
mutableStateOf로 Route 변경 시 필요한 부분만 Recomposition
2. 네비게이션 로직 - AndroidNavigatorProxy
WebView 상태 유지의 핵심은 Compose Recomposition을 회피하는 것입니다. Fragment를 백스택에 유지하여 destroy되지 않도록 합니다.
// AndroidNavigatorProxy.kt
override fun navigate(route: String, navOptions: NavOptions?) {
// ...
// LaunchMode 처리
when (targetScreen.launchMode) {
NavLaunchMode.SINGLE_TOP -> {
if (현재 화면과 동일한 화면이면) {
기존 Fragment의 updateRoute 호출
return
}
}
NavLaunchMode.SINGLE_TASK -> {
백스택을 역순으로 탐색하여 {
if (동일한 화면의 Fragment를 찾으면) {
상위 모든 Fragment 제거 후 찾은 Fragment의 updateRoute 호출
return
}
}
}
}
// ...
// 새 Fragment 추가
fragmentManager.beginTransaction().apply {
val fragment = ScreenFragment.newInstance(route, targetTransition)
add(R.id.fragment_container, fragment)
addToBackStack(route)
// ...
}.commit()
}
iOS 구현 - UINavigationController 기반
Android의 FragmentManager와 동일한 개념으로, iOS에서는 UINavigationController를 활용하여 네비게이션을 구현했습니다.
1. UIViewController 화면 단위 - ScreenViewController
Android의 ScreenFragment와 동일한 역할을 하는 ScreenViewController를 구현했습니다.
// ScreenViewController.kt
class ScreenViewController(
route: String?,
val transition: NavTransition? = null
) : UIViewController(nibName = null, bundle = null) {
var currentRoute by mutableStateOf(route)
private val composeViewController = ComposeUIViewController {
Screen(route = currentRoute)
}
init {
addChildViewController(composeViewController)
view.addSubview(composeViewController.view)
composeViewController.didMoveToParentViewController(this)
// ...
}
fun updateRoute(route: String) {
currentRoute = route
}
}
핵심 포인트:
- 각 ViewController = 하나의 독립적인 Compose 트리
ComposeUIViewController로 Compose UI를 UIKit에 통합mutableStateOf로 Route 변경 시 Recomposition
2. iOS 네비게이션 관리 - IOSNavigatorProxy
Android의 FragmentManager와 동일한 역할로 UINavigationController를 활용합니다.
// IOSNavigatorProxy.kt - 핵심 로직
override fun navigate(route: String, navOptions: NavOptions?) {
val navigationController = UIApplication.sharedApplication.findNavigationController()
ensureNavigationDelegate(navigationController)
// ...
// LaunchMode 처리 (Android와 동일)
when (targetScreen.launchMode) {
// ...
}
// ...
// UINavigationController에 push
val viewController = ScreenViewController(route, targetTransition)
navigationController.pushViewController(viewController, animated = true)
}
플랫폼 비교
Android vs iOS 구현 비교
| 항목 | Android | iOS |
|---|---|---|
| 네비게이션 관리 | FragmentManager | UINavigationController |
| 화면 단위 | Fragment | UIViewController |
| 상태 유지 | Fragment 백스택 유지 | ViewController 백스택 유지 |
| 화면 전환 | FragmentTransaction | pushViewController |
추가 기능 구현
1. 동적 트랜지션 제어 - overridePendingTransition
각 화면은 기본적으로 정의된 Transition을 가지지만, 때로는 런타임에 동적으로 변경해야 하는 경우가 있습니다.
사용 사례:
- WebView 화면: transition 파라미터에 따라 다른 트랜지션 적용
// Navigator 인터페이스
interface Navigator {
// ...
fun overridePendingTransition(transition: NavTransition?)
}
// 사용 예시
navigator.overridePendingTransition(NavTransition.VERTICAL)
navigator.navigate("some_screen")
코드 구현:
// AndroidNavigatorProxy.kt
private var pendingTransition: NavTransition? = null
override fun overridePendingTransition(transition: NavTransition?) {
pendingTransition = transition
}
override fun navigate(route: String, navOptions: NavOptions?) {
// ...
// WebView 특수 처리
if (targetScreen == Screen.WEBVIEW) {
val webViewData = WebViewDestination.getData(route)
// transition 파라미터에 전달된 트랜지션 사용
overridePendingTransition(webViewData.transition)
}
// 🔑 핵심: pendingTransition이 있으면 우선 사용, 없으면 기본값
val targetTransition = pendingTransition ?: targetScreen.transition
// 사용 후 초기화 (일회성)
overridePendingTransition(null)
// ...
}
이를 통해 화면별 기본 트랜지션을 유지하면서도, 필요할 때만 동적으로 오버라이드할 수 있게 되었습니다.
2. A > B > A 화면 간 데이터 전달 - ScreenResult
B 화면에서 A 화면으로 결과를 전달해야 하는 경우가 있습니다. 예를 들어 주소 검색 화면에서 선택한 주소를 A 화면으로 전달하거나, 문서 스캔 완료 후 A 화면을 새로고침해야 하는 경우입니다.
코드 구현:
- Pending → Commit 패턴 (2단계 전송)
class ScreenResult<T> internal constructor(val resultKey: String) { private val mutex = Mutex() private val _data = MutableSharedFlow<T?>(extraBufferCapacity = 1) internal val data: Flow<T?> = _data.asSharedFlow() private var pendingResult: T? = null private var hasPendingResult: Boolean = false // 1단계: emit - 결과 예약만 (즉시 전송 X) suspend fun emit(result: T?) = mutex.withLock { pendingResult = result hasPendingResult = true } // 2단계: commit - 실제 전송 (onDispose에서 자동 호출) internal suspend fun commit() = mutex.withLock { if (hasPendingResult) { _data.emit(pendingResult) pendingResult = null hasPendingResult = false } } }왜 2단계로 분리했는가?
- iOS의 Swipe Back Gesture 대응:
navigateUp()명시적 호출 없이도 화면이 닫힐 수 있음 - 결과 전송 타이밍 보장: Emitter의
DisposableEffect.onDispose에서commit()을 호출하여, 화면이 완전히 종료되는 시점에 결과 전송
@Composable fun rememberScreenResultEmitter<T>(resultKey: String): ScreenResult<T> { val coroutineScope = rememberCoroutineScope() val screenResult = remember(resultKey) { ScreenResultRegistry.getOrPut(resultKey) { ScreenResult<T>(resultKey) } } DisposableEffect(resultKey) { // 진입 시: null로 초기화 coroutineScope.launch { screenResult.emit(null) } // 🔑 종료 시: 자동으로 commit (NonCancellable로 보장) onDispose { coroutineScope.launch { withContext(NonCancellable) { screenResult.commit() } } } } return screenResult } - iOS의 Swipe Back Gesture 대응:
- 전역 Registry 패턴 (Singleton 저장소)
internal object ScreenResultRegistry : SynchronizedObject() { private val results = mutableMapOf<String, ScreenResult<*>>() fun <T> getOrPut(key: String, create: () -> ScreenResult<T>): ScreenResult<T> = synchronized(this) { results.getOrPut(key) { create() } as ScreenResult<T> } fun <T> get(key: String): ScreenResult<T>? = synchronized(this) { results[key] as? ScreenResult<T> } fun remove(key: String) = synchronized(this) { results.remove(key) } }왜 전역 Registry가 필요한가?
- 독립적인 Compose 트리 연결: Collector와 Emitter가 서로 다른 Fragment/ViewController의 Composable 트리에 존재
- resultKey 기반 공유: UUID 기반
resultKey로 같은ScreenResult인스턴스 보장 - 프로세스 재생성 대응:
resultKey가SavedState에 저장되므로, 앱이 백그라운드에서 종료되었다가 복원되어도 결과 전달 가능
- MutableSharedFlow 버퍼 전략
private val _data = MutableSharedFlow<T?>(extraBufferCapacity = 1) internal val data: Flow<T?> = _data.asSharedFlow()왜 extraBufferCapacity = 1 인가?
- 결과 유실 방지: Collector가 아직 구독을 시작하기 전에
commit()이 호출되어도 결과가 버퍼에 저장됨 - 타이밍 이슈 해결: 화면 전환 애니메이션 중 Collector의
LaunchedEffect가 아직 실행되지 않은 상태에서도 안전
- 결과 유실 방지: Collector가 아직 구독을 시작하기 전에
- Collector의 생명주기 관리
@Composable fun <T> rememberScreenResultCollector(onResult: suspend (result: T?) -> Unit): ScreenResult<T> { val resultKey = rememberSaveable { Uuid.random().toString() } val screenResult = remember(resultKey) { ScreenResultRegistry.getOrPut(resultKey) { ScreenResult<T>(resultKey) } } LaunchedEffect(resultKey) { screenResult.data.collect { data -> onResult(data) } } DisposableEffect(resultKey) { onDispose { ScreenResultRegistry.remove(resultKey) } } return screenResult }설계 핵심:
- UUID 기반 키 생성:
rememberSaveable로 프로세스 재생성 시에도 동일한resultKey유지 - LaunchedEffect로 구독: Collector가 활성 상태일 때만 Flow 구독
- 자동 정리: Collector 화면이 종료되면 Registry에서 제거하여 메모리 누수 방지
- UUID 기반 키 생성:
- NonCancellable Context (결과 전송 보장)
onDispose { coroutineScope.launch { withContext(NonCancellable) { screenResult.commit() } } }왜 NonCancellable이 필요한가?
- Dispose 중 결과 전송 보장:
onDispose내부의 코루틴이 취소되어도commit()은 반드시 실행
- Dispose 중 결과 전송 보장:
실전 예시: 주소 검색
// 주소 입력 화면
@Composable
fun AddressInputScreen() {
val navigator = LocalNavigator.current
var selectedAddress by remember { mutableStateOf<Address?>(null) }
// 🔑 주소 검색 결과 받기
val addressResultCollector = rememberScreenResultCollector<Address> { address ->
selectedAddress = address
}
SFButton(
text = "주소 검색",
onClick = {
navigator.navigate(
route = WebViewDestination.createRoute(
url = "https://kitchenboard.co.kr/address",
resultKey = addressResultCollector.resultKey // 연결
)
)
}
)
}
// WebViewScreen (주소 검색 웹뷰)
@Composable
fun WebViewScreen(data: WebViewDestination.Data) {
val navigator = LocalNavigator.current
// 🔑 결과 보내기
val addressResultEmitter = data.resultKey?.let { key ->
rememberScreenResultEmitter<Address>(key)
}
// WebView JavaScript Interface
LaunchedEffect(webView) {
webView.addJavascriptInterface(object {
@JavascriptInterface
fun onAddressSelected(address: String) {
coroutineScope.launch {
addressResultEmitter?.emit(Address(address))
navigator.navigateUp()
}
}
}, "Android")
}
}
인사이트 정리
1. “사용자 경험은 타협할 수 없다”
처음에는 Compose Navigation을 그대로 사용하려 했습니다. 하지만 WebView 리로딩 문제를 발견하고 과감히 커스텀 네비게이션을 구축했습니다.
선택의 기준:
- ❌ 기술 스택의 ‘정석’을 따르는 것
- ✅ 우리 앱의 사용자에게 최선인 것
2. “포기하지 않으면 불가능은 없다”
Compose Navigation의 한계에 부딪혔을 때, “이러면 CMP 마이그레이션이 불가능한가?”라는 생각이 들었습니다. 하지만 포기하지 않고 다른 방법을 찾았습니다.
문제를 마주했을 때의 선택:
- ❌ “Compose Navigation으로 안 되니 CMP를 포기하자”
- ✅ “Compose Navigation 대신 다른 방법을 찾아보자”
결과적으로 FragmentManager와 UINavigationController라는 네이티브 해법을 찾아냈고, 오히려 더 나은 사용자 경험을 제공할 수 있었습니다.
핵심은 ‘불가능’이 아니라 ‘다른 방법’을 찾는 것입니다.
기술 스택의 제약이 프로젝트의 성공을 막을 수는 없습니다. 문제의 본질을 이해하고, 창의적인 해결책을 찾으면 됩니다.
3. “작은 유틸리티가 큰 차이를 만든다”
처음에는 단순히 “화면 전환만 되면 되지”라고 생각했습니다. 하지만 프로덕션 앱에서는:
- 주소 검색 후 결과를 돌려받아야 하고
- 문서 스캔 후 부모 화면을 새로고침해야 하며
- 화면마다 다른 Transition이 필요했습니다
overridePendingTransition과 ScreenResult 같은 작은 유틸리티들이 사용자 경험의 완성도를 크게 높였습니다. 핵심 아키텍처만큼이나 중요한 것은 세부 UX를 지원하는 유틸리티 계층입니다.
마무리
KMP/CMP 마이그레이션을 고민하고 계신가요?
이 글을 쓰면서 전하고 싶었던 메시지는 단 하나입니다.
“기술 스택의 제약은 프로젝트의 성공을 막을 수 없습니다.”
생태계가 성숙하지 않은 KMP/CMP에서는 예상치 못한 문제들을 마주하게 되지만, 포기하지 않고 문제의 본질을 이해하면 네이티브 해법을 찾을 수 있습니다.
여러분도 마이그레이션 과정에서 분명 예상치 못한 문제를 만나게 될 것입니다.
그때 “KMP/CMP가 아직 성숙하지 않아서 안 되는구나”보다는, “다른 방법은 없을까?”라고 질문해 보시기 바랍니다.
마이그레이션은 완벽한 조건에서 시작하는 것이 아닙니다.
중요한 것은 문제를 마주했을 때 포기하지 않고 해결책을 찾아내는 것입니다.
이 글이 KMP/CMP 마이그레이션을 망설이고 계신 분들께 작은 용기가 되었으면 좋겠습니다.
긴 글 읽어주셔서 감사합니다.
스포카에서는 “식자재 시장을 디지털화한다” 라는 슬로건 아래, 매장과 식자재 유통사에 도움되는 여러 솔루션들을 개발하고 있습니다.
더 나은 제품으로 세상을 바꾸는 성장의 과정에 동참 하실 분들은 채용 정보 페이지를 확인해주세요!