본문 바로가기
Java & Spring

페이징 성능: 카운트 최적화·키셋 페이징

by yamoojin83 2025. 10. 3.

페이징 성능: 카운트 최적화·키셋 페이징

대부분의 목록 API는 Page<T>를 쓰지만, 조인/필터가 복잡해지면 count가 병목이 됩니다. 또한 무한 스크롤에서는 키셋(Keyset) 페이징이 오프셋보다 훨씬 효율적입니다. 이 글은 실무에서 바로 적용하는 3가지 전략을 정리합니다.

전략 1) countQuery 분리


@Query(
  value = "select o from Order o join o.member m where m.status = :s",
  countQuery = "select count(o) from Order o join o.member m where m.status = :s"
)
Page<Order> findByMemberStatus(@Param("s") Status s, Pageable pageable);
  • 목록 쿼리에서 불필요한 fetch join/정렬/컬럼을 빼고 카운트 전용으로 단순화
  • 정렬 컬럼에 인덱스 부여(order by created_at desc 등)

전략 2) Slice로 가볍게


Slice<Order> findTop20ByStatusOrderByIdDesc(Status s, Pageable pageable);

무한 스크롤 UI라면 전체 개수는 의미가 없습니다. Slice다음 페이지가 있는지만 알려주므로 count 쿼리를 생략하고 반응성이 개선됩니다.

전략 3) 키셋(Keyset) 페이징


// 마지막 id를 커서처럼 사용
@Query("select o from Order o where (:cursor is null or o.id < :cursor) order by o.id desc")
List<Order> next(@Param("cursor") Long cursor, Pageable pageable);
  • 장점: 대용량에서도 빠르고 안정적(인덱스 범위 스캔)
  • 주의: 정렬 기준이 단조 증가(id/createdAt)해야 함. 정렬 컬럼에 인덱스 필수.
  • 정렬 다중 컬럼일 때는 (createdAt, id) 복합 인덱스로 중복 방지.

QueryDSL 예시


public Slice<OrderView> next(OrderCond cond, Long cursor, int size) {
  QOrder o = QOrder.order; QMember m = QMember.member;
  List<OrderView> rows = query
    .select(Projections.constructor(OrderView.class, o.id, m.name, o.createdAt))
    .from(o).join(o.member, m)
    .where(
      cond.status() != null ? o.status.eq(cond.status()) : null,
      cursor != null ? o.id.lt(cursor) : null
    )
    .orderBy(o.id.desc())
    .limit(size + 1)
    .fetch();
  boolean hasNext = rows.size() > size;
  if (hasNext) rows.remove(size);
  return new SliceImpl<>(rows, PageRequest.of(0, size), hasNext);
}

대용량 페이징 팁

  • 정렬 컬럼(및 보조키)에 적절한 인덱스 부여
  • 선택 컬럼 최소화(DTO 투영)로 I/O 감소
  • 필터 조건의 선택도가 낮다면 부분 인덱스/파티셔닝 고려

FAQ

Q. 카운트를 대략으로 빨리 구할 수 있나요?
A. 일부 DB의 통계 테이블로 추정값을 얻을 수 있지만(예: PG의 reltuples), JPA 표준 바깥이며 정확도가 필요 없는 분석 화면에만 제한하세요.

Q. 키셋에서 특정 페이지로 점프?
A. 오프셋/카운트를 섞어 하이브리드로 구현하거나, 기간/검색어로 좁힌 뒤 키셋을 적용하세요.

 

 

👉 1편: 연관관계 주인/지연로딩 N+1 체크리스트

👉 2편: 메서드명 쿼리 vs @Query vs QueryDSL 비교

👉 3편: @Transactional 전파/고립수준 이해

👉 4편: 페이징 성능: 카운트 최적화·키셋 페이징