(스프링 mvc2) 검증2 - Bean Validation

짜스의 하루 ·2024년 3월 12일

Bean Validation - 소개

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

public class Itam{
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이라는 기술 표준이다. --> 쉽게 이야기해서 검증 애노테이션과 여러 인터페이스의 모음

Bean Validation 시작

의존관계 추가
(build.gradle에 의존관계 추가)
implementation 'org.springframework.boot:spring-boot-starter-validation'

Item클래스 수정

검증 애노테이션

  • @NotBlank :빈값 + 공백만 있는 경우를 허용하지 않는다.
  • @NotNull : null을 허용하지 않는다
  • @Range(min = 1000, max = 1000000) : 범위 안의 값이어야 한다.
  • @Max(9999) : 최대 9999까지만 허용한다.

BeanVaildationTest 생성

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.getMessage() = " + violation.getMessage());
            
        }
    }
}

테스트 실행 결과

  • 출력 결과를 보면, 위반된 정보들이 정상적으로 나오는 거슬 확인할 수 있다
  • 출력된 오류 메시지는 하이버네이트 validator 에서 기본적으로 제공하는 오류 메시지, 변경할 수 있다.
  • 이후 스프링과 통합하면 우리가 직접 이런 코드를 작성하지는 않으므로, 이렇게 사용하는구나 정도만 참고하자.

Bean Validation - 스프링 적용

먼저 ValidationItemControllerV3 클래스 정리

  • 메서드 제거 : addItemV1() ~ addItemV5()
  • 메서드 이름 변경 : addItemV6() -> addItem()

    수정 후, 실행해보자
    실행해보면, 애노테이션 기반의 Bean Validation이 정상 동작하는 것을 확인할 수 있다.

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

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

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

  • (스프링 부트가 뜰 때 spring-boot-starter-validation 라이브러리가 있으면) 자동으로LocalValidatorFactoryBean을 글로벌 Valiator로 등록한다.
  • LocalValidatorFactoryBean은 Spring Framework에서 제공하는 Hibernate Validator를 기반으로 하는 유효성 검증(Validation)을 지원하는 클래스
  • 이 Validator는 @NotnNull 같은 애노테이션을 보고 검증을 수행한다. 이렇게 글로벌 Validator가 적용되어 있기 때문에, @Valid, @Validated만 적용하면 된다.
    ( 당연히 @Valid 또는 @Validated 가 적용되지 않으면, 애노테이션 기반의 빈 검증기가 동작하지 않는다. )
  • 검증 오류가 발생하면, FieldError, ObjectError를 생성해서 BindingResult에 담아둔다.

참고

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

검증 순서
1. @ModelAttribute 각각의 필드에 타입 변환 시도
--> 성공하면, 다음으로
--> 실패하면, typeMismatch로 FieldError 추가
2. Validator 적용

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

  • Bean Validator 는 바인딩에 실패한 필드는 BeanValidation을 적용하지 않는다.
    --> 타입 변환에 성공해서 바인딩에 성공한 필드여야 BeanValidation 적용이 의미가 있다.
  • @ModelAttribute -> 각자 필드 타입 변환 시도 -> 변환에 성공한 필드만 BeanValidation 적용

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

스프링 부트에서는 spring-boot-starter-validation 라이브러리를 추가하면 자동으로 Bean Validation이 활성화되며, LocalValidatorFactoryBean이 글로벌 Validator로 등록되어 컨트롤러 등에서 @Valid 어노테이션을 이용하여 유효성 검증을 수행할 수 있게 된다.


Bean Validation - 에러 코드

Bean Validation이 기본으로 제공하는 오류 메시지를 좀 더 자세하게 변경하고 싶다면?
--> Bean Validation을 적용하고 bindingResult 에 등록된 검증 오류 코드를 보자.
로그를 확인해보자

  • 오류 코드가 애노테이션 이름으로 등록이 된다.
  • NotBlank라는 오류 코드를 기반으로, MessageCodesResolver를 통해 다양한 메시지 코드가 순서대로 생성된다.

    @NotBlank

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

메시지 등록
메시지를 등록해보자 errors.properties

NotBlank={0} 공백X
Range={0}, {2} ~ {1} 적용
Max={0}, 최대{1}
  • {0}: 필드의 이름 또는 레이블
  • {1}: 최대값 또는 길이 제한
  • {2}: 최소값
  • NotBlank.item.itemName=상품 이름을 적어주세요. 와 같이 상세히 작성해도 된다. --> 그러면 NotBlank 보다 더 우선순위 레벨이 높기 때문에 이게 적용된다.

실행해보면

