Paging 3 개요

이 가이드에서는 Jetpack Compose로 Paging 3를 구현하는 방법을 설명하며, Room 데이터베이스를 사용하는 구현과 사용하지 않는 구현을 모두 다룹니다. 페이지로 나누기는 한 번에 모든 데이터를 로드하는 대신 페이지라고 하는 작고 관리하기 쉬운 청크로 로드하고 표시하여 대규모 데이터 세트를 관리하는 전략입니다.

무한 스크롤 피드 (예: 소셜 미디어 타임라인, 대규모 전자상거래 제품 카탈로그, 광범위한 이메일 받은편지함)가 있는 앱에는 강력한 데이터 페이지로 나누기가 필요합니다. 사용자는 일반적으로 목록의 일부만 보고 휴대기기의 화면 크기는 제한되어 있으므로 전체 데이터 세트를 로드하는 것은 효율적이지 않습니다. 시스템 리소스를 낭비하고 끊김 현상이나 앱 정지를 일으켜 사용자 환경을 악화시킬 수 있습니다. 이 문제를 해결하려면 지연 로딩을 사용하면 됩니다. Compose의 LazyList와 같은 구성요소는 UI 측에서 지연 로딩을 처리하지만 디스크나 네트워크에서 데이터를 지연 로딩하면 성능이 더욱 향상됩니다.

Paging 3 라이브러리는 데이터 페이지로 나누기를 처리하는 데 권장되는 솔루션입니다. Paging 2에서 마이그레이션하는 경우 Paging 3으로 마이그레이션을 참고하세요.

기본 요건

계속하기 전에 다음 사항을 숙지하세요.

  • Android의 네트워킹 (이 문서에서는 Retrofit을 사용하지만 Paging 3는 Ktor와 같은 모든 라이브러리에서 작동함)
  • Compose UI 도구 키트

종속 항목 설정

앱 수준 build.gradle.kts 파일에 다음 종속 항목을 추가합니다.

dependencies {
  val paging_version = "3.4.0"

  // Paging Compose
  implementation("androidx.paging:paging-compose:$paging_version")

  // Networking dependencies used in this guide
  val retrofit = "3.0.0"
  val kotlinxSerializationJson = "1.9.0"
  val retrofitKotlinxSerializationConverter = "1.0.0"
  val okhttp = "4.12.0"

  implementation("com.squareup.retrofit2:retrofit:$retrofit")
  implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxSerializationJson")
  implementation("com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:$retrofitKotlinxSerializationConverter")
  implementation(platform("com.squareup.okhttp3:okhttp-bom:$okhttp"))
  implementation("com.squareup.okhttp3:okhttp")
  implementation("com.squareup.okhttp3:logging-interceptor")
}

Pager 클래스 정의

Pager 클래스는 페이지로 나누기의 기본 진입점입니다. PagingData의 반응형 스트림을 생성합니다. Pager를 인스턴스화하고 ViewModel 내에서 재사용해야 합니다.

Pager은 데이터를 가져오고 표시하는 방법을 결정하기 위해 PagingConfig이 필요합니다.

// Configs for pagination
val PAGING_CONFIG = PagingConfig(
    pageSize = 50, // Items requested from data source
    enablePlaceholders = false,
    initialLoadSize = 50,
    prefetchDistance = 10 // Items from the end that trigger the next fetch
)

데이터베이스 없이 (네트워크만) 또는 데이터베이스를 사용하여 (Room 사용) 두 가지 방법으로 Pager를 구현할 수 있습니다.

데이터베이스 없이 구현

데이터베이스를 사용하지 않는 경우 필요에 따라 데이터 로드를 처리하려면 PagingSource<Key, Value>가 필요합니다. 이 예시에서 키는 Int이고 값은 Product입니다.

PagingSource에서 두 개의 추상 메서드를 구현해야 합니다.

  • load: LoadParams를 수신하는 정지 함수입니다. Refresh, Append 또는 Prepend 요청의 데이터를 가져오는 데 사용합니다.

  • getRefreshKey: 페이저가 무효화된 경우 데이터를 다시 로드하는 데 사용되는 키를 제공합니다. 이 메서드는 사용자의 현재 스크롤 위치 (state.anchorPosition)를 기반으로 키를 계산합니다.

다음 코드 예는 로컬 데이터베이스 없이 Paging 3를 사용할 때 데이터 가져오기 로직을 정의하는 데 필요한 ProductPagingSource 클래스를 구현하는 방법을 보여줍니다.

