Spring Data JPA 게시글은 대부분 인프런의 김영한님의 강의인 '실전! 스프링 데이터 JPA' 기반으로 내용을 정리했습니다.
스프링 데이터 JPA 페이징과 정렬
페이징과 정렬 파라미터
- org.springframework.data.domain.Sort : 정렬 기능
- org.springframework.data.domain.Pageable : 페이징 기능(내부에 Sort 포함)
패키지명에서 알 수 있는 건 JPA를 사용하지 않는다는 것이다. 어떤 DB(관계형, noSql 등)를 사용하던 이 기능을 사용할 수 있도록 공통화시킨 것이다. 세부 구현은 물론 개발자가 알아서 직접 개발해야 한다.
특별한 반환 타입
- org.springframework.data.domain.Page
- 추가 count 쿼리 결과를 포함하는 페이징
- totalCount가 필요할 때 사용
- org.springframework.data.domain.Slice
- 추가 count 쿼리 없이 다음 페이지만 확인 가능
- 내부적으로 limit + 1 조회
- 무한 스크롤 페이징에 좋음
- List (자바 컬렉션) : 추가 count 쿼리 없이 결과만 반환
Page 인터페이스
public interface Page<T> extends Slice<T> {
static <T> Page<T> empty() {
return empty(Pageable.unpaged());
}
static <T> Page<T> empty(Pageable pageable) {
return new PageImpl<>(Collections.emptyList(), pageable, 0);
}
int getTotalPages(); // 전체 페이지 수
long getTotalElements(); // 전체 데이터 수
<U> Page<U> map(Function<? super T, ? extends U> converter); // 변환기
}
page 예제
- MemberRepository.interface
Page<Member> findByAge(int age, Pageable pageable);
- MemberRepositoryTest.class
@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));
int age = 10;
//** 1
PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));
// when
//** 2
Page<Member> page = memberRepository.findByAge(age, pageRequest);
//** 3
//long totalCount = memberRepository.totalCount(age);
// then
List<Member> content = page.getContent(); // 데이터 꺼내기
long totalElements = page.getTotalElements(); // totalCount
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(); // 다음 페이지 있는지?
}
- Page는 1부터가 아닌 0부터 시작이므로 주의해야 한다.
- findByAge의 두 번째 파라미터는 Pageable 인터페이스이다. Pageable의 구현체인 PageRequest 객체를 사용하여 넘긴다. 주로 PageRequest 구현체를 사용한다.
- totalCount는 굳이 작성하지 않아도 된다. 반환 타입이 Page일 경우 스프링 데이터 JPA에서 알아서 totalCount 쿼리까지 한 번에 날려준다.
// 페이징 쿼리
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 ?
// totalCount 쿼리
select
count(member0_.member_id) as col_0_0_
from
member member0_
where
member0_.age=?
offset이 페이징 쿼리에서 보이지 않는 이유는 저번 게시글에서도 말했지만 offset 값이 0이기 때문에 굳이 넣지 않아도 되기 때문이다. totalCount 역시 order by절이 보이지 않는데, 이 이유는 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(); // 이전 페이지 여부
default Pageable getPageable() { // 페이지 요청 정보
return PageRequest.of(getNumber(), getSize(), getSort());
}
Pageable nextPageable(); // 다음 페이지 객체
Pageable previousPageable(); // 이전 페이지 객체
<U> Slice<U> map(Function<? super T, ? extends U> converter); // 변환기
}
Slice는 totalPage를 이용하지 않고 그냥 다음 페이지가 있어? 없어? 기준으로 작동되는 기능이다.
Slice 예제
- MemberRepository.interface
// Page<Member> findByAge(int age, Pageable pageable);
Slice<Member> findByAge(int age, Pageable pageable);
참고로 위 코드를 수정하지 않으면 Page로 적용되어 totalCount가 발생한다. 이 이유는 Page의 부모가 Slice이기 때문에 테스트 코드에서 Slice만 수정하면 Page로 적용된다.
- MemberRepositoryTest.class
@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));
int age = 10;
PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));
// when
Slice<Member> page = memberRepository.findByAge(age, pageRequest); //** Slice로 변경
// 아래 totalCount와 관련된 메서드는 작동되지 않아 주석처리
// then
List<Member> content = page.getContent(); // 데이터 꺼내기
// long totalElements = page.getTotalElements(); // totalCount
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(); // 다음 페이지 있는지?
}
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 ?
쿼리는 totalCount와 관련된 쿼리는 발생되지 않는다. 여기서 주의 깊게 봐야 할 것은 출력 로그에 찍힌 select 쿼리의 limit 값이다. 예제에서는 0~3번째의 데이터, 즉 3개를 가져오라 명령했지만 select 쿼리가 나갈 때는 요청한 데이터의 +1 개를 가져온다. 즉 4개를 가져오게 된다.
List 예제
List 타입 반환의 경우 페이징이 주된 목적이 아니라, 그냥 데이터를 몇 번째부터 몇 번째까지 가져와가 목적이므로 페이징과 관련된 기타 기능들은 작동하지 않는다. 고로 totalCount 쿼리도 발생하지 않는다.
- MemberRepository.interface
List findByAge(int age, Pageable pageable);
- MemberRepsoitoryTest.class
@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));
int age = 10;
PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));
// when
List page = memberRepository.findByAge(age, pageRequest);
// 아래 메서드는 전부 동작하지 않는다.
// then
// List<Member> content = page.getContent(); // 데이터 꺼내기
// long totalElements = page.getTotalElements(); // totalCount
// 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(); // 다음 페이지 있는지?
}
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 ?
! totalCount를 정하는 건 PageRequest 구현체가 아니고 반환 타입에 따라 결정된다.
Count Query 분리
totalCount는 DB에 있는 모든 데이터를 조회한다. 그렇기 때문에 totalCount 자체가 성능이 좋지 않고 무겁다. 그래서 totalCount의 쿼리를 잘 짜야할 경우가 있는데.. 특히 Join이 들어가는 경우에 신경을 많이 써야 한다. 만약 Member와 Team을 left outer join일 경우 totalCount는 굳이 join 할 필요가 없다. 왜냐하면 join을 하기 전이나, 하고 난 후나 데이터의 개수는 똑같기 때문이다(where절이 없는 경우에 한해).
- 참고
- MemberRepository.interface
@Query(value = "select m from Member m left join m.team t")
Page<Member> findByAge(int age, Pageable pageable);
@Query 애노테이션을 추가해서 Member와 Team을 left join 해보자.
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_
left outer join
team team1_
on member0_.team_id=team1_.team_id
order by
member0_.username desc limit ?
// count
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할 필요가 없는데도 join을 하면서 성능을 좋지 않게 만들고 있다.
- MemberRepository.interface
@Query(value = "select m from Member m left join m.team t",
countQuery = "select count(m) from Member m")
Page<Member> findByAge(int age, Pageable pageable);
@Query 안에 countQuery라는 속성을 따로 주면 CountQuery를 따로 지정할 수 있다.
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_
left outer join
team team1_
on member0_.team_id=team1_.team_id
order by
member0_.username desc limit ?
// count query
select
count(member0_.member_id) as col_0_0_
from
member member0_
! 참고로 Sort도 조건이 너무 복잡하면 PageRequest 구현체로 해결할 수가 없다. 그럴 경우엔 여기에서 작성한 것처럼 @Query의 value 속성에 sort 조건을 넣으면 된다.
DTO 변환
Page<Member> page = memberRepository.findByAge(age, pageRequest);
참고로 예제에서 위와 같이 Member 엔티티를 직접 가져와서 사용했는데, 실무에서 이런 식으로 사용하면 절대 안 된다. 엔티티는 절대 외부에 노출시키면 안 되고, 애플리케이션 안에 숨겨놓고 DTO로 변환해서 사용해야 한다.
Page<MemberDto> toMap =
page.map(member -> new MemberDto(member.getId(), member.getUsername(), null));
이런 식으로 Page를 유지하면서 MemberDto로 변환할 수 있고 API로 반환해도 된다.