검증 요구사항
- 타입 검증
- 가격, 수량에 문자가 들어가면 검증 오류 처리
- 필드 검증
- 상품명: 필수, 공백X
- 가격: 1000원 이상, 1백만원 이하
- 수량: 최대 9999
- 특정 필드의 범위를 넘어서는 검증
- 가격*수량의 합은 10,000원 이상
검증 처리 과정
상품 저장 성공시

- 상품 저장 요청 -> 검증 로직 통과 -> 상품 저장 -> 상품 상세 화면으로 redirect
상품 저장 검증 실패 시

- 상품 저장 요청 -> 검증 로직 실패 -> Model에 검증 오류 결과 포함해서 담은 채로 상품 등록 폼
1. 검증 처리 (1)
1) 컨트롤러 Controller
@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}";
}
(1) 검증 오류 보관
Map<String, String> errors = new HashMap<>();
- 검증시 오류 발생하면 errors에 담음
- 어떤 필드에서 오류가 발생했는지 구분하기 위해 오류가 발생한 필드명을 key로 사용
(2) 필드 검증 로직
if (!StringUtils.hasText(item.getItemName())) {
errors.put("itemName", "상품 이름은 필수입니다.");
}
(3) 특정 필드의 범위를 넘어서는 검증 로직
// 특정 필드가 아닌 복합 룰 검증
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice);
}
}
- 특정 필드를 넘어서는 오류 : 특정 필드 이름을 넣을 수 없으므로 globalError라는 key 사용
(4) 검증 실패시 다시 입력폼
// 검증에 실패하면 다시 입력 폼으로 (에러가 있으면)
if (!errors.isEmpty()) {
log.info("errors = {}", errors);
model.addAttribute("errors", errors);
return "validation/v1/addForm";
}
- 오류 메시지가 하나라도 있으면 오류 메시지 출력을 위해 model에 errors 담고, 입력폼으로 보냄
(!errors.isEmpty() -> errors가 비어있지 않으면 -> errors가 하나라도 있으면)
(5) 검증 실패 후 입력폼에 데이터 그대로 보여주기
- @ModelAttribute Item item
-> model에 item을 담음
-> 검증 실패 후 등록 폼으로 되돌아왔을 때 model에 item이 담겨있음
-> 등록 폼에서 ${item}을 쓸 때 itemName, price, quantity에 검증 실패 후 입력했던 데이터가 그대로 담겨있음
2) 등록 폼 addForm
<!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}"
th:class="${errors?.containsKey('price')} ? 'form-control field-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-control field-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>
(1) css 추가
.field-error {
border-color: #dc3545;
color: #dc3545;
}
- 오류 메시지 빨간색으로 강조
(2) 글로벌 오류 메시지
<div th:if="${errors?.containsKey('globalError')}">
<p class="field-error" th:text="${errors['globalError']}">오류 메시지</p>
</div>
- th:if : errors에 내용이 있을 때 -> 'globalError' 라는 key를 포함하고 있으면 'globalError' 메시지 출력
💡 errors?.containsKey()
등록폼에 처음 들어갈 때는 errors가 null
-> errors.containsKey() 호출 -> null.containsKey()
-> NullPointerException 발생
errors?. : errors가 null일 때 NullPointerException이 발생하는 대신, 아예 null 반환
-> th:if 실행 X -> 오류 메시지 출력 X
(3) 필드 오류 처리
<input type="text" id="itemName" th:field="*{itemName}"
th:class="${errors?.containsKey('itemName')} ? 'form-control field-error' : 'form-control'"
class="form-control" placeholder="이름을 입력하세요">
<input type="text"
th:classappend="${errors?.containsKey('itemName')} ? 'field-error' : _"
class="form-control">
- 필드 오류 처리
- 위 둘중 하나 사용 ( th:class ~~ / th:classappend ~~ )
- 해당 필드에 오류가 있으면 field-error 클래스 -> 폼의 색깔을 빨간색으로 강조,
오류가 없으면 기본 폼 ( _ : (No-Operation) 아무것도 X )
<div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}">
상품명 오류
</div>
- 필드 오류 처리 - 메시지
- errors에 'itemName'이라는 키가 있으면 itemName의 오류메시지 출력
3) 실행

