Notice
Recent Posts
Recent Comments
«   2026/07   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
Tags
more
Archives
Today
Total
관리 메뉴

훌륭한 개발자가 되기 위하여

Jetpack Compose에서 Paging3로 대용량 데이터 처리하기 본문

안드로이드

Jetpack Compose에서 Paging3로 대용량 데이터 처리하기

jay20033 2025. 9. 18. 21:10

Paging을 적용 하게 된 계기

프로젝트 구현 중 API를 통해 대규모 운동 데이터를 가져와야 했다.

처음에는 단순히 API 호출 후 모든 데이터를 한 번에 불러오는 방식을 사용했지만, 다음과 같은 문제가 발생했다.

  • 초기 로딩 시간이 길어짐
  • 한 번에 많은 데이터를 메모리에 올려 리소스 낭비
  • 스크롤 시 사용하지 않는 데이터도 계속 유지됨

이 문제를 해결하기 위해 Jetpack Paging3 라이브러리를 도입했다.

Paging 라이브러리를 사용하면 로컬 DB 또는 네트워크로부터 대규모 데이터를 "페이지 단위"로 로드하고 UI에 표시할 수 있다.

Paging3는 Google 권장 아키텍처(MVVM)에 맞게 설계되어 있어 Repository - ViewModel - UI 레이어가 명확하게 분리된다.


Paging란 무엇인가?

Paging는 Android Jetpack의 일부로, 대규모 데이터 세트를 점진적으로 로드하고 표시하도록 도와주는 라이브러리이다.

 

🥸 핵심 아이디어

"사용자가 실제로 필요로 하는 데이터만, 필요한 시점에 로드하자"

Paging의 장점

  1. 메모리 사용 최적화
    • Paging된 데이터는 필요한 만큼만 메모리에 유지
    • 스크롤에서 벗어난 데이터는 자동으로 정리됨
  2. 네트워크 요청 최적화
    • 요청 중복 제거 기능 기본 제공
    • 네트워크 대역폭과 시스템 리소스를 효율적으로 사용
  3. 자동 데이터 로딩
    • 사용자가 리스트의 끝까지 스크롤하면 어댑터가 자동으로 다음 페이지 요청
    • 무한 스크롤 기능 구현 가능
  4. 강력한 비동기 지원
    • Kotlin Coroutine / Flow
    • LiveData
    • RxJava
  5. 기본 제공되는 에러 처리
    • 새로고침 (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 동작 흐름

  1. UI에서 colletAsLazyPagingItems() 호출
  2. Pager가 PagingSource 생성
  3. PagingSource.load() 호출
  4. LoadResult.Page 반환
  5. PagingData 생성
  6. UI는 필요한 만큼만 데이터 소비
  7. 스크롤시 append 로드 자동 호출

참고자료

https://developer.android.com/topic/libraries/architecture/paging/v3-overview?hl=ko

https://developer.android.com/codelabs/android-paging?hl=ko#19

https://dev-inventory.com/18