Spring/Spring MVC - 활용

[Spirng] Bean Vaildation(빈 밸리데이션) - 검증

진이최고다 2023. 9. 7. 22:17

Bean Validation 소개와 이해

Bean Validation 은 개발자가 반복적으로 작성해야하는 검증 로직을 표준화하여 관리하는 기술이다. 이를 통해 코드로 매번 검증 로직을 작성하는 대신, 어노테이션을 사용하여 간결하게 검증을 수행할 수 있다.

 

Bean Validation 이란?

  • Bean Validation 은 특정 구현체가 아니라 JSR-380(Bean Validation 2.0)이라는 기술 표준이다.
  • 기본적으로 검증 어노테이션과 인터페이스의 모음으로 구성되어 있다.
  • Bean Validation 표준의 구현체 중 가장 대표적인 것은 Hibernate Validator 이다.
  • Hibernate ValidationORM 과는 관련이 없으며, 검증 기능에 특화되어 있다.

 

Hibernate Validation 관련 

공식 사이트 : https://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


Bean Validation 시작

의존성 추가

Bean Validation을 사용하려면, 프로젝트에 관련 라이브러리를 추가해야 한다. 

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

의존성을 추가하면 필요한 라이브러리가 함께 추가된다.

 

Jakarta Bean Validation

jakarta.validation-api : Bean Validation 인터페이스

hibernate-validator 구현체

 

Bean Validation 사용
import lombok.Data;
import org.hibernate.validator.constraints.Range;

import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

@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;
    
    // Constructor, Getter, Setter...
}

검증 어노테이션 

  • @NotBlank : 문자열이 비어있거나, 공백만 있으면 안된다.
  • @NotNull : 필드의 값이 null 이면 안된다.
  • @Range(min, max) : 값이 지정된 범위 내에 있어야 한다.
  • @Max(value) : 값이 지정된 최대값 보다 작거나 같아야 한다.

 

참고

어노테이션의 시작이 'javax.validation' 이면 Bean Validation 의 표준 인터페이스 이고,

'org.hibernate.validator' 로 시작하면 Hibernate Validator 의 특정 구현체에만 있는 검증 기능이다.

실무에서는 대부분 Hibernate Validation 를 사용하므로 자유롭게 사용해도 된다.

 

테스트 코드
import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
import java.util.Set;

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 = " + violation.getMessage());
        }

    }
}

ValidatorFactroy 와 Validator 를 사용하여 검증을 수행하고, 결과를 ConstraintViolation 의 집합으로 받는다.


스프링에서의 Bean Validation 적용

1. 어노테이션 기반의 Bean Validation 

스프링에서 객체를 검증하기 위한 몇 가지 어노테이션을 제공한다. 

예를 들어, '@Validated' 또는 '@Valid' 어노테이션을 사용하여 컨트롤러의 메서드 매개변수에 검증을 적용할 수 있다.

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

'BindingResult' 는 검증 결과를 저장하는 객체로, 검증에 실패한 경우 해당 결과를 담고 있다.

 

2. 스프링 MVC와 Bean Validator

  • 스프링 부트는 'spirng-boot-starter-validation' 라이브러리가 포함되면 자동으로 Bean Validator 를 스프링에 통합한다.
  • 'LocalValidatorFactoryBean' 는 글로벌 'Validator' 로 등록한다. 이 'Validator' '@NotNull' 등의 어노테이션을 검사하여 검증을 수행한다.
  • 검증 오류가 발생하면,  FieldError, ObjectError 가 생성되어 'BindingResult' 에 추가된다.

 

3. @Validator 와 @Valid

  • @Validated@Valid 둘 다 Bean Validation 에 사용할 수 있다.
  • @Valid Java 표준 어노테이션이고, @Validated스프링 전용 어노테이션이다.
  • @Validated 는 그룹 기능을 제공하는 것이 특징이다.

 

