Android Paging 3 완벽 가이드 — RecyclerView, SQLiteOpenHelper, Retrofit과 무한 스크롤 구현
Android 앱에서 RecyclerView를 사용하다 보면 데이터가 많아질수록 성능 문제가 발생할 수 있습니다.
예를 들어 조류 도감 앱에서 수천 건의 조류 정보를 SQLite 데이터베이스에 저장하고 있다고 가정하겠습니다.
처음에는 다음과 같이 전체 데이터를 조회할 수 있습니다.
val birds = database.getAllBirds()
adapter.submitList(birds)데이터가 적을 때는 큰 문제가 없을 수 있습니다.
하지만 데이터가 증가하면 다음과 같은 문제가 발생할 수 있습니다.
첫 화면을 표시하는 시간이 길어진다.
데이터 조회 시 메모리 사용량이 증가한다.
검색 결과가 많을 때 화면이 느려진다.
RecyclerView에 불필요한 데이터가 전달된다.
서버에서 대량 데이터를 한 번에 내려받는다.
화면을 다시 열 때 목록 전체를 다시 조회한다.
이러한 문제를 해결하는 방법 중 하나가 Paging 3입니다.
Paging 3는 데이터를 필요한 단위로 나누어 불러오고 RecyclerView에 전달할 수 있도록 도와주는 Jetpack 라이브러리입니다.
이번 글에서는 Room을 사용하지 않고 SQLiteOpenHelper로 데이터베이스를 관리하는 기존 Android 앱을 기준으로 Paging 3를 구현하겠습니다.
또한 Retrofit 기반 서버 페이징과 광고 아이템을 포함한 RecyclerView 처리 방법도 살펴보겠습니다.
개발 환경: Kotlin, XML ViewBinding, MVVM, Coroutine, StateFlow, RecyclerView, SQLiteOpenHelper, Retrofit2, Paging 3, minSdk 24 이상. 코드에서 라이브러리 버전은 공식 릴리스와 프로젝트 호환성을 확인해 적용해야 합니다.
1. Paging 3란?
Paging 3는 Android Jetpack에서 제공하는 데이터 페이징 라이브러리입니다.
전체 데이터를 한 번에 불러오는 대신 필요한 페이지를 순차적으로 로드합니다.
예를 들어 데이터가 10,000건이고 페이지 크기가 30이라면 처음부터 10,000건을 모두 가져올 필요가 없습니다.
전체 데이터: 10,000건
첫 번째 로드
└── 1~30번 데이터
스크롤 이동
└── 31~60번 데이터
추가 스크롤
└── 61~90번 데이터실제 Paging 3에서는 초기 로드 크기와 미리 가져오기 설정에 따라 한 번에 불러오는 데이터 수가 달라질 수 있습니다.
Paging 3의 장점은 다음과 같습니다.
필요한 데이터만 로드
메모리 사용량 관리
스크롤 위치에 따른 추가 로딩
로딩 상태 처리
오류 및 재시도 지원
Coroutine과 Flow 연동
RecyclerView와 통합
로컬 DB 및 서버 API 지원
Paging 3 아키텍처
2. 주요 구성요소
Paging 3의 주요 구성요소는 다음과 같습니다.
| 구성요소 | 역할 |
|---|---|
PagingSource | 데이터를 페이지 단위로 로드 |
Pager | PagingSource를 이용해 PagingData 생성 |
PagingConfig | 페이지 크기 및 로딩 정책 |
PagingData | 페이징 데이터 컨테이너 |
PagingDataAdapter | RecyclerView에 데이터 표시 |
LoadState | 로딩·오류·완료 상태 |
RemoteMediator | 로컬 DB와 네트워크 연동 시 활용 |
이번 글에서는 먼저 PagingSource를 직접 구현하는 방법부터 살펴보겠습니다.
3. MVVM 기반 구조
SQLiteOpenHelper를 사용하는 앱에서는 다음과 같이 구성할 수 있습니다.
BirdListFragment
│
▼
BirdListViewModel
│
▼
BirdRepository
│
▼
Pager
│
▼
BirdPagingSource
│
▼
SQLiteOpenHelper
│
▼
SQLite Database이 구조의 장점은 UI가 SQLite 데이터베이스에 직접 접근하지 않는다는 것입니다.
Fragment는 PagingData를 수집해 RecyclerView에 전달하고, 데이터 조회는 Repository와 PagingSource에서 처리합니다.
Gradle 설정
4. Paging 3 의존성 추가
app/build.gradle.kts에 다음 라이브러리를 추가합니다.
dependencies {
implementation(
"androidx.paging:paging-runtime-ktx:<version>"
)
implementation(
"androidx.recyclerview:recyclerview:<version>"
)
implementation(
"androidx.lifecycle:lifecycle-runtime-ktx:<version>"
)
implementation(
"androidx.lifecycle:lifecycle-viewmodel-ktx:<version>"
)
}<version>은 공식 AndroidX 릴리스에서 확인한 버전으로 교체합니다.
Paging 3를 XML 기반 RecyclerView에서 사용하려면 paging-runtime 계열 라이브러리가 필요합니다.
SQLite 데이터베이스 준비
5. 조류 데이터 테이블
다음과 같은 SQLite 테이블이 있다고 가정하겠습니다.
CREATE TABLE korean_birds (
KTSN INTEGER PRIMARY KEY,
Species TEXT,
SpeciesKor TEXT,
OrderKor TEXT,
FamilyKor TEXT,
GenusKor TEXT
);실제 프로젝트의 테이블과 컬럼 이름에 맞게 수정해야 합니다.
검색과 페이징에 필요한 컬럼에는 적절한 인덱스를 검토할 수 있습니다.
CREATE INDEX IF NOT EXISTS
idx_birds_species_kor
ON korean_birds(SpeciesKor);인덱스는 검색 조건과 정렬 방식에 따라 효과가 달라집니다.
특히 LIKE '%검색어%'처럼 앞부분에 와일드카드가 있는 검색은 일반 B-tree 인덱스를 효율적으로 활용하기 어려울 수 있습니다.
PagingSource 구현
6. PagingSource의 역할
PagingSource는 Paging 3에서 데이터를 직접 불러오는 핵심 구성요소입니다.
일반적으로 다음 함수를 구현합니다.
override suspend fun load(
params: LoadParams<Int>
): LoadResult<Int, Bird>여기서 Int는 페이지 키의 자료형이고, Bird는 데이터 항목의 자료형입니다.
7. Bird 데이터 모델
data class Bird(
val ktsn: Long,
val species: String?,
val speciesKor: String?,
val orderKor: String?,
val familyKor: String?
)이 예제에서는 조류 정보를 단순화했습니다.
실제 프로젝트에서는 필요한 컬럼을 추가할 수 있습니다.
8. SQLiteOpenHelper 조회 함수
먼저 SQLite에서 일정 범위의 데이터를 가져오는 함수를 구현합니다.
class BirdDatabaseHelper(
context: Context
) : SQLiteOpenHelper(
context,
"birds.sqlite",
null,
1
) {
override fun onCreate(db: SQLiteDatabase) {
// 실제 앱에서는 테이블 생성 또는
// assets DB 초기화 정책을 적용합니다.
}
override fun onUpgrade(
db: SQLiteDatabase,
oldVersion: Int,
newVersion: Int
) {
// 스키마 마이그레이션
}
fun getBirdsPage(
limit: Int,
offset: Int
): List<Bird> {
val result = mutableListOf<Bird>()
val db = readableDatabase
val cursor = db.rawQuery(
"""
SELECT
KTSN,
Species,
SpeciesKor,
OrderKor,
FamilyKor
FROM korean_birds
ORDER BY KTSN ASC
LIMIT ? OFFSET ?
""".trimIndent(),
arrayOf(
limit.toString(),
offset.toString()
)
)
cursor.use {
while (it.moveToNext()) {
result.add(
Bird(
ktsn = it.getLong(0),
species = it.getString(1),
speciesKor = it.getString(2),
orderKor = it.getString(3),
familyKor = it.getString(4)
)
)
}
}
return result
}
}여기서는 ORDER BY KTSN ASC를 사용했습니다.
페이징에서는 정렬 순서가 안정적이어야 합니다.
정렬 기준이 명확하지 않으면 페이지 간 데이터가 중복되거나 누락될 수 있습니다.
또한 이 예제는 이미 준비된 데이터베이스를 전제로 합니다. assets에 포함된 SQLite 파일을 복사해 사용하는 앱이라면 기존 DB 초기화 로직을 유지해야 합니다.
9. BirdPagingSource 구현
class BirdPagingSource(
private val database: BirdDatabaseHelper
) : PagingSource<Int, Bird>() {
override suspend fun load(
params: LoadParams<Int>
): LoadResult<Int, Bird> {
return try {
val offset = params.key ?: 0
val loadSize = params.loadSize
val birds = withContext(Dispatchers.IO) {
database.getBirdsPage(
limit = loadSize,
offset = offset
)
}
val nextKey =
if (birds.size < loadSize) {
null
} else {
offset + birds.size
}
LoadResult.Page(
data = birds,
prevKey = if (offset == 0) {
null
} else {
maxOf(0, offset - loadSize)
},
nextKey = nextKey
)
} catch (e: Exception) {
if (e is CancellationException) {
throw e
}
LoadResult.Error(e)
}
}
override fun getRefreshKey(
state: PagingState<Int, Bird>
): Int? {
val anchor = state.anchorPosition ?: return null
val page = state.closestPageToPosition(anchor)
?: return null
return page.prevKey?.plus(
state.config.pageSize
) ?: page.nextKey?.minus(
state.config.pageSize
)
}
}위 코드는 LIMIT/OFFSET 기반 PagingSource의 기본 구조를 보여주는 예제입니다.
다만 실제 운영에서는 getRefreshKey()를 더욱 신중하게 구현해야 합니다.
특히 초기 로드 크기와 이후 페이지 크기가 다를 수 있으므로, 단순히 pageSize를 더하거나 빼는 방식은 정확한 위치 복원에 한계가 있습니다.
다음 절에서 개선 방법을 살펴보겠습니다.
안정적인 RefreshKey
10. OFFSET 기반 PagingSource 개선
OFFSET을 키로 사용하는 경우 getRefreshKey()는 현재 화면의 앵커 위치를 기준으로 계산할 수 있습니다.
예를 들어 다음과 같이 구현할 수 있습니다.
override fun getRefreshKey(
state: PagingState<Int, Bird>
): Int? {
val anchorPosition =
state.anchorPosition ?: return null
return maxOf(
0,
anchorPosition -
state.config.initialLoadSize / 2
)
}이 방식은 화면에 표시 중인 위치 주변을 다시 로드하도록 하는 단순한 전략입니다.
다만 데이터가 추가되거나 삭제되어 OFFSET이 변하면 정확한 항목 위치를 유지하지 못할 수 있습니다.
따라서 데이터 변경이 잦은 목록에서는 키셋 페이징을 검토하는 것이 좋습니다.
Repository 구성
11. Pager 생성
Repository에서 Pager를 생성합니다.
class BirdRepository(
private val database: BirdDatabaseHelper
) {
fun getBirds(): Flow<PagingData<Bird>> {
return Pager(
config = PagingConfig(
pageSize = 30,
initialLoadSize = 60,
prefetchDistance = 10,
enablePlaceholders = false
),
pagingSourceFactory = {
BirdPagingSource(database)
}
).flow
}
}여기서는 다음과 같이 설정했습니다.
| 옵션 | 값 | 의미 |
|---|---|---|
pageSize | 30 | 권장 페이지 크기 |
initialLoadSize | 60 | 초기 로드 요청 크기 |
prefetchDistance | 10 | 추가 로드 시작 거리 |
enablePlaceholders | false | 미로드 항목 자리 표시 비활성화 |
PagingConfig의 값은 앱의 데이터 크기와 화면 구성에 따라 조정해야 합니다.
ViewModel 구성
12. cachedIn 사용
class BirdListViewModel(
private val repository: BirdRepository
) : ViewModel() {
val birds =
repository.getBirds()
.cachedIn(viewModelScope)
}cachedIn(viewModelScope)은 ViewModel 범위에서 PagingData 스트림을 캐시하는 데 사용합니다.
이를 통해 화면 회전 등으로 UI가 다시 생성되었을 때 이미 로드한 데이터를 재사용하는 데 도움이 됩니다.
다만 영구적인 디스크 캐시를 제공하는 것은 아닙니다.
PagingDataAdapter 구현
13. 기존 ListAdapter와 차이
일반적인 RecyclerView에서는 ListAdapter를 사용할 수 있습니다.
class BirdAdapter :
ListAdapter<Bird, BirdViewHolder>(DIFF_CALLBACK)Paging 3에서는 PagingDataAdapter를 사용합니다.
class BirdPagingAdapter :
PagingDataAdapter<Bird, BirdViewHolder>(
DIFF_CALLBACK
)PagingDataAdapter는 PagingData를 받아 RecyclerView에 필요한 항목을 표시합니다.
14. DiffUtil 구현
private val DIFF_CALLBACK =
object : DiffUtil.ItemCallback<Bird>() {
override fun areItemsTheSame(
oldItem: Bird,
newItem: Bird
): Boolean {
return oldItem.ktsn == newItem.ktsn
}
override fun areContentsTheSame(
oldItem: Bird,
newItem: Bird
): Boolean {
return oldItem == newItem
}
}여기서는 KTSN을 항목의 고유 식별자로 사용했습니다.
areItemsTheSame()에서는 고유 식별자를 비교하고, areContentsTheSame()에서는 실제 데이터 내용이 같은지 확인합니다.
15. PagingDataAdapter 전체 예제
class BirdPagingAdapter(
private val onItemClick: (Bird) -> Unit
) : PagingDataAdapter<Bird, BirdPagingAdapter.BirdViewHolder>(
DIFF_CALLBACK
) {
inner class BirdViewHolder(
private val binding: ItemBirdBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(bird: Bird) {
binding.textBirdName.text =
bird.speciesKor ?: bird.species.orEmpty()
binding.textScientificName.text =
bird.species.orEmpty()
binding.root.setOnClickListener {
onItemClick(bird)
}
}
}
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): BirdViewHolder {
val binding = ItemBirdBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return BirdViewHolder(binding)
}
override fun onBindViewHolder(
holder: BirdViewHolder,
position: Int
) {
val bird = getItem(position)
if (bird != null) {
holder.bind(bird)
}
}
companion object {
private val DIFF_CALLBACK =
object : DiffUtil.ItemCallback<Bird>() {
override fun areItemsTheSame(
oldItem: Bird,
newItem: Bird
) = oldItem.ktsn == newItem.ktsn
override fun areContentsTheSame(
oldItem: Bird,
newItem: Bird
) = oldItem == newItem
}
}
}getItem(position)은 null을 반환할 수 있으므로 안전하게 처리해야 합니다.
특히 Placeholder를 활성화한 경우에는 null 항목을 표시하는 UI도 고려해야 합니다.
Fragment 연동
16. RecyclerView 설정
private val birdAdapter =
BirdPagingAdapter { bird ->
findNavController().navigate(
BirdListFragmentDirections
.actionBirdListToDetail(bird.ktsn)
)
}Fragment에서는 다음과 같이 설정합니다.
binding.recyclerView.apply {
layoutManager =
LinearLayoutManager(requireContext())
adapter = birdAdapter
}위 예제는 Navigation Component의 Safe Args를 사용하는 프로젝트를 가정합니다.
Safe Args를 사용하지 않는다면 Bundle 또는 다른 네비게이션 방식으로 변경할 수 있습니다.
17. PagingData 수집
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(
Lifecycle.State.STARTED
) {
viewModel.birds.collectLatest { pagingData ->
birdAdapter.submitData(pagingData)
}
}
}collectLatest()는 새로운 PagingData가 전달되면 이전 수집 작업을 취소하고 최신 데이터를 처리할 수 있도록 합니다.
검색 조건이 변경되는 화면에서는 특히 유용합니다.
로딩 상태 처리
18. LoadState란?
Paging 3는 데이터 로딩 상태를 LoadState로 제공합니다.
대표적인 상태는 다음과 같습니다.
LoadState.LoadingLoadState.NotLoadingLoadState.Error
또한 로드 유형은 다음과 같이 구분됩니다.
refreshprependappend
이를 이용하면 첫 화면 로딩과 추가 페이지 로딩을 구분할 수 있습니다.
19. 첫 화면 로딩 표시
viewLifecycleOwner.lifecycleScope.launch {
birdAdapter.loadStateFlow.collectLatest { loadStates ->
binding.progressBar.isVisible =
loadStates.refresh is LoadState.Loading
binding.errorLayout.isVisible =
loadStates.refresh is LoadState.Error
binding.emptyLayout.isVisible =
loadStates.refresh is LoadState.NotLoading &&
birdAdapter.itemCount == 0
}
}이 예제는 기본적인 로딩 및 오류 상태 표시 방식입니다.
실제 앱에서는 빈 목록 화면과 오류 화면이 동시에 표시되지 않도록 UI 상태를 명확하게 구성해야 합니다.
오류 재시도
20. retry() 사용
Paging 3에서는 로딩 실패 시 재시도를 지원합니다.
binding.buttonRetry.setOnClickListener {
birdAdapter.retry()
}retry()는 실패한 로드 작업을 다시 시도합니다.
반면 refresh()는 새로운 PagingSource 세대를 통해 데이터를 다시 로드하는 데 사용합니다.
두 함수는 목적이 다르므로 구분해야 합니다.
LoadStateAdapter
21. 목록 하단 로딩 표시
무한 스크롤에서는 추가 데이터를 불러오는 동안 목록 하단에 로딩 UI를 표시할 수 있습니다.
Paging 3에서는 LoadStateAdapter를 사용할 수 있습니다.
class BirdLoadStateAdapter(
private val retry: () -> Unit
) : LoadStateAdapter<BirdLoadStateAdapter.ViewHolder>() {
class ViewHolder(
val binding: ItemLoadStateBinding
) : RecyclerView.ViewHolder(binding.root)
override fun onCreateViewHolder(
parent: ViewGroup,
loadState: LoadState
): ViewHolder {
val binding = ItemLoadStateBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return ViewHolder(binding)
}
override fun onBindViewHolder(
holder: ViewHolder,
loadState: LoadState
) {
holder.binding.progressBar.isVisible =
loadState is LoadState.Loading
holder.binding.buttonRetry.isVisible =
loadState is LoadState.Error
holder.binding.buttonRetry.setOnClickListener {
retry()
}
}
}이 예제에서는 ItemLoadStateBinding에 ProgressBar와 재시도 버튼이 있다고 가정합니다.
22. Footer 연결
binding.recyclerView.adapter =
birdAdapter.withLoadStateFooter(
footer = BirdLoadStateAdapter {
birdAdapter.retry()
}
)이렇게 하면 추가 페이지를 로드할 때 하단 로딩 상태를 표시할 수 있습니다.
검색 기능과 Paging 3
23. 검색어가 변경되면 어떻게 할까?
검색 기능에서는 사용자가 검색어를 변경할 때마다 새로운 데이터 집합을 조회해야 합니다.
예를 들어 다음과 같은 흐름입니다.
검색어 입력
↓
StateFlow
↓
flatMapLatest
↓
Pager 생성
↓
PagingData
↓
RecyclerView이 구조를 사용하면 이전 검색 결과를 계속 로드하는 문제를 줄일 수 있습니다.
24. 검색어 StateFlow
class BirdSearchViewModel(
private val repository: BirdRepository
) : ViewModel() {
private val query =
MutableStateFlow("")
val birds =
query
.debounce(300)
.distinctUntilChanged()
.flatMapLatest { keyword ->
repository.searchBirds(keyword)
}
.cachedIn(viewModelScope)
fun search(keyword: String) {
query.value = keyword
}
}이 예제에서는 Repository에 searchBirds(keyword) 함수가 구현되어 있다고 가정합니다.
debounce(300)은 입력이 빠르게 변경되는 동안 불필요한 검색 요청을 줄이는 데 도움이 됩니다.
25. SQLite 검색 PagingSource
검색어를 사용하는 PagingSource는 다음과 같이 구성할 수 있습니다.
class BirdSearchPagingSource(
private val database: BirdDatabaseHelper,
private val keyword: String
) : PagingSource<Int, Bird>() {
override suspend fun load(
params: LoadParams<Int>
): LoadResult<Int, Bird> {
return try {
val offset = params.key ?: 0
val birds = withContext(Dispatchers.IO) {
database.searchBirdsPage(
keyword = keyword,
limit = params.loadSize,
offset = offset
)
}
LoadResult.Page(
data = birds,
prevKey = null,
nextKey = if (birds.size < params.loadSize) {
null
} else {
offset + birds.size
}
)
} catch (e: Exception) {
if (e is CancellationException) throw e
LoadResult.Error(e)
}
}
override fun getRefreshKey(
state: PagingState<Int, Bird>
): Int? = null
}이 코드는 검색 결과를 앞으로만 추가 로드하는 단순화된 예제입니다.
getRefreshKey()가 null이면 새로고침 시 처음부터 로드합니다.
실제 앱에서는 스크롤 위치 유지가 필요한지에 따라 개선할 수 있습니다.
Retrofit 기반 페이징
26. 서버 API가 페이지를 제공하는 경우
서버에서 다음과 같은 API를 제공한다고 가정하겠습니다.
GET /api/birds?page=1&size=30응답은 다음과 같은 형태입니다.
{
"items": [
{
"ktsn": 12345,
"species": "Passer montanus",
"speciesKor": "참새"
}
],
"hasNext": true
}이 예제는 서버가 1부터 시작하는 페이지 번호를 사용한다고 가정합니다.
27. Retrofit API 정의
interface BirdApi {
@GET("api/birds")
suspend fun getBirds(
@Query("page") page: Int,
@Query("size") size: Int
): BirdPageResponse
}응답 모델은 다음과 같습니다.
data class BirdPageResponse(
val items: List<Bird>,
val hasNext: Boolean
)28. RetrofitPagingSource 구현
class BirdRemotePagingSource(
private val api: BirdApi
) : PagingSource<Int, Bird>() {
override suspend fun load(
params: LoadParams<Int>
): LoadResult<Int, Bird> {
return try {
val page = params.key ?: 1
val response = api.getBirds(
page = page,
size = 30
)
LoadResult.Page(
data = response.items,
prevKey = if (page == 1) {
null
} else {
page - 1
},
nextKey = if (response.hasNext) {
page + 1
} else {
null
}
)
} catch (e: Exception) {
if (e is CancellationException) throw e
LoadResult.Error(e)
}
}
override fun getRefreshKey(
state: PagingState<Int, Bird>
): Int? {
val anchor = state.anchorPosition ?: return null
val page = state.closestPageToPosition(anchor)
return page?.prevKey?.plus(1)
?: page?.nextKey?.minus(1)
}
}이 예제는 서버 API가 항상 30개 단위의 페이지를 제공한다고 가정합니다.
실제 Paging 3에서는 params.loadSize가 페이지 크기와 다를 수 있습니다.
서버 API가 페이지 번호와 고정 페이지 크기를 사용하는 경우에는 초기 로드와 추가 로드 정책을 명확하게 맞춰야 합니다.
가능하다면 서버에서 다음 페이지 커서 또는 명확한 페이지 토큰을 제공하는 방식도 고려할 수 있습니다.
OFFSET 페이징과 Keyset 페이징
29. OFFSET 페이징의 한계
SQLite에서 다음과 같은 쿼리를 사용할 수 있습니다.
SELECT *
FROM korean_birds
ORDER BY KTSN
LIMIT 30 OFFSET 3000;이 방식은 구현이 단순하지만 OFFSET이 커질수록 앞부분의 데이터를 건너뛰는 비용이 증가할 수 있습니다.
또한 데이터가 중간에 추가되거나 삭제되면 페이지 간 중복이나 누락이 발생할 수 있습니다.
30. Keyset 페이징
Keyset 페이징은 마지막으로 조회한 항목의 키를 기준으로 다음 데이터를 조회하는 방식입니다.
SELECT *
FROM korean_birds
WHERE KTSN > ?
ORDER BY KTSN ASC
LIMIT 30;예를 들어 마지막으로 조회한 KTSN이 1000이라면 다음 페이지에서는 1000보다 큰 KTSN을 조회합니다.
이 방식은 고유하고 안정적인 정렬 키가 있을 때 유용합니다.
다만 검색 조건이 변경되거나 여러 컬럼으로 정렬하는 경우에는 커서 설계를 더 신중하게 해야 합니다.
광고 아이템과 Paging 3
31. 광고가 포함된 RecyclerView
조류 도감 앱에서 데이터 항목 사이에 광고를 표시한다고 가정하겠습니다.
조류 데이터
조류 데이터
조류 데이터
광고
조류 데이터
조류 데이터PagingDataAdapter는 일반 데이터 목록을 페이징하는 데 적합합니다.
하지만 광고를 일정 간격으로 삽입하려면 별도의 UI 모델을 사용하는 것이 좋습니다.
32. UI 모델 정의
sealed interface BirdListItem {
data class BirdItem(
val bird: Bird
) : BirdListItem
data class AdItem(
val id: String
) : BirdListItem
}이렇게 하면 조류 데이터와 광고 아이템을 구분할 수 있습니다.
33. insertSeparators 활용
Paging 3에서는 insertSeparators()를 이용해 데이터 사이에 구분 항목을 삽입할 수 있습니다.
val uiItems =
repository.getBirds()
.map { pagingData ->
pagingData
.map<Bird, BirdListItem> { bird ->
BirdListItem.BirdItem(bird)
}
.insertSeparators { before, after ->
if (
before is BirdListItem.BirdItem &&
after is BirdListItem.BirdItem &&
after.bird.ktsn % 10L == 0L
) {
BirdListItem.AdItem(
id = "ad_${after.bird.ktsn}"
)
} else {
null
}
}
}이 코드는 설명을 위한 예제입니다.
실제 광고 삽입 위치를 KTSN 값으로 결정하는 것은 적절하지 않을 수 있습니다.
일정한 항목 간격으로 광고를 삽입하려면 데이터 위치나 별도의 삽입 정책을 기준으로 설계해야 합니다.
또한 광고는 콘텐츠와 명확하게 구분하고 AdMob 정책을 준수해야 합니다.
데이터 변경과 PagingSource 무효화
34. 좋아요 상태가 변경되면?
조류 상세 화면에서 좋아요 상태를 변경했다고 가정하겠습니다.
목록으로 돌아왔을 때 변경된 상태를 표시해야 합니다.
Paging 3에서는 PagingSource.invalidate()를 사용해 기존 데이터 소스를 무효화할 수 있습니다.
pagingSource.invalidate()하지만 PagingSource를 무조건 새로 생성하거나 invalidate하는 방식은 불필요한 전체 갱신을 유발할 수 있습니다.
실제 프로젝트에서는 Repository가 현재 PagingSource를 관리하거나, 데이터 변경 이벤트를 이용해 필요한 시점에 갱신하도록 구성하는 것이 좋습니다.
35. SQLiteOpenHelper의 주의사항
Room을 사용하면 데이터 변경을 감지해 PagingSource를 무효화하는 기능을 활용할 수 있습니다.
반면 SQLiteOpenHelper를 직접 사용하는 경우에는 이러한 연동을 직접 구현해야 합니다.
예를 들어 다음과 같은 상황을 고려해야 합니다.
좋아요 상태 변경
메모 수정
데이터베이스 업데이트
검색 조건 변경
정렬 기준 변경
데이터 변경 이후 목록이 자동으로 갱신된다고 가정하면 안 됩니다.
성능 최적화
36. 페이지 크기 설정
페이지 크기는 데이터 항목의 크기와 화면 구조에 따라 조정해야 합니다.
예를 들어 이미지가 많은 카드 목록에서는 페이지 크기를 지나치게 크게 설정하면 이미지 로딩 요청이 많아질 수 있습니다.
반대로 페이지 크기가 너무 작으면 데이터 조회가 자주 발생할 수 있습니다.
따라서 다음 항목을 함께 확인하는 것이 좋습니다.
첫 화면 표시 시간
스크롤 부드러움
SQLite 쿼리 시간
메모리 사용량
이미지 로딩 시간
추가 페이지 로드 빈도
37. Glide와 Paging 3
Paging 3는 데이터 로딩을 관리하지만 이미지 메모리 관리를 대신해 주지는 않습니다.
RecyclerView에서 이미지를 표시할 때는 Glide 같은 이미지 로딩 라이브러리를 활용할 수 있습니다.
Glide.with(binding.imageBird)
.load(imageUrl)
.placeholder(R.drawable.ic_bird_placeholder)
.into(binding.imageBird)ViewHolder가 재사용되므로 이전 이미지 요청이 새로운 항목에 잘못 표시되지 않도록 주의해야 합니다.
이미지가 없는 항목에는 기본 이미지를 표시하는 것이 좋습니다.
Paging 3 테스트
38. PagingSource 테스트
PagingSource는 로드 결과를 직접 검증할 수 있습니다.
예를 들어 다음과 같은 항목을 테스트할 수 있습니다.
첫 페이지 로드
다음 페이지 로드
마지막 페이지 처리
빈 데이터베이스
SQLite 조회 오류
검색 결과 0건
데이터 변경 후 갱신
정렬 순서 유지
테스트에서는 작은 가짜 데이터베이스나 Fake Repository를 활용할 수 있습니다.
39. 성능 테스트
Paging 3를 도입했다고 해서 자동으로 모든 성능 문제가 해결되는 것은 아닙니다.
실제 기기에서 다음 항목을 확인해야 합니다.
| 테스트 | 확인 내용 |
|---|---|
| 첫 화면 | 초기 데이터 표시 시간 |
| 빠른 스크롤 | 프레임 드롭 |
| 검색 | 입력 후 결과 표시 시간 |
| 화면 회전 | 스크롤 위치 유지 |
| 앱 복귀 | 불필요한 재조회 |
| DB 업데이트 | 데이터 갱신 |
| 대량 데이터 | 메모리 사용량 |
| 네트워크 오류 | 재시도 동작 |
Android Studio Profiler와 Macrobenchmark 등을 활용하면 성능을 더 자세히 분석할 수 있습니다.
기존 앱에 적용하는 순서
40. 단계별 마이그레이션
기존 RecyclerView + ListAdapter 구조를 Paging 3로 전환하려면 다음 순서를 권장합니다.
1단계: 전체 데이터 조회 코드 확인
현재 한 번에 몇 건의 데이터를 조회하는지 확인합니다.
2단계: 안정적인 정렬 기준 정의
KTSN 등 고유한 정렬 키를 사용합니다.
3단계: SQLite 페이지 조회 함수 구현
LIMIT/OFFSET 또는 Keyset 방식으로 데이터를 조회합니다.
4단계: PagingSource 구현
페이지 로드와 오류 처리를 구현합니다.
5단계: Repository에 Pager 추가
PagingConfig를 설정합니다.
6단계: ViewModel에서 cachedIn 적용
화면 재생성 시 데이터 재사용을 지원합니다.
7단계: PagingDataAdapter 전환
기존 RecyclerView 항목 UI를 유지하면서 어댑터를 변경합니다.
8단계: LoadState 처리
초기 로딩, 추가 로딩, 오류, 빈 목록을 표시합니다.
9단계: 검색 기능 연동
StateFlow와 flatMapLatest를 활용합니다.
10단계: 데이터 변경 처리
SQLiteOpenHelper 환경에서 PagingSource 무효화를 구현합니다.
Paging 3 체크리스트
41. 출시 전 점검
Paging 3 의존성 추가
안정적인 정렬 기준 정의
SQLite 페이지 조회 구현
PagingSource 구현
getRefreshKey 정책 검토
Repository에 Pager 구성
PagingConfig 조정
ViewModel cachedIn 적용
PagingDataAdapter 구현
DiffUtil 적용
Fragment에서 PagingData 수집
LoadState 처리
빈 목록 화면 처리
오류 재시도 구현
검색어 변경 시 새 PagingSource 생성
Coroutine 취소 처리
SQLite 변경 시 invalidate 처리
이미지 로딩 최적화
광고 아이템 분리
빠른 스크롤 테스트
화면 회전 테스트
대량 데이터 성능 테스트
네트워크 오류 테스트
앱 재진입 시 상태 복원 확인
42. 마무리
Paging 3는 Android 앱에서 대량 데이터를 효율적으로 표시하기 위한 유용한 라이브러리입니다.
특히 RecyclerView에서 수천 건 이상의 데이터를 다루거나 서버에서 페이지 단위로 데이터를 가져오는 앱에서 활용할 수 있습니다.
이번 글의 핵심을 정리하면 다음과 같습니다.
Paging 3는 데이터를 필요한 단위로 나누어 로드합니다.
Room을 사용하지 않아도 PagingSource를 직접 구현할 수 있습니다.
SQLiteOpenHelper에서는 데이터 변경 감지를 직접 관리해야 합니다.
Pager와 PagingConfig로 로딩 정책을 설정합니다.
PagingDataAdapter를 이용해 RecyclerView와 연동합니다.
LoadState로 로딩과 오류 상태를 처리합니다.
StateFlow와 flatMapLatest로 검색 기능을 구현할 수 있습니다.
OFFSET 페이징은 구현이 간단하지만 대량 데이터에서 한계가 있습니다.
Keyset 페이징은 안정적인 정렬 키가 있을 때 유용합니다.
Paging 3 도입 후에도 실제 기기에서 성능을 검증해야 합니다.
기존 Android 앱에서 모든 데이터를 한 번에 조회하고 있다면 Paging 3 도입을 검토할 만합니다.
다만 데이터가 수백 건 정도이고 현재 성능 문제가 없다면 Paging 3가 반드시 필요한 것은 아닙니다.
복잡한 구조를 추가하기 전에 실제 데이터 크기와 성능 문제를 먼저 확인하는 것이 좋습니다.
Paging 3의 핵심은 단순히 무한 스크롤을 만드는 것이 아니라, 필요한 데이터를 필요한 시점에 안정적으로 불러오는 구조를 만드는 것입니다.
참고 자료
Android Paging 개요
https://developer.android.com/topic/libraries/architecture/paging/v3-overviewPaging 데이터 로드
https://developer.android.com/topic/libraries/architecture/paging/v3-paged-dataPaging 데이터 표시
https://developer.android.com/topic/libraries/architecture/paging/v3-paged-data#display-paged-dataPaging 로드 상태 관리
https://developer.android.com/topic/libraries/architecture/paging/load-statePaging 데이터 변환
https://developer.android.com/topic/libraries/architecture/paging/v3-transformPaging 테스트
https://developer.android.com/topic/libraries/architecture/paging/test-pagingRecyclerView
https://developer.android.com/develop/ui/views/layout/recyclerviewSQLiteOpenHelper
https://developer.android.com/reference/android/database/sqlite/SQLiteOpenHelper
태그: Android, Kotlin, Paging 3, RecyclerView, PagingSource, PagingDataAdapter, SQLiteOpenHelper, SQLite, Retrofit2, MVVM, StateFlow, Coroutine, 무한 스크롤, Android 성능 최적화, Android 앱 개발