검증 기능을 매번 코드로 작성하는 것은 상당히 번거롭다.
특히 필드에 대한 검증 로직은 대부분 빈 값인지, 특정 크기를 넘는지 아닌지와 같이 매우 일반적인 로직이다.
public class Itam{
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(9999)
private Integer quantity;
}
Bean Validation이다.Bean Validation을 잘 활용하면, 애노테이션 하나로 검증 로직을 매우 편리하게 적용할 수 있다. 검증에 대한 제약 조건을 애노테이션 기반으로 적용할 수 있다Bean Validation 이란?
의존관계 추가
(build.gradle에 의존관계 추가)
implementation 'org.springframework.boot:spring-boot-starter-validation'
Item클래스 수정

검증 애노테이션
BeanVaildationTest 생성
public class BeanValidationTest {
@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>> violations = validator.validate(item);
for (ConstraintViolation<Item> violation : violations) {
System.out.println("violation = " + violation);
System.out.println("violation.getMessage() = " + violation.getMessage());
}
}
}
테스트 실행 결과

먼저 ValidationItemControllerV3 클래스 정리

실행해보면, 애노테이션 기반의 Bean Validation이 정상 동작하는 것을 확인할 수 있다. 스프링 MVC는 어떻게 Bean Validator를 사용할까?
spring-boot-starter-validation 라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합한다.스프링 부트는 자동으로 글로벌 Validator로 등록한다
LocalValidatorFactoryBean을 글로벌 Valiator로 등록한다. LocalValidatorFactoryBean은 Spring Framework에서 제공하는 Hibernate Validator를 기반으로 하는 유효성 검증(Validation)을 지원하는 클래스@NotnNull 같은 애노테이션을 보고 검증을 수행한다. 이렇게 글로벌 Validator가 적용되어 있기 때문에, @Valid, @Validated만 적용하면 된다.참고
@Validated, @Valid 둘다 사용이 가능하다javax.validation.@Valid 를 사용하려면 build.gradle 의존관계 추가가 필요하다. (이전에 추가했다.)implementation 'org.springframework.boot:spring-boot-starter-validation'@Validated는 스프링 전용 애노테이션이고, @Valid는 자바 표준 검증 애노테이셔니다. 둘 중, 아무거나 사용해도 동일하게 작동한다@Validated는 내부에 groups라는 기능을 포함하고 있다. 검증 순서
1. @ModelAttribute 각각의 필드에 타입 변환 시도
--> 성공하면, 다음으로
--> 실패하면, typeMismatch로 FieldError 추가
2. Validator 적용
바인딩 성공한 필드만 Bean Validation 적용
예시)
itemName에 문자 "A" 입력 -> 타입 변환 성공 -> itemName 필드에 BeanValidation 적용
price에 문자 "A" 입력 -> "A"를 숫자 타입 변환 시도 실패 -> typeMisMatch FieldError 추가 -> price 필드는 BeanValidation 적용 X
스프링 부트에서는 spring-boot-starter-validation 라이브러리를 추가하면 자동으로 Bean Validation이 활성화되며, LocalValidatorFactoryBean이 글로벌 Validator로 등록되어 컨트롤러 등에서 @Valid 어노테이션을 이용하여 유효성 검증을 수행할 수 있게 된다.
Bean Validation이 기본으로 제공하는 오류 메시지를 좀 더 자세하게 변경하고 싶다면?
--> Bean Validation을 적용하고 bindingResult 에 등록된 검증 오류 코드를 보자.
로그를 확인해보자

@NotBlank
메시지 등록
메시지를 등록해보자 errors.properties
NotBlank={0} 공백X
Range={0}, {2} ~ {1} 적용
Max={0}, 최대{1}
실행해보면

BeanValidation 메시지 칮는 순서
1. 생성된 메시지 코드 순서대로 messageSource에서 메시지 찾기
예시) NotBlank.item.itemName -> NotBlank.itemName -> NotBlank.java.lang.String -> NotBlank
1에서 못찾으면, 애노테이션의 message 속성 사용 ( @NotBlank(message = "공백X") )

