안드로이드의 Paging3 라이브러리는 다양한 소스로부터 대용량의 데이터를 효율적으로 관리하고 가져오는 Android Jetpack의 라이브러리이다. Paging3를 사용하면 로컬이나 네트워크로부터 페이지 단위의 데이터를 가져올 수 있다. 이번 포스팅에서는 Paging3 소개와 예제를 통해 Paging3의 사용법을 소개한다.
페이징 라이브러리의 장점
- 페이징된 데이터는 인-메모리 캐싱하며, 이를 통해 빠른 응답과 버벅거림 없이 데이터를 불러올 수 있다.
- 페이지의 끝에 도달하면 데이터를 요청하고 불러오는 리사이클러 뷰 어댑터를 제공한다.
- 중복된 요청을 막을 수 있어, 사용자의 대역폭과 시스템 리소스를 절약할 수 있다.
- 코틀린으로 구성되어 있어, 모든 코틀린 및 Jetpack 라이브러리와 완벽한 호환이 가능하며, Coroutines, Flow, LiveData, RxJava모두 지원한다.
- 새로고침이나 재시도와 같은 내장된 예외 처리를 지원한다.
페이징 라이브러리 아키텍처
페이징 라이브러리는 앱의 기본 계층에 잘 작동하고 통합되는데, 이는 페이징 아키텍처가 앱의 기본 계층에 기반하여 구성되어 있어, 기존에 구성된 계층에 쉽게 포함시킬 수 있다. 아래의 그림을 통해 확인할 수 있다.
Repository 계층
- PagingSource는 추상 클래스로, 네트워크로부터 페이징된 데이터를 가져온다. PagingSource를 구현하기 위해 두 가지 매개변수를 정의해야 한다. 하나는 페이지를 가져오기 위한 식별자, 하나는 페이지에 속한 값의 타입이다.
- RemoteMediator는 네트워크 및 로컬 데이터베이스에서 페이징된 데이터를 가져온다. RemoteMediator를 사용하면, 로컬 데이터베이스와 네트워크 모두 동시에 페이징하며, 데이터는 모두 로컬 데이터베이스에서 가져오게 된다.
ViewModel 계층
- Pager는 RemoteMediator 혹은 PagingSource로부터 페이징된 데이터를 받아 반응형 스트림을 제공하는 API이다. Pager는 Flow, LiveData, Observable로 반환될 수 있다.
- PagingData는 ViewModel 계층과 UI계층을 연결하는 요소로, 기존의 데이터 타입을 가지고 있는 반환 타입이다. PagingData는 페이징된 데이터의 컨테이너로 사용된다.
UI 계층
- PagingAdapter는 RecyclerView에서 데이터를 표시하는 역할을 한다. PagingData를 사용하고 내부 로드 이벤트를 수신한다. DiffUtil을 사용하여 분류 후 데이터를 백그라운드 스레드에서 불러오므로, UI 스레드에서 새 항목을 추가하는 동안 버벅거림이 발생하지 않는다.
실제 예제를 통해 알아보자!
아래에서부터 구현할 예제는 Public Beer Api를 불러와 PagingSource를 사용하여 데이터를 불러오는 방법과 RemoteMediator를 사용하여 로컬에서 로드하는 방법을 소개한다. 중요한 코드만 소개하며, 세부적인 코드는 아래의 Git 주소를 통해 확인하자.
해당 예제에 사용한 라이브러리
- DataBinding, Hilt, Paging3, Room, ViewModel, Fragment
- Retrofit2, OkHttp3, Moshi, Glide
- 예제 build.gradle
사용 API
https://punkapi.com/documentation/v2
1. Api Service 선언하기
interface BeerService {
@GET("beers")
suspend fun getBeers(
@Query("page") page: Int,
@Query("per_page") pageCount: Int
): List<BeerResponse>
companion object{
const val BASE_URL = "https://api.punkapi.com/v2/"
}
}
위의 코드에서 page와 pageCount를 꼭 확인해야 한다. page는 현재 보여지는 페이지의 번호, pageCount는 페이지에 표시할 데이터의 갯수이다. 두 개의 매개변수가 있어야 페이징(Pagination), 무한 스크롤, 지연 로딩(Lazy Loading)이 가능하다.
2. PagingSource 구현하기
PagingSource는 네트워크 API를 DataSource로 사용한다. PagingSource는 자동으로 API를 호출하여 페이징을 처리한다.
class BeerPagingSource @Inject constructor(
private val service: BeerService
): PagingSource<Int, BeerEntity>(){
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, BeerEntity> {
return try{
val loadKey = params.key ?: 1
val response = service.getBeers(page = loadKey, pageCount = params.loadSize)
val nextKey = if(response.size < params.loadSize){
null
} else {
loadKey + 1
}
LoadResult.Page(
data = response.map{ it.toBeerEntity() },
prevKey = null,
nextKey = nextKey
)
} catch(e: Exception) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, BeerEntity>): Int? {
return state.anchorPosition?.let { anchorPosition ->
val anchorPage = state.closestPageToPosition(anchorPosition)
anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
}
}
}
PagingSource에서는 API 호출을 위해, 이전에 정의한 API interface와 추가적으로 검색할 쿼리를 매개변수로 입력받을 수 있다. PagingSource는 두 가지 load 함수와 getRefreshKey 함수를 구현하여 사용할 수 있다. 두가지 함수를 살펴보면,
- params는 API 호출을 위한 기본적인 페이지 정보와 크기가 정의되어 있다.
- LoadResult는 Kotlin Sealed 클래스이며 일부 오류의 경우 LoadResult.Error를 반환하고 성공의 경우 LoadResult.Page를 반환합니다. 데이터를 사용할 수 없고 목록 끝에 도달한 경우, prevKey 또는 nextKey의 값으로 null을 전달하여 목록 조건의 끝을 표시합니다.
- load 함수는 page와 pageCount를 통해 API를 호출한다. 현재 페이지는 params.key에서 확인할 수 있고, 처음은 null이 반환된다. 페이지 크기는 params.loadSize를 통해 한 페이지의 데이터 갯수를 확인한다.
- getRefreshKey 함수는 load함수의 loadParams.key를 전달하여, Key번째의 페이지를 새로고침하기 위해 사용되는 함수이다. prevKey 혹은 nextKey를 통해 찾으며, null 처리를 해야 한다(prevKey가 null이면, 첫 페이지, nextKey가 null이면 마지막 페이지, 두 값 모두 null인경우 첫 시작을 의미).
3. Pager 구현하기
다음은 PagingSource로 부터 페이징된 데이터의 스트림을 구현해야 한다. Pager는 PagingSource로부터 PagingData 타입의 반응형 데이터 스트림을 제공한다.
@OptIn(ExperimentalPagingApi::class)
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun providesBeerPager(
beerService: BeerService
): Pager<Int, BeerEntity>{
return Pager(
config = PagingConfig(pageSize = 20),
pagingSourceFactory = {
BeerPagingSource(beerService)
}
)
}
}
Pager를 구현하기 위해 PagingConfig 개체를 통해 페이지의 크기와 PagingSource를 반환하는 함수를 전달해야 한다. Hilt를 통해 Pager를 싱글톤으로 구현하였다. Hilt를 사용하지 않는다면, Repository를 통해 Pager를 사용하도록 구성할 수 있다.
4. Ui에서 사용하기
@HiltViewModel
class BeerViewModel @Inject constructor(
private val pager: Pager<Int, BeerEntity>
): ViewModel() {
val beerPagingFlow = pager
.flow
.map { pagingData -> pagingData.map{ it.toBeer() } }
.cachedIn(viewModelScope)
}
Flow의 Map 연산을 통해, 다른 어떠한 데이터로 변환 가능하며 위의 코드는 PagingData를 받아, PagingSource의 Value Type을 Ui Model로 변환하였다.
cachedIn() 연산자는 데이터 스트림을 공유 가능하게 만들고, 입력한 CoroutineScope를 사용하여 로드 된 데이터를 캐시한다. 이 예제는 Lifecycle lifecycle-viewmodel-ktx 아티팩트에서 제공하는 viewModelScope를 사용한다.
RecyclerView를 통해 데이터를 보여주기 위해서는 어댑터를 설정해야 한다. Paging3 라이브러리는 이를 위해 PagingDataAdapter 클래스를 제공한다.
class BeerAdapter: PagingDataAdapter<Beer, BeerAdapter.BeerViewHolder>(BeerDiffUtil) {
override fun onBindViewHolder(holder: BeerViewHolder, position: Int) {
val item = getItem(position)
holder.bind(item)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BeerViewHolder {
return BeerViewHolder(ItemBeerBinding.inflate(
LayoutInflater.from(parent.context), parent, false
))
}
companion object{
val BeerDiffUtil = object: DiffUtil.ItemCallback<Beer>(){
override fun areItemsTheSame(oldItem: Beer, newItem: Beer): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: Beer, newItem: Beer): Boolean {
return oldItem == newItem
}
}
}
inner class BeerViewHolder(private val binding: ItemBeerBinding)
: RecyclerView.ViewHolder(binding.root){
fun bind(item: Beer?){
binding.item = item
}
}
}
PagingDataAdapter 생성자에게 DiffUtil을 전달해야 하며, 나머지는 일반적인 Recycleview Adapter 구현과 동일하다.
지금까지 PagingSource에서부터 PagingDataAdapter까지 소개하였다. 밑에서는 PagingSource를 걷어내고, RemoteMediator를 통해 데이터 페이징 + 로컬 캐싱을 구현한 예제를 소개한다.
2-1. Local DataBase 구현하기
해당 예제는 Room을 통해 구현되었으며, Room에서 로컬 데이터베이스를 구축하기 위해, Entity, Dao, Database를 구현해야 한다. Entity, Database는 일반적인 Room Database와 동일하며, Dao의 조회 메소드는 추후의 Pager에 PagingSource를 반환하는 함수로 입력되기 때문에, PagingSource를 반환해야 한다.
@Dao
interface BeerDao {
@Upsert
suspend fun upsertBeers(beers: List<BeerEntity>)
@Query("SELECT * FROM beerentity")
fun pagingSource(): PagingSource<Int, BeerEntity>
@Query("DELETE FROM beerentity")
fun deleteAllBeers()
}
2-2. RemoteMediator 구현하기
RemoteMediator는 페이징을 위해 DB에서 결과를 가져오고, 필요할 때마다 네트워크에서 새 데이터를 가져와 로컬 DB에 저장한다. RemoteMediator는 Local DB와 Remote Call 모두 처리해야 하기 때문에, API Servcie와 Database를 인자로 받아 수행한다.
@OptIn(ExperimentalPagingApi::class)
class BeerRemoteMediator(
private val beerDatabase: BeerDatabase,
private val beerService: BeerService
): RemoteMediator<Int, BeerEntity>() {
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, BeerEntity>
): MediatorResult {
return try{
val loadKey = when(loadType){
LoadType.REFRESH -> 1
LoadType.PREPEND -> {
return MediatorResult.Success(endOfPaginationReached = true)
}
LoadType.APPEND -> {
val lastItem = state.lastItemOrNull()
if(lastItem == null){
1
} else{
(lastItem.id / state.config.pageSize + 1)
}
}
}
val beers = getBeersFromNetwork(page = loadKey, pageCount = state.config.pageSize)
handleDatabase(loadType, beers)
MediatorResult.Success(
endOfPaginationReached = beers.isEmpty()
)
} catch(e: IOException){
MediatorResult.Error(e)
} catch(e: HttpException){
MediatorResult.Error(e)
}
}
private suspend fun getBeersFromNetwork(page: Int, pageCount: Int): List<BeerResponse>{
return beerService.getBeers(
page = page,
pageCount = pageCount
)
}
private suspend fun handleDatabase(loadType: LoadType, beers: List<BeerResponse>){
beerDatabase.withTransaction {
if(loadType == LoadType.REFRESH){
beerDatabase.dao.deleteAllBeers()
}
val beerEntities = beers.map{ it.toBeerEntity() }
beerDatabase.dao.upsertBeers(beerEntities)
}
}
}
RemoteMediator<Key, Value> 구현을 생성한다. Key 타입과 Value 타입은 반드시 PagingSource에서 정의한 내용과 같아야한다. RemoteMediator는 load 함수를 재 정의하여 구현할 수 있다. load 함수의 특징은 다음과 같다.
- MediatorResult는 함수의 반환 타입이며, 성공의 경우 MediatorResult.Success를 실패 시 Mediator.Error를 반환한다.
- LoadType의 경우 세 가지로 분류되며, LoadType.APPEND는 현재 페이지 끝에 새로운 데이터를 추가 하는 시점, LoadType.PREPEND는 이전에 불러온 데이터를 다시 불러오는 시점, LoadType.REFRESH는 처음으로 페이징을 위한 데이터를 불러오는 시점이다.
- PagingState는 지금까지 로딩 된 페이지들에 대한 정보, 가장 최근에 엑세스한 인덱스 그리고 페이징 스트림을 초기화 하는데 사용했었던 PagingConfig를 포함한다.
- load 함수는 반드시 다음 단계들을 수행해야 한다.
- load type 및 지금까지 로드된 데이터에 의존하여 네트워크로부터 어떤 페이지를 로드 할 지 결정한다.
- 네트워크 요청을 트리거 한다.
- 로드 작업 결과에 따라 다음과 같은 액션을 수행한다
– 로드가 성공했고 받은 아이템 목록이 비어있지 않다면, 아이템 목록을 데이터베이스에 저장하고 MediatorResult.Success(endOfPaginationReached = false)를 반환한다.
– 로드가 성공했고 받은 아이템 목록이 비어 있다면, MediatorResult.Success(endOfPaginationReached = true)를 반환한다.
– 만약 네트워크 요청으로 인해 에러가 발생한다면 MediatorResult.Error를 반환한다.
2-3. Pager 생성하기
@OptIn(ExperimentalPagingApi::class)
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
@Singleton
fun provideBeerPager(
beerDatabase: BeerDatabase, beerService: BeerService
): Pager<Int, BeerEntity>{
return Pager(
config = PagingConfig(pageSize = 20),
remoteMediator = BeerRemoteMediator(
beerDatabase, beerService
),
pagingSourceFactory = {
beerDatabase.dao.pagingSource()
}
)
}
}
기존 PagingSource만 사용한 것과 거의 유사하나, 매개 변수로 RemoteMediator와 PagingSource 반환 함수로 Dao의 조회 함수를 전달한다는 점에서 차이가 있다.
Paging3 Library중 Repository 단계만 변경되기 때문에 ViewModel 및 UI 계층은 동일하다.
마지막으로
공식문서를 읽었을 때 너무 구축이 어렵다고 생각했다. 그런데 아래의 유튜브를 통해 처음으로 페이징을 구현해볼 기회가 생겼고, 클론 코딩과 그 이상으로 직접 PagingSource와 Ui를 따로 구현해 보았다. 페이징 구현이 복잡하긴 해도, 어플리케이션의 성능을 위해서 꼭 필요하다고 생각한다. 이번에는 예제만 구현해 보았으나, 다음에는 OpenApi를 사용하고 있는 다른 앱에서 페이징을 적용할 예정이다.
소스코드
https://github.com/jeongjaino/EveryAndroid3/tree/main/Paging3Example
참고문헌
https://medium.com/swlh/paging3-recyclerview-pagination-made-easy-333c7dfa8797
Paging3 — Doing Recyclerview Pagination the Right Way
Jetpack Paging library release version 3.0.0-alpha03 is full of new features and is Kotlin first with a pinch of Coroutines & Flow 😊.
medium.com
https://developer.android.com/topic/libraries/architecture/paging/v3-network-db?hl=ko
네트워크 및 데이터베이스의 페이지 | Android 개발자 | Android Developers
네트워크 및 데이터베이스의 페이지 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 네트워크 연결이 불안정하거나 사용자가 오프라인 상태일 때 앱을 사용
developer.android.com
https://www.youtube.com/results?search_query=philipp+paging3
https://www.youtube.com/results?search_query=philipp+paging3
www.youtube.com