스프링데이터 + JPA/스프링 데이터 JPA

16. 스프링 데이터 JPA 페이징, 정렬

sdafdq 2023. 11. 21. 12:10

스프링 데이터 JPA에서 페이징과 정렬을 정말 간단히 지원해 준다.

 

스프링 데이터 JPA는 페이징과 정렬을 표준화 시켜놨다.

 

org.springframework.data.domain.Sort 와

org.springframework.data.Pageable 이 두개로 표준화 시켜놨음.

 

 

페이징도 요새는 2가지 종류가 있는데,

추가 count 결과를 포함하는 페이징과,

추가 count 결과를 포함하지 않는, 다음 페이지가 있나 없나 확인 가능한(내부적으로 limit + 1 하나 더 조회)

왜 모바일 같은 경우 아래로 내리면 그냥 더 나오는..

 

org.springframework.data.Page가 총 count가 몇갠지 추가로 쿼리를 날리는 페이징이고,

org.springframework.data.Slice가 총 count 쿼리 없이 다음페이지만 확인하는 페이징

 

여튼 이제 서론은 넘어가고, 본격적으로 Spring Data JPA의 페이징을 보면,

 

먼저 구현 자체는? 구현이라기 보다는 구현은 SpringDataJPA가 해놓는거니 페이징으로써 가져오도록 하는 것은 간단함.

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

이렇게 반환 타입을 Page<엔티티> 하면 됨.

애초에 페이징 한다는 것이 여러 데이터를 가져온 다는 것 이므로, 저 Page<엔티티> 객체 안에 List<엔티티>를 가지고 있음.

 

먼저 Page<T> 인터페이스를 살펴보자면,

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

이렇게 되어 있음.

보면 empty, 비어있는지 아닌지도 알 수 있고,

getTotalPages() 총 몇페이지가 있냐 이거임. 이제부터 페이지라 함은 실제로 그 한페이지, 두 페이지 왜 게시판 페이지 그거 떠올리면 됨.

getTotalElements() 이거는 그래서 총 몇개의 요소가 있는지. getTotalPages()는 페이지 사이즈로 묶은 것의 총 개수를 세는 것이고, 이거는 그냥 총 요소 개수 전체 자체를 세는 거임.

그 다음은 그 Page가 상속받은 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);

	default Pageable nextOrLastPageable() {
		return hasNext() ? nextPageable() : getPageable();
	}

	default Pageable previousOrFirstPageable() {
		return hasPrevious() ? previousPageable() : getPageable();
	}
}

이거는 좀 나중에 설명하겠음.

아까 설명했던 페이징은 2종류가 있다. 게시판의 페이지처럼 1,2,등의 인덱싱이 붙어있는 페이지랑,

핸드폰 밑으로 내리면 콘텐츠들을 가져오는 저런 인덱싱이 없는 페이징이랑.

그 말한 

org.springframework.data.Slice

즉, Slice가 이거임.

 

이거는 좀 나중에 설명하겠음.

대충 인터페이스 보고 저런 기능들이 있구나 추측을 하고 있으셈. Page가 상속을 받았으니 Page도 저 기능들을 가지고 있으니까.

 

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

    int age = 10;

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

    Page<Member> result = memberRepository.findByAge(age, pageRequest);

    List<Member> content = result.getContent();
    long totalCount = result.getTotalElements();

    assertThat(content.size()).isEqualTo(3);
    assertThat(result.getTotalElements()).isEqualTo(5);
    assertThat(result.getNumber()).isEqualTo(0); //몇번째 페이지 인지.
    assertThat(result.getTotalPages()).isEqualTo(2);
    assertThat(result.isFirst()).isTrue();  // 첫번째 페이지인지
    assertThat(result.hasNext()).isTrue(); //다음 페이지가 있냐
}

먼저 페이징 테스트.

age가 10인것들 잔뜩 만들어 놓고(5개)

 

PageRequest, 페이징을 요청할 땐 방법이 몇개 있는데, 특히 Pageable인터페이스를 상속받은 구현체를 넘겨주는 것.

그 중에서도 PageRequest를 보통 씀.

직역 그대로 페이지 요청

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

페이지요청.of, 즉 무엇의, 무엇으로이루어진것의 페이지요청이냐면,

PageRequest pageRequest = PageRequest.of(페이지넘버, 페이지사이즈, Sort.by(정렬순서, 정렬할컬럼명));

즉, 페이지넘버, 이거는 1페이지냐 2페이지냐 그거임. offset이 아니라 지금 니가 요구하고 있는 것이 몇번째 페이지라는 거임. 게시판에서 2번째 페이지를 가져오냐 8번째 페이지를 가져오냐 그 말임.

페이지 사이즈는 한 페이지가 가지고 있는 elements의 갯수들, 말 그대로 한 페이지당 몇개의 요소들을 가지고 있는지.

그 다음 Sort.by() 해서 정렬순서와 무엇을기준으로 정렬할건지 보냄.

of 저것도 다 오버로딩 되어 있어서 저 Sort.by 안넘겨도 됨.

 

