스프링 검증(Validation) : Bean Validation 기술 표준을 이용해서 더 간결하고 깔끔하게 검증하기

byeol·2023년 3월 20일
0
post-thumbnail

앞서 나는 Controller에서 검증 로직을 분리하고 스프링에서 검증기를 호출하는 방법을 배웠다.

이보다 더 간결하고 보기 좋게 애노테이션을 활용하여 검증 로직을 만들 수 있게 하는 Bean Validation에 대해서 배워보자.

전체적인 흐름은 아래와 같다.

  • 스프링과 통합되지 않은 순수 Bean Validation
  • 스프링 mvc는 어떻게 Bean Validation을 사용하는지
  • Bean Validation 에러코드를 더 자세히 변경하는 방법
  • 페이지마다 도메인에 요구하는 검증 조건이 다른 경우

오늘도 🏃🏃‍♂️🏃‍♀️🏃‍♂️🏃

Bean Validation이란

  • 스프링에서 유효성 검증 로직을 구현하기 위한 사실상 표준
  • 어떤 interface의 특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)이라는 기술 표준이다.
  • 앞서 구현된 코드는 각 계층마다 조금씩 다른 검증 코드를 구현해야 했는데 즉 중복된 코드가 존재할 수도 있다. (어떤 계층은 id가 null 이지만 나머지 속성은 다른 계층과 똑같은 검증 조건이라는 등)
    따라서 이 문제를 해결하기 위해서 계층을 하나의 도메인으로 묶어서 도메인 계층에서 애노테이션 기반의 검증을 가능하도록 한 것이 Bean Validation이다!
  • 의존관계 추가는 아래와 같다. (build.gradle)
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    -jakarta.validation-api는 Bean Validation 인터페이스이다.
  • hiberate-validator는 구현체이다.

이제 실제로 활용해보자.
먼저 스프링과 통합되지 않은 순수 Bean Validation을 살펴본다.

스프링과 통합되지 않은 순수 Bean Validation

검증 애노테이션

...
public class Item {

  
    private Long id;

    @NotBlank
    private String itemName;

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

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

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}
  • 직관적으로 의미를 알 수 있는 애노테이션이 쓰인다. 이렇게 검증 로직을 도메인 객체에 나타냄으로써 각 계층에 대한 검증로직을 구현하지 않는다.

