Spring Framework/Spring MVC

[Spring Framework] Bean Validation

소재훈 2023. 5. 19. 03:59

코드를 이용해서 모든 Validation 에러를 처리하는 것은 상당히 번거롭다. 필드에러 같은 경우에는 빈 값을 입력하거나 올바른 범위를 입력하지 않은 간단한 에러들이 대부분입니다. 이럴 때는 매번 받아온 데이터를 직접 자바 코드로 작성해서 검증하기보다는 Bean Validation이라는 기술을 사용하면 좀 더 간편하게 데이터를 검증할 수 있습니다. Bean Validation을 소개하고 사용하는 방법, 그리고 단점과 해결법까지 알아보도록 하겠습니다.

Bean Validation 이란?

Bean Validation 이란 특정한 구현체를 말하는 것이 아니라 Bean Validation 2.0(JSR-380)이라는 기술 표준을 의미합니다. 검증기능을 위한 검증 애노테이션과 여러 인터페이스들이 모여있습니다. Bean Validation을 구현한 구현체 중 하나가 하이버네이트 Validation입니다.

하이버네이트 Validator 검증 애노테이션은 다음 문서에 정리되어 있습니다.

 

Hibernate Validator 6.2.5.Final - Jakarta Bean Validation Reference Implementation: Reference Guide

Validating data is a common task that occurs throughout all application layers, from the presentation to the persistence layer. Often the same validation logic is implemented in each layer which is time consuming and error-prone. To avoid duplication of th

docs.jboss.org

Bean Validation 사용하기

Bean Validation 의존관계 추가

Bean Validation을 사용하기 위해서는 먼저 build.gradle 파일에 다음 의존관계를 추가해주어야 합니다.

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

그러면 Jakarta Bean Validation 라이브러리가 추가됩니다. hibernate validator의 구현체입니다.

애노테이션 적용하기

예를 들어서 Item이라는 클래스가 있다고 할 때 다음과 같이 필드에 애노테이션을 적용할 수 있습니다.

@Data
public class Item {

    @NotNull
    private Long id;

    @NotBlank
    private String itemName;

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

    @NotNull
    @Max(value = 9999)
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

null 이 되어서는 안되는 필드, 빈칸이 되면 안 되는 필드, 값을 입력한다면 그 범위는 어떻게 되어야 하는지 최댓값이 있다면 그 값이 얼마나 되어야 하는지 등

기존에는 @ModelAttribute로 전달받은 객체를 검증하는 Validator를 직접 정의하여 자바코드로 검사하였다면 Bean Validation을 이용하면 컨트롤러 메서드에 @Validated, @Valid가 붙은 인스턴스를 자동으로 검사해 줍니다.

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

검증기 생성하기

Validation 클래스의 buildDefaultValidatorFactory() 클래스 메서드를 사용해서 팩토리를 만든 다음 getValidator() 메서드를 사용해서 validator를 만들 수 있지만 어차피 스프링과 통합해서 사용하면 직접 생성하는 코드를 작성하지 않기 때문에 사용하는 방법정도만 알아두면 된다.

ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
Validator validator = factory.getValidator();
Set<ConstraintVioldation<대충객체이름>> violdations = validator.validate(검증할객체);

스프링과 Bean Validation

그렇다면 스프링과 검증기를 어떻게 사용할 수 있을까? 스프링 부트가 자동으로 글로벌 Validator를 등록해준다. LocalValidatorFactorBean이라는 구현체를 글로벌 Validator로 등록한다. 이 Validator는 아까 보았던 애노테이션을 보고 검증을 수행합니다.

검증기를 자동으로 적용되어 있기 때문에 우리는 @ModelAttribute 로 받아온 데이터나 @RequestBody 로 받아온 데이터를 @Validated, @Valid 애노테이션을 적용하는 것만으로 검증 기능을 사용할 수 있다. 검증오류가 발생하면 FieldError, ObjectError 인스턴스를 생성해서 BindingResult에 담아줍니다.

만약 다른 글로벌 Validator를 등록하고 싶다면 시작 클래스로 가서 나만의 검증기를 반환하도록 하면 도비니다. 그러면 기존에 사용되던 애노테이션기반의 빈 검증기가 동작하지 않습니다.

스프링이 검증하는 순서

@ModelAttribute를 통해서 데이터를 받는 경우를 예시로 들어보면

