글 작성자: beaniejoy

어떤 요청에 대한 입력값에 대해서 코드상으로 하나하나 유효성 검증을 하는 것은 상당히 번거로운 일입니다.
예를들어, 어떤 날짜 입력이 주어졌는데 제대로된 날짜 형식으로 입력했는지 검증하는 코드를 로직상에 추가했다면 비즈니스 로직에 집중해야 될 코드가 오염될 수 있습니다.

Spring에서 제공하는 Validation은 이와 같은 입력값에 대한 유효성 검증을 Controller단과 별개로 진행해 간편하게 진행할 수 있도록 기능을 제공하고 있습니다. Spring Validation에 대한 내용을 정리해보고자 합니다.

 

📌 1. gradle 설정

implementation 'org.springframework.boot:spring-boot-starter-validation'

Spring Boot 2.3 버전 부터 spring-boot-starter-validation 의존성을 명시해야 Valid 사용 가능합니다.

 

📌 2. Valid 사용

data class ValidRequestDto(
    @field:NotNull(message = "value는 필수 입력값입니다.")
    val value: String? = null,

    @field:NotNull(message = "createdAt은 필수 입력값입니다.")
    val createdAt: LocalDate? = null,

    @field:NotNull(message = "number는 필수 입력값입니다.")
    val number: Long? = null
)

Kotlin 베이스로 작성했기 때문에 기존에 Java에서 Validation 적용하는 부분과 다른 부분이 있습니다.

 

🔖 2-1. @field:[Validation 관련 어노테이션]

Kotlin으로 Valid 관련 제약사항을 명시할 때 Validation 관련 annotation에 @field:를 prefix로 붙여야 합니다. Java에서 사용하던 방식대로 @NotNull 이렇게 사용하면 Kotlin에서 Java로 convert했을 때 constructor에 Valid 어노테이션이 붙게 되는 것을 볼 수 있습니다.

 

🔖 2-2. 클래스 맴버 필드는 nullable로 지정

코틀린에서는 변수 선언할 때 nullable한 type으로 선언할 수 있습니다. 코틀린으로 Validation 적용하려면 (특히 NotNull 적용시) nullable type으로 선언해야 합니다. @field:NotNull 적용한 필드에 확정형 타입을 선언했다면 요청시 해당 필드를 입력하지 않았을 때 Validation 이용한 원하는 방식의 에러 핸들링을 기대하기 힘듭니다.

예를 들어 

// validation에 의한 유효성 검사가 아닌 type matching에 대한 검증이 우선시된다.
data class ValidRequestDto(
    @field:NotNull(message = "value는 필수 입력값입니다.")
    val value: String
)

이렇게 확정형 타입에 NotNull을 적용한 경우 api 요청시 number 필드에 대한 내용을 입력하지 않았을 때 nullable에 대한 Type Mismatch 에러를 반환하게 됩니다. 이런 경우 Valid에 지정해놓은 default message 등을 무시하게 되고 기타 Validation 관련 원하는 형태의 에러 반환값을 기대할 수 없게 됩니다.

더군다나 필드가 Long, Int 같은 primitive type과 관련있는 타입에 대해서는 확정형 입력시 @RequestBody 형태로 받아 올 때 해당 필드에 대해 입력을 하지 않아도 default value로 설정됩니다.

// number 값을 request body에 입력하지 않아도 0L으로 값이 할당된다.
data class ValidRequestDto(
    //...
    @field:NotNull(message = "number는 필수 입력값입니다.")
    val number: Long
)

위에 RequestDto의 number를 Long 확정형으로 타입 선업했을 때 Java로 변환해보면 long primitive type으로 되어있는 것을 확인할 수 있습니다. Java에서는 primitive type별로 default value가 존재하는데 primitive type에 대해서 클래스 맴버로 선언만 해도 default value가 지정됩니다.

public class DefaultValueTest {
    private int value;	// 0
    private long value2; // 0L
    private boolean value3; // false
    
    //...
}

코틀린에서 Long, Int와 같은 타입은 primitive type과 wrapper class type 둘 다 포함하고 있습니다. 그래서 nullable 하지 않은 확정형으로 클래스 필드로 선언하면 primitive type이 되어 해당 필드에 대해 데이터를 입력하지 않아도 default value로 컴파일러가 지정해줍니다. 이렇게 되면 NotNull Validation을 설정해도 제대로 동작하지 않는 사태가 벌어지게 됩니다.

