Spring Bean Validation

강정우·2023년 12월 19일
0

Spring-boot

목록 보기
43/73
post-thumbnail

Bean Validation

  • 이런 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화 한 것이 바로 Bean Validation 이다.
    예를들어 빈 값이면 안 된다, 특정 범위 내에 있어야한다 이런거.
    그래서 이를 사용하면 애노테이션 하나로 검증 로직을 매우 편리하게 적용할 수 있다

Bean Validation 이란?

  • 먼저 Bean Validation은 특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)이라는 기술 표준이다. 쉽게 이야기해서 검증 애노테이션과 여러 인터페이스의 모음이다. 마치 JPA가 표준 기술이고 그 구현체로 하이버네이트가 있는 것과 같다.
  • Bean Validation을 구현한 기술중에 일반적으로 사용하는 구현체는 하이버네이트 Validator이다. 이름이 하이버네이트가 붙어서 그렇지 ORM과는 관련이 없다.

하이버네이트 Validator 관련 링크

공식 사이트: http://hibernate.org/validator/
공식 메뉴얼: https://docs.jboss.org/hibernate/validator/6.2/reference/en-US/html_single/
검증 어노테이션 모음: https://docs.jboss.org/hibernate/validator/6.2/reference/en-US/html_single/#validator-defineconstraints-spec

위에 검증 어노테이션을 들어가서 보면 굉장히 많은 어노테이션들을 볼 수 있다. 이메일 검증도 그냥 @email 요거 하나 넣으면 된다.

  • 그래서 기술 표준의 인터페이스들은 jakarta.validation에서 볼 수 있다.
  • 그리고 이 인터페이스들은 구현하여 우리가 실제로 갖다 쓸 구현체는 이 hibernate에 구현되어있다.

참고
javax.validation.constraints.NotNull
org.hibernate.validator.constraints.Range
javax.validation 으로 시작하면 특정 구현에 관계없이 제공되는 표준 인터페이스이고,
org.hibernate.validator 로 시작하면 하이버네이트 validator 구현체를 사용할 때만 제공되는 검증 기능이다.

  • 그럼 이제 이 코드를 구현하여 테스트를 해보자.

BeanValitaionTest

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

  • 그렇다면 이를 이용하여 컨트롤러의 메서드를 다시 구현해본다면 따로 Validation 의존성 주입 필요없이 아래 코드에서 @Validated만 있으면 된다.
@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
    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}";
}
  • 그럼 의존성 주입도 없이 @Validated 어노테이션 하나로 스프링 MVC는 어떻게 Bean Validator를 사용할까?
    -> 1. 스프링 부트가 spring-boot-starter-validation 라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합한다.
    1. 스프링 부트는 자동으로 LocalValidatorFactoryBean 을 글로벌 Validator로 등록한다.
    2. 이 Validator는 @NotNull 같은 애노테이션을 보고 검증을 수행한다.
    3. 검증 오류가 발생하면, FieldError , ObjectError 를 생성해서 BindingResult 에 담아준다.
  • 참고로 앞서 포스팅한 것 처럼 LocalValidatorFactoryBean이 global하게 등록되기 때문에 이외의 값이 global하게 등록되면 동작하지 않는다.

참고
검증시 @Validated, @Valid 둘다 사용가능하다.
javax.validation.@Valid 를 사용하려면 build.gradle 의존관계 추가가 필요하다.
implementation 'org.springframework.boot:spring-boot-starter-validation'
@Validated스프링 전용 검증 애노테이션이고, @Valid자바 표준 검증 애노테이션이다.
둘중 아무거나 사용해도 동일하게 작동하지만, @Validated 는 내부에 groups 라는 기능을 포함하고 있다.

Item class

@Data
public class Item {

