[android] Room Migration 제대로 알고써보자

윤찬·2025년 7월 22일

Android

목록 보기
8/37

페이징 처리를 구현할 때 사용되었던 방법 중 하나가 RemoteMediator가 있다. 이는 api를 받아온 정보를 Room에 저장해서 값을 가져오는 방식인데, 예전에 회사에서 이 방법을 썼다가 데이터 값이 바뀌면서 많은 오류가 발생한 적이 있다.

이 경우 마이그레이션을 해줬어야 했는데 그 때 당시에는 마이그레이션에 대해 몰랐었고 이번에 프로젝트를 구현하면서 마이그레이션에 대한 방법들을 배워보도록 하자.

기본 베이스

일단 Room에 대한 기본 구현들은 생략하고 어떻게 동작하는지만 간략하게 소개하면 아래와 같다.
기본 베이스는 Room 공식 문서에 있는 User를 이용해 데이터를 만들었고 이를 통해 화면에 보여주는 용으로 구현했다.

//user 정보
@Entity
data class User(
    @PrimaryKey val uid: Int,
    @ColumnInfo(name = "first_name") val firstName: String?,
    @ColumnInfo(name = "last_name") val lastName: String?
)
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    private val viewModel: MainViewModel by viewModels()
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            RoomExTheme {
                val state by viewModel.state.collectAsStateWithLifecycle()
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    Greeting(
                        viewModel = viewModel,
                        users = state.users,
                        modifier = Modifier.padding(innerPadding)
                    )
                }
            }
        }
    }
}

@Composable
fun Greeting(
    viewModel: MainViewModel,
    users: List<User>,
    modifier: Modifier = Modifier
) {
    Column(modifier = modifier.fillMaxSize()){
        LazyColumn(
            modifier = modifier
                .fillMaxWidth()
                .weight(1f)
        ) {
            items(
                count = users.size
            ) {
                Column(
                    modifier = Modifier.fillMaxWidth().padding(8.dp),
                    verticalArrangement = Arrangement.spacedBy(4.dp)
                ) {
                    Text(text="${users[it].uid}")
                    Text(text="${users[it].firstName}")
                    Text(text="${users[it].lastName}")
                }
            }
        }

        Button(
            modifier = Modifier.fillMaxWidth().height(50.dp),
            onClick = { viewModel.insertUsers(3) }
        ) {
            Text("랜덤유저 생성기")
        }
    }
}

코드를 보면 조잡하지만 간단하게 테스트할 용으로 작성했기 때문에 대충 구현했다.
버튼을 누르면 User가 3개씩 생성이 되는데 생성 될때마다 변경된 데이터를 보여주도록 구현했다. 결관느 아래 gif를 보면 금방 알 것이다.

이제부터 어떤 값이 변경될 때 어떻게 마이그레이션을 처리하면 좋을지 알아보자.

마이그레이션 적용해보기

시작하기에 앞서 Room 마이그레이션에는 자동 이전과 수동 이전 방식이 있다.
자동 이전인 경우는 아래와 같은 코드를 이용해 간단하게 정보를 변경할 수 있다.

@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
  ...
}

다만 변경하는 것에는 제약이 있는데, 아래의 조건일 경우에만 자동 이전을 이용해 마이그레이션을 적용할 수 있다.

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

이 두 조건을 아래에 있는 4개의 어노테이션을 사용해 변경이 가능하다.

  • @DeleteTable
  • @RenameTable
  • @DeleteColumn
  • @RenameColumn

참고로 자동이전을 사용하려면 gradle쪽에 추가 작업이 필요하다.

plugins {
    id("androidx.room")
}

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

위 작업을 하게 되면 이제 build했을 때 해당 버전에 맞는 json파일을 찾을 수 있을것이다.(schemas 패키지)

Column의 이름이 바뀌었을 경우

앞서 위에 User 엔티티를 봤을 것이다.
그 중에서 first_name의 컬럼명을 바꿨을 때 자동 이전으로 해결해보자

//user 정보
@Entity
data class User(
    @PrimaryKey val uid: Int,
    //first_name -> first_name2로 변경
    @ColumnInfo(name = "first_name2") val firstName: String?,
    @ColumnInfo(name = "last_name") val lastName: String?
)

