이 글은 누구를 위한 것인가
- Room DB 스키마를 바꿀 때마다 앱이 크래시되는 팀
- fallbackToDestructiveMigration()을 쓰고 있어서 사용자 데이터가 날아가는 팀
- 복잡한 스키마 변경(컬럼 이름 변경, 테이블 분리)을 안전하게 하고 싶은 개발자
들어가며
Room DB 버전을 올리고 마이그레이션을 작성하지 않으면 앱이 시작되자마자 크래시된다. fallbackToDestructiveMigration()으로 도망가면 사용자 데이터가 날아간다. 올바른 마이그레이션이 필요하다.
이 글은 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이 필요하다. 마이그레이션 테스트는 실제 디바이스에서 반드시 실행하라.