Validation은 엄청 중요한 기능중의 하나이다.
예를 들어, 가격 수량에 문자가 들어오면 안되는데 들어온다던가, 상품명이 지원하지 않는 포멧을 가지고 있다던가 글자수 제한을 넘긴다던가 하는 validation이 존재할 것이고 이를 지켜야 사이트 오류를 막을 수 있다.
이런 검증은 클라이언트 검증과 서버검증 두 방식이 있다.
다만 클라이언트 검증은 유저가 조작할 수 있는 문제가 있다.
반대로 서버검증은 이러한 부분은 안전하지만, 사용자가 즉각적으로 알아차리기가 힘들어진다.
따라서 이중으로 검증하는것이 중요하다.
또한 검증오류 로그를 잘 남겨야한다.
상품저장은 PRG 흐름으로 작동한다.
1. get으로 등록 form을 불러온다
2. post로 add한다
3. 상품 상세 page로 redirect 한다.
4. 상품상세 페이지를 get한다.
만약 post add 과정에서 model 검증에서 오류가 난다면, 상품등록 form을 다시 그대로 보여주면서, 오류 메시지를 보여줘야 한다.
다음과 같은 검증을 어떤식으로 구현해야 할까?
// 오류 저장 맵
Map<String, String> errors = new HashMap<>();
// 검증 로직
if (!StringUtils.hasText(item.getItemName())) {
errors.put("itemName", "상품이름은 필수입니다.");
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
errors.put("price", "가격은 1,000 ~ 1,000,000 까지 허용합니다");
}
if (item.getQuantity() == null || item.getQuantity() >= 9999) {
errors.put("quantity", "수량은 최대 9,999 까지 허용합니다.");
}
// 복합 검증
if (item.getPrice() != null ** item.getQuantity() != null) {
int result = item.getPrice(); * item.getQuantity();
if (result < 10000) {
errors.put("globalError", "가격 * 수량은 10000 이상이여야 합니다.");
}
}
if (!errors.isEmpty()) {
model.addAttribute("errors", errors);
return "validation/v1/addForm";
}
이렇게 구현을 하면 다양한 검증은 물론, 검증에 실패했을 때, 이를 다시 원래 호출되었던 form으로 돌려주는 역할을 한다.
error 메시지의 경우, model에 담긴 error를 보고 thymeleaf에서 처리해주는 방식이다.
이러한 문제들은 사실 스프링 프레임워크에 다 정리되어있다.
스프링의 검증 오류 방식이다.
스프링이 제공하는 검증오류를 보관하는 객체이다. bindingresult가 있으면, ModelAttribute에 바인딩하다가 오류가 나도 컨트롤러가 호출이 돼서 오류가 발생하게 된다.
@ModelAttribute 의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 FieldError 생성해서
BindingResult 에 넣어준다.
개발자가 직접 넣어주는 경우
validator 사용
// 검증 로직
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item", "itemName", "상품이름은 필수입니다.");
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
bindingResult.addError(new FieldError("item", "price", "가격은 1,000 ~ 1,000,000 까지 허용합니다.");
}
if (item.getQuantity() == null || item.getQuantity() >= 9999) {
bindingResult.addError(new FieldError("item", "quantity", "수량은 최대 9,999 까지 허용합니다.");
}
// 복합 검증
if (item.getPrice() != null && item.getQuantity() != null) {
int result = item.getPrice() * item.getQuantity();
if (result < 10000) {
bindingResult.addError(new ObjectError("item", "가격 * 수량은 10000 이상이여야 합니다."));
}
}
if (bindingResult.hasErrors()) {
model.addAttribute("errors", bindingResult);
return "validation/v2/addForm";
}
bindingresult는 반드시 @ModelAttribute 뒤에 와야한다
binding result는 기존의 map errors를 대체하는 역할을 하는 스프링 기능중 하나이다.
파라미터 목록
objectName : 오류가 발생한 객체 이름
field : 오류 필드
rejectedValue : 사용자가 입력한 값(거절된 값)
bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
codes : 메시지 코드
arguments : 메시지에서 사용하는 인자
defaultMessage : 기본 오류 메시지
그 후, thymeleaf로 필드에러와 오브젝트 에러를 처리해주는 코드를 추가하면 된다.
하지만 이런 방식도 아직 문제가 있는데, 오류 발생한 데이터가 유지가 되지 않는점이다.
필드에러에는 구현메소드가 사실 2개가 있다. 여기서 rejectedValue를 설정해주면 값이 유지가 된다.
// 검증 로직
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, null, null, "상품이름은 필수입니다."));
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
}
if (item.getQuantity() == null || item.getQuantity() >= 9999) {
bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, null, null, "수량은 최대 9,999 까지 허용합니다."));
}
이런식으로 작성하면 값이 유지가 된다.
또한, bindingresult를 사용하면, type오류가 발생하는것도 잡아서 이를 처리해줄 수 있게 된다.
messages 로 국제화를 했던것처럼, error.properties를 만들어서, 이 메시지들을 각 오류생성객체에 바인딩해주면 된다.
이렇게 어플리케이션 프로퍼티에 메시지로 등록을 한 다음,
// 검증 로직
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item", "itemName",
item.getItemName(), false, new String[]{"required.item.itemName"}, null,
null)); }
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() >
1000000) {
bindingResult.addError(new FieldError("item", "price", item.getPrice(),
false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}, null));
}
if (item.getQuantity() == null || item.getQuantity() > 10000) {
bindingResult.addError(new FieldError("item", "quantity",
item.getQuantity(), false, new String[]{"max.item.quantity"}, new Object[]
{9999}, null));
}
// 복합 검증
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.addError(new ObjectError("item", new String[]
{"totalPriceMin"}, new Object[]{10000, resultPrice}, null));
}
}
이런식으로 바인딩해주면
이렇게 설정한 오류메시지가 나온다.
reject, rejectvalue를 사용하면, fieldError나 objectError을 만들지 않고도 오류처리를 할 수 있다.
// 검증 로직
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required");
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null); }
if (item.getQuantity() == null || item.getQuantity() > 10000) {
bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null);
}
// 복합 검증
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
이전보다 훨씬 코드가 간결해진것을 알 수 있다.
자세히 보면, 기존의 코드처럼 정확하게 required.item.itemName 같이 안쓰고 required만 써도 오류코드를 알아서 찾아오는 것을 볼 수 있다.
오류메시지를 디테일하게 쓸 때도 있지만, 간단하게 쓸 때도 있다.
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
required=필수값입니다.
range=범위는 {0} ~ {1}까지 입니다.
max=최대 {0} 까지 허용됩니다.
보편적인 방법은 범용적으로 쓰다가, 디테일한 메시지가 필요한 곳에서 세부적인 오류코드를 쓰는 방법이다.
위에서 작성한 코드는 required를 오류메시지 키로 잡고 있다. 이때 스프링은 required를 가진 모든 오류 메시지를 찾는다. 그리고 그 중 가장 디테일한 값을 가진 오류메시지를 우선순위로 판단, 오류메시지로 선택하게 되고 이를 오류로 출력한다.
MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();
MessageCodesResolver는 오류메시지를 처리해주는 스프링 메서드의 하나이다.
이를 통해 메시지를 생성해보면
String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");
required 에러코드를 가진, item 오브젝트 메시지를 전부 생성하여
messageCode = required.item
messageCode = required
가 생성된다.
String[] messageCodes = codesResolver.resolveMessageCodes("required", "item", "itemName", String.class);
required 에러코드를 가진, item 오브젝트, itemName 필드를 가진 메시지를 전부 생성하여
messageCode = required.item.itemName
messageCode = required.itemName
messageCode = required.java.lang.String
messageCode = required
가 생성된다.
이 resolver에는 생성 규칙이 존재한다.
객체 오류
1.: code + "." + object name
2.: code
예) 오류 코드: required, object name: item
1.: required.item
2.: required
필드 오류
1.: code + "." + object name + "." + field
2.: code + "." + field
3.: code + "." + field type
4.: code
예) 오류 코드: typeMismatch, object name "user", field "age", field type: int
1. "typeMismatch.user.age"
2. "typeMismatch.age"
3. "typeMismatch.int"
4. "typeMismatch"
이렇게 생성된 코드를 가지고 있다가 요청에 맞춰 errors.properties에서 찾고, 그 안에서 찾아서 보내준다.
핵심은 구체적인것부터 덜 구체적인것으로 넘어가는것이다.
지금까지는 오류검증로직을 컨트롤러에 떠넘겨서 처리했었다. 그래서 이 오류검증 로직들을 처리하는 validator라는 객체를 만들어서 한번에 관리할 것이다.
@Component
public class ItemValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return Item.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object target, Errors errors) {
Item item = (Item) target;
// 검증 로직
if (!StringUtils.hasText(item.getItemName())) {
errors.rejectValue("itemName", "required");
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
errors.rejectValue("price", "range", new Object[]{1000, 1000000}, null); }
if (item.getQuantity() == null || item.getQuantity() > 10000) {
errors.rejectValue("quantity", "max", new Object[]{9999}, null);
}
// 복합 검증
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
errors.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
}
}
Errors는 bindingResult의 부모객체이다. 따라서 bindingResult를 쓸 수 있다.
@PostMapping("/add")
public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
itemValidator.validate(item, bindingResult);
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return "validation/v2/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
이렇게 검증부분을 validator로 넘겨서 처리하면 코드가 더 깔끔해진다.
웹 데이터 바인더에 검증기를 추가하면 해당 컨트롤러에서는 @Validated 어노테이션으로 검증기를 자동 적용 할 수 있다.
@InitBinder
public void init(WebDataBinder dataBinder) {
log.info("init binder {}", dataBinder); dataBinder.addValidators(itemValidator);
}
@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return "validation/v2/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}