그러면 앱에 RemoteMediator를 적용해보도록 하겠습니다. 설명에 들어가기 전에 RemoteMediator는 현재 실험중인 API로 분류되고 있기 때문에 동작에 버그가 있을 수 있고, 또 이후의 버전업에 따라 사용법이 크게 변화할 수도 있다는 점을 미리 알려드립니다.
Paging 기초 강의에서 설명한 것처럼 RemoteMediator는 네트워크 데이터를 DB에 캐시하고, DB를 Single source of truth로 사용합니다. SSOT는 항상 정확하며 최신의 데이터가 포함된 소스이기 때문에 앱은 DB에 캐시된 데이터로부터만 PagingData를 얻어서 표시하게 됩니다.
그러면 우선은 RemoteMediator의 실제 동작구조에 대해 알아보겠습니다. DB에 데이터를 40개 로드한 상태에서 40-49번 데이터를 로드합니다. 그러면 DB에는 데이터가 없기 때문에 네트워크에서 새로운 50-59번 데이터를 로드하고 DB에 저장합니다. DB의 내용이 바뀌었으므로 DB 본체를 무효화하고 getRefreshKey로 현재 위치를 다시 확인합니다. 바뀐 DB의 값을 20-49번까지 읽어오면 nextKey 값을 50으로 설정한 뒤 대기합니다. 그러다가 50번 키 요청이 오면 50-59번까지의 아이템을 로드하는 형태로 작업이 수행됩니다.
그러면 RemoteMediator를 구현하기 위해 필요한 것들을 정리해 보겠습니다. 우선은 리모트에서 데이터를 받아올 API 인터페이스가 필요합니다. API 인터페이스는 Retrofit으로 이미 만들었습니다. DTO는 Book이라는 클래스로 만들었구요. 그리고 받아온 데이터를 저장할 로컬 DB가 필요하죠. 그리고 리모트 데이터와 로컬 데이터를 연결하는 키가 필요합니다. DB에 저장된 마지막 항목에는 해당 항목이 속한 리모트의 페이지번호를 알 수 있는 방법이 없기 때문에 각 항목에 대해 이전 페이지와 다음 페이지를 저장하는 키 테이블을 따로 만들어야 합니다.
우선은 model > RemoteKey라는 이름의 키 테이블을 만듭니다. Book 항목의 isbn을 저장하는 isbn를 고유키로 지정합니다. 그리고 항목의 이전 페이지와 다음 페이지를 저장하도록 합니다.
@Entity(tableName = "remote_keys")
data class RemoteKey(
@PrimaryKey(autoGenerate = false)
val isbn: String,
val prevKey: Int?,
val nextKey: Int?,
)
RemoteKey는 필요에 따라 저장, 삭제 및 isbn 호출을 해야 할 필요가 있으므로 RemoteKeysDao에서 필요한 메소드를 정의하여 줍니다.
@Dao
interface RemoteKeysDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun addAllRemoteKeys(remoteKey: List<RemoteKey>)
@Query("DELETE FROM remote_keys")
suspend fun deleteAllRemoteKeys()
@Query("SELECT * FROM remote_keys WHERE isbn = :isbn")
suspend fun getRemoteKey(isbn: String): RemoteKey?
}
다음은 Books 테이블에 대한 CRUD를 수행할 MediatorDao를 작성합니다. 전체 항목 저장 및 삭제, 그리고 PagingSource를 반환하는 메소드를 작성하면 됩니다.
@Dao
interface MediatorDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun addAllBooks(books: List<Book>)
@Query("DELETE FROM books")
suspend fun deleteAllBooks()
@Query("SELECT * FROM books")
fun getAllBooks(): PagingSource<Int, Book>
}
그리고 Books 테이블과 remote_keys 테이블을 관리할 MediatorDatabase를 작성합니다. 기본적인 구조는 BookSearchDatabase와 동일합니다.
@Database(
entities = [Book::class, RemoteKey::class],
version = 1,
exportSchema = false
)
@TypeConverters(OrmConverter::class)
abstract class MediatorDatabase : RoomDatabase() {
abstract fun mediatorDao(): MediatorDao
abstract fun remoteKeysDao(): RemoteKeysDao
companion object {
@Volatile
private var INSTANCE: MediatorDatabase? = null
private fun buildDatabase(context: Context): MediatorDatabase =
Room.databaseBuilder(
context.applicationContext,
MediatorDatabase::class.java,
"mediator-books"
).build()
fun getInstance(context: Context): MediatorDatabase =
INSTANCE ?: synchronized(this) {
INSTANCE ?: buildDatabase(context).also { INSTANCE = it }
}
}
}
자 그러면 RemoteMediator 클래스를 작성하면 됩니다. 우선은 load 이외의 함수부터 작성하겠습니다.
이 클래스에선 api와 db를 이용해야 하므로 작업에 필요한 생성자를 전달해 줍니다.
@ExperimentalPagingApi
class BookSearchRemoteMediator(
private val api: BookSearchApi,
private val query: String,
private val sort: String,
private val mediatorDatabase: MediatorDatabase,
) : RemoteMediator<Int, Book>() {
override suspend fun initialize(): InitializeAction {
return InitializeAction.LAUNCH_INITIAL_REFRESH
}
private suspend fun getRemoteKeyForLastItem(state: PagingState<Int, Book>): RemoteKey? {
return state.pages.lastOrNull { it.data.isNotEmpty() }?.data?.lastOrNull()
?.let { book ->
mediatorDatabase.remoteKeysDao().getRemoteKey(book.isbn)
}
}
private suspend fun getRemoteKeyForFirstItem(state: PagingState<Int, Book>): RemoteKey? {
return state.pages.firstOrNull { it.data.isNotEmpty() }?.data?.firstOrNull()
?.let { book ->
mediatorDatabase.remoteKeysDao().getRemoteKey(book.isbn)
}
}
private suspend fun getRemoteKeyClosestToCurrentPosition(state: PagingState<Int, Book>): RemoteKey? {
return state.anchorPosition?.let { position ->
state.closestItemToPosition(position)?.isbn?.let { isbn ->
mediatorDatabase.remoteKeysDao().getRemoteKey(isbn)
}
}
}
}
initialize 는 LAUNCH_INITIAL_REFRESH를 반환하도록 합니다. 이렇게 하면 RemoteMediator가 처음 실행될 때 DB의 값을 확인하지 않고 리모트 값을 가져오기 때문에 항상 최신값을 표시하게 할 수 있습니다.
그리고 getRemoteKeyForLastItem, getRemoteKeyForFirstItem, getRemoteKeyClosestToCurrentPosition 을 정의합니다. 각 메소드는 전달받은 PagingState의 속성을 이용해서 DB의 마지막, 처음 그리고 현재 포지션에서 가장 근접한 아이템의 RemoteKey를 반환합니다.
다음은 load 내부에서 리모트 API에 요청할 pageNumber를 정의합니다. 전달받은 loadType을 보고 각 상태에 따라 가져올 RemoteKey를 정의합니다.
LoadType.APPEND: 페이지 끝에 새로운 데이터를 로드해야 합니다. DB의 마지막 아이템을 보고 네트워크에서 받아와야 할 페이지를 계산합니다. 우선 getRemoteKeyForLastItem 메소드로 remoteKeys 값을 확인합니다. 값이 null일 경우 새로고침한 데이터가 아직 DB에 없다는 뜻입니다. 그 경우 endOfPaginationReached = false로 MediatorResult.Success를 반환해서 load를 다시 호출하도록 합니다. remoteKeys가 null이 아니지만 nextKey가 null이면 페이지 마지막에 도달했다는 것을 의미합니다.
LoadType.PREPEND: 페이지 처음에 새로운 데이터를 로드해야 합니다. APPEND와 동일한 방식으로 prevKey를 체크합니다.
LoadType.REFRESH: 데이터를 처음으로 로드하거나 PagingDataAdapter의 refresh가 호출될 때 호출됩니다. getRemoteKeyClosestToCurrentPosition 메소드로 remoteKeys 값을 확인합니다. 이 메소드는 PagingState의 anchorPosition을 확인하는데, Paging이 첫 번째 로드일 경우 anchorPosition은 null이므로 STARTING_PAGE_INDEX 페이지를 로드하면 됩니다. 그리고 리프레시일 경우의 anchorPosition은 디스플레이 된 목록에 보이는 첫번째 항목입니다. nextKey에서 1을 빼면 현재 페이지 번호가 되므로 그 값을 리턴하도록 합니다.
@ExperimentalPagingApi
class BookSearchRemoteMediator(
private val api: BookSearchApi,
private val query: String,
private val sort: String,
private val mediatorDatabase: MediatorDatabase,
) : RemoteMediator<Int, Book>() {
...
+ override suspend fun load(
+ loadType: LoadType,
+ state: PagingState<Int, Book>,
+ ): MediatorResult {
+ return try {
+ val pageNumber = when (loadType) {
+ LoadType.APPEND -> {
+ val remoteKeys = getRemoteKeyForLastItem(state)
+ val nextKey = remoteKeys?.nextKey
+ ?: return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
+ nextKey
+ }
+ LoadType.PREPEND -> {
+ val remoteKeys = getRemoteKeyForFirstItem(state)
+ val prevKey = remoteKeys?.prevKey
+ ?: return MediatorResult.Success(endOfPaginationReached = remoteKeys != null)
+ prevKey
+ }
+ LoadType.REFRESH -> {
+ val remoteKeys = getRemoteKeyClosestToCurrentPosition(state)
+ remoteKeys?.nextKey?.minus(1) ?: STARTING_PAGE_INDEX
+ }
+ }
+ companion object {
+ const val STARTING_PAGE_INDEX = 1
+ }
}
다음은 네트워크 작업을 정의합니다. 기본적으로는 BookSearchPagingSource에서 사용했던 구조와 유사한데, DB를 확인하고 RemoteKey를 참고하여 DB를 갱신하는 부분이 추가로 들어가야 합니다. 그리고 로드 작업 결과에 따라 MediatorResult를 반환하면 됩니다. 우선은 response, endOfPaginationReached, data, prevKey, nextKey를 순서대로 정의합니다.
다음은 withTransaction을 이용해서 리모트 데이터를 DB에 저장합니다. 만약 LoadType이 REFRESH이면 새 쿼리라는 것을 의미하므로 데이터베이스를 지우고 새로운 값을 저장합니다. 그렇지 않다면 기존 DB에 데이터를 추가하면 됩니다.
마지막으로 endOfPaginationReached 값을 MediatorResult.Success에 담아 반환하고 만약 데이터 요청 중 오류가 발생하면 MediatorResult.Error 를 반환하면 됩니다.
@ExperimentalPagingApi
class BookSearchRemoteMediator(
private val api: BookSearchApi,
private val query: String,
private val sort: String,
private val mediatorDatabase: MediatorDatabase,
) : RemoteMediator<Int, Book>() {
...
+ override suspend fun load(
+ loadType: LoadType,
+ state: PagingState<Int, Book>,
+ ): MediatorResult {
+ return try {
...
+ val response = api.searchBooks(query, sort, pageNumber, PAGING_SIZE)
+ val endOfPaginationReached = response.body()?.meta?.isEnd!!
+
+ val data = response.body()?.documents!!
+ val prevKey = if (pageNumber == STARTING_PAGE_INDEX) null else pageNumber - 1
+ val nextKey = if (endOfPaginationReached) null else pageNumber + 1
+
+ mediatorDatabase.withTransaction {
+ if (loadType == LoadType.REFRESH) {
+ mediatorDatabase.mediatorDao().deleteAllBooks()
+ mediatorDatabase.remoteKeysDao().deleteAllRemoteKeys()
+ }
+ val keys = data.map { book ->
+ RemoteKey(book.isbn, prevKey, nextKey)
+ }
+ mediatorDatabase.remoteKeysDao().addAllRemoteKeys(keys)
+ mediatorDatabase.mediatorDao().addAllBooks(data)
+ }
+ MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
+ } catch (exception: IOException) {
+ MediatorResult.Error(exception)
+ } catch (exception: HttpException) {
+ MediatorResult.Error(exception)
+ }
+ }
}
다음은 리포지토리에서 Pager가 BookSearchRemoteMediator의 결과를 PagingData로 변환하는 작업을 정의합니다. 기존의 searchBooksPaging에 remoteMediator도 초기값으로 전달하도록 합니다. PagingSource는 데이터베이스를 변경할 때마다 무효화되기 때문에 Pager가 새로운 PagingSource 인스턴스를 가져오는 방법을 알고 있어야 합니다. 그럴려면 pagingSourceFactory에 데이터베이스 쿼리를 호출하는 함수를 정의하면 됩니다.
강의 처음에도 말씀드렸지만 RemoteMediator는 현재 실험중인 API이므로 @ExperimentalPagingApi 어노테이션을 붙이지 않으면 동작하지 않습니다.
interface BookSearchRepository {
...
+ @ExperimentalPagingApi
fun searchBooksPaging(query: String, sort: String): Flow<PagingData<Book>>
}
class BookSearchRepositoryImpl(
private val db: BookSearchDatabase,
+ private val mediatorDb: MediatorDatabase,
private val dataStore: DataStore<Preferences>,
private val api: BookSearchApi,
) : BookSearchRepository {
...
+ @ExperimentalPagingApi
override fun searchBooksPaging(query: String, sort: String): Flow<PagingData<Book>> {
// val pagingSourceFactory = { BookSearchPagingSource(query, sort) }
- val pagingSourceFactory = { BookSearchPagingSource(api, query, sort) }
+ val pagingSourceFactory = { mediatorDb.mediatorDao().getAllBooks() }
return Pager(
config = PagingConfig(
pageSize = PAGING_SIZE,
enablePlaceholders = false,
maxSize = PAGING_SIZE * 3
),
+ remoteMediator = BookSearchRemoteMediator(api, query, sort, mediatorDb),
pagingSourceFactory = pagingSourceFactory
).flow
}
}
ViewModel과 Fragment에도 어노테이션을 붙여줍니다. 그리고 Activity에서 MediatorDatabase 인스턴스를 리포지토리에 전달해주면 사용준비가 끝납니다.
class BookSearchViewModel(
private val bookSearchRepository: BookSearchRepository,
private val workManager: WorkManager,
private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
...
+ @ExperimentalPagingApi
suspend fun searchBooksPaging(query: String) {
bookSearchRepository.searchBooksPaging(query, getSortMode())
.cachedIn(viewModelScope)
.collect {
_searchPagingResult.value = it
}
}
}
+ @ExperimentalPagingApi
class SearchFragment : Fragment() { }
+@ExperimentalPagingApi
fun <T> SearchFragment.collectLatestStateFlow(flow: Flow<T>, collect: suspend (T) -> Unit) { }
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
...
val database = BookSearchDatabase.getInstance(this)
+ val mediatorDatabase = MediatorDatabase.getInstance(this)
+ val bookSearchRepository = BookSearchRepositoryImpl(database, mediatorDatabase, dataStore)
}
}