Todo 앱에 카테고리와 태그 기능을 추가하려니 Preferences DataStore로는 한계가 왔습니다. 할 일 하나에 카테고리 하나, 태그는 여러 개가 붙는 구조라 키-값 저장소로는 표현이 번거로웠습니다. 관계형 데이터를 다루기 위해 KMP에서 SQLDelight를 도입한 과정을 정리합니다.
왜 필요한가
DataStore Preferences는 원래 단순한 키-값 저장에 맞춰져 있습니다. Todo 하나만 저장할 때는 문제없었지만, Todo와 Category가 N:1로, Todo와 Tag가 N:N으로 엮이기 시작하니 이걸 전부 JSON 문자열 하나로 직렬화해서 저장하는 식으로는 조회 조건이 조금만 복잡해져도 감당이 안 됐습니다. "카테고리별로 완료된 할 일 개수"처럼 간단한 집계조차 전체 데이터를 메모리에 올려 필터링해야 했습니다.
Proto DataStore로 옮기는 방법도 검토했지만, 이건 스키마 진화(필드 추가/삭제)를 직접 마이그레이션 코드로 관리해야 하고 여전히 관계형 조회는 애플리케이션 코드에서 처리해야 합니다. 결국 SQL이 필요한 상황이었고, KMP에서 SQLite를 안전하게 쓸 수 있게 해주는 SQLDelight를 선택했습니다.
핵심 개념
SQLDelight는 .sq 파일에 SQL 스키마와 쿼리를 작성하면, 컴파일 타임에 이를 분석해 타입 세이프한 Kotlin API를 생성해주는 Gradle 플러그인입니다. 쿼리를 문자열로 직접 다루지 않기 때문에 컬럼명 오타 같은 실수가 컴파일 에러로 바로 드러납니다.
플랫폼별로 실제 SQLite에 접근하는 드라이버가 다릅니다. 안드로이드는 AndroidSqliteDriver, iOS는 NativeSqliteDriver를 씁니다. DataStore를 도입할 때 썼던 expect/actual 패턴을 드라이버 생성에도 그대로 적용합니다.
실전 예시
먼저 플러그인을 등록하고 데이터베이스 이름을 지정합니다.
// composeApp/build.gradle.kts
plugins {
id("app.cash.sqldelight") version "2.0.2"
}
kotlin {
sourceSets {
commonMain.dependencies {
implementation("app.cash.sqldelight:runtime:2.0.2")
implementation("app.cash.sqldelight:coroutines-extensions:2.0.2")
}
androidMain.dependencies {
implementation("app.cash.sqldelight:android-driver:2.0.2")
}
iosMain.dependencies {
implementation("app.cash.sqldelight:native-driver:2.0.2")
}
}
}
sqldelight {
databases {
create("TodoDatabase") {
packageName.set("todo.db")
}
}
}
.sq 파일은 commonMain/sqldelight/todo/db/Todo.sq 경로에 둡니다. 테이블 정의와 쿼리를 한 파일에 같이 작성합니다.
CREATE TABLE TodoEntity (
id TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL,
done INTEGER AS Boolean NOT NULL DEFAULT 0,
categoryId TEXT
);
selectAll:
SELECT * FROM TodoEntity;
selectByCategory:
SELECT * FROM TodoEntity WHERE categoryId = :categoryId;
insertTodo:
INSERT INTO TodoEntity(id, title, done, categoryId)
VALUES (?, ?, ?, ?);
updateDone:
UPDATE TodoEntity SET done = :done WHERE id = :id;
deleteById:
DELETE FROM TodoEntity WHERE id = :id;
countDoneByCategory:
SELECT categoryId, COUNT(*) AS doneCount
FROM TodoEntity
WHERE done = 1
GROUP BY categoryId;
이렇게 작성하면 TodoDatabase.todoEntityQueries.selectAll()처럼 쿼리 이름이 그대로 함수명이 되는 Kotlin API가 생성됩니다. INTEGER AS Boolean처럼 SQLite 타입을 Kotlin 타입에 매핑하는 것도 지원해서, done 컬럼을 Boolean으로 바로 받을 수 있습니다.
드라이버는 플랫폼별로 다르게 생성합니다.
// commonMain/db/DatabaseDriverFactory.kt
expect class DatabaseDriverFactory {
fun createDriver(): SqlDriver
}
// androidMain/db/DatabaseDriverFactory.kt
actual class DatabaseDriverFactory(private val context: Context) {
actual fun createDriver(): SqlDriver =
AndroidSqliteDriver(TodoDatabase.Schema, context, "todo.db")
}
// iosMain/db/DatabaseDriverFactory.kt
actual class DatabaseDriverFactory {
actual fun createDriver(): SqlDriver =
NativeSqliteDriver(TodoDatabase.Schema, "todo.db")
}
Koin 모듈에 등록해서 TodoDatabase를 싱글턴으로 관리합니다.
// commonMain/di/DatabaseModule.kt
val databaseModule = module {
single { TodoDatabase(get<DatabaseDriverFactory>().createDriver()) }
}
Repository에서는 coroutines-extensions가 제공하는 .asFlow()로 쿼리 결과를 Flow로 바꿔서 UI에 반응형으로 노출합니다.
// commonMain/data/TodoRepository.kt
class TodoRepository(private val db: TodoDatabase) {
fun observeTodosByCategory(categoryId: String): Flow<List<TodoEntity>> =
db.todoEntityQueries
.selectByCategory(categoryId)
.asFlow()
.mapToList(Dispatchers.Default)
suspend fun addTodo(id: String, title: String, categoryId: String?) {
db.todoEntityQueries.insertTodo(id, title, false, categoryId)
}
}
처음에 겪은 문제
스키마를 바꾸고 나서 iOS 시뮬레이터에서만 이런 에러를 만났습니다.
NativeSqliteException: /Users/.../todo.db: no such column: categoryId
원인은 시뮬레이터에 이미 이전 버전 스키마로 만들어진 todo.db 파일이 남아 있었기 때문입니다. SQLDelight는 스키마 버전을 올리고 .sqm 마이그레이션 파일(1.sqm, 2.sqm)을 추가해야 기존 DB를 안전하게 갱신합니다. 마이그레이션 파일 없이 .sq 파일만 고치면, 이미 설치된 앱에서는 컬럼이 없는 채로 남아 있다가 위 에러가 납니다. 개발 중에는 시뮬레이터를 초기화하거나 마이그레이션 파일을 같이 추가하는 습관을 들여야 했습니다.
-- 2.sqm
ALTER TABLE TodoEntity ADD COLUMN categoryId TEXT;
장단점 정리
장점
- SQL을 그대로 쓰면서도 컴파일 타임에 타입과 컬럼명을 검증받을 수 있음
commonMain에 쿼리 로직을 한 번만 작성하면 안드로이드/iOS 양쪽에서 동일하게 동작Flow연동이 기본 제공돼서 DB 변경 사항을 UI에 반응형으로 흘려보내기 쉬움
단점
.sq파일을 고칠 때마다 Gradle 코드 생성 태스크가 다시 돌아야 해서, 잦은 스키마 변경 중에는 빌드 대기 시간이 늘어남- 마이그레이션 파일을 빠뜨리면 위 사례처럼 실기기/시뮬레이터에서만 재현되는 에러가 생기기 쉬움
- Room처럼 어노테이션 기반으로 익숙한 개발자에게는 SQL을 직접 작성하는 방식이 처음엔 번거롭게 느껴질 수 있음
Todo 앱처럼 카테고리·태그 같은 관계형 데이터가 하나둘 늘어나는 시점이라면, DataStore보다 SQLDelight로 시작하는 편이 나중에 마이그레이션하는 수고를 덜어줍니다.