성장기록지
Paging 학습 및 프로젝트 적용 본문
Paging이란?
공식문서에는 다음과 같이 작성되어져있다.
요약하자면 데이터를 가져올 때 한 번에 모든 데이터를 가져오는 것이 아니라 페이지로 나눠서 가져오는 것을 뜻한다.
이때 데이터는 로컬 데이터베이스에 있을 수 있고, 통신을 통해 가져올 수도 있는 것이다.
Paging의 장점
영어는 어려우니 하나씩 해석해보겠다.
- 페이징 된 데이터의 in-memory 캐싱 -> 앱이 페이징 데이터로 작업하는 동안 시스템 리소스를 효율적으로 사용 가능
- 요청 중복 제거 기능이 기본으로 제공되어 앱에서 네트워크 대역폭과 시스템 리소스를 효율적으로 사용 가능.
- 사용자가 로드된 데이터의 끝까지 스크롤할 때 구성 가능한 RecyclerView 어댑터가 자동으로 데이터를 요청 (무한 스크롤이 구현 가능하다는 뜻.)
- Kotlin 코루틴 및 Flow뿐만 아니라 LiveData 및 RxJava를 최고 수준으로 지원.
- 새로고침 및 재시도 기능을 포함하여 오류 처리를 기본으로 지원.
Paging 아키텍쳐
Repository layer
PagingSource
PagingSource 객체가 데이터의 출처와 그 출처에서 데이터를 어떻게 가져올지 정의한다.
PagingSource 개체는 네트워크 소스 및 로컬 데이터베이스를 포함한 모든 단일 소스에서 데이터를 로드할 수 있음.
RemoteMediator
계층화된 데이터 소스에서 페이징을 처리한다.
네트워크로 부터 받은 데이터를 로컬 데이터베이스를 통해 캐시 하는 경우 사용
ViewModel layer
Pager
반응형 스트림에 노출되는 PagingData 인스턴스를 구성하기 위한 공용 API를 제공
PagingSource 개체 및 PagingConfig 개체를 기반으로 구성
PagingData
PagingData 객체는 페이징된 데이터의 스냅샷을 위한 컨테이너이다.
PagingSource 객체를 쿼리하고 결과를 저장한다.
UI layer
PagingDataAdapter
RecyclerView에 PagingData를 표시하는 RecyclerView.Adapter
DiffUtil를 활용해 데이터를 구별한다.
(만약 PagingDataAdapter가 아닌 RecyclerView.Adapter 등을 확장하는
커스텀 어댑터를 구현하려면 AsyncPagingDataDiffer를 사용할 수 있음.)
프로젝트 적용
1. 의존성 추가
toml에 versions와 libraries를 추가하고,
app 모듈 build.gradle에 implementation 해주었다.
[versions]
paging = "3.3.5"
[libraries]
paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging" }
// build.gradle.kts(app)
dependencies{
//...
implementation(libs.paging.runtime)
//...
}
2. PagingSource 정의
일반적인 PagingSource 구현은 생성자에서 제공된 매개변수를 load() 메서드에 전달하여
쿼리에 적절한 데이터를 로드한다고 한다.
따라서 load 내부 구현에는 네트워크 통신 값을 가져오는 로직을 작성해주었다.
네이버 쇼핑 api는 start의 값이 response 아이템의 번호 순서이므로startItemNumber를 nextPageNumber에서 1을 뺴준 값에 loadSize를 곱해주고 1을 더해1,21,41....이렇게 되도록 구성하였다.
(Pager에서 pageSize와 initialLoadSize를 20으로 설정해주었다.)
또한 응답 아이템 개수가 설정해둔 개수보다 작을때에 마지막 페이지이므로
boolean 변수인 isLastPage를 사용해 마지막 페이지 여부를 정해주었다.
package com.example.ishopping.data.source
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.example.ishopping.data.model.ShoppingItem
import com.example.ishopping.data.source.remote.ShoppingService
class SearchPagingSource(
private val service: ShoppingService,
private val query: String
) : PagingSource<Int, ShoppingItem>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, ShoppingItem> {
try {
val nextPageNumber = params.key ?: 1
val startItemNumber = ((nextPageNumber - 1) * params.loadSize) + 1
val response = service.getShoppingItems(
query,
start = startItemNumber
)
val isLastPage = response.items.size < params.loadSize
return LoadResult.Page(
data = response.items,
prevKey = null,
nextKey = if (isLastPage) null else nextPageNumber + 1
)
} catch (e: Exception) {
return LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, ShoppingItem>): Int? {
return state.anchorPosition?.let { anchorPosition ->
val anchorPage = state.closestPageToPosition(anchorPosition)
anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
}
}
}
PagingSource<Key, Value>
Key는 데이터를 로드하는 데 사용되는 식별자, Value는 데이터의 유형이다.
Int 페이지 번호를 Retrofit에 전달하여 네트워크에서 ShoppingItem 객체의 페이지를 로드한다면
PagingSource<Int, ShoppingItem > 형태가 되는 것이다.
LoadParams
실행할 로드 작업에 관한 정보(로드할 키와 로드할 항목 수)가 포함된다.
LoadResult
load() 호출이 성공했는지 여부에 따라 두 가지 형식 중 하나를 취하는 봉인 클래스이다.
- 로드에 성공하면 LoadResult.Page 객체를 반환
- 로드에 실패하면 LoadResult.Error 객체를 반환
아래 그림은 load() 함수가 각 로드의 키를 수신하고 후속 로드용 키를 제공하는 방법을 보여준다.
key가 null 인 상태에서 load()함수를 통해 default key인 1을 받고,
이후 다음 페이지를 원할때마다 key를 +1씩 해준다.
그 후 마지막 페이지에 도달했을 때(nextkey = null) 멈춰준다.
오류 처리
아래와 같이 load()의 오류를 처리해줄 수 있다.
catch (e: IOException) {
// IOException for network failures.
return LoadResult.Error(e)
} catch (e: HttpException) {
// HttpException for any non-2xx HTTP status codes.
return LoadResult.Error(e)
}
3. PagingData 스트림 설정
기존에 repository에서 api호출을 진행하고 있었으므로,
api 호출을 진행한 후, PagingData 스트림으로 변환하는 작업을 진행하였다.
Pager 객체는 pagineSource 객체에서 load 메서드를 호출하여 LoadParams 객체를 제공하고
반환되는 LoadResult 객체를 수신한다.
페이지당 호출하는 아이템 개수를 20개로 설정하였고,(pageSize = 20)
처음에 호출하는 아이템 개수(initialLoadSize)는 pageSize의 3배로 설정되어 있지만,
PagingSource의 isLastPage 변수를 구성하기도 해야되고, 20개정도 불러오면 적정하다고 생각하여
initialLoadSize도 20개로 설정해주었다.
class SearchRepository @Inject constructor(private val shoppingService: ShoppingService) {
fun getShoppingItems(query: String): Flow<PagingData<ShoppingItem>> {
return Pager(
config = PagingConfig(pageSize = 20, initialLoadSize = 20),
pagingSourceFactory = {
SearchPagingSource(shoppingService, query)
}
).flow
}
}
flow.cacheIn(viewModelScope)와 같이 캐싱 기능을 적용할 수 있다.
제공된 coroutineScope를 사용해 로드된 데이터가 캐싱될 수 있다.
공식문서를 참고하여 viewModelScope를 주어 viewmodel에서 캐싱을 진행하였다.
debounce 코드는 다른 글을 통해 설명하겠다.
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
val shoppingItems: Flow<PagingData<ShoppingItem>> =
_searchText
.debounce(500L)
.filter { it.isNotEmpty() }
.flatMapLatest { query ->
searchRepository.getShoppingItems(query)
}
.cachedIn(viewModelScope) // 캐싱 적용
//...
4. RecyclerView 어댑터 정의
공식문서에는 다음과 같이 코드 설명이 되어있다.
이를 반영하여 Adapter를 구성하였다.
onBindViewHolder에서는 null처리를 해줘야 한다는 주석대로
let과 run을 활용하여 비동기 상황에서도 안전하게 처리해주었다.
처리방법은 다른 블로그 글을 통해 상세하게 작성해보도록 하겠다.
package com.example.ishopping.ui.search
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.example.ishopping.data.model.ShoppingItem
import com.example.ishopping.databinding.ItemShoppingItemBinding
import com.example.ishopping.ui.search.SearchShoppingItemAdapter.SearchShoppingItemViewHolder
class SearchShoppingItemAdapter(diffCallback: DiffUtil.ItemCallback<ShoppingItem>) :
PagingDataAdapter<ShoppingItem, SearchShoppingItemViewHolder>(diffCallback) {
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): SearchShoppingItemViewHolder {
return SearchShoppingItemViewHolder.from(parent)
}
override fun onBindViewHolder(
holder: SearchShoppingItemViewHolder,
position: Int
) {
val shoppingItem = getItem(position)
shoppingItem?.let { item ->
holder.bind(item)
} ?: run {
holder.bind(ShoppingItem.placeholder())
}
}
class SearchShoppingItemViewHolder(private val binding: ItemShoppingItemBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(shoppingItem: ShoppingItem) {
binding.shoppingItem = shoppingItem
}
companion object {
fun from(parent: ViewGroup): SearchShoppingItemViewHolder {
val binding = ItemShoppingItemBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return SearchShoppingItemViewHolder(binding)
}
}
}
}
DiffUtil은 아래와 같이 처리하였다.
ShoppingItem의 productId가 유니크한 값이라 areItemsTheSame()은 productId로 비교하도록 하였다
DiffUtil에 대한 학습은 아래 글에 정리해두었다.
https://codinghun.tistory.com/67
package com.example.ishopping.ui.search
import androidx.recyclerview.widget.DiffUtil
import com.example.ishopping.data.model.ShoppingItem
object SearchShoppingItemComparator : DiffUtil.ItemCallback<ShoppingItem>() {
override fun areItemsTheSame(oldItem: ShoppingItem, newItem: ShoppingItem): Boolean {
// Id is unique.
return oldItem.productId == newItem.productId
}
override fun areContentsTheSame(oldItem: ShoppingItem, newItem: ShoppingItem): Boolean {
return oldItem == newItem
}
}
5. UI에 페이징된 데이터 표시
공식문서 가이드는 아래와 같이 되어있다.
fragment는 뷰와 생명주기가 다르므로 viewLifecycleOwner를 쓰라는 주석이 있다.
가이드에 따라 SearchFragment에 다음과 같이 setupRecyclerView()로 코드를 작성해줬다.
검색창을 Activity로 변경 예정인데 그러면 viewLifecCycleOwner만 제거해주면 될 거 같다.
package com.example.ishopping.ui.search
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import com.example.ishopping.databinding.FragmentSearchBinding
import com.example.ishopping.ui.extensions.textChanges
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@AndroidEntryPoint
class SearchFragment : Fragment() {
private var _binding: FragmentSearchBinding? = null
private val binding get() = _binding!!
private val viewModel by viewModels<SearchViewmodel>()
private val pagingAdapter = SearchShoppingItemAdapter(SearchShoppingItemComparator)
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentSearchBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
setupRecyclerView()
observeSearchTextChanges()
binding.rvSearchedShoppingItemList.adapter = pagingAdapter
}
private fun setupRecyclerView() {
viewLifecycleOwner.lifecycleScope.launch {
viewModel.shoppingItems.collectLatest { pagingData ->
pagingAdapter.submitData(pagingData)
}
}
}
private fun observeSearchTextChanges() {
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
binding.searchHomeLayout.editTextSearchText.textChanges()
.collect { text ->
viewModel.updateSearchText(text)
}
}
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
ViewModel에서는 아래와 같이 작성하였다.
debounce를 적용한 과정은 다른 글을 통해 상세히 작성해보겠다.
editText의 값을 searchText로 받아오고 있어서 코드가 아래와 같지만
editText의 값을 바로 가져와서 쓸지도 고민 중이다.
package com.example.ishopping.ui.search
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.example.ishopping.data.model.ShoppingItem
import com.example.ishopping.data.source.SearchRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flatMapLatest
import javax.inject.Inject
@HiltViewModel
class SearchViewmodel @Inject constructor(private val searchRepository: SearchRepository) :
ViewModel() {
private val _searchText = MutableStateFlow("")
val searchText = _searchText.asStateFlow()
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
val shoppingItems: Flow<PagingData<ShoppingItem>> =
_searchText
.debounce(500L)
.filter { it.isNotEmpty() }
.flatMapLatest { query ->
searchRepository.getShoppingItems(query)
}
.cachedIn(viewModelScope)
fun updateSearchText(query: String) {
_searchText.value = query
}
}
동작 예시
아직 액티비티로 분리를 안해줘서 바텀네비게이션 뷰가 보이는 버그가 있다.
살짝 눈감아주시면 감사하겠다.
구현 후기
부스트캠프에선 paging을 직접적으로 배우지 않아서 처음 경험해 보았지만,
정말 유용한 기능이라는 것을 알 수 있었다.
아직 간단한 기능만 구현하였기에 지식이 부족하므로
프로젝트를 완성 후 더 깊이있는 학습을 해야겠다.
참고 자료
https://developer.android.com/topic/libraries/architecture/paging/v3-paged-data?hl=ko
https://915dbfl.github.io/compose/compose-paging3(1)/
'개인 프로젝트' 카테고리의 다른 글
Flow debounce와 프로젝트 적용(검색 기능 api 호출 최적화) (0) | 2025.02.01 |
---|---|
네트워크 통신 라이브러리 사용 고찰 (HttpUrlConnection, OkHttp, retrofit) (0) | 2025.01.15 |
ShapeableImageView로 이미지 뷰 radius 적용 (w.내부 구현) (0) | 2025.01.12 |
개인 프로젝트) 디자인 키트 구매, 활용 api 정하기 (mockup api, 네이버 쇼핑 api) (0) | 2025.01.05 |
개인 프로젝트) 주제와 기술적 도전 기획 (0) | 2025.01.03 |