그래서 Validation 적용할 때는 맴버 필드를 nullable type으로 선언하는 것이 좋습니다.

 

📌 3. Controller단에 Validation 적용

Request Dto부분에 Valid를 설정했으면 이번엔 Controller부분에서 어떻게 Validation을 적용하는지 보겠습니다.
Controller 단에서는 크게 BindingResult 사용유무에 따라 달라지는 것 같습니다.

@GetMapping("/get")
fun getParam(
    @Valid @ModelAttribute validRequestDto: ValidRequestDto
) {...}

이렇게 BindingResult 파라미터 없이 사용했을 때 Validation 유효성 에러가 발생하면 관련 에러내용을 반환하게 됩니다.

@GetMapping("/get")
fun getParam(
    @Valid @ModelAttribute validRequestDto: ValidRequestDto,
    bindingResult: BindingResult
) {...}

BindingResult를 따로 handler에서 잡을 수 있습니다. 위와 같이 @Valid 적용부분 다음 파라미터에 BindingResult를 받아오면 Validation 관련 에러 내용을 BindingResult에 담아서 보내게 됩니다.

여기서 BindingResult에 담겨진 에러 내용을 확인하지 않고 로직을 진행하면 Validation 검증 에러가 발생해도 해당 에러를 체크하지 않고 로직을 수행하게 됩니다. 이렇게 되면 이후 비즈니스 로직 수행 과정에서 예기치 않은 에러가 발생할 수 있어서 위험하다고 할 수 있습니다.

@GetMapping("/get")
fun getParam(
  @Valid @ModelAttribute validRequestDto: ValidRequestDto,
  bindingResult: BindingResult
): ValidRequestDto {
  if (bindingResult.hasErrors()) {
    logger.error("errors : ${bindingResult.fieldErrors}")
    throw RuntimeException("errors ${bindingResult.fieldErrors}")
  }

  //...
}

위와 같은 방식으로 BindingResult에 담겨진 에러 존재 유무를 확인하고 원하는 형식의 에러를 던져야 합니다.

 

🔖 3-1. BindingResult 주의점

An Errors/BindingResult argument is expected to be declared immediately after the model attribute, the @RequestBody or the @RequestPart arguments

BindingResult@Valid가 적용된 @ModelAttribute, @RequestBody 바로 뒤에 argument 설정을 해야 합니다.

@GetMapping("/get")
fun getParam(
    @Valid @ModelAttribute validRequestDto: ValidRequestDto,
    @PageableDefault(page = 0, size = 20) pageable: Pageable,
    bindingResult: BindingResult
) {...}

위와 같이 @Valid선언부와 BindingResult argument 사이에 다른 인자가 존재하면 BindingResult를 사용할 수 없게 됩니다.
(정확히는 Validation 관련 에러가 발생해도 BindingResult에 담기지 않습니다.)

BindingResult 사용해서 에러를 핸들링하고 싶을 때는 위 내용을 꼭 알아두셔야 할 것 같습니다.

 

📌 4. Controller 내 여러 Validation 형태

크게 3가지 형태에 따라 Validation 내용에 미묘한 차이가 발생합니다.

  • @ModelAttribute
  • @RequestBody
  • @RequestParam, @PathVariable

이번에는 위 3가지 형태에 따라 Validation이 어떻게 달라지는지 알아보겠습니다.

 

🔖 4-1. @ModelAttribute

@GetMapping("/get")
fun getParam(
  @Valid @ModelAttribute validRequestDto: ValidRequestDto
) {...}

@ModelAttribute에 Valid 적용시 유효성 검증 에러가 발생하면 기본적으로 400 응답코드를 반환합니다. 그리고 Validation Exception은 기본적으로 BindException으로 떨어지게 됩니다.

@ModelAttributeBindingResult를 받아서 예외 핸들링을 할 수 있습니다.

 

🔖 4-2. @RequestBody

@PostMapping("/post")
fun postRequestBody(
    @Valid @RequestBody validRequestDto: ValidRequestDto
) {...}

기본적으로 Validation 검증 에러가 발생하면 400 응답코드를 반환합니다.

