Validator 학습 로그

appti·2023년 4월 21일
1

학습 로그

목록 보기
4/8

Validator

서론

이번 미션에서 클라이언트의 요청에 대한 최소한의 검증을 컨트롤러에서 처리하기 위해 spring-boot-starter-validation 의존성을 추가해 사용했습니다.

public class GameRequest {

    @NotBlank(message = "공백은 입력할 수 없습니다. 입력 값 : ${validatedValue}")
    private final String names;

    @Positive(message = "1 미만의 값은 입력할 수 없습니다. 입력 값 : ${validatedValue}")
    private final int count;

    public GameRequest(final String names, final int count) {
        this.names = names;
        this.count = count;
    }

    public String getNames() {
        return names;
    }

    public int getCount() {
        return count;
    }
}

@PostMapping("/plays")
public ResponseEntity<GameResponse> plays(@RequestBody @Valid final GameRequest gameRequest) {
    ...
}

당시에는 단순히 사용법만 학습하고 넘어갔기 때문에, 미션에서 사용했던 방식인 필드에 지정하는 방식을 위주로 공식 문서를 확인해보면서 조금만 더 자세히 살펴보고자 합니다.

다만, 다음에 대해서는 이 글에서는 다루지 않고 나중에 다룰 예정입니다.

  • Custom Validator
  • Spring MessageSource & Message Interpolation

spring-boot-starter-validation

spring-boot-starter-validationHibernate Validator를 사용해 자바 빈을 검증하는 기능을 제공한다고 명시되어 있습니다.

그렇다면 이 spring-boot-starter-validation를 살펴보기 위해서는 Hibernate Validator를 확인할 필요가 있어 보입니다.

Hibernate Validator 문서

자세한 내용은 Hibernate Validator 8.0.0.Final - Jakarta Bean Validation Reference Implementation: Reference Guide를 확인해주세요.

등장 배경

Web Application은 대부분 다음과 같이 Layered Architecture를 적용한 경우가 많습니다.

만약 데이터의 안정성을 우선시한다면, Client의 요청에서부터 각 Layer 별로 데이터를 전송하면서 검증이 필요할 것입니다.

이 경우 각 Layer에서 동일한 내용의 검증 로직이 구현되는 경우가 많기 때문에 개발 생산성이 떨어지고 실수할 확률이 높습니다.

Jakarta Bean Validation 3.0은 이러한 단점을 개선하기 위해 Entity에서부터 Method까지의 검증 로직을 위한 Metadata ModelAPI를 제공합니다.

스펙

Hibernate Validator 8Jakarta Bean Validation 3.0을 사용하고자 한다면 자바 11 이상이 필요합니다.

사용 방법

검증하고자 하는 필드에 애노테이션을 통해 명시하는 경우, 유효성 검사 엔진은 getter / setter를 호출하지 않고 직접 필드에 접근해 검증 로직을 수행합니다.

그러므로 검증 시 어떠한 접근 제어자라도 값에 직접 접근하고 검증할 수 있습니다.
단, static 필드에 대해서는 검증할 수 없습니다.

객체 그래프 검증(중첩 검증)

public class Car {

    @NotNull
    @Valid
    private Person driver;

    //...
}

public class Person {

    @NotNull
    private String name;

    //...
}

위의 예제의 경우 Car에 대한 검증이 성공하면 Person에 대한 검증이 진행됩니다.

즉, 검증의 대상이 검증을 명시하는 재귀적인 상황에도 모든 검증이 진행됩니다.
만약 두 객체가 서로의 참조를 가지고 있어 무한 루프가 발생할 수 있는 경우, 유효성 검사 엔진이 이를 방지합니다.

Jakarta Bean Validation 제약조건

해당 내용은 자주 사용하는 애노테이션에 대한 내용만 간단히 정리하도록 하겠습니다.

자세한 내용은 Jakarta Bean Validation constraints, Additional constraints에서 확인해주세요.

이름설명
@NotNull해당 필드가 null일 경우 예외 발생
@NotBlank해당 필드가 null이거나 빈 문자열일 경우 예외 발생
@NotEmpty해당 필드가 null이거나 빈 컬렉션 또는 배열인 경우 예외 발생
@Size해당 필드의 크기가 지정한 범위를 벗어나는 경우 예외 발생
@Min, @Max해당 필드가 지정한 최소값 또는 최대값을 벗어나는 경우 예외 발생
@Pattern해당 필드의 값이 지정한 정규식 패턴과 일치하지 않는 경우 예외 발생
@Email해당 필드의 값이 이메일 주소 형식과 일치하지 않는 경우 예외 발생
@Positive해당 필드의 값이 양수가 아닌 경우 예외 발생
@PositiveOrZero해당 필드의 값이 양수, 0이 아닌 경우 예외 발생
@Negative해당 필드의 값이 음수가 아닌 경우 예외 발생
@NegativeOrZero해당 필드의 값이 음수, 0이 아닌 경우 예외 발생

