
Paging 3 LoadState를 잘 쓰려면 데이터를 나누는 기술보다 상태를 맥락에 맞게 보여주는 감각이 먼저 필요합니다. 지금 이 로딩이 초기 로딩인지, 다음 페이지 로딩인지, 이미 보이는 목록 위에서 난 실패인지를 구분해서 보여줘야 목록 화면이 덜 어색해집니다.
많은 화면이 LoadState를 붙여 놓고도 UX가 투박한 이유는 상태 이름을 읽었기 때문이 아니라, 그 상태가 refresh, append, prepend 중 어디서 발생했는지까지 같이 해석하지 않았기 때문입니다.
공식 문서도 Paging loading state 가이드에서 CombinedLoadStates를 통해 refresh, append, prepend와 데이터 소스별 상태를 읽고 적절한 로딩 표시를 보여주라고 설명합니다. 이 글에서는 그 설명을 RecyclerView 실무 UX 기준으로 다시 풀어보겠습니다.

왜 먼저 알아야 할까
Paging 연결 자체는 생각보다 빨리 끝납니다. 문제는 그다음입니다. 첫 진입 로딩을 어디에 보여줄지, 다음 페이지 실패를 전체 에러로 볼지 footer 에러로 볼지, 비어 있는 화면이 실패인지 결과 0건인지 같은 판단이 바로 이어집니다.
- 첫 진입에서는 full-screen loading이 자연스러운가
- 이미 목록이 보일 때는 footer loading이 더 자연스러운가
- append 실패는 retry row로 남겨야 하는가
- empty state와 error state를 분리하고 있는가
즉 LoadState는 단순 스피너 토글용 API가 아니라 맥락 분기용 API입니다. 이 감각이 없으면 목록 화면이 금방 거칠어집니다.
왜 UX는 더 복잡할까
공식 가이드 기준으로 LoadState는 Loading, NotLoading, Error 세 형태만 가집니다. 하지만 UI에서는 이 세 상태를 그대로 쓰기보다, 어느 LoadType에서 발생했는지에 따라 다른 UX로 번역해야 합니다.
refresh = Loading→ 첫 진입 또는 새로고침일 수 있다append = Loading→ 이미 보이는 목록 아래에 다음 페이지를 붙이는 중일 수 있다prepend = Loading→ 채팅처럼 상단으로 더 불러오는 화면일 수 있다refresh = Error→ 전체 화면 에러가 더 자연스러울 수 있다append = Error→ 목록은 유지하고 footer retry가 더 자연스러울 수 있다
즉 상태 이름은 세 개뿐이지만 어느 방향의 로드가 실패했는지에 따라 사용자가 기대하는 복구 방법은 완전히 달라집니다.
세 방향을 쉽게 보면
용어만 보면 금방 꼬입니다. 실무에서는 refresh는 화면 기준을 다시 잡는 로드, append는 아래쪽 더 불러오기, prepend는 위쪽 더 불러오기로 번역해 두는 편이 훨씬 쉽습니다.
이 관점으로 보면 왜 refresh가 전체 화면 상태를 좌우하고, append가 footer 상태와 잘 맞고, prepend가 채팅/로그형 UI에서만 더 자주 중요해지는지 감이 잡힙니다.

CombinedLoadStates를 읽을 때는 refresh, append, prepend만 보는 데서 끝내지 말고, 화면이 어떤 기준 상태를 먼저 결정해야 하는지 생각해야 합니다. 특히 캐시와 네트워크를 함께 쓰는 화면에서는 source와 mediator 맥락도 같이 떠올려야 합니다.

