
무한 스크롤이 끊기는 건 네트워크가 느려서가 아니었습니다. 너무 늦게 다음 페이지를 부르도록 설계된 게 진짜 원인이었거든요.
작년에 안드로이드 스튜디오(Android Studio)에서 직접 짠 페이지네이션 코드를 한참 들여다보다가, 결국 클로드 코드(Claude Code)와 같이 Paging 3로 갈아탔어요. 그 과정에서 얻은 자리가 꽤 명확해서 정리해두려고 합니다.
직접 짠 안드로이드 페이지네이션이 끊기는 이유
처음엔 LazyListState.firstVisibleItemIndex 를 보면서 끝에 가까워지면 다음 페이지를 부르는 식으로 짰습니다. 동작은 했지만 세 가지가 계속 발목을 잡더라고요.
빠르게 스크롤하면 같은 페이지를 두세 번 호출해서 동일 아이템이 두 번 그려지는 일이 잦았어요. 디바운스를 걸어도 임계 인덱스를 오가는 순간이 미세하게 다시 트리거되는 경우가 생겨요.
하단 인디케이터도 다음 페이지 요청과 UI 반영 사이의 빈 구간에서 깜빡거리고, 네트워크 에러가 한 번 터지면 재시도 상태가 화면 회전 한 번에 통째로 날아가버리는 식이었습니다. 결국 누적·재시도·복원 세 가지를 직접 관리하는 비용이 너무 컸어요.

