Cursor Pagination은 정렬된 레코드 세트를 탐색하기 위해 고유한 커서를 사용하는 페이지별 데이터 로딩 방식입니다. GraphQL Specification (2025)에 따르면, 커서 기반 페이지네이션은 동적 데이터를 다루는 API에 권장되는 표준입니다. Cursor pagination은 Offset 방식의 주요 단점인 삽입 시 불안정성과 큰 오프셋에서의 성능 저하를 제거합니다.
핵심 포인트
Cursor Pagination은 서버가 데이터와 함께 특수 포인터 — 커서 — 를 반환하는 페이지별 로딩 방식입니다. 클라이언트는 다음 요청에서 이 커서를 사용하여 다음 배치의 레코드를 가져옵니다. 커서는 현재 페이지의 마지막 요소에 대한 고유 식별자입니다.
클라이언트가 “20개 레코드로 5페이지를 주세요”라고 말하는 Offset 페이지네이션과 달리, 커서 페이지네이션은 “ID = 83인 레코드 이후의 20개 레코드를 주세요”라고 작동합니다. 서버는 WHERE id > 83 및 LIMIT 20으로 쿼리를 실행합니다. 이 방식은 삽입과 관계없이 각 레코드가 정확히 하나의 페이지에 포함되도록 보장합니다.
커서 페이지네이션의 개념은 Relay Connection (GraphQL) 사양 덕분에 널리 채택되어 커서 기반 페이지네이션을 현대 API의 표준으로 만들었습니다. Relay는 응답 형식을 정의합니다: edges(커서가 있는 레코드 배열), pageInfo(hasNextPage, hasPreviousPage, startCursor, endCursor).
커서 페이지네이션은 새로운 기술이 아닙니다 — 웹보다 훨씬 이전에 데이터베이스에서 사용되었습니다. SQL에서는 keyset pagination 또는 seek method라고 합니다. 이 방식은 2015년 Relay 사양이 발표된 후 API에서 대중화되었으며, HTTP 전송의 통일성을 위해 커서 형식을 base64 인코딩 문자열로 공식화했습니다.
커서 페이지네이션의 기본 원리는 쿼리가 위치 지정에 오프셋 대신 인덱싱된 필드의 WHERE 조건을 사용한다는 것입니다. 앞 방향에는 WHERE id > last_id, 뒤 방향에는 WHERE id < first_id를 사용합니다. B-tree 인덱스는 O(log n)으로 커서 이후의 첫 번째 레코드를 찾아 안정적인 응답 시간을 제공합니다.
-- 커서 '83' 이후 레코드 20개 가져오기
SELECT id, title, created_at
FROM posts
WHERE id < 83
ORDER BY id DESC
LIMIT 20;
-- 커서 '83' 이전 레코드 20개 가져오기 (뒤로)
SELECT id, title, created_at
FROM posts
WHERE id > 83
ORDER BY id ASC
LIMIT 20;
커서는 단순(ID 값)하거나 복잡(여러 필드로 구성)할 수 있습니다. 단순 커서는 레코드의 기본 키로, 예를 들어 자동 증가 id 또는 UUID입니다. 복합 커서는 고유하지 않은 필드로 정렬하는 데 사용되며, 예를 들어 (created_at, id)에서 id는 동일한 타임스탬프일 때 고유성을 보장합니다.
일반적인 API 형식은 base64 인코딩 문자열로서의 커서입니다. 서버는 커서를 디코딩하고, 값을 추출하며, SQL 쿼리를 구성합니다. Base64 인코딩은 클라이언트로부터 커서의 내부 구조를 숨기고 하위 호환성을 깨지 않고 형식을 변경할 수 있게 합니다. 클라이언트는 응답의 endCursor 필드에서 커서를 받아 다음 요청에서 문자열로 전달합니다.
커서 페이지네이션은 양방향 탐색을 지원합니다. 앞으로(next) 이동하려면 현재 페이지의 마지막 요소 커서를 사용하고, 뒤로(previous) 이동하려면 첫 번째 요소의 커서를 사용합니다. 요청의 after 및 before 매개변수가 방향을 결정합니다: after는 커서 이후의 레코드를, before는 커서 이전의 레코드를 가져옵니다.
커서와 Offset 페이지네이션 중 선택은 API 설계 시 주요 아키텍처 결정 중 하나입니다. 각 방식에는 적용 가능성을 결정하는 장단점이 있습니다. Cursor pagination은 동적 데이터 시나리오에서 우세하고, Offset은 임의 탐색 시나리오에서 우세합니다.
| 특성 | Cursor | Offset |
|---|---|---|
| 삽입 시 안정성 | 높음(중복 없음) | 낮음(페이지 이동) |
| 대규모 데이터 세트 성능 | O(log n) — 안정적 | O(n) — 증가에 따라 저하 |
| 페이지 번호 탐색 | 불가 | 가능(page=5) |
| 구현 복잡성 | 중간 | 낮음 |
| REST 지원 | cursor/before/after | page/offset |
| GraphQL 지원 | Relay 표준 | 권장되지 않음 |
Offset 페이지네이션은 OFFSET 위치까지 전체 테이블 스캔을 수행합니다. offset=100000에서 LIMIT이 20이더라도 데이터베이스는 100000행을 읽고 건너뜁니다. MySQL 및 PostgreSQL은 OFFSET을 최적화할 수 없습니다 — 이는 SQL에서 LIMIT/OFFSET 구현의 특징입니다. 커서 페이지네이션은 O(log n)으로 위치를 찾는 B-tree 인덱스를 사용합니다.
Offset의 추가 문제는 뒤로 페이지를 넘길 때 레코드 “건너뛰기”입니다. 사용자가 5페이지를 로드하고 그 순간 새 레코드가 추가된 경우, 6페이지를 요청하면 5페이지의 레코드를 다시 보거나 새 레코드를 놓칠 수 있습니다. Cursor pagination은 이 시나리오를 완전히 제거합니다: 커서는 세트의 특정 위치를 가리키며 삽입이 위치를 변경하지 않습니다.
백엔드(Kotlin + Spring)와 클라이언트(Android + Retrofit)에서 커서 페이지네이션 구현을 살펴보겠습니다. 서버는 after, before, limit 매개변수를 받아 커서 및 pageInfo와 함께 레코드 목록을 반환합니다. 일반적인 응답에는 페이지네이션 UI 관리를 위한 hasNextPage와 hasPreviousPage가 포함됩니다.
@GetMapping("/posts")
fun getPosts(
@RequestParam after: Long?,
@RequestParam(defaultValue = "20") limit: Int
): CursorResponse<Post> {
val cursor = after ?: Long.MAX_VALUE
val posts = repository.findByIdLessThanOrderByIdDesc(
cursor, PageRequest.of(0, limit)
)
val endCursor = posts.lastOrNull()?.id
return CursorResponse(
data = posts,
pageInfo = PageInfo(
hasNextPage = posts.size == limit,
endCursor = endCursor
)
)
}
클라이언트에서는 Paging 3의 PagingSource를 통해 커서 페이지네이션이 구현되며, 키는 커서(Long)입니다. PagingSource.load는 LoadParams.key — 마지막으로 로드된 레코드의 커서 — 를 받습니다. LoadResult.Page는 데이터와 nextKey — 다음 페이지의 커서 — 를 반환합니다. nextKey = null이면 페이지네이션이 완료됩니다.
// Retrofit API
interface PostApi {
@GET("posts")
suspend fun getPosts(
@Query("after") after: Long?,
@Query("limit") limit: Int = 20
): CursorResponse<Post>
}
// PagingSource with cursor key
class PostPagingSource(
private val api: PostApi
) : PagingSource<Long, Post>() {
override suspend fun load(
params: LoadParams<Long>
): LoadResult<Long, Post> = try {
val response = api.getPosts(
after = params.key,
limit = params.loadSize
)
val nextKey = response.pageInfo.endCursor
LoadResult.Page(
data = response.data,
prevKey = null,
nextKey = nextKey
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
GraphQL에서는 Relay Connection 패턴을 통해 커서 페이지네이션이 구현됩니다. 각 타입에는 Connection(pageInfo 및 edges 포함)과 Edge(node + cursor)가 있습니다. 쿼리는 first, after, last, before 매개변수를 전달합니다. 서버는 커서가 있는 edges 배열과 hasNextPage/hasPreviousPage가 있는 pageInfo를 반환합니다.
커서 페이지네이션은 레코드가 자주 추가되거나 삭제되는 동적 데이터를 다루는 API에 권장됩니다. 전형적인 예: 소셜 네트워크의 뉴스 피드, 채팅 메시지, 거래 내역, 게시물 댓글. 이러한 모든 시나리오에서 일관성과 중복 없는 것이 중요합니다.
Offset 페이지네이션이 더 편리한 시나리오도 있습니다: 페이지 번호 탐색이 필요한 관리자 패널, 결과가 변경될 수 있는 페이지네이션이 있는 검색, 5페이지에 대한 고정 링크가 필요한 보고서 및 분석. 이러한 경우 커서의 장점이 구현 복잡성을 상쇄하지 않습니다.
커서 페이지네이션은 임의 페이지로 “점프”하는 것을 지원하지 않습니다 — 사용자가 “5페이지”를 클릭하여 이동할 수 없습니다. 이는 아키텍처적 제한입니다: 총 페이지 수 계산에는 별도의 COUNT 쿼리가 필요하며, 대규모 테이블에서는 비용이 많이 들 수 있습니다. 이러한 경우 하이브리드 접근 방식: 데이터에는 커서 + 페이지네이션에는 count를 사용합니다.
자주 묻는 질문
커서는 데이터 세트의 위치를 가리키는 고유한 레코드 식별자입니다. 단순(레코드 ID)하거나 복합(여러 필드)일 수 있습니다. 클라이언트는 페이지의 마지막 레코드 커서를 받아 다음 배치를 얻기 위해 다음 요청에서 전달합니다.
Cursor pagination은 새 레코드가 추가될 때 이동의 영향을 받지 않습니다 — 각 요소는 정확히 하나의 페이지에 포함됩니다. 또한 처음 n행을 스캔하는 대신 인덱스를 사용하여 대용량에서도 속도를 유지합니다. Offset은 더 간단하지만 동적 데이터에 불안정합니다.
네, 커서 페이지네이션은 GraphQL에 종속되지 않습니다. 모든 REST API에서 커서를 쿼리 매개변수 ?after=83&limit=20으로 전달하여 구현할 수 있습니다. 응답에는 endCursor와 hasNextPage가 있는 pageInfo가 포함되어야 합니다 — 이를 통해 클라이언트는 내부 커서 구조를 몰라도 로딩을 관리할 수 있습니다.
자동 증가 ID가 최적의 선택입니다: 단조 증가하고, 변경되지 않으며, 효율적으로 인덱싱됩니다. UUID v7(시간 순서)도 적합합니다. 타임스탬프는 동일한 시간에 중복을 생성할 수 있으므로 ID와 결합하세요: (created_at, id)로 커서 고유성을 보장합니다.
커서 페이지네이션은 총 페이지 수를 제공하지 않습니다 — 이것이 제한 사항입니다. 총 정보가 필요하면 동일한 필터로 별도의 COUNT 쿼리를 실행하세요. 대규모 테이블의 경우 EXPLAIN을 통한 근사 계산 또는 분석에서 캐시된 총계를 사용하세요.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.