[스프링] 스프링 Data JPA(4) JPA 페이징과 정렬

2025. 8. 21. 17:52·스프링 데이터 JPA

이번 글에서는 JPA에서 페이징과 정렬을 어떻게 처리하는지 살펴본다.
먼저 순수 JPA로 페이징을 구현해보고, 그다음 스프링 데이터 JPA로 같은 기능을 얼마나 간단히 처리할 수 있는지 비교해보자.

 

1. 순수 JPA에서의 페이징과 정렬

검색 조건: 나이 = 10
정렬 조건: 이름 내림차순
페이징 조건: 첫 번째 페이지, 페이지당 3건

 

리포지토리 코드

public List<Member> findByPage(int age, int offset, int limit) {
    return em.createQuery("select m from Member m where m.age = :age order by m.username desc")
            .setParameter("age", age)
            .setFirstResult(offset)   // 시작 위치
            .setMaxResults(limit)     // 가져올 개수
            .getResultList();
}

 

totalCount 계산

public long totalCount(int age) {
    return em.createQuery("select count(m) from Member m where m.age = :age", Long.class)
            .setParameter("age", age)
            .getSingleResult();
}

 

테스트 코드

@Test
public void paging() throws Exception {
    //given
    memberJpaRepository.save(new Member("member1", 10));
    memberJpaRepository.save(new Member("member2", 10));
    memberJpaRepository.save(new Member("member3", 10));
    memberJpaRepository.save(new Member("member4", 10));
    memberJpaRepository.save(new Member("member5", 10));

    int age = 10;
    int offset = 0;
    int limit = 3;

    //when
    List<Member> members = memberJpaRepository.findByPage(age, offset, limit);
    long totalCount = memberJpaRepository.totalCount(age);

    //then
    assertThat(members.size()).isEqualTo(3);
    assertThat(totalCount).isEqualTo(5);
}

 

순수 JPA로도 페이징이 가능하지만, 쿼리를 두 번(데이터 조회 + count 조회) 날려야 하고, 페이지 계산도 직접 해야 한다는 단점이 있다.

 

2. 스프링 데이터 JPA에서의 페이징과 정렬

스프링 데이터 JPA는 페이징을 위한 표준 인터페이스를 제공한다.

  • Sort: 정렬 조건
  • Pageable: 페이징 조건 (인터페이스)
  • PageRequest: Pageable의 구현체 (page 번호, size, sort 설정 가능)

리포지토리 메소드 정의

public interface MemberRepository extends JpaRepository<Member, Long> {
    Page<Member> findByAge(int age, Pageable pageable);
}

 

실행 코드

@Test
public void page() throws Exception {
    //given
    memberRepository.save(new Member("member1", 10));
    memberRepository.save(new Member("member2", 10));
    memberRepository.save(new Member("member3", 10));
    memberRepository.save(new Member("member4", 10));
    memberRepository.save(new Member("member5", 10));

    //when
    PageRequest pageRequest = PageRequest.of(0, 3,
            Sort.by(Sort.Direction.DESC, "username"));

    Page<Member> page = memberRepository.findByAge(10, pageRequest);

    //then
    List<Member> content = page.getContent(); //조회 데이터
    assertThat(content.size()).isEqualTo(3);       // 조회된 데이터 수
    assertThat(page.getTotalElements()).isEqualTo(5); // 전체 데이터 수
    assertThat(page.getNumber()).isEqualTo(0);        // 현재 페이지 번호 (0부터 시작)
    assertThat(page.getTotalPages()).isEqualTo(2);    // 전체 페이지 수
    assertThat(page.isFirst()).isTrue();              // 첫 페이지 여부
    assertThat(page.hasNext()).isTrue();              // 다음 페이지 여부
}
 

위 코드에서 PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"))는 0번 페이지(첫 페이지)를 요청하고, 한 페이지에는 3건씩 담으며, username 필드를 기준으로 내림차순 정렬하겠다는 의미다.

 

여기서 꼭 기억해야 할 점은, 스프링 데이터 JPA의 페이지는 1이 아니라 0부터 시작한다는 것이다. 따라서 PageRequest.of(0, …)는 첫 번째 페이지, PageRequest.of(1, …)는 두 번째 페이지를 의미한다.

 