먼저 마이그래이션을 진행하지 않고 실행하면 어떤 결과가 나올까?

위와 같이 빌드 될 때 first_name을 찾을 수 없다는 오류와 함께 실행이 되지 않는다.

이를 해결하기 위해 아래와 같이 코드를 작성했다.

@Database(
    entities = [User::class],
    version = 2,
    autoMigrations = [
        AutoMigration(
            from = 1,
            to = 2,
            spec = AppDatabase.ChangeFirstNameColumn::class
        )
    ]
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao

    @RenameColumn(tableName = "User", fromColumnName = "first_name", toColumnName = "first_name2")
    class ChangeFirstNameColumn : AutoMigrationSpec
}

first_name을 first_name2로 바꾸는 ChangeFirstNameColumn을 생성하고 @Database 어노테이션에 autoMigrations를 추가한 것을 볼 수 있다.

Column 삭제하기

이번에는 last_name의 컬럼을 삭제하는 방법을 알아보자. 이 경우도 자동 이전 방식으로 쉽게 구현이 가능하다.

@Entity
data class User(
    @PrimaryKey val uid: Int,
    @ColumnInfo(name = "first_name2") val firstName: String?,
)
@Database(
    entities = [User::class],
    version = 3,
    autoMigrations = [
        AutoMigration(
            from = 1,
            to = 2,
            spec = AppDatabase.ChangeFirstNameColumn::class
        ),
        AutoMigration(
            from = 2,
            to = 3,
            spec = AppDatabase.DeleteLastNameColumn::class
        ),
    ]
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao

    @RenameColumn(tableName = "User", fromColumnName = "first_name", toColumnName = "first_name2")
    class ChangeFirstNameColumn : AutoMigrationSpec

    @DeleteColumn(tableName = "User", columnName = "last_name")
    class DeleteLastNameColumn : AutoMigrationSpec
}

실행해보면 lastName의 결과가 사라진 것을 볼 수 있다.

오우 자동이전은 이런 테이블 또는 컬럼 이름을 변경하거나 삭제할 때 매우 용이하다.


하지만 실전은 컬럼 이름만 바뀌는 것이 아니다. API가 변경되면서 데이터의 값이 바뀌거나 추가되거나, 아니면 다른 데이터클래스를 받아서 사용하는 경우가 생길 수 있다.

이런 경우에는 자동 이전으로는 힘든 경우가 발생해 수동 이전을 이용해 마이그레이션을 진행해야 한다.

삭제한 lastName 복구하기 (String)

이전에 삭제한 컬럼값을 다시 복구해보자.

@Entity
data class User(
    @PrimaryKey val uid: Int,
    @ColumnInfo(name = "first_name2") val firstName: String?,
    //이전과 차이점이라면 String? -> String으로 변경됐다.
    @ColumnInfo(name = "last_name") val lastName: String
)

@Database(
    entities = [User::class],
    version = 4,
    autoMigrations = [
        AutoMigration(
            from = 1,
            to = 2,
            spec = AppDatabase.ChangeFirstNameColumn::class
        ),
        AutoMigration(
            from = 2,
            to = 3,
            spec = AppDatabase.DeleteLastNameColumn::class
        ),
    ]
)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
	//...
	
    //여기가 수동 마이그레이션 한 부분
    companion object {
        val MIGRATION_3_4 = object : Migration(3, 4) {
            override fun migrate(database: SupportSQLiteDatabase) {
            	//직접 SQL문을 작성해서 last_name 컬럼을 추가했다. 디폴트 값은 1로 지정
                database.execSQL("ALTER TABLE User ADD COLUMN last_name TEXT NOT NULL DEFAULT '1'")
            }
        }
    }
}
----

//의존성 주입한 database
@Module
@InstallIn(SingletonComponent::class)
object RoomModule {

    @Singleton
    @Provides
    fun provideRoom(@ApplicationContext context: Context): AppDatabase = Room.databaseBuilder(
        context.applicationContext,
        AppDatabase::class.java,
        "room-ex"
    )
    	//여기에 수동 마이그레이션 작업 진행
        .addMigrations(AppDatabase.MIGRATION_3_4)
        .build()
}
결과를 보면 last_name에 1이 들어가있는 것을 볼 수 있다.

