Spring에서는 객체에 대해서 Validation을 수행하는 매우 편리한 방법을 제공한다.
이전 포스팅에서는 BindingResult와 Validator, 그리고 @Validated 애노테이션을 사용한 Validation 방식을 공부했었다.
이전 포스팅 : Spring에서 Validation
그런데 Controller 기준이 아니라 특정 객체를 기준으로 Validation을 수행한다면 같은 코드가 반복될 확률이 높다. 그렇다면 Validation 코드가 반복되고 여기저기에서 사용될것이다.
이러한 문제를 해결하기 위해서는 스프링에서 Validation 로직을 공통화할 수 있도록 제공한다.
@Data
public class Item {
@NotNull
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(999)
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
위 코드에서 사용된 @NotNull, @NotBlank, @Max(), @Ragne 가 모두 Bean Validation 방식이다.
Bean Validation을 사용하기 위해서는 의존관계를 추가해줘야한다.
// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-validation'
@NotBlank : 빈값 + 공백만 있는 경우를 허용하지 않음
@NotNull : null을 허용하지 않음
@Range : 범위안의 값이어야함
@Max : 최대로 지정된 값이어야함
간단한 BeanValidation 테스트 코드를 보면 아래와 같다.
@Test
public void beanValidationTest() throws Exception {
// 검증기 생성
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
Member member = new Member();
member.setUsername(" ");
member.setAddress(" ");
member.setAge(0);
member.setQuantity(100000);
// 검증기를 이용한 검증 수행
Set<ConstraintViolation<Member>> violations = validator.validate(member);
for (ConstraintViolation<Member> violation : violations) {
System.out.println("violation = " + violation);
System.out.println("violation.getMessage() = " + violation.getMessage());
}
}
violation = ConstraintViolationImpl{interpolatedMessage='999 이하여야 합니다', propertyPath=quantity, rootBeanClass=class hello.itemservice.domain.item.Member, messageTemplate='{javax.validation.constraints.Max.message}'}
violation.getMessage() = 999 이하여야 합니다
violation = ConstraintViolationImpl{interpolatedMessage='널이어서는 안됩니다', propertyPath=id, rootBeanClass=class hello.itemservice.domain.item.Member, messageTemplate='{javax.validation.constraints.NotNull.message}'}
violation.getMessage() = 널이어서는 안됩니다
violation = ConstraintViolationImpl{interpolatedMessage='공백일 수 없습니다', propertyPath=username, rootBeanClass=class hello.itemservice.domain.item.Member, messageTemplate='{javax.validation.constraints.NotBlank.message}'}
violation.getMessage() = 공백일 수 없습니다
violation = ConstraintViolationImpl{interpolatedMessage='공백일 수 없습니다', propertyPath=address, rootBeanClass=class hello.itemservice.domain.item.Member, messageTemplate='{javax.validation.constraints.NotBlank.message}'}
violation.getMessage() = 공백일 수 없습니다
위 테스트 코드에서 봇드싱 검증기를 이용해서 Member 객체에 대한 검증 수행과 그 결과를 터미널에서 확인할 수 있었다.
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
위 코드를 통해서 검증기를 생성하지만, 스프링과 통합된다면 별도로 검증기를 생성하지 않고 자동으로 생성된다.
스프링부트는 spring-boot-starter-validation 라이브러리를 추가하면 자동으로 Bean Validator를 인지하고 스프링에 통합해준다고 한다.(아마도 autoconfiguration 기능을 이용할것으로 예상됨)
스프링 부트는 자동으로 글로벌 Validator를 등록한다고 하며, LocalValidatorFactoryBean을 글로벌 Validator로 등록한다.(모든 컨트롤러에서 사용 가능한 글로벌 Validator) 따라서 @Valid 혹은 @Validated만 적용하면 바로 적용된다.
(이전 포스팅에서 공부했듯이 @Valid or @Validated는 검증기를 찾아서 수행하도록 하기 때문임, 이때 support 함수를 이용해서 검증기 적용해도 되는지 체크하고)
그 결과 검증 오류가 발생하면 FieldError, ObjectError를 생성해서 BindingResult로 담아준다.
하지만, 글로빌 Validator를 스프링부트가 등록해주기 때문에 직접 글로벌 Validator를 등록하게 되면 스필이부트는 Bean Validator를 글로벌 Validator로 등록하지 않는다.
SpringMVC에서 Bean Validator를 검증하는 순서는 아래와 같다.
public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
...
...
...
}
앞선 테스트 코드의 결과는 아래와 같이 출력됐다.
violation = ConstraintViolationImpl{interpolatedMessage='999 이하여야 합니다', propertyPath=quantity, rootBeanClass=class hello.itemservice.domain.item.Member, messageTemplate='{javax.validation.constraints.Max.message}'}
violation.getMessage() = 999 이하여야 합니다
violation = ConstraintViolationImpl{interpolatedMessage='널이어서는 안됩니다', propertyPath=id, rootBeanClass=class hello.itemservice.domain.item.Member, messageTemplate='{javax.validation.constraints.NotNull.message}'}
violation.getMessage() = 널이어서는 안됩니다
violation = ConstraintViolationImpl{interpolatedMessage='공백일 수 없습니다', propertyPath=username, rootBeanClass=class hello.itemservice.domain.item.Member, messageTemplate='{javax.validation.constraints.NotBlank.message}'}
violation.getMessage() = 공백일 수 없습니다
violation = ConstraintViolationImpl{interpolatedMessage='공백일 수 없습니다', propertyPath=address, rootBeanClass=class hello.itemservice.domain.item.Member, messageTemplate='{javax.validation.constraints.NotBlank.message}'}
violation.getMessage() = 공백일 수 없습니다
근데, 위 메시지는 스프링에서 기본적으로 제공해주는 공통된 메시지다.
만약 이러한 오류 메시지를 변경하기 위해서는 아래같이 메시지코드를 작성해주면 된다.
//errors.properties
NotBlank.member.userName=유저 이름을 적어주세요.
rejectValue 함수에서 에러코드를 조합하듯이 NotBlank 애노테이션을 사용하는 경우 위처럼 상세하게 남겨놓을 수 있다.
따라서, BeanValidation 메시지를 찾는 순서를 정리하면 아래와 같다고 한다.
1. 생성된 메시지 코드 순서대로 messageSource에서 메시지 찾기(errors.properties, messages.properties)
2. 애노테이션의 messages속성 사용 (ex, @NotBlank(messages = "공백X"))
3. 라이브러리가 제공하는 기본값 사용
특정 객체의 필드 오류가 아닌 여러 필드의 조합으로 발생하는 오류는 어떻게 검증할 수 있을까?
스프링에서는 ScriptAssert 라는 애노테이션을 제공한다.
@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >=
10000")
public class Item
_this는 현재 객체를 의미하고 script 안에 그 검증 내용을 작성할 수 있다.
메시지 코드 또한 아래 처럼 작성이 가능하다.
ScriptAssert.item=
ScriptAssert=
하지만 각 객체마다 작성하기에는 매우 번잡스럽기 떄문에 글로벌오류는 직접 자바 코드로 작성하는걸 권장한다고 한다.
내가 만든 Member 객체에 대해서 비지니스 로직을 수행할때, 검증 조건이 서로 다르다면 어떻게 해야할까?
만약 그 검증 조건이 서로 상충한다면?
이럴때 group 기능을 사용할 수 있다고 한다!
@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})
// @NotNull
// @Range(min = 1000, max = 1000000)
private Integer price;
@NotNull(groups = {SaveCheck.class, UpdateCheck.class})
@Max(value = 9999, groups = {SaveCheck.class})
// @NotNull
// @Max(999)
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
public interface SaveCheck {
}
public interface UpdateCheck {
}
@PostMapping("/add")
public String addItem2(@Validated(SaveCheck.class) @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
Bean Validation에서 group을 적용하는 방법은 아래와 같이 정리할 수 있다.
1. group으로 사용할 인터페이스를 작성(SaveCheck, UpdateCheck)
2. 객체에 group 적용
@NotNull(groups = UpdateCheck.class)
private Long id;
@NotBlank(groups = {SaveCheck.class, UpdateCheck.class})
private String userName;
즉, Save할때와 update 할떄 각각 적용해야할 검증 조건을 다르게 적용할 수있다.
@PostMapping("/add")
public String addItem2(@Validated(SaveCheck.class) @ModelAttribute Item item,
@PostMapping("/{itemId}/edit")
public String editV2(@PathVariable Long itemId, @Validated(UpdateCheck.class) @ModelAttribute Item item,
하지만 group을 사용할 경우 객체의 복잡도가 올라가기 때문에 선호하진 않고, save할때와 update할떄 서로 다른 객체를 써서 사용할 수 있도록 하는 방식을 선호한다고 한다.
(그런데 아직까진 나는 잘 못느끼겠다. 객체가 크지 않아서 그런가?)
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult) {
위 코드는 ItemSaveForm과 ItemUpdateForm이라는 객체를 각각 만들어서 사용한다. 따라서 검증기가 중복되지않는다. 다만, Item 객체를 다시 만들어야하기 때문에 좀더 코드가 길어질 수 있다.
@Valid와 @Validated는 ModelAttribute 뿐만 아니라 RequestBody에서도 동작한다고 한다.
RequestBody와 ResponseBody는 API 메시지를 만들때 주로 사용한다.
따라서 아래와 같이 3가지 상황에 대해서 정리해야한다.
1. 성공 요청 : 성공
2. 실패 요청 : Json 객체 생성 자체가 실패
3. 검증 오류 요청 : Json 객체까지는 생성하나 검증 오류 발생
만약 Json 조차도 못만든다면 컨트롤러 자체가 호출되지 않고 예외가 발생하게 된다.
검증 오류가 발생하는 경우는 검증 오류에 대한 Json 메시지가 생성되게 된다.
아래는 실제 검증 오류 메시지다.
[
{
"codes": [
"Max.itemSaveForm.quantity",
"Max.quantity",
"Max.java.lang.Integer",
"Max"
],
"arguments": [
{
"codes": [
"itemSaveForm.quantity",
"quantity"
],
"arguments": null,
"defaultMessage": "quantity",
"code": "quantity"
},
9999
],
"defaultMessage": "9999 이하여야 합니다",
"objectName": "itemSaveForm",
"field": "quantity",
"rejectedValue": 10000,
"bindingFailure": false,
"code": "Max"
}
]
해당 포스팅은 아래의 강의를 듣고 공부한 내용입니다.
김영한님의 SpringMVC2-Bean Validation