Kotlin & Compose Multiplatform으로 iOS & Android 출시까지

Wonseok Kim
Preat
Published in
24 min readOct 24, 2023

이번 포스팅에서는 Preat의 Android와 iOS 서비스의 공식 런칭까지 KotlinCompose Multiplatform을 활용한 여정을 공유하고자 합니다. 🙇‍♂️🙇🙇‍♀️

🤖 기존의 안드로이드 서비스

공식 런칭은 아니지만 Preat 서비스는 원래 Google Play 스토어에 안드로이드 버전 먼저 출시가 되었습니다. (2023.05.31)

Preat 안드로이드 버전 출시가 된 후 많은 사람들과 만나면서 가장 많이 들었던 말은..

“그래서 iOS는 언제 나와요?”

팀 내의 iOS 네이티브 개발 인력이 없었던 상황이었고, 그렇다고 새로운 iOS 개발자를 영입하기에는 시간적, 금전적 리소스가 부족하였습니다. 그렇게 제대로된 개발 경험이라고는 안드로이드 개발밖에 없던 제가 iOS 개발까지 맡게 되었습니다.

🤔 Flutter가 아니고 왜 Kotlin Multiplatform?

사실 걱정이 많았습니다. Preat의 iOS 프로덕트를 비교적 짧은 시간 안에 구현해내기 위해서 저에게는 iOS 네이티브, Flutter 개발을 새롭게 익히는 것보다 그나마 안드로이드 개발을 하면서 익숙했던 Kotlin으로 비즈니스 로직과 UI(물론 iOS는 아직 alpha)도 Compose로 공유할 수 있는 Kotlin Multiplatform & Compose Multiplatform 조합이 iOS 개발경험이 전무한 상태에서 프로덕트까지 만들어내야 하는 목표를 달성해내기에 더 수월할 것이라고 판단하였습니다.

✅ Multiplatform 으로 전환하기까지의 준비과정

기존의 안드로이드 프로젝트를 Kotlin Multiplatform으로 마이그레이션 하기위해서 기존의 주요 안드로이드 라이브러리를 Multiplatform 라이브러리로 전환했어야 했습니다. 마이그레이션 해야할 부분은 다음과 같았습니다.

  1. Network
  2. Storage (RDB)
  3. Storage (key-value)
  4. Navigation
  5. Dependency Injection
  6. Logging
  7. Image Loading
  8. Etc.
  1. Network

안드로이드 개발자라면 익숙한 RetrofitMultiplatform 환경에서는 사용할 수 없습니다. Okhttp가 지금은 Kotlin으로 작성되었지만, 여전히 JVM에 종속되어있기 때문입니다.

그래서 Ktor로 전환하였습니다.

@OptIn(kotlin.experimental.ExperimentalNativeApi::class)
internal fun createHttpClient(): HttpClient {
return createPlatformHttpClient().config {
defaultRequest {
url {
protocol = URLProtocol.HTTPS
host = if (isDebug) PREAT_TEST_HOST else PREAT_SERVER_HOST
port = DEFAULT_PORT
}
header(
HttpHeaders.ContentType,
ContentType.Application.Json
)
}

install(ContentNegotiation) {
json(
Json {
prettyPrint = true
isLenient = true
encodeDefaults = true
}
)
}

ContentEncoding {
gzip()
deflate()
}

install(Logging) {
level = LogLevel.ALL
logger = object : Logger {
override fun log(message: String) {
Napier.i(message)
}
}
}

install(HttpTimeout) {
requestTimeoutMillis = 50_000
}
}
}

2. Storage (RDB)

기존의 안드로이드 프로젝트에서는 로컬 DB에 데이터를 저장하고 사용하기 위해 Room DB를 사용하고 있었습니다. 하지만 Kotlin Multiplatform 환경에서의 로컬 데이터 관리를 위해서는 SQLDelight로의 마이그레이션이 필요했습니다.

다만 SQLDelight를 적용하면서 Android API Level 29이하의 기기에 대해서 sqlite 관련 이슈가 조금 있었습니다.

