Spring/Spring boot

검증 2 (Bean Validation)

챛채 2023. 9. 11. 16:52

5. 검증 2 - Bean Validation

1. Bean Validation

검증 기능을 검증 1에서 처럼 매번 작성하는 것은 번거롭기 때문에 이런 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고 표준화한 것이 바로 Bean Validation이다.

 

Bean Validation은 특정 구현체라기보단 기술 표준이다. 즉 검증 애노테이션과 여러 인터페이스의 모음이라고 할 수 있다.

 

먼저 순수한 Bean Validation 사용법 부터 알아보려하는데 사용하려면 의존 관계를 추가해야 한다. 

build.gradle에 implementation 'org.springframework.boot:spring-boot-starter-validation' 를 추가해준다. 

 

[item.java]

Bean Validation 애노테이션을 적용해보았다.

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

@NotBlank : 빈값 + 공백만 있는 경우를 허용하지 않는다.

@NotNull : null을 허용하지 않는다.

@Range(min = 1000, max = 1000000) : 범위 안의 값이어야 한다.

@Max(9999) : 최대 9999개까지만 허용한다.

 

[BeanValidationTest.java]

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.message() = " + violation.getMessage());
        }
    }
}

검증기를 생성하는 코드는 이후에 스프링과 통합하면 직접 작성하지 않아도 되기 때문에 참고만 하면 된다.

검증 대상(item)을 직접 검증기에 넣고 그 결과를 받는다.  Set에는 ConstrainViolation이라는 검증 오류가 담기는데 결과가 비어 있으면 검증 오류가 없는 것이다.

 

실행 결과를 보면 검증 오류가 발생한 객체, 필드, 메시지 정보 등을 확인할 수 있다.

