Super Kawaii Cute Cat Kaoani [스프링 MVC 2편] 5. 검증2 - Bean Validation

[스프링 MVC 2편] 5. 검증2 - Bean Validation

2024. 1. 25. 16:25
728x90
SMALL

📌 Bean Validation

⚡ 정의

  • 검증 기능을 매번 코드로 작성하면 번거로움
  • Bean Validation : 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화한 것
    • 특정한 구현체가 아니라 Bean Validation 2.0(JSR-380)이라는 기술 표준
      • 검증 애노테이션과 여러 인터페이스의 모음
      • 하이버네이트 Validator(ORM과는 관련X)

하이버네이트 Validator 관련 링크
공식 사이트
공식 메뉴얼
검증 애노테이션 모음

 

⚡ 사용 준비

✔ Bean Validation 의존관계 추가

build.gradle

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

 

✔ Jakarta Bean Validation

jakarta.validation-api : Bean Validation 인터페이스
hibernate-validator 구현체

 

✔ 테스트코드 작성

Item - Bean Validation 애노테이션 적용

package hello.itemservice.domain.item;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.hibernate.validator.constraints.Range;

@Data
public class Item {

    private Long id;

    @NotBlank
    private String itemName;

    @NotBlank
    @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까지만 허용

cf)

javax.validation.constraints.NotNull
org.hibernate.validator.constraints.Range

 javax.validation 으로 시작하면 특정 구현에 관계없이 제공되는 표준 인터페이스이고,
 org.hibernate.validator 로 시작하면 하이버네이트 validator 구현체를 사용할 때만 제공되는 검증 기능
➡ 실무에서 대부분 하이버네이트 validator를 사용하므로 자유롭게 사용 가능

 

BeanValidationTest - Bean Validation 테스트 코드 작성

package hello.itemservice.validation;

import hello.itemservice.domain.item.Item;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validation;
import jakarta.validation.Validator;
import jakarta.validation.ValidatorFactory;
import org.junit.jupiter.api.Test;

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

        }
    }
}

✔ 검증기 생성

 ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
 Validator validator = factory.getValidator();

 

 

✔ 검증 실행

  • 검증 대상( item )을 직접 검증기에 넣고 그 결과를 받음
  • Set 에는 ConstraintViolation 이라는 검증 오류가 담김
  • 결과가 비어있으면 검증 오류가 없는 것
Set<ConstraintViolation<Item>> violations = validator.validate(item); 

 

 

✔ 실행 결과 (일부 생략)

violation={interpolatedMessage='공백일 수 없습니다', propertyPath=itemName, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{javax.validation.constraints.NotBlank.message}'} violation.message=공백일 수 없습니다

violation={interpolatedMessage='9999 이하여야 합니다', propertyPath=quantity, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{javax.validation.constraints.Max.message}'} violation.message=9999 이하여야 합니다

violation={interpolatedMessage='1000에서 1000000 사이여야 합니다', propertyPath=price, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{org.hibernate.validator.constraints.Range.message}'}
violation.message=1000에서 1000000 사이여야 합니다

 

 

⚡ 스프링 적용

ValidationItemControllerV3 코드 수정

package hello.itemservice.web.validation;

import hello.itemservice.domain.item.Item;
import hello.itemservice.domain.item.ItemRepository;
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.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.List;

@Controller
@RequestMapping("/validation/v3/items")
@RequiredArgsConstructor
@Slf4j
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")
    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}";
    }

    @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, @ModelAttribute Item item) {
        itemRepository.update(itemId, item);
        return "redirect:/validation/v3/items/{itemId}";
    }

}

스프링 MVC는 어떻게 Bean Validator를 사용?

  • 스프링 부트가 spring-boot-starter-validation 라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합

 

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

  • LocalValidatorFactoryBean 을 글로벌 Validator로 등록
  • 이 Validator는 @NotNull 같은 애노테이션을 보고 검증 수행
  • 검증 오류가 발생하면, FieldError , ObjectError 를 생성해서 BindingResult 에 담아준다.

 

