이 글은 누구를 위한 것인가
- Room DB 스키마를 바꿀 때마다 앱이 크래시되는 팀
- fallbackToDestructiveMigration()을 쓰고 있어서 사용자 데이터가 날아가는 팀
- 복잡한 스키마 변경(컬럼 이름 변경, 테이블 분리)을 안전하게 하고 싶은 개발자
들어가며
Room DB 버전을 올리고 마이그레이션을 작성하지 않으면 앱이 시작되자마자 크래시된다. fallbackToDestructiveMigration()으로 도망가면 사용자 데이터가 날아간다. Room 공식 문서의 마이그레이션 절에 자동/수동 마이그레이션 조건이 정리돼 있다.
이 글은 bluefoxdev.kr의 Android 데이터베이스 관리 를 참고하여 작성했습니다.
1. Room 마이그레이션 전략
[마이그레이션 방법 비교]
수동 Migration:
SQL로 직접 스키마 변경
복잡한 변환 가능
실수 가능성 있음
모든 Android 버전 지원
AutoMigration (Room 2.4+):
@AutoMigration 어노테이션
단순 추가/삭제 자동 처리
이름 변경은 @RenameColumn/@DeleteColumn
복잡한 변환 불가
[마이그레이션 작성 규칙]
버전 N → N+1 마이그레이션 항상 작성
여러 버전 건너뛰는 마이그레이션도 추가
예: 1→3 마이그레이션 추가 (1→2→3 외에)
→ 이미 버전 1인 사용자를 위해
[안전한 스키마 변경 방법]
컬럼 추가: ALTER TABLE ADD COLUMN
컬럼 삭제: 신규 테이블 생성 후 복사 후 삭제
컬럼 이름 변경: 위와 동일 (SQLite 제한)
테이블 이름 변경: ALTER TABLE RENAME TO
외래 키 추가: 테이블 재생성 필요
2. 마이그레이션 구현
import androidx.room.*
import androidx.sqlite.db.SupportSQLiteDatabase
// 엔티티 정의
@Entity(tableName = "notes")
data class Note(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val title: String,
val content: String,
val createdAt: Long = System.currentTimeMillis(),
// 버전 2에서 추가
val isPinned: Boolean = false,
// 버전 3에서 추가
val categoryId: Long? = null,
)
// 자동 마이그레이션 (1 → 2: isPinned 컬럼 추가)
@AutoMigration(from = 1, to = 2)
interface MigrationFrom1To2 : AutoMigrationSpec
// 컬럼 이름 변경을 포함한 자동 마이그레이션 (2 → 3)
@AutoMigration(
from = 2,
to = 3,
spec = MigrationFrom2To3::class,
)
interface MigrationFrom2To3Annotation : AutoMigrationSpec
@RenameColumn(tableName = "notes", fromColumnName = "body", toColumnName = "content")
class MigrationFrom2To3 : AutoMigrationSpec
// 복잡한 수동 마이그레이션 (3 → 4: 테이블 분리)
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
// categories 테이블 생성
db.execSQL("""
CREATE TABLE IF NOT EXISTS `categories` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` TEXT NOT NULL,
`color` INTEGER NOT NULL DEFAULT 0
)
""")
// 기존 notes의 category 문자열을 새 테이블로 마이그레이션
db.execSQL("""
INSERT INTO categories (name)
SELECT DISTINCT categoryName FROM notes
WHERE categoryName IS NOT NULL AND categoryName != ''
""")
// notes 테이블 재구성 (categoryId 추가)
db.execSQL("""
CREATE TABLE IF NOT EXISTS `notes_new` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`title` TEXT NOT NULL,
`content` TEXT NOT NULL,
`createdAt` INTEGER NOT NULL,
`isPinned` INTEGER NOT NULL DEFAULT 0,
`categoryId` INTEGER,
FOREIGN KEY(`categoryId`) REFERENCES `categories`(`id`)
ON DELETE SET NULL
)
""")
db.execSQL("""
INSERT INTO notes_new (id, title, content, createdAt, isPinned, categoryId)
SELECT n.id, n.title, n.content, n.createdAt, n.isPinned, c.id
FROM notes n
LEFT JOIN categories c ON n.categoryName = c.name
""")
db.execSQL("DROP TABLE notes")
db.execSQL("ALTER TABLE notes_new RENAME TO notes")
// 인덱스 재생성
db.execSQL("CREATE INDEX IF NOT EXISTS index_notes_categoryId ON notes(categoryId)")
}
}
// 건너뛰는 버전용 마이그레이션 (1 → 4)
val MIGRATION_1_4 = object : Migration(1, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
// 1→2, 2→3, 3→4를 한 번에 처리
db.execSQL("ALTER TABLE notes ADD COLUMN isPinned INTEGER NOT NULL DEFAULT 0")
// ... 나머지 변환
}
}
// Database 정의
@Database(
entities = [Note::class, Category::class],
version = 4,
autoMigrations = [
MigrationFrom1To2::class,
MigrationFrom2To3Annotation::class,
],
)
abstract class AppDatabase : RoomDatabase() {
abstract fun noteDao(): NoteDao
companion object {
@Volatile private var INSTANCE: AppDatabase? = null
fun getInstance(context: android.content.Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"app_database",
)
.addMigrations(MIGRATION_3_4, MIGRATION_1_4)
// fallbackToDestructiveMigration() 절대 사용 금지
.build()
.also { INSTANCE = it }
}
}
}
}
// 마이그레이션 테스트
class MigrationTest {
private val TEST_DB = "migration-test"
@get:org.junit.Rule
val helper = androidx.room.testing.MigrationTestHelper(
androidx.test.platform.app.InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java,
)
@org.junit.Test
fun testMigration3To4() {
// 버전 3 DB 생성
helper.createDatabase(TEST_DB, 3).apply {
execSQL("INSERT INTO notes VALUES (1, '제목', '내용', 0, 0, 'work')")
close()
}
// 마이그레이션 실행
helper.runMigrationsAndValidate(TEST_DB, 4, true, MIGRATION_3_4)
}
}
마무리
Room 마이그레이션의 규칙: 버전을 올릴 때마다 반드시 Migration을 작성하고, 버전 1에서 최신으로 건너뛰는 마이그레이션도 추가하라. AutoMigration은 컬럼 추가/이름 변경에 편리하지만, 테이블 분리나 데이터 변환은 수동 Migration이 필요하다. 마이그레이션 테스트는 실제 디바이스에서 반드시 실행하라.