Java Category/Spring

[Spring MVC] 검증(Validation) - Bean Validation

ReBugs 2024. 3. 14.

이 글은 인프런 김영한님의 Spring 강의를 바탕으로 개인적인 정리를 위해 작성한 글입니다.


Bean Validation

Bean Validation은 자바 애플리케이션에서 객체의 속성이 정해진 제약 조건에 맞는지 검증하기 위한 표준이다. Bean Validation 1.0은 JSR 303으로 처음 도입되었으며, 이후 Bean Validation 2.0은 JSR 380으로 업데이트 되었다. 이 표준은 애플리케이션 전반에 걸쳐 일관된 데이터 검증 로직을 제공함으로써 개발자가 중복된 검증 코드를 작성하는 것을 방지하고, 유지 보수를 용이하게 한다.

 

Bean Validation은 특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)이라는 기술 표준이다.

쉽게 이야 기해서 검증 어노테이션과 여러 인터페이스의 모음이다. 마치 JPA가 표준 기술이고 그 구현체로 하이버네이트가 있는 것과 같다.
Bean Validation을 구현한 기술중에 일반적으로 사용하는 구현체는 하이버네이트 Validator이다. 이름에 하이버네이트가 붙었다고 해서 ORM과는 관련이 없다.

Jakarta Bean Validation

jakarta.validation-api : Bean Validation 인터페이스
hibernate-validator :  구현체

 

하이버네이트 Validator 관련 링크

공식 사이트: http://hibernate.org/validator/
공식 메뉴얼: https://docs.jboss.org/hibernate/validator/6.2/reference/en-US/html_single/
검증 애노테이션 모음: https://docs.jboss.org/hibernate/validator/6.2/reference/en-US/ html_single/#validator-defineconstraints-spec

 

검증 어노테이션

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

 

@Data
 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;
     
     public Item() {
     }
     public Item(String itemName, Integer price, Integer quantity) {
         this.itemName = itemName;
         this.price = price;
         this.quantity = quantity;
     }
}

 

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);

        //검증 대상(item)을 직접 검증기에 넣고 그 결과를 받는다. Set 에는 ConstraintViolation 이라는 검증 오류가 담긴다.
        //따라서 결과가 비어있으면 검증 오류가 없는 것이다.
        Set<ConstraintViolation<Item>> violations = validator.validate(item);

        for (ConstraintViolation<Item> violation : violations) {
            System.out.println("violation=" + violation);
            System.out.println("violation.message=" + violation.getMessage());
        }
    }
}

 

스프링부트에 적용

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

build.gradle

implementation 'org.springframework.boot:spring-boot-starter-validation'

 

스프링 부트가 spring-boot-starter-validation 라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합한다.

스프링 부트는 자동으로 글로벌 Validator로 등록한다.

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

 

검증 순서

  • @ModelAttribute 각각의 필드에 타입 변환 시도한다.
  • 성공하면 Validator를 적용한다.
  • 타입 변환을 실패하면 typeMismatch로 FieldError를 추가한다.

 

바인딩에 성공한 필드만 Bean Validation 적용

BeanValidator는 바인딩에 실패한 필드에 대해서는 BeanValidation을 적용하지 않는다. 이는 타입 변환에 성공해서 바인딩에 성공한 필드에만 BeanValidation 적용이 의미가 있기 때문이다. 즉, 일단 모델 객체에 바인딩 받는 값이 정상으로 들어와야 검증도 의미가 있다는 것이다.

@ModelAttribute 각각의 필드 타입 변환 시도에서 변환에 성공한 필드만 BeanValidation 적용하는 예시

itemName에 문자 "A" 입력하고 타입 변환에 성공하면 itemName 필드에 BeanValidation을 적용한다.price에 문자 "A" 입력했을 때 "A"를 숫자 타입으로 변환 시도가 실패하면 typeMismatch로 FieldError를 추가하고, price 필드에는 BeanValidation을 적용하지 않는다.

 

 

에러 코드

Bean Validation이 기본으로 제공하는 오류 메시지를 좀 더 자세히 변경하고 싶다면 두 가지 방법이 있다.

  • 검증 어노테이션에 message 속성을 지정하여 변경
  • 메시지 소스 파일 사용

 

검증 어노테이션에 message 속성을 지정하여 변경

검증 어노테이션에 message 속성을 지정하여 사용자 정의 메시지를 설정할 수 있다. 예를 들어, @NotNull 어노테이션에 대해 다음과 같이 사용자 정의 메시지를 지정할 수 있다.