    private Long id;
    @NotBlank(message = "호울뤼 쓑!")
    private String itemName;
    @NotNull(message = "holy molly")
    @Range(min = 1000, max = 1_000_000)
    private Integer price;
    @NotNull(message = "비어있음 안 된당께!")
    @Max(9999)
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}
  • 그리고 객체 단에 validation 어노테이션에 message 속성을 따로 등록해두면 properties보다 더 우선하여 들어간다.

  • 이때 검증 순서는
  1. @ModelAttribute 각각의 필드에 타입 변환 시도
    1. 성공하면 다음으로
    2. 실패하면 typeMismatch 로 FieldError 추가
  2. Validator 적용
    • 바인딩에 성공한 필드만 Bean Validation 적용
  • 즉, BeanValidator는 바인딩에 실패한 필드는 BeanValidation을 적용하지 않는다.
    일단 모델 객체에 바인딩 받는 값이 정상으로 들어와야 검증도 의미있기 때문이다.
    @ModelAttribute 각각의 필드 타입 변환시도 변환에 성공한 필드만 BeanValidation 적용
    실패하면 properties에 등록한 메시지가 나옴

BeanValidation error code

  • Bean Validation이 기본으로 제공하는 오류 메시지를 좀 더 자세히 변경하고 싶으면 어떻게 하면 될까?
    Bean Validation을 적용하고 bindingResult 에 등록된 검증 오류 코드를 보면 오류 코드가 애노테이션 이름으로 등록되는데 이는 마치 typeMismatch 와 유사하다.
    NotBlank 라는 오류 코드를 기반으로 MessageCodesResolver 를 통해 다양한 메시지 코드가 순서대로 생성된
    다.

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

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

  • 그리고 만약 간단하게 4레벨로 properties에 다음과 같이 넣어두었다면

NotBlank={0} no white space
Range={0}, allowed {2} ~ {1}
Max={0}, max: {1}
  • 이렇게 각각 {0} argument에는 필드 이름이 자동으로 들어가는 것을 확인할 수 있다.

Object error 처리("@ScriptAssert()")

  • 그럼 FieldError말고 ObjectError는 어떻게 처리할까? @ScriptAssert()를 사용하면 된다.