violation={interpolatedMessage='공백일 수 없습니다', propertyPath=itemName, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{javax.validation.constraints.NotBlank.message}'} violation.message=공백일 수 없습니다

 

violation={interpolatedMessage='9999 이하여야 합니다', propertyPath=quantity, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{javax.validation.constraints.Max.message}'} violation.message=9999 이하여야 합니다

 

violation={interpolatedMessage='1000에서 1000000 사이여야 합니다', propertyPath=price, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{org.hibernate.validator.constraints.Range.message}'} violation.message=1000에서 1000000 사이여야 합니다

 

정리

Bean Validation을 직접 사용하는 방법도 있지만 스프링은 이미 개발자를 위해서 빈 검증기를 스프링에 완전하게 통합해두었다.

 

2. Bean Validation - 스프링 적용

@Slf4j
@Controller
@RequestMapping("/validation/v3/items")
@RequiredArgsConstructor
public class ValidationItemControllerV3 {
 
     private final ItemRepository itemRepository;

     @GetMapping
     public String items(Model model) {
         List<Item> items = itemRepository.findAll();
         model.addAttribute("items", items);
         return "validation/v3/items";
     }

     @GetMapping("/{itemId}")
         public String item(@PathVariable long itemId, Model model) {
         Item item = itemRepository.findById(itemId);
         model.addAttribute("item", item);
         return "validation/v3/item";
     }

     @GetMapping("/add")
         public String addForm(Model model) {
         model.addAttribute("item", new Item());
         return "validation/v3/addForm";
     }

/*
검증하고 싶은 타겟 객체에 @Validated 넣고 바로 뒤에
파라미터 BindResult 넣기
*/
     @PostMapping("/add")
         public String addItem(@Validated @ModelAttribute Item item, BindingResult
    bindingResult, RedirectAttributes redirectAttributes) {
		//검증에 실패하면 다시 입력 폼으로
         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}";
     }

     @GetMapping("/{itemId}/edit")
     public String editForm(@PathVariable Long itemId, Model model) {
         Item item = itemRepository.findById(itemId);
         model.addAttribute("item", item);
         return "validation/v3/editForm";
     }

     @PostMapping("/{itemId}/edit")
     public String edit(@PathVariable Long itemId, @ModelAttribute Item item) {
         itemRepository.update(itemId, item);
         return "redirect:/validation/v3/items/{itemId}";
     }
    }

기존에 만약 ItemValidator를 등록해 두었으면 오류 검증기가 중복 적용 되기 때문에 제거해야한다.

 

스프링 부트는 자동으로 글로벌 Validator로 등록한다. 이 Validator는 @NotNull 같은 애노테이션을 보고 검증을 수행하는데 글로벌 Validator가 적용되어 있기 때문에 @Valid, @Validated만 적용을 하면 된다.

검증 오류가 발생하게 되면 FieldError, ObjectError를 생성해서 BindingResult에 담아준다.

 

-참고

검증시 @Validated, @Valid는 둘 다 사용이 가능하다. @Valid 사용하기 위해서는 build.gradle에 의존 관계만 추가해주면 된다. @Validated는 스프링 전용 검증 애노테이션이고 @Valid는 자바 표준 검증 애노테이션이다. 둘 차이는 @Validated 내부에 groups라는 기능을 포함하고 있다는 차이 뿐이다.

 

검증 순서는

1. @ModelAttribute 각각의 필드에 타입 변환을 시도하여 성공하면 다음으로 넘어가고 실패하면 typeMismatch로 FieldError을 추가하고 검증이 진행되지 않는다.

2. 검증 애노테이션 기반으로 Validator을 적용한다.

 

바인딩에 성공한 필드만 Bean Validation이 적용 되는데 일단 모델 객체에 바인딩 받는 값이 정상으로 들어와야 검증도 의미가 있다.

즉, @ModelAttribute -> 각각의 필드 타입 변환시도 -> 변환에 성공한 필드만 BeanValidation 적용

 

예를 들자면 itemName에 문자 "A"를 입력하면 타입변환이 성공해 itemName 필드에 BeanValidation 적용된다.

하지만 price에 문자 "A"를 입력하게 되면 "A"를 숫자 타입으로 변환하는데 실패하여 typeMEsmatch FieldError를 추가하고 price 필드는 BeanValidation을 적용하지 않는다.

 

3. Bean Validation - 에러 코드

Bean Validation을 적용하고 bindingResult에 등록된 검증 오류 코드를 보면 오류 코드가 애노테이션 이름으로 등록이 된다. typeMismatch와 비슷하다. NotBlank라는 오류 코드를 기반으로 MessageCodeResolver를 통해 다양한 메시지 코드가

순서대로 생성된다.

 

@NotBlank

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

[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. 라이브러리가 제공하는 기본 값 사용 (공백일 수 없습니다.)

애노테이션의 message 사용 예

@NotBlank(message = "공백은 입력할 수 없습니다.")
private String itemName;

 

4. Bean Validation - 오브젝트 오류

지금까지는 필드에다가 애노테이션을 적어 필드 오류를 해결했는데 복합적으로 검사하는 "오브젝트 오류"는 어떻게 해결해야 하나 싶은데 오브젝트 오류는 그냥 컨트롤러에서 자바 코드로 복합 룰 검증하는 코드를 적어주는 방식을 사용하는 게 제일 낫다

@PostMapping("/add")
    public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
        //@Validated : BeanValidation이 자동으로 적용됨

        //특정 필드가 아닌 전체 예외
        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()) { //bindingResult가 error가 있으면
            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}";
    }

 

5. Bean Validation - 한계

등록할 때와 수정할 때의 요구사항이 다른 경우 문제가 발생한다. 

등록에서는 ID를 입력 받는 칸이 없지만, 수정할 때는 ID가 필수인 경우가 그 예시이다.

등록시

  • id 값이 생성되어 들어가기 때문에 id 값 없어도 된다.
  • quantity는 최대 999개

수정시

  • id 값 필요하다.
  • quantity 수량을 무제한으로 변경

 

6. Bean Validation - groups

위와 같은 한계를 해결하기 위한 방법으로 각각 등록과 수정을 위해 따로 인터페이스를 만들고 이후에 Validated에 groups를 적용하는 방식이다. 

 

[UpdateCheck.java]

package hello.itemservice.domain.item;

//수정용
public interface UpdateCheck {
}

[SaveCheck.java]

package hello.itemservice.domain.item;

//저장용
public interface SaveCheck {
}

 

필드마다 groups로 저장용, 수정용으로 어떤 검증할지 등록한다.

@Data
public class Item {

    @NotNull(groups = UpdateCheck.class) //수정 요구사항 추가
    private Long id;
    
    @NotBlank(groups = {SaveCheck.class, UpdateCheck.class}) //빈값 + 공백만 있는 경우 허용 X
    private String itemName;
    
    @NotNull(groups = {SaveCheck.class, UpdateCheck.class}) //null 허용 X
    @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}) //최대 9999까지만 허용
    private Integer quantity;
    
    public Item() {}
    
    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}
