BackEnd/JPA

[JPA] 쿼리 메소드 기능 - 3

샤아이인 2022. 5. 4.

내가 공부한 것을 올리며, 중요한 단원은 저 자신도 곱씹어 볼 겸 상세히 기록하고 얕은 부분들은 가볍게 포스팅하겠습니다.

 

7. 순수 JPA 페이징과 정렬

이번 시간에는 순수하게 JPA를 사용해 페이징 처리를 해보고, 다음번에 Spring Data JPA를 통해 페이징 처리를 하자.

 

다음 조건에 맞도록 페이징 처리를 해보자.

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

 

우선 Repository 코드는 다음과 같다.

@Repository
public class MemberJpaRepository {

    @PersistenceContext
    private EntityManager em;

    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();

    }

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

findByPage를 통해 페이징 처리된 데이터를 가져오고, totalCount는 현제 페이지가 몇번째 페이지인지 확인할때 사용한다.

 

테스트 코드는 다음과 같다.

@Test
public void pasing_test() {
    // 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);

    //페이지 계산 공식 적용...
    // totalPage = totalCount / size ...
    // 마지막 페이지 ...
    // 최초 페이지 ..

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

 

실행된 SQL문은 다음과 같다.

// paging 처리
select
    member0_.member_id as member_i1_0_,
    member0_.age as age2_0_,
    member0_.team_id as team_id4_0_,
    member0_.username as username3_0_ 
from
    member member0_ 
where
    member0_.age=? 
order by
    member0_.username desc limit 3 offset 1
    
    
// totalCount 처리
select
    count(member0_.member_id) as col_0_0_ 
from
    member member0_ 
where
    member0_.age=10

 

테스트 코드를 보면 페이지를 계산하는 부분은 작성하지 않았다.

왜냐하면 다음 시간에 Spring Data JPA를 통해 더욱 편하게 계산하는 방법이 있기 때문이다!!

 

8. 스프링 데이터 JPA 페이징과 정렬

 

▶ 페이징과 정렬 파라미터

- org.springframework.data.domain.Sort : 정렬 기능

- org.springframework.data.domain.Pageable : 페이징 기능 (내부에 Sort 포함)

 

잘 보면 둘다 org.springframework.data 페키지 안에 들어있다.

즉, 관계형 DB, NoSQL 이든 뭐든 페이징 처리를 공통화 시킨 것 이다!

 

특별한 반환 타입

- org.springframework.data.domain.Page : 추가 count 쿼리 결과를 포함하는 페이징 (totalCount가 같이 나간다)

- org.springframework.data.domain.Slice : 추가 count 쿼리 없이 다음 페이지만 확인 가능 (내부적으로 limit + 1조회)
- List (자바 컬렉션): 추가 count 쿼리 없이 결과만 반환

 

Page를 사용해서 페이징 처리를 해보자.

우선 다음과 같이 쿼리메서드를 추가해 주자. 이름은 findByAge이다.

public interface MemberRepository extends JpaRepository<Member, Long> {

    Page<Member> findByAge(int age, Pageable pageable);
}

 이를 사용하는 테스트 코드는 다음과 같다.

@Test
public void pasing_() {
    // 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));

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

    // when
    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); //페이지 번호
    assertThat(page.getTotalPages()).isEqualTo(2); //전체 페이지 번호
    assertThat(page.isFirst()).isTrue(); //첫번째 항목인가?
    assertThat(page.hasNext()).isTrue(); //다음 페이지가 있는가?
}

이번에는 pageRequest 라는 객체를 만들어서 findByAge의 인자로 전달하고 있다.

 

두 번째 파라미터로 받은 Pagable 은 인터페이스다. 따라서 실제 사용할 때는 해당 인터페이스를 구현한 PageRequest 객체를 사용한다.
PageRequest 생성자의 첫 번째 파라미터에는 현재 페이지를, 두 번째 파라미터에는 조회할 데이터 수를 입력한다.

여기에 추가로 정렬 정보도 파라미터로 사용할 수 있다.

(ps 페이지는 0부터 시작한다.)

 

이렇게 전달하기만 하면 끝이난다! 반환으로 Page<Member>를 반환해 준다.

위에서 보이듯 이렇게 반환된 Page 타입에는 기본적으로 지원되는 메서드 들이 있다.

interface를 열어보면 기본으로 지원해주는 메서드들 명시되어있다.

public interface Page<T> extends Slice<T> {
    int getTotalPages(); //전체 페이지 수
    long getTotalElements(); //전체 데이터 수
    <U> Page<U> map(Function<? super T, ? extends U> converter); //변환기
}

