스프링 공부 (인프런 김영한 선생님)/스프링 MVC 2편

[스프링 웹 MVC 2편] 007. Bean Validation

ProgYun. 2023. 8. 15. 22:24

검증코드를 이전과 같이 컨트롤러에 모두 넣는 방식
DataBinder를 쓴다고 해도 ItemValidator를 따로 만들어서 검증 로직을 구성하기는 번거롭다

따라서 다음과 같은 방법으로 스프링은 검증을 위한 편의 기능을 제공한다

아래는 강의를 듣고 완성한 코드를 모두 첨부한 것이다.

package hello.itemservice.domain.item;  

import lombok.Data;  
import org.hibernate.validator.constraints.Range; // Hibernate Validator에서만 동작  
import org.hibernate.validator.constraints.ScriptAssert;  

import javax.validation.constraints.Max;  
import javax.validation.constraints.NotBlank; // 표준 제공 (어떤 구현체에서도 동작)  
import javax.validation.constraints.NotNull;  

@Data  
// @ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000", message = "10000원 넘게 입력해주세요") // 이거까지 가는건 좀 오버  
// 검증 기능이 해당 객체를 넘어서는 경우가 많음 억지로 사용하는 것보다 오브젝트 오류 관련 부분만 직접 자바 코드로 작성하는 것을 권장  

// 그냥 복합 룰 검증으로 사용하자!  
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;  
    }  
}

보면 @NotBlank, @NotNull @Range와 같은 Annotation 기반의 검증 방식을 제공하는 것을 알 수 있다.

Bean Validation은 특정한 구현체가 아니라 기술 표준에 해당한다!
-> 기술 표준 : 검증 애노테이션과 여러 인터페이스의 모음

Like JPA - 표준 기술 / Hibernate - 그 구현체!

Bean Validation이라는 표준 기술의 구현체는 Hibernate Validator인데, ORM과는 연관이 없다!


본격적인 Bean Validation 사용 설정

build.gradle에 다음과 같이 내용을 추가하자

implementation 'org.springframework.boot:spring-boot-starter-validation'

테스트 코드 작성

package hello.itemservice.domain.item;  

import lombok.Data;  
import org.hibernate.validator.constraints.Range; // Hibernate Validator에서만 동작  
import org.hibernate.validator.constraints.ScriptAssert;  

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(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) : 값의 범위를 1000에서 1000000 사이만 허용함
@Max(9999) : 최대 9999까지만 허용함

()안에 message = "쓸 메세지" 식으로 넣으면 해당 오류 메세지가 errors에 들어간다.

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.setQuantity(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());  
            // Set에는 ConstratintViolation이라는 검증 오류가 담김.
            // 비어있으면 오류가 없는 것임.
        }  
    }  
}

![[Pasted image 20230815211651.png]]
이미 실습을 거치고 다음단계로 넘어가서 테스트 코드 결과가 나오지 않는다....

여기서 Item 객체는 왜 Bean으로 등록되는지 궁금했는데 (애노테이션이 없음에도...)
rootBeanClassItem 객체에 @Data 어노테이션이 있어도 스프링 빈으로 등록한다는 답변이 있었다

![[Pasted image 20230815211935.png]]


스프링 Validator 직접 적용

package hello.itemservice.web.validation;  

import com.sun.jdi.Field;  
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.util.StringUtils;  
import org.springframework.validation.BindingResult;  
import org.springframework.validation.FieldError;  
import org.springframework.validation.ObjectError;  
import org.springframework.validation.ValidationUtils;  
import org.springframework.validation.annotation.Validated;  
import org.springframework.web.bind.WebDataBinder;  
import org.springframework.web.bind.annotation.*;  
import org.springframework.web.servlet.mvc.support.RedirectAttributes;  

import java.util.HashMap;  
import java.util.List;  
import java.util.Map;  

