Spring

스프링 MVC 검증 - Bean Validation

dev-rootable 2023. 7. 22. 19:53

📌 Bean Validation이란

 

검증 기능을 매번 코드로 작성하는 것은 번거롭고, 검증 로직은 대부분 범위나 빈값을 체크하는 일반적인 로직이다.

 

애노테이션으로 일반적인 검증 로직을 모든 프로젝트에 적용할 수 있도록 공통화하고, 표준화한 것

 

특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)이라는 기술 표준이다. 즉, 검증 애노테이션과 여러 인터페이스의 모음이다. Bean Validation을 구현한 기술 중에 일반적으로 사용하는 구현체는 하이버네이트 Validator이다.

 

📌 스프링에 Bean Validation 적용

 

Bean Validation 의존 관계를 추가해야 한다.

 

//build.gradle
implementation 'org.springframework.boot:spring-boot-starter-validation'

 

🔎 스프링 MVC는 어떻게 Bean Validator를 사용하는가

 

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

 

  1. 스프링 부트는 LocalValidatorFactoryBean을 글로벌 Validator로 등록
  2. 원하는 컨트롤러에서 검증 대상에 @Validated 또는 @Valid 적용
  3. LocalValidatorFactoryBean은 타깃의 검증 애노테이션을 보고 검증을 수행한다.

 

LocalValidatorFactoryBean은 검증 오류가 발생하면 FieldError 또는 ObjectError를 생성하여 BindingResult에 담아준다.

 

💡 @Validated vs @Valid

@Validated는 스프링 전용 검증 애노테이션이고, @Valid는 자바 표준 검증 애노테이션이다. 그래서 @Valid는 build.gradle에 의존관계 추가가 필요하고, groups라는 기능이 포함되지 않았다.

 

@ModelAttribute를 통해 바인딩에 성공한 필드만 Bean Validation 적용
ex)typeMismatch 등으로 바인딩에 실패하는 경우 FieldError가 추가되고 Bean Validation은 적용되지 않음

 

🔎 검증 애노테이션

 

Bean Validation을 적용하려면 검증 대상 도메인에 검증 애노테이션을 붙여야 한다.

 

@Data
//@ScriptAssert(lang = "javascript", script = "_this.price" * "_this.quantity" >= 10000)
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;
    }
    
}

 

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

 

💡 @ScriptAssert

@ScriptAssert를 통해 ObjectError도 애노테이션으로 자동화할 수 있다. 하지만 위 코드처럼 필드와 값을 직접 지정하기 때문에 검증 애노테이션의 목적인 보편화 및 표준화에 제약이 많아 활용도가 떨어진다. 따라서, 기존에 했던 것처럼 BindingResult를 통해 컨트롤러 안에 작성하는 것이 더 유연한 운영이 될 것이다.

 

Bean Validation에서 제공하는 기본 에러 메시지 출력

 

이러한 기본 메시지를 좀 더 자세하게 변경하려면 어떻게 해야 할까?

 

이것도 마찬가지로 MessageCodesResolver에서 다양한 오류 메시지를 생성하여 해결한다.

 

📌 Bean Validation 에러 코드 변경

 

Bean Validation을 적용하면 오류 코드가 애노테이션 이름으로 등록된다.

 

MessageCodesResolver를 통해 아래와 같은 메시지 코드가 순서대로 생성된다.

 

애노테이션 기반 오류 메시지 코드

 

이러한 규칙을 이용하여 MessageSource가 참고하는 파일에 에러 메시지를 작성할 수 있다.

 

이를 위해 application.properties에 errors.properties를 등록한다.

 

spring.messages.basename=messages,errors

 

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

 

Bean Validation 에러 메시지 수정

 

{0}에는 필드명이 들어 갔고, {1}부터는 각 검증 애노테이션의 규칙에 따라 정의되었다.

 

📌 Bean Validation 메시지 찾는 순서(1 ~ 3 순서)

 

1. MessageCodesResolver에 의해 생성된 메시지 코드 순서대로 MessageSource에서 찾기

 

2. 검증 애노테이션의 message 속성 확인

 

@NotBlank(message = "공백 X")
private String name;

 

3. 라이브러리가 제공하는 기본 메시지

 

📌 컨트롤러에 Bean Validation 적용 - 등록 & 수정

 

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

    }

 

 

    @PostMapping("/{itemId}/edit")
    public String edit(@PathVariable Long itemId, @Validated @ModelAttribute Item item, BindingResult bindingResult) {
        //특정 필드 예외가 아닌 전체 예외
        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/editForm";
        }

        itemRepository.update(itemId, item);
        return "redirect:/validation/v3/items/{itemId}";
    }

 

@Validated만 사용하면 간단하게 컨트롤러 작성이 끝난다. 그런데, 등록과 수정 요구 사항이 다르다면 어떻게 될까?

 

 

 

등록과 수정에서 검증 조건의 충돌이 발생한다. 즉, 등록과 수정은 같은 Bean Validation을 적용할 수 없다. 이를 해결하기 위해 Bean Validation은 "groups "라는 기능을 제공한다.

 

📌 Bean Validation groups

 

