훌륭한 개발자가 되기 위하여
Jetpack Compose에서 Paging3로 대용량 데이터 처리하기 본문
Paging을 적용 하게 된 계기
프로젝트 구현 중 API를 통해 대규모 운동 데이터를 가져와야 했다.
처음에는 단순히 API 호출 후 모든 데이터를 한 번에 불러오는 방식을 사용했지만, 다음과 같은 문제가 발생했다.
- 초기 로딩 시간이 길어짐
- 한 번에 많은 데이터를 메모리에 올려 리소스 낭비
- 스크롤 시 사용하지 않는 데이터도 계속 유지됨
이 문제를 해결하기 위해 Jetpack Paging3 라이브러리를 도입했다.
Paging 라이브러리를 사용하면 로컬 DB 또는 네트워크로부터 대규모 데이터를 "페이지 단위"로 로드하고 UI에 표시할 수 있다.
Paging3는 Google 권장 아키텍처(MVVM)에 맞게 설계되어 있어 Repository - ViewModel - UI 레이어가 명확하게 분리된다.
Paging란 무엇인가?
Paging는 Android Jetpack의 일부로, 대규모 데이터 세트를 점진적으로 로드하고 표시하도록 도와주는 라이브러리이다.
🥸 핵심 아이디어
"사용자가 실제로 필요로 하는 데이터만, 필요한 시점에 로드하자"
Paging의 장점
- 메모리 사용 최적화
- Paging된 데이터는 필요한 만큼만 메모리에 유지
- 스크롤에서 벗어난 데이터는 자동으로 정리됨
- 네트워크 요청 최적화
- 요청 중복 제거 기능 기본 제공
- 네트워크 대역폭과 시스템 리소스를 효율적으로 사용
- 자동 데이터 로딩
- 사용자가 리스트의 끝까지 스크롤하면 어댑터가 자동으로 다음 페이지 요청
- 무한 스크롤 기능 구현 가능
- 강력한 비동기 지원
- Kotlin Coroutine / Flow
- LiveData
- RxJava
- 기본 제공되는 에러 처리
- 새로고침 (Refresh)
- 재시도 (Retry)
- 로딩 상태 관리 (LoadState)
Paging 전체 구조
Repository 레이어
👉 PagingSource : 데이터 소스에서 페이지 단위로 데이터를 불러오는 역할을 한다.
PagingSource는 데이터를 어디서, 어떻게 가져올 것인가만 책임진다.
순수한 데이터 로더이며, 언제 load()를 호출할지는 Pager와 UI 레이어가 결정한다.
주요 역할
- 네트워크 API 호출
- 로컬 DB 조회 (Room DB)
- 다음 페이지 키 계산
class ExercisePagingSource(
private val exerciseRemoteDataSource: ExerciseRemoteDataSource,
private val name: String?,
private val bodyPart: String?
) : PagingSource<String, ExerciseEntity>() {
override suspend fun load(params: LoadParams<String>): LoadResult<String, ExerciseEntity> {
return try {
val cursor = params.key
val result = exerciseRemoteDataSource.getExercises(cursor, params.loadSize, name, bodyPart)
LoadResult.Page(
data = result.data,
prevKey = if (result.hasPreviousPage) result.prevCursor else null,
nextKey = if (result.hasNextPage) result.nextCursor else null
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<String, ExerciseEntity>): String? {
return state.anchorPosition?.let { anchorPosition ->
state.closestPageToPosition(anchorPosition)?.prevKey
?: state.closestPageToPosition(anchorPosition)?.nextKey
}
}
}
load() 함수
- PagingSource<Key, Value>
- Key는 데이터를 로드하는 데 사용되는 식별자를 정의
- Value는 데이터 자체의 유형
Ex) 필자의 경우, String 형태의 Cursor를 Retrofit에 전달하여 네트워크에서 Exercise 객체의 페이지를 로드하고 있다.
- LoadParams : 실행할 로드 작업에 관한 정보가 포함되어 있다.
- Key : 현재 페이지 키 (null이면 첫 페이지)
- loadSize : 요청할 데이터 개수
- ... 등이 있다.
- LoadResult : 로드 작업의 결과
- LoadResult.Page : 정상 로드
- LoadResult.Error : 에러 발생
getRefreshKey() : 새로고침 시 기준이 되는 Key를 반환한다.
Ex) : 화면 회전이나 refresh가 발생했을 때, 현재 사용자가 보고 있던 위치(anchorPosition)을 기준으로 가장 가까운 페이지의 key를 반환하여 비슷한 위치부터 다시 로드하도록 돕는다.
👉 RemoteMediator : 네트워크 + 로컬 DB 캐시를 함께 사용하는 경우에 사용된다. (선택사항)
- 네트워크에서 데이터 가져오기
- Room DB에 저장
- PagingSource는 DB만 바라보도록 설계
RemoteMediator 사용시 효과적인 경우
- 오프라인에서도 데이터 표시가 필요할 때
- 네트워크 데이터를 로컬 DB에 캐싱해야 할 때
- 앱 재실행 시 이전 데이터를 유지해야 할 때
ViewModel 레이어
👉 PagingConfig : 페이징 동작 방식을 설정하는 구성 객체
override fun getPagedExercises(
pageSize: Int,
name: String?,
bodyPart: String?
): Flow<PagingData<Exercise>> {
return Pager(
config = PagingConfig(
pageSize = pageSize,
enablePlaceholders = false
),
pagingSourceFactory = {
ExercisePagingSource(
exerciseRemoteDataSource,
name,
bodyPart
)
}
).flow.map { pagingData -> pagingData.map { it.toDomain() } }
}
- pageSize : 한 페이지 당 로드 개수
- initialLoadSize : 최초 로드 개수 (Default 값은 pageSize * 3이다.)


