페이지네이션 완전 정복 — OFFSET/LIMIT이 느려지는 이유와 커서 기반 조회 설계
페이지네이션, 왜 따로 알아야 하나요?
목록 API를 만들다 보면 페이지네이션은 거의 항상 들어갑니다. 게시글 목록, 상품 목록, 채팅방 목록, 관리자 화면까지 대부분의 조회 API가 페이지 단위 응답을 사용합니다.
그런데 실무에서는 이런 문제가 자주 생깁니다.
- 초반 페이지는 빠른데 뒤로 갈수록 응답이 느려집니다
OFFSET기반 조회를 쓰는데 데이터가 중간에 끼거나 빠진 것처럼 보입니다- 무한 스크롤을 붙이려는데 다음 페이지 기준을 어떻게 잡아야 할지 애매합니다
- 정렬 조건은 있는데 어떤 인덱스를 만들어야 할지 감이 안 잡힙니다
- 총 개수까지 같이 내려주려니
COUNT쿼리 비용도 부담됩니다
페이지네이션은 단순히 LIMIT 20 OFFSET 40을 붙이는 문제가 아닙니다. 정렬 기준, 인덱스 구조, 데이터 변경 시 일관성, UX 방식이 함께 얽힌 설계 문제입니다.
기준: 이 글은 MySQL 8.x와 일반적인 API 서버 조회 패턴을 기준으로 설명합니다. 다만 페이지네이션의 핵심 원리는 다른 DBMS에도 거의 동일하게 적용됩니다.
먼저 선택 기준부터 보면
실무에서는 보통 아래처럼 판단하면 크게 틀리지 않습니다.
| 상황 | 더 잘 맞는 방식 | 이유 |
|---|---|---|
| 관리자 화면, 페이지 번호 이동이 중요함 | OFFSET/LIMIT |
구현이 단순하고 page=37 같은 직접 이동이 쉽습니다 |
| 무한 스크롤, 피드, 대용량 목록 | 커서 기반 | 깊은 페이지로 갈수록 더 안정적이고 빠릅니다 |
| 총 개수와 마지막 페이지 번호가 꼭 필요함 | OFFSET/LIMIT 또는 별도 COUNT |
커서 방식은 "몇 페이지째" 개념과 잘 안 맞습니다 |
| 데이터가 자주 추가되고 앞쪽 정렬이 흔들림 | 커서 기반 | 앞 페이지에 새 데이터가 끼어들 때 중복/누락을 줄이기 쉽습니다 |
핵심은 OFFSET이 틀렸느냐가 아닙니다. UI와 데이터 규모에 맞는 방식을 고르는 것이 중요합니다.
Phase 1. 페이지네이션은 정확히 무엇을 하는가?
페이지네이션은 결국 정렬된 결과 집합에서 일부 구간만 잘라서 반환하는 것입니다.
그래서 페이지네이션에서 가장 먼저 정해야 할 것은 LIMIT이 아니라 정렬 기준(ORDER BY) 입니다.
예를 들어 게시글 최신순 목록이라면 보통 이렇게 생각합니다.
SELECT id, title, created_at
FROM posts
WHERE status = 'PUBLISHED'
ORDER BY created_at DESC
LIMIT 20;
겉으로는 충분해 보이지만, 여기에는 한 가지 빈틈이 있습니다.
- 같은 초에 생성된 게시글이 여러 개면 순서가 애매할 수 있습니다
- 요청마다 같은
created_at묶음의 내부 순서가 바뀌면 페이지 경계가 흔들릴 수 있습니다
그래서 실무에서는 보통 동률을 깨 줄 tie-breaker 를 함께 둡니다.
SELECT id, title, created_at
FROM posts
WHERE status = 'PUBLISHED'
ORDER BY created_at DESC, id DESC
LIMIT 20;
즉, 페이지네이션의 출발점은 이것입니다.
항상 재현 가능한 정렬 순서를 먼저 만든다
이 원칙이 깨지면 OFFSET이든 커서든 안정적인 페이지네이션이 어렵습니다.
Phase 2. OFFSET/LIMIT 방식은 어떻게 동작할까?
가장 익숙한 방식은 OFFSET/LIMIT입니다.
SELECT id, title, created_at
FROM posts
WHERE status = 'PUBLISHED'
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 0;
다음 페이지는 이렇게 됩니다.
SELECT id, title, created_at
FROM posts
WHERE status = 'PUBLISHED'
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 20;
세 번째 페이지는 OFFSET 40, 네 번째는 OFFSET 60입니다.
장점
- 구현이 단순합니다
page=1,page=2,page=37처럼 페이지 번호 기반 UI와 잘 맞습니다- 정확한 총 개수와 함께 내려주기 쉽습니다
- 데이터 규모가 작거나 앞쪽 몇 페이지만 보는 화면에서는 충분히 실용적입니다
잘 맞는 화면
- 관리자 목록
- 백오피스 검색 결과
- 페이지 번호 버튼이 중요한 게시판
- 사용자가 임의 페이지로 점프해야 하는 화면
즉, OFFSET/LIMIT은 지금도 충분히 유효한 도구입니다. 문제는 깊은 페이지와 큰 테이블로 갈수록 비용이 커진다는 점입니다.
Phase 3. 왜 OFFSET은 뒤로 갈수록 느려질까?
예를 들어 20개씩 보여 주는 목록에서 5001번째 페이지를 읽는다고 가정해 보겠습니다.
SELECT id, title, created_at
FROM posts
WHERE status = 'PUBLISHED'
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 100000;
이 쿼리는 결과 20건만 반환하지만, DB 입장에서는 앞의 100000건을 먼저 지나가야 합니다.
핵심은 이것입니다.
LIMIT 20은 "20건만 반환"하라는 뜻입니다OFFSET 100000은 "그 전에 있는 100000건은 건너뛰라"는 뜻입니다- 즉, DB는 보통 100020건 위치까지 도달한 뒤에야 원하는 20건을 줄 수 있습니다
인덱스가 있더라도 사정이 완전히 달라지지는 않습니다.
- 정렬에 맞는 인덱스가 있으면 정렬 비용은 줄어들 수 있습니다
- 하지만 깊은
OFFSET에서는 여전히 앞쪽 인덱스 엔트리를 많이 스캔해야 합니다 - 선택 컬럼이나 추가 조건에 따라 테이블 접근 비용까지 붙으면 더 비싸질 수 있습니다
즉, OFFSET의 본질적인 비용은 앞쪽 구간을 버리기 위해 읽는 작업에 있습니다.
페이지 크기가 커지면 더 민감해진다
페이지 크기를 20에서 100으로 늘리면 어떻게 될까요?
- 한 번에 읽는 행 수가 늘어납니다
- 뒤쪽 페이지의 스캔 비용도 더 커집니다
- 페이지 크기 증가가 N+1 쿼리 문제와 겹치면 응답 시간은 더 급격히 흔들릴 수 있습니다
그래서 "페이지네이션이 느리다"는 말은 종종 페이지네이션 자체보다 큰 페이지 크기 + 깊은 OFFSET + 추가 조회 구조가 합쳐진 결과이기도 합니다.
Phase 4. 정렬과 인덱스가 페이지네이션 성능을 좌우한다
페이지네이션 쿼리에서 인덱스가 중요한 이유는 단순합니다.
정렬과 필터를 인덱스가 뒷받침하지 못하면, 페이지네이션은 앞 페이지조차 불필요하게 비싸질 수 있습니다
예를 들어 이런 쿼리가 있다고 가정해 보겠습니다.
SELECT id, title, created_at
FROM posts
WHERE status = 'PUBLISHED'
ORDER BY created_at DESC, id DESC
LIMIT 20;
이 경우 실무에서는 보통 아래와 같은 복합 인덱스를 유력한 후보로 먼저 떠올립니다.
(status, created_at DESC, id DESC)
이 쿼리 패턴에서 이 인덱스가 자주 잘 맞는 이유는 다음과 같습니다.
status = 'PUBLISHED'로 먼저 범위를 좁히고- 그 안에서
created_at DESC, id DESC순서대로 읽을 수 있기 때문입니다
자주 쓰는 출발점
- 동등 조건 컬럼을 먼저 검토합니다
- 그다음 정렬 컬럼을 붙이는 후보를 봅니다
- 동률을 깨는
tie-breaker컬럼도 인덱스에 포함할지 봅니다
예:
WHERE author_id = ?
AND status = 'PUBLISHED'
ORDER BY created_at DESC, id DESC
이런 쿼리라면 보통 아래 같은 인덱스를 대표 후보로 검토합니다.
(author_id, status, created_at DESC, id DESC)
다만 이 순서를 절대 규칙처럼 받아들이면 안 됩니다. 실제 인덱스 선택은 데이터 분포, 선택도, 추가 조건, 조회 컬럼, MySQL 옵티마이저 판단에 따라 달라질 수 있으므로 최종적으로는 EXPLAIN으로 확인해야 합니다.
자주 놓치는 함정
ORDER BY DATE(created_at)처럼 정렬 컬럼에 함수를 씌움- 정렬과 맞지 않는 인덱스를 기대함
tie-breaker없이created_at하나만 정렬에 사용함EXPLAIN없이 "인덱스가 있으니 괜찮겠지"라고 생각함
이 부분은 인덱스 기본 글, 인덱스가 안 타는 이유, 실행 계획 읽는 법과 함께 보면 더 잘 연결됩니다.
Phase 5. 커서 기반 페이지네이션은 무엇이 다를까?
커서 기반 페이지네이션은 흔히 keyset pagination 또는 seek method 라고도 부릅니다.
핵심 아이디어는 단순합니다.
"몇 건을 건너뛸지"가 아니라 "마지막으로 본 위치가 어디인지"를 기준으로 다음 페이지를 읽는다
예를 들어 첫 페이지는 이렇게 읽습니다.
SELECT id, title, created_at
FROM posts
WHERE status = 'PUBLISHED'
ORDER BY created_at DESC, id DESC
LIMIT 20;
이 페이지의 마지막 행이 다음 값이었다고 가정해 보겠습니다.
created_at = 2026-04-10 10:00:00
id = 105
그다음 페이지는 OFFSET 20이 아니라, 이 마지막 값을 기준으로 읽습니다.
SELECT id, title, created_at
FROM posts
WHERE status = 'PUBLISHED'
AND (
created_at < '2026-04-10 10:00:00'
OR (created_at = '2026-04-10 10:00:00' AND id < 105)
)
ORDER BY created_at DESC, id DESC
LIMIT 20;
설명을 위해 조건을 풀어 썼지만, 의미는 명확합니다.
- 이전 페이지의 마지막 행보다 뒤에 있는 데이터만 읽습니다
- 앞쪽 20건, 40건, 100000건을 버리기 위해 스캔하지 않습니다
왜 깊은 페이지에서 유리할까?
커서 방식은 보통 "이 위치 다음부터" 읽는 형태이기 때문에, 깊은 OFFSET처럼 앞 구간을 반복해서 버리는 비용이 적습니다.
그래서 특히 아래 상황에서 잘 맞습니다.
- 무한 스크롤
- 최신순 피드
- 채팅방 목록
- 오래된 페이지까지 계속 내려가는 대용량 목록
즉, 커서 방식의 강점은 깊은 페이지 접근과 안정적인 이어 읽기에 있습니다.
Phase 6. 커서 컬럼은 어떻게 골라야 할까?
좋은 커서 컬럼은 아무 컬럼이나 될 수 없습니다. 보통 아래 조건이 중요합니다.
- 정렬 기준과 같아야 합니다 - 커서는 결국 정렬 경계를 표현해야 합니다
- 거의 변하지 않아야 합니다 - 중간에 값이 바뀌면 레코드 위치가 움직입니다
- 순서를 안정적으로 구분할 수 있어야 합니다 - 동률이 많다면 보조 키가 필요합니다
- 인덱스로 지원돼야 합니다 - 커서 조건도 결국 검색 조건이기 때문입니다
자주 쓰는 조합
| 정렬 목적 | 자주 쓰는 커서 |
|---|---|
| 최신순 | created_at + id |
| 오래된 순 | created_at + id |
| ID 순 탐색 | id |
| 점수 + 최신순 | score + created_at + id |
피해야 할 예시
- 자주 수정되는
updated_at하나만 커서로 사용 - 중복이 많은 값 하나만 사용
- 제목, 이름처럼 표시용 문자열 사용
NULL이 섞여 정렬 의미가 불명확한 컬럼 사용
중요한 실무 포인트
커서는 정렬과 필터에 종속됩니다.
예를 들어:
status = 'PUBLISHED'최신순 목록에서 만든 커서status = 'DRAFT'목록에 그대로 재사용
이렇게 하면 안 됩니다. 커서는 "이 조건에서의 다음 위치"를 의미하므로, 정렬이나 필터가 바뀌면 기존 커서는 무효입니다.
Phase 7. 왜 created_at 하나만으로는 부족할까?
이 부분이 커서 기반 페이지네이션에서 가장 많이 빠지는 함정입니다.
예를 들어 첫 페이지 마지막 행이 다음과 같다고 해 보겠습니다.
(created_at = 2026-04-10 10:00:00, id = 105)
그런데 아직 같은 시각의 다른 행이 더 남아 있습니다.
(2026-04-10 10:00:00, 104)
(2026-04-10 10:00:00, 103)
이때 다음 페이지 조건을 이렇게 쓰면 문제가 생깁니다.
WHERE created_at < '2026-04-10 10:00:00'
그러면 104, 103은 같은 시각이기 때문에 통째로 빠집니다.
반대로 이렇게 쓰면:
WHERE created_at <= '2026-04-10 10:00:00'
이번에는 105가 다시 포함되어 중복될 수 있습니다.
그래서 tie-breaker가 필요합니다.
WHERE created_at < :cursorCreatedAt
OR (created_at = :cursorCreatedAt AND id < :cursorId)
즉, 커서 기반 페이지네이션은 사실상 아래 두 가지를 함께 요구합니다.
- 결정적인 정렬
- 그 정렬을 그대로 반영한 복합 커서
정렬이 ORDER BY created_at DESC, id DESC라면 커서 조건도 같은 구조를 따라가야 합니다.
Phase 8. 이전 페이지, 다음 페이지, 무한 스크롤은 어떻게 처리할까?
무한 스크롤에서는 보통 "다음 페이지"만 있으면 충분합니다. 이 경우 API는 대개 이런 형태로 설계합니다.
{
"items": [
{ "id": 124, "title": "..." },
{ "id": 123, "title": "..." }
],
"nextCursor": "opaque-token",
"hasNext": true
}
여기서 nextCursor는 보통 클라이언트가 내부 구조를 몰라도 되도록 opaque token으로 두는 편이 많습니다. 서버는 내부적으로 createdAt, id, 필터 정보를 인코딩해 저장할 수 있습니다.
hasNext는 어떻게 계산할까?
가장 흔한 방법은 limit + 1건을 읽는 것입니다.
예를 들어 페이지 크기가 20이면:
LIMIT 21
- 21건이 오면
hasNext = true - 실제 응답에는 앞 20건만 내보냄
- 마지막 1건은 다음 페이지 존재 여부 판단에만 사용
이 방식은 불필요한 COUNT(*) 없이도 "다음 페이지가 있는가"를 판단하게 해 줍니다.
이전 페이지는 어떻게 할까?
이전 페이지가 필요하면 보통 비교 방향과 정렬 방향을 뒤집어 조회한 뒤 애플리케이션에서 다시 뒤집습니다.
예를 들어 현재 커서보다 앞쪽 데이터를 읽고 싶다면:
SELECT id, title, created_at
FROM posts
WHERE status = 'PUBLISHED'
AND (
created_at > :cursorCreatedAt
OR (created_at = :cursorCreatedAt AND id > :cursorId)
)
ORDER BY created_at ASC, id ASC
LIMIT 20;
그다음 애플리케이션에서 결과를 다시 역순으로 뒤집어 원래 정렬(DESC)에 맞춥니다.
커서 방식이 불편한 경우
- 사용자가
37페이지로 직접 이동해야 함 - 페이지 번호 버튼이 UX 핵심임
- "마지막 페이지" 개념이 중요함
이런 화면은 커서 방식보다 OFFSET/LIMIT이 더 자연스럽습니다.
Phase 9. 총 개수(COUNT)는 언제 같이 주고 언제 분리할까?
많은 목록 API가 이 응답을 고민합니다.
{
"items": [...],
"page": 3,
"size": 20,
"totalCount": 128731
}
문제는 totalCount가 공짜가 아니라는 점입니다.
- 필터 조건이 복잡할 수 있습니다
- 조인이나 검색 조건이 들어가면
COUNT(*)도 비싸질 수 있습니다 - 데이터가 큰 테이블에서는 목록 조회와 별도로 또 하나의 무거운 쿼리가 추가됩니다
총 개수가 꼭 필요한 경우
- 관리자/백오피스 화면
- 검색 결과 페이지 번호 UI
- 마지막 페이지 계산이 필요한 화면
굳이 필요 없는 경우
- 무한 스크롤
- 피드형 목록
- "더 보기" 버튼 기반 UI
- 높은 QPS의 공개 API
이런 경우에는 hasNext만으로 충분한 경우가 많습니다.
실무에서 자주 하는 선택
- 목록 조회와
COUNT를 분리합니다 COUNT가 꼭 필요 없는 화면은 아예 생략합니다- 약간 오래돼도 되는 개수라면 캐시를 검토합니다
핵심은 이것입니다.
목록을 보여 주는 데 꼭 필요하지 않은 총 개수 때문에, 모든 요청에 비싼 집계 쿼리를 얹지 않는다
Phase 10. 왜 중복과 누락이 생길까?
페이지네이션은 요청이 한 번에 끝나지 않습니다. 첫 페이지를 읽고, 몇 초 뒤 두 번째 페이지를 읽는 동안 데이터가 바뀔 수 있습니다.
OFFSET 방식에서 생기는 흔한 문제
첫 페이지가 OFFSET 0 LIMIT 20이고, 그 사이에 맨 앞에 새 글 1개가 추가됐다고 가정해 보겠습니다.
요청 1: 1~20번째 행 조회
새 글 1건이 맨 앞에 삽입
요청 2: OFFSET 20 LIMIT 20
그러면 두 번째 요청은 원래 21번째가 아니라, 삽입으로 밀려난 다른 구간을 읽게 됩니다. 이 과정에서:
- 첫 페이지 마지막 항목이 두 번째 페이지에서 다시 보이거나
- 어떤 항목은 한 번도 안 보이고 건너뛰어질 수 있습니다
삭제가 중간에 일어나도 비슷한 문제가 생깁니다.
커서 방식은 왜 더 안정적일까?
커서 방식은 "이전 페이지 마지막 행 이후"를 기준으로 읽기 때문에, 앞쪽에 새 데이터가 추가되어도 다음 페이지 경계가 덜 흔들립니다.
하지만 커서도 만능은 아닙니다.
- 정렬 키 자체가 수정되면 레코드 위치가 바뀔 수 있습니다
- 점수 기반 랭킹처럼 정렬 값이 계속 변하면 중복/누락이 여전히 생길 수 있습니다
즉, 커서 방식은 보통 OFFSET보다 안정적이지만, 정렬 키가 움직이는 목록에서는 완전한 스냅샷 보장을 해 주지 않습니다.
정말 같은 스냅샷으로 끝까지 읽어야 한다면
예를 들어 대용량 내보내기(export)처럼 "처음 본 시점의 결과"를 끝까지 유지해야 한다면, 일반적인 HTTP 페이지네이션만으로는 부족할 수 있습니다.
이럴 때는 보통 아래 같은 접근을 검토합니다.
- 첫 페이지 시점의 상한 정렬 키를 고정합니다
- 정렬이
created_at DESC, id DESC라면 예:created_at < :maxCreatedAt OR (created_at = :maxCreatedAt AND id <= :maxId) - 또는 배치/비동기 작업으로 스냅샷 ID 집합을 따로 만듭니다
즉, 강한 일관성이 필요한 다페이지 조회는 페이지네이션 방식만의 문제가 아니라 스냅샷 설계 문제가 됩니다.
Phase 11. 언제 OFFSET, 언제 커서를 쓰면 좋을까?
실무 기준으로 압축하면 아래 표에 가깝습니다.
| 상황 | 추천 방식 | 이유 |
|---|---|---|
| 게시판형 목록 + 페이지 번호 이동 | OFFSET/LIMIT |
사용자가 임의 페이지로 이동하기 쉽습니다 |
| 관리자 테이블 + 총 건수 표시 | OFFSET/LIMIT |
page, totalCount, lastPage 개념과 잘 맞습니다 |
| 피드, 타임라인, 무한 스크롤 | 커서 기반 | 깊은 페이지와 데이터 삽입에 더 안정적입니다 |
| 채팅방 목록, 알림 목록, 활동 로그 | 커서 기반 | 최신순 이어 읽기가 자연스럽습니다 |
| 대용량 목록을 끝까지 순차 탐색 | 커서 기반 | 깊은 OFFSET 비용을 피하기 쉽습니다 |
실무에서 흔한 하이브리드
완전히 하나만 쓰는 것이 아니라, 아래처럼 섞는 경우도 많습니다.
- 사용자 화면 피드: 커서 기반
- 관리자 검색 화면:
OFFSET/LIMIT - 첫 몇 페이지는
OFFSET, 깊은 탐색 API는 별도 커서 제공
중요한 것은 "유행하는 방식"이 아니라 화면이 요구하는 탐색 방식과 데이터 특성입니다.
Phase 12. 실무 체크리스트
페이지네이션 API를 만들 때는 아래 항목을 먼저 확인하면 좋습니다.
ORDER BY가 결정적인가? 예:created_at DESC, id DESC- 정렬과 필터를 받쳐 줄 복합 인덱스가 있는가?
- 깊은 페이지 탐색이 중요한가, 아니면 페이지 번호 이동이 중요한가?
- 페이지 크기 상한을 두고 있는가? 예: 최대 100
- 커서 컬럼이 잘 안 바뀌는 값인가?
tie-breaker없이 단일 컬럼 커서를 쓰고 있지 않은가?hasNext만으로 충분한데 매번COUNT(*)를 하고 있지 않은가?- 실행 계획으로 실제 인덱스 사용 여부를 확인했는가?
- 데이터 삽입/삭제가 발생하는 상황에서 중복·누락 테스트를 해 봤는가?
한눈에 보는 선택 기준
지금까지의 차이를 실무 관점에서 줄이면 아래 표에 가깝습니다.
| 기준 | OFFSET/LIMIT |
커서 기반 |
|---|---|---|
| 구현 난이도 | 더 단순합니다 | 커서 인코딩과 정렬 키 설계가 더 필요합니다 |
| 페이지 번호 이동 | 잘 맞습니다 | 잘 안 맞습니다 |
| 무한 스크롤 | 가능하지만 깊은 페이지에서 불리합니다 | 가장 잘 맞습니다 |
| 깊은 페이지 성능 | 뒤로 갈수록 불리해집니다 | 상대적으로 더 안정적입니다 |
| 총 개수 표시 | 같이 주기 쉽습니다 | 보통 hasNext 중심으로 갑니다 |
| 데이터 삽입 시 경계 안정성 | 앞쪽 데이터 변동에 더 취약합니다 | 보통 더 안정적입니다 |
정리
- 페이지네이션의 출발점은
LIMIT이 아니라ORDER BY입니다 —tie-breaker까지 포함한 결정적인 정렬이 먼저 있어야 합니다 OFFSET/LIMIT은 단순하고 여전히 유용합니다 — 관리자 화면, 페이지 번호 이동, 총 개수 표시가 중요한 UI와 잘 맞습니다- 커서 기반은 깊은 페이지와 무한 스크롤에 더 잘 맞습니다 — 앞 구간을 반복해서 버리지 않아 대용량 최신순 목록에서 유리합니다
- 성능은 페이지네이션 문법보다 인덱스 설계에 더 크게 좌우됩니다 — 필터, 정렬,
tie-breaker를 함께 보고EXPLAIN으로 확인해야 합니다 - 중복·누락 문제는 데이터 변경과 함께 봐야 합니다 — 목록이 자주 바뀌는 환경일수록 경계 안정성과 스냅샷 요구사항을 분리해서 판단해야 합니다
핵심을 한 문장으로 줄이면 이렇습니다.
페이지네이션은
LIMIT문법의 문제가 아니라, 정렬과 인덱스와 일관성과 UX를 함께 맞추는 설계 문제입니다