<Spring MVC> 검증2 - Bean Validation

라모스·2022년 3월 10일
1

Spring MVC🌱

목록 보기
9/10
post-thumbnail

Bean Validation 이란?

Bean Validation은 특정한 구현체가 아닌 Bean Validation 2.0(JSR-380)이라는 기술 표준이다. 검증 애노테이션과 여러 인터페이스의 모음이다. JPA가 표준 기술이고 그 구현체로 하이버네이트가 있는 것과 같은 논리이다.

Bean Validation을 구현한 기술 중 일반적으로 사용하는 구현체는 하이버네이트 Validation인데, ORM과는 관련이 없다. (단지 이름이 하이버네이트 일 뿐)

검증 기능을 매번 코드로 작성하는 것은 상당히 번거롭다. 특히 특정 필드에 대한 검증 로직은 대부분 빈 값인지 아닌지, 특정 크기를 넘는지 아닌지와 같이 매우 일반적인 로직이다.

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이다. 이를 잘 활용하면 애노테이션 하나로 검증 로직을 매우 편리하게 적용할 수 있다.

📌 검증 애노테이션

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

Bean Validation - 시작

다음과 같이 build.gradle에 의존관계를 추가해야 한다.

implementation 'org.springframework.boot:spring-boot-starter-validation'
@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>> validate = validator.validate(item);
    for (ConstraintViolation<Item> violation : validate) {
        System.out.println("violation = " + violation);
        System.out.println("violation.getMessage() = " + violation.getMessage());
    }
}

임의로 검증기를 생성하고, 검증 대상을 넣어 그 결과를 받는 테스트 코드이다. 스프링과 통합하면 직접 이런 코드를 작성하진 않는다. 결과는 다음과 같다.

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 사이여야 합니다

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

빈 검증기(Bean Validation)을 직접 사용하는 방법은 위와 같았는데, 스프링은 이미 개발자를 위해 빈 검증기를 스프링에 완전히 통합해두었다.

스프링 MVC는 어떻게 Bean Validator를 사용?

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

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

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

참고로 다음과 같이 직접 글로벌 Validator를 직접 등록하면 스프링 부트는 Bean Validator를 글로벌 Validator로 등록하지 않는다. 따라서 애노테이션 기반의 빈 검증기가 동작하지 않으므로 다음 부분은 제거해야 한다.

@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {
    // 글로벌 검증기 추가
    @Override
    public Validator getValidator() {
        return new ItemValidator();
    }
    //...
}

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

필드 검증

Controller 계층에 다음과 같은 코드를 작성하면 스프링에서는 자동으로 엔티티에 적용된 검증 애노테이션을 수행한다.

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

검증 순서

  1. @ModelAttribute 각각의 필드에 타입 변환 시도
  • 성공하면 다음으로
  • 실패하면 typeMissmatchFieldError 추가
  1. Validator 적용

즉, 바인딩에 성공한 필드만 Bean Validation를 적용한다.

@ModelAttribute → 각각의 필드에 타입 변환 시도 → 변환에 성공한 필드만 Bean Validation 적용

Bean Validation - 에러 코드

Bean Validation을 사용하면서 따로 messages.properties를 설정하거나 작성한 적이 없는데도, 위 처럼 기본 메시지가 출력되고 있다. 이는 해당 라이브러리에서 지정한 기본 메시지인데 이를 임의로 바꾸려면 어떻게 해야 할까?

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

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

  • @NotBlank
    • NotBlank.item.itemName
    • NotBlank.itemName
    • NotBlank.java.lang.String
    • NotBlank
  • @Range
    • Range.item.price
    • Range.price
    • Range.java.lang.Integer
    • Range

이 메시지코드에 메시지를 직접 등록하면 그대로 메시지가 적용될 것이다.

NotBlank={0} 공백은 유효하지 않습니다.
Range={0}, {2}~{1}만 허용됩니다.
Max={0}, 최대{1}까지만 허용됩니다.

