Paging3+RecyclerView를 Compose로 마이그레이션하기
요즘 오디를 Compose로 마이그레이션하고 있다.
오디는 우아한테크코스에서 팀 프로젝트로 개발했던 앱인데,
당시에는 Compose를 모르는 상태였기 때문에 모든 UI가 XML로 구현되어 있다.
틈틈이 마이그레이션을 하는게 요즘 내 힐링이다. 코드가 간결해지는게 눈에 보여서 재밌다 ㅎ
오디의 주소 검색 화면은 Paging3을 사용해 무한 스크롤을 구현했었다.
이 화면을 마이그레이션하며 "Compose에 Paging3를 어떻게 적용할지"에 대해 배운 것들을 정리해 보려고 한다.
이 글은 RecyclerView에서 Compose로 마이그레이션하는 방법을 중점적으로 다룬다.
즉, Paging3와 PagingSource, PagingDataAdapter에 대한 내용을 자세히 다루지 않는다.
가장 먼저, RecyclerView와 Compose 각각의 방법에 대해 요약해 보자면 아래와 같다.
RecyclerView의 Paging3 적용 방법
1. PagingSource를 작성한다.
2. PagingDataAdapter를 작성한다.
3. PagingSource에서 가져온 데이터를 collect한다.
4. Adapter의 submitData를 호출한다.
Compose의 Paging3 적용 방법
1. PagingSource를 작성한다.
2. PagingSource에서 가져온 데이터를 State로 변환한다.
3. LazyList(LazyColumn, LazyRow...)를 사용해 데이터를 화면에 보여준다.
Compose는 (당연하게도) Adapter를 정의할 필요가 없다.
PagingSource를 작성하는 것 외에는, 일반 Compose 구현과 비슷하기 때문에, 더 이해하기 쉽다.
*
PagingSource는 데이터 소스에서 페이지 별로 데이터를 가져오는 방법을 정의한 객체다.
PagingDataAdapter는 RecyclerView의 Adapter 역할을 한다. 페이징 데이터를 쉽게 처리할 수 있도록 해준다.
그럼 더 자세한 구현 방법을 알아보자.
1. Compose Paging3 의존성 추가
// libs.versions
androidx-compose-paging = { group = "androidx.paging", name = "paging-compose", version.ref = "androidxPaging" }
// build.gradle
implementation(libs.androidx.compose.paging)
Compose에서 Paging을 사용하기 위해서는 별도의 의존성 추가가 필요하다.
2025/04/04 기준 최신 버전은 3.3.6이다.
2. 컴포저블에서 Paging 데이터 가져오기
val addresses = viewModel.address.collectAsLazyPagingItems()
collectAsLazyPagingItems 라는 함수를 통해 PagingData를 Compose에서 사용할 수 있는 상태로 변환한다.
@Composable
public fun <T : Any> Flow<PagingData<T>>.collectAsLazyPagingItems(
context: CoroutineContext = EmptyCoroutineContext
): LazyPagingItems<T>
collectAsLazyPagingItems의 리시버는 Flow<PagingData<T>>이며, 반환 타입은 LazyPagingItems이다.
바로 이 LazyPagingItems 덕분에 페이지 데이터를 LazyList에서 바로 사용할 수 있는 것이다.
내부적으로는 MutableState 타입의 itemSnapshotList가 Flow를 바라보고 있다.
3. LazyList로 페이지 데이터 띄우기
@Composable
private fun AddressList(
addresses: LazyPagingItems<Address>,
onClickAddress: (Address) -> Unit,
) {
LazyColumn(...) {
items(count = addresses.itemCount) { index ->
val address = addresses[index] ?: return@items
AddressItem(address, onClickAddress)
}
}
}
LazyPagingItems와 LazyList를 사용해 페이지 데이터를 화면에 띄워보자.
보다시피 기존에 구현하던 LazyList 방식과 별반 다를게 없다.
LazyPagingItems의 itemCount를 통해 아이템 개수를 전달한다.
fun items(
count: Int,
key: ((index: Int) -> Any)? = null,
contentType: (index: Int) -> Any? = { null },
itemContent: @Composable LazyItemScope.(index: Int) -> Unit
)
inline fun <T> LazyListScope.items(
items: List<T>,
noinline key: ((item: T) -> Any)? = null,
noinline contentType: (item: T) -> Any? = { null },
crossinline itemContent: @Composable LazyItemScope.(item: T) -> Unit
)
기존 List를 사용했을 때와 차이점은 사용하는 items 함수가 다르다는 것이다.
위 코드에서 첫 번째 함수가 Paging과 함께 사용하는 함수고, 두 번째 함수가 일반적으로 List와 함께 사용하는 함수다.
두 번째 함수의 items 매개변수 타입이 List<T>이므로, Paging에서는 사용할 수 없다.
첫 번째 items 함수
- LazyListScope 인터페이스 내부에 정의되어 있다.
- 아이템의 개수를 전달하고, itemContent에서는 람다의 인자 index를 통해 직접 아이템을 가져온다.
두 번째 items 함수
- LazyListScope의 확장 함수 형태다.
- 아이템 리스트를 전달하고, itemContent에서는 람다의 인자가 아이템이므로, 바로 사용한다.
4. LazyList에 key 지정하기
@Composable
private fun AddressList(
addresses: LazyPagingItems<Address>,
onClickAddress: (Address) -> Unit,
) {
LazyColumn(...) {
items(
count = addresses.itemCount,
key = addresses.itemKey { it.id },
) { index ->
val address = addresses[index] ?: return@items
AddressItem(address, onClickAddress)
}
}
}
LazyList에서는 리컴포지션을 최적화하기 위해 key를 지정한다.
LazyPagingItems를 사용할 때도 itemKey라는 확장 함수를 통해 key를 지정할 수 있다.
public fun <T : Any> LazyPagingItems<T>.itemKey(
key: ((item: T) -> Any)? = null
)
itemKey 람다의 인자 item을 사용해 어떤 값을 key로 사용할 지 지정한다.
위 예제 코드에서는 Address 객체의 id를 key로 사용했다. ( itemKey { it.id } )
만약 key가 null이라면(기본값), index가 key가 된다.
5. 동적으로 데이터가 로드되지 않는다면
LazyList에서는 무조건 LazyPagingItems 타입을 사용해야 한다.
무한 스크롤의 이점인 지연 로딩이 되지 않을 수 있기 때문이다.
@Composable
fun AddressSearchScreen(
viewModel: AddressSearchViewModel = hiltViewModel(),
) {
val addresses = viewModel.address.collectAsLazyPagingItems()
Scaffold(...) {
AddressSearchContent(
addresses = addresses.toList()
)
}
}
private fun <T : Any> LazyPagingItems<T>.toList(): List<T> {
val list = mutableListOf<T>()
repeat(itemCount) {
val item = get(it) ?: return@repeat
list.add(item)
}
return list
}
기존에는 위 코드처럼 LazyPagingItems이 아닌 List를 사용했었다.
굳이굳이 LazyPagingItems의 확장 함수 toList()를 작성해 가면서..;;
@Composable
@Preview(showSystemUi = true)
private fun AddressSearchContentPreview() {
OdyTheme {
AddressSearchContent(
addresses = listOf(...),
)
}
}
이유는 프리뷰를 더 수월하게 작성하기 위함이다.
LazyPagingItems의 객체를 직접 생성할 수 없기 때문에, 컴포저블의 인자로 LazyPagingItems를 전달할 수 없었다.
그런데 데이터를 로드해 오는 부분에 로그를 찍어보니, 한 번에 모든 데이터를 불러오고 있었다.
즉, List가 아닌 LazyPagingItems를 사용해야 데이터를 동적으로 불러올 수 있다.
지금 생각해 보면 당연하다.. LazyPagingItems 내부의 PagingData에서 스크롤 시 자동으로 새로운 페이지를 불러오고 있기 때문이다.
그렇다면 LazyPagingItems를 사용했을 때 프리뷰는 어떻게 작성할까?
6. LazyPagingItems 프리뷰 작성하기
프리뷰로 확인하려는 컴포저블 함수의 인자로 LazyPagingItems를 전달해야 한다.
하지만 LazyPagingItems의 객체를 직접 생성할 수는 없다.
@Composable
@Preview(showSystemUi = true)
private fun AddressSearchContentPreview() {
val addresses = listOf(...)
val pagingData = PagingData.from(addresses)
val lazyPagingItems = flowOf(pagingData).collectAsLazyPagingItems()
OdyTheme {
AddressSearchContent(
addresses = lazyPagingItems,
)
}
}
이 경우 위처럼 프리뷰를 작성한다.
LazyPagingItems는 PagingData를 Flow 형태로 가지고 있다.
즉, PagingData의 Flow를 생성 후 collectAsLazyPagingItems를 호출하여 LazyPagingItems를 생성하면 된다.
7. PagingSource의 LoadState 가져오기
class AddressPagingSource(...) : PagingSource<Int, Address>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Address> {
return runCatching {
val addresses = addressRepository.fetchAddresses(...).getOrThrow()
LoadResult.Page(...)
}.getOrElse { throwable ->
LoadResult.Error(throwable)
}
}
...
}
PagingSource에서는 데이터 소스에서 데이터를 가져오다가 오류가 발생한 경우, LoadResult.Error를 반환할 수 있다.
이러한 LoadResult를 UI 계층에서 받아와 에러 핸들링을 해야 하는 경우가 있다. (ex: 토스트, 스낵바, 에러 화면 등)
adapter.addLoadStateListener { loadState ->
when (loadState.append) {
LoadState.Loading -> {}
is LoadState.NotLoading -> {}
is LoadState.Error -> {}
}
}
RecyclerView에서는 PagingDataAdapter의 addLoadStateListener를 사용하여 LoadState를 받아왔다.
@Composable
fun AddressSearchScreen(
viewModel: AddressSearchViewModel = hiltViewModel(),
) {
val addresses = viewModel.address.collectAsLazyPagingItems()
Scaffold(...) { innerPadding ->
when (val state = addresses.loadState.append) {
LoadState.Loading -> TODO()
is LoadState.Error -> TODO()
is LoadState.NotLoading -> TODO()
}
AddressSearchContent(...)
}
}
Compose의 경우 collectAsLazyPagingItems에서 가져온 LazyPagingItems를 통해 loadState에 접근할 수 있다.
class CombinedLoadStates(
val refresh: LoadState,
val prepend: LoadState,
val append: LoadState,
val source: LoadStates,
val mediator: LoadStates?
)
addLoadStateListener 와 LazyPagingItems의 loadState는 CombinedLoadStates 타입이다.
CombinedLoadStates는 LoadState를 가지고 있는 일종의 컬렉션이다.
에러 핸들링을 위해 refresh, prepend, append를 주로 사용한다.
- refresh: 데이터를 새로고침하거나 처음 데이터를 로드했을 때의 상태
- prepend: 데이터의 앞쪽에 새로운 데이터를 추가할 때의 상태 (위로 스크롤)
- append: 데이터의 뒤쪽에 새로운 데이터를 추가할 때의 상태 (아래로 스크롤)
요약
- 컴포저블 함수 collectAsLazyPagingItems를 사용하여 Flow<PagingData<T>>를 LazyPagingItems<T>로 변환한다.
- LazyPagingItems를 사용해 LazyList에서 페이지 데이터를 화면에 보여준다.
- LazyPagingItems를 사용해 LoadState를 가져올 수 있다.
참고
https://developer.android.com/reference/kotlin/androidx/paging/compose/package-summary