티스토리 뷰

Bean Validation

  • Bean Validation은 엔터프라이즈 레벨의 애플리케이션 개발시 자주 반복되는 유효성 검사 패턴을 정리한 Java EE 표준이다. 레드햇, 오라클 등 세계 굴지의 IT 기업에 종사하는 전문가 집단이 참여하여 표준을 만들었다. 인터페이스를 잘 이해하고 구현체를 활용하면 소스 코드의 가독성을 높아지고 많은 코드를 절약할 수 있다.
  • 1.0(JSR 303)이 최초 버전으로 2009-10-12 발표되었다. [스펙 링크]
  • 1.1(JSR 349)가 2013-04-10 발표되었다. [스펙 링크]
  • 2.0(JSR 380)이 현재 최신 버전으로 2017-08-03 발표되었다. Java 8이 요구되며 그 밑의 버전은 지원하지 않는다. [스펙 링크]
  • JSR 380 스펙의 인터페이스는 javax.validation 그룹의 validation-api 아티팩트에서 확인할 수 있다. [메이븐 저장소 링크] 인터페이스에 대한 대표적인 구현체로는 org.hibernate.validator 그룹의 hibernate-validator 아티팩트가 있다. [메이븐 저장소 링크] Spring Boot 2.0 기반의 프로젝트라면 이미 둘다 적용되어 있어 따로 종속성 관리를 할 필요가 없다.
  • Spring Boot에서의 사용법은 무척 간단하다. 컨트롤러 메써드의 파라메터 레벨에 JSR 380이 제공하는 @Valid 어노테이션만 명시하면 된다.

목표

  • 컨트롤러 메써드의 @PathVariable, @RequestParam 요청 파라메터에 대해 JSR-380 유효성 검사를 수행한다.
  • 컨트롤러 메써드의 POJO 요청 클래스에 대해 JSR-380 유효성 검사를 수행한다.

요청 클래스

  • 클라이언트로부터의 요청 파라메터를 담을 클래스를 아래와 같이 작성한다.
data class CreateFooRequest(

    @JsonProperty
    @get:NotEmpty(message = "EMPTY_FOO")
    @get:Pattern(regexp = "[0-9]+", message = "FOO_MUST_BE_POSITIVE_NUMBER")
    @get:Size(min = 1, max = 15, message = "FOO_MUST_BE_LESS_THAN_16_DIGITS")
    val foo: String?,

    @JsonProperty
    @get:NotNull(message = "EMPTY_BAR")
    @get:Min(value = 1, message = "BAR_MUST_BE_GREATER_THAN_ZERO")
    @get:Max(value = 9999999999999, message = "BAR_MUST_BE_LESS_THAN_10_000_000_000_000")
    val bar: Long?
) {

    @AssertTrue(message = "INVALID_BAR")
    fun isBarTrue(): Boolean {
        return false
    }
}
  • 필드 레벨에 JSR 380이 제공하는 유효성 검사 어노테이션을 명시하면 자동으로 유효성 검사를 수행해준다.
  • 필드 타입에 Nullable 조건을 추가해야 누락된 파라메터에 대해서 정상적으로 유효성 검사를 수행할 수 있다. Nullable 조건 없는 필드 타입의 파라메터를 생략할 경우, org.springframework.beans.BeanInstantiationException(내부적으로는 IllegalArgumentException: Parameter spectified as non-null is null) 예외가 발생한다. 또한, 문자열 타입의 파라메터의 경우 @NotNull이 아닌 @NotEmpty 조건을 명시해야 유효성 검사가 가능하다. (@NotNull은 무시된다.)
  • @AssertTrueJSR 380이 제공하지 않는 별도의 유효성 검사를 작성하고자 할 경우 사용할 수 있다.
  • 한편, JSR 380은 유효성 검사 실패시 message 속성에 단일 메시지만 전달할 수 있어, error_code, error_message가 한쌍을 이루는 현대적인 REST API의 응답 설계와는 어울리지 않는 면이 있다. API 응답을 받는 클라이언트 입장에서는 인간 친화적인 error_message만 받는 것 보다는, error_code를 받는 편이 코드 레벨로 오류를 식별하고 처리하기 직관적이기 때문이다. 이를 극복하기 위해, message 속성에는 error_code만 명시하고 이에 맵핑되는 error_message를 별도로 정의한 후 아래 설명할 @ControllerAdvice를 통해 별도의 오류 응답 로직을 작성하는 방법을 추천한다.

