#spring, #jpa

Pageable을 이용한 Pagination을 처리하는 다양한 방법

Spring Data JPA에서 Pageable 를 활용한 Pagination 의 개념과 방법을 알아본다.

Pageable을 활용한 Pagination이 무엇인가?

많은 게시판은 모든 글을 한 번에 보여주지 않고 페이지를 나눠 쪽수별로 제공한다. 정렬 방식 또한 설정하여, 보고 싶은 정보의 우선순위를 설정할 수도 있다. 이처럼 정렬 방식과 페이지의 크기, 그리고 몇 번째 페이지인지의 요청에 따라 정보를 전달해주는 것이 Pagination 이다.

이를 개발자가 직접 구현해서 사용할 수도 있으나, JPA에서는 이를 편하게 사용할 수 있도록 Pageable 이라는 객체를 제공한다. ‘page=3&size=10&sort=id,DESC’ 형식의 QueryParameter를 추가로 요청을 보내게 되면, 쉽게 원하는 형식의 데이터들을 얻을 수 있다. 이 예시는 id의 내림차순으로 정렬한, 1쪽 10개의 글의 구성의 3번째 페이지의 정보를 요청 보내는 것이다.

Pageable JPA에서 사용하기

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 의 형식으로 반환하는 것을 확인할 수 있다.

그렇다면 또 궁금증이 생긴다. 기본 설정을 수정하려면 어떤 방식을 사용할 수 있을까?

@PageDefault 어노테이션

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 어노테이션으로 설정한다.


지금까지 org.springframework.data.web 에 정의된 PageableHandlerMethodArgumentResolver 를 살펴보았다. 그렇다면, SpringBoot의 어디에서 이를 Bean으로 등록하고 사용하고 있을까?

SpringDataWebConfiguration

@Configuration(proxyBeanMethods=false)
public class SpringDataWebConfiguration {
  private @Autowired Optional<PageableHandlerMethodArgumentResolverCustomizer> pageableResolverCustomizer;

  @Bean
  public PageableHandlerMethodArgumentResolver pageableResolver() {

    PageableHandlerMethodArgumentResolver pageableResolver = new PageableHandlerMethodArgumentResolver(sortResolver.get());
    customizePageableResolver(pageableResolver);
    return pageableResolver;
  }

  protected void customizePageableResolver(PageableHandlerMethodArgumentResolver pageableResolver) {
    pageableResolverCustomizer.ifPresent(c -> c.customize(pageableResolver));
  }    
}

SpringDataWebConfiguration에서 PageableHandlerMethodArgumentResolver Bean을 등록한다. spring-boot-starter-web의 의존성을 추가하면 자동으로 등록된다.

내부적으로 pageableResolverCustomizer가 존재하는 경우 이를 적용한다.

그렇다면 PageableHandlerMethodArgumentResolverCustomizer Bean은 또 어디서 정의될까?

SpringDataWebAutoConfiguration

@Configuration(proxyBeanMethods=false)
public class SpringDataWebAutoConfiguration {
  @Bean
  @ConditionalOnMissingBean
  public PageableHandlerMethodArgumentResolverCustomizer pageableCustomizer() {
    return (resolver) -> {
      Pageable pageable = this.properties.getPageable();
      resolver.setPageParameterName(pageable.getPageParameter());
      resolver.setSizeParameterName(pageable.getSizeParameter());
      resolver.setOneIndexedParameters(pageable.isOneIndexedParameters());
      resolver.setPrefix(pageable.getPrefix());
      resolver.setQualifierDelimiter(pageable.getQualifierDelimiter());
      resolver.setFallbackPageable(PageRequest.of(0, pageable.getDefaultPageSize()));
      resolver.setMaxPageSize(pageable.getMaxPageSize());
    };
  }    
}

SpringDataWebAutoConfiguration 설정 파일에서 PageableHandlerMethodArgumentResolverCustomizer Bean을 등록을 관리한다.

@ConditionalOnMissingBean설정의 경우, 해당 Bean이 기존의 정의되지 않은 경우에만, 등록되도록 작업 되어 있다.

SpringDataWebProperties 클래스 내부적으로 기본값이 설정되어 있고(하나의 Page의 크기 = 20) 이는 @ConfigurationProperties("spring.data.web")를 가르키고 있기 때문에, application-properties에 별도로 ‘spring.data.web.pageable.default-page-size=100’과 같은 설정을 추가해주면, 이 설정으로 변경할 수 있다.

CustomPageableConfiguration 적용하기

@Configuration
public class CustomPageableConfiguration {
    @Bean
    public PageableHandlerMethodArgumentResolverCustomizer customize() {
        return p -> p.setFallbackPageable(PageRequest.of(0, Integer.MAX_VALUE));
    }
}

별도의 CustomPageableConfiguration을 만들어서 PageableHandlerMethodArgumentResolverCustomizer Bean을 미리 정의하게 되면, SpringDataWebAutoConfiguration에서는 추가적인 Bean생성이 되지 않게 된다.

Auto-configuration 설정들은 User-defined Bean들이 등록하고 난 다음에 적용되기 때문이다.


위의 설정들 중 우선순위는?

다소 복잡하게 설명이 되었지만, 페이지네이션 관련 설정을 제외한 요청을 보냈을 때, Page의 설정을 수정하는 방법은 다음과 같은 3가지 정도이다.

  1. @PageDefault어노테이션 사용. → DefaultPage를 정의
  2. application-properties에 별도로 ‘spring.data.web.pageable.default-page-size=100’과 같은 설정을 추가 → Bean 등록 시 FallbackPage의 설정을 수정(이와 별개로 Bean을 구성하는 다른 설정들도 수정 가능)
  3. CustomPageableConfiguration 에서 설정 진행.→ 2번에서는 application-properties에서 설정을 진행했다면, 동일한 설정을 java 코드에서 진행한다. → 마찬가지로 Bean등록시 FallbackPage의 설정을 수정(이와 별개로 Bean을 구성하는 다른 설정들도 수정 가능)

그렇다면, 동시엔 이 3가지 설정의 적용된다면, 어떤 설정이 우선순위를 가질까?

첫 번째 우선순위를 지니는 것은 1. @PageDefault어노테이션의 사용이다. 2, 3번의 경우 FallbackPage의 설정을 결정하는 것인데 반해, 1번의 설정은 DefaultPage를 설정하기 때문이다. 내부 구현에서는 DefaultPage가 설정되어 있으면, 그 설정을 따른다.

2, 3번의 대결에서는 3번이 우선순위를 가진다. CustomPageableConfiguration 에서 PageableHandlerMethodArgumentResolverCustomizer Bean을 생성하게 되면 @ConditionalOnMissingBean설정에 따라 2번 로직은 실행되지 않게 된다.

우선순위만의 결론을 내리자면 1 -> 3 -> 2의 순이 되겠다. 개인적으로 추천하는 방식은, DefaultPage와 FallbackPage의 특징을 잘 살려 각각 정의하고 사용하는 것을 추천한다. 2, 3번 설정 중 어떤 방식을 택하는지는 개인의 취향의 문제라 남겨두겠다.


정리

지금까지 Pageable의 작동 방식과, 이를 상황에 맞게 사용하는 방법을 알아보았다. 이 글이, 이번 경우에 한정된 것이 아니라 Springboot를 사용하게 되었을 때 자동으로 설정되는 수많은 설정을 커스터마이징 할 수 있는 기반이 되었으면 한다.

참고 자료