관리 메뉴

막내의 막무가내 프로그래밍 & 일상

[안드로이드] Room 로컬 데이터베이스에서 기본형이 아닌 객체 필드값 저장방법 (feat. @Embeded, @TypeConverter) 본문

안드로이드/코틀린 & 아키텍처 & Recent

[안드로이드] Room 로컬 데이터베이스에서 기본형이 아닌 객체 필드값 저장방법 (feat. @Embeded, @TypeConverter)

막무가내막내 2020. 12. 17. 00:13
728x90

 

[2021-04-14 업데이트]

 [2020.12.16 블로그 포스팅 스터디 3 번째 글]




github.com/mtjin/NoMoneyTrip

 

mtjin/NoMoneyTrip

[SKT/한국관광공사] 2020 스마트 관광 앱 개발 공모전 '무전여행' 앱. Contribute to mtjin/NoMoneyTrip development by creating an account on GitHub.

github.com

 

이전에 만든 프로젝트에서 시간이 부족해서 거의 다 로컬 데이터베이스는 사용안하고 서버 API(Remote) 에서만 불러오게 구현을 했었습니다. 시간이 날때 조금씩 리펙토링을 하고 있는데 이번에는 빠른 UI 갱신을 위해 로컬캐싱을 추가 구현하던 도중 Room에서 객체필드값을 가진 객체를 저장하는건 처음이여서 시행착오가 있었습니다.

 

 대한 해결과정과 시행착오 부분을 간략하게 기록을 해보려합니다. 

 

먼저 Room 데이터베이스에 관한 설명은 간단히 하겠습니다. 예전에 포스팅에서 다루기도 했었고요 :) 

Room은 SQLite에 대한 추상화 레이어를 제공하여 원활한 데이터베이스 액세스를 지원하는 동시에 SQLite를 완벽히 활용합니다. ORM 방식이며 크게 @Database, @Dao, @Entitiy 로 이루어져 있습니다.

 

developer.android.com/guide/topics/data?hl=ko

 

앱 데이터 및 파일  |  Android 개발자  |  Android Developers

앱 데이터 및 파일 앱 및 사용자 데이터를 기기의 파일, 키-값 쌍, 데이터베이스 또는 기타 데이터 유형으로 보존하고 다른 앱과 기기 간에 데이터를 공유하는 방법을 알아보세요. 백업 서비스를

developer.android.com

 

 

 

서버 API와 파이어베이스 DB를 둘 다 사용하였는데 커뮤니티 페이지의 파이어베이스 부분을 로컬캐싱 구현한 부분을 예시로 기록하려고합니다. 

 

1. 먼저 어떤 구조로 데이터를 받아오는지 살펴보겠습니다. 이 글의 주제는 아니므로 간략하게 :)

 

 

[ViewModel]

평범한 ViewModel 코드, Repository 에서 데이터를 불러오도록 합니다.

package com.mtjin.nomoneytrip.views.community

import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.mtjin.nomoneytrip.base.BaseViewModel
import com.mtjin.nomoneytrip.data.community.UserReview
import com.mtjin.nomoneytrip.data.community.source.CommunityRepository
import com.mtjin.nomoneytrip.data.reservation_history.ReservationProduct
import com.mtjin.nomoneytrip.utils.SingleLiveEvent
import com.mtjin.nomoneytrip.utils.TAG
import com.mtjin.nomoneytrip.utils.extensions.getTimestamp
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.rxkotlin.subscribeBy
import io.reactivex.schedulers.Schedulers

class CommunityViewModel(private val repository: CommunityRepository) : BaseViewModel() {

    private val _goTourHistory = SingleLiveEvent<List<ReservationProduct>>()
    private val _goTourNoHistory = SingleLiveEvent<Unit>()
    private val _userReviewList = MutableLiveData<List<UserReview>>()

