Spring/MVC

[Spring/MVC] 12. Bean Validation

lumana 2025. 1. 19. 17:23

 

12. Bean Validaiton


정리

이전 챕터에서 검증 로직을 스프링 Validator를 구현하여 처리하는 방법까지 살펴봤다. 그런데, 매 번 코드로 검증 로직을 작성하는 것은 상당히 번거롭다. 특히 특정 필드에 대한 검증 로직은 대부분 빈 값인지 아닌지, 특정 크기를 넘는지 아닌지와 같이 매우 일반적인 로직이다.


이런 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화 한 것이 바로 Bean Validation 이다.

public class Item {

	private Long id;

	@NotBlank(message = "공백 X") // 이런 식으로 메시지를 지정할 수도 있다.
	private String itemName;

	@NotNull
	@Range(min = 1000, max = 1000000)
	private Integer price;

	@NotNull
	@Max(9999)
	private Integer quantity;
	 //...
}

Bean Validation을 잘 활용하면, 애노테이션 하나로 검증 로직을 매우 편리하게 적용할 수 있다.


검증 애노테이션

  • @NotBlank : 빈값 + 공백만 있는 경우를 허용하지 않는다.
  • @NotNull : null 을 허용하지 않는다.
  • @Range(min = 1000, max = 1000000) : 범위 안의 값이어야 한다.
  • @Max(9999) : 최대 9999까지만 허용한다.

Bean Validation 이란?
먼저 Bean Validation은 특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)이라는 기술 표준이다. (검증 애노테이션과 여러 인터페이스의 모음이다.) 일반적으로 사용하는 구현체는 하이버네이트 Validator이다. 자세한 내용은 공식 문서를 참고하면 된다.


애노테이션을 사용하다보면 어떤 건 하이버네이트, 어떤 건 javax인데, 대부분 하이버네이트 Validator를 사용하므로 크게 신경쓰지 말고 사용하자.


Bean Validation - 순수 자바 Validation

의존관계 추가
Bean Validation을 사용하려면 다음 의존관계를 추가해야 한다.


build.gradle

implementation 'org.springframework.boot:spring-boot-starter-validation'
  • Jakarta Bean Validation
    • jakarta.validation-api : Bean Validation 인터페이스
  • hibernate-validator 구현체

Bean Validation 동작 테스트

@Test
 void beanValidation() {
     ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
     Validator validator = factory.getValidator();

     // 생략
	// 아이템 객체 생성

     Set<ConstraintViolation<Item>> violations = validator.validate(item);
     // violation.getMessage()로 오류 출력 가능
 }

DefaultFactory에서 validator를 생성한 후, 검증 대상( item )을 직접 검증기에 넣고 그 결과를 받는다. Set 에는 ConstraintViolation 이라는 검증 오류가 담긴다.


스프링과 검증기
스프링은 이미 개발자를 위해 빈 검증기를 스프링에 완전히 통합해두었다.


Bean Validation - 스프링 적용

기존에 등록한 ItemValidator를 제거하자. 오류 검증기가 중복 적용된다.

private final ItemValidator itemValidator;

@InitBinder
public void init(WebDataBinder dataBinder) {
    log.info("init binder {}", dataBinder);
    dataBinder.addValidators(itemValidator);
}

스프링 부트는 자동으로 글로벌 Validator로 등록한다.
LocalValidatorFactoryBean 을 글로벌 Validator로 등록한다. 이 Validator는 @NotNull 같은 애노테이션을 보고 검증을 수행한다. 이렇게 글로벌 Validator가 적용되어 있기 때문에, @Valid, @Validated 만 적용하면 된다. 검증 오류가 발생하면, FieldError, ObjectError 를 생성해서 BindingResult 에 담아준다.


만약 개발자가 Validator를 하나 만들어서 글로벌로 등록하면, LocalValidatorFactoryBean는 등록되지 않는다.


참고

