스프링

Pageable 커스텀 예외처리

장몽이 2025. 6. 7.

siso 사이드프로젝트를 진행하면서 스프링에서 페이징 처리를 위해 제공하는 Pageable 인터페이스의 예외처리를 적용한 방법을 정리해보자. 

커스텀 예외처리를 적용한 이유 

  1. siso는 @RestControllerAdvice 에서 예외를 처리한다. Pageable 기본 예외처리 말고 내가 원하는 예외 타입을 던지고 정해놓은 형식에 맞게 처리되길 원했다. 예를 들어 page size -1 요청에 대해서 IllegalArgumentException: Page size must not be less than one! 가 발생하는데, 나는 내가 정의한 예외 타입 - 예외 코드에 의해 처리되길 바란다. 
  2. Pageable은 page size가 -1 이라던가 하는 일반적으로 잘못된 요청에 대해서는 예외처리를 하지만, 더 다양한 상황에대해 예외처리하고 싶었다. 특히 정렬 방식에 대해서도 예외처리를 하고 싶었다. 
  3. 컨트롤러마다 별도의 예외 처리 로직을 작성하면 중복되기 때문에 중복을 해소하고 싶었다.   

해결 방법 

    @GetMapping("/{encryptedCongressmanId}")
    public ResponseEntity<RatingListDTO> ratingList(
            @PathVariable String encryptedCongressmanId,
            @PageConfig(allowedSorts = {LIKE, DISLIKE, TOPICALITY, REG_DATE},
                    defaultSort = "topicality, DESC", defaultSize = 20) Pageable pageable,
            @Validated @ModelAttribute CountCursor cursor, @LoginId(required = false) Long loginId) {
        return ResponseEntity.ok(
                ratingService.validateAndGetRecentRatings(encryptedCongressmanId, pageable, cursor, loginId)
        );
    }

 

@PageConfig 애너테이션, argumentresolver 를 사용했다. 

 

PageConfig 

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface PageConfig {
    SortProperty[] allowedSorts() default SortProperty.ID; // 허용된 sort 값

    String defaultSort() default "id, DESC"; // 기본 sort

    int defaultPage() default 1; // 기본 page

    int defaultSize() default 10; // 기본 size
}

페이징이 필요한 컨트롤러는 @PageConfig 가 달린 파라미터를 받도록 했다. 

@PageConfig는 허용된 정렬 방식들 allowedSorts, 정렬 방식을 지정하지 않고 요청했을 때 지정할 정렬 방식 defaultSort, 기본 페이지, 기본 페이지 사이즈를 속성으로 갖는다. 

컨트롤러에서 애너테이션 속성의 기본값을 설정하지 않을 경우는 @PageConfig 에서 정의한 기본값을 사용한다. 

예를 들어 @PageConfig 를 사용할 때 허용 정렬 방식을 따로 지정하지 않으면 id 정렬만 허용하게 된다. 

 

CustomPageableResolver

    @Override
    public boolean supportsParameter(final MethodParameter parameter) {
        return parameter.hasParameterAnnotation(PageConfig.class) && Pageable.class.isAssignableFrom(
                parameter.getParameterType());
    }

    @Override
    public Object resolveArgument(final MethodParameter parameter, final ModelAndViewContainer mavContainer,
                                  final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory) {
        final PageConfig pageConfig = parameter.getParameterAnnotation(PageConfig.class);

        final int page = getIntParam(webRequest, PAGE_PARAM, pageConfig.defaultPage());
        final int size = getIntParam(webRequest, SIZE_PARAM, pageConfig.defaultSize());
        final String sort = resolveSort(webRequest.getParameter(SORT_PARAM), pageConfig);

        validatePageAndSize(page, size);

        final String[] sortParts = sort.split(",");
        final String property = sortParts[0].trim();
        final boolean isDescending = sortParts.length == 2 && "DESC".equalsIgnoreCase(sortParts[1].trim());

        // page - 1 ( 요청 : 1페이지 -> 실제 : 0페이지 )
        return PageRequest.of(page - 1, size,
                isDescending ? Sort.by(property).descending() : Sort.by(property).ascending());
    }
    
    ... 생략
    
    private void validatePageAndSize(final int page, final int size) {
        if (page <= 0) {
            throw new CustomException(PageableErrorCode.INVALID_PAGE);
        }
        if (size < 1) {
            throw new CustomException(PageableErrorCode.INVALID_SIZE);
        }
    }

    private boolean isValidSort(final String sort, final SortProperty[] allowedSorts) {
        if (sort == null || sort.trim().isEmpty()) {
            return true; // sort가 없으면 통과
        }

        final String[] parts = sort.split(",");
        if (parts.length == 1) {
            return isAllowedSort(parts[0].trim(), allowedSorts); // 정렬 속성만 있어도 통과
        }

        if (parts.length == 2) {
            final String property = parts[0].trim();
            final String direction = parts[1].trim().toUpperCase();
            return isAllowedSort(property, allowedSorts) && isAllowedDirection(direction);
        }
        return false;
    }

    private static boolean isAllowedDirection(final String direction) {
        if ("ASC".equals(direction) || "DESC".equals(direction)) {
            return true;
        }
        throw new CustomException(PageableErrorCode.UNSUPPORTED_SORT_DIRECTION);
    }

    private boolean isAllowedSort(final String property, final SortProperty[] allowedSorts) {
        for (final SortProperty allowedSort : allowedSorts) {
            if (allowedSort.getValue().equals(property)) {
                return true;
            }
        }
        throw new CustomException(PageableErrorCode.UNSUPPORTED_SORT_PROPERTY);
    }
    
    ... 생략

 

PageConfig 애너테이션이 달린 파라미터에 대해 해당 리졸버가 동작하며, 속성들에 대해 검증한다. 

검증 실패 시 예외를 던지며, 내가 원하는 예외를 던질 수 있어서 @RestControllerAdvice 에서 공통적으로 처리할 수 있어 편리하다. 

결과적으로 Pageable 구현체를 반환하므로 페이징 기능은 그대로 사용할 수 있다. 

댓글