4. 검증(1) - validation
1. 검증 요구사항
상품 관리 시스템에 새로운 요구사항을 추가해보자.
요구사항 : 검증 로직 추가
- 타입 검증 : 가격, 수량에 문자 들어가면 검증 오류 처리
- 필드 검증 : 상품명 (필수, 공백X), 가격(1000원 이상, 1백만원 이하), 수량(최대 9999)
- 특정 필드의 범위를 넘어서는 검증 (가격 * 수량 합 10,000원 이상)
컨트롤러의 중요한 역할중 하나는 HTTP 요청이 정상인지 검증하는 것이다.
-클라이언트 검증, 서버 검증
- 클라이언트 검증은 조작할 수 있으므로 보안에 취약하다.
- 서버만으로 검증하면, 즉각적인 고객 사용성이 부족해진다.
- 둘을 적절히 섞어서 사용하되, 최종적으로 서버 검증은 필수이다.
- API 방식을 사용하면 API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨주어야 한다.
2. 검증 직접 처리
사용자가 상품 등록 폼에서 정상 범위의 데이터를 입력하면, 서버에서는 검증 로직이 통과하고, 상품 저장 후 상품 상세 화면으로 redirect 한다.
상품 저장 검증 실패
1. 컨트롤러에서 add하면 addForm.html이 나온다.
2. POST로 add를 날려 Controller에 들어오는데 검증에 실패 된다.
3. Controller에서 검증 로직을 돌린다.
4. 상품 등록 Form을 다시 보여주어야 한다. (값을 유지한 상태로)
5. 상품 등록 실패 하면 Model에 기존 데이터 그대로 담고 검증 오류 결과도 같이 포함해서 보내야한다.
3. 검증 직접 처리- 개발
[ValidationItemControllerV1.java]
@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에 담아두고 어떤 필드에서 오류가 발생하였는지 구분하기 위해 오류 발생한 필드명을 key로 사용한다. 그 이후에 뷰에서 이 데이터를 사용하여 고객에게 오류 메시지를 출력해준다.
특정 필드의 범위를 넘어서는 검증 로직에서는 특정 필드를 넘는 오류를 처리해야 하기 때문에 globalError라는 key를 사용한다.
그리고 검증에서 오류 메시지가 하나라도 있으면 오류 메시지를 출력해야하기 때문에 model에 errors를 담고, 입력 폼 있는 뷰 템플릿으로 보낸다.
[V1/addForm.html]
글로벌 오류 메시지
<div th:if="${errors?.containsKey('globalError')}">
<p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
</div>
오류 메시지는 errors에 내용이 있는 경우에만 출력한다. 타임리프 th:if를 사용하게 되면 조건에 만족할 때만 해당 HTML 태그를 출력할 수 있다.
남은 문제는 뷰 템플릿에서 중복 처리가 많다는 것이다. 또한 타입 오류 처리가 안된다 즉 Item의 price, quantity 같은 숫자 필드는 타입이 Integer이므로 문자 타입으로 설정하는 것이 불가능한데 문자가 들어오면 오류가 발생한다. 그런데 이 오류가 스프링 MVC에서 컨트롤러에 진입하기도 전에 400 예외가 발생한다.
타입 오류가 나도 고객이 입력한 문자를 화면에 남겨야 한다. 즉 고객이 입력한 값도 어딘가에 별도로 관리가 되어야 한다는 뜻이다.
4. BindingResult1
[validationItemControllerV2.java]
@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
//반드시 ModelAttribute 객체 다음에 BindingResult가 와야함.(순서 중요)
//BindingResult가 errors 역할을 대신 해줌
//검증 로직 (field 오류는 FieldError 객체에 담아주면 된다.)
if(!StringUtils.hasText(item.getItemName())) {
//errors.put("itemName", "상품 이름은 필수입니다.");
bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
//public FieldError(String objectName, String field, String defaultMessage)
}
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));
//어떤 특정 필드가 아닌 글로벌 오류이기 때문에 ObjectError
}
}
//검증에 실패하면 다시 입력 폼으로
if (bindingResult.hasErrors()) { //bindingResult가 error가 있으면
log.info("errors = {}", bindingResult);
/*
model.addAttribute("errors", errors);
bindingResult는 modelAttribute에 안 담아도 된다. ->자동으로 view에 넘어가기 때문에 생략 가능)
*/
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 bindingResult 파라미터의 위치는 @ModelAttribute Item item 다음에 와야 한다.
BindingResult는 Model에 자동으로 포함된다.
FieldError 생성자
public FieldError(String objectName, String field, String defaultMessage) {}
필드에 오류가 있으면 FieldError 객체를 생성하여 bindingResult에 담아둔다.
-objectName : @ModelAttribute이름
-field : 오류 발생 필드 이름
-defaultMessage : 오류 기본 메시지
ObjectError 생성자
public ObjectError(String objectName, String defaultMessage) {}
특정 필드를 넘어서는 오류가 발생한다면 ObjectError객체를 생성하여 bindingResult에 담아둔다.
-objectName : @ModelAttribute이름
-defaultMessage : 오류 기본 메시지
[validation/v2/addForm.html]
<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>
<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" placeholder="가격을 입력하세요">
<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" placeholder="수량을 입력하세요">
<div class="field-error" th:errors="*{quantity}">수량 오류</div>
</div>
타임리프는 스프링 BindingResult를 활용해서 편리하게 검증 오류를 표현하는 기능을 제공한다.
#fields로는 BindingResult가 제공하는 검증 오류에 접근할 수 있다.
th : errors는 해당 필드에 오류가 있는 경우 태그를 출력한다. th : if의 편의 버전이다.
th:errorclass는 th:field에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.
검증, 오류 메시지 공식 메뉴얼은 밑의 주소를 참고하면 좋다.
https://www.thymeleaf.org/doc/tutorials/3.0/thymeleafspring.html#validation-and-error-messages
Tutorial: Thymeleaf + Spring
Preface This tutorial explains how Thymeleaf can be integrated with the Spring Framework, especially (but not only) Spring MVC. Note that Thymeleaf has integrations for both versions 3.x and 4.x of the Spring Framework, provided by two separate libraries c
www.thymeleaf.org
5. BindingResult2
스프링이 제공하는 검증 오류를 보관하는 객체로 검증 오류가 발생하면 여기에 보관을 하면 된다.
BindingResult가 있으면 @ModelAttribute에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출된다.
@ModelAttribute에 바인딩 시 타입 오류가 발생하면 BindingResult가 없을 땐 400 오류가 발생하면서 컨트롤러가 호출되지 않고 오류 페이지로 이동하지만 BindingResult가 있으면 오류 정보를 BindingResult에 담아 컨트롤러를 정상 호출한다.
BindingResult에 검증 오류를 적용하는 방법
- @ModelAttribute의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 FieldError 생성해서 BindingResult에 넣어준다.
- 개발자가 직접 넣어준다
- Validator 사용
BindingResult vs Errors
BindingResult는 인터페이스이고 Errors 인터페이스를 상속 받고 있다. 실제 넘어오는 구현체를 둘 다 구현하고 있어서 Errors를 사용해도 된다. Errors는 단순 오류 저장과 조회 기능을 제공하는데 BindingResult는 여기에 더해서 추가적인 기능들을 제공한다. 주로 관례상 BindingResult를 많이 사용한다.
6. FieldError, ObjectError
앞서 오류 메시지를 처리하는 방법을 알아보았는데 오류가 발생한 경우 고객이 입력한 내용이 모두 사라지는 문제가 아직 남아있다.
[ValidationItemControllerV2.java] - 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()) { //bindingResult가 error가 있으면
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 생성자
FieldError는 앞서 말한 생성자 말고도 하나를 더 제공하는데
public FieldError(String objectName, String field, @Nullable Object rejectedValue, boolean bindingFailure,
@Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage)
- ObjectName : 오류가 발생한 객체 이름
- field : 오류 필드
- rejectedValue : 사용자가 입력한 값(거절된 값)
- bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값(바인딩이 실패한 것은 아니라 false사용)
- codes : 메시지 코드
- arguments : 메시지에 사용하는 인자
- defaultMessage : 기본 오류 메시지
오류 발생시 사용자 입력 값 유지
FieldError는 오류 발생시 사용자 입력 값을 저장하는 기능을 제공한다.(rejectedValue)
가격에 숫자 아닌 문자가 입력된다면 가격은 Integer타입이라 문자를 보관할 수 있는 방법이 없다. 그래서 오류가 발생 했을 때 사용자 입력 값을 보관하는 별도의 방법이 필요하다. 그리고 검증 오류 발생시에 화면에 다시 출력하면 된다.
타임리프의 사용자 입력 값 유지
th:field = "*{price}"
th:field는 정상 상황에서는 모델 객체의 값을 사용하지만 오류 발생하면 FieldError에서 보관한 값을 사용해서 출력한다.
만약 타입 오류로 바인딩에 실패하면 스프링은 FieldError를 생성하면서 사용자가 입력한 값을 넣어두고 해당 오류를 BindingResult에 담아 컨트롤러를 호출한다. 그래서 타입 오류 같은 바인딩 실패시에도 사용자 오류 메시지를 정상 출력 가능하다.
7. 오류 코드와 메시지 처리1
오류메시지를 체계적으로 다루기 위해 messages.properties말고 errors.properties 별도 파일로 관리를 해보려한다.
application.properties에 밑의 설정을 추가하게 되면 messages.properties, errors.properties 두 파일 모두 인식한다.
spring.message.basename=messages,errors
[errors.properties]
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
[validationItemControllerV2.java] - addItemV3()
@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
log.info("objectName ={}", bindingResult.getObjectName());
log.info("target ={}", bindingResult.getTarget());
//검증 로직
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()) { //bindingResult가 error가 있으면
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}까지 허용합니다.
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));
}
codes는 required.item.itemName을 사용해서 메시지 코드를 지정한다. 메시지 코드는 하나가 아닌 배열로 여러 값을 전달할 수 있는데 순서대로 매칭해서 처음 매칭되는 메시지가 사용된다.
arguments는 Object[ ] {1000, 1000000}를 사용해 코드의 {0}, {1}로 치환할 값을 전달해준다.
8. 오류 코드와 메시지 처리 2
FieldError와 ObjectError는 다루기가 너무 번거롭고 오류 코드를 좀 더 자동화 해보려고 한다.
BindingResult는 검증해야 할 객체(target)바로 다음에 온다. 즉 본인이 검증해야 할 객체를 알고 있다.
BindingResult가 제공하는 rejectValue(), reject()를 사용하면 FieldError, ObjectError를 직접 생성하지 않고도 검증 오류를 다룰 수 있다.
[ValidationItemControllerV2.java] - addItemV4()
@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
log.info("objectName ={}", bindingResult.getObjectName());
log.info("target ={}", bindingResult.getTarget());
//검증 로직
if(!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required");
//new String[]{"required.item.itemName", "required"};
}
if(item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 10000000) {
bindingResult.rejectValue("price", "range", new Object[]{1000, 10000000}, 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()) { //bindingResult가 error가 있으면
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}";
}
rejectValue()
void rejectValue(@Nullable String field, String errorCode,
@Nullable Object[] errorArgs, @Nullable String defaultMessage);
reject()
void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
- field : 오류 필드명
- errorCode : 오류 코드(메시지에 등록된 코드 X,, messageResolver을 위한 오류 코드)
- errorArgs : 오류 메시지에서 {0}을 치환하기 위한 값
- defaultMessage : 오류 메시지 찾을 수 없을 때 사용하는 기본 메시지
- target을 이미 알고 있으므로 target에 대한 정보는 없어도 된다.
9. 오류 코드와 메시지 처리 3
오류 코드 만들 때 단순하게 만들 수도 자세히 만들 수도 있다. 단순하게 만들면 범용성이 좋아서 여러 곳에서 사용하기 좋지만 세밀하게 작성하기 어렵고 자세하게 만들면 범용성이 떨어진다.
그래서 가장 좋은 방법은 범용성으로 사용하다가, 세밀하게 작성해야 하는 경우 세밀한 내용 적용되게 메시지에 단계를 두는 것이다.
스프링의 MessageCodeResolver을 사용하면 편리하게 쓸 수 있다.
[MessageCodeResolverTest.java]
public class MessageCodesResolverTest {
MessageCodesResolver codesResolver = new DefaultMessageCodesResolver(); //검증 오류 코드로 메시지 코드 생성
//MessageCodeResolver 인터페이스, DefaultMessageCodesResolver는 기본 구현체
//Objcet 관련 Error
@Test
void messageCodesResolverObject(){
String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");
// for (String messageCode : messageCodes) {
// System.out.println("messageCode = " + messageCode);
// }
assertThat(messageCodes).containsExactly("required.item", "required");
}
//Field 관련 Error
@Test
void messageCodesResolverField() {
String[] messageCodes = codesResolver.resolveMessageCodes("required", "item", "itemName", String.class);
for (String messageCode : messageCodes) {
System.out.println("messageCode = " + messageCode);
}
//bindingResult.rejectValue("itemName", "required"); //rejectValue안에서 codesResolver 호출
assertThat(messageCodes).containsExactly(
"required.item.itemName",
"required.itemName",
"required.java.lang.String",
"required"
);
}
}
DefaultMEssageCodesResolver의 기본 메시지 생성 규칙은 다음과 같다.
객체 오류
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"
rejectValue()와 reject()는 내부에서 MessageCodesResolver를 사용하고 여기에서 메시지 코드를 생성한다.
타임리프 화면 랜더링할 때 th:errors가 실행되는데 만약 오류가 있다면 생성된 오류 메시지 코드를 순서대로 돌아가며 메시지를 찾지만 없으면 디폴트 메시지를 출력한다.
10. 오류 코드 메시지 처리 4
오류 코드 관리 전략
핵심은 구체적인 것에서 덜 구체적인 것으로!
[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} 까지 허용합니다.
itemName의 경우 required 검증 오류 메시지가 발생하면 다음과 같은 메시지가 생성된다.
1. required.item.itemName
2. required.itemName
3. required.java.lang.String
4. required
구체적인 것에서 덜 구체적인 순서대로 찾는다.
ValidationUtils
사용 전
if(!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required", "기본: 상품 이름은 필수입니다.");}
사용 후
다음과 같이 한줄로 가능, 제공하는 기능은 Empty, 공백 같은 단순 기능만 제공한다.
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
정리
1. rejectValue() 호출
2. MessageCodesResolver를 사용해 검증 오류 코드로 메시지 코드 생성
3. new FieldError() 생성하며 메시지 코드 보관
4. th:errors에서 메시지 코드들로 메시지 순서대로 메시지에서 찾아 노출시킴
검증 오류 코드는 개발자가 직접 설정한 오류 코드 (rejectValue() 직접 호출)와 스프링이 직접 검증 오류에 추가한 경우(타입 오류)가 있다.
price 필드에 "A"를 입력하고 로그를 보면 다음과 같이 4가지 메시지 코드가 입력되어 있다.
typeMismatch.item.price
typeMismatch.price
typeMismatch.java.lang.Integer
typeMismatch
이와 같이 스프링은 타입 오류가 발생하면 typeMismatch라는 오류 코드를 사용하고 이 오류 코드가 MessageCodeResolver를 통하면서 4가지 메시지 코드로 생성된 것이다.
error.properties에 추가를 한 후에 다시 확인해 보면 정상적으로 뜨는 것을 볼 수 있다.
#추가
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다
11. Validator 분리
컨트롤러에서 검증 로직이 차지하는 부분은 꽤 크다 그래서 복잡한 검증 로직을 별도로 분리해보고자 한다.
[ItemValidator.java]
@Component
public class ItemValidator implements Validator {
@Override
public boolean supports(Class<?> clazz){
return Item.class.isAssignableFrom(clazz);
//파라미터로 넘어오는 class가 item 지원이 되는지
//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() > 10000000) {
errors.rejectValue("price", "range", new Object[]{1000, 10000000}, 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);
}
}
}
}
ItemValidator 검증기 직접 호출하기
[ValidationControllerV2.java] - addItemV5()
private final ItemValidator itemValidator;
@PostMapping("/add")
public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
//ItemValidator 호출
itemValidator.validate(item,bindingResult); //검증기
//검증에 실패하면 다시 입력 폼으로
if (bindingResult.hasErrors()) { //bindingResult가 error가 있으면
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}";
}
ItemValidator를 스프링 빈으로 주입 받아서 직접 호출했다.
이번엔 Validator 인터페이스를 사용하여 검증기를 만들면 스프링의 추가적인 도움을 받을 수 있다.
WebDataBinder는 스프링 파라미터 바인딩 역할을 해주고 검증 기능도 내부에 포함한다.
WebDataBinder을 통해 사용하기
[ValidationControllerV2.java] - addItemV6()
@InitBinder
public void init(WebDataBinder dataBinder){
dataBinder.addValidators(itemValidator);
//컨트롤러가 호출이 될 때 마다 항상 먼저 불러져서 검증기를 넣어둠
//다른 컨트롤러에서는 적용 X
}
@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
//@Validated : Item에 대해서 자동으로 검증기가 수행되고 결과가 BindingResult에 담겨 있음
//검증에 실패하면 다시 입력 폼으로
if (bindingResult.hasErrors()) { //bindingResult가 error가 있으면
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}";
}
검증 대상 앞에 @Validated가 붙었는데 이는 검증기를 실행하라는 애노테이션이다. 이 애노테이션이 붙으면 WebDataBinder에 등록한 검증기를 찾아서 실행하는데 여러 검증기를 등록하게 되면 구분이 필요하여 validator의 supports()가 사용된다.
이곳에서는 supports(Item.class)가 호출이되고 결과가 true이므로 ItemValidator의 validate()가 호출된다.
'Spring > Spring boot' 카테고리의 다른 글
[JDBC] 데이터 접근 기술 - 테스트 (0) | 2023.09.10 |
---|---|
[JDBC] JdbcTemplate (0) | 2023.09.05 |
[JDBC] 트랜잭션 2 (0) | 2023.08.17 |
[JDBC] 트랜잭션 1 (1) | 2023.08.16 |
[JDBC] 커넥션풀, 데이터 소스 (1) | 2023.08.10 |