본문 바로가기
Backend, Server/Spring MVC

[Spring MVC] 검증 Validation - (2)

by ggyongi 2022. 2. 1.
반응형

 

앞서 배운 검증에서는 검증 과정을 직접 코드로 작성했다.

하지만 대부분 빈 값 여부, 크기 제한, 범위 제한 등 지극히 일반적인 로직이다.

이러한 로직은 이미 스프링이 편하게 사용할 수 있도록 준비를 다 해놨다.

그것이 Bean Validation이다.

 

Bean Validation 시작하기

- build.gradle에 추가

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

그리고 Item 클래스에 다음과 같이 어노테이션을 붙여주기만 하면,

다양한 검증 로직을 쉽게 등록할 수 있다.

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

 

Bean Validation 사용하기

테스트를 통해 사용법을 알아보자.

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();와 같은 방법으로 검증기를 생성해야 한다.

그리고 검증 대상(item)을 검증기에 넣고 그 반환값으로 Set을 받을 수 있다.

Set에는 ConstraintViolation이라는 검증 오류가 담기고, 만약 set이 비어있다면 검증 오류가 없는 것이다.

 

근데, 매번 이렇게 사용하려면 좀 불편하지 않나?라는 생각이 들 수 있다.

이를 예상하고 스프링은 모든 것을 준비해두었다!

 

Bean Validation 적용하기

검증하고자 하는 객체에 @Validated만 추가해주면 된다.

그럼 알아서 그 안의 필드에 있는 @NotNull 같은 어노테이션을 보고 검증을 해준다.

 @PostMapping("/add")
 public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {

     //특정 필드가 아닌 복합 룰 검증
     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("error = {}", 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}";
 }

검증 순서는 

1. @ModelAttribute: 각 필드에 타입 변환 시도

성공하면 -> 다음 단계로

실패하면 -> typeMismatch로 FieldError를 추가

2. Validator 적용 : 바인딩에 성공한 필드만 Bean Validation 적용

 

 

Bean Validation의 에러 코드

- 어노테이션 이름으로 오류 코드가 등록됨

- 예시

- @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}부터는 각 어노테이션마다 적절한 값이 들어가게 된다.

 

 

+) 특정 필드가 아닌 글로벌 오류의 경우 @ScriptAsset()이 있지만 잘 사용하지 않으므로 생략.

 

 

 

Bean Validation - 상황에 따라 다른 검증 적용

방법 1. groups

방법 2. 폼 전송을 위한 별도의 모델 객체를 만들어 사용

 

방법 1. groups

인터페이스로 다음과 같이 그룹을 만들고,

public interface SaveCheck {
}

 

public interface UpdateCheck {
}

 아래와 같이 groups 설정을 해주면 어노테이션 별로 별도의 검증을 수행

@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000", message = "총 합이 10000 을 넘게 해주세요")
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=100, 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;
    }
}

적용은 아래와 같이 @Validated에 (SaveCheck.class)와 같이 설정을 해주면 된다.

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

이 방법은 다소 복잡도가 올라가서 잘 사용하지 않는다.(어노테이션도 어지러워짐)

실무에선 아래 방법이 더 잘 사용된다.

 

 

방법 2. 폼 전송을 위한 별도의 모델 객체를 만들어 사용

아래와 같이 저장용 폼 객체, 수정용 폼 객체를 만들어준다.

@Data
public class ItemSaveForm {

    @NotBlank
    private String itemName;

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

    @NotNull
    @Max(9999)
    private Integer quantity;

}

 


@Data
public class ItemUpdateForm {

    @NotNull
    private Long id;

    @NotBlank
    private String itemName;

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

    //수정 시에는 자유롭게 변경 가능
    private Integer quantity;
    
}

그리고 사용은 아래와 같이 하면 된다.

 

- 저장용 폼 객체 사용하기

@ModelAttribute를 적용할 객체를 Item이 아닌 ItemSaveForm으로 설정한다.

이때 @ModelAttribute에 이름을 item이라고 따로 설정해줘야 .addAttribute() 자동 저장 시에 저 이름으로 저장해서 폼을 별도로 수정하지 않고 그대로 쓸 수 있게 된다.

그리고 레포지토리에 item 객체를 저장하기 위해 item 객체를 만들어서 데이터를 받아오는 과정이 필요하다.

더보기
  @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("error = {}", 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("error = {}", 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}";
    }

 

Bean Validation - HTTP 메시지 컨버터

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


@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("검증 오류 발생 error ={}", bindingResult);
            return bindingResult.getAllErrors();
        }

        log.info("성공 로직 실행");
        return form;
    }

}

 

여기서 포스트맨을 활용해서 바디에 다음 json 데이터를 담아서 보내보자.

{"itemName":"hello", "price":1000, "quantity": 10}를 보내면 검증에 성공할 것이고,

{"itemName":"hello", "price":"A", "quantity": 10}와 같이 보내면 검증에 실패할 것이다.

위의 경우는 정확히 하자면 Item객체 자체가 생성되지 못하기 때문에 컨트롤러 호출 전 오류가 발생하는 것이다.

그래서 Bad Request 에러가 발생한다.

{"itemName":"hello", "price":1000, "quantity": 10000}를 보내면 HttpMessageConverter는 성공하지만 검증 단계에서 실패하게 된다. 그래서 결과로 bindingResult.getAllError()를 날려줄 것이다.

 

 

@ModelAttribute vs @RequestBody 차이점
HTTP 요청 파리미터를 처리하는 @ModelAttribute 는 각각의 필드 단위로 세밀하게 적용된다. 그래서
특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리할 수 있었다.
HttpMessageConverter 는 @ModelAttribute 와 다르게 각각의 필드 단위로 적용되는 것이 아니라, 
전체 객체 단위로 적용된다.
따라서 메시지 컨버터의 작동이 성공해서 Item 객체를 만들어야 @Valid , @Validated 가 적용된다.


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

 

 

----------------------------------------------
참고 : 인프런 김영한님 강의(스프링 MVC 2편 - 백엔드 웹 개발 활용 기술)

 

비전공자 네카라 신입 취업 노하우

시행착오 끝에 얻어낸 취업 노하우가 모두 담긴 전자책!

kmong.com

댓글