페이지네이션을 모르는 개발자는 없다. 처음 게시판을 만들 때 LIMIT 20 OFFSET 40 한 줄로 2페이지를 만들어본 순간부터 우리는 페이지네이션을 "아는" 개발자가 된다. 실제로 페이지네이션은 CRUD 다음으로 배우는, 어쩌면 가장 시시해 보이는 기능이다.
그런데 몇 가지 장면을 떠올려보자. 커뮤니티 앱에서 스크롤을 내리다가 방금 본 글이 또 나온 적이 있는가? 잘 돌아가던 어드민의 주문 목록이 어느 날부터 마지막 페이지로 갈수록 하염없이 느려진 적은? 새벽에 울린 슬로 쿼리 알람을 열어보니 범인이 OFFSET 4980000이었던 적은 없는가?
이 장면들은 전부 페이지네이션이 만든 문제다. 그런데 세 문제의 원인은 각각 다르다. 하나는 일관성 문제고, 하나는 데이터베이스의 물리적 동작 문제고, 하나는 크롤러와 인프라의 문제다. LIMIT과 OFFSET을 아는 것과 페이지네이션을 아는 것은 생각보다 거리가 멀다.
이 글에서는 페이지네이션이라는 주제를 바닥까지 파본다. 백엔드에서 시작해 데이터베이스 내부 동작, 분산 환경과 인프라, API 설계, 그리고 프론트엔드까지, 목록을 나눠 보여준다는 단순한 행위 뒤에 숨어 있는 문제들을 하나씩 꺼내볼 것이다. 글이 길지만 각 장은 독립적으로 읽을 수 있게 구성했으니 필요한 부분만 골라 읽어도 좋다.
페이지네이션이 정말 풀려는 문제
구현 이야기를 하기 전에 문제를 다시 정의해보자. 페이지네이션은 왜 하는가? 당연하게도 데이터가 많기 때문이다. 500만 건의 주문을 한 번에 내려줄 수는 없다. 서버는 메모리가 터지고, 네트워크는 수백 MB를 전송해야 하며, 클라이언트는 그걸 파싱하다 멈춘다. 그래서 잘라서 보내준다. 여기까지는 누구나 아는 이야기다.
하지만 "잘라서 보낸다"는 말에는 함정이 있다. 데이터가 멈춰 있다면 자르는 일은 쉽다. 문제는 데이터가 계속 변한다는 것이다. 사용자가 1페이지를 읽는 동안에도 새 주문이 들어오고, 글이 삭제되고, 랭킹이 바뀐다. 우리가 "2페이지"라고 부르는 것은 사실 존재하지 않는다. 1페이지를 요청한 시점의 2페이지와 실제로 2페이지를 요청한 시점의 2페이지는 서로 다른 데이터다.
그래서 페이지네이션을 이렇게 다시 정의해보자. 페이지네이션은 끊임없이 변하는 데이터 위에 안정적인 창(window)을 만드는 일이다. 이렇게 정의하고 나면 좋은 페이지네이션이 갖춰야 할 조건도 자연스럽게 나온다.
- 정확성: 끝까지 읽었을 때 누락도 중복도 없어야 한다.
- 성능: 1페이지든 10,000페이지든 비슷한 속도로 응답해야 한다.
- 탐색성: 원하는 위치로 건너뛸 수 있어야 한다. "전체 1,234페이지 중 817페이지"로 점프하는 것 말이다.
- 공유 가능성: 특정 페이지를 URL로 남에게 건넬 수 있고, 다시 열었을 때 같은 것을 볼 수 있어야 한다.
문제는 이 네 가지를 동시에 만족하는 방법이 존재하지 않는다는 점이다. 뒤에서 살펴보겠지만 오프셋 방식은 탐색성과 공유 가능성을 주는 대신 정확성과 성능을 포기하고, 커서 방식은 정확성과 성능을 주는 대신 탐색성을 포기한다. 페이지네이션 설계란 결국 이 트레이드오프에서 우리 제품에 맞는 지점을 고르는 일이다. 뒤에 나오는 내용은 전부 이 네 조건 사이의 줄다리기다.
오프셋과 커서
먼저 페이지네이션의 두 기본 방식과 그 변형들을 바닥까지 파보자. 여기서 다지는 개념이 이후 모든 논의의 뼈대가 된다.
오프셋: 우리가 아는 그 방식
가장 익숙한 방식부터 보자. 커머스 어드민의 주문 목록을 만든다고 하면 대부분 이런 쿼리를 떠올릴 것이다.
SELECT * FROM orders
ORDER BY created_at DESC
LIMIT 20 OFFSET 40; -- 3페이지 (페이지당 20건)직관적이고, 아무 프레임워크나 ORM에서든 한 줄이면 되고, 페이지 번호 UI와 자연스럽게 맞아떨어진다. 그런데 이 쿼리를 실행할 때 데이터베이스 안에서 무슨 일이 벌어지는지 생각해본 적 있는가?
데이터베이스는 건너뛰지 못한다
OFFSET 40이라는 구문을 보면 마치 데이터베이스가 40건을 "건너뛰고" 41번째부터 읽는 것처럼 보인다. 하지만 실제로는 그렇지 않다. 데이터베이스는 앞의 40건을 전부 읽은 다음 버린다. 배열 인덱싱처럼 원하는 위치로 점프하는 것이 아니다.
왜 점프하지 못할까? 대부분의 데이터베이스 인덱스는 B-Tree라는 자료구조로, 값을 정렬된 상태로 유지해서 "이 값이 어디 있는지"와 "이 값의 다음 항목"을 빠르게 찾도록 설계되어 있다. 그런데 "정렬 순서상 40번째 항목"이 어디 있는지는 B-Tree도 모른다.1 41번째 항목을 찾는 유일한 방법은 첫 항목부터 40개를 세면서 걸어가는 것뿐이다. 40건이면 아무 문제가 없다. 하지만 백만 번째 페이지라면 이야기가 달라진다.
실제로 어떤 일이 벌어지는지 측정해봤다. 로컬 머신(Apple Silicon, MySQL 9.7.1)에 약 520만 건의 주문 테이블을 만들고 (created_at, id) 복합 인덱스를 걸어둔 상태다. EXPLAIN ANALYZE는 데이터베이스가 쿼리를 어떤 방법과 순서로 처리했는지(실행 계획)를 실제 소요 시간과 함께 보여주는 명령이다.
EXPLAIN ANALYZE
SELECT * FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 1000000;-> Limit/Offset: 20/1000000 row(s) (actual time=5190..5190 rows=20)
-> Sort: orders.created_at DESC, orders.id DESC,
limit input to 1000020 row(s) per chunk (actual time=5088..5170 rows=1e+6)
-> Table scan on orders (actual time=0.0649..1481 rows=5.19e+6)20건을 반환하기 위해 테이블 520만 건을 전부 스캔하고 정렬까지 했다. 5.19초다. 눈에 띄는 점은 실행 방법을 결정하는 옵티마이저가 인덱스를 아예 포기했다는 것이다. 얕은 오프셋에서는 인덱스를 역순으로 걸어가지만, 오프셋이 깊어지면 인덱스 순서를 따라 테이블 여기저기의 행을 찾아다니는 비용이 차라리 전체를 읽고 정렬하는 비용보다 커진다고 판단해 풀 스캔으로 전환해버린다. 깊이별로 정리하면 이렇다.
| OFFSET | 응답 시간 | 실행 계획 |
|---|---|---|
| 0 | 0.07ms | 인덱스 역순 스캔 |
| 1,000 | 2.1ms | 인덱스 역순 스캔 |
| 10,000 | 67ms | 인덱스 역순 스캔 |
| 100,000 | 4,349ms | 풀 스캔 + 정렬로 전환 |
| 1,000,000 | 4,982ms | 풀 스캔 + 정렬 |
| 3,000,000 | 5,727ms | 풀 스캔 + 정렬 |
오프셋 방식의 비용은 페이지 깊이에 비례해서 늘어난다. 시간 복잡도로 쓰면 O(offset + limit)이다. 사용자 대부분이 1~3페이지만 본다면 문제가 드러나지 않는다. 그래서 서비스 초기에는 아무도 눈치채지 못하다가, 데이터가 쌓이고 누군가(대개는 사람이 아니라 크롤러다) 깊은 페이지를 두드리기 시작하면 그제서야 슬로 쿼리 알람이 울린다.
참고로 이것은 RDB만의 동작이 아니다. MongoDB의 skip()도, Elasticsearch의 from도 이름만 다를 뿐 앞의 것을 전부 읽고 버린다. 저장 구조가 무엇이든 "정렬 순서상 N번째"를 공짜로 찾는 방법은 없기 때문이다. 해법이 커서라는 점도 같다. MongoDB라면 정렬 필드에 _id를 타이브레이커로 붙이면 그대로 커서가 된다.
전체 페이지 수의 대가
오프셋 방식과 단짝인 UI가 있다. < 1 2 3 ... 259,715 > 같은 페이지 번호 목록이다. 이 UI를 그리려면 전체 건수가 필요하므로 목록 쿼리와 함께 COUNT 쿼리를 날리게 된다.
SELECT COUNT(*) FROM orders WHERE user_id = 42;COUNT(*)는 공짜가 아니다. 요즘 데이터베이스는 대부분 MVCC(다중 버전 동시성 제어)라는 방식을 쓰는데, 같은 테이블이라도 트랜잭션마다 보이는 행이 다를 수 있도록 행의 여러 버전을 함께 유지하는 구조다. 그래서 "행이 몇 개인가"에 대한 단일한 정답을 미리 들고 있을 수 없고, 정확한 개수를 세려면 조건에 맞는 행을 실제로 하나하나 세어야 한다.2 앞의 520만 건짜리 주문 테이블에서 조건 없는 COUNT(*)가 355ms였다. 목록 20건을 가져오는 것보다 개수를 세는 쪽이 수십 배 비싼 상황은 실무에서 흔하다. 필터가 붙으면 필터 조건에 맞는 행을 전부 세야 하니 더 비싸진다.
그래서 실무에서는 절충한다. 가장 쉬운 길은 정확한 개수를 포기하는 것이다. 통계 정보 기반의 근사치는 거의 공짜인데, 실제로 앞의 벤치마크 테이블에서 information_schema의 추정 행 수는 5,123,708건이었고 실제는 5,194,304건이었다. 오차 1.4%다. "약 512만 건"이라고 보여주는 데는 아무 문제가 없다. 구글 검색이 "약 1,020,000개의 결과"라고 말하는 것도 같은 이유다. 구글도 정확한 개수를 세지 않는다.
아예 개수를 보여주지 않는 선택도 있다. 페이지 번호 대신 "다음" 버튼이나 무한 스크롤을 쓰면 전체 개수가 필요 없다. 다음 페이지가 있는지만 알면 되는데, 이건 20건이 필요할 때 21건을 조회해서 21번째가 존재하는지 보는 것으로 충분하다(뒤에서 API 설계를 다룰 때 다시 이야기할 것이다). 그럼에도 정확한 개수가 꼭 필요한 어드민 같은 화면이라면, 목록은 즉시 반환하고 개수는 별도 요청으로 나중에 채우는 식으로 둘을 분리하는 방법이 남는다.
ORM이 조용히 해주는 일들
이 지점에서 ORM 이야기를 잠깐 해야 한다. Spring Data JPA의 findAll(pageable) 한 줄에는 방금 다룬 내용이 그대로 압축되어 있다. 반환 타입을 Page<T>로 받는 순간 목록 쿼리와 함께 COUNT 쿼리가 자동으로 나간다. 전체 페이지 수를 계산해야 하기 때문이다. COUNT가 필요 없다면 Slice<T>로 받으면 되는데, 이것이 바로 "n+1건을 조회해서 다음 페이지 유무만 확인하는" 방식이다. 이 차이를 모른 채 모든 목록에 Page를 쓰면 목록마다 COUNT 비용을 내게 된다.
더 위험한 것은 컬렉션 fetch join과 페이징을 함께 쓸 때다. 일대다 조인은 결과 행을 뻥튀기하므로 SQL의 LIMIT로는 "엔티티 20개"를 자를 수 없다. 그래서 Hibernate는 전체 결과를 메모리로 가져온 뒤 애플리케이션에서 자른다. 로그에 HHH000104: firstResult/maxResults specified with collection fetch; applying in memory 경고가 보인다면 페이지네이션이 사실상 풀 스캔으로 동작하고 있다는 뜻이다. 추상화는 편리하지만, 그 아래에서 어떤 쿼리가 나가는지만큼은 페이지네이션에서 반드시 확인해야 한다.
페이지가 밀리는 문제
성능보다 교묘한 문제가 하나 더 있다. 사용자가 1페이지를 보고 있는 동안 새 글이 하나 등록됐다고 하자. 최신순 정렬이므로 새 글은 1페이지 맨 위에 들어가고, 기존 목록은 전부 한 칸씩 밀린다. 이때 사용자가 2페이지를 누르면 어떻게 될까? 1페이지 맨 아래에 있던 글이 2페이지 맨 위로 밀려 내려와 있으므로 같은 글을 두 번 보게 된다. 반대로 1페이지의 글이 삭제되면 목록이 한 칸씩 당겨지면서 2페이지 맨 위로 올라갔어야 할 글을 영영 보지 못한다.
아래 애니메이션이 이 과정을 보여준다. 1페이지를 읽은 뒤 새 글이 등록되고, 2페이지를 읽는 순간 오프셋 화면에는 이미 본 글이 다시 나타난다. 오른쪽의 커서 방식이 왜 무사한지는 바로 다음 장에서 다룬다. "몇 번째"라는 고정 위치와 "글 10 다음"이라는 책갈피의 차이를 눈여겨보자.
오프셋은 "몇 번째부터"라는 위치를 기억하는데, 데이터가 변하면 위치와 내용의 대응이 깨져버린다. 이것이 페이지 시프트(page shift) 문제다. 게시판에서는 애교로 넘어갈 수 있지만, 무한 스크롤 피드에서는 같은 글이 계속 나타나 완성도를 의심받는 앱이 되고, 정산 데이터를 페이지 단위로 배치 처리하는 시스템이라면 데이터 누락 사고가 된다. 실제로 오프셋 기반으로 전체 데이터를 순회하며 처리하는 배치가 실행 중의 삭제 때문에 일부 행을 건너뛰는 사고는 생각보다 흔하다.
그럼에도 오프셋이 정답인 경우
여기까지 읽으면 오프셋은 쓰면 안 되는 기술처럼 보이지만, 그렇지 않다. 오프셋은 네 가지 조건 중 탐색성과 공유 가능성을 유일하게 제공하는 방식이다.
어드민 화면을 생각해보자. CS 담당자는 "주문 목록 중간쯤으로 건너뛰어서" 찾고 싶어 하고, 동료에게 "347페이지 봐주세요"라고 링크를 건넨다. 데이터 변동도 적고, 깊은 페이지를 볼 일도 드물고, 봐도 사내 사용자 몇 명이다. 이런 화면에 커서 기반 무한 스크롤을 도입하면 오히려 사용성이 나빠진다. 마찬가지로 데이터가 수천 건 규모라면 오프셋의 성능 문제는 영원히 발생하지 않는다. 존재하지 않는 문제를 미리 풀 필요는 없다.
정리하면 오프셋이 맞는 상황은 이렇다. 데이터 규모가 작거나 깊은 페이지 접근이 드물고, 임의 페이지 점프가 실제 요구사항이며, 목록이 실시간으로 변하지 않는 경우. 어드민, 사내 도구, 정적인 콘텐츠 목록이 여기에 해당한다. 반대로 데이터가 계속 쌓이고, 사용자가 끝없이 스크롤하며, 목록이 실시간으로 변하는 화면이라면 다음 절의 커서 방식이 필요하다.
커서: 위치가 아니라 지점을 기억하기
오프셋의 두 문제(깊이 비례 비용, 페이지 시프트)의 근본 원인은 같다. "몇 번째"라는 위치로 다음 페이지를 정의하기 때문이다. 그렇다면 발상을 바꿔서, 위치가 아니라 "어디까지 읽었는지"라는 지점을 기억하면 어떨까?
책갈피를 생각하면 된다. "3장 47페이지까지 읽었다"가 아니라 책갈피를 꽂아두는 것이다. 앞쪽 페이지가 찢겨나가도 책갈피가 가리키는 지점은 변하지 않는다. 이것이 커서(cursor)3 기반 페이지네이션, 다른 말로 키셋(keyset) 페이지네이션이다.4
-- 첫 페이지: 그냥 최신 20건
SELECT * FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- 다음 페이지: 마지막으로 본 주문보다 "오래된" 20건
SELECT * FROM orders
WHERE created_at < '2025-06-03 17:43:18' -- 마지막 행의 created_at
ORDER BY created_at DESC, id DESC
LIMIT 20;WHERE created_at < ? 조건은 인덱스 범위 조건이므로, 데이터베이스는 B-Tree에서 해당 지점을 바로 찾아 거기서부터 20건만 읽는다. 앞에 100만 건이 있든 1,000만 건이 있든 상관없다. 엄밀히는 트리 탐색을 포함해 O(log N + limit)이지만, 로그 항은 페이지 깊이가 아니라 전체 크기에만 의존하므로 어느 페이지든 같은 비용이라는 점이 핵심이다. 페이지 시프트도 사라진다. 새 글이 위에 추가되든 말든 "이 시각보다 오래된 글"이라는 조건은 영향을 받지 않기 때문이다.
타이브레이커: 커서의 첫 번째 함정
그런데 위 쿼리에는 버그가 있다. created_at이 같은 주문이 두 건 이상 있다면 어떻게 될까? 마지막으로 본 행의 created_at이 10:00:00인데 같은 시각의 주문이 세 건이라면, created_at < '10:00:00' 조건은 나머지 두 건을 건너뛴다. 중복을 없애려다 누락을 만든 것이다.
타임스탬프는 유일할 것 같지만 실무에서는 전혀 그렇지 않다. 대량 등록 배치, 이벤트 오픈 순간의 동시 주문, 초 단위로 잘리는 컬럼 정밀도 등 같은 값은 얼마든지 생긴다. 그래서 커서에는 반드시 유일성을 보장하는 타이브레이커(tie-breaker) 컬럼이 필요하다. 보통 PK를 쓴다.
-- 표준 SQL의 행 값 비교(row value comparison)
SELECT * FROM orders
WHERE (created_at, id) < ('2025-06-03 17:43:18', 1860448)
ORDER BY created_at DESC, id DESC
LIMIT 20;(created_at, id)라는 복합 값으로 비교하면 정렬 순서상의 지점이 유일하게 결정된다. 정렬 기준(ORDER BY created_at DESC, id DESC)과 커서 조건, 그리고 인덱스 (created_at, id)가 정확히 같은 컬럼 조합이어야 한다는 점이 핵심이다. 셋 중 하나라도 어긋나면 누락이 생기거나 인덱스를 타지 못한다.
MySQL의 튜플 비교 함정
그런데 여기서 실측을 하다가 재미있는 것을 발견했다. 위의 행 값 비교 쿼리를 MySQL에서 그대로 실행하면 어떻게 될까? 이론대로라면 깊이와 무관하게 1ms 미만이어야 한다.
-> Limit: 20 row(s) (actual time=7231..7231 rows=20)
-> Filter: ((orders.created_at, orders.id) < ('2025-06-03 17:43:18', 1860448))
-> Index scan on orders using idx_orders_created_at_id (reverse)
(actual time=0.133..7187 rows=1e+6)7.2초가 걸렸다. 실행 계획을 보면 인덱스 범위 스캔이 아니라 인덱스를 처음부터 역순으로 걸으며 조건을 필터로 검사하고 있다. 즉, 커서 지점 앞의 100만 건을 전부 읽고 버렸다. 오프셋과 완전히 같은 동작이다. 심지어 300만 건 깊이에서는 21.6초로, 풀 스캔으로 전환하는 오프셋(5.7초)보다도 느렸다.
이유는 MySQL 옵티마이저가 행 값 비교를 인덱스 범위 조건으로 변환하지 못하기 때문이다.5 반면 PostgreSQL은 같은 문법을 완벽하게 범위 스캔으로 최적화한다. MySQL에서는 조건을 손으로 풀어써야 한다.
-- MySQL에서는 이렇게 풀어써야 인덱스 범위 스캔을 탄다
SELECT * FROM orders
WHERE created_at < '2025-06-03 17:43:18'
OR (created_at = '2025-06-03 17:43:18' AND id < 1860448)
ORDER BY created_at DESC, id DESC
LIMIT 20;-> Limit: 20 row(s) (actual time=0.0131..0.178 rows=20)
-> Index range scan on orders using idx_orders_created_at_id
over (created_at < '2025-06-03 17:43:18') OR
(created_at = '2025-06-03 17:43:18' AND id < 1860448) (reverse)
(actual time=0.013..0.177 rows=20)같은 지점, 같은 결과인데 7,231ms가 0.18ms가 됐다. 4만 배 차이다. "커서 페이지네이션을 도입했는데 느려요"라는 질문의 상당수가 이 함정에 빠져 있다. 문법이 돌아간다는 것과 인덱스를 탄다는 것은 완전히 다른 문제다. 페이지네이션 쿼리는 반드시 실행 계획을 열어 range 스캔인지 확인해야 한다.
전체 실측 결과를 나란히 놓으면 이렇다. 같은 지점의 20건을 가져오는 데 걸린 시간이다.
| 깊이 | OFFSET | 커서 (튜플 비교) | 커서 (전개형) |
|---|---|---|---|
| 1,000 | 2.1ms | 1.8ms | 0.18ms |
| 10,000 | 67ms | 68ms | 0.19ms |
| 100,000 | 4,349ms | 714ms | 0.18ms |
| 1,000,000 | 4,982ms | 7,175ms | 0.15ms |
| 3,000,000 | 5,727ms | 21,602ms | 0.18ms |
올바르게 작성한 커서 쿼리만이 깊이와 무관한 평평한 그래프를 그린다. 두 방식이 인덱스를 걷는 모습을 비교하면 이 차이가 한눈에 보인다.
커서 토큰 설계
커서 방식을 API로 내보낼 때 ?after_created_at=2025-06-03T17:43:18&after_id=1860448처럼 내부 컬럼을 그대로 노출하는 경우가 있는데, 좋지 않은 습관이다. 클라이언트가 커서의 구조에 의존하게 되면 정렬 기준을 바꾸거나 컬럼을 바꿀 때 API가 깨진다. 커서는 불투명(opaque) 토큰으로 만들어야 한다.
// 서버 내부에서만 아는 구조
{ "k": ["2025-06-03T17:43:18.000000", 1860448], "o": "created_at:desc", "v": 1 }
// 클라이언트에게는 그저 문자열
"eyJrIjpbIjIwMjUtMDYtMDNUMTc6NDM6MTguMDAwMDAwIiwxODYwNDQ4XSwibyI6ImNyZWF0ZWRfYXQ6ZGVzYyIsInYiOjF9"내부 구조를 JSON으로 만들고 Base64로 감싸는 것이 가장 흔한 형태다. 여기에 몇 가지를 더 고려하면 좋다.
- 버전을 넣어두면 커서 구조를 바꿀 때 구버전 커서를 구분해서 처리(거부 또는 마이그레이션)할 수 있다. 커서는 클라이언트 어딘가에 저장되어 며칠 뒤에 돌아오기도 한다.
- 정렬 기준이나 필터의 지문을 넣어두면 "최신순으로 받은 커서를 가격순 목록에 던지는" 오용을 감지할 수 있다.
- Base64는 암호화가 아니다. 사용자가 디코딩해서 내용을 보고 조작할 수 있다. 커서 값이 권한과 관련된다면(예: 다른 사용자의 ID가 들어있다면) 비밀 키로 만든 서명(HMAC)을 붙여 변조를 막아야 한다. 이 이야기는 API 설계 장에서 다시 다룬다.
커서가 포기하는 것들
커서는 은탄환이 아니다. 정의상 포기하는 것들이 있다.
임의 페이지 점프가 불가능하다. 커서는 "지금 지점의 다음"만 알 수 있으므로 817페이지로 건너뛸 방법이 없다. 페이지 번호 UI 자체를 만들 수 없다. 이것은 구현 미숙이 아니라 방식의 본질이다. 점프가 진짜 요구사항이라면 커서를 쓰면 안 된다.
전체 개수와 궁합이 나쁘다. 커서 방식은 대개 무한 스크롤과 함께 쓰이는데, "전체 3,847건 중 몇 번째"라는 정보 자체가 UI에 없으므로 자연스럽게 COUNT도 사라진다. 만약 개수가 필요하다면 근사치나 비동기 조회를 조합한다.
그 밖에도 자잘한 함정들이 있다. 이전 페이지로 가려면 > 조건에 정렬을 뒤집어 조회한 뒤 결과를 다시 뒤집어야 해서 방향마다 커서 관리 코드가 늘어나고(Relay 스펙이 after/before, first/last를 모두 정의하는 이유다), 정렬 값이 NULL이면 < 비교 자체가 성립하지 않아 커서 조건에서 빠져버린다. NULL이 가능한 컬럼으로 정렬해야 한다면 COALESCE로 대체값을 정해 인덱스에 반영하거나, 애초에 NOT NULL 컬럼으로 정렬하는 편이 훨씬 단순하다.
가장 교묘한 함정은 정렬 값이 변하는 컬럼이다. updated_at처럼 값이 바뀌는 컬럼으로 정렬하면, 순회 도중 수정된 행이 정렬 순서상 커서 앞뒤로 순간이동한다. 이미 읽은 구간으로 이동하면 다시 나타나고(중복), 아직 읽지 않은 구간에서 읽은 구간으로 이동하면 사라진다(누락). 커서 로직 자체는 결백하다. 흔들리는 값을 좌표로 삼은 것이 문제다. "최근 수정된 순" 목록이 정말 필요하다면 수정 사건 자체를 불변 레코드로 쌓아 그것을 페이지네이션하는 편이 안전하다. 비슷한 결의 함정이 두 가지 더 있다. 정렬 방향이 섞이면(ORDER BY price ASC, created_at DESC) 행 값 비교가 성립하지 않아 조건을 손으로 풀어쓰고 인덱스도 방향을 맞춰 만들어야 하고, 문자열 정렬은 콜레이션(문자열 비교 규칙)에 따라 순서가 달라지므로 애플리케이션이 만든 커서와 데이터베이스의 비교 결과가 어긋나지 않는지 확인해야 한다.
두 방식을 정리하면 이렇다.
| 오프셋 | 커서 | |
|---|---|---|
| 깊은 페이지 성능 | 깊이에 비례 | 깊이와 무관 |
| 페이지 시프트 | 발생 | 없음 (지점 기준) |
| 임의 페이지 점프 | 가능 | 불가능 |
| 전체 페이지 수 표시 | 자연스러움 | 부자연스러움 |
| 구현 난이도 | 낮음 | 타이브레이커, 토큰 설계 필요 |
| 어울리는 UI | 페이지 번호 | 무한 스크롤, 더보기 |
오프셋도 커서도 아닌 선택지들
두 방식이 전부는 아니다. 상황에 따라 유용한 변형들이 있다.
Deferred Join: 오프셋을 버릴 수 없을 때
페이지 번호 UI를 유지해야 해서 오프셋을 버릴 수 없다면, 오프셋의 비용을 줄이는 기법이 있다. 오프셋이 느린 이유를 쪼개보면 "앞의 N건을 읽는 비용"과 "읽은 행의 전체 데이터를 가져오는 비용"이 섞여 있다. 그렇다면 무거운 전체 행이 아니라 가벼운 PK만으로 오프셋을 통과한 다음, 최종 20건만 전체 행을 조회하면 어떨까?
SELECT o.* FROM orders o
JOIN (
SELECT id FROM orders
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 1000000 -- 인덱스만으로 처리되는 서브쿼리
) t ON o.id = t.id
ORDER BY o.created_at DESC, o.id DESC;서브쿼리가 필요로 하는 id는 이미 (created_at, id) 인덱스 안에 들어 있으므로, 테이블에 갈 필요 없이 인덱스만 읽어서 처리된다. 이렇게 쿼리에 필요한 컬럼이 전부 인덱스에 있는 상태를 커버링 인덱스라 부른다. 덕분에 서브쿼리는 가볍게 인덱스만 걸어가고, 바깥 쿼리는 최종 20건만 PK로 조회한다. 실측으로 OFFSET 1,000,000에서 4,982ms가 184ms가 됐다. 27배 개선이다. 물론 여전히 O(offset)이라 근본 해결은 아니다. 깊이가 계속 깊어지면 결국 느려진다. 오프셋 UI를 유지하면서 시간을 버는 완화책으로 이해하면 된다.
시간 창(time-window) 방식
로그나 이벤트처럼 시간이 1급 축인 데이터는 "다음 20건"이 아니라 "다음 1개월"처럼 시간 범위로 자르는 것이 자연스러울 때가 있다. 은행 앱에서 거래내역을 월 단위로 넘겨 보는 조회, 커머스 주문 내역의 "최근 3개월 / 2025년" 필터가 정확히 이 방식이다. 페이지 크기가 균일하지 않다는 단점이 있지만(어떤 시간대는 0건, 어떤 시간대는 10만 건), 커서 관리가 필요 없고 병렬 조회가 쉽다는 장점이 있다.
ID 범위 분할: 배치와 마이그레이션의 정석
전체 테이블을 순회하는 배치 작업에서 LIMIT 1000 OFFSET n을 쓰는 것은 최악의 선택이다. 순회가 깊어질수록 느려지고, 순회 도중의 삽입/삭제로 누락이 생긴다. 이때는 PK 범위로 자르는 것이 정석이다.
SELECT * FROM orders WHERE id > :last_id ORDER BY id LIMIT 1000;
-- 마지막 행의 id를 :last_id로 갱신하며 반복사실상 PK 단일 컬럼 커서다. 추가 인덱스도 필요 없고, 처리 도중 실패해도 마지막 last_id부터 재개할 수 있다. 대량 데이터 마이그레이션, 전체 재색인, CSV 익스포트는 전부 이 패턴을 써야 한다.
하이브리드: 얕은 곳은 오프셋, 깊은 곳은 차단
실무에서 의외로 많이 쓰이는 전략은 깊은 페이지를 그냥 막는 것이다. 구글 검색은 아무리 결과가 많아도 수십 페이지까지만 보여준다. 그 뒤는 "검색어를 구체화하세요"다. 사용자 행동 데이터를 보면 대부분의 서비스에서 10페이지 이상 넘어가는 사용자는 사실상 없다. 있다면 크롤러다. 얕은 구간은 오프셋으로 페이지 번호 UI를 제공하고, 특정 깊이 이상은 400을 반환하거나 검색 유도로 대체하는 것은 부끄러운 타협이 아니라 합리적인 설계다. Elasticsearch가 기본 설정으로 10,000건 이상의 오프셋 접근을 거부하는 것도 같은 철학인데, 이 이야기는 뒤에서 자세히 다룬다.
현실은 더 복잡하다
기본기를 갖췄으니 이제 현실을 초대할 차례다. 데이터는 읽는 동안에도 변하고, 목록에는 필터와 랭킹이 붙고, 시스템은 여러 대의 서버로 흩어진다. 교과서의 페이지네이션이 실무에서 만나는 문제들을 살펴보자.
읽는 동안 데이터는 변한다: 일관성 문제
커서로 페이지 시프트를 해결했다고 일관성 문제가 끝난 것은 아니다. 조금 더 깊이 들어가보자.
커서가 보장하는 것은 정확히 말하면 "이미 읽은 지점보다 앞의 변동에 영향받지 않는다"는 것뿐이다. 아직 읽지 않은 뒤쪽 데이터는 여전히 변할 수 있다. 3페이지를 읽는 시점에 7페이지 영역의 글이 삭제되면 그 글은 결과에서 빠지고, 수정되면 수정된 내용이 나온다. 즉, 커서 페이지네이션의 결과는 어느 한 시점의 스냅샷이 아니라 여러 시점의 조각이 이어붙은 모자이크다.
대부분의 서비스에서 이것은 문제가 아니다. 피드를 읽는 사용자는 각 글이 "그 글을 읽은 시점"의 내용이면 충분하다. 하지만 문제가 되는 경우가 있다. 회계 마감 리포트를 페이지 단위로 내려받는 동안 데이터가 바뀐다면? 1페이지의 합계와 30페이지의 합계가 서로 다른 시점을 반영한다면? 이런 경우 전체 순회가 하나의 일관된 스냅샷 위에서 이루어져야 한다.
트랜잭션으로 해결되지 않을까?
MVCC를 지원하는 데이터베이스에서 트랜잭션 내내 같은 스냅샷을 보도록 보장하는 REPEATABLE READ 격리 수준으로 트랜잭션을 열면, 그 안에서는 일관된 데이터를 읽을 수 있다. 문제는 페이지네이션이 여러 개의 독립적인 HTTP 요청이라는 점이다. 1페이지 요청과 2페이지 요청은 다른 트랜잭션이고, 어쩌면 다른 서버로 라우팅된다. 요청 사이에 트랜잭션을 열어둔 채 사용자가 다음 페이지를 누를 때까지 기다리는 것은 커넥션을 인질로 잡는 것과 같아서 현실적이지 않다. 결국 요청과 요청 사이에서 스냅샷을 유지하려면 데이터베이스 트랜잭션 바깥의 다른 장치가 필요하다.
PIT: 스냅샷에 이름을 붙이기
이 문제에 대한 검색엔진 진영의 답이 PIT(Point in Time)다. Elasticsearch에서는 검색을 시작하기 전에 PIT를 열 수 있는데, 이것은 "지금 이 순간의 인덱스 상태"에 대한 핸들이다.
POST /orders/_pit?keep_alive=5m
→ { "id": "46ToAwMDaWR5BXV1aWQy..." }
// 이후 모든 페이지 요청에 이 PIT id를 함께 보낸다
GET /_search
{
"pit": { "id": "46ToAwMDaWR5BXV1aWQy...", "keep_alive": "5m" },
"sort": [{ "created_at": "desc" }, { "_shard_doc": "asc" }],
"search_after": [1748965398000, 1860448]
}PIT를 연 뒤의 색인/삭제는 이 검색에 보이지 않는다. 여러 요청에 걸친 순회 전체가 하나의 스냅샷 위에서 이루어지는 것이다. 내부적으로는 그 시점의 세그먼트(불변 파일들)를 참조로 붙잡아 머지 후에도 지우지 않는 방식이라, 열어둔 만큼 디스크와 힙에 비용을 지불한다. keep_alive로 수명을 짧게 잡는 이유다.
RDB에서 비슷한 것이 필요하다면 선택지는 결과 집합의 구체화(materialization)다. 조건에 맞는 PK 목록을 시작 시점에 뽑아 임시 저장소에 박아두고, 페이지 요청마다 그 목록에서 잘라 조회하는 것이다. 검색 시작 시점의 결과가 고정되므로 스냅샷 순회가 되고, 대가로 저장 비용과 신선도를 지불한다. "검색 결과가 500건을 넘으면 다운로드 요청으로 전환하고 비동기로 파일을 만들어주는" 어드민 패턴도 본질적으로 같은 해법이다.
여기서도 트레이드오프의 축은 같다. 스냅샷의 일관성과 데이터의 신선함은 동시에 가질 수 없다. 피드는 신선함을, 리포트는 일관성을 고르는 것뿐이다.
프론트엔드로 넘기는 해법
반대로 일관성 문제를 백엔드에서 풀지 않고 UX로 흡수하는 방법도 있다. 트위터(X)를 떠올려보자. 타임라인을 읽는 동안 새 트윗이 도착해도 목록이 갑자기 밀리지 않는다. 대신 상단에 "새 게시물 N개" 버튼이 뜨고, 사용자가 누를 때만 반영된다. 읽고 있는 목록은 고정하고, 변경은 사용자의 명시적 행동으로만 반영한다는 원칙이다. 새 데이터의 존재는 알리되 강제로 끼워넣지 않는 이 패턴은 별도의 스냅샷 장치 없이도 "읽는 동안 안 변하는" 경험을 만든다. 일관성 문제를 데이터 계층이 아니라 상호작용 설계로 푸는 좋은 예다.
필터와 정렬이 끼어들면
여기까지의 논의는 정렬 기준이 하나로 고정된 목록을 가정했다. 하지만 실무의 목록에는 필터와 정렬 옵션이 붙는다. "배송 완료 상태 + 최근 한 달 + 금액 높은 순" 같은 조합 말이다. 필터가 등장하는 순간 페이지네이션의 난이도는 한 단계 올라간다.
인덱스 조합 폭발
커서 페이지네이션이 빠른 이유는 (정렬 컬럼, 타이브레이커) 인덱스의 범위 스캔이기 때문이었다. 그런데 필터가 붙으면 인덱스는 (필터 컬럼들, 정렬 컬럼, 타이브레이커) 형태가 되어야 한다.6
-- "배송 완료 + 최신순" 화면을 위한 전용 인덱스: 등호 조건 → 정렬 컬럼 → 타이브레이커 순
CREATE INDEX idx_orders_status_created ON orders (status, created_at, id);문제는 필터가 조합형이라는 것이다. 상태로도 거르고, 기간으로도 거르고, 사용자로도 거르고, 각각을 켜고 끌 수 있다면? 정렬 옵션이 3개라면? 모든 조합에 최적 인덱스를 만들면 인덱스 수가 곱셈으로 늘어난다. 쓰기 성능과 저장 공간이 인덱스 수에 비례해 나빠지므로 어딘가에서 타협해야 한다.
현실적인 전략은 이렇다. 접근 로그를 보고 실제로 자주 쓰이는 조합 상위 몇 개에만 전용 인덱스를 만들고, 나머지 조합은 행을 가장 잘게 걸러내는(선택도가 높은) 컬럼의 인덱스를 태운 뒤 남은 조건은 필터로 처리되는 것을 받아들인다. 그리고 조합이 정말 자유로워야 하는 화면은 애초에 RDB가 감당할 문제가 아니다.
검색엔진이 필요해지는 순간
어드민의 만능 검색을 생각해보자. 상태·기간·금액을 아무렇게나 조합하고, 상품명 일부로도 찾을 수 있어야 한다. 조합마다 인덱스를 만들 수는 없고 LIKE '%검색어%'는 인덱스를 타지 못하니, RDB 위에서 이 요구를 버티는 것은 밑 빠진 독에 물 붓기다. 이럴 때 등장하는 것이 Elasticsearch 같은 검색엔진이다. 검색엔진은 문서를 역인덱스(inverted index), 즉 "단어 → 그 단어가 등장하는 문서 목록" 구조로 저장하고 필드별 인덱스를 실행 시점에 교집합하기 때문에 임의 조합 필터와 전문 검색에 강하다. 그래서 실무에서는 원본은 RDB에 두고 검색용 사본을 검색엔진에 색인하는 이원화가 흔하다. "목록 조회는 RDB, 조건 검색은 검색엔진"이라는 조합은 이 지점에서 나온다.
다만 검색엔진으로 넘어가면 페이지네이션의 물리학도 함께 바뀐다. Elasticsearch에서 from: 990, size: 10으로 100번째 페이지를 요청하면 RDB의 오프셋보다 훨씬 나쁜 일이 벌어진다. 인덱스가 샤드(shard)라는 조각으로 나뉘어 여러 노드에 분산 저장되어 있어서, 관련도순 1,000번째 문서가 어느 샤드에 있는지 아무도 모르기 때문이다. 그래서 이 요청을 받으면 각 샤드가 자기 몫의 상위 1,000건(from + size)을 뽑아 코디네이터 노드로 보내고, 코디네이터는 샤드 수 × 1,000건을 병합한 뒤 990건을 버리고 10건을 반환한다. 샤드가 10개라면 10건을 보여주기 위해 10,000건이 네트워크를 건넌다. 오프셋 비용이 샤드 수만큼 증폭되는 것이다.
그래서 Elasticsearch는 아예 기본 설정(index.max_result_window = 10000)으로 from + size가 10,000을 넘는 요청을 거부한다. 처음 만나면 당황스러운 에러지만, 이것은 제한이 아니라 보호 장치다. "그 너머는 다른 방식을 쓰라"는 뜻이고, 그 다른 방식이 정렬 값을 커서로 쓰는 search_after와 앞서 본 PIT다. 각 샤드가 "이 지점 이후의 상위 10건"만 반환하면 되므로 깊이와 무관해진다. 과거에는 전체 순회용으로 scroll API를 썼지만, 스냅샷 유지 비용이 검색 컨텍스트에 묶여 있어 무겁고 공유도 안 됐기 때문에 지금은 PIT + search_after가 공식 권장이다.
커서와 필터의 결합: 쿼리 지문
필터가 있는 목록에 커서를 쓸 때 놓치기 쉬운 문제가 있다. 커서는 특정 쿼리의 정렬 순서 위에서만 의미가 있다. "배송 완료" 필터로 받은 커서를 "결제 대기" 필터 요청에 넣으면? 정렬 기준을 최신순에서 금액순으로 바꾸면서 이전 커서를 재사용하면? 서버는 에러 없이 조용히 이상한 결과를 반환한다. 커서의 값은 멀쩡한 비교값이기 때문이다.
그래서 앞서 커서 토큰 설계에서 말한 쿼리 지문이 필요하다. 커서를 만들 때 필터 + 정렬 조건의 해시를 함께 넣고, 커서를 받을 때 현재 요청의 조건과 대조해서 다르면 400을 반환한다.
fun encodeCursor(lastRow: OrderRow, query: ListQuery): String {
// 정렬·필터 조건을 정규화해서 지문을 굽는다
val fingerprint = sha256("${query.sort}|${query.normalizedFilters()}").take(8)
return base64Encode(json(
"k" to listOf(lastRow.createdAt, lastRow.id),
"f" to fingerprint,
"v" to 1,
))
}
fun decodeCursor(token: String, query: ListQuery): Cursor {
val cursor = parseCursor(token)
val expected = sha256("${query.sort}|${query.normalizedFilters()}").take(8)
require(cursor.fingerprint == expected) {
"커서가 발급된 쿼리와 현재 요청의 조건이 다릅니다"
}
return cursor
}"커서는 그 커서를 발급한 쿼리에서만 유효하다"는 계약을 코드로 강제하는 것이다. 이 검증이 없는 목록 API는 클라이언트의 사소한 버그(필터 변경 시 커서 초기화 누락)를 서버가 이상한 데이터로 응답하는 미스터리로 바꿔놓는다. 실제로 이런 버그는 재현이 어려워 며칠을 잡아먹는다.
개인화 피드: 페이지네이션이 가장 어려워지는 곳
지금까지의 목록에는 공통점이 있다. 정렬 순서가 모두에게 같고, 시간이 지나도 안정적이라는 것이다. 최신순, 가격순, 이름순은 누가 언제 조회해도 순서가 크게 변하지 않는다. 그런데 추천 피드는 다르다. 정렬 기준이 "당신이 지금 좋아할 확률"이다. 사람마다 다르고, 방금 무엇을 눌렀는지에 따라 초 단위로 변한다. 여기서 근본적인 질문이 나온다. 순서 자체가 흔들리는데 "다음 페이지"란 무엇인가?
score < 0.87 같은 커서는 성립하지 않는다. 다음 요청 때 모델이 모든 글의 점수를 다시 매겼을 것이기 때문이다. 이 문제를 실무에서 푸는 방식은 크게 두 가지다.
하나는 세션 스냅샷 방식이다. 첫 요청에서 랭킹 결과(글 ID 목록)를 한 번 뽑아 세션 저장소(주로 Redis)에 박아두고, 이후 페이지 요청은 그 목록을 잘라서 서빙한다.
ZADD feed:sess:8f3a 0.97 post:1024 0.95 post:887 0.93 post:1201 ... # 랭킹 스냅샷 저장
ZRANGE feed:sess:8f3a 40 59 REV # "3페이지" = 41~60번째커서는 "그 목록의 몇 번째까지 읽었나"가 된다. 역설적이게도 이것은 오프셋이다. 다만 변하는 원본이 아니라 고정된 스냅샷 위의 오프셋이라 페이지 시프트가 없다. 사실상 검색엔진의 PIT를 애플리케이션 레벨에서 직접 구현한 것이다. 스냅샷의 수명(예: 30분)이 지나면 새 랭킹으로 갱신한다.
다른 하나는 재개 토큰 방식이다. 커서에 비교값이 아니라 랭킹을 재현하는 데 필요한 상태를 담는다.
{
"reqId": "rank-8f3a",
"served": [1024, 887, 1201],
"candidateOffset": 120,
"v": 2
}랭킹 요청 ID, 이미 반환한 글 ID 목록(또는 그것을 아주 작은 공간으로 요약해 포함 여부만 검사하는 블룸 필터), 후보 생성기의 진행 위치 같은 것들이다. 다음 요청에서 랭킹을 다시 돌리되 이미 보낸 글을 제외하고 상위 N건을 반환한다. 신선함을 유지하면서 중복만 막는 접근으로, 커서라기보다 "이어하기 토큰"에 가깝다. 대형 추천 피드의 커서를 디코딩해보면 대개 이런 상태 덩어리가 들어 있다.
두 방식 모두에서 중요한 것이 중복 제거다. 랭킹은 언제든 같은 글을 다시 상위로 올릴 수 있으므로, "이 세션에서 이미 내려보낸 글" 목록을 어딘가에 유지하고 후보에서 빼야 한다. 무한 스크롤에서 같은 글이 두 번 보이는 것은 사용자가 가장 빠르게 알아채는 품질 문제이기 때문에, 대부분의 피드 시스템은 서버의 dedup에 더해 클라이언트에서도 ID 기준 방어를 한 번 더 한다.
타임라인 캐시: 팔로우 피드의 고전적 해법
추천이 아니라 팔로우 기반 피드(내가 팔로우한 사람들의 글 최신순)라면 순서는 안정적이지만 다른 문제가 있다. "팔로우한 300명의 글을 시간순 병합"하는 쿼리는 매 요청마다 돌리기엔 비싸다. 그래서 트위터로 유명해진 fan-out 아키텍처가 나왔다.7
- Fan-out on write: 글이 작성되는 순간 팔로워 각각의 타임라인 캐시(Redis Sorted Set, score = 작성 시각)에 글 ID를 밀어 넣는다. 읽기는 자기 캐시의 범위 조회 한 방이라 페이지네이션이 공짜에 가깝다. 대가는 쓰기 증폭이다. 팔로워 1,000만 명의 유명인이 글을 쓰면 1,000만 번의 캐시 쓰기가 발생한다.
- Fan-out on read: 읽는 시점에 팔로우 목록의 글을 모아 병합한다. 쓰기는 공짜지만 읽기가 비싸다.
타임라인 캐시의 저장소로 RDB가 아니라 Redis Sorted Set이 표준 재료인 이유도 분명하다. 인메모리라서 "매 요청 범위 조회"를 마이크로초 단위로 처리하고, ZRANGE timeline (1748965398 -inf BYSCORE REV LIMIT 0 20처럼 "마지막으로 본 글의 시각 미만에서 최신순 20건"이라는 커서 조회가 명령 하나로 끝난다. 여는 괄호 (는 해당 값을 제외한다는 표기로, 커서의 "이 지점 다음부터"와 정확히 대응한다.
실무 시스템은 대부분 혼합형이다. 보통 계정은 write 방식으로, 팔로워가 많은 계정은 read 방식으로 처리하고 읽기 시점에 병합한다. 페이지네이션 관점에서 보면 fan-out on write는 결국 **"사용자마다 미리 정렬된 목록을 만들어두는 것"**이다. 페이지네이션이 어려운 이유는 요청 시점에 정렬하기 때문이고, 미리 정렬해두면 쉬워진다. 비정규화로 읽기 문제를 쓰기 시점으로 옮기는, 데이터 시스템의 오래된 지혜가 여기서도 반복된다.
분산 환경과 인프라: 페이지는 서버 바깥에서도 깨진다
단일 데이터베이스를 벗어나면 페이지네이션을 위협하는 것들이 늘어난다. 쿼리는 완벽한데 아키텍처가 페이지를 깨뜨리는 경우들이다.
샤딩: 오프셋 비용이 샤드 수만큼 증폭된다
데이터가 커져 테이블을 여러 샤드로 나눴다고 하자. "전체 최신순 3페이지"를 만들려면 어떻게 해야 할까? 각 샤드의 3페이지를 모아서는 답이 안 나온다. 전역 순서상 41~60번째가 어느 샤드에 몇 건씩 있는지 모르기 때문이다. 정답은 각 샤드에서 상위 60건씩 가져와 병합 정렬 후 40건을 버리는 것이다. 어디서 본 구조 아닌가? Elasticsearch의 분산 검색이 정확히 이것이었다. 샤드 4개면 오프셋 비용이 4배가 된다. 깊은 페이지로 갈수록 코디네이터가 병합해야 할 양이 샤드 수에 비례해 커진다.
커서 방식은 이 증폭을 잘라낸다. 커서 지점 이후의 상위 20건만 각 샤드에서 가져오면 되므로 샤드당 20건, 병합도 20 × 샤드 수로 상수다. 샤딩 환경에서 커서가 선택이 아니라 필수에 가까워지는 이유다.
처음부터 분산으로 태어난 저장소들을 보면 이 결론은 더 선명해진다. DynamoDB는 Query 응답에 담긴 LastEvaluatedKey를 다음 요청의 ExclusiveStartKey로 넘기는 방식이고, Cassandra도 불투명한 paging state 토큰을 주고받는다. 오프셋은 API에 아예 존재하지 않는다. 분산 해시 저장소에서 "전역 정렬 순서상 N번째"라는 질문은 모든 노드를 뒤져야 답할 수 있어 성립하지 않고, "이 파티션에서 이 키 다음"만이 성립하기 때문이다. RDB에서 우리가 커서로 "이주"하는 것과 달리 이쪽 세계는 커서가 모국어인 셈이다.
리플리카 지연: 1페이지와 2페이지가 다른 세계를 본다
읽기 부하 분산을 위해 리드 리플리카를 쓰는 구조에서는 미묘한 사고가 생긴다. 1페이지 요청은 리플리카 A로, 2페이지 요청은 복제가 0.5초 늦은 리플리카 B로 라우팅되면, 두 페이지가 서로 다른 시점의 데이터베이스를 보게 된다. 오프셋이라면 두 리플리카가 보는 목록 길이가 달라 페이지 경계가 무작위로 밀리고, 커서라도 방금 삭제된 행이 지연된 리플리카에서 되살아나는 식으로 경계가 어긋날 수 있다. 방금 글을 쓴 사용자가 "내 글이 목록에 없다"며 새로고침을 연타하는 read-your-writes 문제도 같은 뿌리다. 글은 프라이머리에 쓰였는데 목록은 아직 복제가 안 된 리플리카에서 읽은 것이다.
이 문제는 쿼리를 아무리 고쳐도 해결되지 않는다. 세션 단위로 같은 리플리카에 고정(session affinity)하거나, 복제 지연 이내의 재요청은 같은 노드로 보내거나, 커서에 "이 시점 이후 복제본만 허용" 정보(GTID나 LSN 같은, 복제가 어디까지 진행됐는지를 나타내는 값)를 실어 리플리카가 따라잡을 때까지 대기시키는 인프라 수준의 해법이 필요하다. 페이지네이션 버그처럼 보이는 문제의 원인이 로드밸런서인 경우가 실제로 있다.
삭제는 단번에 사라지지 않는다
지연 이야기가 나온 김에 삭제를 다시 보자. 우리는 DELETE가 커밋되는 순간 데이터가 사라진다고 생각하지만, 시스템 곳곳에서 삭제는 한동안 "표식"으로만 존재한다.
저장소 내부부터 그렇다. Cassandra나 RocksDB 같은 LSM 트리 기반 저장소는 삭제를 즉시 수행하지 않고 툼스톤(tombstone)이라는 삭제 표식을 기록한다.8 실제 데이터는 나중에 조각난 파일들을 병합하고 정리하는 백그라운드 작업인 컴팩션(compaction) 때 지워진다. 문제는 범위를 읽는 페이지네이션 쿼리가 살아있는 행을 찾기 위해 툼스톤도 전부 읽고 건너뛰어야 한다는 것이다.
큐(queue)처럼 쓰이는 테이블을 상상해보자. 앞에서부터 처리하고 지우기를 반복하면 파티션 앞부분에 툼스톤이 수십만 개 쌓인다. "다음 100건"을 읽는 쿼리는 100건을 찾기 위해 툼스톤 수십만 개를 헤치고 가야 한다. 결과는 100건뿐인데 지연은 수 초가 되고, Cassandra는 tombstone_failure_threshold(기본 10만 개)를 넘으면 아예 쿼리를 실패시킨다. RDB의 "깊은 오프셋" 문제와 구조적으로 동일하다. 결과에 포함되지 않는 것을 읽는 비용이 페이지네이션을 죽이는 것이다. Cassandra를 큐로 쓰지 말라는 격언이 있는 이유 중 하나다.
시야를 시스템 전체로 넓히면 삭제는 더 오래 산다. RDB에서 글을 지워도 검색엔진의 사본은 다음 색인 반영까지, 응답 캐시는 TTL이 끝날 때까지, 리플리카는 복제가 따라잡을 때까지 그 글이 살아있다고 믿는다. 사용자가 방금 지운 글이 검색 결과나 목록에 유령처럼 나타나는 이유다. 전파가 끝날 때까지 그냥 기다리는 수밖에 없을까?
여기서 저장소가 내부에서 하던 일을 우리가 시스템 수준에서 반복할 수 있다. 애플리케이션 레벨 툼스톤이다. 삭제 즉시 Redis에 "최근 삭제된 ID"를 기록해두고, 어느 사본에서 목록을 만들었든 응답을 내보내기 직전에 이 목록으로 한 번 걸러내는 것이다.
ZADD deleted:posts 1751712000 post:1024 # 삭제 즉시 기록 (score = 삭제 시각)
ZRANGE deleted:posts (1751711400 +inf BYSCORE # 응답 직전, 최근 10분간 삭제된 ID 조회
ZREMRANGEBYSCORE deleted:posts -inf (1751711400 # 전파가 끝난 오래된 툼스톤은 청소툼스톤의 보관 기간은 가장 느린 전파 경로(검색엔진 색인 주기, 캐시 TTL)보다 조금 길게 잡으면 된다. 그 시간이 지나면 모든 사본에 삭제가 반영됐을 테니 툼스톤도 지워도 된다. 삭제를 표식으로 남겼다가 전파가 끝나면 청소한다는 점까지, LSM 트리의 툼스톤과 같은 구조가 애플리케이션 계층에서 반복되는 것이다.
캐시와 CDN: 페이지를 캐시하면 생기는 일
목록 API 앞에 캐시를 두는 것은 자연스러운 최적화지만 페이지네이션과는 궁합 문제가 있다. 페이지 단위로 캐시하면(GET /posts?page=1을 60초 캐시) 글이 하나 삭제되는 순간 모든 페이지의 경계가 밀리므로 전 페이지의 캐시가 논리적으로 무효가 된다. 하지만 실제로는 유효 시간(TTL)이 남은 캐시가 계속 서빙되어, 1페이지(신선)와 2페이지(60초 전)의 경계에서 중복/누락이 난다. 페이지 시프트 문제가 캐시 계층에서 재발하는 것이다.
커서 기반이라면 사정이 낫다. 커서 요청의 응답은 "이 지점 이후 20건"이라 앞쪽 변동에 영향받지 않고, 같은 커서로의 재요청은 안전하게 캐시할 수 있다. 다만 커서는 값의 종류가 사실상 무한이라 캐시 적중률이 낮다. 그래서 실무에서는 첫 페이지만 짧게 캐시하는 전략이 흔하다. 트래픽의 대부분은 어차피 첫 페이지에 몰리기 때문에, 첫 페이지 캐시만으로 대부분의 부하를 흡수할 수 있다.
마이크로서비스: 조인이 없는 곳에서 한 페이지 만들기
주문 서비스와 리뷰 서비스가 분리되어 있는데 "리뷰를 남길 수 있는 주문 목록"을 페이지네이션해야 한다면? 조인할 데이터베이스가 없으므로 어느 한쪽 기준으로 페이지를 만들고 다른 쪽을 호출해 조립해야 한다. 주문 서비스에서 20건을 가져와 리뷰 서비스에 "이 중 리뷰 없는 것?"을 물으면, 답이 13건일 때 부족한 7건을 위해 주문을 또 가져와야 한다. 필터 통과율을 알 수 없으므로 한 페이지를 채우기 위한 반복 호출이 생기고, 최악의 경우 전체를 순회한다.
이 문제의 정석 해법은 호출 반복을 견디는 것이 아니라 조회 전용 읽기 모델을 만드는 것이다. 두 서비스의 이벤트를 구독해 "리뷰 가능 주문" 목록을 미리 물리적으로 유지하면, 페이지네이션은 다시 단일 저장소의 문제로 돌아온다. 쓰기 모델과 별개로 조회 전용 모델을 두는 CQRS 패턴이 필요해지는 전형적인 순간이다. 페이지네이션이 어려워지는 순간은 대부분 "정렬·필터의 재료가 한 저장소에 없을 때"이고, 그때의 답은 쿼리 기교가 아니라 데이터 배치의 변경이다.
크롤러: 깊은 페이지의 주 고객
인프라 이야기의 끝은 트래픽의 정체다. 깊은 페이지의 접근 로그를 분석해보면 사람이 거의 없다. 검색엔진 봇, 스크래퍼, 그리고 잘못 짠 자동화 스크립트가 ?page=1부터 끝까지 순서대로 두드리는 것이 대부분이다. 오프셋 방식에서 이것은 페이지가 깊어질수록 무거워지는 쿼리를 무한정 맞아주는 것과 같다. 저비용 요청으로 고비용 쿼리를 유발할 수 있다는 점에서 비대칭적이고, 실제로 크롤러의 전체 순회가 DB를 밀어버리는 사고는 드물지 않다.
대응은 계층적으로 한다. 애플리케이션에서는 최대 페이지 깊이를 제한하고(앞의 하이브리드 전략), robots.txt와 rel="canonical"로 착한 봇의 순회를 줄이고, rate limit으로 나쁜 봇의 속도를 깎고, 정말 전체 데이터가 필요한 상대(제휴사 등)에게는 페이지네이션 API 대신 벌크 익스포트(스냅샷 파일 다운로드나 스트리밍 전송)를 따로 제공한다. "전체 데이터를 원하는 클라이언트에게 페이지네이션 API를 순회시키는 것"은 양쪽 모두에게 최악의 인터페이스다.
사용자에게 닿기까지
잘 만든 페이지네이션 쿼리는 아직 절반이다. 그것을 API라는 계약으로 내보내고, 화면에 그리고, 운영에서 지켜내는 나머지 절반이 남아 있다.
API로 내보내기
내부 구현을 정했다면 이제 바깥에 어떤 얼굴로 내보낼지 정해야 한다. 페이지네이션 API 설계는 이미 업계에 검증된 관례들이 있으므로 바퀴를 재발명하기 전에 살펴볼 가치가 있다.
REST 진영에서 참고할 만한 것은 두 가지다. GitHub API는 응답의 Link 헤더에 다음/이전/처음/마지막 페이지의 URL을 통째로 담아준다.
Link: <https://api.github.com/repositories?since=364>; rel="next",
<https://api.github.com/repositories{?since}>; rel="first"클라이언트가 URL을 조립하지 않고 서버가 준 링크를 따라가기만 하게 만드는 설계다. 서버가 페이지네이션 방식을 오프셋에서 커서로 바꿔도 클라이언트 코드는 한 줄도 바뀌지 않는다. 한편 Google의 API 설계 지침(AIP-158)은 모든 목록 API에 page_token / next_page_token이라는 불투명 토큰을 쓰라고 못박는다. 오프셋을 노출하는 순간 내부 구현 방식이 API 계약에 묶여버리므로, 나중에 저장소와 구현을 바꿀 자유를 지키기 위한 규칙이다.
GraphQL 진영에는 Relay Connection 스펙이 사실상의 표준이다.
{
orders(first: 20, after: "b3JkZXI6MTg2MDQ0OA==") {
edges {
cursor
node {
id
totalAmount
}
}
pageInfo {
hasNextPage
endCursor
}
}
}구조가 장황해 보이지만 뜯어보면 이 글에서 다룬 문제들에 대한 답이 하나씩 들어 있다. 커서는 불투명 토큰이고, 페이지가 아니라 항목(edge)마다 커서가 붙어 어느 지점에서든 이어읽기가 가능하며, hasNextPage로 전체 개수 없이 끝을 판정하고, after/first와 before/last로 양방향을 대칭적으로 지원한다. 페이지네이션 API가 고민해야 할 것들의 체크리스트로 읽어도 좋다.
방식과 무관하게 응답 설계에서 챙길 것이 몇 가지 있다.
먼저 hasNext는 n+1 조회로 구한다. 다음 페이지의 존재를 알기 위해 COUNT를 돌리는 것은 낭비다. 20건이 필요하면 21건을 조회해서, 21건이 오면 hasNext: true와 함께 20건만 반환하면 된다. 추가 비용은 한 행이다. 사소해 보이지만 이 관용구를 모르면 목록 API마다 COUNT가 하나씩 붙는다.
totalCount는 신중하게 넣어야 한다. 응답에 넣는 순간 그 값의 정합성은 API의 계약이 되는데, 커서로 순회하는 동안 total이 요청마다 바뀌면 클라이언트의 "n/total" 표시는 오히려 혼란스럽다. 꼭 필요하면 근사치임을 이름에 드러내는 것(approximateCount)도 방법이다.
마지막으로 빈 페이지와 끝 페이지를 구분해야 한다. 결과가 0건인 것과 더 이상 없는 것은 다르다. 특히 필터가 걸린 커서 순회에서는 "이번 청크에는 조건에 맞는 게 없지만 뒤에 더 있음"이 흔하므로, 결과가 비어 있어도 nextCursor는 줄 수 있어야 한다. 클라이언트는 목록이 비었는지가 아니라 커서의 유무로 종료를 판단해야 한다.
덧붙여 문서화할 것이 하나 더 있다. 동시 쓰기 상황에서 이 API가 무엇을 보장하는지다. 좋은 본보기가 Redis의 SCAN 명령이다. 공식 문서는 "순회 시작부터 끝까지 존재한 키는 반드시 반환된다(누락 없음), 같은 키가 여러 번 반환될 수 있다(중복 허용), 도중에 추가·삭제된 키는 반환될 수도 아닐 수도 있다"고 보장 수준을 못박는다. 변하는 데이터 위의 순회는 어차피 완벽할 수 없으니, 무엇이 보장되고 무엇이 보장되지 않는지를 명세하는 것이 최선이라는 태도다. 이 명세 덕분에 SCAN으로 전체 키 마이그레이션 배치를 돌리는 개발자는 처리를 멱등하게(같은 키를 두 번 처리해도 무해하게)만 만들면 안전하다고 확신할 수 있다. 우리의 목록 API 문서에는 이런 문단이 있는가?
커서는 공격 표면이다
보안 관점도 잠깐 짚자. 페이지네이션 파라미터는 사용자가 마음대로 바꿔 보낼 수 있는 입력이다.
가장 흔한 실수는 page_size를 검증하지 않는 것이다. ?limit=1000000 요청 하나가 서버 메모리를 삼킨다. 상한(보통 100 안팎)을 강제하는 것은 기본이다. 다음은 커서 변조다. Base64는 눈가림일 뿐이므로 사용자는 커서를 디코딩해 값을 바꿔 다시 보낼 수 있다. 커서 안에 user_id 같은 권한 관련 값이 들어 있고 서버가 이를 신뢰한다면, 값을 바꿔치기하는 것만으로 다른 사용자의 데이터를 열람하는 IDOR(Insecure Direct Object Reference) 취약점이 된다. 커서에는 위치 정보만 담고 권한은 항상 세션에서 다시 확인하거나, HMAC 서명으로 변조 자체를 막아야 한다.
마지막으로 열거(enumeration) 공격이 있다. 순차 증가하는 ID를 커서로 그대로 노출하면 전체 데이터 규모와 증가 속도를 추정할 재료가 경쟁사에게 그대로 공개된다. 최신 주문 ID는 누적 주문 수의 근사치고, 어제와 오늘의 ID 차이는 하루 주문량의 근사치다. 실제로 이 방법으로 스타트업의 거래량을 추정하는 사례는 유명하다.9 외부 노출 식별자를 UUID나 난수형 ID로 두는 관행에는 이런 배경도 있다.
프론트엔드: 받은 페이지를 그리는 일도 만만치 않다
서버가 완벽한 페이지를 내려줘도 프론트엔드에는 프론트엔드의 문제가 있다. 순서대로 살펴보자.
페이지 번호냐, 무한 스크롤이냐
먼저 UI 패턴 선택이다. 이것은 취향 문제가 아니라 사용자가 목록에서 무엇을 하려는가의 문제다.
무한 스크롤은 목적 없이 소비하는 목록에 맞다. 피드, 추천, 갤러리처럼 "다음에 뭐가 나올지 보는 것" 자체가 목적인 경우다. 반면 페이지 번호는 위치가 의미를 가지는 목록에 맞다. 검색 결과에서 "3페이지까지 봤다"는 진행 상황이고, 어드민에서 "몇 페이지"는 동료와 공유하는 좌표다. 무한 스크롤에서는 "아까 그 항목"으로 돌아가는 것이 사실상 불가능하다는 점도 중요하다. 비교하며 고르는 커머스 검색 결과에 무한 스크롤을 쓰면 사용자는 아까 본 상품을 찾아 하염없이 스크롤을 되감게 된다. 중간 형태인 "더보기" 버튼은 무한 스크롤의 흐름과 페이지의 통제감을 절충한 것으로, 푸터에 도달할 수 있어야 하는 페이지(무한 스크롤은 푸터를 영원히 밀어낸다)에서 특히 유용하다.
기술 선택과 UI 선택은 독립적이지 않다. 페이지 번호 UI를 고르는 순간 백엔드는 오프셋(또는 스냅샷 위의 오프셋)이어야 하고, 무한 스크롤을 고르면 커서가 자연스럽다. UX 결정이 데이터베이스 쿼리 형태를 결정하는, 스택 전체를 관통하는 몇 안 되는 사례다.
URL과의 관계도 여기서 갈린다. ?page=3은 새로고침해도, 북마크해도, 동료에게 보내도 동작한다. 반면 커서를 URL에 넣으면 길고 흉한 데다 스냅샷이 만료되면 깨진 링크가 된다. 그래서 커서 기반 화면에서는 "목록의 현재 위치"를 공유한다는 개념을 포기하고, 대신 개별 항목의 딥링크를 공유 단위로 삼는 것이 일반적이다. 목록 상태의 공유 가능성이 제품 요구사항이라면 이것도 방식 선택 단계에서 미리 따져야 한다.
무한 스크롤의 구현: 감지와 상태
스크롤 끝 감지는 이제 IntersectionObserver가 표준이다. 목록 끝에 센티널(sentinel) 요소를 두고 그것이 뷰포트에 들어오면 다음 페이지를 요청한다. 스크롤 이벤트 핸들러에서 scrollHeight를 계산하던 시절의 방식은 메인 스레드를 두드리는 비용 때문에 더 이상 쓸 이유가 없다. 센티널을 목록 맨 끝이 아니라 끝에서 몇 항목 앞에 두면 사용자가 바닥에 닿기 전에 다음 페이지를 미리 가져올 수 있다(프리페치). 로딩 스피너는 보여주는 것보다 안 보이게 만드는 쪽이 언제나 낫다. 실제 데이터 요청과 상태 관리는 TanStack Query의 useInfiniteQuery 같은 도구가 사실상 표준 관용구가 됐다.
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ["orders", filters], // 필터가 바뀌면 캐시도 분리된다
queryFn: ({ pageParam }) => fetchOrders({ cursor: pageParam, ...filters }),
initialPageParam: undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});이 관용구에서 눈여겨볼 부분은 queryKey에 필터가 들어간다는 점이다. 앞서 서버에서 쿼리 지문으로 커서 오용을 막았는데, 클라이언트에서도 같은 원칙이 반복된다. 필터가 다르면 다른 목록이고, 다른 목록의 페이지들은 섞이면 안 된다. 필터 변경 시 캐시 키가 통째로 바뀌므로 이전 필터의 페이지가 새 목록에 끼어드는 사고가 원천 차단된다.
또 하나 조심할 것은 변이(mutation) 이후의 처리다. 무한 스크롤로 5페이지까지 읽은 상태에서 항목 하나를 삭제하면 어떻게 할까? 목록 전체를 refetch하면 5페이지 분량의 요청이 다시 나가고 스크롤이 튄다. 그래서 보통 캐시에서 해당 항목만 제거하는 국소 업데이트를 쓰는데, 이때 서버와 클라이언트의 페이지 경계는 이미 어긋나기 시작했다는 것을 받아들여야 한다. 무한 스크롤의 캐시는 시간이 지날수록 서버의 진실에서 멀어지는 근사치이고, 화면을 벗어났다 돌아오는 시점 등 적절한 계기에 처음부터 다시 동기화하는 것이 현실적인 전략이다.
서버가 완벽해도 프론트가 마지막 방어선이다
이 글에서 본 것처럼 중복 항목은 어디서든 샌다. 리플리카 지연, 캐시 경계, 랭킹 변동, 커서 경계의 미묘한 버그까지. 그래서 성숙한 무한 스크롤 구현은 렌더링 직전에 ID 기준 중복 제거를 한 번 더 한다. React라면 어차피 key로 ID를 쓰므로, 중복이 새어 들어왔을 때 콘솔의 duplicate key 경고로 발견되는 경우도 많다. 경고를 무시하지 말고 "어느 계층에서 샜는지"를 추적하는 단서로 쓰자.
스크롤 복원: 무한 스크롤의 최대 난제
사용자가 피드를 한참 내리다 글 하나를 눌러 상세로 갔다가 뒤로가기를 눌렀다. 스크롤이 맨 위로 튕긴다면 그 앱의 무한 스크롤은 실패작이다. 브라우저는 일반 페이지의 스크롤을 복원해주지만, 무한 스크롤 목록은 돌아온 시점에 DOM이 비어 있으므로 복원할 대상이 없다.
제대로 하려면 세 가지를 저장해야 한다. 지금까지 로드한 데이터(또는 그것을 재현할 커서 시퀀스), 스크롤 위치, 그리고 이것들을 히스토리 엔트리에 연결할 키다. useInfiniteQuery처럼 페이지 데이터를 메모리 캐시에 유지하는 도구를 쓰면 데이터 복원은 해결되고, 스크롤 위치는 sessionStorage나 히스토리 state에 남겨 마운트 후 복원한다. 이때 이미지 로딩으로 레이아웃이 밀리면 복원 위치가 어긋나므로 이미지에 크기를 미리 지정하는 것까지가 한 세트다. 참고로 이 문제는 SPA만의 것이 아니다. 서버 렌더링 시절의 "더보기"도 뒤로가기에서 같은 문제를 겪었고, 그 시절의 해법(URL 해시에 상태 기록)이 지금의 히스토리 state로 이어진 것이다.
가상화: DOM은 유한해야 한다
무한 스크롤로 수백 페이지를 읽으면 DOM 노드가 수만 개가 된다. 메모리도 문제지만 매 프레임의 스타일/레이아웃 계산이 무거워져 스크롤 자체가 버벅이기 시작한다. 해법은 가상화(windowing)다. 뷰포트 근처의 항목만 실제 DOM으로 만들고 나머지는 높이만 차지하는 빈 공간으로 두는 기법으로, @tanstack/react-virtual이나 react-window가 표준 도구다. "무한" 스크롤이라는 이름과 달리 DOM은 유한하게 유지하는 것이 핵심이다. 수천 건 이상을 다루는 목록(어드민 그리드, 로그 뷰어, 채팅)에서는 페이지네이션과 가상화가 사실상 한 몸으로 설계되어야 한다.
접근성과 SEO: 무한 스크롤의 숨은 비용
무한 스크롤은 접근성 관점에서 문제가 많은 패턴이다. 키보드 사용자는 푸터에 도달할 수 없고, 스크린 리더 사용자는 목록이 조용히 늘어난 것을 모른다. 최소한 새 항목이 로드될 때 변경 사항을 스크린 리더가 읽어주게 하는 aria-live 영역으로 알리고, "더보기" 버튼 대안을 제공하고, 로드된 위치로 포커스가 자연스럽게 이어지게 해야 한다. 이것이 부담스럽다면 처음부터 "더보기" 버튼이 접근성 면에서 더 안전한 선택이다.
SEO 관점에서는, 크롤러는 사람처럼 스크롤하며 기다려주지 않으므로 스크롤해야만 로드되는 콘텐츠는 색인을 보장할 수 없다고 보는 것이 안전하다. 콘텐츠가 검색 유입에 중요하다면 무한 스크롤과 별개로 크롤러가 따라갈 수 있는 페이지형 URL(?page=2)이 링크로 존재해야 한다. 한때 표준이던 rel="prev/next"를 구글이 2019년에 폐기하면서, 지금은 "각 페이지가 자립적인 URL로 존재하고 서로 링크되어 있는 것" 자체가 사실상의 가이드다.
운영: 페이지네이션은 배포 후에 시작된다
배포하고 나면 다른 종류의 문제가 시작된다. 페이지네이션 문제의 고약한 점은 평균 지표에 나타나지 않는다는 것이다. 트래픽의 95%는 1~2페이지에 몰리므로 평균 응답 시간은 늘 건강해 보인다. 깊은 페이지의 5초짜리 쿼리는 가장 느린 1%의 응답 시간을 나타내는 p99 언저리에 숨어 있다가, 크롤러가 전체 순회를 시작하는 날 DB 커넥션 풀을 잠식하며 전체 장애로 번진다. 그래서 목록 API는 평균이 아니라 꼬리 지연(p99)을 보고, 슬로 쿼리 로그에서 OFFSET 큰 값이 보이면 크롤러 접근 패턴부터 의심하는 것이 순서다.
테스트도 목록 API는 특별 취급이 필요하다. 행이 10건인 개발 DB에서는 이 글의 모든 문제가 재현되지 않는다. 성능 문제는 수백만 건에서, 정확성 문제는 동시 쓰기에서만 나타나기 때문이다. 최소한 다음 시나리오는 자동화할 가치가 있다. 순회 중 삽입/삭제가 있을 때 전체 순회 결과에 누락/중복이 없는지(커서 로직의 회귀 테스트), 경계 조건(정확히 페이지 크기와 같은 건수, 0건, 마지막 페이지), 그리고 동일 정렬 값이 페이지 크기보다 많은 경우(타이브레이커 검증)다. 특히 마지막 케이스는 운영 데이터에서만 우연히 발견되는 대표적인 버그다. 첫 번째 시나리오는 이런 모양이 된다.
@Test
fun `순회 도중 삽입이 있어도 시작 시점의 행은 전부, 한 번씩만 나온다`() {
val initialIds = seedOrders(count = 500).map { it.id }.toSet()
val seen = mutableListOf<Long>()
var cursor: String? = null
do {
val page = api.listOrders(cursor = cursor, size = 20)
seen += page.items.map { it.id }
insertRandomOrders(count = 3) // 순회 중간에 계속 쓰기가 끼어든다
cursor = page.nextCursor
} while (cursor != null)
assertTrue(seen.containsAll(initialIds)) // 누락 없음
assertEquals(seen.size, seen.toSet().size) // 중복 없음
}그래서 무엇을 선택해야 할까
여기까지의 내용에서 결정에 필요한 것만 추리면 이렇게 정리할 수 있다.
| 상황 | 추천 | 이유 |
|---|---|---|
| 어드민, 사내 도구 | 오프셋 + deferred join | 점프/공유가 실제 요구사항, 데이터 변동 적음 |
| 커뮤니티 목록, 댓글 | 커서 (created_at + id) | 계속 쌓이는 데이터, 시프트 방지 |
| 무한 스크롤 피드 | 커서 + 클라이언트 dedup | 정확성과 깊이 무관 성능이 관건 |
| 추천/개인화 피드 | 세션 스냅샷 or 재개 토큰 | 순서 자체가 불안정하므로 고정이 먼저 |
| 검색 결과 | 검색엔진 + search_after, 깊이 제한 | 임의 필터 조합, 깊은 페이지 차단이 정석 |
| 배치, 마이그레이션, 익스포트 | PK 범위 커서 | 재개 가능, 깊이 무관, 추가 인덱스 불요 |
| 회계/정산 리포트 | 스냅샷(PIT/구체화) 위의 순회 | 순회 전체의 일관성이 정확성 요건 |
표보다 중요한 것은 질문의 순서다. 구현 방식부터 고르지 말고 요구사항의 우선순위부터 정하자. 임의 페이지 점프가 진짜 필요한가? 순회 도중의 정확성이 돈과 직결되는가? 데이터는 얼마나 자주 변하는가? 깊은 페이지에 실제로 사람이 가는가? 이 네 질문에 답하면 방식은 거의 자동으로 결정된다. 그리고 어떤 방식을 고르든 실행 계획을 열어 인덱스를 타는지 확인하는 것, 동시 쓰기 상황에서 무엇이 보장되는지 명세하는 것은 공통의 숙제다.
마치며
글의 첫 질문으로 돌아가자. 우리는 정말 페이지네이션을 잘 알고 있을까? LIMIT과 OFFSET을 아는 것은 시작일 뿐이었다. 목록을 나눠 보여준다는 단순한 요구 뒤에는 B-Tree가 오프셋으로 점프하지 못한다는 물리적 제약, 읽는 동안에도 변하는 데이터, 샤드와 리플리카가 만드는 시점의 어긋남, 그리고 뒤로가기 한 번에 무너지는 스크롤 위치까지, 스택의 모든 계층이 얽혀 있었다.
관통하는 원리는 하나다. 페이지네이션은 변하는 데이터 위에 안정적인 창을 만드는 일이고, 정확성·성능·탐색성·공유 가능성을 동시에 모두 가질 수는 없다는 것이다. 오프셋과 커서, PIT와 스냅샷, 페이지 번호와 무한 스크롤은 전부 이 트레이드오프 위의 서로 다른 선택일 뿐이다. 그래서 "무엇이 최신 기법인가"보다 "우리 제품은 무엇을 포기할 수 있는가"가 언제나 먼저 올 질문이다.
다음에 목록 화면을 설계할 일이 생기면, findAll(pageable)을 치기 전에 잠깐 멈춰서 물어보자. 이 목록은 얼마나 커지는가, 읽는 동안 얼마나 변하는가, 사용자는 어디까지 내려가는가. 그 답에 따라 한 줄이면 충분할 수도 있고, 이 글의 절반이 필요할 수도 있다. 어느 쪽이든 이제는 알고 선택하는 것이다.
Footnotes
-
정확히는 일반적인 B-Tree 이야기다. 각 노드에 서브트리의 항목 수를 저장하는 카운티드 B-Tree(counted B-tree)나 Order Statistic Tree를 쓰면 N번째 항목을 O(log N)에 찾을 수 있지만, 모든 삽입/삭제 시 경로상의 카운트를 갱신해야 하는 비용 때문에 범용 데이터베이스는 채택하지 않는다. ↩
-
MyISAM 시절의 MySQL은 테이블 메타데이터에 행 수를 들고 있어 조건 없는
COUNT(*)가 O(1)이었다. "예전엔 빨랐는데"라는 기억은 사실 스토리지 엔진이 바뀐 것이다. InnoDB는 MVCC 때문에 그렇게 할 수 없다. ↩ -
데이터베이스의 서버 커서(
DECLARE CURSOR), 즉 쿼리 결과를 세션 안에 붙잡아두고 순차적으로 읽어가는 객체와는 다른 개념이다. 페이지네이션의 커서는 요청 사이를 오가는 값일 뿐 서버에 아무것도 붙잡아두지 않는다. ↩ -
이 방식의 대중화에는 "No Offset"이라는 슬로건으로 오프셋의 문제를 알린 Markus Winand의 use-the-index-luke.com이 큰 역할을 했다. ↩
-
MySQL 옵티마이저는 행 생성자(row constructor) 비교를
=와IN에 대해서는 범위 최적화하지만,</>같은 부등호 비교는 인덱스 범위 조건으로 변환하지 못한다. PostgreSQL은 행 값 비교를 완전하게 지원하므로 튜플 문법을 그대로 쓰면 된다. ↩ -
등호 조건 컬럼은 인덱스 앞쪽에, 범위/정렬 컬럼은 그 뒤에 놓는 것이 복합 인덱스 설계의 기본 규칙이다.
WHERE status = ? ORDER BY created_at DESC쿼리라면 인덱스는(status, created_at, id)가 된다. ↩ -
트위터의 타임라인 아키텍처는 Raffi Krikorian의 발표 "Timelines at Scale"(QCon 2012)로 잘 알려져 있다. 마틴 클레프만의 "데이터 중심 애플리케이션 설계" 1장에서도 이 사례로 부하 기술(describing load)을 설명한다. ↩
-
LSM 트리는 쓰기를 불변 파일(SSTable)에 append하는 구조라 이미 쓴 데이터를 제자리에서 지울 수 없다. 그래서 "지워졌다"는 사실 자체를 새 레코드로 쓰는데 이것이 툼스톤이다. Cassandra, RocksDB, LevelDB 등 쓰기 최적화 저장소 대부분이 이 구조다. ↩
-
2차 세계대전 때 연합군이 노획한 독일 전차의 일련번호로 월 생산량을 추정한 "German tank problem"이 원형이다. 순차 ID는 그 자체로 통계 정보다. ↩