본문 바로가기
Projects/FineByMe

[Android/Kotlin] Jetpack Compose + Paging3로 무한 스크롤 구현하기

by quessr 2025. 5. 15.

 

사진 리스트처럼 무한히 스크롤되는 데이터를 효율적으로 불러오려면, 모든 데이터를 한 번에 불러오는 것이 아니라 필요할 때 필요한 만큼만 로드하는 방식이 필요합니다.
Jetpack Compose에서는 Paging3와 함께 이를 쉽게 구현할 수 있습니다.

이 글에서는 Unsplash API를 예시로, PagingSource, Pager, PagingData, 그리고 LazyPagingItems를 어떻게 연결하는지를 공부한 내용을 기반으로 정리해보았습니다.


1. 의존성 추가

먼저 build.gradle에 아래와 같이 Paging 관련 의존성을 추가합니다.

// 필수: Paging 로직의 핵심 구성
implementation("androidx.paging:paging-runtime:3.3.2")

// 선택: Paging 데이터를 Compose UI와 연결해주는 확장
implementation("androidx.paging:paging-compose:1.0.0-alpha18")

 


2. PagingSource 구현하기

PagingSource는 Paging3에서 가장 중요한 구성요소 중 하나로,
네트워크나 DB 등 외부에서 데이터를 "페이지 단위"로 로드하는 로직을 정의합니다.

class PhotoPagingSource(
    private val unSplashDataSource: UnSplashDataSource,
    private val query: String
) : PagingSource<Int, Photo>() {
    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Photo> {
        val page = params.key ?: 1
        val perPage = params.loadSize

        return try {
            val result = if (query.isBlank()) {
                unSplashDataSource.getRandomPhotoList(page, perPage)
            } else {
                unSplashDataSource.getSearchPhotoList(query, page, perPage)
            }

            val photos = PhotoMapper.mapToPhotoList(result)
            LoadResult.Page(
                data = photos,
                prevKey = if (page == 1) null else page - 1,
                nextKey = if (photos.isEmpty()) null else page + 1
            )
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }

    override fun getRefreshKey(state: PagingState<Int, Photo>): Int? = null
}

 

  • load()는 실제 데이터를 요청하고, 성공하면 LoadResult.Page, 실패 시 LoadResult.Error를 반환합니다.
  • getRefreshKey()는 PagingSource가 새로 생성될 때, 어느 키부터 다시 불러올지를 결정합니다.
    예: 새로고침, 구성 변경(화면 회전) 등의 상황에서 스크롤 위치 근처를 다시 불러오기 위해 필요합니다.

3. Repository에서 Pager 구성하기

Pager는 PagingSource와 PagingConfig를 바탕으로,
페이징 가능한 데이터를 제공하는 Flow<PagingData<T>>를 만들어주는 API입니다.

override fun getPhotoPagingList(query: String): Flow<PagingData<Photo>> {
    return Pager(
        config = PagingConfig(pageSize = 20),
        pagingSourceFactory = { PhotoPagingSource(unsplashDataSource, query) }
    ).flow
}

 

 

  • PagingConfig에는 페이지 크기, 사전 로딩 거리 등을 설정할 수 있습니다.
  • .flow를 통해 Flow<PagingData<Photo>>를 반환하고, 이는 Compose에서 collectAsLazyPagingItems()로 수집됩니다.

4. Compose에서 LazyPagingItems로 UI 연결하기

Paging3에서 만들어진 Flow<PagingData<Photo>>는 Compose의 LazyColumn 또는 LazyVerticalStaggeredGrid에서 직접 사용할 수 없습니다.
따라서 이를 UI와 연결해주는 어댑터 객체인 LazyPagingItems<Photo>로 변환합니다.

val photos = viewModel.photoPagingFlow.collectAsLazyPagingItems()

 

 

이후 LazyVerticalStaggeredGrid를 사용해 UI를 구성합니다.

@Composable
fun PhotoStaggeredGrid(
    photos: LazyPagingItems<Photo>,
    onPhotoClick: (Photo) -> Unit,
    gridState: LazyStaggeredGridState
) {
    val density = LocalDensity.current
    val screenWidth = LocalConfiguration.current.screenWidthDp.dp
    val itemSpacing = 4.dp * 3
    val itemWidthDp = (screenWidth - itemSpacing) / 2
    val itemWidthPx = with(density) { itemWidthDp.toPx() }.toInt()

    LazyVerticalStaggeredGrid(
        columns = StaggeredGridCells.Fixed(2),
        modifier = Modifier.fillMaxSize(),
        state = gridState,
        contentPadding = PaddingValues(4.dp),
        horizontalArrangement = Arrangement.spacedBy(4.dp),
        verticalItemSpacing = 4.dp
    ) {
        items(count = photos.itemCount) { index ->
            val photo = photos[index]
            photo?.let {
                val heightPx = it.calculateHeight(itemWidthPx)
                PhotoItem(
                    photo = it,
                    modifier = Modifier
                        .fillMaxWidth()
                        .height(with(density) { heightPx.toDp() })
                        .clip(RoundedCornerShape(8.dp)),
                    onClick = { onPhotoClick(photo) }
                )
            }
        }
    }
}

5. Paging이 필요 없는 고정 데이터는 List<Photo>로 처리

즐겨찾기처럼 이미 메모리에 있는 정적인 데이터는 Paging이 필요 없기 때문에
별도로 List<Photo>를 받아서 같은 UI 컴포넌트에 재사용할 수 있습니다.

@Composable
fun PhotoStaggeredGrid(
    photos: List<Photo>,
    onPhotoClick: (Photo) -> Unit,
    gridState: LazyStaggeredGridState
) {
    // 위와 동일한 그리드 구조 사용
    ...
}

정리하며

이번 글에서는 Paging3와 Jetpack Compose를 함께 사용해 무한 스크롤 가능한 리스트를 구성하는 전체 흐름을 정리해보았습니다.

  • PagingSource는 데이터를 어떻게 불러올지를 담당하고,
  • Pager는 이를 기반으로 PagingData 스트림을 만들어내며,
  • Compose에서는 LazyPagingItems로 변환해 LazyColumn이나 StaggeredGrid 등에 바로 그릴 수 있도록 연결됩니다.

또한 즐겨찾기처럼 고정된 데이터는 별도의 List<Photo> 버전으로 같은 UI에 재사용할 수 있게 만들어,
상황에 따라 적절한 방식으로 구성할 수 있도록 분리해 두었습니다.


배운 점

이번 구현을 통해 Paging3의 구조를 처음부터 끝까지 한 번 직접 다뤄보며

  • Pager와 PagingSource의 관계
  • getRefreshKey()의 역할
  • Flow<PagingData>와 LazyPagingItems의 연결 방식
  • Compose 환경에서의 UI 처리 흐름

등을 이해할 수 있었습니다.


참고: https://developer.android.com/jetpack/androidx/releases/paging?hl=ko

https://developer.android.com/reference/kotlin/androidx/paging/compose/LazyPagingItems?_gl=1*a9w9qz*_up*MQ

반응형