Android Room DB 마이그레이션 전략: 데이터 손실 없는 스키마 변경

모바일 개발

AndroidRoom Database마이그레이션JetpackSQLite

이 글은 누구를 위한 것인가

  • 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이 필요하다. 마이그레이션 테스트는 실제 디바이스에서 반드시 실행하라.