Post

검증

BindingResult

스프링은 검증 오류를 BindingResult 객체에 보관한다. 객체의 필드에 검증 오류가 발생하면 FieldError 클래스를 사용하고, 복합적인 검증 오류가 발생하면 ObjectError 클래스를 사용한다.

BindingResult 객체는 반드시 @ModelAttribute 가 적용된 객체 뒤에 선언되어야 하며, model에 추가하지 않아도 자동으로 추가된다.

1
2
3
4
@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
    ...
}

FieldError

1
2
3
4
5
6
public FieldError(String objectName, String field, String defaultMessage);

public FieldError(String objectName, String field, 
                    @Nullable Object rejectedValue, boolean bindingFailure, 
                    @Nullable String[] codes, @Nullable Object[] arguments, 
                    @Nullable String defaultMessage);
  • objectName : 오류가 발생한 객체 이름
  • field : 오류 필드
  • rejectedValue : 사용자가 입력한 값
  • bindingFailure : 바인딩 오류 or 검증 오류 구분 값
  • codes : 오류 메시지 프로퍼티
  • arguments : 메시지 인자
  • defaultMessage : 기본 오류 메시지

ObjectError

1
2
3
4
public ObjectError(String objectName, String defaultMessage);

public ObjectError(String objectName, @Nullable String[] codes, 
                    @Nullable Object[] arguments, @Nullable String defaultMessage);
  • objectName : 오류가 발생한 객체 이름
  • codes : 오류 메시지 프로퍼티
  • arguments : 메시지 인자
  • defaultMessage : 기본 오류 메시지

Thymeleaf에서 사용하기

1
2
3
4
5
6
7
8
9
<!-- #fields -->
<div th:if="${#fields.hasGlobalErrors()}">
    <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">복합 오류 메세지</p>
</div>

<!-- th:errorclass , th:errors -->
<input type="text" class="form-control" placeholder="가격을 입력하세요"
        th:field="*{price}" th:errorclass="field-error">
<div class="field-error" th:errors="*{price}"></div>
  • #fields : BindingResult에 추가된 오류에 접근할 수 있다.
  • th:errors : 지정한 필드에 오류가 있으면 태그를 출력한다.
  • th:errorclass : th:field 로 지정한 필드에 오류가 있으면 class 를 추가한다.

메시지 기능 사용하기

FieldError / ObjectError

FieldErrorObjectErrorcodesarguments 매개변수를 이용하면 메시지 기능을 사용한다.

먼저 에러 메시지를 관리하기 위한 파일(errors.properties)을 추가하고 application.properties 에 추가한다.

1
2
3
4
required.item.itemName=상품 이름을 필수입니다.
range.item.price=가격은 {0} ~ {1}까지 허용됩니다.
max.item.quantity=수량은 최대 {0}까지 허용됩니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
1
spring.messages.basename=messages,errors

errors_en.properties 파일을 추가하면 국제화 처리도 가능하다.

그리고 FieldErrorObjectError를 추가할 때 errors.properties 에 작성한 프로퍼티를 인자로 전달하면 된다.

1
2
3
4
5
6
7
8
// FieldError
bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, new String[]{"required.item.itemName"}, null, null));

// ObjectError
int resultPrice = item.getPrice() * item.getQuantity();
if(resultPrice < 10000) {
    bindingResult.addError(new ObjectError("item", new String[]{"totalPriceMin"}, new Object[]{10000, resultPrice}, null));
}

rejectValue() / reject()

FieldErrorObjectErrorcodes 매개변수를 보면 배열로 되어있다. 여기에 개발자가 하드코딩으로 codes 매개변수에 값을 전달하는 것은 비효율적이다.

rejectValue()reject()를 사용하면 FieldError, ObjectError 객체를 생성하지 않고 비교적 간단하게 메시지를 사용할 수 있다.

생성자

1
2
3
4
5
// rejectValue()
void rejectValue(@Nullable String field, String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);

// reject()
void reject(String errorCode, @Nullable Object[] errorArgs, @Nullable String defaultMessage);

사용

1
2
3
4
5
// rejectValue()
bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null);

// reject()
bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);

rejectValue()reject()를 사용하면 MessageCodesResolver가 메시지 프로퍼티 리스트를 생성하고 순회하면서 첫번째로 매칭되는 프로퍼티의 값을 오류 메시지로 사용하고, 리스트에 없다면 defaultMessage를 사용한다.

컨트롤러의 매핑함수에 BindingResult가 있으면 바인드 오류(정수형 변수에 문자가 들어가는 경우)가 발생할 때 스프링이 자체적으로 FieldError를 추가한다. (요청 시 넘어온 값은 보존됨) 이 때 errorCodetypeMismatch 이며, BindingResult가 없을 때 바인드 오류가 발생하면 400 - Bad Request 에러를 반환한다.

메시지 프로퍼티 리스트를 생성하는 규칙

  • rejectValue() 를 사용했을 때

    1. errorCode.object-name.field
    2. errorCode.field
    3. errorCode.field-type
    4. errorCode
  • reject() 를 사용했을 때

    1. errorCode.object-name
    2. errorCode

