리스트 화면에 아이템 수가 많아지면 전체를 한 번에 불러오는 대신 스크롤 위치에 맞춰 페이지 단위로 나눠 가져와야 합니다. 안드로이드에서는 이걸 androidx.paging(Paging 3)으로 처리해왔는데, 오랫동안 안드로이드 전용 아티팩트였습니다. Compose 화면을 iOS까지 공유하는 프로젝트에서는 페이지네이션 로직만 플랫폼별로 따로 짜거나, 아예 페이지네이션을 포기하고 한 번에 다 불러오는 식으로 우회해야 했습니다. Paging 3.3부터 paging-commonpaging-compose가 commonMain 타겟을 지원하면서, 이 부분도 하나의 코드로 합칠 수 있게 됐습니다.

왜 필요한가

페이지네이션을 플랫폼별로 나눠 짜면 "다음 페이지를 언제 요청할지", "로딩 중 상태를 어떻게 표시할지", "에러 발생 시 재시도를 어떻게 처리할지" 같은 로직을 두 번 구현해야 합니다. 안드로이드는 PagingSourcePager로 처리하고, iOS 쪽은 직접 offset/limit 변수를 들고 스크롤 끝을 감지해서 다음 페이지를 요청하는 식으로 짜는 경우가 많았습니다. 두 구현이 갈라지면 페이지 크기를 바꾸거나 캐싱 정책을 조정할 때마다 두 곳을 똑같이 고쳐야 합니다. Paging의 공통 타겟 지원은 PagingSource, Pager, PagingData를 commonMain에 그대로 노출해서, 이 로직을 한 곳에 모을 수 있게 해줍니다.

핵심 개념

commonMain에 의존성을 추가합니다.

// composeApp/build.gradle.kts
kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation("androidx.paging:paging-common:3.3.2")
            implementation("androidx.paging:paging-compose:3.3.2")
        }
    }
}

PagingSource는 페이지 키(주로 Int 오프셋)를 받아서 한 페이지 분량의 데이터를 가져오는 역할만 합니다. 이전/다음 페이지 키 계산도 여기서 함께 합니다.

// commonMain/paging/ItemPagingSource.kt
import androidx.paging.PagingSource
import androidx.paging.PagingState

class ItemPagingSource(
    private val api: ItemApi,
) : PagingSource<Int, Item>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Item> {
        val page = params.key ?: 0
        return try {
            val response = api.fetchItems(page = page, size = params.loadSize)
            LoadResult.Page(
                data = response.items,
                prevKey = if (page == 0) null else page - 1,
                nextKey = if (response.items.isEmpty()) null else page + 1,
            )
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }

    override fun getRefreshKey(state: PagingState<Int, Item>): Int? {
        return state.anchorPosition?.let { anchor ->
            state.closestPageToPosition(anchor)?.prevKey?.plus(1)
        }
    }
}

PagerPagingConfigPagingSource 팩토리를 받아 Flow<PagingData<Item>>을 만들어줍니다.

// commonMain/paging/ItemRepository.kt
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import kotlinx.coroutines.flow.Flow

class ItemRepository(private val api: ItemApi) {
    fun itemsStream(): Flow<PagingData<Item>> {
        return Pager(
            config = PagingConfig(pageSize = 20, enablePlaceholders = false),
        ) {
            ItemPagingSource(api)
        }.flow
    }
}

실전 예시: ViewModel과 화면 연결

ViewModel에서는 cachedIn(viewModelScope)으로 스트림을 구독 범위 밖에서도 유지되도록 캐싱합니다.

// commonMain/paging/ItemListViewModel.kt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import kotlinx.coroutines.flow.Flow

class ItemListViewModel(repository: ItemRepository) : ViewModel() {
    val items: Flow<PagingData<Item>> = repository.itemsStream()
        .cachedIn(viewModelScope)
}

화면에서는 collectAsLazyPagingItems()로 구독하고, LazyColumn에서 itemCount만큼 순회합니다.

// commonMain/paging/ItemListScreen.kt
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.paging.LoadState
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey

@Composable
fun ItemListScreen(viewModel: ItemListViewModel) {
    val lazyItems = viewModel.items.collectAsLazyPagingItems()

    LazyColumn {
        items(
            count = lazyItems.itemCount,
            key = lazyItems.itemKey { it.id },
        ) { index ->
            val item = lazyItems[index]
            if (item != null) {
                Text(item.name)
            }
        }

        if (lazyItems.loadState.append is LoadState.Loading) {
            item { CircularProgressIndicator() }
        }
    }
}

겪은 문제: placeholder 항목에서 key 중복 크래시

처음에는 key = { index -> lazyItems[index]?.id }처럼 아이템 리스트 접근 연산자를 직접 key 람다에 넣었습니다. enablePlaceholders가 기본값(true)인 상태에서 아직 로드되지 않은 자리는 null을 반환하는데, LazyColumn은 서로 다른 인덱스에서 나온 null key를 같은 값으로 취급합니다. 그 결과 리스트를 빠르게 스크롤할 때 다음 예외가 발생했습니다.

java.lang.IllegalArgumentException: Key "null" was already used. If you are using LazyColumn/Row please make sure you provide a unique key for each item.

두 가지로 나눠 해결했습니다. 우선 PagingConfig(enablePlaceholders = false)로 null 자리 자체를 없앴습니다. 그리고 lazyItems[index] 대신 paging-compose가 제공하는 itemKey { it.id } 확장 함수로 바꿨습니다. itemKey는 내부적으로 peek(index)를 써서 로딩을 트리거하지 않고 key만 조회하기 때문에, key를 계산하는 과정에서 불필요한 데이터 요청이 추가로 발생하는 것도 함께 막아줍니다.

장단점 정리

장점

  • 안드로이드에서 쓰던 PagingSource/Pager/cachedIn 코드를 거의 그대로 commonMain으로 옮길 수 있어서, 페이지네이션 로직을 플랫폼별로 중복 작성할 필요가 없음
  • LoadState로 초기 로딩·추가 로딩·에러 상태를 구분할 수 있어서, 로딩 인디케이터와 재시도 버튼을 표준화된 방식으로 붙일 수 있음
  • itemKey처럼 placeholder를 안전하게 다루는 유틸리티가 함께 제공돼서, 위와 같은 key 충돌 문제를 직접 처리할 필요가 줄어듦

단점

  • placeholder 관련 동작(enablePlaceholders, itemKey vs 직접 인덱스 접근)을 정확히 이해하지 못하면 위에서 겪은 것과 같은 크래시를 만나기 쉬움
  • 서버 API가 오프셋 기반이 아니라 커서 기반 페이지네이션이라면 PagingSource의 키 타입을 커서 문자열로 바꾸는 등 API 설계에 맞춘 추가 작업이 필요함
  • 멀티플랫폼 지원이 상대적으로 최근에 들어온 기능이라, 마이너 버전을 올릴 때 API가 바뀌는 경우를 대비해 변경 로그를 챙겨봐야 함

리스트 화면에 페이지네이션이 이미 필요하고 안드로이드 쪽에서 Paging 3에 익숙하다면, 서드파티 페이지네이션 라이브러리를 새로 배우는 것보다 이쪽이 학습 비용이 낮습니다.