Hanbit the Developer

[Kotlin] Android Offline Caching Using Room 본문

Mobile/Android

[Kotlin] Android Offline Caching Using Room

hanbikan 2022. 8. 13. 03:15

배경

앱 메인 화면에 진입을 할 때, 정보를 로드해서 뷰에 나타나기까지의 시간이 조금 있었고, 짧은 시간이긴 하지만 사용자 경험이 별로 안 좋아보였습니다. 이에 따라 저는 Room을 통해 이전의 데이터를 로컬에 캐싱하고, 이 데이터를 이용하여 네트워크 처리가 되기 이전에 캐싱된 데이터를 미리 보여주기로 하였습니다.

 

개요

  1. Room 관련 세팅
  2. Room Entity, Dao, Repository
  3. RoomDatabase, TypeConverters, DI
  4. ViewModel에 Offline Cache 적용

 

구현

1. Room 관련 세팅

build.gradle(root)

buildscript {
    ext {
        roomVersion = '2.4.3'
    }
}

build.gradle(module)

apply plugin: 'kotlin-kapt'

// ... 

dependencies {
    // Room
    implementation("androidx.room:room-runtime:$rootProject.roomVersion")
    annotationProcessor("androidx.room:room-compiler:$rootProject.roomVersion")
    // To use Kotlin annotation processing tool (kapt)
    kapt("androidx.room:room-compiler:$rootProject.roomVersion")
    // optional - Kotlin Extensions and Coroutines support for Room
    implementation("androidx.room:room-ktx:$rootProject.roomVersion")
    // optional - Test helpers
    testImplementation("androidx.room:room-testing:$rootProject.roomVersion")
}

*22년 8월 기준 최신 버전이며, 아래 링크를 참조합니다.

https://developer.android.com/training/data-storage/room?hl=ko

 

Room을 사용하여 로컬 데이터베이스에 데이터 저장  |  Android 개발자  |  Android Developers

Room 라이브러리를 사용하여 더 쉽게 데이터를 유지하는 방법 알아보기

developer.android.com

 

2. Room Entity, Dao, Repository

User.kt(Entity)

@Entity(tableName = "user")
data class User(
    @PrimaryKey val id: Long,
    val name: String,
    val complicatedObject: ComplicatedObject
)

@Entity를 사용하여 데이터를 정의합니다.

여기서 ComplicatedObject는 이후에 설명하게 될 TypeConverters를 위해 추가한 임의 데이터 클래스입니다. 구글 문서에 따르면 성능을 위해 객체 참조를 허용하지 않으며, 대신 TypeConverters를 제공합니다.

 

UserDao.kt(Dao)

@Dao
interface UserDao {
    @Query("SELECT * FROM user")
    fun getUserList(): List<User>

    @Query("DELETE FROM user")
    suspend fun deleteUserTable()

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertUser(user: User)
}

@Dao를 사용하여 데이터에 액세스할 내용을 정의합니다.(데이터베이스 쿼리를 잘 모르신다면 Select, Insert, Update, Delete 정도만 가볍게 보고 오시는 걸 추천드립니다.)

 

UserRepository.kt(Repository)

@Singleton
class UserRepository @Inject constructor(
    private val userDao: UserDao
) {
    fun getUserList() = userDao.getUserList()

    suspend fun resetUserList(userList: List<User>) {
    	userDao.deleteUserTable()
        userList.forEach {
        	userDao.insertUser(it)
        }
    }
}

실제로 ViewModel에서 사용하게 될 함수입니다. 우선 뷰모델이 초기화될 때 이전에 저장한 유저 리스트를 불러와서 미리 데이터를 채우고 있어야 합니다. 이를 위한 함수가 getUserList()입니다.

그리고 유저 리스트를 API 통신을 통해 얻어왔을 때, 새로 들어온 정보를 로컬에 저장해야 합니다. 이에 대한 함수는 resetUserList()입니다. deleteUserTable()로 유저 테이블을 초기화한 뒤, 인풋 데이터를 각각 insert 해줍니다.

 

