@PathVariable 값 Converter의 활용으로 검증하기

Glen·2023년 5월 5일
0

배운것

목록 보기
12/37

Given

다음과 같은 URL이 있다.

GET /products/{productId}

해당 URL은 Product라는 자원에서 특정 Product를 조회한다.

해당 URL을 스프링을 사용하여 컨트롤러의 핸들러 메소드에 매핑한다면 다음과 같다.

@GetMapping("/products/{productId}")
public ResponseEntity<Product> findProduct(@PathVariable Long productId) {  
    Procuct product = productService.findProductById(productId);
    return ResponseEntity.ok()
            .body(ResultResponse.ok(product));  
}

스프링에서 제공하는 @PathVariable 어노테이션을 사용하여 URL 경로의 일부를 매개변수로 추출하여 사용할 수 있다.

따라서 /products/1이라는 URL로 요청할 시 ID가 1인 Product를 조회할 수 있다.

하지만 /product/abc 또는 /product/-1과 같은 잘못된 ID로 조회하면 어떻게 될까?

When

그렇게 된다면 다음과 같이 검증 로직을 작성할 수 있을 것이다.

@GetMapping("/products/{productId}")
public ResponseEntity<Product> findProduct(@PathVariable Long productId) {
    if (Objects.isNull(productId) || productId <= 0) {  
        throw new InvalidIdException();  
    }
    Procuct product = productService.findProductById(productId);
    return ResponseEntity.ok()
            .body(ResultResponse.ok(product));  
}

하지만 deleteProduct 혹은 다른 자원에서 @PathVariable을 사용하는 경우 해당 검증 로직을 중복으로 작성해야 하는 경우가 생길 것이다.

또한 abc와 같은 숫자로 파싱이 불가능한 경우 InvalidIdException이 발생하기 전, MethodArgumentTypeMismatchException 예외가 발생하므로 @ControllerAdvice에서 @ExceptionHandler의 공통 처리가 불가능하다.

그러면 어떻게 @PathVariable로 넘어오는 값을 중복 코드가 생기지 않게 검증할 수 있을까?

정규식 사용

우선 제일 쉬운 방법은 정규식을 적용하는 것이다.

@GetMapping("/products/{productId:[1-9]\\d*}") // 1부터 시작하는 값만 허용

해당 방법을 사용하면 간편하게 해결할 수 있지만, 중복 제거는 할 수 없다.

@GetMapping("/products/{productId:[1-9]\\d*}")
...
@DeleteMapping("/products/{productId:[1-9]\\d*}")
...
@GetMapping("/members/{memberId:[1-9]\\d*}")

위와 같이 다른 핸들러 메서드에도 정규식 패턴을 적용해 주어야 한다.

또한 잘못된 ID를 입력하면 404 상태 코드를 반환하기 때문에 클라이언트 입장에서 명시적인 예외를 확인하기 어렵다.

Bean Validation(JSR-303)

JSR-303 Validation을 적용하는 것도 하나의 방법이다.

@PathVariable을 사용할 때 검증하는 방법은 @Valid를 사용하는 것과는 다른 방법을 사용해야 한다.

@Validated // @Validated를 컨트롤러 클래스 상단에 추가 해야 한다.
@RestController
public class ProductApiController {
    @GetMapping("/products/{productId}")
    public ResponseEntity<Product> findProduct(@PathVariable @Positive Long productId) { // @Positive와 같은 Validation 어노테이션을 적용한다.
        ...
    }
}

이때 -1과 같은 잘못된 ID가 값으로 들어왔을 때 ConstraintViolationException 예외가 발생한다.

따라서 @ControllerAdviceConstraintViolationExceptionMethodArgumentTypeMismatchException 예외를 처리하는 핸들러를 만들어야 하므로, 중복이 여전히 발생한다.

또한 여러 조건을 추가할 때 파라미터가 길어지고, 사용자 정의 메시지를 적기 까다롭다.