검증시 @Validated @Valid 둘 다 사용가능하다. @Validated 는 스프링 전용 검증 애노테이션이고, @Valid 는 자바 표준 검증 애노테이션이다. @Validated@Valid에 없는 groups 라는 기능을 포함하고 있다. (자주 사용하는 기능은 아니다)


검증 순서

  1. @ModelAttribute 각각의 필드에 타입 변환 시도
    1. 성공하면 다음으로
    2. 실패하면 typeMismatchFieldError 추가
    • 바인딩에 실패한 필드는 BeanValidation을 적용하지 않는다.
      • 일단 모델 객체에 바인딩 받는 값이 정상으로 들어와야 검증도 의미가 있다.
  2. Validator 적용

이렇게 하고 기존 코드를 돌려보자.


우리가 만들었던 ItemValidator 검증기를 사용하는게 아니기 때문에 오류 코드로 인해 기존에 만들어뒀던 에러 메시지가 나오지 않는다. 어떻게 해야 Bean Validation에서 에러 메시지를 원하는 대로 출력할 수 있을까?


Bean Validation - 에러 코드

Bean Validation을 적용하면 오류 코드가 애노테이션 이름으로 등록된다.
예를 들면, NotBlank 라는 오류 코드를 기반으로 MessageCodesResolver 를 통해 다양한 메시지 코드가 순서대로 생성된다.


@NotBlank

  • NotBlank.item.itemName
  • NotBlank.itemName
  • NotBlank.java.lang.String
  • NotBlank

@Range

  • Range.item.price
  • Range.price
  • Range.java.lang.Integer
  • Range

이 오류 코드에 맞춰서 메시지 코드를 추가해주면 된다.

#Bean Validation 추가
NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}

메시지 찾는 순서

  1. 생성된 메시지 코드 순서대로 messageSource 에서 메시지 찾기
  2. 애노테이션의 message 속성 사용 @NotBlank(message = "공백! {0}")
  3. 라이브러리가 제공하는 기본 값 사용 공백일 수 없습니다.

FieldError가 아닌 ObjectError(글로벌 에러)

Bean Validation을 통해 오브젝트 관련 오류(ObjectError)를 처리하기 위해서는 @ScriptAssert() 를 사용하면 된다.

@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000")

하지만, JDK 17부터 자바스크립트 엔진을 지원하지 않는 GraalVM이 사용되면서 ScriptEvaluatorNotFoundException 가 발생한다. 자바스크립트를 지원하는 Nashorn 엔진 의존성을 추가해서 해결할 수는 있지만, 애초에 @ScriptAssert()에는 제약이 많고, 실무에서는 검증 기능이 해당 객체의 범위를 넘어서는 경우들도 종종 등장하는데, 그런 경우 대응이 어렵다.


그래서 김영한 강사님은 오브젝트 오류(글로벌 오류)의 경우 @ScriptAssert 을 억지로 사용하는 것 보다는 다음과 같이 오브젝트 오류 관련 부분만 직접 자바 코드로 작성하는 것을 권장한다. (검증하는 로직을 메서드로 뽑은 뒤에 컨트롤러에서 호출해주면 제일 깔끔하다)


Bean Validation - 한계

위에서는 Item 객체에 바로 Bean Validation을 적용했다. 하지만 우리 예제에서 Item 객체가 상품을 등록할 때만 쓰이는게 아니라, 수정할 때도 쓰인다. 등록할 때와 수정할 때 요구사항(비즈니스 로직)이 다를 수도 있다.


현재 상황에서는 item 은 등록과 수정에서 검증 조건의 충돌이 발생할 것이다. 이를 해결하는 방법으로는 크게 두 가지가 있다.

  1. groups
  2. 객체 분리

Bean Validation - groups

우리 예제에서는 등록 시에 검증할 기능과 수정 시에 검증할 기능을 각각 그룹으로 나누어 적용할 수 있다.


groups 적용

저장용 groups 생성

package hello.itemservice.domain.item;

public interface SaveCheck {
}

수정용 groups 생성

package hello.itemservice.domain.item;

