Java 아키텍처·Spring 용어 사전
Spring Data JPAPage · Slice · Pageable · 프로젝션

페이징

Page는 전체 개수를 세고 Slice는 세지 않는다. count 쿼리가 성능을 가른다.

Pageable로 페이지를 요청하면 PageSlice에 따라 쿼리 수가 달라진다.

결정적 차이 — 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라면 필수 설정이다.

함께 보면 좋은 용어

노트에서 맥락과 함께 보기 — Spring Data JPA — 리포지토리·페이징·프로젝션