minOS

Bean Validation - Bean Validation 애노테이션 적용,스프링 적용,에러 코드 본문

TIL/김영한의 스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술

Bean Validation - Bean Validation 애노테이션 적용,스프링 적용,에러 코드

minOE 2024. 9. 18. 09:07
728x90

Bean Validation

특정 필드에 대한 검증 로직은 대부분 빈 값인 지 아닌지, 특정 크기를 넘는지 아닌지와 같이 매우 일반적인 로직이다.
검증 기능을 지금처럼 매번 코드로 작성하는 것은 상당히 번거롭다.
 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;
     }​

 

이런 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화 한 것이 바로 Bean Validation 이다. Bean Validation을 잘 활용하면, 애노테이션 하나로 검증 로직을 매우 편리하게 적용할 수 있다.


Bean Validation 이란?

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

 

 

Item - Bean Validation 애노테이션 적용

 

package hello.itemservice.domain.item;
 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;
     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까지만 허용한다.




테스트 코드 작성

package hello.itemservice.validation;

import hello.itemservice.domain.item.Item;
import org.junit.jupiter.api.Test;

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>> validate = validator.validate(item);
        for (ConstraintViolation<Item> violation : validate) {
            System.out.println("Violation = " + violation);
            System.out.println("Violation = " + violation.getMessage());

        }

    }
}

 

검증 실행


검증 대상
( `item` )을 직접 검증기에 넣고 그 결과를 받는다. `Set` 에는 `ConstraintViolation` 이라는 검증 오류가

담긴다. 따라서 결과가 비어있으면 검증 오류가 없는 것이다. 오류가 있으면 담겨서 나온다.

Set<ConstraintViolation<Item>> violations = validator.validate(item);

 

출력 결과

 

`ConstraintViolation` 출력 결과를 보면, 검증 오류가 발생한 객체, 필드, 메시지 정보등 다양한 정보를 확인할 수 있다.

 

 

 

Bean Validation - 스프링 적용

package hello.itemservice.web.validation;

import hello.itemservice.domain.item.Item;
import hello.itemservice.domain.item.ItemRepository;
import hello.itemservice.domain.item.SaveCheck;
import hello.itemservice.domain.item.UpdateCheck;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import java.util.List;

@Slf4j
@Controller
@RequestMapping("/validation/v3/items")
@RequiredArgsConstructor
public class ValidationItemControllerV3 {

    private final ItemRepository itemRepository;


    @GetMapping
    public String items(Model model) {
        List<Item> items = itemRepository.findAll();
        model.addAttribute("items", items);
        return "/validation/v3/items";
    }

    @GetMapping("/{itemId}")
    public String item(@PathVariable long itemId, Model model) {
        Item item = itemRepository.findById(itemId);
        model.addAttribute("item", item);
        return "/validation/v3/item";
    }

    @GetMapping("/add")
    public String addForm(Model model) {
        model.addAttribute("item", new Item());
        return "/validation/v3/addForm";
    }


    //@PostMapping("/add") //`BindingResult bindingResult` 파라미터의 위치는 `@ModelAttribute Item item` 다음에 와야 한다.
    public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult , RedirectAttributes redirectAttributes, Model model) {


        //특정 필드가 아난 복합 룰 검증
        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("error ={}", bindingResult);
            //model.addAttribute("errors",errors); BindResult는 자동으로 view로 넘어감
            return "/validation/v3/addForm";
        }

        // 성공 로직

        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v3/items/{itemId}";
    }



   

    @GetMapping("/{itemId}/edit")
    public String editForm(@PathVariable Long itemId, Model model) {
        Item item = itemRepository.findById(itemId);
        model.addAttribute("item", item);
        return "/validation/v3/editForm";
    }

    //@PostMapping("/{itemId}/edit")
    public String edit(@PathVariable Long itemId, @Validated @ModelAttribute Item item, BindingResult bindingResult) {
        itemRepository.update(itemId, item);

        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("error ={}", bindingResult);
            return "/validation/v3/editForm";
        }

        return "redirect:/validation/v3/items/{itemId}";
    }

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

        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("error ={}", bindingResult);
            return "/validation/v3/editForm";
        }

        return "redirect:/validation/v3/items/{itemId}";
    }


}



스프링 부트는 자동으로 글로벌 Validator로 등록한다.

`LocalValidatorFactoryBean` 을 글로벌 Validator로 등록한다. Validator`@NotNull` 같은 애노테이션을

보고 검증을 수행한다. 이렇게 글로벌 Validator가 적용되어 있기 때문에, `@Valid` , `@Validated` 만 적용하면 된다. 검증 오류가 발생하면, `FieldError` , `ObjectError` 를 생성해서 `BindingResult` 에 담아준다.


