나만의 애플리케이션 만들기
이 튜토리얼은 IntelliJ IDEA를 사용하지만, Android Studio에서도 따라 할 수 있습니다. 두 IDE 모두 동일한 핵심 기능과 Kotlin Multiplatform 지원을 공유합니다.
이 튜토리얼은 공유 로직과 UI를 사용하여 Compose Multiplatform 앱 만들기 튜토리얼의 마지막 부분입니다. 계속 진행하기 전에 이전 단계를 완료했는지 확인하세요.
이제 마법사가 생성한 샘플 프로젝트를 탐색하고 개선했으니, 이미 알고 있는 개념을 사용하고 새로운 개념을 도입하여 처음부터 자신만의 애플리케이션을 만들 수 있습니다.
사용자가 국가와 도시를 입력하면 해당 국가의 수도 시간을 표시하는 "현지 시간 애플리케이션"을 만들 것입니다. Compose Multiplatform 앱의 모든 기능은 멀티플랫폼 라이브러리를 사용하여 공통 코드에 구현됩니다. 드롭다운 메뉴 내에서 이미지를 로드하고 표시하며, 이벤트, 스타일, 테마, 수정자(modifier), 레이아웃을 사용하게 될 것입니다.
각 단계에서 세 가지 플랫폼(iOS, Android, 데스크톱) 모두에서 애플리케이션을 실행하거나, 필요에 가장 적합한 특정 플랫폼에 집중할 수 있습니다.
프로젝트의 최종 상태는 GitHub 저장소에서 찾을 수 있습니다.
기본 틀 마련하기
시작하려면 새로운 App
컴포저블을 구현하세요.
composeApp/src/commonMain/kotlin
에서App.kt
파일을 열고 코드를 다음App
컴포저블로 대체하세요.kotlin@Composable @Preview fun App() { MaterialTheme { var timeAtLocation by remember { mutableStateOf("No location selected") } Column( modifier = Modifier .safeContentPadding() .fillMaxSize(), ) { Text(timeAtLocation) Button(onClick = { timeAtLocation = "13:30" }) { Text("Show Time At Location") } } } }
- 레이아웃은 두 개의 컴포저블을 포함하는 열입니다. 첫 번째는
Text
컴포저블이고, 두 번째는Button
입니다. - 두 컴포저블은
timeAtLocation
속성이라는 단일 공유 상태로 연결됩니다.Text
컴포저블은 이 상태의 옵저버입니다. Button
컴포저블은onClick
이벤트 핸들러를 사용하여 상태를 변경합니다.
- 레이아웃은 두 개의 컴포저블을 포함하는 열입니다. 첫 번째는
Android 및 iOS에서 애플리케이션을 실행하세요.
애플리케이션을 실행하고 버튼을 클릭하면 하드코딩된 시간이 표시됩니다.
데스크톱에서 애플리케이션을 실행합니다. 작동은 하지만, UI에 비해 창이 너무 큽니다.
이를 해결하려면
composeApp/src/desktopMain/kotlin
에서main.kt
파일을 다음과 같이 업데이트하세요.kotlinfun main() = application { val state = rememberWindowState( size = DpSize(400.dp, 250.dp), position = WindowPosition(300.dp, 300.dp) ) Window( title = "Local Time App", onCloseRequest = ::exitApplication, state = state, alwaysOnTop = true ) { App() } }
여기서는 창의 제목을 설정하고
WindowState
타입을 사용하여 창의 초기 크기와 화면에서의 위치를 지정합니다.데스크톱 앱에서 변경 사항을 실시간으로 확인하려면 Compose Hot Reload를 사용하세요:
main.kt
파일에서 거터(gutter)의 실행 아이콘을 클릭하세요.- Compose Hot Reload (Alpha)로 'main [desktop]' 실행을 선택하세요.
앱이 자동으로 업데이트되는 것을 보려면 수정된 파일( / )을 저장하세요.
Compose Hot Reload는 현재 알파 단계이므로 기능이 변경될 수 있습니다.
IDE의 지침에 따라 누락된 의존성을 임포트하세요.
데스크톱 애플리케이션을 다시 실행하세요. 모양이 개선될 것입니다.
Compose Hot Reload 데모
사용자 입력 지원
이제 사용자가 도시 이름을 입력하여 해당 위치의 시간을 확인할 수 있도록 합니다. 이를 달성하는 가장 간단한 방법은 TextField
컴포저블을 추가하는 것입니다.
App
의 현재 구현을 아래 코드로 대체하세요.kotlin@Composable @Preview fun App() { MaterialTheme { var location by remember { mutableStateOf("Europe/Paris") } var timeAtLocation by remember { mutableStateOf("No location selected") } Column( modifier = Modifier .safeContentPadding() .fillMaxSize(), ) { Text(timeAtLocation) TextField(value = location, onValueChange = { location = it }) Button(onClick = { timeAtLocation = "13:30" }) { Text("Show Time At Location") } } } }
새 코드는
TextField
와location
속성을 모두 추가합니다. 사용자가 텍스트 필드에 입력할 때,onValueChange
이벤트 핸들러를 사용하여 속성 값이 점진적으로 업데이트됩니다.IDE의 지침에 따라 누락된 의존성을 임포트하세요.
타겟팅하는 각 플랫폼에서 애플리케이션을 실행하세요.


