앞서 DB relationship 에 대해 SQLite로 알아보았다. one to one, one to many, many to many relationship에 대해선 이전글을 참고하고, 동일한 내용을 Room에서 구현해보자.
Room 적용시 꼭 직접 테스트 하길 바란다. 처음 사용해보면서 SQL을 사용하는 것보다 오히려 혼란스러운 점도 많았다. 예를들어, api level 24 인 android 7.0 에뮬레이터에선 테이블 생성이 안된건지 그런 테이블이 없다며 오류를 뱉어내고 제대로 동작하지 않았다.
Set up
우선, 안드로이드에서 Room을 사용하기위해 라이브러리를 추가해야한다.
plugins {
id 'com.android.application'
id 'kotlin-android'
id 'kotlin-kapt' // <-- kapt 플러그인 추가
}
...
dependencies {
def room_version = "2.3.0"
implementation "androidx.room:room-runtime:$room_version"
kapt "androidx.room:room-compiler:$room_version"
// optional - Kotlin Extensions and Coroutines support for Room
implementation "androidx.room:room-ktx:$room_version"
// optional - Test helpers
testImplementation "androidx.room:room-testing:$room_version"
}
공식문서에는 명시가 안되어 있는데, Room은 Kotlin annotation에 의해 해석이 되기 때문에 kapt 플러그인이 필요하다. 사용중이라면 상관없지만, 없다면 추가해야한다.
기본적인 Room 사용법
Room은 다음의 3가지를 구현해야 한다.
- Entity class : @Entity 어노테이션을 붙인 data class 이다. 테이블 스키마에 해당한다.
- DAO : Room은 DAO 모델을 따른다. 쿼리를 추상화해 사용자가 쿼리대신 사용하는 인터페이스 이다. @Dao 어노테이션을 붙여준다.
- Database class : 앱에서 Database에 접근하기 위한 기본 객체. DB가 없으면 생성하고, 사용자에게 DAO를 리턴해서 사용할 수 있게 해준다. @Database 어노테이션을 붙여준다.
이전에 다뤘던 one to one 의 예제를 Room에서 구현해보자. 이전 예제는 주차장에서 주차권을 받는 차에 대한 것이었다. 주차권은 하나의 차에 대해서만 발급되고, 차도 하나의 주차권만 갖게된다. 테이블 형태는 다음과 같았다.