위와 같이 Page와 같은 타입은 totalCount에 대한 쿼리를 날리게 된다.

이에 반해 Slice 타입은 totalCount 쿼리가 생기지 않는다.

 

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를 사용하는 Repository에 다음과 같이 명시하자. 반환타입이 Slice이다.

Slice<Member> findByAge(int age, Pageable pageable);

이를 적용하는 테스트 코드는 다음과 같다.

@Test
public void pasing_() {
    // 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));

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

    // when
    Slice<Member> page = memberRepository.findByAge(10, pageRequest);

    // then
    List<Member> content = page.getContent();
    assertThat(content.size()).isEqualTo(3); //조회된 데이터 수
    assertThat(page.getNumber()).isEqualTo(0); //페이지 번호
    assertThat(page.isFirst()).isTrue(); //첫번째 항목인가?
    assertThat(page.hasNext()).isTrue(); //다음 페이지가 있는가?
}

우리의 테스트 코드를 보면 PageRequest.of(0, 3, ...)으로 되어있다.

이는 0페이지 기준으로 3개씩 가져오라는 의미이다.

 

하지만 생성된 쿼리를 보면 조금 이상하다. 다음 쿼리를 살펴보자.

select member0_.member_id as member_i1_0_, 
       member0_.age as age2_0_, 
       member0_.team_id as team_id4_0_, 
       member0_.username as username3_0_ 
from member member0_ 
where member0_.age=10 
order by member0_.username desc limit 4;

limit이 4로 출력된다.

내가 요청한 숫자인 3보다 1 증가된 4로 요청된 것 이다.

이렇게 사용하는 이유는, 데이터를 1개 더 가져오게 되면 다음 페이지가 있음을 알수 있기 때문에 이를 활용하는 목적이다.

 

만약 반환 타입을 List로 변경한다면? 다음과 같이 말이다.

List<Member> findByAge(int age, Pageable pageable);

이 경우에는 그냥 딱 데이터만 페이지에 맞게 가져오는 일만 하게 된다.

다음 페이지가 있는지? totalCount가 몇개인지? 등등 부가적인 기능이 필요 없을때는 List로 반환하면 된다.

 

실행되는 쿼리는 다음과 같다.

select member0_.member_id as member_i1_0_, 
       member0_.age as age2_0_, 
       member0_.team_id as team_id4_0_, 
       member0_.username as username3_0_ 
from member member0_ 
where member0_.age=10 
order by member0_.username desc limit 3;

그냥 limit만 나오고 끝난다!


일반적으로 총 아이템의 수, 즉 totalCount를 구하는 Page 타입은 성능이 느리다.

데이터가 많아질수록 Page의 성능이 안좋아 질 수 있다.

 

우선 다음과 같은 @Query를 사용하는 메서드를 통해 페이징 처리를 하고 있다고 해보자.

@Query(value = "select m from Member m left join m.team t")
Page<Member> findByAge(int age, Pageable pageable);

Page 를 반환타입으로 명시하면 다음과 같이 count를 하는 쿼리에서도 불필요한 join을 하고있는것이 문제이다.

select
    count(member0_.member_id) as col_0_0_ 
from
    member member0_ 
left outer join
    team team1_ 
        on member0_.team_id=team1_.team_id

count만 하면 되는데 join을 하고있으니 데이터가 많아질수록 성능 하락이 생기기 마련이다.

 

따라서 이런경우 count 쿼리를 따로 분리하여 사용할 수 있다. 다음과 같이 말이다!

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

변경된 count 쿼리는 다음과 같다.

select
    count(member0_.username) as col_0_0_ 
from
    member member0_

매우 간단해진것을 확인할 수 있다.


실무 꿀팁 1가지!

 

실무에서는 어떠한 경우에서도 Entity를 직접 반환하면 안된다. 항상 DTO를 사용하여 반환해야 한다.

Page<Member> page = memberRepository.findByAge(10, pageRequest);
Page<MemberDto> toMap = page.map(m -> new MemberDto(m.getId(), m.getUsername(), null));

위 코드와 같이 Map을 통해 MemberDto로 변환하여 반환하면 된다!

'BackEnd > JPA' 카테고리의 다른 글

[JPA] 확장 기능  (0) 2022.05.06
[JPA] 쿼리 메소드 기능 - 4  (0) 2022.05.04
[JPA] 쿼리 메소드 기능 - 2  (0) 2022.05.04
[JPA] 쿼리 메소드 기능 - 1  (0) 2022.05.03
[JPA] 공통 인터페이스 기능  (0) 2022.05.03

댓글