Communitygithub.com

Kotlin Multiplatform (KMP) 기초

Android, iOS, 데스크톱, 웹(WASM) 및 서버에서 코드를 공유할 수 있도록 하는 Kotlin Multiplatform의 기본 개념을 설명합니다.

Kotlin Multiplatform (KMP) 기초란 무엇인가요?

이 스킬은 Kotlin Multiplatform(KMP) 프로젝트의 핵심 구조와 설정 방법을 상세히 제공합니다. 멀티 모듈 아키텍처, api/impl 패턴, commonMain과 플랫폼별 소스 세트 계층 구조, Gradle 설정(버전 카탈로그 포함), expect/actual 패턴을 통한 플랫폼별 구현, 의존성 규칙, 그리고 공통 Result 타입 및 Repository 패턴과 같은 일반적인 디자인 패턴을 다룹니다. 또한 공유 테스트와 플랫폼별 테스트 전략을 포함한 테스트 방법론도 설명합니다. 이 가이드는 새로운 KMP 프로젝트를 설정하거나 기존 프로젝트의 구조를 이해하려는 모든 개발자에게 필수적인 참고 자료입니다. 설명된 패턴은 Android, iOS, 데스크톱, WASM을 포함한 모든 타겟에서 일관된 비즈니스 로직 공유와 클린한 모듈 경계를 가능하게 합니다.

지원 대상✓Claude Code~Codex CLI~Cursor
npx skills add ImL1s/flutter-claude-skills

즐겨 사용하는 AI에게 물어보기

이 에이전트 스킬이 미리 로드된 새 채팅을 엽니다.

문서

Kotlin Multiplatform (KMP) 기초은(는) 무엇을 하나요?

Kotlin Multiplatform은 Android, iOS, 데스크톱, 웹(WASM) 및 서버에서 코드를 공유할 수 있게 해줍니다.

프로젝트 구조

멀티 모듈 아키텍처 (Feature 기반 + api/impl)

your-project-admin/
├── build.gradle.kts              # 루트 빌드 설정
├── settings.gradle.kts           # 모듈 포함
├── gradle/libs.versions.toml     # 버전 카탈로그
│
├── core/
│   ├── common/                   # 유틸리티, Result 타입, 확장 함수
│   │   └── src/commonMain/kotlin/
│   ├── data/                     # 데이터 추상화, DataStore
│   │   ├── src/commonMain/kotlin/
│   │   ├── src/androidMain/kotlin/
│   │   └── src/iosMain/kotlin/
│   ├── database/                 # Room (Android/iOS/JVM 전용)
│   │   └── src/commonMain/kotlin/
│   ├── network/                  # Ktor 클라이언트
│   │   └── src/commonMain/kotlin/
│   └── ui/                       # 디자인 시스템, 테마
│       └── src/commonMain/kotlin/
│
├── feature/
│   ├── auth/
│   │   ├── api/                  # 공개 인터페이스, 모델
│   │   │   └── src/commonMain/kotlin/
│   │   └── impl/                 # 구현, UI
│   │       └── src/commonMain/kotlin/
│   └── home/
│       ├── api/
│       └── impl/
│
├── composeApp/                   # 플랫폼 진입점
│   ├── src/commonMain/           # 앱 구성, DI 그래프
│   ├── src/androidMain/          # MainActivity
│   ├── src/iosMain/              # iOS 진입점
│   ├── src/jvmMain/              # 데스크톱 main()
│   └── src/wasmJsMain/           # 웹 진입점
│
└── iosApp/                       # Xcode 프로젝트

소스 세트 계층 구조

commonMain
├── androidMain
├── iosMain
│   ├── iosX64Main
│   ├── iosArm64Main
│   └── iosSimulatorArm64Main
├── jvmMain
├── wasmJsMain
└── jsMain (폴백)

Gradle 설정

루트 build.gradle.kts

plugins {
    alias(libs.plugins.kotlinMultiplatform) apply false
    alias(libs.plugins.androidApplication) apply false
    alias(libs.plugins.androidLibrary) apply false
    alias(libs.plugins.composeMultiplatform) apply false
    alias(libs.plugins.composeCompiler) apply false
    alias(libs.plugins.kotlinSerialization) apply false
    alias(libs.plugins.ksp) apply false
    alias(libs.plugins.room) apply false
    alias(libs.plugins.metro) apply false
}

settings.gradle.kts

pluginManagement {
    repositories {
        google()
        mavenCentral()
        gradlePluginPortal()
    }
}

dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
    }
}

rootProject.name = "your-project-admin"

// 코어 모듈
include(":core:common")
include(":core:data")
include(":core:database")
include(":core:network")
include(":core:ui")

// Feature 모듈
include(":feature:auth:api")
include(":feature:auth:impl")
include(":feature:home:api")
include(":feature:home:impl")

