JUN0.DEV
JUN0.DEV

Pageable 입력 검증: PageRequestDto 설계

Published on
  • avatarJunyoung Yang

카카오테크캠퍼스에서 선물하기 API에 페이지네이션을 구현하면서, 요청 파라미터를 어디까지 신뢰할 수 있는지 검토했다.

처음에는 Spring의 Pageable을 그대로 컨트롤러 파라미터로 받으면 편하다고 생각했다. page, size, sort가 자동으로 바인딩되기 때문에 코드가 간단해진다.

하지만 API 계약을 생각하면 그대로 노출하는 것이 항상 좋은 선택은 아니었다. 사용자가 허용하지 않은 정렬 필드를 넣거나, 지나치게 큰 page size를 요청하면 어떻게 처리할지 기준이 필요했다.

처음 보인 문제

예를 들어 아래와 같은 API가 있다고 봤다.

GET /api/wishlist?page=0&size=3&sort=product.name,desc

이때 사용자가 의도적으로 비정상적인 값을 넣으면 문제가 생길 수 있다.

  • sort에 허용하지 않은 필드를 넣을 수 있다.
  • sort에 정렬 기준을 과도하게 많이 넣을 수 있다.
  • size=100000처럼 지나치게 큰 값을 넣을 수 있다.

Spring 기본 Pageable은 편리하지만, 요청 파라미터가 그대로 바인딩되기 때문에 API가 허용할 정렬 기준과 페이지 크기를 별도로 통제하지 않으면 계약이 느슨해질 수 있다.

해결 방안

해결 방향은 별도의 요청 DTO를 두고, 정렬 가능한 필드와 페이지 크기를 명시적으로 제한하는 것이었다.

먼저 정렬 가능한 필드를 enum으로 관리할 수 있도록 공통 인터페이스를 뒀다.

public interface SortField {
    String getFieldName();
}

도메인별로 허용할 정렬 필드는 enum으로 정의했다.

public enum WishlistSortField implements SortField {
    CREATED_AT("createdAt"),
    PRODUCT_NAME("product.name"),
    PRODUCT_PRICE("product.price");

    private final String fieldName;

    WishlistSortField(String fieldName) {
        this.fieldName = fieldName;
    }

    @Override
    public String getFieldName() {
        return fieldName;
    }
}

그리고 요청 DTO에서 page, size, sortBy, ascending을 받아 안전한 PageRequest로 변환했다.

public record PageRequestDto(
        @Min(value = 1, message = "페이지 번호는 1 이상이어야 한다.")
        Integer page,

        @Range(min = 1, max = 100, message = "페이지 크기는 1 이상 100 이하여야 한다.")
        Integer size,

        String sortBy,

        Boolean ascending
) {
    public <T extends Enum<T> & SortField> PageRequest toSafePageable(
            Class<T> enumClass, T defaultSortField) {
        int page = this.page != null ? this.page : 1;
        int size = this.size != null ? this.size : 10;
        T sortField = defaultSortField;
        boolean ascending = this.ascending != null ? this.ascending : true;

        if (sortBy != null) {
            for (T enumValue : enumClass.getEnumConstants()) {
                if (enumValue.getFieldName().equals(sortBy)) {
                    sortField = enumValue;
                }
            }
        }

        return PageRequest.of(page - 1, size,
                Sort.by(
                        ascending ? Sort.Direction.ASC : Sort.Direction.DESC,
                        sortField.getFieldName()
                )
        );
    }
}

적용 후 달라진 점

이 구조로 바꾸면 컨트롤러 진입 시점에 페이지 요청 정책이 정리된다.

@GetMapping
public Page<WishlistItemDto> getWishlistItems(
        @Valid PageRequestDto pageRequest) {
    Pageable pageable = pageRequest.toSafePageable(
            WishlistSortField.class,
            WishlistSortField.CREATED_AT
    );
    return wishlistService.getItems(pageable);
}

방어 항목도 명확해졌다.

입력처리
size=100000최대값 제한
sort=malicious_fieldenum에 없으면 기본값 사용
sort 파라미터 다수 전달단일 sortBy만 처리
page=-1최소값 제한

서비스 계층에서는 검증된 pageable을 받는다는 전제로 로직을 단순하게 유지할 수 있었다.

마무리

이 작업은 거창한 보안 기능을 만든 것이 아니라, API 계약을 선명하게 만든 작업에 가까웠다.

Spring이 제공하는 Pageable은 편리하지만, 외부 입력을 그대로 열어두면 API가 허용하는 범위가 흐려질 수 있다. 페이지 크기, 정렬 필드, 기본 정렬 정책을 직접 정리하면서 입력 검증도 API 설계의 일부라는 점을 확인했다.