상황에 따른 검증 기능을 각각 그룹으로 나누어 적용한다.

 

저장용 groups 생성

 

public interface SaveCheck {
}

 

수정용 groups 생성

 

public interface UpdateCheck {
}

 

이를 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;

    ...
}

 

변경된 등록 및 수정 컨트롤러는 다음과 같다.

 

...

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

	...

}

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

	...

}

 

groups 기능을 사용하여 등록과 수정 시 각각 다른 검증 스펙을 적용할 수 있었다. 하지만 Item은 물론이고, 전반적으로 복잡도가 올라갔다.

 

사실 groups 기능은 잘 사용하지 않는데, 그 이유는 실무에서는 주로 다음에 등장하는 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문이다.

 

📌 Form 전송 객체 분리

 

🔎 groups 문제점

 

실무에서는 폼 데이터에 수 많은 부가 데이터가 함께 포함되는 등 복잡한 형태가 될 수 있다. groups처럼 고정된 도메인에 검증 애노테이션을 상황에 따라 운영하는 것보다 예측하기 힘든 폼 데이터가 들어와도 필요한 데이터만 담을 수 있도록, 즉 원하는 폼을 정의한 클래스 객체를 사용하는 것이 바람직하다.

 

🔎 폼 데이터 전달에 사용할 객체에 따른 장단점

 

✔ 폼 데이터 전달에 도메인 객체 사용

 

HTML Form ➡ Domain ➡ Controller ➡ Domain ➡ Repository

 

  • 장점 : 도메인 객체를 컨트롤러, 리포지토리까지 직접 전달해서 중간에 도메인 객체를 만드는 과정이 없다.
  • 단점 : 간단한 경우에만 적용할 수 있고, 검증 중복이 발생할 수 있어 groups를 사용해야 한다.

 

✔ 폼 데이터 전달에 별도의 객체 사용

 

HTML Form ➡ Class for Form ➡ Controller ➡ Domain 생성 ➡ Repository

 

  • 장점 : 폼 데이터가 아무리 복잡해도 거기에 맞춘 별도의 폼 객체를 사용해서 데이터를 받을 수 있고, 검증 중복이 없다.
  • 단점 : 폼 데이터를 기반으로 컨트롤러에서 도메인 객체를 생성하는 과정이 추가된다.

 

검증 중복이 없고 유연하게 데이터를 받을 수 있는 별도 폼 객체를 사용하자

 

🔎 저장용 폼 객체

 

@Data
public class ItemSaveForm {

    @NotBlank
    private String itemName;

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

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

 

@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 itemParam = new Item();
    itemParam.setItemName(form.getItemName());
    itemParam.setPrice(form.getPrice());
    itemParam.setQuantity(form.getQuantity());

    itemRepository.update(itemId, itemParam);
    return "redirect:/validation/v4/items/{itemId}";

}

 

@ModelAttribute는 폼 객체에 맞춰 바인딩을 수행한다. 그리고 @Validated를 통해 검증을 수행한 후, BindingResult로 검증 결과를 받는다.

 

폼 객체를 사용했기 때문에 도메인 객체를 생성하는 작업이 추가되었다.

 

📌 API에 Bean Validation 적용

 

@RequestBody처럼 HttpMessageConverter를 사용하는 애노테이션에도 Bean Validation을 적용할 수 있다.

 

API 요청 결과는 다음 3가지로 나뉜다.

 

  • 성공 요청 ➡ 성공
  • 실패 요청 ➡ 바인딩 실패로 컨트롤러 자체가 호출되지 않고 Validator도 실행되지 않는다.
  • 검증 오류 요청 ➡ 바인딩은 성공했지만 검증 실패

 

🔎 코드 및 테스트

 

@RequestBody는 HttpMessagaeConverter를 통해 HTTP 요청 바디를 읽어 ItemSaveForm에 바인딩한다.

 

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

}

 

검증 오류 발생 시, 모든 에러를 출력하도록 했다. bindingResult의 getAllErrors()는 ObjectError와 FieldError를 반환한다. 스프링은 ObjectError와 FieldError를 JSON으로 변환하여 클라이언트로 내린다. 결과는 다음과 같다.

 

요청 Body

 

quantity 필드의 @Max로 인한 검증 오류

 

HttpMessageConverter를 사용하는 @RequestBody도 검증을 적용할 수 있다는 것을 알 수 있었고, BindingResult를 통해 오류 정보도 받을 수 있었다.

 

💡 @ModelAttribute vs @RequestBody

HTTP 파라미터를 처리하는 @ModelAttribute는 필드 단위로 정교하게 바인딩이 적용된다. 그래서 특정 필드가 바인딩되지 않아도 나머지 필드는 정상 바인딩되고, Validator를 사용한 검증도 가능하다.

HTTP body를 읽는 @RequestBody는 HttpMessageConverter 단계에서 바인딩에 실패하면 아무 기능도 수행하지 않는다. 그 이유는 HttpMessageConverter는 필드 단위가 아니라, 객체 단위로 적용되기 때문이다.

 

Reference:

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

 

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 인프런 | 강의

웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습할 수 있

www.inflearn.com