⚡ 검증 순서

  1. @ModelAttribute 각각의 필드에 타입 변환 시도
    ➡ 성공하면 다음으로
    ➡ 실패하면 typeMismatchFieldError 추가
  2. Validator 적용

 

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

  • BeanValidator는 바인딩에 실패한 필드는 BeanValidation을 적용하지 않음
  • @ModelAttribute 각각의 필드 타입 변환 시도
    • 변환에 성공한 필드만 BeanValidation 적용

예시)

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

 

⚡ 에러 코드

➡ Bean Validation이 기본으로 제공하는 오류 메시지를 좀 더 자세히 변경

➡ 오류 코드가 애노테이션 이름으로 등록됨
typeMismatch 와 유사

 

✔ @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. 라이브러리가 제공하는 기본 값 사용 공백일 수 없습니다.

 

✔ 애노테이션의 message 사용 예

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

 

⚡ 오브젝트 오류

  • Bean Validation에서 특정 필드( FieldError )가 아닌 해당 오브젝트 관련 오류( ObjectError ) 해결 방법
    • @ScriptAssert() 사용
@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000")
public class Item {
//...
}

 

✔ 메시지 코드

  • ScriptAssert.item
  • ScriptAssert

➡ 제약이 많고 복잡
➡ 오브젝트 오류(글로벌 오류)의 경우 @ScriptAssert 을 억지로 사용하는 것 보다는 다음과 같이 오브젝트 오류 관련 부분만 직접 자바 코드로 작성하는 것을 권장

 

ValidationItemControllerV3 - 글로벌 오류 추가

@PostMapping("/add")
public String addItem(@Validated @ModelAttribute 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()){
		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 부분 제거하고 수정

 

 

⚡ 상품 수정에 적용

ValidationItemControllerV3 - edit() 변경

@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @Validated @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}";
}

 

 

🔶 validation/v3/editForm/html 변경

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <link th:href="@{/css/bootstrap.min.css}"
          href="../css/bootstrap.min.css" rel="stylesheet">
    <style>
        .container {
            max-width: 560px;
        }
        .field-error {
            border-color: #dc3545;
            color: #dc3545;
        }
    </style>
</head>
<body>

<div class="container">

    <div class="py-5 text-center">
        <h2 th:text="#{page.updateItem}">상품 수정</h2>
    </div>

    <form action="item.html" th:action th:object="${item}" method="post">

        <div th:if="${#fields.hasGlobalErrors()}">
            <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">글로벌 오류 메시지</p>
        </div>

        <div>
            <label for="id" th:text="#{label.item.id}">상품 ID</label>
            <input type="text" id="id" th:field="*{id}" class="form-control" readonly>
        </div>
        <div>
            <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
            <input type="text" id="itemName" th:field="*{itemName}" class="form-control" placeholder="이름을 입력하세요">
            <div class="field-error" th:errors="*{itemName}">
                상품명 오류
            </div>
        </div>
        <div>
            <label for="price" th:text="#{label.item.price}">가격</label> 
            <input type="text" id="price" th:field="*{price}" th:errorclass="field-error" class="form-control" placeholder="가격을 입력하세요">
            <div class="field-error" th:errors="*{price}">
                가격 오류
            </div>
        </div>
        <div>
            <label for="quantity" th:text="#{label.item.quantity}">수량</label>
            <input type="text" id="quantity" th:field="*{quantity}" th:errorclass="field-error" class="form-control" placeholder="수량을 입력하세요">
            <div class="field-error" th:errors="*{quantity}"> 
                수량 오류
            </div>
        </div>

        <hr class="my-4">

        <div class="row">
            <div class="col">
                <button class="w-100 btn btn-primary btn-lg" type="submit" th:text="#{button.save}">저장</button>
            </div>
            <div class="col">
                <button class="w-100 btn btn-secondary btn-lg"
                        onclick="location.href='item.html'"
                        th:onclick="|location.href='@{/validation/v3/items/{itemId}(itemId=${item.id})}'|"
                        type="button" th:text="#{button.cancel}">취소</button>
            </div>
        </div>
    </form>
</div> <!-- /container -->
</body>
</html>
  • .field-error css 추가
  • 글로벌 오류 메시지
  • 상품명, 가격, 수량 필드에 검증 기능 추가

 

 

⚡ 한계

🖇 수정시 검증 요구사항

