쌩로그

스프링 MVC 2편 - Ch05. 검증2 - Bean Validation 본문

Spring/Spring & Spring Boot

스프링 MVC 2편 - Ch05. 검증2 - Bean Validation

.쌩수. 2024. 6. 23. 00:40
반응형

목록

  1. 포스팅 개요
  2. 본론
      2-1. Bean Validation - 소개
      2-2. Bean Validation - 시작
      2-3. Bean Validation - 프로젝트 준비 V3
      2-4. Bean Validation - 스프링 적용
      2-6. Bean Validation - 에러 코드
      2-7. Bean Validation - 오브젝트 오류
      2-8. Bean Validation - 수정에 적용
      2-9. Bean Validation - 한계
      2-10. Bean Validation - groups
      2-11. Form 전송 객체 분리 - 프로젝트 준비 V4
      2-12. Form 전송 객체 분리 - 소개
      2-13. Form 전송 객체 분리 - 개발
      2-14. Bean Validation - HTTP 메시지 컨버터
  3. 요약

1. 포스팅 개요

인프런에서 영한님의 스프링 MVC 2편 Section 05 검증2 - Bean Validation을 학습하며 정리한 포스팅이다.

2. 본론

2-1. Bean Validation - 소개

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

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; 

    //... 
}
  • @NotBlank : null과 공백을 허용하지 않는다. 즉 itemName은 null과 공백을 허용하지 않는다.
  • @NotNull : null을 허용하지 않는다.
  • @Range : min에서 max까지 허용한다. 즐 price는 1,000원이상 1,000,000 이하다.
  • @Max : 최대값을 지정한다. 즉 quantity는 9999까지 허용된다.

이렇게 사용하면 이전 섹션(섹션 4)에서 적용한 ItemValidatorvalidate의 대부분의 코드를 지워도 된다.

이런 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화 한 것이 바로 Bean Validation 이다.
Bean Validation을 잘 활용하면, 애노테이션 하나로 검증 로직을 매우 편리하게 적용할 수 있다.

Bean Validation 이란?

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

하이버네이트 Validator 관련 링크

2-2. Bean Validation - 시작

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

의존관계 추가

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

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

spring-boot-starter-validation 의존관계를 추가하면 라이브러리가 추가 된다.

Jakarta Bean Validation

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

이제 테스트 코드를 작성해보자.

먼저 Item 클래스를 다음과 같이 작성한다.

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 = 10000000) // 하이버네이트에서 제공  
    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를 사용하므로 자유롭게 사용해도 된다.

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


    }  
}

item에 setter로 지정한 값들은 다 검증에서 막히는 값들이다.
테스트 결과는 다음과 같다.

