[JPA] 페이징 API
MySQL, ORACLE의 페이징 처리는 복잡하기로 유명하다. JPA는 API 2개만 알면 페이징 구현이 가능하다. 그리고 JPA가 알아서 DB에 맞추어 복잡한 페이징 SQL을 자동 생성한다. 페이징 쿼리 1) String jpql = "sel
lordofkangs.tistory.com
JPA는 페이징 할 수 있는 페이징API를 제공한다.
페이징API를 사용하려면 개발자가 리포지토리 구현체를 직접 구현해야 한다. SpringDataJPA의 철학은 개발자는 리포지토리 인터페이스만 구현하고 구현체는 SpringDataJPA가 알잘딱깔센하게 자동 생성함에 있다. 개발자는 직접 페이징API를 사용하지 않고 페이징에 필요한 데이터만 넘기면 된다.
이를위해, SpringDataJpa는 페이징 전용 인터페이스를 제공한다.
▷ 파라미터용 인터페이스
- Sort
- Pageable
▷ 반환타입용 인터페이스
- Page
- Slice
개발자가 Sort나 Pageable 인터페이스의 구현체에 데이터를 담아 파라미터로 넘기면 SpringDataJPA는 페이징을 처리하여 그 결과를 Page이나 Slice 객체로 반환한다.
Pageable
MemberRepository 인터페이스
public interface MemberRepository extends JpaRepository<Member,Long>{
Page<Member> findByAge(int age, Pageable pageable);
}
개발자는 MemberRepository 인터페이스에 기능 하나를 정의했다. SpringDataJPA는 메소드이름으로 쿼리문을 자동생성하는데 페이징 처리도 같이 한다. 왜냐하면 파라미터로 페이징 관련 데이터도 같이 넘어오기 때문이다.
테스트 코드를 보자.
@SpringBootTest
@Transactional
@Rollback(value = false)
class MemberRepositoryTest {
@Test
public void paging(){
//데이터 세팅
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));
//페이징 데이터 : 0페이지에서 3개 가져와, 사용자 이름으로 내림차순
PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));
//리포지토리에서 메소드 호출하기
int age = 10;
Page<Member> page = memberRepository.findByAge(age, pageRequest);
}
}
Pageable 인터페이스의 구현체는 PageRequest이다. PageRequest 구현체는 PageRequst.of() 정적메소드로 생성할 수 있다.
PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username"));
PageRequest 객체를 생성할 때, 페이징 정보를 파라미터로 넘겨야 한다. 파라미터로 몇 번째 페이지를 가져올 것인지, 몇 개의 데이터를 가져올 것인지, 정렬은 어떤 데이터를 기준으로 어떤 방식으로 할 것인지를 설정할 수 있다. 복잡하게 페이징 쿼리를 짜거나 페이징API를 사용하는 것이 아니라 페이징 정보만 파라미터로 넘기면 페이징된 쿼리가 자동생성된다.
select
m1_0.member_id,
m1_0.age,
m1_0.created_by,
m1_0.created_date,
m1_0.last_modified_by,
m1_0.team_id,
m1_0.updated_date,
m1_0.username
from
member m1_0
where
m1_0.age=10
order by
m1_0.username desc -- 정렬
offset 0 rows fetch first 3 rows only -- 페이징
위와 같이, offset-fetch를 활용한 페이징 쿼리가 자동생성 되었다. 정렬 또한 포함되어 있음을 확인할 수 있다.
SpringDataJPA는 개발자가 직접 페이징 로직과 쿼리를 구현하지 않아도 자동으로 이들을 생성한다. 그리고 SELECT 결과는 Page나 Slice 객체에 담아 반환한다.
Page와 Slice
Page와 Slice는 SpringDataJPA가 제공하는 페이징용 반환타입이다. 반환된 데이터를 그저 List 자료구조에 담아 반환한다면 페이징 관련 기능을 사용할 수 없다. Page와 Slice는 페이징 관련 추가기능을 가지고 있는 인터페이스이다.
Page가 반환타입인 경우, SELECT쿼리와 Count쿼리가 실행된다.
select count(m1_0.member_id)
from member m1_0
where m1_0.age=10;
페이징 쿼리는 조건에 맞는 데이터 중에 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));
PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username")); //0페이지에서 3개 가져와, 사용자 이름으로 내림차순
//when
int age =10;
Page<Member> page = memberRepository.findByAge(age, pageRequest);
//then
List<Member> content = page.getContent(); // 조회된 데이터 ( List )
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 인터페이스는 페이징에 필요한 데이터를 제공한다. SpringDataJPA가 없었다면 개발자가 직접 코드로 구현했어야 하는 일이다. 이제 개발자는 그저 페이징 데이터를 가져와 사용만하면 된다.
Slice는 Page와 개념이 조금 다르다.
Page는 전체 데이터 중에 몇번째, 몇개의 데이터임을 보이는 개념이다. 그래서 전체 데이트를 조회하는 카운트 쿼리가 추가로 실행되었다. 반면, Slice는 전체 데이터는 중요하지 않다. 현재 페이지와 다음 페이지만 신경쓴다. 스크롤을 내리면 내릴수록 무한히 컨텐츠가 나오는 웹사이트를 본 적이 있을 것이다. 이는 Page가 아닌 Slice로 구현된 페이지이다.
Slice는 요청한 데이터 개수에서 하나의 데이터를 더 가져온다.
MemberRepository
public interface MemberRepository extends JpaRepository<Member,Long>{
Slice<Member> findByAge(int age, Pageable pageable);
}
테스트 코드
@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));
PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username")); //0페이지에서 3개 가져와, 사용자 이름으로 내림차순
//when
int age =10;
Slice<Member> page = memberRepository.findByAge(age, pageRequest);
}
페이지를 Slice타입으로 받아보자. Slice 반환타입으로 받으면 아래와 같은 SQL문이 생성되어 실행된다.
select
m1_0.member_id,m1_0.age,
m1_0.created_by,m1_0.created_date,
m1_0.last_modified_by,
m1_0.team_id,
m1_0.updated_date,
m1_0.username
from member m1_0
where m1_0.age=10
order by m1_0.username desc offset 0 rows fetch first 4 rows only;
조회하는 데이터 개수를 3으로 제한했지만 쿼리문은 4개의 데이터를 가져옴을 알 수 있다. Slice 방식은 마우스 스크롤을 내렸을 때 숨겨진 데이터가 보여줘야하기 때문에 하나의 데이터를 미리 추가로 가져오는 것이다. 그러므로 전체 데이터의 개수가 필요없는 Slice는 카운트 쿼리를 수행할 필요가 없다.
Slice도 다양한 기능을 제공하지만 전체 데이터 관련 기능은 제공하지 않는다. 이는 카운트 쿼리를 실행해야 얻을 수 있는 데이터이기 때문이다. 그래서 Page 인터페이스가 Slice 인터페이스를 상속하는 구조이다. Page의 기능은 Slice의 기능 + 전체 데이터 관련 기능 이기 때문이다.
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); //변환기
}
Page 인터페이스
public interface Page<T> extends Slice<T> {
int getTotalPages(); //전체 페이지 수
long getTotalElements(); //전체 데이터 수
<U> Page<U> map(Function<? super T, ? extends U> converter); //변환기
}
변환기(map)
페이지 객체는 조회된 데이터 + 페이징 기능으로 구성되어 있다. 조회된 데이터는 엔티티이다. 엔티티 데이터는 화면스펙에 맞도록 DTO로 변환되는 경우가 잦은데, 페이지 객체는 변환을 쉽게 할 수 있도록 변환기능을 제공한다.
Page<Member> page = memberRepository.findByAge(10, pageRequest);
Page<MemberDto> dtoPage = page.map(m -> new MemberDto()); //변환!!
page 객체는 map메소드를 사용하여 변환기능을 구현한다. map 메소드는 Function 함수형 인터페이스를 파라미터로 받는다. Function 함수형 인터페이스는 X를 Y로 변환하는 함수를 가진 인터페이스이다. 함수형 인터페이스 구현을 위해, 변환 로직을 람다표현식으로 넣어주면 된다.
이로써, 간단한 코드로 엔티티를 DTO로 변환해줄 수 있게 된다.
카운트 쿼리 분리 실행하기
Page 반환타입은 카운트쿼리를 자동생성한다고 말했다.
카운트 쿼리는 조회쿼리를 기반으로 생성되기에, 조회쿼리가 복잡하면 카운트 쿼리도 복잡해져 성능이 떨어지거나 잘못된 카운트쿼리를 생성할 가능성이 있다. 그래서 SpringDataJPA는 카운트 쿼리를 분리하여 실행할 수 있는 방법을 제공한다.
@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, PageRequest pageRequest);
@Query 어노테이션은 메소드이름으로 자동생성하기 어려운 복잡한 JPQL을 실행해야 하는 경우 사용한다. 복잡해지는 만큼 @Query는 countQuery 속성을 제공하여 성능좋은 countQuery를 작성할 수 있도록 도와준다.
참고자료
실전! 스프링 데이터 JPA - 인프런 | 강의
스프링 데이터 JPA는 기존의 한계를 넘어 마치 마법처럼 리포지토리에 구현 클래스 없이 인터페이스만으로 개발을 완료할 수 있습니다. 그리고 반복 개발해온 기본 CRUD 기능도 모두 제공합니다.
www.inflearn.com
'JPA > Spring Data JPA' 카테고리의 다른 글
[SpringDataJPA] @EntityGraph (0) | 2023.07.13 |
---|---|
[SpringDataJPA] 벌크성 수정쿼리 ( @Modifying ) (0) | 2023.07.13 |
[SpringDataJPA] @Query (0) | 2023.07.11 |
[SpringDataJPA] 메소드 이름으로 쿼리생성하기 (0) | 2023.07.11 |
[SpringDataJPA] SpringDataJPA란? (0) | 2023.07.06 |