last_name을 String -> String?으로 바꾸기

사실 이것도 하기 위해 String 값으로 지정했다. 일단 String -> String?으로 지정하면 어떻게 될까?

Process: com.example.roomex, PID: 12911
java.lang.IllegalStateException: Room cannot verify the data integrity. Looks like you've changed schema but forgot to update the version number. You can simply fix this by increasing the version number. Expected identity hash: 007ff2de03629f14142918de481f70e8, found: cbb22dbd3a93e65eccdefa2cdb0f8cc8
at androidx.room.BaseRoomConnectionManager.checkIdentity(RoomConnectionManager.kt:288)
at androidx.room.BaseRoomConnectionManager.onOpen(RoomConnectionManager.kt:267)
at androidx.room.RoomConnectionManagerSupportOpenHelperCallback.onOpen(RoomConnectionManager.android.kt:164)atandroidx.sqlite.db.framework.FrameworkSQLiteOpenHelperSupportOpenHelperCallback.onOpen(RoomConnectionManager.android.kt:164) at androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperOpenHelper.onOpen(FrameworkSQLiteOpenHelper.android.kt:279)
at android.database.sqlite.SQLiteOpenHelper.getDatabaseLocked(SQLiteOpenHelper.java:427)
at android.database.sqlite.SQLiteOpenHelper.getWritableDatabase(SQLiteOpenHelper.java:316)

바로 위와 같이 실행하면서 오류가 발생한다. Room은 hash값을 이용해 정보를 확인하는데 이 때 엔티티의 값이 NULLABLE로만 바뀌어도 오류가 발생한다.

그럼 버전만 올려도 되는거 아니냐? 라고 생각이 들어서 한번 버전만 올려보았다.

Process: com.example.roomex, PID: 12991
java.lang.IllegalStateException: A migration from 4 to 5 was required but not found. Please provide the necessary Migration path via RoomDatabase.Builder.addMigration(...) or allow for destructive migrations via one of the RoomDatabase.Builder.fallbackToDestructiveMigration* functions.

버전만 올렸을 때는 위와 같은 오류가 발생한다. 마이그레이션이 필요하다는 오류가 뜬다. 즉, null값으로 바뀌거나 반대로 nullable하지 않은 값으로 바뀌었을 때도 마이그레이션을 진행해야 한다.

이 또한 수동 이전을 통해 해결할 수 있다.

참고로 sql 작성문은 gpt한테 물어봤다... sql 잘 사용하면 이것보다 잘 짤 수 있을 것 같은데 대부분 새로운 테이블 생성 -> 기존 테이블 Insert -> 기존 테이블 삭제 -> 새로운 테이블을 기존테이블로 이름을 바꾸는 방식을 사용하는 것 같다.

@Entity
data class User(
    @PrimaryKey val uid: Int,
    @ColumnInfo(name = "first_name2") val firstName: String?,
    //다시 String?으로 변경
    @ColumnInfo(name = "last_name") val lastName: String?
)
@Database(
    entities = [User::class],
    version = 5,
    autoMigrations = [
        AutoMigration(
            from = 1,
            to = 2,
            spec = AppDatabase.ChangeFirstNameColumn::class
        ),
        AutoMigration(
            from = 2,
            to = 3,
            spec = AppDatabase.DeleteLastNameColumn::class
        ),
    ]
)
abstract class AppDatabase : RoomDatabase() {
    //...
    companion object {
        //...
		
        //여기가 nullable한 값으로 바뀐 sql문
        val MIGRATION_4_5 = object : Migration(4, 5) {
            override fun migrate(database: SupportSQLiteDatabase) {
                // 1. 새 테이블 생성 (last_name 컬럼을 nullable로 정의)
                database.execSQL("""
            CREATE TABLE IF NOT EXISTS User_new (
                uid INTEGER NOT NULL PRIMARY KEY,
                first_name2 TEXT,
                last_name TEXT
            )
        """.trimIndent())

                // 2. 기존 데이터 복사
                database.execSQL("""
            INSERT INTO User_new (uid, first_name2, last_name)
            SELECT uid, first_name2, last_name FROM User
        """.trimIndent())

                // 3. 기존 테이블 삭제
                database.execSQL("DROP TABLE User")

                // 4. 새 테이블 이름 변경
                database.execSQL("ALTER TABLE User_new RENAME TO User")
            }
        }
    }
}