violation = ConstraintViolationImpl{interpolatedMessage='공백일 수 없습니다', propertyPath=itemName, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{javax.validation.constraints.NotBlank.message}'}
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='1000에서 10000000 사이여야 합니다', propertyPath=price, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{org.hibernate.validator.constraints.Range.message}'}
violation.getMessage() = 1000에서 10000000 사이여야 합니다

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

검증기 생성
다음 코드와 같이 검증기를 생성한다.
이후 스프링과 통합하면 우리가 직접 이런 코드를 작성하지는 않으므로, "이렇게 사용하는구나~" 정도만 참고하자.

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

검증 실행 검증 대상( item )을 직접 검증기에 넣고 그 결과를 받는다.
Set 에는 ConstraintViolation 이라는 검증 오류가 담긴다.
따라서 결과가 비어있으면 검증 오류가 없는 것이다.

Set<ConstraintViolation<Item>> violations = validator.validate(item)

정리

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

참고로 다음과 같이 Bean Validation에 메세지를 적용할 수도 있다.

@Data  
public class Item {  

    private Long id;  

    @NotBlank(message = "공백X")  
    private String itemName;

    ...
}

위와 같이 message에 "공백X"를 넣었다.
테스트 출력 결과는 다음과 같다.

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='1000에서 10000000 사이여야 합니다', propertyPath=price, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{org.hibernate.validator.constraints.Range.message}'}
violation.getMessage() = 1000에서 10000000 사이여야 합니다
violation = ConstraintViolationImpl{interpolatedMessage='공백X', propertyPath=itemName, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='공백X'}
violation.getMessage() = 공백X

"공백X"가 나온 것을 확인할 수 있다.

2-3. Bean Validation - 프로젝트 준비 V3

생략

참고로 V2를 V3로 복사하자.

2-4. Bean Validation - 스프링 적용

ValidationItemControllerV3 클래스 코드를 수정하자.

V2에서 작성했던 addItem1 ~ addItem5 까지 지우고,
ItemValidator를 삭제하자.(init 메서드도 삭제하면 된다.)

실행해보면 애노테이션 기반의 Bean Validation이 정상 동작하는 것을 확인할 수 있다.
참고로 특정 필드의 범위를 넘어서는 검증("가격 * 수량의 합은 10,000원 이상") 기능이 빠졌는데, 이 부분은 조금 뒤에 설명한다.

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

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

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

LocalValidatorFactoryBean 을 글로벌 Validator로 등록한다.
LocalValidatorFactoryBean@NotNull 같은 애노테이션을 보고 검증을 수행한다.
이렇게 글로벌 Validator가 적용되어 있기 때문에, @Valid , @Validated 만 적용하면 된다.

참고로 @Validated를 붙이지 않으면 검증 기능이 수행되지 않는다.

@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 라는 기능을 포함하고 있다.
이 부분은 조금 뒤에 다시 설명한다.

검증 순서

  1. @ModelAttribute 각각의 필드에 타입 변환 시도
    1. 성공하면 다음으로
    2. 실패하면 typeMismatchFieldError 추가 (예 : 가격에 문자를 넣는 경우)
  2. Validator 적용

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

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

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

예)

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

참고로 나는 아래 사진과 같이 아무 입력을 안 했을 때 "널이어서는 안됩니다"라는 문구를 등록한 적이 없다.

@NotNull에 대한 부분인데, @NotNull에서 default Message가 가리키는 javax.validation.constraints.NotNull.message로 컨트롤을 누르고, 클릭하면, 다음과 같이 나온다.

ValidationMessage_ko.properties 파일을 볼 수 있을 것이다.

이처럼 이미 스프링이 메세지도 다 만들어놓은 것을 확인할 수 있다.
짝짝짝!!!!~~

2-6. Bean Validation - 에러 코드

Bean Validation이 기본으로 제공하는 오류 메시지를 좀 더 자세히 변경하고 싶으면 어떻게 하면 될까?

Bean Validation을 적용하고 bindingResult 에 등록된 검증 오류 코드를 보자.
상품 이름만 null로 하여 '저장'을 클릭하면 다음과 같이 된다.

로그는 다음과 같다.

Field error in object 'item' on field 'itemName': rejected value []; codes [NotBlank.item.itemName,NotBlank.itemName,NotBlank.java.lang.String,NotBlank]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [item.itemName,itemName]; arguments []; default message [itemName]]; default message [공백X]

오류 코드가 애노테이션 이름으로 등록된다.
마치 typeMismatch 와 유사하다.

NotBlank 라는 오류 코드를 기반으로 MessageCodesResolver 를 통해 다양한 메시지 코드가 순서대로 생성된다.

따라서 다음과 같은 애너테이션의 항목마다 에러메세지를 등록해주면 에러 메세지를 변경할 수 있다.

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

참고로 {0} 은 필드명이고, {1} , {2} ...은 각 애노테이션 마다 다르다.

실행결과는 다음과 같다.

BeanValidation 메시지 찾는 순서는 다음과 같다.

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

애노테이션의 message 사용 예

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

우선 순위가 높은 값을 properties로 적용해주면 된다.

