본문으로 바로가기

Android Room & Coroutines

category 개발이야기/Android 2019. 11. 25. 15:21
반응형

해당 내용은 하기 링크를 번역한 내용입니다.

https://medium.com/androiddevelopers/room-coroutines-422b786dc4c5

물론 추가적인 의역 및 설명이 추가되어 있습니다.

Room 2.1에서 부터 Kotlin coroutine을 지원하기 시작했습니다. 2.2 버전부터는 return값으로 flow도 제공하지요~

Room에서 coroutine을 지원하면서 DAO에서 suspend function을 만들어 선언해 놓으면 해당 function들은 main thread에서 동작하지 않습니다.

따라서 DB 접근할때 따로 background thread를 만들어서 접근할 필요가 없어졌습니다.

아래에서는 이를 사용하는 방법과 내부적으로 어떻게 동작하는지와 test는 어떻게 진행해야하는지에 대해 설명합니다.


Add some suspense to your database

원문에서는 아래와 같은 import를 해야 한다고 적혀있습니다.
implementation "androidx.room:room-coroutiens:${versions.room}"
하지만 직접 해보니 gradle sync에서 error가 나더군요.
따라서 하기와 같이 room 관련 import 정도만 추가하면 될것 같습니다.
implementation "androidx.room:room-ktx:$room_version"


추가적으로 kotlin은 1.3.0 이상, coroutine은 1.0.0 이상이 필요합니다.

현재 버전으로 kotlin은 1.3.6, 코루틴은 1.3.2가 최신입니다 (2019.11.25 기준)


Dao의 경우 아래 예제처럼 suspend function을 정의하면 됩니다.

기본 function에서 단순히 suspend만 붙임으로써 cortouine을 이용하여 background에서 동작하는 함수를 생성할 수 있습니다.

@Dao
interface UsersDao {

    @Query("SELECT * FROM users")
    suspend fun getUsers(): List<User>

    @Query("UPDATE users SET age = age + 1 WHERE userId = :userId")
    suspend fun incrementUserAge(userId: String)

    @Insert
    suspend fun insertUser(user: User)

    @Update
    suspend fun updateUser(user: User)

    @Delete
    suspend fun deleteUser(user: User)

}


@Transaction역시 suspend 함수로 만들 수 있으며, 다른 suspend function에서 호출될 수 있습니다.

@Dao
abstract class UsersDao {
    
    @Transaction
    open suspend fun setLoggedInUser(loggedInUser: User) {
        deleteUser(loggedInUser)
        insertUser(loggedInUser)
    }

    @Query("DELETE FROM users")
    abstract fun deleteUser(user: User)

    @Insert
    abstract suspend fun insertUser(user: User)
}

또한 다른 DAO에서 transaction을 만들어 suspension 함수를 호출 할 수도 있습니다.

class Repository(val database: MyDatabase) {

    suspend fun clearData(){
        database.withTransaction {
            database.userDao().deleteLoggedInUser() // suspend function
            database.commentsDao().deleteComments() // suspend function
        }
    }    
}


여기서 사용된 withTransaction은 suspend 함수 입니다.

따라서 이 extension function을 사용하려면 coroutineScope 내부에서 호출되어야 합니다.

내부 구현 사항을 따라가 보면 Transaction을 열고 해당 block의 함수를 수행후 Transaction을 닫도록 되어 있습니다.


또한 setTransctionExecutor 또는 setQueryExecutor를 사용하여, room database를 build 할 때 room의 쿼리를 동작시킬 executor를 지정해 줄수도 있습니다.

상세한 내용은 아래 링크에서 확인 가능합니다.

https://developer.android.com/reference/androidx/room/RoomDatabase.Builder#setTransactionExecutor(java.util.concurrent.Executor)


하지만..링크를 타고 가기 구찮으니 링크의 내용을 간단히 정리해 보면!!
  • non-blocking asynchronus transction query나 일반 query를 실행할 executor를 따로 세팅 할수 있다!
  • 이렇게 설정한 executor는 LiveData의 유효성 검사, Flowable scheduling, ListenableFuture task에도 쓰인다.
  • setTransctionExecutor setQueryExecutor 둘다 세팅하지 않은경우 AAC lib에서 제공하는 thread를 공휴해서 쓴다.
  • setTransctionExecutor를 설정하지 않았지만 setQueryExecutor을 설정하면 transaction에서도 설정된 executor를 사용한다.