이렇게 함으로써 단순한 메시지와 자세한 메시지를 함께 사용하여 범용성이 좋아지고, 개발자는 코드를 수정할 필요없이 프로퍼티만 추가하거나 수정하면 된다.

검증 로직 분리하기

별도의 클래스로 Validator를 구현하면 컨트롤러에 작성한 검증 로직을 분리할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@Component
public class ItemValidator implements Validator {

    @Override
    public boolean supports(Class<?> clazz) {
        return Item.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        ...
    }
}

직접 구현한 Validator를 사용할 때는 다음과 같은 방법이 있다.

  1. 주입 받아서 직접 호출하기
    1
    2
    3
    4
    5
    6
    7
    
     private final ItemValidator itemValidator;
     ...
     @PostMapping("/add")
     public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
         itemValidator.validate(item, bindingResult);
         ...
     }
    
  2. WebDataBinder에 추가하고 @Validated 어노테이션을 사용하기 (컨트롤러에서만 동작)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    
     private final ItemValidator itemValidator;
    
     @InitBinder
     public void init(WebDataBinder dataBinder) {
         dataBinder.addValidators(itemValidator);
     }
     ...
     @PostMapping("/add")
     public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
         if (bindingResult.hasErrors()) {
             return "validation/v2/addForm";
         }
         ...
     }
    
  3. 전역으로 설정하기

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    
     @SpringBootApplication
     public class ItemServiceApplication implements WebMvcConfigurer {
         public static void main(String[] args) {
             SpringApplication.run(ItemServiceApplication.class, args);
         }
    
         @Override
         public Validator getValidator() {
             return new ItemValidator();
         }
     }
    

    전역으로 설정하면 스프링은 BeanValidator를 자동으로 등록하지 않는다.

Bean Validation 기술

Bean Validation 기술은 애노테이션과 인터페이스를 통해 검증 로직을 공통화하고 표준화한 것이다. (마치 JPA 기술처럼) Bean Validation 기술을 사용하면 애노테이션 하나로 검증 로직을 매우 편리하게 적용할 수 있다.

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

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

의존성을 추가하면 스프링 부트는 LocalValidatorFactoryBean을 자동으로 전역 Validator 로 등록한다. 따라서 모델 클래스에 검증 애노테이션을 추가하고 @Validated 를 적용하기만 하면 된다. (지원하는 검증 애노테이션 링크)

1
2
3
4
5
6
7
8
9
@Data
public class Item {
    private Long id;

    @NotBlank
    private String itemName;

    ...
}

Validation은 @ModelAttribute로 모델 클래스에 바인딩이 성공한 필드만 적용한다. 바인딩조차 되지 않았다면 검증을 하지 않는다.

메시지 프로퍼티 생성 규칙

Bean Validation은 애노테이션 명을 기반으로 프로퍼티를 생성한다.
만약 검증 애노테이션이 @NotBlank라면 생성되는 프로퍼티는 다음과 같다.

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

메시지 적용 우선순위 : 메시지 프로퍼티 > 애노테이션 message 속성 > 라이브러리 기본값

ObjectError 처리

@ScriptAssert 애노테이션을 사용하면 복합 검증을 사용할 수 있다.

1
2
3
4
5
@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000")
public class Item {
    ...
}

생성되는 메시지 프로퍼티

  • ScriptAssert.item
  • ScriptAssert

다만, 이 방법은 다른 객체들과 연관된 검증은 대응이 어렵기 때문에 복합 검증은 따로 자바 코드로 작성하는 것이 좋다.

groups 속성

동일한 모델 클래스로 2가지의 기능을 수행하는데 각 기능의 검증이 다른 경우 Validation을 적용할 수 없다.
이 때 groups 를 이용하면 지정한 기능에 대해서만 검증 애노테이션이 실행되도록 할 수 있다.

저장용 groups

1
2
3
4
package hello.itemservice.domain.item;

public interface SavaCheck {
}

수정용 groups

1
2
3
4
package hello.itemservice.domain.item;

public interface UpdateCheck {
}

모델 클래스에 groups 적용

1
2
3
4
5
6
7
8
9
@Data
public class Item {
    @NotNull(groups = UpdateCheck.class)
    private Long id;

    @NotBlank(groups = {SaveCheck.class, UpdateCheck.class})
    private String itemName;
    ...
}

컨트롤러 매핑 함수에 적용

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

@Valid에는 groups 를 적용할 수 있는 기능이 없다. 또한, groups 를 사용하면 모델 클래스의 복잡도가 증가하여 일반적으로는 모델을 분리해서 사용한다.

@RequestBody 검증도 가능하다

@Valid@Validated@RequestBody 에도 적용할 수 있다.

다만, HTTP 메시지 컨버터가 HTTP body를 객체로 변환하는 도중 오류가 발생하면 Exception이 발생해 검증이 실행되지 않는다. (필드에 바인딩이 실패해서 typeMismatch 필드 에러를 추가하는 @ModelAttribute와 다르다.)

This post is licensed under CC BY 4.0 by the author.