2-7. Bean Validation - 오브젝트 오류

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

@ScriptAssert() 를 사용하면 된다.

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

    private Long id;
    ...
}

결과는 다음과 같다.

message를 수정해보자.

@Data  
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000", message = "총합이 10000원 넘게 입력해주세요.")  
public class Item {  

    private Long id;
    ...
}

이전의 코드에서 message 옵션만 추가했다.

다음은 실행 결과다.

로그는 다음과 같다.

Error in object 'item': codes [ScriptAssert.item,ScriptAssert]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [item.,]; arguments []; default message [],_this,javascript,,_this.price * _this.quantity >= 10000]; default message [총합이 10000원 넘게 입력해주세요.]


메시지 코드는 다음과 같다.

  • ScriptAssert.item
  • ScriptAssert

그런데 실제 사용해보면 제약이 많고 복잡하다.
그리고 실무에서는 검증 기능이 해당 객체의 범위를 넘어서는 경우들도 종종 등장하는데, 그런 경우 대응이 어렵다.
따라서 오브젝트 오류(글로벌 오류)의 경우 @ScriptAssert 을 억지로 사용하는 것 보다는 다음과 같이 오브젝트 오 류 관련 부분만 직접 자바 코드로 작성하는 것을 권장한다.

ValidationItemControllerV3에 글로벌 오류를 추가해보자.
참고로 방금 작성했던 @ScriptAssert 부분을 제거해야 한다.

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

    // 특정 필드가 아닌 복할 룰 검증  
    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";     // 상품 등록폼  
    }  

    // errors에 안 걸리면 성공로직  
    Item savedItem = itemRepository.save(item);  
    redirectAttributes.addAttribute("itemId", savedItem.getId());  
    redirectAttributes.addAttribute("status", true);  
    return "redirect:/validation/v3/items/{itemId}";  
}

ItemValidator에 작성한 코드 일부를 넣었다.

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

editForm은 다음과 같이 수정하자.