BeanValidation 메시지 칮는 순서
1. 생성된 메시지 코드 순서대로 messageSource에서 메시지 찾기
예시) NotBlank.item.itemName -> NotBlank.itemName -> NotBlank.java.lang.String -> NotBlank

  1. 1에서 못찾으면, 애노테이션의 message 속성 사용 ( @NotBlank(message = "공백X") )

  2. 2에서 못찾으면, 라이브러리가 제공하는 기본 값 아용 (공백일 수 없습니다)


Bean Validation - 오브젝트 오류

Bean Validation에서 특정 필드( FieldError)가 아닌, 해당 오브젝트 관련 오류(ObjectError)은 어떻게 처리할 수 있을 까?

@ScriptAssert()사용
Item 클래스에 아래 코드를 추가해보자
@ScriptAssert(lang = "javascript", script="_this.price * _this.quantity >= 10000")

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

따라서 오브젝트 오류(글로벌 오류)의 경우, @ScriptAssert를 억지로 사용하는 것보다는 다음과 같이 오브젝트 관련 부분만 직접 자바 코드로 작성하는 것을 권장한다.

@ScriptAssert 부분 주석 처리 & ValidationItemControllerV3 - 글로벌 오류 추가

if (item.getPrice() != null && item.getQuantity() != null){
           int resultPrice = item.getPrice() * item.getQuantity();
           if(resultPrice <10000){
               bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice},null);
           }
       }


정상적으로 작동이 되는 것을 확인할 수 있다.


Bean Validation - 수정에 적용

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

edit() 추가

  • Item 모델 객체에 @Validated 추가
  • 검증 오류가 발생하면, editForm으로 이동하는 코드 추가

editForm.html코드 수정

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

  • 정상적으로 검증 처리가 됨을 확인할 수 있다.

Bean Validation 한계

등록 시 기존 요구사항

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

수정 시 요구사항 추가

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

수정 요구사항 적용
Item 클래스 수정: 수정시에는 Item에서 id값이 필수이고, quantity도 무제한으로 적용할 수 있다.

  • 수정 요구사항을 적용하기 위해
    --> id : @NotNull추가
    --> quantity : @Max(9999)제거
    정상적으로 상품 수정 요구사항이 반영되 것을 확인할 수 있다.

  • 그런데 문제가 발생한다

    정상적으로 값을 입력했음에도 상품이 등록되지 않는다.

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

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

등록시 화면이 넘어가지 않으면서 다음과 같은 오류를 볼 수 있다.

  • 'id': rejected value [null];: 등록 시에는 id값이 없기 때문이다.
    @NotNull을 id에 적용하 것 때문에 검증에 실패하고 다시 폼 화면으로 돌아온다.

결과적으로 item은 등록과 수정에서 검증 조건의 충돌이 발생(등록에서는 허용하는 것을 수정에서는 허용하지 않음), 등록과 수정은 같은 BeanValidation을 적용할 수 없다.


Bean Validation - groups

동일한 모델 객체를 등록과 수정을 할 떄, 각각 다르게 검증하는 방법을 알아보자

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

BeanValiation groups 기능 사용
이런 문제를 해결하기 위해 Bean Validation은 groups라는 기능을 제공한다

저장용 groups 생성

수정용 groups 생성

Item-groups 적용: Item 클래스를 다음과 같이 수정한다.

  • id 의 @NotNull : 수정시에만 사용
  • quantity 의 @Max : 등록시에만 적용

ValidationControllerV3 코드 수정
@Validated가 적용될 때, groups가 SaveCheck.class인 것만 검증하게 된다.

@Validated가 적용될 때, groups가 UpdateCheck.class인 것만 검증하게 된다.

참고

  • groups 기능을 사용하려면 @Validated를 사용해야 한다

정리

  • groups 기능을 사용해서 등록과 수정시에 각각 다르게 검증을 할 수 있었다. 그런데 groups 기능을 사용하니 Item 은 물론이고, 전반적으로 복잡도가 올라갔다.
  • 사실 groups 기능은 실제 잘 사용되지는 않는데, 그 이유는 실무에서는 주로 다음에 등장하는 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문이다.

Form 전송 객체 분리

실무에서는 groups를 잘 사용하지 않는다. 등록 시 폼에서 전달하는 데이터가, Item 도메인 객체와 딱 맞지 않기 떄문이다.
실무에서는 가령 회원 등록시, 회원과 관련된 데이터만 받는 것이 아니라, 약관 정보도 추가로 받는 등 Item과 관계없는 수 많은 부가 데이터가 넘어온다.

그래서 보통 Item을 직접 전달받는 것이 아니라, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다.
예를 들면, ItemSaveForm 이라는 HTML폼에서 입력한 내용을 전달 받을 전용 객체를 만들어서, @ModelAttribute로 사용한다.
--> 이것을 통해서 컨트롤러에서 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해서 Item을 생성한다.

폼 데이터 전달에 Item 도메인 객체 사용
HTML Form -> Item -> Controller -> Item -> Repository

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

