ecsimsw

Spring AOP 를 사용한 페이지네이션 Max page size 명시 본문

Spring AOP 를 사용한 페이지네이션 Max page size 명시

JinHwan Kim 2022. 8. 5. 06:18

배경

발명소 프로젝트 중이었다. 발명소는 신뢰도 있는 프로젝트에서 '변수명', '메서드명', '클래스명' 등 개발자의 작명이 얼마나 보편적으로 사용되는지를 검색할 수 있는 서비스이다. 개발자는 본인의 작명이 얼마나 빈번하게 사용되고, 어떤 상황에서 어떻게 사용되는지 확인할 수 있다.

 

시운영에서는 백만개를 넘는 코드 파일이 있었고, 테스트에서 가장 많았던 변수명(i) 또는 코드 내 단순 단어 검색은 그 검색 결과가 만개를 훌쩍 넘었다. 우리 팀은 페이지네이션을 적용했고, 그 페이지 수를 프론트 앱에서 결정할 수 있도록 했다. 서버에 보내는 요청 예시는 아래와 같다.

 

i가 변수명으로 사용된 코드 부분을 검색하는데 한 페이지 안에 30개의 검색 결과를 갖는다고 할 때, 두 번째 페이지를 반환해줘.
// ex) www.api.varmyeong.com/page=1&size=30

 

비교적 개발 속도가 늦었던 프론트 개발을 쉽도록 하기 위해서였는데, 아래처럼 누군가 의도적으로 페이지 사이즈를 크게 변경하여 쿼리를 서버에 요구한다고 생각하니 서버에서 해당 요청을 처리하기 위해 오랜 시간과 자원이 사용될 것이라는 생각이 들었다.

 

i가 변수명으로 사용된 코드 부분을 검색하는데 한 페이지 안에 만개의 검색 결과를 갖는다고 할 때, 첫 번째 페이지를 반환해줘.
// ex) www.api.varmyeong.com/page=0&size=10000

 

이런 배경으로 페이지의 최대 사이즈를 제한하게 되었다.

 

요구 사항

0. 서버에 요청할 수 있는 최대 페이지 사이즈를 수정할 것

1. 사용자의 요청과 관리자의 요청의 최대 페이지 사이즈를 달리 할 것

2. 코드 안에서 다른 로직 없이, 깔끔하게 최대 사이즈를 지정할 수 있는 방법을 고민할 것

 

 

해결 방법

1. 설정 파일로 페이지 사이즈와 최대 페이지 사이즈 지정하기

 

일단 설정 파일로 페이지 사이즈와 요청 가능한 최대 페이지 사이즈를 지정할 수 있다. 설정하지 않았을 때의 페이지 사이즈는 20, 최대 페이지 사이즈는 2000으로, 다른 설정이 없었다면 한 페이지에 2000개의 검색 결과를 담아 반환하는 경우도 생겼을 것이다. 

 

아래는 application-properties에서 페이지 사이즈, 최대 페이지 사이즈를 지정할 수 있는 프로퍼티 키이다.

 

페이지 사이즈 (default = 20)

spring.data.web.pageable.default-page-size

 

최대 페이지 사이즈 (default = 2000)

spring.data.web.pageable.max-page-size

 

문제는 이 방법은 전체 프로젝트 적용이므로, 쉽게 사용자의 API와 관리자의 API의 최대 페이지 사이즈를 분리하여 지정하지 못했다.

 

 

2. ArgumentResovler 정의하기

 

2-1 PageableHandlerMethodArgumentResolver 사용하기

 

결국 url path 쿼리 파라미터로 들어온 page와 size를 Pageable 객체에 매핑시키는 것이니, Argument resolver가 그 일을 하지 않겠어?라는 아이디어에서, 페이지네이션 쿼리 파라미터를 resolve하는 Argument resolver를 찾기 시작했다.

 

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        PageableHandlerMethodArgumentResolver pageableHandlerMethodArgumentResolver = new PageableHandlerMethodArgumentResolver();
        pageableHandlerMethodArgumentResolver.setMaxPageSize(3);
        argumentResolvers.add(pageableHandlerMethodArgumentResolver);
    }
}

 

WebMvcConfigurer를 구현하고, addArgumentResolvers의 인자 argumentResolvers에 새로 설정값을 추가한 PageableHandlerMethodArgumentResolver를 등록하는 것으로 max page size를 지정할 수 있었다. 

 

 

2-2 PageableHandlerMethodArgumentResolver 커스텀하기

 

이번에는 PageableHandlerMethodArgumentResolver를 커스텀하여 어노테이션으로 max page size를 지정할 수 있도록 하려고 한다.

 

