검증 요구사항
- 지금까지 만든 웹 애플리케이션은 폼 입력시 숫자를 문자로 작성하거나해서 검증 오류가 발생하면 오류 화면으로 바로 이동한다.
- 이렇게 되면 사용자는 처음부터 해당 폼으로 다시 이동해서 입력을 해야 한다.
- 웹 서비스는 폼 입력시 오류가 발생하면, 고객이 입력한 데이터를 유지한 상태로 어떤 오류가 발생했는지 친절하게 알려주어야 한다.
- 컨트롤러에서 이런 HTTP 요청이 정상인지 검증하는 작업이 매우 중요하다.
요구사항 추가
- 타입 검증
- 가격, 수량에 문자가 들어가면 검증 오류 처리
- 필드 검증
- 상품명: 필수, 공백X
- 가격: 1000원 이상, 1백만원 이하
- 수량: 최대 9999
- 특정 필드의 범위를 넘어서는 검증
- 가격 * 수량의 합은 10,000원 이상
클라이언트 검증와 서버 검증
- 클라이언트 검증은 조작할 수 있으므로 보안에 취약하다.
- 서버만으로 검증하면, 즉각적인 고객 사용성이 부족해진다.
- 둘을 적절히 섞어서 사용하되, 최종적으로 서버 검증은 필수
- API 방식을 사용하면 API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨주어야 함
프로젝트 설정 (v1)
- 스프링 이니셜라이저를 통해 프로젝트를 생성하자.
- 프로젝트 선택
- Project
- Gradle - Groovy Project
- Language
- Java
- Spring Boot
- 3.x.x
- Project
- Project Metadata
- Group
- hello
- Artifact
- validation
- Name
- validation
- Package name
- hello.validation
- Packaging
- Jar (주의!)
- Java
- 17 또는 21
- Group
- Dependencies
- Spring Web
- Thymeleaf
- Lombok
- 프로젝트 선택
추가 설정
- 이전 프로젝트인
message
에서 일부 소스를 가져오자.- .java 파일의 패키지는 message 부분을 validation으로 변경하자.
- 가져올 목록
- src/main
- java/hello/itemservice
- domain/item
- Item.java
- ItemRepository.java
- domain/item
- web/item/basic
- BasicItemController.java
- java/hello/itemservice
- resources
- static
- css
- bootstrap.min.css
- index.html
- css
- templates/basic
- addForm.html
- editForm.html
- item.html
- items.html
- messages.properties
- messages_en.properties
- static
- src/main
- 이번에는 프로젝트는 버전별로 검증 단계가 변화하는 과정을 확인할 것이다.
/basic
을 일괄 변경으로/validation/v1
으로 변경하자.
BasicItemController
의 이름을ValidationItemControllerV1
으로 변경하자.ValidationItemControllerV1
에서basic/
을validation/v1
으로 변경하자.resources/templates/basic
에 있는 html 파일들을resources/templates/validation/v1
폴더 생성 후 이동하자.
검증 직접 처리 - 정의
- 사용자가 상품 등록 폼에서 정상 범위의 데이터를 입력하면, 서버에서는 검증 로직이 통과하고, 상품을 저장하고, 상품 상세 화면으로 redirect한다.
- 고객이 상품 등록 폼에서 상품명을 입력하지 않거나, 가격, 수량 등이 너무 작거나 커서 검증 범위를 넘어서면, 서버 검증 로직이 실패해야 한다.
- 이렇게 검증에 실패한 경우 고객에게 다시 상품 등록 폼을 보여주고, 어떤 값을 잘못 입력했는지 친절하게 알려주어야 한다.
검증 직접 처리 - 개발
컨트롤러
ValidationItemControllerV1
에서 addItemV1 ~ addItemV6까지 제거하자.- 대신에 아래 코드를 추가하자.
@PostMapping("/add")
public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) {
//검증 오류 결과를 보관
Map<String, String> errors = new HashMap<>();
//검증 로직
//org.springframework.util.StringUtils 필요
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}";
}
- 검증시 오류가 발생하면 errors 에 담아둔다.
- 이 때 어떤 필드에서 오류가 발생했는지 구분하기 위해 오류가 발생한 필드명을 key로 사용한다.
- 이후 뷰에서 이 데이터를 사용해서 고객에게 친절한 오류 메시지를 출력할 수 있다.
- 특정 필드를 넘어서는 오류를 처리해야 할 수도 있다.
- 이때는 필드 이름을 넣을 수 없으므로 globalError라는 key를 사용한다.
- 만약 검증에서 오류 메시지가 하나라도 있으면 오류 메시지를 출력하기 위해서는
model에 errors를 담고, 입력 폼이 있는 뷰 템플릿으로 보낸다.
HTML
resources/templates/validation/v1/addForm.html
을 아래와 같이 수정하자.th:text="${errors['globalError']}">
처럼 에러 메시지를 표기하자.
<!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>
Safe Navigation Operator
- 만약
th:text="${errors['globalError']}">
에서 error가 null이면 어떻게 될까?- 등록폼에 진입한 시점에는 errors 가 없다.
- 그래서
errors.containsKey()
를 호출하는 순간NullPointerException
이 발생한다.
- 그래서
errors.containsKey('quantity')
를errors?.containsKey('quantity')
처럼 변경해서 사용한다.?
를.
앞에 붙이게 되면 대상 객체가 null이 아닐 때만 동작하게 된다.
프로젝트 설정 (v2)
- 이번에는 다음 스텝으로 넘어가보자.
ValidationItemControllerV1
를 그대로 복사해서ValidationItemControllerV2
를 만들자.ValidationItemControllerV2
에서 클래스명을 제외하고 모든v1
을v2
로 변경하자.resources/templates/validation
경로에서v1
폴더에 있는 모든 파일을v2
폴더를 생성한 후 복사 및 붙여넣기를 해주자.
BindingResult (1)
- 스프링이 제공하는 검증 오류 처리 방법을 알아보자.
- 스프링에서는
BindingResult
을 통해 오류를 검증한다.
컨트롤러
ValidationItemControllerV2
에서 기존의addItem
을 주석 처리한 후에 아래의 코드를 추가하자.- 여기서 주의할 점은
BindingResult bindingResult
파라미터의 위치는@ModelAttribute Item item
다음에 와야 한다.
- 여기서 주의할 점은
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}";
}
FieldError
public FieldError(String objectName, String field, String defaultMessage) {}
- 필드에 오류가 있으면 FieldError 객체를 생성해서 bindingResult에 담아두면 된다.
- 파라미터
- objectName
- @ModelAttribute 이름
- field
- 오류가 발생한 필드 이름
- defaultMessage
- 오류 기본 메시지
- objectName
ObjectError
public ObjectError(String objectName, String defaultMessage) {}
- 특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성해서 bindingResult에 담아두면 된다.
- 파라미터
- objectName
- @ModelAttribute 의 이름
- defaultMessage
- 오류 기본 메시지
- objectName
HTML
resources/templates/validation/v2/addForm.html
에서 form 내부의 오류 메시지 영역을 아래와 같이 수정하자.
<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>
타임리프 스프링 검증 오류 통합 기능
- 타임리프는 스프링의
BindingResult
를 활용해서 편리하게 검증 오류를 표현하는 기능을 제공한다. - 종류
#fields
#fields
로BindingResult
가 제공하는 검증 오류에 접근할 수 있다.
th:errors
- 해당 필드에 오류가 있는 경우에 태그를 출력한다.
th:if
의 편의 버전이다.
th:errorclass
th:field
에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.
- 메뉴얼
BindingResult (2)
BindingResult
는 스프링이 제공하는 검증 오류를 보관하는 객체이다.- 검증 오류가 발생하면 여기에 보관하면 된다.
BindingResult
가 있으면@ModelAttribute
에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출된다!
@ModelAttribute
에 바인딩 시 타입 오류가 발생하면?
BindingResult
가 없는 경우- 400 오류가 발생하면서 컨트롤러가 호출되지 않고, 오류 페이지로 이동한다.
BindingResult
가 있는 경우- 오류 정보(FieldError)를
BindingResult
에 담아서 컨트롤러를 정상 호출한다.
- 오류 정보(FieldError)를
BindingResult에 검증 오류를 적용하는 3가지 방법
- @ModelAttribute 의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 FieldError 생성해서 BindingResult 에 넣어준다.
- 개발자가 직접 넣어준다.
- Validator 사용
BindingResult 사용 시 알아야 사항
- 메서드의 파라미터로 사용할 때
BindingResult
는 검증할 대상 바로 다음에 와야한다. - 만약에
Item
에 대해 검증하려고 한다면@ModelAttribute Item item
바로 다음에BindingResult
가 와야 한다. BindingResult
는 Model에 자동으로 포함된다
BindingResult와 Errors
BindingResult
는 인터페이스이고,Errors
인터페이스를 상속받고 있다.- 실제 넘어오는 구현체는
BeanPropertyBindingResult
이다. - 그런데
BeanPropertyBindingResult
는BindingResult
와Errors
둘다 구현하고 있다. - 그래서
BindingResult
대신에Errors
를 사용해도 된다.
- 실제 넘어오는 구현체는
Errors
인터페이스는 단순한 오류 저장과 조회 기능을 제공한다.BindingResult
는 여기에 더해서 추가적인 기능들을 제공한다.addError()
도BindingResult
가 제공한다.
- 주로 관례상 BindingResult 를 많이 사용한다.
- 패키지
BindingResult
org.springframework.validation.BindingResult
Errors
org.springframework.validation.Errors
FieldError, ObjectError
- 이번에는 데 오류가 발생하는 경우 고객이 입력한 내용이 모두 사라지는 현상을 고쳐보자.
컨트롤러
ValidationItemControllerV2
에서addItemV1
을 주석 처리하고 아래의 코드를 추가하자.
@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
- 기본 오류 메시지
- objectName
- ObjectError 도 유사하게 두 가지 생성자를 제공한다.
- 사용자의 입력 데이터가 컨트롤러의 @ModelAttribute 에 바인딩되는 시점에 오류가 발생하면 모델 객체에 사용자 입력 값을 유지하기 어렵다.
- 만약에 가격에 숫자가 아닌 문자가 입력된다면 가격은 Integer 타입이므로 문자를 보관할 수 있는 방법이 없다.
- 그래서 오류가 발생한 경우 사용자 입력 값을 보관하는 별도의 방법이 필요하다.
- 그리고 이렇게 보관한 사용자 입력 값을 검증 오류 발생시 화면에 다시 출력하면 된다.
- FieldError는 오류 발생시 사용자 입력 값을 저장하는 기능을 제공한다.
- FieldError에서 rejectedValue가 바로 오류 발생시 사용자 입력 값을 저장하는 필드다.
- bindingFailure는 타입 오류 같은 바인딩이 실패했는지 여부를 적어주면 된다.
- 여기서는 바인딩이 실패한 것은 아니기 때문에 false 를 사용한다.
타임리프의 사용자 입력 값 유지
- 타임리프의 th:field 는 매우 똑똑하게 동작한다.
- 정상 상황에는 모델 객체의 값을 사용한다.
- 오류가 발생하면 FieldError에서 보관한 값을 사용해서 값을 출력한다.
th:field="*{price}"
같은 경우를 의미한다.
스프링의 바인딩 오류 처리
- 타입 오류로 바인딩에 실패하면 스프링은 FieldError를 생성하면서 사용자가 입력한 값을 넣어둔다.
- 그리고 해당 오류를 BindingResult에 담아서 컨트롤러를 호출한다.
- 즉, 타입 오류 같은 바인딩 실패시에도 사용자의 오류 메시지를 정상 출력할 수 있다.
오류 코드와 메시지 처리1
- 이번에는 오류 메시지를 관리하는 방법을 알아보자.
- 기존의
messages.properties
를 사용해도 되긴 하다.- 하지만 쉬운 관리를 위해
errors.properties
를 생성하자.
- 하지만 쉬운 관리를 위해
errors.properties
도 스프링이 알아서 인식할 수 있게application.properties
를 수정하자.spring.messages.basename=messages,errors
- 참고로
spring.messages.basename
으로 정의한 이름들은 모두 국제화가 가능하다.- 즉,
errors_en.properties
라고 만들면 영문 버전이 되는 것이다.
- 즉,
메세지 추가
errors.properties
추가 후 아래 내용을 추가하자.
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
컨트롤러
ValidationItemControllerV2
에서addItemV2
을 주석 처리하고 아래의 코드를 추가하자.
@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}";
}
- FieldError에 배열을 넘기면
errors.properties
에 정의된 변수 영역에 값을 치환할 수 있다.
오류 코드와 메시지 처리2
- FieldError, ObjectError는 다루기 너무 번거롭다.
- 그러면 이번에는
BindingResult
가 제공하는rejectValue()
와reject()
를 사용해보자.
컨트롤러
ValidationItemControllerV2
에서addItemV3
을 주석 처리하고 아래의 코드를 추가하자.
@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
- 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지
- field
BindingResult
는 어떤 객체를 대상으로 검증하는지 target을 이미 알고 있다.- 그래서
target(item)
에 대한 정보는 없어도 된다.
- 그래서
축약된 오류 코드
FieldError()
를 직접 다룰 때는 오류 코드를range.item.price
와 같이 모두 입력했다.- 그런데
rejectValue()
를 사용하고 부터는 오류 코드를 range로 간단하게 입력했다. - 그래도 오류 메시지를 잘 찾아서 출력한다.
- 그런데
MessageCodesResolver
를 통한 규칙이 이를 가능하게 한다.
오류 코드와 메시지 처리3
- 오류 코드는 다양하게 만들 수 있다.
- 자세한 버전
range.item.price =상품의 가격 범위 오류 입니다.
- 간단한 버전
range=범위 오류 입니다.
- 자세한 버전
- 단순하게 만들면 범용성이 좋아서 여러곳에서 사용할 수 있다.
- 대신에 메시지를 세밀하게 작성하기 어렵다.
- 그렇다고 너무 자세하게 만들면 범용성이 떨어진다.
- 그래서 가장 좋은 방법은 범용성으로 사용하다가, 세밀하게 작성해야 하는 경우에는 세밀한 내용이 적용되도록 메시지에 단계를 두는 방법이다.
required.item.itemName
와required
를 비교하면 당연히required.item.itemName
가 세밀한 메시지 코드다.- 스프링은 메시지 코드의 우선순위에 따라 자동으로 알맞는 메시지를 가져온다.
- 이는 스프링이
MessageCodesResolver
을 통해 제공하는 기능이다. xxx.yyy.zzz
처럼.
이 많이 들어갈 수록 세밀하다고 판단하여 높은 우선순위로 보여지는 메시지가 된다.
메시지 추가
errors.properties
에 아래와 같이 추가하자.- 코드가 겹치니 첫번째
required.item.itemName
는 주석 처리하자.
- 코드가 겹치니 첫번째
#Level1
required.item.itemName: 상품 이름은 필수 입니다.
#Level2
required: 필수 값 입니다.
오류 코드와 메시지 처리4
- 테스트를 통해
MessageCodesResolver
에 대해 알아보자.
테스트 생성
package hello.validation;
import org.junit.jupiter.api.Test;
import org.springframework.validation.DefaultMessageCodesResolver;
import org.springframework.validation.MessageCodesResolver;
import static org.assertj.core.api.Assertions.assertThat;
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
- 검증 오류 코드로 메시지 코드들을 생성한다.
MessageCodesResolver
는 인터페이스이고, 기본 구현체는DefaultMessageCodesResolver
이다.- 주로 ObjectError와 FieldError를 함께 사용한다.
DefaultMessageCodesResolver의 기본 메시지 생성 규칙
- 객체 오류
- code +
.
+ object name - code
예) 오류 코드: required, object name: item
1.: required.item
2.: required
- code +
- 필드 오류
- 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”
- code +
동작 방식
- rejectValue()와 reject()는 내부에서
MessageCodesResolver
를 사용한다.- 여기에서 메시지 코드들을 생성한다.
FieldError
와ObjectError
의 생성자를 보면, 오류 코드를 하나가 아니라 여러 오류 코드를 가질 수 있다.MessageCodesResolver
를 통해서 생성된 순서대로 오류 코드를 보관한다.
오류 코드와 메시지 처리5
- 오류 코드는 관리하기 위한 전략이 필요하다.
- 핵심은 구체적인 것을 먼저 만들고 덜 구체적인 것을 후순위로 만드는 것이다.
왜 이렇게 복잡하게 사용해야 할까?
- 모든 오류 코드에 대해서 메시지를 각각 다 정의하면 개발자 입장에서 관리하기 너무 힘들다.
- 극단적인 예시를 든다면 필드가 1,000건이 있다면 오류 메시지도 1,000건을 정의해야 한다.
- 그래서 크게 중요하지 않은 메시지는 범용성 있는 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
의 경우에는required
검증 오류 메시지가 발생하면 아래 순서대로 메시지가 생성된다.- required.item.itemName
- required.itemName
- required.java.lang.String
- required
- 이렇게 생성된 메시지 코드를 기반으로 순서대로
MessageSource
에서 구체적인 것에서 덜 구체적인 순서대로 메시지에서 찾는다.- 메시지에 1번이 없으면 2번을 찾고, 2번이 없으면 3번을 찾는다.
- 즉, 크게 중요하지 않은 오류 메시지는 기존에 정의된 것을
재활용
할 수 있다.
오류 코드와 메시지 처리6
- 검증 오류 코드는 다음과 같이 2가지로 나눌 수 있다.
- 개발자가 직접 설정한 오류 코드 →
rejectValue()
를 직접 호출 - 스프링이 직접 검증 오류에 추가한 경우
- 주로 타입 정보가 맞지 않는다.
- 개발자가 직접 설정한 오류 코드 →
ValidationUtils
- 유효성 검증을 위한 간단한 유틸리티 클래스다.
제공하는 기능은 Empty, 공백 같은 단순한 기능만 제공한다.
- 기존에는 이런 코드였다고 가정해보자.
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required", "기본: 상품 이름은 필수입니다.");
}
- ValidationUtils를 사용하면 아래와 같이 바꿀 수 있다.
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
Validator 분리1
- 컨트롤러에서 검증 로직이 차지하는 부분은 매우 크다.
- 이런 경우 별도의 클래스로 역할을 분리하는 것이 좋다.
Validator 인터페이스
- 스프링은 검증을 체계적으로 제공하기 위해 다음 인터페이스를 제공한다.
public interface Validator {
boolean supports(Class<?> clazz);
void validate(Object target, Errors errors);
}
supports()
는 해당 검증기를 지원하는 여부를 확인한다.validate(Object target, Errors errors)
는 검증 대상 객체와BindingResult
를 통해 검증을 진행한다.
Validator 구현
- 실제로 유효성 검사기를 구현해보자.
package hello.validation.web.item.validation;
import hello.validation.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);
}
}
}
}
Item.class.isAssignableFrom
를 통해 Item 클래스만 해당 검사기를 사용할 수 있게 하였다.
Validator 적용
ValidationItemControllerV2
에서addItemV4
을 주석 처리하고 아래의 코드를 추가하자.
private final ItemValidator itemValidator;
@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}";
}
- 유효성 검사기를 별도 클래스로 분리하여 컨트롤러의 검증 부분의 양이 엄청나게 줄었다.
- 게다가 Item 클래스에 대해서는 해당 유효성 검사기는 재사용할 수 있다.
Validator 분리2
- 스프링이 Validator 인터페이스를 별도로 제공하는 이유는 체계적으로 검증 기능을 도입하기 위해서다.
- 그런데 앞에서는 검증기를 직접 불러서 사용했다.
- 실제로 이렇게 사용해도 된다.
- 그런데 Validator 인터페이스를 사용해서 검증기를 만들면 스프링의 추가적인 도움을 받을 수 있다.
WebDataBinder를 통해서 사용하기
WebDataBinder
는 스프링의 파라미터 바인딩의 역할을 해주고 검증 기능도 내부에 포함한다.ValidationItemControllerV2
에 아래 코드를 추가하자.
@InitBinder
public void init(WebDataBinder dataBinder) {
log.info("init binder {}", dataBinder);
dataBinder.addValidators(itemValidator);
}
WebDataBinder 적용
ValidationItemControllerV2
에서addItemV5
을 주석 처리하고 아래의 코드를 추가하자.
@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}";
}
addItemV6의 동작 방식
@Validated
는 검증기를 실행하라는 애노테이션이다.@Validated
애노테이션이 붙으면 앞서WebDataBinder
에 등록한 검증기를 찾아서 실행한다.- 그런데 여러 검증기를 등록한다면 그 중에 어떤 검증기가 실행되어야 할지 구분이 필요하다.
- 이 때 supports() 가 사용된다.
- 여기서는 supports(Item.class) 호출되고, 결과가 true이므로 ItemValidator의 validate()가 호출된다.
글로벌 설정
- 모든 컨트롤러에 다 적용하는 방법도 있다.
@SpringBootApplication
애노테이션이 있는 메인 클래스에implements WebMvcConfigurer
를 추가한다.- 아래 코드를 추가한다.
//org.springframework.validation.Validator
@Override
public Validator getValidator() {
//return WebMvcConfigurer.super.getValidator();
return new ItemValidator();
}
글로벌 설정 시 주의사항
- 글로벌 설정을 하면
BeanValidator
라는 것이 자동 등록되지 않는다. - 게다가 글로벌 설정을 직접 사용하는 경우는 드물다.
@Validated와 @Valid
- 검증시
@Validated
와@Valid
둘 다 사용가능하다. - 다만
@Valid
를 사용하려면 build.gradle 의존관계 추가가 필요하다.implementation 'org.springframework.boot:spring-boot-starter-validation'
@Validated
는 스프링 전용 검증 애노테이션이고,@Valid
는 자바 표준 검증 애노테이션이다.