데이터가 100만 건이면, 한 번에 전부 조회할 수 없습니다. 어떻게 나눠서 보여줄까요?

대부분의 서비스에서 목록 조회는 페이징 처리가 필수입니다. Spring Data JPA는 Pageable 인터페이스를 통해 페이징과 정렬을 쉽게 처리할 수 있도록 지원합니다. 하지만 대량 데이터에서의 성능 문제를 이해하지 못하면 운영 환경에서 장애가 발생할 수 있습니다.

개념 정의

페이징(Paging) 은 전체 데이터를 일정 크기로 나누어 한 번에 필요한 만큼만 조회하는 기법입니다. Spring Data JPA는 Pageable, Page, Slice 등의 인터페이스를 제공하여 이를 추상화합니다.

기본 사용법

PageRequest 생성

JAVA
// 0번째 페이지, 10건 조회, age 내림차순 정렬
Pageable pageable = PageRequest.of(0, 10, Sort.by("age").descending());

// 여러 정렬 조건
Pageable pageable = PageRequest.of(0, 10,
    Sort.by(Sort.Order.desc("age"), Sort.Order.asc("name")));

Spring Data의 페이지 번호는 0부터 시작합니다.

Repository 메서드

JAVA
public interface MemberRepository extends JpaRepository<Member, Long> {

    // Page 반환 — COUNT 쿼리 자동 실행
    Page<Member> findByAge(int age, Pageable pageable);

    // Slice 반환 — COUNT 쿼리 없음
    Slice<Member> findByTeamName(String teamName, Pageable pageable);

    // List 반환 — 페이징만, 전체 수 불필요
    List<Member> findByName(String name, Pageable pageable);
}

Page vs Slice

Page

JAVA
Page<Member> page = memberRepository.findByAge(20, PageRequest.of(0, 10));

List<Member> content = page.getContent();       // 조회된 데이터
int totalPages = page.getTotalPages();           // 전체 페이지 수
long totalElements = page.getTotalElements();    // 전체 데이터 수
boolean hasNext = page.hasNext();                // 다음 페이지 존재 여부
int number = page.getNumber();                   // 현재 페이지 번호

Page는 ** 총 개수를 알아야** 하므로 별도의 COUNT 쿼리가 자동으로 실행됩니다.

SQL
-- 실제 실행되는 쿼리 2개
SELECT * FROM member WHERE age = 20 ORDER BY id LIMIT 10 OFFSET 0;
SELECT COUNT(*) FROM member WHERE age = 20;

Slice

JAVA
Slice<Member> slice = memberRepository.findByTeamName("teamA", PageRequest.of(0, 10));

List<Member> content = slice.getContent();
boolean hasNext = slice.hasNext();    // 다음 페이지 존재 여부
// slice.getTotalElements()  — 사용 불가!
// slice.getTotalPages()     — 사용 불가!

Slice는 요청한 크기 + 1개 를 조회하여 다음 페이지 존재 여부만 확인합니다. COUNT 쿼리가 없으므로 성능상 유리합니다.

SQL
-- 실제 실행되는 쿼리 1개 (11건 조회 → 다음 페이지 존재 여부 판단)
SELECT * FROM member WHERE team_name = 'teamA' ORDER BY id LIMIT 11 OFFSET 0;

언제 무엇을 사용할까

상황추천
전통적인 페이지 번호 UI (1, 2, 3...)Page
모바일 무한 스크롤Slice
전체 수가 필요 없는 단순 목록List + Pageable

COUNT 쿼리 최적화

Page를 사용할 때 COUNT 쿼리가 성능 병목이 될 수 있습니다. 특히 조인이 포함된 쿼리에서 COUNT까지 조인하면 불필요한 비용이 발생합니다.

별도 COUNT 쿼리 지정

JAVA
@Query(value = "SELECT m FROM Member m LEFT JOIN m.team t WHERE m.age > :age",
       countQuery = "SELECT COUNT(m) FROM Member m WHERE m.age > :age")
Page<Member> findByAgeWithCount(@Param("age") int age, Pageable pageable);

countQuery를 별도로 지정하면 불필요한 조인 없이 COUNT만 효율적으로 실행할 수 있습니다.

PageableExecutionUtils

JAVA
// QueryDSL 등에서 커스텀 페이징 시 사용
JPAQuery<Long> countQuery = queryFactory
    .select(member.count())
    .from(member)
    .where(condition);

return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);

