티스토리 뷰
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?
// Enum 타입
@JsonProperty
@get:NotNull(message = "EMPTY_FOOBAR")
val sendMethod: FooBar?
) {
@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은 무시된다.) @AssertTrue
는 JSR 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가 된다. 이를 이용해 원하는 오류 응답 처리를 수행하도록 코드를 작성할 수 있다.
참고 글
댓글
최근에 올라온 글
최근에 달린 댓글
- Total
- Today
- Yesterday
링크
TAG
- spring
- chrome
- 로드바이크
- jsp
- Tomcat
- DynamoDB
- node.js
- 태그를 입력해 주세요.
- Eclipse
- bootstrap
- 평속
- Kendo UI
- Spring MVC 3
- MySQL
- 자전거
- 로드 바이크
- jpa
- kotlin
- Kendo UI Web Grid
- Spring Boot
- jstl
- JavaScript
- maven
- 알뜰폰
- JHipster
- java
- CentOS
- 구동계
- Docker
- graylog
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
글 보관함