πŸ“š Android Paging3 + Jetpack Compose: LoadState 처리 κ³΅ν†΅ν™”ν•˜κΈ°

μ΄μ§„μ˜Β·2025λ…„ 8μ›” 25일
post-thumbnail

πŸš€ Paging μ΄λž€???

Android μ•± κ°œλ°œμ„ ν•˜λ‹€ 보면
μ„œλ²„λ‘œλΆ€ν„° ν•œ λ²ˆμ— λͺ¨λ“  데이터λ₯Ό κ°€μ Έμ˜€κΈ° μ–΄λ €μš΄ κ²½μš°κ°€ λ§ŽμŠ΅λ‹ˆλ‹€.

이럴 λ•Œ μ‚¬μš©ν•˜λŠ” λŒ€ν‘œμ μΈ 기술이 λ°”λ‘œ
πŸ‘‰ Paging μž…λ‹ˆλ‹€.


πŸ“¦ Paging3 + Jetpack Compose

특히 Paging3 + Jetpack Compose 쑰합은
λ°©λŒ€ν•œ 데이터λ₯Ό 리슀트 ν˜•νƒœλ‘œ μžμ—°μŠ€λŸ½κ²Œ, λŠκΉ€ 없이 보여쀄 수 μžˆμ–΄
μ‹€λ¬΄μ—μ„œ 널리 ν™œμš©λ©λ‹ˆλ‹€.


🎨 UXλŠ” μ™œ μ€‘μš”ν• κΉŒ?

μ €λŠ” κ°œλ°œν•˜λ©΄μ„œ μ‚¬μš©μž κ²½ν—˜(UX)을 무엇보닀 μ€‘μš”ν•˜κ²Œ μƒκ°ν•©λ‹ˆλ‹€.

데이터가 λ‘œλ“œλ˜λŠ” λ™μ•ˆ 화면이 ν…… λΉ„μ–΄ 있으면,
μ‚¬μš©μžλŠ” λΆˆμ•ˆν•˜κ²Œ μƒκ°ν•©λ‹ˆλ‹€.
"앱이 멈좘 건가?" ν•˜λŠ” μ˜λ¬Έμ„ κ°–κ²Œ 되죠.


✨ Skeleton Loading λ„μž…

κ·Έλž˜μ„œ μ €λŠ” λ‘œλ”© μƒνƒœμ—μ„œ λ‹¨μˆœ μŠ€ν”Όλ„ˆ λŒ€μ‹ 
πŸ‘‰ Skeleton Loading(μŠ€μΌˆλ ˆν†€ λ‘œλ”©)을 μ μš©ν•©λ‹ˆλ‹€.

πŸ’‘ μŠ€μΌˆλ ˆν†€ λ‘œλ”©μ€ μ‹€μ œ μ½˜ν…μΈ μ˜ λ ˆμ΄μ•„μ›ƒμ„ νšŒμƒ‰ 블둝 λ“±μœΌλ‘œ 미리 κ·Έλ € 보여주어
μ‚¬μš©μžκ°€ "κ³§ 데이터가 λ‚˜νƒ€λ‚˜κ² κ΅¬λ‚˜" λΌλŠ” 심리적 μ•ˆμ •κ°μ„ λŠλ‚„ 수 있게 ν•˜λŠ” λ°©μ‹μž…λ‹ˆλ‹€.


πŸŒ€ Paging3의 LoadStateλž€?

LazyPagingItemsλ₯Ό μ“°λ©΄ 데이터 λ‘œλ”© μƒνƒœλ₯Ό μ•Œ 수 μžˆλŠ” loadStateκ°€ μ œκ³΅λ©λ‹ˆλ‹€.

  • refresh β†’ 초기 λ‘œλ”© / μƒˆλ‘œκ³ μΉ¨
  • append β†’ 리슀트 ν•˜λ‹¨ μΆ”κ°€ λ‘œλ”©

각각 Loading, Error, NotLoading μƒνƒœλ₯Ό κ°€μ§‘λ‹ˆλ‹€.

즉, μš°λ¦¬κ°€ ν•΄μ•Ό ν•  일은 κ°„λ‹¨ν•©λ‹ˆλ‹€:

  • ⏳ Loading β†’ μŠ€μΌˆλ ˆν†€ or λ‘œλ”© UI 보여주기
  • ❌ Error β†’ μ—λŸ¬ UI + μž¬μ‹œλ„ λ²„νŠΌ