2에서 못찾으면, 라이브러리가 제공하는 기본 값 아용 (공백일 수 없습니다)
Bean Validation에서 특정 필드( FieldError)가 아닌, 해당 오브젝트 관련 오류(ObjectError)은 어떻게 처리할 수 있을 까?
@ScriptAssert()사용
Item 클래스에 아래 코드를 추가해보자
@ScriptAssert(lang = "javascript", script="_this.price * _this.quantity >= 10000")
실제 사용해보면 제약이 많고 복잡하다. 그리고 실무에서는 검증 기능이 해당 객체의 범위를 넘어서는 경우들도 종종 등장하는데, 그런 경우 대응이 어렵다.
따라서 오브젝트 오류(글로벌 오류)의 경우, @ScriptAssert를 억지로 사용하는 것보다는 다음과 같이 오브젝트 관련 부분만 직접 자바 코드로 작성하는 것을 권장한다.
@ScriptAssert 부분 주석 처리 & ValidationItemControllerV3 - 글로벌 오류 추가
if (item.getPrice() != null && item.getQuantity() != null){
int resultPrice = item.getPrice() * item.getQuantity();
if(resultPrice <10000){
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice},null);
}
}

정상적으로 작동이 되는 것을 확인할 수 있다.
상품 수정에도 빈 검증(Bean Validation)을 적용해보자
edit() 추가

editForm.html코드 수정
<body>
<div class="container">
<div class="py-5 text-center">
<h2 th:text="#{page.updateItem}">상품 수정</h2>
</div>
<form action="item.html" th:action th:object="${item}" method="post">
<div th:if="${#fields.hasGlobalErrors()}">
<p class="field-error" th:each="err : ${#fields.hasGlobalErrors()}" th:text="${err}">글로벌 오류</p>
</div>
<div>
<label for="id" th:text="#{label.item.id}">상품 ID</label>
<input type="text" id="id" th:field="*{id}" class="form-control" readonly>
</div>
<div>
<label for="itemName" th:text="#{label.item.itemName}">상품명</label>
<input type="text" id="itemName" th:field="*{itemName}" th:errorclass="field-error" class="form-control">
<div class="field-error" th:errors="*{itemName}">상품명 오류</div>
</div>
<div>
<label for="price" th:text="#{label.item.price}">가격</label>
<input type="text" id="price" th:field="*{price}" th:errorclass="field-error" class="form-control">
<div class="field-error" th:errors="*{price}">가격 오류</div>
</div>
<div>
<label for="quantity" th:text="#{label.item.quantity}">수량</label>
<input type="text" id="quantity" th:field="*{quantity}" th:errorclass="field-error" class="form-control">
<div class="field-error" th:errors="*{quantity}">수량 오류</div>
</div>
<hr class="my-4">
<div class="row">
<div class="col">
<button class="w-100 btn btn-primary btn-lg" type="submit" th:text="#{button.save}">저장</button>
</div>
<div class="col">
<button class="w-100 btn btn-secondary btn-lg"
onclick="location.href='item.html'"
th:onclick="|location.href='@{/validation/v3/items/{itemId}(itemId=${item.id})}'|"
type="button" th:text="#{button.cancel}">취소</button>
</div>
</div>
</form>
</div> <!-- /container -->
</body>

등록 시 기존 요구사항
수정 시 요구사항 추가
수정 요구사항 적용
Item 클래스 수정: 수정시에는 Item에서 id값이 필수이고, quantity도 무제한으로 적용할 수 있다.

수정 요구사항을 적용하기 위해
--> id : @NotNull추가
--> quantity : @Max(9999)제거
정상적으로 상품 수정 요구사항이 반영되 것을 확인할 수 있다.
그런데 문제가 발생한다

정상적으로 값을 입력했음에도 상품이 등록되지 않는다.
수정은 잘 동작하지만 등록에서 문제가 발생한다
등록시 화면이 넘어가지 않으면서 다음과 같은 오류를 볼 수 있다.
'id': rejected value [null];: 등록 시에는 id값이 없기 때문이다.결과적으로 item은 등록과 수정에서 검증 조건의 충돌이 발생(등록에서는 허용하는 것을 수정에서는 허용하지 않음), 등록과 수정은 같은 BeanValidation을 적용할 수 없다.
동일한 모델 객체를 등록과 수정을 할 떄, 각각 다르게 검증하는 방법을 알아보자
방법 2가지
① BeanValidation의 groups 기능을 사용한다.
② Item을 직접 사용하지 않고, ItemSaveForm, ItemUpdateForm 같은 폼 전송을 위한 별도의 모델 객체를 만들어서 사용한다.
BeanValiation groups 기능 사용
이런 문제를 해결하기 위해 Bean Validation은 groups라는 기능을 제공한다
저장용 groups 생성

수정용 groups 생성

Item-groups 적용: Item 클래스를 다음과 같이 수정한다.