로그 출력 (오류 메시지)

근데 위 방법대로 하면 문제점이 多
- 뷰 템플릿에서 중복되는 부분, 좀 복잡함
- 타입 오류 처리 안됨 (숫자 타입에 문자 들어갈 경우)
등등
2. 검증 처리 (2) BindingResult
1) BindingResult
- 스프링이 제공하는 검증 오류를 보관하는 객체 (검증 오류가 발생하면 여기에 보관)
- BindingResult가 있으면 @ModelAttribute에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출됨 !
예) 숫자 타입에 문자를 넣었을 때

- BindingResult가 없으면 -> 400 오류 발생 -> 컨트롤러 호출 X -> 오류 페이지로 이동
- BIndingResult가 있으면 -> 오류 정보(FieldError)를 BindingResult에 담아서 컨트롤러 호출
* BindingResult에 검증 오류를 적용하는 방법 3가지
- @ModelAttribute의 객체에 타입 오류 등으로 바인딩 자체가 실패하는 경우 스프링이 FieldError를 따로 생성해서 BindingResult에 넣어줌
- 개발자가 직접 넣어줌 (아래 컨트롤러 로직)
- Validator 사용 -> 나중에
* BindingResult 파라미터의 위치
- 반드시 @ModelAttribute 다음에 와야 함 !
- ModelAttribute의 객체의 바인딩 결과를 담고 있기 때문
- BindingResult는 Model에 자동으로 포함
2) 컨트롤러 Controller
@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
// 검증 로직
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 까지 허용합니다."));
}
// 특정 필드가 아닌 복합 룰 검증
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}";
}
(1) 필드 오류 - 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 : 오류 기본 메시지
(2) 글로벌 오류 - ObjectError
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 생성자 요약
public ObjectError(String objectName, String defaultMessage) {}
- 특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성해서 bindingResult에 담아둠
- objectName : @ModelAttribute 이름
- defaultMessage : 오류 기본 메시지
3) 등록 폼 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="${#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>
<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> <!-- /container -->
</body>
</html>
타임리프 스프링 검증 오류 통합 기능
- BindingResult를 활용해서 편리하게 검증 오류 표현
(1) 글로벌 오류 처리
<div th:if="${#fields.hasGlobalErrors()}">
<p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">글로벌 오류 메시지</p>
</div>
- #fields : BindingResult가 제공하는 검증 오류가 접근
- 글로벌 오류가 여러개일 수도 있으니 th:each 로 여러개 출력
(2) 필드 오류 처리
<input type="text" id="itemName" th:field="*{itemName}"
th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요">
<div class="field-error" th:errors="*{itemName}">
상품명 오류
</div>
- th:errors : 해당 필드에 오류가 있는 경우에 태그 출력
- th:errorclass : th:field에서 지정한 필드에 오류가 있으면 class 정보 추가
4) 실행

-> 근데 입력한 데이터가 유지되지 않음
3. 검증 처리 (3) FieldError, ObjectError
- 사용자 입력 오류 데이터가 화면에 남도록 유지
1) 컨트롤러
@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()) {
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}";
}
(1) FieldError 생성자 ( 두 가지 생성자 제공 ) ≒ ObjectError 생성자
public FieldError(String objectName, String field, String defaultMessage);
public FieldError(String objectName, String field, @Nullable Object rejectedValue, boolean bindingFailure, @Nullable String[] codes, @NullableObject[] arguments, @Nullable String defaultMessage)
- objectName : 오류가 발생한 객체 이름
- field : 오류 필드
- rejectedValue : 사용자가 입력한 값 (거절된 값)
- bindingFailure : 타입 오류 같은 바인딩 실패인지(true), 검증 실패인지(false) 구분 값
- codes : 메시지 코드
- arguments : 메시지에서 사용하는 인자
- defaultMessage : 기본 오류 메시지
(2) 오류 발생시 사용자 입력 값 유지
- FieldError : 오류 발생시 사용자 입력 값을 저장하는 객체
- rejectedValue : 오류 발생시 사용자 입력 값을 저장하는 필드
(3) 타임리프의 사용자 입력 값 유지
th:field="*{price}"
- th:field : 정상 상황 -> 모델 객체의 값 출력
오류 발생 -> FieldError에서 보관한 값 출력