Page<Member> result = memberRepository.findByAge(age, pageRequest);

List<Member> content = result.getContent();
long totalCount = result.getTotalElements();

assertThat(content.size()).isEqualTo(3);
assertThat(result.getTotalElements()).isEqualTo(5);
assertThat(result.getNumber()).isEqualTo(0); //몇번째 페이지 인지.
assertThat(result.getTotalPages()).isEqualTo(2);
assertThat(result.isFirst()).isTrue();  // 첫번째 페이지인지
assertThat(result.hasNext()).isTrue(); //다음 페이지가 있냐

여튼 그렇게 저 pageRequest, 페이징 요청에 관한 정보를 담은 객체를 넘겨 페이징을 요청할 수 있고, 그걸 위에서 봤던 인터페이스, Page에 담아서 결과를 받을 수 있다.

Page는 위에서 봤던 대로 Slice 인터페이스가 가지고 있는 기능을 포함하여 여러 기능을 가지고 있다.

Slice는 뭔지 위에서 설명을 했고, 그럼 그 Page + Slice 기능들을 몇가지 보면, (getTotalPages 등 여러 페이지와 관련되어 보이는 건 Page거임. 그러니까, Slice는 한 페이지를 생각하면 되는거고(hasNext 등도 Slice꺼긴 한데, 얘는 애초에 조회해 올 때 limit + 1 이런식이라. 그냥 한페이지더? 라는 정도임.), Page는 여러페이지, 전체 페이지 단위를 생각하면 됨)

Slice는 한페이지, 혹은 앞뒤로 한페이지 정도,

Page는 전체 페이지에 대한 접근. totalPages()해서 전체 페이지의 수라던지 totalElements해서 전체 페이지의 전체 요소 개수라던지.

 

여튼 위에 설명을 해 보면 저렇게 Page로 받고,

Page 객체 안의 content라는 것이 요소들을 가지고 있음. 그래서 우리가 List<Member>하듯이 그거 얻으려면 저거 getContent() 해서 얻으면 됨. 

getTotalElements()는 전체 페이지의 전체 요소의 숫자고,

content저거는 이제 그냥 List<엔티티>로 보면되고,

getNumber()는 자기가 몇번째 페이지 인지. 이거는 한페이지에 관한 내용이니 Slice가 가지고 있음. Slice가 자기 페이지에 관한 정보들임.

getTotalPages()는 전체 페이지에 관한 정보 이므로 Page꺼,

isFirst()는 첫번째 페이지인지, 즉 자기 개인 페이지에 관한 개인적인 정보이므로 Slice꺼.

다음 hasNext() 도 다음페이지가 있는지 없는지, 얘도 자기 개인적인 페이지에 대한, 자기 개인 페이지의 다음 페이지가 있냐 없냐에 대한 정보이므로 Slice꺼.

 

테스트는 다 통과임.

content 사이즈는 우리가 페이지 사이즈를 3개씩 잡았었으니 맞고,

우리가 등록한 총요소가 5개이니 맞고,

우리가 PageRequest로 요청한 페이지 넘버가 0이므로 맞고,

총 페이지 수도 3, 3 인데 요소가 총 5개니 3, 2 해서 총 2개 맞고,

첫번째 페이지 인 것도 맞고,

다음 페이지가 있는것도 맞고.

 

아, 쿼리 날라가는 건

    select
        m1_0.member_id,
        m1_0.age,
        m1_0.team_id,
        m1_0.username 
    from
        member m1_0 
    where
        m1_0.age=? 
    order by
        m1_0.username desc 
    offset
        ? rows 
    fetch
        first ? rows only
select m1_0.member_id,m1_0.age,m1_0.team_id,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;
        
        
    select
        count(m1_0.member_id) 
    from
        member m1_0 
    where
        m1_0.age=?
select count(m1_0.member_id) from member m1_0 where m1_0.age=10;

 

이렇게 2개 날라감.

실제로 컨텐츠 가져오는 쿼리 하나랑, 총 count 가져오는 쿼리 하나.

 

다음은 이제 Slice를 볼거임.

 

아 그 전에, 하나 말하고 싶은게,

PageRequest는 그냥 페이징을 요청하는 정보를 담은 객체임.

즉, Page<T>나 Slice<T>가 가지고 있는 기능들, getTotalPages(), hasNext()등등이랑은 전혀 관계가 없는거임.

그래서, 

List<Member> findListByAge(int age, Pageable pageable);
List<Member> result = memberRepository.findListByAge(age, pageRequest);

그냥 이런 식으로 바로 List<T>로 가져와도 됨.

왜 우리가 뭐 페이지 정보 이런 거 필요없이 그냥 데이터만 가져오라고 할 수도 있으니까.

 

이제 Slice를 보면,

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

    int age = 10;

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

    Slice<Member> result = memberRepository.findSliceByAge(age, pageRequest);

    List<Member> content = result.getContent();


    assertThat(content.size()).isEqualTo(3);
//        assertThat(result.getTotalElements()).isEqualTo(5);
    assertThat(result.getNumber()).isEqualTo(0); //몇번째 페이지 인지.
