검증 기능을 지금처럼 매번 코드로 작성하는 것은 상당히 번거롭다.
특히 특정 필드에 대한 검증 로직은 대부분 빈 값이 아닌지, 특정 크기를 넘는지 등 매우 일반적인 로직이다.
public class Item {
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(9999)
private Integer quantity;
//...
}
위 코드처럼 검증 로직을 모든 프로젝트에 적용할 수 있도록 공통화 하고 표준화 한 것이 Bean Validation이다.
이것을 잘 활용하면 어노테이션 하나로 검증 로직을 편리하게 적용할 수 있다.
Bean Validation은 특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)이라는 기술 표준이다.
쉽게 이야기해서 검증 어노테이션과 여러 인터페이스의 모음이다.
Bean Validation - 시작
Bean Validation을 사용하려면 build.gradle에 의존성을 추가해야한다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
jakarta.validation-api: Bean Validation 인터페이스
hibernate-validator : 구현체
package hello.itemservice.domain.item;
import lombok.Data;
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@Data
public class Item {
private Long id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1_000_000)
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 : 빈값 + 공백만 있는 경우를 허용하지 않는다.
@NotNull : null을 허용하지 않는다.
@Range : 최소값~ 최대값 사이만 허용한다
@Max : 최대값 설정
package hello.itemservice.validation;
import hello.itemservice.domain.item.Item;
import org.junit.jupiter.api.Test;
import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
import java.util.Set;
public class BeanValidationTest {
@Test
void beanValidation() {
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
Item item = new Item();
item.setItemName(" "); //공백
item.setPrice(0);
item.setQuantity(10000);
Set<ConstraintViolation<Item>> violations = validator.validate(item);
for (ConstraintViolation<Item> violation : violations) {
System.out.println("violation = " + violation);
System.out.println("violation = " + violation.getMessage());
}
}
}
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
위 코드로 검증기를 생성한다. 스프링 통합을 하면 위 코드를 직접 사용할 일은 없다. 그냥 이런식으로 사용된다정도만 알고있으면 됨
Set<ConstraintViolation<Item>> violations = validator.validate(item);
검증 대상을 직접 검증기에 넣고 결과를 받는다. Set에는 ConstraintViolation이라는 검증 오류가 담긴다.
따라서 결과가 비어있으면 검증 오류가 없는것이다.

ConstraintViolation을 사용하면 검증 오류가 발생한 객체, 필드, 메세지 정보 등 다양한 정보를 확인할 수 있다.
Bean Validation - 프로젝트 V3
@Controller
@RequestMapping("/validation/v3/items")
@RequiredArgsConstructor
@Slf4j
public class ValidationItemControllerV3 {
private final ItemRepository itemRepository;
...
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item,
BindingResult bindingResult,
RedirectAttributes redirectAttributes,
Model model) {
// 검증에 실패하면 다시 입력 폼으로
if (bindingResult.hasErrors()) { // 부정의 부정 = 긍정. 리팩토링 신경써서하기
log.info("errors={}", bindingResult);
return "validation/v3/addForm";
}
// 성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v3/items/{itemId}";
}
}
V2에 있었던 ItemValidator (WebDataBinder 사용 코드)를 삭제하고 실행하면 어노테이션 기반의 Bean Validation이 작동한다.
스프링MVC가 어떻게 Bean Validator를 사용하는가?
스프링 부트가 spring-boot-starter-validation 라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합한다.
스프링 부트는 자동으로 글로벌 Validator로 등록한다.
LocalValidatorFactoryBean을 글로벌 Validator로 등록한다. 이 Validator는 @NotNull같은 어노테이션을 보고 검증을 수행한다.
이렇게 글로벌로 설정되어있기 때문에 @Valid, @Validated만 적용하면, 검증 오류 발생 시 FieldError, ObjectError를 생성해서 Binding Result에 넣어주게 되는 것이다.
검증 순서 정리
1. @ModelAttribute 각각의 필드에 타입 변환 시도
- 성공하면 다음로직 수행
- 실패하면 typeMismatch로 FieldError 추가
2. Validator 적용
바인딩에 성공한 필드만 Bean Validation이 적용된다.
바인딩에 실패한 필드는 적용하지 않는다. 애초에 바인딩이 실패했는데, 검증을 할 이유가 없다
(타입이 안맞는데 @Range, @Max를 수행할 이유가 없는것)
*참고로 타입에러 발생 시 이전과 같이 "숫자만 입력해주세요"라는 메세지가 출력되는데, 이는 errors.properties에서 우리가 미리 설정해둔 내용을 가져와 출력한것이다.
Bean Validation - 에러코드
Bean Validation이 기본으로 제공하는 오류메세지를 더 자세히 변경할 수 있다.
먼저 bindingResult에 등록된 검증 오류 코드를 보자