Validator

기본적으로 다음과 같은 코드를 통해 Validator의 인스턴스를 조회할 수 있습니다.

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

Validator를 통해 다양한 방식으로 제약조건을 검증할 수 있으며, 검증의 결과는 항상 Set<ConstraintViolation>를 반환합니다.

해당 Set이 비어 있는 경우 검증에 성공한 상황입니다.

validate()

해당 메소드를 사용하는 경우, 지정한 대상의 모든 제약 조건을 검증합니다.

Car car = new Car( null, true );

Set<ConstraintViolation<Car>> constraintViolations = validator.validate(car);

assertEquals(1, constraintViolations.size());
assertEquals("must not be null", constraintViolations.iterator().next().getMessage());

validateProperty()

해당 메소드를 사용하는 경우, 지정한 대상의 특정 필드에 대한 제약 조건을 검증합니다.

Car car = new Car(null, true);

Set<ConstraintViolation<Car>> constraintViolations = validator.validateProperty(
        car,
        "manufacturer"
);

assertEquals(1, constraintViolations.size());
assertEquals("must not be null", constraintViolations.iterator().next().getMessage());

validateValue()

해당 메소드를 사용하는 경우, 지정한 대상의 특정 필드에 대한 값을 지정하고 지정한 값이 제약 조건을 만족하는지 검증할 수 있습니다.

Set<ConstraintViolation<Car>> constraintViolations = validator.validateValue(
        Car.class,
        "manufacturer",
        null
);

assertEquals(1, constraintViolations.size());
assertEquals("must not be null", constraintViolations.iterator().next().getMessage());

ConstraintViolation

모든 제약조건 검증 메소드는 ConstraintViolation를 반환합니다.

ConstraintViolation는 검증 실패 원인에 대한 유용한 정보를 확인할 수 있습니다.

getMessage()

검증 실패 원인 메세지를 String 타입으로 반환합니다.

"must not be null"

getMessageTemplate()

보간이 적용되지 않은 검증 실패 원인 메세지를 String 타입으로 반환합니다.

"{NotNull.message}"

getInvalidValue()

검증에 실패한 값을 반환합니다.

getConstraintDescriptor()

검증에 실패한 대상의 MetadataConstraintDescriptor<?> 타입으로 반환합니다.

메세지 보간(Interpolation)

메세지 보간(Message Interpolation)은 제약조건 검증이 실패한 경우 오류 메세지를 생성하는 로직입니다.

default 메세지 보간

제약 조건은 message 속성을 사용하여 default 메세지를 정의할 수 있습니다.

public class Car {

    @NotNull(message = "The manufacturer name must not be null")
    private String manufacturer;
}

위 예제는 @NotNull 제약조건이 위배된 경우 MessageInterpolator를 사용해 message 속성에 지정한 default 메세지를 보간합니다.

보간된 메세지는 ConstraintViolation.getMessage()를 호출해 확인할 수 있습니다.

Message descriptors에는 메시지 매개변수뿐만 아니라 보간 중에 변환될 메세지 표현식도 포함될 수 있습니다.
메세지 매개변수는 {}로 묶인 문자열 리터럴이며, 메세지 표현식은 ${}로 묶인 문자열 리터럴입니다.

메세지 표현식(message expressions)을 활용한 메세지 보간

Hibernate Validator Jakarta Expression Language를 통해 메세지 표현식을 활용할 수 있습니다.

유효성 검사 엔진은 Jakarta EL Context에서 다음과 같은 객체를 사용할 수 있게 합니다.

  • Annotation 속성에 매핑된 제약조건의 속성 값
  • 현 시점에서 검증된 대상(여기서는 요청을 매핑하는 필드)의 값을 validatedValue라는 이름으로 접근 가능
  • java.util.Formatter.format(String format, Object… args)와 같이 가변 인자를 받을 수 있는 name formatter에 매핑된 빈

예제

public class Car {

    @NotNull
    private String manufacturer;

    @Size(
            min = 2,
            max = 14,
            message = "The license plate '${validatedValue}' must be between {min} and {max} characters long"
    )
    private String licensePlate;

    @Min(
            value = 2,
            message = "There must be at least {value} seat${value > 1 ? 's' : ''}"
    )
    private int seatCount;

    @DecimalMax(
            value = "350",
            message = "The top speed ${formatter.format('%1$.2f', validatedValue)} is higher " +
                    "than {value}"
    )
    private double topSpeed;

    @DecimalMax(value = "100000", message = "Price must not be higher than ${value}")
    private BigDecimal price;