// 앱 진입점
include(":composeApp")

gradle/libs.versions.toml

[versions]
kotlin = "2.1.0"
agp = "8.7.3"
compose-multiplatform = "1.7.3"
ktor = "3.1.1"
room = "2.8.4"
datastore = "1.2.0"
decompose = "3.5.0"
metro = "0.1.1"
essenty = "2.5.0"
coroutines = "1.10.1"
serialization = "1.7.3"
ksp = "2.1.0-1.0.29"

[libraries]
# Kotlin
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" }

# Ktor
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }

# Room (Android, iOS, JVM 전용)
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version = "2.6.2" }

# DataStore
datastore-preferences-core = { module = "androidx.datastore:datastore-preferences-core", version.ref = "datastore" }

# Decompose
decompose = { module = "com.arkivanov.decompose:decompose", version.ref = "decompose" }
decompose-compose = { module = "com.arkivanov.decompose:extensions-compose", version.ref = "decompose" }
essenty-lifecycle = { module = "com.arkivanov.essenty:lifecycle", version.ref = "essenty" }

[plugins]
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidLibrary = { id = "com.android.library", version.ref = "agp" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
room = { id = "androidx.room", version.ref = "room" }
metro = { id = "dev.zacsweers.metro", version.ref = "metro" }

모듈 build.gradle.kts (KMP 라이브러리)

// core/common/build.gradle.kts
plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.androidLibrary)
}

kotlin {
    androidTarget {
        compilations.all {
            kotlinOptions {
                jvmTarget = "17"
            }
        }
    }

    listOf(
        iosX64(),
        iosArm64(),
        iosSimulatorArm64()
    ).forEach { iosTarget ->
        iosTarget.binaries.framework {
            baseName = "CoreCommon"
            isStatic = true
        }
    }

    jvm("desktop")

    @OptIn(ExperimentalWasmDsl::class)
    wasmJs {
        browser()
    }

    sourceSets {
        commonMain.dependencies {
            implementation(libs.kotlinx.coroutines.core)
        }

        androidMain.dependencies {
            // Android 전용
        }

        iosMain.dependencies {
            // iOS 전용
        }

        val desktopMain by getting {
            dependencies {
                // 데스크톱 전용
            }
        }
    }
}

android {
    namespace = "com.your-project.admin.core.common"
    compileSdk = 35

    defaultConfig {
        minSdk = 24
    }

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
}

expect/actual 패턴

선언 (commonMain)

// commonMain/kotlin/Platform.kt
expect class PlatformContext

expect fun getPlatformName(): String

expect fun createDataStorePath(context: PlatformContext): String

Android 구현

// androidMain/kotlin/Platform.android.kt
actual typealias PlatformContext = android.content.Context

actual fun getPlatformName(): String = "Android ${android.os.Build.VERSION.SDK_INT}"

actual fun createDataStorePath(context: PlatformContext): String {
    return context.filesDir.resolve("datastore").absolutePath
}

iOS 구현

// iosMain/kotlin/Platform.ios.kt
import platform.Foundation.NSDocumentDirectory
import platform.Foundation.NSFileManager
import platform.Foundation.NSUserDomainMask

actual class PlatformContext

actual fun getPlatformName(): String = "iOS"

actual fun createDataStorePath(context: PlatformContext): String {
    val documentDir = NSFileManager.defaultManager.URLForDirectory(
        NSDocumentDirectory,
        NSUserDomainMask,
        null,
        false,
        null
    )
    return "${documentDir?.path}/datastore"
}

데스크톱 구현

// desktopMain/kotlin/Platform.jvm.kt
import java.io.File

actual class PlatformContext

actual fun getPlatformName(): String =
    "${System.getProperty("os.name")} ${System.getProperty("os.version")}"

actual fun createDataStorePath(context: PlatformContext): String {
    val home = System.getProperty("user.home")
    return File(home, ".your-project-admin/datastore").absolutePath
}

WASM 구현

// wasmJsMain/kotlin/Platform.wasmJs.kt
actual class PlatformContext

actual fun getPlatformName(): String = "Web (WASM)"

actual fun createDataStorePath(context: PlatformContext): String {
    return "your-project-admin-datastore"  // localStorage 사용
}

모듈 의존성

의존성 규칙

composeApp
├── feature:auth:impl
│   ├── feature:auth:api
│   ├── core:ui
│   └── core:network
├── feature:home:impl
│   ├── feature:home:api
│   ├── core:ui
│   └── core:database
├── core:ui
│   └── core:common
├── core:network
│   └── core:common
├── core:database
│   └── core:common
└── core:data
    └── core:common

api/impl 패턴

// feature/auth/api/build.gradle.kts
plugins {
    alias(libs.plugins.kotlinMultiplatform)
}

kotlin {
    // 타겟...

    sourceSets {
        commonMain.dependencies {
            // 모델과 인터페이스만 - 구현은 없음
            api(projects.core.common)
        }
    }
}

