커서 기반 페이징 vs OFFSET, 느려지는 이유

같은 테이블, 같은 쿼리인데 1페이지는 수 밀리초, 5만 페이지는 수 초가 걸립니다. 100만 행 규모에서 OFFSET 990000으로 뒷페이지를 조회하면 응답이 눈에 띄게 느려지지만, 커서 기반 페이징으로 바꾸면 몇 번째 페이지든 거의 일정하게 수 밀리초 안에 끝납니다. 페이지 번호만 바뀌었을 뿐인데 성능이 이렇게 갈리는 이유는 두 방식이 데이터를 읽는 원리가 근본적으로 다르기 때문입니다.

이 글에서는 LIMIT OFFSET 방식이 왜 뒤로 갈수록 느려지는지, 커서(keyset) 방식은 어떻게 위치와 무관하게 일정한 성능을 내는지, 그리고 둘을 언제 어떻게 선택해야 하는지를 SQL 예시와 함께 비교합니다.


OFFSET 페이징은 왜 뒤로 갈수록 느려지나

가장 흔한 페이징은 LIMITOFFSET 조합입니다. “20개씩, 몇 개를 건너뛰고”라는 직관적인 방식입니다.

-- 뒤 페이지일수록 앞의 990,000행을 읽고 그냥 버린다
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 990000;

문제는 OFFSET의 동작 방식에 있습니다. 데이터베이스는 OFFSET 990000을 만나면 앞의 99만 개 행을 실제로 읽어서 정렬한 뒤, 그냥 버리고 그다음 20개만 반환합니다. 즉 건너뛴다고 해서 건너뛴 만큼 일을 안 하는 게 아니라, 그만큼을 다 훑고 폐기하는 것입니다.

그래서 OFFSET 페이징의 비용은 페이지 번호에 비례해 커집니다. 1페이지는 20개만 보면 되지만, 5만 페이지는 100만 개 가까이를 읽고 버려야 합니다. 인덱스가 있어도 이 “읽고 버리는” 작업 자체는 사라지지 않습니다. 인덱스 튜닝으로 쿼리 성능을 끌어올리는 접근은 MySQL 쿼리 지연 해결: 3개월 클라우드 DB 분투기에서 다뤘지만, OFFSET 자체의 구조적 비용은 다른 접근이 필요합니다.

여기에 정확성 문제까지 있습니다. 페이지를 넘기는 사이 새 글이 추가되거나 삭제되면 경계가 밀려, 같은 글을 두 번 보거나 아예 건너뛰는 일이 생깁니다.


커서(keyset) 페이징의 원리: 건너뛰지 않고 이어서 읽는다

커서 기반 페이징은 발상이 다릅니다. “몇 개를 건너뛸까”가 아니라 “마지막으로 본 지점 다음부터 읽자”입니다. keyset 페이징 또는 seek 방식이라고도 부릅니다.

핵심은 직전 페이지의 마지막 행이 가진 정렬 키 값을 커서로 기억했다가, 다음 조회에서 그 값보다 뒤에 있는 행만 가져오는 것입니다.

-- 마지막으로 본 행의 (created_at, id)를 커서로 삼아 그 지점부터 20개
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ('2026-06-01 10:00:00', 48213)
ORDER BY created_at DESC, id DESC
LIMIT 20;

이 쿼리는 OFFSET이 없습니다. 정렬 키에 인덱스가 있으면 데이터베이스는 커서 값에 해당하는 인덱스 위치로 곧바로 이동해 거기서부터 20개만 읽습니다. 앞의 수십만 행을 읽고 버리는 과정이 통째로 사라지므로, 첫 페이지든 마지막 페이지든 비용이 거의 같습니다. 성능이 페이지 위치와 무관하게 일정해지는 이유가 여기에 있습니다.

