본문 바로가기
spring

mvc2 - Bean Validation

by 오우지 2022. 2. 17.

Bean Validation

검증 기능을 매번 코드로 작성하는 것은 상당히 번거롭다. 특히 특정 필드에 대한 검증 로직은 대부분 일반적인 로직이다. 이런 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고 표준화 한 것이 바로 Bean Validdation이다.

Bean Validation을 잘 활용하면 애노테이션 하나로 검증 로직을 편리하게 사용할 수 있다.

 

 

Bean Validation이란 javax 기술 표준으로 구현체가 아니다. 따라서 검증 애노테이션과 인터페이스의 모음이다. 이를 구현한 기술 중 일반적으로 사용하는 구현체는 하이버네이트 Validator다.

 

 

Bean Validation을 사용하기 위해 의존관계를 추가해줘야 한다.

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

이제 도메인으로 가서

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

다음과 같이 어노테이션을 달아준다.

 

검증기 생성

public class BeanValidationTest {

    @Test
    void beanValidation(){
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        Validator validator = factory.getValidator();

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

        Set<ConstraintViolation<Item>> validate = validator.validate(item);
        for (ConstraintViolation<Item> violation : validate) {
            System.out.println("violation = " + violation);
            System.out.println("violation = " + violation.getMessage());
        }
    }
}

테스트 코드는 이렇게 생성할 수 있지만 스프링과 통합해서 사용하므로 참고만 하면 된다.

 

실행해보면 필드의 범위를 넘어서는 검증을 제외하면 애노테이션 기반의 Bean Validation이 정상적으로 작동하는 것을 알 수 있다. 스프링 부트가 spring-boot-starter-validation 라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합한다.

 

LocalValidatorFactoryBean을 글로벌 Validator로 등록한다. 이 Validator는 애노테이션을 보고 검증을 수행한다. 이렇게 글로벌 Validator가 적용되어 있기 때문에 @Valid @Validated만 적용하면 된다. 

 

글로벌 Validator를 직접 등록하면 스프링 부트는 Bean Validator를 글로벌 Validator로 등록하지 않는다.

 

@Validated와 @Valid 둘 다 사용 가능하다. @Valid는 자바 표준으로 스프링이 아닌 외부 프레임워크에서도 사용 가능하다. 하지만 Valid를 사용하려면 의존관계 추가가 필요하다. 또한, Validated는 내부에 groups라는 기능을 포함하고 있다.

 

검증 순서

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

2. 성공하면 다음으로

3. 실패하면 typeMismatch로 FieldError 추가

4. Validator 적용

 

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

BeanValidator는 바인딩에 실패한 필드는 BeanValidator를 적용하지 않는다.

ex) int에 문자를 넣으면 price 필드는 BeanValidation이 적용되지 않는다.

 

 

Bean Validation 에러 코드

Bean Validation이 기본으로 제공하는 에러 코드를 좀 더 자세히 변경하고 싶으면 어떻게 해야 할까?

bean Validation 적용 후 에러 코드를 보면 typeMismatch와 유사하다.

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

 

BeanValidation 메시지 찾는 순서

1. 생성된 메시지 코드 순서대로 messageSource에서 메시지 찾기

2. 애노테이션의 message 속성 사용 -> @NotBlank(message = "공백!{0}")

3. 라이브러리가 제공하는 기본 값 가용 -> 공백일 수 없습니다.

 

 

Bean Validation 오브젝트 오류

Bean Validation에서 특정 필드(FieldError)가 아닌 해당 오브젝트 관련 오류(ObjectError)는 어떻게 처리할 수 있을까?

@ScriptAssert()를 사용하면 된다.

 

@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000", message = "총 합이 10000원 넘게 입력해주세요")
public class Item {}

하지만 ScriptAssert는 제약 조건이 많고 복잡하다. 따라서 오브젝트 오류는 @ScriptAssert보다는 오브젝트 오류 부분만 직접 자바 코드로 작성하는 것이 낫다.

 

 

Bean Validation - 한계

요청사항을 수정해보자.

1. 등록시에는 quantity 수량을 최대 9999까지 등록할 수 있지만 수정시에는 수량을 무제한으로 변경할 수 있다.

2. 등록시에는 id에 값이 없어도 되지만, 수정시에는 id값이 필수다.

 

도메인의 어노테이션 수정으로는 위의 요구사항을 충족시킬 수 없다.

 

 

