Bean Validation
검증 기능을 매번 코드로 작성하는 것은 상당히 번거롭다. 특히 특정 필드에 대한 검증 로직은 대부분 빈 값인지 아닌지, 특정 크기를 넘는지 아닌지와 같이 매우 일반적인 로직이다. 이러한 불편함을 애노테이션 하나로 검증 로직을 적용한 것이 Bean Validation
이다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
build.gradle
에 라이브러리를 추가하자. 해당 라이브러리를 등록하면 스프링 부트는 자동으로 글로벌Validator
로 등록한다. 단, 사용할 때 해당 메서드 파라미터에@Validated
또는@Valid
를 넣어줘야 한다. 검증 오류가 발생하면FieldError
,ObjectError
를 생성해서BindingResult
에 담아준다.- 이후에 나올
groups
기능을 사용하려면 스프링 전용 검증 애노테이션인@Validated
를 사용해야 한다.
import lombok.Data;
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@Data
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
을 적용할 수 있다. 애노테이션이 직관적이기 때문에 별도의 설명은 넘어간다.
import hello.itemservice.domain.item.Item;
import org.junit.jupiter.api.Test;
import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
import java.util.Set;
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.message = " + violation.getMessage());
}
}
}
- 검증기 생성 방식이다. 이후에 스프링과 통합하여 사용하므로 참고만 하자.
검증 순서
@ModelAttribute
각각의 필드에 타입 변환 시도- 성공하면 다음으로
- 실패하면
typeMismatch
로FieldError
추가
- Validator 적용
여기서 고민해볼 점은 1번 과정에서 바인딩에 성공한 필드만 BeanValidation을 적용한다는 것이다. 그 이유는 당연하게도 모델 객체에 바인딩 받는 값이 정상적으로 들어와야 검증에도 의미가 있기 때문이다.
즉, 중간에 오류가 발생 시 typeMismatch
를 통해 BindingResult
에 값을 담아줄 뿐 BeanValidation은 적용되지 않는다.
Bean Validation - 에러 코드
BeanValidation이 기본으로 제공하는 오류 메시지는 bindingResult
에 등록되며 확인할 수 있다. 즉, typeMismatch
와 유사하다. 즉, 에러 메시지만 설정하면 직접 에러 코드를 구현할 수 있다.
#Bean Validation 추가
NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}
errors.properties
에 메시지를 등록해보자.
- 적용한 메시지가 정상 적용되는 것을 확인할 수 있다.
Bean Validation - 오브젝트 오류
특정 필드 (FieldError
)가 아닌 해당 오브젝트 관련 오류(ObjectError
)는 어떻게 처리할 수 있을까?
다음과 같이 @ScriptAssert()
를 사용하면 된다.
package hello.itemservice.domain.item;
import lombok.Data;
import org.hibernate.validator.constraints.Range;
import org.hibernate.validator.constraints.ScriptAssert;
import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000",
message = "총합이 10000원 넘게 넣어주세요.")
public class Item {
private Long id;
@NotBlank(message = "공백X")
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;
}
}
- 실행해보면 정상 수행되는 것을 확인할 수 있다. 다만, 실제 사용하기에 제약이 많고 복잡하므로 직접 자바 코드로 작성하는 것을 권장한다.
@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
// 특정 필드가 아닌 복합 룰 검증
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
if (bindingResult.hasErrors()) {
log.info("errors = {}", bindingResult);
return "validation/v3/addForm";
}
// 성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v3/items/{itemId}";
}
- 기존 방식대로 오브젝트와 관련된 검증은 직접 처리하는 것이 바람직하다.
Bean Validation - 수정에 적용
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @Validated @ModelAttribute Item item, BindingResult bindingResult) {
// 특정 필드가 아닌 복합 룰 검증
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
if (bindingResult.hasErrors()) {
log.info("errors = {}", bindingResult);
return "validation/v3/editForm";
}
itemRepository.update(itemId, item);
return "redirect:/validation/v3/items/{itemId}";
}
edit
에도 적용하기 위해 파라미터에@Validated
,BindingResult
를 추가하고, 오브젝트 에러와 에러 존재 여부 판단 조건을 넣어준다.
<form action="item.html" th:action th:object="${item}" method="post">
<div th:if="${#fields.hasGlobalErrors()}">
<p class="field-error" th:each="err : ${#fields.globalErrors()}" 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>
- 화면에도 보여주기 위해
th:errorclass="field-error"
와th:errors
를 적용시켜서 오류 발생 시에 화면의 변화를 줄 수 있도록 한다.
Bean Validation 한계
현재 까지는 등록과 수정에 동일한 검증을 했다. 만약에 등록과 수정 때 요구 사항이 달라져 검증 범위가 다르다면 어떻게 될까?
다음과 같이 수정의 요구사항 변경으로 인한 엔티티 클래스 변경을 봐보자.
import lombok.Data;
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@Data
public class Item {
@NotNull // 수정 요구사항 추가
private Long id;
@NotBlank(message = "공백X")
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;
}
}
- 수정 시에는 요구사항대로 동작하지만, 등록 시에
id
값이 필수가 되어 다음 페이지로 넘어가지 못하는 문제와 수량의 최댓값을 초과할 수 있는 문제가 발생한다. 즉, 등록과 수정 간의 검증 조건의 충돌이 발생한다. 이 문제는groups
를 통해 해결할 수 있다.
Bean Validation - groups
동일한 모델 객체를 등록할 때와 수정할 때 각각 다르게 검증하는 방법에 대해 알아보자.
- Beanvalidation의
groups
기능을 사용한다. - Item을 직접 사용하지 않고, 별도의 폼을 위한 DTO 객체를 만들어서 사용한다.
package hello.itemservice.domain.item;
import lombok.Data;
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@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;
}
}
- 다음과 같이
Item
클래스에 적용된 검증 애노테이션 별로groups
를 지정한다.groups
에 지정한 인터페이스들은 빈 인터페이스로 단순히 명시용이다.
@PostMapping("/add")
public String addItem2(@Validated(SaveCheck.class) @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);
}
}
if (bindingResult.hasErrors()) {
log.info("errors = {}", bindingResult);
return "validation/v3/addForm";
}
// 성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v3/items/{itemId}";
}
@PostMapping("/{itemId}/edit")
public String edit2(@PathVariable Long itemId, @Validated(UpdateCheck.class) @ModelAttribute Item item, BindingResult bindingResult) {
// 특정 필드가 아닌 복합 룰 검증
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
if (bindingResult.hasErrors()) {
log.info("errors = {}", bindingResult);
return "validation/v3/editForm";
}
itemRepository.update(itemId, item);
return "redirect:/validation/v3/items/{itemId}";
}
- 컨트롤러에서 등록과 수정 메서드안에서
@Validated(인터페이스명)
을 넣어서 구현할 수 있다.
groups
기능을 사용해서 등록과 수정 시에 다르게 검증할 수 있었다. 그런데, Item은 물론이고, 전반적으로 복잡도가 올라갔다. 사실 groups
는 실제 잘 사용되지는 않는데, 그 이유는 실무에서는 주로 다음에 등장하는 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문이다.
Form 전송 객체 분리
groups
를 실무에서 잘 사용하지 않는 이유는 기능 별 복잡한 폼의 데이터를 처리하기 때문에 별도의 전용 객체를 만들어서 @ModelAttribute
를 통해 전달하기 때문이다.
폼 데이터 전달에 Item 도메인 객체 사용
HTML Form
- Item
- Controller
- Item
- Repository
- 장점 : Item 도메인 객체를 컨트롤러, 레포지토리까지 직접 전달해서 중간에 Item을 만드는 과정이 없기 때문에 간단하다.
- 단점 : 간단한 경우에만 적용할 수 있으며, 수정 시 검증이 중복될 수 있고, groups를 사용해야 한다.
폼 데이터 전달을 위한 별도의 객체 사용
HTML Form
- ItemSaveFrom
- Controller
- Item 생성
- Repository
- 장점 : 전송하는 폼 데이터가 복잡해도 별도의 폼 객체를 사용해서 데이터를 전달 받을 수 있다. 별도의 폼 객체를 만들기 때문에 검증이 중복되지 않는다.
- 단점 : 폼 데이터를 기반으로 컨트롤러에서 Item 객체를 생성하는 변환 과정이 추가된다.
package hello.itemservice.web.validation.form;
import lombok.Data;
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@Data
public class ItemSaveForm {
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(value = 9999)
private Integer quantity;
}
- 등록을 위한 Item 폼 객체 생성
package hello.itemservice.web.validation.form;
import lombok.Data;
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@Data
public class ItemUpdateForm {
@NotNull
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
// 수정에서는 수량은 자유롭게 변경
private Integer quantity;
}
- 수정을 위한 Item 폼 객체 생성
@PostMapping("/add")
public String addItem2(@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}";
}
@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}";
}
- 컨트롤러에서 별도의 객체를 바인딩할 때 주의할 점은
@ModelAttribute
에서 기본적으로 생성되는 네임이 있으므로 뷰 단에서 적용한 이름을 맞춰줘야 한다. (아니면 모두 수정 해야한다.) 또한 기존 모델에 필요한 값을 넣어 레포지토리에 넣어주면 된다.
Bean Validation - HTTP 메시지 컨버터
@ModelAttribute
는 HTTP 요청 파라미터(URL 쿼리 스트링, POST Form)를 다룰 때 사용한다.
@RequestBody
는 HTTP Body의 데이터를 객체로 변환할 때 사용한다. 주로 API JSON 요청을 다룰 때 사용한다.
@Valid
, @Validated
는 HttpMessageConverter(@RequestBody)
에도 적용할 수 있다.
API의 경우 3가지 경우를 나누어 생각해볼 수 있다.
- 성공 요청
- 실패 요청 : JSON을 객체로 생성하는 것 자체가 실패
- 검증 오류 요청 : JSON 객체로 생성은 했으나, 검증에서 실패
- Postman에서 Body - raw -JSON을 선택한 후 Post로 데이터를 전송했을 때 성공 결과
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.web.validation.form.ItemSaveForm["price"])]
price
에 문자값을 넣어보면 JSON 객체를 생성하는 것을 실패한 로그가 나온다. 즉,HttpMessageConverter
가 객체를 만들지 못하는 경우이다. 이 경우는Item
객체를 만들지 못하기 때문에 컨트롤러 자체가 호출되지 않고 그 전에 예외가 발생하기 때문에Validator
도 실행되지 않는다.
Field error in object 'itemSaveForm' on field 'quantity': rejected value [1000000]; codes [Max.itemSaveForm.quantity,Max.quantity,Max.java.lang.Integer,Max]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [itemSaveForm.quantity,quantity]; arguments []; default message [quantity],9999]; default message [9999 이하여야 합니다]
- 객체는 생성했지만 검증에 실패한 경우
bindingResult
에ObjectError
와FieldError
에 오류 결과를 보관한다.
@ModelAttribute vs @RequestBody
HTTP 요청 파라미터를 처리하는 @ModelAttribute
는 각각의 필드 단위로 세밀하게 적용된다. 그래서 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리할 수 있었다. 즉, 정상 바인딩 된 필드는 검증을 적용할 수 있다.
HttpMessageConverter
를 사용하여 객체 단위로 처리하는 @RequestBody
는 필드 단위 적용이 아니기 때문에 Item
객체를 만들어야 @Valid
, @Validated
를 적용할 수 있다.
본 포스팅은 인프런 - 김영한님의 '스프링 MVC 2편 - 백엔드 웹 개발 활용 기술'을 참고하였습니다.