class ProductPagingSource : PagingSource<Int, Product>() {
    override fun getRefreshKey(state: PagingState<Int, Product>): Int {

// This is called when the Pager needs to load new data after invalidation
      // (for example, when the user scrolls quickly or the data stream is
      // manually refreshed).

      // It tries to calculate the page key (offset) that is closest to the
      // item the user was last viewing (`state.anchorPosition`).

        return ((state.anchorPosition ?: 0) - state.config.initialLoadSize / 2).coerceAtLeast(0)
    }

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Product> {
        return when (params) {
                // Case 1: The very first load or a manual refresh. Start from
                // offset 0.
            is LoadParams.Refresh<Int> -> {
                fetchProducts(0, params.loadSize)
            }
                // Case 2: User scrolled to the end of the list. Load the next
                // 'page' using the stored key.
            is LoadParams.Append<Int> -> {
                fetchProducts(params.key, params.loadSize)
            }
                // Case 3: Loading backward. Not supported in this
                // implementation.
            is LoadParams.Prepend<Int> -> LoadResult.Invalid()
        }
    }
// Helper function to interact with the API service and map the response
//  into a [LoadResult.Page] or [LoadResult.Error].
    private suspend fun fetchProducts(key: Int, limit: Int): LoadResult<Int, Product> {
        return try {
            val response = productService.fetchProducts(limit, key)

            LoadResult.Page(
                data = response.products,
                prevKey = null,
                nextKey = (key + response.products.size).takeIf { nextKey ->
                    nextKey < response.total
                }
            )
        } catch (e: Exception) {
                // Captures network failures or JSON parsing errors to display
                // in the UI.
            LoadResult.Error(e)
        }
    }
}

ViewModel 클래스에서 Pager를 만듭니다.

val productPager = Pager(
    //  Configuration: Defines page size, prefetch distance, and placeholders.
    config = PAGING_CONFIG,
    //  Initial State: Start loading data from the very first index (offset 0).
    initialKey = 0,
    //  Factory: Creates a new instance of the PagingSource whenever the
    // data is invalidated (for example, calling pagingSource.invalidate()).
    pagingSourceFactory = { ProductPagingSource() }
).flow.cachedIn(viewModelScope)

데이터베이스로 구현

Room을 사용하면 데이터베이스에서 PagingSource 클래스를 자동으로 생성합니다. 하지만 데이터베이스는 네트워크에서 언제 더 많은 데이터를 가져와야 하는지 알지 못합니다. 이를 처리하려면 RemoteMediator을 구현하세요.

RemoteMediator.load() 메서드는 loadType (Append, Prepend 또는 Refresh) 및 상태를 제공합니다. 성공 또는 실패 여부와 페이지로 나누기의 끝에 도달했는지를 나타내는 MediatorResult를 반환합니다.

@OptIn(ExperimentalPagingApi::class)
@OptIn(ExperimentalPagingApi::class)
class ProductRemoteMediator : RemoteMediator<Int, Product>() {
    override suspend fun load(
        loadType: LoadType,
        state: PagingState<Int, Product>
    ): MediatorResult {
        return try {
            // Get the count of loaded items to calculate the skip value
            val skip = when (loadType) {
                LoadType.REFRESH -> 0
                LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
                LoadType.APPEND -> {
                    InMemoryDatabaseProvider.INSTANCE.productDao().getCount()
                }
            }

            val response = productService.fetchProducts(
                state.config.pageSize,
                skip
            )

            InMemoryDatabaseProvider.INSTANCE.productDao().apply {
                insertAll(response.products)
            }

            MediatorResult.Success(
                endOfPaginationReached = response.skip + response.limit >= response.total
            )
        } catch (e: Exception) {
            MediatorResult.Error(e)
        }
    }
}

ViewModel에서 Room이 PagingSource 클래스를 처리하므로 구현이 크게 간소화됩니다.

val productPager = ProductRepository().fetchProducts().flow.cachedIn(viewModelScope)

네트워크 설정

이전 예시에서는 네트워크 서비스를 사용합니다. 이 섹션에서는 api.example.com/products 엔드포인트에서 데이터를 가져오는 데 사용되는 Retrofit 및 직렬화 설정을 제공합니다.

데이터 클래스

