주완 님의 블로그
Android - Paging 라이브러리 본문
Manifest Android InterView에 대한 내용을 읽다 문득, 생각이나서 갖고와 봤습니다.
Jetpack Paging 라이브러리란 개념인데요, 한 번 낋여보도록 하겠습니다.
- Jetpack Paging 라이브러리란?
Manifest Android InterView p.282 발췌
대규모 데이터 셋을 청크 또는 “페이지” 단위로 로드하고 표시하는 프로세스를 돕도록 설계된 안드로이드 아키텍쳐 컴포넌트입니다. 데이터 베이스나 API 와 같은 소스에서 데이터를 효율적으로 가져와야 하는 애플리케이션에 특히 유용하며, 메모리 사용량을 최소화하고 RecyclerView 기반 UI의 전반적인 성능을 향상시킵니다.
Paging 라이브러리는 데이터를 점진적으로 로드하기 위한 구조화된 접근 방식을 제공합니다. 데이터 캐싱, 재시도 메커니즘, 효율적인 메모리 사용과 같은 주요 측면을 기본적으로 처리합니다. 해당 라이브러리는 로컬데이터소스(가령, Room 데이터베이스)와 원격 소스(가령,네트워크 API) 또는 이 둘의 조합을 모두 지원합니다.
페이징 라이브러리는 로컬 데이터베이스 또는 **네트워크(Remote)**의 데이터를 페이지 단위로 UI에 쉽게 표현할 수 있도록 도와주는 라이브러리다.
라이브러리를 사용하지 않고 기존에 페이징을 구현하기 위해서는 RecyclerView와 같은 리스트 UI가 상단 또는 하단에 도달했는지 판단하는 코드를 작성하고, 다음 페이지를 로드(or Refresh)하는 코드를 또 작성해야만 했다.
페이징이 필요한 모든 화면에 동일한 코드를 작성해야만 했고 네트워크 오류, 스크롤 감지 이상(?)과 같은 예외 처리 코드도 상당했다.
위 문제점들을 포함한 여러 문제를 해결하기 위하여 Jetpack Paging Library가 출시되었다.
그리고 2021년 5월 Paging 3 라이브러리가 Stable 버전이 되었다.
Paging 2 vs Paging 3
Paging 3 정식 출시로 Paging 2.x.x 기존 API 대부분을 지원 중단한 상태이다.
무엇이 달라졌는지 간단하게만 살펴보자.
- Coroutine, Flow, LiveData, RxJava를 위한 최고 수준의 지원을 제공한다.
- 재시도(Retry)와 새로고침(Refresh) 기능을 포함하여 반응형 UI 디자인을 위해 LoadState와 Error Signal이 내장되었다.
- Paging 2의 DataSource 서브 클래스를 모두 통합하여 심플한 데이터소스 인터페이스를 제공한다.
- 취소 기능이 내장되었다.
- List Separator, Loading Header, Footer가 내장되었다.
- 데이터 캐싱이 가능하다.
⇒ 이런 차이점이 있다고 합니다
Paging 3으로 이전 | App architecture | Android Developers
구성 요소와 동작 방식을 알아보자면
페이징 라이브러리 개요 | App architecture | Android Developers
Paging 라이브러리의 구성 요소
- PagingData: 점진적으로 로드되는 데이터 스트림을 나타냅니다. RecyclerView와 같은 UI 컴포넌트에 의해 관찰되고 사용될 수 있습니다.
- PagingSource: 데이터 소스에서 데이터가 로드되는 방식을 정의하는 역할을 합니다. 위치 또는 ID와 같은 키값을 기반으로 데이터 페이지를 로드하는 메서드를 제공합니다.
- Pager: PagingSource와 PagingData 간의 중개자 역할을 합니다. PagingData 스트림의 생명주기를 관리합니다.
- RemoteMediator: 로컬 캐싱과 원격 API 데이터를 결합할 때 경계 조건을 구현하는 데 사용됩니다.