public interface UpdateCheck {
}

Item - groups 적용

@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 = 1000000, groups = {SaveCheck.class, UpdateCheck.class})
 private Integer price;

 @NotNull(groups = {SaveCheck.class, UpdateCheck.class})
 @Max(value = 9999, groups = SaveCheck.class) //등록시에만 적용
 private Integer quantity;
//...

ValidationItemControllerV3 - 저장 로직에 SaveCheck Groups 적용

@PostMapping("/add")
public String addItemV2(@Validated(SaveCheck.class) @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
    //...
}

@Validatedvalue=SaveCheck.class 속성 값을 지정해줬다. (value=는 생략 가능)
마찬가지로 수정 메서드에는 @Validated(UpdateCheck.class)를 지정해주면 된다.


참고

@Valid 에는 groups를 적용할 수 있는 기능이 없다. 따라서 groups를 사용하려면 @Validated 를 사용해야 한다.


하지만 실무에서는 groups 를 잘 사용하지 않는데, 그 이유가 다른 곳에 있다. 바로 등록시 폼에서 전달하는 데이터가 Item 도메인 객체와 딱 맞지 않기 때문이다. (실무에서는 예제처럼 도메인이 하나만 있는 경우는 거의 없다)


예를 들면 회원 등록시 회원과 관련된 데이터만 전달받는 것이 아니라, 약관 정보도 추가로 받는 등 Item 과 관계없는 수 많은 부가 데이터가 넘어온다.


그래서 보통 Item 을 직접 전달받는 것이 아니라, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다. 예를 들면 ItemSaveForm 이라는 폼을 전달받는 전용 객체를 만들어서 @ModelAttribute 로 사용한다. 이것을 통해 컨트롤러에서 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해서 Item 을 생성한다.


폼 데이터 전달을 위한 별도의 객체 사용

HTML Form ItemSaveForm Controller Item 생성 Repository

  • 장점: 전송하는 폼 데이터가 복잡해도 거기에 맞춘 별도의 폼 객체를 사용해서 데이터를 전달 받을 수 있다.보통 등록과, 수정용으로 별도의 폼 객체를 만들기 때문에 검증이 중복되지 않는다.
  • 단점: 폼 데이터를 기반으로 컨트롤러에서 Item 객체를 생성하는 변환 과정이 추가된다.

수정의 경우 등록과 수정은 완전히 다른 데이터가 넘어온다. 요구사항도 다르다. 검증 로직도 많이 달라진다. 그래서 ItemUpdateForm 이라는 별도의 객체로 데이터를 전달받는 것이 좋다.


Item 도메인 객체를 폼 전달 데이터로 사용하고, 그대로 쭉 넘기면 편리하겠지만, 앞에서 설명한 것과 같이 실무에서는 Item 의 데이터만 넘어오는 것이 아니라 무수한 추가 데이터가 넘어온다. 그리고 더 나아가서 Item 을 생성하는데 필요한 추가 데이터를 데이터베이스나 다른 곳에서 찾아와야 할 수도 있다.


Q: 등록, 수정용 뷰 템플릿이 비슷한데 합치는게 좋을까요?
한 페이지에 그러니까 뷰 템플릿 파일을 등록과 수정을 합치는게 좋을지 고민이 될 수 있다. 각각 장단점이 있으므로 고민하는게 좋지만, 어설프게 합치면 수 많은 분기문(등록일 때, 수정일 때) 때문에 나중에 유지보수에서 고통을 맛본다고 한다.


ITEM 원복

@Data
public class Item {

 private Long id;
 private String itemName;
 private Integer price;
 private Integer quantity;
}

Item 저장용 폼

@Data
public class ItemSaveForm {

 @NotBlank
 private String itemName;

 @NotNull
 @Range(min = 1000, max = 1000000)
 private Integer price;

 @NotNull
 @Max(value = 9999)
 private Integer quantity;
}

이런식으로 수정 폼도 만든다.


addItem() 메서드 수정- 폼 객체 바인딩