테스트 코드

 @Test
    void beanValidation(){

        //검증기 직접 생성
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        Validator validator = factory.getValidator();


        Item item = new Item();
        item.setItemName(" ");
        item.setPrice(0);
        item.setQuantity(10000);

        // 검증 대상을 직접 검증기에 넣고 그 결과 받기
        //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

스프링 MVC의 Bean Validator

앞서 build.gradle에 의존관계를 추가했다.

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

위와 같이 추가하면 검증 객체에 @Validater가 있으면 자동으로 Bean Validator로 인지하고 검증한다.

스프링 부트 글로벌 Validator

글로벌 Validator은 모든 컨트롤러에 적용되는 검증기이다.

스프링 부트는 LacalValidatorFactoryBean을 글로벌 Validator로 등록한다.
따라서 앞서 main 함수에 직접 등록했던 과정은 필요없고 검증할 객체에 @Valid나 @Validater 애노테이션을 붙여주면 된다.

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

  • 바인딩 실패(타입 오류) : 실패하면 Bean Validation을 적용하지 않고 바로 typeMismatch FieldError에 추가된다,
  • 바인딩 성공 : Bean Validation 적용을 받아 검증을 한다.

Bean Valiation 에러 코드 자세히 변경하기

기본으로 제공하는 오류 메세지를 좀 더 자세히 변경하기

메세지 코드는 검증 애노테이션을 기반으로 만들어진다.
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 메시지 찾는 순서

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

그렇다면 특정 필드가 아닌 오브젝트 오류에 대해서는 어떻게 해결할까?

예를 들어 price는 무조건 1000원 이상이어야 한다.
수량은 9999를 넘길 수 없다가 한 특정 필드에 대한 검증조건이라면

price * quantity 는 10000원 이상이어야 한다가 오브젝트 오류이다.

이건 도메인 객체에서 애노테이션으로 검증이 불가능한걸까?

가능하다!

@ScriptAssert()를 사용하여

@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >=
10000")
public class Item { ...}

위와 같이 표현한다

하지만 제약이 많고 복잡해서 권장하지 않는 방법이다.
따라서 직접 자바 코드로 작성하는 것을 권장!

if (item.getPrice() != null && item.getQuantity() != null) {
   int resultPrice = item.getPrice() * item.getQuantity();
   if (resultPrice < 10000) {
           bindingResult.reject("totalPriceMin", new Object[]          {10000,resultPrice}, null);
   }
 }

검증 조건이 조금씩 다른 경우

등록 페이지의 도메인 객체 요구 조건 ≠ 수정 페이지의 도메인 객체 요구 조건

검증 조건이 충돌하지 않도록 설정하는 방법에 대해서 알아보자

Bean Validation의 groups 기능 사용하기

groups의 기능은 @Validated에서 제공한다.

전체적 흐름
각각의 페이지에 대한 인터페이스 생성(수정 Form 인터페이스, 등록 Form 인터페이스)
➡️ 도메인 객체에 groups를 이용해서 검증 로직에 해당하는 인터페이스 넣기
➡️ Controller에서 @Validated(인터페이스.class)로 해당 검증 로직을 거쳐가게 하기

인터페이스로 그룹 생성

  • 등록 페이지에 대한 그룹

    package hello.itemservice.domain.item;
    
    public interface SaveCheck {
    }
  • 수정 페이지에 대한 그룹

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

도메인 객체에 그룹 적용

...

@NotNull(groups = UpdateCheck.class) //수정시에만 적용
private Long id;

@NotBlank(groups = {SaveCheck.class, UpdateCheck.class})
private String itemName;
...

Controller 적용

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

하지만 실무에서는 잘 사용되지 않는 groups 기능

여러개의 각 계층을 건들다보니 복잡도가 올라갔다.
따라서 객체 자체를 분리해서 사용하는 다음 방법을 이용하자.

도메인 객체 분리하기

Item이라는 도메인 객체가 있다고 가정하자

클라이언트가 Form에 데이터를 입력하여
서버가 데이터를 받을 때
그 데이터가 Item 도메인 객체의 정보만을 가지고 있는 것이 아니라
다른 부가적인 정보를 가지고 있기 때문에

바로 Form으로부터 Item 객체만을 받는게 불가능하다.

따라서 실무에서는
Form으로 Item 객체를 바로 받기 않고

Form ➡️ ItemSaveForm ➡️ Controller ➡️ Controller에서 Item 객체 생성 ➡️ Repository 순으로 전달된다.

Item 객체

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

모든 검증 애노테이션이 제거되었다.

Form으로부터 별도의 객체

@Data
public class ItemSaveForm {

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

별도 객체에 검증 애노테이션을 넣는다.

Controller

@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("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}";
    }
  • 👆 @ModelAttribute("item") : ModelAttribute를 통해서 뷰템플릿으로 받은 객체의 이름을 "item"으로 설정했다.
    이는 뷰템플릿에
    <form action="item.html" th:action th:object="${item}" method="post">
    "item"이라는 이름으로 값을 받았기 때문이다.

  • 👆 보면 Controller에서 Item 객체를 직접 생성한다.

    @Valid, @Validater와 HttpMessageConverter

    먼저 HttpMessageConverter에 대해서 알아보자

    클라언트로부터 데이터를 전달받을 때 Get+쿼리 파라미터 , Post-HTML Form , HTTP Message Body가 있습니다. 반대로 서버에서 클라이언트로 데이터를 전달할 때는 동적/정적 HTML, HTTP Message Body가 있습니다.

HTTP API를 제공하는 경우 HTTP Message Body에 JSON, XML, TEXT 형식으로 데이터를 직접 담아서 요청 및 응답을 처리
HttpMessageConverter는 HttpEntity 객체나 @RequestBody, @ResponseBody 애노테이션을 사용하여 데이터를 직접 매핑할 필요 없이 자동으로 요청 및 응답 데이터를 적절한 형식으로 변환해주는 기능을 제공한다.

그렇다면 Contoroller에서 @Validated @ModelAttribute와 @Validated @RequestBody로 데이터를 전달받을 수 있는데 (물론 전달 방식의 차이가 있지만) 둘이 바인딩 오류를 처리하는 방식에 차이가 있다.

@Validated @ModelAttribute의 경우 바인딩오류가 발생한 필드를 제외한 필드는 정상으로 처리되지만
@Validated @RequestBody는 객체 자체가 만들어지지 않는다.

profile
꾸준하게 Ready, Set, Go!

0개의 댓글