Spring mvc2 – 3. 유효성 검사 2 – Bean 유효성 검사

빈 유효성 검사 소개

Bean 검증 – 시작하기

  • Spring과 통합하지 않고 순수 빈 유효성 검사를 사용하는 방법

하나. 종속성 추가

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

2. 추가된 의존성으로 추가된 라이브러리

  • 라이브러리가 설치되면 다음 두 패키지가 함께 설치됩니다.
    1. jakarta.validation-api : 빈 검증 인터페이스
      • 자체적으로 구현할 수 없습니다.
      • Java 표준이기 때문에 인터페이스의 validatoin 함수는 모든 구현에 적용할 수 있습니다. 예) @NotNull
    2. 최대 절전 유효성 검사기 구현
      • 실제로는 거의 빈 유효성 검사 인터페이스의 구현으로 사용됩니다.
      • Hibernet에는 자체 주석이 있습니다. 예) @범위

빈 유효성 검사를 사용한 예

  • 즉, 이렇게 주석을 달아서 사용할 수 있지만 주석만 붙여서 바로 작동하는 것은 아닙니다.
  • 작동 방식 Bean 유효성 검사 – Spring 적용 부분을 ​​설명했다.
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 // java표준 인터페이스의 검증 어노테이션으로 구현체인 하이버네트가 구현해준다.
    private String itemName;
    
    @NotNull
    @Range(min = 1000, max = 100000) // 하이버네트 검증 어노테이션으로 하이버네트가 직접 가지고 있는 것
    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 유효성 검사가 가져온 것을 볼 수 있습니다.
    1. javax.validation: 특정 구현과 상관없이 제공되는 표준 인터페이스입니다.
      • 주로 다른 구현(인터페이스를 상속하는 구현)에 의해 구현된 주석을 제공합니다.
      • Validation.java 클래스도 있습니다. – 스프링 작업 중에는 사용되지 않습니다. 자바에서만 사용
    2. org.hibernate.validation: 유효성 검사 주석이 Hibernate 구현에 의해 직접 소유될 때 가져옴
  • 검증 참고 사항
    • @NotBlank: 빈 값 + 공백은 허용되지 않습니다.
    • @NotNull: 0은 허용되지 않습니다.
    • @Range(min = 1000, max = 1000000): 값이 범위 내에 있어야 합니다.
    • @Max(9999): 9999까지만 허용됩니다.

테스트 코드

public class BeanValidationTest{
    
    @Test
    void beanValidaion() {
        // 검증기 생성
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        Validator validator = factory.getValidator();
        
        // 실제 Item 담는다
        Item item = new Item();
        item.setItemName(" "); // 문제가 되는 부분
        item.setPrice(0);
        item.setQuantity(10000);
        
        // 검증 실행
        Set<ConstrainViolation<Item>> violations = validator.validate(item);
        for (ConstrainViolation<Item> violation : violations){
            System.out.println("violation=" + violation);
            System.out.println("violation.message=" + violation,getMessage());
        }
    }
}
  • 검사기 만들기
    • 테스트할 때나 Spring과 연동할 때 사용하며 직접 작성하는 경우는 없다.
    • 심사관 : 심사관
  • 확인 실행
    • 유효성 검사기는 요소를 유효성 검사기에 삽입하고 결과를 문장으로 반환합니다.
    • ConstraintViolation: 유효성 검사 오류
      • 오류가 없으면 값이 비어 있습니다.
  • 검증 결과
    • 검증 오류가 발생한 객체, 필드, 메시지 정보 등 다양한 정보를 검증할 수 있습니다.

Bean 유효성 검사 – Spring 적용

ValidationItemControllerV3

@Slf4j
@Controller
@RequestMapping("/validation/v3/items")
@RequiredArgConstuctor
public class ValidationItemControllerV3 {
    
    private final ItemRepository itemRepository;
    
    // 직접 만들었던 검증기 제거
    // private final ItemValidator itemValidator;
    // @InitBinder controller를 호출할 때마다 검증을 하기 위해 먼저 호출되는 것. 그리고 항상 new로 호출
    // public void init(WebDataBinder dataBinder){
    //     dataBinder.addValidators(itemValidator);
    // }
    ...
    