    val goTourHistory: LiveData<List<ReservationProduct>> get() = _goTourHistory
    val goTourNoHistory: LiveData<Unit> get() = _goTourNoHistory
    val userReviewList: LiveData<List<UserReview>> get() = _userReviewList

    fun goReview() {
        compositeDisposable.add(
            repository.requestMyReviews()
                .map { it.filter { item -> !item.reservation.reviewed && item.reservation.endDateTimestamp <= getTimestamp() && item.reservation.state != 1 && item.reservation.state != 3 } }
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribeBy(
                    onSuccess = {
                        if (it.isEmpty()) _goTourNoHistory.call()
                        else _goTourHistory.value = it

                    },
                    onError = {
                        Log.d(TAG, "CommunityViewModel goReview() -> $it")
                    }
                )
        )
    }

    fun requestReviews(city: String) {
        compositeDisposable.add(
            repository.requestReviews(city)
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribeBy(
                    onNext = {
                        _userReviewList.value = it
                    },
                    onError = {
                        Log.d(TAG, "CommunityViewModel requestReviews() -> $it")
                    }
                )
        )
    }

    fun updateReviewRecommend(userReview: UserReview) {
        compositeDisposable.add(
            repository.updateReviewRecommend(userReview)
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .subscribeBy(
                    onComplete = {},
                    onError = { Log.d(TAG, it.toString()) }
                )
        )
    }
}

 

 

[Repository]

다음과 같이 concat()을 사용해 로컬과 서버 디비에서 차례대로 불러와 좀 더 빠르게 UI갱신이 되게 하였습니다. Local과 Remote 데이터를 적절히 조합 및 분리합니다.

참고로 Repository에서 observeOn()을 붙인 이유는 파이어베이스 DB와 통신 시 기존의 RxJava의 비동기흐름이 끊켜서 메인스레드에서 룸에 접근하게 되어 에러가 나더라고요. 다른 Observable을 사용해서 해결하는 방법도 있겠지만 그 당시 시간이 없었기 때문에 이렇게 하였습니다.

package com.mtjin.nomoneytrip.data.community.source

import com.mtjin.nomoneytrip.data.community.UserReview
import com.mtjin.nomoneytrip.data.community.source.local.CommunityLocalDataSource
import com.mtjin.nomoneytrip.data.community.source.remote.CommunityRemoteDataSource
import com.mtjin.nomoneytrip.data.reservation_history.ReservationProduct
import io.reactivex.Completable
import io.reactivex.Flowable
import io.reactivex.Single
import io.reactivex.schedulers.Schedulers

class CommunityRepositoryImpl(
    private val remote: CommunityRemoteDataSource,
    private val local: CommunityLocalDataSource
) : CommunityRepository {
    override fun requestMyReviews(): Single<List<ReservationProduct>> = remote.requestMyReviews()

    override fun requestReviews(cityCode: String): Flowable<List<UserReview>> {
        return local.getUserReviews()
            .observeOn(Schedulers.io())
            .onErrorReturn { listOf() }
            .flatMapPublisher { cachedItems ->
                if (cachedItems.isEmpty()) {
                    requestRemoteReviews(cityCode).toFlowable().onErrorReturn { listOf() }
                } else {
                    val local = Single.just(cachedItems)
                    val remote = requestRemoteReviews(cityCode).onErrorResumeNext { local }
                    Single.concat(local, remote)
                }
            }
    }

    override fun requestRemoteReviews(cityCode: String): Single<List<UserReview>> {
        return remote.requestReviews(cityCode).observeOn(Schedulers.io())
            .flatMap {
                local.insertUserReviews(it)
                    .andThen(Single.just(it))
            }
    }

    override fun updateReviewRecommend(userReview: UserReview): Completable =
        remote.updateReviewRecommend(userReview)


}

 

[remote]

요약해서 인터페이스 부분 코드를 첨부했습니다. 파이어베이스 데이터베이스에서 데이터를 불러옵니다.

