KMP & CMP
- Kotlin Multiplatform & Compose Multiplatform 도입 안내서
- 🧩 목차
- 📎 부록
- 📖 1부. Kotlin Multiplatform 입문과 실전 샘플 프로젝트
- 🔹 Kotlin Multiplatform의 개념
- 🔹 왜 KMP인가?
- 🔹 Android와 iOS 코드 공유 구조
- 🔹 Gradle 설정
- 🔹 Android Studio / Xcode 연동
- 🔹 프로젝트 구조 이해
- 🔹 기본 구조 설계: shared, androidApp, iosApp
- 🔹 shared 모듈 만들기
- 🔹 Android와 iOS 간의 연결
- 🔹 Ktor: 네트워크 통신 라이브러리
- 🔹 Koin: DI(Dependency Injection) 라이브러리
- 🔹 Skie: Swift 연동을 위한 보조 도구
- 📖 2부. Clean Architecture로 확장하기
- 🔹 Clean Architecture 계층 구조
- 🔹 Domain 계층
- 🔹 Data 계층
- 🔹 Presentation 계층
- 🔹 domain 계층
- 🔹 data 계층
- 🔹 presentation 계층
- 🔹 UseCase 구현 예제
- 🔹 ViewModel 구성 예제 (KMP)
- 🔹 Android에서 ViewModel 연결 예제
- 🔹 Skie + Koin + Swift 연동
- 🔹 Swift에서 ViewController 연결 예시
- 🔹 공용 UseCase, Repository 단위 테스트 (mockk 활용)
- 🔹 API 테스트: Ktor MockEngine
- 🔹 정리
- 📖 3부. Compose Multiplatform 도입하기
- 🔹 Jetpack Compose vs Compose Multiplatform
- 🔹 Compose Multiplatform의 특징
- 🔹 언제 Compose Multiplatform을 도입할까?
- 🔹 UI 공유 모듈 구성
- 🔹 Composable UI 예시
- 🔹 Android에서 적용
- 🔹 iOS에서 적용
- 🔹 shared ViewModel을 Compose에서 쓰기
- 🔹 Navigation 적용
- 🔹 iOS에서 shared ViewModel 사용하기
- 🔹 정리
- 🔹 클린 아키텍처 + Compose Multiplatform 구조
- 🔹 Compose Multiplatform 기반 UI 예시
- 🔹 Android에서 실행
- 🔹 iOS에서 실행 (Swift + Compose Multiplatform)
- 🔹 결과 요약
- 🔹 플랫폼 차이 대응
- 🔹 퍼포먼스와 네이티브 경험
- 🔹 기타 대응 팁
- 📎 부록
Kotlin Multiplatform & Compose Multiplatform 도입 안내서
🧩 목차
📖 1부. Kotlin Multiplatform 입문과 실전 샘플 프로젝트
-
KMP란 무엇인가?
- Kotlin Multiplatform의 개념
- 왜 KMP인가?
- Android와 iOS 코드 공유 구조
-
개발 환경 준비
- Gradle 설정
- Android Studio / Xcode 연동
- 프로젝트 구조 이해
-
샘플 프로젝트 만들기
- 기본 구조 설계:
shared,androidApp,iosApp - shared 모듈 만들기
- Android와 iOS 간의 연결
- 기본 구조 설계:
-
주요 라이브러리 소개
- Ktor: 네트워크 통신
- 기본 설정 및 API 호출 예제
- Koin: DI(Dependency Injection)
- shared에서 DI 구성
- Skie: Swift 연동을 위한 설정
- Swift에서의 사용 예제
- Ktor: 네트워크 통신
📖 2부. Clean Architecture로 확장하기
-
Clean Architecture의 개념
- Domain, UseCase, Repository, Data 계층 설명
-
KMP Clean Architecture 프로젝트 구조
shared/domain,shared/data,shared/presentation모듈 구성
-
UseCase와 ViewModel 구성
- UseCase 구현 예제
- Android에서 ViewModel 연결
-
iOS에서 ViewModel 사용하기
- Skie + Koin + Swift 연동
- iOS ViewController에서 UseCase 호출 예제
-
테스트 전략
- 공용 UseCase 단위 테스트
- Ktor MockEngine을 활용한 테스트
📖 3부. Compose Multiplatform 도입하기
-
Compose Multiplatform 소개
- Jetpack Compose vs Compose Multiplatform
-
프로젝트 설정과 구조
- UI 공유 모듈 구성
- Android, iOS에서 UI 적용
-
ViewModel과 상태관리
- shared ViewModel을 Compose에서 쓰기
- Navigation 적용
-
실전 예제: Todo 앱
- 클린 아키텍처 + Compose Multiplatform
- iOS/Android에서 실행 가능한 UI 구성
-
멀티플랫폼 UI에서의 한계와 대응 전략
- 플랫폼 차이 대응
- 퍼포먼스와 네이티브 경험
📎 부록
- 부록 A: 각 플랫폼별 빌드 스크립트 최적화
- 부록 B: Skie 설정 심화 및 Troubleshooting
- 부록 C: Gradle 버전 관리와 Kotlin DSL
📖 1부. Kotlin Multiplatform 입문과 실전 샘플 프로젝트
- KMP란 무엇인가?
KMP(Kotlin Multiplatform)는 JetBrains에서 개발한 Kotlin 언어의 멀티플랫폼 기능이다. KMP를 사용하면 하나의 공통 코드를 기반으로 Android, iOS, 데스크탑, 웹 등 여러 플랫폼에서 동작하는 애플리케이션을 개발할 수 있다.
즉, 핵심 로직(비즈니스 로직, 네트워크 통신, 데이터 처리 등)을 하나의 코드베이스에서 관리하면서, 각 플랫폼에 맞는 UI나 플랫폼 특화 기능만 따로 구현하면 된다. 이를 통해 생산성과 유지보수성이 크게 향상된다.
🔹 Kotlin Multiplatform의 개념
Kotlin은 원래 JVM 기반 언어였지만, Kotlin/Native와 Kotlin/JS를 통해 다양한 플랫폼으로 확장되었다.
KMP는 이 확장성을 활용하여 공통 코드는 commonMain에 작성하고, 플랫폼 특화 코드는 androidMain, iosMain 등의 디렉토리에 따로 작성하는 구조를 가진다.
commonMain: 플랫폼에 의존하지 않는 공통 코드 (모델, UseCase, Repository 등)androidMain: Android 전용 구현체 (예: Room, Android Logger)iosMain: iOS 전용 구현체 (예: CoreData, iOS-specific API)
이러한 구조를 통해 하나의 UseCase나 Repository가 두 플랫폼에서 동일한 방식으로 작동할 수 있도록 한다.
🔹 왜 KMP인가?
✅ 코드 재사용 극대화
공통 로직을 한 번만 작성하면 Android와 iOS 양쪽에서 사용할 수 있다. 팀이 하나의 공통 레이어를 유지보수하면 되므로 코드 품질도 올라가고 개발 비용도 줄어든다.
✅ 플랫폼별 UI 유지
UI는 플랫폼마다 특성이 다르기 때문에, Android에서는 Jetpack Compose, iOS에서는 SwiftUI나 UIKit 등 각자의 방식대로 구현할 수 있다. 공통 로직과 UI를 분리할 수 있어 설계가 유연하다.
✅ 테스트 효율성 증가
공통 로직에 대한 테스트 코드를 하나만 작성해도 두 플랫폼 모두에 적용된다. 예를 들어 비즈니스 로직이나 API 호출 로직은 공통 모듈에서 테스트 가능하므로 QA 품질이 높아진다.
✅ 점진적 도입 가능
기존 앱에 부분적으로 도입할 수 있다. 예를 들어 먼저 네트워크 계층만 공통화하고, 점차적으로 UseCase, Repository, ViewModel 등으로 확장하는 방식이 가능하다.
🔹 Android와 iOS 코드 공유 구조
KMP에서는 보통 shared라는 공통 모듈을 만들고 다음과 같이 구성한다:
📦 shared
┣ 📂 commonMain
┃ ┣ 📂 models
┃ ┣ 📂 repository
┃ ┣ 📂 usecase
┃ ┗ 📂 di
┣ 📂 androidMain
┃ ┗ 📂 platform
┣ 📂 iosMain
┃ ┗ 📂 platform
models: 공통으로 사용하는 데이터 클래스 정의repository: 인터페이스 정의 및 일부 공통 구현usecase: 앱의 핵심 로직di: 의존성 주입 구성 (예: Koin 사용)
androidMain과 iosMain에는 실제 플랫폼별 구현체가 들어가며, 이 구조를 통해 Android와 iOS 모두 같은 인터페이스를 기반으로 작동하게 된다.
KMP는 아직 발전 중이지만, JetBrains와 커뮤니티의 활발한 지원 덕분에 점점 더 안정화되고 있다. 특히 비즈니스 로직 공유를 통해 생산성을 높이고자 하는 스타트업이나 소규모 팀에게 매우 유용한 선택지가 될 수 있다.
- 개발 환경 준비
Kotlin Multiplatform 프로젝트를 시작하려면 먼저 개발 환경을 세팅해야 한다. Android와 iOS를 모두 타겟으로 하기 때문에, 두 플랫폼의 개발 도구가 모두 필요하다.
🔹 Gradle 설정
KMP 프로젝트는 Gradle 기반으로 구성된다. 아래는 기본적인 settings.gradle.kts와 build.gradle.kts 설정이다.
settings.gradle.kts 예시:
pluginManagement {
repositories {
google()
gradlePluginPortal()
mavenCentral()
}
}
rootProject.name = "MyKMPProject"
include(":shared")
shared/build.gradle.kts 예시:
kotlin {
androidTarget()
iosX64()
iosArm64()
iosSimulatorArm64()
sourceSets {
val commonMain by getting {
dependencies {
implementation("io.insert-koin:koin-core:3.5.0")
implementation("io.ktor:ktor-client-core:2.3.5")
}
}
val androidMain by getting {
dependencies {
implementation("io.ktor:ktor-client-okhttp:2.3.5")
}
}
val iosMain by creating {
dependsOn(commonMain)
dependencies {
implementation("io.ktor:ktor-client-darwin:2.3.5")
}
}
iosX64Main.dependsOn(iosMain)
iosArm64Main.dependsOn(iosMain)
iosSimulatorArm64Main.dependsOn(iosMain)
}
}
Gradle 설정이 끝나면 shared 모듈에서 공통 코드 작성이 가능해진다.
🔹 Android Studio / Xcode 연동
KMP 개발을 위해서는 Android Studio와 Xcode 모두 설치되어 있어야 한다.
- Android Studio: Android 앱 개발, Gradle 빌드 설정, Emulator 실행 등
- Xcode: iOS 앱 실행, Framework 연동, Swift/Obj-C 연동
iOS 연동 방법 요약:
shared모듈을 빌드해서.framework생성./gradlew :shared:assembleReleaseXCFramework- 생성된
shared.xcframework를 Xcode 프로젝트에 포함 - Swift에서 import 후 사용
Skie 라이브러리를 쓰면 Swift와의 연동이 훨씬 간단해지므로 함께 사용하는 걸 추천한다.
🔹 프로젝트 구조 이해
KMP 프로젝트는 보통 다음과 같은 구조를 가진다:
📦 root
┣ 📂 androidApp (Android 전용 코드)
┣ 📂 iosApp (iOS 전용 코드)
┣ 📂 shared (공통 코드)
┗ 📄 build.gradle.kts
androidApp: Android 전용 UI, Activity, ViewModel 등iosApp: Xcode 프로젝트, SwiftUI 또는 UIKit 기반 화면shared: 공통 비즈니스 로직, 데이터 모델, UseCase, Repository 등
이 구조를 기반으로 각 플랫폼은 공통 모듈(shared)을 import하여 재사용하게 된다.
KMP의 개발 환경은 한 번 익숙해지면 강력한 생산성을 제공한다. 특히 팀이 Android와 iOS를 모두 관리해야 하는 경우, 이 구조는 코드 중복을 줄이고 일관된 로직을 유지하는 데 큰 도움이 된다.
- 샘플 프로젝트 만들기
이 장에서는 Kotlin Multiplatform 프로젝트를 직접 구성해보며, shared 모듈을 만들고 Android와 iOS에서 공통 코드를 어떻게 사용하는지 구체적으로 알아본다. 이 샘플은 간단한 Todo 앱을 기반으로 하며, 핵심 로직은 공통화하고 UI는 각 플랫폼에서 따로 구현한다.
🔹 기본 구조 설계: shared, androidApp, iosApp
KMP 프로젝트는 크게 세 가지 모듈로 구성된다.
📦 root
┣ 📂 shared (공통 로직)
┣ 📂 androidApp (Android 앱)
┗ 📂 iosApp (iOS 앱)
shared: Kotlin 코드로 작성되며 Android와 iOS에서 공통으로 사용된다. 이 모듈 안에는 모델, UseCase, Repository, DI, 네트워크 통신 등 핵심 비즈니스 로직이 들어간다.androidApp: 일반적인 Android 프로젝트이며, shared 모듈을 Gradle을 통해 종속성으로 추가한다.iosApp: Xcode 기반 프로젝트이며, shared 모듈을.xcframework형태로 가져와 Swift 코드에서 사용한다.
이러한 구조는 공통 로직을 하나로 유지하면서도 각 플랫폼의 사용자 경험(UX)을 보장할 수 있는 설계를 가능하게 한다.
🔹 shared 모듈 만들기
shared 모듈은 KMP의 핵심이다. 먼저 Gradle에 멀티플랫폼 플러그인을 설정하고 Android, iOS 양쪽을 타겟팅하도록 구성한다.
예를 들어 다음과 같이 설정할 수 있다.
build.gradle.kts (shared):
kotlin {
androidTarget()
iosX64()
iosArm64()
iosSimulatorArm64()
sourceSets {
val commonMain by getting {
dependencies {
implementation("io.ktor:ktor-client-core:2.3.5")
implementation("io.insert-koin:koin-core:3.5.0")
}
}
val androidMain by getting {
dependencies {
implementation("io.ktor:ktor-client-okhttp:2.3.5")
}
}
val iosMain by creating {
dependsOn(commonMain)
dependencies {
implementation("io.ktor:ktor-client-darwin:2.3.5")
}
}
iosX64Main.dependsOn(iosMain)
iosArm64Main.dependsOn(iosMain)
iosSimulatorArm64Main.dependsOn(iosMain)
}
}
이제 commonMain에 비즈니스 로직을 작성하면 Android와 iOS 양쪽에서 모두 사용할 수 있다. 예를 들어 TodoItem, TodoRepository, GetTodoListUseCase 같은 클래스들이 여기에 들어간다.
🔹 Android와 iOS 간의 연결
공통 모듈을 만든 후에는 각 플랫폼에서 이 모듈을 사용할 수 있도록 연결해야 한다.
Android 연결 방법
Android 쪽은 Gradle로 직접 shared 모듈을 의존성에 추가하면 된다:
androidApp/build.gradle.kts
dependencies {
implementation(project(":shared"))
}
그 후 Android Activity나 ViewModel 등에서 공통 로직을 호출하면 된다.
iOS 연결 방법
iOS는 shared 모듈을 .xcframework로 변환한 후 Xcode 프로젝트에 포함시킨다.
1. 프레임워크 생성
./gradlew :shared:assembleReleaseXCFramework
2. Xcode에서 연결
- Xcode의
Frameworks, Libraries, and Embedded Content에 생성된shared.xcframework를 추가 - Swift에서
import shared를 통해 사용할 수 있음
Skie를 사용한 iOS 연동
전통적인 Kotlin/Native 방식은 Swift에서 사용할 때 인터페이스가 불편할 수 있다. 이 문제를 해결하기 위해 Skie를 사용하면 더 직관적인 Swift 인터페이스로 Kotlin 클래스를 사용할 수 있다.
이 샘플 프로젝트를 통해 공통 로직은 하나의 코드로 유지하면서도, 각 플랫폼에 맞는 화면과 UX를 유지할 수 있다는 것을 직접 경험할 수 있다. 이 구조는 향후 클린 아키텍처나 Compose Multiplatform 도입에도 유리한 기반이 된다.
📌 TIP: KMP 프로젝트를 더 쉽게 시작하려면?
JetBrains에서 제공하는 KMP Project Wizard를 사용하면 여러 플랫폼을 지원하는 프로젝트를 몇 번의 클릭만으로 생성할 수 있다.
Android, iOS, Compose Multiplatform, 테스트 코드 설정까지 자동으로 구성되므로 처음 시작하는 사람에게 매우 유용하다.
- 주요 라이브러리 소개
Kotlin Multiplatform에서는 플랫폼 간 공통 기능을 구현할 때 사용할 수 있는 다양한 라이브러리들이 있다.
이 장에서는 그중에서도 실무에서 자주 쓰이는 세 가지 핵심 라이브러리인 Ktor, Koin, Skie에 대해 소개하고, KMP 프로젝트에 어떻게 적용할 수 있는지 예제와 함께 알아본다.
🔹 Ktor: 네트워크 통신 라이브러리
Ktor는 JetBrains에서 만든 Kotlin 기반 네트워크 라이브러리로, 클라이언트와 서버 모두 지원한다.
KMP에서는 Ktor Client를 사용하여 Android와 iOS에서 공통된 방식으로 API 통신을 처리할 수 있다.
Gradle 의존성 설정 (shared/build.gradle.kts):
commonMain {
dependencies {
implementation("io.ktor:ktor-client-core:2.3.5")
implementation("io.ktor:ktor-client-content-negotiation:2.3.5")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.5")
}
}
androidMain {
dependencies {
implementation("io.ktor:ktor-client-okhttp:2.3.5")
}
}
iosMain {
dependencies {
implementation("io.ktor:ktor-client-darwin:2.3.5")
}
}
Ktor 사용 예제:
class TodoApi(private val client: HttpClient) {
suspend fun fetchTodos(): List<TodoItem> {
return client.get("https://example.com/todos").body()
}
}
클라이언트 구성:
val httpClient = HttpClient {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}
Ktor는 모듈화되어 있어서 필요에 따라 기능을 선택적으로 구성할 수 있고, 공통 비즈니스 로직에 통신 코드를 작성할 수 있어 테스트와 유지보수에 유리하다.
🔹 Koin: DI(Dependency Injection) 라이브러리
Koin은 Kotlin에 최적화된 경량 DI 라이브러리다.
KMP에서도 공통 모듈에서 Koin을 설정하면 Android와 iOS 양쪽에서 동일한 DI 컨테이너를 사용할 수 있다.
Gradle 설정:
commonMain {
dependencies {
implementation("io.insert-koin:koin-core:3.5.0")
}
}
Koin 모듈 예제:
val sharedModule = module {
single { HttpClientProvider().client }
single { TodoApi(get()) }
single { GetTodoListUseCase(get()) }
}
Koin 초기화 (공통 진입점):
fun initKoin() = startKoin {
modules(sharedModule)
}
Android에서는 Application 클래스에서 호출하면 되고, iOS에서는 initKoin()을 Swift 코드에서 불러와 사용한다.
이를 통해 공통 ViewModel이나 UseCase 등을 플랫폼 구분 없이 구성할 수 있다.
🔹 Skie: Swift 연동을 위한 보조 도구
Skie 는 Kotlin 코드에서 정의된 클래스, 함수, 인터페이스 등을 iOS의 Swift에서 더 자연스럽게 사용할 수 있도록 도와주는 도구다.
기존 Kotlin/Native 방식은 Swift에서 호출 시 복잡한 네이밍과 타입 변환이 필요했지만, Skie를 사용하면 Swift 개발자도 직관적으로 Kotlin API를 사용할 수 있다.
Skie 설정 예시 (Gradle):
plugins {
id("dev.icerock.skie") version "0.4.0"
}
iOS 사용 예제 (Swift):
let useCase = SharedGetTodoListUseCase()
useCase.execute { todos, error in
if let list = todos {
print(list)
}
}
Skie는 KMP의 iOS 접근성을 크게 개선해주며, 실제 서비스에 적용할 때도 iOS 개발자와의 협업이 훨씬 수월해진다.
이 세 가지 라이브러리를 통해 KMP 프로젝트에서 네트워크 통신, 의존성 주입, Swift 연동까지 효율적으로 구성할 수 있다. 실무에서는 이 조합이 가장 많이 사용되며, 이후 클린 아키텍처나 Compose Multiplatform 구조로 확장하기에도 안정적인 기반이 된다.
📖 2부. Clean Architecture로 확장하기
- Clean Architecture의 개념
Clean Architecture는 소프트웨어 구조의 유지보수성과 확장성을 높이기 위한 설계 원칙이다.
Kotlin Multiplatform에서도 이 개념을 적용하면 플랫폼마다 다른 UI나 기능을 가지더라도 핵심 로직은 단일 구조로 통일할 수 있다.
🔹 Clean Architecture 계층 구조
Clean Architecture는 크게 네 가지 계층으로 나뉜다:
┌────────────────────┐
│ Presentation │ ← Android/iOS 화면, ViewModel
└────────────────────┘
↓
┌────────────────────┐
│ UseCase │ ← 도메인 로직 (비즈니스 규칙)
└────────────────────┘
↓
┌────────────────────┐
│ Repository │ ← 추상화된 인터페이스, 구현체 분리
└────────────────────┘
↓
┌────────────────────┐
│ Data │ ← 실제 구현 (API, DB, etc)
└────────────────────┘
각 계층은 의존성 역전 원칙에 따라 아래 계층을 몰라도 동작할 수 있어야 하며, interface를 통해 결합도를 낮추는 것이 핵심이다.
🔹 Domain 계층
Domain은 앱의 중심이 되는 규칙(비즈니스 로직)을 정의하는 계층이다.
이 계층은 플랫폼에 의존하지 않으며, 대부분 다음을 포함한다:
- UseCase: 특정 기능을 수행하는 단위
- Entity / Domain Model: 비즈니스 로직에서 사용하는 핵심 데이터 구조
- Repository 인터페이스: 외부 데이터 소스를 추상화한 계약
예시:
interface TodoRepository {
suspend fun getTodos(): List<TodoItem>
}
class GetTodoListUseCase(private val repo: TodoRepository) {
suspend fun execute(): List<TodoItem> = repo.getTodos()
}
🔹 Data 계층
Data 계층은 Repository 인터페이스의 실제 구현을 담당한다.
예를 들어 API 통신, 로컬 DB 접근, 캐싱 로직 등이 이 계층에 들어간다.
예시:
class TodoRepositoryImpl(private val api: TodoApi) : TodoRepository {
override suspend fun getTodos(): List<TodoItem> = api.fetchTodos()
}
이렇게 작성하면 ViewModel이나 UseCase는 TodoRepository 인터페이스만 참조하게 되어, 실제 구현은 교체하거나 테스트하기가 쉬워진다.
🔹 Presentation 계층
이 계층은 Android/iOS 각 플랫폼에서 UI를 담당하는 부분이다.
KMP에서는 shared 모듈 안에 ViewModel을 넣고, 이를 Android나 iOS에서 호출하는 방식으로 많이 설계한다.
- Android: Jetpack Compose + shared ViewModel
- iOS: SwiftUI/UIViewController + shared ViewModel (Skie를 통해)
ViewModel 예시:
class TodoViewModel(
private val useCase: GetTodoListUseCase
) {
private val _todos = MutableStateFlow<List<TodoItem>>(emptyList())
val todos: StateFlow<List<TodoItem>> = _todos
fun loadTodos() {
CoroutineScope(Dispatchers.Default).launch {
_todos.value = useCase.execute()
}
}
}
이렇게 하면 플랫폼마다 UI는 다르지만, ViewModel과 UseCase, Repository는 하나로 통일된 구조로 관리할 수 있다.
Clean Architecture를 KMP에 도입하면 각 플랫폼의 구현과 무관하게 하나의 중심 로직을 기반으로 기능을 확장할 수 있다.
유지보수가 쉬워지고 테스트 가능성이 높아지는 것은 물론, 팀원 간 역할 분담도 명확해진다.
- KMP Clean Architecture 프로젝트 구조
Kotlin Multiplatform에서 Clean Architecture를 적용할 때는 shared 모듈 내부를 계층별로 분리하여 구성하는 것이 좋다. 일반적으로 다음과 같은 디렉토리 구조를 따른다:
📦 shared
┣ 📂 domain # 비즈니스 로직, 인터페이스, UseCase
┣ 📂 data # 실제 구현체(API, DB 등)
┣ 📂 presentation # ViewModel 및 상태관리
┗ 📄 ...
🔹 domain 계층
- 책임: 앱의 핵심 비즈니스 로직 정의
- 구성: Repository 인터페이스, UseCase, Entity
// TodoRepository.kt
interface TodoRepository {
suspend fun getTodos(): List<TodoItem>
}
// GetTodoListUseCase.kt
class GetTodoListUseCase(private val repository: TodoRepository) {
suspend fun execute(): List<TodoItem> = repository.getTodos()
}
이 계층은 외부 라이브러리에 의존하지 않으며, 가장 순수한 로직만 포함한다.
🔹 data 계층
- 책임: domain 계층에서 정의한 Repository 인터페이스 구현
- 구성: API 통신(Ktor), DB 접근, 캐싱 등
// TodoRepositoryImpl.kt
class TodoRepositoryImpl(private val api: TodoApi) : TodoRepository {
override suspend fun getTodos(): List<TodoItem> = api.fetchTodos()
}
외부 의존성을 적극적으로 사용하지만, domain에 영향을 주지 않도록 분리된 구조를 유지한다.
🔹 presentation 계층
- 책임: 플랫폼과 연결되는 진입점
- 구성: ViewModel, StateFlow, UI 상태관리
// TodoViewModel.kt
class TodoViewModel(
private val useCase: GetTodoListUseCase
) {
private val _todos = MutableStateFlow<List<TodoItem>>(emptyList())
val todos: StateFlow<List<TodoItem>> = _todos
fun load() {
CoroutineScope(Dispatchers.Default).launch {
_todos.value = useCase.execute()
}
}
}
이 계층은 Android/iOS의 화면에서 직접 호출되며, 각 플랫폼의 UI와 shared 로직을 연결하는 다리 역할을 한다.
이러한 디렉토리 구조를 따름으로써, 비즈니스 로직, 실제 구현, UI 상태관리를 명확히 분리할 수 있고 테스트 작성이나 기능 추가 시에도 유지보수가 쉬워진다.
- UseCase와 ViewModel 구성
Clean Architecture에서 UseCase는 비즈니스 로직을 담당하는 핵심 단위이며, ViewModel은 UI 계층과 UseCase를 연결하는 역할을 한다.
Kotlin Multiplatform에서는 이 둘을 shared 모듈 안에서 정의하고 Android와 iOS에서 공통으로 사용할 수 있다.
🔹 UseCase 구현 예제
UseCase는 특정한 도메인 기능(예: Todo 리스트 불러오기)을 하나의 클래스에 캡슐화하는 방식으로 작성한다.
// shared/domain/usecase/GetTodoListUseCase.kt
class GetTodoListUseCase(
private val repository: TodoRepository
) {
suspend fun execute(): List<TodoItem> {
return repository.getTodos()
}
}
이렇게 UseCase를 구성하면 테스트하기 쉬우며, 비즈니스 로직을 재사용하거나 조합하기도 쉽다.
🔹 ViewModel 구성 예제 (KMP)
ViewModel은 StateFlow를 통해 UI에 상태를 전달하고, UseCase를 호출하는 중간 계층이다.
// shared/presentation/viewmodel/TodoViewModel.kt
class TodoViewModel(
private val getTodoListUseCase: GetTodoListUseCase
) {
private val _todos = MutableStateFlow<List<TodoItem>>(emptyList())
val todos: StateFlow<List<TodoItem>> = _todos
fun loadTodos() {
CoroutineScope(Dispatchers.Default).launch {
val result = getTodoListUseCase.execute()
_todos.value = result
}
}
}
StateFlow는 Android의 Jetpack Compose, iOS의 SwiftUI에서도 상태 기반 UI 갱신에 적합하다.
🔹 Android에서 ViewModel 연결 예제
Android에서는 Jetpack Compose나 일반 View 시스템에서 shared ViewModel을 직접 사용할 수 있다. Koin을 통해 DI로 주입하면 훨씬 간결하게 관리할 수 있다.
Koin 설정 예시 (shared/di/sharedModule.kt):
val sharedModule = module {
single { GetTodoListUseCase(get()) }
single { TodoViewModel(get()) }
}
AndroidApp/Application.kt:
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@MyApp)
modules(sharedModule)
}
}
}
Activity 또는 Compose에서 사용:
val viewModel: TodoViewModel = getKoin().get()
LaunchedEffect(Unit) {
viewModel.loadTodos()
}
Android에서는 ViewModel을 그대로 가져와서 사용할 수 있고, StateFlow를 관찰하여 화면을 갱신할 수 있다.
이처럼 UseCase와 ViewModel을 shared 모듈에 작성하면 Android와 iOS 모두에서 공통 비즈니스 로직을 재사용하면서도, 각 플랫폼에 맞는 UI를 유지할 수 있다.
- iOS에서 ViewModel 사용하기
Kotlin Multiplatform에서 ViewModel과 UseCase를 iOS에서 활용하기 위해서는 Swift와 Kotlin 간 연동이 매끄러워야 한다.
Skie를 이용하면 Kotlin에서 작성한 ViewModel을 Swift 코드에서 손쉽게 사용할 수 있으며, Koin을 활용한 DI도 충분히 적용 가능하다.
🔹 Skie + Koin + Swift 연동
Kotlin 측 구성
공통 shared 모듈에서 다음과 같이 ViewModel과 UseCase를 정의해 둔다.
class TodoViewModel(
private val useCase: GetTodoListUseCase
) {
private val _todos = MutableStateFlow<List<TodoItem>>(emptyList())
val todos: StateFlow<List<TodoItem>> = _todos
fun load() {
CoroutineScope(Dispatchers.Default).launch {
_todos.value = useCase.execute()
}
}
fun observeTodos(observer: (List<TodoItem>) -> Unit): Closeable {
val job = CoroutineScope(Dispatchers.Default).launch {
todos.collect { observer(it) }
}
return object : Closeable {
override fun close() {
job.cancel()
}
}
}
}
이 구조는 Swift에서도 자연스럽게 사용할 수 있도록 설계되어 있다.
Skie가 observeTodos 함수를 Swift 클로저와 매핑해준다.
Koin 초기화 (shared/commonMain)
fun initKoin() = startKoin {
modules(
module {
single { GetTodoListUseCase(get()) }
single { TodoViewModel(get()) }
}
)
}
이 함수는 Swift에서 앱 시작 시 한 번 호출하면 된다.
🔹 Swift에서 ViewController 연결 예시
Swift에서는 다음과 같이 ViewModel을 가져와서 사용할 수 있다:
import shared
class TodoViewController: UIViewController {
private var viewModel: SharedTodoViewModel?
private var observer: Kotlinx_coroutines_coreCloseable?
override func viewDidLoad() {
super.viewDidLoad()
// Koin 초기화
SharedKoinKt.doInitKoin()
// ViewModel 가져오기
viewModel = SharedKoinKt.getKoin().get(objCClass: SharedTodoViewModel.self) as? SharedTodoViewModel
// 구독 시작
observer = viewModel?.observeTodos { list in
print("받은 Todo 목록: \(list)")
}
// 데이터 로드
viewModel?.load()
}
deinit {
observer?.close()
}
}
observeTodos는 Swift 클로저를 매개로 받아 상태를 계속 구독한다.viewModel?.load()를 호출하면 Kotlin ViewModel의 UseCase가 실행된다.Koin을 통해 ViewModel을 주입받고, Skie 덕분에 Swift에서 타입 안전하게 사용할 수 있다.
이 방식은 KMP의 구조적 이점을 그대로 가져오면서도 Swift에서의 사용성을 극대화할 수 있다.
특히 Skie를 사용하면 Kotlin/Native의 복잡한 C 인터페이스 대신, Swift 친화적인 Kotlin API를 구현할 수 있어 iOS 개발자와의 협업도 훨씬 쉬워진다.
- 테스트 전략
멀티플랫폼 환경에서도 공통 로직에 대한 테스트는 필수다. Clean Architecture 구조에서는 UseCase, Repository 인터페이스, API 통신 등 대부분의 로직이 shared 모듈에 포함되므로, 이 부분에 대한 테스트를 잘 구성해두면 전체 앱의 품질을 안정적으로 유지할 수 있다.
이 장에서는 mockk와 Ktor MockEngine을 이용해 단위 테스트를 구성하는 방법을 다룬다.
🔹 공용 UseCase, Repository 단위 테스트 (mockk 활용)
mockk는 Kotlin에서 가장 널리 쓰이는 모킹(mocking) 라이브러리로, multiplatform에서도 사용할 수 있다.
build.gradle.kts
commonTest {
dependencies {
implementation("io.mockk:mockk-common:1.13.7")
implementation(kotlin("test"))
}
}
예제: UseCase 테스트
class GetTodoListUseCaseTest {
private val fakeRepository = mockk<TodoRepository>()
@BeforeTest
fun setup() {
coEvery { fakeRepository.getTodos() } returns listOf(
TodoItem(1, "Test", false)
)
}
@Test
fun `UseCase should return todo list`() = runTest {
val useCase = GetTodoListUseCase(fakeRepository)
val result = useCase.execute()
assertEquals(1, result.size)
assertEquals("Test", result.first().title)
}
}
coEvery로 suspend 함수 모킹 가능runTest로 코루틴 테스트 안전하게 실행
🔹 API 테스트: Ktor MockEngine
Ktor는 HTTP 통신을 추상화하고 있어, 테스트 환경에서 실제 네트워크를 사용하지 않고도 응답을 시뮬레이션할 수 있다.
의존성 추가:
commonTest {
dependencies {
implementation("io.ktor:ktor-client-mock:2.3.5")
}
}
예제: Ktor API 테스트
@Test
fun `fetchTodos should parse response correctly`() = runTest {
val mockEngine = MockEngine { request ->
respond(
content = "[{\"id\":1,\"title\":\"Hello\",\"isDone\":false}]",
headers = headersOf(HttpHeaders.ContentType, "application/json")
)
}
val client = HttpClient(mockEngine) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}
val api = TodoApi(client)
val todos = api.fetchTodos()
assertEquals(1, todos.size)
assertEquals("Hello", todos.first().title)
}
MockEngine으로 가짜 응답을 지정할 수 있음- 실제 서버와 통신 없이 API 파싱 테스트 가능
🔹 정리
| 테스트 대상 | 전략 | 도구 |
|---|---|---|
| UseCase | Repository 모킹 | mockk |
| Repository | API 의존성 제거 | mockk, fakeImpl |
| API (Ktor client) | HTTP 응답 시뮬레이션 | Ktor MockEngine |
| ViewModel 상태 | StateFlow 값 검증 | runTest + 상태 비교 |
멀티플랫폼 프로젝트에서는 공통 모듈의 테스트가 곧 전체 앱의 품질을 의미한다. KMP에서도 테스트 환경을 잘 구성해두면, 릴리스 전 기능 검증뿐 아니라 리팩토링 시 안정성 확보에도 큰 도움이 된다.
📖 3부. Compose Multiplatform 도입하기
- Compose Multiplatform 소개
Kotlin의 Jetpack Compose는 Android UI 개발의 패러다임을 혁신한 선언형 UI 프레임워크다.
이 Compose를 멀티플랫폼에서도 활용할 수 있게 만든 것이 바로 Compose Multiplatform이다.
Compose Multiplatform은 Android, iOS, 데스크탑, 웹에서 하나의 Kotlin 기반 UI 코드로 다양한 플랫폼에 대응할 수 있게 해주며, JetBrains가 중심이 되어 개발하고 있다.
🔹 Jetpack Compose vs Compose Multiplatform
| 항목 | Jetpack Compose | Compose Multiplatform |
|---|---|---|
| 대상 플랫폼 | Android | Android, iOS, Desktop, Web |
| 개발 주체 | JetBrains | |
| 언어 | Kotlin | Kotlin |
| 렌더링 방식 | Android View 시스템 기반 | Skia 기반 자체 렌더링 |
| 선언형 UI | 지원 | 지원 |
| 플랫폼 통합도 | Android에 최적화됨 | 다중 플랫폼을 타겟으로 함 |
| iOS 네이티브 연동성 | 미지원 | UIKitController 등으로 일부 지원 |
🔹 Compose Multiplatform의 특징
- 코드 재사용: 공통 UI를 Kotlin으로 구성 → Android, iOS, 데스크탑 등에서 동시에 사용
- 디자인 일관성 유지: 하나의 코드로 여러 플랫폼에 동일한 화면 구성 가능
- 프로그래밍 모델 통일: 모든 플랫폼에서 동일한
@ComposableAPI 사용 - 빠른 피드백 루프: Preview나 데스크탑 빌드를 통한 빠른 UI 테스트 가능
🔹 언제 Compose Multiplatform을 도입할까?
- Android + iOS 앱의 UI 스타일 일관성이 중요한 경우
- 데스크탑까지 포함한 앱을 동시에 운영하려는 경우
- 공통 ViewModel, 상태 기반 설계를 활용하는 경우
- iOS UI를 SwiftUI가 아닌 Kotlin 코드로 직접 구성하고자 할 때
단, iOS나 Web에서는 여전히 한계점이 있으므로 MVP나 내부 도구부터 점진적으로 적용하는 것이 추천된다.
Compose Multiplatform은 Kotlin 기반 멀티플랫폼 전략의 핵심 도구로 자리 잡고 있으며, Kotlin/Native 및 Compose UI 시스템과 결합해 강력한 UI 재사용성을 제공한다.
앞으로의 장에서는 Compose Multiplatform을 실제로 프로젝트에 적용하는 방법을 단계별로 살펴볼 예정이다.
- 프로젝트 설정과 구조
Compose Multiplatform을 적용하려면, 기존 KMP 프로젝트 구조에 UI 모듈을 공유하는 구조를 추가해야 한다. 이 장에서는 Compose 기반 UI를 공유하는 방법과 Android, iOS 양쪽에서 이를 어떻게 사용하는지 설명한다.
🔹 UI 공유 모듈 구성
UI는 보통 shared/ui 또는 shared/presentation/ui 디렉토리 아래 @Composable 함수들로 작성한다.
Compose Multiplatform이 포함된 shared 모듈의 build.gradle.kts에는 다음 설정이 필요하다.
shared/build.gradle.kts
plugins {
kotlin("multiplatform")
id("org.jetbrains.compose") version "1.5.0"
}
kotlin {
androidTarget()
iosArm64()
iosSimulatorArm64()
iosX64()
sourceSets {
val commonMain by getting {
dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material)
}
}
val androidMain by getting {
dependencies {
implementation(compose.ui)
implementation(compose.uiTooling)
}
}
val iosMain by creating {
dependsOn(commonMain)
}
}
}
compose {
kotlinCompilerPlugin.set("1.5.0")
}
commonMain에 Compose 관련 의존성을 추가하면 Android와 iOS 모두에서 사용할 수 있다.
🔹 Composable UI 예시
TodoListScreen.kt
@Composable
fun TodoListScreen(viewModel: TodoViewModel) {
val todoList by viewModel.todos.collectAsState()
Column {
todoList.forEach {
Text(text = it.title)
}
}
}
이 UI는 Android와 iOS 모두에서 사용 가능하다.
🔹 Android에서 적용
Android에서는 Activity 혹은 Fragment에서 바로 사용할 수 있다.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val viewModel = getKoin().get<TodoViewModel>()
setContent {
TodoListScreen(viewModel)
}
}
}
Compose는 Android의 View 시스템과 완전히 통합되어 있으므로 추가 설정 없이 바로 작동한다.
🔹 iOS에서 적용
iOS에서는 UIViewController로 Compose UI를 감싼 후 Swift 코드에서 호출할 수 있다.
shared/ui/MainViewController.kt
fun MainViewController(): UIViewController = ComposeUIViewController {
val viewModel = getKoin().get<TodoViewModel>()
TodoListScreen(viewModel)
}
Swift 코드에서 연결
let vc = SharedMainViewControllerKt.MainViewController()
window.rootViewController = vc
이처럼 Compose UI를 UIKit 컨트롤러로 래핑하면 iOS에서 재사용이 가능하다.
Compose Multiplatform은 각 플랫폼의 ViewController나 Activity에 통합될 수 있으므로, 전체 UI를 Compose로 구성하거나 일부 화면만 공유할 수도 있다.
향후에는 SwiftUI 수준의 성능과 호환성이 기대되며, 점진적으로 활용 범위를 넓혀가는 것이 현실적인 접근이다.
- ViewModel과 상태관리
Compose Multiplatform에서 상태관리는 선언형 UI와 매우 밀접한 구조로 작동한다.
특히 shared 모듈에 ViewModel을 작성하고 Android와 iOS 양쪽에서 이를 재사용하면 로직을 통합하고 화면 상태를 일관되게 유지할 수 있다.
🔹 shared ViewModel을 Compose에서 쓰기
공통 ViewModel은 StateFlow 또는 MutableStateFlow를 통해 UI 상태를 노출한다. Compose에서는 이를 collectAsState()로 구독하여 자동으로 화면을 업데이트할 수 있다.
공통 ViewModel 예시:
class TodoViewModel(
private val getTodoListUseCase: GetTodoListUseCase
) {
private val _todos = MutableStateFlow<List<TodoItem>>(emptyList())
val todos: StateFlow<List<TodoItem>> = _todos
fun loadTodos() {
CoroutineScope(Dispatchers.Default).launch {
_todos.value = getTodoListUseCase.execute()
}
}
}
Composable에서 ViewModel 사용:
@Composable
fun TodoListScreen(viewModel: TodoViewModel) {
val todos by viewModel.todos.collectAsState()
LazyColumn {
items(todos) {
Text(text = it.title)
}
}
}
🔹 Navigation 적용
Compose Multiplatform은 Android의 공식 Navigation 컴포넌트는 지원하지 않지만, 상태 기반 분기 또는 라이브러리(Voyager 등)를 사용하여 라우팅할 수 있다.
간단한 상태 기반 라우팅 예시:
enum class Screen {
TodoList, Detail
}
class NavigationViewModel {
private val _screen = MutableStateFlow(Screen.TodoList)
val screen: StateFlow<Screen> = _screen
fun navigateTo(screen: Screen) {
_screen.value = screen
}
}
Composable에서 화면 전환 처리:
@Composable
fun AppRoot(navViewModel: NavigationViewModel, todoViewModel: TodoViewModel) {
val screen by navViewModel.screen.collectAsState()
when (screen) {
Screen.TodoList -> TodoListScreen(todoViewModel)
Screen.Detail -> Text("Detail Screen")
}
}
🔹 iOS에서 shared ViewModel 사용하기
iOS에서는 ComposeUIViewController를 생성하여 ViewModel과 함께 화면을 렌더링할 수 있다.
ViewModel은 StateFlow로 상태를 노출하고, Compose는 이를 iOS에서도 동일한 방식으로 처리할 수 있다.
공통 코드 (shared/ui/MainViewController.kt):
fun MainViewController(): UIViewController = ComposeUIViewController {
val viewModel = getKoin().get<TodoViewModel>()
TodoListScreen(viewModel)
}
Swift 코드에서 ViewController 사용:
import shared
let vc = SharedMainViewControllerKt.MainViewController()
window.rootViewController = vc
ViewModel은 Koin을 통해 주입받을 수 있으며, Swift에서 직접 ViewModel에 접근할 필요 없이 Kotlin UI 안에서 상태관리와 로직이 모두 처리된다.
🔹 정리
- shared ViewModel을 통해 공통 상태를 관리하고, Android/iOS에서 동일하게 사용 가능
- Compose에서
collectAsState()로 ViewModel의StateFlow를 UI에 반영 - Navigation은 상태 기반 분기 혹은 외부 라이브러리를 사용하여 구현
- iOS에서도
ComposeUIViewController를 통해 같은 ViewModel을 사용할 수 있어 완전한 로직 공유가 가능
이 방식은 선언형 UI + KMP 구조의 강점을 극대화하며, 두 플랫폼 모두에서 유지보수가 쉬운 구조를 만든다.
- 실전 예제: Todo 앱
이번 장에서는 지금까지 구성한 Clean Architecture 구조와 라이브러리들을 바탕으로 실전 Todo 앱을 설계한다.
이 앱은 공통 비즈니스 로직은 shared 모듈에, UI는 Android와 iOS 각각에 구성하며, 특히 Android에서는 Compose Multiplatform을 통해 UI까지 공유하는 예시를 보여준다.
🔹 클린 아키텍처 + Compose Multiplatform 구조
전체 프로젝트는 아래와 같은 구조를 따른다:
📦 root
┣ 📂 shared
┃ ┣ 📂 domain # UseCase, Repository 인터페이스
┃ ┣ 📂 data # Repository 구현체, API
┃ ┣ 📂 presentation # ViewModel, 상태관리
┃ ┗ 📂 ui # Compose Multiplatform UI
┣ 📂 androidApp
┣ 📂 iosApp
domain: 비즈니스 규칙 정의data: API와 DB 구현presentation: ViewModel 구성ui: Compose Multiplatform으로 만든 화면- 각 플랫폼에서는 이 구조를 기반으로 앱을 띄운다
🔹 Compose Multiplatform 기반 UI 예시
TodoListScreen.kt
@Composable
fun TodoListScreen(viewModel: TodoViewModel) {
val todoList by viewModel.todos.collectAsState()
LazyColumn {
items(todoList) { todo ->
Text(text = todo.title)
}
}
}
Compose Multiplatform 덕분에 이 UI 코드는 Android와 iOS에서 동시에 사용할 수 있다.
단, iOS에서는 Compose runtime을 연결해주는 플랫폼별 초기화 코드가 필요하다.
🔹 Android에서 실행
androidApp 모듈에서는 아래처럼 사용한다:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val viewModel = getKoin().get<TodoViewModel>()
setContent {
TodoListScreen(viewModel = viewModel)
}
}
}
🔹 iOS에서 실행 (Swift + Compose Multiplatform)
Skie나 Compose Multiplatform의 UIKitController를 활용해서 Swift 화면에 Kotlin UI를 포함할 수 있다.
import shared
let viewController = MainViewControllerKt.MainViewController()
window.rootViewController = viewController
그리고 Kotlin 쪽에서는:
fun MainViewController(): UIViewController = ComposeUIViewController {
val viewModel = getKoin().get<TodoViewModel>()
TodoListScreen(viewModel = viewModel)
}
이 방식은 아직 Compose Multiplatform이 실험적인 단계이긴 하지만, 빠르게 발전하고 있으며 실제 앱에서도 적용이 가능하다.
🔹 결과 요약
- 비즈니스 로직은 Kotlin으로 공유
- ViewModel도 공통으로 구성
- UI까지 Compose로 공유 가능 (Android/iOS)
- iOS에서는 Swift에서 쉽게 연동
이 예제를 바탕으로, 이후 더 복잡한 앱에서도 아키텍처를 재활용하고 기능을 확장하는 기반을 만들 수 있다.
- 멀티플랫폼 UI에서의 한계와 대응 전략
Kotlin Multiplatform과 Compose Multiplatform은 코드 공유 측면에서 강력한 장점을 제공하지만, 실제 제품에 적용할 때는 일부 제약과 한계도 존재한다. 이 장에서는 UI 멀티플랫폼 구성에서 마주치는 대표적인 문제들과, 이를 해결하기 위한 실용적인 대응 전략들을 정리한다.
🔹 플랫폼 차이 대응
❗️문제: UI 컴포넌트의 접근성과 기능 차이
- Android와 iOS는 UI 컴포넌트 구조와 이벤트 처리 방식이 다르다.
- 예를 들어 iOS에서는 스와이프 제스처나 네비게이션 뷰, 스크롤 동작 등이 Android와 차이가 크다.
- Compose Multiplatform은 아직 iOS에서 완전한 UIKit 대체 수준은 아니다.
✅ 대응 전략
- 조건부 컴포저블을 사용하여 플랫폼에 따라 다른 UI를 분기 처리한다.
@Composable
fun PlatformButton(text: String, onClick: () -> Unit) {
if (Platform.isIos()) {
IosStyledButton(text, onClick)
} else {
AndroidStyledButton(text, onClick)
}
}
- expect/actual을 사용해 플랫폼 특화 로직을 분리 구현한다.
- 필요할 경우 Compose를 포기하고, iOS는 SwiftUI/UIViewController로, Android는 Compose로 UI를 개별 구성할 수 있다.
🔹 퍼포먼스와 네이티브 경험
❗️문제: 퍼포먼스와 사용자 경험 저하 가능성
- iOS에서는 Compose runtime이 직접 렌더링을 담당하며 UIKit과 완전히 동일한 수준의 최적화는 아니다.
- 특히 스크롤 성능이나 애니메이션, 접근성 등에서 한계가 있다.
✅ 대응 전략
- 초기 MVP나 기능 단위로 도입하여 범위를 제한한다.
- 스크롤/애니메이션이 핵심 UX인 화면은 iOS 고유 방식으로 구현한다.
- 네이티브 기능이 필요한 부분은 Kotlin/Native + Swift 브리지를 통해 분리 처리한다.
🔹 기타 대응 팁
- 복잡한 UI보다는 비즈니스 중심 화면부터 공유 시작
- 공통 ViewModel + 공통 상태관리로 UI 연결부 단순화
- 앱 전반이 아닌 일부 화면만 Compose MPP로 적용해도 큰 효과
- Compose 자체의 버그나 iOS side effect는 GitHub 이슈와 공식 release note를 수시로 참고
멀티플랫폼 UI는 모든 걸 공유할 수는 없지만, 아키텍처와 핵심 로직을 단일화하고 유지보수를 단순화하는 데 큰 가치를 준다.
현실적인 제약을 이해하고 전략적으로 대응하면, Kotlin Multiplatform은 충분히 실무에 적용할 수 있는 강력한 선택지가 된다.
📎 부록
부록 A: 각 플랫폼별 빌드 스크립트 최적화
멀티플랫폼 프로젝트는 빌드 타겟이 많기 때문에, 빌드 속도와 디버그 편의성을 고려한 설정이 중요하다.
Android
- Gradle 캐시 사용:
gradle.properties에org.gradle.caching=true설정 - Incremental build:
kapt.incremental.apt=true,kotlin.incremental=true설정 - 속도 개선을 위한 병렬 처리:
org.gradle.parallel=true
org.gradle.configureondemand=true
iOS
- 필요한 타겟만 빌드:
./gradlew :shared:assembleDebugIosArm64 //실기기의 경우
./gradlew :shared:assembleDebugIosX64 //x86에뮬레이터의 경우
- Xcode에서 빌드 속도 개선:
XCFramework를 사전 생성하여 재활용 - Skie나 cinterop 시 의존성 충돌 주의
부록 B: Skie 설정 심화 및 Troubleshooting
Skie는 Kotlin 클래스와 Swift 코드 간의 인터페이스를 매끄럽게 연결해주는 도구다.
다만 다음과 같은 점들을 주의해야 한다.
🔹 Skie 설정 예시
Gradle 설정 (build.gradle.kts)
plugins {
id("dev.icerock.skie") version "0.4.0"
}
Skie 활성화
skie {
swiftExport {
visibility = SwiftExportVisibility.Public
}
}
🔹 사용 예제
✅ Flow 사용 예제
class Counter {
private val _count = MutableStateFlow(0)
val count: StateFlow<Int> = _count
fun increment() {
_count.value += 1
}
fun observeCount(observer: (Int) -> Unit): Closeable {
val job = CoroutineScope(Dispatchers.Default).launch {
count.collect {
observer(it)
}
}
return object : Closeable {
override fun close() = job.cancel()
}
}
}
Swift에서 사용
let counter = SharedCounter()
let listener = counter.observeCount { value in
print("Count: \(value)")
}
✅ Sealed class 사용 예제
sealed class Result {
data class Success(val message: String) : Result()
data class Error(val code: Int) : Result()
}
Skie는 Result 타입을 Swift에서 enum-like 구조로 자동 변환하여 사용 가능하게 해준다.
Swift에서 사용
let result: SharedResult = getResult()
switch result {
case let success as SharedResultSuccess:
print(success.message)
case let error as SharedResultError:
print(error.code)
default:
break
}
✅ Enum 사용 예제
enum class Priority {
LOW, MEDIUM, HIGH
}
Swift에서 사용
let level: SharedPriority = .high
✅ suspend fun 사용 예제
class MessageService {
suspend fun fetchMessage(): String {
delay(300)
return "Hello from KMP!"
}
fun fetchMessageWithCallback(callback: (String?, Throwable?) -> Unit) {
CoroutineScope(Dispatchers.Default).launch {
try {
val result = fetchMessage()
callback(result, null)
} catch (e: Throwable) {
callback(null, e)
}
}
}
}
Swift에서 사용
let service = SharedMessageService()
service.fetchMessageWithCallback { message, error in
print(message ?? "Error")
}
자주 발생하는 이슈
- Objective-C symbol 충돌: Swift 클래스와 이름이 중복될 경우 충돌 발생 →
@ObjCName사용 가능 - public API 누락: Kotlin 클래스에
internalscope가 있으면 export 안됨 - iOS에서 타입 캐스팅 문제: Skie는 Swift 타입 추론을 위해 명시적 래핑을 요구하는 경우가 있음
부록 B: Skie 설정 심화 및 Troubleshooting
Skie는 Kotlin Multiplatform 프로젝트에서 Swift와의 연결을 자연스럽게 만들어주는 도구이다.
단순한 클래스 export 뿐 아니라, Flow, Sealed class, Enum, suspend fun 등을 Swift에서 쉽게 사용할 수 있도록 도와준다.
이처럼 Skie를 이용하면 Flow, sealed class, enum, suspend fun 등을 Swift에서도 타입 안정성 있게 사용할 수 있다.
기존 Kotlin/Native 방식보다 훨씬 직관적이며, Swift 개발자와 협업할 때도 학습 부담을 줄일 수 있다.
부록 C: Gradle 버전 관리와 Kotlin DSL
멀티플랫폼에서는 빌드 설정이 복잡해질 수 있으므로, Gradle 설정은 명확하게 관리하는 것이 중요하다.
Gradle 버전 통일
gradle-wrapper.properties에서 버전을 고정:
distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip
Kotlin DSL 사용 시 팁
build.gradle.kts에서는version = "x.y.z"형태로 명시- 모든 버전은 루트
libs.versions.toml에서 통합 관리하면 유지보수 용이
예시: libs.versions.toml
[versions]
kotlin = "1.9.10"
koin = "3.5.0"
ktor = "2.3.5"
[libraries]
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
이 구조를 사용하면 플러그인 및 라이브러리 의존성 버전 변경 시 실수를 줄일 수 있고, 팀 간 협업에서도 코드 일관성을 유지하기 좋다.




0 comments:
댓글 쓰기