본문 바로가기
Spring/MVC 2편

검증2 - Bean Validation

by JHyun0302 2023. 8. 7.
728x90

Bean Validation 이란?

  • 먼저 Bean Validation은 특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)이라는 기술 표준이다.
  • 쉽게 이야기해서 검증 애노테이션과 여러 인터페이스의 모음이다.
  • Bean Validation을 구현한 기술중에 일반적으로 사용하는 구현체는 하이버네이트 Validator이다.
  • 이름이 하이버네이트가 붙어서 그렇지 ORM과는 관련이 없다.

 

 

build.gradle

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

 

 

검증 애노테이션

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

 

 

 

 

 


검증 순서

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

  1. 성공하면 다음으로
  2. 실패하면 typeMismatch 로 FieldError 추가

2. Validator 적용

 

 

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

  • @ModelAttribute → 각각의 필드 타입 변환시도 → 변환에 성공한 필드만 BeanValidation 적용

ex)

  • itemName 에 문자 "A" 입력 → 타입 변환 성공 → itemName 필드에 BeanValidation 적용
  • price 에 문자 "A" 입력 → "A"를 숫자 타입 변환 시도 실패→  typeMismatch FieldError 추가 →  price 필드는 BeanValidation 적용 X

 

 

 

 

 


Bean Validation - 에러 코드

 

@NotBlank

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

 

@Range

  • Range.item.price
  • Range.price
  • Range.java.lang.Integer
  • Range

 

 

errors.properties

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

 

 

BeanValidation 메시지 찾는 순서

  1. 생성된 메시지 코드 순서대로 messageSource 에서 메시지 찾기
  2. 애노테이션의 message 속성 사용 @NotBlank(message = "공백! {0}")
  3. 라이브러리가 제공하는 기본 값 사용 공백일 수 없습니다.

 

 

 

 

 


Bean Validation - 한계

 

 

데이터를 등록할 때와 수정할 때는 요구사항이 다를 수 있다.

 

 

수정시 요구사항

  • 등록시에는 quantity 수량을 최대 9999까지 등록할 수 있지만 수정시에는 수량을 무제한으로 변경할 수 있다.
  • 등록시에는 id 에 값이 없어도 되지만, 수정시에는 id 값이 필수이다.

 

@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 에 값도 없고, quantity 수량 제한 최대 값인 9999도 적용되지 않는 문제가 발생
  • 등록시에는 id 값이 없다는 오류 발생

 

결과적으로 item 은 등록과 수정에서 검증 조건의 충돌이 발생하고, 등록과 수정은 같은 BeanValidation 을 적용할 수 없다. 

 

 

 

 

 


Bean Validation - groups

 

// 저장용 groups 생성
public interface SaveCheck {
}

// 수정용 groups 생성
public interface UpdateCheck {
}

 

 

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

 

 

 

 

◎ 정리

  • groups 기능을 사용해서 등록과 수정시에 각각 다르게 검증을 할 수 있었다.
  • 그런데 groups 기능을 사용하니 Item 은 물론이고, 전반적으로 복잡도가 올라갔다.
    사실 groups 기능은 실제 잘 사용되지는 않는다. 대신 등록용 폼 객체와 수정용 폼 객체를 분리해서 많이 사용한다.

 

 

 

 

 

 


Form 전송 객체 분리

 

 

폼 데이터 전달을 위한 별도의 객체 사용

HTML Form -> ItemSaveForm -> Controller -> Item 생성 -> Repository

  • 장점: 전송하는 폼 데이터가 복잡해도 거기에 맞춘 별도의 폼 객체를 사용해서 데이터를 전달 받을 수 있다. 보통 등록과, 수정용으로 별도의 폼 객체를 만들기 때문에 검증이 중복되지 않는다.
  • 단점: 폼 데이터를 기반으로 컨트롤러에서 Item 객체를 생성하는 변환 과정이 추가된다.

 

 

ItemSaveForm - ITEM 저장용 폼

@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 - ITEM 수정용 폼

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

 

 

 

 

 


Bean Validation - HTTP 메시지 컨버터

 

 

참고

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

 

ApiController 

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

 

 

 

 

@ModelAttribute vs @RequestBody

HTTP 요청 파리미터를 처리하는 @ModelAttribute 는 각각의 필드 단위로 세밀하게 적용된다. 그래서 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리할 수 있었다.

HttpMessageConverter @ModelAttribute 와 다르게 각각의 필드 단위로 적용되는 것이 아니라, 전체 객체 단위로 적용된다.

따라서 메시지 컨버터의 작동이 성공해서 ItemSaveForm 객체를 만들어야 @Valid , @Validated 가 적용된다.

 

 

  • @ModelAttribute 는 필드 단위로 정교하게 바인딩이 적용된다. 특정 필드가 바인딩 되지 않아도 나머지 필드는 정상 바인딩 되고, Validator를 사용한 검증도 적용할 수 있다.
  • @RequestBody 는 HttpMessageConverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계 자체가 진행되지 않고 예외가 발생한다. 컨트롤러도 호출되지 않고, Validator도 적용할 수 없다.

 

반응형

'Spring > MVC 2편' 카테고리의 다른 글

로그인 처리2 - 필터, 인터셉터  (0) 2023.08.07
로그인 처리1 - 쿠키, 세션  (0) 2023.08.07
검증1 - Validation  (0) 2023.08.06
메시지, 국제화  (0) 2023.08.06
타임리프 - 스프링 통합과 폼  (0) 2023.08.06