📌 검증 요구사항
⚡ 요구사항 : 검증 로직 추가
- 타입 검증
- 가격, 수량에 문자가 들어가면 검증 오류 처리
- 필드 검증
- 상품명: 필수, 공백X
- 가격: 1000원 이상, 1백만원 이하
- 수량: 최대 9999
- 특정 필드의 범위를 넘어서는 검증
- 가격 * 수량의 합은 10,000원 이상
⚡ 컨트롤러의 중요한 역할중 하나는 HTTP 요청이 정상인지 검증하는 것
cf) 클라이언트 검증, 서버 검증
- 클라이언트 검증은 조작할 수 있으므로 보안에 취약
- 서버만으로 검증하면, 즉각적인 고객 사용성이 부족
- 둘을 적절히 섞어서 사용하되, 최종적으로 서버 검증은 필수
- API 방식을 사용하면 API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨주어야 함
📌 검증 직접 처리
⚡ 소개
✔ 상품 저장 성공
- 사용자가 상품 등록 폼에서 정상 범위의 데이터를 입력하면, 서버에서는 검증 로직이 통과하고, 상품을 저장하고, 상품 상세 화면으로 redirect
✔ 상품 저장 검증 실패
- 고객이 상품 등록 폼에서 상품명을 입력하지 않거나, 가격, 수량 등이 너무 작거나 커서 검증 범위를 넘어서면, 서버 검증 로직이 실패
- 검증에 실패한 경우 고객에게 다시 상품 등록 폼을 보여주고, 어떤 값을 잘못 입력했는지 친절하게 알려줘야 함
⚡ 개발
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()){
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<String, String> errors = new HashMap<>();
✔ 검증 로직
if (!StringUtils.hasText(item.getItemName())) {
errors.put("itemName", "상품 이름은 필수입니다.");
}
➡ import org.springframework.util.StringUtils;
추가 필요
- 검증시 오류가 발생하면
errors
에 담아둠 - 어떤 필드에서 오류가 발생했는지 구분하기 위해 오류가 발생한 필드명을
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
를 사용
✔ 검증에 실패하면 다시 입력 폼으로
if (!errors.isEmpty()) {
model.addAttribute("errors", errors);
return "validation/v1/addForm";
}
- 만약 검증에서 오류 메시지가 하나라도 있으면 오류 메시지를 출력하기 위해
model
에errors
를 담고, 입력 폼이 있는 뷰 템플릿으로 보냄
add.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-controlfield-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}" th:class="${errors?.containsKey('price')} ? 'form-controlfield-error' : 'form-control'" class="form-control" placeholder="가격을 입력하세요">
<div class="field-error" th:if="${errors?.containsKey('price')}" th:text="${errors['price']}">
가격 오류
</div>
</div>
<div>
<label for="quantity" th:text="#{label.item.quantity}">수량</label> <input type="text" id="quantity" th:field="*{quantity}" th:class="${errors?.containsKey('quantity')} ? 'form-controlfield-error' : 'form-control'"
class="form-control" placeholder="수량을 입력하세요">
<div class="field-error" th:if="${errors?.containsKey('quantity')}" th:text="${errors['quantity']}"> 수량 오류
</div>
</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>
- 오류 메시지는
errors
에 내용이 있을 때만 출력하면 됨 - 타임리프의
th:if
를 사용하면 조건에 만족할 때만 해당 HTML 태그를 출력할 수 있음
cf)
Safe Navigation Operator
- errors가 null이라면 errors.containsKey()를 호출하는 순간 NullPointerException 발생
- errors?.은 errors가 null일 때 NullPointerException이 발생하는 대신 null을 반환하는 문법
- th:if에서 null은 실패로 처리되므로 오류메시지가 출력되지 않음
SpringEL이 제공하는 문법
참고 링크
✔ 필드 오류 처리
<input type="text" th:classappend="${errors?.containsKey('itemName')} ? 'field-error' : _"
class="form-control">
- classappend 를 사용해서 해당 필드에 오류가 있으면 field-error 라는 클래스 정보를 더해서 폼의 색깔을 빨간색으로 강조
- 만약 값이 없으면 _ (No-Operation)을 사용해서 아무것도 하지 않음
✔ 필드 오류 처리 - 입력 폼 색상 적용
<input type="text" class="form-control field-error">
✔ 필드 오류 처리 - 메시지
<div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}">
상품명 오류
</div>
- 글로벌 오류 메시지에서 설명한 내용과 동일하고, 필드 오류를 대상
실행
http://localhost:8080/validation/v1/items/add
⚡ 정리
- 만약 검증 오류가 발생하면 입력 폼을 다시 보여줌
- 검증 오류들을 고객에게 친절하게 안내해서 다시 입력할 수 있게 함
- 검증 오류가 발생해도 고객이 입력한 데이터가 유지됨
⚡ 남은 문제점
- 뷰 템플릿에서 중복 처리가 많음
- 타입 오류 처리가 안됨
Item
의price
,quantity
같은 숫자 필드는 타입이Integer
이므로 문자 타입 으로 설정하는 것이 불가능
- 숫자 타입에 문자가 들어오면 오류가 발생
- 스프링 MVC에서 컨트롤러에 진입하기도 전에 예외가 발생하기 때문에, 컨트롤러가 호출되지도 않고, 400 예외가 발생 하면서 오류 페이지를 띄워줌
Item
의price
에 문자를 입력하는 것 처럼 타입 오류가 발생해도 고객이 입력한 문자를 화면에 남겨야 한다.- 만약 컨트롤러가 호출된다고 가정해도
Item
의price
는Integer
이므로 문자를 보관 불가능
- 만약 컨트롤러가 호출된다고 가정해도
- 결국 문자는 바인딩이 불가능하므로 고객이 입력한 문자가 사라지게 되고, 고객은 본인이 어떤 내용을 입력해서 오류가 발생했는지 이해하기 어려움
- 결국 고객이 입력한 값도 어딘가에 별도로 관리가 되어야 함
📌 BindingResult1
ValidationItemControllerV2 - addItemV1
@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes){
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() >=10000){
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
다음에 와야 함
✔ 필드 오류 - FieldError
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
}
FieldError 생성자 요약
public FieldError(String objectName, String field, String defaultMessage) {}
- 필드에 오류가 있으면
FieldError
객체를 생성해서bindingResult
에 담아두면 됨objectName
:@ModelAttribute
이름field
: 오류가 발생한 필드 이름defaultMessage
: 오류 기본 메시지
✔ 글로벌 오류 - ObjectError
bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
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
:#fields
로BindingResult
가 제공하는 검증 오류에 접근할 수 있음th:errors
: 해당 필드에 오류가 있는 경우에 태그 출력th:if
의 편의 버전
th:errorclass
:th:field
에서 지정한 필드에 오류가 있으면class
정보 추가- 검증과 오류 메시지 공식 메뉴얼
✔ 글로벌 오류 처리
<div th:if="${#fields.hasGlobalErrors()}">
<p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p>
</div>
✔ 필드 오류 처리
<input type="text" id="itemName" th:field="*{itemName}" th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요">
<div class="field-error" th:errors="*{itemName}">
상품명 오류
</div>
⚡ BindingResult 정리
- 스프링이 제공하는 검증 오류를 보관하는 객체
- 검증 오류가 발생하면 여기에 보관
BindingResult
가 있으면@ModelAttribute
에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출!!!
❓ @ModelAttribute에 바인딩 시 타입 오류가 발생하면?
BindingResult
가 없으면 400 오류가 발생하면서 컨트롤러가 호출되지 않고, 오류 페이지로 이동BindingResult
가 있으면 오류 정보(FieldError
)를BindingResult
에 담아서 컨트롤러를 정상 호출
✔ BindingResult에 검증 오류를 적용하는 3가지 방법
@ModelAttribute
의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이FieldError
생성해서BindingResult
에 넣어줌
➡ 개발자가 직접 넣어줌
✔ 타입 오류 확인
- 숫자가 입력되어야 할 곳에 문자를 입력해서 타입을 다르게 해서
BindingResult
를 호출하고bindingResult
의 값을 확인
❗ 주의
BindingResult
는 검증할 대상 바로 다음에 와야함- 순서 중요
- ex)
@ModelAttribute Item item
, 바로 다음에BindingResult
가 와야 함BindingResult
는 Model에 자동으로 포함됨
⚡ BindingResult와 Errors
org.springframework.validation.Errors
org.springframework.validation.BindingResult
✔ Errors
- 인터페이스이고, 단순한 오류 저장과 조회 기능 제공
✔ BindingResult
- 인터페이스이고,
Errors
인터페이스를 상속받고 있음 - 실제 넘어오는 구현체는
BeanPropertyBindingResult
라는 것인데, 둘다 구현하고 있으므로BindingResult
대신에Errors
를 사용해도 됨 - Errors에 더해서 추가적인 기능들을 제공
addError()
도BindingResult
가 제공하므로 여기서는BindingResult
를 사용- 주로 관례상
BindingResult
를 많이 사용
📌 FieidError, ObjectError
- 오류가 나면 고객이 입력한 내용이 모두 사라지는 문제 발생!
ValidationItemControllerV2 - addItemV2
@PostMapping("/add")
public String addItemV2(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes){
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() >=10000){
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 생성자
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
: 기본 오류 메시지ObjectError
도 유사하게 두 가지 생성자를 제공
✔ 오류 발생시 사용자 입력 값 유지
new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다.")
- 사용자의 입력 데이터가 컨트롤러의
@ModelAttribute
에 바인딩되는 시점에 오류가 발생하면 모델 객체에 사용자 입력 값을 유지하기 어려움 - ex) 가격에 숫자가 아닌 문자가 입력된다면 가격은
Integer
타입이므로 문자를 보관할 수 있는 방법이 없음- 오류가 발생한 경우 사용자 입력 값을 보관하는 별도의 방법이 필요
- 이렇게 보관한 사용자 입력 값을 검증 오류 발생시 화면에 다시 출력
FieldError
는 오류 발생시 사용자 입력 값을 저장하는 기능을 제공rejectedValue
가 바로 오류 발생시 사용자 입력 값을 저장하는 필드bindingFailure
는 타입 오류 같은 바인딩이 실패했는지 여부를 적어주면 됨- 바인딩이 실패한 것은 아니기 때문에
false
를 사용
- 바인딩이 실패한 것은 아니기 때문에
✔ 타임리프의 사용자 입력 값 유지
th:field="*{price}"
- 타임리프의
th:field
는 매우 똑똑하게 동작하는데, 정상 상황에는 모델 객체의 값을 사용하지만, 오류가 발생하면FieldError
에서 보관한 값을 사용해서 값을 출력
✔ 스프링의 바인딩 오류 처리
- 타입 오류로 바인딩에 실패하면 스프링은
FieldError
를 생성하면서 사용자가 입력한 값을 넣어둠 - 해당 오류를
BindingResult
에 담아서 컨트롤러를 호출 - 타입 오류 같은 바인딩 실패시에도 사용자의 오류 메시지 정상 출력 가능
📌 오류 코드와 메시지 처리 1
⚡ FieldError 생성자
🔶 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
: 기본 오류 메시지
➡ FieldError
, ObjectError
의 생성자는 codes
, arguments
제공
➡ 이것은 오류 발생시 오류 코드로 메시지를 찾기 위해 사용
✔ errors 메시지 파일 생성
messages.properties
를 사용해도 되지만, 오류 메시지를 구분하기 쉽게errors.properties
라는 별도의 파일로 관리- 스프링 부트가 해당 메시지 파일을 인식할 수 있게 다음 설정 추가
messages.properties
,errors.properties
두 파일을 모두 인식- 생략하면
messages.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}
cf) errors_en.properties 파일을 생성하면 오류 메시지도 국제화 처리 가능
ValidationControllerV2 - addItemV3() 추가
@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
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() > 10000) {
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}
로 치환할 값을 전달
📌 오류 코드와 메시지 처리 2
- 오류 코드를 좀 더 자동화하기
- 컨트롤러에서
BindingResult
는 검증해야 할 객체인target
바로 다음에 오기 때문에BindingResult
는 이미 본인이 검증해야 할 객체인target
을 알고 있음
log.info("objectName={}", bindingResult.getObjectName());
log.info("target={}", bindingResult.getTarget());
✔ 실행 결과
objectName=item //@ModelAttribute name
target=Item(id=null, itemName=상품, price=100, quantity=1234)
⚡ rejectValue() , reject()
BindingResult
가 제공하는rejectValue()
,reject()
를 사용하면FieldError
,ObjectError
를 직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있음
ValidationControllerV2 - addItemV4() 추가
@PostMapping("/add")
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())) {
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() > 10000) {
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}";
}
errors.properties
에 있는 코드를 직접 입력하지 않았는데 잘 실행됨
✔ rejectValue()
void rejectValue(@Nullable String field, String errorCode,
@Nullable Object[] errorArgs, @Nullable String defaultMessage);
field
: 오류 필드명errorCode
: 오류 코드(이 오류 코드는 메시지에 등록된 코드가 아니라 뒤에서 설명할 messageResolver를 위한 오류 코드)errorArgs
: 오류 메시지에서{0}
을 치환하기 위한 값defaultMessage
: 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null)
BindingResult
: 어떤 객체를 대상으로 검증하는지 target을 이미 알고 있음- target(item)에 대한 정보는 없어도 됨
- 오류 필드명은 동일하게
price
사용
축약된 오류 코드
FieldError()
를 직접 다룰 때는 오류 코드를range.item.price
와 같이 모두 입력rejectValue()
를 사용하고 부터는 오류 코드를range
로 간단하게 입력MessageCodesResolver
이해
🔶 errors.properties
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
✔ reject()
void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String
defaultMessage);
📌 오류 코드와 메시지 처리 3
✔ 자세한 오류 코드
required.item.itemName
: 상품 이름은 필수 입니다.range.item.price
: 상품의 가격 범위 오류 입니다.
✔ 단순한 오류 코드
required
: 필수 값 입니다.range
: 범위 오류 입니다.- 여러 곳에서 사용 가능
required: 필수 값 입니다.
required
라는 메시지만 있으면 이 메시지를 선택해서 사용함
#Level1
required.item.itemName: 상품 이름은 필수 입니다.
#Level2
required: 필수 값 입니다.
➡ 우선순위로 사용
➡ 스프링은 MessageCodesResolver
라는 것으로 이러한 기능을 지원
📌 오류 코드와 메시지 처리 4
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.MessageCodesResolver;
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);
assertThat(messageCodes).containsExactly(
"required.item.itemName",
"required.itemName",
"required.java.lang.String",
"required"
);
}
}
- 검증 오류 코드로 메시지 코드들을 생성
MessageCodesResolver
인터페이스DefaultMessageCodesResolver
는 기본 구현체- 주로
ObjectError
,FieldError
함께 사용
⚡ 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"
4. "typeMismatch"
동작 방식
rejectValue()
,reject()
는 내부에서MessageCodesResolver
를 사용- 여기에서 메시지 코드들 생성
FieldError
,ObjectError
의 생성자를 보면, 오류 코드를 하나가 아니라 여러 오류 코드를 가질 수 있음MessageCodesResolver
를 통해서 생성된 순서대로 오류 코드를 보관- 이 부분을
BindingResult
의 로그를 통해서 확인
codes [range.item.price, range.price, range.java.lang.Integer, range]
✔ FieldError
rejectValue("itemName", "required")
- 다음 4가지 오류 코드를 자동으로 생성
required.item.itemName
required.itemName
required.java.lang.String
required
✔ ObjectError
reject("totalPriceMin")`
- 다음 2가지 오류 코드를 자동으로 생성
totalPriceMin.item
totalPriceMin
오류 메시지 출력
- 타임리프 화면을 렌더링 할 때
th:errors
가 실행 - 만약 이때 오류가 있다면 생성된 오류 메시지 코드를 순서대로 돌아가면서 메시지를 찾고 없으면 디폴트 메시지 출력
📌 오류 코드와 메시지 처리 5
⚡ 오류 코드 관리 전략
- 핵심은 구체적인 것에서! 덜 구체적인 것으로!
- 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} 까지 허용합니다.
- 객체 오류와 필드 오류로 나눔
- 범용성에 따라 레벨을 나눔
✔ itemName
의 경우 required
검증 오류 메시지가 발생하면 다음 코드 순서대로 메시지가 생성
1. required.item.itemName
2. required.itemName
3. required.java.lang.String
4. required
➡ 순서대로 MessageSource
에서 메시지에서 찾음
➡ 구체적인 것에서 덜 구체적인 순서대로
⚡ ValidationUtils
✔ ValidationUtils 사용 전
if(!StringUtils.hasText(item.getItemName())){
bindingResult.rejectValue("itemName", "required", "기본: 상품 이름은 필수입니다.");
}
✔ ValidationUtils 사용 후
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
⚡ 정리
rejectValue()
호출MessageCodesResolver
를 사용해서 검증 오류 코드로 메시지 코드들을 생성new FieldError()
를 생성하면서 메시지 코드들을 보관th:erros
에서 메시지 코드들로 메시지를 순서대로 메시지에서 찾고, 노출
📌 오류 코드와 메시지 처리 6
✔ 스프링이 직접 만든 오류 메시지 처리
- 개발자가 직접 설정한 오류 코드
rejectValue()
를 직접 호출 - 스프링이 직접 검증 오류에 추가한 경우(주로 타입 정보가 맞지 않음)
✔ 메시지 코드 전략의 강점
price
필드에 문자 "A"를 입력- 로그를 확인해보면
BindingResult
에FieldError
가 담겨있고, 다음과 같은 메시지 코드들 생성
codes[typeMismatch.item.price,typeMismatch.price,typeMismatch.java.lang.Integer,ty peMismatch]
typeMismatch.item.price
typeMismatch.price
typeMismatch.java.lang.Integer
typeMismatch
➡ 스프링은 타입 오류가 발생하면 typeMismatch
라는 오류 코드를 사용
➡ 이 오류 코드가 MessageCodesResolver
를 통하면서 4가지 메시지 코드가 생성된 것
실행
- 아직
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.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.
📌 Validator 분리
➡ 복잡한 검증 로직을 별도로 분리 (별도의 클래스로 역할을 분리, 재사용)
ItemValidator
package hello.itemservice.web.validation;
import hello.itemservice.domain.item.Item;
import org.springframework.stereotype.Component;
import org.springframework.validation.Errors;
import org.springframework.validation.ValidationUtils;
import org.springframework.validation.Validator;
@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;
ValidationUtils.rejectIfEmptyOrWhitespace(errors, "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()>10000){
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
ItemValidator 직접 호출
ValidationItemControllerV2 - addItemV5()
@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}";
}
⚡ WebDataBinder
- 스프링이
Validator
인터페이스를 별도로 제공하는 이유는 체계적으로 검증 기능을 도입하기 위해서 Validator
인터페이스를 사용해서 검증기를 만들면 스프링의 추가적인 도움을 받을 수 있음WebDataBinder
: 스프링의 파라미터 바인딩의 역할을 해주고 검증 기능도 내부에 포함
ValidationControllerV2
@InitBinder
public void init(WebDataBinder dataBinder){
log.info("init binder {}", dataBinder);
dataBinder.addValidators(itemValidator);
}
WebDataBinder
에 검증기를 추가하면 해당 컨트롤러에서는 검증기를 자동으로 적용할 수 있음@InitBinder
해당 컨트롤러에만 영향을 줌(글로벌 설정은 별도로 해야함)
⚡ @Validated 적용
ValidationItemControllerV2 - addItemV6()
@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}";
}
동작 방식
@Validated
는 검증기를 실행하라는 애노테이션- 이 애노테이션이 붙으면 앞서
WebDataBinder
에 등록한 검증기를 찾아서 실행 - 여러 검증기를 등록한다면 그 중에 어떤 검증기가 실행되어야 할지 구분이 필요
- 이때
supports()
가 사용
- 이 애노테이션이 붙으면 앞서
supports(Item.class)
호출되고, 결과가true
이므로ItemValidator
의validate()
가 호출
✔ 글로벌 설정 - 모든 컨트롤러에 다 적용
package hello.itemservice;
import hello.itemservice.web.validation.ItemValidator;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import javax.xml.validation.Validator;
@SpringBootApplication
public class ItemServiceApplication {
public static void main(String[] args) {
SpringApplication.run(ItemServiceApplication.class, args);
}
@Override
public Validator getValidator(){
return new ItemValidator();
}
}
주의❗
글로벌 설정을 하면 BeanValidator가 자동 등록되지 않음
➡ 글로벌 설정을 직접 사용하는 경우는 드물다.
cf)
- 검증시 @Validated @Valid 둘다 사용가능
- javax.validation.@Valid 를 사용하려면 build.gradle 의존관계 추가가 필요
implementation 'org.springframework.boot:spring-boot-starter-validation'
- @Validated 는 스프링 전용 검증 애노테이션이고, @Valid 는 자바 표준 검증 애노테이션
김영한님의 <스프링 MVC 2편 - 백엔드 웹 개발 활용 기술_김영한>을 수강하고 작성한 글입니다

PREV
[스프링 MVC 2편] 3. 메시지, 국제화
📌 메시지, 국제화 소개 ⚡ 메시지 기능 상품명이라는 단어를 모두 상품이름으로 고쳐야할 때 사용 ex) 🔶 messages.properties 메시지 관리용 파일 생성 item=상품 item.id=상품 ID item.itemName=상품명 item.p
nyeroni.tistory.com
NEXT
[스프링 MVC 2편] 5. 검증2 - Bean Validation
📌 Bean Validation ⚡ 정의 검증 기능을 매번 코드로 작성하면 번거로움 Bean Validation : 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화한 것 특정한 구현체가 아니라 Bean Validation 2.
nyeroni.tistory.com
'인프런 Spring 강의 정리 > 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술' 카테고리의 다른 글
[스프링 MVC 2편] 6. 로그인 처리1 - 쿠키, 세션 (2) | 2024.01.25 |
---|---|
[스프링 MVC 2편] 5. 검증2 - Bean Validation (0) | 2024.01.25 |
[스프링 MVC 2편] 3. 메시지, 국제화 (1) | 2024.01.25 |
[스프링 MVC 2편] 2. 타임리프 - 스프링 통합과 폼 (1) | 2024.01.25 |
[스프링 MVC 2편] 1. 타임리프 - 기본 기능 (1) | 2024.01.25 |