Kotlin Multiplatform과 Compose Multiplatform: iOS/Android 코드 공유

모바일

Kotlin MultiplatformCompose MultiplatformKMPiOSAndroid

이 글은 누구를 위한 것인가

  • iOS/Android 코드베이스를 통합하고 중복을 줄이려는 팀
  • Kotlin Multiplatform으로 비즈니스 로직을 공유하려는 개발자
  • Compose Multiplatform으로 UI까지 공유하려는 팀

들어가며

"iOS 팀이 만든 기능을 Android에도 그대로 구현해야 한다" — KMP는 비즈니스 로직(API 호출, DB, 검증)을 Kotlin으로 한 번 작성하고 iOS/Android에서 공유한다. Compose Multiplatform은 UI까지 공유해 진정한 코드 재사용을 가능하게 한다.

이 글은 bluefoxdev.kr의 Kotlin Multiplatform Compose 가이드 를 참고하여 작성했습니다.


1. KMP 아키텍처

[모듈 구조]
  shared/
    commonMain/  ← iOS + Android 공통
      data/      (Ktor, SQLDelight)
      domain/    (비즈니스 로직)
      ui/        (Compose Multiplatform)
    iosMain/     ← iOS 전용 (expect/actual)
    androidMain/ ← Android 전용

[expect/actual 패턴]
  commonMain: expect fun getPlatformName(): String
  androidMain: actual fun getPlatformName() = "Android"
  iosMain: actual fun getPlatformName() = "iOS"

[공유 가능한 것]
  네트워킹: Ktor
  로컬 DB: SQLDelight
  직렬화: kotlinx.serialization
  날짜: kotlinx-datetime
  코루틴: kotlinx.coroutines
  DI: Koin (멀티플랫폼)
  UI: Compose Multiplatform (iOS 베타)

[플랫폼 전용]
  iOS: SwiftUI 통합, CoreBluetooth, NFC
  Android: Android View, 권한, 알림

2. KMP 구현

// shared/commonMain/kotlin/data/ProductRepository.kt
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.flow.Flow
import kotlinx.serialization.Serializable

@Serializable
data class Product(
    val id: String,
    val name: String,
    val price: Double,
    val category: String
)

class ProductRepository(private val db: ProductDatabase) {
    private val client = HttpClient {
        install(ContentNegotiation) { json() }
    }
    
    // 네트워크 + 로컬 캐시 (Offline First)
    suspend fun getProducts(): List<Product> {
        return try {
            val products = client.get("https://api.example.com/products").body<List<Product>>()
            db.productQueries.insertAll(products.map { it.toEntity() })
            products
        } catch (e: Exception) {
            db.productQueries.selectAll().executeAsList().map { it.toProduct() }
        }
    }
    
    fun observeProducts(): Flow<List<Product>> {
        return db.productQueries.selectAll().asFlow().mapToList()
    }
}

// shared/commonMain/kotlin/presentation/ProductViewModel.kt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch

class ProductViewModel(
    private val repository: ProductRepository,
    private val scope: CoroutineScope
) {
    private val _uiState = MutableStateFlow(ProductUiState())
    val uiState: StateFlow<ProductUiState> = _uiState.asStateFlow()
    
    init {
        loadProducts()
    }
    
    fun loadProducts() {
        scope.launch {
            _uiState.update { it.copy(isLoading = true) }
            try {
                val products = repository.getProducts()
                _uiState.update { it.copy(products = products, isLoading = false) }
            } catch (e: Exception) {
                _uiState.update { it.copy(error = e.message, isLoading = false) }
            }
        }
    }
}

data class ProductUiState(
    val products: List<Product> = emptyList(),
    val isLoading: Boolean = false,
    val error: String? = null
)

// shared/commonMain/kotlin/ui/ProductListScreen.kt (Compose Multiplatform)
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier

@Composable
fun ProductListScreen(viewModel: ProductViewModel) {
    val state by viewModel.uiState.collectAsState()
    
    when {
        state.isLoading -> CircularProgressIndicator()
        state.error != null -> Text("오류: ${state.error}")
        else -> LazyColumn {
            items(state.products) { product ->
                ProductCard(product = product)
            }
        }
    }
}

@Composable
fun ProductCard(product: Product) {
    Card(modifier = Modifier) {
        ListItem(
            headlineContent = { Text(product.name) },
            supportingContent = { Text("₩${product.price.toLong()}") },
            trailingContent = { Badge { Text(product.category) } }
        )
    }
}

// expect/actual: 플랫폼 특화 기능
// commonMain
expect class PlatformLogger() {
    fun log(message: String)
}

// androidMain
actual class PlatformLogger actual constructor() {
    actual fun log(message: String) = android.util.Log.d("KMP", message)
}

// iosMain
actual class PlatformLogger actual constructor() {
    actual fun log(message: String) = println("[KMP] $message")
}

// iOS에서 Kotlin 모듈 사용 (Swift)
// import shared
// let viewModel = ProductViewModel(repository: ..., scope: ...)
// viewModel.uiState.collect { state in ... }

마무리

KMP의 핵심은 "비즈니스 로직을 한 번만 작성"이다. Ktor + SQLDelight + ViewModel을 commonMain에 구현하면 iOS/Android가 동일한 데이터 레이어를 사용한다. Compose Multiplatform은 UI까지 공유하지만 iOS 지원이 안정화 단계라 프로덕션에는 신중하게 검토해야 한다. expect/actual로 플랫폼 전용 기능을 깔끔하게 분리한다.