검증 요구사항
지금까지 만든 웹 애플리케이션은 폼 입력시 숫자를 문자로 작성하거나해서 검증 오류가 발생하면 오류 화면으로 바로 이동한다.
이렇게 되면 사용자는 처음부터 해당 폼으로 다시 이동해서 입력을 해야 한다.
아마도 이런 서비스라면 사용자는 금방 떠나버릴 것이다.
웹 서비스는 폼 입력시 오류가 발생하면, 고객이 입력한 데이터를 유지한 상태로 어떤 오류가 발생했는지 친절하게 알려주어야 한다.
에러뜨면 이동하는 화면은 아래 페이지를 말하는것이다.
컨트롤러의 중요한 역할중 하나는 HTTP 요청이 정상인지 검증하는 것이다.
그리고 정상 로직보다 이런 검증 로직을 잘 개발하는 것이 어쩌면 더 어려울 수 있다.
검증 직접 처리
고객이 상품 등록 폼에서 상품명을 입력하지 않거나, 가격, 수량 등이 너무 작거나 커서
검증 범위를 넘어서면, 서버 검증 로직이 실패해야 한다.
이렇게 검증에 실패한 경우 고객에게 다시 상품 등록 폼을 보여주고,
어떤 값을 잘못 입력했는지 친절하게 알려주어야 한다.
(모델에 검증오류 결과를 포함시키고, 다시 상품 등록폼으로 리다이렉트 해줘야한다)
-> 모델에 입력된 데이터는 다시 다 담고, 오류메시지도 담는다 (입력된 값을 다시 입력하지않게끔하기위해서)
프로젝트 (상품 등록 검증 처리) V1
상품 등록 컨트롤러
@PostMapping("/add")
public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) {
//검증 오류결과를 저장할 객체 생성
Map<String, String> errors = new HashMap<>();
//검증 로직
if (!StringUtils.hasText(item.getItemName())) { //StringUtils는 스프링꺼를 사용해야한다.
//itemName에 글자가 없다면 검증오류 저장객체에 값 추가
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", "수량은 최대 9999 까지 허용합니다.");
}
//특정 필드가 아닌 복합 룰 검증
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
errors.put("globalError", "가격 * 수량의 합은 10000원 이상이어야 합니다. 현재값 = " + resultPrice);
}
}
//검증에 실패하면 다시 입력 폼으로
if (!errors.isEmpty()) { //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}";
}
조건문을 통해서 들어온 값에 대한 검증을 진행한다.
검증 실패시 error 객체에 값을 담고 error를 모델에 담은 후 다시 입력폼을 렌더링한다.
이렇게 까지만하면 검증오류가 발생시 입력폼으로 다시 돌아가기는 하지만 에러메시지를 보여주진않는다.
에러메시지를 보여주기 위해 템플릿을 수정한다.
글로벌 에러 메시지 처리
<div th:if="${errors?.containsKey('globalError')}">
<p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
</div>
errors? 라는 문법으로 error가 존재하면 ?가 사라지고 erros.contatinsKey()가 된다고 생각하면된다.
error가 존재하지않다면 null이 되버려서 th:if = null 이 되서 해당 태그가 실행되지 않게된다.
이것은 SpringEL 문법이고 아래 문서를 참고한다.
필드 오류 처리 방법 1
<input type="text" id="itemName" th:field="*{itemName}"
th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control' "
class="form-control"
placeholder="이름을 입력하세요">
위에서 사용한 SpringEL문법과 삼항연산자의 조합으로 class속성의 값을 동적으로 처리하고있다.
만약 error가 존재하고 그 안에 itemName이라는 키가 존재한다면 class속성을 form-control field-error로 하고
아니라면 class속성을 form-control로 처리하는 부분이다.
필드 오류 처리 방법 2
<input type="text" th:classappend="${errors?.containsKey('itemName')} ? 'fielderror' : _"
class="form-control">
classappend를 이용하는 방법이다.
현재 class 속성뒤에 값을 붙이는 속성인데
만약 error가 존재하고 그 안에 itemName이라는 키가 존재한다면 뒤에 fielderror 붙여주고
만약 값이 없으면 _ (No-Operation)을 사용해서 아무것도 하지 않는다
필드 오류 처리 메시지
<div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}">
상품명 오류
</div>
결과
@ModelAttribute Item item
@ModelAttribute때문에 입력된 값은 바로 다시 모델에 담기고,
에러가 발생한것을 에러객체에 저장해서 모델에 담아 넘겨서 처리했기 가능했다.
남은 문제점
뷰 템플릿에서 중복 처리가 많다. 뭔가 비슷하다.
타입 오류 처리가 안된다.
Item 의 price , quantity 같은 숫자 필드는 타입이 Integer 이므로 문자 타입으로 설정하는 것이 불가능하다.
숫자 타입에 문자가 들어오면 오류가 발생한다. 그런데 이러한 오류는 스프링MVC에서 컨트롤러에 진입하기도 전에 예외가 발생하기 때문에, 컨트롤러가 호출되지도 않고, 400 예외가 발생하면서 오류 페이지를 띄워준다.
Item 의 price에 문자를 입력하는 것 처럼 타입 오류가 발생해도 고객이 입력한 문자를 화면에 남겨야 한다.
만약 컨트롤러가 호출된다고 가정해도 Item 의 price 는 Integer 이므로 문자를 보관할 수가 없다.
결국 문자는 바인딩이 불가능하므로 고객이 입력한 문자가 사라지게 되고,
고객은 본인이 어떤 내용을 입력해서 오류가 발생했는지 이해하기 어렵다
결국 고객이 입력한 값도 어딘가에 별도로 관리가 되어야 한다.
프로젝트 (상품 등록 검증 처리) V2
지금부터 스프링이 제공하는 검증 오류 처리 방법을 알아보자. 여기서 핵심은 BindingResult이다
프로젝트 V2 의 addItemV1
@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
//BindingResult는 이전에 했던 errors처럼 에러를 저장하는 용도.
//객체의 필드에서 발생하는 필드단위 에러는 FiledError객체를 만들어 저장한다.
//검증 로직
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item","itemName","상품 이름은 필수입니다."));
//필드단위 에러는 스프링이 제공하는 FiedlError 객체를 만들어 BindingResult에 저장한다.
//오브젝트이름, 필드명, 메시지를 넣어 생성해주면 된다.
}
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", "수량은 최대 9999 까지 허용합니다."));
}
//특정 필드가 아닌 복합 룰 검증
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.addError(new ObjectError("item","가격 * 수량의 합은 10000원 이상이어야 합니다. 현재값 = " + resultPrice));
// 필드오류가 아닐경우 ObjectError를 생성해서 넣어준다 (오브젝트이름,메시지)
}
}
//검증에 실패하면 다시 입력 폼으로
if (bindingResult.hasErrors()) { // bindingResult에 에러가 담겨있다면
log.info("erros = {}", bindingResult);
//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
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, ~
앞에 객체인 Item에 데이터가 바인딩될때 발생하는 오류가 저장되는 객체이다.
아래에서 자세히 설명한다.
필드 에러
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item","itemName","상품 이름은 필수입니다."));
//필드단위 에러는 스프링이 제공하는 FiedlError 객체를 만들어 BindingResult에 저장한다.
//오브젝트이름, 필드명, 메시지를 넣어 생성해주면 된다.
}
필드에러 생성자
public FieldError(String objectName, String field, String defaultMessage) {}
필드에러는 오브젝트에러의 자식이다.
그래서 addError가 동작가능하다.
글로벌 오류 - Object Error (global error)
//특정 필드가 아닌 복합 룰 검증
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.addError(new ObjectError("item","가격 * 수량의 합은 10000원 이상이어야 합니다. 현재값 = " + resultPrice));
// 필드오류가 아닐경우 ObjectError를 생성해서 넣어준다 (오브젝트이름,메시지)
}
}
ObjectError 생성자 요약
public ObjectError(String objectName, String defaultMessage) {}
Object Error는 item객체의 에러인데 필드에러를 넘어서는 복합적인 에러를 만들때 생성해서 bindingResult에 담아둔다.
뷰 템플릿수정
BindingResult, FieldError , ObjectError를 사용하니까 뷰 템플릿도 수정이 필요하다.
글로벌 오류 처리 - Object Error
<div th:if="${#fields.hasGlobalErrors()}">
<p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p>
</div>
#fields를 이용해서 BindingResult가 제공하는 검증오류에 접근 할 수 있다.
GlobalErros는 Object Error를 의미한다.
필드 오류 처리
<div class="field-error" th:erros="*{itemName}">
상품명 오류
</div>
bindingResult에 필드 에러를 담을 때
bindingResult.addError(new FieldError("item","itemName","상품 이름은 필수입니다."));
필드명을 담았기 때문에
th:errors="*{필드명}" 으로 해당 필드에러가 들어왔는지 체크하고 있으면 해당 태그가 동작한다.
th:errors의 설명은 다음과 같다. ( 반복문을 통해서 해당 필드의 오류들을 다 가져오는것을 대신하는 속성이다.
해당 필드의 오류가 있으면 반복을 통해 가져온다)
https://www.thymeleaf.org/doc/tutorials/3.1/thymeleafspring.html#field-errors
Instead of iterating, we could have also used th:errors, a specialized attribute which builds a list
with all the errors for the specified selector, separated by <br />:
<input type="text" id="itemName" th:field="*{itemName}"
th:errorclass="field-error"
class="form-control"
placeholder="이름을 입력하세요">
에러 발생시 class 속성 바꿔주는 로직을
th:errorclass를 이용해서 수정하였다.
th:field에 설정된 필드명에 대한 에러가 BindingResult에 있을시 기존 class에 errorclass를 붙여준다!
th:errorclass="field-error"
class="form-control"
이런식으로 구성하면
필드 에러 처리 전체코드
<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>
th:field 의 *{}는 Item 객체의 quantity이고
th:erros의 *{}는 BindingResult 객체의 quantity이다.
검증과 오류 메시지 공식 메뉴얼
https://www.thymeleaf.org/doc/tutorials/3.0/thymeleafspring.html#validation-and-error-messages
결과
BindingResult에 아무에러도 없을테니까 에러메지시가 안보인다.
아무값없이 저장을 누르면 BindingResult에 에러들이 들어가고 다시 상품등록 뷰 템플릿이 렌더링 되면서 에러메시지들이 보인다.
BindingResult
스프링이 제공하는 검증 오류를 보관하는 객체이다.
검증 오류가 발생하면 여기에 보관하면 된다.
BindingResult가 있으면 @ModelAttribute에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출된다!
BindingResult가 있으면 오류정보(FieldError)를 BindingResult에 담아서 컨트롤러를 정상 호출한다!
모델에 담긴 BindingResult를 확인하기 위해 모델 log를 찍어봤다.
model = org.springframework.validation.BindingResult.item=org.springframework.validation.BeanPropertyBindingResult: 1 errors
로그가 길어서 Item 객체관련 로그는 지웠고, BindingResult관련 로그이다.
위에서 했던 프로젝트를 실행시키고 price에 숫자가 아닌 문자를 넣었을때
우리가 추가해준 price에 대한 필드에러
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
bindingResult.addError(new FieldError("item","price", "가격은 1,000 ~ 1,000,000까지 허용 됩니다."));
}
도 출력되고
타입 오류에 대한 예외메시지 또한 출력되는 모습이다.
그 이유는
<div class="field-error" th:errors="*{price}">
가격 오류
</div>
price라는 필드에 에러가 생기면 에러메시지가 보이도록 했기 때문이다. (price 필드에 생긴 모든 에러를 보여주는것같다)
소스를 보면
<div class="field-error">Failed to convert property value of type java.lang.String to required type java.lang.Integer for property price; nested exception is java.lang.NumberFormatException: For input string: "qqqq"
<br />가격은 1,000 ~ 1,000,000까지 허용 됩니다.</div>
이런식으로 되어있다.
타입이 안맞아서 에러가 발생해서 에러정보를 가지고 FieldError를 만들어서 BindingResult에 담았고
타입이 안맞으니까 저장이 안되서 필드가 null이기 때문에 에러가 BindingResult에 저장있는 상태이다!
BindingResult에 검증 오류를 적용하는 3가지 방법
1. @ModelAttribute 의 객체에 타입 오류 등으로 바인딩이 실패하는 경우
스프링이 FieldError 생성해서 BindingResult 에 넣어준다. (타입오류같은 검증오류같은경우! -> 진짜 객체에 바인딩하려고했는데 발생하는 오류들 == 바인딩 실패)
2. 개발자가 직접 넣어준다. ( 비즈니스적인 검증 오류들)
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
bindingResult.addError(new FieldError("item","price", "가격은 1,000 ~ 1,000,000까지 허용 됩니다."));
}
이것처럼
3. Validator 사용 이것은 뒤에서 설명
BindingResult와 Errors
BindingResult 는 인터페이스이고, Errors 인터페이스를 상속받고 있다.
(인터페이스가 인터페이스를 상속받을때는 extends)
Errors 인터페이스는 단순한 오류 저장과 조회 기능을 제공한다.
BindingResult 는 여기에 더해서 추가적인 기능들을 제공한다.
BindingResult를 쓸때 실제 넘어오는 구현체는 BeanPropertyBindingResult 라는 것인데,
BeanPropertyBindingResult는 Errors,BidingResult를 모두 구현하고있다.
BindingResult 대신에 Errors를 사용해서 BeanPropertyBindingResult를 받아도 된다. (Error는 최상위 인터페이스니까)
public String addItemV1(@ModelAttribute Item item, Errors bindingResult,
이런식으로! ,하지만 Erros는 BindingResult과는 다르게 사용할 수 있는 기능이 별로없다(addError()가 없음)
addError() 도 BindingResult 가 제공하므로 여기서는 BindingResult 를 사용하자.
주로 관례상 BindingResult 를 많이 사용한다.
FieldError, ObjectError
상품등록폼에서 값을 적고, 등록버튼을 눌렀을때
값이 검증에 실패한다면 입력한 값이 사라져버린다.
예를들어)
가격은 최소 1000원이여야하는데 1을 적고 저장을 누르면
에러메시지가 보이면서, 가격에 있던 데이터가 사라졌다.
오류를 발생한 값까지 유지되도록 해보자.
FieldError를 만드는 생성자
생성자를 보면 2가지 방법이 있다. 2번째 방법으로 수정한다.
1.
public FieldError(String objectName, String field, String defaultMessage);
예시)
bindingResult.addError(new FieldError("item","itemName","상품 이름은 필수입니다."));
2.
public FieldError(String objectName, String field, @Nullable Object rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage);
예시)
bindingResult.addError(new FieldError("item","itemName",item.getItemName(),false,null,null,"상품 이름은 필수입니다."));
bindingFailure는 타입오류같이 바인딩을 하려고했을때 실패한건지
아니면 비즈니스로직상 검증에 실패한건지를 묻는것이다.
code와 arguments는 defaultMessage를 메소드화해서 대체하는것인데 뒤에서 설명한다.
FieldError를 통해 사용자 입력값을 보관!
(FieldError를 이용해서 바인딩 오류가 발생하거나, 검증실패한 입력값에 대해 보관한다)
사용자의 입력 데이터가 컨트롤러의 @ModelAttribute 에 바인딩되는 시점에 오류가 발생하면
모델 객체에 사용자 입력 값을 유지하기 어렵다.
예를 들어서
가격에 숫자가 아닌 문자가 입력된다면 가격은 Integer 타입이므로 문자를 보관할 수 있는 방법이 없다.
그래서 오류가 발생한 경우 사용자 입력 값을 보관하는 별도의 방법이 필요하다.
그리고 이렇게 보관한 사용자 입력 값을 검증 오류 발생시 화면에 다시 출력하면 된다.
FieldError 는 오류 발생시 사용자 입력 값을 저장하는 기능을 제공한다.
여기서 rejectedValue 가 바로 오류 발생시 사용자 입력 값을 저장하는 필드다.
(상품등록 뷰에서 form으로 인해 데이터가 쿼리파라미터로 들어올것이고 만약 바인딩오류 또는 검증실패할 경우
FieldError를 만들텐데 그때 전달된rejectedValue( item.getItemName()) 으로 인해
프로퍼티 이름을 알게되고 getParam("프로퍼티명")을 통해 쿼리파라미터에 있는 값을 가져와 저장한다.(바인딩 오류라면 스프링이 FieldError를 만들고 rejectedValue를 채울때 앞에처럼동작 , 그리고 bindingFailure는 true로 채울것이다.)
검증실패라면 그냥 item.getItemName()을해서 프로퍼티를 가져와서 저장할것이다.)
bindingFailure는 타입 오류 같은 바인딩이 실패했는지 여부를 적어주면 된다.
여기서는 바인딩이 실패한 것은 아니기 때문에 false를 사용한다.
Object Error (global error)
Object Error는 어떤객체의 에러인데 그 객체의 필드에러를 넘어서는
복합적인 에러를 만들때 생성해서 bindingResult에 담아둔다.
얘는 필드로 넘어올때의 검증이 아니고, 검증된 필드값에 대한 복합적인 검증이므로
rejectedValue나 bindingFailure 이런 매개변수가 당연히 없다.
code와 arguments만 추가로 존재
bindingResult.addError(new ObjectError("item",null,null,"가격 * 수량의 합은 10000원 이상이어야 합니다. 현재값 = " + resultPrice));
테스트
이렇게 까지만 해놓고 테스트를 해보면
값이 잘 유지되는것을 확인할 수 있다.
현재 내부 코드는 다음과 같다.
프로젝트 V2 의 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,"수량은 최대 9999 까지 허용합니다."));
}
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.addError(new ObjectError("item",null,null,"가격 * 수량의 합은 10000원 이상이어야 합니다. 현재값 = " + resultPrice));
}
}
if (bindingResult.hasErrors()) {
log.info("erros = {}", bindingResult);
log.info("model ={}", model);
return "validation/v2/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
타임리프의 사용자 입력값 유지
th:field="*{price}"
타임리프의 th:field 는 매우 똑똑하게 동작하는데,
정상 상황에는 모델 객체의 값을 사용하지만,
오류가 발생하면 FieldError에서 보관한 값을 사용해서 값을 출력한다
new FieldError("item","price",item.getPrice(),false,null,null,"가격은 1,000 ~ 1,000,000까지 허용 됩니다.")
price로 필드명이 등록된 FieldError에 보관된 값(rejectedValue) 을 이용해서 출력을 해줄것이다.
이전에 값이 비었던 이유는 rejectedValue 값을 넣어주지않았기 때문에 오류 상황시 비어있는값이 출력된것이다.
오류코드와 메시지 처리 1
오류 메시지를 체계적으로 다루어보자.
FieldError , ObjectError 의 생성자는 codes , arguments 를 제공한다.
이것은 오류 발생시 오류 코드로 메시지를 찾기 위해 사용된다.
errors 메시지 파일 생성
messages.properties 를 사용해도 되지만,오류 메시지를 구분하기 쉽게 errors.properties 라는 별도의 파일로 관리해보자
먼저 스프링 부트가 해당 메시지 파일을 인식할 수 있게 다음 설정을 추가한다.
spring.messages.basename=messages,errors
이렇게하면 messages.properties , errors.properties 두 파일을 모두 인식한다.
(생략하면 messages.properties 를 기본으로 인식한다, 그래서 코드를 생략해도되는데 여기서는 errors를 추가해야하므로 넣어준다. )
errors.properties 추가
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
{0}은 첫번째 인자 {1}은 두번째 전달인자
FieldError , ObjectError를 생성할때 코드는 String배열, 인자는 Object배열로 넣어줘야한다.
코드는 String배열로 넣는 이유는 첫번째 인자를 메시지에서 못찾으면 두번째인자로 ... 의 방식으로 동작한다.
new String[]{"required.item.itemName","errors.default"}
그래서 두번쨰를 default값으로 넣어놓으면 첫번째 메시지를 못찾았을때 두번쨰꺼 메시지를 찾는다.
만약 다 없다면 오류페이지(화이트라벨에러페이지)로 가게된다.
프로젝트 V2 의 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("erros = {}", bindingResult);
log.info("model ={}", model);
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
FieldError , ObjectError 는 다루기 너무 번거롭다.
오류 코드도 좀 더 자동화 할 수 있지 않을까? 예) item.itemName 처럼?
컨트롤러에서 BindingResult 는 검증해야 할 객체인 target 바로 다음에 온다.
public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult
따라서 BindingResult 는 이미 본인이 검증해야 할 객체인 target 을 알고 있다.
FieldError 객체를 만들때 매번 바인딩할 객체이름을 적어주었는데 BindingResult가 이미 검증해야할 객체(target)을 알고있다면 생략할 수 있을거같다.
BindingResult 어떤 값을 가지고있는지 확인해보자.
@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
log.info("objectName={}", bindingResult.getObjectName());
log.info("target={}", bindingResult.getTarget());
바인딩 해야하는 객체(타겟)과 그 객체의 이름값이 저장되어있다.
(객체를 출력하려고하면 자동으로 toString()이 실행되서 저 로그에 찍힌것은 Item.toString()이다.)
rejectValue() , reject()
BindingResult 가 제공하는 rejectValue() , reject() 를 사용하면
FieldError , ObjectError 를 직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있다.
rejectValue() , reject() 를 사용해서 기존 코드를 단순화해보자.
rejectValue()
Register a field error for the specified field of the current object
-> 타겟객체의 필드에 대한 필드에러를 등록하는 메소드
정보들을 가지고 필드에러를 생성해서 BindingResult에 저장해준다.
rejectValue()는 Errors라는 인터페이스안에 있는 메소드이다.
Errors객체에 주어진 모든 에러들을 저장하는 메소드라고 한다. (필드에러를 생성해주면서 저장시켜준다)
BindingResult의 구현체는 BeanPropertyBindingResult를 사용하게 될것이고 상속,구현관계를 타고타고 들어가면
어딘가에 rejectValue()를 구현해놓은곳이 있을것이다.
bindingResult.rejectValue("price","range",new Object[]{1000, 1000000}, null);
앞에서 BindingResult 는 어떤 객체를 대상으로 검증하는지 target을 이미 알고 있다고 했다.
따라서 target(item)에 대한 정보는 없어도 된다. 오류 필드명은 동일하게 price 를 사용했다.
(이미 Item이라는 객체를 가지고있고, 이름또한 알고있다, 오류가 난 필드에 대한 이름만 주면된다.)
reject()
Register a global error for the entire target object, using the given error description.
-> 주어진 에러설명을 가지고 타겟 오브젝트에 대한 에러를 등록해주는 메소드
정보들을 가지고 오브젝트 에러를 생성해서 BindingResult에 저장해준다.
bindingResult.reject("totalPriceMin",new Object[]{10000, resultPrice}, null);
축약된 에러코드
FieldError() 를 직접 다룰 때는 오류 코드를 range.item.price 와 같이 모두 입력했다.
그런데 rejectValue() 를 사용하고 부터는 오류 코드를 range 로 간단하게 입력했다.
그래도 오류 메시지를 잘 찾아서 출력한다. 무언가 규칙이 있는 것 처럼 보인다.
이 부분을 이해하려면 MessageCodesResolver 를 이해해야 한다.
왜 이런식으로 오류 코드를 구성하는지 바로 다음에 자세히 알아보자.
프로젝트 V2 의 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");
}
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("erros = {}", bindingResult);
log.info("model ={}", model);
return "validation/v2/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
오류 코드와 메시지 처리3
오류 코드를 만들 때 다음과 같이 자세히 만들 수도 있고, (오류 메세지 파일 만들때)
required.item.itemName : 상품 이름은 필수 입니다.
range.item.price : 상품의 가격 범위 오류 입니다.
또는 다음과 같이 단순하게 만들 수도 있다. (오류 메세지 파일 만들때)
required : 필수 값 입니다
range : 범위 오류 입니다.
단순하게 만들면 범용성이 좋아서 여러곳에서 사용할 수 있지만, 메시지를 세밀하게 작성하기 어렵다.
반대로 너무 자세하게 만들면 범용성이 떨어진다.
가장 좋은 방법은 범용성으로 사용하다가, 세밀하게 작성해야 하는 경우에는 세밀한 내용이 적용되도록
메시지에 단계를 두는 방법이다.
예를 들어서 required 라고 오류 코드를 사용한다고 가정해보자.
다음과 같이 "required 라는 메시지만 있으면" 이 메시지를 선택해서 사용하는 것이다.
그런데
오류 메시지에 required.item.itemName 와 같이 객체명과 필드명을 조합한
세밀한 메시지 코드가 있으면 이 메시지를 높은 우선순위로 사용하는 것이다
bindingResult.rejectValue("itemName","required");
위의 코드를 예시로 들면
"required"라고 에러코드가 넘어갔어도 required.item.itemName의 에러코드가 있으면 그것이 선택되게끔하면된다.
bindingResult.addError(new FieldError("item","itemName",item.getItemName(),false,new String[]{"required.item.itemName"},null,null));
위에 코드처럼 bindingResult.addError()의 파리미터로 String[]로 된 코드값을 전달했었다. (아래와 같은)
new String[]{"required.item.itemName"}
(String[]로 받는 이유는 인덱스 순서대로 해당 메시지에러코드가 존재한다면 그것을 가져오고 없다면 그 후순위 인덱스의 코드를 찾아보기 위해서다.)
"required.item.itemName"과 같은
세밀한 코드를 만들기 위한 재료로서
BindingResult는 이미 타겟에 대한 객체도 가지고있고, 타겟객체에 대한 이름도 가지고있다.
그리고 필드명은 이미 가지고있다.
(Item객체와 item이라는 객체이름)
그러므로
코드.객체이름.필드명으로 코드이름을 하나 만들수 있고 -> 입력된코드이름.item.itemName
그걸 이용해서 String[]의 코드 배열을 만들어서 전달하는것과 같다. (MessageCodesResolver가 만들어준다)
BindingResult.rejectValue()에서는 MessageCodesResolver를 사용해서 메시지코드 String[]을 만들어준다.
new String[] {입력된코드이름.item.itemName , 입력된코드이름}
어디다 전달하냐면 -> 필드에러, 오브젝트 에러를 만들때 전달인자로
그렇게 에러를 만들어서 BindingResult가 가지고있고 해당 에러가 발생하면
오류 메시지 코드로 "required"를 전달했어도,
세밀한 메시지 코드가 있다면 그것이 선택되는것이다.
물론 이렇게 객체명과 필드명을 조합한 메시지가 있는지 우선 확인하고,
없으면 좀 더 범용적인 메시지를 선택하도록 추가 개발을 해야겠지만,
(지금은 세밀한 메시지가 없으면 바로 파라미터로 넘어온 에러코드를 선택한다.
범용성 있게 잘 개발해두면, 메시지의 추가 만으로 매우 편리하게 오류 메시지를 관리할 수 있을 것이다.
스프링은 MessageCodesResolver 라는 것으로 이러한 기능을 지원한다.
BindingResult.rejectValue()에서는 이미 내부에서 MessageCodesResolver를 사용하기 때문에 잘 동작한다.
자세한 동작방식은 다음 글에서 MessageCodesResolver를 공부하면서 알게된다.
'인프런 > 스프링 MVC 2편' 카테고리의 다른 글
9) Validator , @Validated (@Valid) (0) | 2023.02.04 |
---|---|
8)Validation(검증) , 오류 코드와 메시지처리 (4 ~ 6) (0) | 2023.02.03 |
6) 메시지 ,국제화 (0) | 2023.02.02 |
5)타임리프 - 라디오버튼,셀렉트박스 (0) | 2023.02.02 |
4)타임리프 적용,체크박스,멀티체크박스 (0) | 2023.02.02 |
댓글