이 유틸리티는 다음 경우에 **COUNT 쿼리를 실행하지 않습니다 **.

  • 첫 페이지이면서 content 크기가 pageSize보다 작을 때 (전체 데이터가 한 페이지 이내)
  • 마지막 페이지일 때 (offset + content 크기로 전체 수 계산 가능)

정렬 주의사항

엔티티 필드명 사용

JAVA
// Controller에서 요청 파라미터로 정렬 받기
// GET /members?page=0&size=10&sort=age,desc&sort=name,asc
@GetMapping("/members")
public Page<MemberDto> list(Pageable pageable) {
    return memberRepository.findAll(pageable).map(MemberDto::from);
}

복잡한 정렬은 직접 처리

Spring Data의 Sort는 엔티티 필드명 기반이므로, 함수나 CASE 표현식을 사용한 정렬은 지원하지 않습니다.

JAVA
// 이런 정렬은 Sort로 불가능 → 직접 쿼리 작성
// ORDER BY CASE WHEN status = 'ACTIVE' THEN 0 ELSE 1 END

@Query("SELECT m FROM Member m ORDER BY " +
       "CASE WHEN m.status = 'ACTIVE' THEN 0 ELSE 1 END")
List<Member> findAllOrderByStatus(Pageable pageable);

커서 기반 페이징 (무한 스크롤)

오프셋 기반 페이징은 뒤쪽 페이지로 갈수록 성능이 저하됩니다. OFFSET 100000이면 DB가 100,000건을 읽고 건너뛴 후 결과를 반환하기 때문입니다.

오프셋 기반의 문제

SQL
-- 100,001번째부터 10건 → 앞의 100,000건을 모두 스캔
SELECT * FROM member ORDER BY id LIMIT 10 OFFSET 100000;

커서 기반 해결

JAVA
public List<MemberDto> findMembersAfter(Long lastId, int size) {
    return queryFactory
        .select(new QMemberDto(member.id, member.name, member.age))
        .from(member)
        .where(
            member.id.gt(lastId)  // 마지막 조회 ID 이후부터
        )
        .orderBy(member.id.asc())
        .limit(size + 1)          // 다음 페이지 존재 여부 확인용
        .fetch();
}
JAVA
// Controller
@GetMapping("/members")
public CursorResponse<MemberDto> list(
        @RequestParam(required = false) Long lastId,
        @RequestParam(defaultValue = "20") int size) {

    List<MemberDto> result = memberQueryRepository
        .findMembersAfter(lastId != null ? lastId : 0L, size);

    boolean hasNext = result.size() > size;
    if (hasNext) {
        result = result.subList(0, size); // 마지막 1건 제거
    }

    Long nextCursor = hasNext ? result.get(result.size() - 1).getId() : null;
    return new CursorResponse<>(result, nextCursor, hasNext);
}

커서 기반의 장단점

항목오프셋 기반커서 기반
성능뒤쪽 페이지에서 저하일정한 성능
페이지 번호 이동가능불가능
데이터 추가/삭제 시중복/누락 가능안전
구현 복잡도단순상대적으로 복잡

주의할 점

Page 사용 시 COUNT 쿼리가 성능 병목이 될 수 있다

Page는 전체 개수를 알기 위해 별도의 COUNT 쿼리 를 실행합니다. 조인이 포함된 쿼리에서 COUNT까지 조인하면 불필요한 비용이 발생합니다. countQuery를 별도로 지정하거나, 전체 수가 필요 없으면 Slice를 사용해야 합니다.

OFFSET이 큰 페이징은 성능이 급격히 저하된다

OFFSET 100000은 DB가 100,000건을 읽고 건너뛴 후 결과를 반환하기 때문에, 뒤쪽 페이지일수록 성능이 나빠집니다. 대량 데이터의 무한 스크롤에는 **커서 기반 페이징 **(WHERE id > :lastId)이 훨씬 유리합니다.

Spring Data Sort는 함수 기반 정렬을 지원하지 않는다

Sort는 엔티티 필드명 기반이므로 CASE WHEN이나 DB 함수를 사용한 정렬은 표현할 수 없습니다. 이런 경우 @Query로 직접 작성해야 합니다.

정리

항목설명
PageCOUNT 쿼리 포함, 전체 페이지 정보 제공
SliceCOUNT 없이 limit+1로 다음 페이지 여부만 확인
COUNT 최적화countQuery 분리, PageableExecutionUtils 활용
커서 기반WHERE id > :lastId로 일정한 성능 유지
페이지 번호Spring Data는 0부터 시작
댓글 로딩 중...