Paging 라이브러리 작동 방식
Paging 라이브러리는 데이터를 페이지로 분할하여 효율적인 데이터로딩을 가능하게 합니다. 사용자가 RecyclerView를 스크롤하면 라이브러리는 필요에 따라 새 데이터 페이지를 가져와 최소한의 메모리 사용량을 보장합니다. 이 라이브러리는Flow 또는 LiveData를 기본적으로 지원하여 데이터 변경 사항을 관찰하고 그에 따라 UI를 업데이트할 수 있도록 합니다
- PagingSource를 정의하여 데이터 가져오는 방법을 지정합니다.
- Pager를 사용하여 PagingData의 Flow를 생성합니다.
- ViewModel에서 PagingData를 관찰하고 RecyclerView에서 렌더링하기 위해 PagingDataAdapter에 전달합니다.
이렇게, 내용을 알아봤는데요 이해한 요점은
대량 데이터를 “성능 좋고, 버그 없이, 적은 코드로” 무한스크롤하기 위해서
라고 생각했습니다.
Xml을 통한 뷰 작업 시 ListView나 GridView의 단점을 보완하기 위해서 사용된 개념이 Recyclerview입니다. 뷰를 재활용하여 메모리 사용량을 줄이고 성능을 최적화하는게 주 목적이라 생각합니다. 보통 Jetpack compose 에서는 LazyColumn(row,grid등) 을 이용해서 화면에 무한 스크롤을 구현하고 있잖아요, 그래서 화면에 렌더링 될 것만을 표시해서 recylerview와 같이 최적화 시키는 것으로 이해하고 있는데, 여러 프로젝트를 진행하면서 의문이 든 점은.
<aside>
LazyColumn 에 해당하는 item을 서버에서 받아오는 상황에서 (앞서 말한, 네트워크 API 연동)
“초기 화면에 10개정도의 item을 나타낸다” 를 가정 해봅시다.
- 서버에서 100~1000개 정도 되는 item을 한번에 요청해서 필요시에 스크롤을 통해 렌더링 하는 것
- 필요할때마다 pager 단위를 통해, 10개 정도의 item을 load 하는 것 (수동처리 or Paging)
두 개의 방식에 대한 차이점이 궁금했습니다.
</aside>
간단한 계층으로 나눠진 예시를 한 번 봐볼 까요?
먼저 , Paging 없이 일괄적으로 로드하는 방식을 살펴보겠습니다.
- Dto
// LazyColumn에 들어갈 Item 데이터 클래스
data class Item(
val id: Int,
val title: String,
val description: String
)
- Api Service
// API Service 인터페이스 (Retrofit 가정)
interface MyApiService {
// Non-Paging: 전체 데이터를 한 번에 가져옴
@GET("items/all")
suspend fun getItemsAll(): List<Item>
// Paging: 페이지와 개수를 지정하여 데이터를 가져옴
@GET("items/paged")
suspend fun getItemsPaged(
@Query("page") page: Int,
@Query("limit") limit: Int
: List<Item>
}
- ViewModel
class NonPagingViewModel(private val apiService: MyApiService) : ViewModel() {
// 전체 1000개 데이터가 메모리에 저장됨
private val _items = MutableStateFlow<List<Item>>(emptyList())
val items: StateFlow<List<Item>> = _items
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading
// 화면 진입 초기에 모든 Item을 가져온다고 가정
init {
loadAllItems()
}
private fun loadAllItems() {
viewModelScope.launch {
_isLoading.value = true
try {
// 1. API에 전체 데이터 1000개 요청 및 수신
val allData = apiService.getItemsAll()
_items.value = allData // 2. 전체 데이터를 메모리에 저장 (높은 메모리 부하)
} catch (e: Exception) {
// 오류 처리
} finally {
_isLoading.value = false
}
}
}
}
- 컴포져블 함수
@Composable
fun NonPagingScreen(viewModel: NonPagingViewModel = viewModel()) {
val items by viewModel.items.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
if (isLoading) {
// 로딩 스피너 표시
} else {
LazyColumn {
// 3. LazyColumn은 메모리에 있는 1000개 데이터를 참조하여 뷰만 재활용함
items(
items = items,
key = { it.id } // 뷰 재활용 최적화
) { item ->
ListItemView(item)
}
}
// 4. 스크롤 끝 감지 로직이 필요 없음 (데이터가 이미 다 로드됨)
}
}
}
일반적으로는 초기에 로드 시키기 때문에, 한 번에 불러온 데이터를 메모리에 적재 시키고 LazyColumn을 통해 필요시에 자동으로 화면에 렌더링 합니다. 가장 원초적인 방법으로 스크롤에 대한 상태나 임계점에 대한 추가로직을 구현하지 않아도 됩니다. 하지만, 말 그대로 한 번에 많은 양의 데이터를 메모리에 적재 시키기 때문에 부하의 위험이 있어 수동으로 상태와 임계점을 관리해 주기도 합니다!
⇒ 이런식으로도 수동 처리가 가능하다고 하네요( 의문의 제보자 분이 주신 코드입니다)
@Composable
fun LazyListState.OnBottomReached(
// tells how many items before we reach the bottom of the list
// to call onLoadMore function
buffer: Int = 0,
onLoadMore: () -> Unit,
) {
// Buffer must be positive.
// Or our list will never reach the bottom.
require(buffer >= 0) { "buffer cannot be negative, but was $buffer" }
val shouldLoadMore = remember {
derivedStateOf {
val lastVisibleItem = layoutInfo.visibleItemsInfo.lastOrNull()
?: return@derivedStateOf false
lastVisibleItem . index >= layoutInfo . totalItemsCount -1 - buffer
}
}
LaunchedEffect (shouldLoadMore) {
snapshotFlow { shouldLoadMore.value }.collectLatest {
if (it) {
onLoadMore()
}
}
}
}
// 어렵군요
그렇다면, 이번에는 Paging 라이브러리를 사용해 볼까요?
위와 같은 Dto, APi service 형식을 갖는다고 가정하고 진행해봅시다.
- build.gradle
dependencies {
val paging_version = "3.3.6"
implementation("androidx.paging:paging-runtime:$paging_version")
// alternatively - without Android dependencies for tests
testImplementation("androidx.paging:paging-common:$paging_version")
// optional - RxJava2 support
implementation("androidx.paging:paging-rxjava2:$paging_version")
// optional - RxJava3 support
implementation("androidx.paging:paging-rxjava3:$paging_version")
// optional - Guava ListenableFuture support
implementation("androidx.paging:paging-guava:$paging_version")
// optional - Jetpack Compose integration
implementation("androidx.paging:paging-compose:3.4.0-alpha04")
}
- PagingSource( 데이터 로그 로직 정의)
class MyPagingSource(
private val apiService: MyApiService,
private val limit: Int = 10
) : PagingSource<Int, Item>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Item> {
return try {
// 1. 키(페이지 번호)를 가져옴. 없으면(초기로드시) 시작 페이지 1
val currentPage = params.key ?: 1
// 2. API에 현재 페이지와 개수(limit)만 요청
val response = apiService.getItemsPaged(page = currentPage, limit = limit)
LoadResult.Page(
data = response,
prevKey = if (currentPage == 1) null else currentPage - 1,
// 3. 다음 페이지 키를 설정하여 라이브러리가 다음 로드를 준비하게 함
nextKey = if (response.isEmpty()) null else currentPage + 1
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, Item>): Int? {
// 새로고침 시 키 관리 로직
return state.anchorPosition?.let { anchorPosition ->
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
}
}
}
- 역할: 어디서(API 또는 DB) 데이터를 가져올지와 어떤 키(페이지 번호 등)를 사용할지를 정의합니다.
- 동작: PagingSource의 load() 함수에서 실제 네트워크 요청이 이루어지며, 결과를 LoadResult.Page 형태로 반환할 때 다음 페이지를 위한 키(nextKey)를 라이브러리에 알려줍니다.
- ViewModel 을 통해 PagingData Flow 생성 및 관리
class PagingViewModel(private val apiService: MyApiService) : ViewModel() {
// Pager를 사용하여 PagingData의 Flow를 생성
val items = Pager(
config = PagingConfig(
pageSize = 10, // 페이지 당 항목 수 (API 호출 단위)
enablePlaceholders = true
),
pagingSourceFactory = { MyPagingSource(apiService) } // PagingSource 연결
).flow.cachedIn(viewModelScope) // ViewModel 생명주기에 맞게 캐싱
}
- Pager: PagingSource를 기반으로 무한하고 점진적인 데이터 스트림인 Flow<PagingData>를 생성합니다. PagingConfig를 통해 pageSize, prefetchDistance 등의 로드 정책을 설정합니다.
- PagingData: 로드된 데이터 페이지들을 담고 있는 불변(Immutable) 컨테이너입니다. 이 스트림은 메모리 내 캐싱을 담당하며, ViewModel의 cachedIn()을 통해 생명주기 동안 유지됩니다.
- Composable (LazyPagingItems 사용)
@Composable
fun PagingScreen(viewModel: PagingViewModel = viewModel()) {
// 4. Flow<PagingData>를 LazyPagingItems로 변환
val lazyPagingItems = viewModel.items.collectAsLazyPagingItems()
LazyColumn {
// 5. Paging 라이브러리가 제공하는 items 확장 함수 사용
items(
items = lazyPagingItems,
key = { it.id }
) { item ->
if (item != null) {
ListItemView(item)
} else {
// Placeholder UI (로딩 중이거나 데이터가 아직 로드되지 않았을 때)
}
}
// 6. 로딩 상태(푸터) 자동 표시
lazyPagingItems.apply {
when (loadState.append) { // 다음 페이지 로딩 상태
is LoadState.Loading -> {
item { LoadingFooter() }
}
is LoadState.Error -> {
item { ErrorMessage { retry() } } // retry() 기능 내장
}
else -> {}
}
}
// 7. 스크롤 도달 감지 및 다음 페이지 로드는 Paging 라이브러리가 자동으로 처리
}
}
- 스크롤 감지 및 로드 트리거: Composable의 collectAsLazyPagingItems()가 PagingData를 LazyPagingItems로 변환합니다. 이 객체는 LazyColumn의 스크롤을 자동으로 감지하여, 미리 가져오기 거리(Prefetch Distance)에 도달하면 PagingData에게 다음 페이지 로드를 요청하는 신호를 보냅니다. (이것이 수동으로 LazyListState를 관찰하는 코드가 필요 없는 이유입니다.)
- 상태 자동 처리: lazyPagingItems.loadState를 통해 초기 로드(refresh) 및 추가 로드(append)의 로딩/오류 상태를 표준화된 방식으로 제공하며, retry()와 refresh() 함수를 내장하여 간결한 예외 처리를 가능하게 합니다.
RemoteMediator 이 빠졌는데요? 그러게요
RemoteMediator는 Paging 라이브러리를 사용할 때 '네트워크 데이터'와 '로컬 데이터베이스 캐시(Room 등)'를 결합하여 계층화된 데이터 소스를 처리해야 할 때 사용됩니다.
즉, 오프라인 환경 지원이나 네트워크 부하 감소를 위해 데이터를 로컬 DB에 캐싱하는 복잡한 시나리오에서 핵심적인 역할을 합니다.
RemoteMediator는 위와 같은 상황에서 사용한다고 하는데, Room DB를 사용해본적이 개념공부할때 밖에 없었어서. 사실 저기까진 아직 잘 모르겠네요... 나중에 공부해 보도록 하겠습니다.
위와 같이 Paging 라이브러리를 사용했을 때 기존의 수동 처리 방식과의 차이를 정리해보자면
1. 스크롤 위치 및 도달 감지 자동화
Paging 라이브러리의 Compose 통합 패키지(paging-compose)는 LazyPagingItems 객체를 통해 LazyColumn의 스크롤 상태를 자동으로 감시합니다.
- 자동 감지: 개발자가 LazyListState를 만들고, LaunchedEffect와 snapshotFlow를 이용해 *'스크롤이 끝에서 5번째 아이템에 도달했는지*와 같은 복잡한 로직을 짤 필요가 없습니다.
- 로드 트리거: LazyPagingItems는 사용자가 설정된 **'미리 가져오기 거리(Prefetch Distance)'**에 가까워지면, 다음 페이지 로드를 위한 API 호출을 자동으로 트리거하고 PagingSource에 키를 전달합니다.
2. 데이터 스트림 구독 및 리스트 관리 자동화
viewModel.items.collectAsLazyPagingItems()는 Flow<PagingData>를 Compose가 인식하는 상태로 변환하고, 데이터 업데이트를 효율적으로 처리합니다.
- 리스트 통합: PagingSource가 10개씩 나눠 보낸 페이지 데이터들을 LazyPagingItems가 받아 하나의 연속적인 리스트처럼 자동으로 통합하고 관리해 줍니다.
- DiffUtil 성능 최적화: 새로운 데이터 페이지가 로드될 때마다, Paging 라이브러리는 내부적으로 DiffUtil과 유사한 메커니즘을 사용하여 리스트 전체를 다시 그리지 않고 변경된 항목만 효율적으로 업데이트합니다. 이는 RecyclerView나 일반 List를 수동으로 관리할 때 발생하는 성능 문제를 방지합니다.
3. 상태 관리 및 재시도 기능 내장
LazyPagingItems 객체는 데이터 로드와 관련된 모든 상태와 유틸리티 함수를 내장하고 있어, 오류 처리 및 새로고침 코드가 매우 간결해집니다.
- 정형화된 상태: 초기 로드(refresh), 다음 페이지 로드(append), 이전 페이지 로드(prepend)에 대한 Loading, Error, NotLoading 세 가지 상태를 loadState 속성을 통해 정형화하여 제공합니다.
- 이를 통해 개발자는 when (loadState.append) 구문으로 로딩 푸터나 오류 메시지를 쉽게 분기할 수 있습니다.
- 원클릭 재시도/새로고침: 네트워크 오류 발생 시 호출하는 retry() 함수와 전체 목록을 처음부터 다시 로드하는 refresh() 함수가 내장되어 있습니다.
- 개발자는 오류 시 페이지 번호 초기화, 데이터 비우기, API 재호출 등의 복잡한 수동 로직 없이, 간단히 items.retry()만 호출하면 됩니다.
메모리 효율에 대해서 수치화된 무언가를 보여드리고 싶은데, 아직 적용할 기회가 없어서 못보여드리는 점 죄송합니다..
이렇게, Paging 라이브러리에 대해서 알아봤는데요, 사실 프로젝트를 진행하거나 앱잼을 진행할 시기에는 불러오는 데이터의 크기가 엄청나게 많지는 않기 때문에 굳이..? 라는 생각을 하실 수 도 있습니다.
하지만, 나중에 앱을 릴리즈 하고 사용자가 많아지고(싶다.) ,대용량의 데이터를 처리하게 되었을 때 효율적으로 사용할 수 있는 방법이 있지 않을까해서 공부하면서 정리해봤습니다.
'Android' 카테고리의 다른 글
| Android - jetpack navigation 사용시 중첩 네비게이션에 대하여 (0) | 2025.11.11 |
|---|---|
| android- 이미지 라이브러리 Coil (1) | 2025.11.07 |
| 회원가입 플로우 아키텍처 분석: 다중 vs 단일 ViewModel , Navigation구조 (3) | 2025.08.27 |
| 구조 분해 선언과 component 함수 (kotlin in action) (1) | 2025.06.11 |
| 안드로이드 테스트 코드 작성 + Jetpack Compose UI 테스트 (1) | 2025.06.08 |