@RequestBody에 대한 validation 에러는 MethodArgumentNotValidException로 떨어지는데 BindException을 확장한 에러 클래스입니다.
(그래서 BindException으로 Exception handling해도 잡힙니다.)

POST 요청 보낼 시 request body단에 보낼 데이터를 json형태로 지정해주어야 합니다.
지정 안할 시 HttpMessageNotReadableException 발생하게 됩니다.

 

🔖 4-3. @RequestParam

@RestController
@Validated
class ValidationController {
  @GetMapping("/get")
  fun getParam(
      @NotNull(message = "필수값입니다.") @RequestParam("value") value: String?
  ): String? {
    //...
  }
}

@RequestParam은 클래스 단계에서 @Validated어노테이션이 필요합니다.

Any error during path or request validation in Spring results by default in an HTTP 500 response

기본적으로 @PathVariable, @RequestParam에 대한 validation 에러는 500 응답코드를 반환하고 ConstraintViolationException을 던집니다. ControllerAdvice를 통해 500에서 400 응답코드로 변환해 반환할 수 있습니다.

@RequestParam, @PathVariable은 참고로 BindingResult에 담지 않기 때문에 BindingResult를 따로 사용할 수 없습니다.

 

🔖 4-4. @PathVariable

@GetMapping("/{id}")
fun getParam(
    @PathVariable("id") @NotNull(message = "필수값입니다.") id: Long?
): Long? {
  //...
}

위의 @RequestParam내용과 같습니다.

 

🔖 4-5. @RequestParam과 @ModelAttribute

  • @RequestParam
    • validation 절차를 거치지 않고 기본 타입 변환이 이루어짐
    • type 변환에 대한 에러는 400(MethodArgumentTypeMismatchException) 에러를 반환
    • validation에 대한 에러는 500(ConstraintViolationException) 에러 반환
    • 즉 binding 과정에서 발생한 에러들을  BindingResult에 담지 않음
    • Controller단에서 BindingResult를 가져오면 에러 발생
    • Controller단에서 파라미터를 검증하는 코드가 추가될 수 있음
  • @ModelAttribute
    • 검증 과정도 같이 진행
    • binding 과정에서 발생한 에러를 BindingResult에 담음
    • type 변환에 대한 오류, validation에 대한 오류 모두 400(BindException)으로 반환
    • BindingResult를 Controller단에 argument로 가져오면 400에러를 반환하지 않고 처리가 가능
      (단순히 400에러만을 반환하기보다 api 사용자의 편의성을 제공해줄 수 있음)

위의 차이점으로 인해 입력값에 대한 검증에 대한 에러 핸들링까지 고려했을 때 @ModelAttribute를 사용하면 세련되게 처리할 수 있습니다.

@GetMapping("/get")
fun getParam(
  @Valid @ModelAttribute validRequestDto: ValidRequestDto,
  bindingResult: BindingResult
): ValidRequestDto {
  if (bindingResult.hasErrors()) {
    logger.error("errors : ${bindingResult.fieldErrors}")
    return "error/index"
  }

  //...
  
  return "list/index"
}

약간 이상한 예시이긴 하지만 위의 예시처럼 BindingResult를 이용하면 입력값에 대한 유효성 실패로 에러를 던지는 것이 아니라 아예 지정된 페이지를 반환할 수 있게 코드를 구성할 수 있습니다.

이런 방식으로 단순히 400 응답코드를 반환하는 Exception을 던지는 것이 아닌 세련되게 사용자 입장에서 원하는 내용을 반환해줄 수 있습니다.

 

📌 5. Exception Handler

@ExceptionHandler(BindException::class)
fun handleNotValidException(
    e: BindException,
    bindingResult: BindingResult
): ResponseEntity<ErrorDto> {
    logger.error("validation errors : ${bindingResult.fieldErrors}")

    val defaultMessage = bindingResult.fieldError?.defaultMessage
    val code = bindingResult.fieldError?.code

    return ResponseEntity.badRequest()
        .body(
            ErrorDto(
                message = defaultMessage,
                validCode = code,
                description = e.message
            )
        )
}

BindingResult, MethodArgumentNotValidException에 대한 exception handler에서 BindingResult를 직접 받아올 수 있습니다.
(물론 따로 받아와도 됩니다.)