    @PostMapping("/add")
    // @Validated로 BeanValidation 적용
    public String addItem(@Validated @ModelAttribute Item, BindingResult bindingResult, RedirectAttributes redirectAttributes){
        if(bindingResult.hasErrors()){
            return "validation/v3/addForm";
        }
        
        // 성공로직
        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirct:/validation/v3/items/{itemId}";
    }
    
    ...
}
  • 이전 릴리스에서는 자체 ItemValidator 유효성 검사기를 사용했습니다.
  • 검증자 부분을 삭제하고 컨트롤러를 호출하면 별도의 검증자를 만들지 않아도 정상적으로 동작한다.

유효성 검사기가 작동하는 이유

  • Spring MVC는 Bean Validator(== Validator)를 어떻게 사용하는가?
    • build.gradle – 구현 ‘org.springframework.boot:spring-boot-starter-validation’ 라이브러리가 포함되어 있으면 Spring Boot는 자동으로 Bean 유효성 검사기를 찾아 Spring에 통합합니다.
      • 스프링부트 동작
        1. 등록된 Bean Validator 이름은 LocalValidatorFactoryBean(Validator)입니다.
        2. 글로벌 검증인으로 등록
      • Global Validator(LocalValidatorFactoryBean)가 적용되었으므로 @Valid와 @Validated만 적용하면 된다.
        • 주석을 보면 SpringFrameWork는 유효성 검사기를 찾아 유효성 검사를 수행합니다.
        • @Valid: Java 표준 유효성 검사에 대한 참고 사항으로 위 라이브러리가 설치되어 있지 않으면 작동합니다.
        • @Validated: Spring 특정 유효성 검사 주석
  • 지침
    • 사용자가 직접 전역 검증자를 등록하면 Springboot는 빈 검증자(validator=Local…)를 검증자로 등록하지 않습니다.
    • 주석 기반 빈 유효성 검사기는 작동하지 않습니다.

검증 순서

  1. @ModelAttribute는 요청에서 받은 매개변수를 각 필드로 변환하려고 시도합니다.
    • 성공 시 -> 유효성 검사기 적용됨
    • 오류가 발생한 경우 -> FieldError를 typeMismatch로 추가 – 컨트롤러의 @PostMapping 메서드에 BindingResult가 있어야 합니다.
  2. 유효성 검사기 적용
    • 1을 전달한 필드에만 Bean 유효성 검증을 적용하십시오.
      • 빈 유효성 검사기는 바인딩할 수 없는 필드(필드에 입력되지 않은 값)에 대해 빈 유효성 검사를 적용하지 않습니다.
      • 검사는 모델 개체에 바인딩할 값이 정상적으로 도착하는 경우에만 의미가 있습니다.

Bean 유효성 검사 오류 코드

  • 해당 주석 오류 코드를 기반으로 MessageCodesResolver에서 순서대로 다른 메시지 코드를 생성합니다.
  • @NotBlank
    • NotBlank.item.itemName(주석명.클래스명.필드명)
    • NotBlank.itemName
    • NotBlank.java.lang.String
    • 비어 있지 않음
  • @영역
    • 구색.품목가격
    • 범위.가격
    • Range.java.lang.Integer
    • 영역
  • 메시지 등록
    • {0}: 필드 이름
    • {1}, {2}: 각 주석에 값이 할당됩니다.
# errors.properties
NotBlank={0} 공백X
Range={0}, {2} ~{1} 허용
Max={0}, 최대 {1}

  • BeanValidation 메시지 찾기 순서
    1. 생성된 메시지의 코드 개선 순서대로 messageSource에서 메시지 찾기
      1. 오류에서 메시지 코드를 찾을 수 있는 이유는 application.properties에 오류를 등록했기 때문입니다.
    2. 주석 메시지 속성 사용
      • 1에 메시지 코드가 없는 경우 기본값으로 사용됩니다.
      • @NotBlank(message = “공백을 입력할 수 없습니다.”)
    3. 라이브러리에서 제공하는 기본값 사용
      • 2. 이용 가능한 번호가 없을 경우, 도서관의 정보를 이용하십시오.