아래처럼 어노테이션을 정의한다. 메서드에 붙여 사용할 것이고, maxSize의 기본 값은 이전과 동일하게 2000으로 하되, 해당 값을 변경하는 것으로 쉽게 maxSize를 표시할 수 있도록 한다.

 

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
public @interface LimitedSizePagination {
    int maxSize() default 2000;
}

 

PageableHandlerMethodArgumentResolver를 상속하고, 아래와 같이 resolveArgument()를 재정의한다. 

 

@Component
public class LimitedPageableArgumentResolver extends PageableHandlerMethodArgumentResolver {

    @Override
    public Pageable resolveArgument(MethodParameter methodParameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) {
        final Pageable pageable = super.resolveArgument(methodParameter, mavContainer, webRequest, binderFactory);
        if (methodParameter.hasMethodAnnotation(LimitedSizePagination.class)) {
            final int maxSize = methodParameter.getMethodAnnotation(LimitedSizePagination.class).maxSize();
            if (pageable.getPageSize() > maxSize) {
                throw new IllegalArgumentException("page size can't be bigger than " + maxSize);
            }
        }
        return pageable;
    }
}

 

간단히 해석하면 resolveArgument 메서드에서 원래의 부모와 같이 Pageable 객체를 가져오되, 해당 컨트롤러 메서드에 표시된 LimitedSizePagination 어노테이션의 maxSize 값보다 pageSize가 더 크면 에러를 발생시킨다는 생각이었다.

 

@Configuration
public class WebConfig implements WebMvcConfigurer {

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

 

그리고 이렇게 커스텀한 PageableArgumentResolver를 이전과 마찬가지로 argumentResolvers에 등록해주었다. 

 

 

결과 

이제 정의한 어노테이션으로 maxSize를 표시하면 된다. 위의 searchAllCode는 관리자가 사용하는 API로 특별한 maxSize의 규칙을 주지 않았고, 아래는 사용자 측 API로 최대 페이지 사이즈를 20으로 지정하였다.

 

// AdminController
@GetMapping("/admin/code")
public ResponseEntity<..> searchAllCode(Pageable pageable) {
    ..
}

// SearchController
@LimitedSizePagination(maxSize = 20)
@GetMapping("/code/variable/{name}")
public ResponseEntity<..> searchByVariable(@PathVariable String name, Pageable pageable) {
    ..
}

 

만약 지정된 최대 페이지 사이즈보다 큰 페이즈 사이즈 값이 요청으로 들어오면, 아래와 같이 예외를 터트린다. 물론 예시에서의 20이 아닌 다른 값이어도 상관없고, 여러 서로 다른 maxSize를 지정해도 문제없다.

 

 

 

번외 interceptor 

쿼리 파라미터로 들어오는 size의 값이 지정한 maxSize보다 크면 예외를 터트리면 되므로, 인터셉터를 이용해서 요청의 파라미터 값을 확인하는 것으로 처리할 수 도 있을 것 같다. 

 

마찬가지로 어노테이션을 그대로 적용했을 때 인터셉터는 다음과 같이 정의할 수 있다. 처리하는 메서드에 어노테이션이 붙어있다면, maxSize의 값과 쿼리에서 size의 값을 비교하겠다는 의미이다.

 

@Component
public class SearchInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        final HandlerMethod handlerMethod = (HandlerMethod) handler;
        if(handlerMethod.hasMethodAnnotation(LimitedSizePagination.class)) {
            final int maxSize = handlerMethod.getMethodAnnotation(LimitedSizePagination.class).maxSize();
            if(Integer.parseInt(request.getParameter("size")) > maxSize) {
                throw new IllegalArgumentException("page size can't be bigger than " + maxSize);
            }
        }
        return true;
    }
}

 

이럴 경우, 프로젝트 설정으로 페이지 네이션의 쿼리 파라미터 키 이름을 바꾼다고 하면, 파라미터를 가져오는 코드에서 키 이름을 새로 수정해야 할 것이다. request.getParameter("size")에서 "size"를 말한다. 더 문제는 이 문자열이 그냥 매직 스트링이라 쿼리 파라미터 키가 수정되어도 연결되어 에러를 표시하지도 않고, 오타가 생겨도 확인할 방법이 없는 골칫덩어리라는 점.

 

개인적으로는 쿼리 파라미터를 읽어 Pageable 객체로 매핑하는 resolver가 이미 있는데, 굳이 인터셉터로 직접 쿼리 파라미터를 조사할 필요가 있나 생각하여 위의 PageableArgumentResolver를 커스텀하는 방식을 추천하고 싶다.

 

 

해당 PR

아래 PR에서 실제 문제 사항과 해결 방식, 코드를 확인할 수 있다.

 

https://github.com/var-myeong-so/var-myeong-be/pull/2

 

Comments