Android 개발/트러블슈팅

Paging 3 무한 스크롤로 끊김 잡은 자리 — Room 캐시까지 한 번에

stackD 2026. 7. 30. 18:00
반응형

 

무한 스크롤이 끊기는 건 네트워크가 느려서가 아니었습니다. 너무 늦게 다음 페이지를 부르도록 설계된 게 진짜 원인이었거든요.

 

작년에 안드로이드 스튜디오(Android Studio)에서 직접 짠 페이지네이션 코드를 한참 들여다보다가, 결국 클로드 코드(Claude Code)와 같이 Paging 3로 갈아탔어요. 그 과정에서 얻은 자리가 꽤 명확해서 정리해두려고 합니다.

 

직접 짠 안드로이드 페이지네이션이 끊기는 이유

처음엔 LazyListState.firstVisibleItemIndex 를 보면서 끝에 가까워지면 다음 페이지를 부르는 식으로 짰습니다. 동작은 했지만 세 가지가 계속 발목을 잡더라고요.

 

빠르게 스크롤하면 같은 페이지를 두세 번 호출해서 동일 아이템이 두 번 그려지는 일이 잦았어요. 디바운스를 걸어도 임계 인덱스를 오가는 순간이 미세하게 다시 트리거되는 경우가 생겨요.

 

하단 인디케이터도 다음 페이지 요청과 UI 반영 사이의 빈 구간에서 깜빡거리고, 네트워크 에러가 한 번 터지면 재시도 상태가 화면 회전 한 번에 통째로 날아가버리는 식이었습니다. 결국 누적·재시도·복원 세 가지를 직접 관리하는 비용이 너무 컸어요.

 

 

PagingSource 와 prefetchDistance 로 끊김 잡기

Paging 3는 PagingSourceload() 한 함수만 잘 채우면 나머지 흐름은 라이브러리가 책임집니다. LoadParams.key 를 페이지 번호로 받고, 성공 시 LoadResult.Pagedata / 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 와 같게 잡혀 있어서, 페이지를 크게 두지 않으면 사용자가 거의 끝에 닿은 다음에야 다음 페이지를 부르거든요. prefetchDistancepageSize 보다 넉넉히 크게 잡으니 스크롤이 끝나기 한참 전에 다음 묶음이 도착해 있더라고요.

 

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.loadStaterefresh / append / prepend 세 구간으로 나뉘어서, 초기 로딩에는 풀스크린 스피너, 다음 페이지 로딩에는 하단 인디케이터, 에러에는 재시도 버튼을 각각 다른 위치에서 그릴 수 있습니다. items.retry() 한 줄로 실패한 페이지만 다시 시도해주니 직접 짤 때처럼 "어디서부터 다시 부를지" 추적할 필요가 없어졌어요.

 

개인적으로 enablePlaceholders 는 처음에 false 로 두는 걸 추천드리고 싶네요. true 로 두면 items[index] 가 null 일 때 스켈레톤 UI 를 직접 그려야 해서, 일단 끊김부터 잡는 단계에선 오히려 신경 쓸 게 늘어나는 편이에요.

 

 

RemoteMediator 로 Room 캐시까지 묶는 구조

오프라인에서도 마지막에 본 피드가 떠야 한다는 요구가 들어오면서 RemoteMediator 를 붙였습니다. 네트워크에서 받은 페이지를 Room 에 적고, PagingSource 는 항상 Room 만 바라보게 만드는 구조예요.

 

핵심은 세 가지로 정리됩니다.

 

  1. RemoteKeys 테이블에 id / prevKey / nextKey 를 두고, LoadType.REFRESH / PREPEND / APPEND 별로 어떤 키를 꺼낼지 분기합니다.
  2. db.withTransaction { ... } 으로 RemoteKeys 갱신과 본 데이터 insert 를 한 트랜잭션에 묶어 정합성을 잡아야 해요.
  3. REFRESH 일 때는 키 테이블과 본 테이블을 함께 비우고 새로 채우는 식으로 가야, 위에서 당겨 새로고침해도 중복 행이 안 남습니다.

 

RemoteMediator 는 초기 비용이 꽤 듭니다. 그래서 오프라인 캐시가 진짜 요구사항이 아니라면 굳이 손대지 않는 게 낫다고 봐요. 단순 무한 스크롤 피드는 PagingSource 만으로도 충분합니다.

 

 

Paging 3 가 과한 경우 — LazyColumn 만으로 충분한 자리

라이브러리 자체가 가볍지는 않아서, 모든 리스트에 다 끼우는 건 과잉입니다. 다음 경우엔 그냥 List<T> + LazyColumn 이 더 깔끔해요.

 

  1. 아이템이 100~200개 내외로 고정된 설정·카테고리 화면. 한 번에 받아서 메모리에 들고 있는 게 더 빠릅니다.
  2. 전체 데이터가 1,000건 미만이면서 한 번의 네트워크 호출로 끝나는 화면. 페이징 자체가 불필요해요.
  3. 검색 자동완성처럼 결과가 짧고, 입력마다 통째로 갈아끼우는 흐름. Paging 의 누적·캐시 모델과 결이 안 맞습니다.

 

반대로 무한 스크롤 피드·검색 결과·채팅 내역처럼 누적이 길고 재시도·오프라인까지 얽히는 자리는 Paging 3 가 정답에 가깝다고 봅니다. 직접 짠 코드가 늘어나는 속도와 Paging 도입 비용을 비교해보면 손익분기가 생각보다 빨리 오더라고요.

 

결국 끊김의 원인은 네트워크가 아니라 "언제 다음 페이지를 부르냐"였습니다. prefetchDistance 한 줄, .cachedIn(viewModelScope) 한 줄, LazyColumnkey 한 줄. 세 줄이 전부였던 셈이지요.

 

RemoteMediator 와 Room 캐시 쪽은 트랜잭션 경계와 RemoteKeys 스키마 설계가 따로 한 편 분량이라, 다음 글에서 오프라인 우선 구조까지 이어서 풀어볼 생각이에요.

 

반응형
LIST