
@Slf4j
@Controller
@RequestMapping("/validation/v1/items")
@RequiredArgsConstructor
public class ValidationItemControllerV1 {
private final ItemRepository itemRepository;
@GetMapping
public String items(Model model) {
List<Item> items = itemRepository.findAll();
model.addAttribute("items", items);
return "validation/v1/items";
}
@GetMapping("/{itemId}")
public String item(@PathVariable long itemId, Model model) {
Item item = itemRepository.findById(itemId);
model.addAttribute("item", item);
return "validation/v1/item";
}
@GetMapping("/add")
public String addForm(Model model) {
model.addAttribute("item", new Item());
return "validation/v1/addForm";
}
@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}";
}
}
<!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;
}
</style>
</head>
<body>
<div class="container">
<div class="py-5 text-center">
<h2 th:text="#{page.addItem}">상품 등록</h2>
</div>
<div th:if="${errors?.containsKey('globalError')}">
<p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
</div>
<form action="item.html" th:action th:object="${item}" method="post">
<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
Safe Navigation Operator은NullPointerException을 방지하기 위한 것으로 Groovy 언어로부터 시작되었다.errors?.은errors가null일 때,NullPointerException이 발생하는 대신,null을 반환하게끔 만든다.
참고 : Safe Navigation Operator
❗문제점1 : 뷰 템플릿에서 중복되는 부분이 많다.
❗문제점2 : 타입 오류 처리가 안 된다. 숫자 타입을 원하는 가격이나 수량에 문자 타입을 넣어 결과를 처리하게 되면 스프링MVC에서 컨트롤러에 진입하기도 전에 예외가 발생해 컨트롤러가 아예 호출되지도 않고 400예외가 발생하면서 자바 화이트 라벨 에러 페이지가 나온다.
❗문제점3 : 타입 오류가 발생해도 고객이 입력한 문자를 화면에 남겨야 한다. 만약 컨트롤러가 호출된다고 가정해도 타입이 달라 문자를 보관할 수 없다. 문자는 바인딩이 불가능하므로 고객이 입력한 문자가 사라지게 되고, 고객은 본인이 어떤 내용을 입력해서 문제가 발생했는지 찾기 힘들다.
@Slf4j
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {
// ...
@PostMapping("/add")
private 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() >= 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);
// 모델에 자동으로 담겨짐
// model.addAttribute("bindingResult", 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를 활용해서 편리하게 검증 오류를 표현하는 기능을 제공한다.#fields : BindingResult가 제공하는 검증 오류에 접근할 수 있다.th:errors : 해당 필드에 오류가 있는 경우에 태그를 출력한다.th:errorClass : th:field에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.참고 : Validation and Error Messages
스프링이 제공하는 검증 오류를 보관하는 객체 : BindingResult
BindingResult가 있으면 V1 버전의 컨트롤러에서 제기됐던 문제 중 하나인 데이터 바인딩 시 오류가 발생했을 때 컨트롤러가 호출되지않는 문제가 해결이 된다. BindingResult가 있으면 @ModelAttribute에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출된다.
@ModelAttribute의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 FieldError 생성해서 BindResult에 넣어준다.Validator 사용BindingResult는 검증할 대상의 바로 다음에 와야한다. 순서가 중요하다. 또한 BindingResult는 Model에 자동으로 포함이 되기 때문에 파라미터에 Model을 사용하지 않아도 된다.public interface BindingResult extends Errors
BindingResult는 인터페이스이고, Errors 역시 인터페이스이다.BeanPropertyBindingResult라는 것인데 둘 다 구현하고 있으므로 BindingResult 대신에 Errors를 사용해도 된다. Errors 인터페이스는 단순 오류 저장과 조회 기능을 제공한다. BindingResult는 여기에 부가적인 기능들을 추가로 제공한다. addError()도 BindingResult가 제공하는 기능이다.public class FieldError extends ObjectError {
private final String field;
@Nullable
private final Object rejectedValue;
private final boolean bindingFailure;
public FieldError(String objectName, String field, String defaultMessage) {
this(objectName, field, (Object)null, false, (String[])null, (Object[])null, defaultMessage);
}
// 생성자 형태 체크
public FieldError(String objectName, String field, @Nullable Object rejectedValue, boolean bindingFailure, @Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage) {
super(objectName, codes, arguments, defaultMessage);
Assert.notNull(field, "Field must not be null");
this.field = field;
this.rejectedValue = rejectedValue;
this.bindingFailure = bindingFailure;
}
// ...
}
public class ObjectError extends DefaultMessageSourceResolvable {
private final String objectName;
@Nullable
private transient Object source;
public ObjectError(String objectName, String defaultMessage) {
this(objectName, (String[])null, (Object[])null, defaultMessage);
}
// 생성자 형태 체크
public ObjectError(String objectName, @Nullable String[] codes, @Nullable Object[] arguments, @Nullable String defaultMessage) {
super(codes, arguments, defaultMessage);
Assert.notNull(objectName, "Object name must not be null");
this.objectName = objectName;
}
// ...
}
@Slf4j
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {
// ...
@PostMapping("/add")
private 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() >= 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);
// 모델에 자동으로 담겨짐
// model.addAttribute("bindingResult", 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}";
}
// ...
}
@ModelAttribute에 바인딩되는 시점에 오류가 발생하면 모델 객체에 사용자 입력 값을 유지하기 어렵다. 오류가 발생한 경우 사용자 입력 값을 보관하는 별도의 방법이 필요하다. 이렇게 보관한 사용자의 입력 값을 검증 오류 발생 시 화면에 다시 출력한다.FieldError는 오류 발생 시 사용자 입력 값을 저장하는 기능을 제공한다.private final Object rejectedValue 부분이 오류 발생 시 사용자 입력 값을 저장하는 필드가 되는 것이고 private final boolean bindingFailure는 타입 오류와 같이 바인딩이 실패했는지의 여부를 나타낸다.Validation and Error Messages - Simplifying error-based CSS styling: th:errorclass
FieldError를 생성하면서 사용자가 입력한 값을 넣어둔다. 그리고 해당 오류를 BindingResult에 담아서 컨트롤러를 호출한다. 따라서 타입 오류 같은 바인딩 실패가 발생해도 사용자 오류 메시지를 정상적으로 출력할 수 있다.오류 메시지 역시 메시지 프로퍼티와 같이 별도의 파일로 관리하면 여러 코드에서 활용을 할 수 있다.
spring.messages.basename=messages, errors
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
❗참고 : errors_en.properties 파일 생성해서 위와 같이 소스를 작성해두면 오류 메시지 역시 국제화 처리를 할 수 있다.
❗한글 깨지는 현상 해결(참고한 블로그) : 인텔리제이 프로퍼티 한글 설정
@Slf4j
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {
// ...
@PostMapping("/add")
private 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() >= 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", "10000"}, null, null));
}
}
//검증에 실패하면 다시 입력 폼으로
if (bindingResult.hasErrors()) {
log.info("errors = {} ", bindingResult);
// 모델에 자동으로 담겨짐
// model.addAttribute("bindingResult", 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가 제공하는 rejectValue(), reject()를 사용하면 FieldError, ObjectError를 사용하지 않고 깔끔하게 오류를 해결할 수 있다.field : 오류 필드명errorCode : 오류 코드errorArgs : 오류 메시지에서 {0}을 치환하기 위한 값defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지public void reject(String errorCode) {
this.bindingResult.reject(errorCode);
}
public void reject(String errorCode, String defaultMessage) {
this.bindingResult.reject(errorCode, defaultMessage);
}
public void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage) {
this.bindingResult.reject(errorCode, errorArgs, defaultMessage);
}
public void rejectValue(@Nullable String field, String errorCode) {
this.bindingResult.rejectValue(field, errorCode);
}
public void rejectValue(@Nullable String field, String errorCode, String defaultMessage) {
this.bindingResult.rejectValue(field, errorCode, defaultMessage);
}
public void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage) {
this.bindingResult.rejectValue(field, errorCode, errorArgs, defaultMessage);
}
@Slf4j
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {
// ...
@PostMapping("/add")
private String addItemV4(@ModelAttribute Item item,
BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
// 검증 로직
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}, null);
}
}
//검증에 실패하면 다시 입력 폼으로
if (bindingResult.hasErrors()) {
log.info("errors = {} ", bindingResult);
// 모델에 자동으로 담겨짐
// model.addAttribute("bindingResult", 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}";
}
// ...
}
MessageCodesResolver은 인터페이스이고 DefaultCodesResolver는 구현체 클래스이다.public interface MessageCodesResolver {
String[] resolveMessageCodes(String var1, String var2);
String[] resolveMessageCodes(String var1, String var2, String var3, @Nullable Class<?> var4);
}
public class DefaultMessageCodesResolver implements MessageCodesResolver, Serializable {
// ...
}
MessageSource로 찾아 변경한다.required와 같은 메시지로 끝내되 정말 중요한 메시지는 꼭 필요할 때 구체적으로 적어서 사용하는 방식이 좋다.@Slf4j
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {
// ...
@PostMapping("/add")
private String addItemV4(@ModelAttribute Item item,
BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
// ValidationUtils 검증 활용
ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
// 검증 로직
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}, null);
}
}
//검증에 실패하면 다시 입력 폼으로
if (bindingResult.hasErrors()) {
log.info("errors = {} ", bindingResult);
// 모델에 자동으로 담겨짐
// model.addAttribute("bindingResult", 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 인터페이스를 제공한다.
public interface Validator {
boolean supports(Class<?> var1); // 해당 검증기를 지원하는지?
void validate(Object var1, Errors var2); // 검증 대상 객체와 BindingResult
}
@Slf4j
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {
private final ItemRepository itemRepository;
private final ItemValidator itemValidator;
@PostMapping("/add")
private String addItemV5(@ModelAttribute Item item,
BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
// 컨트롤러에서 검증 로직을 분리 → Validator 분리(스프링 빈으로 등록 필수)
itemValidator.validate(item, bindingResult);
//검증에 실패하면 다시 입력 폼으로
if (bindingResult.hasErrors()) {
log.info("errors = {} ", bindingResult);
// 모델에 자동으로 담겨짐
// model.addAttribute("bindingResult", 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 인터페이스를 제공한다고 했다.
Validator 인터페이스를 사용해서 검증기를 만들면 스프링의 추가적인 도움을 받을 수 있다.
WebDataBinder는 스프링의 파라미터 바인딩의 역할을 해주면서 검증 기능도 내부에 포함한다.
WebDataBinder에 검증기를 추가하면 해당 컨트롤러에서는 검증기를 자동으로 적용할 수 있다. @InitBinder 어노테이션은 해당 컨트롤러에만 영향을 준다. 전역적으로 설정하려면 @SpringBootApplication 어노테이션이 붙은 실행 코드 쪽에 작성해주면 된다.
@Slf4j
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {
private final ItemRepository itemRepository;
private final ItemValidator itemValidator;
@InitBinder
public void init(WebDataBinder dataBinder) {
dataBinder.addValidators(itemValidator);
}
// @Validated 어노테이션 추가
@PostMapping("/add")
private String addItemV6(@Validated @ModelAttribute Item item,
BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
//검증에 실패하면 다시 입력 폼으로
if (bindingResult.hasErrors()) {
log.info("errors = {} ", bindingResult);
// 모델에 자동으로 담겨짐
// model.addAttribute("bindingResult", 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}";
}
// ...
}
✔️검증기를 전역적으로 설정하는 코드 - 참고용(직접 사용하는 경우 드물다)
@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {
public static void main(String[] args) {
SpringApplication.run(ItemServiceApplication.class, args);
}
@Override
public Validator getValidator() {
return new ItemValidator();
}
}
❗참고 :
@Valid,@Validated둘 다 사용가능하다.
javax.validation.@Valid를 사용하려면build.Gradle에 의존관계를 추가해야 한다.
@Valid는 자바 표준 검증 어노테이션이고@Validated는 스프링 전용 검증 어노테이션이다.