Android

안드로이드 [Kotlin] - Room을 사용해보자

🤖 Play with Android 🤖 2022. 7. 31. 22:34
728x90


우선 Room에 대해 알아보기 전에 데이터베이스를 간단하게 알아보자

 

관계형 데이터베이스란?

  • 관계형 데이터베이스는 현재 가장 많이 사용되고 있는 데이터베이스의 한 종류
  • 관계형 데이터베이스란 테이블(Table)로 이루어져 있으며, 이 테이블은 키(Key)와 값(Value)의 관계를 나타낸다.
  • 이처럼 데이터의 종속성을 관계(Relationship)로 표현하는 것이 관계형 데이터베이스의 특징이라고 할 수 있다.

 

SQL과 CRUD

  • Structured Query Language (SQL)이란 이러한 관계형 데이터베이스를 조금 더 쉽게 다루기 위해 생겨난 언어이다.
  • CRUD는 Create, Read, Update, Delete를 의미하는 말로 데이터베이스를 다루기 위해 가장 기본이 되는 4가지 명령을 묶어놓은 단어이다.

 

DBMS(Database Management System)란?

  • 데이터베이스를 운영하고 관리하는 소프트웨어
  • 데이터베이스를 '데이터의 집합'이라고 정의한다면, 이런 데이터베이스를 관리하고 운영하는 소프트웨어를 DBMS라고 한다.
  • 이 중에서 RDBMS 즉 Relational DBMS란 관계형 데이터베이스를 관리해주는 소프트웨어이다.
  • 대표적으로 Oracle, MySQL 등이 있다.

 

📌  Room

SNS를 사용하는 중에 비행기모드 등 네트워크 연결이 끊어졌을 때 새로운 사진은 볼 수 없지만 기존에 대화방에 있던 사진들을 확인할 수 있는 경험이 있을 것이다. 혹은 오프라인 저장과 같은 기능은 네트워크가 없어도 확인할 수 있는 경험도 있을 수 있다.

 

이를 데이터 캐싱이라고 하며, 네트워크 액세스를 할 수 없을 경우에도 로컬 데이터베이스의 데이터를 가지고 사용자들이 앱을 사용할 수 있도록 할 수 있다.

 

Android에서는 기존에 SQLite를 이용해 이를 구현했다.

  • Android와 iOS에 기본 채택
  • 미 해군의 구축함에서 이용하기 위해 만들어짐
  • 전 세계에서 1조 개가 넘는 DB가 운용되고 있음

하지만 아래와 같은 이유로 인해 SQLite 보다 Jetpack 라이브러리에 포함된 Room을 사용을 권장하고 있다.

  • SQL 쿼리에 대해서 올바르게 작성이 되었는지 컴파일 타임에 확인할 수 없다. 이로 인해 잘못된 쿼리 사용으로 영향을 받는 데이터가 생긴다면, 오류를 직접 업데이트를 해야 한다. 이 과정이 시간이 오래 걸리고 에러를 발생시키기도 한다.
  • SQL 쿼리와 데이터 객체와의 변환이 자유롭지 못하다. 쿼리를 통해 필터들을 각각 읽고 하나의 데이터 객체의 생성자로서 대입하기 때문에 상용구 코드들이 많이 사용될 수밖에 없다는 단점이 존재한다.

공식문서에서 설명하는 SQLite의 단점

 

Room 구조 & Annotation

 

Room 라이브러리는 엔티티(Entity), 데이터 접근 객체(DAO), 데이터베이스(DB)로 구성되어 있다.

 

Entity

DB내의 테이블, 즉 DB에 저장할 데이터 형식으로 class의 변수들이 컬럼이 되어 테이블을 구성한다.

 

Annotation

@Entitiy(tableName = "your table name") 

테이블 이름을 선언한다. (기본적으로는 entity class 이름을 database table 이름으로 인식한다.)

 

@PrimaryKey

각 Entity는 1개의 Primary Key를 가져야 한다. 일반적으로 고유한 id 값으로 설정한다.

 

@ColumnInfo

Table 내 column을 변수와 매칭한다.

@Entity(tableName = "books") // 데이터베이스에서 사용할 Entity 로 만들어준다.
data class Book(
    @PrimaryKey(autoGenerate = false)
    @field:Json(name = "isbn")
    val isbn: String,
    @ColumnInfo(name = "sale_price")
    @field:Json(name = "sale_price")
    val salePrice: Int,
) : Parcelable

 

DAO

데이터베이스에 접근하여 수행할 작업을 메서드 형태로 정의하는 부분

(SQL 쿼리도 지정 가능)

 

@Insert

"onConflict = OnConflictStrategy.REPLACE" option으로 만일 동일한 PrimaryKey가 있을 경우 덮어쓸 수 있다.

 

@Update
Entity set 업데이트. Return 값으로 업데이트된 행 수를 받을 수 있다.

 

@Delete
Entity set 삭제. Return 값으로 삭제된 행 수를 받을 수 있다.

 

@Query
@Query를 사용하여 DB를 조회할 수 있다.