4. 검증 순서

  1. @ModelAttribute 로 명시된 객체의 각 필드에 대해 타입 변환이 시도 된다.
  2. 타입 변환에 성공하면 해당 필드에 Bean Validation이 적용된다.
  3. 타입 변환에 실패하면 'typemismatch' 라는 'FieldError' 가 추가되며 해당 필드에 Bean Validation은 적용되지 않는다.

Bean Validation - 오류 코드와 메시지 관리

Bean Validation 은 Java 객체의 필드에 대한 검증 규칙을 정의하고, 해당 규칙에 위배될 경우 오류 메시지를 반환하는 기능을 제공한다. 

 

1. 오류 코드 확인

  • Bean Validation 을 적용할 때, 오류 가 발생하면 해당 오류에 대응하는 오류 코드가 생성된다.
  • 예를 들어, '@NotBlank' 어노테이션을 사용한 필드에서 빈 값이 들어오면 'NotBlank' 라는 오류 코드가 생성된다.
  • * tpyemismach 와 유사하다.

 

스프링은 'MessageCodesResolver' 를 사용하여

오류 코드를 기반으로 여러 메시지 코드를 순서대로 생성한다. 

@NotBlank 어노테이션

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

@Range 어노테이션

  • Range.item.price
  • Range.price
  • Range.java.lang.integer
  • Range

2. 메시지 등록

errors.properties
#Bean Validation 추가

NotBlank.item.itemName=상품 이름을 입력 해주세요.

NotBlank={0} 공백은 허용하지 않습니다.
Range={0}, {2} ~ {1} 허용 합니다.
Max={0}, 최대 {1} 범위 값을 입력 해주세요.

 

3. BeanValidation 메시지를 찾는 순서

  1. 생성된 메시지 코드를 순서대로 'messageSource' 에서 검색한다.
  2. 어노테이션의 'message' 속성 값을 사용한다. 예 : '@NotBlank(message = "공백! {0}")
  3. 라이브러리가 제공하는 기본 메시지를 사용한다.

 

4. 어노테이션의 message 사용

때로는 특정 필드에 대해 개별적인 메시지를 지정하고 싶을 떄 가 이다. 이럴 경우 어노테이션의 'message' 속성을 직접 사용하여 메시지를 지정할 수 있다.

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

Bean Validation - 오브젝트 오류 처리

Bean Validation에서는 필드 수준의 오류('FieldError')뿐만 아니라 전체 오브젝트에 관련된 오류('ObjectError')도 처리할 수 있다. 

 

1. @ScriptAssert 어노테이션 사용

@ScriptAssert 어노테이션은 전체 오브젝트에 적용되는 스크립트 기반의 검증을 제공한다.

예를 들어, 아이템의 가격과 수량을 곱한 값이 10,000 이상이 되어야 한다는 검증을 하고 싶다면 다음과 같이 사용할 수 있다.

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

이 방법을 사용하면, 메시지 코드는 다음과 같이 생성된다.

  • ScriptAssert.item
  • ScriptAssert

하지만 @ScriptAssert 를 사용하는 방법은 제약이 많고 복잡하다. 특히 실무에서는 검증 기능이 해당 객체의 범위를 넘어서는 경우가 종종 발생하며, 이러한 경우에는 '@ScpritAssert' 를 사용하여 처리하기 어렵다.

 

2. 직접 오브젝트 오류 처리

위의 제약 때문에 전체 오브젝트에 관한 오류 처리는 '@ScriptAssert' 를 사용하는 것 보다는 직접 자바 코드로 검증 하는 것이 바람직하다.

 

예를 들어, 아이템의 가격과 수량을 곱한 결과가 10,000 미만인 경우 오류를 반호나하고 싶다면 다음과 같이 처리할 수 있다.

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

위의 코드에서는 'bindingResult.reject()' 메소드를 사용하여 전체 오브젝트에 관한 오류를 직접 추가한다.

 

 

JDK8 ~ 14 의 JVM 에서 사용되는 Nashorn 엔진은 Javascript 를 지원하지만,  JDK14 이후 버전부터는 javascript 가 지원되지 않는 GraalVM 를 사용한다. 