@Slf4j  
@Controller  
@RequestMapping("/validation/v3/items")  
@RequiredArgsConstructor  
public class ValidationItemControllerV3 {  
    // LocalValidatorFactoryBean - 애노테이션들을 보고 검증 로직을 구성해줌  
    // Global Validator로 등록되어있어 @Validated만 사용해주면 검증이 됨!  

    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")  
    public String addItem(@Validated @ModelAttribute Item item, // Bean Validation이 그냥 적용됨.  
                            BindingResult bindingResult, // ModelAttribute 바로 뒤에 와야 함! (순서 중요)  
                            RedirectAttributes redirectAttributes,  
                            Model model) {  

        if(item.getPrice() != null && item.getQuantity() != null){ // 오브젝트 관련된 건 따로 처리하고 Method Extraction으로 가독성 향상  
            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}";  

    }  


    @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) {  

        if(item.getPrice() != null && item.getQuantity() != null){ // 오브젝트 관련된 건 따로 처리하고 Method Extraction으로 가독성 향상  
            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}";  
    }  

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

        if(item.getPrice() != null && item.getQuantity() != null){ // 오브젝트 관련된 건 따로 처리하고 Method Extraction으로 가독성 향상  
            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}";  
    }  

}

@Validated 애노테이션을 검증 대상인 Item 객체 앞에 붙여주었다.

이 때, @Validbuild.gradle에 아까 추가한 의존관계를 작성해주어야 사용가능하다.

두 개의 차이는 전자는 스프링 전용 검증 애노테이션이고 후자는 자바 표준 검증 애노테이션인데

@Validated의 경우 groups 기능을 지원한다

이 기능은 Item객체에 붙인 검증기 애노테이션은 global하게 모든 @Validated 가 붙은 Item 객체에서 동일한 검증 로직을 적용하는데, 상품 추가 / 수정 등 다른 비즈니스 로직에서 다른 검증 룰을 적용해야하는 경우 인터페이스를 생성하고 조건을 붙임으로써 사용이 가능한데 자세한 이야기는 추후에 나온다!

Bean Validator 검증 순서

  1. @ModelAttribute에 각각의 필드에 타입 변환을 시도함
    1. 성공하면 다음으로
    2. 실패하면 typeMismatchFieldError를 추가한다.
  2. Validator를 적용하여 룰 검증을 시작한다.

!!중요!!

  • Bean Validator의 경우 바인딩이 성공한 필드에만 적용된다
  • 실패하는 경우 BindingResultFieldError가 추가된다.

Bean Validator 에러 코드

예시

@NotBlank

- NotBlank.item.itemName
- NotBlank.itemName
- NotBlank.java.lang.String
- NotBlank

@Range

- Range.item.price
- Range.price
- Range.java.lang.Integer
- Range
errors.properties

NotBlank = {0} 공백 X
Range = {0}, {2} ~ {1} 허용
Max = {0}, 최대 {1}

{0}은 필드명에 해당하고, {1}, {2}는 각 애노테이션마다 상이함.

Bean Validation 메세지 찾는 순서

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

용례

@NotBlank(message = "공백은 입력할 수 없습니다.")
private String itemName;

여기까지는 모두 필드 오류에 해당하는데, 오브젝트 오류는 다루지 않았따

Bean Validation - 오브젝트 오류

package hello.itemservice.domain.item;  

import lombok.Data;  
import org.hibernate.validator.constraints.Range; // Hibernate Validator에서만 동작  
import org.hibernate.validator.constraints.ScriptAssert;  

import javax.validation.constraints.Max;  
import javax.validation.constraints.NotBlank; // 표준 제공 (어떤 구현체에서도 동작)  
import javax.validation.constraints.NotNull;  

@Data  
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000", message = "10000원 넘게 입력해주세요") // 이거까지 가는건 좀 오버  
// 검증 기능이 해당 객체를 넘어서는 경우가 많음 억지로 사용하는 것보다 오브젝트 오류 관련 부분만 직접 자바 코드로 작성하는 것을 권장  

// 그냥 복합 룰 검증으로 사용하자!  
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는 무시합시다, 나중에 더 작성할 예정입니다.

ScriptAssert는 생각보다 제약이 많고 복잡하고 실무에서는 검증기능이 해당 객체 범위를 벗어나는 경우도 많아서 해당 부분만 BindingResult를 사용하여 자바코드로 직접 작성하는것을 권장한다.

@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000", message = "10000원 넘게 입력해주세요")