❌ LoadState 직접 처리(곡톡화 μ „)

Paging3λ₯Ό Composeμ—μ„œ μ‚¬μš©ν•  λ•Œ κ°€μž₯ ν”ν•œ νŒ¨ν„΄μ€
각 ν™”λ©΄μ—μ„œ loadStateλ₯Ό 직접 λΆ„κΈ° μ²˜λ¦¬ν•˜λŠ” λ°©μ‹μž…λ‹ˆλ‹€.

LazyColumn(
    modifier = Modifier
        .fillMaxSize()
        .padding(top = 8.dp),
    state = scrollState,
    verticalArrangement = Arrangement.spacedBy(12.dp)
) {
    items(
        key = bookListPagingItems.itemKey { it.isbn },
        count = bookListPagingItems.itemCount
    ) { index ->
        /**
         * PagingItem에 λŒ€ν•œ Composable
         */
    }

    val loadState = bookListPagingItems.loadState

    when {
        loadState.refresh is LoadState.Loading -> {
            item {
                /**
                 * μƒˆλ‘œκ³ μΉ¨ μƒνƒœ(초기 λ‘œλ”©) λ‘œλ”© UI (예: Skeleton)
                 */
            }
        }

        loadState.refresh is LoadState.Error -> {
            val error = loadState.refresh as LoadState.Error
            item {
                /**
                 * μƒˆλ‘œκ³ μΉ¨ μƒνƒœ μ—λŸ¬ UI (errorλ₯Ό λ°›μ•„ μž¬μ‹œλ„ 제곡)
                 */
            }
        }

        loadState.append is LoadState.Loading -> {
            item {
                /**
                 * μΆ”κ°€ λ‘œλ”© μƒνƒœ λ‘œλ”© UI (예: ν•˜λ‹¨ Progress)
                 */
            }
        }

        loadState.append is LoadState.Error -> {
            val error = loadState.append as LoadState.Error
            item {
                /**
                 * μΆ”κ°€ λ‘œλ”© μƒνƒœ μ—λŸ¬ UI (errorλ₯Ό λ°›μ•„ μž¬μ‹œλ„ 제곡)
                 */
            }
        }
    }
}

⚠️ 문제점

  • 쀑볡 μ½”λ“œ: ν™”λ©΄λ§ˆλ‹€ λ™μΌν•œ when(loadState) 블둝을 μž‘μ„±ν•΄μ•Ό 함
  • μœ μ§€λ³΄μˆ˜ λΉ„μš© 증가: λ‘œλ”©/μ—λŸ¬ UIκ°€ λ°”λ€Œλ©΄ λͺ¨λ“  화면을 μˆ˜μ •ν•΄μ•Ό 함
  • UX λΆˆμΌκ΄€μ„±: ν™”λ©΄λ³„λ‘œ κ΅¬ν˜„μ΄ μ‘°κΈˆμ”© λ‹¬λΌμ§€λ©΄μ„œ μ‚¬μš©μž κ²½ν—˜μ— 차이가 생길 수 있음
  • 가독성 μ €ν•˜: 리슀트 μ•„μ΄ν…œ 둜직과 λ‘œλ”©/μ—λŸ¬ μ²˜λ¦¬κ°€ λ’€μ„žμ—¬ μ½”λ“œκ°€ λ³΅μž‘ν•΄μ§

βœ… LoadState 곡톡화: pagingLoadStateHandler

μ•žμ—μ„œ λ³Έ κ²ƒμ²˜λŸΌ LoadStateλ₯Ό 직접 μ²˜λ¦¬ν•˜λ©΄
쀑볡 μ½”λ“œ, μœ μ§€λ³΄μˆ˜ λΉ„μš©, UX λΆˆμΌκ΄€μ„± λ“± μ—¬λŸ¬ λ¬Έμ œκ°€ λ°œμƒν•©λ‹ˆλ‹€.

