[Spring Boot][Kotlin] @Valid 사용시에 Enum 값을 검증해보자(Custom Validation, Enum Validation)

Enum Validation 구현하기

들어가며

필자는 Enum 값을 즐겨 사용한다. Enum은 코드로 값을 고정할 수 있다는 점에서 컴파일 단계에서 안전하다. Spring Boot에서 @Valid 어노테이션을 사용하는 Body, Query를 Enum으로 검증해 보자.

주의사항

잘 알아야 하는 것은, 검증 만 하는 것이다. 절대로 변환 을 하는 것이 아니다!

Body 혹은 Query의 String을 Enum으로 변환하고자 하는 것이 아니라, String이 Enum 값에 있는지 확인하고자 하는 것이라는 것을 이해하자.

필자가 처음에 이것을 헷갈렸다가 시간을 너무 낭비했던 경험이 있다.

@Valid

스프링은 @Valid가 달린 객체에 대해서 검증을 실시한다. 검증을 실시할 때는 어노테이션이 달린 멤버 변수에 대해서 검증한다.

@Controller
class FooController(){
    @GetMapping("foo")
    // GetFooRequest 와 PostFooRequest가 값이 올바른지 검증한다.

    fun getFoo(@Valid @ModelAttribute query: GetFooRequest) {
        ...
    }
    
    @PostMapping("foo")
    fun postFoo(@Valid @RequestBody body: PostFooRequest) {
        ...
    }
}
/* GetFooRequest를 검증할 때
 * bar는 null이거나 빈 스트링이면 안되고,
 * pageNumer는 음수이면 안되고,
 * pageSize는 3 이상인지 검증함. 
 */
class GetFooRequest(
    @field:NotBlank
    val bar: String,
    
    @field:Min(0)
    val pageNumber: Int,
    
    @field:Min(3)
    val pageSize: Int,
)
// field는 kotlin에서 사용하는 방식. kotlin에서는 생성자 부분에 멤버변수를 선언할 수 있으므로 생성자 부분에 있는 필드가 어느 부분에 적용되는지를 알려줘야한다.

위의 @NotBlank, @Min 과 같은 어노테이션들을 Bean Validation이라고 한다.(문서)

기본으로 제공되는 어노테이션 말고, 우리가 원하는 방식으로 검증하도록 수정할 수도 있다.

간단한 Spring boot에서의 검증 흐름

검증은 일반적으로 Dispatcher Servlet에서 Handler Adapter를 통해 Controller의 Method를 호출하기 전에 @Valid 파라미터 메소드에 있는지 확인하고 있다면 검증을 시도한다.

  1. 사용자 요청
  2. Dispatcher Sevlet 실행
  3. Handler Mapping을 통해 실행할 컨트롤러와 메소드를 확인
  4. Handler Adapter를 통해 컨트롤러의 메소드 실행
    1. 실행하기 이전 @ModelAttriBute, @RequestBody, @AuthenticationPrincipal 같은 어노테이션 확인 후 적절한 로직 실행
    2. @Valid 어노테이션 확인 후 검증 로직 실행

Custom Validator 구현

1. Annotaion 만들기

이제 @NotBlank 같은 어노테이션을 만들어야 한다. 아래와 같이 만들 수 있다.

import jakarta.validation.Constraint
import jakarta.validation.ConstraintValidator
import jakarta.validation.ConstraintValidatorContext
import jakarta.validation.Payload
import kotlin.reflect.KClass

@Target(
    AnnotationTarget.FUNCTION,
    AnnotationTarget.PROPERTY_GETTER,
    AnnotationTarget.PROPERTY_SETTER,
    AnnotationTarget.FIELD,
    AnnotationTarget.ANNOTATION_CLASS,
    AnnotationTarget.CONSTRUCTOR,
    AnnotationTarget.VALUE_PARAMETER,
    AnnotationTarget.CLASS,
    AnnotationTarget.TYPE,
    AnnotationTarget.TYPE_PARAMETER
)
@Retention(
    AnnotationRetention.RUNTIME
)
annotation class ValidEnum(
    val enum: KClass<out Enum<*>>,
    val isNull: Boolean = false,
    // 아래 값은 필수 값
    val message: String = "허용되지 않은 값입니다.",
    val groups: Array<KClass<Any>> = [],
    val payload: Array<KClass<Payload>> = [],
)
키워드설명
@Target어노테이션의 적용 범위
@Retention언제 적용되는지
message검증 실패 시 출력할 에러 메시지
groups어떤 그룹에 속하는지(그룹 별로 검증할지 안 할지 정할 수 있음)
payload추가적인 검증 메타데이터
enum검증할 Enum 클래스
isNullnull 값을 허용하는지