---
@Module
@InstallIn(SingletonComponent::class)
object RoomModule {

    @Singleton
    @Provides
    fun provideRoom(@ApplicationContext context: Context): AppDatabase = Room.databaseBuilder(
        context.applicationContext,
        AppDatabase::class.java,
        "room-ex"
    )	
    	//마이그래이션 추가
        .addMigrations(AppDatabase.MIGRATION_3_4, AppDatabase.MIGRATION_4_5)
        .build()
}

이번에도 역시 잘 동작한다.


새로운 컬럼 값 추가(데이터 클래스)

이번에는 프리미티브 값이 아닌 데이터 클래스로 이루어진 컬럼이 추가 됐을 때 방법을 알아보자.

예를 들어 주소가 추가된다고 가정해보자

data class Address(
    val city: String,
    val street: String
)
---
@Entity
data class User(
    @PrimaryKey val uid: Int,
    @ColumnInfo(name = "first_name2") val firstName: String?,
    @ColumnInfo(name = "last_name") val lastName: String?,
    @ColumnInfo(name = "address") val address: Address
)

위와 같이 추가가 되었을 때 어떻게 마이그레이션을 진행해야 할까?
사실 방법이 2가지가 있다 첫 번째는 @Embbeded 어노테이션 사용과 @TypeConverter를 적용해 추가하는 방법이다.

먼저 Address로는 @Embedded를 사용했을 때를 알아보자. 먼저 이 Embedded의 역할을 보자

@Embedded는 Room에서 복합 객체(data class)를 테이블의 개별 컬럼으로 분해(flatten)하여 저장하도록 지정하는 어노테이션입니다.
즉, @Entity 클래스 내에 포함된 또 다른 data class의 필드를 별도의 컬럼으로 생성해주는 역할을 합니다.

이 뜻이 무엇이먀면 @Embedded를 사용한 데이터 클래스는 해당 데이터클래스가 벗겨진 파라미터 값을로 채워진다는 것이다.

예를 들면 아래와 같다

@Entity
data class User(
    @PrimaryKey val uid: Int,
    @ColumnInfo(name = "first_name2") val firstName: String?,
    @ColumnInfo(name = "last_name") val lastName: String?,
    @Embedded val address: Address
)

---
//위에가 이제 아래처럼 동작이 된다는 것
@Entity
data class User(
    @PrimaryKey val uid: Int,
    @ColumnInfo(name = "first_name2") val firstName: String?,
    @ColumnInfo(name = "last_name") val lastName: String?,
    //아래 파라미터는 Address 데이터 클래스 안에 있는 파라미터 값
    val city: String,
    val street: String
)

그러면 단순하게 city와 street 컬럼이 추가된다고 생각하면 된다.
이제 그럼 마이그레이션은 단순하게 두 컬럼을 추가하는 sql문을 작성하면 된다.

@Database(
    entities = [User::class],
    version = 6,
    autoMigrations = [
        AutoMigration(
            from = 1,
            to = 2,
            spec = AppDatabase.ChangeFirstNameColumn::class
        ),
        AutoMigration(
            from = 2,
            to = 3,
            spec = AppDatabase.DeleteLastNameColumn::class
        ),
    ]
)
abstract class AppDatabase : RoomDatabase() {
    //...
    companion object {
		
        //...
        
        val MIGRATION_5_6 = object : Migration(5, 6) {
            override fun migrate(database: SupportSQLiteDatabase) {
            	//city 컬럼 추가
                database.execSQL("ALTER TABLE User ADD COLUMN city TEXT NOT NULL DEFAULT 'city'")
                //street 컬럼 추가
                database.execSQL("ALTER TABLE User ADD COLUMN street TEXT NOT NULL DEFAULT 'street'")
            }
        }
    }
}
---
@Module
@InstallIn(SingletonComponent::class)
object RoomModule {