- prefetchDistance : 미리 로드할 거리
- enablePlaceholders : 빈 아이템 표시 여부
👉 Pager : PagingSource와 PagingConfig를 연결하여 Flow<PagingData<T>>를 생성한다.
👉 PagingData
- 페이지로 나뉜 데이터 스냅샷
- 불변(Immutable) 객체
- UI에 전달되는 단위
private fun getPagedExercises(): Flow<PagingData<ExerciseModel>> {
return _directoryUIState
.map { it.query to it.bodyPart?.apiValue }.debounce(300)
.distinctUntilChanged()
.flatMapLatest { (query, bodyPart) ->
if (bodyPart == BodyPartModel.BOOKMARK.apiValue) {
_directoryUIState
.map { state ->
PagingData.from(
state.bookmarkExercises
.filter { it.name.contains(query ?: "", ignoreCase = true) }
)
}
} else {
getPagedExercisesUseCase(10, query, bodyPart)
.map { pagingData -> pagingData.map { it.toPresentation() } }
}
}
.cachedIn(viewModelScope)
}
- 검색어 변경 시 이전 Paging 요청을 취소하기 위해 flatMapLatest를 사용했다.
- debounce를 적용하여 사용자의 빠른 입력으로 인한 불필요한 네트워크 요청을 방지하였다.
- cachedIn(viewModelScope)을 적용하여 화면 회전(Configuration Change)시에도 이미 로드된 데이터를 재사용하도록 하였다.
UI 레이어 (Compose)
LazyPagingItems
Compose에서는 PagingDataAdapter 대신 collectAsLazyPagingItems()를 사용한다.
//ViewModel
val pagingExercises: Flow<PagingData<ExerciseModel>> = getPagedExercises()
// ui
val exercises = viewModel.pagingExercises.collectAsLazyPagingItems()
LaunchedEffect(exercises.loadState) {
val errorState = exercises.loadState.refresh as? LoadState.Error
errorState?.let {
viewModel.onPagingError(it.error)
}
}
...
@Composable
fun DirectoryScreen(
state: DirectoryUIState,
exercises: LazyPagingItems<ExerciseModel>,
...
snackBarHostState: SnackbarHostState
) {
Scaffold(snackbarHost = { SnackbarHost(snackBarHostState) }) { _ ->
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
...
LazyColumn(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(vertical = 10.dp, horizontal = 16.dp)
) {
items(
exercises.itemCount,
key = { index -> exercises[index]?.id ?: index }
)
{ index ->
val exercise = exercises[index]
val isBookmarked = state.bookmarkExercises.any { it.id == exercise?.id }
if (exercise != null) {
DirectoryItem(
exercise = exercise,
isBookmarked = isBookmarked,
onItemClick = { onDirectoryItemClick(exercise.id, isBookmarked) },
onBookmarkClick = { toggleBookmark(exercise, isBookmarked) })
Spacer(modifier = Modifier.height(2.dp))
if (index < exercises.itemCount - 1) {
HorizontalDivider(
modifier = Modifier.fillParentMaxWidth(),
color = MaterialTheme.colorScheme.onSurface,
thickness = 1.dp
)
}
}
}
}
}
}
}
Paging 동작 흐름
- UI에서 colletAsLazyPagingItems() 호출
- Pager가 PagingSource 생성
- PagingSource.load() 호출
- LoadResult.Page 반환
- PagingData 생성
- UI는 필요한 만큼만 데이터 소비
- 스크롤시 append 로드 자동 호출
참고자료
https://developer.android.com/topic/libraries/architecture/paging/v3-overview?hl=ko
https://developer.android.com/codelabs/android-paging?hl=ko#19
'안드로이드' 카테고리의 다른 글
| Jetpack Compose로 Custom Calendar 구현하기 (0) | 2025.10.26 |
|---|---|
| Paging 검색 기능에 Debounce를 적용하여 성능 개선하기 (0) | 2025.10.02 |
| [JetpackCompose] Compose Side Effect (0) | 2025.08.14 |
| [Jetpack Compose] State Hoisting이란? (0) | 2025.08.09 |
| [Jetpack Compose] State와 Remember란? (0) | 2025.08.06 |