목차
한 번에 다 줄 수 없다
게시판에 글이 100만 개 있다. 목록 API가 이렇게 생겼다면 어떻게 될까.
SELECT * FROM posts ORDER BY id DESC; -- 전부DB는 100만 행을 다 읽고, 서버는 그걸 다 메모리에 올리고, 네트워크로 다 실어 보내고, 브라우저는 다 그린다. 사용자는 첫 20개만 볼 건데 말이다. 메모리도 네트워크도 렌더도 다 터진다.
그래서 한 번에 다 주지 않고 조금씩 나눠서 준다. 그게 페이지네이션이다.
페이지로 나눠 준다
나누는 단위가 페이지다. 두 가지만 정하면 된다.
- 얼마씩(size) - 한 번에 몇 개. 예: 20개
- 어디를(page 또는 위치) - 그중 어느 묶음
“한 페이지 20개, 그중 3페이지를 달라.” 이 요청을 어떻게 SQL로 옮기느냐에 따라 방식이 갈린다. 크게 둘이다. offset과 cursor.
Offset 방식: LIMIT과 OFFSET
가장 흔하고 직관적인 방법이다. “앞의 몇 개를 건너뛰고, 그다음 몇 개.”
SELECT * FROM posts
ORDER BY id DESC
LIMIT 20 OFFSET 40; -- 앞 40개 건너뛰고 20개 = 3페이지 (size 20)OFFSET이 건너뛸 개수다. 페이지 번호에서 바로 나온다. OFFSET = (page - 1) × size. 3페이지면 앞 2페이지(40개)를 건너뛴다. 페이지 번호와 계산이 딱 맞아떨어져서 이해하기 쉽다.
Spring Data JPA도 이 방식을 기본으로 준다.
Page<Post> page = postRepository.findAll(PageRequest.of(2, 20)); // 3페이지(0-based)문제가 없어 보인다. 실제로 데이터가 적으면 이걸로 충분하다. 하지만 데이터가 커지면 두 가지가 드러난다.
Offset은 뒤로 갈수록 느려진다
OFFSET 40은 괜찮다. 그런데 OFFSET 1000000은 어떨까.
OFFSET은 “건너뛴다”고 하지만, DB 입장에서 건너뛰기는 읽고 버리기다. 100만 번째부터 20개를 주려면, 앞의 100만 개를 실제로 훑어서 세어야 한다. 그러고는 버린다.
그래서 뒤 페이지일수록 느려진다. 1페이지는 즉시 나오지만 5만 페이지는 한참 걸린다. 반환하는 건 똑같이 20개인데, 버리는 양이 페이지에 비례해 늘기 때문이다. 인덱스가 있어도 그 개수만큼은 훑어야 한다.
Offset은 데이터가 변하면 밀린다
두 번째 문제는 더 미묘하다. 목록을 보는 사이에 데이터가 바뀌면 페이지가 어긋난다.
1페이지(최신 20개)를 봤다. 그 사이 누군가 새 글을 하나 올린다. 이제 모든 글이 한 칸씩 뒤로 밀린다. 그 상태에서 2페이지(OFFSET 20)를 요청하면?
1페이지 끝에서 봤던 981번 글이, 밀린 탓에 2페이지 맨 앞에 또 나온다. 반대로 글이 삭제되면 건너뛰어 누락된다. OFFSET은 “몇 번째”라는 상대적 위치라서, 그 기준점 자체가 움직이면 흔들린다.
Cursor 방식: 마지막으로 본 값 다음부터
cursor(커서, keyset이라고도 한다)는 발상을 바꾼다. **“몇 번째”가 아니라 “무엇 다음”**이다.
1페이지 마지막 글의 id가 981이었다면, 다음 요청은 이렇게 한다.
SELECT * FROM posts
WHERE id < 981 -- 마지막으로 본 id 다음부터
ORDER BY id DESC
LIMIT 20;이 한 줄이 offset의 두 문제를 다 없앤다.
- 빠르다. id에 인덱스가 있으면 981 위치로 바로 점프해서 20개만 읽는다. 앞의 것을 세고 버리지 않는다. 그래서 뒤 페이지도 첫 페이지만큼 빠르다.
- 밀리지 않는다. 기준이 “981”이라는 절대값이라, 앞에 새 글이 추가돼도 “981 다음”은 여전히 981 다음이다. 중복도 누락도 없다.
커서는 무엇을 담나
그러면 클라이언트는 그 “981”을 어떻게 아나. 서버가 한 페이지를 주면서 다음 커서를 같이 준다. 클라이언트는 다음 페이지를 요청할 때 그걸 돌려보낸다.
GET /posts?limit=20&cursor=eyJpZCI6OTgxfQ보통 이 값을 불투명한 토큰(base64 등)으로 감싼다. 클라이언트가 내부를 몰라도 되고, 나중에 커서 구조가 바뀌어도 클라이언트를 안 고쳐도 되게. 클라 입장에선 그냥 “다음 페이지로 가는 표”다.
여기 함정이 하나 있다. 커서의 기준값은 유일하고 순서가 고정된 것이어야 한다. id는 그렇다. 그런데 만약 생성 시각(created_at)으로 정렬한다면? 같은 시각에 만들어진 글이 둘 이상이면 그 사이 순서가 흔들려서, cursor에서도 밀림이 생긴다.
그래서 커서엔 **정렬값 + 유일값(id)**을 같이 담는다.
WHERE (created_at, id) < ('2026-07-19 10:00:00', 981)
ORDER BY created_at DESC, id DESC
LIMIT 20;created_at이 같아도 id로 갈리니 순서가 흔들리지 않는다. 커서 토큰 안에 이 두 값이 들어 있는 이유다. 정렬 기준이 유일하지 않으면 tie-break를 함께 실어야 한다.
총 개수와 페이지 번호는
cursor가 이렇게 좋은데 왜 offset을 안 버리나. 여기에 이유가 있다.
offset은 “전체 152개, 8페이지 중 3페이지” 같은 걸 보여줄 수 있다. SELECT COUNT(*)로 전체 개수를 세서 전체 페이지 수를 계산하고, 사용자가 5페이지로 바로 점프할 수도 있다.
cursor는 이게 어렵다. “981 다음”이라는 상대적 위치라, 지금이 전체 몇 페이지 중 어디인지 모른다. 5페이지로 바로 갈 수도 없다(4페이지 끝 커서를 모르니까). cursor는 **“다음”·“더 보기”**만 자연스럽다.
| offset | cursor | |
|---|---|---|
| 전체 페이지 수 | 셀 수 있다 (COUNT) | 어렵다 |
| 특정 페이지 점프 | 된다 | 안 된다 |
| 어울리는 UI | 페이지 번호 (1 2 3 …) | 무한 스크롤 · 더보기 |
다만 COUNT(*)도 큰 테이블에선 느리다. “전체 몇 페이지”를 정확히 보여주는 것 자체가 비싼 일이라, 데이터가 아주 크면 offset을 쓰더라도 전체 개수를 안 세거나 어림값으로 때우기도 한다.
둘 중 무엇을 쓰나
정답은 없고 UI 요구가 방식을 정한다.
| 상황 | 어울리는 방식 |
|---|---|
| 페이지 번호 UI, 임의 페이지 점프가 필요 | offset |
| 데이터가 적다 (뒤 페이지도 안 느림) | offset (단순한 게 낫다) |
| 무한 스크롤 · “더 보기” | cursor |
| 데이터가 크다 · 계속 바뀐다 (실시간 피드) | cursor |
한 줄로 줄이면 이렇다. offset은 단순하고 페이지 번호에 맞지만, 크고 바뀌는 데이터에서 느려지고 밀린다. cursor는 빠르고 안 밀리지만, 임의 점프와 전체 개수를 못 준다.
그래서 관리자 화면의 표(페이지 번호)는 offset, 타임라인·피드(무한 스크롤)는 cursor로 가는 게 흔한 결론이다.
정리
| 왜 나누나 | 한 번에 다 주면 메모리·네트워크·렌더가 터진다 |
| offset | LIMIT n OFFSET m. 페이지 번호에 직관적 |
| offset의 문제 1 | 뒤로 갈수록 느리다 (건너뛴 만큼 읽고 버린다) |
| offset의 문제 2 | 데이터가 변하면 밀린다 (중복·누락) |
| cursor | WHERE id < 마지막값. 인덱스로 점프 - 빠르고 안 밀린다 |
| 커서 값 | 마지막 정렬값 + 유일값(id). 불투명 토큰으로 감싼다 |
| 정렬 안정성 | 정렬 기준이 유일하지 않으면 id를 tie-break로 함께 |
| 총 개수 | offset은 COUNT로 전체 페이지 제공, cursor는 못 준다 |
| 선택 | 페이지 번호 UI → offset / 무한 스크롤·큰 데이터 → cursor |
한 번에 다 줄 수 없어서 나누는 것까진 같다. 다만 “몇 번째”로 나누면 뒤로 갈수록 무너지고, “무엇 다음”으로 나누면 그게 안 생긴다. 그 차이가 offset과 cursor다.