framework/spring

스프링 mvc2 - 3. 검증 2 - Bean Validation

wooweee 2023. 5. 6. 09:54
728x90

1. Bean Validation 소개

 

2. Bean Validation - 시작

  • 스프링과 통합하지 않는 순수한 Bean Validation 사용하는 방법

 

1. 의존관계 추가

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

 

2. 의존 관계 추가로 추가된 라이브러리

  • 해당 Library를 설치하면 아래 2가지의 Package가 같이 설치가 된다.
    1. jakarta.validation-api : Bean validation 인터페이스
      • 이것 자체로만 구현 불가
      • 자바 표준이기에 관련 어떤 구현체든 해당 인터페이스의 Validatoin 기능이 적용 가능하다. ex) @NotNull 
    2. hibernate-validator 구현체
      • 실무에서 Bean validation 인터페이스의 구현체로 거의 사용한다.
      • 하이버네트 자체적인 어노테이션 존재한다. ex) @Range

 

Bean Validation 사용 예 

  • 이렇게 어노테이션을 붙여서 사용을 할 수 있다는 것이지 어노테이션만 붙였다고 바로 작동되는 것은 아니다.
  • 작동 방식은 Bean Validation - 스프링 적용 part에 설명 해놓았다.
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 Validation이 import 된 것을 알 수 있다.
    1. javax.validation: 특정 구현에 관계없이 제공되는 표준 인터페이스이다.
      • 주로 어노테이션을 제공 - 구현은 다른 구현체(인터페이스를 상속받은)들이 해줌
      • 클래스도 존재 : Validation.java - spring 동작시 안씀. java로만 할 때 사용
    2. org.hibernate.validation: 하이버네이트 구현체가 직접 가진 검증 어노테이션일때 import 됨

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

 

  • test code
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());
        }
    }
}
  • 검증기 생성
    • test할 때나 쓰지 spring과 통합하여 사용시 직접 작성할 경우 없다.
    • 검증기: validator
  • 검증 실행
    • item을 검증기에 직접 넣고 결과를 set에 반환한다. 
    • ConstraintViolation: 검증오류
      • 오류가 없을 시 값이 비어있다.
  • 검증 결과
    • 검증 오류가 발생한 객체, 필드, 메시지 정보등 다양한 정보를 확인할 수 있다.

 

3. Bean Validation - 스프링 적용

 

  • ValidationItemControllerV3
@Slf4j
@Controller
@RequestMapping("/validation/v3/items")
@RequiredArgConstuctor
public class ValidationItemControllerV3 {
    
    private final ItemRepository itemRepository;
    
    // 직접 만들었던 검증 logic 제거
    // 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 검증기를 사용했었다.
  • 검증기 부분을 지우고 controller를 호출하면 따로 검증기를 만들지 않았는데도 정상 작동을 한다.

 

4. 검증기 작동 이유

  • 스프링 MVC가 어떻게 Bean Validator(== 검증기)를 사용하나?
    • build.gradle - implementation 'org.springframework.boot:spring-boot-starter-validation' 라이브러리를 넣으면 스프링 부트가 자동으로 Bean Validator(검증기) 찾아서 스프링에 통합시킨다.
      • 스프링 부트 동작
        1. 등록한 Bean Validator 이름은 LocalValidatorFactoryBean(검증기) 이다.
        2. 글로벌 검증기로 등록
      • 글로벌 검증기(LocalValidatorFactoryBean)이 적용되었기 때문에 @Valid, @Validated만 적용하면 된다.
        • 해당 어노테이션을 보고 SpringFrameWork가 검증기를 찾아서 검증을 실시한다.
        • @Valid: 자바표준 검증 어노테이션으로 아까 위의 library를 설치하지 않으면 작동X
        • @Validated: 스프링 전용 검증 어노테이션
  • 주의점
    • 개인이 직접 글로벌 Validator(검증기)를 등록시, springboot는 Bean Validator(검증기=Local...)을 검증기로 등록하지 않는다.
    • 어노테이션 기반 빈 검증기 동작 안함.

 

5. 검증 순서

  1. @ModelAttribute가 request로 부터 받은 params를 각 필드에 타입 변환 시도
    • 성공시 -> Validator 적용
    • 실패시 -> typeMismatch로 FieldError 추가 - 해당 controller의 @PostMapping의 method에 BindingResult가 존재해야함
  2. Validator 적용
    • 1.에서 성공한 필드만 Bean Validation 적용
      • Bean validator는  바인딩에 실패한 field(field에 못들어간 값)는 Bean Validation을 적용하지 않는다.
      • 모델 객체에 바인딩 받는 값이 정상으로 들어와야 검증도 의미가 있다.

 

6. Bean Validation - 에러코드

  • 해당 어노테이션 오류코드를 기반으로 MessageCodesResolver를 통해 다양한 메시지 코드가 순서대로 생성
    • 어노테이션 이름.class명.필드이름
    • 어노테이션 이름.필드이름
    • 어노테이션 이름.자바클래스
    • 어노테이션 이름
  • @NotBlank
    • NotBlank.item.itemName 
    • NotBlank.itemName
    • NotBlank.java.lang.String
    • NotBlank
  • @Range
    • Range.item.price
    • Range.price
    • Range.java.lang.Integer
    • Range
  • 메시지 등록
    • {0}: 필드명
    • {1}, {2}: 어노테이션 마다 할당 된 값이 존재
# errors.properties
NotBlank={0} 공백X
Range={0}, {2} ~{1} 허용
Max={0}, 최대 {1}

 