    @Singleton
    @Provides
    fun provideRoom(@ApplicationContext context: Context): AppDatabase = Room.databaseBuilder(
        context.applicationContext,
        AppDatabase::class.java,
        "room-ex"
    )
    	//마이그래이션 추가
        .addMigrations(AppDatabase.MIGRATION_3_4, AppDatabase.MIGRATION_4_5, AppDatabase.MIGRATION_5_6)
        .build()
}

이제 앱을 실행하면 아래와 같이 정상적으로 실행되는 것을 볼 수 있다.

정상적으로 동작되니까 조금 뿌듯하네잉..
근데 여기서 궁금증이 있을것이다. Address 파라미터 안에 또다른 데이터 클래스가 있는 경우는 어떻게 해야할까?
사실 정답은 간단한데 해당 클래스 안에서도 @Embedded를 사용하면 된다. 다만 이게 점점 깊어질수록 관리가 빡세질 수 있다는 단점이 있을 것 같다.

이어서 두 번째 방법인 @TypeConverter를 사용해보자
또다른 데이터 클래스 Info가 있다고 생각해보자.

data class Info(
    val phoneNumber: String,
    val age: Int
)
---
@Entity
data class User(
    @PrimaryKey val uid: Int,
    @ColumnInfo(name = "first_name2") val firstName: String?,
    @ColumnInfo(name = "last_name") val lastName: String?,
    @Embedded val address: Address,
    val info: Info?
)

Room에는 엔티티에 데이터 클래스가 있는 경우(프리미티브가 아닌 객체의 정보가 있는 경우) TypeConverter로 변환을 해줘야 한다. 나는 여기서 Json Serialization을 사용했다. 각각 다른 방식으로 편하게 변환해주면 된다.

class UserTypeConverter {
    @TypeConverter
    fun toJson(info: Info): String {
        return Json.encodeToString(info)
    }

    @TypeConverter
    fun fromJson(json: String): Info? {
        return Json.decodeFromString(json)
    }
}

이후 이 컨버터를 등록

@Database(
    entities = [User::class],
    version = 7,
    autoMigrations = [
        AutoMigration(
            from = 1,
            to = 2,
            spec = AppDatabase.ChangeFirstNameColumn::class
        ),
        AutoMigration(
            from = 2,
            to = 3,
            spec = AppDatabase.DeleteLastNameColumn::class
        ),
    ]
)
//여기에 컨버터 등록하기
@TypeConverters(value = [UserTypeConverter::class])
abstract class AppDatabase : RoomDatabase() {
    //...
    companion object {
        //...

		//마이그래이션 등록
        val MIGRATION_6_7 = object : Migration(6, 7) {
            override fun migrate(database: SupportSQLiteDatabase) {
                database.execSQL("ALTER TABLE User ADD COLUMN info TEXT")
            }
        }
    }
}
----
@Module
@InstallIn(SingletonComponent::class)
object RoomModule {

    @Singleton
    @Provides
    fun provideRoom(@ApplicationContext context: Context): AppDatabase = Room.databaseBuilder(
        context.applicationContext,
        AppDatabase::class.java,
        "room-ex"
    )
        .addMigrations(
            AppDatabase.MIGRATION_3_4,
            AppDatabase.MIGRATION_4_5,
            AppDatabase.MIGRATION_5_6,
            //추가하기
            AppDatabase.MIGRATION_6_7
        )
        .build()
}

이후 info가 널인 경우(즉 이 컬럼이 추가되기 전에 이미 존재하는 데이터들) 0과 Nothing을 출력하도록 구현했다.

앱을 실행하면 아래와 같이 정상적으로 동작한다.

버튼을 누르면 이번엔 0과 Nothing이 아닌 값이 정상적으로 들어오는 것을 볼 수 있다.


공식문서를 참조해 간단한 마이그레이션 동작을 진행하는 법을 배웠다. 실무에서는 아마 이것보다 더 복잡해지겠지만 기본 개념들을 학습했다면 이후 업데이틓 했을 때 앱이 실행되지 않는 것을 막을 수 있을 것이다.

profile
좋은 개발자가 되기까지

0개의 댓글