Google은 흑인 공동체를 위한 인종 간 평등을 진전시키기 위해 노력하고 있습니다. Google에서 어떤 노력을 하고 있는지 확인하세요.

Room 데이터베이스 이전

앱에서 기능을 추가 및 변경할 때 Room 항목 클래스를 수정하여 이러한 변경사항을 반영해야 합니다. 그러나 앱 업데이트가 데이터베이스 스키마를 변경할 때 이미 기기 내 데이터베이스에 있는 사용자 데이터를 유지하는 것이 중요합니다.

Room 지속성 라이브러리는 이러한 요구를 해결하기 위해 Migration 클래스를 사용하여 증분 이전을 지원합니다. 각 Migration 서브클래스는 Migration.migrate() 메서드를 재정의하여 startVersionendVersion 간 이전 경로를 정의합니다. 앱 업데이트에 데이터베이스 버전 업그레이드가 필요할 때 Room은 하나 이상의 Migration 서브클래스에서 migrate() 메서드를 실행하여 런타임 시 데이터베이스를 최신 버전으로 이전합니다.

Kotlin

val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL("CREATE TABLE `Fruit` (`id` INTEGER, `name` TEXT, " +
                "PRIMARY KEY(`id`))")
    }
}

val MIGRATION_2_3 = object : Migration(2, 3) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL("ALTER TABLE Book ADD COLUMN pub_year INTEGER")
    }
}

Room.databaseBuilder(applicationContext, MyDb::class.java, "database-name")
        .addMigrations(MIGRATION_1_2, MIGRATION_2_3).build()

자바

static final Migration MIGRATION_1_2 = new Migration(1, 2) {
    @Override
    public void migrate(SupportSQLiteDatabase database) {
        database.execSQL("CREATE TABLE `Fruit` (`id` INTEGER, "
                + "`name` TEXT, PRIMARY KEY(`id`))");
    }
};

static final Migration MIGRATION_2_3 = new Migration(2, 3) {
    @Override
    public void migrate(SupportSQLiteDatabase database) {
        database.execSQL("ALTER TABLE Book "
                + " ADD COLUMN pub_year INTEGER");
    }
};

Room.databaseBuilder(getApplicationContext(), MyDb.class, "database-name")
        .addMigrations(MIGRATION_1_2, MIGRATION_2_3).build();

주의: 이전 로직이 예상대로 계속 작동하도록 하려면 쿼리를 나타내는 상수를 참조하는 대신 전체 쿼리를 사용하세요.

이전 프로세스가 완료되면 Room은 스키마의 유효성을 검사하여 이전이 성공했는지 확인합니다. Room은 문제를 발견하면 일치하지 않는 정보가 포함된 예외를 발생시킵니다.

자세한 내용은 GitHub의 Room 이전 샘플을 참조하세요.

이전 테스트

이전은 복잡한 경우가 많으며 잘못 정의된 이전으로 인해 앱이 비정상 종료될 수 있습니다. 앱의 안정성을 유지하려면 이전을 테스트해야 합니다. Room은 room-testing Maven 아티팩트를 제공하여 테스트 프로세스를 지원합니다. 그러나 이 아티팩트가 작동하려면 먼저 데이터베이스 스키마를 내보내야 합니다.

스키마 내보내기

Room은 컴파일 시 데이터베이스 스키마 정보를 JSON 파일로 내보낼 수 있습니다. 스키마를 내보내려면 다음과 같이 app/build.gradle 파일에서 room.schemaLocation 주석 프로세서 속성을 설정합니다.

build.gradle

android {
    ...
    defaultConfig {
        ...
        javaCompileOptions {
            annotationProcessorOptions {
                arguments += ["room.schemaLocation":
                             "$projectDir/schemas".toString()]
            }
        }
    }
}

내보낸 JSON 파일은 데이터베이스의 스키마 기록을 나타냅니다. 이러한 파일을 버전 제어 시스템에 저장해야 합니다. 이 시스템을 통해 Room이 테스트 목적으로 예전 버전의 데이터베이스를 생성할 수 있기 때문입니다.

단일 이전 테스트