@Dao
interface BookSearchDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE) // 만약 동일한 PrimaryKey 가 있을 경우 덮어쓰기
    suspend fun insertBook(book: Book)

    @Delete
    suspend fun deleteBook(book: Book)

    @Query("SELECT * FROM books")
    fun getFavoriteBooks(): LiveData<List<Book>>
}

 

Room DB

데이터베이스의 생성 및 버전 관리를 하는 클래스이다.

Room DB에서 DAO를 가져와 객체를 통해 데이터를 CRUD 한다.

 

@Database
Class가 Database임을 알려주는 어노테이션.

  • entities
    : 이 DB에 어떤 테이블들이 있는지 명시한다.
  • version
    : Scheme가 바뀔 때 이 version도 바뀌어야 한다.
  • exportSchema
    : Room의 Schema 구조를 폴더로 Export 할 수 있다.
@Database(
    entities = [Book::class],
    version = 1,
    exportSchema = false
)
@TypeConverters(OrmConverter::class)
abstract class BookSearchDatabase : RoomDatabase() {
	...
}

 

 


 

 

 

📌  코드 예제 (실습)

RoomDB를 이용해서 책을 즐겨찾기에 추가하는 기능을 구현해 보자.

 

의존성 추가

build.gradle(Module)

plugins {
  id 'kotlin-kapt'
}

dependencies {

    // Room
    implementation 'androidx.room:room-runtime:2.4.3'
    implementation 'androidx.room:room-ktx:2.4.3'
    kapt 'androidx.room:room-compiler:2.4.3'
}

 

여기서 kapt 란?

코틀린 프로젝트를 컴파일할 때는 javac가 아닌 kotlinc로 컴파일을 하기 때문에 Java로 작성한 어노테이션 프로세서가 동작하지 않는다. 그렇기 때문에 코틀린에서는 이러한 어노테이션 처리기를 위해 KAPT(Kotlin Annotation Processing Tool)를 제공한다.

 

추가

젯브레인과 구글이 합작하여 KAPT보다 속도를 개선한 KSP(Kotlin Symbol Processing)를 내놓았다고 한다.

 

엔티티(Entity) 생성

@Parcelize
@JsonClass(generateAdapter = true)
@Entity(tableName = "books") // 데이터베이스에서 사용할 Entity 로 만들어준다.
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

 

기존에 카카오 책 검색에서 가져오던 Book 클래스에 @Entity를 통해 엔티티로 만들어준다. 또한 위에서 설명한 대로 데이터의 고유값인 isbn에 @PrimaryKey(autoGenerate = false)를 통해 PrimaryKey로 만들어주고 salePrice 즉 카멜 케이스 형태로 저장된 책 가격을 @ColumnInfo 어노테이션을 통해 스네이크 케이스로 변환해준다.

 

 

 

DAO 생성

@Dao
interface BookSearchDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE) // 만약 동일한 PrimaryKey 가 있을 경우 덮어쓰기
    suspend fun insertBook(book: Book)

    @Delete
    suspend fun deleteBook(book: Book)

    @Query("SELECT * FROM books")
    fun getFavoriteBooks(): LiveData<List<Book>>
}
  • 데이터를 추가할 때는 기본적으로 @Insert 어노테이션을 사용하고 onConflict = OnConflictStrategy.REPLACE를 통해 동일한 isbn을 가지고 있는 아이템이 존재한다면 덮어쓰도록 구현한다.
  • 데이터를 삭제할 때는 @Delete를 통해 간단하게 구현 가능하다.
  • 데이터를 조회할 때는 @Query를 써서 SQL 쿼리문을 직접 작성한다. 위 코드에서는 "SELECT * FROM books"를 통해 books 테이블에 있는 모든 데이터를 가져오도록 했다. 이때 반환받는 데이터를 LiveData로 래핑 하여 ViewModel에서 바로 쓰일 수 있도록 했다.

Read 기능을 구현하는 @Query를 제외한 Create, Update, Delete 작업은 시간이 걸리는 작업이기 때문에 코루틴 안에서 비동기적으로 수행하기로 했다. 따라서 suspend 키워드를 함수 앞에 추가해준다.

 

다음은 DAO와 엔티티(Entity)의 동작을 주관하는 데이터베이스 클래스를 만들어준다.

 

 

 

데이터베이스(Database) 생성

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

BookSearchDatabase 클래스는 RoomDatabase()를 상속받는 추상 클래스(abstract class)로 만들어준다. 또한 @Database 어노테이션을 통해 Room에서 사용한 엔티티와 Room의 버전, SchemaExport 여부를 지정해준다.

 

RoomDatabase 객체도 Retrofit 객체와 동일하게 생성하는데 비용이 많이 들기 때문에 중복으로 생성하지 않도록 싱글톤으로 설정해준다. 

 

여기서 @Volatile 이란?

@Volatile 어노테이션은 Java 변수를 Main Memory에 저장하겠다는 것을 명시하는 것이다. 즉 매번 변수의 값을 Read할 때마다 CPU Chche에 저장된 값이 아닌 Main Memory에서 읽는 것을 의미한다. 또한 변수의 값을 Write할 때마다 MainMemory에 작성하는 것이다.

 

