
서비스에서 게시글, 주문 내역, 알림 목록처럼 여러 데이터를 페이지 단위로 조회하는 기능은 자주 사용된다.
처음에는 LIMIT과 OFFSET을 자주 사용한다. 그러나 데이터가 많아질수록 이 방식은 점점 느려질 수 있다.
이번 글에서는 OFFSET 기반 페이지네이션이 왜 비효율적일 수 있는지 확인하고, 이를 커서 기반 페이지네이션으로 어떻게 개선할 수 있는지 정리해보자.어떤 인덱스 전략이 필요한지와 서비스 설계 관점에서 무엇을 고려해야 하는지도 함께 살펴보자.
가장 흔한 페이지네이션 쿼리는 다음과 같다.
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC
LIMIT 20 OFFSET 0;
첫 페이지를 조회할 때는 문제가 없어 보인다. 두 번째 페이지는 OFFSET 20, 세 번째 페이지는 OFFSET 40처럼 증가시키면 된다.
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC
LIMIT 20 OFFSET 100000;
문제는 페이지가 뒤로 갈수록 발생한다. 앞부분의 데이터를 실제로 반환하지는 않더라도, 데이터베이스는 그만큼의 행을 건너뛰는 비용을 감당해야 한다. 즉, 조회 대상이 뒤로 밀릴수록 성능이 점점 악화될 수 있다.
OFFSET은 말 그대로 앞의 N개를 건너뛴 뒤, 그 다음 데이터부터 가져오라는 의미다. 겉으로 보기에는 단순하지만, 내부적으로는 건너뛸 대상도 어느 정도는 확인해야 한다.
예를 들어 아래 쿼리를 보자.
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC
LIMIT 20 OFFSET 100000;
이 쿼리는 “100001번째 데이터부터 20개만 가져오면 되겠네”처럼 보이지만, 실제로는 그 이전 행들에 대한 정렬 순서를 맞추고, 필요한 위치까지 이동해야 한다. 결국 OFFSET 값이 커질수록 불필요하게 읽는 범위도 함께 커진다.
이 방식은 특히 다음과 같은 경우 더 불리하다.
즉, 작은 규모에서는 단순한 구현이 장점이지만, 대용량 조회에서는 한계가 분명하다.
이 문제를 줄이기 위해 자주 사용하는 방식이 커서 기반 페이지네이션이다. 핵심은 “몇 번째 페이지인가”를 기준으로 조회하지 않고, “이전 페이지의 마지막 데이터 이후부터 가져와라”라는 방식으로 바꾸는 것이다.
예를 들어 첫 페이지는 그대로 최신 데이터 20개를 가져온다.
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20;
그리고 응답으로 받은 마지막 행의 created_at, id 값을 다음 요청의 기준점으로 사용한다. 다음 페이지는 아래처럼 조회할 수 있다.
SELECT id, title, created_at
FROM posts
WHERE (created_at < '2026-04-17 10:30:00')
OR (created_at = '2026-04-17 10:30:00' AND id < 1050)
ORDER BY created_at DESC, id DESC
LIMIT 20;
이 방식은 “앞의 100000개를 건너뛰고”가 아니라 “이 기준보다 뒤에 있는 데이터만 가져와라”로 바뀐다. 즉, 데이터베이스가 필요한 범위부터 바로 찾을 수 있게 된다.
커서 기반 페이지네이션을 설명할 때 created_at 하나만 기준으로 두는 예시가 많다. 하지만 실제 서비스에서는 같은 시각에 여러 데이터가 생성될 수 있다. 이 경우 created_at만으로는 정렬 순서가 완전히 고정되지 않는다.
예를 들어 아래 두 데이터가 있다고 하자.
| id | created_at |
|---|---|
| 1050 | 2026-04-17 10:30:00 |
| 1049 | 2026-04-17 10:30:00 |
둘의 생성 시각이 같다면 created_at DESC만으로는 어떤 것이 먼저인지 보장하기 어렵다. 이 상태에서 커서를 created_at 하나만 사용하면 다음 페이지에서 일부 데이터가 중복되거나 누락될 수 있다.
그래서 정렬 기준은 보통 다음처럼 잡는다.
ORDER BY created_at DESC, id DESC
그리고 커서 조건도 동일하게 맞춘다.
WHERE (created_at < :lastCreatedAt)
OR (created_at = :lastCreatedAt AND id < :lastId)
즉, 정렬 기준과 커서 조건은 반드시 일관되어야 한다. 이 부분이 빠지면 커서 기반 페이지네이션은 겉보기만 안정적일 뿐, 실제로는 데이터 정합성 문제가 생길 수 있다.
커서 기반 페이지네이션이 효과를 보려면 인덱스도 함께 설계해야 한다. 다음과 같은 조회가 많다고 가정해보자.
SELECT id, title, created_at
FROM posts
WHERE status = 'PUBLIC'
AND (created_at < :lastCreatedAt
OR (created_at = :lastCreatedAt AND id < :lastId))
ORDER BY created_at DESC, id DESC
LIMIT 20;
이 경우 자주 사용하는 조건과 정렬 기준을 반영한 인덱스를 고려해야 한다. 예를 들면 다음과 같다.
INDEX idx_posts_status_created_id (status, created_at DESC, id DESC)
해당 인덱스가 필요한 이유는
첫째, status = 'PUBLIC' 같은 필터 조건을 빠르게 줄일 수 있어야 한다. 둘째, 그 상태에서 created_at, id 순으로 이미 정렬된 자료를 따라가며 필요한 범위만 읽을 수 있어야 한다. 셋째, LIMIT 20처럼 적은 양만 가져올 때 특히 효율이 높아진다.
여기서 중요한 점은 인덱스 컬럼 순서다. 인덱스는 아무 컬럼이나 많이 넣는다고 좋아지는 것이 아니라, 실제 조회 패턴에 맞아야 한다. 즉, “어떤 WHERE 조건을 자주 쓰는가”, “어떤 ORDER BY가 붙는가”, “LIMIT 조회가 많은가”를 먼저 보고 설계해야 한다.
결국 어느 방식이 무조건 정답이라고 보기는 어렵다. 다만 최신순 목록을 연속으로 조회하는 서비스라면 커서 방식이 더 적합한 경우가 많다.
기존 방식은 이런 생각에 가깝다.
반면 커서 방식은 이렇게 바뀐다.
즉, 전체 결과 집합을 기준으로 일부를 잘라오는 방식에서 현재 위치를 기준으로 다음 구간만 조회하는 방식으로 사고가 바뀐 것이다.
이 차이는 단순히 성능 개선뿐 아니라, 서비스 API를 설계하는 방식에도 영향을 준다.
페이지네이션은 SQL 한 줄의 문제가 아니라 API 설계 문제이기도 하다.
예를 들어 관리자 페이지처럼 “정확히 37페이지 중 12페이지”로 이동해야 하는 화면이라면 OFFSET이 더 적합할 수 있다. 반대로 SNS 피드, 알림 목록, 주문 내역처럼 연속적인 조회 구조라면 커서 방식이 더 자연스럽다.
또한 커서 방식은 보통 응답 값도 달라진다. 기존에는 page, size, totalPages 같은 정보가 중심이었다면, 커서 방식에서는 아래와 같은 구조가 더 일반적이다.
{
"items": [...],
"nextCursor": {
"createdAt": "2026-04-17T10:30:00",
"id": 1050
},
"hasNext": true
}
즉, API 사용자도 “다음 페이지 번호”가 아니라 “다음 조회 기준점”을 받아야 한다. 따라서 페이지네이션 전략은 프론트엔드와 백엔드가 함께 합의해야 하는 부분이다.
모든 조회를 처음부터 커서 기반으로 만들 필요는 없다. 작은 규모의 관리자 페이지나 검색 결과처럼 직접 페이지 이동이 중요한 경우에는 OFFSET이 더 편할 수 있다.
다만 아래 조건에 해당하면 커서 기반 페이지네이션을 우선 검토할 필요가 있다.
처음에는 LIMIT과 OFFSET이 가장 단순하고 익숙한 해결책처럼 보인다. 하지만 데이터가 많아지고 조회가 반복될수록, 이 방식은 점점 비효율적일 수 있다. 특히 최신순 목록을 계속 조회하는 서비스에서는 앞부분을 반복해서 건너뛰는 비용이 누적되기 쉽다.
커서 기반 페이지네이션은 이 문제를 줄일 수 있는 현실적인 대안이다. 다만 쿼리만 바꾸는 것으로 끝나지 않는다. 정렬 기준을 안정적으로 설계해야 하고, 그에 맞는 복합 인덱스를 준비해야 하며, API 응답 구조와 프론트엔드 동작 방식도 함께 고려해야 한다.
결국 쿼리 최적화는 SQL 문법 몇 줄의 문제가 아니다. 어떤 데이터를 어떤 방식으로 보여줄 것인지, 그리고 그 조회 패턴에 맞는 인덱스와 API를 어떻게 설계할 것인지까지 함께 보는 것이 중요하다. 이번 사례는 그 점을 잘 보여주는 대표적인 예시라고 생각한다.