JDK14 이후 부터 @ScriptAssert 를 이용한 자바스크립트 표현식 사용할 수 없다.


Bean Validation 한계와 수정시 검증 요구사항

Bean Validation을 사용하면 편리하게 데이터 검증을 할 수 있지만, 서로 다른 상황에서는 다른 검증 요구사항이 발생할 수 있다. 이러한 상황에서 Bean Validation의 한계가 드러날 수 있다.

 

등록시 요구사항

1. 타입 검증 : 가격과 수량에 문자가 들어가면 검증 오류 처리가 필요하다.

2. 필드 검증 :

  • 상품명 : 필수 입력 항목이며, 공백이 허용되지 않는다.
  • 가격 : 1,000원 이상, 1,000,000원 이하로 제한된다
  • 수량 : 최대 9999까지 허용된다.

3. 범위 검증 : 가격과 수량의 곱은 최소 10,000원 이상이어야 한다.

 

수정시 요구사항

1. 수량 : 등록시에는 최대 9999까지 수량을 등록할 수 있지만, 수정시에는 수량에 대한 제한이 없다.2. ID : 등록시에는 ID 값이 필요 없지만, 수정시에는 ID 값이 반드시 필요하다.

 

수정 요구사항 적용

수정시에는 Item에서 Id값이 필수이고, quantity도 무제한으로 적용할 수 있다.

@Data
public class Item {

    @NotNull // 수정시 필요
    private Long id;
    @NotBlank
    private String itemName;
    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;
    @NotNull
    // @Max(9999) 등록시 필요하지만 수정시에는 필요 없음
    private Integer quantity;
    //...
}

ID 필드에 @NotNull 어노테이션을 추가, 수량 필드에서 @Max(9999) 어노테이션을 제거 

 

문제점

수정은 정상적으로 동작하지만, 등록시에는 문제가 발생한다.

  1. 등록시에는 ID 값이 존재하지 않기 때문에 '@NotNull' 어노테이션 때문에 검증 오류가 발생한다.
  2. 등록시에 수량에 대한 최대 값 제한(9999)이 적용되지 않아 문제가 될 수 있다.

이와 같이 하나의 도메인 객체에 대해 등록과 수정에서 다른 검증 조건이 필요할 때, Bean Validation의 한계가 드러나게 된다.


Bean Validation - groups 사용

Bean Validation 의 'groups' 기능을 사용하면 동일한 모델 객체에 대해 다른 검증 규칙을 설정할 수 있다. 

예를 들어, 객체를 등록하거나 수정할 때의 검증 규칙을 다르게 적용하려는 경우 사용할 수 있다.

 

방법 

  • BeanValidation'groups' 기능 사용
  • 모델 객체를 직접 사용하지 않고 등록과 수정을 위한 별도의 모델 객체를 사용 (예 : ItemSaveForm, ItemUpdateForm )

 

groups 적용

저장용 groups 생성
public interface SaveCheck {}
수정용 groups 생성
public interface UpdateCheck {}
Item 객체에 grorups 적용
@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;
    
    // 생성자 및 기타 메서드
}
Controller - groups 적용
@PostMapping("/add")
public String addItemV2(@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) {
    // ...
}

 

참고

@Valid에는 groups를 적용할 수 있는 기능이 없다. 따라서 groups를 사용하려면 @Validated를 사용해야 한다.

 

'groups' 기능을 사용하여 등록 및 수정 시에 다른 검증 규칙를 적용할 수 있다. 그러나

'groups' 를 사용하면 복잡도가 상승할 수 있다. 실무에서는 등록 및 수정을 위한 별도의 폼 객체를 사용하여 이러한 복잡성을 줄이는 방향으로 접근한다.


Form 전송 객체 분리

객체의 검증에 'groups' 를 자주 사용하지 않는 주된 이유는 전달하는 폼 데이터와 도메인 객체가 항상 정확히 일치하지 않기 때문이다. 폼에서 전달되는 데이터는 도메인 객체보다 복잡하거나 추가적인 정보를 포함하기도 한다.

