2025. 12. 21. 17:05ㆍSpring RoadMap/Spring MVC
백엔드 웹 개발 핵심 기술, 스프링 MVC - 검증 2 (Bean Validation)
이전 글에서 우리는 ItemValidator라는 클래스를 직접 만들어서 검증 로직을 분리했다.
하지만 if (item.getItemName() == null) 같은 코드를 매번 치는 것은 여전히 번거롭다.
"값이 비어있으면 안 됨", "숫자는 1000 이상이어야 함" 같은 로직은 매우 일반적이다. 이를 애노테이션으로 표준화한 기술이 바로 Bean Validation이다.
(참고: Bean Validation은 인터페이스 표준이고, 구현체는 주로 Hibernate Validator를 사용한다.)

1. 설정 및 적용
1-1. 의존관계 추가 (build.gradle)
스프링 부트 2.3 이후부터는 별도의 라이브러리를 추가해야 한다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
1-2. 도메인 객체에 애노테이션 적용
이제 Item 클래스에 검증 로직을 애노테이션으로 선언한다.
@Data
public class Item {
private Long id;
@NotBlank(message = "공백은 입력할 수 없습니다.") // 빈값 + 공백만 있는 경우 허용 안 함
private String itemName;
@NotNull // null 허용 안 함
@Range(min = 1000, max = 1000000) // 범위 안의 값이어야 함
private Integer price;
@NotNull
@Max(9999) // 최대값 지정
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
- @NotBlank: null, "", " " 모두 허용하지 않음. (String 전용)
- @NotNull: null만 허용하지 않음.
- @Range(min, max): 하이버네이트 구현체에서 제공하는 기능.
- @Max: 최대값 제한.
1-3. 스프링 MVC 통합
놀랍게도 컨트롤러 코드는 단 한 줄도 고칠 필요가 없다. (V6 버전 기준)
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, ...) {
if (bindingResult.hasErrors()) {
return "validation/v3/addForm";
}
// ...
}
동작 원리
- spring-boot-starter-validation 라이브러리가 있으면, 스프링 부트가 자동으로 LocalValidatorFactoryBean을 글로벌 Validator로 등록한다.
- 이 Validator는 @NotNull 같은 애노테이션을 보고 검증을 수행한다.
- @Validated (또는 @Valid)가 붙은 객체에 대해 검증을 수행하고, 오류가 발생하면 FieldError를 생성해서 BindingResult에 담아준다.
참고 : 검증시 @Validated, @Valid 둘다 사용가능하다
javax.validation.@Valid 를 사용하려면 build.gradle 의존관계 추가가 필요하다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
@Validated는 스프링 전용 검증 애노테이션이고, @Valid는 자바 표준 검증 애노테이션이다. 둘중 아무거나 사용해도 동일하게 작동하지만, @Validated는 내부에 groups라는 기능을 포함하고 있다.
주의: 바인딩에 성공한 필드만 Bean Validation이 적용된다.
- price에 "A"를 입력 -> 타입 변환 실패 -> 스프링이 typeMismatch 오류를 BindingResult에 추가 -> 값 자체가 없으므로 @Range 같은 검증은 실행하지 않음. (당연한 이치다.)
2. 에러 메시지 커스터마이징
애노테이션의 기본 메시지("비어 있을 수 없습니다" 등)를 우리가 원하는 메시지로 바꾸고 싶다면? 역시 errors.properties를 사용하면 된다.
Bean Validation 적용 시 MessageCodesResolver는 다음 순서로 메시지 코드를 생성한다.
패턴: 애노테이션명.객체명.필드명
예를 들어 Item 객체의 price 필드에서 @Range 오류가 발생했다면:
- Range.item.price
- Range.price
- Range.java.lang.Integer
- Range
errors.properties
# Bean Validation 커스텀 메시지
NotBlank={0} 공백X
Range={0}, {1} ~ {2} 허용
Max={0}, 최대 {1}
# 상세 설정
NotBlank.item.itemName=상품 이름을 적어주세요.
Range.item.price=가격은 {1}원 ~ {2}원 사이여야 합니다.
메시지 인자({0}, {1})는 애노테이션마다 다르니 필요할 때 찾아 쓰면 된다. (보통 {0}은 필드명, {1} 이후는 애노테이션 속성 값)
3. 오브젝트 오류 (Global Error) 처리
필드 에러가 아닌, "가격 * 수량 >= 10,000" 같은 복합 룰은 어떻게 처리할까? @ScriptAssert라는 애노테이션이 있지만, 사용법이 복잡하고 제약이 많아 권장하지 않는다.
이 부분은 자바 코드로 직접 작성하는 것이 가장 깔끔하다.
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult) {
// 특정 필드가 아닌 복합 룰 검증 (자바 코드로 해결)
if (item.getPrice() != null && item.getQuantity() != null) {
int resultPrice = item.getPrice() * item.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
// Bean Validation에 의한 필드 오류 + 위의 글로벌 오류가 합쳐져서 처리됨
if (bindingResult.hasErrors()) {
return "validation/v3/addForm";
}
// ...
}
4. 한계와 해결책: Groups vs DTO 분리
실무에서 가장 많이 부딪히는 문제다. 등록할 때와 수정할 때의 검증 요구사항이 다를 수 있다.
- 등록: id는 null이어야 함 (DB 자동 생성). quantity 제한 9999.
- 수정: id는 필수 (@NotNull). quantity 제한 없음.
하나의 Item 도메인 객체에 애노테이션을 덕지덕지 붙이면 이 충돌을 해결할 수 없다.
해결책 1: Bean Validation Groups (비추천)
마커 인터페이스를 만들어서 상황별로 검증을 분리하는 기능이다.
// 저장용 그룹, 수정용 그룹 인터페이스 생성
public interface SaveCheck {}
public interface UpdateCheck {}
@Data
public class Item {
@NotNull(groups = UpdateCheck.class) // 수정 때만 검증
private Long id;
@Max(value = 9999, groups = SaveCheck.class) // 저장 때만 검증
private Integer quantity;
// ...
}
Controller
public String addItem(@Validated(SaveCheck.class) @ModelAttribute Item item, ...)
public String editItem(@Validated(UpdateCheck.class) @ModelAttribute Item item, ...)
코드가 복잡해지고, 어차피 도메인 객체와 폼 데이터가 딱 맞아떨어지는 경우가 거의 없어서 잘 안 쓴다.
해결책 2: 폼 객체 분리 (DTO 사용) - 추천
Item 도메인 객체를 컨트롤러에서 직접 받지 말고, 요청에 맞는 별도의 객체(Form Object, DTO)를 만들어서 전달받는 방식이다.
ItemSaveForm (저장용)
@Data
public class ItemSaveForm {
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(9999)
private Integer quantity;
}
ItemUpdateForm (수정용)
@Data
public class ItemUpdateForm {
@NotNull
private Long id; // 수정엔 ID 필수
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
// 수량 제한 없음
private Integer quantity;
}
Controller
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult) {
// 1. 폼 객체 검증
if (bindingResult.hasErrors()) {
return "validation/v4/addForm";
}
// 2. 성공 시 도메인 객체로 변환
Item item = new Item();
item.setItemName(form.getItemName());
item.setPrice(form.getPrice());
item.setQuantity(form.getQuantity());
itemRepository.save(item);
// ...
}
- @ModelAttribute("item"): 뷰 템플릿에서 item이라는 이름으로 쓰기 위해 이름을 지정해준다. (생략하면 itemSaveForm이 되어서 타임리프 코드를 다 고쳐야 한다.)
- 이 방식이 코드는 조금 늘어나지만, 각 요청에 맞는 명확한 검증 룰을 적용할 수 있어 유지보수에 훨씬 유리하다. 특히 API 스펙을 정의할 때 필수적이다.
5. HTTP 메시지 컨버터와 검증 (API JSON)
API 요청(@RequestBody)에서도 Bean Validation을 쓸 수 있다.
@PostMapping("/add")
public Object addItem(@RequestBody @Validated ItemSaveForm form, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
log.info("API 검증 오류 발생 errors={}", bindingResult);
return bindingResult.getAllErrors(); // 오류 정보를 JSON으로 반환
}
// 성공 로직
return form;
}
중요: @ModelAttribute vs @RequestBody 차이
- @ModelAttribute (폼 요청):
- 필드 단위로 정교하게 바인딩된다.
- price에 문자를 넣어서 타입 오류가 나도, 나머지 필드(itemName)는 정상적으로 바인딩되고 검증 로직도 돈다.
- BindingResult에 typeMismatch 오류가 담겨서 컨트롤러 로직이 실행된다.
- @RequestBody (API JSON 요청):
- HttpMessageConverter가 JSON을 객체로 변환한다.
- 만약 JSON 형식이 틀리거나, price에 문자를 넣어서 파싱(변환) 자체가 실패하면?
- 컨트롤러 자체가 호출되지 않고 예외가 터져버린다. (HttpMessageNotReadableException)
- 따라서 Validator도 실행되지 않고 BindingResult에 오류를 담을 수도 없다.
API 예외 처리 팁 JSON 변환 단계에서 실패했을 때의 예외 처리는 뒤에서 배울 @ExceptionHandler를 사용해서 처리해야 한다. 변환에 성공한 뒤에, 값이 조건(@Range 등)에 안 맞을 때 비로소 bindingResult에 값이 담기고 컨트롤러 로직이 실행된다.
정리
- Bean Validation: @NotBlank, @NotNull 등의 애노테이션으로 검증 로직을 표준화했다.
- 스프링 통합: spring-boot-starter-validation만 넣으면 별도 설정 없이 @Validated로 바로 사용 가능하다.
- 에러 메시지: MessageCodesResolver 덕분에 AnnotaionName.field 형식으로 메시지를 체계적으로 관리할 수 있다.
- DTO 분리: 등록/수정 등 상황에 따라 검증 조건이 다르다면, Groups 기능을 쓰는 것보다 별도의 폼 객체(ItemSaveForm)를 만들어 사용하는 것이 훨씬 깔끔하고 안전하다.
- API 검증: @RequestBody에도 적용 가능하지만, JSON 파싱 자체가 실패하면 검증 로직 실행 전에 예외가 발생한다는 점을 기억하자.
이것으로 검증 파트를 마스터했다. 이제 사용자가 이상한 데이터를 보내도 서버가 안전하게 방어하고, 친절하게 오류 이유를 알려줄 수 있게 되었다. 다음 섹션에서는 웹 애플리케이션의 필수 관문인 로그인 처리에 대해 알아본다.
'Spring RoadMap > Spring MVC' 카테고리의 다른 글
| 스프링 MVC 2편 (7), 로그인 2 - 필터, 인터셉터 (0) | 2025.12.23 |
|---|---|
| 스프링 MVC 2편 (6), 로그인 1 - 쿠키, 세션 (0) | 2025.12.21 |
| 스프링 MVC 2편 (4), 검증 1 - Validation (0) | 2025.12.21 |
| 스프링 MVC 2편 (1, 2, 3), Thymeleaf (0) | 2025.12.21 |
| 스프링 MVC 1편 (7 - 마지막), 웹 페이지 만들기 (0) | 2025.12.21 |