JPA : Pageable 객체를 이용한 페이징

    이 포스팅은 인프런 영한님의 강의를 복습하며 작성한 글입니다.

     


    페이징 조건

    • 검색조건 : 나이가 10살
    • 정렬 조건 : 이름으로 내림차순
    • 페이징 조건 : 첫번째 페이지, 페이지당 보여줄 데이터는 3건 

    위 조건을 페이징해서 가져와야한다고 하자. 이 때, 순수 JPA와 스프링 데이터 JPA를 사용해서 각각 페이징을 해보고자 한다. 

     


    순수 JPA 페이징

    페이지에 맞는 회원 가져오기

    public List<Member> findByPage(int age, int offset, int limit) {
        return queryFactory.selectFrom(member)
                .where(member.age.eq(age))
                .orderBy(member.username.desc())
                .offset(offset)
                .limit(limit).fetch();
    }

     

    페이징을 위한 Total Count 가져오기

    public Long totalCount(int age) {
        return queryFactory.select(member.count())
                .from(member)
                .where(member.age.eq(age))
                .fetchOne();

     

    페이지 테스트 코드1  → 5명 중, 0 페이지 3명 불러오기

    @Test
    public void paging() {
    
        //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);
    
    
        for (Member member : members) {
            System.out.println("member = " + member);
        }
        System.out.println("totalCount = " + totalCount);
    }

    0페이지를 시작으로 3명을 가져오는 쿼리를 보냈다. 이 때, 이름순으로 역순으로 정렬되어 온다. 

     

    Select 쿼리 확인

    OffSet이 0이기 때문에 OffSet은 따로 나가지 않는 것을 확인할 수 있다. 

     

    실행 결과

    실행결과를 확인해보면, 이름 순으로 역순으로 정리되어 쿼리가 나갔기 때문에 member3~5 회원들이 불러와졌다. 

     

    페이지 테스트 코드2  → 5명 중, 1 페이지 3명 불러오기

    @Test
    public void paging() {
    
        //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 = 1;
        int limit = 3;
    
        //when
        List<Member> members = memberJpaRepository.findByPage(age, offset, limit);
        Long totalCount = memberJpaRepository.totalCount(age);
    
    
        for (Member member : members) {
            System.out.println("member = " + member);
        }
        System.out.println("totalCount = " + totalCount);
    }

    1페이지를 시작으로 3명을 가져오는 쿼리를 보냈다. 이 때, 이름순으로 역순으로 정렬되어 온다.

     

    Select 쿼리 확인

    Offset이 1이기 때문에 이 때는 Offset이 SQL 쿼리에 적용되어 나가는 것을 확인할 수 있다. 

     

    실행 결과

    1페이지부터 3명을 불러와야하는데, 1페이지에는 2명 밖에 존재하지 않는다. 그런데 3명을 불러오게 되니 약간의 내부적으로 문제가 발생하는 것을 확인할 수 있었다. 

    • 기대값 : member1, member2
    • 실제값 : member2, member3, member4

     

     

    정리

    마지막 페이지가 애매하게 되었을 때는 불러오는 값이 굉장히 정합성이 떨어진다. 따라서, 순수 JPA 코드를 사용해 페이징을 한다고 하면 Total Count를 먼저 불러와서 그에 맞도록 동적 페이징 코드를 작성하는 식으로 해결이 필요하다. 

     


    스프링 데이터 JPA 페이징

    위에서 순수 JPA를 사용하게 되면 Total Count를 뽑아와서 현재 내가 몇번째 페이지인지를 계산해서 표시를 해주어야 한다. 그리고 이런 계산은 매우 복잡하다! 이 복잡한 계산을 해결해주기 위해 스프링 데이터 JPA는 페이징과 정렬을 표준화 해두었다. 

     

    페이징과 정렬 파라미터

    • org.springframework.data.Sort : 정렬 기능 
    • org.springframework.data.Pageable: 페이징 기능 (내부에 Sort 포함)

    org.springframework.data 내부에 Sort와 Pageable 클래스가 존재한다. 즉, 모든 DB의 페이징이 공통화 되있음을 의미한다. 

     

    특별한 반환타입

    • org.springframework.data.domain.Page : 페이지 + Total Count 쿼리 포함
    • org.springframework.data.domain.Slice : 페이지만 확인, 내부적으로 limit + 1조회
    • List : 추가 count 쿼리 없이 결과만 반환(페이징 기능 없음)

    Page 형태로 값을 반환받으면 내부적으로 Total Count가 포함되어있다. 편리하지만, 이 때의 문제점은 복잡한 Count 쿼리가 나갈 수 있다는 점이다. 예를 들어 Count 쿼리에 쓸데없는 Join 쿼리가 덕지덕지 붙어 나갈 때가 있는데, 이것이 실제로는 필요 없을 수가 있다. 이런 부분의 최적화가 필요하다.

    또한 Total Count 쿼리는 굉장히 무거운 쿼리가 될 수 있다. 왜냐하면 DB의 모든 테이블을 살펴서 Total Count가 몇개인지를 보기 때문이다. 이런 이유로 데이터가 많은 서비스에서는 Total Count 쿼리 자체가 부담이 될 수 있다. 

    Slice는 Total Count 쿼리는 나가지 않는다. 대신에 내부적으로 Limit + 1로 페이징을 해준다. 그래서 내부적으로 다음 페이지가 있다 없다를 표현해줄 수 있다. 객체를 Limit + 1만큼 표현해주지는 않고, 내부적으로 다음 페이지가 있는지를 표현하는데 사용한다.

     

    스프링 데이터 JPA 페이지 네임 메소드로 작성해보기

    위에서 이야기한 것처럼 Page나 Slice 객체를 반환 타입으로 받게 되면 자동으로 페이징이 된다. 따라서 위 객체를 반환 타입으로 받도록 해야한다. 

     

    Jpa Repository 인터페이스 내부 메서드

    Page<Member> findByAge(int age, Pageable pageable);
    • 다음과 같이 내부 메서드를 작성한다. 메서드 이름으로 자동으로 쿼리가 만들어지도록 한다. 
    • 위는 네임 메서드 자동 생성 기능을 이용해서 쿼리를 만든 것이다. 아래에 직접 쿼리를 작성하는 부분도 추가하고자 한다.

     

    Pageable 객체 넘겨주기

    PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));
    • 메서드에 Pageable을 인자로 넘겨주었다. 
    • Pageable은 인터페이스고, 이런 구현한 객체를 만들어야한다.
      • PageRequest.of()를 이용해 Pageable 구현체를 만들 수 있다. 
      • Offset, limit, 그리고 정렬 기능을 넣을 수 있다. 

     

    테스트 코드 작성

        @Test
        public void paging() {
    
            //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));
    		
            // Pageable 객체 생성
            int age = 10;
            PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));
    
    		// Page 객체 조회
            Page<Member> page = memberRepository.findByAge(age, pageRequest);
    
    		// page 객체를 통해 member → memberDto로 변경
            page.map(member -> new MemberDto(member.getId(), member.getUsername(), null));
    
    
    		// Slice는 limit + 1 만큼 객체를 가져온다.
    		// Slice<Member> page = memberRepository.findByAge(age, pageRequest);
    
            // 페이징 이런 거 필요없이, 데이터만 10개씩 끊어서 가져오라라고 이야기 할 수 있음.
            // JPA 리포지토리는 반환 타입으로 이런 것들을 해줄 수 있음.
            // List<Member> page = memberRepository.findByAge(age, pageRequest);
    
    
            //then
    		// 페이지 내부의 데이터 꺼내기
    		List<Member> content = page.getContent();
            
            // 토탈 페이지 수 확인
            long totalElements = page.getTotalElements(); 
            for (Member member : content) {
                System.out.println("member = " + member);
            }
            
            System.out.println("totalElements = " + totalElements);
    
            // 멤버 객체의 사이즈 확인
            assertThat(content.size()).isEqualTo(3);
            
            // 토탈 페이지수 확인
            assertThat(page.getTotalElements()).isEqualTo(5);
    
            // 페이지 번호 가져오기. JPA 페이지 시작 번호는 0
            assertThat(page.getNumber()).isEqualTo(0);
    
            // 전체 토탈 페이지 개수 확인 (3명 / 2명, limit3이기 때문에 페이지는 2개)
            assertThat(page.getTotalPages()).isEqualTo(2);
    
            // 첫번째 페이지인지 알려준다.
            assertThat(page.isFirst()).isTrue();
    
            // 다음 페이지가 있는지도 알려준다.
            assertThat(page.hasNext()).isTrue();
        }

    다음과 같은 테스트 코드를 작성할 수 있다. 

     

    실행 쿼리 확인

    • 스프링 데이터 JPA를 활용해 자동 쿼리를 생성한 것이고, 이 때 반환 타입을 Page로 받았다.
    • 따라서 객체 조회 쿼리 + Total Count 쿼리가 함께 나가는 것을 알 수 있음. 

     

    실행결과 - Page 반환 타입 사용했을 때 

    0페이지부터 3개를 가져오라고 했을 때의 실행 결과다. 실행결과를 확인하면 정상적으로 0페이지의 3개를 가져온 것을 확인할 수 있다. 

    1페이지부터 3개를 가져오라고 했을 때의 실행 결과다. 총 5명이 DB에 있고, Limit가 3으로 되어있기 때문에 각 페이지는 3/2로 나누어진다. 그래서 1페이지는 2명이 있는 것이 맞고 정상적으로 member1, member2를 가져오는 것을 볼 수 있다. 

     

    Slice 타입을 활용한 페이징

    테스트 코드

    @Test
    public void pagingSlice() {
        //given
        //shift + f6으로 한번에 바꿀 수 있음.
        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));
    
        int age = 10;
        PageRequest pageRequest = PageRequest.of(1, 1, Sort.by(Sort.Direction.DESC, "username"));
        Slice<Member> page = memberRepository.findSliceByAge(age, pageRequest);
    
        List<Member> content = page.getContent();
        for (Member member : content) {
            System.out.println("member = " + member);
        }
    
        System.out.println("page.getNumber() = " + page.getNumber());
        System.out.println("page.getSize() = " + page.getSize());
        System.out.println("page.getNumberOfElements() = " + page.getNumberOfElements());
        System.out.println("page.isFirst() = " + page.isFirst());
        System.out.println("page.hasNext() = " + page.hasNext());
        System.out.println("page.hasPrevious() = " + page.hasPrevious());
    
    }

    총 5명을 저장하고 한 페이지당 1명씩으로 설정했다. 그리고 1번 페이지를 가져와 Slice 타입으로 반환하고 그 결과를 출력하는 코드를 작성했다. 

     

    실행 쿼리 확인

    • 쿼리는 Slice이기 때문에 Total Count 쿼리는 나가지 않았다. 멤버 객체만 가져오는 쿼리만 나갔다.
    • 이 때 주목할 것은 Limit에 "1"이 아닌 "2"가 나갔다는 것이다. 즉, Limit + 1만큼 더 가져와서 다음 페이지가 있는지 없는지를 판단하겠다는 뜻이다. 

     

    실행 결과 

    실행 결과 Member는 1명만 출력이 된다. Limit에 Limit + 1이 나갔기 때문에 2명이 나올 것으로 기대를 했었는데, 1명만 나온다는 것이 확인되었다. 

    • getNumber를 통해 현재 페이지를 알 수 있다.
    • getSize를 통해 현재 페이지의 Limit를 알 수 있다. 
    • getNumberOfElements를 통해 현재 페이지에 나올 데이터 수를 알 수 있다.
    • isFirst를 통해 첫번째 페이지인지 확인할 수 있다. JPA는 0번부터 시작이기 때문에 False가 나왔다.
    • 다음 페이지가 있는지, 이전 페이지가 있는지도 확인할 수 있다. 

    offset = 2, limit = 10을 주었다. 즉 0페이지만 존재하는 상황에서 2페이지를 검색했을 때 결과를 확인했다. 확인해보면 출력되는 객체는 따로 없는 것이 확인되었다. 정상이란 이야기다. 즉, 페이징을 할 때 좀 더 안심하고 쓸 수 있게 되었다고 보면 된다. 

     

    스프링 데이터 JPA Repository 인터페이스 Page 타입 사용 시, Count 쿼리 최적화

    기본적으로 스프링 데이터 JPA Repository 인터페이스가 Page 타입을 받을 때 만들어주는 Count 쿼리는 기본 객체를 가져오는 쿼리와 동일하게 보낸다. 즉, 이런저런 조인등을 다 해서 보내기 때문에 쿼리의 최적화가 필요하다. 

        @Query(countQuery = "select count(m) from Member m where m.age =:age")
        Page<Member> findByAge(int age, Pageable pageable);

    쿼리의 최적화를 하기 위해서는 네임 메서드 위에 @Query 어노테이션에 "countQuery" 옵션 값에 원하는 쿼리를 직접 작성해주면 된다. 

     

    정리

    Page나 Slice 인터페이스를 사용하게 되면, 마지막 페이지의 애매한 부분까지 잘 처리되어 가져오는 것이 확인되었다. 따라서 페이징 기능을 위해 Page나 Slice를 적극 활용하는 것이 좋을 것 같다. 

     

    QUERY DSL을 이용한 페이징 코드 작성

    위의 예시는 스프링 데이터 JPA가 자동으로 생성해주는 메서드 쿼리를 이용한 기능이다. 이번에는 QUERY DSL을 이용해 직접 사용자 리포지토리에 쿼리를 작성하고 그것을 이용하는 방법을 정리했다.

     

    Pageable 만들어서 넘겨주기

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

    다음과 같이 Pageable을 만들어서 넘겨준다.

     

    Page 반환 타입의 Query DSL 쿼리 짜기 → new PageImpl<>()로 반환하기

    @Override
    public Page<Member> findHelloPageMemberCustom(int age, Pageable pageable) {
        List<Member> result = queryFactory.select(member)
                .from(member)
                .where(member.age.eq(age))
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();
    
    
        Long totalCount =  queryFactory.select(member.count())
                .from(member)
                .where(member.age.eq(age))
                .fetchOne();
    
        return new PageImpl<>(result, pageable, totalCount);
    }

    Page 타입에는 실제 객체와 Total Count가 들어가야한다. 따라서 두 가지를 만들어서 PageImpl에 넣어주어야 한다.

    1. 먼저 Select 쿼리를 통해 실제 객체를 가져온다.
    2. Total Count 쿼리를 작성해서 보낸다.

    결과물을 new PageImpl<>()을 통해 Page 구현체를 만들어서 반환해준다. 이 때, 인자로 받은 pageable을 같이 넣어주어야 한다. 

     

    Slice 반환 타입의 Query DSL 쿼리 짜기 → new SliceImpl<>()로 반환하기

    @Override
    public Slice<Member> findHelloSliceMemberCustom(int age, Pageable pageable) {
    
    
        List<Member> result = queryFactory.select(member)
                .from(member)
                .where(member.age.eq(age))
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize() + 1)
                .fetch();
    
    
        List<Member> slicePageResult = getSlicePageResult(result, pageable.getPageSize());
        return new SliceImpl<>(slicePageResult, pageable, hasMemberNext(result, pageable.getPageSize()));
    
    }
    
    private List<Member> getSlicePageResult(List<Member> result, int limit) {
        List<Member> returnValue = new ArrayList<>();
        int cnt = 0;
        for (Member member1 : returnValue) {
            if (cnt == limit) {
                break;
            }
            returnValue.add(member1);
            cnt++;
        }
        return returnValue;
    }
    
    
    private Boolean hasMemberNext(List<Member> result, int limit) {
        return result.size() > limit ? true : false;
    }

    Slice 반환 타입을 만들기 위해서는 다음이 핵심이다.

    • Limit + 1 쿼리를 보내서 다음 페이지가 있는지 확인할 수 있도록 한다.
    • 쿼리로 가져온 데이터를 바탕으로 다음 페이지가 있는지를 확인하고, Limit만큼의 객체를 반환할 수 있도록 한다. 

    위 목적을 달성한 다음에 new SliceImpl<>()을 통해 필요한 값들을 넣어주고 반환해주면 Slice 형태를 사용할 수 있다.  위 목적을 달성하기 위해 두 가지 도우미 함수를 만들어주었다.

    • limit만큼의 객체를 가지는 배열을 만들어주는 함수
    • 다음 페이지가 있는지를 확인하는 함수

     

    댓글

    Designed by JB FACTORY