안녕하세요. 와디즈 앱개발팀 Android 앱 개발자입니다.
지난 7월 와디즈는 혜택 페이지를 개발했어요. 그때 ‘웰컴 쿠폰’ 발급 화면에 Server Driven UI를 도입했습니다. Server Driven UI란 서버에서 UI 디자인 정보를 제공하면, 클라이언트 앱에서 동적으로 화면을 그릴 수 있게 해주는 메커니즘이에요. 앱을 새로 배포하지 않고도 UI 변경이 가능해요.
덕분에 ‘웰컴 쿠폰’ 페이지를 더 편리하고 원활하게 운영할 수 있게 되었어요! 하지만 매번 JSON을 작성하고 변경하는 것은 복잡하고 어려운 일이었죠. 페이지를 운영하는 누구나 쉽게 UI를 관리할 수 있도록 관리자 도구 개발이 필요했습니다. Compose Multiplatform을 도입해 개발하게 되었는데요. 그 경험을 간단히 소개합니다.
Compose Multiplatform 이란
Compose Multiplatform은 JetBrains에서 제공하는 UI 프레임워크에요. Android 개발자들에게 친숙한 Kotlin과 Compose를 사용하여 데스크탑 앱과 모바일 앱을 동시에 개발할 수 있는 도구죠. 일부 실험 기능도 있지만 iOS, Android, Desktop, Web 모두를 지원합니다.
iOS 앱 개발을 시작하려면, macOS, Java, Android Stuido, Xcode, Cocoapods이 필요해요. KDoctor를 설치하고 실행해 아래와 같이 준비되었는지 확인합니다. 추가로, Kotlin Multiplatform Mobile 플러그인 설치도 권장해요.
Compose Multiplatform 시작하기
우선 시작하려면 intelliJ IDEA가 필요해요. New Project → Compose Multiplatform를 선택한 후 프로젝트명, 저장 경로, 패키지명 등을 입력합니다.
Configuration
- Single platform : desktop 또는 web 중 단일 플랫폼 지원으로 충분할 때 선택해요.
- Multiple platform : desktop, mobile, web(Experimental) 등 다중 플랫폼 지원이 필요할 때 선택해요.
프로젝트의 패키지를 살펴볼게요.
프로젝트 내에는 common 모듈과 각 플랫폼 모듈이 구성되어 있어요. common 모듈은 플랫폼에 종속되지 않는 공통 코드를 포함하고 있는데요. Android, 데스크탑과 같이 플랫폼 의존적인 코드는 각각의 플랫폼 모듈에 작성되어 있어요.
common
commonMain : 플랫폼 의존성이 없는 코드
androidMain : android actual 함수, 클래스 작성
desktopMain : desktop actual 함수, 클래스 작성
android : android 플랫폼 종속 코드
AndroidManifest.xml
desktop : desktop 플랫폼 종속 코드
이 프로젝트에서는 대부분의 코드를 commonMain 패키지에 작성할 수 있었어요. Compose를 사용해, 그린 화면은 플랫폼에 종속되지 않기 때문이죠.
그러나 이미지 로딩, 파일 저장 경로 설정, 다이얼로그 표시와 같이 일부 기능들은 각 플랫폼에서 사용하는 라이브러리와 환경이 다릅니다. 플랫폼에 맞춰 코드를 분리하고 효율적으로 관리하기 위해 Compose Multiplatform에서는 expect와 actual이라는 키워드를 사용해요.
자바 인터페이스와 동일한 개념으로 common 모듈에서 expect 함수 또는 클래스를 명시하면, 각 플랫폼(android, desktop)에서 actual을 붙여 구현 함수 또는 클래스를 작성해요. 사용하는 라이브러리 추가도 동일하게 build.gradle에서 각 플랫폼에 맞게 작성합니다.
serialization-json
라이브러리는 플랫폼에 독립적이기 때문에 commonMain
에 추가했어요. material3
라이브러리는 Android 및 데스크탑 플랫폼, 서로 다른 라이브러리를 사용해야 하는데요. 그래서 각 플랫폼에 별도로 추가했습니다. 그 결과, 여러 플랫폼에서 필요한 라이브러리를 효율적으로 관리할 수 있게 되었어요.
android material3
androidx.compose.material3:material3:1.0.1
desktop material3
org.jetbrains.compose.material3:material3-desktop:1.3.0
코드 작성해 보기
다음은, 화면에 UI 컴포넌트를 추가하는 기능을 만들었어요. 리스트를 보여주는 LazyRow
Composable 함수를 생성해 각 옵션의 이름과 이미지를 보여줍니다.
@Composable
private fun ComponentOptionItems(
clickAction: (ServerComponentType) -> Unit
) {
Spacer(modifier = Modifier.background(Color.Black).fillMaxWidth().height(1.dp))
LazyRow(
modifier = Modifier.fillMaxWidth()
.height(200.dp)
.background(Color.White),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
val typeList = ServerComponentType.values()
items(typeList) {
Column(
modifier = Modifier.fillMaxHeight().clickable {
clickAction.invoke(it)
},
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
modifier = Modifier.padding(16.dp),
text = it.name,
color = Color.Black,
fontWeight = FontWeight.Bold
)
getPainterFile(it.name.lowercase())?.let { painter ->
Image(
painter = painter,
contentDescription = it.name
)
}
}
}
}
}
var openDialog: Pair<ServerComponentType, Component?>? by remember {
mutableStateOf(null)
}
showOptionSettingDialog(openDialog?.first, openDialog?.second, deleteAction = { ... })
ComponentOptionItems {
openDialog = Pair(it, null)
}
openDialog
라는 remember 변수를 생성하고, ComponentOptionItems
아이템을 클릭했을 때 openDialog를 변경합니다. showOptionSettingDialog
에서는 openDialog
상태가 변경되었기 때문에 다시 그려져요.
android와 desktop에서 다이얼로그를 보여주는 방법이 다른데요. 따라서 expect 함수를 정의하고 각 플랫폼에 맞게 actual 함수를 작성합니다.
// common
@Composable
expect fun showOptionSettingDialog(
type: ServerComponentType?,
baseComponent: Component?,
deleteAction: () -> Unit, dismissAction: (Component?) -> Unit
)
// android
@Composable
actual fun showOptionSettingDialog(
type: ServerComponentType?,
baseComponent: Component?,
deleteAction: () -> Unit,
dismissAction: (Component?) -> Unit
) {
if (type != null) {
var component by remember { mutableStateOf(baseComponent) }
Dialog(
onDismissRequest = { dismissAction.invoke(null)},
properties = DialogProperties(usePlatformDefaultWidth = false),
content = {
... // 옵션 화면 그리기
}
)
}
}
// desktop
@Composable
actual fun showOptionSettingDialog(
type: ServerComponentType?,
baseComponent: Component?,
deleteAction: () -> Unit,
dismissAction: (Component?) -> Unit
) {
if (type != null) {
var component by remember { mutableStateOf(baseComponent) }
Dialog(
title = type.name,
visible = true,
state = DialogState(size = DpSize(1200.dp, 800.dp)),
onCloseRequest = {
dismissAction.invoke(null)
}) {
... // 옵션 화면 그리기
}
}
}
언뜻 보면 android, desktop 모두 Dialog라는 Composable 함수를 호출하고 있지만, 다른 함수예요.
// android
androidx.compose.ui:ui AndroidDialog.android
// desktop
org.jetbrains.compose.ui:ui-desktop Dialog.desktop
각 다이얼로그에서 그리는 옵션 화면은 플랫폼 상관없이 동일해요. 그래서 common에 정의한 Composable 함수를 호출해 공용으로 사용합니다.
이렇게 Compose Multiplatform을 통해 개발한 결과물은 데스크탑과 Android 앱에서 실행할 수 있어요. Server Driven UI 화면을 구성하여 미리보기 화면도 볼 수 있고요. JSON 데이터를 추출해 파일로 저장도 가능합니다.
이전에는 JSON을 만들고 각 플랫폼에서 테스트해야 하는 번거로움이 있었는데요. 그런 과정 없이 관리자 도구로 UI를 확인할 수 있어서 매우 편리했어요.
마치며
데스크탑 앱 개발은 처음에는 어려워 보였지만 새로운 도전이라는 점에서 설레기도 했어요. Compose도 활용해 볼 수 있어, 개발 후 두 마리 토끼를 잡은 듯 기분이 좋았죠.
다만 몇 가지 아쉬움도 남아 있어요. 피드백에서, 다이얼로그보다는 한 화면에서 수정할 수 있는 Drawer Navigation 방식이 더 편리할 것 같다는 의견이 있었는데요. 이를 참고해 UI/UX를 개선해 볼 생각이에요.
또 운영 담당자가 편리하게 사용할 수 있도록, lineHeight, gravity, margin 등의 개발 용어로 작성된 부분을 누구나 이해하기 쉬운 용어로 바꿀 예정이고요.
앞으로 Server Driven UI와 Compose Multiplatform으로 가능한 것이 무궁무진할 것으로 기대돼요. 실험 기능에서 업데이트될 기능들도 궁금하고요. 의미 있는 경험을 얻을 수 있어서 즐거웠습니다. 🙂🚀