2025년 6월 4일 수요일

KMP & CMP

KMP & CMP

Kotlin Multiplatform & Compose Multiplatform 도입 안내서


🧩 목차

📖 1부. Kotlin Multiplatform 입문과 실전 샘플 프로젝트

  1. KMP란 무엇인가?

    • Kotlin Multiplatform의 개념
    • 왜 KMP인가?
    • Android와 iOS 코드 공유 구조
  2. 개발 환경 준비

    • Gradle 설정
    • Android Studio / Xcode 연동
    • 프로젝트 구조 이해
  3. 샘플 프로젝트 만들기

    • 기본 구조 설계: shared, androidApp, iosApp
    • shared 모듈 만들기
    • Android와 iOS 간의 연결
  4. 주요 라이브러리 소개

    • Ktor: 네트워크 통신
      • 기본 설정 및 API 호출 예제
    • Koin: DI(Dependency Injection)
      • shared에서 DI 구성
    • Skie: Swift 연동을 위한 설정
      • Swift에서의 사용 예제

📖 2부. Clean Architecture로 확장하기

  1. Clean Architecture의 개념

    • Domain, UseCase, Repository, Data 계층 설명
  2. KMP Clean Architecture 프로젝트 구조

    • shared/domain, shared/data, shared/presentation 모듈 구성
  3. UseCase와 ViewModel 구성

    • UseCase 구현 예제
    • Android에서 ViewModel 연결
  4. iOS에서 ViewModel 사용하기

    • Skie + Koin + Swift 연동
    • iOS ViewController에서 UseCase 호출 예제
  5. 테스트 전략

    • 공용 UseCase 단위 테스트
    • Ktor MockEngine을 활용한 테스트

📖 3부. Compose Multiplatform 도입하기

  1. Compose Multiplatform 소개

    • Jetpack Compose vs Compose Multiplatform
  2. 프로젝트 설정과 구조

    • UI 공유 모듈 구성
    • Android, iOS에서 UI 적용
  3. ViewModel과 상태관리

    • shared ViewModel을 Compose에서 쓰기
    • Navigation 적용
  4. 실전 예제: Todo 앱

    • 클린 아키텍처 + Compose Multiplatform
    • iOS/Android에서 실행 가능한 UI 구성
  5. 멀티플랫폼 UI에서의 한계와 대응 전략

    • 플랫폼 차이 대응
    • 퍼포먼스와 네이티브 경험

📎 부록

  • 부록 A: 각 플랫폼별 빌드 스크립트 최적화
  • 부록 B: Skie 설정 심화 및 Troubleshooting
  • 부록 C: Gradle 버전 관리와 Kotlin DSL


📖 1부. Kotlin Multiplatform 입문과 실전 샘플 프로젝트

  1. 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 사용)

androidMainiosMain에는 실제 플랫폼별 구현체가 들어가며, 이 구조를 통해 Android와 iOS 모두 같은 인터페이스를 기반으로 작동하게 된다.

KMP는 아직 발전 중이지만, JetBrains와 커뮤니티의 활발한 지원 덕분에 점점 더 안정화되고 있다. 특히 비즈니스 로직 공유를 통해 생산성을 높이고자 하는 스타트업이나 소규모 팀에게 매우 유용한 선택지가 될 수 있다.


  1. 개발 환경 준비

Kotlin Multiplatform 프로젝트를 시작하려면 먼저 개발 환경을 세팅해야 한다. Android와 iOS를 모두 타겟으로 하기 때문에, 두 플랫폼의 개발 도구가 모두 필요하다.


🔹 Gradle 설정

KMP 프로젝트는 Gradle 기반으로 구성된다. 아래는 기본적인 settings.gradle.ktsbuild.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 연동 방법 요약:

  1. shared 모듈을 빌드해서 .framework 생성
    ./gradlew :shared:assembleReleaseXCFramework
    
  2. 생성된 shared.xcframework를 Xcode 프로젝트에 포함
  3. 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를 모두 관리해야 하는 경우, 이 구조는 코드 중복을 줄이고 일관된 로직을 유지하는 데 큰 도움이 된다.

  1. 샘플 프로젝트 만들기

이 장에서는 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, 테스트 코드 설정까지 자동으로 구성되므로 처음 시작하는 사람에게 매우 유용하다.

  1. 주요 라이브러리 소개

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로 확장하기

  1. 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에 도입하면 각 플랫폼의 구현과 무관하게 하나의 중심 로직을 기반으로 기능을 확장할 수 있다.
유지보수가 쉬워지고 테스트 가능성이 높아지는 것은 물론, 팀원 간 역할 분담도 명확해진다.

  1. 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 상태관리를 명확히 분리할 수 있고 테스트 작성이나 기능 추가 시에도 유지보수가 쉬워진다.

  1. 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를 관찰하여 화면을 갱신할 수 있다.


이처럼 UseCaseViewModelshared 모듈에 작성하면 Android와 iOS 모두에서 공통 비즈니스 로직을 재사용하면서도, 각 플랫폼에 맞는 UI를 유지할 수 있다.

  1. 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 개발자와의 협업도 훨씬 쉬워진다.

  1. 테스트 전략

멀티플랫폼 환경에서도 공통 로직에 대한 테스트는 필수다. Clean Architecture 구조에서는 UseCase, Repository 인터페이스, API 통신 등 대부분의 로직이 shared 모듈에 포함되므로, 이 부분에 대한 테스트를 잘 구성해두면 전체 앱의 품질을 안정적으로 유지할 수 있다.

이 장에서는 mockkKtor 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 도입하기

  1. 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
개발 주체 Google JetBrains
언어 Kotlin Kotlin
렌더링 방식 Android View 시스템 기반 Skia 기반 자체 렌더링
선언형 UI 지원 지원
플랫폼 통합도 Android에 최적화됨 다중 플랫폼을 타겟으로 함
iOS 네이티브 연동성 미지원 UIKitController 등으로 일부 지원

🔹 Compose Multiplatform의 특징

  • 코드 재사용: 공통 UI를 Kotlin으로 구성 → Android, iOS, 데스크탑 등에서 동시에 사용
  • 디자인 일관성 유지: 하나의 코드로 여러 플랫폼에 동일한 화면 구성 가능
  • 프로그래밍 모델 통일: 모든 플랫폼에서 동일한 @Composable API 사용
  • 빠른 피드백 루프: 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을 실제로 프로젝트에 적용하는 방법을 단계별로 살펴볼 예정이다.

  1. 프로젝트 설정과 구조

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 수준의 성능과 호환성이 기대되며, 점진적으로 활용 범위를 넓혀가는 것이 현실적인 접근이다.

  1. 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 구조의 강점을 극대화하며, 두 플랫폼 모두에서 유지보수가 쉬운 구조를 만든다.

  1. 실전 예제: 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에서 쉽게 연동

이 예제를 바탕으로, 이후 더 복잡한 앱에서도 아키텍처를 재활용하고 기능을 확장하는 기반을 만들 수 있다.

  1. 멀티플랫폼 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.propertiesorg.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 클래스에 internal scope가 있으면 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:

댓글 쓰기