오류코드가 어노테이션 이름으로 등록된다. typeMismatch와 유사하다.
NotBlank라는 오류 코드를 기반으로 MessageCodesResolver가 작동해서 다양한 메세지 코드가 순서대로 생성된다.
이전에 rejectValue()에서 FieldError에서 생성된 오류 코드와 똑같다.
그럼 더 자세하게 메세지를 변경해보자
#Bean Validation 추가
NotBlank.item.itemNames=상품이름을 입력하세요.
NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}
그럼 이전에 했던것처럼 등록한 메세지가 정상적으로 적용되는것을 확인할 수 있다.
BeanValidation 메세지 찾는 순서
1. 생성된 메세지 코드 순서대로 messageSource에서 메세지 찾기
2. 어노테이션 message 속성 사용 -> @NotBlank(message = "공백X {0}")
3. 라이브러리가 기본으로 제공하는 메세지 -> 공백일 수 없습니다.
그냥 이전에 했던것과 똑같다. 차이는 스프링이 자동적으로 다 처리해주는것 뿐이다.
Bean Validation - 오브젝트 오류
Item객체에 @ScriptAssert()를 사용하면 된다.
하지만 이 방법은 제약이 많고 복잡하다. 의도하지 않게 작동될 가능성도 있다. 그리고 JDK 버전이 맞지 않으면 사용되지 않음
따라서 오브젝트 오류 관련 부분만 직접 자바코드로 작성하는것을 권장한다.
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item,
BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
// 특정 필드가 아닌 복합 룰 검증
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);
return "validation/v3/addForm";
}
// 성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v3/items/{itemId}";
}
Bean Validation - 수정에 적용
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId,
@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);
}
}
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return "validation/v3/editForm";
}
itemRepository.update(itemId, item);
return "redirect:/validation/v3/items/{itemId}";
}
수정도 마찬가지로 글로벌 에러 로직을 추가하고, @Validated와 BindingResult를 파라미터에 추가해줬다.
editForm은 addForm과같이 수정하면 된다.
Bean Validation - 한계
요구사항이 변경되었다.
수정시에 quantity의 제한을 없애고, id값이 NotNull이어야 한다.
Item 객체에 어노테이션을 그에 맞게 수정하면, 상품 등록에 에러가 발생한다.
등록시엔 id가 필요없지만, 수정시엔 id가 필요하다. 그런데 id 필드에 @NotNull을 적용시키면 등록이 되지 않는다.
그런데, 수정시에 항상 id값이 들어가는데 검증할 필요가 있나?
- HTTP 요청은 언제든지 악의적으로 변경해서 요청할 수 있으므로 최종적으로 서버 검증이 필요하다. HTTP 요청을 변경해서 item의 id값을 삭제하고 요청할 수도 있기 때문이다.
결과적으로 item은 등록과 수정에서 검증 조건의 충돌이 발생하고, 같은 Bean Validation을 적용할 수가 없게된다.
Bean Validation - groups
동일한 모델 객체를 등록할 때와 수정할 때 각각 다르게 검증하는 방법
1. BeanValidation의 groups 기능 사용
2. Item을 직접 사용하지 않고, ItemSaveForm, ItemUpdateForm같은 전송을 위한 별도의 모델 객체를 사용
groups 적용 코드
package hello.itemservice.domain.item;
public interface SaveCheck {
}
package hello.itemservice.domain.item;
public interface UpdateCheck {
}
package hello.itemservice.domain.item;
import lombok.Data;
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@Data
public class Item {
@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 = 1_000_000, groups = {SaveCheck.class, UpdateCheck.class})
private Integer price;
@NotNull(groups = {SaveCheck.class, UpdateCheck.class})
@Max(value = 9999, groups = {SaveCheck.class})
private Integer quantity;
public Item() {
}
public Item(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
Item 객체에 groups를 적용시킨 코드
@PostMapping("/add")
public String addItem2(@Validated(SaveCheck.class) @ModelAttribute Item item,
BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
// 특정 필드가 아닌 복합 룰 검증
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);
return "validation/v3/addForm";
}
// 성공 로직
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v3/items/{itemId}";
}
addItem에 groups를 적용 (@Validated 확인)
@PostMapping("/{itemId}/edit")
public String editV2(@PathVariable Long itemId,
@Validated(UpdateCheck.class) @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);
}
}
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return "validation/v3/editForm";
}
edit에 groups를 적용
결과는 정상 작동한다. 그러나 groups를 사용하니 Item은 물론이고 전반적으로 복잡도가 올라갔다.
사실 groups기능은 잘 사용하지 않고, 주로 등록용 폼과 수정용 폼 객체를 분리해서 사용한다.
현재 프로젝트 규모는 굉장히 작으니 크게 상관없지만 실무에서 사용하긴 부적합하다.
Form 전송 객체 분리 - V4
실무에서는 groups를 잘 사용하지 않는다. 이유는 등록시 폼에서 전달하는 데이터가 Item도메인 객체와 딱 맞지 않기 때문이다.
현재 예제에서는 딱 맞지만, 실무에서는 딱 맞을일이 거의 없다. 회원과 관련된 데이터만 전달받는게 아닌 약관 정보도 추가로 받는 등 수많은 부가 데이터가 함께 넘어온다.
그래서 보통 Item을 직접 전달받는것이 아니라 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다.
예를들어 ItemSavaForm이라는 폼을 전달받는 전용객체를 만들어서 @ModelAttribute로 사용한다.
이것을 통해 컨트롤러에서 폼 데이터를 전달받고, 이후 컨트롤러에서 필요한 데이터를 사용해서 Item을 생성한다.
HTML Form -> ItemSaveForm -> Controller -> Item 생성 -> Repository
이 과정을 거치면 전송하는 폼 데이터가 복잡해도 그에 맞는 객체를 사용해서 데이터를 전달 받을 수 있다. 따라서 검증이 중복되지 않는다.
단점은 Item객체를 컨트롤러에서 생성하는 변환과정이 추가된다는 것이다.
Form 전송 객체 분리
package hello.itemservice.web.validation.form;
import lombok.Data;
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@Data
public class ItemSaveForm {
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
@NotNull
@Max(value = 9999)
private Integer quantity;
}
package hello.itemservice.web.validation.form;
import lombok.Data;
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
@Data
public class ItemUpdateForm {
@NotNull
private Integer id;
@NotBlank
private String itemName;
@NotNull
@Range(min = 1000, max = 1000000)
private Integer price;
//수정시에는 수량은 자유롭게 변경
private Integer quantity;
}
전용 객체를 생성했다.
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form,
BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
// 특정 필드가 아닌 복합 룰 검증
if (form.getPrice() != null
&& form.getQuantity() != null) {
int resultPrice = form.getPrice() * form.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
// 검증에 실패하면 다시 입력 폼으로
if (bindingResult.hasErrors()) { // 부정의 부정 = 긍정. 리팩토링 신경써서하기
log.info("errors={}", bindingResult);
return "validation/v4/addForm";
}
// 성공 로직
Item item = new Item();
item.setItemName(form.getItemName());
item.setPrice(form.getPrice());
item.setQuantity(form.getQuantity());
Item savedItem = itemRepository.save(item);
redirectAttributes.addAttribute("itemId", savedItem.getId());
redirectAttributes.addAttribute("status", true);
return "redirect:/validation/v4/items/{itemId}";
}
@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId,
@Validated @ModelAttribute("item") ItemUpdateForm form,
BindingResult bindingResult) {
// 특정 필드가 아닌 복합 룰 검증
if (form.getPrice() != null
&& form.getQuantity() != null) {
int resultPrice = form.getPrice() * form.getQuantity();
if (resultPrice < 10000) {
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
}
}
if (bindingResult.hasErrors()) {
log.info("errors={}", bindingResult);
return "validation/v4/editForm";
}
Item itemParam = new Item();
itemParam.setItemName(form.getItemName());
itemParam.setPrice(form.getPrice());
itemParam.setQuantity(form.getQuantity());
itemRepository.update(itemId, itemParam);
return "redirect:/validation/v4/items/{itemId}";
}
각 메서드들은 이제 Item대신에 ItemSaveForm, ItemUpdateForm을 전달받는다.
그리고 @Validated로 검증받고, BindingResult로 검증 결과도 받게된다.
Item item = new Item();
item.setItemName(form.getItemName());
item.setPrice(form.getPrice());
item.setQuantity(form.getQuantity());
Item savedItem = itemRepository.save(item);
위에서 언급한대로 추가적으로 Item객체를 생성해야하는 과정이 생겼다.
폼 객체 데이터를 기반으로 Item 객체를 생성했다. 이렇게 폼 객체처럼 중간에 다른 객체가 추가되면 변환하는 과정이 추가되는것이다.
이제 Form 전송 객체를 분리함으로서 등록과 수정에 딱 맞는 기능을 구성하고, 검증도 명확히 분리했다.
Bean Validation - HTTP 메세지 컨버터
@Valid, @Validated는 HttpMessageConverter에도 적용할 수 있다. (@RequestBody)
package hello.itemservice.web.validation;
import hello.itemservice.web.validation.form.ItemSaveForm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Slf4j
@RestController
@RequestMapping("/validation/api/items")
public class ValidationItemApiController {
@PostMapping("/add")
public Object addItem(@RequestBody @Validated ItemSaveForm form, BindingResult bindingResult) {
log.info("API 컨트롤러 호출");
if (bindingResult.hasErrors()) {
log.info("검증 오류 발생 errors={}", bindingResult);
return bindingResult.getAllErrors();
}
log.info("성공 로직 실행");
return form;
}
}
Postman을 사용해서 테스트를 해보면
- 성공 요청 : 성공
- 실패 요청 : JSON을 객체로 만드는것 자체가 실패
- 검증 오류 요청 : JSON을 객체로 생성하는것은 성공, 검증에서 실패
(결과 화면은 생략하겠음)
실패 요청시에, 타입 에러가 발생하면 HttpMessageConverter가 JSON을 객체화 자체를 하지 못한다.
HttpMessageConverter는 @ModelAttribute와 다르게 각각의 필드 단위로 적용되는것이 아니라, 전체 객체 단위로 적용된다.
그래서 한가지 필드만 잘못되어도 정상적으로 작동되지 않는다. 따라서 컨트롤러 호출 자체가 안된다.
(HttpMessageConverter의 플로우를 이해했다면 왜 컨트롤러 호출이 안되는지 알 수 있음)
검증 오류 요청시에, HttpMessageConverter가 JSON을 객체로 변환하는것까지 성공 -> 컨트롤러 호출까지 작동했다.
그 이후 검증 오류가 발생하는데, 이때 위 코드의 return bindingResult.getAllErrors();로 bindingResult에 있는 모든 에러 코드를
JSON으로 변환해서 클라이언트에 전달했다.
HttpMessageConverter에서 실패하면 예외가 발생하는데, 예외처리는 나중에 배울것이다.
결론은 HttpMessageConverter는 한꺼번에 처리, @ModelAttribute는 필드단위로 세세하게 처리가 가능하다.
'Spring' 카테고리의 다른 글
| 로그인 처리2 - 필터, 인터셉터 (0) | 2025.09.18 |
|---|---|
| 로그인 처리1 - 쿠키, 세션 (1) | 2025.09.17 |
| 오류 코드와 메세지처리, Validator 분리 (0) | 2025.09.11 |
| 메세지, 국제화 (1) | 2025.09.07 |
| 타임리프 - 스프링 통합과 폼 (1) | 2025.09.06 |