Testing DAO suspension functions

테스트 하는 방법은 기존 suspending function과 다르지 않습니다.
@Test fun insertAndGetUser() = runBlocking {
    // Given a User that has been inserted into the DB
    userDao.insertUser(user)

    // When getting the Users via the DAO
    val usersFromDb = userDao.getUsers()

    // Then the retrieved Users matches the original user object
    assertEquals(listOf(user), userFromDb)
}

runBlocking으로 coroutine scope을 만들어서 테스트하면 됩니다.


Under the hood

Room 사용자에게 간단한 annotation으로 DB에 접근할수 있는 방법을 제공합니다.
방법이라고는 하나 사실 complie시점에 실제 DB에 접근하기 위한 코드를 대신 생성해 줍니다.
내부적으로 Room에서 일반적인 function과 suspend function을 코드로 구현하는지 확인해 보겠습니다.
@Insert
fun insertUserSync(user: User)

@Insert
suspend fun insertUser(user: User)
위와 같이 두개의 함수를 만듭니다.
똑같지만 하나는 일반 함수고 하나는 suspend 함수 입니다.

@Override
public void insertUserSync(final User user) {
  __db.beginTransaction();
  try {
    __insertionAdapterOfUser.insert(user);
    __db.setTransactionSuccessful();
  } finally {
    __db.endTransaction();
  }
}


일반적인 함수 (synchronous 하게 동작하는)는 transaction을 열고 해당 구문을 실행후 transaction을 닫습니다.

해당 함수는 해당 함수를 호출한 thread에서 수행합니다.


@Override
public Object insertUserSuspend(final User user,
    final Continuation<? super Unit> p1) {
  return CoroutinesRoom.execute(__db, new Callable<Unit>() {
    @Override
    public Unit call() throws Exception {
      __db.beginTransaction();
      try {
        __insertionAdapterOfUser.insert(user);
        __db.setTransactionSuccessful();
        return kotlin.Unit.INSTANCE;
      } finally {
        __db.endTransaction();
      }
    }
  }, p1);
}


위에 suspend function의 구현부분을 보면 해당 insert 구문이 UI thread에서 수행되지 않다는걸 확인할 수 있습니다.

일반 함수 (synchronous 함수)의 내용이 Callable로 wrapping되어서 생성되며 이 Callable은 CoroutinesRoom.execute 라는 suspend function에 의해 수행됩니다.

@JvmStatic
suspend fun <R> execute(
   db: RoomDatabase,
   inTransaction: Boolean,
   callable: Callable<R>
): R {
   if (db.isOpen && db.inTransaction()) {
       return callable.call()
   }

   // Use the transaction dispatcher if we are on a transaction coroutine, otherwise
   // use the database dispatchers.
   val context = coroutineContext[TransactionElement]?.transactionDispatcher
       ?: if (inTransaction) db.transactionDispatcher else db.queryDispatcher
   return withContext(context) {
       callable.call()
   }
}

Case 1. The database is opened and we are in a transaction

DB가 open된 상태이고 이미 transaction 상태이 있다면 callable의 call() 함수를 그냥 수행시킵니다.

그럼 내부에 선언된 insert문이 호출되겠죠?


Case 2. Otherwise

room은 Callable의 call()함수의 내용이 background thread에서의 실행을 보장합니다.

이때 Room은 transactions 작업과 query 작업에 다른 dispatcher를 사용합니다.

이 dispatcher는 room을 빌드할때 설정 할 수 있으며, 만약 따로 설정하지 않았다면 Architecture Component의 IO executor를 사용합니다.

(이건 LiveData의 background 작업을 처리하는 executor와 동일한 executor를 사용합니다.)


결론적으로 Room에서 suspend function을 사용하면 database의 작업이 non-UI Dispatcher에 동작함을 보장합니다.

따라서 DAO를 만들때 suspend를 붙여서 다른 suspend function이나 coroutine에서 호출해서 사용할 수 있습니다.


반응형