  1. @ModelAttribute 각가의 필드에 타입변환을 시도한다.
    1. 성공하면 다음으로 이동한다.
    2. 실패하면 typeMismatch로 FieldError를 추가한다.
  2. Validator를 적용한다.

바인딩에 성공한 필드에만 Bean Validation이 적용된다는 점을 주의해야한다. 타입 변환도 성공못해서 바인딩도 못했는데 Validation을 적용하는것은 의미가 없다. 바인딩 받는 값이 정상으로 들어와야 검증도 의미있는 것이다.

Bean Validation이 에러코드를 만드는 법

적합하지 않은 데이터가 입력되었을 때 Bean Validation이 어떤 에러 코드들을 만드는지 로그를 찍어서 살펴보았다.

오류코드가 애노테이션이름과 등록되는 것을 볼수 있다. 예를 들어서 Item 클래스의 itemName 필드에는 @NotBlank애노테이션이 적용되어 있고, Validation에 걸렸을 때 만들어지는 에러코드는 NotBlank라는 오류코드를 기반으로 MessageCodesResolver가 다양한 메세지 코드를 순서대로 생성해준다.

@NotBlank 일 때,

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

MessageCodesResolver가 만들어주는 메세지 코드의 순서들이 이전에 학습한 것과 같음을 알 수 있다.

이러한 메세지 코드를 properties 파일에 등록해두어서 상황에 맞는 에러메세지를 사용할 수 있다.

만들어지는 에러메세지의 파라미터 중에서 {0}은 거의 필드명을 의미하고, 나머지는 애노테이션마다 다르다는 점만 숙지하면 된다.

Bean Validation이 메세지를 찾는 순서

  1. 생성된 메세지 코드 순서대로 messageSource에서 메세지를 찾는다.
  2. messageSource에 없다면 애노테이션의 message 속성을 사용한다.
  3. 라이브러리가 제공하는 기본 값을 사용한다.

애노테이션의 message 속성을 사용해서 메세지를 따로 등록해줄 수도 있다.

@NotNull(message = "값이 null이면 안돼용 ㅜ.ㅜ")
@Range(min = 1000, max = 1000000)
private Integer price;

오브젝트 오류를 처리하는 방법

Bean Validation에 특정 필드가 아닌 오브젝트관련 오류가 발생했을 때는 @ScriptAssert() 와 같은 애노테이션을 사용할 수도 있다.

하지만 실제로 사용해보면 환경에 따라 제한이 많아 대응이 어렵기 때문에 오브젝트 오류 관련부분만 bindingResult에 reject 메서드를 사용하는 등 직접 자바 코드로 작성하는 것을 권장한다고 한다.

동일한 객체를 각각 다른 곳에서 검증해야할 때

개발을 하다보면 동일한 내용(?)을 각각다른 곳에서 검증해야할 때가 있다. 같은 정보를 보여주더라도 각 경우에 따라서 조건이 다를 수 있기 때문이다. 이런경우를 위해서는 두가지 방법을 사용할 수 있다.

  • Bean Validation의 groups 기능을 사용한다.
  • 각각의 경우마다 별도의 모델객체를 만들어서 사용한다.

결론부터 말하면 groups기능보다는 별도의 모델 객체를 만드는 기능을 주로 사용한다.

Bean Validation - groups

groups 기능이란 어떤 검증기능을 어떤 그룹에 적용할지 설정하는 방법이다.

  1. 그룹을 구분할 인터페이스를 정의한다.
  2. 애노테이션에 어떤 그룹이 이 애노테이션으로 검증을 수행할지 groups 옵션을 추가한다.
  3. @Validated 애노테이션에 어떤 그룹에 속하는지를 적어준다. 그러면 그 그룹에 해당하는 애노테이션에 대해서만 검증을 수행한다.
@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 = 1000000)
    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;
    }
}

SaveCheck 그룹에 검증을 적용하고 싶다면 컨트롤러 단계에서 @Validated 애노테이션의 파라미터로 해당 인터페이스의 타입을 넘겨주면 된다.

public String addItemV2(
    @Validated(SaveCheck.class) @ModelAttribute Item item,
    BindingResult bindingResult, 
    RedirectAttributes redirectAttributes) {
...

}

그러면 SaveCheck 그룹이 적용된 애노테이션만 검증하게 된다.

