Android

[안드로이드]페이징(Paging) - 1 (개념 위주)

나맘임 2023. 9. 10. 15:24

페이징이란?

데이터를 일정한 크기로 나눠서 가져오는 걸 페이징이라고 합니다.

 

왜 나눠서 가져올까요?

이는 네트워크 대역폭과 리소스가 한정되어 있다는 현실적인 이유가 있기 때문입니다.

 

만약 여러분이 네이버와 같이 규모가 매우 큰 사이트를 운영한다고 생각해 봅시다.

 

 

사용자가 검색을 하게 되면 그에 대한 정보를 보여줘야 합니다.

 

근데 블로그, 뉴스 등 너무나도 많은 정보가 있습니다.

 

이를 검색하자마자 다 로딩을 시킨다면? 

 

사용자는 검색 결과 화면을 못 볼 수도 있습니다.

 

그렇기 때문에 페이징이 필요합니다.

 

최소로 필요한 단위를 지정하고 그 정도만 데이터를 가져오고 사용자가 추가로 요청하게 되면 추가로 불러오는 겁니다.

 

 

페이징이 적용된 예시 중 대표적인 것이 바로 검색 시 맨 밑에 있는 페이지 버튼입니다.

 


안드로이드에서 페이징을 어떻게 구현할까요?

안드로이드에서 제공하는 Paging 라이브러리

안드로이드 JetPack 라이브러리의 구성 요소 중 하나로 위 페이징 기법을 쉽게 사용할 수 있도록 도와주는 라이브러리입니다.

페이징 라이브러리 개요  |  Android 개발자  |  Android Developers

 

페이징 라이브러리 개요  |  Android 개발자  |  Android Developers

컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Paging 라이브러리 개요   Android Jetpack의 구성요소 Paging 라이브러리를 사용하면 로컬 저장소에서나 네트워크

developer.android.com

그러면 이 페이징 라이브러리를 사용하면 뭐가 좋을까요?

  • Paging된 데이터의 메모리 내 캐싱. 이렇게 하면 앱이 Paging 데이터로 작업하는 동안 시스템 리소스를 효율적으로 사용할 수 있습니다.
  • 요청 중복 삭제 기능이 기본 제공되므로 앱에서 네트워크 대역폭과 시스템 리소스를 효율적으로 사용할 수 있습니다.
  • 사용자가 로드된 데이터의 끝까지 스크롤할 때 구성 가능한 RecyclerView 어댑터가 자동으로 데이터를 요청합니다.
  • Kotlin 코루틴 및 플로우뿐만 아니라 LiveData 및 RxJava를 최고 수준으로 지원합니다.
  • 새로고침 및 재시도 기능을 포함하여 오류 처리를 기본으로 지원합니다.

쉽게 이해가 안 되실 수 있습니다.

 

페이징 기법은 다른 프로그래밍 언어뿐만 아니라 애플리케이션에서 많이 사용하는 대중적인 기법입니다.

 

이 페이징을 구현하는데 필수적인 것들을 쉽게 처리해 주는 것이 안드로이드 Paging 라이브러리입니다.

 


페이징 라이브러리 권장 구조

공식 문서에서 제시하고 있는 아키텍처를 따라 Repository, ViewModel, UI 계층에 각각 어떻게 적용되는지 알아보겠습니다.

Repository 계층

이 계층에서 사용할 수 있는 객체로는 크게 PagingSource, RemoteMediator 두 개로 나뉘어 있습니다.

(1) PagingSource

데이터 소스와 이 소스에서 데이터를 검색하는 방법을 정의하는 객체로 네트워크 소스 및 로컬 데이터베이스를 포함한 단일 소스에서 데이터를 로드할 수 있는 겁니다.

여기서 중요한 키워드는 '단일 소스'입니다. 

 

서버든 로컬 데이터베이스든 한 곳에서만 데이터를 끌어올 때 사용합니다.

class ExamplePagingSource(
    val backend: ExampleBackendService,
    val query: String
) : PagingSource<Int, User>() {
  override suspend fun load(
    params: LoadParams<Int>
  ): LoadResult<Int, User> {
    try {
      val nextPageNumber = params.key ?: 1
      val response = backend.searchUsers(query, nextPageNumber)
      return LoadResult.Page(
        data = response.users,
        prevKey = null, // Only paging forward.
        nextKey = response.nextPageNumber
      )
    } catch (e: Exception) {
    }
  }

  override fun getRefreshKey(state: PagingState<Int, User>): Int? {

    return state.anchorPosition?.let { anchorPosition ->
      val anchorPage = state.closestPageToPosition(anchorPosition)
      anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
    }
  }
}

그림2. PagingSource의 기본 예시

 

PagingSource 객체는 key와 value로 나누어져 있습니다.

 

Key에는 일반적으로 Int 또는 String을 사용하며

 

Int는 페이지의 넘버나 아이템의 포지션을 표시할 필요가 있을 때,

 

String은 만약 네트워크가 각 response마다 token을 리턴 받을 때 사용합니다.

 

value는 PagingData를 나타냅니다. 보통 data class로 구현을 합니다.

 

PagingSource는 반드시 두 메소드를 구현해야 합니다.

 

load 메소드

페이징 라이브러리가 페이지 데이터를 로드할 때 호출되는 것으로 

 

params 파라미터를 통해서 로드한 페이지의 데이터를 얻을 수 있습니다.

 

쉽게 말해 각 페이지에 존재하는 데이터들이 params에 담겨있다고 생각하시면 됩니다.

 

이를 원하는 대로 작업을 하고 LoadResult 객체로 반환을 해줍니다.

 

LoadResult가 반환될 때 페이징의 결과도 함께 반환됩니다.