이전을 테스트하려면 먼저 Room의 androidx.room:room-testing Maven 아티팩트를 테스트 종속 항목에 추가하고 다음과 같이 내보낸 스키마의 위치를 애셋 폴더로 추가해야 합니다.

build.gradle

android {
    ...
    sourceSets {
        // Adds exported schema location as test app assets.
        androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
    }
}

dependencies {
    ...
      testImplementation "androidx.room:room-testing:2.2.5"
}

테스트 패키지는 내보낸 스키마 파일을 읽을 수 있는 MigrationTestHelper 클래스를 제공합니다. 또한 패키지는 JUnit4 TestRule 인터페이스도 구현하므로 생성된 데이터베이스를 관리할 수 있습니다.

다음 예는 단일 이전 테스트를 보여줍니다.

Kotlin

@RunWith(AndroidJUnit4::class)
class MigrationTest {
    private val TEST_DB = "migration-test"

    @Rule
    val helper: MigrationTestHelper = MigrationTestHelper(
            InstrumentationRegistry.getInstrumentation(),
            MigrationDb::class.java.canonicalName,
            FrameworkSQLiteOpenHelperFactory()
    )

    @Test
    @Throws(IOException::class)
    fun migrate1To2() {
        var db = helper.createDatabase(TEST_DB, 1).apply {
            // db has schema version 1. insert some data using SQL queries.
            // You cannot use DAO classes because they expect the latest schema.
            execSQL(...)

            // Prepare for the next version.
            close()
        }

        // Re-open the database with version 2 and provide
        // MIGRATION_1_2 as the migration process.
        db = helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2)

        // MigrationTestHelper automatically verifies the schema changes,
        // but you need to validate that the data was migrated properly.
    }
}

자바

@RunWith(AndroidJUnit4.class)
public class MigrationTest {
    private static final String TEST_DB = "migration-test";

    @Rule
    public MigrationTestHelper helper;

    public MigrationTest() {
        helper = new MigrationTestHelper(InstrumentationRegistry.getInstrumentation(),
                MigrationDb.class.getCanonicalName(),
                new FrameworkSQLiteOpenHelperFactory());
    }

    @Test
    public void migrate1To2() throws IOException {
        SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1);

        // db has schema version 1. insert some data using SQL queries.
        // You cannot use DAO classes because they expect the latest schema.
        db.execSQL(...);

        // Prepare for the next version.
        db.close();

        // Re-open the database with version 2 and provide
        // MIGRATION_1_2 as the migration process.
        db = helper.runMigrationsAndValidate(TEST_DB, 2, true, MIGRATION_1_2);

        // MigrationTestHelper automatically verifies the schema changes,
        // but you need to validate that the data was migrated properly.
    }
}

모든 이전 테스트

단일 증분 이전을 테스트할 수 있지만 앱 데이터베이스의 정의된 모든 이전을 포괄하는 테스트를 포함하는 것이 좋습니다. 이렇게 하면 최근에 생성된 데이터베이스 인스턴스와 정의된 이전 경로를 따랐던 예전 인스턴스 간에 불일치가 발생하지 않습니다.

다음 예는 정의된 모든 이전에 관한 테스트를 보여줍니다.

Kotlin

@RunWith(AndroidJUnit4::class)
class MigrationTest {
    private val TEST_DB = "migration-test"

    // Array of all migrations
    private val ALL_MIGRATIONS = arrayOf(
            MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)

    @Rule
    val helper: MigrationTestHelper = MigrationTestHelper(
            InstrumentationRegistry.getInstrumentation(),
            AppDatabase::class.java.canonicalName,
            FrameworkSQLiteOpenHelperFactory()
    )

    @Test
    @Throws(IOException::class)
    fun migrateAll() {
        // Create earliest version of the database.
        helper.createDatabase(TEST_DB, 1).apply {
            close()
        }

        // Open latest version of the database. Room will validate the schema
        // once all migrations execute.
        Room.databaseBuilder(
                InstrumentationRegistry.getInstrumentation().getTargetContext(),
                AppDatabase.class,
                TEST_DB
        ).addMigrations(*ALL_MIGRATIONS).build().apply {
            getOpenHelper().getWritableDatabase()
            close()
        }
    }
}