μ €λŠ” 이λ₯Ό ν•΄κ²°ν•˜κΈ° μœ„ν•΄ LazyListScope에 ν™•μž₯ ν•¨μˆ˜λ₯Ό λ§Œλ“€μ–΄
LoadState 처리λ₯Ό κ³΅ν†΅ν™”ν–ˆμŠ΅λ‹ˆλ‹€.

/**
 * νŽ˜μ΄μ§• μƒνƒœμ— λ”°λ₯Έ UI 처리λ₯Ό μœ„ν•œ LazyListScope ν™•μž₯ ν•¨μˆ˜.
 *
 * @param pagingItems LazyPagingItems μΈμŠ€ν„΄μŠ€.
 * @param loadStateRefreshLoading μƒˆλ‘œκ³ μΉ¨ μƒνƒœ(초기 λ‘œλ”©)μ—μ„œ λ‘œλ”© UIλ₯Ό ν‘œμ‹œν•˜λŠ” 컴포저블.
 * @param loadStateRefreshError μƒˆλ‘œκ³ μΉ¨ μƒνƒœμ—μ„œ μ—λŸ¬ UIλ₯Ό ν‘œμ‹œν•˜λŠ” 컴포저블. LoadState.Error 객체λ₯Ό νŒŒλΌλ―Έν„°λ‘œ λ°›μŒ.
 * @param loadStateAppendLoading μΆ”κ°€ λ‘œλ”© μƒνƒœμ—μ„œ λ‘œλ”© UIλ₯Ό ν‘œμ‹œν•˜λŠ” 컴포저블.
 * @param loadStateAppendError μΆ”κ°€ λ‘œλ”© μƒνƒœμ—μ„œ μ—λŸ¬ UIλ₯Ό ν‘œμ‹œν•˜λŠ” 컴포저블. LoadState.Error 객체λ₯Ό νŒŒλΌλ―Έν„°λ‘œ λ°›μŒ.
 */
fun LazyListScope.pagingLoadStateHandler(
    pagingItems: LazyPagingItems<*>,
    loadStateRefreshLoading: @Composable LazyItemScope.() -> Unit = {},
    loadStateRefreshError: @Composable LazyItemScope.(LoadState.Error) -> Unit = {},
    loadStateAppendLoading: @Composable LazyItemScope.() -> Unit = {},
    loadStateAppendError: @Composable LazyItemScope.(LoadState.Error) -> Unit = {}
) {
    val loadState = pagingItems.loadState

    when {
        loadState.refresh is LoadState.Loading -> {
            item {
                loadStateRefreshLoading()
            }
        }

        loadState.refresh is LoadState.Error -> {
            val error = loadState.refresh as LoadState.Error
            item {
                loadStateRefreshError(error)
            }
        }

        loadState.append is LoadState.Loading -> {
            item {
                loadStateAppendLoading()
            }
        }

        loadState.append is LoadState.Error -> {
            val error = loadState.append as LoadState.Error
            item {
                loadStateAppendError(error)
            }
        }
    }
}

πŸ“„ μ‹€μ œ 적용 예제

이제 ν™”λ©΄μ—μ„œλŠ” μ΄λ ‡κ²Œ 단 ν•œ μ€„λ§Œ μΆ”κ°€ν•˜λ©΄ λ©λ‹ˆλ‹€ πŸ‘‡

LazyColumn(
    modifier = Modifier
        .fillMaxSize()
        .padding(top = 8.dp),
    state = scrollState,
    verticalArrangement = Arrangement.spacedBy(12.dp)
) {
    items(
        key = bookListPagingItems.itemKey { it.isbn },
        count = bookListPagingItems.itemCount
    ) { index ->
        bookListPagingItems[index]?.let { book ->
            BookContent(bookModel = book)
        }
    }

    // βœ… LoadState 곡톡 처리
    pagingLoadStateHandler(
        pagingItems = bookListPagingItems,
        loadStateRefreshLoading = { SkeletonBookLoading() }, // 초기 λ‘œλ”© β†’ Skeleton UI
        loadStateRefreshError = { e -> LoadStateRefreshError(error = e) { onRetry() } },
        loadStateAppendLoading = { LoadStateAppendLoading() }, // ν•˜λ‹¨ μΆ”κ°€ λ‘œλ”© β†’ Progress
        loadStateAppendError = { e -> LoadStateAppendError(error = e) { onRetry() } },
    )
}

