[번역] 안드로이드 Room 7가지 유용한 팁

Harry The Great
해리의 유목코딩
14 min readDec 27, 2018

본 내용은 원작자의 허락을 맡아 번역한 내용이며 개인적인 커멘츠는 역주로 표기하였습니다.

Room은 SQLite의 추상레이어로 SQLite를 다루기 더 쉽고 편리하게 해줍니다. 혹시 Room을 처음 접하시는 분이라면 제가 이전에 쓴 글을 참고해보세요.

1. 데이터베이스 초기 데이터

데이터베이스가 생성되자마자 넣어야할 데이터가 있으신가요? 그렇다면 RoomDatabase#Callback을 사용하세요! 데이터베이스가 처음 onCreate상태일때 혹은 onOpen상태일때 addCallback메서드를 이용하여 오버라이딩하세요.

역주: DAO란 Data Access Objects의 약자로 질의(Query)를 통해 DB를 다루는 객체들이며 SQL의 DML의 추상화된 형태라고 생각하시면 쉽습니다.

onCreate은 데이터베이스가 맨 처음 만들어질때 한번만 실행이 되며 그 이후 Room은 데이터베이스 테이블을 만듭니다. onOpen은 데이터베이스가 열릴때 실행됩니다. DAO에 접근하기 위해선 데이터베이스가 만들어진 후 가능하므로 쓰레드를 새로만들어 데이터베이스가 만들어진 후 참조하여 DAO를 가져와 데이터를 넣습니다.

Room.databaseBuilder(context.applicationContext,
DataDatabase::class.java, "Sample.db")
// onCreate이 실행된 이후 데이터를 넣습니다.
.addCallback(object : Callback() {
override fun onCreate(db: SupportSQLiteDatabase) {
super.onCreate(db)
// 새 쓰레드를 만듭니다.
ioThread {
getInstance(context).dataDao()
.insert(PREPOPULATE_DATA)
}
}
})
.build()

자세한 내용은 이곳에서 확인하실 수 있습니다.

주의: ioThread를 이용해 데이터를 넣을때는 이미 데이터베이스가 만들어지고 Dao를 통해 데이터를 Insert를 하는 시점입니다. 이때 만약 ioThread를 사용하고 있을때 앱에 문제가 발생하여 Crash가 난다면 이때 데이터는 영원히 반영되지않습니다.

2. DAO들을 상속받아 사용하세요.

혹시 데이터베이스에서 여러개의 테이블을 사용하고있는데 계속해서 똑같은 Insert, Update, Delete를 복사해서 사용하고 계신가요? DAO들은 상속해서 사용할 수 있습니다. BaseDao<T>와 같은 클래스를 통해 공통으로 사용하는 Insert, Update, Delete메서드를 정의하세요. 그 후 DAO에서 BaseDAO를 상속받아 사용하면됩니다.

interface BaseDao<T> {
@Insert
fun insert(vararg obj: T)
}
@Dao
abstract class DataDao : BaseDao<Data>() {
@Query("SELECT * FROM Data")
abstract fun getData(): List<Data>
}

전체코드는 이곳에서 확인하실 수 있습니다.

컴파일 시점에서 Room에서 DAO 어노테이션을 찾아 코드를 구현해주기때문에 DAO들은 꼭 인터페이스 혹은 추상클래스여야합니다.

3. 트랜잭션에서 질의(Query)들을 실행할때 최소한의 코드만 사용하세요.

Transaction 어노테이션을 활용하여 메서드의 질의들이 하나의 트랜잭션 안에서 실행되도록 하세요. 트랜잭션은 메서드의 Body 내부에서 Exception이 발생하면 반영되지않습니다.

@Dao
abstract class UserDao {

@Transaction
open fun updateData(users: List<User>) {
deleteAllUsers()
insertAll(users)
}
@Insert
abstract fun insertAll(users: List<User>)
@Query("DELETE FROM Users")
abstract fun deleteAllUsers()
}

Transaction 어노테이션을 활용하여 Query 메서드를 실행하면 유용한 경우는 아래와 같습니다.

  • 결과가 매우 큰 경우입니다. 하나의 트랜잭션에서 질의함으로써 결과가 현재 담을 수 있는 커서보다 많더라도 커서윈도우 스왑간 변화에 영행을 주지 주지않습니다.
  • 결과값이 Relation으로 연결된 경우입니다.(Relation 어노테이션) 각 질의가 하나의 트랜잭션에서 실행된다면 데이터베이스의 일관성을 유시할 수 있습니다.

