사진 리스트처럼 무한히 스크롤되는 데이터를 효율적으로 불러오려면, 모든 데이터를 한 번에 불러오는 것이 아니라 필요할 때 필요한 만큼만 로드하는 방식이 필요합니다.
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
'Projects > FineByMe' 카테고리의 다른 글
[Android/Kotlin] Jetpack Compose에서 Flow.debounce()로 검색 요청 최적화하기 (0) | 2025.05.07 |
---|---|
[Android/Kotlin] Room + Flow 조합으로 즐겨찾기 화면 자동 갱신하기 (0) | 2025.04.24 |
[Android/Kotlin] BaseViewModel을 걷어내고 원본 비율을 살린 Masonry 레이아웃 개선기 (0) | 2025.04.14 |
[Android/Kotlin] StaggeredGridLayout 사용시 발생한 화면 재구성문제 수정 (2) | 2024.07.29 |
[Android/Kotlin]FineByMe 프로젝트 리팩토링: RecyclerView.Adapter에서 ListAdapter로의 전환 (1) | 2024.07.10 |