전체 로딩 vs 하단 로딩
첫 페이지를 아직 못 받았는데 footer spinner만 보이면 사용자는 무엇을 기다리는지 이해하기 어렵습니다. 반대로 이미 30개를 보고 있는데 append 로딩 때마다 전체 화면을 덮으면 스크롤 흐름이 끊깁니다.
val isInitialLoading = loadStates.refresh is LoadState.Loading && pagingAdapter.itemCount == 0
val isInitialError = loadStates.refresh is LoadState.Error && pagingAdapter.itemCount == 0
val isListEmpty = loadStates.refresh is LoadState.NotLoading && pagingAdapter.itemCount == 0
progressView.isVisible = isInitialLoading
errorView.isVisible = isInitialError
emptyView.isVisible = isListEmpty
recyclerView.isVisible = !isInitialLoading && !isInitialError여기서 핵심은 상태만 보지 말고 itemCount == 0를 함께 보는 것입니다. 같은 refresh = Error라도 아직 아무 콘텐츠가 없는 실패인지, 캐시가 남아 있는 새로고침 실패인지가 갈리기 때문입니다.
LoadStateAdapter 역할
LoadStateAdapter는 단순히 footer spinner를 붙이는 유틸리티가 아닙니다. 공식 API 문서도 이 adapter가 LoadState.Loading과 LoadState.Error를 item으로 표현하고, PagingDataAdapter.withLoadStateHeaderAndFooter()와 같이 사용할 수 있다고 설명합니다.
val pagingAdapter = ArticlePagingAdapter(onClick = ::openArticle)
recyclerView.adapter = pagingAdapter.withLoadStateFooter(
footer = PagingLoadStateAdapter { pagingAdapter.retry() }
)이렇게 붙이면 목록 아이템과 상태 아이템을 분리할 수 있습니다. 즉 스크롤 아래쪽에서 생긴 문제를 화면 전체 문제처럼 과장하지 않게 됩니다.
ViewHolder에서 보여줄 것
좋은 LoadStateViewHolder는 화려한 UI보다 역할이 분명해야 합니다. 로딩이면 progress, 실패면 메시지와 retry, 성공이면 row 자체를 숨기는 구조가 기본입니다.
class PagingLoadStateViewHolder(
private val binding: ItemPagingLoadStateBinding,
retry: () -> Unit
) : RecyclerView.ViewHolder(binding.root) {
init {
binding.retryButton.setOnClickListener { retry() }
}
fun bind(loadState: LoadState) {
binding.progressBar.isVisible = loadState is LoadState.Loading
binding.retryButton.isVisible = loadState is LoadState.Error
binding.errorText.isVisible = loadState is LoadState.Error
binding.errorText.text = (loadState as? LoadState.Error)?.error?.localizedMessage
}
}실무에서는 에러 원문을 그대로 노출하기보다, “다음 목록을 불러오지 못했습니다. 다시 시도해 주세요.”처럼 맥락형 문구로 바꾸는 편이 사용자에게 더 도움이 됩니다.
refresh 실패와 append 실패를 같은 에러 레이아웃으로 처리하면 UX가 거칠어집니다. 이미 본 목록이 있는데도 전체 화면을 덮어 버리거나, 반대로 첫 진입 실패를 작은 footer 에러처럼 보여 주는 문제가 생깁니다.
- refresh 에러 + itemCount 0 → full-screen error + retry
- append 에러 + itemCount > 0 → footer error row + retry
- refresh 에러 + 캐시 데이터 존재 → 목록 유지 + 상단 안내 또는 작은 retry
이 구분만 해도 “목록은 잘 보이는데 갑자기 앱이 막힌 느낌”이나 “실패했는데 어디서 다시 시도해야 하는지 모르겠다” 같은 불편이 크게 줄어듭니다.
분기 순서 잡기
상태를 읽을 때는 이벤트 우선순위가 중요합니다. 보통은 화면 기준 상태를 먼저 보고, 그다음 append/prepend 같은 보조 상태를 읽는 순서가 가장 안정적입니다.
pagingAdapter.addLoadStateListener { loadStates ->
val isListEmpty = loadStates.refresh is LoadState.NotLoading && pagingAdapter.itemCount == 0
val isInitialLoading = loadStates.refresh is LoadState.Loading && pagingAdapter.itemCount == 0
val isInitialError = loadStates.refresh is LoadState.Error && pagingAdapter.itemCount == 0
binding.emptyView.isVisible = isListEmpty
binding.progressView.isVisible = isInitialLoading
binding.errorView.isVisible = isInitialError
val appendError = loadStates.append as? LoadState.Error
val prependError = loadStates.prepend as? LoadState.Error
if (appendError != null) showAppendRetry()
if (prependError != null) showPrependRetry()
}핵심은 “지금 빈 화면 전체를 바꿔야 하는가”를 먼저 판단하고, 아니면 목록 안쪽의 상태 아이템으로 해결하는 것입니다. 이 순서를 뒤집으면 조건문이 쉽게 난잡해집니다.
RemoteMediator가 들어오면
심화 화면에서는 Room 캐시와 네트워크 동기화를 함께 쓰는 경우가 많습니다. 공식 가이드도 LoadState 신호가 PagingSource와 RemoteMediator 각각에 대해 존재한다고 설명합니다. 즉 지금 보이는 상태가 로컬 표시 상태인지, 원격 동기화 상태인지 분리해서 읽어야 합니다.
예를 들어 캐시 목록이 이미 보이는데 mediator refresh가 돌아간다고 해서 전체 화면을 다시 막아 버리면 UX가 이상해집니다. 이때는 콘텐츠는 유지하고, 상단 작은 동기화 표시나 가벼운 새로고침 피드백이 더 자연스러울 수 있습니다.
자주 하는 실수
Paging 3 자체보다 로딩/에러 UX 설계에서 더 자주 틀립니다. 아래 실수는 특히 많이 보입니다.

