웹에서 입력받을 때 입력받은 값이 내가 원하는, 유효한 값인지 확인하고 원하는 값이 아니라면 사용자에게 알리는 방법.
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
if(!StringUtils.hasText(item.getItemName())) { //itemName에 글자가 없으면
bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
}
...
//특정 필드가 아닌 복합 룰 검증
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";
}
//검증 성공 로직
...
}
- 위처럼 BindingResult를 @ModelAttribute 뒤에서 받아야 한다. 순서가 중요하다!
- FieldError의 매개변수로는 objectName(@ModelAttribute의 이름), field(오류가 발생한 필드이름), defaultMessage가 있다.
- ObjectError는 글로벌오류에서 사용된다. 글로벌 오류는 오류가 발생한 필드이름이 하나가 아니므로 매개변수로 objectName, defaultMessage만 적는다.
그리고 뷰에서
<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>
...
이런 방식으로 사용하는데,
- th:errors는 th:if의 편의버전으로 에러가 있으면 출력한다.
- th:errorclass는 에러가 있으면 class에 field-error를 추가한다.
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)
파라미터 목록
- objectName : 오류가 발생한 객체 이름 field : 오류 필드
- rejectedValue : 사용자가 입력한 값(거절된 값)
- bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
- codes : 메시지 코드
- arguments : 메시지에서 사용하는 인자
- defaultMessage : 기본 오류 메시지
rejectedValue를 통해 사용자가 입력한 값이 검증을 통과하지 못했을 때 그대로 남아있게 할 수 있고, codes와 arguments를 통해 검증을 통과하지 못했을 때 오류 메시지를 통일할 수 있다.
오류 메시지 통일은 errors.properties를 이용하는 것이다. (errors.properties를 이용하기 전에 application.properties에 'spring.messages.basename=messages, errors'를 추가해주어야 한다.)
//errors.properties
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
하지만 FieldError와 ObjectError를 직접 사용하기는 번거로운데, 이때 BindingResult가 제공하는 getObjectName(), getTarget(), rejectValue(), reject()를 이용하면 된다.
reject는 Object이고, rejectValue는 Field이다. 따라서 글로벌 오류에는 reject를 사용하면 된다.
bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, new String[]{"required.item.itemName"}, null, null));
bindingResult.rejectValue("itemName", "required"); //required.item.itemName의 첫 단어만 적기
bindingResult.addError(new ObjectError("item", new String[]{"totalPriceMin"}, new Object[]{10000, resultPrice}, null));
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
위의 코드를 아래 코드로 바꾸어도 동일한 동작을 수행한다.
rejectValue()
- field : 오류 필드명
- errorCode : 오류 코드(이 오류 코드는 메시지에 등록된 코드가 아니다. 뒤에서 설명할 messageResolver를 위한 오류 코드이다.)
- errorArgs : 오류 메시지에서 {0} 을 치환하기 위한 값
- defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지
reject()
- errorCode : 오류 코드(이 오류 코드는 메시지에 등록된 코드가 아니다. 뒤에서 설명할 messageResolver를 위한 오류 코드이다.)
- errorArgs : 오류 메시지에서 {0} 을 치환하기 위한 값
- defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지
여기서 errorCode는 첫 단어만 입력해도 됐는데, 메시지에 등록된 코드가 아니다. messageResolver를 위한 오류 코드이다.
FieldError와는 달리 target을 입력하지 않아도 되는 이유는 BindingResult가 target을 이미 알고 있기 때문이다.
앞에서의 errors.properties의 오류 코드의 이름을 정한 이유
오류 코드를 만들 때 'required.item.itemName=상품 이름은 필수입니다.'처럼 자세히 쓸 수 있고, 'required=필수 값입니다.'처럼 단순하게 만들 수도 있다. 단순하게 만들면 범용성이 좋지만 세밀하게 작성할 수 없고, 자세하게 만들면 범용성이 좋지 않다. 가장 좋은 방법은 범용성으로 사용하다가 세밀하게 작성해야 하는 경우는 세밀한 내용이 적용되도록 단계를 두는 방식이다.
bindingResult.rejectValue("itemName", "required"); //required.item.itemName의 첫 단어만 적기
이 코드 같은 경우에는 errors.properties에 'required'라는 값만 있으면 'required=필수 값입니다.'를 선택하고, 'required.item.itemName=상품 이름은 필수입니다.'처럼 더 디테일한 이름이 있으면 이 메시지를 사용한다.
이런 기능은 MessageCodesResolver로 구현되어 있다.
MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();
@Test
void messageCodesResolverField() {
String[] messageCodes = codesResolver.resolveMessageCodes("required", "item", "itemName", String.class);
for (String messageCode : messageCodes) {
System.out.println("messageCode = " + messageCode);
}
}
이렇게 테스트를 돌리면 결과로
messageCode = required.item.itemName
messageCode = required.itemName
messageCode = required.java.lang.String
messageCode = required
이렇게 나온다. 앞에서 rejectValue()나 reject()를 호출하면, 알아서 codesResolver를 호출하고, FieldError의 오류코드를 입력하는 매개변수 자리에 messageCodes를 넣어 위 순서대로 호출되게 한다.
DefaultMessageCodesResolver의 기본 메시지 생성 규칙
객체 오류
객체 오류의 경우 다음 순서로 2가지 생성
1.: code + "." + object name
2.: code
예) 오류 코드: required, object name: item
1.: required.item
2.: required
필드 오류
필드 오류의 경우 다음 순서로 4가지 메시지 코드 생성
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"
#==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} 까지 허용합니다.
이렇게 errors.properties파일에 정의해두면 한 번에 에러 문자를 정의해 둘 수 있다.
price나 quantity는 Int나 Integer로 받아야하는데 사용자가 문자로 입력하게 되면 스프링 자체에서 오류 코드를 내보낸다.
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.
errors.properties에 이것을 추가하여 그 오류코드를 보기 좋게 바꿀 수 있다.
검증 분리-1
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
log.info("objectName = {}", bindingResult.getObjectName());
log.info("target= {}", bindingResult.getTarget());
//검증 로직
if(!StringUtils.hasText(item.getItemName())) { //itemName에 글자가 없으면
bindingResult.rejectValue("itemName", "required"); //required.item.itemName의 첫 단어만 적기
}
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() > 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}";
}
이렇게 두면 성공로직보다 검증로직이 훨씬 길어서 검증 로직을 따로 분리하는 것이 보기 좋다.
그래서
@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) { //Errors는 BindingResult의 부모 클래스
Item item = (Item) target;
//검증 로직
if(!StringUtils.hasText(item.getItemName())) { //itemName에 글자가 없으면
errors.rejectValue("itemName", "required"); //required.item.itemName의 첫 단어만 적기
}
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() > 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);
}
}
}
}
이렇게 따로 파일을 만들고,
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {
private final ItemRepository itemRepository;
private final ItemValidator itemValidator;
...
@PostMapping("/add")
public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
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}";
}
이렇게 바꿀 수 있다.
검증 분리-2
Controller에
@InitBinder
public void init(WebDataBinder dataBinder) {
dataBinder.addValidators(itemValidator);
}
를 추가하면 해당 컨트롤러에 자동으로 검증기를 적용할 수 있다.
@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
//검증에 실패하면 다시 입력 폼으로
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를 추가하면 WebDataBinder에 등록한 검증기를 찾아서 실행한다. 여러 검증기를 등록한다면 그 중에 어떤 검증기가 실행되어야 할지 구분이 필요하다. 이때 supports() 가 사용된다. 여기서는 supports(Item.class) 호출되고, 결과가 true 이므로 ItemValidator 의 validate() 가 호출된다.
'Spring' 카테고리의 다른 글
쿠키와 세션 (1) | 2024.01.08 |
---|---|
검증2 - Bean Validation (0) | 2024.01.06 |
메시지, 국제화 (0) | 2024.01.01 |
thymeleaf-1 (0) | 2023.12.31 |
로깅 (0) | 2023.12.24 |