  • BeanValidation 메시지 찾는 순서
    1. 생성된 메시지 코드 구체화 순서대로 messageSource에서 메시지 찾기
      1. errors 에서 메시지 코드를 찾을 수 있는 이유는 application.properties에서 errors를 등록했기 때문
    2. 어노테이션 message 속성 사용
      • 1.번에 메시지 코드가 없을 때 default값으로 사용
      • @NotBlank(message = "공백은 입력 불가") 
    3. 라이브러리가 제공하는 기본 값 사용
      • 2.번도 없을 때, library에서 나오는 정보 사용

 

7. Bean Validation - 오브젝트 오류

 

7.1. @ScriptAssert()

  • @ScriptAssert() 사용
    • 메시지 코드 자동 생성
      • ScriptAssert.item
      • scriptAssert
    • 실무에서 사용하기에 제약이 너무 많다.
@Data
@ScriptAsset(lang="javascript", script="_this.price*_this.quantity >= 10000")
public class Item {
    ...
}

 

7.2. 자바 logic

  • 오브젝트 오류의 경우 해당부분만 직접 자바 코드로 작성하는 것을 권장

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}

 

8. Bean Validation - 한계

  • 수정 form 과 등록 form의 요구사항이 다르다
    • 수정
      1. 수량을 무제한으로 변경 가능
      2. id값이 필수
    • 등록
      1. 수량은 9999개로 고정
      2. id값은 등록시 필요 없음
  • 동일 Item class에서 검증 어노테이션으로 다른 상황에 맞게 사용이 힘들다.
    • id 값에 @NotNull을 등록시, 상품등록자체가 막힌다.
  • 해결방안
    1. Bean Validation - groups 기능 사용
    2. Item을 직접 사용하지 않고 form 전송을 위한 별도의 모델 객체를 만들어서 사용

 

9. Bean Validation -groups

  • 잘 사용 안한다.
    • 전반적인 복잡도가 올라가기 때문
    • 실무에선 주로 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문
  • bean validation이 제공하는 기능
  • 등록과 수정의 검증 기능을 각각 그룹으로 나누어 적용할 수 있다.
  • groups를 사용하기 위해선 @Validated만 사용 가능하다.

 

  • 각 그룹 생성
package hello.itemservice.domain.item;

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

public interface UpdateCheck{}

 

  • Item - groups 적용
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;
}

 

  • Controller 저장,수정 post 부분에 각 group 지정
@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){
...
}

 

10. Form 전송 객체 분리 - 소개

  • 실무에서 groups를 잘 사용하지 않는 이유
    • 등록시 폼에서 전달하는 데이터가 Item domain 객체와 딱 맞지 않기 때문
    • Hello World 세상에서는 맞을지 몰라도 Real World에서는 등록시 약관 정보를 비롯한 부가 데이터가 넘어온다.
  • 실무
    • 그래서 보통 item을 직접 전달 받는 것이 아니라 별도의 객체를 만들어서 controller에거 데이터를 전달한다.
    • 이후 컨트롤러에서 필요데이터를 가지고 Item을 생성한다.

 

  • HelloWorld 도메인객체와 RealWorld 도메인 객체 전달과정 비교

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

    2. RealWorld
      •  HTML Form -> ItemSaveForm -> Controller -> Item 생성 -> Repository
        • 장점: 전송하는 폼 데이터가 복잡해도 거기에 맞춘 별도의 폼 객체를 사용해서 데이터를 전달 받을 수 있다. 보통 등록과, 수정용으로 별도의 폼 객체를 만들기 때문에 검증이 중복되지 않는다.

        • 단점: 폼 데이터를 기반으로 컨트롤러에서 Item 객체를 생성하는 변환 과정이 추가된다.

 

11. Form 전송 객체 분리 - 개발

  • Item 원복
@Data
public class Item {
    private Long id;
    private String itemName;
    private Integer price;
    private Integer quantity;
}

 

 

  • ItemSaveForm
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;
    
    ... 현실에서는 동의서 클릭 했는지, 주민등록번호, 개인정보동의, 마케팅 정보 등등 추가적인 조건이 더 존재

}

 

  • ItemUpdateForm
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;

}

 

  • Controller 수정
    • 주의: @ModelAttribute("item")에 item 이름을 넣어줘야지 MVC Model에 담길 때 item으로 key값이 유지 된다. 
    • 검증 대상을 ItemSaveForm form으로 지정해줌
    • 복합 룰 검증은 java logic으로 직접 구현
@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 form으로 지정해줌
  • 작성 순서 : @pathVariable -> @Validated -> @ModelAttribute -> BindingResult
@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}";
}

 

 

12. Bean Validation - HTTP 메시지 컨버터

 

  • @Valid, @Validated는 HttpMessageConverter(@RequestBody)에도 적용 가능하다.

 

  • 참고
    • @ModelAttribute: HTTP 요청 파라미터 다룰 때 사용
    • @RequestBody: HTTP Body의 데이터를 객체로 변환할 때 사용 - API JSON 요청 다룰 때 사용

 

  • Controller 생성
    • PostMan으로 검증

    • API 접근시 3가지 검증 결과가 존재
      1. 성공 요청

      2. 실패 요청
        • 객체 내부 값의 type error가 발생시 JSON을 객체로 생성하는 것 자체가 실패
        • addItem의 @RequestBody가 HTTP messageConverter가 @RequestBody를 보고 ItemSaveForm을 이용해서 JSON을 만들려고 하는데 Tye error로 ItemSaveForm 객체 생성자체를 실패함

      3. 검증 오류 요청
        • type은 일치하여 binding에는 성공하여 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;
    }
}

 

  • 참고