Room 데이터베이스 이전

앱에서 기능을 추가하고 변경하는 경우 Room 항목 클래스와 기본 데이터베이스 테이블을 수정하여 이러한 변경사항을 반영해야 합니다. 앱 업데이트로 인해 데이터베이스 스키마가 변경될 때는 기기 내 데이터베이스에 있는 사용자 데이터를 유지하는 것이 중요합니다.

Room에서는 자동 및 수동 방식의 증분 이전 옵션을 모두 지원합니다. 대부분의 기본 스키마 변경은 자동 이전이 가능하지만 좀 더 복잡한 변경인 경우 수동으로 이전 경로를 정의해야 할 수 있습니다.

자동 이전

두 데이터베이스 버전 간의 자동 이전을 선언하려면 @DatabaseautoMigrations 속성에 @AutoMigration 주석을 추가하세요.

Kotlin

// Database class before the version update.
@Database(
  version = 1,
  entities = [User::class]
)
abstract class AppDatabase : RoomDatabase() {
  ...
}

// Database class after the version update.
@Database(
  version = 2,
  entities = [User::class],
  autoMigrations = [
    AutoMigration (from = 1, to = 2)
  ]
)
abstract class AppDatabase : RoomDatabase() {
  ...
}

Java

// Database class before the version update.
@Database(
  version = 1,
  entities = {User.class}
)
public abstract class AppDatabase extends RoomDatabase {
  ...
}

// Database class after the version update.
@Database(
  version = 2,
  entities = {User.class},
  autoMigrations = {
    @AutoMigration (from = 1, to = 2)
  }
)
public abstract class AppDatabase extends RoomDatabase {
  ...
}

자동 이전 사양

Room에서 모호한 스키마 변경사항을 감지했는데 추가 입력 없이는 이전 계획을 생성할 수 없는 경우 컴파일 시간 오류가 발생하고 개발자에게 AutoMigrationSpec을 구현하라는 메시지가 표시됩니다. 다음 중 하나가 포함된 이전에서 이러한 상황이 가장 자주 발생합니다.

  • 테이블 삭제 또는 이름 바꾸기
  • 열 삭제 또는 이름 바꾸기

AutoMigrationSpec을 사용하여 이전 경로를 올바르게 생성하는 데 필요한 추가 정보를 Room에 제공할 수 있습니다. RoomDatabase 클래스에서 AutoMigrationSpec을 구현하는 정적 클래스를 정의하고 다음 중 하나 이상으로 주석을 달면 됩니다.

자동 이전에 AutoMigrationSpec 구현을 사용하려면 상응하는 @AutoMigration 주석에 spec 속성을 설정하세요.

Kotlin

@Database(
  version = 2,
  entities = [User::class],
  autoMigrations = [
    AutoMigration (
      from = 1,
      to = 2,
      spec = AppDatabase.MyAutoMigration::class
    )
  ]
)
abstract class AppDatabase : RoomDatabase() {
  @RenameTable(fromTableName = "User", toTableName = "AppUser")
  class MyAutoMigration : AutoMigrationSpec
  ...
}

Java

@Database(
  version = 2,
  entities = {AppUser.class},
  autoMigrations = {
    @AutoMigration (
      from = 1,
      to = 2,
      spec = AppDatabase.MyAutoMigration.class
    )
  }
)
public abstract class AppDatabase extends RoomDatabase {
  @RenameTable(fromTableName = "User", toTableName = "AppUser")
  static class MyAutoMigration implements AutoMigrationSpec { }
  ...
}

자동 이전이 완료된 후 앱에서 더 많은 작업을 해야 한다면 onPostMigrate()를 구현하면 됩니다. AutoMigrationSpec에 이 메서드를 구현하면 Room에서는 자동 이전이 완료된 후 이를 호출합니다.

수동 이전

