사용자가 입력하는 데이터들에 대해서 적절한지 이를 검증하는 과정이 필요하다. 웹 서비스는 사용자가 입력한 데이터에 오류가 있다면, 이 데이터를 유지한 상태로 어떤 오류가 발생했는지 친절하게 알려주어야 한다.
컨트롤러의 중요한 역할 중 하나는 HTTP 요청이 정상인지 검증하는 것이다. 그리고 정상 로직보다 이같은 검증 로직을 개발하는 것이 더 어려울 수도 있다.
사용자가 상품 등록 폼에서 정상 범위의 데이터를 입력하면, 서버에서는 검증 로직이 통과하고, 상품을 저장하고, 상품 상세 화면으로 redirect 한다.
사용자가 입력한 데이터가 검증 범위를 넘어서면, 서버 검증 로직이 실패해야 한다. 이렇게 검증에 실패한 경우, 고객에게 다시 상품 등록 폼을 보여주고, 어떤 값을 잘못 입력했는지 알려줘야 한다.
Model에 1. 사용자가 입력한 데이터와 2. 어떤 부분에서 검증 오류가 발생했는지에 관한 정보를 담아서 반환해줘야 한다.
상품 등록 검증 코드를 작성해보자.
@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", "상품 이름은 필수입니다.");
}
검증 시 오류가 발생하면 errors
에 담아둔다. 어떤 필드에서 오류가 발생했는지 구분하기 위해 오류가 발생한 필드명을 key
로 사용한다. 이후 뷰에서 이 데이터를 이용해 고객에게 오류 메시지를 출력할 수 있다.
//특정 필드가 아닌 복합 룰 검증
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice);
}
}
특정 필드를 넘어서는 오류인 경우, 필드의 이름을 넣을 수 없으므로 globarError
라는 key
를 사용한다.
if (!errors.isEmpty()) {
model.addAttribute("errors", errors);
return "validation/v1/addForm";
}
검증에서 오류 메시지가 하나라도 있으면 오류 메시지를 출력하기 위해 model에 errors를 담고 입력 폼이 있는 뷰 템플릿으로 보낸다.
@ModelAttribute 애노테이션을 사용하면 자동으로 model에 객체를 담고, model.attribute로 객체명으로 객체를 넘겨준다. 따라서 다시 뷰로 돌아갔을때 (사용자가 입력한 데이터를 담고 있는) model 객체를 다시 가져와 뷰에 뿌려줄 수 있는 것이다!
<!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>
<div th:if="${errors?.containsKey('globalError')}">
<p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
</div>
오류 메시지는 errors
에 내용이 있을때만 출력된다. 타임리프의 th:if
를 사용하면 조건에 만족할때만 해당 HTML 태그를 출력할 수 있다.
Safe Navigation Operator
- errors가 null이면 errors.containKey()를 호출하는 순간
NullPointerException
이 발생한다.errors?.
은 errors가 null일때NullPointerException
이 발생하는 대신 null을 반환하는 문법이다. (th:if에서 null은 출력되지 않는다.- 스프링의 SpringEL이 제공하는 문법.
@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
다음에 와야 한다.
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
: 오류 기본 메시지bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
ObjectError 생성자 요약
public ObjectError(String objectName, String defaultMessage) {}
특정 필드를 넘어서는 오류가 있으면 ObjectError
객체를 생성해서 bindingResult
에 담아두면 된다.
objectName
: @ModelAttribute
의 이름defaultMessage
: 오류 기본 메시지+) FieldError는 ObjectError의 자식
<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:filed
에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.<div th:if="${#fields.hasGlobalErrors()}">
<p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="$ {err}">전체 오류 메시지</p>
</div>
글로벌 오류가 여러개인 경우 th:each
로 모두 출력할 수 있다.
<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
가 있으면 @ModelAttribute에 데이터 바인딩시 오류가 발생해도 컨트롤러가 호출된다!즉,
FieldError
)를 BindingResult
에 담아서 컨트롤러를 정상 호출한다.@ModelAttribute
의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 (스프링이 FieldError
생성해서 BindingResult
에 넣어준다.)Validator
사용. -> 추후 다룰 예정💭 2종류의 에러가 있다.
1. ModelAttribute의 바인딩 자체 (1번)
2. 로직상의 에러 (2번)
@ModelAttribtue Item item
바로 다음에 BindingResult
가 와야 한다.BindingResult
는 Model에 자동으로 포함된다.org.springframework.validation.Errors
org.springframework.validation.BindingResult
BindingResult는 인터페이스이고, Errors 인터페이스를 상속받고 있다. 실제 넘어오는 구현체는 BeanPropertyBindingResult라는 것인데, 둘 다 구현하고 있으므로 Errors를 대신 사용할 수도 있다.
다만 BindingResult가 Errors에 비해 더 많은 기능을 제공하고 있으므로 주로 관례상 BindingResult를 많이 사용한다.
(코드의 addErorr()도 BindingResult가 제공한다.)
목표:
1. 사용자 입력값 화면에 남기기
2.FieldError
,ObjectError
에 대한 깊은 이해
@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}";
}
두 가지 생성자를 제공한다.
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)
(ObjectError도 유사한 두 가지 생성자를 제공한다)
new FieldError("item", "price", item.getPrice(), false, null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다."
사용자가 price(가격) 입력칸에 숫자가 아닌 문자를 입력했다고 생각해보자. 🤔Integer 타입의 변수(price)에 String 값(ex."qqqq")을 저장할 수 있을까? 오류 발생시 사용자 입력값을 다시 돌려주기 위해서, 사용자 입력 값을 보관할 별도의 방법이 필요하다. FieldError는 이 같은 입력 값 저장 기능을 제공한다.
여기서 rejectedValue
가 오류 발생 시 사용자 입력 값을 저장하는 필드이다. bindingFailure
는 타입 오류 같은 바인딩이 실패했는지 여부를 적어주면 된다. 여기서는 바인딩이 실패한 것은 아니기 때문에 false
를 사용한다.
th:field="*{price}"
FieldError
에서 보관한 값을 사용해서 값 출력스프링이 스스로 영리하게 값을 바꿔가며 넣어준다! 이 행위를 해준다는 사실 자체를 인지하고 있는것도 중요한것 같다.
타입 오류로 바인딩 실패시 스프링이 FieldError를 생성하며 사용자 입력 값을 넣어둔다. 그리고 해당 오류를 BindingResult에 담아 컨트롤러를 호출한다. 따라서 타입 오류 같은 바인딩 실패시에도 오류 메시지를 정상 출력할 수 있다.
목표: 오류 메시지를 체계적으로 다루어보자!
앞서 살펴본 FieldError의 두번째 생성자의 파라미터 중, codes
와 argument
를 활용해 오류 코드로 메시지를 제공할 수 있다!
spring.messages.basename=messages,errors
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
제약조건.객체명.필드이름
형식으로 code값을 설정했다. @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}로 치환할 값을 전달한다.😠 아.. 이거 너무 복잡한데. 오류 코드 key자체도 번잡하고, Filed, ObjectError 파라미터도 너무 많은거 아냐?
목표:
1. FieldError, ObjectError는 다루기 너무 번거롭다.
2. 오류 코드를 좀 더 자동화 할 수 있지 않을까? 예) item.itemName 처럼?
앞서 컨트롤러에서 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)
이러한 출력결과를 확인할 수 있다.
즉, bindingResult는 target에 대해 알고 있다.
@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}";
}
void rejectValue(@Nullable String field,
String errorCode,
@Nullable Object[] errorArgs,
@Nullable String defaultMessage);
{0}
을 치환하기 위한 값bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null)
앞서 BindingResult
는 어떤 객체를 대상으로 검증하는지 target을 이미 알고 있다고 했다. 따라서 target(item)에 대한 정보는 없어도 된다. 오류 필드명은 동일하게 price
를 사용했다.
전반적으로 다소 단축된 모습이다!
FieldError()를 직접 다룰 때는 오류 코드를 range.item.price
와 같이 모두 입력했다. 그런데 rejectValue()를 사용하고부터는 오류 코드를 range
로 간단하게 입력했다. 그래도 무언가 규칙이 있는 것 처럼 오류 메시지를 잘 출력한다. 이 부분을 이해하려면 MessageCodesResolver
를 이해해야 한다.
목표: 어떤 식으로 오류 코드를 설계할 것이지 알아보자!
required.item.itemName : 상품 이름은 필수 입니다.
range.item.price : 상품의 가격 범위 오류 입니다
😰오류 코드가 이렇게까지 복잡할 필요가 있나? 이런 식으로 일일이 모든 변수에 대한 에러 메시지를 설정하려면 그 양이 너무 많은데..
required : 필수 값 입니다.
range : 범위 오류 입니다
🤔이렇게 간단하게 오류 메시지가 나가는건 어떨까?
단순한 오류 메시지는 범용성이 좋으나, 세밀한 작성이 어렵다. 반대로 너무 자세한 오류 메시지는 범용성이 떨어진다. 가장 좋은 방법은 범용성으로 사용하다가, 세밀하게 작성해야 하는 경우에는 세밀한 작성이 가능하도록 메시지에 단계를 두는 방법이다.
예를 들어서,
required
오류 메시지만 사용하는 경우 이 메시지를 선택해서 사용한다.
required: 필수 값 입니다.
그런데 오류 메시지에 required.item.itemName
과 같이 객체명과 필드명을 조합한 세밀한 코드가 있으면 이 메시지를 높은 우선순위로 사용하는 것이다.
#Level1 (더 세밀한 코드가 선택된다.)
required.item.itemName: 상품 이름은 필수 입니다.
#Level2
required: 필수 값 입니다.
위와 같은 방식으로 메시지 개발 시, 객체명과 필드명을 조합한 메시지가 있는지 확인하고, 없으면 좀 더 범용적인 메시지를 선택하도록 추가 개발이 필요하다. 그러나 메시지 작성 코드가 아닌 메시지 사용 부분의 코드는 수정이 필요없다. 범용성 있게 잘 개발해두면, 메시지의 추가만으로 매우 편리하게 오류 메시지를 관리할 수 있게된다.
스프링은 MessageCodeResolver
라는 것으로 이 기능을 지원한다.
목표: 테스트 코드를 통해 MessageCodesResolver를 알아보자!
bindingResult.rejectValue() 실행 시 내부에서 new FieldError를 생성하여 각 인수들을 전달한다. (objectName은 이미 알고 있고, field 값은 전달받는다.) 이 중 codes(String[])값은 MessageCodesResolver가 생성해서 전달한다.
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"
);
}
}
테스트 코드는 모두 통과한다. 따라서, 각 codeResolver가 생성하는 메시지 코드들이 확인 코드 속 String[]
들과 같다.
MessageCodesResolver
인터페이스이고 Default MessageCodesResolver
는 기본 구현체이다.ObjectError
, FieldError
객체 오류의 경우 다음 순서로 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()
는 내부에서 MessageConverter
를 사용한다. 여기에서 메시지 코드들을 생성한다.FieldError
, ObjectError
의 생성자를 보면, 오류 코드를 하나가 아니라 여러 오류 코드를 가질 수 있다. MessageCodesResolver
를 통해서 생성된 순서대로 오류 코드를 보관한다. rejectValue("itemName", "required")
다음 4가지 오류 코드를 자동으로 생성
reject("totalPriceMin")
다음 2가지 오류 코드를 자동으로 생성
타임리프 화면을 렌더링 할 때 th:errors
가 실행된다. 만약 이때 오류가 있다면 생성된 오류 메시지 코드를 순서대로 돌아가면서 메시지를 찾는다. 그리고 없으면 디폴트 메시지를 출력한다.
MessageCodesResolver
는 required.item.itemName
처럼 구체적인 것을 먼저 만들고, required
처럼 덜 구체적인 것을 가장 나중에 만든다. 이 방법으로 메시지와 관련된 공통 전략을 편리하게 도입할 수 있다!
모든 오류 코드에 대해 메시지를 각각 다 정의하고 관리하는 것은 개발자에게 매우 힘든 일이다. 중요도가 작은 메시지는 required
와 같은 범용성 있는 메시지로 끝내고, 매우 중요한 메시지는 꼭 필요할 때 별도로 구체적이게 적어 사용하는 방식이 더 효과적이다.
(이전의 오류 메시지들은 주석 처리)
#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
에서 메시지를 찾는다.
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required", "기본: 상품 이름은 필수입니다.");
}
기존에 이 코드와 완전히 동일한 코드를 ValidationUtils라는 기능을 이용하여 사용할 수 있다.
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName","required");
그냥 이런 기능이 있다고 참고만 하면 된다.
rejectValue()
호출MessageCodesResolver
를 사용해서 검증 오류 코드로 메시지 코드들을 생성new FieldError()
를 생성하면서 메시지 코드들을 보관th:errors
에서 메시지 코드들로 메시지를 순서대로 메시지에서 찾고, 노출검증 오류 코드는 2가지로 나눌 수 있다.
rejectValue()
를 직접 호출Integer 타입의 price
필드에 문자 "A"를 입력해보자.
error와 관련된 로그를 확인해보면 BindingResult
에 FieldError
가 담겨있고, 메시지 코드에 다음과 같은 값들이 담겨있다
codes[typeMismatch.item.price,typeMismatch.price,typeMismatch.java.lang.Integer,typeMismatch]
즉, 스프링은 타입 오류 발생 시 typeMismatch
라는 오류 코드를 사용한다. 이 오류 코드가 MessageCodesResolver
를 통하며 4가지 메시지 코드가 생성된다.
따로 errors.properties
typeMismatch 관련 오류 코드를 생성하지 않으면, 스프링이 생성한 기본 메시지가 출력된다.
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"
다음 내용을 추가하자.
#추가
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.
결과적으로 소스코드를 하나도 수정하지 않고, 원하는 메시지를 단계별로 설정할 수 있다!
📚 설계 방식에 대한 인사이틀 얻자!
스프링에 적용하는 기능 학습보다도, 상황이 달라지더라도 메시지 코드를 설계하는 방식에 대한 인사이트를 얻는것이 중요하다!!!
목표: 복잡한 검증 로직을 별도로 분리하자!
컨트롤러에서 검증 로직이 너무 많은 부분을 차지하고 있다. 이런 경우, 별도의 클래스로 역할을 분리하는 것이 좋다. 유지 보수가 편리해지고, 가독성이 좋아지며, 재사용도 가능하다!
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);
}
supprts() {}
: 해당 검증기를 지원하는 여부 확인 validate(Object target, Errors errors)
: 검증 대상 객체와 BindingResult
Item item = (Item) target;
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}";
}
@RequiredArgsConstructor
를 통해 ItemValidator
를 스프링 빈으로 주입 받아서 직접 호출했다.클래스를 분리해서 기존의 길었던 검증 로직이 itemValidator.validate(item, bindingResult);
이 한 줄로 줄었다!
스프링에서 제공하는 Validator
인터페이스는 검증 기능을 체계적으로 도입하도록 도와준다. 해당 인터페이스 없이도 검증 기능이 구현 가능하나, 사용 시 스프링이 추가적인 도움을 준다.
WebDataBinder
는 스프링의 파라미터 바인딩의 역할을 해주고 검증 기능도 내부에 포함한다.
깊이있게 알 필요는 없다. 스프링 mvc 내부에서 객체에 파라미터를 바인딩하고, 검증하는데 사용하는 기능이다. 이 기능을 밖으로 꺼내서 검증기를 넣어줘야 WebDataBindier
가 이 검증기를 적용해준다. 자세히알필요없다.
@InitBinder
public void init(WebDataBinder dataBinder) {
log.info("init binder {}", dataBinder);
dataBinder.addValidators(itemValidator);
}
WebDataBinder
에 검증기를 추가하면 해당 컨트롤러에서는 검증기를 자동으로 적용할 수 있다.(모든 메서드에)@InitBinder
=> 해당 컨트롤러에만 영향. 글로벌 설정은 별도로 해야한다.@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}";
}
validator를 직접 호출하는 부분이 사라지고, 대신 검증 대상 앞에 @Validated
가 붙었다.
@Validated
는 검증기를 실행하라 는 애노테이션이다. 이 애노테이션이 붙으면 앞서 WebDataBinder
에 등록한 검증기를 찾아서 실행한다. 그런데 여러 검증기를 등록한다면 그중에 어떤 검증기가 실행되어야 할지 구분이 필요하다. 이때 supports()
가 사용된다. 여기서는 supports(Item.class)
가 호출되고, 결과가 true
이므로 ItemValidator
의 validate()
가 호출된다.
@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) {...}
}
@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {
public static void main(String[] args) {
SpringApplication.run(ItemServiceApplication.class, args);
}
@Override
public Validator getValidator() {
return new ItemValidator();
}
}
이렇게 글로벌 설정을 추가할 수 있다. (@InitBinder
를 제거해도 검증기가 잘 작동한다!)
주의!
글로벌 설정 사용 시 BeanValidator가 자동 등록되지 않는다. 따라서 꼭 주석처리 해야한다. 글로벌 설정을 직접 사용하는 경우는 매우 드물다.
주의!
검증시
@Validated
,@Valid
둘 다 사용 가능하다.
@Valid
: 자바 표준 검증 애노테이션.
@Validated
: 스프링 전용 검증 애노테이션
javax.validation.@Valid
를 사용하려면 의존관계 추가가 필요하다.
implementation 'org.springframework.boot:spring-boot-starter-validation