시간 계산
다음 단계는 주어진 입력을 사용하여 시간을 계산하는 것입니다. 이를 위해 currentTimeAt()
함수를 생성하세요.
App.kt
파일로 돌아가 다음 함수를 추가하세요.kotlinfun currentTimeAt(location: String): String? { fun LocalTime.formatted() = "$hour:$minute:$second" return try { val time = Clock.System.now() val zone = TimeZone.of(location) val localTime = time.toLocalDateTime(zone).time "The time in $location is ${localTime.formatted()}" } catch (ex: IllegalTimeZoneException) { null } }
이 함수는 이전에 만들었지만 더 이상 필요하지 않은
todaysDate()
와 유사합니다.IDE의 지침에 따라 누락된 의존성을 임포트하세요.
App
컴포저블을currentTimeAt()
를 호출하도록 조정하세요.kotlin@Composable @Preview fun App() { MaterialTheme { var location by remember { mutableStateOf("Europe/Paris") } var timeAtLocation by remember { mutableStateOf("No location selected") } Column( modifier = Modifier .safeContentPadding() .fillMaxSize() ) { Text(timeAtLocation) TextField(value = location, onValueChange = { location = it }) Button(onClick = { timeAtLocation = currentTimeAt(location) ?: "Invalid Location" }) { Text("Show Time At Location") } } } }
wasmJsMain/kotlin/main.kt
파일에서main()
함수 앞에 다음 코드를 추가하여 웹용 타임존(timezone) 지원을 초기화하세요.kotlin@JsModule("@js-joda/timezone") external object JsJodaTimeZoneModule private val jsJodaTz = JsJodaTimeZoneModule
애플리케이션을 다시 실행하고 유효한 타임존(timezone)을 입력하세요.
버튼을 클릭하세요. 올바른 시간이 표시될 것입니다.


스타일 개선
애플리케이션은 작동하지만, 외관에 문제가 있습니다. 컴포저블 간의 간격이 더 잘 조정될 수 있으며, 시간 메시지는 더 눈에 띄게 표시될 수 있습니다.
이러한 문제를 해결하려면 다음 버전의
App
컴포저블을 사용하세요.kotlin@Composable @Preview fun App() { MaterialTheme { var location by remember { mutableStateOf("Europe/Paris") } var timeAtLocation by remember { mutableStateOf("No location selected") } Column( modifier = Modifier .padding(20.dp) .safeContentPadding() .fillMaxSize(), ) { Text( timeAtLocation, style = TextStyle(fontSize = 20.sp), textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().align(Alignment.CenterHorizontally) ) TextField( value = location, onValueChange = { location = it }, modifier = Modifier.padding(top = 10.dp) ) Button( onClick = { timeAtLocation = currentTimeAt(location) ?: "Invalid Location" }, modifier = Modifier.padding(top = 10.dp) ) { Text("Show Time") } } } }
modifier
매개변수는Column
주변에, 그리고Button
과TextField
의 상단에 패딩을 추가합니다.Text
컴포저블은 사용 가능한 수평 공간을 채우고 내용을 중앙에 정렬합니다.style
매개변수는Text
의 모양을 사용자 정의합니다.
IDE의 지침에 따라 누락된 의존성을 임포트하세요.
Alignment
의 경우androidx.compose.ui
버전을 사용하세요.애플리케이션을 실행하여 모양이 어떻게 개선되었는지 확인하세요.


