Android - Create a Room database(Codelabs)

Posted by RoadtoS7 on September 04, 2020 · 14 mins read

이 포스팅은 구글 Codelabs에서 “Create a Room database”을 공부하여 정리한 포스팅입니다.

대부분의 앱은 사용자가 앱을 닫은 후에도 보관되어야 할 데이터를 가지고 있습니다.
예를 들어서, 앱은 음악 플레이리스트, 게임 아이템 인벤토리, 지출과 수입 내역 등등 여러가지 데이터를 저장해야 합니다.
대부분 이런 데이터를 저장하기 위해서 데이터베이스를 사용합니다

Room은 Android Jetpack의 일환인 데이터베이스 라이브러리 입니다. Room은 데이터베이스를 만들고 구성하는 여러가지의 작업을 하고 일반적인 함수 호출을 이용해서 앱이 데이터베이스와 상호작용할 수 있도록 합니다.
Room은 SQLite 데이터베이스 가장 위에 존재하는 추상계층입니다. Room의 용어와 쿼리 문법은 SQLite 모델에서 사용되는 것과 일치합니다.

아래에 있는 그림은 아키텍처 권장사항에서 Room데이터베이스가 어떻게 위치하는지를 보여줍니다.
Room의 아키텍처상 위치



안드로이드 내에서 클래스들이 데이터를 대표합니다. 그리고 클래스안의 데이터는 함수를 사용해서 접근하거나 수정됩니다.
이와 반대로, 데이터베이스 내에서는 데이터를 다루기 위해 엔티티(Entity)쿼리(Query)가 필요합니다.

엔티티는 데이터베이스에 저장된 객체 또는 개념을 나타냅니다.
하나의 엔티티 클래스는 테이블을 하나를 정의하며, 엔티티 클래스의 인스턴스는 테이블에서 행(row)를 의미합니다.
엔티티 클래스내의 각 속성은 열(column)을 의미합니다.

쿼리는 데이터 베이스 테이블이나 데이터베이스 테이블 조합에 대한 데이터의 요청입니다. 혹은 데이터에 대한 처리 수행 요청에 해당합니다.
일반적인 쿼리문은 엔티티로부터 데이터를 얻거나, 엔티티에 데이터를 삽입하거나, 엔티티를 업데이트 하는 것입니다.

Room은 Kotlin 데이터 클래스로부터 데이터베이스에 저장된 엔티티를 얻는 것이나 함수 선언들로부터 SQL 쿼리를 얻는 것과 같은 어려운 작업을 대신 처리해줍니다.

우리는 entity어노테이션이 달린 데이터 클래스를 반드시 만들어야 하며, DAO(Data Access Object)라는 어노테이션이 달린 인터페이스를 만들어야 합니다.
Room이 이렇게 어노테이션이 달린 클래스들을 이용해서 데이터베이스 안에 테이블을 만들고, 데이터베이스에서 쿼리문을 실행합니다.

아키텍처



Entity 정의

Entity는 일반적으로 data class를 만들고, 만든 클래스에 @Entity주석을 다는 방식으로 생성합니다.

autoGenerated속성을 사용하는 경우, 해당 칼럼 값에 default 값을 설정합니다. 단, 데이터베이스에 의해서 새로운 값이 배정될 수 있도록 변수를 선언할 때 var키워드를 이용하여 선언합니다.

autoGenerated되는 값의 경우 타입이 Int 혹은 Long 둘중에 하나가 되어야 합니다. Int를 사용했을 때, 데이터의 개수가 Int의 범위를 초과하는 경우가 발생한다면, database migration 작업을 하지 않고도 Long타입으로 바꿀 수 있습니다. (왜냐하면, SQLite 데이터베이스에서 두 타입 모두 Integer타입으로 저장되기 때문입니다.)

@Entity주석의 tableName속성을 통해서 데이터베이스 내에서 생성될 테이블 이름을 지정해줄 수 있습니다. optional 이지만, 지정하는 것이 권장됩니다.

  • 예제 코드
    @Entity(tableName = "daily_sleep_quality_table")
    data class SleepNight (
          @PrimaryKey(autoGenerate = true)
          var nightId: Long = 0L,
    
          @ColumnInfo(name = "start_time_milli")
          val startTimeMilli: Long = System.currentTimeMillis(),
    
          @ColumnInfo(name = "end_time_milli")
          var endTimeMilli: Long = startTimeMilli,
    
          @ColumnInfo(name = "quality_rating")
          var sleepQuality: Int = -1
    )
    



DAO 정의

DAO(Data Access Object)는 데이터베이스의 데이터를 삽입, 수정, 삭제를 편리하게 할 수 있도록 함수를 제공합니다.

