- Spring mvc2 타임리프 기본기능
- Spring mvc2 타임리프 스프링 통합과 폼
- Spring mvc2(2) 메세지, 국제화
- Spring mvc2(3) 검증1 validation
- Spring mvc2 검증2 1 bean validation
- Spring mvc2 검증2 2 로그인 처리1 쿠키, 세션
- Spring mvc2 검증2 3 로그인 처리2 필터, 인터셉터
- Spring mvc2 검증2 4 예외 처리와 오류 페이지
- Spring mvc2(1) api 예외처리
- Spring mvc2(2) 스프링 타입 컨버터
- Spring mvc2(3) 파일 업로드
Spring mvc2(3) 검증1 validation
0. 요구사항 정리
요구사항
- 요구사항: 검증 로직 추가
- 타입 검증
- 가격, 수량에 문자가 들어가면 검증 오류 처리
- 필드 검증
- 상품명: 필수, 공백X
- 가격: 1000원 이상, 1백만원 이하
- 수량: 최대 9999
- 특정 필드의 범위를 넘어서는 검증
- 가격 * 수량의 합은 10,000원 이상
검증
- 지금까지 만든 웹 애플리케이션은 폼 입력시 숫자를 문자로 작성하거나해서 검증 오류가 발생하면 오류 화면으로 바로 이동한다.
- 이렇게 되면 사용자는 처음부터 해당 폼으로 다시 이동해서 입력을 해야 한다.
- 아마도 이런 서비스라면 사용자는 금방 떠나버릴 것이다.
- 웹 서비스는 폼 입력시 오류가 발생하면, 고객이 입력한 데이터를 유지한 상태로 어떤 오류가 발생했는지 친절하게 알려주어야 한다.
- 컨트롤러의 중요한 역할중 하나는 HTTP 요청이 정상인지 검증하는 것이다.
클라이언트 검증, 서버 검증
- 클라이언트 검증은 조작할 수 있으므로 보안에 취약하다.
- 서버만으로 검증하면, 즉각적인 고객 사용성이 부족해진다.
- 둘을 적절히 섞어서 사용하되, 최종적으로 서버 검증은 필수
- API 방식을 사용하면 API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨주어야 함
1. 검증 직접 처리 - 소개
상품 저장 성공
상품 저장 검증 실패
- 검증 오류가 발생하면, 고객에게 다시 상품 등록 폼을 보여주고, 어떤 값을 잘못 입력했는지 친절하게 알려주어야 한다.
- model 에 검증 오류 결과를 포함해서 상품등록 폼(addForm.html) 을 다시 렌더링 해야 한다.
2. 검증 직접 처리 - 개발
ValidationItemControllerV1 - addItem() 수정
-
검증 로직 추가 (주석 참고)
-
@PostMapping("/add") public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) { //검증 오류 결과를 보관 Map<String, String> errors = new HashMap<>(); //검증 로직 //item.getItemName() 에 text 가 없으면 errors 에 담아둔다. if (!StringUtils.hasText(item.getItemName())) { errors.put("itemName", "상품 이름은 필수입니다."); } //price 범위 설정 후 error 보관 if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000){ errors.put("price", "가격은 1,000 ~ 1,000,000 까지 허용합니다."); } //quantity 범위 설정 후 error 보관 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 code = {}", errors.values()); //model 에 attribute 로 담는다. 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}"; }
검증 오류 보관
Map errors = new HashMap<>();
- 만약 검증시 오류가 발생하면 어떤 검증에서 오류가 발생했는지 정보를 담아둔다.
addForm.html
-
<!DOCTYPE HTML> <html xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="utf-8"> <link th:href="@{/css/bootstrap.min.css}" href="../css/bootstrap.min.css" rel="stylesheet"> <style> .container { max-width: 560px; } .field-error { border-color: #dc3545; color: #dc3545; } </style> </head> <body> <div class="container"> <div class="py-5 text-center"> <h2 th:text="#{page.addItem}">상품 등록</h2> </div> <form action="item.html" th:action th:object="${item}" method="post"> <div th:if="${errors?.containsKey('globalError')}"> <p class="field-error" th:text="${errors['globalError']}">전체 오류 메세지</p> </div> <div> <label for="itemName" th:text="#{label.item.itemName}">상품명</label> <input type="text" id="itemName" th:field="*{itemName}" th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control'" class="form-control" placeholder="이름을 입력하세요"> <div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}"> 상품명 오류 </div> </div> <div> <label for="price" th:text="#{label.item.price}">가격</label> <input type="text" id="price" th:field="*{price}" class="form-control" th:class="${errors?.containsKey('price')} ? 'form-control field-error' : 'form-control'" placeholder="가격을 입력하세요"> </div> <div class="field-error" th:if="${errors?.containsKey('price')}" th:text="${errors['price']}"> 가격 오류 </div> <div> <label for="quantity" th:text="#{label.item.quantity}">수량</label> <input type="text" id="quantity" th:field="*{quantity}" class="form-control" th:class="${errors?.containsKey('quantity')} ? 'form-control field-error' : 'form-control'" placeholder="수량을 입력하세요"> </div> <div class="field-error" th:if="${errors?.containsKey('quantity')}" th:text="${errors['quantity']}"> 수량 오류 </div> <hr class="my-4"> <div class="row"> <div class="col"> <button class="w-100 btn btn-primary btn-lg" type="submit" th:text="#{button.save}">상품 등록</button> </div> <div class="col"> <button class="w-100 btn btn-secondary btn-lg" onclick="location.href='items.html'" th:onclick="|location.href='@{/validation/v1/items}'|" type="button" th:text="#{button.cancel}">취소</button> </div> </div> </form> </div> <!-- /container --> </body> </html>
css 추가
-
.field-error { border-color: #dc3545; color: #dc3545; }
- 오류 메시지를 빨간색으로 강조하기 위해 추가했다.
글로벌 오류 메시지
-
<div th:if="${errors?.containsKey('globalError')}"> <p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p> </div>
- if -> errors 에 key 로 globalError 가 있으면?
- text = error[‘globalError ‘] (=
"가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice
)
- text = error[‘globalError ‘] (=
- if -> errors 에 key 로 globalError 가 있으면?
Safe Navigation Operator
- 만약 여기에서 errors 가 null 이라면 어떻게 될까?
- 생각해보면 get 으로 등록폼에 진입한 시점에는 errors 가 없다.
- 따라서 errors.containsKey() 를 호출하는 순간 NullPointerException 이 발생한다.
- errors?. 은 errors 가 null 일때 NullPointerException 이 발생하는 대신, null 을 반환하는 문법이다.
- 이것은 스프링의 SpringEL이 제공하는 문법이다.
상품명, 가격, 수량 오류코드 (동일)
-
<input type="text" id="itemName" th:field="*{itemName}" th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control'" class="form-control" placeholder="이름을 입력하세요"> <div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}"> 상품명 오류 </div>
- 먼저 input 을 보면, ${errors?.containsKey(‘itemName’)} 가 있으면
form-control field-error
, 없으면form-control
로 한다. - 즉, errors 맵에 ‘itemName’ 키가 있으면
class="form-control field-error"
, 없으면class="form-control"
<input type="text" th:classappend="${errors?.containsKey('itemName')} ? 'fielderror' : _" class="form-control">
이렇게 classappend 를 사용해서 간단하게도 표현 가능하다.- 밑에 div 태그로 오류메시지를 출력한다.
- 먼저 input 을 보면, ${errors?.containsKey(‘itemName’)} 가 있으면
3. BindingResult
ValidationItemControllerV2 - addItemV1
-
@PostMapping("/add") //1 public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) { //2 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 까지 허용합니다.")); } //3 if(item.getPrice() != null && item.getQuantity() != null){ int resultPrice = item.getPrice() * item.getQuantity(); if(resultPrice < 10000){ bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice)); } } //4 if(bindingResult.hasErrors()){ log.info("errors code = {}", bindingResult); return "validation/v2/addForm"; }
설명
- BindingResult bindingResult 를 무조건
@ModelAttribute Item item
뒤에 넣어준다. 그래야 Item 과 연동이 된다. - bindingResult.addError(new FieldError(“objectName”, “field”, “defaultMessage”))) 로 bindingResult 에 오류메세지를 추가한다.
void addError(ObjectError error)
로, FieldError 는 ObjectError 를 상속받는다.- objectName(@ModelAttribute 이름) , field(오류가 발생한 필드 이름), defaultMessage(오류 기본 메시지) 를 생성자로 받는다.
- bindingResult.addError(new ObjectError(“objectName”, “defaultMessage”))) 로 bindingResult 에 오류메세지를 추가한다.
- objectName(@ModelAttribute 이름), defaultMessage(오류 기본 메시지) 를 생성자로 받는다.
- ObjectError 는 지역변수 에러이기 때문에 hasGlobalError(), GlobalError() 등의 메서드를 사용한다.
- `bindingResult.hasErrors() 를 통해 boolean 검증
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> <div class="field-error" th:errors="*{price}"> 가격 오류 </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> <div class="field-error" th:errors="*{quantity}"> 수량 오류 </div> <hr class="my-4"> <div class="row"> <div class="col"> <button class="w-100 btn btn-primary btn-lg" type="submit" th:text="#{button.save}">상품 등록</button> </div> <div class="col"> <button class="w-100 btn btn-secondary btn-lg" onclick="location.href='items.html'" th:onclick="|location.href='@{/validation/v2/items}'|" type="button" th:text="#{button.cancel}">취소</button> </div> </div> </form>
- 전체
<div th:if="${#fields.hasGlobalErrors()}">
: 만약 fields 에 hasGlobalErrors() 가 참이면- -> ObjectError 가 있으면
th:each ="err : ${#fields.globalErrors()}" th:text="${err}"
.globalErrors()
메서드는 리스트를 반환한다.- err 가 여러 개일 수 있으므로 루프를 통해 text 로 반환한다.
- field 에러
th:field="*{itemName}" th:errorclass="field-error"
- th:errorclass 를 통해 new FieldError() 중 “itemName” 필드 이름을 가진 FieldError 가 있으면 클래스를 만든다.
th:errorclass="field-error"
는th:class="${#fields.hadErrors('itemName')?:'field-error:_}"
과 같다고 볼 수 있으며, 간단하게 표현한 것이다.
<div class="field-error" th:errors="*{quantity}">
- new FieldError() 중 “quantity” 필드 이름을 가진 FieldError 가 있으면
<div class="field-error"
태그와 클래스를 만든다.
- new FieldError() 중 “quantity” 필드 이름을 가진 FieldError 가 있으면
- 전체
BindingResult 동작
- BindingResult 가 있으면 @ModelAttribute 에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출된다!
-
- 스프링이 넘어온 오류를 new FieldError() 로 만들어서 자동으로 넘겨준다.
BindingResult에 검증 오류를 적용하는 3가지 방법
- @ModelAttribute 의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이
FieldError
생성해서BindingResult
에 넣어준다. - 개발자가 직접 넣어준다. (ValudationitemControllerV2 에서 설정한 방법)
- Validator 사용 (어노테이션 사용)
BindingResult와 Errors
- BindingResult 는 인터페이스이고, Errors 인터페이스를 상속받고 있다.
- 실제 넘어오는 구현체는
BeanPropertyBindingResult
라는 것인데, 둘다 구현하고 있으므로 BindingResult 대신에 Errors 를 사용해도 된다. - Errors 인터페이스는 단순한 오류 저장과 조회 기능을 제공한다.
- BindingResult 는 여기에 더해서 추가적인 기능들을 제공한다.
addError()
도 BindingResult 가 제공하므로 여기서는 BindingResult 를 사용한다.
4. FieldError, ObjectError
- 클라이언트가 입력한 값이 error 가 나면 값이 사라진다.
- 이 부분을 해결하기 위해서 FieldError 의 다른 생성자를 사용한다.
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)); } }
FieldError 생성자
-
public FieldError(String objectName, String field, @Nullable Object rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage) { super(objectName, codes, arguments, defaultMessage); Assert.notNull(field, "Field must not be null"); this.field = field; this.rejectedValue = rejectedValue; this.bindingFailure = bindingFailure; }
- @Nullable Object rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable Object[] arguments 총 4개가 추가되었다.
-
파라미터 목록
- objectName : 오류가 발생한 객체 이름
- field : 오류 필드
- rejectedValue : 사용자가 입력한 값(거절된 값)
- bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
- codes : 메시지 코드
- arguments : 메시지에서 사용하는 인자
- defaultMessage : 기본 오류 메시지
-
ObjectError 는 여기서 objectName, codes, arguments, defaultMessage 를 매개변수로 하는 생성자가 있다.
-
public ObjectError( String objectName, @Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage) { super(codes, arguments, defaultMessage); Assert.notNull(objectName, "Object name must not be null"); this.objectName = objectName; }
-
오류 발생시 사용자 입력 값 유지
rejectedValue
- FieldError 에서 Object rejectedValue 변수에 사용자 입력 값을 별도로 보관한다.
- 이렇게 보관한 사용자 입력 값을 검증 오류 발생시 화면에 다시 출력하면 된다
bindingFailure
- 바인딩이 실패했는지 여부를 묻는 것이다. 타입오류는 바인딩실패는 아니므로 false 를 넣어준다.
타임리프의 사용자 입력 값 유지(addForm.html)
th:field="*{price}"
는 정상 상황에는 모델 객체의 값을 사용하지만, 오류가 발생하면FieldError
에서 보관한 값을 사용해서 값을 출력한다.
5. 오류 코드와 메시지 처리1 (FieldError 생성자 사용)
FieldError 생성자 (codes, arguments)
- FieldError 생성자 중 codes 와 arguments 는 properties 에서 찾는다.
errors 메시지 파일 생성(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}
- errors_en.properties 파일을 생성하면 오류 메시지도 국제화 처리를 할 수 있다.
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)); } }
code
new String[]{"range.item.price"}
처럼 properties 의 키값을 사용해서 메시지 코드를 지정한다.- 메시지 코드는 하나가 아니라 배열로 여러 값을 전달할 수 있는데, 순서대로 매칭해서 처음 매칭되는 메시지가 사용된다.
- 따라서 String 배열을 사용한다.
- 해당 부분은 에러 객체에 key를 문자열로 넘겨주는 과정입니다.\
프로퍼티에 지정한 값을 어떻게 중괄호에서 바로 사용이 가능한건지?
- 뷰가 렌더링 되는 과정에서 해당 에러 객체를 처리하게 되는데, 이때 에러가 발생하였다면 해당 에러와 관련된 메시지가 있는지 찾는다.
- 이때, 내부에 만들어진 프로퍼티 파일(messages, errors)를 순회하며 이전에 저장해둔 key에 해당하는 부분이 있는지 확인 후 key의 value를 가져옵니다.
- 즉,
spring.messages.basename=messages,errors
기본 설정으로 messages, errors 를 다 찾아본다는 말! - 따라서 messages, errors 내 key 값이 겹치면 안될 듯
- 즉,
aguments
- 매개변수로
new Object[]
를 사용한다. - 따라서 properties 에 {0}, {1} 등이 있다면 new Object[]{“1000”, “20000”} 처럼 넣어주면 된다.
6. 오류 코드와 메시지 처리2(rejectValue(), reject() 사용)
- 컨트롤러에서 BindingResult 는 검증해야 할 객체인 target 바로 다음에 온다. 따라서 BindingResult 는 이미 본인이 검증해야 할 객체인 target 을 알고 있다.
bindingResult.getObjectName()
= “item” (String)bindingResult.getTarget()
=target=Item(id=null, itemName=상품, price=100, quantity=1234)
(Object)
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[]{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 code = {}", bindingResult); //자동으로 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}"; }
rejectValue()
-
void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
- field : 오류 필드명
- errorCode : 오류 코드(이 오류 코드는 메시지에 등록된 코드가 아니다. 뒤에서 설명할 messageResolver를 위한 오류 코드이다.)
- errorArgs : 오류 메시지에서 {0} 을 치환하기 위한 값
- defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지
rejectValue() 예시
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null)
- BindingResult 는 어떤 객체를 대상으로 하는지 알고 있으므로 객체 생략 (“item” 생략)
- 축약된 오류코드 : 오류 코드를 range 로 간단하게 입력한다. (뒤에 item.price 생략)
- errorArgs 와 defaultMessage 는 똑같다.
reject()
-
void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
- 앞의 내용과 같다.
7. 오류 코드와 메시지 처리3, 4(MessageCodesResolver)
오류 코드 처리 범위
-
오류코드를 만들 때 자세히 만들 수도 있고(
required.item.itemName: 상품 이름은 필수 입니다.
), 또는 단순하게 만들 수도 있다.(required : 필수 값 입니다.
) -
단순하게 만들면 범용성이 좋아서 여러곳에서 사용할 수 있지만, 메시지를 세밀하게 작성하기 어렵다. 반대로 너무 자세하게 만들면 범용성이 떨어진다
-
가장 좋은 방법은 범용성으로 사용하다가, 세밀하게 작성해야 하는 경우에는 세밀한 내용이 적용되도록 메시지에 단계를 두는 방법이다.
-
#Level1 required.item.itemName=상품 이름은 필수 입니다. #Level2 required=필수 값 입니다.
- rejectValue() 에서 String code 값이 required 일 때
- required.item.itemName 와 같이 객체명과 필드명을 조합한 세밀한 메시지 코드가 있고 이 조건을 만족한다면(Item 객체에 itemName 필드이면) 이 메시지를 높은 우선순위로 사용하는 것이다.
- 그렇지 않으면
required=필수 값 입니다.
를 사용한다.
MessageCodesResolverTest
-
package hello.itemservice.validation; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.Test; import org.springframework.validation.DefaultMessageCodesResolver; import org.springframework.validation.FieldError; import org.springframework.validation.MessageCodesResolver; import org.springframework.validation.ObjectError; import static org.assertj.core.api.Assertions.*; public class MessageCodesResolverTest { MessageCodesResolver codesResolver = new DefaultMessageCodesResolver(); @Test void messageCodesResolverObject(){ String[] messageCodes = codesResolver.resolveMessageCodes("required", "item"); assertThat(messageCodes).containsExactly("required.item", "required"); } @Test void messageCodesResolverField(){ String[] messageCodes = codesResolver.resolveMessageCodes("required", "item", "itemName", String.class); for (String messageCode : messageCodes) { System.out.println("messageCode = " + messageCode); } assertThat(messageCodes).containsExactly( "required.item.itemName", "required.itemName", "required.java.lang.String", "required"); } }
- MessageCodesResolver 인터페이스이고 DefaultMessageCodesResolver 는 기본 구현체이다.
.contains vs .containExactly
- contains 메소드는 단순하다. 중복여부, 순서에 관계 없이 값만 일치하면 테스트가 성공한다.
- containsExactly 는 원소가 정확히 일치해야 한다. 중복된 값이 있어도 안되고 순서가 달라져도 안된다. 특정 자료구조의 정확한 값을 테스트 하고 싶은 경우에는 이 메소드를 사용할 수 있다.
Test 1
codesResolver.resolveMessageCodes("required", "item");
를 매개변수로 준다.- required 는 매개변수로 원래 줬고, “item” 은 getObjectName() 으로 얻는다.
- messageCodes 로
"required.item", "required"
를 Sting[] 으로 받는다.
Test 2
codesResolver.resolveMessageCodes("required", "item", "itemName", String.class);
를 매개변수로 준다.- itemName 은 원래 매개변수로 줬고, String.class 는 item.itemName 에서 얻는다.
- messageCodes 로
"required.item.itemName", "required.itemName", "required.java.lang.String", "required"
를 Sting[] 으로 받는다.
DefaultMessageCodesResolver의 기본 메시지 생성 규칙
객체 오류
-
객체 오류의 경우 다음 순서로 2가지 생성
-
code + “.” + object name
-
code
-
-
예) 오류 코드: required, object name: item
- required.item
- required
필드 오류
- 필드 오류의 경우 다음 순서로 4가지 메시지 코드 생성
- code + “.” + object name + “.” + field
- code + “.” + field
- code + “.” + field type
- code
- 예) 오류 코드: typeMismatch, object name “user”, field “age”, field type: int
- “typeMismatch.user.age”
- “typeMismatch.age”
- “typeMismatch.int”
- “typeMismatch”
동작 방식
- rejectValue() , reject() 는 내부에서 MessageCodesResolver 를 사용한다. 여기에서 메시지 코드들을 생성한다.
- FieldError , ObjectError 의 생성자를 보면, 오류 코드를 하나가 아니라 여러 오류 코드를 가질 수 있다. MessageCodesResolver 를 통해서 생성된 순서대로 오류 코드를 보관한다.
- 타임리프 화면을 렌더링 할 때 th:errors 가 실행된다. 만약 이때 오류가 있다면 생성된 오류 메시지 코드를 순서대로 돌아가면서 메시지를 찾는다. 그리고 없으면 디폴트 메시지를 출력한다.
rejectValue() 에서 DefaultMessageCodesResolver 찾아 떠나기
-
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
-
rejectVaue() 는 Error 인터페이스에 있다.
-
public interface Error{ ... void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage); }
-
-
이 Error 코드의 구현체는 AbstractBindingResult 인데, rejectValue 가 이렇게 되어있다.
-
@Override public void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage) { if (!StringUtils.hasLength(getNestedPath()) && !StringUtils.hasLength(field)) { // We're at the top of the nested object hierarchy, // so the present level is not a field but rather the top object. // The best we can do is register a global error here... reject(errorCode, errorArgs, defaultMessage); return; } String fixedField = fixedField(field); Object newVal = getActualFieldValue(fixedField); FieldError fe = new FieldError(getObjectName(), fixedField, newVal, false, resolveMessageCodes(errorCode, field), errorArgs, defaultMessage); addError(fe); }
-
여기서 보면 new FieldError 를 생성하고 addError 를 통해 BindingResult 에 에러정보를 넣는다.
-
매개변수는 getObjectName(), fixedField, newVal, false, resolveMessageCodes(errorCode, field), errorArgs, defaultMessage 가 있는데
- getObjectName() : 이미 BindingResult 에 넣어진 ObjectName 이다. (“Item”)
- fixedField :
fixedField(field);
인데 기본적으로 field 를 그대로 반환한다. (“price”) - newVal : “price” 를 넣어서 Object 형식으로 거절된 값을 반환한다.
- resolveMessageCodes(errorCode, field) : 알아봐야 할 값
-
-
resolveMessageCodes(errorCode, field) : resolveMessageCodes(“range”, “price”) 인데,
-
@Override public String[] resolveMessageCodes(String errorCode, @Nullable String field) { return getMessageCodesResolver().resolveMessageCodes( errorCode, getObjectName(), fixedField(field), getFieldType(field)); }
-
이러한 코드를 가지고 있다.
-
getMessageCodesResolver() 는 AbstractBindingResult 의 messageCodesResolver 를 반환한다.
- AbstractBindingResult 에서 선언된 messageCodesResolver 는
private MessageCodesResolver messageCodesResolver = new DefaultMessageCodesResolver();
이다. 즉DefaultMessageCodesResolver()
를 구현체로 사용하고 있다.
-
-
DefaultMessageCodesResolver().resolveMessageCodes(errorCode, getObjectName(), fixedField(field), getFieldType(field))
-
DefaultMessageCodesResolver().resolveMessageCodes("range", "item", "price", Sting.class)
- getFieldType() 메소드를 통해 fieldtype 을 반환받는다.
-
-
resolveMessageCodes("range", "item", "price", Sting.class)
-
DefaultMessageCodesResolver 에서 resolveMessageCodes 메소드는,
-
@Override public String[] resolveMessageCodes(String errorCode, String objectName, String field, @Nullable Class<?> fieldType) { Set<String> codeList = new LinkedHashSet<>(); List<String> fieldList = new ArrayList<>(); buildFieldList(field, fieldList); addCodes(codeList, errorCode, objectName, fieldList); int dotIndex = field.lastIndexOf('.'); if (dotIndex != -1) { buildFieldList(field.substring(dotIndex + 1), fieldList); } addCodes(codeList, errorCode, null, fieldList); if (fieldType != null) { addCode(codeList, errorCode, null, fieldType.getName()); } addCode(codeList, errorCode, null, null); return StringUtils.toStringArray(codeList); }
- 이렇게 되어 있고, 결론적으로, 매서드를 받아서 “.” 을 찍고
Sting[]
형태로 반환한다.
-
8. 오류 코드와 메시지 처리5(오류 코드 관리 전략)
핵심은 구체적인 것에서! 덜 구체적인 것으로!
- 모든 오류 코드에 대해서 메시지를 각각 다 정의하면 개발자 입장에서 관리하기 너무 힘들다.
- 크게 중요하지 않은 메시지는 범용성 있는 requried 같은 메시지로 끝내고, 정말 중요한 메시지는 꼭 필요할 때 구체적으로 적어서 사용하는 방식이 더 효과적이다.
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)
- itemName 의 경우 required 검증 오류 메시지가 발생하면 다음 코드 순서대로 메시지가 생성된다.
- required.item.itemName
- required.itemName
- required.java.lang.String
- required
- 만약에 크게 중요하지 않은 오류 메시지는 기존에 정의된 것을 그냥 재활용 하면 된다!
ValidationUtils
-
ValidationUtils 사용 전
-
if (!StringUtils.hasText(item.getItemName())) { bindingResult.rejectValue("itemName", "required"); }
-
-
ValidationUtils 사용 후
-
다음과 같이 한줄로 가능하지만 제공하는 기능은 Empty, 공백 같은 단순한 기능만 제공한다.
-
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
-
9. 오류 코드와 메시지 처리6 (스프링이 직접 만든 오류 메시지 처리)
- 검증 오류 코드는 다음과 같이 2가지로 나눌 수 있다.
- 개발자가 직접 설정한 오류 코드 ->
rejectValue()
를 직접 호출 - 스프링이 직접 검증 오류에 추가한 경우(주로 타입 정보가 맞지 않음)
- 개발자가 직접 설정한 오류 코드 ->
price 필드에 문자입력 후 확인
- 로그를 확인해보면 BindingResult 에 FieldError 가 담겨있고, 다음과 같은 메시지 코드들이 생성된 것을 확인할 수 있다.
codes[typeMismatch.item.price,typeMismatch.price,typeMismatch.java.lang.Integer,typ eMismatch]
- errors.properties 에 메시지 코드가 없기 때문에 스프링이 생성한 기본 메시지가 출력된다.
- 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: “A”
error.properties 에 typeMismatch 추가
-
#추가 typeMismatch.java.lang.Integer=숫자를 입력해주세요. typeMismatch=타입 오류입니다
메세지 1개만 출력하기
-
스프링이 직접 검증 오류에 추가한 경우 컨트롤러에서 바로 return 해주면 된다.
-
@PostMapping("/add") public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) { if(bindingResult.hasErrors()){ log.info("errors code = {}", bindingResult); return "validation/v2/addForm"; } //직접 설절한 오류 코드 ...
- 이렇게 하면 bindingResult 에 스프링이 생성한 오류가 들어가자마자 return 한다.
10. Validator 분리 1
ItemValidator
-
컨트롤러에서 주입받아 사용하기 위해 @Component 사용
-
package hello.itemservice.web.validation; import hello.itemservice.domain.item.Item; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.stereotype.Controller; import org.springframework.util.StringUtils; import org.springframework.validation.Errors; import org.springframework.validation.Validator; @Slf4j @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() >= 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); } } } }
supports(Class<?> clazz)
: 지원하는 클래스인지 검증(boolean)validate(Object target, Errors errors)
: 실제 검증 코드- Object target : Item item 을 넣으면 된다.
- Errors errors : bindingResult 를 넣으면 된다(Error 를 상속받으므로)
ValidationItemControllerV2 - addItemV5()
-
private final ItemValidator itemValidator; @PostMapping("/add") public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) { //itemvalidator.validate 호출 itemValidator.validate(item, bindingResult); //실패 로직 if(bindingResult.hasErrors()){ log.info("errors code = {}", 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}"; }
11. Validation 분리 - 2 (WebDataBinder 사용)
WebDataBinder
-
WebDataBinder 는 브라우저를 통해 요청받은 값이 실제 서버의 객체에 바인딩될 때 중간 역할을 한다.
- 스프링의 파라미터 바인딩의 역할을 해주고 검증 기능도 내부에 포함한다.
-
ValidationItemControllerV2에 다음 코드를 추가
-
@InitBinder public void init(WebDataBinder dataBinder) { dataBinder.addValidators(itemValidator); }
-
이렇게 WebDataBinder 에 검증기를 추가하면 해당 컨트롤러에서는 검증기를 자동으로 적용할 수 있다.
- @InitBinder 해당 컨트롤러에만 영향을 준다. 글로벌 설정은 별도로 해야한다.
-
ValidationItemControllerV2 - addItemV6() (@Validated 적용)
-
@PostMapping("/add") public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) { if(bindingResult.hasErrors()){ log.info("errors code = {}", bindingResult); //자동으로 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}"; }
-
@ModelAttribute Item item 앞에 @Validated 를 붙여준다.
-
그러면
dataBinder.addValidators(itemValidator);
로 추가된 dataBinder 가-
itemValidator.support(Item item)
을 한 후 -
true 이면
itemValidator.validate(item, bindingResult)
를 실행한다.
-
-
글로벌 설정 - 모든 컨트롤러에 다 적용
-
@SpringBootApplication public class ItemServiceApplication implements WebMvcConfigurer { public static void main(String[] args) { SpringApplication.run(ItemServiceApplication.class, args); } @Override public Validator getValidator() { return new ItemValidator(); } }
- 로벌 설정을 하면 다음에 설명할 BeanValidator가 자동 등록되지 않는다. 글로벌 설정 부분은 주석처리 해두자.
- 참고로 글로벌 설정을 직접 사용하는 경우는 드물다.
댓글남기기