Bean Validation을 사용하기 전에 BindingResult로 검증 로직을 작성하는 것에 대한 내용을 기록하고자 한다.
데이터를 검증하고 문제가 있는 경우 사용자에게 오류 메시지를 보여줌과 동시에 사용자가 입력한 값도 화면에 그대로 남기기 위해서 어떻게 할 수 있는지 하나씩 살펴보자!
BindingResult는 무엇이며 어떻게 사용해야할까?
org.springframework.validation.BindingResult는 스프링이 제공하는 검증 오류를 보관하는 객체이다.
가장 주의해야할 점은 BindingResult의 파라미터 위치는 @ModelAttribute를 사용하는 검증이 필요한 객체 다음에 와야한다.
그래야 스프링이 제공하는 BindingResult가 자동으로 검증 객체를 찾아 검증이 가능하기 때문이다.
그리고 BindingResult는 Model에 자동으로 포함되기 때문에 model.addAttribute로 굳이 추가하지 않아도 된다.
BindingResult와 FieldError, ObjectError 사용
필드에 오류가 있으면 FieldError 객체를 생성해서 bindingResult에 담아두면 된다.
FieldError의 생성자 매개변수에서 objectName은 @ModelAttribute 이름을, field는 오류가 발생한 필드의 이름을, defaultMessage는 오류가 발생하면 기본으로 보여줄 메시지를 작성해주면 된다.
필드가 아닌 경우에는 ObjectError 객체를 생성해서 bindingResult에 담아두면 된다.
ObjectError 생성자의 매개변수는 FieldError에서 field를 제외하고 동일하다.
작성한 Controller 코드를 살펴보자
@Slf4j
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemController {
private final ItemRepository itemRepository;
@PostMapping("/add")
public String addItem(@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, "수량은 최소 1개, 최대 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);
// bindingResult는 자동으로 model에 들어감
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와 ObejctError의 생성자
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)
파라미터 목록을 살펴보자
- objectName: 오류가 발생한 객체 이름
- field: 오류 필드
- rejectedValue: 사용자가 입력한 값(거절된 값)
- bindingFailure: 타입 오류 같은 바인딩 실패인지, 검증 실패인지에 대한 구분 값
- codes: 메시지 코드
- arguments: 메시지에서 사용하는 인자
- defaultMessage: 기본 오류 메시지
여기서 더 나아가 오류 코드를 좀 더 자동화하도록 해보자
BindingResult가 제공하는 rejectValue(), reject() 사용하기 + 오류 메시지 공통화하기
먼저 오류 메시지를 공통화하기 위해서 errors.properties를 만들고 application.properties에 basename을 추가해주어야 한다.
스프링 부트 메시지 설정 추가를 해주지 않으면 errors 파일을 인식하지 않는다.
errors.properties
#==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} 까지 허용합니다.
#타입 미스매치 관련 오류메시지
typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.
application.properties
spring.messages.basename=messages,errors
Controller 코드
@Slf4j
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {
private final ItemRepository itemRepository;
@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 조건으로 체크하는 것과 같은 기능을 함
// 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() >= 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);
// bindingResult는 자동으로 model에 들어감
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()와 reject()를 사용하면 FieldError와 ObjectError를 직접 생성하지 않고 깔끔하게 검증 오류를 다룰 수 있다.
MessageCodesResolver
rejectValue()와 reject()는 내부에서 MessageCodesResolver를 사용하기 때문에 여기에서 메시지 코드들을 생성한다.
MessageCodesResolver는 검증 오류 코드로 메시지 코드들을 생성하는데 MessageCodesResolver는 인터페이스이고, DefaultMessageCodesResolver는 기본 구현체이다.
DefaultMessageCodesResolver의 기본 메시지 생성 규칙은 아래와 같다.
객체 오류의 경우 다음 순서로 2가지 생성한다.
- : code + "." + object name
- : code
예) 오류 코드: required, object name: item
1.: required.item
2.: required
필드 오류의 경우 다음 순서로 4가지 메시지 코드를 생성한다.
- : code + "." + object name + "." + field
- : code + "." + field
- : code + "." + field type
- : code
예) 오류 코드: typeMismatch, object name "user", field "age", field type: int
1. "typeMismatch.user.age"
2. "typeMismatch.age"
3. "typeMismatch.int"
4. "typeMismatch"
동작 방식은 아래와 같다.
- rejectValue() , reject() 는 내부에서 MessageCodesResolver 를 사용하는데 여기에서 메시지 코드들을 생성한다.
- FieldError , ObjectError 의 생성자를 보면, 오류 코드를 하나가 아니라 여러 오류 코드를 가질 수 있다는 것을 확인할 수 있다.
- MessageCodesResolver 를 통해서 생성된 순서대로 오류 코드를 보관한다.
- 생성된 메시지 코드를 기반으로 순서대로 MessageSource에서 메시지를 찾는다. 구체적인 것 -> 덜 구체적인 순서대로 찾기 때문에 errors.properties에서 LEVEL1 ~ LEVEL4로 구분하여 처리할 수 있다.
- 못찾으면 코드에서 직접 작성한 defaultMessage를 사용한다.
그리고 스프링은 타입 오류가 발생하면 typeMismatch라는 오류 코드를 사용하기 때문에 MessageCodesResolver를 통하면서 4가지 메시지 코드가 생성된다.
- typeMismatch.item.price
- typeMismatch.price
- typeMismatch.java.lang.Integer
- typeMismatch
errors.properties에 타입 미스매치 오류 메시지 추가한 부분이 스프링에서 제공하는 타입 오류를 가지고 처리하기 위해서 추가한 오류 메시지이다.
검증 로직을 컨트롤러에서 분리하기
어느정도 스프링에서 제공하는 검증에 대한 부분을 알았으니 컨트롤러에 추가된 검증 로직을 별도로 분리해보자
검증 로직 분리 -> ItemValidator
Validator 인테페이스를 상속받아 supports와 validate를 구현한다.
supports는 해당하는 검증기를 지원하는 여부를 확인하기 위해서 사용되고, validate는 말 그대로 검증 로직이 들어있는 곳이다.
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) {
/**
* 본인과 자식 클래스까지 클래스가 같은지 확인함
* item == clazz
* item == subItem
*
* 여러 개의 검증기가 있으면 해당 메서드를 통해서 대상에 맞는 검증 로직을 호출하기 위해서 사용됨
*/
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);
}
}
}
}
Controller 코드 수정
@Slf4j
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {
private final ItemRepository itemRepository;
private final ItemValidator itemValidator;
/**
* 컨트롤러가 호출될 때 마다 항상 먼저 호출이 되면서 검증 로직을 태움
*/
@InitBinder
public void init(WebDataBinder dataBinder) {
log.info("init binder {}", dataBinder);
dataBinder.addValidators(itemValidator);
}
/**
* 검증 대상 앞에 @Validated를 추가하여 initBinder가 자동으로 적용됨
* Item에 검증 로직을 태움
*/
@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
// 검증 실패하면 다시 입력 폼으로 이동
if (bindingResult.hasErrors()) {
log.info("errors = {}", bindingResult);
// bindingResult는 자동으로 model에 들어감
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가 Validator 인터페이스를 상속받아 구현했기 때문에 스프링의 추가적인 도움을 받을 수 있다.
WebDataBinder를 통해서 사용할 수 있는데 WebDataBinder는 스프링의 파라미터 바인딩의 역할을 해주고 검증 기능도 내부에 포함하고 있다.
그래서 컨트롤러 코드 상단에 컨트롤러가 호출될 때 마다 항상 먼저 호출이 되면서 검증기를 호출할 수 있는 코드를 작성한 것이다.
@InitBinder를 사용하면 해당 컨트롤러에만 영향을 준다.
@InitBinder
public void init(WebDataBinder dataBinder) {
dataBinder.addValidators(itemValidator);
}
위와 같이 검증기를 자동으로 적용하는 코드를 작성한 뒤에 API 내부에서 직접 validator를 호출하는 것이 아닌
검증 대상 앞에 @Validated 애노테이션을 붙여 사용하면 된다.
@Validated 애노테이션은 검증기를 실행하라는 애노테이션이다.
이 애노테이션이 붙으면서 앞에서 WebDataBinder에 등록한 검증기를 찾아서 실행한다.
여기서 여러 검증기를 등록한다면 어떤 검증기가 실행되어야 할지 구분이 필요한데 그 때 supports()가 사용된다.
@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
:
:
}
만약, 글로벌 설정이 필요하다면(모든 컨트롤러에 적용)
Application 클래스에 WebMvcConfigurer를 상속받아 getValidator()를 구현하면된다.
이렇게 글로벌 설정을 추가하면 기존 컨트롤러의 @InitBinder를 제거해도 글로벌 설정으로 정상 동작한다.
@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {
public static void main(String[] args) {
SpringApplication.run(ItemServiceApplication.class, args);
}
@Override
public Validator getValidator() {
return new ItemValidator();
}
}
타임리프에서 BindingResult 처리하기
타임리프를 사용해서 에러 메시지를 출력하는 방법은 아래와 같다.
- #fields로 BindingResult가 제공하는 검증 오류에 접근할 수 있다.
- th:errors로 해당 필드에 오류가 있는 경우에 태그를 출력한다.
- th:errorclass로 th:field에서 지정한 필드에 오류가 있으면 클래스 정보를 추가한다.
글로벌 오류 처리
<div th:if="${#fields.hasGlobalErrors()}">
<p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p>
</div>
필드 오류 처리
<input type="text" id="itemName" th:field="*{itemName}"
th:errorclass="field-error" class="form-control" placeholder="이름을 입력하세요">
<div class="field-error" th:errors="*{itemName}">
상품명 오류
</div>
전체 타임리프 코드는 아래와 같다.
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8">
<link th:href="@{/css/bootstrap.min.css}"
href="../css/bootstrap.min.css" rel="stylesheet">
<style>
.container {
max-width: 560px;
}
.field-error {
border-color: #dc3545;
color: #dc3545;
}
</style>
</head>
<body>
<div class="container">
<div class="py-5 text-center">
<h2 th:text="#{page.addItem}">상품 등록</h2>
</div>
<form action="item.html" th:action th:object="${item}" method="post">
<div th:if="${#fields.hasGlobalErrors()}">
<p class="field-error" th:each="err: ${#fields.globalErrors()}" th:text="${err}">글로벌 오류 메시지</p>
</div>
<div>
<label for="itemName" th:text="#{label.item.itemName}">상품명</label>
<!-- th:field가 itemName이고, 해당 필드가 bindResults로 가져오는 거라면 th:errorclass가 자동으로 인식할 수 있음-->
<input type="text" id="itemName" th:field="*{itemName}" th:errorclass="field-error"
class="form-control" placeholder="이름을 입력하세요">
<!-- 에러가 있으면 출력, 없으면 출력 안함-->
<div class="field-error" th:errors="*{itemName}">
상품명 오류
</div>
</div>
<div>
<label for="price" th:text="#{label.item.price}">가격</label>
<input type="text" id="price" th:field="*{price}"
th:errorclass="field-error"
class="form-control" placeholder="가격을 입력하세요">
<div class="field-error" th:errors="*{price}">가격 오류</div>
</div>
<div>
<label for="quantity" th:text="#{label.item.quantity}">수량</label>
<input type="text" id="quantity" th:field="*{quantity}"
th:errorclass="field-error"
class="form-control" placeholder="수량을 입력하세요">
<div class="field-error" th:errors="*{quantity}">수량 오류</div>
</div>
<hr class="my-4">
<div class="row">
<div class="col">
<button class="w-100 btn btn-primary btn-lg" type="submit" th:text="#{button.save}">상품 등록</button>
</div>
<div class="col">
<button class="w-100 btn btn-secondary btn-lg"
onclick="location.href='items.html'"
th:onclick="|location.href='@{/validation/v2/items}'|"
type="button" th:text="#{button.cancel}">취소</button>
</div>
</div>
</form>
</div> <!-- /container -->
</body>
</html>
이렇게 작성하고 실행을 시켜보니 아래와 같이 정의한 메시지가 표시되는 것을 확인할 수 있다.

'TIL > Spring' 카테고리의 다른 글
[Spring] Session으로 로그인 기능 구현하기(HttpSession, TrackingModes, Session Timeout) (0) | 2025.03.27 |
---|---|
[Spring] Spring - Bean Validation (0) | 2025.03.20 |
[Spring] 빈 스코프 란? (0) | 2025.03.08 |
[Spring] 빈 생명주기 콜백 이란? - 스프링 초기화, 소멸 메서드 사용하기 (0) | 2025.03.04 |
[Spring] 찍어먹어보자! Spring Security와 인증/인가란 무엇인가?(feat. 로그인, 로그아웃, 회원가입 간단 구현) (0) | 2025.02.02 |