9장. 페이지네이션과 대용량 조회
9.1 모든 데이터를 한 번에 가져올 수는 없다
지금까지의 예제는 대부분 다음과 같았다.
const users = await prisma.user.findMany();
데이터가 적을 때는 문제가 없다.
하지만 사용자가 10만 명, 게시글이 1억 건이 되면 이야기가 달라진다.
- 메모리 사용량이 폭증한다
- 응답이 느려진다
- DB에 큰 부하가 걸린다
그래서 데이터는 “잘라서” 가져와야 한다.
👉 이때 등장하는 것이 **페이지네이션(Pagination)**이다.
9.2 Offset 기반 페이지네이션
가장 흔하게 보는 방식이다.
const page = 3;
const pageSize = 20;
const users = await prisma.user.findMany({
skip: (page - 1) * pageSize,
take: pageSize
});
내부적으로는 다음과 같은 SQL이 실행된다.
SELECT * FROM users
ORDER BY id
LIMIT 20 OFFSET 40;
👉 “몇 페이지로 이동” 같은 UI에 잘 맞는다
- 1페이지, 2페이지, 3페이지 …
- 마지막 페이지로 이동
- 전체 개수 표시
대부분의 게시판이 이 방식을 사용한다.
9.3 Offset의 한계
Offset 방식은 직관적이지만,
데이터가 커지면 두 가지 문제가 드러난다.
1) 뒤로 갈수록 느려진다
SELECT * FROM users ORDER BY id LIMIT 20 OFFSET 1000000;
DB는 OFFSET 1,000,000을 처리하기 위해
👉 100만 건을 모두 읽고 버린 뒤 마지막 20건을 반환한다.
페이지가 뒤로 갈수록
선형적으로 느려진다.
2) 데이터가 추가되면 결과가 흔들린다
페이지를 보는 동안 새 데이터가 들어오면
[1페이지 조회] → 새 글 등록 → [2페이지 조회]
2페이지에 1페이지에 있던 글이 다시 보이게 된다.
👉 누락 또는 중복이 발생한다
9.4 Cursor 기반 페이지네이션
이 한계를 해결하기 위해 등장한 방식이
👉 Cursor 기반 페이지네이션이다.
“몇 번째”가 아니라 “어디부터”로 자른다.
const users = await prisma.user.findMany({
take: 20,
cursor: { id: lastSeenId },
skip: 1,
orderBy: { id: 'asc' }
});
내부 SQL은 다음과 같다.
SELECT * FROM users
WHERE id > 12345
ORDER BY id
LIMIT 20;
👉 마지막으로 본 ID를 기준으로 다음 데이터를 가져온다
Cursor 방식의 장점
- 항상 인덱스를 타기 때문에 빠르다 (성능이 페이지 위치에 영향받지 않음)
- 새 데이터가 들어와도 결과가 흔들리지 않는다
- 무한 스크롤에 최적
Cursor 방식의 한계
- “3페이지로 이동” 같은 임의 페이지 이동이 불가능하다
- 전체 페이지 수를 알기 어렵다
- 정렬 기준이 고유하지 않으면 누락이 발생할 수 있다
(정렬 키는 유니크해야 한다)
9.5 언제 어떤 방식을 쓸 것인가
| 상황 | 추천 방식 |
|---|---|
| 게시판 (페이지 번호 UI) | Offset |
| 관리자 화면 (소량 데이터) | Offset |
| 무한 스크롤 (피드, 타임라인) | Cursor |
| 대용량 데이터 순회 | Cursor |
| 실시간으로 데이터가 늘어나는 목록 | Cursor |
핵심 기준은 두 가지다.
- 임의 페이지 이동이 필요한가? → Offset
- 데이터가 많거나 자주 추가되는가? → Cursor
9.6 페이지네이션은 “조회 전략”이다
페이지네이션은 단순히 LIMIT/OFFSET을 붙이는 일이 아니다.
- 데이터의 크기
- 정렬 기준
- 사용자 UX
- 실시간성
이 모든 것이 영향을 준다.
👉 결국 어떻게 데이터를 잘라서 보여줄 것인가에 대한 설계다.
9.7 핵심 정리
findMany()한 줄로 끝나는 시대는 끝났다- 데이터가 커지면 “자르는 전략”이 필요하다
- Offset은 직관적, Cursor는 확장성
- 그리고 가장 중요한 것은
👉 데이터 규모와 UX에 맞춰 선택하는 것이다