Pageable 사이즈 제한하기

0_0_yoon·2022년 7월 2일
1
post-thumbnail

문제상황

Spring 에서 제공하는 Pageable 을 사용해서 페이징 기능을 구현했다. 이때 F12 서비스에 맞지 않게 많은 page size 를 요청하는 경우 서비스 레이어에 들어오기 전에 제한할 필요가 있었다.

@GetMapping("/reviews")
    public ResponseEntity<ReviewPageResponse> showPage(final Pageable pageable) {
        final ReviewPageResponse reviewPageResponse = reviewService.findPage(pageable);
        return ResponseEntity.ok(reviewPageResponse);
    }

원인

Spring 에서 제공하는 PageableHandlerMethodArgumentResolver 를 사용해서 기존 설정을 바꿀 수 없었다.

// PageableHandlerMethodArgumentResolverSupport
private static final int DEFAULT_MAX_PAGE_SIZE = 2000;

해결

  1. DTO 를 만들어서 유효성 검증을 하자.
public class PagingRequest {

    @Max(value = 100, message = "사이즈는 100 이하여야 합니다.")
    private int size;
    private int page;
    private Sort sort;

    private PagingRequest(final int size, final int page, final Sort sort) {
        this.size = size;
        this.page = page;
        this.sort = sort;
    }

    public Pageable toPageRequest() {
        return PageRequest.of(size, page, sort);
    }
}

해당 DTO 는 ServletModelAttributeMethodProcessor 에 의해 바인딩 된다.(이유는 아래 추가 내용 참고) 이때 문제가 발생한다. ?sort=createdAt,desc 과 같은 형식을 Sort 객체로 바인딩하기 위해서는 SortHandlerMethodArgumentResolver(PageableHandlerMethodArgumentResolver 가 가지고 있다)가 필요하다.

  1. 기존에 있는 PageableHandlerMethodArgumentResolver 를 상속해서 검증 기능을 추가하자.
public class CustomPageableHandlerMethodArgumentResolver extends PageableHandlerMethodArgumentResolver {

    @Override
    public Pageable resolveArgument(final MethodParameter methodParameter, final ModelAndViewContainer mavContainer,
                                    final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory) {

        String pageSize = webRequest.getParameter(getParameterNameToUse(getSizeParameterName(), methodParameter));
        validate(pageSize);
        return super.resolveArgument(methodParameter, mavContainer, webRequest, binderFactory);
    }

    private void validate(final String pageSize) {
        if (Integer.parseInt(pageSize) > 100) {
            throw new PageSizeOutOfBoundsException();
        }
    }
}

WebMvcConfigurer 를 구현해서 ArgumentResolver 를 등록해줬다.

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addArgumentResolvers(final List<HandlerMethodArgumentResolver> argumentResolvers) {
        argumentResolvers.add(new CustomPageableHandlerMethodArgumentResolver());
    }
}

size 값이 100이 넘으면 예외가 발생한다.

PageableHandlerMethodArgumentResolver 를 상속해서 page size 검증 기능을 추가한뒤 Pagealbe 을 처리하는 ArgumentResolver 가 두 개인 상황에서 커스텀한 PageableArgumentResolver 가 기존의 ArgumentResolver 를 100% 대신해서 동작하는지 확인할 필요가 있었다.

CustomPageableHandlerMethodArgumentResolver가 먼저 삽입됐기 때문에 CustomPageableHandlerMethodArgumentResolver 가 PageableHandlerMethodArgumentResolver 를 완전히 대신하게 됐다.

CustomPageableHandlerMethodArgumentResolver 가 먼저 삽입되는 이유는 단순하다. Spring Boot 에서는 디렉토리 순서(알파벳)대로 스캔하면서 bean 을 생성하기 때문에 WebConfig 가 먼저 등록되고

spring-data-commons 라이브러리의 (org.springframework.boot:spring-boot-starter-data-jpa 의존성을 추가하면 같이 추가된다, 여기에 PageableHandlerMethodArgumentResolver 가 들어있다) SpringDataWebConfiguration 가 등록된다.

추가

어떠한 어노테이션도 붙이지 않았는데 Pageable은 어떻게 바인딩 되는 걸까?
@RequestParam , @ModelAttribute 은 생략할 수 있으며 int, String 같은 단순한 자료형은 @RequestParam으로 인식하고 그 외의 객체들은 @ModelAttribute로 인식한다. 그래서 우리가 흔히 쓰는 @RequestBody를 누락하면 자동으로 ServletModelAttributeMethodProcessor 가 argumentResolver로서 바인딩하게 된다.

