많은 게시판은 모든 글을 한 번에 보여주지 않고 페이지를 나눠 쪽수별로 제공한다. 정렬 방식 또한 설정하여, 보고 싶은 정보의 우선순위를 설정할 수도 있다. 이처럼 정렬 방식과 페이지의 크기, 그리고 몇 번째 페이지인지의 요청에 따라 정보를 전달해주는 것이 Pagination 이다.
이를 개발자가 직접 구현해서 사용할 수도 있으나, JPA에서는 이를 편하게 사용할 수 있도록 Pageable 이라는 객체를 제공한다. ‘page=3&size=10&sort=id,DESC’ 형식의 QueryParameter를 추가로 요청을 보내게 되면, 쉽게 원하는 형식의 데이터들을 얻을 수 있다. 이 예시는 id의 내림차순으로 정렬한, 1쪽 10개의 글의 구성의 3번째 페이지의 정보를 요청 보내는 것이다.
Pageable을 사용한 간단한 UserRespository, UserController를 만들어 보면 다음과 같다.
public interface UserRepository extends JpaRepository<Job, Long> {
List<User> findByLastname(String lastName, Pageable pageable);
}
@Controller
public class UserController {
@GetMapping("/users")
public List<UserResponse> findByLastName(@RequestParam String lastName, Pageable pageable) {
// 생략
}
}
📌 위와 같은 방식으로 Pageable 객체를 인수로 넘겨줌으로써 JpaRepository로 부터 원하는 Page만큼의 User 목록을 반환받을 수 있다.
GET /users?lastName=kim&page=3&size=10&sort=id,DESC
Controller에서는 Pageable 객체를 인수로 설정한 후 위와 같은 요청이 들어오게 되면, ‘page=3&size=10&sort=id,DESC’ 해당하는 Pageable 객체를 자동으로 만들어준다.
별다른 수정 없이 Respository로 Pageable을 넘겨주면 되기 때문에, 매우 편리하게 Pagination 처리를 할 수 있다.
Page<User> findByLastname(String lastName, Pageable pageable);
Slice<User> findByLastname(String lastName, Pageable pageable);
List<User> findByLastname(String lastName, Pageable pageable);
JPA에서는 반환 값으로 List, Slice, Page 로 다양하게 제공하고 있다. Page 구현체의 경우에는 전체 Page의 크기를 알아야 하므로, 필요한 Page의 요청과 함께, 전체 페이지 수를 계산하는 count 쿼리가 별도로 실행된다. Slice 구현체의 경우에는 전후의 Slice가 존재하는지 여부에 대한 정보를 가지고 있다. 각 특성을 익혀 본인의 상황에 적합한 자료형을 사용하는 것을 추천한다.
spring.jpa.show-sql=true 옵션을 설정 파일에 추가하면 Page를 반환하는 메소드 실행 시, 실제로 count 명령어가 실행되는 것을 확인 할 수 있다.
public class PageableHandlerMethodArgumentResolver extends PageableHandlerMethodArgumentResolverSupport
implements PageableArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return Pageable.class.equals(parameter.getParameterType());
}
@Override
public Pageable resolveArgument() {
//생략
}
}
📌 Controller에서 별도의 어노테이션 없이 어떻게 ‘page=3&size=10&sort=firstName,ASC’ 같은 로직이 Pageable 객체로 전환되었는지 궁금증이 생긴다.
🔑 이는 org.springframework.data.web 패키지의 안에 정의된 ArgumentResolver인 PageableHandlerMethodArgumentResolver에 정의되어 있다. Pageable의 parameter가 왔을 때, resolveArgument() 로직이 실행되도록 정의가 되어 있다.
그렇다면, Pagination의 정보가 없는 GET ~/users?lastName=kim 의 요청만을 보냈을 때 어떤 형태의 반환 값을 가질까?
실제 테스트를 해보면, 정렬되지 않은 20개씩 분리된 페이지 중 첫 페이지를 반환하는 것을 확인할 수 있다. 내부 구현을 들여다보면, PageableHandlerMethodArgumentResolverSupport 에 정의된 fallbackPageable 의 형식으로 반환하는 것을 확인할 수 있다.
PageableHandlerMethodArgumentResolverSupport 에 정의된 getDefaultFromAnnotationOrFallback() 메소드에서 확인할 수 있다.
📌 @PageDefault로 어노테이션이 붙어 있는 경우에는 fallbackPageable이 아닌, @PageDefault어노테이션에 설정대로 사용자에게 보내준다.
@Controller
public class UserController {
@GetMapping("/users")
public List<UserResponse> findByLastName(@RequestParam String lastName,
@PageDefault(size=100, sort="id", direction = Sort.Direction.DESC) Pageable pageable) {
// 생략
}
}
위와 같은 방식으로 기존의 Pageable 객체 앞에 @PageDefault 어노테이션을 붙여주고 괄호 안에 기본값 설정을 진행한다.
DefaultPage vs FallbackPage
혼동될만한 두 개념을 구분하여 설명하고자 한다. DefaultPage의 경우, 개발자가 정한 기본 Page의 형식이다. 별도로 어노테이션을 통해 설정해주지 않으면 FallbackPage의 설정으로 실행된다. Fallback의 경우 적합한 방식이 없는 경우, 만일을 대비해 만들어 둔 설정이다. PageableHandlerMethodArgumentResolverSupport에는 FallbackPage만 설정되어 있다. DefaultPage는 @PageDefault 어노테이션으로 설정한다.
findAll() 메서드의 반환 타입과 파라미터를 보면 다음과 같은 것들이 존재한다.
A page is a sublist of a list of objects. It allows gain information about the position of it in the containing entire list.
페이지는 개체 목록의 하위 목록입니다. 포함하는 전체 목록에서 위치에 대한 정보를 얻을 수 있습니다.
Basic Java Bean implementation of Pageable.
Pageable의 기본 Java Bean 구현
@Transactional
public List<PostsResponse> getPostsList(int pageChoice){
Page<Posts> postsListPage = postsRepository.findAll(pageableSetting(pageChoice));
if(postsListPage.isEmpty()){
throw new IllegalArgumentException("해당 페이지가 존재하지 않습니다.");
}
List<PostsResponse> postsListResponseList = postsListPage.stream().map(PostsResponse::new).collect(Collectors.toList());
return postsListResponseList;
}
private Pageable pageableSetting(int pageChoice) {
Sort.Direction direction = Sort.Direction.DESC;
Sort sort = Sort.by(direction,"id");
Pageable pageable = PageRequest.of(pageChoice-1,4,sort);
return pageable;
}
변경 전
private Pageable pageableSetting(int pageChoice) {
Sort.Direction direction = Sort.Direction.DESC;
Sort sort = Sort.by(direction,"id");
Pageable pageable = PageRequest.of(pageChoice-1,4,sort);
return pageable;
}
변경 후
private Pageable pageableSetting(int pageChoice) {
return PageRequest.of(pageChoice-1,4,Sort.Direction.DESC,"id");
}
변경 전
@Transactional
public List<PostsResponse> getPostsList(int pageChoice){
Page<Posts> postsListPage = postsRepository.findAll(pageableSetting(pageChoice));
if(postsListPage.isEmpty()){
throw new IllegalArgumentException("해당 페이지가 존재하지 않습니다.");
}
List<PostsResponse> postsListResponseList = postsListPage.stream().map(PostsResponse::new).collect(Collectors.toList());
return postsListResponseList;
}
private Pageable pageableSetting(int pageChoice) {
return PageRequest.of(pageChoice-1,4,Sort.Direction.DESC,"id");
}
변경 후
@Transactional
public List<PostsResponse> getPostsList(int pageChoice){
Page<Posts> postsListPage = postsRepository.findAll(PageRequest.of(pageChoice-1,4,Sort.Direction.DESC,"id"));//페이징 셋팅
if(postsListPage.isEmpty()){
throw new IllegalArgumentException("해당 페이지가 존재하지 않습니다.");
}
List<PostsResponse> postsListResponseList = postsListPage.stream().map(PostsResponse::new).collect(Collectors.toList());
return postsListResponseList;
}
Control단
@GetMapping("/keyword")
public List<PostResponse> searchByKeyword(
@RequestParam String keyword,
@RequestParam(value = "page", required = false, defaultValue = "1") int page,
@RequestParam(value = "size", required = false, defaultValue = "2") int size,
@RequestParam(value = "direction", required = false) Direction direction,
@RequestParam(value = "properties", required = false) String properties
) {
return userService.searchByKeyword(keyword, page, size, direction, properties);
}
Service 단
public List<PostResponse> searchByKeyword(String keyword, int page, int size,
Direction direction, String properties) {
String title = keyword;
String content = keyword;
Page<Post> postsListPage = postsRepository.findAllByTitleContainingOrContentContaining(title,
content,
PageRequest.of(page - 1, size, direction, properties));
List<PostResponse> postResponseList = postsListPage.stream().map(PostResponse::new)
.collect(Collectors.toList());
return postResponseList;
}
Direction 값은 ASC or DESC(대문자로! 해야함)
포스트맨에서 테스트 시
참고
Pageable을 이용한 Pagination을 처리하는 다양한 방법
JPA 에서 Pageable 을 이용한 페이징과 정렬