페이징 개요

앱을 개발하다보면 대량의 데이터를 리사이클러뷰로 처리해야 할 때가 있습니다. 그럴 때마다 다량의 데이터를 한꺼번에 불러오게 되면 시스템이 무거워 지곤 했습니다.

이 문제를 해결하기 위해 우리는 페이징 기능을 사용해야합니다.
페이징 기능이란 시스템 리소스를 효율적으로 활용하기 위해서 일정한 페이지 사이즈 만큼 나눠서 데이터를 로딩하는 기능입니다.

그러나 일반적으로 페이징을 구현하려면 아래 코드와 같이 스크롤 리스너를 이용하여 리사이클러뷰의 상태를 기반으로 페이지 로드 조건을 만들어줘야 합니다.

val scrollListener = object : RecyclerView.OnScrollListener() {
    override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
        super.onScrollStateChanged(recyclerView, newState)
        if (newState == AbsListView.OnScrollListener.SCROLL_STATE_TOUCH_SCROLL)
            isScrolling = true // 현재 스크롤 상태임을 표시
    }

    override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
        super.onScrolled(recyclerView, dx, dy)

        val layoutManager = recyclerView.layoutManager as LinearLayoutManager
        val firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition()
        val visibleItemCount = layoutManager.childCount
        val totalItemCount = layoutManager.itemCount
        // 1) 데이터가 로딩중이 아니어야 한다. & 2) 현재 로드된 페이지가 마지막 페이지가 아니어야 한다.
        val isNotLoadingAndNotLastPage = !isLoading && !isLastPage
        // 3) 스크롤이 제일 끝에 위치해야 한다.
        val isAtLastItem = firstVisibleItemPosition + visibleItemCount >= totalItemCount
        // 4) 이제 처음 로드되는 데이터가 아니어야 한다. 
        val isNotAtBeginning = firstVisibleItemPosition >= 0
        // 5) 리사이클러뷰의 전체 항목의 수가 한 번에 로드되는 항목의 개수(20)보다 많이야 한다
        val isTotalMoreThanVisible = totalItemCount >= QUERY_PAGE_SIZE
        val shouldPaginate = isNotLoadingAndNotLastPage && isAtLastItem && isNotAtBeginning &&
                isTotalMoreThanVisible && isScrolling
        if (shouldPaginate) {
            viewModel.getBreakingNews(COUNTRY) // 조건이 확인되면 다음 페이지를 불러온다.
            isScrolling = false
        }
    }
}

보시다시피 조건이 매우 까다롭습니다. 또한 조건이 조금이라도 엇나가도록 설계한다면 데이터를 로드해야할 때 로딩이 안되거나, 비정상적으로 많은 데이터를 요청할 수도 있습니다.

이 문제를 해결하기 위해 Paging 3 라이브러리를 사용해볼 수 있습니다.

페이징 라이브러리는 리사이클러뷰 스크롤의 끝지점에 도달했을 때 자동으로 데이터를 요청해주고, 요청 중복 제거 기능도 갖추고 있기 때문에 앞서 말한 문제점을 모두 해결할 수 있습니다.

이번 포스팅은 Paging 3의 장점과 구현 방법에 대해서 설명드리도록 하겠습니다.

Paging 3의 장점

  • 페이징된 데이터의 메모리 내 캐싱을 해줍니다.
  • 요청 중복 제거 기능이 기본으로 제공됩니다.
  • 사용자가 로드된 데이터의 끝까지 스크롤할 때 구성 가능한 RecyclerView 어댑터가 자동으로 데이터를 요청합니다.
  • Kotlin 코루틴Flow뿐만 아니라 LiveDataRxJava를 최고 수준으로 지원합니다.
  • 새로고침 및 재시도 기능을 포함하여 오류 처리를 기본으로 지원합니다.

페이징 구현 방법

데이터 소스 정의

첫 번째 단계는 데이터 소스를 식별하기 위해 Paging Source 기능을 정의하는 것입니다. PagingSource API 클래스에는 load() 메서드가 포함되어 있으며, 이 메서드는 상응하는 데이터 소스에서 페이징된 데이터를 검색하는 방법을 나타내기 위해 재정의해야 합니다.

키 및 값 유형 선택

PagingSource<Key, Value>에는 Key와 Value의 두 유형 매개변수가 있습니다. 키는 데이터를 로드하는 데 사용되는 식별자를 정의하며, 값은 데이터 자체의 유형입니다.

