이번 미션에서 클라이언트의 요청에 대한 최소한의 검증을 컨트롤러에서 처리하기 위해 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
은 Hibernate Validator
를 사용해 자바 빈을 검증하는 기능을 제공한다고 명시되어 있습니다.
그렇다면 이 spring-boot-starter-validation
를 살펴보기 위해서는 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 Model
과 API
를 제공합니다.
Hibernate Validator 8
과 Jakarta 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 constraints, Additional constraints에서 확인해주세요.
이름 | 설명 |
---|---|
@NotNull | 해당 필드가 null일 경우 예외 발생 |
@NotBlank | 해당 필드가 null이거나 빈 문자열일 경우 예외 발생 |
@NotEmpty | 해당 필드가 null이거나 빈 컬렉션 또는 배열인 경우 예외 발생 |
@Size | 해당 필드의 크기가 지정한 범위를 벗어나는 경우 예외 발생 |
@Min, @Max | 해당 필드가 지정한 최소값 또는 최대값을 벗어나는 경우 예외 발생 |
@Pattern | 해당 필드의 값이 지정한 정규식 패턴과 일치하지 않는 경우 예외 발생 |
해당 필드의 값이 이메일 주소 형식과 일치하지 않는 경우 예외 발생 | |
@Positive | 해당 필드의 값이 양수가 아닌 경우 예외 발생 |
@PositiveOrZero | 해당 필드의 값이 양수, 0이 아닌 경우 예외 발생 |
@Negative | 해당 필드의 값이 음수가 아닌 경우 예외 발생 |
@NegativeOrZero | 해당 필드의 값이 음수, 0이 아닌 경우 예외 발생 |
기본적으로 다음과 같은 코드를 통해 Validator
의 인스턴스를 조회할 수 있습니다.
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
validator = factory.getValidator();
Validator
를 통해 다양한 방식으로 제약조건을 검증할 수 있으며, 검증의 결과는 항상 Set<ConstraintViolation>
를 반환합니다.
해당 Set
이 비어 있는 경우 검증에 성공한 상황입니다.
해당 메소드를 사용하는 경우, 지정한 대상의 모든 제약 조건을 검증합니다.
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());
해당 메소드를 사용하는 경우, 지정한 대상의 특정 필드에 대한 제약 조건을 검증합니다.
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());
해당 메소드를 사용하는 경우, 지정한 대상의 특정 필드에 대한 값을 지정하고 지정한 값이 제약 조건을 만족하는지 검증할 수 있습니다.
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
는 검증 실패 원인에 대한 유용한 정보를 확인할 수 있습니다.
검증 실패 원인 메세지를 String
타입으로 반환합니다.
"must not be null"
보간이 적용되지 않은 검증 실패 원인 메세지를 String
타입으로 반환합니다.
"{NotNull.message}"
검증에 실패한 값을 반환합니다.
검증에 실패한 대상의 Metadata
를 ConstraintDescriptor<?>
타입으로 반환합니다.
메세지 보간(Message Interpolation
)은 제약조건 검증이 실패한 경우 오류 메세지를 생성하는 로직입니다.
제약 조건은 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
에는 메시지 매개변수뿐만 아니라 보간 중에 변환될 메세지 표현식도 포함될 수 있습니다.
메세지 매개변수는 {}
로 묶인 문자열 리터럴이며, 메세지 표현식은 ${}
로 묶인 문자열 리터럴입니다.
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 Interpolator
를 구현할 수 있습니다.
Custom Interpolator
는 jakarta.validation.MessageInterpolator
인터페이스를 thread-safe
하게 구현해야 합니다.
직접 MessageInterpolator
를 구현하는 것 보다는, Configuration.getDefaultMessageInterpolator()
에서 Default 보간기
를 통해 구현하는 것을 권장합니다.
기본 ValidationMessages
가 아닌 다른 Resource Bundle
을 사용하려고 한다면, ResourceBundleLocator
를 사용할 수 있습니다.
ResourceBundleMessageInterpolator
는 Resource Bundle
의 검색을 해당 SPI
에 위임합니다.
이를 활용한 예제는 다음과 같습니다.
Validator validator = Validation.byDefaultProvider()
.configure()
.messageInterpolator(
new ResourceBundleMessageInterpolator(
new PlatformResourceBundleLocator( "MyMessages" )
)
)
.buildValidatorFactory()
.getValidator();
위에서 학습한 내용을 토대로 간단한 테스트 코드를 작성했습니다.
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);
}
}
스프링 부트에서는 ValidationAutoConfiguration
에서 LocalValidatorFactoryBean
를 빈으로 등록합니다.
InterpolatorFactory
에서 Interpolator
를 조회해 지정하고 반환해주는 것을 확인할 수 있습니다.
문서를 확인해보면, 다음과 같이 설명하고 있습니다.
Spring의 ApplicationContext에서 jakarta.validation(JSR-303) 설정을 위한 핵심 클래스(central class)입니다.
모든 Validator 타입의 종속성에 주입할 수 있습니다.
jakarta.validation API가 존재하지만 명시적으로 Validator가 구성되지 않은 경우 Spring의 MVC 구성 네임스페이스에서도 사용됩니다.
설명에서부터 Validator
를 직접 빈으로 등록하지 않으면 사용된다고 언급되어 있습니다.
setProviderClass()
메소드 설명을 통해 기본으로 JSR-303의 Validator
가 사용되는 것을 확인할 수 있습니다.
문서를 확인하보면, 다음과 같이 설명하고 있습니다.
classpath에 따라 가장 적합한 MessageInterpolator를 선택합니다.
주어진 MessageSource를 사용하여 메시지 매개 변수를 해결하는 Interpolator를 생성하는 새로운 MessageInterpolatorFactory를 생성합니다.
생성자를 확인해보니 전달한 MessageSource
를 활용해 Interpolator
를 생성하는 Factory
를 생성한다고 합니다.
ApplicationContext
를 전달했으니, 애플리케이션에서 읽어온 MessageSource
를 분석해 이를 처리할 수 있는 InterpolatorFactory
를 생성하는 것으로 보입니다.
MessageSourceMessageInterpolator
를 반환하고 이를 Interpolator
로 지정하는 것을 확인할 수 있습니다.
현재 @RequestBody
를 통해 DTO
로 바인딩하는 작업을 진행하고 있습니다.
이 작업은 HandlerMethodArgumentResolver
의 구현체 중 하나인 RequestResponseBodyMethodProcessor
에서 진행합니다.
이름에서도 알 수 있듯이, validateIfApplicable()
에서 검증을 수행합니다.
DataBinder
에 저장되어 있는 Validator
를 통해 검증을 수행합니다.
검증의 결과는 WebDataBinder
에 저장됩니다.
Hibernate Validator
문서에서는 Validator
를 가져오기 위해 Validation.byDefaultProvider()
를 사용했지만, 스프링 부트의 Auto Configuration
에서는 LocalValidatorFactoryBean
을 등록하고 있습니다.
그렇다면 Custom Validator
를 Bean
으로 등록하려면 어떠한 방식을 사용하는 것이 좋을지 궁금했습니다.
간단하게 둘을 비교해보면 다음과 같습니다.
Validation.byDefaultProvider()
validation.xml
사용Validator
인스턴스를 얻을 수 있는 방법LocalValidatorFactoryBean
Validator
를 구현제 경우 LocalValidatorFactoryBean
을 스프링에서 제공해주는 만큼, 스프링에 최적화된 기능을 제공하기 때문에 LocalValidatorFactoryBean
을 사용하겠다고 결정을 내렸습니다.
지금 상황에서 Controller
에서 DTO
를 검증하기 위해 @Valid
를 명시한 상황입니다.
이 경우 @Valid
를 사용해도, @Validated
를 사용해도 무방합니다.
그래서 지금 상황에서는 어떤 애노테이션을 사용하는 것이 좋을지 궁금했습니다.
이번에도 간단하게 둘을 비교해보겠습니다.
@Valid
JSR-303/349
표준@Validated
의존성 관련된 부분은 지금 당장의 경우 스프링을 사용하고 있다 보니 큰 상관이 없다고 느꼈습니다.
@Validated
가 여러 기능을 지원한다고 하지만, 자칫하면 코드가 복잡해질 수 있기 때문에 반드시 필요한 상황이 아니라면 굳이 사용하지 않아 지금은 고려 대상이 아니라고 판단했습니다.
결국 용도
에서 결정해야 한다고 생각했는데, 이 경우 @Validated
보다는 용도가 좁은 @Valid
를 사용하는 것이 의도를 조금 더 명확하다고 판단해 앞으로는 @Valid
를 사용하려고 합니다.