✔ 등록시 기존 요구사항

  • 타입 검증
    • 가격, 수량에 문자가 들어가면 검증 오류 처리
  • 필드 검증
    • 상품명: 필수, 공백X
    • 가격: 1000원 이상, 1백만원 이하
    • 수량: 최대 9999
  • 특정 필드의 범위를 넘어서는 검증
    • 가격 * 수량의 합은 10,000원 이상

 

✔ 수정시 요구사항

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

수정 요구사항 적용

package hello.itemservice.domain.item;

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

cf)

  • 현재 구조에서는 수정시 item  id 값은 항상 들어있도록 로직이 구성되어 있음
  • 검증하지 않아도 된다고 생각할 수 있지만, HTTP 요청은 언제든지 악의적으로 변경해서 요청할 수 있으므로 서버에서 항상 검증 해야 함

 

수정은 잘 동작하지만 등록에서 문제 발생 ❗

  • 등록시에는 id 에 값도 없고, quantity 수량 제한 최대 값인 9999도 적용되지 않는 문제가 발생

 

등록시 화면이 넘어가지 않으면서 다음과 같은 오류 발생 ❗

'id': rejected value [null];
  • 등록시에는 id 에 값이 없음
  • 따라서 @NotNull id 를 적용한 것 때문에 검증에 실패하고 다시 폼 화면으 로 넘어옴
  • 등록 자체도 불가능하고, 수량 제한도 걸지 못함

 

⚡ groups

2가지 방법

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

✔ 저장용 groups 생성

package hello.itemservice.domain.item;

public interface SaveCheck {
}

 

✔ 수정용 groups 생성

package hello.itemservice.domain.item;

public interface UpdateCheck {
}

 

Item - groups 적용

package hello.itemservice.domain.item;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.hibernate.validator.constraints.Range;
import org.hibernate.validator.constraints.ScriptAssert;

@Data
//@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 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;
    }
}

 

ValidationItemControllerV3 - 저장 로직에 SaveCheck Groups 적용

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

 

ValidationItemControllerV3 - 수정 로직에 UpdateCheck Groups 적용

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

 

cf) @Valid에는 groups를 적용할 수 있는 기능이 없기 때문에 groups를 사용하려면 @Validated 를 사용

 

 

📌 Form 전송 객체 분리

⚡ 소개

  • 실무에서는 groups 를 잘 사용하지 않음
  • 등록시 폼에서 전달하는 데이터가 Item 도메인 객체와 딱 맞지 않기 때문
  • 실무에서는 회원 등록 시 회원과 관련된 데이터만 전달 받는 것이 아니라, 약관 정보도 추가로 받는 등 Item 과 관계 없는 수 많은 부가 데이터가 넘어옴
  • Item 을 직접 전달받는 것이 아니라, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달
  • ex) ItemSaveForm 이라는 폼을 전달받는 전용 객체를 만들어서 @ModelAttribute 로 사용
    • 컨트롤러에서 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해서 Item 을 생성

 

✔ 폼 데이터 전달에 Item 도메인 객체 사용

  • HTML Form -> Item -> Controller -> Item -> Repository

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

 

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

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

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

 

등록과 수정은 완전히 다른 데이터가 넘어옴
ex) 회원 등록시에는 로그인id, 주민번호 등등을 받을 수 있지만, 수정시에는 이런 부분이 빠지고 검증 로직도 많이 달라짐
➡ 폼 데이터 전달을 위한 별도의 객체를 사용하고, 등록, 수정용 폼 객체를 나누면 등록, 수정이 완전히 분리되기 때문에 groups 를 적용할 일은 드뭄

 

✔ 이름은 의미있게

ItemSave 라고 해도 되고, ItemSaveForm , ItemSaveRequestItemSaveDto 등으로 사용해도 됨

▪ 중요한 것은 일관성!

 

✔ 등록, 수정용 뷰 템플릿이 비슷하다면?

  • 합칠지 말지는 각각 장단점이 있으므로 고민하는게 좋지만, 어설프게 합치면 수 많은 분기문(등록일 때, 수정일 때) 때문에 나중에 유지보수가 힘듦
  • 어설픈 분기문들이 보이기 시작하면 분리해야 할 신호

 

⚡ 개발

ITEM 원복

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