다음 코드 예에서는 kotlinx.serialization와 함께 사용하여 네트워크 서비스에서 페이지로 구분된 JSON 응답을 파싱하는 두 데이터 클래스 ProductResponseProduct를 정의하는 방법을 보여줍니다.

@Serializable
data class ProductResponse(
    val products: List<Product>,
    val total: Int,
    val skip: Int,
    val limit: Int
)

@Serializable
data class Product(
    val id: Int,
    var title: String = "",
    // ... other fields (description, price, etc.)
    val thumbnail: String = ""
)

개조 서비스

다음 코드 예에서는 네트워크 전용 구현을 위해 Retrofit 서비스 인터페이스(ProductService)를 정의하여 Paging 3 라이브러리에서 데이터 페이지를 가져오는 데 필요한 엔드포인트(@GET("/products"))와 필수 페이지로 나누기 매개변수(limitskip)를 지정하는 방법을 보여줍니다.

interface ProductService {
    @GET("/products")
    suspend fun fetchProducts(
        @Query("limit") limit: Int,
        @Query("skip") skip: Int
    ): ProductResponse
}

// Setup logic (abbreviated)
val jsonConverter = Json { ignoreUnknownKeys = true }
val retrofit = Retrofit.Builder()
    .baseUrl("https://api.example.com")
    .addConverterFactory(jsonConverter.asConverterFactory("application/json".toMediaType()))
    // ... client setup
    .build()

Compose에서 데이터 사용

Pager를 설정한 후 UI에 데이터를 표시할 수 있습니다.

  1. 흐름 수집: collectAsLazyPagingItems()를 사용하여 흐름을 상태 인식 지연 로드 페이지 항목 객체로 변환합니다.

    val productPagingData = mainViewModel.productPager.collectAsLazyPagingItems()
    

    결과 LazyPagingItems 객체는 항목 수와 색인화된 액세스를 제공하므로 목록 항목을 렌더링하기 위해 LazyColumn 메서드에서 직접 사용할 수 있습니다.

  2. LazyColumn에 바인딩: 데이터를 LazyColumn 목록에 전달합니다. RecyclerView 목록에서 이전하는 경우 withLoadStateHeaderAndFooter를 사용하여 목록의 상단 또는 하단에 로드 스피너 또는 오류 재시도 버튼을 표시하는 데 익숙할 수 있습니다.

    Compose에서는 이를 위해 특별한 어댑터가 필요하지 않습니다. prepend (헤더) 및 append (바닥글) 로드 상태에 직접 반응하여 기본 items {} 블록 전후에 조건부로 item {} 블록을 추가하면 정확히 동일한 동작을 달성할 수 있습니다.

    LazyColumn {
        // --- HEADER (Equivalent to loadStateHeader) ---
        // Reacts to 'prepend' states when scrolling towards the top
        if (productPagingData.loadState.prepend is LoadState.Loading) {
            item {
                Box(modifier = Modifier.fillMaxWidth(), contentAlignment =
                Alignment.Center) {
                    CircularProgressIndicator(modifier = Modifier.padding(16.dp))
                }
            }
        }
        if (productPagingData.loadState.prepend is LoadState.Error) {
            item {
                ErrorHeader(onRetry = { productPagingData.retry() })
            }
        }
    
        // --- MAIN LIST ITEMS ---
        items(count = productPagingData.itemCount) { index ->
            val product = productPagingData[index]
            if (product != null) {
                UserPagingListItem(product = product)
            }
        }
    
        // --- FOOTER (Equivalent to loadStateFooter) ---
        // Reacts to 'append' states when scrolling towards the bottom
        if (productPagingData.loadState.append is LoadState.Loading) {
            item {
                Box(modifier = Modifier.fillMaxWidth(), contentAlignment =
                Alignment.Center) {
                    CircularProgressIndicator(modifier = Modifier.padding(16.dp))
                }
            }
        }
        if (productPagingData.loadState.append is LoadState.Error) {
            item {
               ErrorFooter(onRetry = { productPagingData.retry() })
            }
        }
    }
    

Compose의 기능을 통해 항목 모음을 효과적으로 표시하는 방법에 관한 자세한 내용은 목록 및 그리드를 참고하세요.

로드 상태 처리

PagingData 객체는 로드 상태 정보를 통합합니다. 이를 사용하여 다양한 상태 (refresh, append, prepend)에 로딩 스피너 또는 오류 메시지를 표시할 수 있습니다.