1. Entity class
SQLite에서 테이블을 생성한 것과 비슷하게 Entry class를 만들어보자.
@Entity (tableName="car")
data class Car(
@PrimaryKey val car_id: Long,
@ColumnInfo(name="car_number") val car_number: String,
@ColumnInfo(name="product_name") val product_name: String
)
@Entity (tableName="ticket",
foreignKeys = [
ForeignKey(
entity = Car::class,
parentColumns = ["car_id"],
childColumns = ["car_id"]
)
])
data class Ticket(
@PrimaryKey val ticket_id: Long,
@ColumnInfo(name="name") val name: String,
@ColumnInfo(name="enter_time") val enter_time: String,
@ColumnInfo(name="exit_time") val exit_time: String?,
@ColumnInfo(name="car_id") val car_id: Long
)
어노테이션들을 주의깊게 보자. @Entry에 인자로 테이블 이름이 들어간다. 컬럼들에는 @PrimaryKey 로 Primary key 표시를 하고있고, 각 컬럼에 해당하는 변수들에 @ColumnInfo로 컬럼이름을 지정해주고 있다. 이 부분이 없으면, 자동으로 변수명 컬럼이름을 생성하는 것으로 알고있으나, 명확하게 사용하기위해 써줬다.
Ticket 클래스에 붙은 @Entity에 ForeignKey를 지정해놓은게 보일것이다. Foreign key와 앞으로 다룰 @Relation의 차이점은, Foreign Key는 테이블 스키마상의 제약사항이고, @Relation은 쿼리와 연관되어 JOIN의 역할을 한다. 이에대해선 stack overflow의 설명을 참고하자.
2. Dao 정의
Dao(Data Access Object)는 SQL의 쿼리문을 추상화 해준다. 컴파일 타임에 SQL문을 체크한다는 엄청큰 장점과 사용의 간편함등 장점이 많다. 또한 기본적인 insert, delete, update에 대해선 SQL문을 필요로 하지 않고 직접 처리해준다.
Car, Ticket 테이블 각각에 대한 insert, update, delete 를 먼저 만들어보자. 우선 알아야할 점은 Dao 구현은 Room에서 처리해주므로 interface만 만들어준다. 또한, 기본적인 insert, update, delete는 Room에서 알아서 처리해주므로 SQL Query문없이 작성이 가능하다. 이 외에 값을 가져오거나, 복잡한 쿼리가 필요한 경우는 직접 만들어야 한다. Car, Ticket에 대해 만들어본 예제는 다음과 같다.
@Dao
interface CarDao {
@Insert
fun insertCars(vararg cars: Car): List<Long>
@Update
fun updateCars(vararg cars: Car)
@Delete
fun deleteCars(vararg cars: Car)
@Query("DELETE FROM car WHERE car_id = :carId")
fun deleteById(carId: Long)
@Query("SELECT * FROM car")
fun getCars(): List<Car>
}
@Dao
interface TicketDao {
@Insert
fun insertTickets(vararg tickets: Ticket): List<Long>
@Update
fun updateTickets(vararg tickets: Ticket)
@Delete
fun deleteTickets(vararg tickets: Ticket)
@Query("SELECT * FROM ticket")
fun getTickets(): List<Ticket>
}
@Insert, Update, @Delete 어노테이션을 사용하는 경우, 따로 SQL 문이 들어가있지 않는걸 볼 수 있다. 또한, insert 같은 경우, 리턴값을 표시해주면 추가된 row의 id값을 돌려준다. @Delete 어노테이션은 필요한건 id겠지만, 기본적으로 객체를 인자로 받는다. id로 삭제하기 위해 deleteById()에서 직접 SQL 쿼리문을 만들어 줬다. 이처럼 사용자 쿼리문은 전부 @Query 어노테이션을 사용한다. 각 테이블의 데이터를 전부 보기위한 getCars(), getTickets()는 @Query 어노테이션으로 쿼리를 날려주고 리턴값을 List<Car>, List<Ticket>으로 받아오고 있다.
3. Database 정의
지금까지 만든 DB를 사용하려면 Database 클래스를 만들어야 한다. DB 스키마에 해당하는 Entity들을 인자로 넘겨주고, 앱에서 사용할 인터페이스로 앞서 만든 Dao들을 넘겨줘야한다. @Database 어노테이션을 이용하고 RoomDatabase를 상속해서 abstract class로 만들어야 한다. 지금까지 만든 Car, Ticket Entity들과 Dao들을 이용해 만들면 다음과 같이된다.
@Database(entities = [Car::class, Ticket::class], version = 1)
abstract class ParkingDatabase : RoomDatabase(){
abstract fun carDao(): CarDao
abstract fun ticketDao(): TicketDao
companion object {
// For Singleton instantiation
@Volatile private var instance: ParkingDatabase? = null
fun getInstance(context: Context): ParkingDatabase {
return instance ?: synchronized(this) {
instance ?: buildDatabase(context).also { instance = it }
}
}
private fun buildDatabase(context: Context): ParkingDatabase{
return Room.databaseBuilder(context.applicationContext, ParkingDatabase::class.java, "Parking").build()
}
}
}
@Database 어노테이션에 entities로 각 Entity 클래스들을 넘겨주고, RoomDatabase를 상속받아 추상 클래스를 만든다. 클래스 몸체에선 Dao 인터페이스를 돌려주는 메소드들이 들어갔다. database 클래스가 최소한으로 필요한건 여기까지다.
companion object 부분은 DB를 앱에서 singleton으로 사용하기위한 코드이다. buildDatabase()를 보면, Room의 databaseBuilder로 Database 인스턴스를 어떻게 생성하는지 알 수 있다.
4. 정의한 database와 Dao의 사용
이제 앱에서 사용하는 코드를 보자. 싱글톤 구현을 만들었으므로 앱에서 사용시, getInstance()로 Database를 받아와 사용하면 된다. 단, 안드로이드에선 네트워크 IO나 Database IO가 UI를 블럭시킬수 있어 메인쓰레드에서의 사용을 금지시키고 있다. 쓰레드를 만들어 사용할 수도 있으나, 좀 더 간편해진 coroutine을 사용하자. Room에서도 coroutine을 지원한다. Room에서 coroutine을 사용하는 방법은 간단하다. 사용하는 Dao 인터페이스에 suspend를 붙여주면 된다. 앞의 Dao를 다시 써보면 다음과 같다.
@Dao
interface CarDao {
@Insert
suspend fun insertCars(vararg cars: Car)
@Update
suspend fun updateCars(vararg cars: Car)
@Delete
suspend fun deleteCars(vararg cars: Car)
@Query("DELETE FROM car WHERE car_id = :carId")
suspend fun deleteById(carId: Long)
@Query("SELECT * FROM car")
suspend fun getCars(): List<Car>
}
@Dao
interface TicketDao {
@Insert
suspend fun insertTickets(vararg tickets: Ticket): List<Long>
@Update
suspend fun updateTickets(vararg tickets: Ticket)
@Delete
suspend fun deleteTickets(vararg tickets: Ticket)
@Query("SELECT * FROM ticket")
suspend fun getTickets(): List<Ticket>
}
코루틴을 사용할 땐, Main Thread safe하게 작성하는게 원칙이다. 코루틴이란게 메인쓰레드에서도 동작하기 때문에, suspend 함수를 작성할 때 시간이 오래걸린다면 다른 withContext()등을 사용해서 context에서 실행되도록 한다는 뜻이다. 네트웍이나 DB작업은 보통 Dispatcher.IO 컨텐스트에서 실행하는데, Room에서 suspend가 붙으면 자체적으로 별도의 context를 만들어 실행한다. 따라서, Room database를 사용할 때 메인쓰레드에서 시작해도 문제가 안된다.
Coroutine에 대해선 아직 따로 정리를 안해서 무슨말인지 잘 모르겠다면, 공식 문서를 보고 학습이 좀 필요하다.
fragment의 viewmodel에 작성해본 샘플 코드는 다음과 같다.
fun roomTest(){
viewModelScope.launch {
parkingRoomSample()
}
}
...
suspend fun parkingRoomSample(){
val database = ParkingDatabase.getInstance(getApplication())
val carDao = database.carDao()
withContext(Dispatchers.IO){
database.clearAllTables()
}
carDao.insertCars(
Car(1, "11가1234", "avante"),
Car(2, "22너2345", "damas"),
Car(3, "33다3456", "bmw m2")
)
val cars: List<Car> = carDao.getCars()
for (car in cars){
Log.i("room test", car.toString())
}
ticketDao.insertTickets(
Ticket(1, "parking 1", "2021-01-03 09:30:00", null, 2),
Ticket(2, "parking 1", "2021-02-10 13:10:00", null, 3),
Ticket(3, "parking 1", "2021-02-10 13:10:00", null, 1)
)
val tickets: List<Ticket> = ticketDao.getTickets()
for(ticket in tickets){
Log.i("room test", ticket.toString())
}
}
roomTest()가 호출되면, 코드를 실행한다. viewmodel에서 실행하기 때문에 coroutine을 viewModelScope에서 실행하고 있으며, launch()에서 따로 context를 지정하지 않기 때문에 메인쓰레드로 시작한다.
parkingRoomSample()을 보면, 싱글톤 database를 얻어오고 이로부터 Dao들을 가져온다. 이 Dao 인터페이스를 통해 쿼리를 날린다. 샘플코드에서 DB에 중복 insert가 되지 않도록 database.clearAllTables()를 불러주고 있다. 이 함수는 라이브러리제공 함수지만, suspend가 아니라서 withContext(Dispatch.IO)로 별도 컨텍스트로 실행하고 있다. 앞에서 말했듯이 메인쓰레드에서 DB관련 작업은 에러를 발생하기 때문이다. 위 코드의 실행결과는 다음과 같다.
I/room test: Car(car_id=1, car_number=11가1234, product_name=avante)
I/room test: Car(car_id=2, car_number=22너2345, product_name=damas)
I/room test: Car(car_id=3, car_number=33다3456, product_name=bmw m2)
I/room test: Ticket(ticket_id=1, name=parking 1, enter_time=2021-01-03 09:30:00, exit_time=null, car_id=2)
I/room test: Ticket(ticket_id=2, name=parking 1, enter_time=2021-02-10 13:10:00, exit_time=null, car_id=3)
I/room test: Ticket(ticket_id=3, name=parking 1, enter_time=2021-02-10 13:10:00, exit_time=null, car_id=1)
Room에서 Database를 어떻게 생성하고 사용하는지 간략히 보았다. 서론이 엄청 길어졌는데, 이제 본론인 one to one relationship으로 돌아가자.
One to One
앞에서 Car 와 Ticket의 각 개별 테이블에 대해 Room으로 구현했었다. 이제 SQLite에서 JOIN 쿼리를 했듯 1:1 관계를 사용해보자.
여기에서 새로 등장하는건 @Relation 어노테이션인데, 단계적으로 접근해보자. 우선 쿼리에서 값을 받아올 data class를 만들어야 한다. Car 와 Ticket을 둘 다 담은 data class를 만들자. 1:1 관계이기 때문에 하나의 Car와 하나의 Ticket만 포함한다.
data class CarAndTicket(
val car: Car,
val ticket: Ticket
)
음… 두 테이블의 데이터를 모두 담을 수 있지만, JOIN을 생각해보면 원하는 형태는 아니다. JOIN이 새로운 테이블을 만들듯, Car와 Ticket의 멤버들로 구성된 새로운 data class가 되면 좋을 것 같다. Car에 대해서만 풀어서 써보면 다음과 같다.
data class CarAndTicket(
val car_id: Long,
val car_number: String,
val product_name: String,
val ticket: Ticket
)
이렇게 사용시 코드의 중복이기 때문에 Car가 변경되는 경우, CarAndTicket도 매번 수정해야 한다는 점이다. 간단하게 이를 해결해주는 방법이 있는데, 바로 @Embedded 어노테이션이다.
data class CarAndTicket(
@Embedded val car: Car,
val ticket: Ticket
)
위와 같이 사용하면, 자동으로 Car 클래스를 분해해서 멤버들만 CarAndTicket으로 넣어준다. 그럼 Ticket도 @Embedded를 써야하는게 아닌가? Ticket은 여기서 다룰 핵심인 @Relation 어노테이션을 사용해 처리할 것이다. @Relation은 다음과 같이 사용한다.
data class CarAndTicket(
@Embedded val car: Car,
@Relation(
parentColumn = "car_id",
entityColumn = "car_id"
)
val ticket: Ticket
)
@Relation 어노테이션이 ticket 변수를 꾸미고 있다. 어노테이션 안에는 parentColumn이 있고 entityColumn이 있는데, 외래키로 참조하는 Entity가 parent이고 참조 컬럼이 parentColumn이다. 여기서는 Car 클래스의 Primary key인 car_id를 명시해줬다. @Relation이 ticket을 꾸미고 있기 때문에 entityColumn은 Ticket의 Column임을 알 수 있다. parent 와 entity 관계가 곧 두 테이블의 관계를 표현하고 있음을 알 것이다. 이와같이 Room에서 relationship은 @Relation 어노테이션으로 표현한다.
아 그런가 싶다가 의문이 생긴다. parentColumn이 Car의 컬럼임을 어떻게 알아먹는거지? parent 클래스가 명시된 곳이 없는데? 바로앞에 car 멤버변수를 준걸로 인식하나? @Embedded되서 클래스 멤버들이 decompose되서 들어올텐데?” 이해를 하기엔 정보가 부족해 혼란에 빠질텐데, 다음에 나오는 @Dao 쿼리까지 살펴봐야 완성이 되기 때문이다.
@Dao
interface CarTicketDao{
@Transaction
@Query("SELECT * FROM car")
suspend fun getAllCarAndTickets(): List<CarAndTicket>
@Transaction
@Query("SELECT * FROM car WHERE car.car_number = :car_number")
suspend fun findCar(car_number: String): CarAndTicket
}
getAllCarAndTickets()를 살펴보자. 우선 @Transaction 어노테이션이 보인다. 이는 database에서 익히 알듯이 여러개의 쿼리를 원자단위로 실행할 때 사용하는 것이다. SQLite에서 JOIN을 사용했지만, 다음과 같이 두개의 쿼리로 처리할 수도 있다.
SELECT * FROM car
SELECT * FROM ticket WHERE car_id IN ( car_id1, car_id2, ...)
Room에서는 이와같이 두개의 쿼리로 처리를 하는지 @Transaction을 반드시 사용해야 한다. 두번째 흥미로운 부분은 @Query 어노테이션에 있는 SQL 쿼리문이 car에 대해서만 구성되어있다. 차이점이라면 리턴값이 CarAndTicket 형태란 점이다. 즉, 리턴값의 CarAndTicket 클래스와 쿼리문에 명시한 대상인 car 테이블을 조합하여 처리를 해준다는 얘기다.
개인적으로 이부분에서 상당히 혼란스러웠었는데, JOIN의 개념보다는 바로위에 있는 두번의 쿼리를 추상화 한걸로 이해하면 쉬울거 같다. 메인 쿼리는 car에 날리는 거고, 여기에 연관된 데이터들을 다른 테이블에서 추가로 가져온다고 생각하자.
findCar()를 살펴보면, 조건에 부합하는 특정 아이템만 가져오는 방법도 알 수 있다. 함수의 인자인 car_number를 @Query 어노테이션 안에서 :car_number 와 같이 참조하고 있다.
One to Many
Room의 사용에 대한 기본적인 것은 One to One에서 다 다뤘기 때문에 One to many 의 경우는 간단하다. 쿼리를 받아오는 data class만 Many에 대응하도록 List를 사용해 변경된다. one to one의 예제를 변경하면 정말 간단해 지겠지만, 이전 포스트에서 다뤘던 택배물품(package)와 배송정보(recipient)로 구성해 보겠다.
- recipient를 receipient로 잘못 적었다. 이미지를 미처 수정하지 못함 ㅜ