디자인 리팩터링
애플리케이션은 작동하지만, 오타에 취약합니다. 예를 들어, 사용자가 "France" 대신 "Franse"를 입력하면 앱은 해당 입력을 처리할 수 없습니다. 사용자에게 미리 정의된 목록에서 국가를 선택하도록 요청하는 것이 더 좋습니다.
이를 달성하려면
App
컴포저블에서 디자인을 변경하세요.kotlindata class Country(val name: String, val zone: TimeZone) fun currentTimeAt(location: String, zone: TimeZone): String { fun LocalTime.formatted() = "$hour:$minute:$second" val time = Clock.System.now() val localTime = time.toLocalDateTime(zone).time return "The time in $location is ${localTime.formatted()}" } fun countries() = listOf( Country("Japan", TimeZone.of("Asia/Tokyo")), Country("France", TimeZone.of("Europe/Paris")), Country("Mexico", TimeZone.of("America/Mexico_City")), Country("Indonesia", TimeZone.of("Asia/Jakarta")), Country("Egypt", TimeZone.of("Africa/Cairo")), ) @Composable @Preview fun App(countries: List<Country> = countries()) { MaterialTheme { var showCountries by remember { mutableStateOf(false) } var timeAtLocation by remember { mutableStateOf("No location selected") } Column( modifier = Modifier .padding(20.dp) .safeContentPadding() .fillMaxSize(), ) { Text( timeAtLocation, style = TextStyle(fontSize = 20.sp), textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().align(Alignment.CenterHorizontally) ) Row(modifier = Modifier.padding(start = 20.dp, top = 10.dp)) { DropdownMenu( expanded = showCountries, onDismissRequest = { showCountries = false } ) { countries().forEach { (name, zone) -> DropdownMenuItem( text = { Text(name)}, onClick = { timeAtLocation = currentTimeAt(name, zone) showCountries = false } ) } } } Button(modifier = Modifier.padding(start = 20.dp, top = 10.dp), onClick = { showCountries = !showCountries }) { Text("Select Location") } } } }
Country
타입은 이름과 타임존(timezone)으로 구성됩니다.currentTimeAt()
함수는 두 번째 매개변수로TimeZone
을 받습니다.- 이제
App
은 국가 목록을 매개변수로 요구합니다.countries()
함수가 이 목록을 제공합니다. DropdownMenu
가TextField
를 대체했습니다.showCountries
속성 값은DropdownMenu
의 가시성을 결정합니다. 각 국가에 대한DropdownMenuItem
이 있습니다.
IDE의 지침에 따라 누락된 의존성을 임포트하세요.
애플리케이션을 실행하여 재설계된 버전을 확인하세요.


