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데이터를 페이지 단위로 로드
PagerPagingSource를 이용해 PagingData 생성
PagingConfig페이지 크기 및 로딩 정책
PagingData페이징 데이터 컨테이너
PagingDataAdapterRecyclerView에 데이터 표시
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
    }
}

여기서는 다음과 같이 설정했습니다.

옵션값의미
pageSize30권장 페이지 크기
initialLoadSize60초기 로드 요청 크기
prefetchDistance10추가 로드 시작 거리
enablePlaceholdersfalse미로드 항목 자리 표시 비활성화

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.Loading

  • LoadState.NotLoading

  • LoadState.Error

또한 로드 유형은 다음과 같이 구분됩니다.

  • refresh

  • prepend

  • append

이를 이용하면 첫 화면 로딩과 추가 페이지 로딩을 구분할 수 있습니다.


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에서 수천 건 이상의 데이터를 다루거나 서버에서 페이지 단위로 데이터를 가져오는 앱에서 활용할 수 있습니다.

이번 글의 핵심을 정리하면 다음과 같습니다.

  1. Paging 3는 데이터를 필요한 단위로 나누어 로드합니다.

  2. Room을 사용하지 않아도 PagingSource를 직접 구현할 수 있습니다.

  3. SQLiteOpenHelper에서는 데이터 변경 감지를 직접 관리해야 합니다.

  4. Pager와 PagingConfig로 로딩 정책을 설정합니다.

  5. PagingDataAdapter를 이용해 RecyclerView와 연동합니다.

  6. LoadState로 로딩과 오류 상태를 처리합니다.

  7. StateFlow와 flatMapLatest로 검색 기능을 구현할 수 있습니다.

  8. OFFSET 페이징은 구현이 간단하지만 대량 데이터에서 한계가 있습니다.

  9. Keyset 페이징은 안정적인 정렬 키가 있을 때 유용합니다.

  10. Paging 3 도입 후에도 실제 기기에서 성능을 검증해야 합니다.

기존 Android 앱에서 모든 데이터를 한 번에 조회하고 있다면 Paging 3 도입을 검토할 만합니다.

다만 데이터가 수백 건 정도이고 현재 성능 문제가 없다면 Paging 3가 반드시 필요한 것은 아닙니다.

복잡한 구조를 추가하기 전에 실제 데이터 크기와 성능 문제를 먼저 확인하는 것이 좋습니다.

Paging 3의 핵심은 단순히 무한 스크롤을 만드는 것이 아니라, 필요한 데이터를 필요한 시점에 안정적으로 불러오는 구조를 만드는 것입니다.


참고 자료

태그: Android, Kotlin, Paging 3, RecyclerView, PagingSource, PagingDataAdapter, SQLiteOpenHelper, SQLite, Retrofit2, MVVM, StateFlow, Coroutine, 무한 스크롤, Android 성능 최적화, Android 앱 개발