복잡한 스키마 변경사항이 포함된 이전이라면 Room에서 적절한 이전 경로를 자동으로 생성하지 못할 수도 있습니다. 예를 들어 한 테이블의 데이터를 두 테이블로 분할하려고 하는 경우 Room에서는 어떻게 분할해야 하는지 알 수 없습니다. 이 같은 경우에는 Migration 클래스를 구현하여 수동으로 이전 경로를 정의해야 합니다.

Migration 클래스는 Migration.migrate() 메서드를 재정의하여 startVersionendVersion 간의 이전 경로를 명시적으로 정의합니다. addMigrations() 메서드를 사용하여 Migration 클래스를 데이터베이스 빌더에 추가하세요.

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()

Java

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은 room-testing Maven 아티팩트를 제공하여 자동 및 수동 이전의 테스트 프로세스를 지원합니다. 이 아티팩트가 작동하려면 먼저 데이터베이스 스키마를 내보내야 합니다.

스키마 내보내기

Room은 컴파일 시 데이터베이스 스키마 정보를 JSON 파일로 내보낼 수 있습니다. 내보낸 JSON 파일은 데이터베이스의 스키마 기록을 나타냅니다. Room이 더 낮은 버전의 데이터베이스를 만들어서 테스트하고 자동 이전 생성을 사용 설정할 수 있도록 이러한 파일을 버전 제어 시스템에 저장하세요.

Room Gradle 플러그인을 사용하여 스키마 위치 설정

Room 버전 2.6.0 이상을 사용하는 경우 Room Gradle 플러그인을 적용하고 room 확장 프로그램을 사용하여 스키마 디렉터리를 지정할 수 있습니다.

Groovy

plugins {
  id 'androidx.room'
}

room {
  schemaDirectory "$projectDir/schemas"
}

Kotlin

plugins {
  id("androidx.room")
}

room {
  schemaDirectory("$projectDir/schemas")
}

데이터베이스 스키마가 변형, 버전 또는 빌드 유형에 따라 다른 경우 schemaDirectory() 구성을 여러 번 사용하고 각각 variantMatchName을 첫 번째 인수로 지정하여 서로 다른 위치를 지정해야 합니다. 각 구성은 변형 이름과의 간단한 비교를 기준으로 하나 이상의 변형을 일치시킬 수 있습니다.

모든 대안을 포괄해야 합니다. 또한 variantMatchName 없이 schemaDirectory()를 포함하여 다른 구성과 일치하지 않는 변형을 처리할 수도 있습니다. 예를 들어 두 가지 빌드 버전(demofull)과 두 가지 빌드 유형(debugrelease)이 있는 앱의 경우 유효한 구성은 다음과 같습니다.

Groovy

room {
  // Applies to 'demoDebug' only
  schemaLocation "demoDebug", "$projectDir/schemas/demoDebug"

  // Applies to 'demoDebug' and 'demoRelease'
  schemaLocation "demo", "$projectDir/schemas/demo"

  // Applies to 'demoDebug' and 'fullDebug'
  schemaLocation "debug", "$projectDir/schemas/debug"

  // Applies to variants that aren't matched by other configurations.
  schemaLocation "$projectDir/schemas"
}

Kotlin

room {
  // Applies to 'demoDebug' only
  schemaLocation("demoDebug", "$projectDir/schemas/demoDebug")

  // Applies to 'demoDebug' and 'demoRelease'
  schemaLocation("demo", "$projectDir/schemas/demo")

  // Applies to 'demoDebug' and 'fullDebug'
  schemaLocation("debug", "$projectDir/schemas/debug")

  // Applies to variants that aren't matched by other configurations.
  schemaLocation("$projectDir/schemas")
}

주석 프로세서 옵션을 사용하여 스키마 위치 설정

Room 버전 2.5.2 이하를 사용하거나 Room Gradle 플러그인을 사용하지 않는다면 room.schemaLocation 주석 프로세서 옵션을 사용하여 스키마 위치를 설정합니다.

이 디렉터리의 파일은 일부 Gradle 작업의 입력 및 출력으로 사용됩니다. 증분 빌드와 캐시된 빌드의 정확성과 성능을 위해 Gradle의 CommandLineArgumentProvider를 사용하여 이 디렉터리에 대해 Gradle에 알려야 합니다.