- append 로딩 때마다 전체 화면 progress를 덮는다
- append 에러를 토스트로만 끝내고 retry 경로를 남기지 않는다
- 첫 로딩 실패와 empty state를 같은 빈 화면으로 처리한다
- retry를 별도 API 호출로만 빼서 Paging 흐름과 상태 연결이 어긋난다
이 네 가지는 모두 “실패 범위”와 “복구 위치”를 맞추지 못해서 생기는 문제입니다. 실패한 위치에 retry가 남아 있어야 사용자가 덜 헷갈립니다.
빠른 체크리스트

refresh와append를 같은 UI로 처리하고 있지 않은가- 첫 로딩 실패와 결과 0건을 같은 빈 화면으로 처리하고 있지 않은가
- append 실패 후 footer retry가 화면에 남아 있는가
- 이미 목록이 있을 때 전체 화면 오버레이를 남발하고 있지 않은가
itemCount == 0를 상태 해석에 함께 쓰고 있는가- RemoteMediator가 있다면 source와 mediator 맥락을 섞어 읽고 있지 않은가
이 여섯 가지만 점검해도 대부분의 Paging 목록 화면은 꽤 자연스러워집니다. 결국 좋은 Paging UX는 빠른 로딩보다 상황에 맞는 표시에서 갈립니다. 이미 목록 갱신 자체가 흔들린다면 RecyclerView DiffUtil 글과 ListAdapter 글을 같이 보면 업데이트 흐름을 더 안정적으로 연결할 수 있습니다.
이 글을 더 쉽게 읽는 법
이 글은 API 이름을 외우기보다, 지금 문제가 첫 화면 문제인지 목록 하단 문제인지 먼저 나누어 읽으면 훨씬 쉬워집니다.
즉 LoadState는 상태 이름보다 실패 범위와 retry 위치를 맞추는 도구로 받아들이는 편이 실무적으로 더 유용합니다.
마무리
Paging 3의 LoadState는 스피너를 몇 개 더 붙이는 기능이 아닙니다. 지금의 기다림이 어떤 기다림인지, 실패가 어느 범위의 실패인지, 사용자가 어디서 다시 시도해야 하는지를 UI가 설명하게 만드는 도구입니다.
짧게 정리하면 이렇습니다. refresh는 화면 기준 상태를 만들고, append/prepend는 그 위에 붙는 보조 상태라고 생각하면 됩니다. 그리고 LoadStateAdapter와 retry UX를 그 기준에 맞춰 배치하면 목록 화면이 훨씬 자연스러워집니다.