--- id: android-paging-3-patterns title: Android Paging 3 — 효율적 페이지네이션 category: Coding status: draft source_trust_level: B verification_status: conceptual created_at: 2026-05-09 updated_at: 2026-05-09 tags: [android, paging, list, vibe-coding] tech_stack: { language: "Kotlin / Jetpack Paging 3", applicable_to: ["Android"] } applied_in: [] aliases: [PagingSource, RemoteMediator, PagingData, LazyColumn] --- # Android Paging 3 > 큰 list 를 chunk 로 fetch + 캐시 + 무한 스크롤. **PagingSource (단일 source)** 또는 **RemoteMediator + Room (network + DB)** 두 패턴. Compose / RecyclerView 모두 지원. ## 📖 핵심 개념 - PagingSource: load(params) → PagingSourceLoadResult. - Pager: configuration (pageSize, prefetch). - PagingData: ViewModel 에서 Compose / Adapter 로 흐름. ## 💻 코드 패턴 ### 단일 PagingSource (network only) ```kotlin class UserPagingSource(private val api: UserApi) : PagingSource() { override fun getRefreshKey(state: PagingState): Int? { return state.anchorPosition?.let { state.closestPageToPosition(it)?.prevKey?.plus(1) } } override suspend fun load(params: LoadParams): LoadResult = try { val page = params.key ?: 1 val res = api.fetchUsers(page = page, size = params.loadSize) LoadResult.Page( data = res.items, prevKey = if (page == 1) null else page - 1, nextKey = if (res.items.isEmpty()) null else page + 1, ) } catch (e: IOException) { LoadResult.Error(e) } } class UserViewModel(api: UserApi) : ViewModel() { val users: Flow> = Pager( config = PagingConfig(pageSize = 20, prefetchDistance = 3), pagingSourceFactory = { UserPagingSource(api) } ).flow.cachedIn(viewModelScope) } ``` ### Compose 사용 ```kotlin @Composable fun UserList(viewModel: UserViewModel) { val users = viewModel.users.collectAsLazyPagingItems() LazyColumn { items(users.itemCount, key = users.itemKey { it.id }) { index -> users[index]?.let { UserRow(it) } } when (val s = users.loadState.append) { is LoadState.Loading -> item { Spinner() } is LoadState.Error -> item { ErrorRow(s.error) { users.retry() } } else -> Unit } } } ``` ### RemoteMediator (network + DB cache) ```kotlin @OptIn(ExperimentalPagingApi::class) class UserRemoteMediator( private val api: UserApi, private val db: AppDb ) : RemoteMediator() { override suspend fun load(loadType: LoadType, state: PagingState): MediatorResult { try { val page = when (loadType) { LoadType.REFRESH -> 1 LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true) LoadType.APPEND -> { val last = state.lastItemOrNull() ?: return MediatorResult.Success(true) last.page + 1 } } val res = api.fetchUsers(page = page, size = state.config.pageSize) db.withTransaction { if (loadType == LoadType.REFRESH) db.userDao().clear() db.userDao().upsertAll(res.items.map { it.toEntity(page) }) } return MediatorResult.Success(endOfPaginationReached = res.items.isEmpty()) } catch (e: Exception) { return MediatorResult.Error(e) } } } // ViewModel val users = Pager( config = PagingConfig(pageSize = 20), remoteMediator = UserRemoteMediator(api, db), pagingSourceFactory = { db.userDao().pagingSource() } ).flow.cachedIn(viewModelScope) ``` ## 🤔 의사결정 기준 | 상황 | 패턴 | |---|---| | Network only, 캐시 불필요 | PagingSource | | Network + DB 캐시 / 오프라인 | RemoteMediator + Room | | 검색 (key 가 string) | PagingSource | | Cursor pagination | key = cursor | | 작은 list (50개 미만) | Paging 불필요 — 그냥 Flow | ## ❌ 안티패턴 - **cachedIn 없이**: configuration change 마다 새 fetch. cachedIn(viewModelScope). - **getRefreshKey 잘못**: refresh 시 첫 page 로 다시 → 사용자 위치 잃음. - **key 로 unstable id (timestamp)**: 같은 row 가 다른 page 에 나타남. - **error 상태 무시**: 사용자 멈춤 모름. retry button. - **endOfPaginationReached 잘못 판정**: 무한 fetch 또는 일찍 멈춤. ## 🤖 LLM 활용 힌트 - 신규 = Paging 3 + Compose collectAsLazyPagingItems. - offline 필요 = RemoteMediator. ## 🔗 관련 문서 - [[Android_Room_Patterns]] - [[React_Virtualization_Lists]]