catch (e: IOException) {
  return LoadResult.Error(e)
} catch (e: HttpException) {
  return LoadResult.Error(e)
}

위와 같이 에러를 검출할 수 있습니다.

 

 

getRefrestKey 메소드

현재 페이징의 정보를 가지고 있는 PagingState 객체를 받으며

 

페이지를 넘겨 새 PagingSource를 호출할 때 사용자가 새로고침 후에도 현재 위치를 잃지 않도록 

 

새 PagingSource로 키를 넘겨주는 메소드입니다.

 

그림 3. load 메소드의 작동 방식

 

(2) RemoteMediator

이름의 뜻인 '원격 중재자' 답게 서버와 로컬 데이터베이스 모두 사용할 필요가 있을 때 사용합니다. 

 

서버에서 데이터를 불러와 로컬 데이터베이스에 캐싱을 할 때 사용합니다.

 

즉, 서버와 로컬 데이터베이스 중간에서 '중재'를 하면서 필요한 데이터를 페이징하여

 

PagingSource를 지속적으로 갱신할 수 있도록 합니다.

그림 4. PagingSource, RemoteMediator 모두 사용하는 방식

전반적인 과정을 다시 보면

 

로컬 데이터베이스에 저장되어 있는 데이터를 PagingSource를 이용하여 UI 담당인 Pager에게 넘겨줍니다.

 

이 PagingSource의 키값을 다 사용했을 경우, 즉, 기존 로컬 데이터베이스에서 페이징 한 데이터들을 다 사용했을 때

 

RemoteMediator가 이를 감지하여 서버로부터 데이터를 추가로 가져와 PagingSource를 새로 생성합니다.

 

이를 반복하는 구조로 간다고 생각하시면 됩니다.

 

override suspend fun load(
  loadType: LoadType,
  state: PagingState<Int, User>
): MediatorResult {
  return try {
    val loadKey = when (loadType) {
      LoadType.REFRESH -> null
      LoadType.PREPEND ->
        return MediatorResult.Success(endOfPaginationReached = true)
      LoadType.APPEND -> {
        val lastItem = state.lastItemOrNull()
        if (lastItem == null) {
          return MediatorResult.Success(
            endOfPaginationReached = true
          )
        }

        lastItem.id
      }
    }

    val response = networkService.searchUsers(
      query = query, after = loadKey
    )

    database.withTransaction {
      if (loadType == LoadType.REFRESH) {
        userDao.deleteByQuery(query)
      }
      userDao.insertAll(response.users)
    }

    MediatorResult.Success(
      endOfPaginationReached = response.nextKey == null
    )
  } catch (e: IOException) {
    MediatorResult.Error(e)
  } catch (e: HttpException) {
    MediatorResult.Error(e)
  }
}

그림5. RemoteMediator 예시

RemoteMediator 또한 Key와 Value로 값이 나누어져 있습니다. 

이는 PagingSource 객체와 똑같이 정의되어있어야 합니다.

 

LoadType에 대하여

load 메소드가 실행되는 상황에 따라 LoadType 값이 달라집니다.

 

일단 기본적으로 앱이 시작될 때 refresh부터 append까지 다 진행이 됩니다.

(1) LoadType.REFRESH

초기 로드나 refresh 요청이 들어왔을 때를 나타냅니다.

 

RemoteMediator의 모든 데이터를 재 load 합니다. 

(2) LoadType.PREPEND

이전 페이지를 다시 로드하는 경우일 때를 나타냅니다.

 

예를 들어 스크롤을 내리다가 이전 데이터를 보기 위해 다시 올리는 경우 PREPEND를 불러옵니다.

 

정상적으로 데이터가 처리됐다는 의미로 SUCCESS로 처리합니다

 

endOfPaginationReached = true 를 하는 이유가 

 

위 예시에선 리사이클러뷰를 통해 스크롤 방식으로 데이터를 표시합니다.

 

이때, 페이지의 맨 처음, 즉 스크롤의 맨 위에서 위로 땡길 때 자동으로 refresh가 호출되기 때문에 

 

endOfPaginationReached = true 함으로써 페이지의 맨 끝에 도달했음을 알리고 새로 호출을 안되게 합니다.

(3) LoadType.APPEND

다음 페이지의 데이터를 호출할 때 사용합니다.

 

위 예시에선 현재 키를 데이터베이스에서 호출한 뒤 다음 키의 값을 가져오는데

 

이다음 키 값이 null 이라면 페이지의 끝에 도달했다는 것이므로 로드할 것이 없다는 걸 의미합니다.

 

이땐,  endOfPaginationReached = true 함으로써 페이지의 맨 끝에 도달했음을 알리고 새로 호출을 안되게 합니다.

 

초기화 메서드에 관하여

override suspend fun initialize(): InitializeAction {
  val cacheTimeout = TimeUnit.MILLISECONDS.convert(1, TimeUnit.HOURS)
  return if (System.currentTimeMillis() - db.lastUpdated() <= cacheTimeout)
  {
    InitializeAction.SKIP_INITIAL_REFRESH
  } else {
    InitializeAction.LAUNCH_INITIAL_REFRESH
  }
}

앱이 시작 되면 모든 loadType이 실행된다고 하였습니다.

 

이때, 불필요한 refresh를 막기 위해 초기화 메서드를 지원합니다.

 

위 코드는 현재 시간과 최근 로드한 시간을 비교하여

 

기간이 짧다면 InitializeAction.SKIP_INITAL_REFRESH 플래그를 날려서 refresh를 스킵하고

 

그 반대의 경우 InitializeAction.LAUNCH_INITAL_REFRESH 플래그를 날려 refresh 합니다