ValidationControllerV3 코드 수정
@Validated가 적용될 때, groups가 SaveCheck.class인 것만 검증하게 된다.
@Validated가 적용될 때, groups가 UpdateCheck.class인 것만 검증하게 된다.
참고
groups 기능을 사용하려면 @Validated를 사용해야 한다정리
- groups 기능을 사용해서 등록과 수정시에 각각 다르게 검증을 할 수 있었다. 그런데 groups 기능을 사용하니 Item 은 물론이고, 전반적으로 복잡도가 올라갔다.
- 사실 groups 기능은 실제 잘 사용되지는 않는데, 그 이유는 실무에서는 주로 다음에 등장하는 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문이다.
실무에서는 groups를 잘 사용하지 않는다. 등록 시 폼에서 전달하는 데이터가, Item 도메인 객체와 딱 맞지 않기 떄문이다.
실무에서는 가령 회원 등록시, 회원과 관련된 데이터만 받는 것이 아니라, 약관 정보도 추가로 받는 등 Item과 관계없는 수 많은 부가 데이터가 넘어온다.
그래서 보통 Item을 직접 전달받는 것이 아니라, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다.
예를 들면, ItemSaveForm 이라는 HTML폼에서 입력한 내용을 전달 받을 전용 객체를 만들어서, @ModelAttribute로 사용한다.
--> 이것을 통해서 컨트롤러에서 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해서 Item을 생성한다.
폼 데이터 전달에 Item 도메인 객체 사용
HTML Form -> Item -> Controller -> Item -> Repository
폼 데이터 전달을 위한 별도의 객체 사용
HTML form -> ItemSaveForm -> Controller -> Item 생성 -> Repository
등록과 수정은 완전히 다른 데이터가 넘어온다. 예를 들면 등록시에는 로그인id, 주민번호 등등을 받을 수 있지만, 수정시에는 이런 부분이 빠진다. 그리고 검증 로직도 많이 달라진다. 그래서 ItemUpdateForm 이라는 별도의 객체로 데이터를 전달받는 것이 좋다
Item 원상복구: 이제 기존 Item의 검증은 사용하지 않으므로 검증 코드를 제거한다
ItemSaveForm 생성 - 저장
@Data
public class ItemSaveForm {
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(9999)
private Integer quantity;
}
ItemUpdateForm 생성 - 수정
@Data
public class ItemUpdateForm {
@NotNull
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
//수정에서는 수량은 자유롭게 변경할 수 있다.
private Integer quantity;
}
ValidationItemControllerV4 - addItem
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//특정 필드가 아닌 복합 룰 검증
if (form.getPrice() != null && form.getQuantity() != null){
int resultPrice = form.getPrice() * form.getQuantity();
if(resultPrice <10000){
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice},null);
}
}
//검증에 실패하면
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return "validation/v4/addForm";
}
//성공 로직
Item item = new Item();
item.setItemName(form.getItemName());
item.setPrice(form.getPrice());
item.setQuantity(form.getQuantity());
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v4/items/{itemId}";
}
ValidationItemControllerV4 - edit
@PostMapping("/{itemId}/edit")
public String edit2(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult) {
if (form.getPrice() != null && form.getQuantity() != null){
int resultPrice = form.getPrice() * form.getQuantity();
if(resultPrice <10000){
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice},null);
}
}
if(bindingResult.hasErrors()){
log.info("errors={}",bindingResult);
return "validation/v4/editForm";
}
Item itemParam = new Item();
itemParam.setItemName(form.getItemName());
itemParam.setPrice(form.getPrice());
itemParam.setQuantity(form.getQuantity());
itemRepository.update(itemId, itemParam);
return "redirect:/validation/v4/items/{itemId}";
}
}
폼 객체 바인딩

폼 객체를 Item으로 변환

정리
@Valid, @Validated는 HttpMessageConverter(@RequestBody)에도 적용할 수 있다.
참고
ValidationItemApiController 생성
@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;
}
}
Postman 성공


Postman 실패

Postman 검증 오류 요청 테스트

API의 경우 3가지 경우를 나눠서 생각해야 한다
@ModelAttribute VS @RequestBody
ItemSaveForm 객체를 들어야 @Validated가 적용된다정리
- @ModelAttribute 는 필드 단위로 정교하게 바인딩이 적용된다. 특정 필드가 바인딩 되지 않아도 나머지 필드는 정상 바인딩이 되고,
Validator를 사용한 검증도 적용할 수 있다.- @RequestBody는 HttpMessageConverter 단계에서 JSON 데티어를 객체로 변경하지 못하면, 이후 단계 자체가 진행되지 않고 예외가 발생한다. 컨트롤러도 호출되지 않고 Validator도 적용할 수 없다.