if(item.getPrice() != null && item.getQuantity() != null){ // 오브젝트 관련된 건 따로 처리하고 Method Extraction으로 가독성 향상  
    int resultPrice = item.getPrice() * item.getQuantity();  
    if(resultPrice < 10000){  
        bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);  
    }

Bean Validation의 한계와 Groups / 별도의 폼 객체 생성

이전에 언급한대로, 데이터 등록시와 수정시 다른 검증룰을 적용하고 싶을 수 있는데
지금 그대로면 문제가 발생한다.

id에 초장부터 NOT NULL을 추가해버리면 id값이 초반에는 없는데 (itemRepository에 의해 생성)
id 값을 요구하기 때문에.... 오류가 나버린다!

Groups와 별도의 폼 객체 생성

  • Bean Validation의 groups 기능을 사용한다.
  • Item을 직접 사용하지 않고, ItemSaveForm, ItemUpdateForm 같은 폼 전송을 위한 별도의 모델 객체를 만들어서 사용한다.

groups 사용방법

package hello.itemservice.domain.item;  

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

public interface UpdateCheck {  
}

두 개의 인터페이스를 만들고

다음과 같이 도메인 코드를 수정한다

package hello.itemservice.domain.item;  

import lombok.Data;  
import org.hibernate.validator.constraints.Range; // Hibernate Validator에서만 동작  
import org.hibernate.validator.constraints.ScriptAssert;  

import javax.validation.constraints.Max;  
import javax.validation.constraints.NotBlank; // 표준 제공 (어떤 구현체에서도 동작)  
import javax.validation.constraints.NotNull;  

@Data  
// @ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000", message = "10000원 넘게 입력해주세요") // 이거까지 가는건 좀 오버  
// 검증 기능이 해당 객체를 넘어서는 경우가 많음 억지로 사용하는 것보다 오브젝트 오류 관련 부분만 직접 자바 코드로 작성하는 것을 권장  

// 그냥 복합 룰 검증으로 사용하자!  
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;  
    }  
}

그리고 컨트롤러 코드를 다음과 같이 수정한다

@PostMapping("/add")  
public String addItemV2(@Validated(SaveCheck.class) @ModelAttribute Item item, // Bean Validation이 그냥 적용됨.  
                        BindingResult bindingResult, // ModelAttribute 바로 뒤에 와야 함! (순서 중요)  
                        RedirectAttributes redirectAttributes,  
                        Model model) {  

    if(item.getPrice() != null && item.getQuantity() != null){ // 오브젝트 관련된 건 따로 처리하고 Method Extraction으로 가독성 향상  
        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}";  

}

먼저 SaveCheck.classUpdateCheck.class 두 인터페이스를 선언하고
엔티티에 다음과 같이 @NotNull(groups = UpdateCheck.class) 식으로 추가한다.
그리고 컨트롤러의 @Validated()안에 UpdateCheck.class를 그대로 써주면 된다.

별도 폼 객체 전송

위와 같이 groups를 사용하면 복잡도가 많이 올라간다, 그 대신 드디어 DTO 활용에 대해서 공부할 기회가 생겨서 기분이 좋다.

평소에 뭐지 싶었던 내용들이었는데 드디어 배우네요.....

등록시 폼에서 전달하는 데이터가 Item 도메인 객체 필드와 일치하지 않아서 별도의 객체를 생성합니다.

HTML FORM -> Item -> Controller -> Item -> Reposistory

장점 : Item 도메인 객체를 컨트롤러, 리포지토리까지 직접 전달해서 별도의 객체를 만드는 과정이 없다
단점 : 간단한 경우에만 사용 가능하고 수정시 검증이 중복될 수 있으며 groups를 사용해야 한다.

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

여기서 Item 생성은 Controller에서 일어난다.

장점 : 전송하는 폼 데이터가 복잡해도 거기에 맞춘 별도의 폼 객체를 활용하여 데이터 전달 가능
등록, 수정용으로 폼별로 객체를 별도로 만들기 때문에 검증 중복 X
단점 : 폼 데이터 기반의 컨트롤러에서 Item 객체를 생성하는 변환 과정이 추가됨(컨트롤러가 헤비해짐)

 package hello.itemservice.domain.item;  

