검색 앱을 개발할 때 온라인에서 검색 시 서버 데이터를 가져와 보여주고, 오프라인에서 검색하게 되면 캐싱된 데이터가 보여지도록 구현을 하려고 했습니다. 그러기 위해서는 온라인 검색 시 가져온 데이터를 로컬에 저장하는 캐싱 작업이 필요했고 온라인, 오프라인에서 데이터를 가져올 때 모든 데이터를 페이징 처리를 해야했습니다. 즉, 페이징 + 캐싱을 결합한 플로우를 설계했고 그 과정을 담은 글입니다.
먼저 처음에 설계했던 방식을 설명드리겠습니다.
다이어그램을 보시면 ViewModel은 Local의 데이터만 받고 Network 데이터는 Local에 동기화를 시키는 용도로 사용하였습니다. 로컬에 데이터가 없을 때 네트워크에서 요청한 데이터를 로컬에 업데이트 후 로컬 데이터를 다시 사용자에게 전달해주는 방식이었습니다. 하지만, 서버에서 가져온 데이터를 바로 넘겨주는 것이 아닌 로컬에 저장 후 다시 데이터를 가져오는 방식은 비효율적이라 판단했습니다.
그래서, 로컬 데이터가 있는 경우 로컬 데이터를 넘겨주고, 없는 경우는 네트워크 데이터를 넘겨주도록 설계를 수정했습니다.
만약 페이징 시 10개의 데이터를 요청하는데 3개의 로컬 데이터만 존재한다면 나머지 7개의 서버 데이터를 결합해 전달하게 됩니다.
처음에는 Paging3 라이브러리를 사용하여 이와 같은 구조를 구현하려 했습니다. 하지만, PagingAdapter로 받는 PagingData는 하나의 페이징 소스로부터 전달받을 수 있는 구조였고 repository에서 동일한 데이터를 로컬과 network에 요청하는 로직을 구현하기에는 커스텀이 필요했습니다.
물론 Paging3에서도 캐싱 관련된 작업을 수행하는 RemoteMediator라는 기능도 제공해줍니다. 제가 처음에 설계한 방식과 동일하게 로컬에 데이터가 있는 경우 로컬 데이터를 전달하고 페이지가 넘어가서 더 이상 로컬 데이터가 존재하지 않는 경우에는 서버에 데이터를 요청해 로컬에 저장하고 UI로 전달하는 방식입니다. 이 RemoteMediator를 사용하면 제가 설계한 방식과 동일하게 구현할 수 있을 줄 알았으나, 한 가지 문제가 존재했습니다.
제가 설계한 방식은 로컬에서 데이터를 요청하여 전달하더라도 서버에 데이터를 요청하여 동기화 작업을 진행합니다. 즉, 로컬 데이터는 서버 데이터와 100% 일치하도록 동기화하며 로컬은 서버의 복사본의 개념입니다. 하지만, RemoteMediator 방식은 제가 설계한 방식과는 다르게 이미 캐싱된 데이터에 있어 동기화 작업을 하지 않고, 동기화를 하기 위해서는 커스텀이 필요했으며, 이 과정이 까다로웠고 더 큰 비용이 든다고 판단했습니다. 그래서, RemoteMediator를 사용하지 않기로 결정했습니다.
페이징, 캐싱 기능이 동작하는 영상을 보도록 하겠습니다. 먼저, 통합검색에서 검색 시 각 카테고리 별 데이터를 3개씩 요청합니다. 네트워크에서 가져온 데이터 사이즈가 3인 것을 확인할 수 있습니다. 다음으로 블로그 탭을 누르게 되면 기존에 저장된 3개의 데이터를 가져오고 나머지는 네트워크 데이터 7개를 가져오는 것을 볼 수 있습니다. 이후로 페이징을 하게 되면 저장된 데이터가 3개밖에 없기 때문에 네트워크에서 데이터를 가져오는 것을 확인할 수 있습니다. 그리고 다시 검색을 하게 되면 이제는 로컬에 저장된 데이터가 있기 때문에 캐시 데이터를 가져오게 되고 더이상 없는 경우 다시 네트워크 데이터를 가져오게 됩니다.
이러한 동작들을 효율적으로 수행하기 위해서는 비동기 작업 필요합니다. 먼저, 로컬에 데이터 10개를 요청합니다. 10개의 데이터가 모두 로컬에 존재하는 경우와 로컬에 데이터가 부족해 네트워크 데이터를 가져와야 하는 경우 두 가지로 나누어 보겠습니다.
먼저 간단한 시퀀스 다이어그램으로 10개의 데이터가 모두 로컬에 존재하는 경우를 보겠습니다. 데이터를 요청하고 로컬 데이터 10개를 수신하였습니다.
그리고 가져온 데이터를 UI에 그대로 전달하는 작업과 서버에서 데이터를 요청하고 로컬에 동기화하는 작업을 동시에 수행합니다.
다음은 로컬에 데이터가 부족한 경우를 보겠습니다. 똑같이 데이터를 10개 요청하였으나 3개만 전달받았습니다.
이 경우 서버에 데이터를 요청하고 10개를 전달 받습니다.
그리고 전달받은 서버 데이터를 동기화 하는 작업과 로컬과 서버 데이터를 결합하여 UI 에 전달하는 작업을 동시에 수행하게 됩니다. 이와 같이 로컬에 동기화하는 작업과 UI에 데이터를 보여주는 작업을 비동기로 처리하여 효율적으로 작업들을 처리 할 수 있었습니다.
생각보다 간단하게 구현할 수 있었지만 설계하는데 시간이 많이 소요됐습니다. 페이징과 캐싱을 직접 구현해보니 RemoteMediator의 동작 원리에 대해서도 어느정도 이해할 수 있었습니다.
class PostRepositoryImpl @Inject constructor(
private val postLocalDataSource: PostLocalDataSource,
private val postRemoteDataSource: PostRemoteDataSource
) : PostRepository
Data 계층에서 repository 구현체에는 Local과 Remote에 접근하는 DataSource가 분리되어 있습니다.
val result = if (isOffline) {
getLocalPosts // postLocalDataSource.getBlogList(query, start, display)
} else {
getLocalOrRemotePosts(getLocalPosts, getRemotePosts, savePosts, display)
}
만약 오프라인 상태일 경우 로컬DB에 있는 데이터를 가져오고 온라인일 경우에는 로컬 또는 원격 서버에서 데이터를 가져오게 됩니다.
로컬 또는 원격 서버에서 데이터를 가져오는 함수입니다.
val requiredDataSize = display - cachedData.size
if (requiredDataSize == 0) {
Completable.fromCallable {
val networkData = getRemotePosts(display)
.onErrorReturn { emptyList() }
.blockingGet()
if (networkData.isNotEmpty()) {
savePosts(networkData)
}
}.subscribeOn(Schedulers.io()).subscribe()
Single.just(cachedData)
}
먼저 10개 데이터를 요청했을 때 10개 데이터가 캐싱되어있는 경우에는 캐싱 데이터 10개를 그대로 반환하게 됩니다. 그와 동시에 원격 데이터를 가져와 로컬DB에 업데이트 하게됩니다. 그러면, 다음 캐싱 데이터를 불러올 때 업데이트 된 데이터를 가져오게 됩니다.
else {
val networkData = getRemotePosts(display).flatMap { networkData ->
val data = if (networkData.isNotEmpty()) {
savePosts(networkData)
networkData.subList(cachedData.size, display)
} else {
networkData
}
Single.just(data)
}.onErrorReturn {
emptyList()
}.blockingGet()
val result = mutableListOf<PostData>().apply {
addAll(cachedData)
addAll(networkData)
}
Single.just(result)
}
10개의 데이터를 요청했는데 10개보다 적은 데이터가 캐싱되어 있는 경우에는 원격 서버에 10개 데이터를 모두 요청해 가져온 뒤 로컬 DB에 저장하고 캐싱 데이터와 원격 데이터를 합쳐 UI로 반환하게 됩니다.
전체코드
class PostRepositoryImpl @Inject constructor(
private val postLocalDataSource: PostLocalDataSource,
private val postRemoteDataSource: PostRemoteDataSource
) : PostRepository {
override fun getBlogPosts(query: String, start: Int, display: Int, isOffline: Boolean): Single<List<Post.Blog>> {
val getLocalPosts = postLocalDataSource.getBlogList(query, start, display)
val getRemotePosts = { requiredDataSize: Int ->
postRemoteDataSource.getBlogPosts(query, start, requiredDataSize)
}
val savePosts = { networkData: List<PostData> ->
postLocalDataSource.saveBlogs(networkData, start, query)
}
val result = if (isOffline) {
getLocalPosts
} else {
getLocalOrRemotePosts(getLocalPosts, getRemotePosts, savePosts, display)
}
return result.map { posts ->
posts.map { post ->
(post as PostData.BlogData).toDomain()
}
}
}
private fun getLocalOrRemotePosts(
getLocalPosts: Single<List<PostData>>,
getRemotePosts: (Int) -> Single<List<PostData>>,
savePosts: (List<PostData>) -> Unit,
display: Int,
): Single<List<PostData>> {
return getLocalPosts.flatMap { cachedData ->
val requiredDataSize = display - cachedData.size
if (requiredDataSize == 0) {
Completable.fromCallable {
val networkData = getRemotePosts(display)
.onErrorReturn { emptyList() }
.blockingGet()
if (networkData.isNotEmpty()) {
savePosts(networkData)
}
}.subscribeOn(Schedulers.io()).subscribe()
// Log.d("getLocalOrRemotePosts", "cached data size: ${cachedData.size} / network data size: 0")
Single.just(cachedData)
} else {
val networkData = getRemotePosts(display).flatMap { networkData ->
val data = if (networkData.isNotEmpty()) {
savePosts(networkData)
networkData.subList(cachedData.size, display)
} else {
networkData
}
Single.just(data)
}.onErrorReturn {
emptyList()
}.blockingGet()
val result = mutableListOf<PostData>().apply {
addAll(cachedData)
addAll(networkData)
}
// Log.d("getLocalOrRemotePosts", "cached data size: ${cachedData.size} / network data size: ${networkData.size}")
Single.just(result)
}
}
}
}