Spring Data JPA 기준, 페이지네이션에는 다양한 방식이 존재합니다.
크게 Page와 Slice를 비교해볼 수 있어요.
둘 다 Pagable을 객체를 인자로 받아, PageRequest.of(page, size) 와 같은 방식으로 요청할 수 있습니다.
정렬 조건도 함께 줄 수 있어요.
// PageRequest.of( 페이지 번호, 한 페이지 내 데이터 크기, 정렬 조건)
Pageable pageable = PageRequest.of(0, 10, Sort.by("createdAt").descending());
Page
전체 개수와 페이지 수까지 함께 조회하는 방식.
A page is a sublist of a list of objects. It allows gain information about the position of it in the containing entire list.
Page의 가장 큰 특징은, 조회 데이터뿐만 아니라 전체 데이터 건수와 전체 페이지 수도 같이 볼 수 있다는 점입니다.
즉, 전체 페이지 수를 계산할 수 있고, "현재 몇 번째 페이지에 있는지"도 명확하게 확인할 수 있습니다.
Page<User> page = userRepository.findAll(PageRequest.of(0, 10));
long totalElements = page.getTotalElements(); // 전체 데이터 개수
int totalPages = page.getTotalPages(); // 전체 페이지 수
boolean hasNext = page.hasNext();
다만 내부적으로 전체 데이터 개수를 가져오기 위해 count 쿼리가 한 번 더 실행되기 때문에, 데이터가 많을 경우 성능 저하가 발생합니다.
Slice
count 쿼리 없이, 다음 페이지 존재 여부만 확인하는 방식.
A slice of data that indicates whether there's a next or previous slice available. Allows to obtain a Pageable to request a previous or next Slice.
Slice는 이와 다르게 현재 페이지의 데이터만 볼 수 있습니다.
전체 데이터 건수나 전체 페이지 수는 알 수 없고, 단순히 다음 Slice가 존재하는지 여부만 확인할 수 있습니다.
// Slice
Slice<User> slice = userRepository.findAll(PageRequest.of(0, 10));
boolean hasNext = slice.hasNext(); // 다음 Slice 여부만 확인
요청한 size에서 1개를 더 조회해서 "다음 페이지가 있는지"를 판별하는 방식으로 동작합니다.
Page와 다르게 count 쿼리를 실행하지 않아 성능상 이점이 있습니다.
| 구분 | Page | Slice |
| 데이터 | ✅ | ✅ |
| 전체 건수(total count) | ✅ | ❌ |
| 전체 페이지 수(total pages) | ✅ | ❌ |
| hasNext | ✅ | ✅ |
| count 쿼리 | 실행 | 실행 안 함 |
언제 Page, 언제 Slice를 써야 할까?
그렇다면, 우리는 Page와 Slice의 사용 기준을 나누어야합니다.
Page는 총 페이지 수를 함께 반환하므로, 게시판 등 페이지 번호를 선택할 수 있는 UI에 유리합니다.
Slice는 다음 페이지가 있는지 여부만 알 수 있으므로, 더보기 버튼이나 무한 스크롤에서 유리합니다.
이 두 페이지네이션 방식은 Offset을 기반으로 합니다.
Offset 기반 페이지네이션
LIMIT과 OFFSET을 사용하여 특정 구간을 잘라 가져오는 방식
예를 들어, 10개씩 끊어서 2페이지를 보고 싶다면 다음과 같이 쿼리를 작성합니다.
SELECT *
FROM posts
ORDER BY created_at DESC
LIMIT 10 OFFSET 10;
대부분의 SQL에서 지원되는 표준 문법이고, 구현이 간단하다는 장점이 있어 많이 사용됩니다.
하지만..
1. Offset은 내부적으로 앞의 데이터를 모두 스캔 후 버리는 구조이기 때문에.. Offset이 커질수록 성능이 저하됩니다.
2. tie-breaker가 없는 정렬 조건을 사용한다면 db에서 조회할 때마다 순서의 변경이 생겨 정합성의 문제가 생길 수도 있습니다. 이건 사실 Index 설정으로 해결할 수 있습니다..
3. 조회할 때에 새로운 데이터가 추가되어도, 순서가 변경되어 데이터 정합성에 어긋나는 문제가 생기겠죠?
이러한 문제점이 존재합니다.
이에 고려해볼 수 있는 방식이 Cursur 기반 페이지네이션 입니다.
Cursor 기반 페이지네이션 (Keyset Pagination)
마지막으로 본 데이터의 “커서(키)”를 기준으로 그 이후 데이터를 가져오는 방식
Offset의 성능 문제와 정합성 문제를 해결하기 위해 등장한 방식입니다.
마지막 레코드의 키(예: created_at, id)를 기억해 두고, 그 다음 데이터를 가져옵니다.
SELECT *
FROM posts
WHERE (created_at, id) < (:lastCreatedAt, :lastId)
ORDER BY created_at DESC, id DESC
LIMIT 10;
일단 Offset 에 비해 성능이 우수하다는 장점이 있습니다. Where 조건을 통해 데이터를 모두 스캔할 필요가 없어졌기 때문이죠.
또한 Offset의 3번 문제가 해결됩니다. 데이터가 새로 추가되어도 데이터 중복 등 정합성을 걱정할 필요가 없습니다.
Cursur도 단점이 존재하는데,
Offset과 마찬가지로 정렬 기준이 고정되어야 한다는 점입니다. 하지만 이 부분은 Offset과 마찬가지로 Index 설정으로 해결할 수 있습니다.
또한 게시판 내 페이징 처리와 같이, 1페이지에서 5페이지로 바로 이동하는 등 번호 점프가 불가능합니다.
또한 return에 nextCursor을 내려주고, 다음 페이징 요청을 위해선 클라이언트가 이 커서 값을 반드시 보관하고 있어야 됩니다.
| 구분 | Offset(Page/Slice) | Cursor |
| 구현 난이도 | 쉬움 | 다소 복잡 |
| 성능 (깊은 페이지) | 느려짐 | 빠름 |
| 정합성 | 깨질 수 있음 | 안정적 |
| 페이지 점프 | 가능 (5페이지로 이동) | 불가능 (순차 탐색만) |
| 적합한 사례 | 게시판, 검색 결과 | 피드, 무한 스크롤, 실시간 로그 |
언제 무엇을 써야 할까?
페이지 번호 점프가 불가능하다는 Cursur의 단점 때문에, 게시판이나 검색 결과 등에 전체 페이지 수를 노출해야 할 때는 Offset이 적합합니다.
만약 실시간이 중요하거나, 대규모 데이터를 처리할 때에는 성능 저하와 정합성 문제가 비교적 덜한 Cursor가 적합합니다.
서비스의 UX와 데이터 특성에 따라 Offset과 Cursor를 적절히 선택하는 것이 중요합니다.
'CS' 카테고리의 다른 글
| [DBMS] Indexing 인덱싱 (0) | 2025.04.13 |
|---|---|
| [DBMS] Introduction to SQL (0) | 2025.03.10 |
| [CS] ICMP: 인터넷 제어 메시지 프로토콜 (Internet Control Message Protocol) (1) | 2025.02.08 |
| [CS] TCP 혼잡 제어(TCP Congestion Control) (2) | 2025.02.01 |
| [CS] 신뢰적인 데이터 전송(Reliable Data Transfer) (2) | 2025.01.17 |