Item 의 검증은 사용하지 않으므로 검증 코드 제거

 

ItemSaveForm - ITEM 저장용 폼

package hello.itemservice.web.validation.form;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.hibernate.validator.constraints.Range;

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

package hello.itemservice.web.validation.form;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.hibernate.validator.constraints.Range;

@Data
public class ItemUpdateForm {

    @NotNull
    private Long id;

    @NotBlank
    private String itemName;

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

    //수정에서는 수량은 자유롭게 변경 가능
    private Integer quantity;

}

 

ValidationItemControllerV4

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 hello.itemservice.web.validation.form.ItemSaveForm;
import hello.itemservice.web.validation.form.ItemUpdateForm;
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;

@Controller
@RequestMapping("/validation/v4/items")
@RequiredArgsConstructor
@Slf4j
public class ValidationItemControllerV4 {

    private final ItemRepository itemRepository;

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

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

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

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

    }

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

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

    }

}

 

✔ 폼 객체 바인딩

 @PostMapping("/add")
 public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
    //...
}

Item 대신에 ItemSaveform 을 전달 받음
@Validated 로 검증도 수행하고, BindingResult 로 검증 결과도 받음

 

주의❗

  • @ModelAttribute("item")  item 이름을 넣어준 부분을 주의
  • 이것을 넣지 않으면 ItemSaveForm 의 경우 규칙에 의해 itemSaveForm 이라는 이름으로 MVC Model에 담기게 됨
  • 뷰 템플릿에서 접근하는 th:object 이름도 함께 변경해주어야 함

 

 

폼 객체를 Item으로 변환

//성공 로직
Item item = new Item(); 
item.setItemName(form.getItemName()); 
item.setPrice(form.getPrice()); 
item.setQuantity(form.getQuantity());

Item savedItem = itemRepository.save(item);
  • 폼 객체의 데이터를 기반으로 Item 객체를 생성

 

수정

@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult) {
    //...
}
  • 폼 객체의 데이터를 기반으로 Item 객체를 생성

 

반응형

 

📌 Bean Validation - HTTP 메시지 컨버터

@Valid , @ValidatedHttpMessageConverter ( @RequestBody )에도 적용 가능

 

cf)

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

 

 

ValidationItemApiController 생성

package hello.itemservice.web.validation;

import hello.itemservice.web.validation.form.ItemSaveForm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.BindException;
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("검증 오류 발생 errors={}", bindingResult);
            return bindingResult.getAllErrors();
        }
        log.info("성공 로직 실행");
        return  form;
    }
}

Postman으로 사용해서 테스트

✔ 성공 요청

 POST http://localhost:8080/validation/api/items/add
 {"itemName":"hello", "price":1000, "quantity": 10}

➡ Postman에서 Body  raw ➡ JSON을 선택해야 함

 

✔ API의 경우 3가지 경우를 나누어 생각해야 함

  • 성공 요청 : 성공
  • 실패 요청 : JSON을 객체로 생성하는 것 자체가 실패함
  • 검증 오류 요청 : JSON을 객체로 생성하는 것은 성공했고, 검증에서 실패함

 

✔ 성공 요청 로그

API 컨트롤러 호출
성공 로직 실행

 

✔ 실패 요청

 POST http://localhost:8080/validation/api/items/add
 {"itemName":"hello", "price":"A", "quantity": 10}

 

✔ 실패 요청 결과

{
    "timestamp": "2021-04-20T00:00:00.000+00:00",
    "status": 400,
    "error": "Bad Request",
    "message": "",
    "path": "/validation/api/items/add"
}

 

✔ 실패 요청 로그

.w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Cannot deserialize value of type `java.lang.Integer` from String "A": not a valid Integer value; nested exception is com.fasterxml.jackson.databind.exc.InvalidFormatException: Cannot deserialize value of type `java.lang.Integer` from String "A": not a valid Integer value at [Source: (PushbackInputStream); line: 1, column: 30] (through reference chain: hello.itemservice.domain.item.Item["price"])]
  • HttpMessageConverter 에서 요청 JSONItemSaveForm 객체로 생성하는데 실패함
  • 이 경우는 ItemSaveForm 객체를 만들지 못하기 때문에 컨트롤러 자체가 호출되지 않고 그 전에 예외 발생
  • Validator도 실행되지 않음

 