(4) 스프링의 바인딩 오류 처리
- 타입 오류 -> 바인딩 실패 -> 스프링이 FieldError 생성 -> FieldError의 rejectedValue 필드에 사용자가 입력한 값 저장 -> 해당 오류를 BindingResult에 담아서 컨트롤러 호출 -> 사용자 입력 값 유지 + 오류 메시지 정상 출력

4. 오류 코드와 메시지 처리 1 - FieldError 직접 사용
1) FieldError 생성자 확인 ( 두 가지 생성자 제공 ) ≒ ObjectError 생성자
public FieldError(String objectName, String field, String defaultMessage);
public FieldError(String objectName, String field, @Nullable Object rejectedValue, boolean bindingFailure, @Nullable String[] codes, @NullableObject[] arguments, @Nullable String defaultMessage)
- objectName : 오류가 발생한 객체 이름
- field : 오류 필드
- rejectedValue : 사용자가 입력한 값 (거절된 값)
- bindingFailure : 타입 오류 같은 바인딩 실패인지(true), 검증 실패인지(false) 구분 값
- codes : 메시지 코드
- arguments : 메시지에서 사용하는 인자
- defaultMessage : 기본 오류 메시지
--> codes, arguments 제공 : 오류 발생시 오류코드로 메시지 찾기
2) errors 메시지 파일 생성
(1) errors.properties 추가
* src/main/resources/errors.properties
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
- messages.properties에 추가해도 되지만, 오류 메시지를 구분하기 쉽게 별도의 파일로 관리
- errors_en.properties 파일을 생성하면 오류 메시지도 국제화 처리 가능
(2) 스프링 부트 메시지 설정 추가
* application.properties
spring.messages.basename=messages,errors
- 스프링 부트가 해당 메시지 파일을 인식할 수 있도록 설정
- messages.properties, errors.properties 두 파일 모두 인식
(생략하면 messages.properties를 기본으로 인식)
3) errors에 등록한 메시지 사용
(1) 컨트롤러
@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("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.addError(new FieldError("item", "price", item.getPrice(), false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}, null));
- ctrl + p --> 매개변수 확인
- errors 파일 내용
ex) range.item.price=가격은 {0} ~ {1} 까지 허용합니다. - 매개변수 codes 부분 (new String[] 을 받음)
- errors 파일에서 설정한 "range.item.price" 메시지 코드 입력
- 배열로 여러 값 전달 할 수 있음
new String[]{"required.item.itemName", "required.message"} 이런식으로 해놓으면
프로퍼티 파일에서 순서대로 찾아서 매칭되는 메시지 사용
- 매개변수 arguments 부분 (new Object[] 를 받음)
- 메시지 코드의 파라미터로 치환할 값 전달
-> 근데 FieldError, ObjectError는 다루기 좀 복잡해
5. 오류 코드와 메시지 처리 2 - rejectValue(), reject() 사용
1) BindingResult의 역할
- 검증해야 할 target 바로 다음에 위치
-> 본인이 검증해야 할 객체인 target을 알고 있음
컨트롤러에서 확인
log.info("objectName={}", bindingResult.getObjectName());
log.info("target={}", bindingResult.getTarget());
출력결과
objectName=item // @ModelAttribute name
target=Item(id=null, itemName=상품, price=100, quantity=1234) // target 객체
--> BindingResult는 이미 검증해야 할 객체를 알고 있음
2) rejectValue() , reject()
- BindingResult는 검증해야 할 객체를 알고 있음
-> FieldError, ObjectError를 직접 생성하지 않고, 검증 오류를 쉽게 다룰 수 있음
- BindingResult의 reject() : 검증해야 할 객체 (Object)
- BindingResult의 rejectValue() : 검증해야 할 객체의 필드
3) 기존 코드 단순화
* 컨트롤러
@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 = {}", 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}";
}
(1) rejectValue() -> 필드
void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
- field : 오류 필드명
(BindingResult는 어떤 객체를 대상으로 검증하는지 target을 알고 있음 -> 객체 없이 필드만 적어도 됨) - errorCode : 오류 코드
(error 메시지에 등록된 코드 : "range.item.price"
매개변수에 작성하는 코드 : "range"
-> 축약 오류 코드 (object + 필드를 합쳐서 뭔가 규칙에 따라 정해짐 -> 뒤에 설명 MessageCodesResolver)) - errorArgs : 오류 메시지에서 프로퍼티를 치환할 값
- defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지
(2) reject() -> 객체
void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
- 가격 * 수량 -> 특정 필드 대상 X -> reject()
- errorCode : 축약 코드
- errorAgrs : 프로퍼티
-> errors 파일 오류코드 : "required.item.itemName"
-> FieldError() 를 직접 다룰 때 오류코드 : "required.item.itemName" 그대로 모두 입력했음
-> rejectValue() 를 사용할 때 오류코드 : "required"만 적어도 오류 메시지를 잘 찾아서 출력함
-> 왜 그러냐???
-> MessageCodesResolver 때문
6. 오류 코드와 메시지 처리 3 - MessageCodesResolver
1) 테스트 코드로 MessageCodesResolver 알아보기
* 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 javax.print.DocFlavor;
import static org.assertj.core.api.Assertions.*;
public class MessageCodesResolverTest {
MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();
@Test
void messageCodesResolverObject() {
String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");
for (String messageCode : messageCodes) {
System.out.println("messageCode = " + messageCode);
}
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"
);
}
}
(1) MessageCodesResolver
MessageCodesResolver codesResolver = new DefaultMessageCodesResolver();
- 스프링이 제공
- 검증 오류 코드 (ex - "required") 로 메시지 코드들을 만들어줌
- MessageCodesResolver - 인터페이스
DefaultMessageCodesResolver - 기본 구현체
(2) '객체' 오류
@Test
void messageCodesResolverObject() {
String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");
for (String messageCode : messageCodes) {
System.out.println("messageCode = " + messageCode);
}
assertThat(messageCodes).containsExactly("required.item", "required");
}
출력 결과
messageCode = required.item
messageCode = required
- MessageCodesResolver가 오류코드("required"), 객체("item")를 가지고 메시지 코드 생성
(3) '필드' 오류
@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"
);
}
출력 결과
messageCode = required.item.itemName
messageCode = required.itemName
messageCode = required.java.lang.String
messageCode = required
- MessageCodesResolver가 오류코드("required"), 객체("item"), 필드("itemName")를 가지고 메시지 코드 생성
2) DefaultMessageCodesResolver의 기본 메시지 생성 규칙
(1) 객체 오류
객체 오류의 경우 다음 순서로 2가지 생성
1.: code + "." + object name
2.: code
예) 오류 코드: required, object name: item
1.: required.item
2.: required
(2) 필드 오류
필드 오류의 경우 다음 순서로 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"
3) 동작 방식
- bindingResult의 rejectValue() / reject() 실행
(예 - bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null)) - rejectValue, reject 안에서 MessageCodesResolver 호출
- codesResolver를 실행해서 메시지 코드 생성
(예 - String[] messageCodes = codesResolver.resolveMessageCodes("required","item", "itemName", String.class)) - 메시지 코드가 배열로 뽑혀나옴 (구체적인 것부터)
(예 - required.item.itemName / required.itemName / required.java.lang.String / required) - new FieldError, new ObjectError를 만들어서 codes 매개변수에 뽑혀나온 메시지 코드를 배열로 넣음
* 컨트롤러 로그로 확인 (codes [ ~~~ ])
// 검증에 실패하면 다시 입력 폼으로 (에러가 있으면)
if (bindingResult.hasErrors()) {
log.info("errors = {}", bindingResult);
return "validation/v2/addForm";
}
Field error in object 'item' on field 'itemName': rejected value []; codes [required.item.itemName,required.itemName,required.java.lang.String,required]; arguments []; default message [null]
4) 결론 - 컨트롤러 ( 축약 코드만 적어도 됐던 이유 )
@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.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
(1) rejectValue("itemName", "required") 실행 - FieldError
다음 4가지 오류 코드 자동으로 생성
- required.item.itemName
- required.itemName
- required.java.lang.String
- required
(2) reject("totalPriceMin") 실행 - ObjectError
다음 2가지 오류 코드 자동으로 생성
- totalPriceMin.item
- totalPriceMin
5) 오류 메시지 출력 - 뷰 템플릿
<div class="field-error" th:errors="*{itemName}">
상품명 오류
</div>
타임리프 화면 렌더링 -> th:errors 실행 -> 오류가 있으면 위에서 생성된 오류 메시지 코드를 순서대로 돌아가면서 메시지 찾음 -> 없으면 디폴트 메시지 출력
6) 프로젝트에 적용
컨트롤러, 뷰 템플릿은 수정 필요 X
(1) 에러메시지 설정
* 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} 까지 허용합니다.
- 메시지 생성 순서
- Level1 : 구체적 (required.item.itemName)
- Level2 : 그 다음 구체적 (required.itemName)
- Level3 : 타입 (required.java.lang.String)
- Level4 : 범용적 (required)
- 위 순서대로 MessageSource에서 메시지 찾음
(2) 실행
* Level1 주석 -> Level3 에러메시지 출력