실행 결과로 반환된 Page<Member> 객체는 단순히 데이터 리스트만 제공하는 것이 아니라, 페이징에 필요한 다양한 부가 정보도 함께 제공한다.

  • page.getContent() → 실제 조회된 데이터
  • page.getTotalElements() → 전체 데이터 개수
  • page.getTotalPages() → 전체 페이지 수
  • page.getNumber() → 현재 페이지 번호
  • page.isFirst() / page.hasNext() → 첫 페이지 여부, 다음 페이지 존재 여부

즉, 순수 JPA로 구현했을 때처럼 결과 쿼리와 카운트 쿼리를 따로 날리고, 페이지 수 계산을 직접 하는 수고를 덜 수 있다. Page 객체 하나만으로도 목록 화면을 구성하는 데 필요한 거의 모든 정보가 자동으로 준비되기 때문이다.

 

이 점이 바로 스프링 데이터 JPA 페이징의 가장 큰 장점이다. 정렬 조건, 페이징 계산, 전체 카운트 처리까지 표준 API로 통일된 방식을 제공하므로, 코드가 훨씬 간결하고 유지보수도 쉽다.

 

3. Page → DTO 변환하기

실무에서는 엔티티 자체를 그대로 API 응답으로 반환하는 경우가 거의 없다.
대부분 화면에 필요한 데이터만 담은 DTO로 변환해서 반환한다.

스프링 데이터 JPA의 Page 객체는 map() 함수를 제공하므로, 간단하게 DTO로 변환할 수 있다.

Page<Member> page = memberRepository.findByAge(10, pageRequest);

// 엔티티를 DTO로 변환
Page<MemberDto> dtoPage = page.map(member -> new MemberDto(member.getId(), member.getUsername()));

이렇게 하면 조회된 데이터뿐 아니라, Page가 가진 전체 카운트, 페이지 번호, 다음 페이지 여부 등의 부가 정보도 그대로 유지된다.
즉, 단순히 결과 데이터만 바꿔치기하는 방식이라 페이징 정보와 API 응답 구조를 그대로 재사용할 수 있다.

 

4. Page vs Slice vs List

스프링 데이터 JPA는 페이징 결과를 받을 때 3가지 옵션을 제공한다.

  • Page: 조회 결과 + 전체 데이터 개수 + 전체 페이지 수까지 필요한 경우
    → 내부적으로 count 쿼리를 추가 실행한다.
  • Slice: 다음 페이지가 있는지만 알면 되는 경우
    → count 쿼리를 실행하지 않고, 요청한 개수보다 하나 더 조회해서 다음 페이지 존재 여부만 판단한다.
  • List: 단순히 결과 데이터만 필요할 때

예를 들어 모바일 앱에서 “무한 스크롤” 형태로 구현한다면 전체 페이지 수는 불필요하므로, Slice를 활용하는 것이 성능적으로 유리하다.

 

5. Count 쿼리 최적화

앞서 Page를 사용하면 데이터 조회 쿼리와 함께 자동으로 count 쿼리도 실행된다고 했다.
그런데 복잡한 join이 포함된 조회 쿼리의 경우, 동일한 join이 count 쿼리에도 그대로 들어가면 불필요한 성능 낭비가 발생한다.

이럴 때는 @Query 애노테이션을 이용해 countQuery를 따로 분리할 수 있다.

@Query(
    value = "select m from Member m left join m.team t",
    countQuery = "select count(m) from Member m"
)
Page<Member> findAllWithTeam(Pageable pageable);
  • value : 실제 데이터 조회용 JPQL
  • countQuery : 카운트 전용 쿼리

이렇게 하면 데이터 조회는 복잡한 join을 포함하되, 카운트는 단순히 Member만 세므로 성능을 크게 개선할 수 있다.

 

6. Slice 활용과 무한 스크롤