class CommunityRemoteDataSourceImpl(private val database: DatabaseReference) :
    CommunityRemoteDataSource{
    ..
    ..
    ..
 }
    
package com.mtjin.nomoneytrip.data.community.source.remote

import com.mtjin.nomoneytrip.data.community.UserReview
import com.mtjin.nomoneytrip.data.reservation_history.ReservationProduct
import io.reactivex.Completable
import io.reactivex.Single

interface CommunityRemoteDataSource {
    fun requestMyReviews(): Single<List<ReservationProduct>>
    fun requestReviews(cityCode: String): Single<List<UserReview>>
    fun updateReviewRecommend(userReview: UserReview): Completable
}

 

 

[local]

지극히 평범한 Room 데이터베이스에서 불러오는 코드

class CommunityLocalDataSourceImpl(
    private val userReviewDao: UserReviewDao
) : CommunityLocalDataSource {

    override fun insertUserReviews(userReviews: List<UserReview>): Completable =
        userReviewDao.insertUserReviews(userReviews)

    override fun getUserReviews(): Single<List<UserReview>> = userReviewDao.getUserReviews()
}

 

 

2. 이번글의 시행착오인 Room 구성요소 관련 코드이고 겪은 시행착오를 다룰 부분입니다. 

[Entity]

먼저 데이터를 저장하는 객체가 다음과 같이 UserReview라는 객체인데 그 안에 또 객체가 있는 형식이었습니다. 파이어베이스 DB에서는 이렇게 하면 알아서 저장이 되지만 Room은 기본타입만 가능하기 때문에 바로 에러가 발생합니다.

 

발생원인은 다음 공식문서에서 찾아볼 수 있습니다.

https://developer.android.com/training/data-storage/room/referencing-data?hl=ko#understand-no-object-references 

 

Room을 사용하여 복잡한 데이터 참조  |  Android 개발자  |  Android Developers

Room을 사용하여 복잡한 데이터 참조 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Room은 기본 유형과 박싱된 유형 간 변환을 위한 기능을 제공하지만 항목

developer.android.com

 

그래서 이를 편리하게 저장하게 할 수 있게 나온것이 @Embeded 입니다. 또한 이 안에 perfix를 설정해주어 해당 객체의 값들의 컬럼이름의 접두사를 붙일 수 있습니다. 저 같은 경우 객체간 id와 같이 겹치는 컬럼명이 존재하기 때문에 접두사를 안해주면 중복컬럼으로 에러가 나서 사용했습니다.

 

@Embeded란?

