새로운 프로젝트를 설정 후 검증(Validation) 실습을 해보겠다.
검증 로직을 추가해달라는 요구 사항이 나왔다.
웹 서비스는 폼 입력시 오류가 발생하면 고객이 입력한 데이터를 유지한 상태로 어떤 오류가 발생했는지 알려줘야 사용자를 잃지 않을 수 있다.
컨트롤러의 중요한 역할 중 하나는 HTTP 요청이 정상인지 검증하는 것이다.
<상품 저장 성공 Process>
0. Get /add => 1. POST /add => 2. Redirect /item/{id} => 3. GET /items/{id}
: 상품 등록 폼 => : 상품 저장 => : 홈 이동 => : 상품 상세
<상품 저장 실패 Process>
0. Get /add => 1. POST /add => 검증 실패!
: 상품 등록 폼 => : 상품 저장 => : Model에 검증 오류 결과 포함 => : 상품 등록 폼
검증에 실패한 경우 고객에게 다시 상품 등록 폼을 보여주고, 어떤 값을 잘못 입력했는지 알려주어야 한다.
<검증 직접 처리 개발>
ValidationItemControllerV1 - addItem() 수정
@PostMapping("/add")
public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) {
// 검증 오류 결과를 보관
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 resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice);
}
}
// 검증에 실패하면 다시 입력 폼으로
if (!errors.isEmpty()) {
log.info("errors = {}", errors);
model.addAttribute("errors", errors);
return "validation/v1/addForm";
}
// 성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v1/items/{itemId}";
}
검증시 오류가 발생하면, 어떤 오류가 발생했는지 정보를 담아둔다.
errors.put("itemName", "상품 이름은 필수입니다.");
}
각 검증 로직에 어떤 오류가 발생했는지 구분하기 위해 오류가 발생한 필드명을 key로 사용한다.
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice);
}
}
특정 필드가 아니라 복합 필드 오류인 경우, 필드 이름을 넣을 수 없기 때문에 globalError라는 key를 사용한다.
addForm.html에 오류 메시지 영역 추가
<div th:if="${errors?.containsKey('globalError')}">
<p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
</div>
오류 메시지는 errors에 내용이 있을 때만 출력하면 된다. 타임리프의 th:if 를 사용해서 조건에 만족할 때만 해당 HTML 태그를 출력한다.
여기서 errors?.는 errors가 null 일때 NullPointerException이 발생하는 대신, null을 반환하는 문법이다. th:if에서 null은 실패로 처리되므로 오류 메시지가 처리되지 않는다.(SpringEL문법)
정리
만약 검증 오류가 발생하면 고객이 입력한 데이터를 유지하며, 입력 폼을 다시 보여준다.
검증 오류들을 고객에게 안내해서 오류가 발생한 부분을 다시 입력할 수 있게 한다.
문제점
1. 뷰 템플릿에 중복 처리가 많다.
2. 타입 오류 처리가 안된다. 숫자 타입에 문자가 들어오면 오류가 발생한다. 이러한 오류는 스프링 MVC의 컨트롤러 진입 전에 예외가 발생해서 컨트롤러가 호출되지도 않고, 400 예외가 발생하면서 오류 페이지를 띄워준다.
3. 타입 오류가 발생해도 고객이 입력한 문자를 화면에 남겨야 한다. 만약 컨트롤러가 호출되더라도 Integer타입인 price에 문자가 입력되었다면 문자를 보관할 수 없다.
=> 결국 고객이 입력한 값도 어딘가에 별도로 관리되어야 한다!
이를 해결하기 위해 스프링이 제공하는 검증 방법을 알아보자.
<BindingResult>
ValidationItemControllerV2 - addItemV1
@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
// 검증 로직
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 resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
}
}
// 검증에 실패하면 다시 입력 폼으로
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}";
}
*BindingResult bindingResult의 위치는 @ModelAttribute Item item 다음에 와야 한다!
Field 오류 - FieldError
bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
}
public FieldError(String objectName, String field, String defaultMessage) {}
필드에 오류가 있으면 FieldError 객체를 생성해서 bindingResult에 담아두면 된다.
*objectName: @ModelAttribute 이름
*field: 오류가 발생한 필드 이름
*defaultMessage: 오류 기본 메시지
Global 오류 - ObjectError
bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
}
public ObjectError(String objectName, String defaultMessage() {}
validation/v2/addForm.html 수정
타임리프는 스프링의 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="itemName" th:text="#{label.item.itemName}">상품명</label>
<input type="text" id="itemName" th:field="*{itemName}"
th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요">
<div class="field-error" th:errors="*{itemName}">상품명 오류</div>
</div>
...
</form>
* #fields: BindingResult가 제공하는 검증 오류에 접근할 수 있다.
* th:erros : 해당 필드에 오류가 있는 경우 태그를 출력한다.(th:if의 편의 버전)
* th:errorclass : th:field에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.
위에서 사용하면서 보았듯이, BindingResult는 스프링이 제공하는 검증 오류를 보관하는 객체이다.
BindingResult가 있으면 @ModelAttribute에 데이터 바인딩시 오류가 발생해도 컨트롤러가 호출된다.
이제 오류 발생시 고객이 입력한 내용이 모두 사라지는 것만 해결하면 된다. 해결해보자.
<FieldError, ObjectError>
ValidationItemControllerV2 - addItemV2 추가
@PostMapping("/add")
public String addItemV2(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
// 검증 로직
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 까지 허용합니다."));
}
// 특정 필드가 아닌 복합 룰 검증
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.addError(new ObjectError("item", null, null, "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
}
}
// 검증에 실패하면 다시 입력 폼으로
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}";
}
FieldError은 두 가지 생성자를 제공한다.
public FieldError(String objectName, String field, String defaultMessage);
public FieldError(String objectName, String field, @Nullable Object rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage)
FieldError, ObjectError의 생성자는 오류 코드로 메시지를 찾기 위해 codes, arguments를 제공한다.
errors.properties라는 별도의 파일을 추가해 application에 설정 후 에러 메시지를 관리해보자.
ValidationItemControllerV2 - addItemV3 추가
@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
// 검증 로직
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() >= 9999) {
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));
}
}
// 검증에 실패하면 다시 입력 폼으로
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}";
}
//range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
new FieldError("item", "price", item.getPrice(), false, new String[] {"range.item.price"}, new Object[]{1000, 1000000}
* codes: required.item.itemName을 사용해서 메시지 코드를 지정한다. 배열로 여러 값을 전달할 수 있는데, 순서대로 매칭해서 처음 매칭되는 메시지가 사용된다.
* arguments: Object[]{1000, 1000000}를 사용해서 {0}, {1}로 치환할 값을 전달한다.
하지만, FieldError, ObjectError는 다루기 너무 번거롭다. 잘 쓰지 않을 것 같다.
좀 더 자동화할 수 없을까?
BindingResult는 검증해야할 객체인 target 바로 다음에 온다. 따라서 BindingResult는 이미 본인이 검증해야할 객체ㅔ인 target을 알고 있다.
BindingResult가 제공하는 rejectValue(), reject()를 사용하면 깔끔하게 검증 오류를 다룰 수 있다.
이제 기존 코드를 단순화해보자.
ValidationItemControllerV2 - addItemV4 추가
@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
// 검증 로직
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[]{100, 1000000}, null);
}
if (item.getQuantity() == null || item.getQuantity() >= 9999) {
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);
}
}
// 검증에 실패하면 다시 입력 폼으로
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}";
}
void rejectValue(@Nullable String field, String errorCode,
@Nullable Object[] errorArgs, @Nullable String defaultMessage);
*field : 오류 필드명
*errorCode : 오류 코드(이 오류 코드는 메시지에 등록된 코드가 아니다. 뒤에서 설명할 messageResolver를 위한 오류 코드이다.)
*errorArgs : 오류 메시지에서 `{0}` 을 치환하기 위한 값
*defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지
BindingResult는 target을 이미 알고 있기 때문에, 검증 객체에 대한 정보는 없어도 된다.
스프링에서 오류 코드는 MessageCodesResolver라는 것으로 지원한다.
MessageCodesResolver는 required.item.itemName처럼 구체적인 것을 먼저 만들어주고, required처럼 덜 구체적인 것을 가장 나중에 만든다.
이렇게 하면 메시지와 관련된 공통 전략을 편리하게 도입할 수 있다.
크게 중요하지 않은 메시지는 범용성있게 작성하고, 정말 중요한 메시지는 구체적으로 적어서 사용하는 방식이 더 효과적이다.
이런 오류 코드 전략을 도입해본다.
errors.properties에 아래 코드를 추가한다.
#required.item.itemName=상품 이름은 필수입니다.
#range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
#max.item.quantity=수량은 최대 {0} 까지 허용합니다.
#totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
#==ObjectError==
#Level1
totalPriceMin.item=상품의 가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
#Level2 - 생략
totalPriceMin=전체 가격은 {0}원 이상이어야 합니다. 현재 값 = {1}
#==FieldError==
#Level1
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
#Level2 - 생략
#Level3
required.java.lang.String = 필수 문자입니다.
required.java.lang.Integer = 필수 숫자입니다.
min.java.lang.String = {0} 이상의 문자를 입력해주세요.
min.java.lang.Integer = {0} 이상의 숫자를 입력해주세요.
range.java.lang.String = {0} ~ {1} 까지의 문자를 입력해주세요.
range.java.lang.Integer = {0} ~ {1} 까지의 숫자를 입력해주세요.
max.java.lang.String = {0} 까지의 문자를 허용합니다.
max.java.lang.Integer = {0} 까지의 숫자를 허용합니다.
#Level4
required = 필수 값 입니다.
min= {0} 이상이어야 합니다.
range= {0} ~ {1} 범위를 허용합니다.
max= {0} 까지 허용합니다.
#추가
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.
검증 오류 메시지가 발생하면 우선순위대로 메시지가 생성된다.
이렇게 생성된 메시지 코드를 기반으로 순서대로 MessageSource에서 메시지를 찾는다.
<Validator 분리>
컨트롤러에 검증 로직이 차지하는 부분은 매우 크다. 이런 경우 별도의 클래스로 역할을 분리하는 것이 좋다. 그리고 이렇게 분리한 검증 로직을 재사용할 수도 있다.
ItemValidator 작성
package hello.itemservice.web.validation;
import hello.itemservice.domain.item.Item;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;
@Component
public class ItemValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return Item.class.isAssignableFrom(clazz);
//item == clazz
//item == subItem
}
@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[]{100, 1000000}, null);
}
if (item.getQuantity() == null || item.getQuantity() >= 9999) {
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);
}
}
}
}
스프링은 검증을 체계적으로 제공하기 위해 인터페이스를 제공한다.
public interface Validator {
boolean supports(Class<?> clazz);
void validate(Object target, Errors errors);
}
*supports() {} : 해당 검증기를 지원하는 여부 확인
*validate(Object target, Errors errors) : 검증 대상 객체와 BindingResult
ValidationItemControllerV2 - addItemV5 추가(ItemValidator 직접 호출)
@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 인터페이스를 사용해서 검증기를 만들면 스프링의 추가적인 도움을 받을 수 있다.
WebDataBinder 사용
WebDataBinder는 스프링의 파라미터 바인딩의 역할을 해주고 검증 기능도 내부에 포함한다.
ValidationItemControllerV2에 추가
@InitBinder
public void init(WebDataBinder dataBinder) {
dataBinder.addValidators(itemValidator);
}
WebDataBinder에 검증기를 추가하면 해당 컨트롤러에서는 검증기를 자동으로 적용할 수 있다.
ValidationItemControllerV2 - addItemV6 추가(@Validated 적용)
@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}";
}
validator를 직접 호출하는 부분이 사라지고, 검증 대상 앞에 @Validated가 붙었다.
@Validated
검증기를 실행하라는 애노테이션이다.
WebDataBinder에 등록한 검증기를 찾아서 실행한다. 여러 검증기를 등록하면 그 중에 어떤 검증기가 실행되어야 할지 구분이 필요하다. 이때 supports()가 사용된다.(여기서는 supports(Item.class)가 호출된다.)
'공부 > Spring' 카테고리의 다른 글
[Spring MVC](11) 로그인 처리1 - 쿠키 & 세션 (0) | 2024.02.17 |
---|---|
[Spring MVC](10) Bean Validator (0) | 2024.01.28 |
[Spring MVC](8) 웹 페이지 만들기 (0) | 2024.01.22 |
[Spring MVC](7) 기본 기능 - 요청 데이터 2, 응답 (1) | 2024.01.22 |
[Spring MVC](6) 기본 기능 - 요청 매핑, 요청 데이터 1 (0) | 2024.01.22 |