불필요한 리컴포지션을 방지하고 UI가 로드 라이프사이클의 의미 있는 전환에만 반응하도록 하려면 상태 관찰을 필터링해야 합니다. loadState는 내부 변경사항에 따라 자주 업데이트되므로 복잡한 상태 변경사항을 위해 직접 읽으면 끊김 현상이 발생할 수 있습니다.

snapshotFlow를 사용하여 상태를 관찰하고 distinctUntilChangedBy 속성과 같은 흐름 연산자를 적용하여 이를 최적화할 수 있습니다. 이는 빈 상태를 표시하거나 오류 스낵바와 같은 부작용을 트리거할 때 특히 유용합니다.

val snackbarHostState = remember { SnackbarHostState() }

LaunchedEffect(productPagingData.loadState) {
    snapshotFlow { productPagingData.loadState }
        // Filter out updates that don't change the refresh state
        .distinctUntilChangedBy { it.refresh }
        // Only react when the state is an Error
        .filter { it.refresh is LoadState.Error }
        .collect { loadState ->
            val error = (loadState.refresh as LoadState.Error).error
            snackbarHostState.showSnackbar(
                message = "Data failed to load: ${error.localizedMessage}",
                actionLabel = "Retry"
            )
        }
}

새로고침 상태를 확인하여 전체 화면 로드 스피너를 표시할 때는 derivedStateOf를 사용하여 불필요한 리컴포지션을 방지하세요.

또한 RemoteMediator (예: 앞서 나온 Room 데이터베이스 구현)을 사용하는 경우 편의 loadState.refresh 속성 대신 기본 데이터 소스 (loadState.source.refresh)의 로드 상태를 명시적으로 검사합니다. 편의 속성은 데이터베이스가 새 항목을 UI에 추가하기 전에 네트워크 가져오기가 완료되었다고 보고할 수 있습니다. source를 확인하면 UI가 로컬 데이터베이스와 완전히 동기화되어 로더가 너무 일찍 사라지는 것을 방지할 수 있습니다.

// Safely check the refresh state for a full-screen spinner
// without triggering unnecessary recompositions
val isRefreshing by remember {
    derivedStateOf { productPagingData.loadState.source.refresh is LoadState.Loading }
}
if (isRefreshing) {
    // Show UI for refreshing (for example, full screen spinner)
    Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        CircularProgressIndicator()
    }
}

LoadState.Error를 확인하여 사용자에게 다시 시도 버튼이나 오류 메시지를 표시할 수도 있습니다. 기본 예외를 노출하고 사용자 복구를 위한 내장 retry() 함수를 지원하므로 LoadState.Error를 사용하는 것이 좋습니다.

if (refreshState is LoadState.Error) {
   val e = refreshState as LoadState.Error

   // This composable should ideally replace the entire list if the initial load
   // fails.
   ErrorScreen(
       message = "Data failed to load: ${e.error.localizedMessage}",
       onClickRetry = { productPagingData.retry() }
   )
}

구현 테스트

페이지로 나누기 구현을 테스트하면 데이터가 올바르게 로드되고, 변환이 예상대로 적용되며, UI가 상태 변경에 올바르게 반응하는지 확인할 수 있습니다. Paging 3 라이브러리는 이 프로세스를 간소화하기 위한 전용 테스트 아티팩트(androidx.paging:paging-testing)를 제공합니다.

먼저 build.gradle 파일에 테스트 종속 항목을 추가합니다.

testImplementation("androidx.paging:paging-testing:$paging_version")

데이터 영역 테스트

PagingSource를 직접 테스트하려면 TestPager를 사용하세요. 이 유틸리티는 Paging 3의 기본 메커니즘을 처리하며 전체 Pager 설정 없이 초기 로드 (새로고침), 데이터 추가 또는 데이터 앞에 추가와 같은 특이 사례를 독립적으로 확인할 수 있습니다.

@Test
fun testProductPagingSource() = runTest {
    val pagingSource = ProductPagingSource(mockApiService)

    // Create a TestPager to interact with the PagingSource
    val pager = TestPager(
        config = PAGING_CONFIG,
        pagingSource = pagingSource
    )

    // Trigger an initial load
    val result = pager.refresh() as PagingSource.LoadResult.Page

    // Assert the data size and edge cases like next/prev keys
    assertEquals(50, result.data.size)
    assertNull(result.prevKey)
    assertEquals(50, result.nextKey)
}

ViewModel 로직 및 변환 테스트