<!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">  
            <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}" 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}" 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-erro`r css 추가 글로벌 오류 메시지 상품명, 가격, 수량 필드에 검증 기능 추가

결과는 다음과 같다.

작 적용된 것을 확인할 수 있다.

2-9. Bean Validation - 한계

데이터를 등록할 때와 수정할 때는 요구사항이 다를 수 있다.

등록시 기존 요구사항

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

수정시 요구사항

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

수정시 요구사항을 적용해보자.
수정시에는 Item 에서 id 값이 필수이고, quantity 도 무제한으로 적용할 수 있다.

다음과 같이 Item 을 작성하자.

@Data  
public class Item {  

    @NotNull // 수정 요구사항 추가  
    private Long id;  

    @NotBlank(message = "공백X")  
    private String itemName;  

    @NotNull  
    @Range(min = 1000, max = 10000000) // 하이버네이트에서 제공  
    private Integer price;  

    @NotNull  
//    @Max(9999) // 수정 요구사항 추가  
    private Integer quantity;  

    public Item() {  
    }
    ...
}

이렇게 하면 수정은 요구사항대로 된다.(등록이 문제겠지..)

그러나 저장이 안 된다.

현재 등록시 저장이 되지 않고 있다.
로그를 보면 다음과 같다.

2024-06-21 07:01:03.286  INFO 29484 --- [nio-8080-exec-8] h.i.w.v.ValidationItemControllerV3       : errors = org.springframework.validation.BeanPropertyBindingResult: 1 errors
Field error in object 'item' on field 'id': rejected value [null]; codes [NotNull.item.id,NotNull.id,NotNull.java.lang.Long,NotNull]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [item.id,id]; arguments []; default message [id]]; default message [널이어서는 안됩니다]

상품을 등록할 땐 id가 없는데, id가 없다는 error를 남기고 있다.
Item 클래스에서 id에 @NotNull을 넣어서 그렇다.
또한 수량은 지금 알맞게 입력해서 그렇지 수량에도 문제가 있다.

이처럼 수정은 잘 동작하지만 등록에서 문제가 발생한다.
등록시에는 id 에 값도 없고, quantity 수량 제한 최대 값인 9999도 적용되지 않는 문제가 발생한다.

등록시 화면이 넘어가지 않으면서 다음과 같은 오류를 볼 수 있다.
'id': rejected value [null];
왜냐하면 등록시에는 id 에 값이 없다. 따라서 @NotNull id 를 적용한 것 때문에 검증에 실패하고 다시 폼 화면으로 넘어온다.
결국 등록 자체도 불가능하고, 수량 제한도 걸지 못한다.

결과적으로 item등록과 수정에서 검증 조건의 충돌이 발생하고, 등록과 수정은 같은 BeanValidation을 적용할 수 없다.

이 문제를 어떻게 해결할 수 있을까?
다음에서 설명한다.

참고

현재 구조에서는 수정시 itemid 값은 항상 들어있도록 로직이 구성되어 있다.
그래서 검증하지 않아도 된다고 생각할 수 있다.
그런데 HTTP 요청은 언제든지 악의적으로 변경해서 요청할 수 있으므로 서버에서 항상 검증 해야 한다.
예를 들어서 HTTP 요청을 변경해서 itemid 값을 삭제하고 요청할 수도 있다. 따라서 최종 검증은 서버에서 진행하는 것이 안전한다.

2-10. Bean Validation - groups

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

방법 2가지

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

BeanValidation groups 기능 사용
이런 문제를 해결하기 위해 Bean Validationgroups라는 기능을 제공한다.
예를 들어서 등록시에 검증할 기능과 수정시에 검증할 기능을 각각 그룹으로 나누어 적용할 수 있다.

코드로 확인해보자.

groups를 적용하자.

저장용 groups 생성

package hello.itemservice.domain.item;  

public interface SaveCheck {  
}

수정용 groups 생성

package hello.itemservice.domain.item;  

public interface UpdateCheck {  
}

Item 클래스

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 {  

    @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 = 10000000, 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, 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";     // 상품 등록폼  
    }  

    // errors에 안 걸리면 성공로직  
    Item savedItem = itemRepository.save(item);  
    redirectAttributes.addAttribute("itemId", savedItem.getId());  
    redirectAttributes.addAttribute("status", true);  
    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) {  
        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에서 속성을 group을 줌으로써 적용할 BeanValidation을 설정할 수 있다.

실행결과는 다음과 같다.

수량을 검증한 결과다.

등록이 제대로 되는 것을 확인할 수 있다.

이처럼 수정 요구사항이 반영됐음을 알 수 있다.

참고

@Valid 에는 groups를 적용할 수 있는 기능이 없다.
따라서 groups를 사용하려면 @Validated 를 사용해야 한다.

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

2-11. Form 전송 객체 분리 - 프로젝트 준비 V4

-생략-

V1을 V2로,
V2를 V3로 복붙한 것처럼
V3를 V4를 만들어주면 된다.

2-12. Form 전송 객체 분리 - 소개

이번에는 ValidationItemV4Controller를 사용할 것이다.

실무에서는 groups 를 잘 사용하지 않는데, 그 이유가 다른 곳에 있다.
바로 등록시 폼에서 전달하는 데이터가 Item 도메인 객체와 딱 맞지 않기 때문이다.
소위 "Hello World" 예제에서는 폼에서 전달하는 데이터와 Item 도메인 객체가 딱 맞는다.
하지만 실무에서는 회원 등록시 회원과 관련된 데이터만 전달받는 것이 아니라, 약관 정보도 추가로 받는 등 Item 과 관계없는 수 많은 부가 데이터가 넘어온다.
그래서 보통 Item 을 직접 전달받는 것이 아니라, 복잡한 폼의 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다.
예를 들면 ItemSaveForm 이라는 폼을 전달받는 전용 객체를 만들어서 @ModelAttribute 로 사용한다.
이것을 통해 컨트롤러에서 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해서 Item 을 생성한다.

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

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

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

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

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

Item 도메인 객체를 폼 전달 데이터로 사용하고, 그대로 쭉 넘기면 편리하겠지만, 앞에서 설명한 것과 같이 실무에서는 Item 의 데이터만 넘어오는 것이 아니라 무수한 추가 데이터가 넘어온다.

그리고 더 나아가서 Item 을 생성하는데 필요한 추가 데이터를 데이터베이스나 다른 곳에서 찾아와야 할 수도 있다.

따라서 이렇게 폼 데이터 전달을 위한 별도의 객체를 사용하고, 등록, 수정용 폼 객체를 나누면 등록, 수정이 완전히 분리되기 때문에 groups 를 적용할 일은 드물다.

Q: 이름은 어떻게 지어야 하나요?
이름은 의미있게 지으면 된다. ItemSave 라고 해도 되고, ItemSaveForm , ItemSaveRequest , ItemSaveDto 등으로 사용해도 된다. 중요한 것은 일관성이다.

Q: 등록, 수정용 뷰 템플릿이 비슷한데 합치는게 좋을까요?
한 페이지에 그러니까 뷰 템플릿 파일을 등록과 수정을 합치는 게 좋을지 고민이 될 수 있다.
각각 장단점이 있으므로 고민하는게 좋지만, 어설프게 합치면 수 많은 분기문(등록일 때, 수정일 때) 때문에 나중에 유지보수에서 고통을 맛본다.
이런 어설픈 분기문들이 보이기 시작하면 분리해야 할 신호이다.

2-13. Form 전송 객체 분리 - 개발

ITEM 원복
이제 Item 의 검증은 사용하지 않으므로 검증 코드를 제거해도 된다.
검증 부분을 주석을 하든, 다음과 같이 지우든 하면 된다.

import lombok.Data;  

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

그리고 web/validation/form 이라는 패키지를 생성하고,
거기에 Form 클래스를 작성한다.

먼저 등록을 위한 ItemSaveForm 클래스다.

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;  

}

다음은 수정을 위한 ItemUpdateForm이다.

import lombok.Data;  
import org.hibernate.validator.constraints.Range;  

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;  

}

그리고 컨트롤러 폼에서 위에서 작성한 Form을 받도록 적용하자.
먼저 등록폼이다.

@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());  


    // errors에 안 걸리면 성공로직  
    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());  
    redirectAttributes.addAttribute("status", true);  
    return "redirect:/validation/v4/items/{itemId}";  
}

ItemItemSaveForm 타입으로 변경해준다.
주의할 점은 @ModelAttribute에서 value를 item으로 주지 않으면, 타입의 첫 글자가 소문자인 값으로 들어간다.
(템플리에서 이미 item이라는 변수로 받고 있는데, 변경하기 귀찮게되기 때문에, "item"으로 적용하는 것..이다. 영한님도 귀찮은가보다..ㅋㅋ)

또한 성공 로직에서 form에서 받은 데이터를 받아서 Item 객체에 지정해주고, save를 호출해야한다...!

다음은 수정이다.

@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 = itemRepository.findById(itemId);  
    itemParam.setQuantity(form.getQuantity());  
    itemParam.setItemName(form.getItemName());  
    itemParam.setPrice(form.getPrice());

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

등록과 유사하다.
수정에도 등록과 마찬가지로 성공 로직에서 데이터들을 옮겨줘야한다...!

실행하면 정상적으로 수행됨을 확인할 수 있다..!

주의

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

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

위에서 소개 부분에도 나오지만, 하이버네이트에서 제공하는 검증 애너테이션 홈페이지를 잘 활용하면 좋을 거 같다.

웬만한 거 다 있다고 하고, ㄹㅇ 다 있다..;;

2-14. Bean Validation - HTTP 메시지 컨버터

@Valid , @ValidatedHttpMessageConverter ( @RequestBody )에도 적용할 수 있다.

참고 \

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

ValidationItemApiController 생성하자.

다음과 같이 작성하자.

import hello.itemservice.web.validation.form.ItemSaveForm;  
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/item")  
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을 켜서 확인해보자.

다음은 성공 케이스 데이터이다.

다음은 데이터와 호출 성공에 대한 결과다.

로그는 다음과 같다.

다음은 실패케이스 데이터다.

실패에 대한 결과다.

로그는 다음과 같다.

2024-06-23 00:23:03.480  WARN 2372 --- [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: 13] (through reference chain: hello.itemservice.web.validation.form.ItemSaveForm["price"])]

로그를 보면 컨트롤러의 메서드를 호출한 흔적이 없다.
즉 컨트롤러의 호출이 되지 않았다.

참고로 JSON 으로 넣은 데이터가 @RequestBody의 타입 객체로 바껴야 한다.
바껴야!!! validation 을 할 수 있는데, 현재 그 단계까지도 못 간 것이다...!!

HTTP 메시지 컨버터가 ItemSaveForm 객체를 만들어야 컨트롤러를 호출할 수 있는데,
현재 객체조차 만들지 못 한 것이다.

왜냐하면, JSON 데이터를 객체로 바꾸는 것이 실패한 것이다..!

그렇기 때문에 컨트롤러에서 처리하지 못하고, 예외가 빵! 터진 것이다..!
참고로 예외에 대한 부분은 강의 후반에 다룬다..

따라서 이는 실패 케이스이다.

API의 경우 3가지 경우를 나누어 생각해야 한다.

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

이제 검증 오류를 살펴보자.

값은 다음과 같이 한다.
quantity 부분에서 9999를 초과하므로, 검증 오류가 날 것이다.

다음은 포스트맨의 응답 결과이다.

로그는 다음과 같다.

2024-06-23 00:31:25.976  INFO 2372 --- [nio-8080-exec-9] h.i.w.v.ValidationItemApiController      : API 컨트롤로 호출
2024-06-23 00:31:25.976  INFO 2372 --- [nio-8080-exec-9] h.i.w.v.ValidationItemApiController      : 검증 오류 발생 errors=org.springframework.validation.BeanPropertyBindingResult: 1 errors
Field error in object 'itemSaveForm' on field 'quantity': rejected value [10000]; 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 이하여야 합니다]

로그를 보면 검증 오류가 정상 수행된 것을 확인할 수 있다.

참고

return bindingResult.getAllErrors();ObjectErrorFieldError 를 반환한다.
스프링이 이 객체를 JSON으로 변환해서 클라이언트에 전달했다.
여기서는 예시로 보여주기 위해서 검증 오류 객체들을 그대로 반환 했다.
실제 개발할 때는 이 객체들을 그대로 사용하지 말고, 필요한 데이터만 뽑아서 별도의 API 스펙을 정의하고 그에 맞는 객체를 만들어서 반환해야 한다.

@ModelAttribute vs @RequestBody

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

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

참고

HttpMessageConverter 단계에서 실패하면 예외가 발생한다.
예외 발생시 원하는 모양으로 예외를 처리하는 방법은 예외 처리 부분에서 다룬다

3. 요약

이전 내용과 같은 맥락으로 검증에 대해 살펴보았는데, 스프링이 제공하는 Bean Validation을 통해서 어떻게 검증이 이뤄지는지 매커니즘을 알아보았다.

그리고 프로젝트를 진행하면서 수없이 봤던 오류가 왜 뜨는지 이제야 알게됬다..
아마 Bean Validation에 대한 부분은 추후 예외 처리 부분에서 더더욱 확실하게 알 수 있지 않을까 예상해본다...!!

728x90
Comments