{0}은 필드명이고, {1}, {2}, ...는 각 애노테이션마다 다르다(보통 arguments)

이 방법 외에도 다음과 같이 직접 애노테이션에 message 속성으로 지정할수도 있다.

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

BeanValidation 메시지 찾는 순서

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

Bean Validation - 오브젝트 오류

특정 필드가 아닌 해당 오브젝트 관련 오류(ObjectError)는 어떻게 처리할 수 있을까?
@ScriptAssert()를 사용하면 된다.

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

메시지 코드는 다음과 같은 순서로 생성된다.

  • ScriptAssert.item
  • 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);
        }
    }
}

만약 코드 중복이 걱정된다면 따로 메소드로 분리하도록 하자.

  • 간단한 필드 검증에는 Bean Validation을 이용하여 검증 애노테이션을 활용하자
  • 복잡한 객체 검증에는 제약이 많은 애노테이션보단 코드로 직접 구현하자
    • 코드의 재사용성이 높다면 모듈화를 진행하라

Bean Validation의 한계

데이터를 등록(Post)할 때와 수정(Fetch, Put)할 때는 요구사항이 다를 수 있다.
변경된 제약조건은 기존에 작성된 Item 엔티티에선 적용이 불가능하다.

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

그렇다고 수정에 맞춰 아이디 필드에 @NotNull 검증 애노테이션을 붙히고 수량 필드에 @Max 애노테이션을 지우면 수정은 의도한대로 동작할지 몰라도 상품 등록시 아직 존재하지 않는게 당연한 아이디가 null이기에 검증 오류가 날 것이고, 수량도 9999개를 넘는 숫자를 넣어도 문제가 발생하지 않을 것이다.

결과적으로 Item은 등록과 수정에서 검증 조건의 충돌이 발생하고, 등록과 수정은 같은 BeanValidation을 적용할 수 없다. 이 문제를 어떻게 해결해야할까?

Bean Validation - groups를 사용해 검증 분리

이런 문제를 해결하기 위해 Bean Validation은 groups라는 기능을 제공한다.
예를 들어 등록시에 검증할 기능과 수정시에 검증할 기능을 각각 그룹으로 나누어 적용할 수 있다.

  • 저장/수정 용 groups 생성
public interface SaveCheck {}
public interface UpdateCheck {}
  • Item 객체에 groups 속성 설정(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;
    }
}
  • 컨트롤러에서 필요한 검증 group 선택
@PostMapping("/add")
public String addItemV2(@Validated(SaveCheck.class) @ModelAttribute Item item,
                        BindingResult bindingResult,
                        RedirectAttributes redirectAttributes,
                        Model model) {
    //...
}

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

이 방식을 사용하면 Item은 물론이고 전반적으로 복잡도가 올라간다. 이 방식은 실제로 잘 사용되지 않는다.

Form 전송 객체 분리를 이용한 검증 분리

앞서 언급한 groups 방식을 실제론 잘 사용하지 않는데, 등록시 폼에서 전달하는 데이터가 Item 도메인 객체와 딱 맞지 않기 때문이다. 실무에선 회원 등록 시 회원과 관련된 데이터만 전달 받는 것이 아니라, 약관 정보도 추가로 받는 등 Item과 관계없는 수 많은 부가 데이터가 넘어온다.

따라서 보통 Item을 직접 전달받는 것이 아닌, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다. 예를 들어, ItemSaveForm이라는 폼을 전달받는 전용 객체를 만들어 @ModelAttribute로 사용한다. 이를 통해 컨트롤러에서 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해서 Item을 생성한다.

  • 폼 데이터 전달에 Item 도메인 객체 사용: HTML Form -> Item -> Controller -> Item -> Repository
    • 장점: Item 도메인 객체를 컨트롤러, 리포지토리까지 직접 전달해서 중간에 Item을 만드는 과정이 없어서 간단함.
    • 단점: 간단한 경우에만 적용 가능. 수정시 검증이 중복될 수 있고, groups를 사용해야 함.
  • 폼 데이터 전달을 위한 별도의 객체 사용: HTML Form -> ItemSaveForm -> Controller -> Item 생성 -> Repository
    • 장점: 전송하는 폼 데이터가 복잡해도 거기에 맞춘 별도의 폼 객체를 사용해서 데이터를 전달받을 수 있다. 보통 등록과, 수정용으로 별도의 폼 객체를 만들기 때문에 검증이 중복되지 않는다.
    • 단점: 폼 데이터를 기반으로 컨트롤러에서 Item 객체를 생성하는 변환 과정이 추가된다.