* Level2, 3 주석 -> Level4 에러메시지 출력

* Level4 주석 -> 없으면 코드에 적은 defaultMessage 적용
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required", "기본 : 상품 이름은 필수입니다.");
}

7) ValidationUtils - empty, 공백 방지
원래 코드
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required", "기본 : 상품 이름은 필수입니다.");
}
↓ 다음 한줄로 가능
ValidationUtils 적용
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
8) 정리
- 구체적인 것 -> 덜 구체적인 것
- MessageCodesResolver는 구체적인 것부터 만들고, 덜 구체적인 것을 나중에 만듦
- 복잡하게 사용하는 이유
- 모든 오류 코드에 대해 메시지를 다 정의하면 관리하기 너무 힘듦
- 크게 중요하지 않은 메시지 -> 범용성 있는 "required"
중요한 메시지 -> 구체적으로 "required.item.itemName"
- 순서
- rejectValue() / reject() 호출
- MessageCondesResolver 사용 -> 검증 오류 코드로 메시지 코드 생성
- new FieldError() / new ObjectError() 생성 -> codes 매개변수에 메시지 코드들 저장
- th:errors에서 메시지 코드들로 메시지를 순서대로 찾아서 출력
7. 오류 코드와 메시지 처리 4 - 스프링이 직접 만든 오류 메시지 처리
1) 검증 오류 코드 2가지
- 개발자가 직접 설정한 오류 코드 -> rejectValue() 직접 호출
- 스프링이 직접 검증 오류에 추가 (주로 타입 정보가 맞지 않는 경우)
2) 스프링이 직접 만든 오류 메시지 처리
* 가격 필드 (타입 - Integer)에 문자 입력할 경우

