Spring validation(with kotlin)

freddie·2021년 4월 22일
5

spring

목록 보기
1/4

Kotlin Spring Validation 사용하기

Validation은 개발에 있어서 가장 중요한 부분중 하나다.
외부로부터 들어온 요청에 어떤 값이 포함될지 모르고, 이 잘못된 값으로 작업이 진행되면 위험한 상태가 될 수 있기 때문이다.

spring을 사용할때는 주로 Jakarta Bean Validation의 구현체인 Hibernate Validator를 사용한다.

Hibernate Validator

그렇기 때문에 먼저 Hibernate Validator에 대해서 먼저 알아보자.

Valiation 사용하기

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>으로 결과를 반환한다.

이렇게해야 검증에 실패한걸 하나씩 확인하는게 아니라 한번에 확인이 가능하기 때문이다.

Message

위에서 검증 결과를 보면 ConstraintViolationImpl로 결과를 반환하는데, 여기에 포함된 속성을 살펴보자.

  • propertyPath : 검증 실패가 발생한 위치
  • rootBeanClass : 실패한 대상 클래스
  • messageTemplate : 오류 메시지의 template key
  • interpolatedMessage : messageTemplate을 이용해서 치환된 메시지값
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}'}

Custom Validation

커스텀 어노테이션을 사용해서 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}'}

Validation In Spring

동작 원리

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 와 @Validated

이 두가지 어노테이션이 무슨 차이가 있는지 궁금했는데, 참고한 블로그에 설명이 되어있었다.

@Valid는 jakarta에서 제공하는 어노테이션인데, group관련 설정이 없어서 스프링에서 추가로 @Validated라는 어노테이션을 추가로 만들어서 사용하는 거라고 한다.

참고

profile
하루에 하나씩만 배워보자

0개의 댓글