@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
    //...
}

Item 대신에 ItemSaveForm 을 전달 받는다. 그리고 @Validated 로 검증도 수행하고, BindingResult 로 검증 결과도 받는다.


주의
@ModelAttribute("item")item 이름을 넣어준 부분을 주의하자. 이것을 넣지 않으면 ItemSaveForm 의 경우 규칙에 의해 itemSaveForm 이라는 이름으로 MVC Model에 담기게 된다. 이렇게 되면 뷰 템플릿에서 접근하는 th:object 이름도 함께 변경해주어야 한다.


폼 객체를 Item으로 변환

//성공 로직
Item item = new Item();
item.setItemName(form.getItemName());
item.setPrice(form.getPrice());
item.setQuantity(form.getQuantity());

Item savedItem = itemRepository.save(item);

폼 객체의 데이터를 기반으로 Item 객체를 생성한다. 이렇게 폼 객체 처럼 중간에 다른 객체가 추가되면 변환하는 과정이 추가된다.


이런식으로 edit() 메서드도 수정해주자.


Bean Validation - HTTP 메시지 컨버터

@Valid, @ValidatedHttpMessageConverter ( @RequestBody )에도 적용할 수 있다. 어떤 차이인지 기억이 안 난다면 MVC 앞 쪽 내용을 복습하고 오자.


@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;
}

API의 경우 3가지 경우를 나누어 생각해야 한다.

  1. 성공 요청: 성공
  2. 실패 요청: JSON을 객체로 생성하는 것 자체가 실패함
    • 타입이 맞지 않는다던지, …
  3. 검증 오류 요청: JSON을 객체로 생성하는 것은 성공했고, 검증에서 실패함


실패 요청: JSON을 객체로 생성하는 것 자체가 실패한 경우 - 결과

{
 "timestamp": "2021-04-20T00:00:00.000+00:00",
 "status": 400,
 "error": "Bad Request",
 "message": "",
 "path": "/validation/api/items/add"
}

HttpMessageConverter 에서 요청 JSON을 ItemSaveForm 객체로 생성하는데 실패한다.
이 경우는 ItemSaveForm 객체를 만들지 못하기 때문에 컨트롤러 자체가 호출되지 않고 그 전에 예외가 발생한다. 물론 Validator도 실행되지 않는다.


검증 오류 요청: JSON을 객체로 생성하는 것은 성공했고, 검증에서 실패한 경우 - 결과

[
 {
  "codes": [
   "Max.itemSaveForm.quantity",
   "Max.quantity",
   "Max.java.lang.Integer",
   "Max"
  ],
  "arguments": [
   {
    "codes": [
     "itemSaveForm.quantity",
     "quantity"
    ],
    "arguments": null,
    "defaultMessage": "quantity",
    "code": "quantity"
   },
   9999
  ],
  "defaultMessage": "9999 이하여야 합니다",
  "objectName": "itemSaveForm",
  "field": "quantity",
  "rejectedValue": 10000,
  "bindingFailure": false,
  "code": "Max"
 }
]

return bindingResult.getAllErrors();ObjectErrorFieldError 를 반환한다. 스프링이 이 객체를 JSON으로 변환해서 클라이언트에 전달했다.


실무에서는 API 스펙을 정해서 필요한 데이터만 전달해주면 된다.


@ModelAttribute vs @RequestBody

  • @ModelAttribute 는 필드 단위로 정교하게 바인딩이 적용된다. 특정 필드가 바인딩 되지 않아도 나머지 필드는 정상 바인딩 되고, Validator를 사용한 검증도 적용할 수 있다.
  • @RequestBody 는 HttpMessageConverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계 자체가 진행되지 않고 예외가 발생한다. 컨트롤러도 호출되지 않고, Validator도 적용할 수 없다.

참고
HttpMessageConverter 단계에서 실패하면 예외가 발생한다. 예외 발생시 원하는 모양으로 예외를 처리하는 방법은 예외 처리 부분에서 다룬다.


Ref) 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술