개요
우리가 흔히 사용하는 @Validated , BindingResult는 처음부터 존재했던 게 아니라 점진적으로 발전된 결과물이다.
그 과정을 따라가 보려고 한다
V1 - 가장 원시적인 검증 방식 (Map 사용)
@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}";
}
V1에서는 검증 오류를 이렇게 직접 관리했다
Map<String, String> errors = new HashMap<>();
Error 맵을 만들고 검증 실패가 나면 해당 맵에 에러 메시지와 어떤 필드에서 검증 실패가 났는지 기록했다
errors.put("price", "가격은 1,000 ~ 1,000,000 까지 허용 합니다");
그리고 다시 뷰로 보낼 때 model에 이 ErrorMap을 담아서 뷰에서 검증 결과를 보여줬다
model.addAttribute("errors", errors);
return "validation/v1/addForm";
이 방식의 문제점
1. 검증 로직 자체가 실행이 안될 수 있다
예를 들어 사용자가 price = abc를 입력하면 Item.price는 Integer 이기 때문에 Data Binding 단계에서 TypeDismatch 예외가 발생한다
실제 요청 흐름을 따라가 보면
HTTP 요청
↓
DataBinding (문자 → 객체 변환)
↓
타입 변환 실패 ❌
↓
컨트롤러 호출 안됨 ❌
↓
400 에러 페이지
즉 우리가 만든 검증 로직 자체가 실행되지 않는다.
2. 사용자 입력값이 사라짐
사용자가 어떤 입력값을 입력해서 검증이 실패했는지 알 수가 없다 price = "qqq"를 입력했지만 예외가 발생하고 사용자가 "qqq"를 입력했다는 그런 입력 값이 사라져 사용자는 뭐가 문제인지 알 수 없게 된다
참고
@ModelAttribute는 필드 중 하나가 바인딩이 실패해도 BindingResult랑 같이 쓰면 Binding실패를 허용해서 Controller까지 호출된다
반면에 @ResponseBody 나 @RestController는 필드중 하나라도 바인딩이 실패하면 예외가 발생한다
V2 - BindingResult 도입
이러한 분제를 해결하기 위해 등장한 게 바로 BindingResult
핵심 변화
@PostMapping("/add")
public String addItem( @ModelAttribute Item item,BindingResult bindingResult )
기존에 BindingResult가 없을 때는
HTTP 요청
↓
DataBinding 실패 ❌
↓
컨트롤러 호출 안됨
이러한 문제가 있었지만 이제 BindingResult가 등장함으로 다음과 같이 흐름이 변하게 된다
HTTP 요청
↓
DataBinding 실패
↓
FieldError 객체 생성
↓
BindingResult 저장
↓
컨트롤러 호출됨 ✅
코드로 변화된 부분을 살펴보자면 에러를 처리하는 부분이 다음과 같이 객체를 생성하는 것으로 바뀌게 된다
기존
if (!StringUtils.hasText(item.getItemName())) {
errors.put("itemName", "상품 이름은 필수입니다.");
}
첫 번째 변화
FieldError객체와 와 BindingResult의 도입
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError("item","itemName","상품 이름은 필수 입니다."));
}
두 번째 변화
이후 사용자가 어떤 오류값을 입력해서 검증이 실패했는지 나타내는 reject value를 포함한 여러 기능이 추가되어 다음과 같이 바뀐다
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError(
"item","itemName",
item.getItemName(),
false,
null,
null,
"상품 이름은 필수 입니다."));
}
FieldError의 생성자 파라미터의 의미는 입력하는 순서대로 다음과 같다
- 어떤 Target 객체를 검증? → "item"
- 그 객체의 어떤 필드? → "itemName"
- 어떤 입력 잘못된 입력값을 입력했나? (reject value) → "item.getName()"
- 바인딩에 실패해서 발생한 오류인가? (bindingFailure) → false
- Code(Message code) → null
- Argument(Message Argument) → null
- default Message → "상품 이름은 필수입니다 "
V2-3 메시지 코드 도입
여기선 이전 버전과 달라진 게 Message를 보여주는 부분이 하드코딩 된 부분에서 이전에 학습한 MessageSource로 바뀌었다
//검증 로직
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.addError(new FieldError(
"item",
"itemName",
item.getItemName(),
false,
new String[]{"required.item.itemName"},
null,
null));
}
이전 부분에서 다음과 같은 부분이 변경되었다
- 어떤 Target 객체를 검증? → "item"
- 그 객체의 어떤 필드? → "itemName"
- 어떤 입력 잘못된 입력값을 입력했나? (reject value) → "item.getName()"
- 바인딩에 실패해서 발생한 오류인가? (bindingFailure) → false
- Code(Message code) → null → new String []{"required.item.itemName"}
- Argument(Message Argument) → null → new Object []{"1,000원", "1,000,000원"}
- default Message → "상품 이름은 필수입니다 "
V2-4 rejectValue()
bindResult.addError(new FieldError("~"~~,,,.....) 너무 길기 때문에 축약 버전이 등장하기 시작했다.
if (!StringUtils.hasText(item.getItemName())) {
bindingResult.rejectValue("itemName","required");
}
이제 필드 Error 은
bindingResult.rejectValue
Global Error는
bindingResult.reject
를 사용해서 간편하게 사용된다
여기서 주의할 점은 인자값으로 필드와 , MessageSource의 코드 값을 받는다
여기선 필드는 itemName , MessageSource의 코드는 required이다
스프링은 해당 메시지 소스 코드를 총다음과 같은 방식으로 만들고 해당 메시지 소스 코드가 존재하지 않으면
defaultMessage를 출력한다
스프링은 메세지 파일에서 이 순서대로 메시지가 존재하는지 찾는다 여기서는 코드값을 required를 줬으므로 다음과 같이 메시지를 찾기 시작한다
required.item.itemName
required.itemName
required.java.lang.String
required
참고
이렇게 메시지를 관리하고 메세지를 찾고 하는 일은 MessageCodesResolver가 담당한다
V2-5 Validator 인터페이스 도입
사실 많이 편해졌어도 여전히 문제가 남아있다. 컨트롤러마다 하나하나 에러를 등록해줘야 하고 필드가 많아지고 , 컨트롤러가 많아지면 그 수만큼 같은 코드를 중복 작성해야 한다
또 컨트롤러는 컨트롤러의 역할에만 집중하고 검증 기는 검증의 역할에 집중해야 한다 즉 검증의 역할을 컨트롤러에서 분리해야 하는 문제도 여전히 존재한다
그래서 등장하게 된 게 Validator 인터페이스다
@Component
public class ItemValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return Item.class.isAssignableFrom(clazz);
// 파라미터로 넘어오는 clazz가 Item class를 지원 하냐
// item == clazz
// item == subItem
}
@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[]{"1,000원", "1,000,000원"}, null);
}
if (item.getQuantity() == null || item.getQuantity() >= 9999) {
errors.rejectValue("quantity", "max", new Object[]{"9,999"}, null);
}
if (item.getQuantity() != null && item.getPrice() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
errors.reject("totalPriceMin",new Object[]{10000 , resultPrice},null);
}
}
}
Validator 인터페이스를 구현해서
- supports 메서드에 어떤 class를 검증할 건지 구현하고
- validate 메서드에 검증 로직을 구현한다
이렇게 따로 분리를 하고 필요한 컨트롤러에서 가져다 쓰면 된다
@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}";
}
처음 코드와 비교했을 때 엄청나게 편리해졌다 하지만 개발자들은 더 더 편리하고 싶어 해서 더 발전한다
V2-6 WebDataBinder + @Validated
사실 Validator를 분리했지 그냥 클래스를 만들고 호출하는 것도 개발자의 몫이다
위에 코드에서
itemValidator.validate (item,bindingResult)
이렇게 간단해졌지만 여전히 저 똑같은 코드를 검증이 필요한 곳곳에 똑같이 호출해서 써야 한다
그래서 @Validated 어노테이션이 등장하게 된다
@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes ) {
//검증에 실패하면 다시 입력 폼으로
if (bindingResult.hasErrors()) {
// log.info("errors={}", bindingResult);
bindingResult.getAllErrors().forEach(e -> {
log.info("error = {}", e);
});
return "validation/v2/addForm";
}
//성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v2/items/{itemId}";
}
이제는 호출도 필요 없이 검증을 원하는 Model 앞에@Validated를 붙이기만 하면 스프링이 알아서 Validator 인터페이스의 구현채를 돌면서 supports를 호출하면서 적절한 Validator를 찾아서 우리가 짠 검증 로직을 실행시켜준다
이게 인터페이스에 의존, 유연한 확장, 다형성 을 따르는 스프링의 힘이다
참고
이전에 BindingResult를 인자로 할 때 항상 Model 뒤에 선언했다 그 이유는 그렇게 해야 BidningResult가 Model을 검증할 객체로 인식하기 때문이다 그래서 여기서도 @Validated의 위치는 @ModelAttribute 앞에 있어야 한다
V3 - Bean Validation + Group
Bean Validation
이제는 우리가 검증 코드를 짤 필요도 없다 어노테이션이 등장했다 이렇게 어노테이션으로 검증 규약을 하는 스펙..? 표준..? 을 BeanValidation이라고 한다
이전에 Validator를 통해서 우리의 검증 로직을 만들었는데 이제 그럴 필요도 없어지고 다음과 같이 사용하면 된다
@Data
public class ItemSaveForm {
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(9999)
private Integer quantity;
}
어노테이션은 그냥 별 역할을 안 한다 어노테이션 말 그대로 주석이다
이 주석이 붙어있으면 어떻게 동작하세요~~ 를 나타내는 그냥 구분자? 역할이라고 생각하면 된다
실제 내부 동작 과정은 위에 계속 설명한 과정을 통해서 이뤄진다
다시 한번 말하지만 어노테이션은 사용자(개발자)가 편하라고 만들어진 것이고 내부의 동작과정은 위에 설명한 대로 동작한다
또 또.. 또.. 다른 문제 등장
등록할 때의 validation 기준과 수정할 때의 validation 기준이 다르다 그니깐 Validation 기준이 서로 충돌할 수 있다
예를 들어
- 등록 시에는 quantity 수량이 최대 9999까지 등록할 수 있지만 수정 시에는 수량을 무제한으로 변경할 수 있다
- 등록 시에는 id에 값이 없어도 되지만 수정 시에는 id값이 필수다
그래서 등장한 게
Bean Validation Group
@Validated(SaveCheck.class)
@Validated(UpdateCheck.class)
@NotNull(groups = UpdateCheck.class)
private Long id;
@NotBlank(groups = {SaveCheck.class,UpdateCheck.class})
private String itemName;
@NotNull(groups = {SaveCheck.class,UpdateCheck.class})
@Range(min = 1000, max = 1000000 , groups = {SaveCheck.class,UpdateCheck.class})
private Integer price;
그룹을 지정해서 해당 그룹마다 다른 Validation기준을 가지게 한다
그런데 사실 너무 복잡하다.. 그래서 잘 안 씀
그리고 Groups를 실제로 잘 이용하지 않는데 그 이유가 실무에서는 주로 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문이다
(최종 버전 ) V4 - Form 객체 분리
별건 아니고 여태까지 하나의 DTO를 써서 나타난 문제다
그래서
ItemSaveForm
ItemUpdateForm
이렇게 개별 DTO로 분리하고 각자의 맞는 Validaiton을적용하자
'🌿 스프링 > 스프링 MVC 2편' 카테고리의 다른 글
| 서블릿 예외 처리와 오류 페이지 (0) | 2026.02.15 |
|---|---|
| 서블릿 필터와 스블릿 인터셉터 (0) | 2026.02.15 |
| 로그인 처리- 쿠키,세션 (0) | 2026.02.14 |
| 메시지 , 국제화 기능 (0) | 2026.02.14 |
| 타임리프 기본 기능 과 스프링 (0) | 2026.02.14 |