테이블 스키마를 표현하는 Entity 클래스를 만들면 다음과 같다.
@Entity(tableName = "recipient")
data class Recipient(
@PrimaryKey(autoGenerate = true) val recipient_id: Long = 0,
@ColumnInfo(name = "name") val name: String,
@ColumnInfo(name = "address") val address: String
)
@Entity(tableName = "package",
foreignKeys = [
ForeignKey(
entity = Recipient::class,
parentColumns = ["recipient_id"],
childColumns = ["recipient_id"]
)
]
)
data class Package(
@PrimaryKey(autoGenerate = true) val package_id: Long = 0,
@ColumnInfo(name = "name") val name: String,
@ColumnInfo(name = "recipient_id") val recipient_id: Long
)
data class RecipientPackages(
@Embedded val recipient: Recipient,
@Relation(
parentColumn = "recipient_id",
entityColumn = "recipient_id"
)
val packages: List<Package>?
)
one to one과 거의 동일하지만, 놓치지 말아야할 차이점은 @Relation 어노테이션이 꾸미는 멤버가 List<Package>? 형태라는 점이다. one to many이기 때문에 리스트 형태로 여러개가 올 수도, null일수도 있다. 중요하진 않지만, 조금 변화룰 준 부분은 @PrimaryKey에 autoGenerate를 설정했다. 자동 생성을 위해 초기값 0도 할당했다.
Dao 와 Database 정의는 다음과 같다.
@Dao
interface RecipientDao{
@Insert
suspend fun insertRecipients(vararg recipients: Recipient)
@Update
suspend fun updateRecipients(vararg recipients: Recipient)
@Delete
suspend fun deleteRecipients(vararg recipients: Recipient)
@Query("SELECT * from recipient")
suspend fun getAllRecipients(): List<Recipient>
}
@Dao
interface PackageDao{
@Insert
suspend fun insertPackages(vararg packages: Package)
@Update
suspend fun updatePackages(vararg packages: Package)
@Delete
suspend fun deletePackages(vararg packages: Package)
@Query("SELECT * FROM package")
suspend fun getAllPackages(): List<Package>
}
@Dao
interface RecipientAndPackagesDao {
@Transaction
@Query("SELECT * from recipient")
suspend fun getAllRecipientAndPackages(): List<RecipientPackages>?
@Transaction
@Query("SELECT * from recipient WHERE recipient_id = :id")
suspend fun getRecipient(id: Long): RecipientPackages?
}
@Database(entities = [Recipient::class, Package::class], version=1)
abstract class PackageDatabase: RoomDatabase(){
abstract fun recipientDao(): RecipientDao
abstract fun packageDao(): PackageDao
abstract fun recipientAndPackageDao(): RecipientAndPackagesDao
companion object {
// For Singleton instantiation
@Volatile private var instance: PackageDatabase? = null
fun getInstance(context: Context): PackageDatabase {
return instance ?: synchronized(this) {
instance ?: buildDatabase(context).also { instance = it }
}
}
private fun buildDatabase(context: Context): PackageDatabase{
return Room.databaseBuilder(context, PackageDatabase::class.java, "Parking").build()
}
}
}
one to one과 별다른 점은 없다. 바로 사용 예를 보자.
private suspend fun packagesSample(){
val database = PackageDatabase.getInstance(getApplication())
val recipientDao = database.recipientDao()
val packageDao = database.packageDao()
val recipientAndPackagesDao = database.recipientAndPackageDao()
withContext(Dispatchers.IO){
database.clearAllTables()
}
recipientDao.insertRecipients(
Recipient(1, "김택배", "서울시 짬뽕동 11-22"),
Recipient(2, "포장지", "부산시 짬짜동 33-44"),
Recipient(3, "강벽돌", "대전시 사기동 55-77"),
)
packageDao.insertPackages(
Package(name = "햇반 한상자", recipient_id = 1),
Package(name = "컵라면박스", recipient_id = 1),
Package(name = "커피포트", recipient_id = 1),
Package(name = "박스티", recipient_id = 2),
Package(name = "양말", recipient_id = 2),
Package(name = "아이폰", recipient_id = 3),
Package(name = "아이팟", recipient_id = 3),
)
val allItems = recipientAndPackagesDao.getAllRecipientAndPackages()
allItems?.forEach {
Log.i("room test", it.toString())
}
}
역시 익히 아는 내용들이다. 사소한 변화는 앞서 Entity 정의시 PrimaryKey를 autogenerate로 설정했기 때문에 insert시에 id없이 삽입 가능하다는 점이다. 코틀린 코드상에서 id를 스킵하기 위해서 name=””, recipient_id=””와 같이 named argument를 사용했다. 사소하지만, 출력 루프도 조금 변경해봤다. 결과는 다음과 같이 나온다.
RecipientPackages(recipient=Recipient(recipient_id=1, name=김택배, address=서울시 짬뽕동 11-22),
packages=[
Package(package_id=1, name=햇반 한상자, recipient_id=1),
Package(package_id=2, name=컵라면박스, recipient_id=1),
Package(package_id=3, name=커피포트, recipient_id=1)
]
)
RecipientPackages(recipient=Recipient(recipient_id=2, name=포장지, address=부산시 짬짜동 33-44),
packages=[
Package(package_id=4, name=박스티, recipient_id=2),
Package(package_id=5, name=양말, recipient_id=2)
]
)
RecipientPackages(recipient=Recipient(recipient_id=3, name=강벽돌, address=대전시 사기동 55-77),
packages=[
Package(package_id=6, name=아이폰, recipient_id=3),
Package(package_id=7, name=아이팟, recipient_id=3)
]
)
Many to Many
대망의 many to many relationship이다. 색다른 부분은 두 테이블간을 연결하는 정션(또는 브릿지) 테이블의 존재인데, 스키마 구조에 대해선 SQLite에서 이미 다뤘다.

