Spring Pageable

soluinoon·2023년 7월 12일
1

링크텍스트

개요

요청마다 데이터를 모두 주는건 매우 비효율적인 방식입니다.

예를들어 게시물이 1000개 가량 존재한다고 가정해봅시다. 누군가가 게시물 목록 조회 API를 호출할 때 마다 1000개 전부를 주면 엄청엄청 오래걸리고 부하도 심하겠죠?

따라서, 대부분의 웹은 사진과 같은 페이지로 구분해서 데이터를 전달합니다.
이렇게 데이터를 정렬하고, 페이지 크기에 따라 나눠서 전달하는 것이 바로 페이지네이션(pagination)이라고 합니다.

💡 Pagination (페이지네이션)

데이터를 정렬기준, 페이지 크기, 몇번째 페이지인지를 토대로 정보를 전달해주는 것

Spring에서 Pagination

스프링에서 페이지네이션은 사용자가 직접 구현해서 사용할 수 있고, Spring에서 제공하는 Pageable을 사용할 수도 있습니다.

Pageable

Pageable은 Spring에서 제공하는 Pagination을 위한 인터페이스 입니다.

Pageable 상속 구조


요런 구조로 실제 구현체인 PageRequest를 사용합니다.
PageRequest의 생성자는 다음과 같습니다.

page, size, sort를 사용하는 것을 확인할 수 있습니다.

💡 변수 설명

size : 한 페이지당 담길 데이터의 양 ex) 10, 5, ...
page : size를 기준으로 몇번째 페이지인지? ex) 0, 1, ...
sort : 무엇을 기준으로 정렬할 것인지? ex) createdAt,DESC, description

이것을 기준으로 요청 URL에 변수를 넘기면, 페이지네이션이 동작합니다.
ex) GET /chats/:id?member=1&page=0&size=10&sort=description,DESC
(없으면 기본값으로 동작합니다. 이 부분은 아래서 자세하게 설명할께요)


추가적으로 sizepage는 추상클래스인 AbstractPageRequest에 있습니다. 페이지 넘버와 페이지 사이즈 오류처리를 확인할 수 있습니다.

실제 사용

Pageable이 편리한 점은 Spring Data JPA를 사용시 직접 넘겨주면 편리하게 페이지네이션을 할 수 있다는 점 입니다.

Controller

@GetMapping("/chats/{id}")
public ResponseEntity<BaseResponse> findMessagesByChatRoomId(@PathVariable("id") Long chatRoomId,
                                                                 @RequestParam(value = "member", required = true) Long memberId,
                                                                 Pageable pageable) {
	List<MessagesResponseDto> messages = chatService.findMessages(chatRoomId, memberId, pageable);
	return new ResponseEntity<>(new BaseResponse(messages), HttpStatus.OK);
}

먼저 컨트롤러에서 인자로 Pageable을 넣어줍니다.

Service

public List<MessagesResponseDto> findMessages(Long chatRoomId, Long requestUserId, Pageable pageable) throws BaseException {
        // 로직...

        List<Message> messages = messageRepository.findAllByChatRoom_IdOrderByCreatedAt(chatRoomId, pageable);

        return messages.stream()
                .map(message -> new MessagesResponseDto(message, requestUserId))
                .collect(Collectors.toList());
    }

서비스에서 비즈니스 로직을 수행하며, Spring Data Repository에 Pageable을 인자로 함께 넘겨줍니다.

Repository

public interface MessageRepository extends JpaRepository<Message, Long> {
    List<Message> findAllByChatRoom_IdOrderByCreatedAt(Long chatRoomId, Pageable pageable);
}

단순하게 Pageable 인자 하나만 추가해도 동작할까요?

결과


테스트 데이터 목록입니다. 총 8개의 채팅내역이 존재합니다.

public ResponseEntity<BaseResponse> findMessagesByChatRoomId(@PathVariable("id") Long chatRoomId,
                                                                 @RequestParam(value = "member", required = true) Long memberId,
                                                                 Pageable pageable) {
        log.info("size = {}, page = {}, sorted = {}", pageable.getPageSize(), pageable.getPageNumber(), pageable.getSort());
        List<MessagesResponseDto> messages = chatService.findMessages(chatRoomId, memberId, pageable);
        return new ResponseEntity<>(new BaseResponse(messages), HttpStatus.OK);
    }

Pageable에 어떤 값이 들어오는지 확인하기 위해 로그를 추가해보겠습니다.


페이지 당 데이터 갯수를 2개로 설정한 페이지네이션에서 0번째 페이지를 가져와보겠습니다.

제일 처음 데이터 2개가 넘어오는걸 확인할 수 있죠?

로그에서도 정상적으로 Pageable에 값이 넘어왔다고 확인할 수 있네요.

페이지를 1로 바꾸면 그 다음 2개의 데이터가 넘어옵니다.

저희가 sort는 다루지 않았는데, 제 코드에서 문제점을 발견해 아래에서 자세하게 다루겠습니다.

추가

Pageable sort와 JpaRepository OrderBy를 동시에 쓸 경우

제 JpaRepository 코드를 다시한번 보겠습니다.

List<Message> findAllByChatRoom_IdOrderByCreatedAt(Long chatRoomId, Pageable pageable);

여기서 저는 OrderByCreatedAtPageablesort를 동시에 쓰게 됩니다. 그럼 어떻게 될까요?

테스트 코드를 위와 같이 변경하고 description을 기준으로 내림차순으로 정렬해 보겠습니다.


Pageable이 문제없이 초기화 됐습니다.

