안드로이드에서 Room으로 짜둔 데이터 계층을 iOS까지 그대로 쓰고 싶은 경우가 있습니다. 예전에는 방법이 마땅치 않아서 SQLDelight처럼 SQL을 직접 작성하는 별도 라이브러리로 갈아타거나, 안드로이드와 iOS의 데이터 계층을 아예 따로 구현해야 했습니다. Room이 2.7부터 Kotlin Multiplatform을 공식 지원하면서, @Entity·@Dao·@Database 같은 기존 Room 어노테이션을 그대로 commonMain에 옮겨 쓸 수 있게 됐습니다.
왜 필요한가
SQLDelight는 .sq 파일에 SQL을 직접 작성하고 이를 기반으로 Kotlin API를 생성하는 방식입니다. SQL을 다루는 데 익숙하면 이 방식이 편하지만, 이미 안드로이드 Room 코드베이스가 커져 있는 상태에서 멀티플랫폼으로 확장하려면 데이터 계층을 SQL 스키마로 다시 옮겨 적는 작업이 필요합니다.
Room Multiplatform은 반대로 접근합니다. 엔티티와 DAO를 어노테이션으로 선언하는 기존 방식은 그대로 두고, 어노테이션 프로세서를 kapt 대신 KSP로 바꾸고 SQLite 드라이버를 플랫폼 독립적인 것으로 교체해서 commonMain에서 컴파일되게 만드는 식입니다. 안드로이드 Room에 익숙한 팀이라면 학습 비용이 SQLDelight보다 적습니다.
핵심 개념
Room Multiplatform에서 바뀌는 부분은 크게 두 가지입니다.
드라이버: 기존 안드로이드 Room은 내부적으로 플랫폼 SQLite 구현에 의존했지만, 멀티플랫폼에서는 androidx.sqlite:sqlite-bundled 아티팩트가 제공하는 순수 Kotlin/Native SQLite 바인딩(BundledSQLiteDriver)을 씁니다. 안드로이드, iOS, 데스크톱 JVM 어디서든 같은 드라이버로 동작합니다.
데이터베이스 생성자: Room.databaseBuilder()가 플랫폼마다 요구하는 인자가 다릅니다(안드로이드는 Context, iOS는 파일 경로). 이 차이를 흡수하기 위해 @ConstructedBy 어노테이션과 RoomDatabaseConstructor를 쓴 expect/actual 객체를 하나 선언해 두면, KSP가 각 플랫폼용 초기화 코드를 자동으로 생성해줍니다. 개발자가 직접 작성하는 건 이 expect 선언 하나뿐입니다.
실전 예시
의존성부터 등록합니다. KSP는 네이티브 타겟마다 개별적으로 추가해야 합니다.
// composeApp/build.gradle.kts
plugins {
id("com.google.devtools.ksp")
id("androidx.room") version "2.7.0"
}
kotlin {
sourceSets {
commonMain.dependencies {
implementation("androidx.room:room-runtime:2.7.0")
implementation("androidx.sqlite:sqlite-bundled:2.5.0")
}
}
}
dependencies {
add("kspAndroid", "androidx.room:room-compiler:2.7.0")
add("kspIosX64", "androidx.room:room-compiler:2.7.0")
add("kspIosArm64", "androidx.room:room-compiler:2.7.0")
add("kspIosSimulatorArm64", "androidx.room:room-compiler:2.7.0")
}
엔티티와 DAO는 안드로이드 전용 Room과 문법이 동일합니다.
// commonMain/db/Todo.kt
@Entity
data class TodoEntity(
@PrimaryKey val id: String,
val title: String,
val done: Boolean,
val categoryId: String?,
)
@Dao
interface TodoDao {
@Query("SELECT * FROM TodoEntity WHERE categoryId = :categoryId")
fun observeByCategory(categoryId: String): Flow<List<TodoEntity>>
@Insert
suspend fun insert(todo: TodoEntity)
@Query("UPDATE TodoEntity SET done = :done WHERE id = :id")
suspend fun updateDone(id: String, done: Boolean)
}
@Database에 @ConstructedBy를 붙이고, 같은 파일에 expect object를 선언합니다.
// commonMain/db/AppDatabase.kt
@Database(entities = [TodoEntity::class], version = 1)
@ConstructedBy(AppDatabaseConstructor::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun todoDao(): TodoDao
}
expect object AppDatabaseConstructor : RoomDatabaseConstructor<AppDatabase> {
override fun initialize(): AppDatabase
}
빌더 함수는 플랫폼별로 다르게 구현합니다. 안드로이드는 Context가 필요하고, iOS는 문서 디렉터리 경로만 있으면 됩니다.
// commonMain/db/DatabaseBuilder.kt
expect fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase>
// androidMain/db/DatabaseBuilder.kt
actual fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase> {
val dbFile = appContext.getDatabasePath("app.db")
return Room.databaseBuilder<AppDatabase>(
context = appContext,
name = dbFile.absolutePath,
)
}
// iosMain/db/DatabaseBuilder.kt
actual fun getDatabaseBuilder(): RoomDatabase.Builder<AppDatabase> {
val documentDirectory = NSFileManager.defaultManager.URLForDirectory(
directory = NSDocumentDirectory,
inDomain = NSUserDomainMask,
appropriateForURL = null,
create = false,
error = null,
)
val dbFilePath = requireNotNull(documentDirectory?.path) + "/app.db"
return Room.databaseBuilder<AppDatabase>(name = dbFilePath)
}
마지막으로 드라이버와 쿼리용 코루틴 컨텍스트를 지정해서 데이터베이스를 완성합니다.
// commonMain/di/DatabaseModule.kt
fun buildDatabase(): AppDatabase =
getDatabaseBuilder()
.setDriver(BundledSQLiteDriver())
.setQueryCoroutineContext(Dispatchers.IO)
.build()
처음에 겪은 문제
iOS 타겟 빌드에서만 이런 에러를 만났습니다.
e: Expected class 'expect object AppDatabaseConstructor' does not have
actual declaration in module <iosSimulatorArm64Main>
원인은 dependencies 블록에 kspIosSimulatorArm64를 빠뜨렸기 때문이었습니다. KSP는 소스셋 단위가 아니라 컴파일 타겟 단위로 별도 등록해야 해서, iosArm64용 컴파일러는 실기기 빌드에서만 돌고 시뮬레이터 빌드(iosSimulatorArm64)에서는 actual 구현이 생성되지 않은 채로 남아 있었습니다. 실기기에서는 빌드가 되는데 시뮬레이터에서만 실패해서 원인을 찾는 데 시간이 걸렸습니다. 타겟마다 KSP 의존성을 빠짐없이 추가했는지 build.gradle.kts를 다시 확인하고 나서야 해결됐습니다.
장단점 정리
장점
- 기존 안드로이드 Room 코드(엔티티, DAO, 쿼리)를 문법 변경 없이
commonMain으로 옮길 수 있음 - 어노테이션 기반이라 SQL 문자열을 직접 다루지 않아도 되고, 컴파일 타임에 쿼리와 반환 타입 정합성을 검증함
- Google이 공식 지원하는 라이브러리라 Room 생태계(마이그레이션 테스트 도구 등)를 그대로 활용 가능
단점
- 네이티브 타겟마다 KSP 의존성을 개별 등록해야 해서 타겟이 늘어날수록 설정이 장황해짐
expect/actual빌더 함수를 플랫폼별로 직접 작성해야 하는 보일러플레이트가 여전히 남아 있음- SQLDelight에 비해 멀티플랫폼 지원 이력이 짧아서, 마이그레이션이나 드라이버 관련 이슈를 검색했을 때 자료가 상대적으로 적음
기존 Room 코드베이스가 이미 있고 그걸 iOS로 확장하는 상황이라면 Room Multiplatform이 SQL을 새로 배우는 것보다 부담이 적습니다. 반대로 처음부터 멀티플랫폼으로 설계한다면 SQLDelight 쪽 자료와 커뮤니티가 아직은 더 두텁습니다.