@ModelAttribute 를 생략할 수 있는 이유

그렇다면 Pageable은 SimpleValueType이 아니므로 당연히ServletModelAttributeMethodProcessor가 바인딩을 해줄 것이다. 하지만 그림과 같이 PageableHandlerMethodArgumentResolver가 선택된다.

이게 어떻게 가능한걸까? 이유는 단순했다.

// RequestMappingHandlerAdapter
private List<HandlerMethodArgumentResolver> getDefaultArgumentResolvers() {
	List<HandlerMethodArgumentResolver> resolvers = new ArrayList<>(30);

	// Annotation-based argument resolution
	resolvers.add(new RequestParamMethodArgumentResolver(getBeanFactory(), false));
	resolvers.add(new RequestParamMapMethodArgumentResolver());
	resolvers.add(new PathVariableMethodArgumentResolver());
	resolvers.add(new PathVariableMapMethodArgumentResolver());
	resolvers.add(new MatrixVariableMethodArgumentResolver());
	resolvers.add(new MatrixVariableMapMethodArgumentResolver());
	resolvers.add(new ServletModelAttributeMethodProcessor(false));
	resolvers.add(new RequestResponseBodyMethodProcessor(getMessageConverters(), this.requestResponseBodyAdvice));
	resolvers.add(new RequestPartMethodArgumentResolver(getMessageConverters(), this.requestResponseBodyAdvice));
	resolvers.add(new RequestHeaderMethodArgumentResolver(getBeanFactory()));
	resolvers.add(new RequestHeaderMapMethodArgumentResolver());
	resolvers.add(new ServletCookieValueMethodArgumentResolver(getBeanFactory()));
	resolvers.add(new ExpressionValueMethodArgumentResolver(getBeanFactory()));
	resolvers.add(new SessionAttributeMethodArgumentResolver());
	resolvers.add(new RequestAttributeMethodArgumentResolver());

	// Type-based argument resolution
	resolvers.add(new ServletRequestMethodArgumentResolver());
	resolvers.add(new ServletResponseMethodArgumentResolver());
	resolvers.add(new HttpEntityMethodProcessor(getMessageConverters(), this.requestResponseBodyAdvice));
	resolvers.add(new RedirectAttributesMethodArgumentResolver());
	resolvers.add(new ModelMethodProcessor());
	resolvers.add(new MapMethodProcessor());
	resolvers.add(new ErrorsMethodArgumentResolver());
	resolvers.add(new SessionStatusMethodArgumentResolver());
	resolvers.add(new UriComponentsBuilderMethodArgumentResolver());
	if (KotlinDetector.isKotlinPresent()) {
		resolvers.add(new ContinuationHandlerMethodArgumentResolver());
	}
    
    // 여기에서 PageableHandlerMethodArgumentResolver 가 등록된다.
	// Custom arguments
	if (getCustomArgumentResolvers() != null) {
		resolvers.addAll(getCustomArgumentResolvers());
	}

	// Catch-all
	resolvers.add(new PrincipalMethodArgumentResolver());
	resolvers.add(new RequestParamMethodArgumentResolver(getBeanFactory(), true));
	resolvers.add(new ServletModelAttributeMethodProcessor(true));

	return resolvers;
}

RequestMappingHandlerAdapter 에서 ArgumentResolver들을 초기화할 때 ServletModelAttributeMethodProcessor 를 가장 마지막에 넣어줬기 때문이다.(항상 제일 마지막에 삽입된다, customAgumentResolver는 Catch-all 위에서 넣어준다)
PageableHandlerMethodArgumentResolver 는 파라미터의 어노테이션을 확인하지 않고 단지 파라미터가 Pageable 타입이면 된다.

// PageableHandlerMethodArgumentResolver
@Override
public boolean supportsParameter(MethodParameter parameter) {
	return Pageable.class.equals(parameter.getParameterType());
}

결국 우리는 Pageable을 사용하기 위해서는 어노테이션을 붙이지 않고 그저 Pageable 타입만 사용하면 된다.

profile
꾸준하게 쌓아가자

2개의 댓글

comment-user-thumbnail
2022년 7월 2일

진짜 좋네요,,,

1개의 답글