Offset Pagination — 오프셋 페이지네이션 — HTTP API를 통해 데이터를 페이지 단위로 로드하는 방법입니다. 클라이언트는 offset(시작부터의 오프셋)과 limit(페이지 크기) 매개변수를 전송하고, 서버는 offset 위치부터 레코드를 반환합니다. REST API Tutorial에 따르면, 이 접근 방식은 구현의 단순성으로 인해 RESTful 서비스에서 널리 사용됩니다. 그러나 대량의 데이터에서는 원하는 위치까지 전체 테이블을 스캔하기 때문에 오프셋 페이지네이션의 성능이 저하됩니다.
주요 포인트
Offset Pagination은 클라이언트 요청에 offset(건너뛸 레코드 수)과 limit(반환할 레코드 수)의 두 가지 매개변수가 포함되는 데이터 페이지네이션 방식입니다. 서버는 OFFSET과 LIMIT이 포함된 SQL 쿼리를 실행하고, 지정된 행 수를 건너뛴 후 고정 크기의 결과 세트를 반환합니다.
이 방식은 관계형 데이터베이스에서 페이지 탐색을 구성하는 가장 간단한 방법으로 시작되었으며, REST 아키텍처의 발전과 함께 HTTP API로 전송되었습니다. Offset Pagination은 서버에 상태를 저장할 필요가 없습니다 — 각 요청은 독립적이며 쿼리에 필요한 모든 정보를 포함합니다.
Postman(2025)의 API 디자인 보고서에 따르면, 오프셋 페이지네이션은 공개 REST API의 72%에서 사용되어, 대규모 데이터 세트에서 알려진 성능 제한에도 불구하고 지배적인 표준이 되었습니다.
Offset Pagination을 사용하는 일반적인 REST 요청에는 쿼리 매개변수 offset과 limit이 포함됩니다. 응답에는 요청된 페이지의 레코드 목록과 탐색 인터페이스를 구축하기 위한 메타데이터가 포함됩니다.
limit 매개변수는 반환되는 레코드 수를 제한하고 서버와 클라이언트를 과도한 로드로부터 보호합니다. 데이터 복잡성에 따라 limit의 일반적인 값은 페이지당 10~50개 레코드입니다.
data class PageRequest(
val offset: Int,
val limit: Int
)
data class PageResponse<T>(
val items: List<T>,
val total: Int,
val hasMore: Boolean
)
fun RetrofitApi.fetchPage(request: PageRequest): Call<PageResponse<Item>>
Offset Pagination은 OFFSET과 FETCH NEXT(또는 MySQL/SQLite의 LIMIT) 구문을 사용하는 SQL 쿼리로 변환됩니다. 데이터베이스 서버는 테이블을 스캔하고, 오프셋과 동일한 행 수를 건너뛴 후 다음 limit 행을 반환합니다. 오프셋이 클수록 쿼리 실행 시간이 길어집니다.
성능 문제는 데이터베이스가 오프셋 위치로 직접 이동할 수 없다는 사실에서 비롯됩니다 — 이전의 모든 행을 읽고 버려야 합니다. offset = 100000, limit = 20인 경우 DBMS는 100,020행을 읽고 20행만 반환합니다.
SQL — 서버가 오프셋 페이지네이션을 실행하는 언어입니다. PostgreSQL과 MySQL은 LIMIT을 사용하고, SQL Server와 Oracle은 OFFSET...FETCH를 사용합니다. 다양한 DBMS가 이 쿼리를 다르게 최적화하지만, 기본적인 스캔 문제는 동일합니다.
SELECT id, title, created_at
FROM articles
ORDER BY created_at DESC
OFFSET 100000 ROWS
FETCH NEXT 20 ROWS ONLY;
데이터 일관성은 동적 세트로 작업할 때 Offset Pagination의 주요 단점입니다. 두 사용자 요청 사이에 테이블 시작 부분에 새 레코드가 추가되면 기존의 모든 레코드가 이동합니다. 사용자는 중복이나 누락을 보게 됩니다.
limit = 20인 100개 레코드 테이블을 가정해 보겠습니다. 페이지 1에서 사용자는 레코드 1-20을 봅니다. 관리자가 5개의 새 레코드를 추가합니다. 페이지 2에서 사용자는 예상된 21-40 대신 레코드 26-45를 봅니다 — 레코드 21-25는 누락되고, 이전 세트의 레코드 21-25는 페이지 1에서 중복됩니다.
Cursor-based 페이지네이션은 현재 페이지의 마지막 레코드에 대한 포인터를 사용하는 Offset Pagination의 대안입니다. 숫자 오프셋 대신 클라이언트는 마지막으로 수신한 레코드의 식별자를 전송하고, 서버는 그 이후의 다음 N개 레코드를 반환합니다.
Cursor-based 접근 방식은 일관성 문제를 해결합니다: 커서는 위치가 아닌 특정 레코드를 참조하므로 삽입이나 삭제 시 커서 위치가 변경되지 않습니다. 그러나 구현이 더 복잡합니다 — 고유한 정렬 가능한 필드(일반적으로 ID 또는 타임스탬프)가 필요합니다.
| 매개변수 | Offset Pagination | Cursor-based Pagination |
|---|---|---|
| 단순성 | 높음 — 두 개의 숫자 매개변수 | 중간 — 커서 인코딩 필요 |
| 일관성 | 낮음 — 삽입 시 중복 | 높음 — 변경 사항에 영향 받지 않음 |
| 성능 | 큰 오프셋에서 저하 | 모든 볼륨에서 안정적 |
| 페이지 이동 | 가능 — 모든 페이지로 이동 가능 | 불가능 — 순차 탐색만 가능 |
| 적합한 용도 | <10K 레코드 테이블, 페이지 번호 UI | 피드, 무한 스크롤, 대규모 세트 |
접근 방식 간의 선택은 사용자의 인터페이스 요구 사항에 따라 달라집니다. 페이지 번호 탐색과 직접 이동이 필요한 경우 Offset Pagination이 더 간단합니다. 무한 스크롤이나 뉴스 피드의 경우 커서가 더 적합합니다.
Keyset 페이지네이션은 OFFSET 대신 WHERE을 사용하여 고유 키로 필터링을 수행하는 cursor-based 접근 방식의 변형입니다. SQL 쿼리는 WHERE id > lastId와 같은 조건을 사용하여 데이터베이스가 버려진 행을 스캔하지 않고 인덱스를 사용할 수 있게 합니다.
PostgreSQL Wiki에 따르면, keyset 페이지네이션은 큰 오프셋에서 오프셋 쿼리보다 100~1000배 빠릅니다. 인덱스 스캔이 전체 테이블 스캔을 대체하기 때문입니다. 단점은 순차적 탐색 없이 임의의 페이지로 이동할 수 없다는 것입니다.
Offset Pagination은 사용자가 페이지 번호 인터페이스를 필요로 하는 중소 규모 데이터 세트(최대 10,000개 레코드)에 최적입니다. 일반적인 시나리오에는 관리자 패널, 주문 목록, 페이지 기반 페이지네이션이 있는 필터링된 카탈로그가 포함됩니다.
모바일 애플리케이션의 경우, 새 삽입이 드물거나 불가능한 기록 데이터를 로드할 때 오프셋 페이지네이션이 적합합니다 — 예를 들어 사용자 주문 내역, 완료된 작업 목록, 트랜잭션 아카이브 등입니다. 이러한 시나리오에서는 일관성 문제가 발생하지 않습니다.
소셜 미디어 피드, 댓글 목록, 채팅 및 빈번한 삽입이 있는 기타 동적 세트에는 권장되지 않습니다. 이러한 경우 누락과 중복 레코드가 사용자 경험을 저하시키고 클라이언트 측에서 추가 중복 제거 로직이 필요합니다.
하이브리드 페이지네이션은 offset과 cursor를 결합합니다: 첫 번째 요청은 offset을 사용하여 초기 페이지를 표시하고, 후속 요청은 cursor를 사용하여 무한 스크롤 로딩을 수행합니다. 이 접근 방식은 Instagram과 Twitter에서 사용되며, 첫 번째 페이지는 cursor로 로드되지만 이전 보기로 돌아갈 때 위치를 계산하기 위해 offset이 사용됩니다.
하이브리드 접근 방식을 구현하려면 클라이언트에 사용자의 가상 위치를 저장하고 서버에서 두 페이지네이션 메커니즘을 조정해야 합니다. Instagram Engineering 블로그에 따르면, 그들의 팀은 초기 로딩을 위한 offset을 대체하는 추가 startCursor 필드와 함께 cursor-based 페이지네이션을 사용합니다.
모바일 애플리케이션은 Android에서 Retrofit/OkHttp, iOS에서 URLSession/Combine과 함께 Offset Pagination을 사용합니다. 일반적인 패턴은 RecyclerView.OnScrollListener 또는 UICollectionView 프리페칭을 통해 목록 끝까지 스크롤할 때 다음 페이지를 로드하는 것입니다.
모바일 클라이언트에서 오프셋 페이지네이션을 구현하려면 세 가지 구성 요소가 필요합니다: 페이지네이션 관리자(현재 offset과 hasMore 저장), 목록 어댑터(항목 및 로딩 표시기 표시), 리포지토리(요청 실행 및 오류 처리). Android Jetpack은 Paging 3 라이브러리를 제공하며, 이는 offset 및 cursor-based 페이지네이션을 기본적으로 지원합니다.
Paging 3은 페이지별 데이터 로딩을 위한 Android Jetpack 라이브러리입니다. 오프셋 추적, 로딩 상태 관리, 스크롤 시 자동 프리페칭을 포함한 페이지네이션 로직을 캡슐화합니다. PagingSource는 다음 페이지와 이전 페이지의 키를 정의합니다.
class OffsetPagingSource(
private val api: ApiService,
private val limit: Int = 20
) : PagingSource<Int, Item>() {
override suspend fun load(
params: LoadParams<Int>
): LoadResult<Int, Item> {
val offset = params.key ?: 0
return try {
val response = api.getItems(offset, limit)
LoadResult.Page(
data = response.items,
prevKey = null,
nextKey = if (response.hasMore) offset + limit else null
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
}
PagingSource는 페이지 탐색을 위한 prevKey와 nextKey를 정의합니다. 오프셋 페이지네이션에서 prevKey는 항상 null이고(기록을 저장하지 않으면 이전 페이지로 이동 불가), nextKey는 서버가 hasMore = false를 반환할 때까지 로드할 때마다 limit만큼 증가합니다. 이는 모바일 목록을 위한 간단하고 예측 가능한 모델입니다.
첫 번째 실수 — 정렬 없이 레코드 순서에 의존하는 것입니다. Offset Pagination은 고유 필드에 대한 안정적인 ORDER BY 정렬이 필요합니다. 이것이 없으면 DBMS가 임의의 순서로 레코드를 반환하여 페이지 간에 무작위 중복과 누락이 발생할 수 있습니다.
두 번째 실수 — UI에서 페이지 번호를 계산하기 위해 offset을 사용하는 것입니다. page = offset / limit + 1 공식은 로드 사이에 레코드가 삭제되거나 추가되지 않은 경우에만 작동합니다. 동적 데이터에서는 페이지 번호가 부정확해지고 사용자가 잘못된 정보를 보게 됩니다.
세 번째 실수 — 큰 offset이 있는 쿼리의 타임아웃을 무시하는 것입니다. offset이 100,000을 초과하면 쿼리가 수십 초가 걸려 UI를 차단하고 서버 리소스를 소모할 수 있습니다. API 수준에서 최대 offset 값을 설정하고(예: 10,000) 대용량의 경우 cursor-based 페이지네이션을 사용하는 것이 좋습니다.
네 번째 실수 — 응답에 total count를 포함하지 않는 것입니다. 총 레코드 수가 없으면 클라이언트가 페이지 수를 표시할 수 없고 번호가 매겨진 페이지네이션을 구현할 수 없습니다. 그러나 큰 테이블에서 COUNT(*)는 비용이 많이 듭니다 — 100,000개 레코드가 넘는 세트의 경우 근사 추정치를 사용하거나 최대 총계 값을 제한하세요.
자주 묻는 질문
Offset은 레코드를 건너뛰기 위해 숫자 오프셋을 사용하고, cursor는 이전 페이지의 마지막 레코드에 대한 포인터를 사용합니다. Offset은 구현하기 쉽지만 삽입 시 중복과 큰 오프셋에서 성능 저하가 발생합니다. Cursor는 데이터 변경에 관계없이 안정적입니다.
오프셋 페이지네이션은 전체 테이블 스캔으로 인해 10,000개 레코드 이상의 오프셋에서 비효율적입니다. 또한 요청 사이에 새 레코드가 나타나는 동적 세트(피드, 채팅)에도 부적합합니다 — 사용자가 탐색 중에 누락과 중복 레코드를 보게 됩니다.
최적의 limit은 레코드 크기와 네트워크 속도에 따라 달라집니다 — 페이지당 10~50개 항목입니다. 큰 이미지가 있는 목록의 경우 limit = 10-15를 사용하고, 텍스트 데이터의 경우 20-50을 사용하세요. 항상 클라이언트가 서버 측 최대 제한(보통 100)과 함께 자신의 limit을 지정할 수 있도록 허용하세요.
중복을 처리하려면 고유 ID로 클라이언트 측 중복 제거를 사용하거나, 고유 필드에 안정적인 정렬을 적용하거나, cursor-based 페이지네이션으로 전환하세요. Android Paging 3은 목록 항목의 자동 중복 제거를 위한 key를 지원합니다.
네, GraphQL은 쿼리의 offset 및 limit 인수를 통해 오프셋 페이지네이션을 지원하지만, Relay 사양은 cursor-based 접근 방식을 권장합니다. Apollo GraphQL 및 Relay 라이브러리는 자동 페이지 상태 관리와 함께 오프셋 페이지네이션을 기본적으로 지원합니다.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.