이번 영상에서는 검색결과에서 마음에 드는 책을 Room을 통해 SQLite DB에 저장하고 불러오는 기능을 구현해보도록 하겠습니다.
우선은 Room과 어노테이션 처리를 위한 Kapt Dependency를 추가합니다.
plugins {
+ id 'kotlin-kapt'
}
dependencies {
+ // Room
+ implementation 'androidx.room:room-runtime:2.4.2'
+ implementation 'androidx.room:room-ktx:2.4.2'
+ kapt 'androidx.room:room-compiler:2.4.2'
}
Book 클래스에 @Entity를 붙여서 데이터베이스에서 사용할 Entity로 만들어주고, 테이블 이름은 books로 정해주겠습니다.
코틀린에서는 클래스나 변수명에 camelCase를 사용하지만 SQLite는 기본적으로 대소문자 구분을 하지 않습니다. Room에서는 따로 설정을 하지 않을 경우 DB의 Key 값이 변수명과 동일하게 설정되는데, 여기서는 @ColumnInfo를 사용해서 DB의 Key값을 snake_case로 변환하도록 했습니다.
isbn은 책의 고유한 값이므로 @PrimaryKey 어노테이션을 붙여서 아이템을 구분할 때 사용할 고유키로 지정하도록 했습니다.
@JsonClass(generateAdapter = true)
@Parcelize
+@Entity(tableName = "books")
data class Book(
@field:Json(name = "authors")
val authors: List<String>,
@field:Json(name = "contents")
val contents: String,
@field:Json(name = "datetime")
val datetime: String,
+ @PrimaryKey(autoGenerate = false)
@field:Json(name = "isbn")
val isbn: String,
@field:Json(name = "price")
val price: Int,
@field:Json(name = "publisher")
val publisher: String,
+ @ColumnInfo(name = "sale_price")
@field:Json(name = "sale_price")
val salePrice: Int,
@field:Json(name = "status")
val status: String,
@field:Json(name = "thumbnail")
val thumbnail: String,
@field:Json(name = "title")
val title: String,
@field:Json(name = "translators")
val translators: List<String>,
@field:Json(name = "url")
val url: String,
) : Parcelable
데이터베이스를 조작하는 쿼리는 Dao에서 담당합니다. data/db 폴더 아래에 @Dao 어노테이션을 붙인 인터페이스 클래스를 준비해 CRUD를 구현합니다.
@Dao
interface BookSearchDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertBook(book: Book)
@Delete
suspend fun deleteBook(book: Book)
@Query("SELECT * FROM books")
fun getFavoriteBooks(): LiveData<List<Book>>
}
Create 작업을 위해 @Insert 어노테이션을 붙인 insertBook을 만듭니다. onConflict = REPLACE 속성을 부여해서 만약 DB에 동일한 isbn을 가진 데이터가 존재할 경우 덮어쓰도록 했습니다. 그리고 Entity를 넘겨주면 나머지는 Room이 알아서 SQL DB에 데이터를 추가하는 작업을 수행합니다. Delete는 @Delete 어노테이션을 붙인 함수를 만들어주면 됩니다.
Read명령은 @Query를 써서 쿼리를 직접 사용해야 합니다. 여기서는 "SELECT * FROM books"를 써서 books 테이블에서 모든 데이터를 가져오도록 했습니다. 이때 반환받는 데이터의 형식은 LiveData 타입을 가지도록 하였습니다. 쿼리를 제외한 CUD 작업은 시간이 걸리는 작업이라 코루틴 안에서 비동기적으로 수행할 것이므로 suspend 키워드를 붙여줍니다.
Dao와 Entity의 동작을 주관하는 BookSearchDatabase 클래스를 data/db 폴더 아래에 만들어줍니다. Room의 스펙을 따라 abstract class로 만들어주며, @Database 어노테이션을 써서 Room에서 사용할 Entity와 DB 버전, 스키마 익스포트 여부를 지정해줍니다.
다음은 Room에서 사용할 Dao를 지정하고 싱글톤 설정을 해 줍니다. Retrofit 객체와 동일하게 데이터베이스 객체도 생성하는데 비용이 많이 들기 때문에 중복으로 생성하지 않도록 싱글톤 설정을 해 줍니다.
@Database(
entities = [Book::class],
version = 1,
exportSchema = false
)
abstract class BookSearchDatabase : RoomDatabase() {
abstract fun bookSearchDao(): BookSearchDao
companion object {
@Volatile
private var INSTANCE: BookSearchDatabase? = null
private fun buildDatabase(context: Context): BookSearchDatabase =
Room.databaseBuilder(
context.applicationContext,
BookSearchDatabase::class.java,
"favorite-books"
).build()
fun getInstance(context: Context): BookSearchDatabase =
INSTANCE ?: synchronized(this) {
INSTANCE ?: buildDatabase(context).also { INSTANCE = it }
}
}
}
Room은 ORM에 Primitive(boolean, byte, char, short, int, long, float and double)와 Boxed(primitve 타입의 wrapper 클래스) Type만을 사용할 수 있게 제한하고 있습니다. Room에서 일반 객체로 ORM을 수행하면 UI 스레드에서 Lazy Loading을 해야하는데 그러면 처리속도가 느려지게 되고, 그렇다고 Eager Loading을 채택하면 필요하지 않은 데이터를 모두 로딩하게되어 메모리 낭비가 심해지기 때문에 이런 구조를 채택했다고 합니다.
그렇다고 DB에서 Primitive Type이 아닌 데이터를 사용할 수 없는건 아니고 TypeConverter를 사용해서 데이터를 Primitive Type으로 변환하면 됩니다. 우리가 Entity로 사용할 Book 클래스에는 Primitve Type이 아닌 List<String> 타입이 있기 때문에, 이것을 DB에 저장하기 위해 String으로 변환해야 합니다. 여기서는 이러한 데이터의 직렬화에 코틀린 공식 라이브러리인 kotlinx.serialization을 사용하겠습니다. 우선 Dependency를 추가해줍니다.
// project 레벨의 build.gradle
plugins {
+ id 'org.jetbrains.kotlin.plugin.serialization' version '1.6.10' apply false
}
// app 레벨의 build.gradle
dependencies {
// Kotlin serialization
+ implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2'
}
그리고 data/db 아래에 다음과 같이 List<String> <-> String을 상호변환하는 컨버터를 추가합니다.
class OrmConverter {
@TypeConverter
fun fromList(value: List<String>) = Json.encodeToString(value)
@TypeConverter
fun toList(value: String) = Json.decodeFromString<List<String>>(value)
}
그리고 컨버터를 BookSearchDatabase에 등록해주면 컨버터가 필요한 상황에 Room이 알아서 타입 컨버터를 사용하게 됩니다.
+ @TypeConverters(OrmConverter::class)
abstract class BookSearchDatabase : RoomDatabase() {