그런데 위의 코드를 보면 알겠지만 groups 기능을 적용했을 때는 코드가 상당히 더러워진다. 따라서 groups 기능 보다는 다음에 소개할 각각의 경우마다 사용할 객체를 따로 만들고, 컨트롤러에서 받아온 결과를 정제해서 필요한 객체를 만드는 방법을 주로 사용한다.

전송 객체 분리

같은 데이터를 조회한다고 해도 경우에 따라서 완전히 다른 데이터가 넘어오는 경우가 있을 수 있다. 예를 들어 유저 정보를 등록한다고 생각해보자. 회원가입을 할 때는 사용자의 이름, 패스워드, 주민번호, 닉네임 그리고 이용약관 동의여부까지 컨트롤러에게 넘어갈 수 있지만, 그 이후에는 마이페이지를 통해서 유저 정보를 클라이언트에게 넘겨준다고 하더라도 그 정보를 모두 사용하지는 않을 것이다. 회원가입에서 입력하지 않았던 정보도 있을 것이다.

그래서 각각의 상황마다 별도의 객체로 데이터를 만드는 방법을 사용하게 된다.

각각의 객체마다 필요한 데이터, 필요한 Bean Validation Annotation 을 적용하고, 받아온 데이터를 컨트롤러에서 필요하다면 정제하면 되는 것이다.

어떠한 상품이 있고, 이 상품을 등록할 때는 id 정보를 사용자가 HTML 폼에 입력하지 않고 null이 전송되지만, 수정할 때는 폼에 드러나고, id 값이 전송된다고 가정해보자. 수정에서는 id 값이 null이 되면 안되기 때문에 id 필드에 @NotNull 애노테이션을 적용하게 되면 새로운 상품을 등록할 때는 값이 null이 되어버리기 때문에 에러가 발생할 것이다.

따라서 폼별로 별도의 객체를 정의해 줄 수 있다.

// 등록 폼
@Data
public class ItemSaveForm {

    @NotBlank
    private String itemName;

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

    @NotNull
    @Max(value = 9999)
    private Integer quantity;

}
// 수정 폼
@Data
public class ItemUpdateForm {

    @NotNull
    private Long id;

    @NotBlank
    private String itemName;

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

    // 수정에서는 수량은 자유롭게 변경할 수 있다.
    private Integer quantity;

}

Bean Validation - HTTP 메세지 컨버터

지금까지의 경우는 쿼리파라미터로 데이터가 넘어오거나, HTML form 방식으로 HTTP Body 메세지에 쿼리파라미터 형식으로 데이터가 전달된 경우에 대해서 다룬 것이다.

이번에는 이러한 두 방식이 아닌 HTTP Body의 데이터를 객체로 변환하는 @RequestBody의 경우를 알아보자.

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

  1. 성공한 경우
  2. JSON을 객체로 변환하는 것 자체를 실패한 경우
  3. JSON을 객체로 변환하는 것은 성공 했지만 검증에서 실패한 경우

성공한 경우

성공한 경우에는 객체로 잘 변환해준다… 정해진 API 스펙에 맞춰서 데이터를 잘 보내주면 된다. 끝이다…

JSON 객체변환 실패

HttpMessageConverter에서 요청으로 온 JSON을 객체로 만드는데 실패한 경우에는 컨트롤러 자체가 호출되지 않는다.

  • HTTP 요청 파라미터를 처리하는 @ModelAttribute는 필드 단위로 적용되기 때문에 메서드에 BindingResult 파라미터만 있으면 타입에 맞지 않는 데이터가 발생했을 때 그것을 FieldError 인스턴스로 만들고 BindingResult에 넣어주고 컨트롤러를 호출해준다. 따라서 Validator를 이용한 검증도 적용할 수 있다.
  • 하지만 @RequestBody의 경우에는 HttpMessageConverter에서 JSON을 객체로 변경하는 것을 실패하면 이후 단계 자체가 진행되지 않고 예외가 발생한다는 점을 이해해야한다. 컨트롤러도 호출되지 않고, Validator도 적용할 수 없다. 이러한 예외가 발생했을 때의 대응 방법은 이후에 알아보자.

검증실패

검증에 실패하면 bindingResult를 통해서 에러객체에 접근할 수 있고, 이를 이용해서 API 스펙에 맞게 데이터를 반환하면 된다.