message, groups, payload는 필수 파라미터이다. 자세한 건 공식 문서를 참고해 보자.

2. Validator 만들기

어노테이션을 만들었으니, 스프링이 검증 로직을 실행할 때 사용할 Validator가 필요하다.

따로 파일을 분리해서 만들어도 되고, 방금 만든 어노테이션 클래스의 inner class로 만들어도 된다. 필자는 inner class로 만들도록 한다.

import com.fhdufhdu.windows7board.common.dto.ValidEnum.EnumValidator
import jakarta.validation.Constraint
import jakarta.validation.ConstraintValidator
import jakarta.validation.ConstraintValidatorContext
import jakarta.validation.Payload
import kotlin.reflect.KClass

@Target(
    AnnotationTarget.FUNCTION,
    AnnotationTarget.PROPERTY_GETTER,
    AnnotationTarget.PROPERTY_SETTER,
    AnnotationTarget.FIELD,
    AnnotationTarget.ANNOTATION_CLASS,
    AnnotationTarget.CONSTRUCTOR,
    AnnotationTarget.VALUE_PARAMETER,
    AnnotationTarget.CLASS,
    AnnotationTarget.TYPE,
    AnnotationTarget.TYPE_PARAMETER
)
@Retention(
    AnnotationRetention.RUNTIME
)
// 추가된 부분
@Constraint(validatedBy = [EnumValidator::class])
annotation class ValidEnum(
    val enum: KClass<out Enum<*>>,
    val isNull: Boolean = false,
    val message: String = "허용되지 않은 값입니다.",
    val groups: Array<KClass<Any>> = [],
    val payload: Array<KClass<Payload>> = [],
    val ignoreCase: Boolean = false,
) {
    // 추가된 부분
    class EnumValidator(
    ) : ConstraintValidator<ValidEnum, String> {
        private lateinit var annotation: ValidEnum

        override fun initialize(constraintAnnotation: ValidEnum) {
            this.annotation = constraintAnnotation
        }

        override fun isValid(value: String?, context: ConstraintValidatorContext): Boolean {
            val enums = annotation.enum.java.enumConstants
            if (value == null && annotation.isNull) {
                return true
            }

            return enums.any { it.name == value }
        }
    }
}

Validator는 jakarta.validation.ConstraintValidator 인터페이스를 구현해서 만들어야 한다.

initialize를 통해 어노테이션 정보를 가져오고, isValid를 통해 검증 여부를 판단할 수 있다.(true 성공, false 실패)

우리는 isValid 함수를 집중적으로 구현할 것이다.

  1. 우선 어노테이션의 enum 멤버 변수에서 모든 enum 값 리스트 추출
  2. 어노테이션에 isNull이 붙어있고 value가 null이라면 true
  3. enum 값 리스트에서 value가 하나라도 매칭되면 true, 그렇지 않으면 false

3. 적용

Request DTO

class GetFooRequest(
    @field:ValidEnum(enum = SortCriteria::class)
    val sortCriteria: String = SortCriteria.CREATED_AT.name,
) {
    enum class SortCriteria {
        TITLE, CONTENT, CREATED_AT, USER_ID
    }
}

Controller

@RestController
@PreAuthorize("isAuthenticated()")
@ResponseBody
class FooController(
    private val fooService: FooService
) {
    @GetMapping("foo")
    fun getFoo(@Valid @ModelAttribute query: GetFooRequest) {}
}

이렇게 사용할 수 있다.

디버그 모드로 적용 확인

이제 이 Custom Validation이 잘 적용되는지 확인해 보자.

Break Point

해당 라인에 break point를 걸어서 디버그 모드로 확인해 보자.

JVM Stack

간단한 “Spring boot에서의 검증 플로우” 챕터에서 말했던 것처럼 검증 로직이 진행된다는 것을 알 수 있다.

마치며

이 기능에 대해 구현하고 블로그를 작성하며 Spring boot의 내부 구조와 작동 방식에 대해서 다시 한번 공부하게 되었고, Bean Validation에 대한 공식 문서를 보면서 작동 방식에 대해 익숙해질 수 있었다.

또한 디버그 모드를 통해 validation이 작동하는 위치에 대한 추측을 증명할 수 있었다.

블로그 글을 오랜만에 작성하면서 “알고 있는 지식은 공유해야 본인에게도 정리가 잘되는 것 같다"는 생각이 다시 한번 들었다. 앞으로도 게으름 피우지 말고 열심히 블로그 글을 작성해야겠다.