@ControllerAdvice

  • 유효성 검사의 결과가 여과없이 클라이언트에게 노출되는 것을 방지하기 위해 컨트롤러 어드바이스 클래스를 아래와 같이 작성한다.
@ControllerAdvice
class FooControllerAdvice {

    @ExceptionHandler(value = [BindException::class, MethodArgumentNotValidException::class])
    @ResponseBody
    fun handleMethodArgumentNotValidException(ex: Exception): ResponseEntity<*> {

        var errorMessage: String? = null
        if (ex is BindException) {
            errorMessage = ex.bindingResult.allErrors[0].defaultMessage
        } else if (ex is MethodArgumentNotValidException) {
            errorMessage = ex.bindingResult.allErrors[0].defaultMessage
        }

        return ResponseEntity(errorMessage, HttpStatus.BAD_REQUEST)
    }
}
  • 컨트롤러에서 유효성 검사 실패시 발생하는 예외는 2가지가 있다. 메써드 레벨에 @RequestBody가 명시되었을 경우(주로 POST, PUT 요청) MethodArgumentNotValidException 예외가 발생한다. 메써드 레벨에 @RequestBody가 명시되어 있지 않을 경우(주로 GET 요청) @ModelAttribute가 기본 적용되어 BindException이 발생한다. 따라서 2가지 경우에 대한 예외 처리를 작성하면 입맛에 맞는 커스텀 오류 응답을 클라이언트에게 제공할 수 있다 [관련 링크]
  • 위 예제는 전체 컨트롤러 동작에 영향을 끼치는 전역 유효성 검사 로직이라고 할 수 있다. 만약, 유효성 검사의 범위를 특정 단일 컨트롤러로 국한하고 싶다면 해당 컨트롤러 내부에 처리 메써드를 작성하고 @ExceptionHandler를 명시하면 된다. 유효성 검사의 범위를 2개 이상의 복수개의 컨트롤러에 국한하고 싶다면 위 예제에서 @ControllerAdvice(assignableTypes = [FooController::class, BarController::class])와 같이 변경하면 된다.

@RestController

  • 컨트롤러 클래스를 아래와 같이 작성한다.
@RestController
class FooController {

    @PostMapping("/foos")
    fun createFoo(@RequestBody @Validated request: CreateFooRequest): ResponseEntity<*> {

        return ResponseEntity<Any>(HttpStatus.OK)
    }
}
  • 요청 파라메터 앞에 명시한 @Validated가 유효성 검사를 활성해주는 역할을 한다. (@Valid를 명시해도 된다.)

GET 요청 파라메터를 POJO로 처리하기

  • 컨트롤러 클래스에서 GET 요청 파라메터는 일반적으로 메써드 아규먼트 레벨에서 @RequestParam으로 직접 받아 처리하는 것이 일반적이다. 이 경우, Bean Validation을 적용하려면 제약이 많아, 앞서 POST 요청에서와 같이 POJO로 처리하게 되면 이점이 많아진다.
@GetMapping
fun requestProcessResult(@Validated request: GetFooRequest): ResponseEntity<*> { ... }
  • 이 경우, 한가지 제약사항이 있는데 Spring MVC는 요청 파라메터명을 무조건 camelCase(ex: fooBar)로만 받을 수 있기 때문에, 다른 네이밍 컨벤션을 사용할 수 없게 된다. 이를 해결하기 위해 원하는 컨벤션으로 요청 파라메터명을 받아 camelCase로 변경하는 커스텀 필터를 제작하면 제약에서 자유로워진다. 커스텀 필터 제작 방법은 아래와 같다.
package com.jsonobject.example;

import com.google.common.base.CaseFormat;
import java.io.IOException;
import java.util.Collections;
import java.util.Enumeration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import javax.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.filter.OncePerRequestFilter;

@Configuration
public class RequestParamConfig {

    @Bean
    public Filter requestParamSnakeToCamelCaseFilter() {
        return new OncePerRequestFilter() {

            @Override
            protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
                throws ServletException, IOException {

                final Map<String, String[]> formattedParams = new ConcurrentHashMap<>();

                for (String param : request.getParameterMap().keySet()) {
                    // 요청 파라메터명을 snakeCase으로 받아 camelCase로 변환한다. GET 요청에서도 POJO로 처리할 수 있게 된다.
                    // 원래 camelCase 요청은 모두 소문자로 변경되는 것에 유의해야 한다.
                    String formattedParam = CaseFormat.LOWER_UNDERSCORE.to(CaseFormat.LOWER_CAMEL, param);
                    formattedParams.put(formattedParam, request.getParameterValues(param));
                }

                filterChain.doFilter(new HttpServletRequestWrapper(request) {
                    @Override
                    public String getParameter(String name) {
                        return formattedParams.containsKey(name) ? formattedParams.get(name)[0] : null;
                    }

                    @Override
                    public Enumeration<String> getParameterNames() {
                        return Collections.enumeration(formattedParams.keySet());
                    }

                    @Override
                    public String[] getParameterValues(String name) {
                        return formattedParams.get(name);
                    }

                    @Override
                    public Map<String, String[]> getParameterMap() {
                        return formattedParams;
                    }
                }, response);
            }
        };
    }
}