실무에서 모바일 앱이나 웹에서 무한 스크롤을 구현할 때는 전체 데이터 개수(totalCount)가 꼭 필요하지 않다.
이 경우 Page 대신 Slice를 사용하면 더 효율적이다.

 
Slice<Member> slice = memberRepository.findByAge(10, PageRequest.of(0, 3));
  • 내부적으로 count 쿼리를 실행하지 않는다.
  • 대신 요청한 개수보다 하나 더 조회해서 "다음 페이지가 있는지" 여부만 판단한다.
  • 성능 최적화 + 무한 스크롤에 최적화된 방식.

7. 실무에서 주의할 점

  1. Page는 항상 count 쿼리가 추가 실행된다.
    → 불필요하다면 Slice나 List를 활용할 것.
  2. 복잡한 조회 쿼리에서는 countQuery 최적화가 필수적이다.
    → 특히 대규모 데이터 환경에서 전체 count는 가장 무거운 연산 중 하나.
  3. API 응답에는 반드시 DTO 변환을 권장한다.
    → 엔티티 그대로 반환하면 양방향 연관관계나 LAZY 로딩 때문에 예기치 못한 문제가 발생한다.

 

마무리하며

순수 JPA만으로도 페이징을 구현할 수 있지만, 쿼리 2번과 직접적인 계산이 필요해 다소 번거롭다.
반면 스프링 데이터 JPA는 Pageable과 Page를 중심으로 페이징과 정렬을 표준화하여 훨씬 간결한 코드와 풍부한 부가 정보를 제공한다.

 

특히 Page 객체를 통해 데이터뿐만 아니라 전체 개수, 페이지 수, 현재 위치, 다음 페이지 여부까지 한 번에 얻을 수 있으며, DTO 변환까지 손쉽게 처리할 수 있다.


여기에 Slice를 활용한 무한 스크롤, countQuery 최적화를 통한 성능 개선 등 상황에 맞는 전략을 더하면 실무에서도 충분히 강력하고 유연하게 활용할 수 있다.

 

결국 스프링 데이터 JPA의 페이징 기능은 단순한 편의성을 넘어, 유지보수성과 확장성을 크게 높여주는 도구라는 점이 가장 큰 장점이다.

 

감사합니다.

'스프링 데이터 JPA' 카테고리의 다른 글

[스프링] 스프링 Data JPA(6) @EntityGraph  (0) 2025.08.21
[스프링] 스프링 Data JPA(5) 벌크성 수정쿼리  (0) 2025.08.21
[스프링] 스프링 Data JPA(3) @Query, 리포지토리 메소드에 쿼리 정의하기  (0) 2025.08.21
[스프링] 스프링 Data JPA(2) 메소드 이름으로 쿼리생성  (1) 2025.08.21
[스프링] 스프링 Data JPA에 대하여  (4) 2025.08.16
'스프링 데이터 JPA' 카테고리의 다른 글
  • [스프링] 스프링 Data JPA(6) @EntityGraph
  • [스프링] 스프링 Data JPA(5) 벌크성 수정쿼리
  • [스프링] 스프링 Data JPA(3) @Query, 리포지토리 메소드에 쿼리 정의하기
  • [스프링] 스프링 Data JPA(2) 메소드 이름으로 쿼리생성
0kingki_
0kingki_
자바 + 스프링 웹 개발
  • 0kingki_
    0kingki_
    0kingki_
  • 전체
    오늘
    어제
    • 분류 전체보기 (134)
      • 코딩 테스트 (54)
      • 자바 (21)
      • 스프링 (27)
      • 타임리프 (16)
      • 스프링 데이터 JPA (8)
      • 최적화 (2)
      • QueryDSL (4)
      • AWS (2)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    예외 처리
    자바
    BFS
    Java
    타임리프
    QueryDSL
    스프링 데이터 JPA
    스프링 컨테이너
    최적화
    코딩 테스트
    쿼리
    재귀
    코딩테스트
    스프링
    객체지향
    SOLID
    thymeleaf
    쿼리dsl
    mvc
    fetch join
    다형성
    불변객체
    JPA
    컬렉션
    백준
    spring
    dfs
    SpringDataJpa
    예외처리
    LocalDateTime
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
0kingki_
[스프링] 스프링 Data JPA(4) JPA 페이징과 정렬
상단으로

티스토리툴바