폼 데이터 전달을 위한 별도의 객체 사용
HTML form -> ItemSaveForm -> Controller -> Item 생성 -> Repository

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

등록과 수정은 완전히 다른 데이터가 넘어온다. 예를 들면 등록시에는 로그인id, 주민번호 등등을 받을 수 있지만, 수정시에는 이런 부분이 빠진다. 그리고 검증 로직도 많이 달라진다. 그래서 ItemUpdateForm 이라는 별도의 객체로 데이터를 전달받는 것이 좋다


Form 전송 객체 분리 - 개발

Item 원상복구: 이제 기존 Item의 검증은 사용하지 않으므로 검증 코드를 제거한다

ItemSaveForm 생성 - 저장

@Data
public class ItemSaveForm {

    @NotBlank
    private String itemName;

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

    @NotNull
    @Max(9999)
    private Integer quantity;
}
  • 저장하는 기능에는 id는 필요 없다.

ItemUpdateForm 생성 - 수정

@Data
public class ItemUpdateForm {

    @NotNull
    private Long id;

    @NotBlank
    private String itemName;

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

    //수정에서는 수량은 자유롭게 변경할 수 있다.
    private Integer quantity;
}
  • 수정에서는 id가 필수값이다.(@NotNull)
  • 요구 사항에 따라 수량에 대한 입력 제한은 없으므로 모두 제거하였다.

ValidationItemControllerV4 - addItem

 @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}";
    }
  • 요청 데이터를 ItemSaveForm으로 받을 수 있도록 수정했다.
  • 화면은 별도로 수정하지 않기 위해서 @ModelAttribute("item") 이름을 추가하였다.
    --> 이것을 생략하면 itemSaveForm 이라는 이름으로 Model에 담기기 떄문이다.

ValidationItemControllerV4 - edit

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

}
  • addForm과 동일하게 적용하였다.

폼 객체 바인딩

  • Item 대신에 ItemSaveForm을 전달 받는다. 그리고 @Validated로 검증을 수행하고, BindingResult로 검증 결과도 받는다.

폼 객체를 Item으로 변환

  • 폼 객체의 데이터를 기반으로 Item 객체를 생성한다. 이렇게 폼 객체처럼 중간에 다른 객체가 추가되면, 변환하는 과정이 추가된다.

정리

  • Form 전송 객체를 분리해서 등록과 수정에 딱 맞는 기능을 구성하고, 검증도 명확히 분리했다.

Bean Validation - HTTP 메시지 컨버터

@Valid, @Validated는 HttpMessageConverter(@RequestBody)에도 적용할 수 있다.

참고

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

ValidationItemApiController 생성

@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 성공

Postman 실패

  • postman 결과를 보면, 스프링이 반환한 json 형식의 오류를 확인할 수 있다.
  • 그런데 로그를 보면, 컨트롤러가 호출되지 않았다.
    --> 요청 JSON 정보를 가지고 ItemSaveForm 객체를 만드는데 실패했기 때문
    --> 컨트롤러 자체가 아예 호출이 안되고 예외가 발생한다.
    (API 경우, JSON을 객체로 생성하는 것 자체가 실패한 경우, 컨트롤러로 못넘어오고 끝나게 된다)

Postman 검증 오류 요청 테스트

  • 요청 JSON 정보를 객체로 만드는데 성공했지만, 검증 오류가 발생해서 bindingResult에 오류 정보가 들어간 것이다. 따라서 bindingResult가 가진 모든 오류 정보가 반환된다.

API의 경우 3가지 경우를 나눠서 생각해야 한다

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

@ModelAttribute VS @RequestBody

  • HTTP 요청 파라미터를 처리하는 @ModelAttribute는 각각의 필드 단위로 세밀하게 적용된다. 그래서 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상적으로 처리할 수 있다
  • HttpMessageConverter는 @ModelAttribute와 다르게 각각의 필드 단위로 적용되는 것이 아니라 전체 객체 단위로 적용된다.
    따라서 메시지 컨버터의 작동이 성공해서 ItemSaveForm 객체를 들어야 @Validated가 적용된다

정리

  • @ModelAttribute 는 필드 단위로 정교하게 바인딩이 적용된다. 특정 필드가 바인딩 되지 않아도 나머지 필드는 정상 바인딩이 되고, Validator를 사용한 검증도 적용할 수 있다.
  • @RequestBody는 HttpMessageConverter 단계에서 JSON 데티어를 객체로 변경하지 못하면, 이후 단계 자체가 진행되지 않고 예외가 발생한다. 컨트롤러도 호출되지 않고 Validator도 적용할 수 없다.
profile
2024. 01. 02 ~ 백앤드 공부 시작, 2024. 04.01 ~ 프론트 공부 시작

0개의 댓글