πŸ€” μ¨λ³΄λ‹ˆ μ•„μ‰¬μš΄ 점

이 방식(pagingLoadStateHandler 직접 호좜)은 λΆ„λͺ… νŽΈλ¦¬ν•΄μ‘Œμ§€λ§Œ,
μ‹€μ œλ‘œ μ—¬λŸ¬ 화면에 μ μš©ν•˜λ‹€ λ³΄λ‹ˆ 쑰금 μ•„μ‰¬μš΄ 점이 μžˆμ—ˆμŠ΅λ‹ˆλ‹€.

  • 맀번 loadStateRefreshLoading, loadStateRefreshError,
    loadStateAppendLoading, loadStateAppendErrorλ₯Ό λͺ¨λ‘ λ„˜κ²¨μ€˜μ•Ό 함
  • 사싀 ν™”λ©΄ μž…μž₯μ—μ„œ μ€‘μš”ν•œ 건 retry 이벀트만 λ„˜κ²¨μ£Όλ©΄ λ˜λŠ” κ²½μš°κ°€ λŒ€λΆ€λΆ„
  • λ‘œλ”©/μ—λŸ¬ UIλŠ” ν”„λ‘œμ νŠΈ μ „μ—­μ—μ„œ λ™μΌν•˜κ²Œ μ“°λŠ” νŒ¨ν„΄μ΄ 많음

즉, Loading UIλŠ” λ‘œλ”©μ‹œμ— 항상 λ…ΈμΆœλ˜μ–΄μ•Όν•˜κ³  μ‹€μ œ ν™”λ©΄μ—μ„œλŠ” λ°›λŠ” μ΄λ²€νŠΈλŠ” μž¬μ‹œλ„ 이벀트 뿐인데
λΆˆν•„μš”ν•˜κ²Œ μ½”λ“œκ°€ κΈΈμ–΄μ§€λŠ” λŠλ‚Œμ΄ μžˆμ—ˆμŠ΅λ‹ˆλ‹€.


πŸ”§ ν•œ 단계 더 λ‹¨μˆœν™”: handlePagingLoadState

κ·Έλž˜μ„œ μ €λŠ” ν”„λ‘œμ νŠΈμ— 맞게 ν•œ 단계 더 λž˜ν•‘ν•œ ν•¨μˆ˜λ₯Ό λ§Œλ“€μ—ˆμŠ΅λ‹ˆλ‹€.
이 ν•¨μˆ˜ μ•ˆμ—μ„œ λ‘œλ”©/μ—λŸ¬ UIλ₯Ό 고정해두고,
ν™”λ©΄μ—μ„œλŠ” onRetry ν•˜λ‚˜λ§Œ λ„˜κ²¨μ£Όλ©΄ λ©λ‹ˆλ‹€.

LazyColumn(
    modifier = Modifier
        .fillMaxSize()
        .padding(top = 8.dp),
    state = scrollState,
    verticalArrangement = Arrangement.spacedBy(12.dp)
) {
    items(
        key = bookListPagingItems.itemKey { it.isbn },
        count = bookListPagingItems.itemCount
    ) { index ->
        /**
         * PagingItem에 λŒ€ν•œ Composable
         */
    }

    // βœ… LoadState 곡톡 처리 (onRetry만 λ„˜κ²¨μ£Όλ©΄ 됨)
    handlePagingLoadState(
        bookListPagingItems = bookListPagingItems,
        onRetry = onRetry
    )
}

πŸ”¨ 그리고 래퍼 ν•¨μˆ˜λŠ” μ΄λ ‡κ²Œ μ •μ˜ν–ˆμŠ΅λ‹ˆλ‹€:

private fun LazyListScope.handlePagingLoadState(
    bookListPagingItems: LazyPagingItems<BookModel>,
    onRetry: () -> Unit,
) {
    pagingLoadStateHandler(
        pagingItems = bookListPagingItems,
        loadStateRefreshLoading = {
            LoadStateRefreshLoading()
        },
        loadStateRefreshError = { error ->
            LoadStateRefreshError(
                error = error,
                onClickRetry = onRetry
            )
        },
        loadStateAppendLoading = {
            LoadStateAppendLoading()
        },
        loadStateAppendError = { error ->
            LoadStateAppendError(
                error = error,
                onClickRetry = onRetry
            )
        },
    )
}