@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000")
pulibc classs Item {
	...
    
  • 이런식으로 사용하며 메시지 코드는 다음과 같이 부여하면 된다.
ScriptAssert.item
ScriptAssert
  • 참고로 현재 jdk8 ~ jdk14JVM 상에서 사용되는 Nashorn 엔진은 javascript를 지원하는데, jdk14 이후 버전부터는 javascript가 지원되지 않는 GraalVM 을 사용하여 스프링 부트 3이후에는 java 17 이상을 사용하는 것이 필수조건으로 되어있기 때문에, 더는 스프링 부트 3에서는 @ScriptAssert를 이용한 자바스크립트 표현식을 사용할 수 없다.

  • 그래서 이거 말고 따로 그냥

@PostMapping("/add")
public String addItemWithObjError(@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("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}";
}
  • 위 코드처럼 오브젝트 오류(글로벌 오류)의 경우 @ScriptAssert 을 억지로 사용하는 것 보다는 다음과 같이 오브젝트 오류 관련 부분만 직접 자바 코드로 작성하는 것을 권장한다.
    위 코드는 앞서 bindingResult.reject() 메서드, Error 객체를 배워야 사용이 가능한 부분이다.

Groups

  • 우선 컨트롤러에서 edit 메서드에서 요구사항이 추가되었다고 가정하자.
  1. id는 null이 아니다. 2. 수량에는 재한이 없다.
  • 이때 id가 null이면 안 된다고 하여 item 객체에 @Notnull 어노테이션을 무지성으로 넣어버리면 어떤 문제가 발생하냐면
    아이템을 새로 등록할 때는 id가 부여되지 않았기 때문에 아예 등록을 할 수 없다.
    이때 이런 문제를 해결할 수 있는 부분이 바로 groups 이다.

  • 그런데 또 다른 방법이 있는데 form 전송용 바인딩 객체로 따로 만들어도 되긴하다.

  1. 사용법은 우선 원하는 check에 대한 interface를 만든다.
    나는 edit과 add 메서드를 그룹으로 묶고 싶기 때문에 각각의 check에 대한 것을 만들었다.(참고로 내용은 비어있어도 됨.)

  1. 다음 Item에 각각의 field에 대하여 edit과 add 메서드에서 Validation을 진행할 때 필요한 필드값에 group 속성으로 명시해준다.
@Data
public class Item {

    @NotNull(groups = UpdateCheck.class)
    private Long id;
    @NotBlank(groups = {UpdateCheck.class, SaveCheck.class})
    private String itemName;
    @NotNull(groups = {UpdateCheck.class, SaveCheck.class})
    @Range(min = 1000, max = 1_000_000, groups = {UpdateCheck.class, SaveCheck.class})
    private Integer price;
    @NotNull(groups = {UpdateCheck.class, SaveCheck.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;
    }
}
  1. 그리고 해당 메서드에서 @Validate 어노테이션에 value 속성을 등록해준다.
    참고로 default라서 그냥 빼고 클래스만 명시해도 되긴하다.
@PostMapping("/add")
public String addItem2(@Validated(SaveCheck.class) @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}";
}
  • 이렇게 @Validate에 인터페이스를 값으로 넘겨줬다면 Item에 등록한 필드값에서 SaveCheck.class가 적혀있는 필드값만 Validate가 동작한다.

  • 참고로 앞에서 검증을 하려면 @Valid, @Validated 어노테이션 둘 다 사용해도 된다고 했는데 이 groups 기능을 사용하려면 @Validated을 사용해야한다.

  • 그런데 문제는 groups를 보면 Item 객체와 로직이 복잡하고 직관이지 못하다.

form 전용 객체 만들기

  • 그래서 사실 현업에서는 전용 객체를 따로 만들어둔다.
    그럼 그냥 기존 객체를 사용하는 방법(groups)과 별도의 객체를 생성하는 방법의 장단점을 알아보자.

  • 폼 데이터 전달에 Item 도메인 객체 사용
    HTML Form -> Item -> Controller -> Item -> Repository

    • 장점: Item 도메인 객체를 컨트롤러, 리포지토리 까지 직접 전달해서 중간에 Item을 만드는 과정이 없어서
      간단하다.
    • 단점: 간단한 경우에만 적용할 수 있다. 수정시 검증이 중복될 수 있고, groups를 사용해야 한다.
  • 폼 데이터 전달을 위한 별도의 객체 사용
    HTML Form -> ItemSaveForm -> Controller -> Item 생성 -> Repository

    • 장점: 전송하는 폼 데이터가 복잡해도 거기에 맞춘 별도의 폼 객체를 사용해서 데이터를 전달 받을 수 있다.
      보통 등록과, 수정용으로 별도의 폼 객체를 만들기 때문에 검증이 중복되지 않는다.
    • 단점: 폼 데이터를 기반으로 컨트롤러에서 Item 객체를 생성하는 변환 과정이 추가된다.
  • 물론 추가와 수정의 form은 굉장히 많이 변할 수 있어 그냥 따로 생성하여 관리하는 것이 좋다.

  • 이때 이름 짓는 방법은 따로 컨벤션이 존재하지 않기 때문에 ItemSaveForm, ItemSaveRequest, ItemSaveDto 처럼 그냥 일관성 있게만 지으면 된다.

  • 이런식으로 간단하게 SaveFrom, UpdateForm 으로 전송용 객체를 따로 생성하였고

ItemUpdateForm

@Data
public class ItemUpdateForm {
    @NotNull
    private String id;
    @NotBlank
    private String itemName;
    @NotNull
    @Range(min = 1000, max = 1_000_000)
    private Integer price;
    private Integer quantity;
}
  • 요구상에 맞춰 validate할 field값들에 알맞는 어노테이션들을 넣어줬다.

컨트롤러 메서드 중 update 메서드

@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("errors={}", bindingResult);
        return "validation/v4/editForm";
    }

    Item item = new Item();
    item.setItemName(form.getItemName());
    item.setPrice(form.getPrice());
    item.setQuantity(form.getQuantity());

    itemRepository.update(itemId, item);
    return "redirect:/validation/v4/items/{itemId}";
}
  • 위 코드에서 주의해야할 점.
  1. 이제 일반 Item 객체가 아닌 form 전송용 dto를 따로 만들었다. 해당 객체를 검증해야하기 때문에 @Validated, @ModelAttribute를 받는 전송용 객체를 넣어주면 된다.

  2. @ModelAttribute의 기본값은 다음에 나오는 대상 객체의 class 이름의 앞문자만 바꿔서 들어가기 때문에 html 파일을 통째로 바꿀거 아니면 item으로 바꾸어주어야한다.

  3. 결과적으로 업데이트 값이 Item 객체이기 때문에 해당 객체를 생성하여 값을 채워서 넘겨줘야한다.