Room데이터베이스를 사용하면, Kotlin 함수를 정의하고 호출하는 것을 통해 데이터베이스에 쿼리를 실행합니다.

데이터베이스에 쿼리를 실행하는 함수들은 데이터베이스 쿼리와 맵핑됩니다.
즉, 우리는 쿼리와 함수가 맵핑될 수 있도록 DAO안에다가 어노테이션을 이용하여 정의합니다. 그러면 우리가 DAO안의 함수를 호출했을 때, Room은 어노테이션 맵핑을 보고, 적절한 쿼리로 맵핑시켜서 실행합니다.
DAO를 데이터베이스에 접근하기 위한 커스텀 인터페이스로 생각하면 됩니다.

Insert, Update, Delete와 같은 일반적인 데이터베이스 쿼리에 대해서는 Room에서 @Insert, @Update, @Delete와 같은 어노테이션을 제공합니다.

이외의 쿼리문에 대해서는 @Query 어노테이션을 사용합니다. @Query어노테이션 안에 SQLite가 지원하는 떤 쿼리문이든 작성할 수 있습니다.

그리고 Room의 어노테이션을 사용하여 작성한 쿼리 문들은 컴파일 타입에서 컴파일러가 신텍스 오류 검사를 수행합니다.

@Insert
fun insert(night: SleepNight)

위와 같이, @Insert어노테이션만 작성하면, Room에서 함수 인자로 주어진 SleepNight객체 하나를 테이블에 삽입하는 코드를 자동으로 생성하여 합니다. 그리고 insert()함수를 호출하면, Room은 데이터베이스에 있는 엔티티에 데이터를 삽입하는 쿼리문을 실행합니다.
@Update, @Delete 코드도 마찬가지 입니다.



@Update
fun update(night: SleepNight)

@Update어노테이션이 작성된 함수를 실행하면, Room에서는 해당 함수 파라미터로 들어온 객체의 Primary Key와 동일한 Primary Key를 가지는 엔티티를 업데이트 시킵니다.



@Query("SELECT * FROM daily_sleep_quality_table WHERE nightId = :key")
fun get(key: Long): SleepNight?

이 밖의 쿼리문을 실행할 때에는 @Query어노테이션을 사용합니다.
@Query어노테이션 안에 String타입으로 쿼리문을 작성하여 넣어줍니다. 그리고 쿼리문에서 함수의 파라미터를 참조하는 경우 :콜론을 앞에 붙여줍니다.
위 예시에서는 함수 파라미터 key를 쿼리문 안에서 참조하기 위해, 쿼리 문에서 :key라고 표시한 것을 볼 수 있습니다.



@Query("DELETE FROM daily_sleep_quality_table")
fun clear()

위 함수는 daily_sleep_quality_table테이블에 있는 모든 데이터를 없애는 함수입니다.
하지만 테이블까지 삭제하지는 않습니다.

@Delete어노테이션을 사용할 수도 있지만, 이는 테이블에서 하나의 아이템을 삭제할 때 유용합니다.
왜냐하면 테이블의 여러 아이템을 삭제할 때 @Delete를 사용한다면, 삭제할 아이템 리스트를 제공해야 합니다.
그렇다면 결국 삭제할 아이템을 제공하기 위해, 테이블에 어떤 아이템들이 있는지 알아야 하며, 삭제할 데이터를 가지고 와야합니다.
따라서 @Delete어노테이션은 테이블의 모든 엔트리를 삭제하는 것이 아닌, 특정 엔트리를 삭제할 때 유용합니다.



@Query("SELECT * FROM daily_sleep_quality_table ORDER BY nightId DESC")
fun getAllNights(): LiveData<List<SleepNight>>

getAllNights()함수는 SleepNight엔티티 리스트를 LiveData타입으로 반환합니다.
Room은 이 LiveData를 최신상태로 유지할 것이므로, 데이터를 얻기 위해 함수를 한번만 호출하면 됩니다. 다시 말해, 한번만 호출하여 데이터베이스의 값을 가져온다면, 이후에 데이터베이스에서 수정이 일어나더라도, RoomLiveData를 통해 변경된 데이터가 앱에 반영될 수 있도록 합니다.



Database 정의

Database는 추상 데이터베이스 홀더에 @Database주석을 작성하여 만듭니다.
이 클래스는 싱글톤 패턴으로 Database인스턴스를 생성하여 반환합니다.
즉, 객체가 이미 존재하면 존재하는 객체를 반환하고, 그렇지 않으면 새로 생성하여 반환합니다.

