[스프링 MVC 2편 - 백엔드 웹 개발 활용 기술] 05. 검증2 - Validation

Turtle·2024년 7월 15일
0
post-thumbnail

🙄Bean Validation

Bean Validation을 사용하려면 build.Gradle에 의존관계를 추가해야 한다.

implementation 'org.springframework.boot:spring-boot-starter-validation'
@Data
public class Item {

    @NotNull
    private Long id;

    @NotBlank
    private String itemName;

    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;

    @NotNull
    @Max(value = 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까지만 허용한다.

Bean Validation 테스트 코드 실행

public class BeanValidationTest {

    @Test
    @DisplayName("Bean Validation 적용 테스트")
    void beanValidation() {
        ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory();
        Validator validator = validatorFactory.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.getMessage() = " + violation.getMessage());
        }
    }
}

실행 결과

violation = ConstraintViolationImpl{interpolatedMessage='9999 이하여야 합니다', propertyPath=quantity, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{javax.validation.constraints.Max.message}'}
violation.getMessage() = 9999 이하여야 합니다
violation = ConstraintViolationImpl{interpolatedMessage='공백일 수 없습니다', propertyPath=itemName, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{javax.validation.constraints.NotBlank.message}'}
violation.getMessage() = 공백일 수 없습니다
violation = ConstraintViolationImpl{interpolatedMessage='1000에서 1000000 사이여야 합니다', propertyPath=price, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{org.hibernate.validator.constraints.Range.message}'}
violation.getMessage() = 1000에서 1000000 사이여야 합니다
violation = ConstraintViolationImpl{interpolatedMessage='널이어서는 안됩니다', propertyPath=id, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{javax.validation.constraints.NotNull.message}'}
violation.getMessage() = 널이어서는 안됩니다

🙄Bean Validation - 스프링 적용

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

	// ...

    @PostMapping("/add")
    public String addItem(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
        
        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}";
    }
	
    // ...
}
  • ✔️스프링 MVC에서 Bean Validation을 어떻게 사용하는가
    • 스프링 부트에 spring-boot-starter-validation 라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링 통합을 진행한다.
    • 스프링 부트는 자동으로 글로벌 Validator로 등록한다.
    • LocalValidatorFactoryBean을 글로벌 Validator로 등록한다.
    • 이 Validator는 @NotNull과 같은 어노테이션을 보고 검증을 수행한다.
    • 엔티티에 어노테이션을 사용하면 글로벌 Validator가 적용되어 있기 때문에 @Valid@Validated만 적용하면 된다.
    • 검증 오류가 발생하면 FieldError, ObjectError를 생성해서 BindingResult아 담아준다.
    • @SpringBootApplication 어노테이션이 붙은 곳에서 전역적으로 Validator를 직접 등록하면 스프링 부트는 Bean Validator를 글로벌 Validator로 등록하지 않는다.
  • ✔️검증 순서 정리
    • @ModelAttribute : 각각의 필드에 맞게 타입 변환 시도
      • 성공하면 다음으로 진행
      • 실패하면 typeMismatchFieldError를 추가
    • Validator 적용
      • 바인딩에 성공한 필드들만 Bean Validation 적용
@Target({ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ModelAttribute {
    @AliasFor("name")
    String value() default "";

    @AliasFor("value")
    String name() default "";

    boolean binding() default true;
}

❗참고 : @Valid, @Validated 둘 다 사용가능하다.
javax.validation.@Valid를 사용하려면 build.Gradle에 의존관계를 추가해야 한다.
@Valid는 자바 표준 검증 어노테이션이고 @Validated는 스프링 전용 검증 어노테이션이다.

🙄Bean Validation - 에러 코드

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

✔️Bean Validation 메시지 찾는 순서
1. 생성된 메시지 코드 순서대로 messageSource에서 메시지 찾기
2. 어노테이션의 message 속성을 사용
3. 라이브러리가 제공하는 기본 값을 사용

🙄Bean Validation - 오브젝트 오류

위의 에러는 FieldError에 해당하고 이제 ObjectError에 대해서는 @ScriptAssert()를 사용하면 된다.

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

    @NotNull
    private Long id;

    @NotBlank
    private String itemName;

    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;

    @NotNull
    @Max(value = 9999)
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}
@Slf4j
@Controller
@RequestMapping("/validation/v3/items")
@RequiredArgsConstructor
public class ValidationItemControllerV3 {

	// ...

    @PostMapping("/add")
    public String addItemV2(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

        // FieldError(X), ObjectError(O)
        // void reject(String var1, @Nullable Object[] var2, @Nullable String var3);
        // 특정 필드 예외가 아닌 전체 예외
        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}";
    }
	// ...

}
  • ✔️권장사항
    • 오브젝트 오류(글로벌 오류)의 경우 @ScriptAssert보다는 오브젝트 오류 부분만 자바 코드로 직접 사용하는 것을 권장한다.

에러 : Resolve: org.hibernate.validator.spi.scripting.ScriptEvaluatorNotFoundException: HV000232: No JSR 223 script engine found for language "javascript"

Nashorn(내장 JDK 자바스크립트 엔진)은 JDK 11에서 더 이상 사용되지 않으며 JDK 15에서 제거되었다. build.Gradle에 의존관계를 추가해서 사용하려면 아래 코드를 build.Gradle에 넣고 빌드하면 임시방편으로나마 해결이 되는 것을 확인할 수 있었다.

implementation 'org.openjdk.nashorn:nashorn-core:15.4'

🙄Bean Validation - 한계

예를 들어, id값의 어노테이션으로 @NotNull로 지정하면 해당 필드는 Null값이 들어올 수 없게 된다. 주문을 생성하는 시점에서는 id값이 필요가 없으나 주문을 수정하는 시점에서는 id값을 반드시 알아야 하는데 이렇게 되면 주문 수정은 되겠지만 주문을 생성할 수 없는 문제가 발생한다. 동일한 모델 객체를 등록할 때와 수정할 때 각각 다른 검증 방식을 적용하려면 groups를 사용하면 된다.

🙄Bean Validation - groups

@Data
public class Item {

    @NotNull(groups = UpdateCheck.class)
    private Long id;

    @NotBlank(groups = {UpdateCheck.class, SaveCheck.class})
    private String itemName;

    @NotNull(groups = {UpdateCheck.class, SaveCheck.class})
    @Range(min = 1000, max = 1000000, groups = {UpdateCheck.class, SaveCheck.class})
    private Integer price;

    @NotNull(groups = {UpdateCheck.class, SaveCheck.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;
    }
}
@Slf4j
@Controller
@RequestMapping("/validation/v3/items")
@RequiredArgsConstructor
public class ValidationItemControllerV3 {

	// ❓수정인데 왜 @PutMapping 안 쓰고 @PostMapping 쓰나?
    // HTTP 강의에서도 공부했다시피 form의 경우 GET, POST만 지원하기에 수정이나 삭제 시에도 @PostMapping을 사용한다.
    @PostMapping("/{itemId}/edit")
    public String editV2(@PathVariable Long itemId, @Validated(UpdateCheck.class) @ModelAttribute Item item, BindingResult bindingResult) {

        //특정 필드가 아닌 복합 룰 검증
        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/editForm";
        }

        itemRepository.update(itemId, item);
        return "redirect:/validation/v3/items/{itemId}";
    }

}
  • ✔️groups()
    • groups() 기능을 사용해서 등록과 수정 시 각각 다르게 검증을 할 수 있었다.
    • @Valid에는 groups()를 적용할 수 있는 기능이 없다. 따라서 groups()를 사용하려면 @Validated를 사용하고 각각의 검증별 인터페이스를 만들어 사용해야 한다.
    • groups() 기능은 거의 잘 사용되지 않는다. 주로 폼 객체와 수정용 객체를 분리해서 사용하는 것이 실무 트렌드이기 때문이다.

🙄Form 전송 객체 분리

@Data
public class Item {

    private Long id;
    private String itemName;
    private Integer price;
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

엔티티에 직접적으로 Validation을 사용하지 않고 DTO를 사용하게끔

@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;
}
@Slf4j
@Controller
@RequestMapping("/validation/v4/items")
@RequiredArgsConstructor
public class ValidationItemControllerV4 {

    private final ItemRepository itemRepository;

	// DTO를 넣도록
    @PostMapping("/add")
    public String addItem(@Validated @ModelAttribute(name = "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}";
    }

	// DTO를 넣도록
    @PostMapping("/{itemId}/edit")
    public String edit(@PathVariable Long itemId, @Validated @ModelAttribute(name = "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 요청 파라미터를 다룰 때 사용한다. @RequestBody, @ResponseBody는 HTTP 요청(주로 JSON)을 자바 객체로, 자바 객체를 HTTP 요청(주로 JSON)으로 변환할 때 사용한다.

@Slf4j
@RestController // @ResponseBody 포함 → 결국 자바 객체를 JSON으로 변환해줌
@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;
    }
}

✔️잘못된 데이터 입력 예시

{
    "itemName" : " ",
    "price" : "QQQ",	// 가격이라는 숫자 타입에 문자 타입을 넣은 경우
    "quantity" : 500
}

실행 결과

2024-07-15 23:17:24.809  WARN 2168 --- [nio-8080-exec-7] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type `java.lang.Integer` from String "QQQ": not a valid Integer value; nested exception is com.fasterxml.jackson.databind.exc.InvalidFormatException: Cannot deserialize value of type `java.lang.Integer` from String "QQQ": not a valid Integer value
 at [Source: (PushbackInputStream); line: 3, column: 15] (through reference chain: hello.itemservice.web.validation.form.ItemSaveForm["price"])]

❗컨트롤러가 호출되지 않았다. → 검증 로직의 순서를 다시 떠올려보자. 현재 컨트롤러에서는 엔티티를 직접적으로 외부에 노출시키지 않고 전달 데이터 객체인 DTO를 사용했다. DTO의 각각의 필드에 맞게 타입 변환이 시도된 다음 바인딩이 성공적으로 수행되었다면 컨트롤러가 호출이 되는데 현재 바인딩이 실패했기에 컨트롤러가 호출이 되지 않는 것이다.

@ModelAttribute vs @RequestBody 정리

HTTP 요청 파라미터를 처리하는 @ModelAttribute는 각각의 필드 단위로 세밀하게 적용되어 바인딩을 수행한다. 그래서 특정 필드에 맞지 않는 오류가 발생하더라도 나머지 필드는 정상적으로 처리가 가능하다. HTTP 메시지 컨버터는 @ModelAttribute와 다르게 @RequestBody를 사용하여 각각의 필드 단위가 아니라 전체 객체 단위로 적용된다. 따라서 HTTP 메시지 컨버터 작동이 정상적으로 작동하여 성공하여 객체를 만들어야 검증이 수행된다.

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

0개의 댓글