한 가지 조건이 있습니다. 정렬 키가 유일해야 커서가 한 지점을 정확히 가리킵니다. created_at처럼 중복될 수 있는 값이면, 위 예시처럼 id 같은 유일한 컬럼을 tie-breaker로 묶어 복합 커서로 만들고, 같은 순서의 복합 인덱스 (created_at, id)를 둬야 합니다.


OFFSET vs 커서 기반, 한눈에 비교

OFFSET은 앞 행을 읽고 버리지만, 커서는 곧바로 seek 한다

두 방식의 차이를 정리하면 다음과 같습니다.

구분OFFSET 페이징커서(keyset) 페이징
뒤 페이지 성능갈수록 느려짐 (OFFSET에 비례)위치와 무관하게 일정
임의 페이지 점프가능 (10페이지로 바로)불가 (이전/다음 순차 이동)
총 페이지 수 표시쉬움어려움
데이터 변경 시 정확성경계 밀림(중복·누락) 발생안정적
정렬 키 조건특별한 조건 없음유일한 정렬 키(복합 커서) 필요
적합한 UI페이지 번호 클릭무한 스크롤·더보기

요약하면 OFFSET은 임의 페이지 이동이 쉬운 대신 뒤로 갈수록 느리고, 커서 방식은 성능이 일정한 대신 순차 이동만 가능합니다. 둘은 우열이 아니라 용도가 다른 도구입니다.


커서 페이징의 약점과 도입 조건

커서 방식이 항상 정답은 아닙니다. 가장 큰 제약은 임의 페이지로 바로 뛸 수 없다는 점입니다. 커서는 “직전 페이지의 마지막 지점”을 알아야 다음을 계산하므로, 7페이지를 건너뛰고 곧장 8페이지로 가는 식의 이동이 안 됩니다. “1 2 3 … 10” 같은 페이지 번호 UI와는 잘 맞지 않습니다.

총 개수와 전체 페이지 수를 보여주기도 까다롭습니다. 별도의 COUNT 쿼리가 필요하고, 그 자체가 대용량에서는 또 다른 부하가 됩니다. 목록과 함께 전체 건수를 효율적으로 다루는 방법은 MyBatis WHERE·SELECT 공통화 실전 패턴에서 정리한 접근을 참고할 만합니다.

도입하려면 몇 가지 조건을 갖춰야 합니다. 정렬 기준이 안정적이고 유일해야 하며(복합 커서로 보완), 그 정렬 순서와 일치하는 인덱스가 있어야 합니다. 커서 값은 보통 클라이언트에 노출할 때 Base64 등으로 인코딩해 다음 요청에 그대로 실어 보냅니다. 쿼리를 타입 안전하게 조립하고 싶다면 JPA N+1 해결, Fetch Join·EntityGraph·QueryDSL 비교에서 다룬 QueryDSL이 동적 커서 조건을 만들기에 편리합니다.


언제 무엇을 쓸까

정리하면 선택 기준은 데이터 규모와 UI 형태 두 가지로 좁혀집니다. 관리자 화면처럼 데이터가 많지 않고 “3페이지로 바로 가기”가 필요하다면 OFFSET이 단순하고 충분합니다. 구현이 쉽고 총 페이지 수를 보여주기도 편합니다.

반대로 피드나 타임라인, 검색 결과처럼 데이터가 크고 무한 스크롤·더보기 방식으로 순차 탐색하는 화면이라면 커서 기반 페이징이 정답에 가깝습니다. 사용자가 뒤로 깊이 들어가도 성능이 무너지지 않고, 데이터가 실시간으로 바뀌어도 중복·누락 없이 안정적으로 이어집니다. 실제 서비스에서는 두 방식을 화면 성격에 따라 나눠 쓰는 경우가 많습니다. 지금 만들고 있는 목록이 “몇 페이지로 점프”가 중요한지, “끝없이 이어 보기”가 중요한지부터 따져 보면 선택이 명확해집니다.

댓글 남기기