정션 테이블이 없으면, 데이터의 중복이 발생하므로 자연스럽게 도출되는 구조이다. Entity 클래스를 정의해보자.
@Entity(tableName = "bookmark")
data class Bookmark(
@PrimaryKey val bookmark_id: Long,
@ColumnInfo(name = "name") val name: String?,
@ColumnInfo(name= "url") val url: String?
)
@Entity(tableName = "tag")
data class Tag(
@PrimaryKey val tag_id: Long,
@ColumnInfo(name = "tag_name") val tag_name: String?
)
@Entity(
tableName = "bookmark_tag",
primaryKeys = ["bookmark_id", "tag_id"]
)
data class BookmarkTag(
@ColumnInfo(name = "bookmark_id") val bookmark_id: Long,
@ColumnInfo(name = "tag_id") val tag_id: Long
)
정션 테이블 bookmark_tag 추가되어 있음을 볼 수 있다. 정션테이블의 Primary key는 별도로 생성하지 않고 두 테이블의 id가 합쳐진 복합키(Composite key)로 정의되어 있다. Room에서 복합키를 이렇게 정의한다는 것도 알게되었다.
쿼리로 받아올 데이터 클래스와 relation을 구현해보자.
data class BookmarkWithTags(
@Embedded val bookmark: Bookmark,
@Relation(
parentColumn = "bookmark_id",
entityColumn = "tag_id",
associateBy = Junction(BookmarkTag::class)
)
val tags: List<Tag>?
)
data class TagWithBookmarks(
@Embedded val tag: Tag,
@Relation(
parentColumn = "tag_id",
entityColumn = "bookmark_id",
associateBy = Junction(BookmarkTag::class)
)
val bookmarks: List<Bookmark>?
)
@Relation 어노테이션에 새로운 부분이 보인다. associateBy = Junction() 부분인데, 바로 정션 테이블을 지정해주고 있다. parentColumn, entityColumn은 기존에 사용했던 방법과 동일한데, Junction을 추가되어 정션테이블의 처리를 Room에서 알아서 해주게 된다. SQLite에서의 쿼리문을 되돌아 보면, 3개의 테이블을 두번의 JOIN으로 꽤 복잡하게 처리했었다.
이제 Dao를 구현해보자.
@Dao
interface BookmarkDao {
@Insert
suspend fun insert(vararg bookmarks: Bookmark)
@Update
suspend fun update(vararg bookmarks: Bookmark)
@Delete
suspend fun delete(vararg bookmarks: Bookmark)
}
@Dao
interface TagDao{
@Insert
suspend fun insert(vararg tags: Tag)
@Update
suspend fun update(vararg tags: Tag)
@Delete
suspend fun delete(vararg tags: Tag)
}
@Dao
interface BookmarkTagJunctionDao{
@Insert
suspend fun insert(vararg bookmarkTag: BookmarkTag)
@Update
suspend fun update(vararg bookmarkTag: BookmarkTag)
@Delete
suspend fun delete(vararg bookmarkTag: BookmarkTag)
}
@Dao
interface BookmarkAndTagDao{
@Transaction
@Query("SELECT * FROM bookmark WHERE name = :bookmarkName")
suspend fun getBookmarkTags(bookmarkName: String): List<BookmarkWithTags>
@Transaction
@Query("SELECT * FROM tag WHERE tag_name = :tagName")
suspend fun getTagBookmarks(tagName: String): List<TagWithBookmarks>
}
Entity와 Relation들이 잘 정의되어 있으면, Dao구현은 그다지 어렵지 않다. BookmarkAndTagDao에서 특정 북마크에 딸린 태그들을 가져오는 getBookmarkTags()와 특정 태그의 북마크들을 가져오는 getTagBookmarks()를 만들었다.
마지막으로 database를 만들고 사용해보자.
@Database(entities = [Bookmark::class, Tag::class, BookmarkTag::class], version = 1)
abstract class BookmarkDatabase: RoomDatabase() {
abstract fun bookmarkDao(): BookmarkDao
abstract fun tagDao(): TagDao
abstract fun bookamrkTagJunctionDao(): BookmarkTagJunctionDao
abstract fun bookmarkAndTagDao(): BookmarkAndTagDao
companion object {
// For Singleton instantiation
@Volatile private var instance: BookmarkDatabase? = null
fun getInstance(context: Context): BookmarkDatabase {
return instance ?: synchronized(this) {
instance ?: buildDatabase(context).also { instance = it }
}
}
private fun buildDatabase(context: Context): BookmarkDatabase{
return Room.databaseBuilder(context, BookmarkDatabase::class.java, "Parking").build()
}
}
}
Database는 그저 만들어진 Dao interface들을 리턴해주는게 전부기 때문에 더 간단하다. Singleton 구현부분이 동일한데, 리턴하는 클래스이름만 수정하다보니, 수정없이 재사용 가능한 방법을 고민해볼만한데, 당장 쉽게 답이 떠오르진 않는다.
만들어진 database의 사용 예는 다음과 같다.
suspend fun bookmarkSample(){
val database = BookmarkDatabase.getInstance(getApplication())
val bookmarkDao = database.bookmarkDao()
val tagDao = database.tagDao()
val junctionDao = database.bookamrkTagJunctionDao()
val bookmarkAndTagDao = database.bookmarkAndTagDao()
withContext(Dispatchers.IO){
database.clearAllTables()
}
bookmarkDao.insert(
Bookmark(1, "android developer", "https://developer.android.com/"),
Bookmark(2, "unity", "https://unity.com"),
Bookmark(3, "kotlin", "https://kotlinlang.org/")
)
tagDao.insert(
Tag(1, "programming"),
Tag(2, "android"),
Tag(3, "unity"),
Tag(4, "game"),
Tag(5, "kotlin"),
Tag(6, "language")
)
junctionDao.insert(
BookmarkTag(1, 1),
BookmarkTag(1, 2),
BookmarkTag(2, 1),
BookmarkTag(2, 3),
BookmarkTag(2, 4),
BookmarkTag(3, 1),
BookmarkTag(3, 5),
BookmarkTag(3, 6)
)
val bookmarkTags = bookmarkAndTagDao.getBookmarkTags("kotlin")
val tagBookmarks = bookmarkAndTagDao.getTagBookmarks("programming")
Log.i("room test", bookmarkTags.toString())
Log.i("room test", tagBookmarks.toString())
}
위 샘플 코드의 결과는 다음과 같다.
[BookmarkWithTags(bookmark=Bookmark(bookmark_id=3, name=kotlin, url=https://kotlinlang.org/),
tags=[
Tag(tag_id=1, tag_name=programming),
Tag(tag_id=5, tag_name=kotlin),
Tag(tag_id=6, tag_name=language)])]
[TagWithBookmarks(tag=Tag(tag_id=1, tag_name=programming),
bookmarks=[
Bookmark(bookmark_id=1, name=android developer, url=https://developer.android.com/),
Bookmark(bookmark_id=2, name=unity, url=https://unity.com),
Bookmark(bookmark_id=3, name=kotlin, url=https://kotlinlang.org/)
])]
마무리이…
나로서도 처음 다루는걸 코드를 짜보고 테스트 해보며 작성하다보니 Room에 대해 모든걸 설명하진 못했지만, Relationship에 따른 구현을 필요한 만큼은 알아보았다. 다른 사람이 보기엔 어떨지 몰라도 일단 내가 다시 찾아볼 때 필요한 내용은 다룬듯. 복잡한 DB 구조를 사용하는게 아니라면, 일상적으로 사용하는데 문제없을 정도의 내용이었던거 같다. 바로 이전에 포스팅한 DB relationship with SQLite 와 비교해서 보면 이해하기 좋을것이다.