    public Car(
            String manufacturer,
            String licensePlate,
            int seatCount,
            double topSpeed,
            BigDecimal price) {
        this.manufacturer = manufacturer;
        this.licensePlate = licensePlate;
        this.seatCount = seatCount;
        this.topSpeed = topSpeed;
        this.price = price;
    }

    //getters and setters ...
}
  • @NotNull
    • 메세지 속성에 특정 값이 명시되지 않았기 때문에 Jakarta Bean 유효성 검사의 default 메세지가 출력
  • @Size
    • 메세지 매개 변수({min}, {max})의 보간과 EL Expression${validatedValue}를 사용했으므로 이를 보간한 메세지가 출력
  • @Min
    • 삼항식이 지정된 EL Expression을 사용해 메세지를 동적으로 선택해 메세지를 보간
  • @DecimalMax
    • topSpeed에 대한 메세지의 경우 Formatter를 활용해 메세지의 형식 지정
    • price에 대한 메세지의 경우 EL Expression 사용

이 코드에 대한 테스트 코드는 다음과 같습니다.

Car car = new Car(null, "A", 1, 400.123456, BigDecimal.valueOf(200000));

String message = validator.validateProperty(car, "manufacturer")
        .iterator()
        .next()
        .getMessage();
assertEquals("must not be null", message);

message = validator.validateProperty(car, "licensePlate")
        .iterator()
        .next()
        .getMessage();
assertEquals(
        "The license plate 'A' must be between 2 and 14 characters long",
        message
);

message = validator.validateProperty(car, "seatCount").iterator().next().getMessage();
assertEquals("There must be at least 2 seats", message);

message = validator.validateProperty(car, "topSpeed").iterator().next().getMessage();
assertEquals("The top speed 400.12 is higher than 350", message);

message = validator.validateProperty(car, "price").iterator().next().getMessage();
assertEquals("Price must not be higher than $100000", message);

Custom Message Interpolation

필요한 경우, 메세지를 보간하기 위한 Custom Interpolator를 구현할 수 있습니다.

Custom Interpolator jakarta.validation.MessageInterpolator 인터페이스를 thread-safe하게 구현해야 합니다.

직접 MessageInterpolator를 구현하는 것 보다는, Configuration.getDefaultMessageInterpolator()에서 Default 보간기를 통해 구현하는 것을 권장합니다.

ResourceBundleLocator

기본 ValidationMessages가 아닌 다른 Resource Bundle을 사용하려고 한다면, ResourceBundleLocator를 사용할 수 있습니다.

ResourceBundleMessageInterpolatorResource Bundle의 검색을 해당 SPI에 위임합니다.

이를 활용한 예제는 다음과 같습니다.

Validator validator = Validation.byDefaultProvider()
        .configure()
        .messageInterpolator(
                new ResourceBundleMessageInterpolator(
                        new PlatformResourceBundleLocator( "MyMessages" )
                )
        )
        .buildValidatorFactory()
        .getValidator();

GameRequest 간단 테스트

위에서 학습한 내용을 토대로 간단한 테스트 코드를 작성했습니다.

class GameRequestTest {

    private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();

    @Test
    void names_and_count_success_test() {
        final GameRequest gameRequest = new GameRequest("a,b", 10);

        final Set<ConstraintViolation<GameRequest>> result = validator.validate(gameRequest);

        assertThat(result).isEmpty();
    }

    @Test
    void names_fail_count_success_test() {
        final GameRequest gameRequest = new GameRequest(" ", 10);

        final Set<ConstraintViolation<GameRequest>> result = validator.validate(gameRequest);

        assertAll(
                () -> assertThat(result).hasSize(1),
                () -> assertThat(result.iterator().next().getMessage()).contains("공백은 입력할 수 없습니다. 입력 값 :  ")
        );
    }

    @Test
    void names_success_count_fail_test() {
        final GameRequest gameRequest = new GameRequest("a,b", -1);

        final Set<ConstraintViolation<GameRequest>> result = validator.validate(gameRequest);

        assertAll(
                () -> assertThat(result).hasSize(1),
                () -> assertThat(result.iterator().next().getMessage()).contains("1 미만의 값은 입력할 수 없습니다. 입력 값 : -1")
        );
    }

    @Test
    void names_and_count_fail_test() {
        final GameRequest gameRequest = new GameRequest(" ", -1);

        final Set<ConstraintViolation<GameRequest>> result = validator.validate(gameRequest);

        assertThat(result).hasSize(2);
    }
}

스프링 부트

Auto Configuration

스프링 부트에서는 ValidationAutoConfiguration에서 LocalValidatorFactoryBean를 빈으로 등록합니다.

InterpolatorFactory에서 Interpolator를 조회해 지정하고 반환해주는 것을 확인할 수 있습니다.