Bean 유효성 검사 – 개체 오류

  • @ScriptAssert() 사용
    • 메시지 코드 자동 생성
      • ScriptAssert.항목
      • scriptAssert
    • 실사용에는 한계가 너무 많습니다.
@Data
@ScriptAsset(lang="javascript", script="_this.price*_this.quantity >= 10000")
public class Item {
    ...
}

  • 개체 오류의 경우 해당 부분만 Java 코드에 직접 작성하는 것이 좋습니다.

ValidationItemControllerV3 – 전역 오류 추가됨

@PostMapping("/add")
public String addItem(@Validated @ModelAttribue 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()){
        return "validation/v3/addForm";
    }
    ...
}

오류 메시지 추가

# ObjectError
# Level1
totalPriceMin.item=상품의 가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}

Bean 유효성 검사 제한 사항

  • 처리 양식과 등록 양식의 요구 사항이 다릅니다.
    • 보정
      1. 수량은 무기한 변경될 수 있습니다.
      2. ID 값이 필요합니다.
    • 등록
      1. 수량은 9999로 고정
      2. 등록 시 ID 값이 필요하지 않습니다.
  • 동일한 Item 클래스의 유효성 검사 주석으로 다양한 상황에서 사용하기 어렵습니다.
    • ID 값에 @NotNull을 등록하면 상품 등록 자체가 차단됩니다.
  • 솔루션
    1. Bean 유효성 검사 – 그룹 기능 사용
    2. Item을 직접 사용하지 않고 양식 제출을 위한 별도의 모델 객체 생성 및 사용

빈 검증 그룹

  • 잘 사용하지 마세요
    • 전반적인 복잡성이 증가하기 때문에
    • 실제로는 등록을 위한 폼 객체와 수정을 위한 폼 객체를 분리하여 사용하기 때문이다.
  • Bean Validation에서 제공하는 기능
  • 등록 및 수정 확인 기능을 그룹으로 구분하여 적용할 수 있습니다.
  • @Validated 만 그룹을 사용하는 데 사용할 수 있습니다.

각 그룹 만들기

package hello.itemservice.domain.item;

public interface SaveCheck{}
package hello.itemservice.domain.item;

public interface UpdateCheck{}

항목 그룹 적용

package hello.itemservice.domain.item;

@Data
public class Item {

    @NotNull(groups = UpdateCheck.class) // 수정시에만 적용
    private Long id;
    
    @NotBlank(groups = {SaveCheck.class, UpdateCheck.class}) // 수정과 등록에 둘다 적용
    private String itemName;
    
    ...
    
    @Max(value = 9999, groups = SaveCheck.class) // 등록시에만 적용
    private nteger quantity;
}

컨트롤러에서 각 그룹을 할당하고 게시물을 편집합니다.

@PostMapping("/add")
public String addItemV2(@Validated(SaveCheck.class) @ModelAttribue Item item, BindingResult bindingResult, RedirecAttributes redirectAttributes){
 ...
}

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

양식 제출 객체 분리 – 소개

  • 그룹이 실제로 자주 사용되지 않는 이유
    • 이는 등록 시 양식에서 전송된 데이터가 항목 도메인 개체와 정확히 일치하지 않기 때문입니다.
    • 헬로우 월드에서는 사실일 수 있지만 실제 세계에서는 등록 시 일반 이용 약관을 포함한 추가 데이터가 전송됩니다.
  • 관행
    • 따라서 요소를 직접 가져오는 대신 별도의 객체를 생성하여 컨트롤러에 데이터를 전달합니다.
    • 그런 다음 컨트롤러는 필요한 데이터로 항목을 생성합니다.

  • HelloWorld 도메인 개체와 RealWorld 도메인 개체의 배포 프로세스 비교
  • 헬로 월드
    • HTML 양식 -> 항목 -> 컨트롤러 -> 항목 -> 저장소
      • 장점 : 아이템 도메인 객체를 컨트롤러와 리포지토리에 직접 전달하여 중간에 아이템을 생성하는 과정이 없기 때문에 간편하다.
      • 단점: 단순한 경우에만 적용됩니다. 수정 시 검증 중복 가능, 그룹 사용 필수
  • 현실 세계
    • HTML Form -> ItemSaveForm -> Controller -> Create Item -> 저장소
      • 장점: 제출할 폼 데이터가 복잡하더라도 그에 맞는 별도의 폼 객체를 이용해 데이터를 받을 수 있다. 등록 및 수정을 위해 별도의 양식 개체가 생성되기 때문에 일반적으로 유효성 검사는 중복되지 않습니다.
      • 단점: 양식 데이터를 기반으로 컨트롤러에서 항목 개체를 생성하는 변환 프로세스를 추가합니다.