(Query가 아닌)Delete, Update, Insert 어노테이션들들의 파라미터를 통해 사용하는경우 Transaction처리를 자동으로 해줍니다.

4. 필요한것만 읽으세요.

데이터베이스에서 질의할때 모든 필드를 전부 리턴하시나요? 그렇다면 앱의 메모리를 신경써야할때입니다. 전체 필드가 아닌 필요한 필드들만 로드하는것은 IO비용을 줄임으로써 메모리 사용을 줄여줄뿐만 아니라 쿼리의 속도도 줄여줍니다. 서로 다른 Object를 쓰더라도 Room은 자동으로 칼럼들을 맵핑시켜줍니다.

많은 칼럼이 들어간 User 오브젝트를 생각해보겠습니다.

@Entity(tableName = "users")
data class User(@PrimaryKey
val id: String,
val userName: String,
val firstName: String,
val lastName: String,
val email: String,
val dateOfBirth: Date,
val registrationDate: Date)

일부 화면에서는 모든 데이터가 아닌 몇몇 칼럼들만 필요한 경우가 있을 수 있습니다. 이럴때 User 오브젝트를 이용하는것이 아닌 UserMinimal과 같은 오브젝트를 만드세요.

data class UserMinimal(val userId: String,
val firstName: String,
val lastName: String)

이제 DAO를 통해서 맵핑된 데이터를 받을 수 있습니다.

@Dao
interface UserDao {
@Query(“SELECT userId, firstName, lastName FROM Users)
fun getUsersMinimal(): List<UserMinimal>
}

5. 외래키 제약을 꼭 사용하세요.

비록 Room이 테이블간 관계를 실제 지원하지는 않지만 (논리적으로) 외래키간 제약을 줄수는 있습니다.

Room에선 ForeignKey 어노테이션을 사용할 수 있고 Entity어노테이션에서도 사용 가능합니다. 이를 통해 SQLite Foreign Key와 같은 기능을 사용할 수 있게됩니다. 이를 통해 테이블간 관계 그리고 일관성을 지킬 수 있습니다. 엔티티에서 부모엔티티를 참조하거나 칼럼에서 정의하세요.

주인(User)와 애완동물(Pet)클래스가 있다고 가정해보겠습니다. 애완동물은 주인이 있고 주인(User)의 id를 외래키로 참조합니다.

@Entity(tableName = "pets",
foreignKeys = arrayOf(
ForeignKey(entity = User::class,
parentColumns = arrayOf("userId"),
childColumns = arrayOf("owner"))))
data class Pet(@PrimaryKey val petId: String,
val name: String,
val owner: String)

추가적으로 부모엔티티가 삭제되거나 업데이트 될때의 무결성 제약조건을 줄 수 있습니다. 예를들면 NO_ACTION, RESTRICT, SET_NULL, SET_DEFAULT ,CASCADE입니다. SQLite에서 사용하던것과 동일하게 작동합니다.

주의: Room에선, columns에서의 Default Value를 설정을 지원하지 않기떄문에 SET_DEFAULT가 SET_NULL과 같이 작동합니다.

6. 1:M관계를 @Relation 을 통해 간결화하세요.

앞서 언급한 주인(User)-애완동물(Pet)의 경우 우리는 1:M관계를 가지고있다고 말할 수 있습니다.(주인은 여러개의 애완동물을 가지고있습니다.) 그렇다면 유저리스트를 가져올때 애완동물도 함께 가져오는 List<UserAndAllPets>같은 형태를 만들어보겠습니다.

data class UserAndAllPets (val user: User,
val pets: List<Pet> = ArrayList())

만약 원래대로라면 우리는 2개의 질의를 구현해야합니다. 하나는 모든 주인리스트를 가져오는 질의그리고 다른 하나는 주인의 ID에 맞게 애완동물을 가져오는 코드 입니다.

@Query(“SELECT * FROM Users”)
public List<User> getUsers();
@Query(“SELECT * FROM Pets where owner = :userId”)
public List<Pet> getPetsForUser(String userId);

위와 같은 코드를 사용한다면 반복문을 통해 모든 주인과관계된 애완동물 리스트를 질의해야합니다. Relation 어노테이션은 List나 Set에서만 사용할 수 있습니다. 그럼 UserAndAllPets를 아래와같이 변경하겠습니다.

class UserAndAllPets {   @Embedded
var user: User? = null
@Relation(parentColumn = “userId”,
entityColumn = “owner”)
var pets: List<Pet> = ArrayList()
}

