2025. 12. 21. 16:48ㆍSpring RoadMap/Spring MVC
백엔드 웹 개발 핵심 기술, 스프링 MVC - 검증(Validation)
웹 애플리케이션에서 검증(Validation)은 타협할 수 없는 부분이다. 클라이언트(Front-end)에서 유효성 검사를 하더라도, 보안과 데이터 무결성을 위해 서버(Back-end)에서의 검증은 필수다.
이번 글에서는 스프링 MVC가 제공하는 검증 메커니즘이 어떻게 발전해왔는지, 그 내부 원리(BindingResult, FieldError, MessageCodesResolver)를 코드로 상세히 뜯어본다.

1. 검증 요구사항

- 사용자가 상품 등록 폼에서 정상 범위의 데이터 입력
- 서버에서 검증 로직 통과하고 상품 저장
- 상품 상세 화면으로 리다이렉트

- 사용자가 검증 범위를 넘어서는 데이터 입력
- 서버 검증 로직 실패
- 사용자에게 상품 등록 폼을 다시 보여주고 어떤 값을 잘못 입력했는지 알림
우리가 만들 상품 관리 시스템에 다음과 같은 검증 로직을 추가한다고 가정하자.
- 타입 검증: 가격, 수량에 문자가 들어가면 검증 오류 처리.
- 필드 검증:
- 상품명: 필수, 공백 X.
- 가격: 1,000원 이상 ~ 1,000,000원 이하.
- 수량: 최대 9,999개.
- 글로벌 검증(복합 룰): 가격 * 수량의 합은 10,000원 이상이어야 함.
2. V1: 단순히 Map을 사용한 검증 (무식한 방법)
가장 먼저 떠오르는 방법은 검증 오류가 발생할 때마다 Map에 오류 정보를 담아 뷰로 넘기는 것이다.
ValidationItemControllerV1
@PostMapping("/add")
public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) {
// 1. 검증 오류 결과를 보관할 객체 생성
Map<String, String> errors = new HashMap<>();
// 2. 필드 검증 로직
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 까지 허용합니다.");
}
// 3. 특정 필드가 아닌 복합 룰 검증 (글로벌 오류)
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice);
}
}
// 4. 검증 실패 시 다시 입력 폼으로 이동
if (!errors.isEmpty()) {
log.info("errors = {}", errors);
model.addAttribute("errors", errors);
return "validation/v1/addForm";
}
// 5. 성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v1/items/{itemId}";
}
글로벌 오류 메시지
<div th:if="${errors?.containsKey('globalError')}">
<p class="field-error" th:text="${errors['globalError']}">전체 오류 메시지</p>
</div>
필드 오류 처리
<input type="text" th:classappend="${errors?.containsKey('itemName')} ? 'fielderror' : _" class="form-control">
직접 처리의 문제점
- 타입 오류 처리 불가: Item 객체의 price는 Integer다. 만약 사용자가 "A"를 입력하면 컨트롤러에 진입하기도 전에 400 Bad Request 예외가 터져버린다. 즉, 우리가 만든 errors 맵 로직은 실행조차 되지 않는다.
- 값 유지의 번거로움: 오류가 발생해서 폼으로 돌아갔을 때, 사용자가 입력했던 데이터가 다 날아간다(물론 @ModelAttribute가 어느 정도 해주지만 타입 오류 시에는 불가능하다).
- 뷰 템플릿 복잡도 증가: 뷰에서 Map을 직접 꺼내서 처리해야 하므로 코드가 지저분해진다.
3. V2: BindingResult 사용 (스프링의 해결사)
스프링은 검증 오류를 보관하는 전용 객체인 BindingResult를 제공한다.
중요한 규칙: BindingResult 파라미터는 반드시 검증할 객체(@ModelAttribute) 바로 뒤에 와야 한다.
ValidationItemControllerV2
@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
// 1. 필드 검증
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 까지 허용합니다."));
}
// 2. 글로벌 오류 (ObjectError)
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
}
}
// 3. 검증 실패 로직
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
// model.addAttribute("errors", errors); // BindingResult는 자동으로 뷰에 넘어간다. 생략 가능.
return "validation/v2/addForm";
}
// 성공 로직 생략...
}
BindingResult의 특징
- 타입 오류 방어: @ModelAttribute 바인딩 시 타입 오류가 발생해도 컨트롤러가 호출된다! 스프링이 오류 정보를 BindingResult에 담아서 컨트롤러를 정상적으로 실행시켜 준다.
- FieldError: 필드에 오류가 있을 때 사용. new FieldError(객체명, 필드명, 메시지)
- ObjectError: 특정 필드가 아닌 글로벌 오류일 때 사용. new ObjectError(객체명, 메시지)
BindingResult에 검증 오류를 적용하는 3가지 방법
- @ModelAttribute의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 FieldError 생성해서 BindingResult 에 포함
- 개발자가 직접 적용
- Validator 사용
4. V3: 사용자 입력 값 유지 (FieldError의 2번째 생성자)
V2의 문제점은 오류가 발생했을 때 사용자가 입력했던 잘못된 값(예: 가격에 'qqq')이 사라진다는 점이다. 이를 해결하기 위해 FieldError의 두 번째 생성자를 사용한다.
ValidationItemControllerV2 - addItemV2
if (!StringUtils.hasText(item.getItemName())) {
// rejectedValue: 사용자가 입력한 거절된 값
// bindingFailure: 타입 오류 같은 바인딩 실패인지, 검증 로직 실패인지 구분 (여기선 검증 로직 실패니 false)
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 까지 허용합니다."));
}
FieldError 생성자 파라미터 상세
- objectName: 오류가 발생한 객체 이름
- field: 오류 필드
- rejectedValue: 사용자가 입력한 값 (거절된 값) -> 이것을 넣어줘야 뷰에서 다시 보여줄 수 있다.
- bindingFailure: 타입 오류면 true, 검증 오류면 false
- codes: 메시지 코드 (뒤에서 설명)
- arguments: 메시지에서 사용하는 인자
- defaultMessage: 기본 오류 메시지
스프링의 바인딩이 실패(타입 오류)하면, 스프링은 내부적으로 이 생성자를 이용해 BindingResult에 사용자의 입력 값을 담아둔다. 그래서 우리가 타임리프의 th:field를 쓰면 오류가 나도 값이 유지되는 것이다.
5. V4: 오류 메시지의 체계적 관리 (MessageCodesResolver)
하드코딩된 오류 메시지("상품 이름은 필수입니다")를 소스코드에서 제거하고, errors.properties 파일로 관리해보자.
application.properties
spring.messages.basename=messages,errors
errors.properties
required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
ValidationItemControllerV2 - addItemV3 (rejectValue 사용)
FieldError를 직접 생성하는 것은 너무 번거롭다(생성자 파라미터가 너무 많다). BindingResult는 이미 본인이 검증해야 할 객체(item)를 알고 있다. 따라서 rejectValue() 메서드를 사용하면 코드가 획기적으로 줄어든다.
@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
// 1. 필드 검증 - rejectValue() 사용
if (!StringUtils.hasText(item.getItemName())) {
// field, errorCode, errorArgs, defaultMessage
bindingResult.rejectValue("itemName", "required", null, null);
}
if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
}
// ... 나머지 생략 ...
// 2. 글로벌 오류 - reject() 사용
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()) {
return "validation/v2/addForm";
}
// ... 성공 로직 ...
}
핵심 원리: MessageCodesResolver bindingResult.rejectValue("itemName", "required")를 호출하면 내부적으로 MessageCodesResolver가 동작하여 다음과 같은 순서로 메시지 코드를 생성한다. (구체적인 것에서 덜 구체적인 순서로)
- required.item.itemName (코드 + 객체명 + 필드명)
- required.itemName (코드 + 필드명)
- required.java.lang.String (코드 + 타입)
- required (코드)
스프링은 이 순서대로 errors.properties를 뒤져서 메시지를 찾는다. 개발자는 범용적인 메시지(required)를 정의해두고, 특별히 중요한 필드만 구체적인 메시지(required.item.itemName)를 정의하면 된다.
6. V5: 검증 로직의 분리 (Validator 인터페이스)
컨트롤러에 검증 로직이 가득 차 있어서, 컨트롤러의 본분인 "요청을 받아 응답을 준다"는 역할이 흐려지고 있다. 검증 로직을 별도 클래스로 분리하자.
ItemValidator
@Component
public class ItemValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
// Item 클래스와 그 자식 클래스를 검증할 수 있는지 확인
return Item.class.isAssignableFrom(clazz);
}
@Override
public void validate(Object target, Errors errors) {
Item item = (Item) target;
// 기존 컨트롤러에 있던 검증 로직을 그대로 복사해옴
// BindingResult는 Errors의 자식이므로 그대로 사용 가능
if (!StringUtils.hasText(item.getItemName())) {
errors.rejectValue("itemName", "required");
}
// ... (나머지 로직 동일) ...
}
}
ValidationItemControllerV2 - addItemV5 (Validator 적용)
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {
private final ItemValidator itemValidator; // 주입 받음
@PostMapping("/add")
public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
// 검증 실행
itemValidator.validate(item, bindingResult);
// 검증 실패 시
if (bindingResult.hasErrors()) {
return "validation/v2/addForm";
}
// 성공 로직...
}
}
이제 컨트롤러는 검증 로직이 어떻게 돌아가는지 알 필요가 없다. "검증해줘"라고 던지고, "결과가 어때?"하고 확인만 하면 된다.
7. V6: 검증 자동화 (@Validated)
검증기를 매번 itemValidator.validate()로 직접 호출하는 것도 귀찮다. 스프링은 애노테이션 하나로 이를 자동화해준다.
WebDataBinder 설정 (컨트롤러 내)
@InitBinder
public void init(WebDataBinder dataBinder) {
// 이 컨트롤러로 요청이 올 때마다 검증기를 자동으로 적용
dataBinder.addValidators(itemValidator);
}
ValidationItemControllerV2 - addItemV6 (최종)
@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
// 코드 한 줄 없이 검증 끝!
// @Validated가 있으면 앞서 등록한 itemValidator.supports()를 체크하고,
// 맞으면 validate()를 실행해서 bindingResult에 오류를 담아준다.
if (bindingResult.hasErrors()) {
return "validation/v2/addForm";
}
// 성공 로직...
return "redirect:/validation/v2/items/{itemId}";
}
- @Validated: "이 객체를 검증하라"는 뜻이다. (@Valid도 사용 가능하다. @Valid는 자바 표준, @Validated는 스프링 전용이며 그룹 검증 기능이 추가된다.)
- 이 애노테이션이 붙으면 WebDataBinder에 등록된 검증기를 찾아서 실행한다. 여러 검증기가 등록되어 있다면 supports() 메서드로 어떤 검증기를 쓸지 결정한다.
정리
우리는 검증 로직을 다음과 같은 단계로 발전시켰다.
- Map 사용: 타입 변환 오류 처리가 안 되고 코드가 복잡함.
- BindingResult: 스프링이 제공하는 검증 오류 보관소. 타입 오류도 안전하게 처리.
- FieldError: 사용자가 입력한 거절된 값을 저장하여 화면에 다시 보여줌.
- rejectValue + MessageCodesResolver: 복잡한 생성자 대신 에러 코드(required)만으로 메시지를 체계적으로 관리.
- Validator 분리: 컨트롤러에서 복잡한 검증 로직을 제거.
- @Validated: 애노테이션 하나로 검증 실행을 자동화.
이 과정을 통해 컨트롤러 코드는 매우 깔끔해졌고, 검증 로직은 재사용 가능하며 체계적인 구조를 갖추게 되었다. 다음 섹션에서는 검증 로직을 더 간편하게 만드는 Bean Validation(애노테이션 기반 검증)에 대해 알아본다.
'Spring RoadMap > Spring MVC' 카테고리의 다른 글
| 스프링 MVC 2편 (6), 로그인 1 - 쿠키, 세션 (0) | 2025.12.21 |
|---|---|
| 스프링 MVC 2편 (5), 검증 2 - Bean Validation (0) | 2025.12.21 |
| 스프링 MVC 2편 (1, 2, 3), Thymeleaf (0) | 2025.12.21 |
| 스프링 MVC 1편 (7 - 마지막), 웹 페이지 만들기 (0) | 2025.12.21 |
| 스프링 MVC 1편 (6), 스프링 MVC 기본 기능 (1) | 2025.12.21 |