(Android/Paging) Paging3 + Retrofit — 무한 스크롤 구현


1. 전체 구조

Retrofit API ──▶ PagingSource ──▶ Pager ──▶ ViewModel(Flow<PagingData>) ──▶ UI
  (서버 호출)     (페이지 변환)    (설정)

서버가 페이지 번호(offset) 방식인지, 커서(next token) 방식인지에 따라 PagingSource의 Key 타입이 달라집니다.


2. API 정의

interface ArticleApi {
    // 페이지 번호 방식
    @GET("articles")
    suspend fun getArticles(
        @Query("page") page: Int,
        @Query("size") size: Int,
        @Query("q") query: String? = null
    ): ArticleResponse
}

data class ArticleResponse(
    val items: List<ArticleDto>,
    val hasNext: Boolean
)

3. 페이지 번호 방식 PagingSource

class ArticlePagingSource(
    private val api: ArticleApi,
    private val query: String?
) : PagingSource<Int, Article>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Article> {
        val page = params.key ?: START_PAGE
        return try {
            val response = api.getArticles(page, params.loadSize, query)

            LoadResult.Page(
                data = response.items.map { it.toDomain() },
                prevKey = if (page == START_PAGE) null else page - 1,
                // 서버가 알려주는 hasNext로 마지막 페이지 판단 (빈 응답 대기보다 정확)
                nextKey = if (response.hasNext) page + 1 else null
            )
        } catch (e: IOException) {
            LoadResult.Error(e)      // 네트워크 오류
        } catch (e: HttpException) {
            LoadResult.Error(e)      // 서버 오류 (4xx, 5xx)
        }
    }

    override fun getRefreshKey(state: PagingState<Int, Article>): Int? =
        state.anchorPosition?.let { state.closestPageToPosition(it)?.prevKey?.plus(1) }

    private companion object { const val START_PAGE = 1 }
}

CancellationException은 잡으면 안 됩니다. 위처럼 IOException/HttpException만 잡거나, 광범위하게 Exception을 잡는다면 CancellationException은 다시 던져야 합니다.


4. 커서 방식 (next token) PagingSource

서버가 nextCursor를 내려주는 경우 Key를 String으로 사용합니다.

class FeedPagingSource(private val api: FeedApi) : PagingSource<String, Post>() {

    override suspend fun load(params: LoadParams<String>): LoadResult<String, Post> =
        try {
            val response = api.getFeed(cursor = params.key, limit = params.loadSize)
            LoadResult.Page(
                data = response.posts,
                prevKey = null,                   // 한 방향(아래로)만 로드
                nextKey = response.nextCursor     // null이면 끝
            )
        } catch (e: Exception) {
            if (e is CancellationException) throw e
            LoadResult.Error(e)
        }

    override fun getRefreshKey(state: PagingState<String, Post>): String? = null
}

5. Repository와 ViewModel

class ArticleRepository @Inject constructor(private val api: ArticleApi) {

    fun getArticles(query: String?): Flow<PagingData<Article>> = Pager(
        config = PagingConfig(pageSize = 20, prefetchDistance = 5),
        pagingSourceFactory = { ArticlePagingSource(api, query) }
    ).flow
}

@HiltViewModel
class ArticleViewModel @Inject constructor(
    repository: ArticleRepository
) : ViewModel() {

    private val query = MutableStateFlow<String?>(null)

    // 검색어가 바뀔 때마다 새 Pager로 교체 (flatMapLatest)
    val articles: Flow<PagingData<Article>> = query
        .debounce(300)
        .flatMapLatest { repository.getArticles(it) }
        .cachedIn(viewModelScope)

    fun search(text: String) { query.value = text.ifBlank { null } }
}

검색어 변경 시 flatMapLatest로 이전 Pager를 취소하고 새로 시작합니다. debounce로 타이핑마다 요청하는 것을 방지합니다.


6. 에러 처리와 재시도

@Composable
fun ArticleScreen(vm: ArticleViewModel = hiltViewModel()) {
    val items = vm.articles.collectAsLazyPagingItems()

    LazyColumn {
        items(items.itemCount, key = items.itemKey { it.id }) { index ->
            items[index]?.let { ArticleItem(it) }
        }

        // 하단(append) 상태
        when (val append = items.loadState.append) {
            is LoadState.Loading -> item { CircularProgressIndicator() }
            is LoadState.Error -> item {
                ErrorFooter(append.error.message) { items.retry() }  // 마지막 실패 지점부터 재시도
            }
            else -> Unit
        }
    }

    // 최초 로드 상태
    if (items.loadState.refresh is LoadState.Loading) FullScreenLoading()
    if (items.loadState.refresh is LoadState.Error) FullScreenError { items.refresh() }
}
동작 설명
retry() 실패한 마지막 로드만 다시 시도
refresh() 데이터를 버리고 처음부터 다시 로드

7. 정리



Related Posts