참고

검증시 `@Validated`
`
javax.validation.@Valid` 를 사용하려면 `build.gradle` 의존관계 추가가 필요하다.
`implementation 'org.springframework.boot:spring-boot-starter-validation'`
@Validated` 는 스프링 전용 검증 애노테이션이고, `@Valid` 는 자바 표준 검증 애노테이션이다. 둘중 아무거나 사용해도 동일하게 작동하지만, `@Validated 는 내부에 `groups` 라는 기능을 포함하고 있다.


검증 순서

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

         1-1)성공하면 다음으로

         1-2)실패하면 `typeMismatch` `FieldError` 추가
2.  Validator 적용



바인딩에 성공한 필드만 Bean Validation 적용
BeanValidator는 바인딩에 실패한 필드는 BeanValidation을 적용하지 않는다.
생각해보면 타입 변환에 성공해서 바인딩에 성공한 필드여야 BeanValidation 적용이 의미 있다. (일단 모델 객체에 바인딩 받는 값이 정상으로 들어와야 검증도 의미가 있다.)

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


ex)

`itemName` 에 문자 "A" 입력 -> 타입 변환 성공 -> `itemName` 필드에 BeanValidation 적용

`price에 문자 "A" 입력 ->"A"를 숫자 타입 변환 시도 실패 ->typeMismatch FieldError 추가 ->`price` 필드는 BeanValidation 적용 X




1. `itemName`에 문자 "A" 입력:
   - 사용자가 `itemName` 필드에 "A"라는 문자를 입력한다.
   - `itemName`은 보통 문자열(String) 타입이므로, "A"는 문제없이 받아들여진다.

2. `itemName` 타입 변환 성공:
   - 입력된 "A"는 이미 문자열이므로 별도의 타입 변환이 필요 없다.
   - 시스템은 이를 성공적으로 처리한다.

3. `itemName` 필드에 BeanValidation 적용:
   - 타입 변환이 성공했으므로, 다음 단계인 Bean Validation이 적용된다.
   - 예를 들어, @NotBlank, @Size 등의 검증 어노테이션이 있다면 이때 검증이 이루어진다.

4. `price`에 문자 "A" 입력:
   - 사용자가 `price` 필드에 "A"라는 문자를 입력한다.
   - `price`는 보통 숫자 타입(예: int, Integer, BigDecimal 등)으로 정의된다.

5. "A"를 숫자 타입으로 변환 시도 실패:
   - 시스템은 "A"를 숫자로 변환하려고 시도한다.
   - 하지만 "A"는 숫자가 아니므로 이 변환 과정은 실패한다.

6. typeMismatch FieldError 추가:
   - 타입 변환 실패로 인해 시스템은 'typeMismatch'라는 FieldError를 생성한다.
   - 이 오류는 입력값이 예상된 타입과 일치하지 않음을 나타낸다.

7. `price` 필드는 BeanValidation 적용 X:
   - 타입 변환에 실패했기 때문에, `price` 필드에 대한 Bean Validation은 수행되지 않는다.
   - Bean Validation은 타입 변환이 성공한 후에 적용되는 단계이기 때문이다.

이 예시가 의미하는 바는 다음과 같다:

1. 데이터 바인딩 과정에서 타입 변환은 Bean Validation 이전에 이루어진다.
2. 타입 변환에 성공한 필드만 Bean Validation이 적용된다.
3. 타입 변환에 실패한 필드는 더 이상의 검증 과정을 거치지 않고 오류로 처리된다.
4. 따라서 개발자는 타입 변환 오류와 Bean Validation 오류를 구분하여 처리해야 한다.

이러한 과정을 이해하면 폼 검증 로직을 더 효과적으로 구현하고 사용자에게 적절한 오류 메시지를 제공할 수 있다.

 

 

Bean Validation - 에러 코드


Bean Validation이 기본으로 제공하는 오류 메시지를 좀 더 자세히 변경하고 싶으면 어떻게 하면 될까?
Bean Validation을 적용하고 `bindingResult` 에 등록된 검증 오류 코드를 보자.
오류 코드가 애노테이션 이름으로 등록된다. 마치 `typeMismatch` 와 유사하다. `NotBlank` 라는 오류 코드를 기반으로 `MessageCodesResolver` 를 통해 다양한 메시지 코드가 순서대로 생성된다.

예시

@NotBlank
NotBlank.item.itemName
NotBlank.itemName
NotBlank.java.lang.String
NotBlank


@Range 
Range.item.price

Range.price
Range.java.lang.Integer
Range



메시지 등록

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

 


728x90