public class User {
    
    @NotNull(message = "공백은 입력할 수 없습니다.")
    private String name;
}

 

메시지 소스파일 사용

Bean Validation을 적용하고 bindingResult 에 등록된 검증 오류 코드를 보면, 오류 코드가 어노테이션 이름으로 등록된다.

예를 들어 NotBlank 라는 오류 코드를 기반으로 MessageCodesResolver 를 통해 다양한 메시지 코드가 순서대로 생성된다.

 

@NotBlank

  1. NotBlank.item.itemName
  2. NotBlank.itemName
  3. NotBlank.java.lang.String
  4. NotBlank

 

@Range

  1. Range.item.price
  2. Range.price
  3. Range.java.lang.Integer
  4. Range

 

따라서 properties 파일에 등록된 메시지를 적용할 수 있다.

errors.properties

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

{0} 은 필드명이고, {1} , {2} ...은 각 어노테이션 마다 다르다.

 

BeanValidation 메시지 찾는 순서

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

 

Bean Validation에서 특정 필드( FieldError )가 아닌 해당 오브젝트 관련 오류( ObjectError )는 @ScriptAssert() 를 사용하면 된다.
@Data
 @ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000")
 public class Item {
//...
}

하지만 실제 사용해보면 제약이 많고 복잡하다.
그리고 실무에서는 검증 기능이 해당 객체의 범위를 넘어서는 경우들도 종종 등장하는데, 그런 경우 대응이 어렵다.
따라서 오브젝트 오류(글로벌 오류)의 경우 @ScriptAssert 을 억지로 사용하는 것 보다는 같이 오브젝트  류 관련 부분만 직접 자바 코드로 작성하는 것이 좋다.

 

 

동일한 모델 객체를 등각각 다르게 검증

데이터를 등록할 때와 수정할 때는 요구사항이 다를 수 있다.

예를 들면 아래와 같다.

등록시 요구사항

타입 검증

  • 가격, 수량에 문자가 들어가면 검증 오류 처리

 

필드 검증

  • 상품명: 필수, 공백 없어야 함
  • 가격: 1000원 이상, 1백만원 이하
  • 수량: 최대 9999

 

특정 필드의 범위를 넘어서는 검증

  • 가격 * 수량의 합은 10,000원 이상

 

수정시 요구사항

  • 등록시에는 quantity 수량을 최대 9999까지 등록할 수 있지만 수정시에는 수량을 무제한으로 변경할 수 있다.
  • 등록시에는 id 에 값이 없어도 되지만, 수정시에는 id 값이 필수이다.

 

동일한 모델 객체를 등록할 때와 수정할 때 각각 다르게 검증하는 방법은 아래와 같이 두 가지가 있다.

  • BeanValidation의 groups 기능을 사용
  • 동일 객체를 직접 사용하지 않고, ItemSaveForm, ItemUpdateForm 같은 폼 전송을 위한 별도의 모델 객체를 만들어서 사용

 

groups

이 기능을 사용하면 등록시에 검증할 기능과 수정시에 검증할 기능을 각각 그룹으로 나누어 적용할 수 있다.

 

저장용 groups 생성

 package hello.itemservice.domain.item;
 public interface SaveCheck {
 
 }



수정용 groups 생성

 package hello.itemservice.domain.item;
 public interface UpdateCheck {
 
 }

 

모델 객체에 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;
    public Item() {
    }
    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

 

저장 로직에 SaveCheck Groups 적용