@PostMapping("/add")
public String addItem2(@Validated(SaveCheck.class) @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
    //@Validated : BeanValidation이 자동으로 적용됨
@PostMapping("/{itemId}/edit")
public String editV2(@PathVariable Long itemId, @Validated(UpdateCheck.class) @ModelAttribute Item item, BindingResult bindingResult) {
    //@Valid에는 groups를 적용할 수 있는 기능이 없어서 @Validated를 사용해야한다.

이런식으로 등록할땐 SaveCheck.class, 수정할 때는 UpdateCheck.class를 @Validated안에 넣어준다.

하지만 실무에서 groups 기능은 잘 쓰이지 않는다. 왜냐하면 실무에서는 주로 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문이다.

 

7. Form 전송 객체 분리

실무에서는 groups를 잘 사용하지 않는다. 바로 등록시 폼에서 전달하는 데이터가 Item 도메인 객체와 딱 맞지 않기 때문이다. "Hello World"예제에서는 폼에서 전달하는 데이터와 Item 도메인 객체가 딱 맞는다. 하지만 실무에서는 회원 등록시 회원과 관련된 데이터만 전달 받는 것이 아닌 약관 정보도 추가로 받는 등 Item과 관계 없는 수많은 부가 데이터가 넘어온다.

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

 

예를 들자면 ItemSaveForm이라는 폼을 전달받는 전용 객체를 만들어 @ModelAttribute로 사용한다.

이것을 통해 컨트롤러에서 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해 Item을 생성한다.

 

[ItemSaveForm.java]

@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.java]

@Data
public class ItemUpdateForm {
    @NotNull
    private Long id;
    @NotBlank
    private String itemName;
    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;

    //수정에서는 수량은 자유롭게 변경할 수 있다.
    private Integer quantity;
}

사용시 파라미터에 Item 대신 새로 만든 객체를 넣어준다.

대신 model(view)에서는 "item"이라는 객체 이름을 그대로 사용하므로 @ModelAttribute에("item")을 넣어준다.

 

[ValidationItemControllerV4.java]

@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
    /*
    * ModelAttribute에 item을 넣어주지 않으면 ItemSaveForm 객체 이름(itemSaveForm)으로 MVC Model에 담기게 됨.
    * model.Attribute("itemSaveForm", form) 이런식으로 담기게 된다.
    * 그러면 뷰 템플릿에서 접근하는 th:object 이름도 함께 변경해야 한다.
    * */


    //특정 필드가 아닌 복합 룰 검증
    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()) { //bindingResult가 error가 있으면
        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}";
}

Item 대신에 ItemSaveform을 전달 받고 @Validated로 검증도 수행하고, BindingResult로 검증 결과도 받게 된다.

 

수정의 경우도 등록과 같다.

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

 

 

8. Bean Validation - HTTP 메시지 컨버터

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

 

참고

@ModelAttribute는 HTTP 요청 파라미터 (URL 쿼리 스트링, POST Form)를 다룰 때 사용하고

@ReequestBody는 HTTP Body의 데이터를 객체로 변환할 때 사용한다. 주로 API JSON 요청을 다룰 때 사용한다.

 

JSON 데이터를 객체로 변경해야 하기 때문에 @ModelAttribute를 사용하지 않고 @RequestBody를 사용한다.

검증 오류 발생시에는 bindingResult.getAllErrors()를 쓰면 가지고 있는 모든 Error를 반환해준다.

 

[ValidationItemApiControlelr.java]

@Slf4j
@RestController
@RequestMapping("/validation/api/items")
public class ValidationItemApiController {

    //RestController면 자동으로 @ResponseBody 붙음
    @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을 객체로 생성하는 것 자체가 실패함 -> 객체를 만들지 못하기 때문에 컨트롤러 자체가 호출되지 않고 그전 예외가 발생한다. Validator도 실행 안 된다.
  • 검증 오류 요청 : JSON을 객체로 생성하는 것은 성공 했지만 검증에서 실패

@ModelAttribute  VS  @RequestBody

  • @ModelAttibute는 각각의 필드 단위로 적용되는데 특정 필드가 바인딩 되지 않아도 (ex. price에 "aa"문자를 넣음) 나머지 필드는 정상 바인딩 되고, Validator를 사용한 검증도 적용할 수 있다. HttpMessageConveter는 @ModelAttribute와 다르게 각각 필드 단위로 적용되는 것이 아닌 객체 단위로 적용된다. 따라서 메시지 컨버터 작동에 성공해서 ItemSaveForm 객체를 만들어야 @Valid, @Validated가 적용된다.
  • @RequestBody는 Http메시지컨버터 단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계가 진행되지 않고 바로 예외가 발생한다. 그래서 컨트롤러가 아예 호출이 되지 않아서 Validator도 적용할 수 없다.