3. RoomDatabase, TypeConverters, DI

이제 위에서 구성한 UserRepository를 바로 쓰면 될 것 같지만 아직 약간의 절차가 남았습니다.

AppDatabase.kt(RoomDatabase 구성)

*해당 게시글 하단부 '추가로 개선한 내용(2)'에서 아래 코드의 개선된 버전을 제공합니다.

@Database(entities = [User::class], version = 1, exportSchema = false)
@TypeConverters(Converter::class)
abstract class AppDatabase: RoomDatabase() {
    abstract fun userDao(): UserDao

    companion object {
    	// RoomDatabase는 리소스가 매우 크기 때문에 Singleton 패턴을 따라야 합니다.
        // 인스턴스가 1개만 있음을 보장하기 위해 @Volatile과 동기화를 사용합니다.
        @Volatile private var instance: AppDatabase? = null

        fun getInstance(context: Context): AppDatabase {
            return instance ?: synchronized(this) {
                instance ?: buildDatabase(context).also { instance = it }
            }
        }

        private fun buildDatabase(context: Context): AppDatabase {
            return Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME)
                .build()
        }
    }
}

데이터베이스를 보유할 AppDatabase 클래스를 정의합니다.

@TypeConverters 어노테이션에 참조된 Converter 클래스는 다음과 같습니다.

 

Converter.kt(TypeConverters)

class Converter {
    @TypeConverter fun complicatedObjectToString(value: ComplicatedObject): String = Gson().toJson(value)
    @TypeConverter fun stringToComplicatedObject(value: String): List<User> = Gson().fromJson(value, Array<ComplicatedObject>::class.java).toList()
}

앞서 언급하였듯이, 객체 참조가 허용되지 않으므로 TypeConverter를 정의해주고 이를 RoomDatabase에서 참조할 수 있게 해야 합니다. 위와 같은 코드를 작성해두면 ComplicatedObject라는 데이터 클래스가 자동으로 변환이 되어 사용됩니다.

 

DatabaseModule.kt(DI)

*해당 게시글 하단부 '추가로 개선한 내용(2)'에서 아래 코드의 개선된 버전을 제공합니다.

@InstallIn(SingletonComponent::class)
@Module
class DatabaseModule {

    @Singleton
    @Provides
    fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase {
        return AppDatabase.getInstance(context)
    }

    @Provides
    fun provideUserDao(appDatabase: AppDatabase): UserDao {
        return appDatabase.userDao()
    }
}

이렇게 만든 AppDatabase와 Dao(Repository 코드 생성자에 인젝션)를 제공해줄 DatabaseModule을 작성합니다.

 

4. ViewModel에 Offline Cache 적용

UserViewModel.kt

@HiltViewModel
class UserViewModel @Inject constructor(
    private val userRepository: UserRepository
): ViewModel() {
    val userList = MutableLiveData<List<UserModel>>()
    init {
    	// Offline cache 데이터 불러오기
        viewModelScope.launch(Dispatchers.IO) {
            userList.value?.let {
                if (userList.value.count == 0) {
                    userList.value = userRepository.getUserList().toUserModelList()
                    userList.postValue(userList.value)
                }
            }
        }
    }

    suspend fun readUserList() {
        try {
            // 기존 데이터 read API CALL 로직

            // Offline cache를 위해 데이터 저장
            userList.value?.let {
                userRepository.resetUserList(userList.value.toUserList())
            }
        } catch (e: Exception) {
            // Retry?
        }
    }
}

클린 아키텍처를 따르기 위해서, Room에서 정의했던 Local Entity와 네트워크를 위한 Remote Entity, 그리고 뷰에 그리기 위한 Model은 서로 달라야 하며, 이것을 확실히 하기 위해 userList의 타입을 'UserModel'으로 명시하였습니다. Room의 Entity와 Model 간의 전환에 대한 내용(Translator)은 따로 다루지 않겠습니다.