import lombok.Data;  
import org.hibernate.validator.constraints.Range; // Hibernate Validator에서만 동작  
import org.hibernate.validator.constraints.ScriptAssert;  

import javax.validation.constraints.Max;  
import javax.validation.constraints.NotBlank; // 표준 제공 (어떤 구현체에서도 동작)  
import javax.validation.constraints.NotNull;  

@Data  
// @ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000", message = "10000원 넘게 입력해주세요") // 이거까지 가는건 좀 오버  
// 검증 기능이 해당 객체를 넘어서는 경우가 많음 억지로 사용하는 것보다 오브젝트 오류 관련 부분만 직접 자바 코드로 작성하는 것을 권장  

// 그냥 복합 룰 검증으로 사용하자!  
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;  
    }  
}

Item 객체에 붙은 모든 애노테이션을 빼고 순수하게(?) 만들었다.
@Data는 남겨두었다

그리고 각 폼 별로 DTO 객체를 생성했다 그 코드는 아래와 같다

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;  

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

import hello.itemservice.domain.item.SaveCheck;  
import hello.itemservice.domain.item.UpdateCheck;  
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;  


}

각각 저장을 위한 폼 객체, 수정을 위한 폼 객체에 해당하며 Bean Validation 검증 룰이 각각 다르기 때문에 그 차이를 두었다.

    @PostMapping("/add")  
    public String addItemV2(@Validated @ModelAttribute("item") ItemSaveForm form, // Bean Validation이 그냥 적용됨.  
                            BindingResult bindingResult, // ModelAttribute 바로 뒤에 와야 함! (순서 중요)  
                            RedirectAttributes redirectAttributes,  
                            Model model) {  

        if(form.getPrice() != null && form.getQuantity() != null){ // 오브젝트 관련된 건 따로 처리하고 Method Extraction으로 가독성 향상  
            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.setQuantity(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 editV2(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult) {  

        if(form.getPrice() != null && form.getQuantity() != null){ // 오브젝트 관련된 건 따로 처리하고 Method Extraction으로 가독성 향상  
            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 = new Item();  
        item.setItemName(form.getItemName());  
        item.setPrice(form.getPrice());  
        item.setQuantity(form.getQuantity());  

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

}

각각 @ModelAttribute에 사용할 객체 형태가 바뀌었기 때문에 Item 객체로 받아오던 것을 Form객체로 변경해주었는데 이때 주의할 것이 기존에 작성된 thymeleaf에 .addAttribute가 여전히 item이다.

문제는 이걸 ItemSaveFormItemUpdateForm으로 바꾸면 그 key값도 변경된다는 것인데 이때 @ModelAttribute()안에 이름을 따로 기술해줄 수 있기 때문에 그 방식을 사용한다.


Bean Validation - HTTP 메세지 컨버터

package hello.itemservice.web.validation;  

import hello.itemservice.web.validation.form.ItemSaveForm;  
import lombok.extern.java.Log;  
import lombok.extern.slf4j.Slf4j;  
import org.springframework.validation.BindingResult;  
import org.springframework.validation.annotation.Validated;  
import org.springframework.web.bind.annotation.PostMapping;  
import org.springframework.web.bind.annotation.RequestBody;  
import org.springframework.web.bind.annotation.RequestMapping;  
import org.springframework.web.bind.annotation.RestController;  

@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("검증 오류 발생 = {}", bindingResult);  
            return bindingResult.getAllErrors();  
        }  

        log.info("성공 로직 실행");  
        return form;  
    }  
}

@ModelAttribute 는 각각의 필드 단위로 세세하게 적용되어서 특정 필드에 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리가 가능하나, HttpMessageConverter@ModelAttribute와 다르게 필드 단위로 적용되는 것이 아니라 객체 단위로 적용된다.

따라서 메세지 컨버터의 작동이 일단 성공해서 ItemSaveForm 객체가 제대로 생성이 되었다는 전제가 있어야 @Valid, @Validated가 적용될 수 있다.

@RequestBody에서 예외를 처리하는 방법은 예외처리부분에서 추후에 다루게 된다.