그렇다면 @Volatile 어노테이션은 왜 필요할까?

  • @Volatile 어노테이션을 사용하고 있지 않은 MultiThread 어플리케이션에서는 Task를 수행하는 동안 성능 향상을 위해 Main Memory에서 읽은 변수 값을 CPU Cache에 저장하게 된다. 
  • 만약 MultiThread 환경에서 Thread 변수 값을 가져올 때 각각의 CPU Cache에 저장된 값이 다르다면 변수 값 불일치 문제가 발생할 수 있다.
  • @Volatile 어노테이션을 사용하면 변수를 Main Memory에 저장하고 불러오기 때문에 변수 값 불일치 문제를 방지할 수 있다.

 

 

 

 

 

 

 

TypeConverter 생성

우리가 Entity로 사용할 Book 클래스에는 authors라는 변수가 있다. 이 authors는 List<String>타입인데 Room은 기본적으로 Primitive 타입(short, int, long, float, double, byte, char, boolean)과 그 wrapping 타입만(Primitive 타입을 객체로 한번 감싼 클래스)을 지원한다.

따라서 List객체를 저장하려고 하면 에러가 발생하게 된다.

 

왜 Room이 Primitive 타입만을 지원하는지는 공식문서에 설명이 되어있다.

https://developer.android.com/training/data-storage/room/referencing-data

 

따라서 우리는 Type Converter를 사용하여 authors의 List<String>을 일반 String 타입으로 변환하여 저장할 것이다.

이를 위해 데이터 직렬화를 해야 하는데 간단하게 사용할 수 있는 Kotlinx Serilaization을 이용해보자.

 

Project 단위의 build.gradle에 다음과 같이 코드를 추가해주자

build.gradle(Project)

plugins {
    ...
    id 'org.jetbrains.kotlin.plugin.serialization' version '1.6.10' apply false
}

 

Module 단위의 build.gradle에 다음과 같이 의존성을 추가해주자

build.gradle(Module)

dependencies {

    // Kotlin serialization
    implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.3'
}

 

그 다음은 List<String>과 String을 상호 변환하는 Converter를 추가해준다.

class OrmConverter {
    @TypeConverter
    fun fromList(value: List<String>) = Json.encodeToString(value)

    @TypeConverter
    fun toList(value: String) = Json.decodeFromString<List<String>>(value)
}

List<String>이 들어오면 encodeToString을 통해 String으로 encode 해주고 String이 들어오면 List<String>으로 decode 해주는 형식이다. 

 

마지막으로 이 converter를 데이터베이스에 @TypeConverters(OrmConverter::class)를 추가해주면 컨버터가 필요한 상황에 알아서 TypeConverter를 사용하게 된다.

@Database(
    entities = [Book::class],
    version = 1,
    exportSchema = false
)
@TypeConverters(OrmConverter::class) // TypeConverters 추가
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 }
            }
    }
}

 

 

나머지 코드 구현

이제 RoomDB를 사용할 준비는 끝났다. Repositroy에서 BookDatabase를 생성자로 받아 BookSearchDao에 작성한 메서드를 구현하면 된다.

 

BookSearchRepository

interface BookSearchRepository {

    suspend fun searchBooks(
        query: String,
        sort: String,
        page: Int,
        size: Int
    ): Response<SearchResponse>

    // Room
    suspend fun insertBook(book: Book)

    suspend fun deleteBook(book: Book)

    fun getFavoriteBooks(): LiveData<List<Book>>
}

 

BookSearchRepositoryImpl

class BookSearchRepositoryImpl(
    private val db: BookSearchDatabase
) : BookSearchRepository {
    override suspend fun searchBooks(
        query: String,
        sort: String,
        page: Int,
        size: Int
    ): Response<SearchResponse> {
        return RetrofitInstance.api.searchBooks(query, sort, page, size)
    }

    override suspend fun insertBook(book: Book) {
        db.bookSearchDao().insertBook(book)
    }

    override suspend fun deleteBook(book: Book) {
        db.bookSearchDao().deleteBook(book)
    }

    override fun getFavoriteBooks(): LiveData<List<Book>> {
        return db.bookSearchDao().getFavoriteBooks()
    }
}

 

BookSearchViewModel

class BookSearchViewModel(private val bookSearchRepository: BookSearchRepository) : ViewModel() {

    private val _searchResult = MutableLiveData<SearchResponse>()
    val searchResult: LiveData<SearchResponse> = _searchResult

    fun searchBooks(query: String) {
        viewModelScope.launch {
            val response = bookSearchRepository.searchBooks(query, "accuracy", 1, 15)
            if (response.isSuccessful) {
                response.body()?.let { body ->
                    _searchResult.value = body
                }
            }
        }
    }

    // Room
    fun saveBook(book: Book) {
        viewModelScope.launch {
            bookSearchRepository.insertBook(book)
        }
    }

    fun deleteBook(book: Book) {
        viewModelScope.launch {
            bookSearchRepository.deleteBook(book)
        }
    }

    val favoriteBooks: LiveData<List<Book>> = bookSearchRepository.getFavoriteBooks()
}