Bean Validation은 특정한 구현체가 아닌 Bean Validation 2.0(JSR-380)이라는 기술 표준이다. 검증 애노테이션과 여러 인터페이스의 모음이다. JPA가 표준 기술이고 그 구현체로 하이버네이트가 있는 것과 같은 논리이다.
Bean Validation을 구현한 기술 중 일반적으로 사용하는 구현체는 하이버네이트 Validation인데, ORM과는 관련이 없다. (단지 이름이 하이버네이트 일 뿐)
검증 기능을 매번 코드로 작성하는 것은 상당히 번거롭다. 특히 특정 필드에 대한 검증 로직은 대부분 빈 값인지 아닌지, 특정 크기를 넘는지 아닌지와 같이 매우 일반적인 로직이다.
public class Item {
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(9999)
private Integer quantity;
public Item() {}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
이런 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화 한 것이 Bean Validation이다. 이를 잘 활용하면 애노테이션 하나로 검증 로직을 매우 편리하게 적용할 수 있다.
📌 검증 애노테이션
@NotBlank
: 빈값 + 공백만 있는 경우를 허용하지 않는다.@NotNull
: null을 허용하지 않는다.@Range(min, max)
: 범위 안의 값이어야 한다.@Max(9999)
: 최대 9999까지만 허용
다음과 같이 build.gradle
에 의존관계를 추가해야 한다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
@Test
void beanValidation() {
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
Item item = new Item();
item.setItemName(" ");
item.setPrice(0);
item.setQuantity(10000);
Set<ConstraintViolation<Item>> validate = validator.validate(item);
for (ConstraintViolation<Item> violation : validate) {
System.out.println("violation = " + violation);
System.out.println("violation.getMessage() = " + violation.getMessage());
}
}
임의로 검증기를 생성하고, 검증 대상을 넣어 그 결과를 받는 테스트 코드이다. 스프링과 통합하면 직접 이런 코드를 작성하진 않는다. 결과는 다음과 같다.
violation={interpolatedMessage='공백일 수 없습니다', propertyPath=itemName,
rootBeanClass=class hello.itemservice.domain.item.Item,
messageTemplate='{javax.validation.constraints.NotBlank.message}'}
violation.message=공백일 수 없습니다
violation={interpolatedMessage='9999 이하여야 합니다', propertyPath=quantity,
rootBeanClass=class hello.itemservice.domain.item.Item,
messageTemplate='{javax.validation.constraints.Max.message}'}
violation.message=9999 이하여야 합니다
violation={interpolatedMessage='1000에서 1000000 사이여야 합니다',
propertyPath=price, rootBeanClass=class hello.itemservice.domain.item.Item,
messageTemplate='{org.hibernate.validator.constraints.Range.message}'}
violation.message=1000에서 1000000 사이여야 합니다
ConstraintViolation
출력 결과를 보면, 검증 오류가 발생한 객체, 필드, 메시지 정보 등 다양한 정보를 확인할 수 있다.
빈 검증기(Bean Validation)을 직접 사용하는 방법은 위와 같았는데, 스프링은 이미 개발자를 위해 빈 검증기를 스프링에 완전히 통합해두었다.
스프링 부트가 spring-boot-starter-validation
라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합한다.
LocalValidatorFactoryBean
을 글로벌 Validator로 등록한다. 이 Validator는 @NotNull
같은 애노테이션을 보고 검증을 수행한다. 이렇게 글로벌 Validator가 적용되어 있기 때문에, @Valid
, @Validated
만 적용하면 된다. 검증 오류가 발생하면, FieldError
, ObjectError
를 생성해서 BindingResult
에 담아준다.
참고로 다음과 같이 직접 글로벌 Validator를 직접 등록하면 스프링 부트는 Bean Validator를 글로벌 Validator
로 등록하지 않는다. 따라서 애노테이션 기반의 빈 검증기가 동작하지 않으므로 다음 부분은 제거해야 한다.
@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {
// 글로벌 검증기 추가
@Override
public Validator getValidator() {
return new ItemValidator();
}
//...
}
📌 참고
@Validated
는 스프링 전용 검증 애노테이션이고,@Valid
는 자바 표준 검증 애노테이션이다. 둘중
아무거나 사용해도 동일하게 작동하지만,@Validated
는 내부에 groups 라는 기능을 포함하고 있다.
Controller 계층에 다음과 같은 코드를 작성하면 스프링에서는 자동으로 엔티티에 적용된 검증 애노테이션을 수행한다.
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item,
BindingResult bindingResult,
RedirectAttributes redirectAttributes,
Model model) {
//...
}
@ModelAttribute
각각의 필드에 타입 변환 시도typeMissmatch
로 FieldError
추가즉, 바인딩에 성공한 필드만 Bean Validation를 적용한다.
@ModelAttribute
→ 각각의 필드에 타입 변환 시도 → 변환에 성공한 필드만 Bean Validation 적용
Bean Validation을 사용하면서 따로 messages.properties
를 설정하거나 작성한 적이 없는데도, 위 처럼 기본 메시지가 출력되고 있다. 이는 해당 라이브러리에서 지정한 기본 메시지인데 이를 임의로 바꾸려면 어떻게 해야 할까?
Bean Validation을 적용하고 bindingResult
에 등록된 검증 오류를 보면 오류 코드가 애노테이션 이름으로 등록된다. typeMissmatch
와 유사하다.
NotBlank
라는 오류 코드를 기반으로 MessageCodesResolver
를 통해 다양한 메시지 코드가 순서대로 생성된다.
@NotBlank
@Range
이 메시지코드에 메시지를 직접 등록하면 그대로 메시지가 적용될 것이다.
NotBlank={0} 공백은 유효하지 않습니다.
Range={0}, {2}~{1}만 허용됩니다.
Max={0}, 최대{1}까지만 허용됩니다.
{0}은 필드명이고, {1}, {2}, ...는 각 애노테이션마다 다르다(보통 arguments)
이 방법 외에도 다음과 같이 직접 애노테이션에 message 속성으로 지정할수도 있다.
@NotBlank(message = "공백은 입력할 수 없습니다.")
private String itemName;
messageSource
에서 메시지 찾기message
속성 사용 → @NotBlank(message = "공백! {0}")
특정 필드가 아닌 해당 오브젝트 관련 오류(ObjectError)는 어떻게 처리할 수 있을까?
@ScriptAssert()
를 사용하면 된다.
@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000")
public class Item {
//...
}
메시지 코드는 다음과 같은 순서로 생성된다.
하지만 이 방식을 실제 사용해보면 제약이 많고 복잡하다. 실무에서는 검증 기능이 해당 객체의 범위를 넘어서는 경우들이 종종 등장하는데, 이 경우 대응이 어려워 잘 쓰이지 않는다.
따라서 오브젝트 오류(글로벌 오류)의 경우 관련 부분만 직접 자바 코드로 작성하는 것이 더 좋은 방법이다.
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item,
BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
}
만약 코드 중복이 걱정된다면 따로 메소드로 분리하도록 하자.
데이터를 등록(Post)할 때와 수정(Fetch, Put)할 때는 요구사항이 다를 수 있다.
변경된 제약조건은 기존에 작성된 Item 엔티티에선 적용이 불가능하다.
quantity
수량을 최대 9999까지 등록할 수 있지만, 수정시에는 수량을 무제한으로 변경할 수 있다.id
에 값이 없어도 되지만, 수정시에는 id 값이 필수이다.그렇다고 수정에 맞춰 아이디 필드에 @NotNull
검증 애노테이션을 붙히고 수량 필드에 @Max
애노테이션을 지우면 수정은 의도한대로 동작할지 몰라도 상품 등록시 아직 존재하지 않는게 당연한 아이디가 null이기에 검증 오류가 날 것이고, 수량도 9999개를 넘는 숫자를 넣어도 문제가 발생하지 않을 것이다.
결과적으로 Item은 등록과 수정에서 검증 조건의 충돌이 발생하고, 등록과 수정은 같은 BeanValidation을 적용할 수 없다. 이 문제를 어떻게 해결해야할까?
이런 문제를 해결하기 위해 Bean Validation은 groups라는 기능을 제공한다.
예를 들어 등록시에 검증할 기능과 수정시에 검증할 기능을 각각 그룹으로 나누어 적용할 수 있다.
public interface SaveCheck {}
public interface UpdateCheck {}
@Data
public class Item {
@NotNull(groups = UpdateCheck.class) // 수정시에만 적용
private Long id;
@NotBlank(groups = {SaveCheck.class, UpdateCheck.class})
private String itemName;
@NotNull(groups = {SaveCheck.class, UpdateCheck.class})
@Range(min = 1000, max = 1000000, groups = {SaveCheck.class, UpdateCheck.class})
private Integer price;
@NotNull(groups = {SaveCheck.class, UpdateCheck.class})
@Max(value = 9999, groups = SaveCheck.class)
private Integer quantity;
public Item() {}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
@PostMapping("/add")
public String addItemV2(@Validated(SaveCheck.class) @ModelAttribute Item item,
BindingResult bindingResult,
RedirectAttributes redirectAttributes,
Model model) {
//...
}
@PostMapping("/{itemId}/edit")
public String editV2(@PathVariable Long itemId,
@Validated(UpdateCheck.class) @ModelAttribute Item item,
BindingResult bindingResult) {
//...
}
이 방식을 사용하면 Item은 물론이고 전반적으로 복잡도가 올라간다. 이 방식은 실제로 잘 사용되지 않는다.
앞서 언급한 groups 방식을 실제론 잘 사용하지 않는데, 등록시 폼에서 전달하는 데이터가 Item
도메인 객체와 딱 맞지 않기 때문이다. 실무에선 회원 등록 시 회원과 관련된 데이터만 전달 받는 것이 아니라, 약관 정보도 추가로 받는 등 Item
과 관계없는 수 많은 부가 데이터가 넘어온다.
따라서 보통 Item
을 직접 전달받는 것이 아닌, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다. 예를 들어, ItemSaveForm
이라는 폼을 전달받는 전용 객체를 만들어 @ModelAttribute
로 사용한다. 이를 통해 컨트롤러에서 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해서 Item
을 생성한다.
HTML Form -> Item -> Controller -> Item -> Repository
HTML Form -> ItemSaveForm -> Controller -> Item 생성 -> Repository
Item 객체와 Form 객체는 다음과 같다.
@Data
public class Item {
private Long id;
private String itemName;
private Integer price;
private Integer quantity;
}
@Data
public class ItemSaveForm {
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(value = 9999)
private Integer quantity;
}
@Data
public class ItemUpdateForm {
@NotNull
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
//수정에서는 수량은 자유롭게 변경할 수 있다.
private Integer quantity;
}
컨트롤러는 다음과 같다.
@PostMapping("/add")
public String addItemV2(@Validated @ModelAttribute("item") ItemSaveForm form,
BindingResult bindingResult,
RedirectAttributes redirectAttributes,
Model model) {
//...
}
@PostMapping("/{itemId}/edit")
public String editV2(@PathVariable Long itemId,
@Validated @ModelAttribute("item") ItemUpdateForm form,
BindingResult bindingResult) {
//...
}
@ModelAttribute
에 추가되는 value 속성@ModelAttribute("item")
에 item 이름을 넣어준 부분을 주의하자. 이를 넣지 않으면 ItemSaveForm
의 경우 규칙에 의해 itemSaveForm
이라는 이름으로 MVC Model에 담기게 된다. 이렇게 되면 뷰 템플릿에서 접근하는 th:object
이름도 함께 변경해야 한다.//성공 로직
Item item = new Item();
item.setItemName(form.getItemName());
item.setPrice(form.getPrice());
item.setQuantity(form.getQuantity());
Item savedItem = itemRepository.save(item);
Form 방식을 이용하여 검증을 했는데, @Valid
, @Validated
는 HttpMessageConverter
(@RequestBody
)에도 적용할 수 있다.
@Slf4j
@RestController
@RequestMapping("/validation/api/items")
public class ValidationItemApiController {
@PostMapping("/add")
public Object addItem(@RequestBody @Validated ItemSaveForm form,
BindingResult bindingResult) {
log.info("API 컨트롤러 호출");
if (bindingResult.hasErrors()) {
log.info("검증 오류 발생 errors={}", bindingResult);
return bindingResult.getAllErrors();
}
log.info("성공 로직 실행");
return form;
}
}
테스트용 요청 정보는 다음과 같다.
POST http://localhost:8080/validation/api/items/add
Content-Type: application/json
{"itemName":"hello", "price":1000, "quantity": 10}
API의 경우 3가지 경우를 나누어 생각해야 한다.
price 값에 다음과 같이 숫자가 아닌 문자를 전달해서 실패하게 만들어보자.
POST http://localhost:8080/validation/api/items/add
{"itemName":"hello", "price":"A", "quantity": 10}
실패 요청 결과는 다음과 같다.
{
"timestamp": "2021-04-20T00:00:00.000+00:00",
"status": 400,
"error": "Bad Request",
"message": "",
"path": "/validation/api/items/add"
}
실패 요청 로그는 다음과 같다.
.w.s.m.s.DefaultHandlerExceptionResolver : Resolved
[org.springframework.http.converter.HttpMessageNotReadableException: JSON parse
error: Cannot deserialize value of type `java.lang.Integer` from String "A":
not a valid Integer value; nested exception is
com.fasterxml.jackson.databind.exc.InvalidFormatException: Cannot deserialize
value of type `java.lang.Integer` from String "A": not a valid Integer value
at [Source: (PushbackInputStream); line: 1, column: 30] (through reference
chain: hello.itemservice.domain.item.Item["price"])]
HttpMessageConverter
에서 요청 JSON을 Item
객체로 생성하는데 실패한다. 이 경우는 Item
객체를 만들지 못하기 때문에 컨트롤러 자체가 호출되지 않고 그 전에 예외가 발생한다. 또한 Validator도 실행되지 않는다.
HTTP 요청 파라미터를 처리하는 @ModelAttribute
는 각각의 필드 단위로 세밀하게 적용된다. 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리할 수 있다.
HttpMessageConverter
는 @ModelAttribute
와 다르게 각각의 필드 단위로 적용되는 것이 아니라, 전체 객체 단위로 적용된다. 따라서 메시지 컨버터의 작동이 성공해서 Item
객체를 만들어야 @Valid
, @Validated
가 적용된다.
@ModelAttribute
: 필드 단위로 정교하게 바인딩이 적용된다. 특정 필드가 바인딩 되지 않아도 나머지 필드는 정상 바인딩 되고, Validator를 사용한 검증도 적용할 수 있다.@RequestBody
: HttpMessageConverter
단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계 자체가 진행되지 않고 예외가 발생한다. 컨트롤러도 호출되지 않고, Validator도 적용할 수 없다.// HttpMessageConverter
단계에서 실패하면 예외가 발생한다.