검증 2 - Bean Validation

LeeKyoungChang·2022년 1월 27일
0
post-thumbnail

'스프링 MVC 2편 - 백엔드 웹 개발 활용 기술' 수업을 듣고 정리한 내용입니다.

 

📚 1. Bean Validation - 소개

  • 검증 기능을 지금처럼 매번 코드로 작성하는 것은 상당히 번거롭다.
  • 특히 특정 필드에 대한 검증 로직은 대부분 빈 값인지 아닌지, 특정 크기를 넘는지 아닌지와 같이 매우 일반적인 로직이다.
    • 빈(null) 값인지 체크
    • 범위를 넘는지 체크

 

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을 잘 활용하면, 애노테이션 하나로 검증 로직을 매우 편리하게 적용할 수 있다.

 

📖 A. Bean Validation이란?

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

 

💡 참고
하이버네이트 Validator 관련 링크

 

📚 2. Bean Validation - 시작

  • Bean Validation 기능을 어떻게 사용하는지 코드로 알아보자.
  • 먼저 스프링과 통합하지 않고, 순수한 Bean Validation 사용법 부터 테스트 코드로 알아보자.

 

Bean Validation 의존관계 추가
build.gradle

implementation 'org.springframework.boot:spring-boot-starter-validation'
  • spring-boot-starter-validation 의존관계를 추가하면 라이브러리가 추가 된다.

 

Jakarta Bean Validation

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

 

📖 A. 테스트 코드

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까지만 허용한다.

 

💡 참고
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 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>> 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 사이여야 합니다
스크린샷 2022-01-27 오후 4 26 54 스크린샷 2022-01-27 오후 3 30 11

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

 

📌 정리

  • 이와 같이 빈 검증기(Bean Validation)를 직접 사용하는 방법을 알아보았다.
  • 아마 지금까지 배웠던 스프링 MVC 검증 방법에 빈 검증기를 어떻게 적용하면 좋을지 여러가지 생각이 들 것이다.
  • 스프링은 이미 개발자를 위해 빈 검증기를 스프링에 완전히 통합해두었다.

 

📚 3. Bean Validation - 스프링 적용

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.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")
    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}";
    }

}

 

실행

  • 애노테이션 기반의 Bean Validation이 정상 동작한다.
스크린샷 2022-01-27 오후 4 29 40

 

💡 참고

  • 특정 필드의 범위를 넘어서는 검증(가격 * 수량의 합은 10,000원 이상) 기능은 나중에 공부한다. (현재는 빠져있다.)

 

✓ 스프링 MVC는 어떻게 Bean Validator 사용법
스프링 부트가 spring-boot-starter-validation 라이브러리를 넣으면 자동으로 Bean Validator를 인지하고 스프링에 통합한다.

 

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

  • LocalValidatorFactoryBean 을 글로벌 Validator로 등록한다.
  • Validator@NotNull 같은 애노테이션을 보고 검증을 수행한다.
  • 글로벌 Validator가 적용되어 있기 때문에, @Valid , @Validated 만 적용하면 된다.
  • 검증 오류가 발생하면, FieldError , ObjectError 를 생성해서 BindingResult 에 담아준다.

 

⚠️ 주의

  • 다음과 같이 직접 글로벌 Validator를 직접 등록하면 스프링 부트는 Bean Validator를 글로벌 Validator 로 등록하지 않는다.
  • 따라서 애노테이션 기반의 빈 검증기가 동작하지 않는다.
  • 다음 부분은 제거하자.

다음 부분

@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {
	// 글로벌 검증기 추가
	@Override
	public Validator getValidator() {
          return new ItemValidator();
    }
	// ...
}

 

💡 참고

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

 

📖 A. 검증 순서

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

2. Validator 적용

 

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

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

 

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

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

 

📚 4. Bean Validation - 에러 코드

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

 

스크린샷 2022-01-27 오후 5 01 15

🔭 @NotBlank

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

🔭 @Range

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

 

📖 A. 메시지 등록

원하는 형태로 출력되도록 메시지를 등록해보자!

 

스크린샷 2022-01-27 오후 5 04 16

errors.properties

#Bean Validation 추가
NotBlank={0} 공백X 
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}
  • {0} : 필드명
  • {1} , {2} ...은 각 애노테이션 마다 다르다.

 

실행

  • 등록한 메시지가 정상 적용된다.
스크린샷 2022-01-27 오후 5 04 03

 

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

 

✓ 애노테이션의 message 사용 예

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

 

📚 5. Bean Validation - 오브젝트 오류

특정 필드( FieldError )가 아닌 해당 오브젝트 관련 오류( ObjectError ) 처리 방법에 대해 알아보자!

 

📖 A. @ScriptAssert()

@ScriptAssert()을 사용시 해당 오브젝트 관련 오류(ObjectError)를 처리할 수 있다.

@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000")
public class Item {
    //...
}
  • 실행시 정상 수행되며 메시지 코드는 다음과 같이 생성된다.

 

✓ 메시지 코드

ScriptAssert.item
ScriptAssert
  • 그런데 실제 사용해보면 제약이 많고 복잡하다.
  • 그리고 실무에서는 검증 기능이 해당 객체의 범위를 넘어서는 경우들도 종종 등장하는데, 그런 경우 대응이 어렵다.

 

따라서
➡️ 오브젝트 오류(글로벌 오류)의 경우 @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}";
    }

 

실행 결과
스크린샷 2022-01-27 오후 5 30 51

 

📚 6. Bean Validation - 수정에 적용

상품 수정에도 빈 검증(Bean Validation) 을 적용해보자.

 

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}";
    }
  • edit() : Item 모델 객체에 @Validated 를 추가
  • 검증 오류가 발생하면 editForm 으로 이동하는 코드 추가

 

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}"
                   th:errorclass="field-error" class="form-control">
            <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">
            <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">
            <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 추가
  • 글로벌 오류 메시지
  • 상품명, 가격, 수량 필드에 검증 기능 추가

 

실행 결과
스크린샷 2022-01-27 오후 6 04 27

 


참고

profile
"야, (오류 만났어?) 너두 (해결) 할 수 있어"

0개의 댓글