예 : 회원 등록시 회원과 관련된 데이터 + 약관 정보 추가

 

도메인 객체로 폼 데이터 사용

흐름 : HTML Form -> Item -> Contoller -> Item -> Respository

장점 :

  • 중간 변환 과정 없이 직접 객체를 전달하여 간단함

단점 :

  • 간단한 경우에만 적용 가능
  • 수정 시 검증이 중복될 수 있음
  • 'groups' 를 사용해야 한다.

 

별도의 폼 전송 객체 사용

흐름 : HTMl Form -> itemSaveForm -> Contoller -> Item 생성 -> Repository장점 :

  • 복잡한 폼 데이터에 맞게 별도의 객체를 사용하여 데이터 전달 가능.
  • 등록과 수정을 위한 별도의 폼 객체 사용으로 검증 중복을 방지

단점

  • 폼 데이터를 기반으로 컨트롤러에서 도메인 객체로의 변환 과정 필요

 

복잡한 폼에서는 도메인 객체 대신 전송 전용 객체 (예 : ItemSaveForm, ItemUpdateForm)를 사용하여 데이터를 컨트롤러에 전달한다. 컨트롤러에서는 이 데이터를 기반으로 필요한 도메인 객체를 생성하거나 수정한다. 이 접근법은 등록과 수정의 요구사항이 크게 다른 경우 특히 유용하다. 예를 들어, 사용자 등록에서는 로그인 ID, 주민번호 등의 데이터를 받을 수 있지만, 수정에서는 이러한 정보를 받지 않는다.


Form 전송 객체 분리 - 개발

1. Item코드 복원

검증 코드를 제거, 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;

    private Integer quantity;

	//...
}

 

2. 폼 객체 정의

등록과 수정을 위한 두 가지 폼 객체 'ItemSaveFom', 'ItemUpdateForm' 정의

이를 통해 등록과 수정에 필요한 검증 규칙을 명확하게 분리할 수 있다.

@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;
}
3. 컨트롤러 수정

등록과정에서 ItemSaveForm 를 사용하여 데이터를 바인딩하고, 검증

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

수정과정에서 ItemUpdateForm 를 사용하여 데이터를 바인딩하고, 검증

@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @Validated@ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult) {

	//...
}

@ModelAttribute("item") 을 사용하면 뷰 템플릿에서 해당 객체를 접근할 때 사용하는 이름을 지정할 수 있다. 이름을 지정 하지 않으면 기본적으로 객체의 클래스 이름을 카멜케이스로 변환한 이름을 사용하게된다. 따라서 이름을 명시적으로 지정하지 않으면 뷰 템플릿에서 접근하는 이름도 변경해야 한다.


Bean Validation 과 HTTP 메시지 컨버터

@Valid, @ValidatedHttpMessageConverter (@RequestBody)에도 적용할 수 있다.

 

@ModelAttribute  vs @RequestBody

  • @ModelAttribute 는 HTTP 요청 파라미터(URL 쿼리 스트링, POST Form)를 처리할 때 사용한다.
  • @RequestBody 는 HTTP Body 의 데이터를 객체 변환할 때 사용한다. 주로 API JSON 요청을 처리할 때 사용한다.

 

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

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

 

@ModelAttribute vs @RequestBody 차이점

  • @ModelAttribute 는 각각의 필드 단위로 세말하게 적용된다. 따라서 특정 필드에 문제가 있어도 나머지 필드는 정상적으로 처리된다.
  • @RequestBody 는 전체 객체 단위로 적용된다. 따라서 JSON 데이터를 객체로 변환하는데, 실패하면 컨트롤러 호출 자체가 중단되며, Validation도 적용되지 않는다.

 

HttpMessageConverter 단계에서 오류가 발생하면 예외가 발생한다. 원하는 형태로 예외를 처리하려면 별도의 예외 처리 로직이 필요하다.

 

 

출처 : 인프런 - 🔗 스프링 MVC 2 - 백엔드 개발 활용 기술by 우아한형제 김영한님