📑 목차
Image by Boskampi on Pixabay
Kotlin Multiplatform, 왜 선택했나?
모바일 앱 개발에서 항상 마주하는 딜레마가 있습니다. iOS와 Android 플랫폼을 동시에 지원해야 할 때, 각각의 코드를 별도로 작성해야 할까요? 아니면 하나의 코드로 두 플랫폼을 모두 커버하는 크로스플랫폼 솔루션을 선택해야 할까요? 이 질문은 개발 자원, 출시 속도, 유지보수 비용 등 다양한 측면에서 프로젝트의 성패를 좌우하는 중요한 결정입니다.
저희 팀은 초기 벤처 프로젝트부터 다양한 크로스플랫폼 기술을 검토했습니다. React Native, Flutter와 같은 선발 주자들도 매력적인 옵션이었지만, 궁극적으로 Kotlin Multiplatform (KMP)을 선택하게 된 배경에는 몇 가지 핵심적인 이유가 있습니다. 가장 큰 이유는 비즈니스 로직의 완벽한 재사용에 대한 열망이었습니다. 단순히 UI만 공유하는 것을 넘어, 데이터 처리, 네트워크 통신, 비즈니스 규칙 등 핵심 로직을 한 번 작성하여 iOS와 Android는 물론, 향후 웹이나 데스크톱까지 확장할 수 있는 가능성이 KMP의 가장 큰 장점으로 다가왔습니다.
특히 이미 Android 개발에 Kotlin을 적극적으로 사용하고 있었기에, Kotlin 언어의 생산성과 안정성을 그대로 가져갈 수 있다는 점도 큰 이점이었습니다. 기존 Kotlin 생태계의 풍부한 라이브러리와 도구를 활용할 수 있다는 점은 개발 초기 진입 장벽을 낮추는 데 크게 기여했습니다. 또한, 각 플랫폼의 UI는 네이티브로 구현하여 최적의 사용자 경험을 제공하면서, 백엔드와 모바일 클라이언트 간의 API 계약 및 데이터 모델을 통합할 수 있다는 점도 중요한 고려사항이었습니다. 이는 개발 초기부터 백엔드 개발자와의 협업 효율성을 극대화하는 데 도움을 주었습니다.
KMP 아키텍처 설계와 도전 과제
공유 모듈 설계 전략
KMP 프로젝트의 핵심은 공유 모듈(Shared Module)의 설계입니다. 저희는 MVVM 패턴을 기반으로 Common Main 모듈에 ViewModel, Repository 인터페이스, 데이터 모델, 유틸리티 클래스 등을 배치했습니다. 비즈니스 로직은 최대한 이 공유 모듈에 집중시켰고, 플랫폼별로 다른 구현이 필요한 부분은 expect/actual 메커니즘을 활용했습니다. 예를 들어, 로컬 데이터베이스 접근이나 특정 센서 사용 등은 플랫폼 종속적인 부분이므로, 공유 모듈에서 인터페이스를 정의하고 각 플랫폼 모듈에서 구현하는 방식을 택했습니다.
초기 설계 단계에서 가장 중요하게 생각했던 부분은 의존성 주입(Dependency Injection)입니다. Koin과 같은 DI 프레임워크를 활용하여 공유 모듈 내에서 의존성을 관리하고, 플랫폼별로 필요한 구현체를 주입받도록 구성했습니다. 이는 코드의 유연성을 높이고 테스트를 용이하게 하는 데 큰 도움이 되었습니다. 또한, 비동기 처리를 위해 Kotlin Coroutines를 적극적으로 활용하여 공유 모듈 내에서 비동기 로직을 일관성 있게 처리할 수 있었습니다.
빌드 시스템 복잡성 관리
KMP 프로젝트를 진행하면서 가장 큰 도전 과제 중 하나는 빌드 시스템의 복잡성이었습니다. Android는 Gradle, iOS는 Xcode를 사용하며, 이 두 시스템을 KMP 프로젝트 구조에 맞춰 연동하는 과정에서 여러 시행착오를 겪었습니다. 특히 iOS 빌드 프로세스에 KMP 공유 모듈을 프레임워크 형태로 포함하는 과정에서 Gradle 스크립트 설정과 Xcode 빌드 페이즈 설정이 중요했습니다. 처음에는 Gradle의 packForXcode 태스크를 활용하여 공유 모듈을 생성했으나, 점차 빌드 속도와 안정성을 개선하기 위해 캐싱 전략과 증분 빌드를 최적화하는 데 많은 노력을 기울였습니다.
또한, 라이브러리 의존성 관리도 복잡했습니다. 공유 모듈에서 사용하는 라이브러리는 KMP를 지원하는 라이브러리여야 하며, 플랫폼별로 특정 라이브러리를 사용할 경우 expect/actual로 래핑하거나 플랫폼 모듈에서 직접 의존성을 추가해야 했습니다. 예를 들어, 네트워크 통신을 위해 Ktor HTTP Client를 사용했고, 직렬화를 위해 kotlinx.serialization을 선택하여 공유 모듈에서 일관된 데이터 통신을 구현했습니다. 이 과정에서 각 라이브러리의 버전 호환성과 KMP 지원 여부를 꼼꼼히 확인하는 것이 필수적이었습니다.
플랫폼별 상호작용과 네이티브 모듈 연동
expect/actual 메커니즘 활용
KMP의 핵심 기능 중 하나인 expect/actual 메커니즘은 플랫폼 종속적인 기능을 공유 모듈에서 추상화하고, 각 플랫폼에서 구체적으로 구현할 수 있게 해줍니다. 저희 프로젝트에서는 주로 다음과 같은 경우에 이 메커니즘을 활용했습니다:
- 로컬 저장소 접근: Realm, SQLite 같은 플랫폼별 데이터베이스를 사용해야 할 때, 공유 모듈에서 저장소 인터페이스를
expect로 선언하고 Android에서는 Room, iOS에서는 CoreData 또는 SwiftData와 같은 네이티브 솔루션을actual로 구현했습니다. - 특정 센서 및 하드웨어 기능: GPS, 카메라, 블루투스 등 하드웨어 접근이 필요한 경우.
- 플랫폼별 유틸리티: 토스트 메시지 표시, 파일 경로 접근, 디바이스 정보 가져오기 등.
예를 들어, 간단한 로그 기능을 구현할 때 다음과 같이 expect/actual을 사용할 수 있습니다.
// commonMain/kotlin/com/example/LogHelper.kt
package com.example
expect class PlatformLogger() {
fun log(message: String)
}
// androidMain/kotlin/com/example/LogHelper.kt
package com.example
import android.util.Log
actual class PlatformLogger actual constructor() {
actual fun log(message: String) {
Log.d("KMP_APP", message)
}
}
// iosMain/kotlin/com/example/LogHelper.kt
package com.example
import platform.Foundation.NSLog
actual class PlatformLogger actual constructor() {
actual fun log(message: String) {
NSLog("KMP_APP: %@", message)
}
}
이러한 방식은 공유 로직과 플랫폼 로직을 명확하게 분리하여 코드의 가독성과 유지보수성을 높이는 데 기여했습니다.
네이티브 라이브러리 및 UI 연동
KMP는 UI 레이어를 공유하지 않기 때문에, 각 플랫폼의 UI는 네이티브로 구현해야 합니다. Android에서는 Jetpack Compose, iOS에서는 SwiftUI나 UIKit을 사용합니다. 공유 모듈에서 정의된 ViewModel을 각 플랫폼의 UI 레이어에서 구독하여 데이터를 표시하고 사용자 입력을 처리하는 방식입니다. 이 과정에서 Android는 ViewModel 인스턴스를 직접 사용할 수 있지만, iOS에서는 KMP 공유 모듈이 Swift/Objective-C 프레임워크 형태로 노출되므로, Swift에서 Kotlin 코드를 호출하는 방식에 익숙해져야 합니다.
때로는 특정 플랫폼에서만 제공하는 복잡한 네이티브 라이브러리를 사용해야 할 때가 있습니다. 예를 들어, 특정 결제 모듈이나 지도 SDK 같은 경우입니다. 이런 경우, 플랫폼 모듈에서 해당 라이브러리를 직접 사용하고, 필요한 경우 공유 모듈에 인터페이스를 정의하여 플랫폼 모듈이 이를 구현하도록 합니다. 또는 Foreign Function Interface (FFI)를 통해 직접 네이티브 코드를 호출하는 방법도 고려할 수 있습니다. 예를 들어, iOS의 경우 KMP가 생성하는 Objective-C 헤더를 통해 Swift에서 Kotlin 함수를 호출할 수 있으며, Kotlin/Native의 C Interop 기능을 활용하여 C 라이브러리와 연동할 수도 있습니다.
Image by jamesmarkosborne on Pixabay
테스트 및 디버깅 경험 공유
공유 모듈 테스트 전략
KMP 프로젝트에서 테스트는 매우 중요합니다. 특히 비즈니스 로직이 집중된 공유 모듈에 대한 테스트는 전체 애플리케이션의 안정성을 보장하는 핵심입니다. 저희는 공유 모듈에 대한 단위 테스트를 작성할 때 Kotlin/JVM 환경에서 실행되도록 구성했습니다. Mockito, MockK와 같은 목(Mock) 프레임워크를 활용하여 의존성을 분리하고, Coroutines의 테스트 유틸리티를 사용하여 비동기 로직을 효과적으로 테스트했습니다. 테스트 커버리지를 높이는 데 중점을 두었으며, 특히 expect/actual로 분리된 로직의 경우, expect 인터페이스에 대한 테스트를 공유 모듈에서 진행하고, actual 구현체에 대한 테스트는 각 플랫폼 모듈에서 추가로 진행하는 전략을 사용했습니다.
// commonTest/kotlin/com/example/MyViewModelTest.kt
package com.example
import com.example.model.User
import com.example.repository.UserRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
class MyViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var userRepository: UserRepository // Mocking 필요
@BeforeTest
fun setup() {
Dispatchers.setMain(testDispatcher)
// userRepository = mockk() // 실제 프로젝트에서는 목킹 라이브러리 사용
}
@Test
fun `fetchUser_success_updatesUserState`() = runTest(testDispatcher) {
// given
// coEvery { userRepository.getUser(any()) } returns User("testId", "Test User")
val viewModel = MyViewModel(userRepository)
// when
viewModel.fetchUser("123")
testDispatcher.scheduler.advanceUntilIdle() // Coroutine 완료 대기
// then
// assertEquals("Test User", viewModel.userState.value.name)
}
}
크로스플랫폼 디버깅 팁
KMP 프로젝트의 디버깅은 처음에는 다소 생소하게 느껴질 수 있습니다. 공유 모듈의 코드는 Android Studio에서 Kotlin/JVM 환경으로 디버깅할 수 있지만, iOS 시뮬레이터나 실제 기기에서 실행되는 Kotlin/Native 코드를 디버깅하는 것은 다른 접근 방식이 필요합니다. 일반적으로 iOS 디버깅은 Xcode에서 진행하며, Kotlin 코드를 중단점으로 설정하고 디버깅할 수 있습니다. IntelliJ IDEA Ultimate 버전은 KMP 프로젝트의 통합 디버깅을 지원하여 Android와 iOS 양쪽 모두에서 Kotlin 코드를 디버깅할 수 있는 강력한 기능을 제공합니다.
하지만 때로는 특정 플랫폼에서만 발생하는 미묘한 버그를 찾아야 할 때가 있습니다. 이럴 때는 각 플랫폼의 네이티브 디버깅 도구(Android Studio의 디버거, Xcode의 LLDB)를 활용하는 것이 효과적입니다. 예를 들어, iOS에서 Kotlin 객체가 예상치 못하게 해제되거나, Swift와 Kotlin 간의 타입 변환에서 문제가 발생할 경우, Xcode의 메모리 디버깅 도구나 LLDB 명령어를 통해 문제를 깊이 있게 파고들어야 했습니다. 경험상 공유 모듈의 로직은 단위 테스트로 대부분 커버하고, 플랫폼별 연동 문제나 UI 관련 버그는 각 플랫폼의 네이티브 디버깅 도구로 해결하는 것이 효율적이었습니다.
성능 최적화와 사용자 경험
KMP는 UI 레이어를 네이티브로 구현하기 때문에, 이론적으로는 네이티브 앱과 동일한 성능과 사용자 경험(UX)을 제공할 수 있습니다. 하지만 공유 모듈의 설계 방식이나 Kotlin/Native의 특정 기능 사용에 따라 성능에 영향을 미칠 수 있는 요소들이 있습니다. 특히 Kotlin/Native의 메모리 관리 모델(ARC)은 JVM과는 다른 특성을 가지므로, 객체 라이프사이클과 참조 관리에 주의를 기울여야 합니다. 불필요한 객체 생성이나 메모리 누수는 iOS 환경에서 성능 저하를 일으킬 수 있습니다.
저희는 성능 최적화를 위해 다음과 같은 방법을 적용했습니다:
- 콜드 스타트 최적화: 앱 시작 시 공유 모듈의 초기화 비용을 최소화하고, 필요한 데이터만 로드하도록 최적화했습니다.
- 네트워크 및 데이터 처리 효율화: Ktor HTTP Client의 캐싱 기능을 활용하고, kotlinx.serialization을 통한 데이터 직렬화/역직렬화 과정을 효율적으로 관리하여 불필요한 오버헤드를 줄였습니다.
- 백그라운드 처리: 복잡한 연산이나 대용량 데이터 처리는 Coroutines의 Dispatchers를 활용하여 백그라운드 스레드에서 수행하고, UI 스레드는 항상 반응성을 유지하도록 했습니다.
- 프로파일링: Android Studio의 CPU 프로파일러와 Xcode의 Instruments를 사용하여 앱의 성능 병목 지점을 주기적으로 측정하고 개선했습니다.
결과적으로, KMP를 통해 개발된 앱은 사용자에게 네이티브 앱과 거의 동일한 수준의 부드럽고 반응성 있는 경험을 제공할 수 있었습니다. 핵심 비즈니스 로직이 통합되어 일관된 동작을 보장하고, 각 플랫폼의 UI 가이드라인을 충실히 따를 수 있었던 것이 큰 장점이었습니다.
Image by nattanan23 on Pixabay
KMP 도입의 장단점 분석
저희 팀의 실전 경험을 바탕으로 Kotlin Multiplatform의 주요 장점과 단점을 정리해 보았습니다. KMP 도입을 고려하는 팀이라면 다음 표를 참고하여 프로젝트에 맞는 합리적인 결정을 내리는 데 도움이 되기를 바랍니다.
| 구분 | 장점 (Pros) | 단점 (Cons) |
|---|---|---|
| 코드 재사용성 |
|
|
| 개발 경험 |
|
|
| 성능 및 안정성 |
|
|
결론 및 향후 전망
저희 팀의 Kotlin Multiplatform 실전 개발 경험은 긍정적이었습니다. 비즈니스 로직의 통합과 코드 재사용을 통해 개발 효율성을 크게 높일 수 있었고, 각 플랫폼의 최적화된 사용자 경험을 유지하면서도 일관된 제품을 제공할 수 있었습니다. 물론 빌드 시스템의 복잡성이나 초기 학습 곡선과 같은 도전 과제도 있었지만, KMP가 제공하는 장점들이 이러한 단점들을 상쇄하고도 남았습니다.
KMP는 단순히 모바일 크로스플랫폼 솔루션을 넘어, 백엔드, 웹(Kotlin/Wasm), 데스크톱 등 다양한 플랫폼에서 Kotlin 코드를 공유하여 풀스택 개발의 가능성을 열어주는 강력한 도구입니다. 특히 JetBrains가 KMP에 대한 투자를 지속하고 있고, KMP를 기반으로 한 선언형 UI 프레임워크인 Compose Multiplatform이 성장함에 따라, KMP는 더욱 강력한 개발 생태계를 구축할 것으로 기대됩니다.
만약 여러분의 팀이 Android 개발에 Kotlin을 사용하고 있거나, 비즈니스 로직을 통합하여 여러 플랫폼에 걸쳐 일관된 경험을 제공하고자 한다면, Kotlin Multiplatform은 분명히 매력적인 선택지가 될 것입니다. 초기 설정의 어려움을 극복하고 나면, 코드 재사용의 이점과 Kotlin 언어의 생산성을 만끽할 수 있을 것입니다.
Kotlin Multiplatform에 대한 여러분의 경험이나 질문이 있다면 댓글로 자유롭게 공유해 주세요. 함께 고민하고 발전해 나가는 커뮤니티가 되기를 바랍니다!