Validation은 개발에 있어서 가장 중요한 부분중 하나다.
외부로부터 들어온 요청에 어떤 값이 포함될지 모르고, 이 잘못된 값으로 작업이 진행되면 위험한 상태가 될 수 있기 때문이다.
spring을 사용할때는 주로 Jakarta Bean Validation의 구현체인 Hibernate Validator를 사용한다.
그렇기 때문에 먼저 Hibernate Validator에 대해서 먼저 알아보자.
validation을 사용하기 위해서는 다음 의존성을 추가해준다.
implementation("org.hibernate.validator:hibernate-validator:6.1.2.Final")
implementation("org.glassfish:jakarta.el:3.0.3")
Jakarta bean validation을 이용하면 어노테이션을 기반으로 편하게 Validation을 체크할 수 있다.
다양한 어노테이션을 이용해서 아래와 같이 테스트를 진행해봤다.
fun main(args: Array<String>) {
val article = Article(
articleNo = -1,
title = "abc",
description = " ",
createdAt = LocalDateTime.now().minusSeconds(1),
publishedOn = LocalDateTime.now(),
score = 11
)
val validatorFactory = Validation.buildDefaultValidatorFactory()
val validator = validatorFactory.validator
val constraints = validator.validate(article)
constraints.forEach(System.out::println)
}
data class Article(
@Positive
private val articleNo: Int,
@Length(min = 4, max = 20)
private val title: String,
@NotBlank
@Length(max = 500)
private val description: String,
@FutureOrPresent
private val createdAt: LocalDateTime,
@FutureOrPresent
private val publishedOn: LocalDateTime,
@Min(0)
@Max(10)
private val score: Int
)
분명 제약조건을 위반했음에도 결과로 나오는 내용이 없다.
왜 그럴까?
어노테이션을 위 처럼 걸면 생성자의 파라미터에 설정되는것이고 실제로 우리가 원하는건 필드나 getter에 걸고 싶기때문에 field:
를 명시해줘야 한다.
어노테이션을 다시 설정하고 실행해보면 정상적으로 동작하는 것을 알 수 있다.
data class Article(
@field:Positive
private val articleNo: Int,
@field:Length(min = 4, max = 20)
private val title: String,
@field:NotBlank
@field:Length(max = 500)
private val description: String,
@field:FutureOrPresent
private val createdAt: LocalDateTime,
@field:FutureOrPresent
private val publishedOn: LocalDateTime,
@field:Min(0)
@field:Max(10)
private val score: Int
)
실행결과
ConstraintViolationImpl{interpolatedMessage='현재 또는 미래의 날짜여야 합니다', propertyPath=createdAt, rootBeanClass=class io.freddie.kotlinstudy.validation.Article, messageTemplate='{javax.validation.constraints.FutureOrPresent.message}'}
ConstraintViolationImpl{interpolatedMessage='길이가 4에서 20 사이여야 합니다', propertyPath=title, rootBeanClass=class io.freddie.kotlinstudy.validation.Article, messageTemplate='{org.hibernate.validator.constraints.Length.message}'}
ConstraintViolationImpl{interpolatedMessage='10 이하여야 합니다', propertyPath=score, rootBeanClass=class io.freddie.kotlinstudy.validation.Article, messageTemplate='{javax.validation.constraints.Max.message}'}
ConstraintViolationImpl{interpolatedMessage='0보다 커야 합니다', propertyPath=articleNo, rootBeanClass=class io.freddie.kotlinstudy.validation.Article, messageTemplate='{javax.validation.constraints.Positive.message}'}
ConstraintViolationImpl{interpolatedMessage='공백일 수 없습니다', propertyPath=description, rootBeanClass=class io.freddie.kotlinstudy.validation.Article, messageTemplate='{javax.validation.constraints.NotBlank.message}'}
ConstraintViolationImpl{interpolatedMessage='현재 또는 미래의 날짜여야 합니다', propertyPath=publishedOn, rootBeanClass=class io.freddie.kotlinstudy.validation.Article, messageTemplate='{javax.validation.constraints.FutureOrPresent.message}'}
중첩 클래스의 경우는 @Valid
어노테이션을 사용하여 검증할 수 있다.
data class Article(
@field:Positive
private val articleNo: Int,
@field:Length(min = 4, max = 20)
private val title: String,
@field:NotBlank
@field:Length(max = 500)
private val description: String,
@field:FutureOrPresent
private val createdAt: LocalDateTime,
@field:FutureOrPresent
private val publishedOn: LocalDateTime,
@field:Min(0)
@field:Max(10)
private val score: Int,
@field:Valid
private val images: List<Image>
)
data class Image(
@field:URL(message = "이미지 URL형식이어야 합니다.")
private val url: String
)
validate를 호출한 결과를 보면 Set<ConstraintViolation>
으로 결과를 반환한다.
이렇게해야 검증에 실패한걸 하나씩 확인하는게 아니라 한번에 확인이 가능하기 때문이다.
위에서 검증 결과를 보면 ConstraintViolationImpl
로 결과를 반환하는데, 여기에 포함된 속성을 살펴보자.
data class Image(
@field:URL(message = "이미지 URL형식이어야 합니다.")
private val url: String
)
실행결과
ConstraintViolationImpl{interpolatedMessage='이미지 URL형식이어야 합니다.', propertyPath=images[0].url, rootBeanClass=class io.freddie.kotlinstudy.validation.Article, messageTemplate='이미지 URL형식이어야 합니다.'}
그런데 코드에 직접 메시지를 적는건 다국어 처리가 어려워서 별로 좋지 않은 방법이다.
다국어 처리를 포함해서 메시지를 새로 정의하려면 ValidationMessages_ko.properties
처럼 파일을 만들고 message를 추가해준다.
그리고 여기 추가된 메시지의 키를 어노테이션에 적용해주면 된다.
ValidationMessage.properties
Article.Length.message="아티클 제목은 4~20 이내여야 합니다"
Article.kt
data class Article(
@field:Positive
private val articleNo: Int,
@field:Length(min = 4, max = 20, message = "{Article.Length.message}")
private val title: String,
@field:NotBlank
@field:Length(max = 500)
private val description: String,
@field:FutureOrPresent
private val createdAt: LocalDateTime,
@field:FutureOrPresent
private val publishedOn: LocalDateTime,
@field:Min(0)
@field:Max(10)
private val score: Int,
@field:Valid
private val images: List<Image>
)
실행결과
ConstraintViolationImpl{interpolatedMessage='"아티클 제목은 4~20 이내여야 합니다"', propertyPath=title, rootBeanClass=class io.freddie.kotlinstudy.validation.Article, messageTemplate='{Article.Length.message}'}
커스텀 어노테이션을 사용해서 Validation처리를 할 수 있다.
입력하면 안되는 단어가 description에 포함된 경우를 검증하기 위해서 DescriptionValidWord
라는 어노테이션을 만들어 보자.
DescriptionValidWord
@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [DescriptionValidator::class])
annotation class DescriptionValidWord(
val message: String = "{DescriptionValidWord}",
val groups: Array<KClass<*>> = [],
val payload: Array<KClass<out Payload>> = [],
val invalidWord: Array<String> = []
)
validatedBy 속성을 통해 어떤 Validator를 이용해서 이 어노테이션을 검증할지 정할 수 있다.
아래처럼 ConstraintValidator를 구현해서 만들어주자
DescriptionValidator
class DescriptionValidator : ConstraintValidator<DescriptionValidWord, String> {
private lateinit var invalidWord: Array<String>
override fun isValid(value: String?, context: ConstraintValidatorContext): Boolean {
if (value == null) {
return true
}
return invalidWord.find {
value.contains(it)
} == null
}
override fun initialize(constraintAnnotation: DescriptionValidWord) {
invalidWord = constraintAnnotation.invalidWord
}
}
Test
data class Article(
@field:NotBlank
@field:Length(max = 500)
@field:DescriptionValidWord(invalidWord = ["money", "bad"])
private val description: String
)
fun main(args: Array<String>) {
val article = Article(
description = "I want a lot of money."
)
val validatorFactory = Validation.buildDefaultValidatorFactory()
val validator = validatorFactory.validator
val constraints = validator.validate(article)
constraints.forEach(System.out::println)
}
ValidationMessages.properties
DescriptionValidWord="잘못된 문자가 포함되어 있습니다[${validatedValue} contains {invalidWord}]."
Message 처리와도 관련된 내용인데, ${}
을 사용하면 message interpolation을 할 수 있다.
validatedValue는 검증을 한 대상값이 들어가고, 어노테이션의 속성값을 가져와서 출력할수도 있다.
어노테이션 속성이 아닌 외부의 값을 가져오려면 ${}
을 사용해야 하고 아닌 경우에는 {}
만 사용해도 된다.
실행결과
ConstraintViolationImpl{interpolatedMessage='"잘못된 문자가 포함되어 있습니다[I want a lot of money. contains $[money, bad]]."', propertyPath=description, rootBeanClass=class io.freddie.kotlinstudy.validation.Article, messageTemplate='{DescriptionValidWord}'}
Spring의 Validation도 hibernate validator를 사용한다.
spring boot 를 사용하는 경우 간단하게 starter를 추가하면 관련 의존성과 설정이 추가된다.
implementation("org.springframework.boot:spring-boot-starter-validation")
@Validated
어노테이션을 Spring bean의 클래스나 메서드에 붙여주면, 파라미터나 리턴값을 검증할 수 있다.
@Validated
어노테이션이 붙은 Bean을 생성하면서 proxy를 만들고, 프록시가 MethodValidationInterceptor
라는 인터셉터를 통해서 메서드가 실행될때 검증 로직을 실행한다.
MethodValidationInterceptor
에서는 검증에 실패하면 ConstraintViolationException
을 throw한다.
Controller에서 받는 @RequestBody의 경우 MethodValidationInterceptor
를 타기전에 검증로직을 처리해서 MethodArgumentNotValidException
를 내려준다. Controller로 들어오는 사용자 요청이 잘못된 경우 400에러를 내려주기 위해서 별도로 처리한것 같다.
이 두가지 어노테이션이 무슨 차이가 있는지 궁금했는데, 참고한 블로그에 설명이 되어있었다.
@Valid
는 jakarta에서 제공하는 어노테이션인데, group관련 설정이 없어서 스프링에서 추가로 @Validated
라는 어노테이션을 추가로 만들어서 사용하는 거라고 한다.