Marks a field of an Entity or POJO to allow nested fields (i.e. fields of the annotated field's class) to be referenced directly in the SQL queries.

때로 개발자는 객체에 여러 필드가 포함되어 있는 경우에도 데이터베이스 로직에서 항목 또는 데이터 객체를 응집된 전체로 표현하고자 합니다. 이 경우 @Embedded 주석을 사용하여 테이블 내의 하위 필드로 분해하려고 하는 객체를 나타낼 수 있습니다. 그러면 다른 개별 열을 쿼리하듯 삽입된 필드를 쿼리할 수 있습니다.

예를 들어 User 클래스에는 Address 유형의 필드를 포함할 수 있으며 이 유형의 필드는 street, city, state 및 postCode라는 필드의 구성을 나타냅니다. 구성된 열을 테이블에 별도로 저장하려면 다음 코드 스니펫에서와 같이 @Embedded로 주석 처리된 Address 필드를 User 클래스에 포함합니다.

    data class Address(
        val street: String?,
        val state: String?,
        val city: String?,
        @ColumnInfo(name = "post_code") val postCode: Int
    )

    @Entity
    data class User(
        @PrimaryKey val id: Int,
        val firstName: String?,
        @Embedded val address: Address?
    )
    

 

@Embeded에서 perfix란?

항목에 동일한 유형의 삽입된 필드가 여러 개 있으면 prefix 속성을 설정하여 각 열을 고유하게 유지할 수 있습니다. 그러면 Room은 제공된 값을 삽입된 객체의 각 열 이름 시작 부분에 추가합니다.

 

 

developer.android.com/reference/android/arch/persistence/room/Embedded

 

Embedded  |  Android 개발자  |  Android Developers

Embedded The android.arch Architecture Components packages are no longer maintained. They have been superseded by the corresponding androidx.* packages. See androidx.room.Embedded instead. public abstract @interface Embedded implements Annotation android.a

developer.android.com

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

 

객체 간 관계 정의  |  Android 개발자  |  Android Developers

SQLite는 관계형 데이터베이스이므로 항목 간 관계를 지정할 수 있습니다. 대부분의 객체 관계 매핑(ORM) 라이브러리에서는 항목 객체가 서로를 참조할 수 있지만, Room은 이러한 상호 참조를 명시

developer.android.com

위 링크에서 이외에도 @Relation ,@Transcation 등 다양한 기능을 볼 수 있습니다.

 

 

참고로 val이 아닌 var을 사용한 이유는 파이어베이스 DB는 var로 해야합니다. val로 하면 에러납니다.

위의 내용들을 적용한 결과입니다. (UserReview가 저장할 메인 객체)

package com.mtjin.nomoneytrip.data.community

import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.mtjin.nomoneytrip.data.home.Product
import com.mtjin.nomoneytrip.data.login.User

//리뷰 받아올때 리뷰아이템과 유저정보 조합해서 UserReview 클래스로 받아올 예정
@Entity(tableName = "userReview")
data class UserReview(
    @PrimaryKey var id: String = "",
    @Embedded(prefix = "user_") var user: User,
    @Embedded(prefix = "review_") var review: Review,
    @Embedded(prefix = "product_") var product: Product
)
@Parcelize
@Entity(tableName = "user")
data class User(
    @PrimaryKey var id: String = "",
    var name: String = "",
    var fcm: String = "",
    var email: String = "",
    var pw: String = "",
    var image: String = "",
    var tel: String = ""
) : Parcelable
//참고로 @Embeded로 참조된 클래스가 꼭 Enttity가 아니여도 됩니다.
data class Review(
    var id: String = "",
    var userId: String = "",
    var productId: String = "",
    var reservationId: String = "",
    var timestamp: Long = 0,
    var image: String = "",
    var content: String = "",
    var city: String = "",
    var recommendList: List<String> = ArrayList()
)
@Parcelize
data class Product(
    var id: String = "",
    var imageList: List<String> = ArrayList(),
    var hashTagList: List<String> = ArrayList(),
    var optionList: List<String> = ArrayList(),
    var favoriteList: List<String> = ArrayList(),
    var title: String = "",
    var content: String = "",
    var city: String = "",
    var address: String = "",
    var homePage: String = "",
    var xPos: String = "",
    var yPos: String = "",
    var checkIn: String = "",
    var checkOut: String = "",
    var phone: String = "",
    var time: String = "",
    var ratingList: List<Float> = ArrayList(),
    var internet: Boolean = false,
    var parking: Boolean = false,
    var market: Boolean = false,
    var animal: Boolean = false,
    var fcm: String = ""
) : Parcelable

하지만 문제가 하나 더 있는데 리스트(List) 타입이 있다는 점입니다. 데이터베이스에는 기본타입만 저장이 가능한데 기본타입이 아니기 때문에 그냥 저장시 에러납니다. 이것은 @TypeConverter를 통해 저장할떄는 json형식 즉 String 타입으로 저장되게 하고 불러올때는 리스트(List)로 변환되어 불러와지게 해결했습니다.

 

@TypeConverter란?

때로 앱은 개발자가 단일 데이터베이스 열에 값을 저장하려고 하는 맞춤 데이터 유형을 사용해야 합니다. 이러한 종류의 맞춤 유형 지원을 추가하려면 TypeConverter를 제공하면 됩니다. 이 유형 변환기는 Room이 유지할 수 있는 알려진 유형과 맞춤 클래스를 상호 변환합니다.

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

 

객체 간 관계 정의  |  Android 개발자  |  Android Developers

SQLite는 관계형 데이터베이스이므로 항목 간 관계를 지정할 수 있습니다. 대부분의 객체 관계 매핑(ORM) 라이브러리에서는 항목 객체가 서로를 참조할 수 있지만, Room은 이러한 상호 참조를 명시

developer.android.com

TypeConverter와 Gson을 사용하여 json(String) <-> List<Float>, List<String>  간에 변환될 수 있는 함수를 만들어 주도록 합니다. 함수에는 @TypeConverter 어노테이션을 붙여줘야 데이터베이스에서 인식을 할 수 있습니다. !!

그 후 밑에 Database에 @TypeConverters(MyTypeConverters::class) 같이 이 MyTypeConverters 클래스를 명시해줘야합니다. 

package com.mtjin.nomoneytrip.data.database.type_converter

import androidx.room.TypeConverter
import com.google.gson.Gson

class MyTypeConverters {
    @TypeConverter
    fun fromStringList(value: List<String>?): String = Gson().toJson(value)

    @TypeConverter
    fun toStringList(value: String) = Gson().fromJson(value, Array<String>::class.java).toList()

    @TypeConverter
    fun fromFloatList(value: List<Float>?): String = Gson().toJson(value)

    @TypeConverter
    fun toFloatList(value: String) = Gson().fromJson(value, Array<Float>::class.java).toList()
}

//만약 Priority라는 enum class를 DB에 저장하려고하면 
//String으로 변환해서 저장해도 되기 때문에 다음과 같이 할 수 도 있을 것이다.
@TypeConverter
fun fromPriority(priority: Priority): String {
    return priority.name
}

@TypeConverter
fun toPriority(priority: String): Priority {
    return Priority.valueOf(priority)
}

 

 

[Database]

Entitiy 설정 및 타입컨버터 설정

package com.mtjin.nomoneytrip.data.database

import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.mtjin.nomoneytrip.data.community.UserReview
import com.mtjin.nomoneytrip.data.community.source.local.UserReviewDao
import com.mtjin.nomoneytrip.data.database.type_converter.MyTypeConverters
import com.mtjin.nomoneytrip.data.login.User
import com.mtjin.nomoneytrip.data.profile.soruce.local.UserDao

@Database(
    entities = [UserReview::class, User::class],
    version = 1,
    exportSchema = false
)
@TypeConverters(MyTypeConverters::class)
abstract class MyRoomDatabase : RoomDatabase() {
    abstract fun userReviewDao(): UserReviewDao
    abstract fun userDao(): UserDao
}

 

 

[Dao]

평범한 DAO 다.

package com.mtjin.nomoneytrip.data.community.source.local

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.mtjin.nomoneytrip.data.community.UserReview
import io.reactivex.Completable
import io.reactivex.Single

@Dao
interface UserReviewDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insertUserReviews(userReviews: List<UserReview>): Completable

    @Query("SELECT * FROM userReview")
    fun getUserReviews(): Single<List<UserReview>>

    @Query("SELECT * FROM userReview WHERE user_id = :uuid")
    fun getMyUserReviews(uuid: String): Single<List<UserReview>>
}

 

 

 

 

[앱 삭제 후 다운로드 받고 첫 실행시킨 후 캐싱된 이후의 속도차이 쿠키 영상]

동영상 올릴때마다 드는 생각이지만 크기 조절이 불가능해서 전체화면으로 클릭해서 봐야한다. 흠..

 

속도가 개선되었다

 

 

간략하게 처음 해본 Room의 @Embeded, @TypeConverter 등 기능들과 중간 과정들을 정리해봤습니다.

 

 

댓글과 공감은 큰 힘이 됩니다. 감사합니다. :) !!

 

 

728x90
Comments