✔ 검증 오류 요청

POST http://localhost:8080/validation/api/items/add
{"itemName":"hello", "price":1000, "quantity": 10000}
  • HttpMessageConverter 는 성공하지만 검증(Validator)에서 오류가 발생하는 경우
  • 수량( quantity )이 10000 이면 BeanValidation @Max(9999) 에서 걸림

 

✔ 검증 오류 결과

[
    {
        "codes": [
               "Max.itemSaveForm.quantity",
            "Max.quantity",
            "Max.java.lang.Integer",
            "Max"
          ],
          "arguments": [
        {
            "codes": [
                "itemSaveForm.quantity",
                "quantity"
            ],
            "arguments": null,
            "defaultMessage": "quantity",
            "code": "quantity"
        },
        9999
    ],
    "defaultMessage": "9999 이하여야 합니다", 
    "objectName": "itemSaveForm", 
    "field": "quantity",
    "rejectedValue": 10000, 
    "bindingFailure": false,
    "code": "Max"
    }
}
  • return bindingResult.getAllErrors();ObjectErrorFieldError 를 반환
  • 스프링이 이 객체를 JSON으로 변환해서 클라이언트에 전달(여기서는 예시로 보여주기 위해서 검증 오류 객체들을 그대로 반환)
  • 실제 개발할 때는 이 객체들을 그대로 사용하지 말고, 필요한 데이터만 뽑아서 별도의 API 스펙을 정의하고 그에 맞는 객체를 만들어서 반환해야 함

 

✔ 검증 오류 요청 로그

API 컨트롤러 호출
검증 오류 발생, errors=org.springframework.validation.BeanPropertyBindingResult: 1 errors
Field error in object 'itemSaveForm' on field 'quantity': rejected value
[99999]; codes
[Max.itemSaveForm.quantity,Max.quantity,Max.java.lang.Integer,Max]; arguments
[org.springframework.context.support.DefaultMessageSourceResolvable: codes
[itemSaveForm.quantity,quantity]; arguments []; default message
[quantity],9999]; default message [9999 이하여야 합니다]

검증 오류 정상 수행

 

 

⚡ @ModelAttribute vs @RequestBody

  • HTTP 요청 파리미터를 처리하는 @ModelAttribute 각각의 필드 단위로 세밀하게 적용
    • 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리 가능
    • 필드 단위로 정교하게 바인딩이 적용됨
    • 특정 필드가 바인딩 되지 않아도 나머지 필드 는 정상 바인딩 되고, Validator를 사용한 검증도 적용 가능
  • HttpMessageConverter@ModelAttribute 와 다르게 각각의 필드 단위로 적용되는 것이 아니라, 전체 객체 단위로 적용된다.
    • 메시지 컨버터의 작동이 성공해서 ItemSaveForm 객체를 만들어야 @Valid , @Validated 가 적용됨
    •  HttpMessageConverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계 자체가 진행되지 않고 예외가 발생
    • 컨트롤러도 호출되지 않고, Validator도 적용 불가능

 

김영한님의 <스프링 MVC 2편 - 백엔드 웹 개발 활용 기술_김영한>을 수강하고 작성한 글입니다

 

 

 


PREV

 

[스프링 MVC 2편] 4. 검증1 - Validation

📌 검증 요구사항 ⚡ 요구사항 : 검증 로직 추가 타입 검증 가격, 수량에 문자가 들어가면 검증 오류 처리 필드 검증 상품명: 필수, 공백X 가격: 1000원 이상, 1백만원 이하 수량: 최대 9999 특정 필

nyeroni.tistory.com

NEXT

 

[스프링 MVC 2편] 6. 로그인 처리1 - 쿠키, 세션

📌 로그인 요구사항 ✔ 홈 화면 - 로그인 전 회원 가입 로그인 ✔ 홈 화면 - 로그인 후 본인 이름(누구님 환영합니다.) 상품 관리 로그 아웃 ✔ 회원 가입 ✔ 로그인 ✔ 상품 관리 ✔ 보안 요구사

nyeroni.tistory.com

 

728x90
LIST

BELATED ARTICLES

more