좀 더 자세한 순서를 나열하자면 다음과 같습니다.

  1. RoomDatabase를 상속받는 abstract class를 만듭니다. 이 클래스가 데이터베이스 홀더 역할을 합니다.
    추상 클래스 형태로 작성하는 이유는 Room이 이 추상클래스를 바탕으로 구현체를 만들어줄 것이기 때문입니다.

  2. 이 추상 클래스에 @Database어노테이션을 달아줍니다. 이때 @Databaseentities속성과 version속성을 반드시 지정해야합니다.
    entities속성에는 이 데이터베이스에서 사용할 Entity들을 리스트 형태로 작성합니다. version속성에는 데이터베이스에 대한 버전 넘버를 지정합니다.

  3. companion object내부에 이 추상 메서드를 작성하거나 혹은 DAO를 추상 멤버 변수로 정의합니다. Room에서 구현체를 만들어줄 것입니다.

  4. 앱내에서 단 하나의 데이터베이스 인스터스만 필요하기 때문에, RoomDatabase를 싱글톤으로 생성합니다.(@Database로 지정한 추상 클래스의 인스턴스를 싱글톤 패턴으로 생성합니다.)

@Database(entities=[SleepNight::class], version = 1, exportSchema = false)
abstract class SleepDatabase : RoomDatabase(){
  //...
}



@Database의 속성중 exportSchema는 스키마 버전 히스토리를 백업해놓을지 여부를 나타냅니다.
위의 예시 코드에서는 false를 했기 때문에 스키마 버전 숫자에 따라서 백업을 따로 하지 않도록 설정한 것 입니다.

@Database(entities=[SleepNight::class], version = 1, exportSchema = false)
abstract class SleepDatabase : RoomDatabase(){
  abstract val sleepDatabaseDao: SleepDatabaseDao
}

데이터베이스는 DAO에 대해서 알고 있어야 합니다. 따라서 모든 DAO에 대해서 DAO를 반환하는 추상 변수를 클래스 내에 정의해 놓아야 합니다.



그리고 클래스 내부에 companion object를 정의합니다. 사용자들은 이 클래스를 생성하지 않고도 companion object를 이용하여 데이터베이스를 생성 및 획득할 수 있습니다.
@Database추상 클래스의 목적은 오직 데이터베이스를 제공하는 것이기 때문에, 이 클래스를 인스턴스화할 필요는 없습니다.

@Database(entities=[SleepNight::class], version = 1, exportSchema = false)
abstract class SleepDatabase : RoomDatabase(){
    abstract val sleepDatabaseDao: SleepDatabaseDao

    companion object{

    }
}



companion object안에 현재 데이터베이스를 타입으로 하고, private nullable 속성의 var 변수로 INSTANCE를 하나 선언합니다. 그리고 이 변수에 null값을 할당합니다.
데이터베이스가 한번 생성되면 이 INSTANCE라는 변수가 보관하는 역할을 할 것입니다.
이러한 방식을 사용하면, 데이터베이스 커넥션을 반복적으로 여는 것을 막을 수 있습니다.
데이터 베이스 커넥션은 리소스 소모가 심하기 때문에 반복적으로 여는 것은 좋지 않습니다.

@Database(entities=[SleepNight::class], version = 1, exportSchema = false)
abstract class SleepDatabase : RoomDatabase(){
    abstract val sleepDatabaseDao: SleepDatabaseDao

    companion object{
        var INSTANCE: SleepDatabase? = null

    }
}



다음과 같이 INSTANCE@Volatile어노테이션을 달아줍니다.
@Volatile로 지정된 변수는 캐싱되지 않습니다. 그리고 변수에 대한 일기, 쓰기 모두 main memory 위에서 일어납니다.
따라서 @Volatile 변수는 항상 최신상태를 유지하면서 모든 스레드에게 같은 값을 갖습니다.
이 말은 곧, 한 스레드에서 변수를 수정한다 해도, 이 수정사항이 다른 모든 스레드에게도 즉시 인식된다는 것을 의미합니다.
두개의 스레드가 캐시에 똑같은 스레드를 두고 업데이트 할 때 일어나는 문제가 일어나지 않습니다.

@Database(entities=[SleepNight::class], version = 1, exportSchema = false)
abstract class SleepDatabase : RoomDatabase(){
    abstract val sleepDatabaseDao: SleepDatabaseDao

    companion object{
        @Volatile
        var INSTANCE: SleepDatabase? = null

    }
}



companion object안에 context를 인자로 받는 getInstance() 메서드를 정의합니다.
getInstance()내에서 데이터베이스 빌더를 사용하기 위해 context가 필요합니다.
메서드의 반환타입으로 데이터베이스 클래스를 지정합니다.

