검증 - Validation
컨트롤러의 중요한 역할 중 하나는 HTTP 요청이 정상인지 검증하는 것이다.
웹 서비스는 폼 입력시 오류가 발생하면, 고객이 입력한 데이터를 유지한 상태로 어떤 오류가 발생했는지 알려주어야 한다.
정상 로직보다 이런 검증 로직을 잘 개발하는 것이 어쩌면 더 어려울 수도 있다.
검증 요구사항
상품 관리 시스템에 새로운 요구사항이 추가되었다.
요구사항 : 검증 로직 추가
- 타입 검증
- 가격, 수량에 문자가 들어가면 검증 오류 처리
- 필드 검증
- 상품명 : 필수, 공백X
- 가격 : 1000원 이상, 1백만원 이하
- 수량 : 최대 9999
- 특정 필드의 범위를 넘어서는 검증
- 가격 * 수량의 합은 10,000원 이상
검증 방식 1 (스프링 제공 X)
- 고객이 상품 등록 폼에서 요구사항에 맞지 않는 값을 입력했을 경우 즉, 검증에 실패한 경우 고객에게 다시 상품 등록 폼을 보여주고, 어떤 값을 잘못 입력했는지 알려주어야 한다.
@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}";
}
HashMap
에 오류 관련 정보를 보관한 후 오류가 있을 경우 다시 입력 폼으로, 없을 경우 리다이렉트를 하는 방식으로 검증 처리를 할 수 있다. 오류가 발생하고 화면을 전환하는데 사용자가 입력한 데이터가 그대로 남아있을 수 있는 이유는@ModelAttribute
에서 자동으로item
을model.addAttribute
해주기 때문에 뷰단에서 표시한 데이터를 그대로 화면에 보여줄 수 있다.- 오류가 발생했을 때 사용자에게 해당 오류를 보여주기 위해 뷰단에서 오류를 표시할 수 있도록 처리해주자.
<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>
th:if
를 통해 컨트롤러에서 보낸errors
정보가 있으면 오류 화면을 보여준다. 여기서 주의할 점은errors?.containsKey()
이다.?
은null
일 때NPE
가 아닌null
을 반환해주는 것으로 SpringEL이 제공해주는 문법이다. 즉,null
은 실패로 처리되므로 오류 메시지가 출력되지 않아서 등록을 처음 눌렀을 때 정상화면을 보여줄 수 있는 방식이다.
<input type="text" th:classappend="${errors?.containsKey('itemName')} ? 'field-error' : _"
class="form-control">
- 위의 소스 코드에선
th:class
를 사용하여 조건 처리를 해주었지만,th:classappend
도 마찬가지로 조건이 참이면 필드를 추가, 거짓이면 그대로 두어서 오류가 발생했을 때 해당 폼의 css를 추가적으로 보여줄 수 있다.
남은 문제점
- 뷰 템플릿에서 중복 처리가 많다. 공통 처리를 할 수 있을 것 같다.
Item
의 숫자 필드는 타입이Integer
이므로 문자 타입으로 설정하는 것이 불가능하다. 해당 오류는 컨트롤러에 진입하기 전에 예외가 발생하기 때문에 컨트롤러가 호출되지 않고, 400예외가 발생하면서 오류페이지로 넘어간다. 즉, 타입 오류가 발생했을 때를 대비하여 별도의 관리가 필요하다.
검증 방식 2 (스프링 제공 O)
BindingResult1
@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}";
}
- 이전에
HashMap
을 사용했지만 스프링에서 제공하는BindingResult
에addError
기능을 사용해서 검증을 적용할 수 있다. 필드 에러는FieldError
, 글로벌 에러는ObjectError
를 사용한다. - 기존에
!errors.isEmpty()
는 부정의 부정으로 가독성에 좋지 않은 모습을 보였었다.bindingResult.hasErrors()
로 사용하면서 가독성이 한 층 좋아진 모습을 볼 수 있다.
<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>
- 타임리프는 스프링의
BindingResult
를 활용해서 편리하게 검증 오류를 표현하는 기능을 제공한다. #fields
:BindingResult
가 제공하는 검증 오류에 접근할 수 있다.th:errors
: 해당 필드에 오류가 있는 경우에 태그를 출력한다. 기존th:if
방식의 편의 버전이다.th:errorclass
:th:field
에서 지정한 필드에 오류가 있으면class
정보를 추가한다.
검증과 오류 메시지에 대한 타임리프 공식 메뉴얼은 다음 링크를 통해 확인해보자.
https://www.thymeleaf.org/doc/tutorials/3.0/thymeleafspring.html#validation-and-error-messages
BindingResult2
BindingResult
가 없으면 타입 오류시 400 오류가 발생하고 오류페이지로 이동한다.BindingResult
가 있으면 오류 정보 (FieldError
)를BindingResult
에 담아서 컨트롤러를 정상 호출한다.- 현재 타입 에러에 대한 문제는 해결했지만, 오류가 발생한 필드의 값은 사라지게 된다.
FieldError
와ObjectError
에 대해 자세히 알아보자.
FieldError, ObjectError
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)
FieldError
생성자는 두 가지의 생성자를 제공한다.objectName
: 오류가 발생한 객체 이름,field
: 오류 필드,rejectedValue
: 사용자가 입력한 값 ,bindingFailure
: 바인딩 실패 여부 ,codes
: 메시지 코드,arguments
: 메시지에 사용하는 인자,defaultMessage
: 기본 오류 메시지ObjectError
도 유사하게 두 가지 생성자를 제공한다.
@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}";
}
- 기존의
FieldError
를 다른 생성자로 사용자의 입력 값을rejectedValue
에 넣어서 처리했다. 즉, 사용자의 입력 데이터가 컨트롤러의@ModelAttribute
에 바인딩되는 시점에 오류가 발생했을 때rejectedValue
에 입력 값을 저장하여 다시 보여줄 수 있게 되는 것이다.
- 다음과 같이 오류가 발생했을 때도 사용자의 입력값이 전달되도록 처리가 된 결과를 볼 수 있다.
오류 코드와 메시지 처리
이전에 봤던 FieldError
와 ObjectError
의 생성자를 보면 codes
와 arguments
를 제공한다. 이것은 오류 발생 시 오류 코드로 메시지를 찾기 위해 사용된다.
오류 코드와 메시지 처리 1
spring.messages.basename=messages,errors
- 디폴트는 message를 찾아가므로 새로운 errors 메시지를 위해
application.properties
에 다음과 같이 추가하자.
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
errors.properties
에 다음과 같이 설정하자.
@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}";
}
codes
: 메시지 코드를 지정한다. 리턴 값은 String 배열 형태로 전달해야 한다. 해당 메시지 코드가 리턴한 배열 중에 하나도 없고defaultMessage
또한 없다면 오류가 발생한다.arguments
: {0}, {1}과 같이 파라미터를 통해 전달할 값이 있다면 Object 배열 형태로 전달해야 한다.
- 다음과 같이 메시지 처리를 통해 오류를 표시한 결과를 볼 수 있다. 다만,
FieldError
와ObjectError
를 다루기가 쉽지 않다. 이어서 오류 코드를 자동화하는 방법에 대해 알아보자.
오류 코드와 메시지 처리2
BindingResult
는 검증해야 할 객체인 target
객체를 알고 있다. BindingResult
가 제공하는 rejectValue()
, reject()
를 사용하면 FieldError
, ObjectError
를 직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있다.
void rejectValue(@Nullable String field, String errorCode,
@Nullable Object[] errorArgs, @Nullable String defaultMessage);
field
: 오류 필드명,errorCode
: 오류 코드 (메시지에 등록된 코드가 아닌messageResolver
를 위한 오류 코드),errorArgs
: 오류 메시지에서 파라미터를 치환하기 위한 값,defaultMessage
: 오류메시지를 찾을 수 없을 때 사용하는 기본 메시지
@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
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() >= 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}";
}
rejectValue
를 사용하여 전 보다 훨씬 간결하게 사용할 수 있다. 여기서 주의할 점은errorCode
가 메시지 코드 값이 아니고 메시지 코드의 첫 시작부분 하나만으로 동작이 된다. 이 기능에 대해서 뒤에서 더 알아보자.
오류 코드와 메시지 처리 3
#Level1
required.item.itemName: 상품 이름은 필수 입니다.
#Level2
required: 필수 값 입니다.
MessageCodesResolver
는errorCode
가required
일 때required.item.itemName
즉, 현재 필드의 정보에 대한 메시지가 있는지 확인하고 없다면required
에 대한 메시지를 찾는다. 즉, 개발자들은 메시지를 다음과 같이 처리 해두면 범용적으로 사용이 가능하다.
오류 코드와 메시지 처리 4
package hello.itemservice.validation;
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();
/*
messageCode = required.item
messageCode = required
*/
@Test
void messageCodesResolverObject() {
String[] messageCodes = codesResolver.resolveMessageCodes("required", "item");
assertThat(messageCodes).containsExactly("required.item", "required");
}
/*
messageCode = required.item.itemName
messageCode = required.itemName
messageCode = required.java.lang.String
messageCode = 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
를 통해 메시지 코드를 생성한다. 생성한 메시지 코드를 통해 우선 순위대로BindingResult.rejectValue
에 메시지 코드를 넣어 준다.해당 구현체에는 메시지 코드 생성 규칙이 있다.
// reject("required")
1.: code + "." + object name
2.: code
예) 오류 코드: required, object name: item
1.: required.item
2.: required
ObjectError
의 경우 다음 순서로 2가지 생성
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"
FieldError
의 경우 다음 순서로4가지 메시지 코드 생성
오류 코드와 메시지 처리 5
MessageCodesResolver
는 구체적인 순으로 먼저 만든다. 이렇게 하면 메시지와 관련된 공통 전략을 편리하게 도입할 수 있다.
크게 중요하지 않은 범용성 있는 메시지는 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} 까지 허용합니다.
- 객체 오류와 필드 오류를 범용성에 따라 레벨을 나누어 분리하자. 다음과 같이 개발하면 레벨 별로 나누어서 범용적으로 사용할 수 있다.
// rejectValue와 동일한 기능을 수행
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName", "required");
}
ValidationUtils
를 사용하는 방법도 있다. 제공하는 기능은 Empty, 공백 같은 단순한 기능만 제공하므로 참고해두자.
오류 코드와 메시지 처리 6
검증 오류 코드는 다음과 같이 2가지로 나눌 수 있다.
- 개발자가 직접 설정한 오류 코드 :
rejectValue()
를 직접 호출 - 스프링이 직접 검증 오류에 추가한 경우(주로 타입 정보가 맞지 않음)
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
라는 오류 코드를 사용한다. 이전에 타입 오류가 발생했을 때 다음과 같은 로그가 화면에 보여졌을 것이다. 이 부분은 메시지코드에 typeMismatch
에 대한 설정이 없어서 기본 메시지가 출력된 것이다.
#추가
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.
- 다음과 같이
errors.properties
에 내용을 추가한다면 설정한 메시지가 화면에 전달된다.
Validator 분리
컨트롤러에서 검증 로직이 차지하는 부분은 매우 크다. 이런 경우 별도의 클래스로 역할을 분리하는 것이 좋다. 그리고 이렇게 분리한 검증 로직을 재사용 할 수도 있다.
Validator 분리 1
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);
}
@Override
public void validate(Object target, Errors errors) {
Item item = (Item) target;
if (!StringUtils.hasText(item.getItemName())) {
errors.rejectValue("itemName", "required");
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
errors.rejectValue("price","range", new Object[]{1000,1000000}, null);
}
if (item.getQuantity() == null || item.getQuantity() >= 9999) {
errors.rejectValue("quantity", "max", new Object[]{9999}, null);
}
// 특정 필드가 아닌 복합 룰 검증
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
errors.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
}
}
- 검증 로직을 별도의 클래스로 분리한다.
Validator
를 사용하여 구현체를 개발해준다.
@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
스프링 빈을 가져와서(@Component
를 사용해서 빈 등록 후@Autowired
)validate()
로 구현체에 파라미터를 넣어주면 된다.
Validator 분리 2
WebDataBinder
는 스프링 파라미터 바인딩의 역할을 해주고 검증 기능도 내부에 포함한다.
@InitBinder
public void init(WebDataBinder dataBinder) {
dataBinder.addValidators(itemValidator);
}
- 컨트롤러에
WebDataBinder
를 추가하면 해당 컨트롤러에서는 검증기를 자동으로 적용할 수 있다.@InitBinder
는 해당 컨트롤러에만 영향을 주기 위해 사용한다. 글로벌 설정은 별도로 해야 된다. (마지막에 설명)
@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
만 추가해주면 자동으로 검증을 해준다.@Validated
는 검증기를 실행하는 의미의 애노테이션으로WebDataBinder
를 찾아서 실행한다. 이 때 어떤 검증기가 실행될 지를 구별하기 위해supports()
가 사용된다. 여기서는supports(Item.class)
가 호출되고ItemValidator
의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();
}
}
- 다음과 같이 시작 부분에
WebMvcConfiguer
를 구현체로ItemValidator
를 적용시키면 글로벌 설정이 가능하다. 글로벌 설정을 한 경우BeanValidator
가 자동 등록되지 않는다. 참고로 직접 글로벌 설정을 사용하는 경우는 드물다.
참고
검증시 @Validated
, @Valid
둘 다 사용 가능하다.
@Valid
는 자바 표준 검증 애노테이션으로 gradle의 의존관계가 추가로 필요하다.
@Validated
는 스프링 전용 검증 애노테이션이다.
클라이언트 검증은 조작할 수 있으므로 보안에 취약하다. 서버만으로 검증하면, 즉각적인 고객 사용성이 부족해진다.
둘을 적절히 섞어서 사용하되, 최종적으로 서버 검증은 필수로 하자. API 방식을 사용하며 API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남길 수 있도록 하자.
본 포스팅은 인프런 - 김영한님의 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술을 참고하였습니다.