Spring - Bean Validation

박민수·2023년 11월 14일
0

Spring

목록 보기
37/46
post-thumbnail

Bean Validation

Bean Validation은 애노테이션 형태로 검증 로직을 매우 편리하게 적용할 수 있는 API이다. 특정한 구현체가 아니라 검증 애노테이션과 여러 인터페이스의 모음이며, 일반적으로 사용하는 구현체는 하이버네이트 Validator이다.

Bean Validation 기능을 어떻게 사용하는지 코드로 알아보자.
먼저 Bean Validation을 사용하려면 다음 의존관계를 추가해야 한다.

의존관계 추가

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

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

특정 필드 오류 검증

Item.class

  • @NotBlank : 빈값 + 공백만 있는 경우를 허용하지 않는다.
  • @NotNull : null 을 허용하지 않는다.
  • @Range(min = 1000, max = 1000000) : 범위 안의 값이어야 한다.
  • @Max(9999) : 최대 9999까지만 허용한다.
@Data
public class Item {
    private Long id;
    
    @NotBlank(message = "공백X")
    private String itemName;
    
    @NotNull(message = "널X")
    @Range(min = 1000, max = 1000000)
    private Integer price;
    
    @NotNull(message = "널X")
    @Max(9999)
    private Integer quantity;
	...
}

ItemController.class

검증시 @Validated, @Valid 둘 다 사용이 가능하지만 @Valid를 사용하려면 build.gradle에 의존관계를 추가하는 과정이 필요하다.

@Validated는 스프링 전용 검증 애노테이션이고, @Valid는 자바 표준 검증 애노테이션이다.
둘 중 아무거나 사용해도 동일하게 작동하지만, @Validated는 내부에 groups라는 기능을 포함하고 있다.

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

검증 순서

  1. @Model Attribute에 의해 각각의 필드에 타입 변환을 시도한다. (요청 파라미터 값들을 각각의 필드에 넣어줌)
  2. 데이터 바인딩에 실패하면 typeMismatch로 FieldError를 추가한다.
  3. Validator를 적용한다.

바인딩에 성공한 필드만 BeanValidation을 적용한다. BeanValidator는 바인딩에 실패한 필드는 BeanValidation을 적용하지 않는다.

에러 메시지 등록

Bean Validation에서 기본으로 제공하는 오류 메시지를 커스텀하여 사용할 수 있다.
Bean Validation을 적용하고 bindingResult에 등록된 검증 오류 코드를 확인해보면, 오류 코드가 애노테이션 이름으로 등록된다는 것을 확인할 수 있다. 마치 typeMismatch와 유사하다.
아래와 같이 애노테이션 이름을 기반으로 MessageCodesResolver를 통해 다양한 메시지 코드가 순서대로 생성된다.

@NotBlank

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

@Range

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

Range

  • error.properties - 메시지 내용 변경

오류 메시지 내용을 변경하려면 errors.properties에 메시지의 내용을 등록하면 된다. {0}은 필드명이고, {1}, {2}는 각 애노테이션 마다 다르다.
Range의 index 순서가 {2} ~ {1} 인 이유는 어노테이션의 속성 이름이 알파벳 순서로 처리되기 때문이다. Range에는 Min과 Max 두가지 속성이 있다. Max가 Min보다 알파벳 기준으로 우선순위가 더 높다.

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

메시지 찾는 순서

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

오브젝트 오류

Bean Validation는 특정 필드가 아닌 오브젝트 관련 오류를 처리하기 위한 @ScriptAssert() 애노테이션을 제공한다. 그런데 실제 사용해보면 제약이 많고 복잡하다.
따라서 오브젝트 오류(글로벌 오류)의 경우 @ScriptAssert를 억지로 사용하는 것 보다는 다음과 같이 오브젝트 오류 관련 부분만 직접 자바 코드로 작성하는 것을 권장한다.

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

동일한 객체 검증 분리

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

  1. BeanValidation의 groups 기능 사용
  2. 등록용 폼 객체와 수정용 폼 객체 분리

먼저 BeanValidation에서 제공하는 groups 사용법을 알아보자.

저장용 groups 생성

public interface SaveCheck {
}

수정용 groups 생성

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) {
    ...
}

수정 로직에 UpdateCheck Groups 적용

@PostMapping("/{itemId}/edit")
public String editV2(@PathVariable Long itemId, @Validated(UpdateCheck.class) @ModelAttribute Item item, BindingResult bindingResult) {
    ...
}

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

정리

groups 기능을 사용해서 등록과 수정시에 각각 다르게 검증을 할 수 있었다. 그런데 groups 기능을 사용하니 Item 은 물론이고, 전반적으로 복잡도가 올라갔다.
사실 groups 기능은 실제 잘 사용되지는 않는데, 그 이유는 실무에서는 주로 다음에 등장하는 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문이다.

등록용 폼 객체와 수정용 폼 객체 분리

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

item 수정용 폼

@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) {
    ...
}

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

수정용 폼 객체 바인딩

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

참조
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard

profile
안녕하세요 백엔드 개발자입니다.

0개의 댓글