위와같은 Rleation정의는 다오 내에서 하나의 질의를 통해 Room이 주인(Users)과 애완동물(Pet) 테이블을 자동으로 맵핑시켜줍니다.

@Transaction
@Query(“SELECT * FROM Users”)
List<UserAndAllPets> getUsers();

7. False Positive(오탐)오류를 피하세요.

user의 id를 통해 검색하는 Observable 질의를 보겠습니다.

@Query(“SELECT * FROM Users WHERE userId = :id)
fun getUserById(id: String): LiveData<User>
// or@Query(“SELECT * FROM Users WHERE userId = :id)
fun getUserById(id: String): Flowable<User>

위와같은 코드는 User에 데이터가 변경될때 새로운 변경값을 가져오지만 Insert, Select, Delete와 같은 Query에도 새로운 변경값을 가져오며 False Positive Notification(역주: 리턴값은 받지만 현재 데이터와 같기때문에 데이터는 변경하지 않는 호출)을 만듭니다. 게다가 여러분의 질의가 다른 테이블도 참조한다면 다른테이블도 계속해서 새로운 리턴값을 만들어냅니다.

이 과정을 요약하면 아래와 같습니다.

  1. SQLite에 DELETE, UPDATE, INSERT문들이 질의됩니다.
  2. Room은 InvalidateTracker요청을 만들고 user의 옵저버는 데이터가 변경이 되었던 아니던 데이터가 변경되었다고 테이블에 알립니다.
  3. LiveData와 Floable 질의는 InvalidationTracker.Observer#onInvalidated호출이 되면 다시 질의하며 데이터를 가져옵니다.

Room은 오직 데이터의 변경만 받고 데이터가 바뀌었는지는 알 수 없습니다. 그러므로 다시 쿼리한 후 LiveData와 Floable에 의해 데이터를 다시 가져옵니다. 룸에는 데이터를 직접 메모리에 가지고있거나 equals()와 같은 호출을 하지 않기때문에 데이터가 변경이 되었는지 아닌지를 알 수 없습니다.

이럴때 우리는 Dao Filter Emission이 필요하고 오직 특정한 상황에서만 데이터를 다시 리턴합니다.

만약 Flowable로 구현했다면 Flowable#distinctUntilChanged를 이용하세요.

@Daoabstract class UserDao : BaseDao<User>() {/**
* Get a user by id.
* @return the user from the table with a specific id.
*/
@Query(“SELECT * FROM Users WHERE userid = :id”)
protected abstract fun getUserById(id: String): Flowable<User>
fun getDistinctUserById(id: String):
Flowable<User> = getUserById(id)
.distinctUntilChanged()
}

만약 LiveData로 구현했다면 MediatorLiveData를 사용하여 아래와 같이 리턴 데이터의 반영을 허락하세요.

fun <T> LiveData<T>.getDistinct(): LiveData<T> {
val distinctLiveData = MediatorLiveData<T>()
distinctLiveData.addSource(this, object : Observer<T> {
private var initialized = false
private var lastObj: T? = null
override fun onChanged(obj: T?) {
if (!initialized) {
initialized = true
lastObj = obj
distinctLiveData.postValue(lastObj)
} else if ((obj == null && lastObj != null)
|| obj != lastObj) {
lastObj = obj
distinctLiveData.postValue(lastObj)
}
}
})
return distinctLiveData
}

DAO들에서 LiveData가 리턴되는지 public으로 메서드를 선언했는지 그리고 Query는 Protectd로 선언하세요.

@Dao
abstract class UserDao : BaseDao<User>() {
@Query(“SELECT * FROM Users WHERE userid = :id”)
protected abstract fun getUserById(id: String): LiveData<User>
fun getDistinctUserById(id: String):
LiveData<User> = getUserById(id).getDistinct()
}

전체코드는 이곳에서 보실 수 있습니다.

주의: 만약 데이터 리스트를 리턴받아 보여주고있다면 Paging Library의 LivePagedListBuilder를 사용해보세요. 이 라이브러리는 Diff를 통해서 자동으로 변경된 UI만을 업데이트하여줍니다.

더 많은 내용이 알고싶으신가요? 이전 글들을 참고해보세요.

--

--

Harry The Great
해리의 유목코딩

Android & IOS Developer 😀 미디움 이외에 스니펫이나 디버그노트로 활용하는 https://www.harrymikoshi.com/ 블로그도 운영하고있습니다.