자바

@RunWith(AndroidJUnit4.class)
public class MigrationTest {
    private static final String TEST_DB = "migration-test";

    @Rule
    public MigrationTestHelper helper;

    public MigrationTest() {
        helper = new MigrationTestHelper(InstrumentationRegistry.getInstrumentation(),
                AppDatabase.class.getCanonicalName(),
                new FrameworkSQLiteOpenHelperFactory());
    }

    @Test
    public void migrateAll() throws IOException {
        // Create earliest version of the database.
        SupportSQLiteDatabase db = helper.createDatabase(TEST_DB, 1);
        db.close();

        // Open latest version of the database. Room will validate the schema
        // once all migrations execute.
        AppDatabase appDb = Room.databaseBuilder(
                InstrumentationRegistry.getInstrumentation().getTargetContext(),
                AppDatabase.class,
                TEST_DB)
                .addMigrations(ALL_MIGRATIONS).build();
        appDb.getOpenHelper().getWritableDatabase();
        appDb.close();
    }

    // Array of all migrations
    private static final Migration[] ALL_MIGRATIONS = new Migration[]{
            MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4};
}

누락된 이전 경로의 적절한 처리

Room이 기기의 기존 데이터베이스를 현재 버전으로 업그레이드하기 위한 이전 경로를 찾을 수 없으면 IllegalStateException이 발생합니다. 이전 경로가 누락되었을 때 기존 데이터를 잃어도 괜찮다면 데이터베이스 생성 시 다음과 같이 fallbackToDestructiveMigration() 빌더 메서드를 호출합니다.

Kotlin

Room.databaseBuilder(applicationContext, MyDb::class.java, "database-name")
        .fallbackToDestructiveMigration()
        .build()

자바

Room.databaseBuilder(getApplicationContext(), MyDb.class, "database-name")
        .fallbackToDestructiveMigration()
        .build();

이 메서드는 정의된 이전 경로가 없는 증분 이전을 실행해야 할 때 앱의 데이터베이스에 테이블을 파괴적인 방식으로 다시 생성하도록 Room에 지시합니다.

Room이 특정 상황에서만 파괴적인 재생성으로 대체하도록 하려면 다음과 같이 fallbackToDestructiveMigration()의 대안이 몇 가지 있습니다.

  • 스키마 기록의 특정 버전에서 이전 경로로 해결할 수 없는 오류가 발생하면 대신 fallbackToDestructiveMigrationFrom()을 사용하세요. 이 메서드를 사용하면 특정 버전에서 이전할 때만 Room이 파괴적인 재생성으로 대체합니다.
  • 데이터베이스 상위 버전에서 하위 버전으로 이전할 때만 Room이 파괴적인 재생성으로 대체하도록 하려면 fallbackToDestructiveMigrationOnDowngrade()를 사용하세요.

Room 2.2.0으로 업그레이드 시 열 기본값 처리

Room 2.2.0 이상에서 @ColumnInfo(defaultValue = "...") 주석을 사용하여 열의 기본값을 정의할 수 있습니다. 2.2.0보다 낮은 버전에서 열의 기본값을 정의하는 유일한 방법은 실행된 SQL 문에서 직접 기본값을 정의하는 것으로, 이는 Room이 알지 못하는 기본값을 생성합니다. 즉, 데이터베이스가 원래 2.2.0보다 낮은 Room 버전으로 생성되었다면 Room 2.2.0을 사용하도록 앱을 업그레이드하려고 할 때 Room API를 사용하지 않고 정의한 기존 기본값에 특수한 이전 경로를 제공해야 할 수 있습니다.

예를 들어 데이터베이스 버전 1이 Song 항목을 정의한다고 가정해 보겠습니다.

Kotlin

// Song Entity, DB Version 1, Room 2.1.0
@Entity
data class Song(
    @PrimaryKey
    val id: Long,
    val title: String
)

자바