@Database(entities=[SleepNight::class], version = 1, exportSchema = false)
abstract class SleepDatabase : RoomDatabase(){
    abstract val sleepDatabaseDao: SleepDatabaseDao

    companion object{
        @Volatile
        var INSTANCE: SleepDatabase? = null

        fun getInstance(context: Context): SleepDatabase{

        }
    }
}



getInstance()메서드 내에 synchronized{}블럭을 생성하고 this를 넘겨줍니다.
this라는 것은 synchronized키워드가 존재하는 객체를 의미합니다.
따라서 여기서 this는 companion object를 의미합니다.
this를 lock으로 가지고서 한번에 한개의 스레드만 synchronized블럭을 실행할 수 있습니다.

동시에 여러개의 스레드가 데이터베이스를 생성하고자 할 수 있으며, 이는 데이터베이스가 여러개 생기는 결과를 낳을 수 있습니다.
이 문제가 간단한 앱에서는 벌어지지 않겠지만, 더 복잡한 앱에서는 충분히 일어날 수 있습니다.
따라서 데이터베이스를 얻는 코드를 synchronized로 감싸는 것은 오직 한번에 한개의 스레드만 synchronized블럭의 코드를 실행할 수 있기 때문에, 데이터베이스가 한번만 초기화되도록 합니다.



synchronized블럭내에 INSTANCE의 값을 지역변수 instance에 할당합니다.
이때 instance가 지역변수이기 때문에 smart cast가 일어나서 INSTANCEinstance는 같은 타입이 됩니다.

@Database(entities=[SleepNight::class], version = 1, exportSchema = false)
abstract class SleepDatabase : RoomDatabase(){
    abstract val sleepDatabaseDao: SleepDatabaseDao

    companion object{
        @Volatile
        var INSTANCE: SleepDatabase? = null

        fun getInstance(context: Context): SleepDatabase{
            var instance = INSTANCE


        }
    }
}



synchronized블럭 마지막에 instance를 반환하는 코드를 작성합니다. 그리고 instance 반환 구문위에 instancenull인지 체크하는 if문을 작성합니다.

@Database(entities=[SleepNight::class], version = 1, exportSchema = false)
abstract class SleepDatabase : RoomDatabase(){
    abstract val sleepDatabaseDao: SleepDatabaseDao

    companion object{
        @Volatile
        var INSTANCE: SleepDatabase? = null

        fun getInstance(context: Context): SleepDatabase{
            var instance = INSTANCE
            if(instance == null){

            }
            return instance
        }
    }
}



만약 instancenull이라면 database builder를 이용하여 데이터베이스를 얻습니다.
따라서 if문안에서 Room.databaseBuilder에 호출합니다. 이때 첫번째 인자로는 context, 두번째 인자로는 데이터베이스 클래스 이름, 세번째 인자로는 데이터베이스 이름을 전달합니다.

instance = Room.databaseBuilder(
                        context,
                        SleepDatabase::class.java,
                        "sleep_history_database")



그리고 Room.databaseBuilder.fallbackToDestructiveMigration()를 사용하여 migration strategy를 붙여줍니다.

일반적으로 데이터베이스 스키마가 수정된다면, .fallbackToDestructiveMigration()메서드에 migration strategy 객체를 인자로 전달해야 합니다.
migration strategy 객체란, 이전 스키마에 있던 열들을 어떻게 새로운 스키마로 손실 없이 옮길지, 방법을 정의하고 있는 객체입니다.

migration strategy를 붙여줬다면, build()함수를 호출하여 데이터베이스를 생성합니다.

instance= Room.databaseBuilder(
                        context,
                        SleepDatabase::class.java,
                        "sleep_history_database")
                        .fallbackToDestructiveMigration()
                        .build()

생성한 데이터베이스를 INSTANCE에도 할당합니다.

INSTANCE = instance



최종코드는 다음과 같습니다.

@Database(entities = [SleepNight::class], version = 1, exportSchema = false)
abstract class SleepDatabase : RoomDatabase() {
    abstract val sleepDatabaseDao: SleepDatabaseDao

    companion object {
        @Volatile
        var INSTANCE: SleepDatabase? = null

        fun getInstance(context: Context): SleepDatabase {
            var instance = INSTANCE
            if (instance == null) {
                instance = Room.databaseBuilder(
                        context,
                        SleepDatabase::class.java,
                        "sleep_history_database")
                        .fallbackToDestructiveMigration()
                        .build()
                INSTANCE = instance
            }
            return instance  
        }
    }
}