LocalValidatorFactoryBean

문서를 확인해보면, 다음과 같이 설명하고 있습니다.

Spring의 ApplicationContext에서 jakarta.validation(JSR-303) 설정을 위한 핵심 클래스(central class)입니다.
모든 Validator 타입의 종속성에 주입할 수 있습니다.
jakarta.validation API가 존재하지만 명시적으로 Validator가 구성되지 않은 경우 Spring의 MVC 구성 네임스페이스에서도 사용됩니다.

설명에서부터 Validator를 직접 빈으로 등록하지 않으면 사용된다고 언급되어 있습니다.

setProviderClass() 메소드 설명을 통해 기본으로 JSR-303의 Validator가 사용되는 것을 확인할 수 있습니다.

MessageInterpolatorFactory

문서를 확인하보면, 다음과 같이 설명하고 있습니다.

classpath에 따라 가장 적합한 MessageInterpolator를 선택합니다.

주어진 MessageSource를 사용하여 메시지 매개 변수를 해결하는 Interpolator를 생성하는 새로운 MessageInterpolatorFactory를 생성합니다.

생성자를 확인해보니 전달한 MessageSource를 활용해 Interpolator를 생성하는 Factory를 생성한다고 합니다.

ApplicationContext를 전달했으니, 애플리케이션에서 읽어온 MessageSource를 분석해 이를 처리할 수 있는 InterpolatorFactory를 생성하는 것으로 보입니다.

MessageSourceMessageInterpolator를 반환하고 이를 Interpolator로 지정하는 것을 확인할 수 있습니다.

디버깅

현재 @RequestBody를 통해 DTO로 바인딩하는 작업을 진행하고 있습니다.

이 작업은 HandlerMethodArgumentResolver의 구현체 중 하나인 RequestResponseBodyMethodProcessor에서 진행합니다.

이름에서도 알 수 있듯이, validateIfApplicable()에서 검증을 수행합니다.

DataBinder에 저장되어 있는 Validator를 통해 검증을 수행합니다.

검증의 결과는 WebDataBinder에 저장됩니다.

궁금했던 내용

Validation.byDefaultProvider()? LocalValidatorFactoryBean?

Hibernate Validator 문서에서는 Validator를 가져오기 위해 Validation.byDefaultProvider()를 사용했지만, 스프링 부트의 Auto Configuration에서는 LocalValidatorFactoryBean을 등록하고 있습니다.

그렇다면 Custom ValidatorBean으로 등록하려면 어떠한 방식을 사용하는 것이 좋을지 궁금했습니다.

간단하게 둘을 비교해보면 다음과 같습니다.

  • Validation.byDefaultProvider()
    • 기본적으로 검증을 위해 validation.xml 사용
      • 없는 경우 기본 설정 사용
    • 가장 간단하게 Validator 인스턴스를 얻을 수 있는 방법
    • 어느 정도 유연한 설정 가능
  • LocalValidatorFactoryBean
    • 직접 Validator를 구현
    • 유연한 설정 가능
    • 스프링에 최적화된 기능 제공

제 경우 LocalValidatorFactoryBean을 스프링에서 제공해주는 만큼, 스프링에 최적화된 기능을 제공하기 때문에 LocalValidatorFactoryBean을 사용하겠다고 결정을 내렸습니다.

@Valid? @Validated?

지금 상황에서 Controller에서 DTO를 검증하기 위해 @Valid를 명시한 상황입니다.
이 경우 @Valid를 사용해도, @Validated를 사용해도 무방합니다.

그래서 지금 상황에서는 어떤 애노테이션을 사용하는 것이 좋을지 궁금했습니다.

이번에도 간단하게 둘을 비교해보겠습니다.

  • @Valid
    • JSR-303/349 표준
    • 객체 필드, 메소드 파라미터 검증 가능
  • @Validated
    • 스프링에서 제공하는 애노테이션
    • 객체 필드, 메소드 파라미터, 메소드 레벨, 클래스 레벨 검증 가능
    • 추가적인 기능 지원
      • 유효성 검증 그룹 지정 기능 등

의존성 관련된 부분은 지금 당장의 경우 스프링을 사용하고 있다 보니 큰 상관이 없다고 느꼈습니다.

@Validated가 여러 기능을 지원한다고 하지만, 자칫하면 코드가 복잡해질 수 있기 때문에 반드시 필요한 상황이 아니라면 굳이 사용하지 않아 지금은 고려 대상이 아니라고 판단했습니다.

결국 용도에서 결정해야 한다고 생각했는데, 이 경우 @Validated보다는 용도가 좁은 @Valid를 사용하는 것이 의도를 조금 더 명확하다고 판단해 앞으로는 @Valid를 사용하려고 합니다.

profile
안녕하세요

0개의 댓글