@RequestParam, @PathVariable 유효성 검사하기

  • 앞서 예제를 통해 POJO의 경우 JSR-380의 유효성 검사가 완전히 가능함을 확인했다. 문제는 컨트롤러의 메써드 파라메터로 바로 작성하는 @RequestParam, @PathVariable이다. JSR-380을 적용하려면 아래와 같은 접근 방법이 요구된다.
@RestController
@Validated
class FooController {

    @GetMapping("/foo")
    fun getFoo(
        @RequestParam("bar")
        @NotEmpty(message = "EMPTY_BAR")
        bar: String?

    ): ResponseEntity<*> {

        return ResponseEntity.ok("bar")
    }

    @ExceptionHandler(value = [ConstraintViolationException::class])
    fun handleConstraintViolationException(ex: ConstraintViolationException): ResponseEntity<*> {

        val errorMessage = ex.constraintViolations.first().message

        return ResponseEntity(errorMessage, HttpStatus.BAD_REQUEST)
    }
}
  • 반드시 컨트롤러 클래스 레벨에 @Validated를 명시해야 유효성 검사가 작동한다. 메써드의 파라메터 레벨에는 명시해도 작동하지 않는다. (참고로 Java EE 표준 어노테이션인 @Valid는 클래스 레벨에 명시가 불가능하여 본 기능에는 사용할 수 없다.)
  • 파라메터 레벨에서는 앞서와 다르게 유효성 검사 오류시 javax.validation.ConstraintViolationException 예외를 발생시킨다. 위 예제와 같이 전역 또는 해당 컨트롤러에서 @ExceptionHandler를 작성하여 적절한 오류 응답 로직을 작성하면 된다.

요청 DTO에 대한 Enum 타입 필드 유효성 검사하기

  • 요청 DTO의 필드에 Enum 타입이 포함되었을 경우 유효성 검사 방법은 직관적이지는 않지만 가능한 방법이 있다. 먼저 아래는 Enum 클래스의 작성 예이다.
@JsonFormat(shape = JsonFormat.Shape.STRING)
enum class FooBarCode(val description: String) {

    FOO("푸"),
    BAR("바");

    companion object {
        @JsonCreator
        @JvmStatic
        fun forValue(code: String): FooBarCode {

            return values().firstOrNull { it.name.contentEquals(code) }
                ?: throw FooBarException(FooBarExceptionErrorCode.INVALID_FOOBAR_CODE)
        }
    }
}
  • Spring Boot는 정의되지 않은 Enum 코드가 JSON 필드 값에 들어올 경우, 파씽 오류와 함께 HttpMessageNotReadableException 예외를 발생시킨다. 위와 같이, @JsonCreator을 부여하면 파씽 오류 부분을 제어할 수 있는데 위의 경우 파씽 오류시 의도적으로 FooBarException이라는 특정 예외를 발생시키도록 작성했다.
  • 위와 같이 의도한 예외를 발생시켜도, HttpMessageNotReadableException 예외가 발생하는 것에는 변함이 없다. 아래와 같이 HttpMessageNotReadableException 예외를 처리하는 @ExceptionHandler 작성이 필요하다.
@ExceptionHandler(HttpMessageNotReadableException::class)
@ResponseBody
fun handleHttpMessageNotReadableException(

    request: HttpServletRequest,
    ex: HttpMessageNotReadableException

): ResponseEntity<*> {

    val rootException = ExceptionUtils.getRootCause(ex)
    if (rootException is FooBarException) {
        return handleFooBarException(rootException)
    }
    ...
}
  • HttpMessageNotReadableException 예외가 발생해도, 최초의 조상이 되는 예외는 앞서 의도적으로 발생시킨 FooBarException가 된다. 이를 이용해 원하는 오류 응답 처리를 수행하도록 코드를 작성할 수 있다.

참고 글

댓글
댓글쓰기 폼