Pageable로 페이지를 요청하면 Page냐 Slice냐에 따라 쿼리 수가 달라진다.
결정적 차이 — count 쿼리
Page<Order> findByStatus(String status, Pageable p); // 조회 + COUNT → 2번
Slice<Order> findByStatus(String status, Pageable p); // 조회만 → 1번
List<Order> findByStatus(String status, Pageable p); // 조회만, 더보기 정보 없음
-
Page — getTotalElements() · getTotalPages() 를 준다
- "1 2 3 ... 57" 같은 페이지 번호 UI 에 필요
- 대신 COUNT 쿼리가 매번 나간다
-
Slice — hasNext() 만 준다 (limit+1 을 읽어 판단한다)
- 무한 스크롤·더보기 버튼이면 이걸로 충분하다
COUNT가 얼마나 비싼가
-
수천만 행 테이블에서 조건에 맞는 행이 많으면
- COUNT(*) 가 본 쿼리보다 오래 걸리는 일이 흔하다
- (LIMIT 20 은 20건만 읽고 멈추지만 COUNT 는 전부 세야 한다)
-
무한 스크롤인데 Page 를 쓰고 있으면 순수한 낭비다
count 쿼리를 따로 줄 수 있다
@Query(value = "select o from Order o join fetch o.member where o.status = :s",
countQuery = "select count(o) from Order o where o.status = :s") // 조인 없이
Page<Order> findWithMember(@Param("s") String s, Pageable p);
본 쿼리의 조인을 count에서 걷어내면 크게 빨라진다.
컬렉션 페치 조인 + 페이징 금지
1:N 을 페치 조인하면 행이 늘어나 DB 의 LIMIT 이 '주문' 단위로 안 잘린다
- JPA 가 전부 읽어 메모리에서 자른다 (HHH000104 경고)
- 대신 default_batch_fetch_size 를 쓴다
프로젝션 — 필요한 칼럼만
// 인터페이스 프로젝션 — 게터만 선언하면 그 칼럼만 SELECT 한다
interface OrderSummary {
Long getId();
String getStatus();
}
List<OrderSummary> findByStatus(String status);
// 동적 프로젝션 — 호출부가 타입을 정한다
<T> List<T> findByStatus(String status, Class<T> type);
엔티티 전체를 읽지 않으므로 커버링 인덱스가 통하고 영속성 컨텍스트 부담도 없다.
깊은 OFFSET 문제는 그대로다
-- OFFSET 1000000 은 앞 100만 행을 읽고 버린다
-- 무한 스크롤이면 키셋(커서) 방식으로 바꾼다
SELECT * FROM orders WHERE id < :lastId ORDER BY id DESC LIMIT 20;
면접 함정
- ❌ "Page가 더 좋다" → 전체 개수가 UI에 필요 없으면 순수한 낭비다.
- ❌ "Slice는 다음 페이지가 있는지 모른다" →
limit+1을 읽어 안다.
정렬이 유일하지 않으면 페이지가 겹친다
// ❌ createdAt 이 같은 행이 여럿이면 페이지 경계에서 중복·누락이 생긴다
PageRequest.of(0, 20, Sort.by(DESC, "createdAt"));
// ✅ 유일한 칼럼을 타이브레이커로 추가한다
PageRequest.of(0, 20, Sort.by(DESC, "createdAt").and(Sort.by(DESC, "id")));
DB는 동일 값의 순서를 보장하지 않으므로, 같은 쿼리를 두 번 실행해도 순서가 달라질 수 있다.
Pageable을 그대로 받을 때의 위험
spring:
data:
web:
pageable:
max-page-size: 100 # 클라이언트가 size=1000000 을 보내는 것을 막는다
default-page-size: 20
one-indexed-parameters: true # page=1 부터 시작 (기본은 0)
max-page-size를 안 잡으면 누군가 ?size=999999로 전체를 끌어가 OOM을 낼 수 있다. 공개 API라면 필수 설정이다.