@GetMapping("/products/{productId}")
public ResponseEntity<Product> findProduct(@PathVariable @Positive(message = "1 이상의 값만 입력해주세요.") Long productId) { // @Positive와 같은 Validation 어노테이션을 적용한다.
    ...
}

여러 조건이 추가될 때 다음과 같이 커스텀 어노테이션을 만들어 중복을 피할 수 있다.

@Positive(message = "1 이상만 입력해주세요.")  
@Max(value = 1000, message = "1000 이상은 불가능 합니다.")  
@Target({ElementType.PARAMETER})  
@Retention(RetentionPolicy.RUNTIME)  
@Constraint(validatedBy = {})  
@Documented  
public @interface ValidId {  
    String message() default "잘못된 ID 값 입니다.";  
  
    Class<?>[] groups() default {};  
  
    Class<? extends Payload>[] payload() default {};  
}
@GetMapping("/products/{productId}")
    public ResponseEntity<Product> findProduct(@PathVariable @ValidId Long productId) {
    ...
}

하지만 여전히 @ValidId 라는 어노테이션을 적어줘야 하고, Controller 클래스에 @Validated 라는 어노테이션을 적어줘야 하는 부분도 생긴다.

또한 @ControllerAdvice에 중복된 핸들러도 정의해야 하는 문제는 여전하다.

Then

Converter

스프링은 Converter라는 인터페이스를 제공한다.

사실 이미 우리는 Converter를 사용하여 @PathVariable에서 넘어온 값을 처리하고 있었다.

/products/1

여기서 URL에서 넘어온 1은 Long과 같은 숫자 형식이 아니다.

String 형식의 문자열이 넘어오고, 스프링에서 기본적으로 등록된 ConversionService의 구현체인 DefaultConversionService에서 등록된 StringToNumberConverterFactory가 String에서 Long 타입으로 파싱을 해준다.

숫자로 변환할 수 없는 문자열을 입력했을 시 MethodArgumentTypeMismatchException 예외가 발생하는 이유도 바로 여기에 있다.

따라서 Id를 포장하는 클래스를 만들고, String -> Id로 변환을 해주는 Converter를 만들고 적용하면 컨트롤러에서 검증 로직을 없애고, @ControllerAdvice에서 중복된 핸들러도 제거할 수 있다.

Converter 인터페이스는 2가지의 제네릭 타입을 적어줘야 한다.

Converter<S, T>

첫 번째 제네릭 타입 S는 Source로 입력된 값의 타입을 적는다.
두 번째 제네릭 타입 T는 Target으로 변환된 값의 타입을 적는다.

따라서 Id Converter는 다음과 같이 만들 수 있다.

public class IdConverter implements Converter<String, Id> {  
    @Override  
    public Id convert(String source) {  
        return Id.from(source);  
    }  
}

Id의 검증 로직은 Id 클래스에 정의한다.

public class Id {  
    private final Long id;  
  
    private Id(Long id) {  
        validatePositive(id);  
        this.id = id;  
    }  
  
    private void validatePositive(Long id) {  
        if (id <= 0) {  
            throw new InvalidIdException("ID는 양수만 가능합니다.");  
        }  
    }  
  
    public static Id from(String id) {  
        validateNull(id);  
        return new Id(parseId(id));  
    }  
  
    private static void validateNull(String id) {  
        if (Objects.isNull(id) || id.isBlank()) {  
            throw new InvalidIdException("ID는 빈 값이 될 수 없습니다.");  
        }  
    }  
  
    private static Long parseId(String id) {  
        try {  
            return Long.parseLong(id);  
        } catch (NumberFormatException e) {  
            throw new InvalidIdException("ID는 숫자만 가능합니다.");  
        }  
    }  
  
    public Long getId() {  
        return id;  
    }  
}

그리고 만든 Converter를 적용하는 법은 WebMvcConfigurer를 구현한 클래스에 적용할 수 있다.

