Ktor 및 SQLDelight를 사용하여 멀티플랫폼 앱 만들기
이 튜토리얼에서는 IntelliJ IDEA를 사용하지만, Android Studio에서도 동일하게 따라 할 수 있습니다. 두 IDE 모두 동일한 핵심 기능과 Kotlin 멀티플랫폼 지원을 제공합니다.
이 튜토리얼은 IntelliJ IDEA를 사용하여 Kotlin 멀티플랫폼으로 iOS 및 Android용 고급 모바일 애플리케이션을 만드는 방법을 보여줍니다. 이 애플리케이션은 다음을 수행합니다:
- Ktor를 사용하여 공개 SpaceX API에서 인터넷을 통해 데이터를 가져옵니다.
- SQLDelight를 사용하여 데이터를 로컬 데이터베이스에 저장합니다.
- SpaceX 로켓 발사 목록과 함께 발사 날짜, 결과, 자세한 발사 설명을 표시합니다.
이 애플리케이션에는 iOS 및 Android 플랫폼 모두를 위한 공유 코드 모듈이 포함됩니다. 비즈니스 로직과 데이터 접근 계층은 공유 모듈에서 한 번만 구현되며, 두 애플리케이션의 UI는 네이티브로 구현됩니다.
프로젝트에서 다음 멀티플랫폼 라이브러리를 사용합니다:
- Ktor를 HTTP 클라이언트로 사용하여 인터넷을 통해 데이터를 가져옵니다.
kotlinx.serialization
를 사용하여 JSON 응답을 엔티티 클래스 객체로 역직렬화합니다.kotlinx.coroutines
를 사용하여 비동기 코드를 작성합니다.- SQLDelight를 사용하여 SQL 쿼리에서 Kotlin 코드를 생성하고 타입 세이프(type-safe)한 데이터베이스 API를 만듭니다.
- Koin을 사용하여 의존성 주입을 통해 플랫폼별 데이터베이스 드라이버를 제공합니다.
프로젝트 만들기
빠른 시작에서 Kotlin 멀티플랫폼 개발 환경 설정 지침을 완료하세요.
IntelliJ IDEA에서 File | New | Project를 선택합니다.
왼쪽 패널에서 Kotlin Multiplatform을 선택합니다 (Android Studio에서는 New Project 마법사의 Generic 탭에서 템플릿을 찾을 수 있습니다).
New Project 창에서 다음 필드를 지정합니다:
- Name: SpaceTutorial
- Group: com.jetbrains
- Artifact: spacetutorial
Android 및 iOS 대상을 선택합니다.
iOS의 경우, UI 공유 안 함(Do not share UI) 옵션을 선택합니다. 두 플랫폼 모두에 네이티브 UI를 구현할 것입니다.
모든 필드와 대상을 지정했으면 **생성(Create)**을 클릭합니다.
Gradle 의존성 추가
공유 모듈에 멀티플랫폼 라이브러리를 추가하려면 build.gradle.kts
파일의 해당 소스 세트(source sets
)에 있는 dependencies {}
블록에 의존성 지침(implementation
)을 추가해야 합니다.
kotlinx.serialization
및 SQLDelight 라이브러리 모두 추가 구성이 필요합니다.
gradle/libs.versions.toml
파일의 버전 카탈로그에서 필요한 모든 의존성을 반영하도록 라인을 변경하거나 추가합니다:
[versions]
블록에서 AGP 버전을 확인하고 나머지를 추가합니다:[versions] agp = "8.7.3" ... coroutinesVersion = "1.10.2" dateTimeVersion = "0.6.2" koin = "4.1.0" ktor = "3.2.3" sqlDelight = "2.1.0" lifecycleViewmodelCompose = "2.9.1" material3 = "1.3.2"
[libraries]
블록에 다음 라이브러리 참조를 추가합니다:[libraries] ... android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqlDelight" } koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" } koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutinesVersion" } kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "dateTimeVersion" } ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } native-driver = { module = "app.cash.sqldelight:native-driver", version.ref = "sqlDelight" } runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqlDelight" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } androidx-compose-material3 = { module = "androidx.compose.material3:material3", version.ref="material3" }
[plugins]
블록에서 필요한 Gradle 플러그인을 지정합니다:[plugins] ... kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } sqldelight = { id = "app.cash.sqldelight", version.ref = "sqlDelight" }
의존성이 추가되면 프로젝트를 다시 동기화하라는 메시지가 나타납니다. Gradle 변경 사항 동기화(Sync Gradle Changes) 버튼을 클릭하여 Gradle 파일을 동기화합니다:
shared/build.gradle.kts
파일의 맨 처음에plugins {}
블록에 다음 줄을 추가합니다:kotlinplugins { // ... alias(libs.plugins.kotlinxSerialization) alias(libs.plugins.sqldelight) }
공통 소스 세트(
common source set
)는 각 라이브러리의 핵심 아티팩트뿐만 아니라 네트워크 요청 및 응답 처리를 위해kotlinx.serialization
을 사용하는 Ktor 직렬화 기능을 필요로 합니다. iOS 및 Android 소스 세트는 SQLDelight 및 Ktor 플랫폼 드라이버도 필요합니다.동일한
shared/build.gradle.kts
파일에 필요한 모든 의존성을 추가합니다:kotlinkotlin { // ... sourceSets { commonMain.dependencies { implementation(libs.kotlinx.coroutines.core) implementation(libs.ktor.client.core) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) implementation(libs.runtime) implementation(libs.kotlinx.datetime) implementation(libs.koin.core) } androidMain.dependencies { implementation(libs.ktor.client.android) implementation(libs.android.driver) } iosMain.dependencies { implementation(libs.ktor.client.darwin) implementation(libs.native.driver) } } }
의존성이 추가되면 Gradle 변경 사항 동기화(Sync Gradle Changes) 버튼을 다시 클릭하여 Gradle 파일을 동기화합니다.
Gradle 동기화가 완료되면 프로젝트 구성이 끝난 것이므로 코드를 작성할 수 있습니다.
멀티플랫폼 의존성에 대한 심층 가이드는 Kotlin 멀티플랫폼 라이브러리의 의존성을 참조하세요.
애플리케이션 데이터 모델 만들기
이 튜토리얼 앱에는 네트워킹 및 캐시 서비스에 대한 파사드(facade)로 공개 SpaceXSDK
클래스가 포함됩니다. 애플리케이션 데이터 모델에는 다음 세 가지 엔티티 클래스가 있습니다:
- 발사에 대한 일반 정보
- 미션 패치 이미지 링크
- 발사 관련 기사 URL
이 튜토리얼의 끝에서는 이 모든 데이터가 UI에 표시되지는 않습니다. 데이터 모델은 직렬화를 보여주기 위해 사용됩니다. 하지만 링크와 패치를 가지고 놀면서 예제를 더 유익한 것으로 확장할 수 있습니다!
필요한 데이터 클래스를 만듭니다:
shared/src/commonMain/kotlin/com/jetbrains/spacetutorial
디렉터리에entity
패키지를 생성한 다음, 해당 패키지 안에Entity.kt
파일을 만듭니다.기본 엔티티에 대한 모든 데이터 클래스를 선언합니다:
kotlin
각 직렬화 가능 클래스는 @Serializable
어노테이션으로 표시되어야 합니다. kotlinx.serialization
플러그인은 어노테이션 인수에 직렬화기(serializer) 링크를 명시적으로 전달하지 않는 한 @Serializable
클래스에 대한 기본 직렬화기를 자동으로 생성합니다.
@SerialName
어노테이션을 사용하면 필드 이름을 재정의할 수 있어 데이터 클래스의 속성에 더 읽기 쉬운 식별자를 사용하여 접근하는 데 도움이 됩니다.
SQLDelight 구성 및 캐시 로직 구현
SQLDelight 구성
SQLDelight 라이브러리를 사용하면 SQL 쿼리에서 타입 세이프(type-safe)한 Kotlin 데이터베이스 API를 생성할 수 있습니다. 컴파일 중에 제너레이터(generator)는 SQL 쿼리를 검증하고 공유 모듈에서 사용할 수 있는 Kotlin 코드로 변환합니다.
SQLDelight 의존성은 이미 프로젝트에 포함되어 있습니다. 라이브러리를 구성하려면 shared/build.gradle.kts
파일을 열고 끝에 sqldelight {}
블록을 추가하세요. 이 블록에는 데이터베이스 목록과 해당 매개변수가 포함되어 있습니다:
sqldelight {
databases {
create("AppDatabase") {
packageName.set("com.jetbrains.spacetutorial.cache")
}
}
}
packageName
매개변수는 생성된 Kotlin 소스의 패키지 이름을 지정합니다.
메시지가 표시되면 Gradle 프로젝트 파일을 동기화하거나, 를 두 번 눌러 **모든 Gradle, Swift Package Manager 프로젝트 동기화(Sync All Gradle, Swift Package Manager projects)**를 검색하세요.
.sq
파일 작업을 위해 공식 SQLDelight 플러그인 설치를 고려해 보세요.
데이터베이스 API 생성
먼저 필요한 모든 SQL 쿼리가 포함된 .sq
파일을 만듭니다. 기본적으로 SQLDelight 플러그인은 소스 세트의 sqldelight
폴더에서 .sq
파일을 찾습니다:
shared/src/commonMain
디렉터리에 새sqldelight
디렉터리를 만듭니다.sqldelight
디렉터리 안에com/jetbrains/spacetutorial/cache
라는 이름의 새 디렉터리를 만들어 패키지용 중첩 디렉터리를 생성합니다.cache
디렉터리 안에AppDatabase.sq
파일을 만듭니다 (build.gradle.kts
파일에 지정한 데이터베이스와 동일한 이름). 애플리케이션의 모든 SQL 쿼리는 이 파일에 저장됩니다.데이터베이스에는 발사 데이터가 포함된 테이블이 있습니다.
AppDatabase.sq
파일에 테이블을 생성하는 다음 코드를 추가합니다:textimport kotlin.Boolean; CREATE TABLE Launch ( flightNumber INTEGER NOT NULL, missionName TEXT NOT NULL, details TEXT, launchSuccess INTEGER AS Boolean DEFAULT NULL, launchDateUTC TEXT NOT NULL, patchUrlSmall TEXT, patchUrlLarge TEXT, articleUrl TEXT );
테이블에 데이터를 삽입하는
insertLaunch
함수를 추가합니다:textinsertLaunch: INSERT INTO Launch(flightNumber, missionName, details, launchSuccess, launchDateUTC, patchUrlSmall, patchUrlLarge, articleUrl) VALUES(?, ?, ?, ?, ?, ?, ?, ?);
테이블의 데이터를 지우는
removeAllLaunches
함수를 추가합니다:textremoveAllLaunches: DELETE FROM Launch;
데이터를 가져오는
selectAllLaunchesInfo
함수를 선언합니다:textselectAllLaunchesInfo: SELECT Launch.* FROM Launch;
해당
AppDatabase
인터페이스를 생성합니다 (이 인터페이스는 나중에 데이터베이스 드라이버로 초기화할 것입니다). 이를 위해 터미널에서 다음 명령을 실행합니다:shell./gradlew generateCommonMainAppDatabaseInterface
생성된 Kotlin 코드는
shared/build/generated/sqldelight
디렉터리에 저장됩니다.
플랫폼별 데이터베이스 드라이버 팩토리 생성
AppDatabase
인터페이스를 초기화하려면 SqlDriver
인스턴스를 전달해야 합니다. SQLDelight는 SQLite 드라이버의 여러 플랫폼별 구현을 제공하므로, 각 플랫폼별로 이러한 인스턴스를 별도로 생성해야 합니다.
expect/actual 인터페이스를 통해 이를 달성할 수도 있지만, 이 프로젝트에서는 Koin을 사용하여 Kotlin 멀티플랫폼에서 의존성 주입을 시도할 것입니다.
데이터베이스 드라이버용 인터페이스를 만듭니다. 이를 위해
shared/src/commonMain/kotlin/com/jetbrains/spacetutorial/
디렉터리에cache
패키지를 만듭니다.cache
패키지 안에DatabaseDriverFactory
인터페이스를 만듭니다:kotlinpackage com.jetbrains.spacetutorial.cache import app.cash.sqldelight.db.SqlDriver interface DatabaseDriverFactory { fun createDriver(): SqlDriver }
Android용으로 이 인터페이스를 구현하는 클래스를 만듭니다:
shared/src/androidMain/kotlin
디렉터리에com.jetbrains.spacetutorial.cache
패키지를 생성한 다음, 그 안에DatabaseDriverFactory.kt
파일을 만듭니다.Android에서 SQLite 드라이버는
AndroidSqliteDriver
클래스에 의해 구현됩니다.DatabaseDriverFactory.kt
파일에서 데이터베이스 정보와 컨텍스트 링크를AndroidSqliteDriver
클래스 생성자에 전달합니다:kotlinpackage com.jetbrains.spacetutorial.cache import android.content.Context import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.android.AndroidSqliteDriver class AndroidDatabaseDriverFactory(private val context: Context) : DatabaseDriverFactory { override fun createDriver(): SqlDriver { return AndroidSqliteDriver(AppDatabase.Schema, context, "launch.db") } }
iOS의 경우,
shared/src/iosMain/kotlin/com/jetbrains/spacetutorial/
디렉터리에cache
패키지를 만듭니다.cache
패키지 안에DatabaseDriverFactory.kt
파일을 만들고 이 코드를 추가합니다:kotlinpackage com.jetbrains.spacetutorial.cache import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.native.NativeSqliteDriver class IOSDatabaseDriverFactory : DatabaseDriverFactory { override fun createDriver(): SqlDriver { return NativeSqliteDriver(AppDatabase.Schema, "launch.db") } }
이러한 드라이버의 인스턴스는 나중에 프로젝트의 플랫폼별 코드에서 구현할 것입니다.
캐시 구현
지금까지 플랫폼 데이터베이스 드라이버를 위한 팩토리와 데이터베이스 작업을 수행하기 위한 AppDatabase
인터페이스를 추가했습니다. 이제 AppDatabase
인터페이스를 래핑하고 캐싱 로직을 포함할 Database
클래스를 만듭니다.
공통 소스 세트
shared/src/commonMain/kotlin
에com.jetbrains.spacetutorial.cache
패키지 안에 새Database
클래스를 만듭니다. 이 클래스에는 두 플랫폼에 공통된 로직이 포함됩니다.AppDatabase
용 드라이버를 제공하기 위해,Database
클래스 생성자에 추상DatabaseDriverFactory
인스턴스를 전달합니다:kotlinpackage com.jetbrains.spacetutorial.cache internal class Database(databaseDriverFactory: DatabaseDriverFactory) { private val database = AppDatabase(databaseDriverFactory.createDriver()) private val dbQuery = database.appDatabaseQueries }
이 클래스의 가시성은
internal
로 설정되어 있어 멀티플랫폼 모듈 내에서만 접근할 수 있습니다.Database
클래스 내부에 일부 데이터 처리 작업을 구현합니다. 먼저, 모든 로켓 발사 목록을 반환하는getAllLaunches
함수를 만듭니다.mapLaunchSelecting
함수는 데이터베이스 쿼리 결과를RocketLaunch
객체에 매핑하는 데 사용됩니다:kotlinimport com.jetbrains.spacetutorial.entity.Links import com.jetbrains.spacetutorial.entity.Patch import com.jetbrains.spacetutorial.entity.RocketLaunch internal class Database(databaseDriverFactory: DatabaseDriverFactory) { // ... internal fun getAllLaunches(): List<RocketLaunch> { return dbQuery.selectAllLaunchesInfo(::mapLaunchSelecting).executeAsList() } private fun mapLaunchSelecting( flightNumber: Long, missionName: String, details: String?, launchSuccess: Boolean?, launchDateUTC: String, patchUrlSmall: String?, patchUrlLarge: String?, articleUrl: String? ): RocketLaunch { return RocketLaunch( flightNumber = flightNumber.toInt(), missionName = missionName, details = details, launchDateUTC = launchDateUTC, launchSuccess = launchSuccess, links = Links( patch = Patch( small = patchUrlSmall, large = patchUrlLarge ), article = articleUrl ) ) } }
데이터베이스를 지우고 새 데이터를 삽입하는
clearAndCreateLaunches
함수를 추가합니다:kotlininternal class Database(databaseDriverFactory: DatabaseDriverFactory) { // ... internal fun clearAndCreateLaunches(launches: List<RocketLaunch>) { dbQuery.transaction { dbQuery.removeAllLaunches() launches.forEach { launch -> dbQuery.insertLaunch( flightNumber = launch.flightNumber.toLong(), missionName = launch.missionName, details = launch.details, launchSuccess = launch.launchSuccess ?: false, launchDateUTC = launch.launchDateUTC, patchUrlSmall = launch.links.patch?.small, patchUrlLarge = launch.links.patch?.large, articleUrl = launch.links.article ) } } } }
API 서비스 구현
인터넷을 통해 데이터를 가져오려면 SpaceX 공개 API와 v5/launches
엔드포인트에서 모든 발사 목록을 가져오는 단일 메서드를 사용할 것입니다.
애플리케이션을 API에 연결할 클래스를 만듭니다:
shared/src/commonMain/kotlin/com/jetbrains/spacetutorial/
디렉터리에network
패키지를 만듭니다.network
디렉터리 안에SpaceXApi
클래스를 만듭니다:kotlinpackage com.jetbrains.spacetutorial.network import io.ktor.client.HttpClient import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json class SpaceXApi { private val httpClient = HttpClient { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true useAlternativeNames = false }) } } }
이 클래스는 네트워크 요청을 실행하고 JSON 응답을
com.jetbrains.spacetutorial.entity
패키지의 엔티티로 역직렬화합니다. KtorHttpClient
인스턴스는httpClient
속성을 초기화하고 저장합니다.이 코드는 Ktor
ContentNegotiation
플러그인을 사용하여GET
요청의 결과를 역직렬화합니다. 이 플러그인은 요청과 응답 페이로드를 JSON으로 처리하며, 필요에 따라 직렬화 및 역직렬화합니다.로켓 발사 목록을 반환하는 데이터 검색 함수를 선언합니다:
kotlinimport com.jetbrains.spacetutorial.entity.RocketLaunch import io.ktor.client.request.get import io.ktor.client.call.body class SpaceXApi { // ... suspend fun getAllLaunches(): List<RocketLaunch> { return httpClient.get("https://api.spacexdata.com/v5/launches").body() } }
getAllLaunches
함수는 suspend
함수인 HttpClient.get()
호출을 포함하므로 suspend
한정자를 가집니다. get()
함수는 인터넷을 통해 데이터를 가져오는 비동기 작업을 포함하며, 코루틴 또는 다른 suspend
함수에서만 호출될 수 있습니다. 네트워크 요청은 HTTP 클라이언트의 스레드 풀에서 실행됩니다.
GET 요청을 보내기 위한 URL은 get()
함수의 인수로 전달됩니다.
SDK 구축
iOS 및 Android 애플리케이션은 공유 모듈을 통해 SpaceX API와 통신하며, 이 모듈은 공개 클래스인 SpaceXSDK
를 제공할 것입니다.
공통 소스 세트
shared/src/commonMain/kotlin
의com.jetbrains.spacetutorial
패키지 안에SpaceXSDK
클래스를 만듭니다. 이 클래스는Database
및SpaceXApi
클래스의 파사드(facade)가 될 것입니다.Database
클래스 인스턴스를 생성하려면DatabaseDriverFactory
인스턴스를 제공합니다:kotlinpackage com.jetbrains.spacetutorial import com.jetbrains.spacetutorial.cache.Database import com.jetbrains.spacetutorial.cache.DatabaseDriverFactory import com.jetbrains.spacetutorial.network.SpaceXApi class SpaceXSDK(databaseDriverFactory: DatabaseDriverFactory, val api: SpaceXApi) { private val database = Database(databaseDriverFactory) }
SpaceXSDK
클래스 생성자를 통해 플랫폼별 코드에 올바른 데이터베이스 드라이버를 주입할 것입니다.생성된 데이터베이스와 API를 사용하여 발사 목록을 가져오는
getLaunches
함수를 추가합니다:kotlinimport com.jetbrains.spacetutorial.entity.RocketLaunch class SpaceXSDK(databaseDriverFactory: DatabaseDriverFactory, val api: SpaceXApi) { // ... @Throws(Exception::class) suspend fun getLaunches(forceReload: Boolean): List<RocketLaunch> { val cachedLaunches = database.getAllLaunches() return if (cachedLaunches.isNotEmpty() && !forceReload) { cachedLaunches } else { api.getAllLaunches().also { database.clearAndCreateLaunches(it) } } } }
이 클래스에는 모든 발사 정보를 가져오는 하나의 함수가 포함되어 있습니다. forceReload
값에 따라 캐시된 값을 반환하거나 인터넷에서 데이터를 로드한 다음 결과로 캐시를 업데이트합니다. 캐시된 데이터가 없는 경우 forceReload
플래그 값에 관계없이 인터넷에서 데이터를 로드합니다.
SDK 클라이언트는 forceReload
플래그를 사용하여 최신 발사 정보를 로드하여 사용자에게 당겨서 새로고침(pull-to-refresh) 제스처를 활성화할 수 있습니다.
모든 Kotlin 예외는 unchecked 예외인 반면, Swift는 checked 에러만 있습니다 (Swift/Objective-C와의 상호 운용성에서 자세한 내용을 참조하세요). 따라서 Swift 코드에서 예상되는 예외를 인식하게 하려면 Swift에서 호출되는 Kotlin 함수는 발생 가능한 예외 클래스 목록을 지정하는 @Throws
어노테이션으로 표시해야 합니다.
Android 애플리케이션 만들기
IntelliJ IDEA는 초기 Gradle 구성을 자동으로 처리하므로 shared
모듈은 이미 Android 애플리케이션에 연결되어 있습니다.
UI 및 프레젠테이션 로직을 구현하기 전에 필요한 모든 UI 의존성을 composeApp/build.gradle.kts
파일에 추가하세요:
kotlin {
// ...
sourceSets {
androidMain.dependencies {
implementation(libs.androidx.compose.material3)
implementation(libs.koin.androidx.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
}
// ...
}
}
메시지가 표시되면 Gradle 프로젝트 파일을 동기화하거나, 를 두 번 눌러 **모든 Gradle, Swift Package Manager 프로젝트 동기화(Sync All Gradle, Swift Package Manager projects)**를 검색하세요.
인터넷 접근 권한 추가
인터넷에 접근하려면 Android 애플리케이션에 적절한 권한이 필요합니다. composeApp/src/androidMain/AndroidManifest.xml
파일에 <uses-permission>
태그를 추가하세요:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!--...-->
</manifest>
의존성 주입 추가
Koin 의존성 주입을 사용하면 다양한 컨텍스트에서 사용할 수 있는 모듈(컴포넌트 세트)을 선언할 수 있습니다. 이 프로젝트에서는 Android 애플리케이션용 모듈과 iOS 앱용 모듈 두 개를 생성합니다. 그런 다음, 해당 모듈을 사용하여 각 네이티브 UI에 Koin을 시작할 것입니다.
Android 앱용 컴포넌트를 포함할 Koin 모듈을 선언합니다:
composeApp/src/androidMain/kotlin
디렉터리에com.jetbrains.spacetutorial
패키지 안에AppModule.kt
파일을 만듭니다.해당 파일에서 모듈을 두 개의 싱글톤으로 선언합니다. 하나는
SpaceXApi
클래스용이고 다른 하나는SpaceXSDK
클래스용입니다:kotlinpackage com.jetbrains.spacetutorial import com.jetbrains.spacetutorial.cache.AndroidDatabaseDriverFactory import com.jetbrains.spacetutorial.network.SpaceXApi import org.koin.android.ext.koin.androidContext import org.koin.dsl.module val appModule = module { single<SpaceXApi> { SpaceXApi() } single<SpaceXSDK> { SpaceXSDK( databaseDriverFactory = AndroidDatabaseDriverFactory( androidContext() ), api = get() ) } }
SpaceXSDK
클래스 생성자는 플랫폼별AndroidDatabaseDriverFactory
클래스로 주입됩니다.get()
함수는 모듈 내의 의존성을 해결합니다. 즉,SpaceXSDK()
의api
매개변수 대신 Koin이 이전에 선언된SpaceXApi
싱글톤을 전달합니다.Koin 모듈을 시작할 사용자 지정
Application
클래스를 만듭니다.AppModule.kt
파일 옆에 다음 코드로Application.kt
파일을 만들고,modules()
함수 호출에서 선언한 모듈을 지정합니다:kotlinpackage com.jetbrains.spacetutorial import android.app.Application import org.koin.android.ext.koin.androidContext import org.koin.core.context.GlobalContext.startKoin class MainApplication : Application() { override fun onCreate() { super.onCreate() startKoin { androidContext(this@MainApplication) modules(appModule) } } }
AndroidManifest.xml
파일의<application>
태그에 생성한MainApplication
클래스를 지정합니다:xml<manifest xmlns:android="http://schemas.android.com/apk/res/android"> ... <application ... android:name="com.jetbrains.spacetutorial.MainApplication"> ... </application> </manifest>
이제 플랫폼별 데이터베이스 드라이버가 제공하는 정보를 사용할 UI를 구현할 준비가 되었습니다.
발사 목록이 포함된 뷰 모델 준비
Jetpack Compose와 Material 3를 사용하여 Android UI를 구현할 것입니다. 먼저 SDK를 사용하여 발사 목록을 가져오는 뷰 모델을 만듭니다. 그런 다음 Material 테마를 설정하고, 마지막으로 이 모든 것을 통합하는 컴포저블(composable) 함수를 작성합니다.
composeApp/src/androidMain
소스 세트의com.jetbrains.spacetutorial
패키지에RocketLaunchViewModel.kt
파일을 만듭니다:kotlinpackage com.jetbrains.spacetutorial import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import com.jetbrains.spacetutorial.entity.RocketLaunch class RocketLaunchViewModel(private val sdk: SpaceXSDK) : ViewModel() { private val _state = mutableStateOf(RocketLaunchScreenState()) val state: State<RocketLaunchScreenState> = _state } data class RocketLaunchScreenState( val isLoading: Boolean = false, val launches: List<RocketLaunch> = emptyList() )
RocketLaunchScreenState
인스턴스는 SDK에서 받은 데이터와 요청의 현재 상태를 저장합니다.이 뷰 모델의 코루틴 스코프 내에서 SDK의
getLaunches
함수를 호출하는loadLaunches
함수를 추가합니다:kotlinimport androidx.lifecycle.viewModelScope import kotlinx.coroutines.launch class RocketLaunchViewModel(private val sdk: SpaceXSDK) : ViewModel() { //... fun loadLaunches() { viewModelScope.launch { _state.value = _state.value.copy(isLoading = true, launches = emptyList()) try { val launches = sdk.getLaunches(forceReload = true) _state.value = _state.value.copy(isLoading = false, launches = launches) } catch (e: Exception) { _state.value = _state.value.copy(isLoading = false, launches = emptyList()) } } } }
그런 다음
RocketLaunchViewModel
객체가 생성되자마자 API에서 데이터를 요청하도록 클래스의init {}
블록에loadLaunches()
호출을 추가합니다:kotlinclass RocketLaunchViewModel(private val sdk: SpaceXSDK) : ViewModel() { // ... init { loadLaunches() } }
이제
AppModule.kt
파일에서 Koin 모듈에 뷰 모델을 지정합니다:kotlinimport org.koin.core.module.dsl.viewModel val appModule = module { // ... viewModel { RocketLaunchViewModel(sdk = get()) } }
Material 테마 구축
Material 테마에서 제공하는 AppTheme
함수를 중심으로 주요 App()
컴포저블을 구축할 것입니다:
Material Theme Builder를 사용하여 Compose 앱의 테마를 생성할 수 있습니다. 색상과 글꼴을 선택한 다음, 오른쪽 하단 모서리에 있는 **테마 내보내기(Export theme)**를 클릭합니다.
내보내기 화면에서 내보내기(Export) 드롭다운을 클릭하고 Jetpack Compose (Theme.kt) 옵션을 선택합니다.
아카이브를 압축 해제하고
theme
폴더를composeApp/src/androidMain/kotlin/com/jetbrains/spacetutorial
디렉터리로 복사합니다.theme
패키지 내부의 각 파일에서package
라인을 생성한 패키지를 참조하도록 변경합니다:kotlinpackage com.jetbrains.spacetutorial.theme
Color.kt
파일에 성공적인 발사와 실패한 발사에 사용할 두 가지 색상 변수를 추가합니다:kotlinval app_theme_successful = Color(0xff4BB543) val app_theme_unsuccessful = Color(0xffFC100D)
프레젠테이션 로직 구현
애플리케이션의 주요 App()
컴포저블을 만들고, ComponentActivity
클래스에서 이를 호출합니다:
com.jetbrains.spacetutorial
패키지 내의theme
디렉터리 옆에 있는App.kt
파일을 열고 기본App()
컴포저블 함수를 대체합니다:kotlinpackage com.jetbrains.spacetutorial import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import org.jetbrains.compose.ui.tooling.preview.Preview import org.koin.androidx.compose.koinViewModel import androidx.compose.material3.ExperimentalMaterial3Api @OptIn( ExperimentalMaterial3Api::class ) @Composable @Preview fun App() { val viewModel = koinViewModel<RocketLaunchViewModel>() val state by remember { viewModel.state } val coroutineScope = rememberCoroutineScope() var isRefreshing by remember { mutableStateOf(false) } val pullToRefreshState = rememberPullToRefreshState() }
여기서는 Koin 뷰 모델 API를 사용하여 Android Koin 모듈에서 선언한
viewModel
을 참조합니다.이제 로딩 화면, 발사 결과 열, 그리고 당겨서 새로고침(pull-to-refresh) 액션을 구현할 UI 코드를 추가합니다:
kotlinpackage com.jetbrains.spacetutorial import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.jetbrains.spacetutorial.entity.RocketLaunch import com.jetbrains.spacetutorial.theme.AppTheme import com.jetbrains.spacetutorial.theme.app_theme_successful import com.jetbrains.spacetutorial.theme.app_theme_unsuccessful import kotlinx.coroutines.launch ... @OptIn( ExperimentalMaterial3Api::class ) @Composable @Preview fun App() { val viewModel = koinViewModel<RocketLaunchViewModel>() val state by remember { viewModel.state } val coroutineScope = rememberCoroutineScope() var isRefreshing by remember { mutableStateOf(false) } val pullToRefreshState = rememberPullToRefreshState() AppTheme { Scaffold( topBar = { TopAppBar( title = { Text( "SpaceX Launches", style = MaterialTheme.typography.headlineLarge ) } ) } ) { padding -> PullToRefreshBox( modifier = Modifier .fillMaxSize() .padding(padding), state = pullToRefreshState, isRefreshing = isRefreshing, onRefresh = { isRefreshing = true coroutineScope.launch { viewModel.loadLaunches() isRefreshing = false } } ) { if (state.isLoading && !isRefreshing) { Column( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxSize() ) { Text("Loading...", style = MaterialTheme.typography.bodyLarge) } } else { LazyColumn { items(state.launches) { launch: RocketLaunch -> Column(modifier = Modifier.padding(16.dp)) { Text( text = "${launch.missionName} - ${launch.launchYear}", style = MaterialTheme.typography.headlineSmall ) Spacer(Modifier.height(8.dp)) Text( text = if (launch.launchSuccess == true) "Successful" else "Unsuccessful", color = if (launch.launchSuccess == true) app_theme_successful else app_theme_unsuccessful ) Spacer(Modifier.height(8.dp)) val details = launch.details if (details != null && details.isNotBlank()) { Text(details) } } HorizontalDivider() } } } } } } }
마지막으로
AndroidManifest.xml
파일의<activity>
태그에MainActivity
클래스를 지정합니다:xml<manifest xmlns:android="http://schemas.android.com/apk/res/android"> ... <application ... <activity ... android:name="com.jetbrains.spacetutorial.MainActivity"> ... </activity> </application> </manifest>
Android 앱을 실행합니다: 실행 구성 메뉴에서 composeApp을 선택하고 에뮬레이터를 고른 다음 실행 버튼을 클릭합니다. 앱은 자동으로 API 요청을 실행하고 발사 목록을 표시합니다 (배경색은 생성한 Material 테마에 따라 달라집니다):
Kotlin 멀티플랫폼 모듈에 비즈니스 로직이 구현되고 네이티브 Jetpack Compose를 사용하여 UI가 만들어진 Android 애플리케이션을 방금 생성했습니다.
iOS 애플리케이션 만들기
프로젝트의 iOS 부분에서는 SwiftUI를 사용하여 사용자 인터페이스를 구축하고 모델-뷰-뷰모델(Model View View-Model) 패턴을 활용할 것입니다.
IntelliJ IDEA는 공유 모듈에 이미 연결된 iOS 프로젝트를 생성합니다. Kotlin 모듈은 shared/build.gradle.kts
파일에 지정된 이름(baseName = "Shared"
)으로 내보내지며, 일반적인 import Shared
문을 사용하여 가져와집니다.
SQLDelight를 위한 동적 링킹 플래그 추가
기본적으로 IntelliJ IDEA는 iOS 프레임워크의 정적 링킹(static linking)에 맞게 설정된 프로젝트를 생성합니다.
iOS에서 네이티브 SQLDelight 드라이버를 사용하려면 Xcode 툴링이 시스템에서 제공하는 SQLite 바이너리를 찾을 수 있도록 동적 링커 플래그를 추가해야 합니다:
- IntelliJ IDEA에서 File | Open Project in Xcode 옵션을 선택하여 Xcode에서 프로젝트를 엽니다.
- Xcode에서 프로젝트 이름을 두 번 클릭하여 설정을 엽니다.
- Build Settings 탭으로 전환하고 Other Linker Flags 필드를 검색합니다.
- 필드 값을 두 번 클릭하고 **+**를 클릭한 다음
-lsqlite3
문자열을 추가합니다.
iOS 의존성 주입을 위한 Koin 클래스 준비
Swift 코드에서 Koin 클래스와 함수를 사용하려면 특수 KoinComponent
클래스를 만들고 iOS용 Koin 모듈을 선언해야 합니다.
shared/src/iosMain/kotlin/
소스 세트에서com/jetbrains/spacetutorial/KoinHelper.kt
라는 이름의 파일을 만듭니다 (cache
폴더 옆에 나타납니다).KoinHelper
클래스를 추가합니다. 이 클래스는SpaceXSDK
클래스를 Koin 지연 주입으로 래핑합니다:kotlinpackage com.jetbrains.spacetutorial import org.koin.core.component.KoinComponent import com.jetbrains.spacetutorial.entity.RocketLaunch import org.koin.core.component.inject class KoinHelper : KoinComponent { private val sdk: SpaceXSDK by inject<SpaceXSDK>() suspend fun getLaunches(forceReload: Boolean): List<RocketLaunch> { return sdk.getLaunches(forceReload = forceReload) } }
KoinHelper
클래스 뒤에initKoin
함수를 추가합니다. 이 함수는 Swift에서 iOS Koin 모듈을 초기화하고 시작하는 데 사용됩니다:kotlinimport com.jetbrains.spacetutorial.cache.IOSDatabaseDriverFactory import com.jetbrains.spacetutorial.network.SpaceXApi import org.koin.core.context.startKoin import org.koin.dsl.module fun initKoin() { startKoin { modules(module { single<SpaceXApi> { SpaceXApi() } single<SpaceXSDK> { SpaceXSDK( databaseDriverFactory = IOSDatabaseDriverFactory(), api = get() ) } }) } }
이제 iOS 앱에서 Koin 모듈을 시작하여 공통 SpaceXSDK
클래스와 함께 네이티브 데이터베이스 드라이버를 사용할 수 있습니다.
UI 구현
먼저 목록의 항목을 표시하기 위한 RocketLaunchRow
SwiftUI 뷰를 생성할 것입니다. 이 뷰는 HStack
및 VStack
뷰를 기반으로 합니다. 데이터를 표시하는 데 유용한 헬퍼가 포함된 RocketLaunchRow
구조체에 대한 확장(extension)이 있을 것입니다.
IntelliJ IDEA에서 프로젝트(Project) 뷰에 있는지 확인하세요.
iosApp
폴더의ContentView.swift
옆에 새 Swift 파일을 만들고 이름을RocketLaunchRow
로 지정합니다.RocketLaunchRow.swift
파일을 다음 코드로 업데이트합니다:Swiftimport SwiftUI import Shared struct RocketLaunchRow: View { var rocketLaunch: RocketLaunch var body: some View { HStack() { VStack(alignment: .leading, spacing: 10.0) { Text("\(rocketLaunch.missionName) - \(String(rocketLaunch.launchYear))").font(.system(size: 18)).bold() Text(launchText).foregroundColor(launchColor) Text("Launch year: \(String(rocketLaunch.launchYear))") Text("\(rocketLaunch.details ?? "")") } Spacer() } } } extension RocketLaunchRow { private var launchText: String { if let isSuccess = rocketLaunch.launchSuccess { return isSuccess.boolValue ? "Successful" : "Unsuccessful" } else { return "No data" } } private var launchColor: Color { if let isSuccess = rocketLaunch.launchSuccess { return isSuccess.boolValue ? Color.green : Color.red } else { return Color.gray } } }
발사 목록은 프로젝트에 이미 포함된
ContentView
뷰에 표시됩니다.데이터를 준비하고 관리할
ViewModel
클래스를 포함하는ContentView
클래스에 대한 확장(extension)을 만듭니다.ContentView.swift
파일에 다음 코드를 추가합니다:Swiftextension ContentView { enum LoadableLaunches { case loading case result([RocketLaunch]) case error(String) } @MainActor class ViewModel: ObservableObject { @Published var launches = LoadableLaunches.loading } }
뷰 모델(
ContentView.ViewModel
)은 Combine 프레임워크를 통해 뷰(ContentView
)와 연결됩니다:ContentView.ViewModel
클래스는ObservableObject
로 선언됩니다.@Published
속성은launches
속성에 사용되므로, 이 속성이 변경될 때마다 뷰 모델이 신호를 내보냅니다.
ContentView_Previews
구조체를 제거합니다: 뷰 모델과 호환되어야 하는 미리 보기를 구현할 필요는 없습니다.ContentView
클래스의 본문을 업데이트하여 발사 목록을 표시하고 새로고침 기능을 추가합니다.- 이것은 UI의 기초 작업입니다: 이 튜토리얼의 다음 단계에서
loadLaunches
함수를 구현할 것입니다. viewModel
속성은@ObservedObject
속성으로 표시되어 뷰 모델을 구독합니다.
swiftstruct ContentView: View { @ObservedObject private(set) var viewModel: ViewModel var body: some View { NavigationView { listView() .navigationBarTitle("SpaceX Launches") .navigationBarItems(trailing: Button("Reload") { self.viewModel.loadLaunches(forceReload: true) }) } } private func listView() -> AnyView { switch viewModel.launches { case .loading: return AnyView(Text("Loading...").multilineTextAlignment(.center)) case .result(let launches): return AnyView(List(launches) { launch in RocketLaunchRow(rocketLaunch: launch) }) case .error(let description): return AnyView(Text(description).multilineTextAlignment(.center)) } } }
- 이것은 UI의 기초 작업입니다: 이 튜토리얼의 다음 단계에서
RocketLaunch
클래스는List
뷰를 초기화하는 매개변수로 사용되므로, Identifiable 프로토콜을 준수해야 합니다. 이 클래스에는 이미id
라는 속성이 있으므로,ContentView.swift
파일 하단에 확장(extension)을 추가하기만 하면 됩니다:Swiftextension RocketLaunch: Identifiable { }
데이터 로드
뷰 모델에서 로켓 발사에 대한 데이터를 가져오려면 멀티플랫폼 라이브러리의 KoinHelper
클래스 인스턴스가 필요합니다. 이를 통해 올바른 데이터베이스 드라이버로 SDK 함수를 호출할 수 있습니다.
ContentView.swift
파일에서ViewModel
클래스를 확장하여KoinHelper
객체와loadLaunches
함수를 포함시킵니다:Swiftextension ContentView { // ... @MainActor class ViewModel: ObservableObject { // ... let helper: KoinHelper = KoinHelper() init() { self.loadLaunches(forceReload: false) } func loadLaunches(forceReload: Bool) { // TODO: retrieve data } } }
KoinHelper.getLaunches()
함수(이는SpaceXSDK
클래스에 대한 호출을 프록시합니다)를 호출하고 그 결과를launches
속성에 저장합니다:Swiftfunc loadLaunches(forceReload: Bool) { Task { do { self.launches = .loading let launches = try await helper.getLaunches(forceReload: forceReload) self.launches = .result(launches) } catch { self.launches = .error(error.localizedDescription) } } }
Kotlin 모듈을 Apple 프레임워크로 컴파일할 때, suspend 함수는 Swift의
async
/await
메커니즘을 사용하여 호출할 수 있습니다.getLaunches
함수가 Kotlin에서@Throws(Exception::class)
어노테이션으로 표시되어 있으므로,Exception
클래스 또는 그 서브클래스의 인스턴스인 모든 예외는NSError
로 Swift에 전파됩니다. 따라서 이러한 모든 예외는loadLaunches()
함수에 의해 잡힐 수 있습니다.앱의 진입점인
iOSApp.swift
파일로 이동하여 Koin 모듈, 뷰, 뷰 모델을 초기화합니다:Swiftimport SwiftUI import Shared @main struct iOSApp: App { init() { KoinHelperKt.doInitKoin() } var body: some Scene { WindowGroup { ContentView(viewModel: .init()) } } }
IntelliJ IDEA에서 iosApp 구성으로 전환하고 에뮬레이터를 선택한 다음 실행하여 결과를 확인합니다:
프로젝트의 최종 버전은
final
브랜치에서 찾을 수 있습니다.
다음 단계는?
이 튜토리얼은 JSON 파싱 및 메인 스레드에서 데이터베이스 요청과 같이 잠재적으로 리소스 소모가 많은 일부 작업을 다룹니다. 동시성 코드를 작성하고 앱을 최적화하는 방법에 대해 알아보려면 코루틴 가이드를 참조하세요.
다음과 같은 추가 학습 자료도 확인할 수 있습니다: