다음과 같은 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로 조회하면 어떻게 될까?
그렇게 된다면 다음과 같이 검증 로직을 작성할 수 있을 것이다.
@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
상태 코드를 반환하기 때문에 클라이언트 입장에서 명시적인 예외를 확인하기 어렵다.
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
예외가 발생한다.
따라서 @ControllerAdvice
에 ConstraintViolationException
과 MethodArgumentTypeMismatchException
예외를 처리하는 핸들러를 만들어야 하므로, 중복이 여전히 발생한다.
또한 여러 조건을 추가할 때 파라미터가 길어지고, 사용자 정의 메시지를 적기 까다롭다.
@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
에 중복된 핸들러도 정의해야 하는 문제는 여전하다.
스프링은 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라는 클래스에서 자체적으로 수행하도록 하여 책임의 분리를 지킬 수 있었다.