이 포스팅은 구글 Codelabs에서 “Create a Room database”을 공부하여 정리한 포스팅입니다.
대부분의 앱은 사용자가 앱을 닫은 후에도 보관되어야 할 데이터를 가지고 있습니다.
예를 들어서, 앱은 음악 플레이리스트, 게임 아이템 인벤토리, 지출과 수입 내역 등등 여러가지 데이터를 저장해야 합니다.
대부분 이런 데이터를 저장하기 위해서 데이터베이스를 사용합니다
Room
은 Android Jetpack의 일환인 데이터베이스 라이브러리 입니다. Room
은 데이터베이스를 만들고 구성하는 여러가지의 작업을 하고 일반적인 함수 호출을 이용해서 앱이 데이터베이스와 상호작용할 수 있도록 합니다.
Room
은 SQLite 데이터베이스 가장 위에 존재하는 추상계층입니다. Room
의 용어와 쿼리 문법은 SQLite 모델에서 사용되는 것과 일치합니다.
아래에 있는 그림은 아키텍처 권장사항에서 Room
데이터베이스가 어떻게 위치하는지를 보여줍니다.
안드로이드 내에서 클래스들이 데이터를 대표합니다. 그리고 클래스안의 데이터는 함수를 사용해서 접근하거나 수정됩니다.
이와 반대로, 데이터베이스 내에서는 데이터를 다루기 위해 엔티티(Entity)
와 쿼리(Query)
가 필요합니다.
엔티티
는 데이터베이스에 저장된 객체 또는 개념을 나타냅니다.
하나의 엔티티 클래스는 테이블을 하나를 정의하며, 엔티티 클래스의 인스턴스는 테이블에서 행(row)를 의미합니다.
엔티티 클래스내의 각 속성은 열(column)을 의미합니다.
쿼리
는 데이터 베이스 테이블이나 데이터베이스 테이블 조합에 대한 데이터의 요청입니다. 혹은 데이터에 대한 처리 수행 요청에 해당합니다.
일반적인 쿼리문은 엔티티로부터 데이터를 얻거나, 엔티티에 데이터를 삽입하거나, 엔티티를 업데이트 하는 것입니다.
Room
은 Kotlin 데이터 클래스로부터 데이터베이스에 저장된 엔티티를 얻는 것이나 함수 선언들로부터 SQL 쿼리를 얻는 것과 같은 어려운 작업을 대신 처리해줍니다.
우리는 entity
어노테이션이 달린 데이터 클래스를 반드시 만들어야 하며, DAO(Data Access Object)
라는 어노테이션이 달린 인터페이스를 만들어야 합니다.
Room
이 이렇게 어노테이션이 달린 클래스들을 이용해서 데이터베이스 안에 테이블을 만들고, 데이터베이스에서 쿼리문을 실행합니다.
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(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
를 최신상태로 유지할 것이므로, 데이터를 얻기 위해 함수를 한번만 호출하면 됩니다. 다시 말해, 한번만 호출하여 데이터베이스의 값을 가져온다면, 이후에 데이터베이스에서 수정이 일어나더라도, Room
이 LiveData
를 통해 변경된 데이터가 앱에 반영될 수 있도록 합니다.
Database
는 추상 데이터베이스 홀더에 @Database
주석을 작성하여 만듭니다.
이 클래스는 싱글톤 패턴으로 Database
인스턴스를 생성하여 반환합니다.
즉, 객체가 이미 존재하면 존재하는 객체를 반환하고, 그렇지 않으면 새로 생성하여 반환합니다.
좀 더 자세한 순서를 나열하자면 다음과 같습니다.
RoomDatabase
를 상속받는 abstract class
를 만듭니다. 이 클래스가 데이터베이스 홀더 역할을 합니다.
추상 클래스 형태로 작성하는 이유는 Room
이 이 추상클래스를 바탕으로 구현체를 만들어줄 것이기 때문입니다.
이 추상 클래스에 @Database
어노테이션을 달아줍니다. 이때 @Database
의 entities
속성과 version
속성을 반드시 지정해야합니다.
entities
속성에는 이 데이터베이스에서 사용할 Entity
들을 리스트 형태로 작성합니다. version
속성에는 데이터베이스에 대한 버전 넘버를 지정합니다.
companion object
내부에 이 추상 메서드를 작성하거나 혹은 DAO
를 추상 멤버 변수로 정의합니다. Room
에서 구현체를 만들어줄 것입니다.
앱내에서 단 하나의 데이터베이스 인스터스만 필요하기 때문에, 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
가 일어나서 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
}
}
}
synchronized
블럭 마지막에 instance
를 반환하는 코드를 작성합니다.
그리고 instance
반환 구문위에 instance
가 null
인지 체크하는 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
}
}
}
만약 instance
가 null
이라면 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
}
}
}