ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Room Migration 적용기
    안드로이드 2023. 7. 25. 00:34
    반응형

    필자가 만들었던 옛날 앱 애플갬성측정기를 리팩터링하면서 겪은 시행착오 중 Room Migration에 대한 적용기이다.

    약 4년 전에 만들어서 그런지, 관심사 분리는 커녕, 한 모델에 remote 응답 값, entity 값, 도메인 등등이 모두 섞여있었고, 디비 스키마도 마음에 들지 않았다.

    변경해야 할 항목은 크게 세 가지로 나눠볼 수 있었다.

    1. Room Coroutine 화
    2. Entity Model이 View까지 전파되지 않게 완전한 관심사 분리
    3. DB Schema 변경

    1번은 굉장히 쉬운 편에 속했다.

    디비 호출을 다른 thread에서 동작시킨 뒤, 결과 값을 main thread로 옮겨주는 로직은 당시 구글 예제 앱에 있던 Executor 객체를 모방해서 사용하고 있었다. 그래서 Coroutine으로 옮길 때 그리 어렵지 않았다.

     

    2번도 뭉쳐져 있는 모델을 View에서도 사용하고 있었기에, 로컬에 저장할 Entity를 새로 만들고, 기존 모델과의 Mapper를 만들어주면 됐다. 하지만 이때 3번과 겹쳐서 머리가 아파졌다.

     

    Room을 사용하다가 Entity 객체의 필드를 지우거나 이름을 바꾸면 앱이 터지는 경험은 누구나 했을 것이라 생각한다. 클래스 필드를 수정하더라도, 이미 선언된 테이블을 수정하겠다는 쿼리를 실행시키지 않았으니, 그 변화를 감지하지 못해 없는 값을 찾다가 터지는 것이다.

    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: fb268e70d9fa9d6c50066f4c731afbf1, found: b782b07768402004f16d743a7ddb33d2

     

    애플갬성측정기 앱은 이제는 아무도 사용하지 않지만, 플레이스토어에 올라와 있는 앱이다. 그렇기에 기존 Entity의 스키마를 변경해 버린 채로 업데이트를 한다면, 사용자는 원인도 모른 채 앱이 크래시가 날 것이며, 재설치를 해야만 다시 앱이 동작하게 될 것이다. 이런 현상을 방지하고자 Migration을 공부하고 적용해 보았다.

     


    AutoMigration

    라떼는 DB Migration을 하려면 디비 버전에 맞춰서 직접 ALTER 수정 쿼리를 일일이 작성해서 실행시켜야 했다. 그런데 지금은 간단한 변경점에 대해서는 자동으로 Migration 작업을 수행해 주는 기능이 생겼다!

    그래서 기존에 있던 Entity에서, Table과 Column 명만 변경되면 되는 친구들에 한해서 너무나 손쉽게 마이그레이션 작업을 할 수 있었다.

     

    본래는 아래처럼 일일이 쿼리를 쓰면서 귀찮은 작업을 했어야 했는데,

    class AppleProductEntityMigration : Migration(1, 2) {
    	override fun migrate(database: SupportSQLiteDatabase) {
    		database.execSQL("ALTER TABLE TestResult RENAME TO TestResultEntity")
    
    		database.execSQL("ALTER TABLE Category RENAME TO AppleProductCategoryEntity")
    
    		database.execSQL("ALTER TABLE ApplePower RENAME COLUMN minPower TO minScore")
    		database.execSQL("ALTER TABLE ApplePower RENAME COLUMN maxPower TO maxScore")
    		database.execSQL("ALTER TABLE ApplePower RENAME TO ApplePowerEntity")
    
    		database.execSQL("ALTER TABLE Product RENAME COLUMN categoryId TO appleProductCategoryId")
    		database.execSQL("ALTER TABLE Product RENAME COLUMN categoryIndex TO sortPriority")
            .....
    	}
    }

    아래처럼 좀 더 편하게 변경할 수 있었다.

    노가다 작업은 동일하지만, 그래도 쿼리를 직접 쓰는 것보다는 수월했다.

    @RenameTable(fromTableName = "TestResult", toTableName = "TestResultEntity")
    @RenameTable(fromTableName = "Category", toTableName = "AppleProductCategoryEntity")
    
    @RenameTable(fromTableName = "ApplePower", toTableName = "ApplePowerEntity")
    @RenameColumn(tableName = "ApplePower", fromColumnName = "minPower", toColumnName = "minScore")
    @RenameColumn(tableName = "ApplePower", fromColumnName = "maxPower", toColumnName = "maxScore")
    
    @RenameTable(fromTableName = "Product", toTableName = "AppleProductEntity")
    @DeleteColumn(tableName = "Product", columnName = "imageUrl")
    @DeleteColumn(tableName = "Product", columnName = "imageByteArray")
    @RenameColumn(tableName = "Product", fromColumnName = "categoryId", toColumnName = "appleProductCategoryId")
    @RenameColumn(tableName = "Product", fromColumnName = "categoryIndex", toColumnName = "sortPriority")
    class RecreateEntitiesMigration : AutoMigrationSpec

     


     

    Manual Migration

    제 아무리 AutoMigration이 제공된다 하더라도, 필자는 기존 Migration을 활용해야만 한다.

    앱을 종료하더라도 이전에 선택했던 항목들을 복원시켜 주기 위해 특정 데이터를 저장하고 있었다. 이 저장된 데이터의 스키마도 변경되니, Migration 후에도 그 상태 그대로 유지하기 위해 저장된 상태 그대로 디비를 마이그레이션 해주어야 했다. 이 작업에 대해서는 AutoMigration의 도움을 받을 수 없었다.

    ...
    database.execSQL("UPDATE AppleProductEntity SET isInBox = 1 WHERE id IN ($selectedProductIds)")
    ...
    database.execSQL("UPDATE AppleProductEntity SET hasAppleCare = 1 WHERE id IN ($hasAppleCareProductIds)")
    database.execSQL("DROP TABLE AppleBoxItem")

    좀 귀찮긴 하지만, SQL 문법만 조금 알고 있다면 그리 어렵지 않게 원하는 대로 실행시킬 수 있다.

     

    백엔드만큼의 복잡한 스킴을 다루지 않기에 그리 복잡하지 않았지만.. 역시나 Cursor를 직접 다루는 행위는 정말이지 끔찍하다. 오랜만에 while문을 쓰게 됐다.

     

     

    Column이 추가만 되는 경우?

    단순히 Entity에 필드가 추가만 된 경우에도 Manual Migration을 작성해주어야만 한다. 그렇지 않으면 Room이 작동을 안하는 기현상을 겪게 될 것이다.

    단순 추가 정도면, AutoMigration을 지원해줄 법도 하지 않나 라는 생각을 했지만.. 공식 문서를 뒤적여보니 아무리 찾아도 단순 추가에 대한 AutoMigration 지원은 찾을 수 없었다 ㅋㅋㅋ

     

    재미있는건, Manual Migration을 작성한 예시 코드에서 Column이 추가된 경우의 예시 코드가 작성 되어있었다.

    이걸 몰라서 삽질을 하다니.. 내 시간 돌려줘요!

     


     

    둘이 동시에 활용!

    가만 생각해 보니, AutoMigration에서 사용되는 SQL query가 생각보다 간단하기에, ManualMigration 파일 하나로도 충분히 목적을 달성할 수 있었다. 그럼에도 불구하고 새로운 AutoMigration을 써보고 싶었기에 간단한 것들은 AutoMigration을, 복잡한 건 Migration을 활용해서 혼합해서 사용하기로 결정했다.

     

    동일 버전 Migration

    두 Migration을 동일한 디비 버전 상승(1 → 2)에서 혼합해서 사용하려다 보니, 아래 문제점이 생겼다.

    AutoMigrationSpec이 빌드 타임에 먼저 DB Scheme json 파일을 생성 후 읽어 비교하다 보니, Migration에서 query를 통해 변경한 테이블명이나 컬럼명인 Scheme 변경사항을 감지하지 못해 빌드 에러를 냈다.

    당연한 이치이기도했다. Migration은 런타임에 SQL query를 실행시키니, 당연히 변경 사항이 빌드 타임에 적용될 리가 없다.

     

    여기서 해결 방법은 두 번의 DB 버전 상승이 있으면 됐다. 1 → 2 에서 AutoMigration을, 그리고 2 → 3에서 Migration을 실행시키게 만들면 해결할 수 있었다.

     

    여기에서 고민이 많았다. 두 번에 나눠 업데이트된 DB가 사실 한 번의 스키마 공사였다는 것을 남기기 어려웠고, scheme json 파일이 3개가 생성되는 것이 마음에 들지 않았다. 그래서 결국 AutoMigration의 활용은 재미있는 공부를 했다 정도로 아쉬움을 뒤로한 채 Migration 파일 하나로 DB 업데이트를 진행하기로 결정했다.

     

    미지원 query

    위 결정은 결국, 다시 AutoMigration를 사용하기로 뒤바뀌었다 ㅋㅋㅋ

    Migration query를 열심히 작성해 보고, 실제로 코드를 실행시켜 보니 어이없는 사실을 알게 되었다.

    SQLite에서는 ALTER TABLE DROP COLUMN 문법을 지원하지 않았다.

    android.database.sqlite.SQLiteException: near "DROP": syntax error (code 1 SQLITE_ERROR): , while compiling: ALTER TABLE AppleBoxItem DROP COLUMN imageByteArray

     

    기존 테이블에서 필요 없는 column들을 지우려 했지만, 전혀 동작하지 않아 이상함을 느꼈는데, 검색해 보니 이게 뭐람.. SQLite에서는 DROP COLUMN을 지원하지 않았다.. 이럴 수가…

    https://www.sqlitetutorial.net/sqlite-alter-table/

     

    SQLite ALTER TABLE & How To Overcome Its Limitations

    This tutorial shows you how to use SQLite ALTER TABLE statement to rename a table and add a new column to a table. The steps for other actions also covered.

    www.sqlitetutorial.net

    https://github.com/dbeaver/dbeaver/issues/9176

     

    Deleting columns in sqlite... causes sql syntax error · Issue #9176 · dbeaver/dbeaver

    I just installed dbeaver and created an sqlite database to have a play with it. I created some columns and wanted to delete one, but it fails:

    github.com

     

    물론 특정 Column을 지우지 않거나, 새로운 임시 테이블을 만들어서 기존 테이블로 대치하는 꼼수도 있겠지만, 코드로 그런 걸 쓰느니 차라리 AutoMigration의 힘을 빌려 더 간단히 만들고자 했다.

    그래서 결국 한 번의 디비 스키마 변경이지만, 두 번의 room 버전 업을 자동/수동 migration 각각 수행하도록 작성하게 되었다.

     


     

    Test

    새로 무언갈 해보았으니, 테스트도 빼먹지 않고 접해보려 했다.

    다행이게도 공식 문서에 Migration Test에 대한 안내가 있었다. 뿐만 아니라 테스트를 위해 라이브러리에서 지원해주기도 했다. 덕분에 큰 어려움 없이 테스트를 시도해 볼 수 있었다.

    https://developer.android.com/training/data-storage/room/migrating-db-versions#single-migration-test

     

    Room 데이터베이스 이전  |  Android 개발자  |  Android Developers

    Room 라이브러리를 사용하여 데이터베이스를 안전하게 이전하는 방법 알아보기

    developer.android.com

     

    테스트는 기본적으로 exported 된 room version의 scheme json 파일을 기반으로 수행되기 때문에, 모든 버전의 스키마 json 파일은 필수이다. 그리고 테스트 코드에서 이 파일을 감지할 수 있게 sourceSet 경로를 설정해주어야 했다. export 할 때 썼던 경로 그대로 작성해 주었다.

    // Room이 자동으로 스키마를 저장할 경로 지정
    kapt {
        arguments {
            arg("room.schemaLocation", "$projectDir/schemas")
        }
    }
    
    // 테스트에서 스키마 파일을 읽어올 수 있게 경로 설정
    sourceSets {
        androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
    }

     

    AutoMigration Test

    AutoMigration에 대한 테스트는 사실 공식 문서에서 적어준 스니펫 정도로만 해도 충분했던 것 같았지만.. 그래도 실제로 Migration이 되었는지 확인해보고 싶었다.

    private val AutoMigration1to2 = RecreateEntitiesMigration()
    
    
    @get:Rule
    val helper: MigrationTestHelper = MigrationTestHelper(
        InstrumentationRegistry.getInstrumentation(),
        AppDatabase::class.java,
        listOf(AutoMigration1to2),
        FrameworkSQLiteOpenHelperFactory()
    )
    
    @Test
    fun validateMigrationVersion1To2() {
    		helper.runMigrationsAndValidate(TestDbName, 2, true)
    }

     

    그런데 이 선택은 굉장히 고통스러운 작업이었다..

    이전 버전의 Entity 객체를 사용할 수 없다. 삭제되었기도 했고, 애초에 지금 소스 코드는 가장 높은 버전의 스키마이기 때문이다.

    즉, Insert query를 직접 작성해야 했다. 그리고 마이그레이션 이후에도 새로운 테이블에 대한 Select query를 직접 작성해야 한다.

    // 테스트의 일부
    helper.createDatabase(TestDbName, 1).apply {
        ...
    	execSQL("INSERT INTO Product VALUES ('NAME', 300, 3, 'CATEGORYID', 'IMAGEURL', 'ID', NULL)")
        close()
    }
    
    val queryResult = runCatching { db.query("SELECT * FROM Product") }
    assertThat(queryResult.isFailure).isTrue()
    
    db.query("SELECT * FROM AppleProductEntity")
        .also { it.moveToNext() }
        .useCursor { cursor ->
    		...
            assertThat(cursor.getColumnIndex("imageUrl")).isEqualTo(-1)
    		assertThat(cursor.getIntColumnOf("sortPriority")).isEqualTo(3)
    		...
    	}

    AutoMigration은 라이브러리가 잘했거니 믿고, 이렇게 까지 상세하게 테스트를 하지 않아도 좋았겠다는 생각이 들었다. 지금은 디비가 작아서 망정이지, 만약 많은 양의 AutoMigration이 있었다면, 꽤나 끔찍했겠다. 테스트코드를 자동으로 만드는 코드를 짜는 게 더 빠를지 모르겠다.

     

    Manual Migration Test

    이 테스트는 AutoMigration에 비해서 정말 필요로 되는 것만 테스트했기에 훨씬 짧았다. 하지만 검증해야 할 로직을 위해 준비해야 할 데이터를 정확하게 만들어주어야 했기에 이 경우도 까다로웠다.

    execSQL("INSERT INTO AppleBoxItem VALUES (0, 'ID1', 'iphone', 100, 1, 'phoneType', 'URL', 'iphoneID', NULL)")
    execSQL("INSERT INTO AppleBoxItem VALUES (1, 'ID2', 'imac', 300, 3, 'labtopType', 'URL', 'imacID', NULL)")
    
    execSQL("INSERT INTO AppleProductEntity VALUES ('iphone', 'phoneType', 100, 1, 0, 0, 'iphoneID')")
    execSQL("INSERT INTO AppleProductEntity VALUES ('imac', 'labtopType', 300, 3, 0, 0, 'imacID')")

    그래도 AutoMigration Test 했을 때처럼, 기존 컬럼이 지워졌는지, 새 컬럼에 데이터가 잘 들어왔는지 일일이 검사할 필요가 없어서 검증문이 비교적 짧았다.

    원하는 것은 기존 스키마에서 저장했던 상품들이 있는 경우 현 디비 테이블에 정보를 잘 갱신해 오는 지였으니, 특정 컬럼에 원하는 값이 잘 세팅됐는지 확인하면 됐다.

     

    AutoMigration Test를 작성할 때는 회의감이 좀 들었는데, Migration Test를 짤 때는 딱 필요한 검증만을 작성했기에 의미 있는 테스트를 작성했다는 느낌을 많이 받았다.

    그래서 그런지, 이 부분에 대해서는 테스트 코드를 먼저 짜고 Migration 코드를 짰어도 좋았겠다는 생각이 뒤늦게 들었다.

     


     

    결론

    앱 업데이트를 하더라도 눈에 보이는 거라고는 티끌도 바뀌지 않았지만, 오히려 업데이트를 하더라도 기존 동작을 그대로 유지시키는 것이 그리 쉬운 일이 아니라는 것을 새삼 체험했다.

     

    이렇게 적은 양의 데이터를 저장하는 데에도 꽤나 많은 수고가 들어가는데, 더 많은 데이터를 다뤄야 한다면 얼마나 작업량이 늘어날지 상상하고 싶지 않아 졌다. 이후 로컬 디비에 저장하는 것이 중요한 앱을 만들게 된다면 이 경험이 유용할 것이라고 기대한다.

    반응형

    댓글

Designed by Tistory.