티스토리 뷰
목록 화면을 만들 때 가장 먼저 손이 가는 게 LIMIT ... OFFSET ... 이다. 프레임워크가 다 해주니까 신경 쓸 일도 없다. Laravel이면 paginate() 한 줄이고, 페이지 번호는 알아서 붙는다.
문제는 데이터가 쌓이고 나서 터진다. 개발 초기에 1,000건으로 테스트할 때는 멀쩡하던 목록이, 운영 6개월 차에 수십만 건이 되면 뒤쪽 페이지에서 눈에 띄게 느려진다. 그리고 느려지는 것보다 더 골치 아픈 문제가 하나 더 있다.
문제 1. 뒤로 갈수록 느려진다
OFFSET 100000 LIMIT 20 을 DB가 어떻게 처리하는지 생각해보면 답이 나온다. DB는 100,000건을 건너뛰는 게 아니라, 100,020건을 실제로 읽은 다음 앞의 100,000건을 버린다. 건너뛴다는 표현이 주는 인상과 실제 동작이 완전히 다르다.
그래서 OFFSET이 커질수록 읽어야 하는 행이 선형으로 늘어난다. 1페이지는 20건만 읽으면 되지만 5,000페이지는 10만 건을 읽어야 한다. 인덱스를 잘 걸어둬도 마찬가지다. 인덱스를 타더라도 결국 그만큼의 엔트리를 순회해야 하고, 커버링 인덱스가 아니면 테이블 접근까지 그만큼 발생한다.
실무에서는 이렇게 나타난다.
- 관리자 페이지에서 마지막 페이지로 점프하면 타임아웃
- 데이터 export 배치가 페이지를 돌면서 점점 느려짐
- 크롤러가 뒷페이지를 긁을 때 DB CPU가 튐
문제 2. 중복과 누락 (이게 더 심각하다)
성능은 느린 걸로 끝나지만 이건 데이터가 잘못 보인다.
최신순 목록에서 사용자가 1페이지를 보고 있는 사이에 새 글 3개가 등록됐다고 하자. 사용자가 2페이지로 넘어가면 OFFSET 20을 다시 계산하는데, 앞에 3개가 밀려 들어왔으니 1페이지 끝에서 봤던 글 3개를 2페이지에서 다시 보게 된다. 중복이다.
반대로 글이 삭제되면 반대 현상이 생긴다. 앞쪽에서 3개가 빠지면 원래 2페이지 앞부분에 있어야 할 글 3개가 1페이지로 당겨지는데, 사용자는 이미 1페이지를 지나온 뒤다. 그 글들은 누락된다.
무한 스크롤에서 이 문제가 특히 잘 드러난다. 스크롤을 내리는데 같은 카드가 두 번 나오거나, 분명 있어야 할 글이 안 보이는 현상이 그것이다. 버그 리포트로 올라와도 재현이 안 돼서 한참 헤매게 된다. 타이밍 문제라서 그렇다.
문제 3. 정렬이 불안정하면 페이지마다 결과가 달라진다
ORDER BY 없이 LIMIT/OFFSET만 쓰면 DB는 행 순서를 보장하지 않는다. 실행 계획이 바뀌거나 리드 레플리카가 여러 대면 페이지마다 다른 순서로 나올 수 있다.
정렬 컬럼에 중복 값이 많을 때도 마찬가지다. ORDER BY created_at DESC 인데 같은 초에 생성된 행이 여럿이면 그들 사이의 순서는 미정이다. 그래서 정렬 키는 항상 유니크한 값을 마지막에 붙여야 한다.
-- 위험
ORDER BY created_at DESC
-- 안전
ORDER BY created_at DESC, id DESC
해결: 커서 기반 페이지네이션
커서 방식은 "몇 개를 건너뛸까"가 아니라 "어디서부터 이어서 읽을까"를 기준으로 한다.
-- OFFSET 방식
SELECT * FROM posts ORDER BY id DESC LIMIT 20 OFFSET 100000;
-- 커서 방식
SELECT * FROM posts WHERE id < 348271 ORDER BY id DESC LIMIT 20;
두 번째 쿼리는 OFFSET이 아무리 커져도 성능이 일정하다. 인덱스에서 id < 348271 위치를 바로 찾아 20건만 읽고 끝나기 때문이다. 100번째 페이지든 10,000번째 페이지든 읽는 행 수가 같다.
중복과 누락 문제도 자연스럽게 사라진다. 기준이 "마지막으로 본 행"이라서, 그 사이에 새 글이 들어와도 이미 본 지점부터 이어서 읽는다.
Laravel에서는
Laravel 8부터 cursorPaginate()가 들어왔다.
// 기존
$posts = Post::orderBy('id', 'desc')->paginate(20);
// 커서 방식
$posts = Post::orderBy('id', 'desc')->cursorPaginate(20);
응답의 next_cursor 값을 다음 요청에 그대로 넘기면 된다. 커서는 정렬 키 값을 인코딩한 문자열이다. 주의할 점은 orderBy를 반드시 명시해야 한다는 것이다. 커서는 정렬 키를 기준으로 만들어지기 때문에 정렬이 없으면 동작 자체가 성립하지 않는다.
복합 정렬일 때
created_at DESC, id DESC 처럼 키가 두 개면 조건도 두 개가 된다.
WHERE (created_at < :last_created_at)
OR (created_at = :last_created_at AND id < :last_id)
-- MySQL 8.0+, PostgreSQL 은 튜플 비교로 더 간결하게
WHERE (created_at, id) < (:last_created_at, :last_id)
Laravel의 cursorPaginate()는 이 조건을 알아서 만들어준다.
커서 방식의 한계
만능은 아니다. 포기해야 하는 게 분명히 있다.
임의 페이지 점프가 안 된다. "7페이지로 이동" 같은 UI를 만들 수 없다. 이전/다음만 가능하다. 무한 스크롤이나 더보기 버튼에는 잘 맞지만, 페이지 번호가 쭉 나열되는 게시판 UI에는 안 맞는다.
전체 건수를 알기 어렵다. COUNT(*)를 따로 돌려야 하는데, 이것도 데이터가 많으면 느리다. 그래서 커서 방식을 쓰는 화면은 보통 총 건수를 안 보여주거나 근사치만 보여준다.
정렬 기준을 바꾸면 커서가 무효가 된다. 정렬을 바꾸는 순간 처음부터 새로 시작해야 한다.
그래서 어떻게 고를까
| 상황 | 선택 |
| 관리자 목록, 수천 건, 페이지 점프 필요 | OFFSET 그대로 |
| 앱/웹 피드, 무한 스크롤, 실시간 유입 | 커서 |
| 배치/export로 전체 순회 | 커서 (필수에 가깝다) |
| 검색 결과, 총 건수 노출 필요 | OFFSET + 상한선 (예: 100페이지까지) |
마지막 항목이 실무에서 자주 쓰는 절충안이다. 구글 검색 결과도 뒤로 무한정 갈 수 없는 게 같은 이유다. OFFSET을 쓰되 최대 페이지를 제한해서 최악의 쿼리가 나오지 않게 막는 것이다.
정리
- OFFSET은 건너뛰는 게 아니라 읽고 버리는 것이다. 뒤로 갈수록 선형으로 느려진다.
- 데이터가 실시간으로 들어오고 나가는 목록에서 OFFSET은 중복과 누락을 만든다.
- 정렬 키에는 반드시 유니크 컬럼을 마지막에 붙인다.
- 무한 스크롤이면 커서, 페이지 번호가 필요하면 OFFSET + 상한선.
목록 하나 만드는 데 뭘 이렇게까지 싶겠지만, 데이터가 쌓인 뒤에 페이지네이션 방식을 바꾸는 건 API 스펙과 프런트를 같이 뜯어야 하는 일이 된다. 처음 설계할 때 이 화면이 어느 쪽인지 한 번만 생각해두면 나중에 훨씬 편하다.
'개발 > SQL' 카테고리의 다른 글
| 인덱스를 걸었는데도 쿼리가 느린 이유, MySQL 실행계획으로 잡는 실전 정리 (0) | 2026.08.20 |
|---|---|
| AWS RDS 비용 줄이는 방법 (성능 개선 도우미) (2) | 2020.09.26 |
| 디비 빠르게 백업하고 복원하기 - mariadb (mysql) (0) | 2020.04.28 |
| MySQL에서 빈 문자열 찾기, NULL과 빈 값은 다르다 (0) | 2020.01.09 |