PagingSource 와 prefetchDistance 로 끊김 잡기
Paging 3는 PagingSource 의 load() 한 함수만 잘 채우면 나머지 흐름은 라이브러리가 책임집니다. LoadParams.key 를 페이지 번호로 받고, 성공 시 LoadResult.Page 로 data / prevKey / nextKey 를 돌려주면 끝이에요. nextKey 가 null 이면 자동으로 로딩이 멈추니까 "마지막 페이지" 체크 로직을 따로 둘 필요가 없습니다.
class FeedPagingSource(
private val api: FeedApi
) : PagingSource<Int, Feed>() {
override suspend fun load(
params: LoadParams<Int>
): LoadResult<Int, Feed> {
val page = params.key ?: 1
return try {
val response = api.getFeed(page, params.loadSize)
LoadResult.Page(
data = response.items,
prevKey = if (page == 1) null else page - 1,
nextKey = if (response.items.isEmpty()) null else page + 1,
)
} catch (e: IOException) {
LoadResult.Error(e)
} catch (e: HttpException) {
LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, Feed>): Int? {
return state.anchorPosition?.let { anchorPosition ->
val anchorPage = state.closestPageToPosition(anchorPosition)
anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
}
}
}
진짜 끊김 해소의 지점은 PagingConfig 였습니다. prefetchDistance 기본값이 pageSize 와 같게 잡혀 있어서, 페이지를 크게 두지 않으면 사용자가 거의 끝에 닿은 다음에야 다음 페이지를 부르거든요. prefetchDistance 를 pageSize 보다 넉넉히 크게 잡으니 스크롤이 끝나기 한참 전에 다음 묶음이 도착해 있더라고요.
val pager = Pager(
config = PagingConfig(
pageSize = 20,
prefetchDistance = 40, // pageSize 보다 크게 — 끝에 닿기 전에 미리 부르기
initialLoadSize = 40,
enablePlaceholders = false,
),
pagingSourceFactory = { FeedPagingSource(api) }
).flow.cachedIn(viewModelScope)
.cachedIn(viewModelScope) 는 절대 빼면 안 됩니다. 빼고 돌렸다가 화면 회전 한 번에 1페이지부터 다시 받아오는 걸 보고 멘붕이 오더라고요.

Compose LazyColumn 페이징 — key 와 loadState 분기
collectAsLazyPagingItems() 로 받은 데이터를 LazyColumn 에 꽂을 때 가장 자주 놓치는 게 key 입니다. 안정적인 ID 를 넘기지 않으면 새 페이지가 붙을 때마다 보이지 않는 아이템까지 재구성돼서 스크롤이 미세하게 튀어요.
val items = viewModel.feedFlow.collectAsLazyPagingItems()
LazyColumn {
items(
count = items.itemCount,
key = items.itemKey { it.id },
contentType = items.itemContentType { "feed" },
) { index ->
val item = items[index] ?: return@items
FeedRow(item)
}
items.loadState.append.let { state ->
when (state) {
is LoadState.Loading -> item { FooterSpinner() }
is LoadState.Error -> item { RetryRow(onClick = { items.retry() }) }
else -> Unit
}
}
}
items.loadState 는 refresh / append / prepend 세 구간으로 나뉘어서, 초기 로딩에는 풀스크린 스피너, 다음 페이지 로딩에는 하단 인디케이터, 에러에는 재시도 버튼을 각각 다른 위치에서 그릴 수 있습니다. items.retry() 한 줄로 실패한 페이지만 다시 시도해주니 직접 짤 때처럼 "어디서부터 다시 부를지" 추적할 필요가 없어졌어요.
개인적으로 enablePlaceholders 는 처음에 false 로 두는 걸 추천드리고 싶네요. true 로 두면 items[index] 가 null 일 때 스켈레톤 UI 를 직접 그려야 해서, 일단 끊김부터 잡는 단계에선 오히려 신경 쓸 게 늘어나는 편이에요.

RemoteMediator 로 Room 캐시까지 묶는 구조
오프라인에서도 마지막에 본 피드가 떠야 한다는 요구가 들어오면서 RemoteMediator 를 붙였습니다. 네트워크에서 받은 페이지를 Room 에 적고, PagingSource 는 항상 Room 만 바라보게 만드는 구조예요.
핵심은 세 가지로 정리됩니다.
RemoteKeys테이블에id / prevKey / nextKey를 두고,LoadType.REFRESH / PREPEND / APPEND별로 어떤 키를 꺼낼지 분기합니다.db.withTransaction { ... }으로RemoteKeys갱신과 본 데이터 insert 를 한 트랜잭션에 묶어 정합성을 잡아야 해요.REFRESH일 때는 키 테이블과 본 테이블을 함께 비우고 새로 채우는 식으로 가야, 위에서 당겨 새로고침해도 중복 행이 안 남습니다.
RemoteMediator 는 초기 비용이 꽤 듭니다. 그래서 오프라인 캐시가 진짜 요구사항이 아니라면 굳이 손대지 않는 게 낫다고 봐요. 단순 무한 스크롤 피드는 PagingSource 만으로도 충분합니다.

Paging 3 가 과한 경우 — LazyColumn 만으로 충분한 자리
라이브러리 자체가 가볍지는 않아서, 모든 리스트에 다 끼우는 건 과잉입니다. 다음 경우엔 그냥 List<T> + LazyColumn 이 더 깔끔해요.
- 아이템이 100~200개 내외로 고정된 설정·카테고리 화면. 한 번에 받아서 메모리에 들고 있는 게 더 빠릅니다.
- 전체 데이터가 1,000건 미만이면서 한 번의 네트워크 호출로 끝나는 화면. 페이징 자체가 불필요해요.
- 검색 자동완성처럼 결과가 짧고, 입력마다 통째로 갈아끼우는 흐름. Paging 의 누적·캐시 모델과 결이 안 맞습니다.
반대로 무한 스크롤 피드·검색 결과·채팅 내역처럼 누적이 길고 재시도·오프라인까지 얽히는 자리는 Paging 3 가 정답에 가깝다고 봅니다. 직접 짠 코드가 늘어나는 속도와 Paging 도입 비용을 비교해보면 손익분기가 생각보다 빨리 오더라고요.
결국 끊김의 원인은 네트워크가 아니라 "언제 다음 페이지를 부르냐"였습니다. prefetchDistance 한 줄, .cachedIn(viewModelScope) 한 줄, LazyColumn 의 key 한 줄. 세 줄이 전부였던 셈이지요.
RemoteMediator 와 Room 캐시 쪽은 트랜잭션 경계와 RemoteKeys 스키마 설계가 따로 한 편 분량이라, 다음 글에서 오프라인 우선 구조까지 이어서 풀어볼 생각이에요.
'Android 개발 > 트러블슈팅' 카테고리의 다른 글
| 안드로이드 배터리 드레인, 밤새 깨어 있던 8시간을 추적한 기록 (0) | 2026.07.25 |
|---|---|
| SharedPreferences를 DataStore로 옮긴 110ms 사냥 기록 (1) | 2026.07.24 |
| Room 느린 쿼리, Database Inspector로 풀스캔 잡은 기록 (1) | 2026.07.23 |
| 업로드 키 분실, Play App Signing 으로 재설정하고 깨달은 운영 원칙 (0) | 2026.07.20 |
| Hilt vs Koin 마이그레이션, 6년차가 둘 다 깔아본 뒤 안 갈아탄 이유 (0) | 2026.07.08 |