Android Jetpack 에는 Paging3 라는 대규모 데이터에 대한 페이징 처리를 도와주는 라이브러리가 존재한다.
이 라이브러리를 사용하는 것은 보통 서버에 존재하는 대규모 데이터를 한 번에 내려받기 어려운 상황.
예를 들어, 유튜브에 접속하였으나 서버에는 영상 데이터가 10만개가 존재한다. 그렇다면 이 모든 데이터를 사용자에게 보여주려고 한다면? 네트워크 과부하가 생기게 되고 이로 인해 앱 크래시가 발생할 것이다.
그렇기에 페이징 방식을 활용해서 서버측으로부터 데이터를 나눠서 내려받고, 또한 많은 양의 데이터를 받았다고 했을때도 유저에게 한 번에 보여주지 않고 페이지를 나눠 보여준다면 UI 렌더링 비용을 획기적으로 낮출 수 있을 것이다. (속도도 빨라보이고)
어쨋든 이 Paging3 라이브러리는 XML, Jetpack Compose 에서 사용되는데 Jetpack Compose 에서는 이 라이브러리를 사용할 때 LazyColumn 에 LazyPaingItems<T> 데이터를 붙여 사용한다.
Paging3 라이브러리를 사용하고 싶다면 아래 의존성을 추가해주면 된다.
Paging-Compose 는 collectAsLazyPagingItems 를 통해 PagingData 를 Jetpack-Compose 에서 사용할 수 있게 변환해주는 함수를 포함한 라이브러리이다.
paging-common-ktx 의 경우 내부에 안드로이드 의존성이 없으므로 Domain Module 에서도 사용이 가능하다.
Domain, Data Module 에서는 paging-common-ktx 를 추가해주도록 하자
paging = "3.3.5"
paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "paging" }
paging-common-ktx = { group = "androidx.paging", name = "paging-common-ktx", version.ref = "paging" }

이러한 Paging3 는 Repository 에서 PagingSource 혹은 내부 DB를 사용하는 경우 RemoteMediator 를 통해 Pager<Key, Value> 로 변환되며 이를 다시 내부 메소드로 구현되어 있는 flow 함수를 통해 Mapping 과정을 거쳐 Flow<PagingData> 형으로 변한다.
그리고 이후 이를 다시 UI 단으로 전달하여 다시 한 번 Mapping 하게 되는데
를 통해 UI 에서 사용할 수 있는 데이터로 변환이 된다.
Paging3 의 Pager<Key, Value> 클래스를 확인해보자

