목록형 UI를 구현할 때 개발자는 고려해야 할 것이 있다.
만약 목록의 모든 데이터를 가져와 보여주려고 한다면 해당 페이지를 보여줄 때 엄청나게 많은 부하와 로딩 시간이 필요할 것이다.
그래서 개발자가 사용하는 기법은 사용자한테 보여주는 데이터 일부만을 가져와 부분 부분 보여주는 기법인데,
컴퓨터에서 크롬과 같은 웹 페이지에서 1페이지 2페이지로 넘어가는 것과는 다르게 모바일에서는 폰을 그대로 스크롤하며 주르륵 뜨는 UX를 많이 경험했을 것이다.
Paging 라이브러리는 로컬 저장소에서나 네트워크를 통해 대규모 데이터 세트의 데이터 페이지를 로드하고 표시할 수 있고, 앱에서 네트워크 대역폭과 시스템 리소스를 모두 더 효율적으로 사용할 수 있도록 구현된 라이브러리이다.
Paging 라이브러리의 이점은 다음과 같다.
- Paging 된 데이터의 메모리 내 캐싱하여 시스템 리소스를 효율적으로 사용할 수 있다.
- 요청 중복 삭제 기능이 기본 제공되므로 앱에서 네트워크 대역폭과 시스템 리소스를 효율적으로 사용할 수 있다.
- 사용자가 로드된 데이터의 끝까지 스크롤할 때 Paging의 RecyclerView 어댑터가 자동으로 데이터를 요청
- Kotlin coroutines 및 Flow뿐만 아니라 LiveData 및 RxJava를 최고 수준으로 지원
- 새로고침 및 재시도 기능을 포함하여 오류 처리를 기본으로 지원한다.
Paging3
PagingSource
목록의 특정 페이지의 데이터 묶음을 로드하는 기본 클래스
로컬이나 서버에서 페이징 단위로 데이터를 가져오는 역할을 수행한다.
일반적인 PagingSource는 구현된 load() 메서드를 사용하여 적합한 데이터를 로드한다.
다음은 android developer에 나와있는 PagingSource의 예시 코드이다.
class ExamplePagingSource(
val backend: ExampleBackendService,
val query: String
) : PagingSource<Int, User>() {
override suspend fun load(
params: LoadParams<Int>
): LoadResult<Int, User> {
try {
// Start refresh at page 1 if undefined.
val pageNumber = params.key ?: 1
val response = backend.searchUsers(query, nextPageNumber)
return LoadResult.Page(
data = response.users,
prevKey = null, // Only paging forward.
nextKey = pageNumber + 1
)
} catch (e: Exception) {
// Handle errors in this block and return LoadResult.Error for
// expected errors (such as a network failure).
}
}
override fun getRefreshKey(state: PagingState<Int, User>): Int? {
// Try to find the page key of the closest page to anchorPosition from
// either the prevKey or the nextKey; you need to handle nullability
// here.
// * prevKey == null -> anchorPage is the first page.
// * nextKey == null -> anchorPage is the last page.
// * both prevKey and nextKey are null -> anchorPage is the
// initial page, so return null.
return state.anchorPosition?.let { anchorPosition ->
val anchorPage = state.closestPageToPosition(anchorPosition)
anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
}
}
}
예시 코드의 load()의 nextKey의 값을 좀 더 쉽게 이해하기 위해 임의로 수정하였다.
• as-is : nextKey = response.nextPageNumber
• to-be : nextKey = PageNumber + 1
이에 대한 설정은 pagingConfig 설정에 따라 예상치 못한 동작을 일으킬 수 있으며, 이에 대한 설명은 밑에서 추가적으로 설정한다.
필요한 내용만 한눈에 보기 위해 load() 메서드만 가져오면 다음과 같다.
override suspend fun load(
params: LoadParams<Int>
): LoadResult<Int, User> {
try {
val pageNumber = params.key ?: 1
val response = backend.searchUsers(query, nextPageNumber)
return LoadResult.Page(
data = response.users,
prevKey = null, // Only paging forward.
nextKey = pageNumber + 1
)
} catch (e: Exception) { }
}
일반적으로 이 코드를 통해서 서버 데이터를 page, size(페이지의 크기)를 통해 서버한테 요청하여 데이터를 가져오는 역할을 수행한다.
예시에는 backend.searchUsers(query, nextPageNumber)로 적혀있지만,
보통 클라이언트가 필요한 size 만큼 서버한테 요청을 보내 데이터를 받아온다.
load() 메서드의 매개 변수인 객체 LoadParams에는 수행될 로드 작업에 대한 정보가 들어 있다.
여기에는 로드할 키와 로드할 항목 수가 포함된다.
쉽게 말해서 page, size 정보가 포함되는데,
어떻게 포함되는 걸까? 에 대한 부분은 뒤에서 설명된다.
load() 메서드의 반환 값인 객체 LoadResult는 로드 작업의 결과를 포함한다.
호출이 성공했는지 LoadResult여부에 따라 두 가지 형태 중 하나를 취하는 sealed class이다.
- 로드에 성공: LoadResult.Page
- 로드에 실패: LoadResult.Error
다음의 예는 load()가 호출되면서 key를 수신하고 그다음 load를 위해 key를 제공하는 방식을 보여준다.
첫 Page를 가져올 때 nextKey에 다음 Page position을 알려주어 다음 load() 호출의 key를 지정해 준다.
return LoadResult.Page(
data = response.users,
prevKey = null, // Only paging forward.
nextKey = pageNumber + 1
)
쉽게 말해서 LoadResult.Page의 data에 서버(or 로컬)에서 가져온 데이터 묶음을 전달해 주고 nextKey는 다음에 가져올 묶음의 page를 알려준다.
그래서 보통 서버와 협업하여 Page와 Size가 정해져 있는 API를 설계해 놓는다.
val response = backend.searchUsers(query, page, size)
그리고 우리는 이 형식을 이용하여 Paging3에서 재공해 주는 LoadParams를 적극적으로 활용할 수 있다.
val page = params.key ?: STARTING_KEY // STARTING_KEY: 서버와 협의한 첫 시작 Page
val size = params.loadSize
LoadParams의 params로 굳이 가져올 필요가 있나?
그냥 size는 50이나 30을 상수로 설정해 주면 되지 않나?라고 생각할 수 있다.
하지만 작업을 하다 보면 paging 기법은 상당히 많은 곳에서 사용되고 경우에 따라 각각 다른 Size를 사용해야 하는 페이지가 존재할 것이다.
프로젝트에서는 많은 곳에서 paging3 라이브러리를 사용하기 때문에 PagingSource와 같은 구현부는 BasePagingSource를 만들어 공통화하고자 하는 경우가 많을 것이다.
그럴 때 size 같은 변수를 상수로 고정하는 것이 아닌 LoadParams의 정보를 활용한다면 좀 더 유연성 있는 설계 가능하다.
또한, PagingConfig 설정을 제대로 활용하기 위해서는 params 설정을 사용해야 하는데,
그래서, LoadParams key값은 LoadResult.Page의 nextKey로 결정된다면,
서버/로컬에서 가져오고자 설정되어 있는 데이터 Size.
initalLoadSize 설정 값에 따라 설정한 PageSize와 다르게 설정될 수 있다.
LoadParams는 loadSize는 어디서 결정되는 것인가?
PagingConfig
페이징 동작을 결정하는 매개변수를 정의하는 클래스
여기에는 페이지 크기, 자리표시자의 사용 설정 여부 등이 포함된다.
Pager
PagingData 스트림을 생성하는 클래스
데이터를 새로고침할 때마다 자체 PagingSource로 지원되는 상응하는 PagingData 내보내기가 별도로 생성된다.
그것은 PagingConfig를 통해 설정해 줄 수 있다.
Pager(
// Configure how data is loaded by passing additional properties to
// PagingConfig, such as prefetchDistance.
config = PagingConfig(pageSize = 20, , enablePlaceholders = false),
pagingSourceFactory = { ExamplePagingSource(backend, query) }
).flow
여기서 Pager는 PagingData의 pagingSourceFactory에 전달된 DataSource를 통해 다음과 같은 스트림을 반환한다.
Flow<PagingData<User>>
또, 여기서 보이는 enablePlaceholders를 true로 설정할 경우, 아직 로드되지 않은 항목에 대해 null을 반환한다.
이걸 통해 개발자는 플레이스홀더 뷰를 표시할 수 있다.
(enablePlaceholders의 기본값은 false이다)
그 밖에도 PagingConfig를 이용하여 유용한 설정을 할 수 있다.
PagingConfig(
pageSize: Int,
prefetchDistance: @IntRange(from = 0) Int,
enablePlaceholders: Boolean,
initialLoadSize: @IntRange(from = 1) Int,
maxSize: @IntRange(from = 2) Int,
jumpThreshold: Int
)
initialLoadSize
첫 번째 로드 데이터의 Size를 결정한다.
PagingSource 일반적으로 처음으로 데이터를 로딩할 때 PageSize보다 큰 요청된 로드 크기를 정의하는데, 이 값이 initalLoadSize이다. 따라서 첫 번째 로드 데이터에는 작은 스크롤을 포함할 만큼 충분히 넓은 범위의 콘텐츠가 로드된다.
쉽게 말하면, 여기서 Paging은 첫 페이지를 로딩할 때 사용자에게 끊김 없는 스크롤을 제공해 주기 위해 기존 size의 배수를 로딩해 오는데 기본값은 다음과 같으며,
DEFAULT_INITIAL_PAGE_MULTIPLIER 값은 3으로 설정되어 있기 때문에 기본값은 3 배수이다.
pageSize * DEFAULT_INITIAL_PAGE_MULTIPLIER,
그렇기 때문에 만약 첫 page(1이라고 할 때)를 로딩해 올 때 pageSize가 20으로 설정되어 있을 경우,
(1, 60)로 호출을 해온다.
[params.loadSize = 60]
자, 그렇다면 우리는 PagingSource에서 고려해주어야 할 부분이 있다.
PagingSource의 load()의 반환값은 다음과 같았는데, 여기서 nextKey를 단순히 + 1로 설정할 경우,
initialLoadSize 설정 때문에 중복된 데이터를 로딩해 올 수 있는 것이다.
return LoadResult.Page(
data = response.users,
prevKey = null, // Only paging forward.
nextKey = pageNumber + 1
)
그렇기 때문에 다음과 같은 처리가 추가적으로 필요하다.
val nextKey = if (repos.isEmpty()) {
null
} else {
// initial load size = 3 * NETWORK_PAGE_SIZE
// ensure we're not requesting duplicating items, at the 2nd request
position + (params.loadSize / NETWORK_PAGE_SIZE)
}
여기서 key값이 null인 경우 스크롤이 끝났다고 인식하여 paging은 데이터 로딩해 오는 것을 중지하게 된다.
실제로 업무 할 때는 없는 페이지에 대한 값을 무조건 호출하는 것이 쓸데없는 부하를 높인다고 가져온 데이터의 크기가 size보다 작을 경우 nextKey를 Null로 설정하여 불필요한 호출을 막기도 하였다.
maxSize
PagingData페이지를 삭제하기 전에 로드할 수 있는 최대 항목 수를 정의한다.
쉽게 말하면 메모리로 들고 있을 paging 데이터 최대 개수를 뜻하는 것이다.
예시로 보여준 로직처럼 단순히 구현될 경우,
페이징은 30개씩 호출되며 사용자가 계속 목록을 내릴 경우 +9999개까지 메모리에 가지고 있게 된다.
(서버에 그만큼 많은 데이터가 있다는 전제하에)
그 최대 개수를 강제하는 설정이 maxSize인 것이다.
이 또한 설정할 경우 고려해주어야 할 부분이 있다.
여기서 단순히 prevKey의 값을 null로 설정하여 아래로만 로딩되는 PagingSorce를 구현했는데,
return LoadResult.Page(
data = response.users,
prevKey = null, // Only paging forward.
nextKey = response.nextPageNumber + 1
)
이렇게 구현할 경우 maxSize로 삭제된 상단의 데이터를 볼 수 없게 된다.
그렇기 때문에 적절한 prevkey를 설정해주어야 하며, 보완한다면 다음과 같을 것이다.
prevKey = when (pageNumber) {
STARTING_KEY -> null
else -> when (val prevKey = ensureValidKey(key = pageNumber)) {
// We're at the start, there's nothing more to load
STARTING_KEY -> null
else -> prevKey
}
}
/**
* Makes sure the paging key is never less than [STARTING_KEY]
*/
private fun ensureValidKey(key: Int) = max(STARTING_KEY, key)
또한, 이렇게 PagingConfig 설정과 Pager로 생성된 PagerData 스트림은 viewModel에서 cachedIn()을 통해 paging 된 데이터를 메모리에 캐싱해야 한다.
이때 PagingData Flow는 목록의 상태에 따라 유기적으로 동작하기 때문에 다른 Flows와 함께 사용되거나 결합하지 되어서는 안 되며, 독립적으로 사용되어야 한다.
PagingFlow
.cachedIn(viewModelScope)
이렇게 설정되어야 paging 데이터가 생애주기에 따라 손실되어 재호출 되는 부하를 줄일 수 있다.
참고 자료
https://developer.android.com/topic/libraries/architecture/paging/v3-overview
페이징 라이브러리 개요 | App architecture | Android Developers
이 페이지는 Cloud Translation API를 통해 번역되었습니다. 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Paging 라이브러리 개요 Android Jetpack의 구성요소 Paging
developer.android.com
https://developer.android.com/codelabs/android-paging-basics?hl=ko#0
Android Paging 기본사항 | Android Developers
이 Codelab에서는 목록을 표시하는 앱에 Paging 라이브러리를 통합합니다. Paging 라이브러리를 사용하면 로컬 저장소에서나 네트워크를 통해 대규모 데이터 세트의 데이터 페이지를 로드하고 표시
developer.android.com
https://developer.android.com/codelabs/android-paging?hl=ko#0
Android Paging 고급 Codelab | Android Developers
이 Codelab에서는 Paging 라이브러리를 포함하도록 샘플 앱을 수정하여 앱의 메모리 공간을 줄입니다.
developer.android.com
https://developer.android.com/reference/kotlin/androidx/paging/PagingConfig
PagingConfig | API reference | Android Developers
androidx.appsearch.builtintypes.properties
developer.android.com
'Android > 학습' 카테고리의 다른 글
[Android] RecyclerView의 동작 과정 (5) | 2025.05.16 |
---|---|
[Kotlin] Kotlin Coroutine with Dispatcher (1) | 2025.04.09 |
[Android] Fragment Manager, Basic 편 (1) | 2025.02.16 |
[Kotlin] StringBuilder (String 메모리 저장 방식과 StringBuffer를 곁들인..) (0) | 2024.12.12 |
[Android] ViewModel Instance 생성 with ViewModelProvider (3) | 2024.11.28 |