23.06.15
DEVOTEE를 활성화 시키면
지금 작성한 커뮤니티 글에 대해 1개의 댓글을 달아줍니다.
버튼을 누르면 글 수정 시 ChatGPT가 작성한 댓글이 수정됩니다.
| 컨텐츠 유형 | 제목 | 저장일 | 삭제 |
|---|
본인인증 로그인에 실패하였습니다.
회원이 아니시거나 본인인증 등록이
완료되지 않은 사용자입니다.
안녕하세요. 에이닷사업부 AI서비스개발본부 iOS개발팀 kimdocs입니다.🍀
모바일 앱을 개발하다 보면,Android와 iOS에서 동일한 비즈니스 로직을 각각 따로 구현해야 하는 상황이 꽤 자주 생깁니다.
서비스가 커지고 수정/변경이 반복될수록 플랫폼 간 로직 중복 및 불일치, 유지보수 부담이 점점 쌓여가죠.
그러다 보니 자연스럽게 이런 고민이 생겼습니다...
그 해답을 찾기 위한 도구 중 하나로 Kotlin Multiplatform을 직접 검토해보게 되었습니다.
Kotlin Multiplatform은 하나의 공통 Kotlin 코드베이스로 여러 플랫폼 (Android, iOS, Web, Desktop 등)에 앱을 개발할 수 있도록 도와주는 크로스 플랫폼 개발도구에요.
JetBrains에서 제공하는 SDK로, Flutter나 React Native처럼 크로스 플랫폼 개발을 지원한다는 점에서는 유사하지만,
가장 큰 차이점은 기존 플랫폼 언어(Android는 Kotlin, iOS는 Swift)를 그대로 사용할 수 있다는 점이에요.
일반적으로 Kotlin Multiplatform에서는 UI는 각 플랫폼 고유 방식으로 개발하고,
공통 비즈니스 로직만 공유하는 구조를 많이 사용해요.
하지만 최근에는 Jetpack Compose를 확장한 Compose Multiplatform도 함께 주목받고 있습니다.
이걸 활용하면 UI까지도 Kotlin으로 공유할 수 있기 때문에, 진정한 의미의 “한 번 작성해 두 플랫폼에서 동작하는 UI”가 가능해집니다.
그럼에도 불구하고, UI를 각 플랫폼 언어로 구현하는 전략에도 분명한 장점이 있어요
플랫폼 고유의 UX 가이드에 맞춘 구현이 용이 (예: iOS의 네이티브 제스처, 스타일)
최신 플랫폼 기능을 제약 없이 활용 가능
기존 네이티브 코드와의 통합이 자연스러움
👉 이번 포스팅에서는 UI는 Android는 Kotlin, iOS는 Swift로 구현하고,
비즈니스 로직만 Kotlin Multiplatform으로 공유하는 구조를 기준으로 설명할 예정이에요.
Kotlin Multiplatform에서는 공통 비즈니스 로직(예: API 통신, 데이터 가공, 도메인 처리 등)을
shared 모듈에 Kotlin으로 작성하고, 이를 Android에서는 Gradle 의존성으로, iOS에서는 Framework로 가져다 씁니다.
덕분에 중복 구현 없이 동일한 로직을 두 플랫폼에서 그대로 사용할 수 있게 됩니다.
Kotlin Multiplatform 프로젝트는 구조상 Shared, Android App, iOS App 크게 세 가지 영역으로 나눌 수 있어요.
이 영역은 실제로 Android와 iOS 앱으로 빌드되는 코드들이 위치한 부분이에요.
구성 방식은 기존 네이티브 개발과 동일하기 때문에, Android 개발자나 iOS 개발자 모두 익숙하게 접근할 수 있습니다.
Android: XML, Jetpack Compose 등으로 UI를 구성하고, Activity, Fragment, Service 등 Android 컴포넌트를 그대로 사용합니다.
iOS: SwiftUI 또는 UIKit 기반의 뷰를 구성하며, UIViewController, NavigationStack 등 기존 iOS 개발 방식 그대로 진행됩니다.
이 모듈들은 주로 UI 구성, 플랫폼 전용 기능 처리, 앱 진입점(MainActivity, AppDelegate 등)을 담당하게 됩니다.
Kotlin Multiplatform에서는 이처럼 UI와 플랫폼 고유 코드는 그대로 유지하면서,
공통 비즈니스 로직만 shared 모듈에서 가져다 사용하는 구조를 취하는 것이죠.
기존 네이티브 개발의 장점을 그대로 유지하면서도, 공통 로직은 재사용할 수 있다는 점에서 두 플랫폼의 장점을 모두 취할 수 있는 형태라고 볼 수 있어요!
플랫폼에 종속되지 않는 공통 로직을 담는 핵심 영역이에요.
아래와 같은 공통 로직을 작성하게 됩니다:
API 통신 로직 (ex. Ktor)
데이터 클래스 (DTO, Domain Model)
비즈니스 로직 (UseCase, Repository 등)
이 Shared 모듈이 바로 Kotlin Multiplatform의 중심이라고 할 수 있어요.
shared 모듈은 내부적으로도 세 가지 소스셋으로 나뉘어 있어요:
shared:commonMain
shared:androidMain
shared:iosMain
우리가 주로 다루는 비즈니스 로직 대부분은 commonMain에 위치하게 됩니다.
이 영역은 어느 플랫폼에도 종속되지 않는 순수 Kotlin 코드로 구성되며,
Kotlin Multiplatform 프로젝트에서 가장 핵심적인 역할을 담당해요.
네트워크 요청
데이터 가공
비즈니스 처리 로직
이처럼 플랫폼에 구애받지 않는 공통 로직들은 전부 이곳에서 구현되며,
Android와 iOS 등 모든 타겟에서 공유되어 실행됩니다.
따라서 commonMain에는 플랫폼 의존적인 코드가 포함되지 않도록 주의해야 해요.
Kotlin Multiplatform에서는 commonMain을 통해 플랫폼에 독립적인 공통 로직을 구현할 수 있지만,
모든 로직을 공통화하는 건 현실적으로 어렵습니다.
예를 들어:
디바이스의 UUID 가져오기
플랫폼별 저장소에 접근하기
OS 버전에 따른 조건 분기 등
위 처럼 플랫폼 고유 기능이 필요한 경우에는 commonMain만으로는 구현에 한계가 생기죠.
이럴 땐 플랫폼 전용 소스셋인 androidMain, iosMain을 활용하게 됩니다.
androidMain에서는 Kotlin으로 Android Framework API (SharedPreferences, LocationManager 등)에 접근할 수 있고,
iosMain에서는 Kotlin에서 iOS의 Foundation, UIKit, SwiftUI 등의 네이티브 기능을 사용할 수 있어요.
앞서 말했듯, shared 모듈에서는 기본적으로 플랫폼에 의존하지 않는 코드만 작성해야 해요.
하지만 플랫폼 기능을 공통 코드 안에서 호출하고 싶은 상황도 분명 존재하죠.
이런 경우에는 Kotlin의 expect / actual 키워드를 활용하면 됩니다.
commonMain(공통 코드)에서 expect 키워드로 함수나 클래스를 선언하고,
각 플랫폼 전용 소스셋(androidMain, iosMain)에서 actual 키워드로 해당 기능을 구현합니다.
예를 들어, UUID를 생성해야 하는 상황을 볼까요?
// shared:commonMain
expect fun generateUUID(): String// shared:androidMain
actual fun generateUUID(): String = UUID.randomUUID().toString()
// shared:iosMain
actual fun generateUUID(): String = NSUUID().UUIDString()이렇게 하면 공통 코드는 generateUUID()가 어떤 플랫폼에서 실행되든 동일한 방식으로 호출할 수 있고,
실제 동작은 플랫폼별로 나뉘어 실행되는 구조가 됩니다.
이렇게 분리된 구조를 통해 코드는 최대한 공유하고, UI나 플랫폼 특화 로직은 각 모듈에 맞게 유연하게 관리할 수 있다는 점이 Kotlin Multiplatform의 큰 장점이에요.
Kotlin Multiplatform은 하나의 공통 모듈을 다양한 플랫폼에서 사용할 수 있게 해주는데요,
실제로 플랫폼별로 컴파일 방식은 조금씩 다르게 동작합니다.
Android에서는 공통 모듈이 JVM 바이트코드로 컴파일되고,
이후 다른 Android 모듈들과 함께 APK에 패키징됩니다.
shared 모듈은 일반 Kotlin 모듈처럼 Gradle을 통해 Android 프로젝트에 통합되며,
Android 앱 내 다른 Kotlin 코드들과 동일한 방식으로 호출할 수 있어요.
공통 모듈은 Kotlin/Native를 통해 네이티브 바이너리 형태로 컴파일되고,
이 과정에서 LLVM 컴파일러를 활용해 iOS에서 실행 가능한 네이티브 코드를 생성해요.
최종적으로 .framework 또는 XCFramework 형태로 빌드되며,
Xcode 프로젝트에 통합되어 Swift나 Objective-C 코드에서 직접 사용할 수 있게 됩니다.
덕분에 iOS에서도 Kotlin으로 작성한 로직을 Swift 코드에서 함수 호출하듯 자연스럽게 사용할 수 있어요.
이번 예제에서는 실제로 에이닷에서 사용 중인 Deeplink 데이터를 사용해서 이를 화면에 보여주는 간단한 리스트 앱을 Kotlin Multiplatform으로 구성해봤어요.
전체적인 토이프로젝트 구성은 다음과 같아요.
Clean Architecture 기반으로 프로젝트를 구성했어요.
공통 로직은 모두 shared 모듈에 구현하고, Android와 iOS App 모듈에는 각각 플랫폼에 맞는 View 로직만 분리해서 작성했습니다.
iOS 의존성 관리는 SPM (Swift Package Manager) 혹은 Cocoapods, Android 의존성관리는 Gradle로 진행했어요.
DI: Koin (Kotlin/Swift 모두 지원)
Networking: Ktor + kotlinx.serialization
Database: SQLDelight
Logging: expect/actual 로직으로 분기
비동기 처리: Coroutine + Flow, Swift에서는 SKIE로 연동
😊 네트워크를 통해 받아온 리스트 데이터를 UI에 어떻게 연결하고 보여주는지에 초점을 맞춰 작성해볼게요 !!
Kotlin Multiplatform에서 네트워크 통신을 구현할 때, 기존 Android에서 자주 사용하던 Retrofit은 사용할 수 없습니다.
그 이유는 Retrofit이 JVM 기반 라이브러리이기 때문이에요.
즉, Android에서는 문제 없이 작동하지만, iOS에서는 컴파일 자체가 불가능하죠.
그래서 Kotlin Multiplatform에서는 멀티플랫폼을 지원하는 Ktor를 네트워크 클라이언트로 사용하는 것이 일반적입니다.
shared 모듈 내의 appModule에서 HttpClient를 구성하여 DI로 주입할 수 있도록 설정했어요.
appModule.kt
fun init(appDeclaration: KoinAppDeclaration = {}) = startKoin {
appDeclaration()
modules(appModule())
}
fun initKoin() = init {}
fun appModule() = module {
single { createJson() }
single { createHttpClient(get()) }
single { DeeplinkApiService(get()) }
single<DeeplinkListRepository> { DeeplinkListRepositoryImpl(get()) }
single<ExecuteFetchDeeplinkListUseCase> {ExecuteFetchDeeplinkListUseCase(get())}
}
fun createJson(): Json = Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
}
fun createHttpClient(json: Json): HttpClient = HttpClient {
install(ContentNegotiation) {
json(json)
}
install(Logging) {
logger = object : Logger {
override fun log(message: String) {
println("Ktor Log: $message")
}
}
level = LogLevel.ALL
}
}HttpClient는 내부적으로 플랫폼에 맞는 네트워크 엔진(Ktor Engine)을 사용해야 합니다.
이를 위해 expect/actual 패턴을 이용해 플랫폼 별 엔진을 분리해서 설정합니다.
commonMain > KtorEngine.kt
expect fun ktorEngineModule(): ModuleandroidMain > KtorEngine.android.kt
actual fun ktorEngineModule() = module {
single { Android.create() }
}iosMain > KtorEngine.ios.kt
actual fun ktorEngineModule() = module {
single { Darwin.create() }
}Darwin이란?
Ktor의 iOS 플랫폼용 네트워크 엔진으로 Kotlin/Native에서 iOS의 네이티브 네트워크 API를 사용할 수 있도록 구현된 Ktor의 내부 엔진 중 하나
iOS의 URLSession을 기반으로 네트워크 요청을 수행함
Kotlin/Native 환경에서도 네이티브와 동일한 수준의 성능과 안정성을 보장
앞서 네트워크 통신을 위한 Ktor 설정과 DI 설정을 마쳤다면
이제 실제로 API 통신을 통해 리스트를 가져와볼게요.
큰 흐름은 다음과 같아요!
(화살표는 데이터의 흐름을 나타냅니다.)
shared:commonMain
class DeeplinkApiService(private val httpClient: HttpClient) {
suspend fun fetchData(): DeeplinkResponse? {
val url = "https://url주소"
return try {
val response: HttpResponse = httpClient.get(url)
response.body()
} catch (e: Exception) {
null
}
}
}Ktor + kotlinx.serialization을 사용해 JSON을 바로 모델로 디코딩합니다.
shared:commonMain
domain > repository
interface DeeplinkListRepository {
suspend fun fetchDeepLinkList() : DeeplinkResponse?
}data > impl
class DeeplinkListRepositoryImpl(
private val apiService: DeeplinkApiService
): DeeplinkListRepository {
override suspend fun fetchDeepLinkList(): DeeplinkResponse? {
return apiService.fetchData()
}
}shared:commonMain
class ExecuteFetchDeeplinkListUseCase(
private val deeplinkListRepository: DeeplinkListRepository
) {
private val _deeplinkListFlow = MutableStateFlow(emptyList<DeeplinkUIModel>())
val deeplinkListFlow: StateFlow<List<DeeplinkUIModel>> = _deeplinkListFlow.asStateFlow()
suspend operator fun invoke() {
val result = deeplinkListRepository.getDeeplinkList()
val mapped = result.map { it.toUIModel() }
_deeplinkListFlow.value = mapped
}
}Android에서는 공통 모듈에서 정의한 UseCase가 제공하는 Flow<List<DeeplinkUIModel>>를 ViewModel에서 직접 구독하여 상태를 관리합니다.
class ListViewModel(
private val executeFetchDeeplinkListUseCase: ExecuteFetchDeeplinkListUseCase
) : ViewModel() {
private val tag = "[ListViewModel]"
private val safeScope = viewModelScope + CoroutineExceptionHandler { _, throwable ->
Log.d(tag, "CoroutineException - $throwable")
}
private val _deeplinkList = MutableStateFlow<List<DeeplinkUIModel>>(emptyList())
val deeplinkList = _deeplinkList.asStateFlow()
init {
Log.d("ListViewModel", "init")
fetchDeeplinkList()
}
fun fetchDeeplinkList() {
safeScope.launch {
executeFetchDeeplinkListUseCase().collect{
_deeplinkList.value = it
}
}
}
}viewModelScope + CoroutineExceptionHandler 조합을 사용해 예외 상황도 안전하게 처리하고 있어요.
UI에서는 StateFlow를
collectAsState()로 구독해 상태를 렌더링합니다.
iOS에서는 Kotlin의 Flow나 StateFlow를 직접 사용할 수 없는데요. 🥲
이를 해결하기 위해 SKIE (Swift-Kotlin Interop Enhancer) 라는 도구를 사용합니다.
SKIE는 Kotlin의 Flow를 Swift 코드에서 async/await + Collector 방식으로 구독할 수 있게 도와주는 브릿지 역할을 합니다.
덕분에 Swift의 @Published와 연동하여 UI 상태를 자연스럽게 갱신할 수 있어요.
class ListViewModel: ObservableObject {
@LazyKoin private var executeFetchDeeplinkListUseCase: ExecuteFetchDeeplinkListUseCase
@Published private(set) var deepLinkList: [DeeplinkUIModel] = []
init() {
fetchDeepLinkList()
}
func fetchDeepLinkList() {
Task {
do {
try await executeFetchDeeplinkListUseCase.invoke()
.collect(collector: Collector<[DeeplinkUIModel]>{ deepLinkList in
DispatchQueue.main.async { [weak self] in
self?.deepLinkList = deepLinkList
}
})
} catch {
// error handling
}
}
}
}Collector<T>는 SKIE에서 제공하는 구조로, Kotlin의 Flow를 Swift에서 비동기적으로 구독할 수 있도록 도와주는 역할을 해요.
다만 이 Collector의 콜백이 어떤 스레드에서 실행될지 보장되지 않는다는 점은 주의해야 해요.
그래서 UI 상태를 업데이트할 땐, DispatchQueue.main.async로 명시적으로 메인 스레드에서 처리해주는 게 안전합니다.
@Composable
fun DeeplinkListScreen(viewModel: ListViewModel = koinViewModel()) {
val items by viewModel.items.collectAsState()
LazyColumn {
items(items) { item ->
Column(Modifier.padding(16.dp)) {
Text(text = item.title, style = MaterialTheme.typography.titleMedium)
Text(text = item.url, style = MaterialTheme.typography.bodySmall)
}
}
}
}struct ContentView: View {
@ObservedObject var viewModel: ListViewModel
var body: some View {
List(viewModel.items, id: \.id) { item in
VStack(alignment: .leading) {
Text(item.title)
.font(.headline)
Text(item.url)
.font(.subheadline)
.foregroundColor(.gray)
}
}
.navigationTitle("Deeplink 리스트")
}
}중복되는 비즈니스 로직을 공통 모듈로 묶어, 개발 리소스를 효율적으로 활용할 수 있어요.
아키텍처 측면에서도 레이어 분리를 구조적으로 잘 유지할 수 있어 유지보수에 유리합니다.
UI는 각 플랫폼의 네이티브 방식으로 구현할 수 있어, 사용자 친화적인 UX 설계가 가능합니다.
공통 모듈에서는 JVM 기반 라이브러리를 쓸 수 없기 때문에, Kotlin Multiplatform을 지원하는 라이브러리로만 구성해야 하는 제한이 있어요.
iOS에서 디버깅이 쉽지 않아요. 크래시 원인을 추적하는 데 시간이 꽤 걸렸습니다.
아직 iOS에서는 Coroutines나 Flow를 완벽하게 대응하지 못하기 때문에, 별도 래핑이 필요합니다. (SKIE 같은 라이브러리를 사용할 수 있지만, 제약이 분명 존재합니다.)
그리고 아직은 Kotlin Multiplatform이 상대적으로 새로운 기술이라 그런지… 자료가 부족한 점도 아쉬움으로 남네요. 😢
아직 상용 서비스에 바로 적용하기엔 다소 아쉬운 부분이 있었지만,
간단한 구조의 앱이나 프로토타입 수준에서는 충분히 시도해볼 만한 가치가 있다고 느꼈습니다.
특히 기존 Android 프로젝트에서 아키텍처가 잘 분리된 구조라면,
비즈니스 로직을 공통화하고 iOS는 UI만 따로 개발하는 방식으로 마이그레이션이 가능하기 때문에,
개발 비용과 시간을 효율적으로 절감할 수 있는 여지가 충분히 있어요.
물론, Flutter처럼 처음부터 모든 것을 크로스 플랫폼으로 개발하는 방법도 있지만,
Kotlin Multiplatform은 기존 코드를 기반으로 점진적으로 확장해나갈 수 있다는 점에서 훨씬 실용적인 접근이라는 생각이 들었습니다.
(다만, 공통 모듈에서 JVM 기반 라이브러리를 사용할 수 없다는 점은 실무에서 꽤나 큰 제약으로 느껴지네요 😢)
앞으로 Kotlin Multiplatform 도입을 고려하시는 분들께 실제 적용 시 고려할 점과 장단점, 느낌점을 공유하고 싶어 작성한 글인데요,
조금이라도 도움이 되셨으면 좋겠습니다 🙌 💛 읽어주셔서 감사합니다!
DEVOTEE를 활성화 시키면
지금 작성한 댓글에 AI가 댓글을 달아줍니다.