본문 바로가기
Spring

[Spring Data JPA] 페이징과 정렬

by 개미가되고싶은사람 2025. 1. 3.

목차

    데이터를 일정한 크기로 나누는 페이징 처리를 이용해 사용자에게 표시하는 경우는 상당히 많습니다. 또한, 페이징 하는 과정에서 특정 기준에 따라 데이터를 정렬해야 하는 경우도 있습니다. 이번 포스팅에서는 Spring Data JPA를 통해 쉽게 페이징하고 정렬하는 방법을 알아보겠습니다.

     

     

    초기 설정

    더보기
    @Entity
    @Getter
    @Setter
    @ToString(of = {"id", "username", "age"})
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    public class Member {
    
        @Id
        @GeneratedValue
        @Column(name = "member_id")
        private Long id;
        private String username;
        private int age;
    
        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn(name = "team_id")
        private Team team;
        
        ....
    }

     

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

     

    페이징

    데이터베이스의 대용량 데이터를 가져와 가공할 때 페이지 단위로 데이터를 분할하여 가져오는 기능을 페이징이라고 합니다. 이런 기능을 Spring Data JPA이 제공해 편리하게 사용할 수 있습니다.

     

    1. Pageable

    Pageable은 페이징 및 정렬을 처리하고, 페이징된 데이터를 검색 및 정렬하는 데 필요한 페이지 번호, 페이지 크기정렬 기준(Sort를 통해)을 캡슐화하고 Repository에 전달될 때 결과가 반환되도록 하는 Spring Data JPA의 인터페이스입니다. 페이지 번호 및 페이지 크기와 같은 자세한 메타 데이터를 포함하는 Page 객체 또는 다음 페이지가 있는지 여부와 같은 기본 페이지 매김 정보를 제공하는 Slice 객체로 사용됩니다. 기본적으로 첫 번째 페이지는 0으로 표시됩니다.

            /**
             * 1. PageRequest 객체 생성
             * PageRequest.of() 메소드를 사용하여 페이지 번호와 페이지 크기를 전달하며 PageRequest 객체를 생성
             * 이때 정렬 조건이 있는 경우 추가적으로 Sort 객체도 함께 인자로 전달 가능
             */
            Pageable firstPageWithFiveElements = PageRequest.of(0, 5);
            
            /**
             * Sort 사용한 예시
             * Pageable pageRequest = PageRequest.of(
             *                 0, 5, Sort.by(Sort.Direction.DESC, "username"));
             * Pageable pageRequest = PageRequest.of(
             *       0, 5,
             *       Sort.by(Sort.Direction.DESC, "username")
             *               .and(Sort.by(Sort.Direction.DESC, "age")));));
             */
    
            /**
             * 2. Repository 계층 메소드 사용
             * Pageable 객체를 Repository 메소드의 매개변수로 전달하여 페이징 처리
             */
            Page<Member> allMembers = memberRepository.findAll(firstPageWithFiveElements);
    
            /**
             * 3. 결과 사용
             * Repository에서 반환된 Page 객체를 사용하여 결과 정보와 다양한 메타데이터 등을 다룰 수 있습니다.
             * (당연히 엔티티를 직접 조회하지 않고 다음과 같이 DTO로 변환해 사용해야 안전합니다.)
             */
    
            Page<MemberDto> memberDtos = allMembers.map(m -> new MemberDto(m.getId(), m.getUsername()));
    
            List<MemberDto> content = memberDtos.getContent(); //조회된 데이터
            int contentSize = content.size();//조회된 데이터 수
            long totalCounts = memberDtos.getTotalElements();//전체 데이터 수
            int pageNumbers = memberDtos.getNumber();//페이지 번호
            int totalPages = memberDtos.getTotalPages();//전체 페이지 번호
            boolean isFirst = memberDtos.isFirst();//첫번째 항목인가?
            boolean hasNextPage = memberDtos.hasNext();//다음 페이지가 있는가?

    findAll (Pageable pageable) 메서드는 기본적으로 Page<T>  객체를 반환합니다. 하지만 페이지 관련 메소드를 정의할 때 반환 타입은 Page<T>, Slice <T> 또는 List<T>를 반환할 수 있습니다.

    Page<Member> findByUsername(String name, Pageable pageable); //count 쿼리 사용
    Slice<Member> findByUsername(String name, Pageable pageable); //count 쿼리 사용 x
    List<Member> findByUsername(String name, Pageable pageable); //count 쿼리 사용 x, 일정 페이지만 가져올 때 사용
    List<Member> findByUsername(String name, Sort sort);

    Page<T> 인스턴스는 일반적으로 특정 데이터 목록을 페이지 단위로 나누어 제공하는데, 사용 가능한 총 페이지 수를 알기 위해서는 추가적인 카운트 쿼리를 실행합니다. 이는 데이터베이스에 대한 추가적인 요청을 의미하므로 오버헤드가 발생합니다. 이러한 오버헤드를 피하기 위해 대신 Slice<T> 또는 List<T>와 같은 컬렉션을 반환하는 방법을 고려할 수 있습니다.

     

     

    2. Slice 와 Page

    Slice

    페이징을 구현할 때 전체 페이지 개수를 알 필요 없고, 추가적인 totalCount 쿼리가 실행되지 않도록 하려면 Slice를 사용하여 다음 페이지만 확인할 수 있는 기능을 개발할 수 있습니다. Slice는 내부적으로 limit + 1을 수행하는데, 예를 들어 페이지당 보여줄 데이터 수를 5으로 설정하면 실제로 6개의 데이터를 가져와서 다음 페이지가 있는지를 쉽게 판단할 수 있습니다. 이렇게 하면 성능 낭비를 줄일 수 있으며, 전체 데이터 수를 계산하지 않고도 필요한 데이터만 효율적으로 가져올 수 있습니다. 따라서, 페이징으로 개발한 기존 코드를 Slice로 반환 타입만 변경하면 무한 스크롤이나 더보기 기능으로 쉽게 전환할 수 있습니다.

    public interface Slice<T> extends Streamable<T> {
        int getNumber(); //현재 페이지
        int getSize(); //페이지 크기
        int getNumberOfElements(); //현재 페이지에 나올 데이터 수
        List<T> getContent(); //조회된 데이터
        boolean hasContent(); //조회된 데이터 존재 여부
        Sort getSort(); //정렬 정보
        boolean isFirst(); //현재 페이지가 첫 페이지인지 여부
        boolean isLast(); //현재 페이지가 마지막 페이지인지 여부
        boolean hasNext(); //다음 페이지 존재 여부
        boolean hasPrevious(); //이전 페이지 존재 여부
        Pageable getPageable(); //페이지 요청 정보 반환
        Pageable nextPageable(); //다음 페이지 객체 정보 반환
        Pageable previousPageable();//이전 페이지 객체 정보 반환
        <U> Slice<U> map(Function<? super T, ? extends U> converter); //Slice로 변환기
    }

    sql 구문은 데이터베이스의 종류에 따라 달라질 수 있습니다. (필자는 H2를 이용해서 limit가 아닌 fetch first 문법을 사용합니다.)

     

     

    Page

    Page는 Slice를 상속합니다. 즉 Slice 기능 외에 추가적인 전체 페이지, 전체 데이터 수를 반환하는 메소드를 가지고 있습니다.  Page 인터페이스는 다음과 같은 메서드를 추가로 제공합니다.

    public interface Page<T> extends Slice<T>{
    	getTotalPages(): 전체 페이지 수를 반환
    	getTotalElements(): 전체 데이터 수를 반환
    }

     

     

    CountQuery⭐⭐

    countQuery는 데이터베이스에서 페이징 처리된 결과를 가져올 때 전체 데이터 수를 효율적으로 계산하기 위해 사용되며, 이를 통해 효율성을 높이고 데이터의 일관성을 유지하며 복잡한 쿼리에서 동적으로 전체 데이터 수를 계산할 수 있는 유연성을 제공합니다. 만약 Page<T>를 사용하는 쿼리가 복잡할 경우, count 쿼리도 복잡해져 성능 최적화가 필요할 수 있습니다. 이때, count 쿼리를 분리하여 사용하면 성능을 개선할 수 있습니다. 특히, totalCount 쿼리는 매우 무겁기 때문에, 이를 별도로 처리함으로써 해당 쿼리의 성능을 향상시킬 수 있습니다.

    @Query(value = "select m " +
            "from Member m " +
            "left join m.team t",
            countQuery = "select count(m) " +
                    "from Member m")
    Page<Member> findAllByAge(int age, Pageable pageRequest);

     

     

     

     

    참고자료

    https://blog.naver.com/fbfbf1/222717885674

     

    Spring Data JPA 페이징/정렬 처리

    findAll() Pageable 인터페이스 페이징 처리 정렬 조건 추가하기

    blog.naver.com

     

    https://www.baeldung.com/spring-data-jpa-pagination-sorting