Koin과 같은 의존성 주입 프레임워크를 사용하여 위치 테이블을 구성하고 주입함으로써 디자인을 더욱 개선할 수 있습니다. 데이터가 외부에 저장된 경우, Ktor 라이브러리를 사용하여 네트워크를 통해 데이터를 가져오거나 SQLDelight 라이브러리를 사용하여 데이터베이스에서 데이터를 가져올 수 있습니다.
이미지 도입
국가 이름 목록은 작동하지만, 시각적으로 매력적이지 않습니다. 이름을 국기 이미지로 대체하여 개선할 수 있습니다.
Compose Multiplatform은 모든 플랫폼에서 공통 코드를 통해 리소스에 접근할 수 있는 라이브러리를 제공합니다. Kotlin Multiplatform 마법사는 이미 이 라이브러리를 추가하고 구성했으므로, 빌드 파일을 수정할 필요 없이 리소스를 로드할 수 있습니다.
프로젝트에서 이미지를 지원하려면 이미지 파일을 다운로드하여 올바른 디렉터리에 저장하고, 이를 로드하고 표시하는 코드를 추가해야 합니다.
Flag CDN과 같은 외부 리소스를 사용하여 이미 생성한 국가 목록과 일치하는 국기 이미지를 다운로드하세요. 이 경우 일본, 프랑스, 멕시코, 인도네시아, 이집트입니다.
동일한 국기 이미지를 모든 플랫폼에서 사용할 수 있도록 이미지를
composeApp/src/commonMain/composeResources/drawable
디렉터리로 이동하세요.추가된 리소스에 대한 접근자를 포함하는
Res
클래스를 생성하려면 애플리케이션을 빌드하거나 실행하세요.이미지를 지원하도록
commonMain/kotlin/.../App.kt
파일의 코드를 업데이트하세요.kotlinimport compose.project.demo.generated.resources.eg import compose.project.demo.generated.resources.fr import compose.project.demo.generated.resources.id import compose.project.demo.generated.resources.jp import compose.project.demo.generated.resources.mx data class Country(val name: String, val zone: TimeZone, val image: DrawableResource) fun currentTimeAt(location: String, zone: TimeZone): String { fun LocalTime.formatted() = "$hour:$minute:$second" val time = Clock.System.now() val localTime = time.toLocalDateTime(zone).time return "The time in $location is ${localTime.formatted()}" } val defaultCountries = listOf( Country("Japan", TimeZone.of("Asia/Tokyo"), Res.drawable.jp), Country("France", TimeZone.of("Europe/Paris"), Res.drawable.fr), Country("Mexico", TimeZone.of("America/Mexico_City"), Res.drawable.mx), Country("Indonesia", TimeZone.of("Asia/Jakarta"), Res.drawable.id), Country("Egypt", TimeZone.of("Africa/Cairo"), Res.drawable.eg) ) @Composable @Preview fun App(countries: List<Country> = defaultCountries) { MaterialTheme { var showCountries by remember { mutableStateOf(false) } var timeAtLocation by remember { mutableStateOf("No location selected") } Column( modifier = Modifier .padding(20.dp) .safeContentPadding() .fillMaxSize(), ) { Text( timeAtLocation, style = TextStyle(fontSize = 20.sp), textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().align(Alignment.CenterHorizontally) ) Row(modifier = Modifier.padding(start = 20.dp, top = 10.dp)) { DropdownMenu( expanded = showCountries, onDismissRequest = { showCountries = false } ) { countries.forEach { (name, zone, image) -> DropdownMenuItem( text = { Row(verticalAlignment = Alignment.CenterVertically) { Image( painterResource(image), modifier = Modifier.size(50.dp).padding(end = 10.dp), contentDescription = "$name flag" ) Text(name) } }, onClick = { timeAtLocation = currentTimeAt(name, zone) showCountries = false } ) } } } Button(modifier = Modifier.padding(start = 20.dp, top = 10.dp), onClick = { showCountries = !showCountries }) { Text("Select Location") } } } }
Country
타입은 관련 이미지의 경로를 저장합니다.App
에 전달된 국가 목록에는 이 경로들이 포함됩니다.App
은 각DropdownMenuItem
에Image
를 표시하고, 그 뒤에 국가 이름을 포함하는Text
컴포저블을 표시합니다.- 각
Image
는 데이터를 가져오기 위해Painter
객체를 필요로 합니다.
IDE의 지침에 따라 누락된 의존성을 임포트하세요.
애플리케이션을 실행하여 새로운 동작을 확인하세요.


프로젝트의 최종 상태는 GitHub 저장소에서 찾을 수 있습니다.
다음 단계
멀티플랫폼 개발을 더 탐구하고 더 많은 프로젝트를 시도해 보시길 권장합니다.
- Android 앱을 크로스 플랫폼으로 만들기
- Ktor 및 SQLDelight를 사용하여 멀티플랫폼 앱 만들기
- UI는 네이티브로 유지하면서 iOS와 Android 간에 비즈니스 로직 공유하기
- Kotlin/Wasm을 사용하여 Compose Multiplatform 앱 만들기
- 엄선된 샘플 프로젝트 목록 보기
커뮤니티에 참여하세요:
Compose Multiplatform GitHub: 저장소에 스타(★)를 주고 기여하세요.
Kotlin Slack: 초대를 받아 #multiplatform 채널에 참여하세요.
Stack Overflow: "kotlin-multiplatform" 태그를 구독하세요.
Kotlin YouTube 채널: 구독하고 Kotlin Multiplatform에 대한 비디오를 시청하세요.