양식 제출 개체 분리 – 개발

  • 기사 오리지날 드레스
@Data
public class Item {
    private Long id;
    private String itemName;
    private Integer price;
    private Integer quantity;
}

  • 저장 및 업데이트 양식 만들기
    1. 항목저장양식
    2. 항목 업데이트 양식
package hello.itemservice.web.validation.form;

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

}
package hello.itemservice.web.validation.form;

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 ItemUpdateForm {
    @NotNull
    private Long id;
    @NotBlank
    private String itemName;
    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;

    //수정에서는 수량은 자유롭게 변경 가능
    private Integer quantity;

}

  • 컨트롤러 변경
    • 주의: MVC 모델에 포함될 때 키 값이 항목으로 유지되도록 @ModelAttribute(“item”)에 항목 이름을 포함해야 합니다.
    • 유효성 검사 대상을 ItemSaveForm 형식으로 설정합니다.
@PostMapping("/add")
// 검증 대상을 ItemSaveForm form으로 지정해줌
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

    // 특정 필드가 아닌 복합 룰 검증 - @ScriptAsset() 사용보다 java code 이용하는 것을 추천
    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";
    }

    // 성공 로직
    // 성공 로직에서는 실제 data를 저장하는 공간을 Item item으로 설정
    Item item = new Item(form.getItemName(),form.getPrice(), 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) {

    // 특정 필드가 아닌 복합 룰 검증 - @ScriptAsset() 사용보다 java code 이용하는 것을 추천
    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 item에 넣어준다.
    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}";
}

Bean 유효성 검사 – HTTP 메시지 변환기

  • @Valid, @Validated: HttpMessageConverter(@RequestBody)에도 적용됩니다.

  • 참조
    • @ModelAttribute: HTTP 요청 매개변수를 처리하는 데 사용됩니다.
    • @RequestBody : HTTP 본문 데이터를 객체로 변환할 때 사용 – API JSON 요청을 처리할 때 사용
  • 컨트롤러 생성
    • PostMan을 사용한 유효성 검사
    • API에 접근할 때 고려해야 할 세 가지 경우가 있습니다.
      1. 성공 요청
      2. 실패한 요청 – 개체의 내부 값에 형식 오류가 발생한 경우 JSON을 개체 자체로 빌드하는 데 실패함
        • addItem의 @RequestBody는 HTTP messageConverter에서 @RequestBody를 보고 ItemSaveForm으로 JSON 생성을 시도하지만 ItemSaveForm 객체 생성 자체는 tye error로 실패한다.
      3. 요청 유효성 검사 오류 – JSON을 개체로 성공적으로 생성했지만 유효성 검사에 실패했습니다.
@Slf4j
@RestController
@RequestMapping("/validation/pai/items")
public class ValidationItemApiController {
    // postMan - Body - raw - json {"itemName":"hello", "price": "1000", "quantity": 100000}

    @PostMapping("/add")
    public Object addItem(@RequestBody @Validated ItemSaveForm form, BindingResult bindingResult){
        //HttpMessageConvert가  @RequestBody를 보고 ItemSaveForm을 이용해서 JSON을 넘겨준다.
        //HttpMessageConvert는 @ModelAttribute와 다르게 각각의 field 단위로 적용되는 것이 아니라 전체 객체 단위로 적용된다. - type이 다르면 controller 호출 자체가 안되는 예외 발생
        log.info("API 컨트롤러 호출");

        if (bindingResult.hasErrors()){
            log.info("검증 오류 발생");
            return bindingResult.getAllErrors();
        }
        log.info("성공 로직 실행");
        return form;
    }
}

  • 참조