@Configuration  
public class WebMvcConfig implements WebMvcConfigurer {
    @Override  
    public void addFormatters(FormatterRegistry registry) {  
        registry.addConverter(new IdConverter());  
    }
}

따라서 이제 Controller는 파라미터로 Id를 받기만 하면 된다.

또한 Id를 받는 핸들러 메서드가 여러 개 생기더라도 더 이상 중복된 검증 로직이나 해당 부분을 처리하는 로직이 없다.

@GetMapping("/products/{productId}")
public ResponseEntity<Product> findProduct(@PathVariable Id productId) {
    ...
}

@DeleteMapping("/products/{productId}")
public ResponseEntity<Product> deleteProduct(@PathVariable Id productId) {
    ...
}

@GetMapping("/members/{memberId}")
public ResponseEntity<Member> findMember(@PathVariable Id memberId) {
    ...
}

ControllerAdvice에서도 MethodArgumentTypeMismatchException를 처리하는 핸들러를 하나만 만들면 되므로, 예외 처리에서도 중복된 코드가 사라진다.

하지만 다음과 같은 문제가 생긴다.

@ExceptionHandler(MethodArgumentTypeMismatchException.class)  
public ResponseEntity<String> handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e) {  
    return ResponseEntity.badRequest()  
            .body(e.getName() + ":" + e.getMessage());  
}

다음과 같은 예외 핸들러를 적용했을 때 다음과 같은 응답이 반환된다.

productId:Failed to convert value of type 'java.lang.String' to required type 'cart.dto.common.Id'; nested exception is org.springframework.core.convert.ConversionFailedException: Failed to convert from type [java.lang.String] to type [@org.springframework.web.bind.annotation.PathVariable cart.dto.common.Id] for value 'abc'; nested exception is cart.exception.InvalidIdException: ID는 숫자만 가능합니다.

우리가 필요한 예외 메시지는 ID는 숫자만 가능합니다. 와 같은 부분인데, 불필요한 메시지까지 포함이 된다.

왜냐하면 우리가 Id 클래스에서 발생시킨 예외는 InvalidIdException인데, 스프링이 Converter로 처리하는 과정에서 예외가 발생하면 MethodArgumentTypeMismatchException로 감싸서 예외를 처리한다.

이를 해결하기 위해 MethodArgumentTypeMismatchException는 다음과 같은 메소드를 제공한다.

e.getRootCause();
e.getMostSpecificCause();  

정확히는 MethodArgumentTypeMismatchException이 상속하는 NestedRuntimeException이 제공하는 메소드이다.

두 메소드의 공통점은 가장 최상위에서 발생한 예외를 가져온다.

하지만 두 메소드의 차이는 다음과 같다.

public Throwable getRootCause() {  
   return NestedExceptionUtils.getRootCause(this);  
}

public Throwable getMostSpecificCause() {  
   Throwable rootCause = getRootCause();  
   return (rootCause != null ? rootCause : this);  
}

getRootCause() 메소드는 null이 발생할 수 있지만, getMostSpecificCause() 메소드는 내부적으로 getRootCause() 메소드를 사용하고, null 검사를 통해 NPE 발생을 막아준다.

따라서 getRootCause()를 사용하는 것 보다, getMostSpecificCause() 메서드를 사용하여 우리가 발생시킨 예외를 꺼내, 명확한 예외 메세지를 표현할 수 있다.

@ExceptionHandler(MethodArgumentTypeMismatchException.class)  
public ResponseEntity<String> handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e) {  
    return ResponseEntity.badRequest()  
            .body(e.getName() + ":" + e.getMostSpecificCause().getMessage());  
}
productId:ID는 숫자만 가능합니다.

최종적으로 사용자 정의 Converter를 적용하여, 중복된 코드를 없애고 검증에 관한 부분을 Controller에서 하는 것이 아닌, Id라는 클래스에서 자체적으로 수행하도록 하여 책임의 분리를 지킬 수 있었다.

profile
꾸준히 성장하고 싶은 사람

0개의 댓글