PagingSource 정의

다음은 페이지 번호를 기준으로 항목을 로드하는 PagingSource 클래스 구현 코드입니다.

class ExamplePagingSource(
    val backend: ExampleBackendService,
    val query: String
) : PagingSource<Int, User>() {
  override suspend fun load(
    params: LoadParams<Int>
  ): LoadResult<Int, User> {
    try {
      // 페이지 번호가 지정되어있지 않다면, 1페이지부터 시작
      val nextPageNumber = params.key ?: 1
      val response = backend.searchUsers(query, nextPageNumber) // 데이터 요청
      return LoadResult.Page(
        data = response.users,
        prevKey = null, // 다음 페이지 로드만 사용할 예정
        nextKey = response.nextPageNumber
      )
    } 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)
	}
  }

  override fun getRefreshKey(state: PagingState<Int, User>): Int? {
    // 데이터가 첫 로드 후 새로고침되거나 무효화되었을 때 키를 반환하여
    // load() 메서드로 전달하여 다시 로딩한다.
    return state.anchorPosition?.let { anchorPosition ->
      val anchorPage = state.closestPageToPosition(anchorPosition)
      anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
    }
  }
}

LoadParams 객체에는 로드할 키와 로드할 항목 수에 대한 정보가 포함됩니다.
LoadResult 객체에는 로드 작업에 대한 결과가 포함됩니다. load() 메서드가 성공적으로 호출되면 LoadResult.Page 객체를 반환하고, 로드에 실패하면 LoadResult.Error 객체를 반환합니다.

PagingData 스트림 설정

그 다음, PagingSource에서 페이징된 데이터의 스트림이 필요합니다. 일반적으로 ViewModel 클래스에서 구현하며 Flow, LiveData, RxJava의 Flowable 유형과 Observable 유형을 비롯한 여러 스트림 유형을 사용할 수 있도록 지원합니다.

val flow = Pager(
  PagingConfig(pageSize = 20)
) {
  ExamplePagingSource(backend, query)
}.flow
  .cachedIn(viewModelScope)

cachedIn() 연산자는 데이터 스트림을 공유 가능하게 하며 제공된 CoroutineScope을 사용하여 로드된 데이터를 캐시합니다.

리사이클러뷰 어댑터 정의

로드된 데이터가 리사이클러뷰에 바인딩될 수 있도록 어댑터를 정의합니다. 아래 코드에는 안나왔지만, UserViewHolder 뷰홀더 클래스를 정의하여 사용하였습니다.

class UserAdapter(diffCallback: DiffUtil.ItemCallback<User>) :
  PagingDataAdapter<User, UserViewHolder>(diffCallback) {
  override fun onCreateViewHolder(
    parent: ViewGroup,
    viewType: Int
  ): UserViewHolder {
    return UserViewHolder(parent)
  }

  override fun onBindViewHolder(holder: UserViewHolder, position: Int) {
    val item = getItem(position)
    // Note that item may be null. ViewHolder must support binding a
    // null item as a placeholder.
    holder.bind(item)
  }
}

또한, Diff Util을 사용하므로, DiffUtil.ItemCallback을 정의하여 사용하여야 합니다.

object UserComparator : DiffUtil.ItemCallback<User>() {
  override fun areItemsTheSame(oldItem: User, newItem: User): Boolean {
    // Id is unique.
    return oldItem.id == newItem.id
  }

  override fun areContentsTheSame(oldItem: User, newItem: User): Boolean {
    return oldItem == newItem
  }
}

UI에 페이징된 데이터 표시

페이징된 데이터를 viewModel에서 받아와 어댑터에 전달해줍니다.
여기서 주의해야할 점은 Activity에서는 lifecycleScope를 다이렉트로 사용할 수 있으나, Fragment에서는 viewLifecycleOwner.lifecycleScore 형태로 나타내어야 합니다.

val viewModel by viewModels<ExampleViewModel>()

val pagingAdapter = UserAdapter(UserComparator)
val recyclerView = findViewById<RecyclerView>(R.id.recycler_view)
recyclerView.adapter = pagingAdapter

// Activities can use lifecycleScope directly, but Fragments should instead use
// viewLifecycleOwner.lifecycleScope.
lifecycleScope.launch {
  viewModel.flow.collectLatest { pagingData ->
    pagingAdapter.submitData(pagingData)
  }
}

0개의 댓글