@PostMapping("/add")
    public String addItemV2(@Validated(SaveCheck.class) @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
  • @Validated(SaveCheck.class)
  • @Validated 어노테이션과 그룹 인터페이스(예: SaveCheck.class)를 함께 사용하는 방식은 특정 검증 그룹을 적용하여 메소드 레벨에서 Bean Validation을 수행할 수 있게 한다.
  • @Validated 어노테이션을 사용하여 컨트롤러 또는 서비스 메소드에 SaveCheck 그룹을 적용한다. 이렇게 하면 해당 메소드가 호출될 때 SaveCheck 그룹에 속하는 검증 규칙만 적용된다

 

 

수정 로직에 UpdateCheck Groups 적용

 @PostMapping("/{itemId}/edit")
    public String editFormV2(@PathVariable Long itemId, @Validated(UpdateCheck.class) @ModelAttribute Item item, BindingResult bindingResult) {
  • @Validated(UpdateCheck.class)
  • @Validated 어노테이션과 그룹 인터페이스(예: UpdateCheck.class)를 함께 사용하는 방식은 특정 검증 그룹을 적용하여 메소드 레벨에서 Bean Validation을 수행할 수 있게 한다.
  • @Validated 어노테이션을 사용하여 컨트롤러 또는 서비스 메소드에 SaveCheck 그룹을 적용한다. 이렇게 하면 해당 메소드가 호출될 때 SaveCheck 그룹에 속하는 검증 규칙만 적용된다

 

groups 기능은 실제 잘 사용되지는 않는데, 그 이유는 실무에서는 주로 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문이다.

 

Form 전송 객체 분리

실무에서는  (예 : Item) 하나의 모델 객체의 데이터만 넘어오는 것이 아니라 무수한 추가 데이터가 넘어온다. 그리고 더 나아가서 Item 을 생성하는데 필요한 추가 데이터를 데이터베이스나 다른 곳에서 찾아와야 할 수도 있다.
따라서 이렇게 폼 데이터 전달을 위한 별도의 객체를 사용하고, 등록, 수정용 폼 객체를 나누면 등록, 수정이 완전히 분리되기 때문에 groups 를 적용할 일은 드물다.

 

→보통 Item 을 직접 전달받는 것이 아니라, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다.

예를 들면 ItemSaveForm 이라는 폼을 전달받는 전용 객체를 만들어서 @ModelAttribute 로 사용한다. 이것을 통해 컨트롤러에서 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해서 Item 을 생성한다.

 

Item 모델 객체

@Data
 public class Item {
     private Long id;
     private String itemName;
     private Integer price;
     private Integer quantity;
}

 

저장용 모델 객체(ItemSaveform)

 @Data
 public class ItemSaveForm {
     @NotBlank
     private String itemName;
     @NotNull
     @Range(min = 1000, max = 1000000)
     private Integer price;
     @NotNull
     @Max(value = 9999)
     private Integer quantity;
}

 

수정용 모델 객체(ItemUpdateform)

@Data
 public class ItemUpdateForm {
     @NotNull
     private Long 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) {

    ...

    //성공 로직
    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}";
}
  • Item 대신에 ItemSaveform 을 전달 받는다.
  • @Validated 로 검증도 수행하고, BindingResult 로 검증 결과도 받는다.

 

수정 컨트롤러

@PostMapping("/{itemId}/edit")
public String editForm(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult) {

    ...

	//성공 로직
    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 대신에 ItemUpdateform 을 전달 받는다.
  • @Validated 로 검증도 수행하고, BindingResult 로 검증 결과도 받는다.

 

주의

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

 

HTTP 메시지 컨버터

@Valid , @Validated 는 HttpMessageConverter ( @RequestBody )에도 적용할 수 있다.

복습

@ModelAttribute 는 HTTP 요청 파라미터(URL 쿼리 스트링, POST Form)를 다룰 때 사용한다.
@RequestBody 는 HTTP Body의 데이터를 객체로 변환할 때 사용한다. 주로 API JSON 요청을 다룰 때 사용한다.

 

ApiController 생성

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

 

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

  • 성공 요청: 성공
  • 실패 요청: JSON을 객체로 생성하는 것 자체가 실패함
  • 검증 오류 요청: JSON을 객체로 생성하는 것은 성공했고, 검증에서 실패함

 

성공 요청

 

실패 요청

price 의 값에 숫자가 아닌 문자를 전달해서 실패하였다.

 

검증 오류 요청

수량( quantity )이 10000 이면 BeanValidation @Max(9999) 에서 걸린다.

return bindingResult.getAllErrors(); 는 ObjectError 와 FieldError 를 반환한다.

스프링이 이 객 체를 JSON으로 변환해서 클라이언트에 전달했다. 여기서는 예시로 보여주기 위해서 검증 오류 객체들을 그대로 반환 했다. 실제 개발할 때는 이 객체들을 그대로 사용하지 말고, 필요한 데이터만 뽑아서 별도의 API 스펙을 정의하고 그에 맞는 객체를 만들어서 반환해야 한다.

 

@ModelAttribute vs @RequestBody

HTTP 요청 파라미터를 처리하는 @ModelAttribute는 각각의 필드 단위로 세밀하게 적용된다. 그래서 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리할 수 있었다. 

HttpMessageConverter는 각각의 필드 단위로 적용되는 것이 아니라, 전체 객체 단위로 적용된다. 따라서 메시지 컨버터의 작동이 성공해서 ItemSaveForm 객체를 만들어야 @Valid, @Validated가 적용된다. 

 

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

댓글