ViewModelPagingData 흐름에 데이터 변환 (예: .map 작업)을 적용하는 경우 asPagingSourceFactoryasSnapshot()를 사용하여 이 로직을 테스트할 수 있습니다.

asPagingSourceFactory 확장 프로그램은 정적 목록을 PagingSource로 변환하여 저장소 레이어를 더 간단하게 모의할 수 있습니다. asSnapshot() 확장 프로그램은 PagingData 스트림을 표준 Kotlin List로 수집하여 변환된 데이터에 표준 어설션을 실행할 수 있습니다.

@Test
fun testViewModelTransformations() = runTest {
    // 1. Mock your initial data using asPagingSourceFactory
    val mockProducts = listOf(Product(1, "A"), Product(2, "B"))
    val pagingSourceFactory = mockProducts.asPagingSourceFactory()

    // 2. Pass the mocked factory to your ViewModel or Pager
    val pager = Pager(
        config = PagingConfig(pageSize = 10),
        pagingSourceFactory = pagingSourceFactory
    )

    // 3. Apply your ViewModel transformations (for example, mapping to a UI
    //    model)
    val transformedFlow = pager.flow.map { pagingData ->
        pagingData.map { product -> product.title.uppercase() }
    }

    // 4. Extract the data as a List using asSnapshot()
    val snapshot: List<String> = transformedFlow.asSnapshot(this)

    // 5. Verify the transformation
    assertEquals(listOf("A", "B"), snapshot)
}

상태와 리컴포지션을 확인하는 UI 테스트

UI를 테스트할 때는 Compose 구성요소가 데이터를 올바르게 렌더링하고 로드 상태에 적절하게 반응하는지 확인합니다. PagingData.from()flowOf()를 사용하여 정적 PagingData을 전달하여 데이터 스트림을 시뮬레이션할 수 있습니다. 또한 SideEffect를 사용하여 테스트 중에 리컴포지션 수를 추적하여 Compose 구성요소가 불필요하게 리컴포지션되지 않도록 할 수 있습니다.

다음 예에서는 로드 상태를 시뮬레이션하고, 로드된 상태로 전환하고, UI 노드와 리컴포지션 수를 모두 확인하는 방법을 보여줍니다.

@get:Rule
val composeTestRule = createComposeRule()

@Test
fun testProductList_loadingAndDataStates() {
    val context = InstrumentationRegistry.getInstrumentation().targetContext

    // Create a MutableStateFlow to emit different PagingData states over time
    val pagingDataFlow = MutableStateFlow(PagingData.empty<Product>())
    var recompositionCount = 0

    composeTestRule.setContent {
        val lazyPagingItems = pagingDataFlow.collectAsLazyPagingItems()

        // Track recompositions of this composable
        SideEffect { recompositionCount++ }

        ProductListScreen(lazyPagingItems = lazyPagingItems)
    }

    // 1. Simulate initial loading state
    pagingDataFlow.value = PagingData.empty(
        sourceLoadStates = LoadStates(
            refresh = LoadState.Loading,
            prepend = LoadState.NotLoading(endOfPaginationReached = false),
            append = LoadState.NotLoading(endOfPaginationReached = false)
        )
    )

    // Verify that the loading indicator is displayed
    composeTestRule.onNodeWithTag("LoadingSpinner").assertIsDisplayed()

    // 2. Simulate data loaded state
    val mockItems = listOf(
        Product(id = 1, title = context.getString(R.string.product_a_title)),
        Product(id = 2, title = context.getString(R.string.product_b_title))
    )

    pagingDataFlow.value = PagingData.from(
        data = mockItems,
        sourceLoadStates = LoadStates(
            refresh = LoadState.NotLoading(endOfPaginationReached = false),
            prepend = LoadState.NotLoading(endOfPaginationReached = false),
            append = LoadState.NotLoading(endOfPaginationReached = false)
        )
    )

    // Wait for the UI to settle and verify the items are displayed
    composeTestRule.waitForIdle()
    composeTestRule.onNodeWithText(context.getString(R.string.product_a_title)).assertIsDisplayed()
    composeTestRule.onNodeWithText(context.getString(R.string.product_b_title)).assertIsDisplayed()

    // 3. Verify recomposition counts
    // Assert that recompositions are within expected limits (for example,
    // initial composition + updating to load state + updating to data state)
    assert(recompositionCount <= 3) {
        "Expected less than or equal to 3 recompositions, but got $recompositionCount"
    }
}