Bean Validation - groups

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

1. BeanValidation의 groups 기능을 사용한다.

2. Item을 직접 사용하지 않고, ItemSaveForm, ItemUpdateForm 같은 폼 전송을 위한 별도의 모델 객체를 만들어서 사용한다.

 

BeanValidation groups 기능 사용

수정용 groups와 저장용 groups를 따로 만든다.

public interface UpdateCheck {
}

public interface SaveCheck {
}

그러고 난 후 Item과 컨트롤러에 적용해주면 된다.

 

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

 

@PostMapping("/add")
    public String addItem2(@Validated(SaveCheck.class) @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}";
    }

코드만 봐도 복잡하다. 실제로 groups 기능은 잘 안쓰고 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용한다.


Form 전송 객체 분리

실무에서는 등록시 폼에서 전달하는 데이터가 도메인 객체와 맞지 않고 수 많은 부가 데이터가 넘어온다.따라서 폼을 전달받는 전용 객체를 만들어서 @ModelAttribute로 사용한다. 그냥 DTO마냥 컨트롤러에서 데이터를 다른 타입으로 받아주고 Item 객체로 변환해주면 된다.

 

@ModelAttribute("item")에 item 이름을 넣어준 부분을 주의해야 하는데 규칙에 따라 itemSaveForm 이라는 이름으로 Model에 담기게 되기 때문에 th:object 이름도 함께 변경해 줘야 한다. 

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

따라서 다음과 같이 이름을 설정해줘야 view에서 정상 작동한다.


Bean Validation - HTTP 메시지 컨버터

 

@Valid, @Validated는 HttpMessageconverter(@RequestBody)에도 적용할 수 있는데

 

@ModelAttribute는 HTTP 요청 파라미터(URL 쿼리 스트링, POST Form)을 다룰때

@RequestBody는 HTTP Body의 데이터를 객체로 변환할 떄 사용한다. 주로 API JSON 요청을 다룰 때  사용한다.

 

 

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

다음과 같이 코드를 작성하고 ItemSaveForm의 Integer를 String 값으로 매핑해서 보내면 컨트롤러 내에서 정상적 호출 로그가 뜨지 않는다. JSON이 객체로 바뀌어야 Validation이 가능한데 그 단계까지 도달하지 못해서 그렇다. 여기는 뒤쪽의 Exception 처리에서 다룬다.

 

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

1. 성공 요청: 성공

2. 실패 요청: JSON을 객체로 생성하는 것 자체가 실패함

3. 검증 오류 요청: JSON을 객체로 생성하는 것은 성공했고 검증에서 실패하는 경우

 

위의 결과는 실패 요청이고 검증 오류 결과를 의도해 quantiti(MAX < 9999)를 10000을 넣어보자

에러 코드는 다음과 같이 반환된다.

[
 {
 "codes": [
 "Max.itemSaveForm.quantity",
 "Max.quantity",
 "Max.java.lang.Integer",
 "Max"
 ],
 "arguments": [
 {
 "codes": [
 "itemSaveForm.quantity",
 "quantity"
 ],
 "arguments": null,
 "defaultMessage": "quantity",
 "code": "quantity"
 },
 9999
 ],
 "defaultMessage": "9999 이하여야 합니다",
 "objectName": "itemSaveForm",
 "field": "quantity",
 "rejectedValue": 10000,
 "bindingFailure": false,
 "code": "Max"
 }
]

return bindingResult.getAllErrors()는 ObjectError와 FieldError를 반환한다.

 

@ModelAttribute vs @RequestBody

@ModelAttribute는 각각의 필드 단위로 세밀하게 적용되기 때문에 필드에 타입이 맞지 않아도 나머지 필드는 정상 처리할 수 있었다. 하지만 HttpMessageConverter는 @ModelAttribute와 다르게 필드 단위가 아닌 전체 객체 단위로 적용된다. 따라서 Item 객체가 만들어져야 Validation을 수행할 수 있다.

'spring' 카테고리의 다른 글

mvc1- 서블릿, JSP, MVC  (0) 2022.02.19
HTTP 웹 기본지식-3 헤더  (0) 2022.02.17
mvc2 - 쿠키, 세션 로그인  (0) 2022.02.14
HTTP 웹 기본지식 - 2  (0) 2022.02.12
MVC-2 message, validation  (0) 2022.02.11