여기서 검증용 form dto를 등록할 때 다시 한 번 말하지만 유용한 어노테이션들은
여기 들어가면 다 있다.

HTTP Message Converter

  • 우선 간단하게 컨트롤러를 하나 만들어보자.
@Slf4j
@RestController
@RequestMapping("/validation/api/items")
public class ValidationItemApiController {
    @PostMapping("/add")
    public Object addItem(@RequestBody @Validated ItemSaveForm form, BindingResult bindingResult) {
        log.info("API controller requested");

        if (bindingResult.hasErrors()) {
            log.info("ㄱㅓㅁ증오류 발생: errors={}", bindingResult);
            return bindingResult.getAllErrors();
        }

        log.info("성공 로직 실행");
        return form;
    }
}
  • 이러한 컨트롤러가 있다고 가정할 때 req를 날리면 어떻게 될까?
    => 로그가 아예 뜨지 않는다. 즉, 컨트롤러 자체가 호출이 안 된다. 왜그럴까?

  • 우선 HTTP message를 보낼 때 해당 메시지 일단은 객체로 바뀌어야한다.
    즉, @Validated 뒤에 명시한 ITemSaveForm객체로 바뀌어야 다음 절차로 검증을 할 수 있는데 일단 거기서 부터 입구컷 당한 것이다.
    => 400 BadRequest Error

  • 그럼 만약 객체 생성은 되는데 검증에 실패한 경우는 어떻게 될까?
    => 컨트롤러를 들어와서 어떠한 메시지를 생성한다. 그 부분이 바로 bindingResult.getAllErrors 이 부분이다.
    즉, 아래 json은 Error 객체인 것이다.

[
    {
        "codes": [
            "Range.itemSaveForm.price",
            "Range.price",
            "Range.java.lang.Integer",
            "Range"
        ],
        "arguments": [
            {
                "codes": [
                    "itemSaveForm.price",
                    "price"
                ],
                "arguments": null,
                "defaultMessage": "price",
                "code": "price"
            },
            1000000,
            1000
        ],
        "defaultMessage": "must be between 1000 and 1000000",
        "objectName": "itemSaveForm",
        "field": "price",
        "rejectedValue": 1,
        "bindingFailure": false,
        "code": "Range"
    },
    {
        "codes": [
            "Max.itemSaveForm.quantity",
            "Max.quantity",
            "Max.java.lang.Integer",
            "Max"
        ],
        "arguments": [
            {
                "codes": [
                    "itemSaveForm.quantity",
                    "quantity"
                ],
                "arguments": null,
                "defaultMessage": "quantity",
                "code": "quantity"
            },
            9999
        ],
        "defaultMessage": "must be less than or equal to 9999",
        "objectName": "itemSaveForm",
        "field": "quantity",
        "rejectedValue": 100000,
        "bindingFailure": false,
        "code": "Max"
    }
]
  • 대충 보면 Price, Quantity 부분에서 각각 range, max value error가 뜬 것을 확인할 수 있다.

@ModelAttribute vs @RequestBody

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

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

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

한마디로, HttpMessageConverter 단계에서 실패하면 예외가 발생한다.

profile
智(지)! 德(덕)! 體(체)!

0개의 댓글