데이터 불러오기 로직부터 살펴보겠습니다. 먼저 구현한 room 함수가 suspend function이므로 viewModelScope에서 처리해주어야 합니다. 또한 UI Thread에서 작업을 수행할 시 아래와 같은 exception이 발생하므로 Dispatchers.IO에서 작업을 처리해주었습니다.

java.lang.IllegalStateException: Cannot access database on the main thread since it may potentially lock the UI for a long period of time.

 

 

이슈

서버에서 내려주는 리스트의 순서와 오프라인 캐시 데이터의 순서가 일치하지 않았다.

로컬과 리모트 데이터 순서가 보장되지 않았고 이 때문에 '카드가 섞이는 듯한' 모습이 연출되었습니다. 이를 위해서 다음과 같이 Entity에 index를 추가하였습니다.

@Entity(tableName = "user")
data class User(
    val index: Int,
    @PrimaryKey val id: Long,
    val name: String,
    val complicatedObject: ComplicatedObject
)

이에 따라 API 호출을 통해 얻은 UserModel 리스트를 User로 변환할 때의 로직이 조금 변경됩니다. index 항목에 UserModel의 인덱스를 넣어주기만 하면 됩니다.

이렇게 인덱스를 넣어주었으므로, 기존 쿼리를 다음과 같이 인덱스를 기준으로 정렬하는 쿼리문으로 수정해줍니다.

@Dao
interface UserDao {
    @Query("SELECT * FROM user ORDER BY user.`index`")
    fun getUserList(): List<User>

// ...

이렇게 하면 서버단에서 정렬 기준을 바꾸더라도 서버와 일치하는 순서를 보장할 수 있게 됩니다.

 

 

결과

사용자 경험이 개선되었습니다! 오프라인 캐시라는 이름답게 오프라인 환경에서도 당연히 잘 작동하는 것 또한 확인할 수 있었습니다.

 

 

+ 추가로 개선한 내용

오프라인 캐시를 위해 Room으로 데이터를 불러오기 이전에 ProgressBar가 거슬린다.

Room 쿼리 동작은 매우 짧은 시간 내에 끝나기 때문에 ProgressBar가 필요 없을 것이라고 생각하여 과감히 삭제했습니다.

 

데이터가 같은 경우에도 데이터가 교체되면서 뷰가 깜빡거린다.

기존에 있던 오프라인 캐시 데이터가 나중에 API 호출로 얻은 데이터로 대체되는 과정에서 이러한 문제가 발생하는 것으로 파악됩니다.

따라서 API 호출 데이터를 삽입할 때 기존 데이터와 동등성 검사를 실시합니다. 이전 데이터와 새 데이터가 다를 경우에만 데이터를 삽입하게끔 if문을 추가했습니다.

 

결과_최종_final

글 첫 부분에 있는 사진과 비교하면 차이가 정말 커요..

긴 여정 끝에… 눈에 띄게 개선된 모습을 볼 수 있었습니다.

 

+ 추가로 개선한 내용(2)

기존에 AppDatabase에서 싱글톤 처리를 복잡하게 해주었는데 사실 그렇게까지 할 필요가 없었습니다. 아래처럼 인스턴스를, Hilt를 통해 싱글톤으로 생성하면 되기 때문입니다..

@InstallIn(SingletonComponent::class)
@Module
class DatabaseModule {

    @Singleton
    @Provides
    fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase {
        return Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME).build()
    }

    @Provides
    fun provideGroupAlbumLocalDao(appDatabase: AppDatabase): GroupAlbumLocalDao {
        return appDatabase.groupAlbumLocalDao()
    }
}

따라서 AppDatabase 코드는 아래와 같이 축약됩니다.

@Database(entities = [User::class], version = 1, exportSchema = false)
@TypeConverters(Converter::class)
abstract class AppDatabase: RoomDatabase() {
    abstract fun UserDao(): UserDao
}

 

 

References

https://developer.android.com/training/data-storage/room?hl=ko