Item 객체와 Form 객체는 다음과 같다.

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

@Data
public class ItemSaveForm {
    
    @NotBlank
    private String itemName;
    
    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;
    
    @NotNull
    @Max(value = 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;
}

컨트롤러는 다음과 같다.

@PostMapping("/add")
public String addItemV2(@Validated @ModelAttribute("item") ItemSaveForm form,
                        BindingResult bindingResult,
                        RedirectAttributes redirectAttributes,
                        Model model) {
    //...
}

@PostMapping("/{itemId}/edit")
public String editV2(@PathVariable Long itemId,
                     @Validated @ModelAttribute("item") ItemUpdateForm form,
                     BindingResult bindingResult) {
    //...
}
  • @ModelAttribute에 추가되는 value 속성
    @ModelAttribute("item")에 item 이름을 넣어준 부분을 주의하자. 이를 넣지 않으면 ItemSaveForm의 경우 규칙에 의해 itemSaveForm이라는 이름으로 MVC Model에 담기게 된다. 이렇게 되면 뷰 템플릿에서 접근하는 th:object 이름도 함께 변경해야 한다.
  • Form 객체의 도메인 객체 변환 작업
//성공 로직
Item item = new Item();
item.setItemName(form.getItemName());
item.setPrice(form.getPrice());
item.setQuantity(form.getQuantity());

Item savedItem = itemRepository.save(item);

Bean Validation - HTTP 메시지 컨버터

Form 방식을 이용하여 검증을 했는데, @Valid, @ValidatedHttpMessageConverter (@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("검증 오류 발생 errors={}", bindingResult);
            return bindingResult.getAllErrors();
        }
        log.info("성공 로직 실행");
        return form;
    }
}

테스트용 요청 정보는 다음과 같다.

POST http://localhost:8080/validation/api/items/add
Content-Type: application/json

{"itemName":"hello", "price":1000, "quantity": 10}

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

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

price 값에 다음과 같이 숫자가 아닌 문자를 전달해서 실패하게 만들어보자.

POST http://localhost:8080/validation/api/items/add
{"itemName":"hello", "price":"A", "quantity": 10}

실패 요청 결과는 다음과 같다.

{
    "timestamp": "2021-04-20T00:00:00.000+00:00",
    "status": 400,
    "error": "Bad Request",
    "message": "",
    "path": "/validation/api/items/add"
}

실패 요청 로그는 다음과 같다.

.w.s.m.s.DefaultHandlerExceptionResolver : Resolved
[org.springframework.http.converter.HttpMessageNotReadableException: JSON parse
error: Cannot deserialize value of type `java.lang.Integer` from String "A":
not a valid Integer value; nested exception is
com.fasterxml.jackson.databind.exc.InvalidFormatException: Cannot deserialize
value of type `java.lang.Integer` from String "A": not a valid Integer value
at [Source: (PushbackInputStream); line: 1, column: 30] (through reference
chain: hello.itemservice.domain.item.Item["price"])]

HttpMessageConverter에서 요청 JSON을 Item 객체로 생성하는데 실패한다. 이 경우는 Item 객체를 만들지 못하기 때문에 컨트롤러 자체가 호출되지 않고 그 전에 예외가 발생한다. 또한 Validator도 실행되지 않는다.

@ModelAttribute vs @RequestBody

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

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

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

// HttpMessageConverter 단계에서 실패하면 예외가 발생한다.

References

profile
Step by step goes a long way.

0개의 댓글