upsertRestaurants:
INSERT INTO restaurantEntity(
id,
name,
images
)
VALUES (?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
images = excluded.images;

다음은 식당 데이터를 로컬 DB에 저장하기 위한 upsert 쿼리를 간략화해서 가져온 코드입니다. 먼저 INSERT INTO ~ ON CONFLICT DO UPDATE 구문은 PostgreSQL에서 사용되는 구문이고, 이를 사용하기 위해서 build.gradle.kts에 다음과 같이 dialect를 추가해줬어야 했습니다. (***는 보안상 가렸습니다.)

sqldelight {
databases {
create("PreatDatabase") {
packageName.set("com.***.***.database")
dialect(libs.sqldelight.sqlite.dialect)
schemaOutputDirectory.set(file("***"))
verifyMigrations.set(true)
}
}
}

그런데, 안드로이드 OS마다 내장되어있는 sqlite 버전이 다르기 때문에, 해당 구문을 API Level 29이하의 기기에 대해서는 사용할 수 없었기 때문에 앱이 크래시나는 현상이 있었습니다. 그래서 Android에서도 최신 SQLite 릴리즈를 따를 수 있도록 requery/sqlite-android Support Library를 적용하고, SQLDelight Driver Factory Android source set에 actual 구현을 다음과 같이 적용하여 이슈를 해결하였습니다.

actual class DriverFactory(private val context: Context) {
actual fun createDriver(): SqlDriver {
return AndroidSqliteDriver(
schema = PreatDatabase.Schema,
context = context,
name = DB_NAME,
factory = RequerySQLiteOpenHelperFactory()
)
}
}

3. Storage (key-value)

기존의 안드로이드 프로젝트에서는 키-값 타입의 단순 데이터를 저장하는 경우 SharedPreferences API를 활용하거나 Preferences DataStore를 사용하였습니다. Kotlin Multiplatform에서는 Multiplatform-Settings라는 라이브러리를 사용하였습니다.


// expect
expect class SettingsFactory {
fun createSettings(): FlowSettings
}

// actual (android)
actual class SettingsFactory(
private val context: Context
) {
actual fun createSettings(): FlowSettings =
DataStoreSettings(context.dataStore)

private val Context.dataStore: DataStore<Preferences>
by preferencesDataStore(name = "settings")
}

// actual (ios)
actual class SettingsFactory {
actual fun createSettings(): FlowSettings {
return NSUserDefaultsSettings(NSUserDefaults.standardUserDefaults).toFlowSettings()
}
}

4. Navigation & Architecture

안드로이드에서는 Jetpack Navigation을 활용했으나, Multiplatform에서는 Stack Navigation과 FlutterBLoC 패턴을 기반으로 하는 Decompose 라이브러리를 도입하였습니다. 이와 함께 같은 저자의 MVIKotlin 라이브러리도 함께 적용하였습니다.

다음은 Decompose를 활용한 stack navigation 적용 예시입니다.

class SearchRootComponent internal constructor(
componentContext: ComponentContext,
private val panel: (ComponentContext, (PanelComponent.Output) -> Unit) -> PanelComponent,
private val result: (ComponentContext, keyword: String, id: Long?, (ResultComponent.Output) -> Unit) -> ResultComponent,
private val navigateBackToMain: () -> Unit
) : ComponentContext by componentContext {

constructor(
componentContext: ComponentContext,
storeFactory: StoreFactory,
navigateBackToMain: () -> Unit
) : this(
componentContext = componentContext,
panel = { childContext, output ->
PanelComponent(
componentContext = childContext,
storeFactory = storeFactory,
output = output
)
},
result = { childContext, keyword, id, output ->
ResultComponent(
componentContext = childContext,
storeFactory = storeFactory,
keyword = keyword,
id = id,
output = output
)
},
navigateBackToMain = navigateBackToMain,
)

private val navigation = StackNavigation<Configuration>()

private val stack =
childStack(
source = navigation,
initialConfiguration = Configuration.Panel,
handleBackButton = true,
childFactory = ::createChild
)

val childStack: Value<ChildStack<*, Child>> = stack

private fun createChild(
configuration: Configuration,
componentContext: ComponentContext
): Child =
when (configuration) {
Configuration.Panel -> Child.Panel(
panel(
componentContext,
::onPanelOutput
)
)

is Configuration.Result -> Child.Result(
result(
componentContext,
configuration.keyword,
configuration.id,
::onResultOutput
)
)
}

private fun onPanelOutput(output: PanelComponent.Output): Unit =
when (output) {
PanelComponent.Output.NavigateBack -> navigateBackToMain()

is PanelComponent.Output.NavigateToResult -> navigation.push(
Configuration.Result(
output.keyword,
output.id
)
)
}

private fun onResultOutput(output: ResultComponent.Output): Unit =
when (output) {
ResultComponent.Output.NavigateBack -> navigation.pop()
}

private sealed class Configuration : Parcelable {
@Parcelize
data object Panel : Configuration()
@Parcelize
data class Result(val keyword: String, val id: Long?) : Configuration()
}

sealed class Child {
data class Panel(val component: PanelComponent) : Child()
data class Result(val component: ResultComponent) : Child()
}
}

해당 라이브러리에 대해 더 궁금하신 분들을 위해 Jetbrains의 유튜브 팟캐스트 영상과 관련 링크도 첨부하겠습니다.

MVIKotlin and Decompose

5. Dependency Injection (DI)

안드로이드 개발자라면 익숙한 Dagger Hilt가 원래의 Preat 안드로이드 버전에서도 적용되고 있었습니다. 최근 Dagger가 ksp를 지원한다는 소식을 접했지만, Multiplatform에서는 Dagger와 유사한 특징을 갖는 kotlin-inject 라이브러리나 Koin 중에서 어떤 것을 선택해야 할지 결정을 해야만 했습니다. 초기에는 kotlin-inject의 컴파일 타임에서의 타입 안정성에 끌렸으나, multiplatform ksp의 호환성 이슈와 빠른 개발 사이클을 고려하여 Koin을 선정하게 되었습니다. 그렇다고 이후에 발생할 수 있는 런타임 이슈를 간과하지 않고, 장기적인 관점에서 kotlin-inject로의 마이그레이션도 계획 중입니다.

6. Logging

효과적인 로깅을 위해서는 적절한 Multiplatform 로깅 라이브러리가 필요했습니다. 저는 안드로이드의 Timber와 유사한 api를 제공하는 Napier를 활용하여 로깅을 진행했습니다.

7. Image Loading

coil 3.x branch

기존 안드로이드 프로젝트에서는 Coil 을 사용했었습니다. 그래서, Coil이 Multiplatform을 지원하는지부터 찾아봤지만, 현재 3.x 버전의 브랜치가 있기는 하나, 언제 릴리즈 될지 장담할 수 없기도 하고, 그래서 먼저 Kamel이라는 라이브러리를 사용했었습니다. Kamel은 심플한 Api를 제공하고, 빠르게 적용해볼 수 있었으나, 커스텀 설정 관련 메서드가 부족했고, 무엇보다 고해상도의 이미지를 로드할 때의 이슈가 발생하여 Kotlin/Native로 빌드된 앱이 종종 크래시 나는 현상이 있었습니다. 따라서 Compose-imageloader라는 라이브러리로 마이그레이션을 진행하고, 디테일한 메모리 관련 설정을 마친 후, 메모리 관련 이슈를 그나마 줄이기 위해 Compose Multiplatform 깃허브 이슈란에 등록하고, 서버 백엔드 팀원과의 충분한 상의 후에, 이미지를 리사이징하여 메모리 관련 이슈를 조금이나마 해결하고자 하였습니다. 또한 최근에는 Kotlin v1.9.20-RC2를 적용하면서 보다 기본적으로 내장되어있으면서 더 향상된 Memroy allocator와 Garbage Collector를 통해서 해당 이슈를 해결해나가는 중입니다.

skia, metal 렌더링, 메모리 관련 문제로 추정되는 이슈 등록 (#3862)

8. ETC..

Kotlin Multiplatform의 기능 중 expect/actual 패턴을 활용하여 네이버 맵 SDK를 iOS와 Android에 각각 적용했습니다. 이 과정에서, Kotlin/Native의 강력한 cinterop 기능을 활용하여 효과적으로 통합할 수 있었습니다.

Naver Map SDK 적용 예시 (일부 로직은 주석 처리했습니다.)

// expect

@OptIn(ExperimentalMaterialApi::class)
@Composable
expect fun SearchNaverMap(
modifier: Modifier,
state: ResultStore.State,
bottomSheetState: ModalBottomSheetState,
onMarkerClick: (ResultStore.State.SearchMarker) -> Unit
)

// actual (android)
@OptIn(ExperimentalNaverMapApi::class, ExperimentalMaterialApi::class)
@Composable
actual fun SearchNaverMap(
modifier: Modifier,
state: ResultStore.State,
bottomSheetState: ModalBottomSheetState,
onMarkerClick: (ResultStore.State.SearchMarker) -> Unit
) {
val mapUiSettings by remember {
mutableStateOf(
MapUiSettings(
isLocationButtonEnabled = false,
isZoomControlEnabled = false
)
)
}
val cameraPositionState = rememberCameraPositionState()
val markerImage =
remember { OverlayImage.fromResource(SharedRes.images.ic_marker.drawableResId) }
val idleMarkerImage =
remember { OverlayImage.fromResource(SharedRes.images.ic_marker_idle.drawableResId) }

LaunchedEffect(key1 = state.cameraPosition) {
// some logic
}

LaunchedEffect(key1 = Unit) {
// some logic
}

NaverMap(
modifier = modifier,
cameraPositionState = cameraPositionState,
uiSettings = mapUiSettings
) {
for (marker in state.markers) {
Marker(
state = MarkerState(
position = LatLng(
marker.latitude,
marker.longitude
)
),
// some logic
onClick = {
onMarkerClick(marker)
true
}
)
}
}
}

// actual (ios)
@OptIn(ExperimentalForeignApi::class, ExperimentalMaterialApi::class)
@Composable
actual fun SearchNaverMap(
modifier: Modifier,
state: ResultStore.State,
bottomSheetState: ModalBottomSheetState,
onMarkerClick: (ResultStore.State.SearchMarker) -> Unit
) {
val naverMapView = remember { NMFMapView() }
val markers = remember { mutableMapOf<Long, NMFMarker>() }
val markerImage = remember {
SharedRes.images.ic_marker.toUIImage()?.let {
NMFOverlayImage.overlayImageWithImage(it)
}
}
val idleMarkerImage = remember {
SharedRes.images.ic_marker_idle.toUIImage()?.let {
NMFOverlayImage.overlayImageWithImage(it)
}
}

LaunchedEffect(key1 = state.markers, key2 = state.clickedMarker) {
// some logic
}

LaunchedEffect(key1 = Unit) {
// some logic
}

UIKitView(
modifier = modifier,
factory = {
naverMapView
},
update = {
// some logic
}
)
}

@ExperimentalForeignApi
private fun createMarker(
iconImage: NMFOverlayImage?,
size: Double,
zIndex: Long,
marker: ResultStore.State.SearchMarker,
onMarkerClick: (ResultStore.State.SearchMarker) -> Unit
): NMFMarker {
return when (iconImage) {
null -> NMFMarker.markerWithPosition(
position = NMGLatLng.latLngWithLat(
lat = marker.latitude,
lng = marker.longitude
)
)

else -> NMFMarker.markerWithPosition(
position = NMGLatLng.latLngWithLat(
lat = marker.latitude,
lng = marker.longitude
),
iconImage = iconImage
)
}.apply {
width = size
height = size
setZIndex(zIndex)
setTouchHandler { _ ->
onMarkerClick(marker)
true // 오버레이 터치 이벤트 소비 (지도로 전파되지 않음)
}
}
}

Kotlin Multiplatform을 이용한 프로젝트에 카카오 로그인 SDK를 통합하는 과정에서, KakaoSDK for iOS가 순수 Swift로 작성되었기 때문에 특별한 접근 방식이 필요했습니다.

현재 Kotlin/Native는 순수 Swift 코드와의 cinterop를 지원하지 않기 때문에 해당 부분은 별도의 Swift 코드로 구현하게 되었습니다.

Pure Swift 모듈에 대해서는 아직 interop을 지원하지 않는다.

이 과정에서 생소했던 iOS의 AppDelegate 개념과 관련해서 많은 삽질을 시도했었던 기억이 납니다. 😅

또한, Apple 앱스토어의 정책상 iOS 앱에는 Apple 로그인 기능이 포함되어야 합니다. 이를 위해, 로그인 버튼의 UI는 Compose를 이용해 구현하고, 버튼 클릭시 발생하는 이벤트 처리는 별도의 Flow로직을 적용하여 처리하였습니다.
다만, 공통된 로직에서 애플 로그인 버튼은 iOS 빌드 시에만 보여져야 하기 때문에 다음과 같이 UI 코드를 작성하였습니다.

if (platform == AppPlatform.IOS) {
TextButton(
modifier = Modifier
.height(52.dp)
.fillMaxWidth(0.7f),
onClick = onAppleLoginButtonClick,
shape = RoundedCornerShape(6.dp),
colors = ButtonDefaults.textButtonColors(
containerColor = Color.Black,
contentColor = Color.White
)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(SharedRes.images.ic_logo_apple),
contentDescription = stringResource(SharedRes.strings.apple_logo_description),
modifier = Modifier.size(18.dp),
tint = Color.White
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = stringResource(SharedRes.strings.start_with_apple),
style = TextStyle(
color = Color.White,
fontSize = 18.sp,
fontFamily = fontFamilyResource(SharedRes.fonts.Pretendard.semiBold),
textAlign = TextAlign.Center
)
)
}
}
}

마치며

Preat은 어떻게 보면 무모한 Kotlin + Compose Multiplatform을 활용하여 프로덕트를 출시하고 관리하고 있습니다. 아직 불안정한 기술인 만큼 기술적으로 이슈가 있지만, 또 아직 많이 부족하지만 사용자에게 만족감을 주기 위해 Preat은 끊임없이 개선하고 노력하고 있습니다.

기술적으로 조언해주실 분들, 관심이 있으신 분들 얼마든지 커피챗 환영합니다! ☕️

앱스토어 다운로드
https://apps.apple.com/kr/app/preat/id6469734148

플레이스토어 다운로드

https://play.google.com/store/apps/details?id=com.freetreechair.preat

이메일 📧

teampreat@gmail.com

--

--