//        assertThat(result.getTotalPages()).isEqualTo(2);
    assertThat(result.isFirst()).isTrue();  // 첫번째 페이지인지
    assertThat(result.hasNext()).isTrue(); //다음 페이지가 있냐
}

Slice 이므로, 현재 내 페이지에 관한 정보들만 이므로 Page전체가 가질 수 있는 정보는 가질 수 없기에 주석처리 해 놨고,

나머지는 Slice로 바꾼거,

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

이거 Slice로 받아야 슬라이스임.

그러니까, Page가 Slice의 자식이기 때문에 Slice에 Page가 받아지므로, 그거 유의해야 함.

Page<T>로 반환타입을 해 놓으면, 

Page<Member> findSliceByAge(int age, Pageable pageable);
Slice<Member> result = memberRepository.findSliceByAge(age, pageRequest);

이렇게 우리가 깜빡하고 반환타입은 Page로 해 놨는데 저거 받을 때는 Slice로 받아놓으면 저거 Page로 받아지니까..

Slice 안에 Page가 들어갈 수 있으니까.. 다형성 때문에

 

여튼, 여기 Slice에서 신기한 점은

우리가 분명 Page 했을때랑 똑같이 페이지 사이즈를 3이라고 요청 했는데,

    select
        m1_0.member_id,
        m1_0.age,
        m1_0.team_id,
        m1_0.username 
    from
        member m1_0 
    where
        m1_0.age=? 
    order by
        m1_0.username desc 
    offset
        ? rows 
    fetch
        first ? rows only
        
select m1_0.member_id,m1_0.age,m1_0.team_id,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;

우선 현재 내 페이지에 대한 것만의 정보이므로 count 쿼리가 안나가고,

보면 우리가 요청한 3개가 아니라 3 + 1개를 땡겨온다.

 

이게 맞는 동작방식이다.

이렇게 요청한 limit를 + 1 개 더 땡겨와서 다음 페이지가 있는지 없는지 판단하는 거다.

 

 

다음 좀 설명하고 싶은게,

사실 Page에서 나가는 count 쿼리, 이거 사실 성능 많이 잡아먹는다.

물론 DB에서 내부적으로 최적화는 되어 있겠지만,

이게 결국 해당하는 모든 데이터를 다 읽어보는 거니 성능을 많이 잡아먹는다. 

 

저런 점이 있다보니 카운트 쿼리가 좀 민감한 부분이 있다.

이거는 지금 이야기는 아닌데, 현재는 하이버네이트6이고 하이버네이트 5이하시절,

 

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

이렇게 가져오면 카운트쿼리도 team을 left (outer) join 하면서 나갔다.

지금 하이버네이트 6은 이제 제대로 저 m만 잘 나간다.

 

    select
        m1_0.member_id,
        m1_0.age,
        m1_0.team_id,
        m1_0.username 
    from
        member m1_0 
    order by
        m1_0.username desc 
    offset
        ? rows 
    fetch
        first ? rows only



    select
        count(m1_0.member_id) 
    from
        member m1_0

사실 그래서 하이버네이트6 쓰는 지금은 상관없는데,

하이버네이트 5 쓰는 옛날거는 저랬다는 거와,

 

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

이렇게 카운트 쿼리를 따로 줄 수 있다는 것을 말하려고 그랬다.

 

    select
        m1_0.member_id,
        m1_0.age,
        m1_0.team_id,
        m1_0.username 
    from
        member m1_0 
    order by
        m1_0.username desc 
    offset
        ? rows 
    fetch
        first ? rows only



    select
        count(m1_0.member_id) 
    from
        member m1_0

결과적으로 똑같이 나가긴 했는데, m, 그러니까 엔티티 자체를 주면 보통 쿼리로 바꿀 땐 그걸 id로 받아들여서 그럼.

 

 

추가로, 

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

여기서 Sort 저 쪽이 잘 안먹힐 때가 있다고 함..

그럴 때는 그냥 쿼리에 추가시켜 주면 됨.

 

 

그리고 이제,

우리가 아까 Page와 Slice의 인터페이스에서 봤던 것 중에 map이 있는데, 그 map이 맞다.

 

Dto 할 때 그냥 이걸로 하셈.

Page<Member> page = memberRepository.findByAge(age, pageRequest);

Page<MemberDto> memberDtos = page.map((member)->{
	return new MemberDto(member.getId(), member.getUsername(), member.getTeam().teamName());
});

이렇게 하면 됨.

map()은 그 자바스크립트에 있는 그 map()이 맞음.

 

batch 때문에 아마 getTeam()해도 batch로 해서 쿼리가 in으로 나갈거임.

 

 

 

 

'스프링데이터 + JPA > 스프링 데이터 JPA' 카테고리의 다른 글

18. @EntityGraph  (0) 2023.11.23
17. 벌크성 수정쿼리  (0) 2023.11.23
15. 순수 JPA 페이징, 정렬  (0) 2023.11.21
14. 스프링 데이터 JPA 반환 타입  (0) 2023.11.21
13. 파라미터 바인딩  (0) 2023.11.20