먼저 아래에 표시된 RoomSchemaArgProvider 클래스를 모듈의 Gradle 빌드 파일에 복사합니다. 샘플 클래스의 asArguments() 메서드는 room.schemaLocation=${schemaDir.path}KSP에 전달합니다. KAPTjavac를 사용하는 경우 이 값을 대신 -Aroom.schemaLocation=${schemaDir.path}로 변경합니다.

Groovy

class RoomSchemaArgProvider implements CommandLineArgumentProvider {

  @InputDirectory
  @PathSensitive(PathSensitivity.RELATIVE)
  File schemaDir

  RoomSchemaArgProvider(File schemaDir) {
    this.schemaDir = schemaDir
  }

  @Override
  Iterable<String> asArguments() {
    // Note: If you're using KAPT and javac, change the line below to
    // return ["-Aroom.schemaLocation=${schemaDir.path}".toString()].
    return ["room.schemaLocation=${schemaDir.path}".toString()]
  }
}

Kotlin

class RoomSchemaArgProvider(
  @get:InputDirectory
  @get:PathSensitive(PathSensitivity.RELATIVE)
  val schemaDir: File
) : CommandLineArgumentProvider {

  override fun asArguments(): Iterable<String> {
    // Note: If you're using KAPT and javac, change the line below to
    // return listOf("-Aroom.schemaLocation=${schemaDir.path}").
    return listOf("room.schemaLocation=${schemaDir.path}")
  }
}

그런 다음 지정된 스키마 디렉터리와 함께 RoomSchemaArgProvider를 사용하도록 컴파일 옵션을 구성합니다.

Groovy

// For KSP, configure using KSP extension:
ksp {
  arg(new RoomSchemaArgProvider(new File(projectDir, "schemas")))
}

// For javac or KAPT, configure using android DSL:
android {
  ...
  defaultConfig {
    javaCompileOptions {
      annotationProcessorOptions {
        compilerArgumentProviders(
          new RoomSchemaArgProvider(new File(projectDir, "schemas"))
        )
      }
    }
  }
}

Kotlin

// For KSP, configure using KSP extension:
ksp {
  arg(RoomSchemaArgProvider(File(projectDir, "schemas")))
}

// For javac or KAPT, configure using android DSL:
android {
  ...
  defaultConfig {
    javaCompileOptions {
      annotationProcessorOptions {
        compilerArgumentProviders(
          RoomSchemaArgProvider(File(projectDir, "schemas"))
        )
      }
    }
  }
}

단일 이전 테스트

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

build.gradle

Groovy

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

dependencies {
    ...
    androidTestImplementation "androidx.room:room-testing:2.6.1"
}

Kotlin

android {
    ...
    sourceSets {
        // Adds exported schema location as test app assets.
        getByName("androidTest").assets.srcDir("$projectDir/schemas")
    }
}

dependencies {
    ...
    testImplementation("androidx.room:room-testing:2.6.1")
}

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

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

Kotlin

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

    @get: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 {
            // Database has schema version 1. Insert some data using SQL queries.
            // You can't 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.
    }
}

Java

@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);

        // Database has schema version 1. Insert some data using SQL queries.
        // You can't 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)

    @get: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 validates the schema
        // once all migrations execute.
        Room.databaseBuilder(
            InstrumentationRegistry.getInstrumentation().targetContext,
            AppDatabase::class.java,
            TEST_DB
        ).addMigrations(*ALL_MIGRATIONS).build().apply {
            openHelper.writableDatabase.close()
        }
    }
}

Java

@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 validates 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()

Java

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, database version 1, Room 2.1.0.
@Entity
data class Song(
    @PrimaryKey
    val id: Long,
    val title: String
)

Java

// Song entity, database 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, database 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 ''")
    }
}

Java

// Song entity, database 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")
    }
}

Java

// 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");
    }
};