페이지네이션이란?
전체 결과 집합을 페이지 단위로 나눠, 요청된 페이지에 해당하는 행만 조회하는것을 말한다.
가장 쉬운 페이지네이션은 OFFSET을 이용한 페이지네이션이다.
SELECT
*
FROM
table
WHERE
~
LIMIT <페이지당 보여줄 개수> OFFSET (<현재 페이지> - 1) * <페이지당 보여줄 개수>
OFFSET 키워드는 SELECT 결과에서 앞쪽 N개 행을 읽어 버리고, 그다음 행부터 반환하게 한다.
SELECT * FROM posts
ORDER BY created_at DESC
OFFSET 100000 LIMIT 20;
OFFSET 100000은 조건에 맞는 행을 처음부터 100,000건 읽어서 전부 버린 뒤, 그다음 20건을 반환한다.
즉 버려질 행도 전부 읽어야 하므로 비용이 OFFSET 값에 비례해서 커진다.
1페이지는 빠르지만 5000페이지는 느린 이유가 이것이다.
버려질 행에 대해서는 무거운 본문 데이터까지 읽지 말고, 좁은 커버링 인덱스에서 id(키)만 빠르게 얻어 스킵한다.
커버링 인덱스란?
쿼리가 필요로 하는 컬럼을 인덱스 자체가 전부 들고 있어서, 테이블 본문(힙)까지 가지 않고 인덱스만 읽어 답하는 것이 커버링 인덱스
최종적으로 살아남은 20건에 대해서만 본 테이블에 접근해 실제 데이터를 가져온다.
SELECT p.*
FROM posts p
JOIN (
SELECT id
FROM posts
ORDER BY created_at DESC
OFFSET 100000 LIMIT 20
) AS sub ON p.id = sub.id
ORDER BY p.created_at DESC;
지연 조인은 여전히 100,000개의 인덱스 엔트리를 스캔해서 버린다.
따라서 시간 복잡도는 O(N) 그대로이고, 줄어드는 것은 "버려질 행 1건당 비용(상수 인자)"뿐이다.
페이지가 충분히 깊어지면 지연 조인도 결국 느려진다.
이건 OFFSET 회피가 아니라 OFFSET 최적화다.
OFFSET을 아예 쓰지 않는다. 직전 페이지의 마지막 행의 정렬 기준값을 다음 페이지의 시작 조건으로 넘긴다.
인덱스를 타고 시작 위치로 곧장 "점프"하므로, 몇 페이지째인지와 무관하게 항상 LIMIT 건수만큼만 읽는다.
SELECT * FROM posts
WHERE id < :last_id -- 직전 페이지 마지막 행의 id
ORDER BY id DESC
LIMIT 20;
이때만 "마지막 id를 커서로 쓴다"는 설명이 정확히 성립한다.
created_at은 중복될 수 있으므로 id를 타이브레이커로 함께 넘겨야 경계에서 행이 누락/중복되지 않는다.
-- row-value 비교 (PostgreSQL이 복합 인덱스로 잘 최적화함)
SELECT * FROM posts
WHERE (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC
LIMIT 20;
(created_at, id) (또는 (created_at DESC, id DESC)).(a, b) < (c, d)는 a < c OR (a = c AND b < d)를 의미한다. 직접 OR로 풀어 써도 되지만 row-value 비교가 더 깔끔하고 인덱스 활용도 좋다.keyset의 빠른 속도는 "직전 페이지의 마지막 행"을 알아야 다음 페이지를 구할 수 있다는 구조에서 나온다. 즉 순차적으로만 이동할 수 있다. 이 특성이 UI 선택을 가른다.
COUNT(*)가 필요한데, 이는 keyset이 피하려던 비용을 다시 불러온다.정리하면, 임의 페이지 점프가 필요하면 OFFSET, 순방향 탐색만 필요하면 keyset이다. 깊은 페이지에서의 성능 때문에 무조건 keyset이 정답인 것은 아니고, UI 요구사항이 먼저다.