// feature/auth/impl/build.gradle.kts
plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.composeMultiplatform)
    alias(libs.plugins.composeCompiler)
}

kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation(projects.feature.auth.api)
            implementation(projects.core.ui)
            implementation(projects.core.network)
            implementation(libs.decompose)
            implementation(libs.decompose.compose)
        }
    }
}

일반적인 패턴

Result 타입

// core/common/src/commonMain/kotlin/Result.kt
sealed class AppResult<out T> {
    data class Success<T>(val data: T) : AppResult<T>()
    data class Error(val message: String, val cause: Throwable? = null) : AppResult<Nothing>()
    data object Loading : AppResult<Nothing>()
}

inline fun <T, R> AppResult<T>.map(transform: (T) -> R): AppResult<R> = when (this) {
    is AppResult.Success -> AppResult.Success(transform(data))
    is AppResult.Error -> this
    is AppResult.Loading -> this
}

inline fun <T> AppResult<T>.onSuccess(action: (T) -> Unit): AppResult<T> {
    if (this is AppResult.Success) action(data)
    return this
}

inline fun <T> AppResult<T>.onError(action: (String, Throwable?) -> Unit): AppResult<T> {
    if (this is AppResult.Error) action(message, cause)
    return this
}

Repository 인터페이스 (api 모듈)

// feature/auth/api/src/commonMain/kotlin/AuthRepository.kt
interface AuthRepository {
    suspend fun login(email: String, password: String): AppResult<User>
    suspend fun logout(): AppResult<Unit>
    fun observeAuthState(): Flow<AuthState>
}

data class User(
    val id: String,
    val email: String,
    val name: String
)

sealed class AuthState {
    data object Unauthenticated : AuthState()
    data class Authenticated(val user: User) : AuthState()
}

Repository 구현 (impl 모듈)

// feature/auth/impl/src/commonMain/kotlin/AuthRepositoryImpl.kt
class AuthRepositoryImpl(
    private val apiService: AuthApiService,
    private val tokenStorage: TokenStorage
) : AuthRepository {

    private val _authState = MutableStateFlow<AuthState>(AuthState.Unauthenticated)

    override suspend fun login(email: String, password: String): AppResult<User> {
        return try {
            val response = apiService.login(LoginRequest(email, password))
            tokenStorage.saveToken(response.token)
            _authState.value = AuthState.Authenticated(response.user)
            AppResult.Success(response.user)
        } catch (e: Exception) {
            AppResult.Error("로그인 실패: ${e.message}", e)
        }
    }

    override suspend fun logout(): AppResult<Unit> {
        tokenStorage.clearToken()
        _authState.value = AuthState.Unauthenticated
        return AppResult.Success(Unit)
    }

    override fun observeAuthState(): Flow<AuthState> = _authState.asStateFlow()
}

런타임 플랫폼 확인

// expect/actual이 과할 때 런타임 확인 사용
enum class Platform {
    Android, iOS, Desktop, Web
}

expect val currentPlatform: Platform

// androidMain
actual val currentPlatform: Platform = Platform.Android

// iosMain
actual val currentPlatform: Platform = Platform.iOS

// desktopMain
actual val currentPlatform: Platform = Platform.Desktop

// wasmJsMain
actual val currentPlatform: Platform = Platform.Web

// 사용법
@Composable
fun AdaptiveComponent() {
    when (currentPlatform) {
        Platform.Android -> AndroidSpecificUI()
        Platform.iOS -> IOSSpecificUI()
        Platform.Desktop -> DesktopSpecificUI()
        Platform.Web -> WebSpecificUI()
    }
}

테스트

공유 테스트 (commonTest)

// core/common/src/commonTest/kotlin/ResultTest.kt
class ResultTest {
    @Test
    fun `map은 성공 값을 변환한다` {
        val result: AppResult<Int> = AppResult.Success(5)
        val mapped = result.map { it * 2 }

        assertTrue(mapped is AppResult.Success)
        assertEquals(10, (mapped as AppResult.Success).data)
    }

    @Test
    fun `map은 에러를 유지한다` {
        val result: AppResult<Int> = AppResult.Error("test error")
        val mapped = result.map { it * 2 }

        assertTrue(mapped is AppResult.Error)
    }
}

플랫폼별 테스트

// androidTest - Robolectric 또는 계측 테스트 사용
// iosTest - 시뮬레이터에서 실행
// jvmTest - 표준 JUnit

모범 사례

권장 사항

  • 가능한 많은 코드를 commonMain에 배치하세요
  • 플랫폼 API에 대해서만 expect/actual을 사용하세요
  • 플랫폼별 구현을 최소로 유지하세요
  • 플랫폼 의존성에는 의존성 주입을 사용하세요

관련 스킬