로그

codes [typeMismatch.item.price,typeMismatch.price,typeMismatch.java.lang.Integer,typeMismatch]
default message [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: "ㅂㅂㅂ"]
(1) 메시지 코드 확인
* 4가지 메시지 코드
- typeMismatch.item.price
- typeMismatch.price
- typeMismatch.java.lang.Integer
- typeMismatch
-> 타입 오류 발생 -> 스프링이 typeMismatch 오류 코드 사용 -> MessageCodesResolver로 메시지 코드 생성
(2) 에러 메시지 변경하기
* 스프링이 생성한 기본 메시지
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: "ㅂㅂㅂ"
* error.properties 추가
#추가
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.

8. Validator 분리 1
현재 컨트롤러에서 검증 로직하는 부분이 너무 긺 -> 검증 로직 부분 분리
1) 검증 로직 객체 생성
* ItemValidator
package hello.itemservice.web.validation;
import hello.itemservice.domain.item.Item;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;
@Component
public class ItemValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return Item.class.isAssignableFrom(clazz); // item == clazz인지, item == item의 자식클래스인지
}
@Override
public void validate(Object target, Errors errors) {
// 검증 대상 객체 (target을 검증 대상 객체로 형변환)
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);
}
}
}
}
(1) Validator 인터페이스
public interface Validator {
boolean supports(Class<?> clazz);
void validate(Object target, Errors errors);
}
- 검증을 체계적으로 하기 위해 스프링이 제공하는 인터페이스
- supports() : 해당 검증기를 지원하는지 여부 확인 (뒤에서 설명)
- validate(Object target, Errors errors) : target - 검증 대상 객체 , errors - BindingResult
(2) @Component
- 스프링 빈으로 등록 -> 컨트롤러에서 주입받아서 사용하기 위함
2) 컨트롤러에서 ItemValidator 호출
// 의존성 주입
private final ItemValidator itemValidator;
@PostMapping("/add")
public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
// 검증
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}";
}
- ItemValidator를 스프링 빈으로 주입받아서 직접 호출
- itemValidator.validate 호출 (검증 대상 객체, 에러)
9. Validator 분리 2 - WebDataBinder 사용
1) WebDataBinder
스프링의 파라미터 바인딩의 역할 + 검증 기능
2) 컨트롤러
private final ItemValidator itemValidator;
@InitBinder
public void init(WebDataBinder dataBinder) {
dataBinder.addValidators(itemValidator);
}
- WebDataBinder에 검증기 추가 -> 해당 컨트롤러에서 검증기 자동 적용 (직접 호출할 필요 X)
@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
// 검증에 실패하면 다시 입력 폼으로 (에러가 있으면)
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 추가
- 검증기를 실행하라는 어노테이션
- @ModelAttribute 객체에 대해 검증 , bindingResult에 에러 담음
- validator 직접 호출할 필요 X
- WebDataBinder에 등록한 검증기를 찾아서 실행
(검증기가 여러개 등록되어 있을 때 -> 구분을 위해 supports() 사용)
* 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) {...}
}
- supports
(supports(Item.class) 호출 -> clazz에 item이 담기면 -> Item에 item이 담길 수 있으면 true -> 이 때 validate 호출)
* 글로벌 설정 - 모든 컨트롤러에 다 적용 (잘 안씀)
@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {
public static void main(String[] args) {
SpringApplication.run(ItemServiceApplication.class, args);
}
@Override
public Validator getValidator() {
return new ItemValidator();
}
}
'Spring' 카테고리의 다른 글
| [인프런/스프링 MVC 2편] 6. 로그인 처리1 - 쿠키, 세션 (0) | 2023.05.25 |
|---|---|
| [인프런/스프링 MVC 2편] 5. 검증 (2) - Bean Validation (2) | 2023.05.24 |
| [인프런/스프링 MVC 2편] 3. 메시지, 국제화 (0) | 2023.05.18 |
| [인프런/스프링 MVC 2편] 2. 타임리프 - 스프링 통합과 폼 (0) | 2023.05.17 |
| [인프런/스프링 MVC 2편] 1. 타임리프 - 기본 기능 (1) | 2023.05.16 |