PagingData 를 꺼내쓰기 위해서는 Pager() 를 구현해야하는데, Pager 는 위와 같은 생성자를 가지고 있다.
PagingConfig 의 경우 페이징 처리할 설정값들로 다음과 같다.
PageSize: 1페이지당 가져올 데이터 사이즈PrefetchDistance : 가져온 데이터 갯수 - PrefetchDistance 까지 읽는 경우 APPEND 호출initialLoadSize: 초기 로드 시 가져올 데이터 사이즈 (PageSize 가 10이어도 이게 30이면 초기에는 30개를 가져옴)
여기서 더욱 주의 깊게 봐야하는 것은 remoteMediator 와 pagingSourceFactory 인데 이들의 사용방법은 다음과 같다.
로컬 DB를 경우하지 않고 데이터를 바로 보여줘야하는 때에 활용한다.
PagingSource Class 를 구현하여 Return 하면 된다.
아래는 직접 구현한 커스텀 클래스이다.
class DevGyuPagingSource(
private val request: suspend (nextPage: Int) -> List<Name>
): PagingSource<Int, Name>(){
override fun getRefreshKey(state: PagingState<Int, Name>): Int? {
return state.anchorPosition?.let { anchorPosition ->
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
}
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Name> {
return run {
val pageIndex = params.key ?: 0
val data = request(pageIndex + 1)
val nextKey = if (data.isEmpty()) null else pageIndex + 1
LoadResult.Page(
data = data,
prevKey = if (pageIndex == 0) null else pageIndex - 1,
nextKey = nextKey
)
}
}
}
getRefresh() 함수는 사용자가 현재 보고 있는 anchorPosition 을 기준으로 새로고침 시 가장 가까운 페이지부터 데이터를 불러오고, load() 에서는 params.key (현재 페이지) 를 기준으로 다음 페이지 Index, 이전 Page Index 를 설정하며 request Lambda 를 통해 데이터를 가져와 이를 return 해준다.
Repository 에서의 호출은 다음과 같다
// Repository 가정 및 dao 의존성 주입 가정
class Repository(val dao: DevGyuDao){
@OptIn(ExperimentalPagingApi::class)
fun getPagingData(): Flow<PagingData<Name>>{
return Pager(
config = PagingConfig(
10,
prefetchDistance = 1,
initialLoadSize = 10
),
// API 호출 가정
pagingSourceFactory = { DevGyuPagingSource(request = { listOf(Name(1, ""))}) }
).flow
}
}
이 경우 우리는 RemoteMediator 를 구현해주고, Room 에서 데이터를 PagingSource 형식으로 꺼내써야한다.
이 방법을 사용하기 위해서는 다음과 같이 따라하면 된다.
우선 Jetpack Room 에서 제공하는 Paging3 와 연동된 라이브러리가 존재한다.
다음 라이브러리를 추가해주자
# 최신 room 버전
room-paging = { group = "androidx.room", name = "room-paging", version.ref = "room" }
이를 적용한 뒤 Room Data Base 의 Dao 에 다음과 같이 쿼리를 추가해준다.

Query 문으로 자신이 꺼내고 싶은 Entity 를 설정해준 뒤 Return 값으로 PagingSource<Int, Entity> 를 설정해준다.
이제 기본 설정은 모두 끝났다.
다만 현재 우리는 Room 에서 PagingSource 를 꺼내는 작업만 설정해줬을 뿐, Paging 상황에 맞춰 Room 에 데이터를 넣어주는 작업은 추가하지 않았다.

우선 RemoteMediator 를 추가해주기 위해 위처럼 RemoteMediator<Key, Value> 를 상속받는 커스텀 클래스를 구현해주자 !
Key 는 페이지, Value 는 내가 꺼내 쓸 데이터를 넣어주면 된다.
이곳에서 initialize 는 초기 로드가 시작되기 전에 호출되는데, 원격에서 데이터를 불러오기를 원한다면 LAUNCH_INITIAL_REFRESH (현재처럼 super.initialize) 를 아니라면 InitializeAction.SKIP_INITIAL_REFRESH 로 설정해준다.
우리가 깊게 봐야하는 것은 load 함수인데, 이곳에서는 유저 이벤트에 의해 PagingData 가 Refresh, Retry 되거나 다음, 이전 페이지 데이터를 필요로 할 때 호출된다.
state 를 통해 현재 페이지, 구성, 포지션을 볼 수 있으며 loadType 을 통해 현재 load() 함수가 실행되고 있을때의 이벤트 타입을 알아낼 수 있다.
@OptIn(ExperimentalPagingApi::class)
class DevGyuRemoteMediator(
val request: (index: Int?) -> DevGyuResponse<List<NameEntity>>,
val refresh: () -> Unit
): RemoteMediator<Int, NameEntity>() {
private var nextPage: Int? = null
override suspend fun initialize(): InitializeAction {
return super.initialize()
}
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, NameEntity>
): MediatorResult {
val nextPageKey = when(loadType) {
LoadType.REFRESH -> {
refresh()
0
}
LoadType.PREPEND -> return MediatorResult.Success(true)
LoadType.APPEND -> nextPage ?: return MediatorResult.Success(true)
}
val apiResponse = request(nextPageKey)
nextPage = apiResponse.nextPage
return MediatorResult.Success(nextPage == null)
}
}
load() 함수의 반환 타입은 위와 같이 MediatorResult 3가지로 나뉘는데, 상황에 맞게 개발자가 설정해주면 된다.
나의 경우 이전 페이지를 새롭게 로드할 필요는 없었기에 PREPEND 는 MediatorResult.Success(true) 로 두었고, Refresh 는 Room DB 를 삭제하는 Lambda 실행, APPEND 는 호출될 때마다 이전에 설정된 nextPage 값에 따라 API 를 호출할지, 그대로 끝낼지를 결정하게 하였다.
이제 위와 같이 Mediator 를 설정하였으니 Room 에 데이터를 넣어주는것은 request() 내부에서 처리하고 refresh() 가 호출될시 DB 삭제를 진행시켜주면 된다 !
Pager 부분의 최종 결과는 다음과 같이 될 것이다.
// Repository 가정
class Repository(val dao: DevGyuDao){
@OptIn(ExperimentalPagingApi::class)
fun getPagingData(): Flow<PagingData<Name>>{
return Pager(
config = PagingConfig(
10,
prefetchDistance = 1,
initialLoadSize = 10
),
remoteMediator = DevGyuRemoteMediator(
// API 호출 가정
request = { DevGyuResponse(it?.plus(1), listOf(NameEntity(1, ""))) },
// DB DELETE 가정
refresh = {}
),
pagingSourceFactory = { dao.getPagingSource() }
).flow.map { it.map { it.toDomain() }}
}
}
이 부분은 굉장히 간단하다.
다음과 같이 repository 에서 PagingData 를 가져오고 cachedIn 으로 캐싱처리를 해주면 된다
class DevGyuViewModel @Inject constructor(
private val repository: Repository
): BaseViewModel() {
val pagingData = repository.getPagingData()
.cachedIn(viewModelScope)
}
만약 이 중간에 내부 데이터 타입을 변경하고 싶다면 PagingData<T>.map 을 활용하자
그 후 Composable 에서 collectAsLazyPagingItems 로 데이터를 변환해준뒤 LazyColumn items 에 collectAsLazyPagingItems 로 변환해준 변수의 itemCount 를 삽입, 내부 content 에서는 get[index] 를 통해 아이템을 가져와 사용하면 된다.
사실 Paging3 를 사용하면서 데이터에 대한 업데이트 없이 보여주기만 해도 되는 화면이라면 얼마나 편하겠느냐만 .. 그런 일이 존재하는 일은 매우 드물다.
유튜브나 인스타그램 같은 것으로 설명을 하자면 댓글, 좋아요, 신고, 가리기 등등 .. 유저에게 필요한 이벤트들이 너무나도 많기 때문이다.
그렇기에 우리는 User Event 가 일어나는 경우 데이터를 업데이트를 진행해줘야한다.
그럴 때 Paging3 가 아닌 일반 StateFlow<Model> 를 사용할 때 데이터 업데이트 방식은 매우 간단하다.
Model Flow 를 update 만 해주면 State 가 변화되면서 Compose Compiler 가 데이터의 변화를 알아차리고, 리컴포지션을 통해 UI 를 재구성해주기 때문이다.
그러나 Paging3 에서 이러한 방식으로 업데이트를 시도하려는 순간
java.lang.IllegalStateException: Attempt to collect twice from pageEventFlow, which is an illegal operation. Did you forget to call Flow<PagingData<*>>.cachedIn(coroutineScope)
위와 같은 에러를 보는 일이 허다하게 생길 것이다.
이러한 에러로 인해 Paging Data Update 를 하지 못하는 이들을 위해 내가 몇 가지 방법을 알려주겠다 !
첫 번째 방법이다.
Room + RemoteMediator 를 사용하여 서버로부터 받아오는 데이터를 Room 에 저장하고, 업데이트가 필요한 경우 Room 에 존재하는 Data 들을 업데이트 해주는 것이다.
이것에 대해서는 위에 기술한대로 RemoteMediator 로 Pager 데이터를 꺼내오고 업데이트가 필요한 아이템에 대해 Room 에서 직접 업데이트를 해주면 Flow 에서 새로운 데이터 방출이 이루어져 값이 업데이트 된다.
다만 RemoteMediator 의 경우 로컬 데이터베이스에 데이터를 저장해야하므로 휘발성 메모리를 가져야하는 데이터라면 이 방식은 추천하지 않는다.
두 번째 방법으로는 Flow<PagingData\> 와 Combine 을 적용하는 것이다.
업데이트 되는 데이터에 대한 StateFlow 를 생성한 뒤, Flow<PagingData> 와 Combine 하고 PagingData.map 을 사용하여 내부 데이터를 변경해주면 된다.
단 이 작업은 PagingData 의 cachedIn 이후에 작업을 해줘야한다.
그렇지 않으면 데이터 수집이 여러 곳에서 일어나 에러가 발생하게 된다.
class DevGyuViewModel @Inject constructor(
private val repository: Repository
): BaseViewModel() {
private val _newNameFlow = MutableStateFlow("")
val newNameFlow = _newNameFlow.asStateFlow()
val pagingData = repository.getPagingData()
.cachedIn(viewModelScope)
.combine(newNameFlow){ pagingData, newName ->
pagingData.map { it.copy(name = newName) }
}
}
나는 데이터 업데이트 방법 중 2번 방식에서 combine 을 사용해도 자꾸 에러가 발생하여 매일 1번 방식으로 데이터를 업데이트하였는데 여간 힘든일이 아니었다.
그러다가 최근 면접과제를 만드는 도중 정말 Room Update 방식밖에 없나 서칭을 하다보니 combine 뒤쪽에서 업데이트를 해주면 된다는 것을 깨닫고 공유하고 싶어 이를 적었다.
누군가는 나같이 어이없는 행동을 하지 않기를 ..