// Song Entity, DB Version 1, Room 2.1.0
@Entity
public class Song {
    @PrimaryKey
    final long id;
    final String title;
}

또한 동일한 데이터베이스의 버전 2가 새로운 NOT NULL 열을 추가하고 버전 1에서 버전 2로의 이전 경로를 정의한다고 가정해 보겠습니다.

Kotlin

// Song Entity, DB Version 2, Room 2.1.0
@Entity
data class Song(
    @PrimaryKey
    val id: Long,
    val title: String,
    val tag: String // Added in version 2.
)

// Migration from 1 to 2, Room 2.1.0
val MIGRATION_1_2 = object : Migration(1, 2) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL(
            "ALTER TABLE Song ADD COLUMN tag TEXT NOT NULL DEFAULT ''")
    }
}

자바

// Song Entity, DB Version 2, Room 2.1.0
@Entity
public class Song {
    @PrimaryKey
    final long id;
    final String title;
    @NonNull
    final String tag; // Added in version 2.
}

// Migration from 1 to 2, Room 2.1.0
static final Migration MIGRATION_1_2 = new Migration(1, 2) {
    @Override
    public void migrate(SupportSQLiteDatabase database) {
        database.execSQL(
            "ALTER TABLE Song ADD COLUMN tag TEXT NOT NULL DEFAULT ''");
    }
};

이로 인해 기본 테이블에 앱 업데이트와 신규 설치 간 불일치가 발생합니다. tag 열의 기본값은 버전 1에서 버전 2로의 이전 경로에만 선언되므로 버전 2부터 앱을 설치한 모든 사용자는 데이터베이스 스키마에 tag의 기본값이 없습니다.

2.2.0보다 낮은 Room 버전에서는 이 불일치가 무해합니다. 그러나 나중에 앱이 Room 2.2.0 이상을 사용하도록 업그레이드하고 @ColumnInfo 주석을 사용하여 tag의 기본값을 포함하도록 Song 항목 클래스를 변경하면 이제 Room에서 이 불일치를 확인할 수 있습니다. 이로 인해 스키마 유효성 검사가 실패합니다.

초기에 이전 경로에서 열 기본값이 선언될 때 데이터베이스 스키마가 모든 사용자에서 일관되도록 하려면 Room 2.2.0 이상을 사용하도록 앱을 처음 업그레이드할 때 다음을 실행합니다.

  1. @ColumnInfo 주석을 사용하여 각 항목 클래스에서 열 기본값을 선언합니다.
  2. 데이터베이스 버전 번호를 1씩 늘립니다.
  3. 삭제 및 재생성 전략을 구현하는 새 버전 이전 경로를 정의하여 필요한 기본값을 기존 열에 추가합니다.

다음 예는 이 프로세스를 보여줍니다.

Kotlin

// Migration from 2 to 3, Room 2.2.0
val MIGRATION_2_3 = object : Migration(2, 3) {
    override fun migrate(database: SupportSQLiteDatabase) {
        database.execSQL("""
                CREATE TABLE new_Song (
                    id INTEGER PRIMARY KEY NOT NULL,
                    name TEXT,
                    tag TEXT NOT NULL DEFAULT ''
                )
                """.trimIndent())
        database.execSQL("""
                INSERT INTO new_Song (id, name, tag)
                SELECT id, name, tag FROM Song
                """.trimIndent())
        database.execSQL("DROP TABLE Song")
        database.execSQL("ALTER TABLE new_Song RENAME TO Song")
    }
}

자바

// Migration from 2 to 3, Room 2.2.0
static final Migration MIGRATION_2_3 = new Migration(2, 3) {
    @Override
    public void migrate(SupportSQLiteDatabase database) {
        database.execSQL("CREATE TABLE new_Song (" +
                "id INTEGER PRIMARY KEY NOT NULL," +
                "name TEXT," +
                "tag TEXT NOT NULL DEFAULT '')");
        database.execSQL("INSERT INTO new_Song (id, name, tag) " +
                "SELECT id, name, tag FROM Song");
        database.execSQL("DROP TABLE Song");
        database.execSQL("ALTER TABLE new_Song RENAME TO Song");
    }
};