KMP의 공유 모듈 아키텍처: Koin을 통한 예상/실제, 인터페이스 및 종속성 주입
Kotlin Multiplatform 프로젝트의 공유 모듈 아키텍처와 차이점
시간이 지남에 따라 확장되는 유지 관리 가능한 애플리케이션과 테스트하기 어려운 코드 엉망
그리고 편집하세요. 메커니즘 expect/actual 강력하지만 규율이 필요합니다.
올바르게 사용됨: 모든 expect 위치가 잘못되면 관리하기 어려운 종속성이 생성됩니다.
이 가이드는 상황에 맞게 SOLID 원칙을 적용하여 공유 양식을 구성하는 방법을 보여줍니다. 멀티플랫폼: 인터페이스를 사용하여 플랫폼별 종속성을 분리하는 방법, 구성 방법 Android 및 iOS에서 작동하는 종속성 주입을 위한 Koin 및 테스트 가능한 코드를 얻는 방법 격리됨 — 강력한 테스트 스위트의 핵심입니다.
무엇을 배울 것인가
- 공유 모듈의 레이어 아키텍처: 도메인, 데이터, 프리젠테이션
- 예상/실제: 피해야 할 올바른 패턴과 안티 패턴
- 플랫폼별 종속성을 격리하는 인터페이스
- 멀티플랫폼 종속성 주입을 위한 Koin
- 격리된 공유 모듈 테스트
공유 모듈의 계층 아키텍처
예상/실제에 대해 이야기하기 전에 공유 모듈의 구조를 정의하는 것이 중요합니다. 깔끔한 아키텍처는 책임을 잘 정의된 3개의 계층으로 분리합니다. 종속성 규칙:
// Struttura raccomandata per il modulo condiviso
shared/src/commonMain/kotlin/
└── com.example.app/
├── domain/ # Layer 1: Regole business (zero dipendenze esterne)
│ ├── model/
│ │ ├── User.kt
│ │ └── Product.kt
│ ├── repository/
│ │ └── UserRepository.kt # Interfacce (non implementazioni!)
│ └── usecase/
│ ├── GetUsersUseCase.kt
│ └── SaveUserUseCase.kt
│
├── data/ # Layer 2: Accesso ai dati
│ ├── network/
│ │ ├── UserApiClient.kt # Ktor HTTP client
│ │ └── dto/ # Data Transfer Objects
│ ├── local/
│ │ └── UserLocalDataSource.kt # SQLDelight
│ └── repository/
│ └── UserRepositoryImpl.kt
│
├── presentation/ # Layer 3: ViewModels e state
│ └── UserListViewModel.kt
│
└── di/ # Dependency Injection modules
├── CommonModule.kt
└── PlatformModule.kt # expect (implementazione platform-specific)
La 종속성 규칙 기본: 각 레이어는 레이어에만 의존할 수 있습니다. 인테리어. 도메인 계층은 Ktor, SQLDelight 또는 Android/iOS에 대해 아무것도 모릅니다. 데이터 계층 도메인 레이어 인터페이스를 구현합니다. 프리젠테이션 계층은 도메인의 사용 사례를 사용합니다.
기대/실제의 올바른 패턴
expect/actual 위해 사용해야합니다 구현 플랫폼별,
비즈니스 API에는 적합하지 않습니다. 적합한 장소 expect 인프라 부분에서는
(데이터베이스 드라이버, HTTP 엔진, 파일 시스템 액세스), 템플릿이나 사용 사례에는 없습니다.
// CORRETTO: expect per infrastruttura platform-specific
// commonMain/kotlin/com/example/app/data/local/DatabaseDriverFactory.kt
expect class DatabaseDriverFactory(context: Any? = null) {
fun create(): SqlDriver
}
// androidMain: usa Android SQLite
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import android.content.Context
actual class DatabaseDriverFactory(private val context: Any? = null) {
actual fun create(): SqlDriver {
return AndroidSqliteDriver(
AppDatabase.Schema,
context as Context,
"app.db"
)
}
}
// iosMain: usa Native SQLite
import app.cash.sqldelight.driver.native.NativeSqliteDriver
actual class DatabaseDriverFactory(private val context: Any? = null) {
actual fun create(): SqlDriver {
return NativeSqliteDriver(AppDatabase.Schema, "app.db")
}
}
// CORRETTO: expect per funzionalita OS-specific senza logica business
// commonMain
expect fun getCurrentTimestamp(): Long
expect fun getDeviceLocale(): String
// androidMain
actual fun getCurrentTimestamp(): Long = System.currentTimeMillis()
actual fun getDeviceLocale(): String = java.util.Locale.getDefault().toLanguageTag()
// iosMain
import platform.Foundation.NSDate
import platform.Foundation.NSLocale
actual fun getCurrentTimestamp(): Long = (NSDate().timeIntervalSince1970 * 1000).toLong()
actual fun getDeviceLocale(): String = NSLocale.currentLocale.localeIdentifier
// SBAGLIATO: expect per logica business (anti-pattern)
// Non fare questo: la logica business deve essere in commonMain
expect fun calculateDiscount(price: Double, percentage: Int): Double
// Questo NON ha senso come expect perche non varia per piattaforma!
종속성을 격리하기 위한 인터페이스
생각보다 깔끔한 패턴 expect/actual 대부분의 경우
그리고 사용 Koin을 통한 플랫폼별 구현을 갖춘 commonMain의 인터페이스.
이 접근 방식은 예상/실제를 요구하지 않으며 모의 테스트를 통해 코드를 더 쉽게 테스트할 수 있게 해줍니다.
// commonMain: interfaccia nel domain layer
interface PlatformFileStorage {
suspend fun readFile(path: String): ByteArray?
suspend fun writeFile(path: String, data: ByteArray)
suspend fun deleteFile(path: String)
suspend fun listFiles(directory: String): List<String>
fun getStorageRoot(): String
}
// commonMain: use case che dipende dall'interfaccia (non dall'implementazione)
class ExportDataUseCase(
private val storage: PlatformFileStorage,
private val serializer: DataSerializer
) {
suspend fun execute(data: AppData, fileName: String): String {
val bytes = serializer.serialize(data)
val path = "${storage.getStorageRoot()}/$fileName"
storage.writeFile(path, bytes)
return path
}
}
// androidMain: implementazione Android
class AndroidFileStorage(private val context: Context) : PlatformFileStorage {
override suspend fun readFile(path: String): ByteArray? =
withContext(Dispatchers.IO) {
runCatching { File(path).readBytes() }.getOrNull()
}
override suspend fun writeFile(path: String, data: ByteArray) =
withContext(Dispatchers.IO) {
File(path).apply { parentFile?.mkdirs() }.writeBytes(data)
}
override fun getStorageRoot(): String = context.filesDir.absolutePath
// ... altri metodi
}
// iosMain: implementazione iOS
class IosFileStorage : PlatformFileStorage {
override suspend fun readFile(path: String): ByteArray? =
withContext(Dispatchers.Default) {
NSData.dataWithContentsOfFile(path)?.toByteArray()
}
override fun getStorageRoot(): String {
val docs = NSSearchPathForDirectoriesInDomains(
NSDocumentDirectory, NSUserDomainMask, true
).first() as String
return docs
}
// ... altri metodi
}
의존성 주입 멀티플랫폼을 위한 Koin
코인 KMP에 가장 적합한 종속성 주입 프레임워크는 다음과 같습니다. 순수 Kotlin은 리플렉션을 사용하지 않으며(iOS의 Kotlin/Native에 매우 중요) 간단한 API를 가지고 있습니다. DSL 기반. 구성에는 공통 모듈과 플랫폼별 모듈이 필요합니다.
// commonMain/kotlin/com/example/app/di/CommonModule.kt
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
val domainModule = module {
// Use cases: dipendono da interfacce, non da implementazioni
singleOf(::GetUsersUseCase)
singleOf(::SaveUserUseCase)
singleOf(::ExportDataUseCase)
}
val dataModule = module {
// Repository: usa l'interfaccia del domain
single<UserRepository> { UserRepositoryImpl(get(), get()) }
// Network client condiviso
single {
HttpClient {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
isLenient = true
})
}
install(HttpTimeout) {
requestTimeoutMillis = 30_000
connectTimeoutMillis = 10_000
}
}
}
}
// expect: il modulo platform-specific deve essere fornito da ogni piattaforma
expect val platformModule: Module
// androidMain/kotlin/com/example/app/di/PlatformModule.android.kt
actual val platformModule = module {
// Android Context via androidContext() helper
single<PlatformFileStorage> { AndroidFileStorage(androidContext()) }
single { DatabaseDriverFactory(androidContext()).create() }
single { AppDatabase(get()) }
}
// androidMain: inizializzazione Koin in Application class
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@MyApplication)
modules(domainModule, dataModule, platformModule)
}
}
}
// iosMain/kotlin/com/example/app/di/PlatformModule.ios.kt
actual val platformModule = module {
single<PlatformFileStorage> { IosFileStorage() }
single { DatabaseDriverFactory().create() }
single { AppDatabase(get()) }
}
// iosMain: inizializzazione Koin (chiamata dallo Swift AppDelegate o App struct)
fun initKoin() {
startKoin {
modules(domainModule, dataModule, platformModule)
}
}
// iosApp/iOSApp.swift - Inizializzazione da Swift
import SwiftUI
import Shared
@main
struct iOSApp: App {
init() {
// Inizializza Koin all'avvio dell'app iOS
KoinKt.doInitKoin()
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Kotlin 흐름과 공유되는 ViewModel
공유 양식의 프레젠테이션 레이어는 ViewModel 및 Kotlin Flows를 사용하여 상태를 노출합니다.
Android와 iOS 모두에 반응합니다. Kotlin Flow는 모든 플랫폼에서 사용할 수 있습니다.
통해 kotlinx-coroutines-core:
// commonMain: ViewModel condiviso
class UserListViewModel(
private val getUsersUseCase: GetUsersUseCase
) : KoinComponent {
private val _state = MutableStateFlow<UserListState>(UserListState.Loading)
val state: StateFlow<UserListState> = _state.asStateFlow()
private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
fun loadUsers() {
coroutineScope.launch {
_state.value = UserListState.Loading
try {
val users = getUsersUseCase.execute()
_state.value = UserListState.Success(users)
} catch (e: Exception) {
_state.value = UserListState.Error(e.message ?: "Errore sconosciuto")
}
}
}
fun onCleared() {
coroutineScope.cancel()
}
}
sealed class UserListState {
object Loading : UserListState()
data class Success(val users: List<User>) : UserListState()
data class Error(val message: String) : UserListState()
}
격리된 공유 모듈 테스트
이 아키텍처의 주요 장점 중 하나는 테스트 가능성입니다. 레이어에는 플랫폼별 종속성이 없으므로 테스트는 에뮬레이터 없이 JVM에서 실행됩니다.
// commonTest: test del use case con mock dell'interfaccia
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlinx.coroutines.test.runTest
class GetUsersUseCaseTest {
// Mock dell'interfaccia UserRepository
private val mockRepository = object : UserRepository {
override suspend fun getUsers() = listOf(
User(1, "Federico", "federico@example.com"),
User(2, "Marco", "marco@example.com")
)
override suspend fun getUserById(id: Long) = null
override suspend fun saveUser(user: User) = user
}
private val useCase = GetUsersUseCase(mockRepository)
@Test
fun testGetUsersReturnsCorrectList() = runTest {
val result = useCase.execute()
assertEquals(2, result.size)
assertEquals("Federico", result[0].name)
}
@Test
fun testGetUsersEmpty() = runTest {
val emptyRepo = object : UserRepository {
override suspend fun getUsers() = emptyList<User>()
override suspend fun getUserById(id: Long) = null
override suspend fun saveUser(user: User) = user
}
val result = GetUsersUseCase(emptyRepo).execute()
assertEquals(0, result.size)
}
}
class UserListViewModelTest {
@Test
fun testInitialStateIsLoading() = runTest {
val viewModel = UserListViewModel(GetUsersUseCase(mockRepository))
// Prima di loadUsers(), lo stato e Loading
assertIs<UserListState.Loading>(viewModel.state.value)
}
@Test
fun testLoadUsersProducesSuccess() = runTest {
val viewModel = UserListViewModel(GetUsersUseCase(mockRepository))
viewModel.loadUsers()
// Aspetta che il coroutine completi
testScheduler.advanceUntilIdle()
val state = viewModel.state.value
assertIs<UserListState.Success>(state)
assertEquals(2, state.users.size)
}
}
KMP 아키텍처 모범 사례
- 도메인 계층을 순수하게 유지하십시오. Ktor, SQLDelight에 대한 종속성이 없습니다. 안드로이드 또는 iOS. 순수한 Kotlin과 인터페이스만 있으면 됩니다.
- 플랫폼에 따라 달라지는 모든 것에 인터페이스를 사용하세요. 대부분의 경우 예상/실제 인터페이스 + Koin을 선호합니다.
- 하위 수준 인프라의 예상/실제: 데이터베이스 드라이버, HTTP 엔진, 파일 시스템 액세스, 시스템 API.
- commonTest에 모든 테스트를 작성합니다. JVM에서 실행되는 테스트는 다음과 같습니다. iOS 시뮬레이터보다 훨씬 빠릅니다. 시나리오를 위한 iOS 테스트 예약 실제로 기본 환경이 필요합니다.
- 코루틴을 플랫폼에 직접 노출하지 마세요. 콜백과 함께 래퍼를 사용하거나 노출을 위해 KMP NativeCoroutines/SKIE를 사용하세요. async/await Swift처럼 자동입니다.
결론 및 다음 단계
공유 모듈의 깔끔한 아키텍처 — 순수 도메인 계층, 인터페이스가 있는 데이터 계층, DI용 Koin — 유지 관리 가능한 KMP 프로젝트의 기반입니다. 예상/실제 메커니즘 필요한 경우에만 사용되며(플랫폼별 인프라) 전체에 걸쳐 인터페이스됩니다. 나머지는 작업하는 팀이 테스트 가능하고 확장 가능하며 이해할 수 있는 코드를 생성합니다. 다양한 플랫폼.
다음 기사에서 더 자세히 설명하겠습니다. 다중 플랫폼 네트워킹을 위한 Ktor 클라이언트: HTTP 클라이언트 구성, JWT 인증 관리, 백오프로 재시도 구현 방법 지수화하고 commonTest의 모의 서버를 사용하여 API 호출을 테스트합니다.
시리즈: Kotlin Multiplatform — 하나의 코드베이스, 모든 플랫폼
- 기사 1: 2026년 KMP — 아키텍처, 구축 및 생태계
- 기사 2: 첫 번째 KMP 프로젝트 구성 — Android, iOS 및 데스크톱
- 제3조(본): 공유 모듈 아키텍처 - 예상/실제, 인터페이스 및 DI
- 기사 4: Ktor 클라이언트와의 멀티플랫폼 네트워킹
- 기사 5: SQLDelight를 사용한 다중 플랫폼 지속성
- 기사 6: Compose 다중 플랫폼 — Android 및 iOS의 공유 UI
- 기사 7: 상태 관리 KMP — ViewModel 및 Kotlin 흐름
- 기사 8: KMP 테스트 - 단위 테스트, 통합 테스트 및 UI 테스트
- 기사 9: Swift 내보내기 — iOS와의 관용적 상호 운용성
- 기사 10: KMP 프로젝트를 위한 CI/CD — GitHub Actions 및 Fastlane
- 기사 11: 사례 연구 — KMP를 활용한 핀테크 앱 제작