πŸ“Š 비ꡐ: μ„Έ κ°€μ§€ μ ‘κ·Ό 방식

ꡬ뢄LoadState 직접 처리pagingLoadStateHandler (곡톡화)handlePagingLoadState (λ‹¨μˆœν™”)
μ½”λ“œ κΈΈμ΄ν™”λ©΄λ§ˆλ‹€ κΈΈκ³  μ€‘λ³΅ν•œ μ€„λ‘œ 쀄어듦, ν•˜μ§€λ§Œ 인자 많음onRetry ν•˜λ‚˜λ§Œ λ„˜κΈ°λ©΄ 끝
μœ μ§€λ³΄μˆ˜UI λ°”λ€Œλ©΄ λͺ¨λ“  ν™”λ©΄ μˆ˜μ •μœ ν‹Έ ν•¨μˆ˜λ§Œ μˆ˜μ •ν•˜λ©΄ 전체 반영래퍼 ν•¨μˆ˜λ§Œ μˆ˜μ •ν•˜λ©΄ 전체 반영
UX μΌκ΄€μ„±ν™”λ©΄λ³„λ‘œ λ‹€λ₯΄κ²Œ κ΅¬ν˜„λ  수 μžˆμŒκ³΅ν†΅ 처리둜 μ–΄λŠ 정도 보μž₯μ „μ—­ UI κ³ μ • β†’ μ™„μ „ 톡일
κ°€λ…μ„±λ¦¬μŠ€νŠΈμ™€ μƒνƒœμ²˜λ¦¬ λ’€μ„žμž„μ½”λ“œ 뢄리가 λ˜μ–΄ κ°œμ„ κ°€μž₯ κ°„κ²°ν•˜κ³  깔끔

🎯 결둠

  • Paging3의 LoadState μ²˜λ¦¬λŠ” 반볡적이고 μ‹€μˆ˜ν•˜κΈ° μ‰¬μš΄ μž‘μ—…μž…λ‹ˆλ‹€.
  • pagingLoadStateHandlerλ₯Ό λ§Œλ“€λ©΄ 쀑볡 제거 + 일관성을 얻을 수 μžˆμŠ΅λ‹ˆλ‹€.
  • 더 λ‚˜μ•„κ°€ handlePagingLoadState 같은 ν”„λ‘œμ νŠΈ 맞좀 래퍼둜 λ‹¨μˆœν™”ν•˜λ©΄,
    ν™”λ©΄μ—μ„œλŠ” onRetry만 λ„˜κΈ°λ©΄ λ˜λ―€λ‘œ μ½”λ“œκ°€ κ°€μž₯ κΉ”λ”ν•΄μ§‘λ‹ˆλ‹€.

πŸ‘‰ 특히 μ €λŠ” UXλ₯Ό μ€‘μš”μ‹œν•˜κΈ° λ•Œλ¬Έμ— Skeleton Loading을 기본으둜 μ μš©ν•΄,
μ‚¬μš©μžμ—κ²Œ "앱이 멈좘 게 μ•„λ‹ˆλΌ 데이터λ₯Ό λΆˆλŸ¬μ˜€λŠ” μ€‘μ΄κ΅¬λ‚˜" ν•˜λŠ” μ•ˆμ •κ°μ„ 주도둝 ν–ˆμŠ΅λ‹ˆλ‹€.


πŸ’¬ μ—¬λŸ¬λΆ„μ€ Paging3의 λ‘œλ”© μƒνƒœλ₯Ό μ–΄λ–»κ²Œ μ²˜λ¦¬ν•˜κ³  κ³„μ‹ κ°€μš”?

  • λ‹¨μˆœ μŠ€ν”Όλ„ˆ?
  • Skeleton Loading?
  • μ•„λ‹ˆλ©΄ μ €μ²˜λŸΌ μœ ν‹Έμ„ λ§Œλ“€μ–΄ 곡톡화?

λŒ“κΈ€λ‘œ κ³΅μœ ν•΄μ£Όμ‹œλ©΄ μ’‹κ² μŠ΅λ‹ˆλ‹€ πŸš€

profile
Android Developer

0개의 λŒ“κΈ€