하지만, description(message) 기준으로 내림차순 정렬되지 않았습니다.
원래라면 한글인 "김"과 "하입보이"가 가장 먼저 와야하는데 말이죠.
여기서 저희는 Pageable에 있는 정렬보다 JPA Repository의 메서드 네임 쿼리가 우선권을 가진다는 것을 유추할 수 있습니다.

Hibernate: 
    select
        message0_.id as id1_5_,
        message0_.chat_room_id as chat_roo5_5_,
        message0_.created_at as created_2_5_,
        message0_.description as descript3_5_,
        message0_.member_id as member_i6_5_,
        message0_.updated_at as updated_4_5_ 
    from
        message message0_ 
    left outer join
        chat_room chatroom1_ 
            on message0_.chat_room_id=chatroom1_.id 
    where
        chatroom1_.id=? 
    order by
        message0_.created_at asc, // 정렬기준 1
        message0_.description desc limit ? // 정렬기준 2

실제 쿼리를 확인해보면 createdAt으로 먼저 정렬한 뒤에, description으로 정렬하는 것을 확인하실 수 있습니다.
SQL에 익숙하신 분은 아시겠지만, createdAt으로 정렬하고 createdAt이 같다면, description으로 내림차순 정렬을 하는 쿼리 입니다.

💡 Pageable보다 JPA Repository의 메서드 네임 쿼리가 우선권을 가진다.

여기서 createdAt을 지우고 다시 요청해보겠습니다.

public interface MessageRepository extends JpaRepository<Message, Long> {
    List<Message> findAllByChatRoom_Id(Long chatRoomId, Pageable pageable);
}


정상적으로 message를 기준으로 내림차순 정렬되는 것을 확인하실 수 있습니다.

💡 따라서 Pageable을 사용할 때는, 의도한 바가 아니라면 메서드 네임드 쿼리에 정렬 기준을 잡지 않는 것이 좋을 것 같습니다.

Pageable의 기본값

만약 Pageable을 쓰고 아무 인자도 주지 않으면 어떻게 될까요?

GET /chats/:id?member=1


size = 20, page = 0, sorted = UNSORTED로 기본값을 확인할 수 있습니다.

이 부분은 PageableHandlerMethodArgumentResolverSupport에서 확인할 수 있는데요, 저희가 따로 기본설정 해주지 않으면 fallbackPageable으로 위에서 확인한 20, 0, UNSORTED의 값으로 반환합니다.

인자를 Pageable에 넣어주는 PageableHandlerMethodArgumentResolver 클래스에서 resolveArgument 메서드를 확인해보겠습니다.

getPageable을 통해 기본값 페이지를 얻어오거나 입력받은 인자를 사용하게 됩니다.

@PageDefault, Pageable 기본값 설정

그렇다면, Pageable의 기본값은 어떻게 설정할까요? 바로 @PageDefault를 사용해 설정할 수 있습니다.

@GetMapping("/chats/{id}")
public ResponseEntity<BaseResponse> findMessagesByChatRoomId(@PathVariable("id") Long chatRoomId,
                                                             @RequestParam(value = "member", required = true) Long memberId,
                                                             @PageableDefault(page = 1, size = 2, sort = "description", direction = Sort.Direction.DESC) Pageable pageable) {
    log.info("size = {}, page = {}, sorted = {}", pageable.getPageSize(), pageable.getPageNumber(), pageable.getSort());
    List<MessagesResponseDto> messages = chatService.findMessages(chatRoomId, memberId, pageable);
    return new ResponseEntity<>(new BaseResponse(messages), HttpStatus.OK);
}
@PageableDefault(page = 1, size = 2, sort = "description", direction = Sort.Direction.DESC) Pageable pageable)

설정 목록은 page, size, sort(정렬기준), direction(정렬방향) 입니다.

로그도 정상적으로 설정한 기본값이 출력되고,

결과도 동일합니다.

Page vs Slice vs List

Slice<Message> findByChatRoom_Id(Long chatRoomId, Pageable pageable);
Page<Message> findByChatRoom_Id(Long chatRoomId, Pageable pageable);
List<Message> findByChatRoom_Id(Long chatRoomId, Pageable pageable);

가장 기본인 List 말고도 반환 타입을 지정할 수 있습니다. 각자의 반환타입을 살펴보겠습니다.

  • Slice : 현재 페이지에서 이전과 다음이 있는지만 알 수 있습니다. 무한 스크롤 처럼 데이터양이 상당히 많고, 전체 페이지 갯수가 필요없는 기능에 사용됩니다.
  • Page : Slice를 상속받고, 총 페이지를 알 수 있습니다. 따라서 count 쿼리가 한번 더 나갑니다. 일반적인 게시판에서 사용됩니다.

Slice는 어떻게 앞과 뒤에 페이지가 있는지 알 수 있을까요? 그 비밀은 Pageable로 넘긴 페이지 사이즈에 있습니다.
넘긴 페이지 사이즈에 +1을 더해서 쿼리를 날리는데요, 여기서 만약 조회가 된다면 다음 페이지가 있다고 판단하게 됩니다.

마치며

계속 미루던 Pageable에 대해 공부를 마쳐서 기쁘네요. 긴 글 읽어주셔서 감사드리고 언제나 잘못된게 있다면 지적 부탁드립니다. 🙇🏻

Reference

https://wonit.tistory.com/483
https://zayson.tistory.com/entry/Spring-Data-JPA%EC%9D%98-Page%EC%99%80-Slice
https://tecoble.techcourse.co.kr/post/2021-08-15-pageable/
https://hudi.blog/spring-data-jpa-pagination/

profile
수박개 입니다.

0개의 댓글