들어가며
안녕하세요~!

이번에도 역시 spring boot validation 에 관하여 알아보도록 해보겠습니다.
우리는 이전 게시글을 통해 spring 에서 어떻게 validation 하고 있는지 소개했었는데요.
https://huisam.tistory.com/entry/spring-validation
Spring Validation 의 종류와 동작하는 방식에 대해 알아보자
들어가며안녕하세요 ㅎㅎ 다들 spring 기반의 application 을 운영하게 되는 일들이 많은데요. 오늘은 client 에서 요청하는 값들에 대해 검증하고 싶을 때 어떻게 검증하는지와 내부 동작과정에 대해
huisam.tistory.com
기본적인 validation 기능들은 다 탑재되어 있어 사용하기에 어렵진 않지만,
일부 미지원되는 기능들이 있어서 어떻게 하면 customization 할 수 있는지 알아보도록 하겠습니다.
custom 을 하기 위한 예제로는 Enum 의 entry set 을 validation 하기 위한 목적으로 하는 예를 들어보겠습니다.
Custom Validator
jakarta bean validation 을 활용하는 것으로 쉽게 validator 를 구현할 수 있도록 확장 포인트를 열어두었는데요.
바로 Constraint 어노테이션을 활용하면 쉽게 custom 하여 구현할 수 있습니다.
@Documented
@Target({ ANNOTATION_TYPE })
@Retention(RUNTIME)
public @interface Constraint {
/**
* {@link ConstraintValidator} classes implementing the constraint. The given classes
* must reference distinct target types for a given {@link ValidationTarget}. If two
* {@code ConstraintValidator}s refer to the same type, an exception will occur.
* <p>
* At most one {@code ConstraintValidator} targeting the array of parameters of
* methods or constructors (aka cross-parameter) is accepted. If two or more
* are present, an exception will occur.
*
* @return array of {@code ConstraintValidator} classes implementing the constraint
*/
Class<? extends ConstraintValidator<?, ?>>[] validatedBy();
}
Constraint 어노테이션을 내가 만든 custom 어노테이션에 선언함으로써 쉽게 구현할 수 있도록 되어 있습니다.
다만, 반드시 정의해야되는 필드변수와 클래스가 있는데요.
- message field: error message 로 노출할 message 변수
- groups field: customize 대상이 되는 target group 변수
- payload field: 확장성을 위한 변수
- ContraintValidator Class: Validation 로직이 구현되어 있는 클래스
groups 와 payload 변수의 경우에는 실질적으로 사용되는 범위가 많지 않아 굳이 사용하실 필요는 없습니다. 대부분의 경우에는 빈값으로 default value 를 주는 것이 일반적입니다.
중요한 것은 message 변수와 ConstraintValidator 클래스인데요. 항상 잊지말고 명시하고, 구현해주는 것이 중요합니다.
우리가 구현해야 될 ConstraintValidator 클래스를 알아보도록 해보겠습니다.
public interface ConstraintValidator<A extends Annotation, T> {
/**
* Initializes the validator in preparation for
* {@link #isValid(Object, ConstraintValidatorContext)} calls.
* The constraint annotation for a given constraint declaration
* is passed.
* <p>
* This method is guaranteed to be called before any use of this instance for
* validation.
* <p>
* The default implementation is a no-op.
*
* @param constraintAnnotation annotation instance for a given constraint declaration
*/
default void initialize(A constraintAnnotation) {
}
/**
* Implements the validation logic.
* The state of {@code value} must not be altered.
* <p>
* This method can be accessed concurrently, thread-safety must be ensured
* by the implementation.
*
* @param value object to validate
* @param context context in which the constraint is evaluated
*
* @return {@code false} if {@code value} does not pass the constraint
*/
boolean isValid(T value, ConstraintValidatorContext context);
}
크게 2가지 함수로 구성되어 있는데요.
- initialize 함수: 클래스가 instance 화 될때 초기화 함수로 1번 호출
- isValid 함수: 실질적으로 수행하게 되는 validation 로직. 올바른 형태면 true 를 리턴하고, 올바르지 않다면 false 를 리턴해야 합니다
함수적으로는 너무나 명확한 명세서라서 크게 설명드릴 것이 없습니다 ㅎㅎ
제네릭 타입만 신경써주시면 되는데요.
A 는 custom annotation 을 지정해주면 되고, T 는 custom annotation 이 명시될 type 으로 지정해주시면 됩니다.
이제 한번 예시와 함께 구현해보겠습니다.
Enum Entry Validator
Enum entry 에 속하지 않은 데이터가 요청될 때 validation 하기 위한 클래스를 작성해보려고 합니다.
요구사항은 아래와 같습니다.
- Enum 에 속하지 않은 데이터가 request body 로 요청될 때 HttpMessageNotReadableException 을 발생시키지 않고, MethodArgumentNotValidException 을 발생시켜서 공통 로직으로 처리한다.
사전 작업
일반적으로 kotlin spring application 에서는 올바른 enum entry 의 데이터가 요청되지 않으면, jackson 에서 deserialize 할 때 실패하여, HttpMessageNotReadableException 을 발생시키게 되는데요.
이러한 옵션을 먼저 조정해보겠습니다.
@Configuration
class JacksonConfiguration {
@Bean
fun jackson2ObjectMapperBuilderCustomizer(): Jackson2ObjectMapperBuilderCustomizer =
Jackson2ObjectMapperBuilderCustomizer {
it.featuresToEnable(
DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL,
)
}
}
설정은 간단합니다.
jackson 에서 지원하는 Unknown enum 을 읽었을 때 null 로 읽게 하여 위와 같은 HttpMessageNotReadableException 을 발생시키지 않도록 합니다.
Custom valid annotation
그러면 annotation 을 만들어보도록 하겠습니다.
@Target(AnnotationTarget.PROPERTY_GETTER)
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [EnumEntryValidator::class]) // validator 지정
annotation class ValidEnumEntry(
val kClass: KClass<out Enum<*>>, // enum kClass 만 허용
val message: String = "Invalid enum entry", // default message 선언
val groups: Array<KClass<*>> = [],
val payload: Array<KClass<out Payload>> = []
)
신경써야되는 요소는 주석으로 설명 추가하였습니다.
Annotation 선언시 크게 신경써야되는 요소는 Target 과 Retention 인데요.
- Target: 어노테이션이 선언되는 위치. Propertry getter 에서만 선언될 목적으로 Property getter 로 지정하였습니다.
- Retention: 어노테이션이 어떤 타이밍에 바이너리로 포함될 것인지.
이렇게 지정만 해둔다면 기본 annotation 준비는 끝났습니다.
Custom validator
다음은 Custom validator 를 만들어야 하는데요.
class EnumEntryValidator : ConstraintValidator<ValidEnumEntry, Enum<*>> {
private val logger = LoggerFactory.getLogger(EnumEntryValidator::class.java)
private val acceptedEntries = mutableSetOf<String>()
override fun initialize(constraintAnnotation: ValidEnumEntry) {
acceptedEntries.addAll(
constraintAnnotation.kClass.java.enumConstants.map { it.name }
)
}
override fun isValid(value: Enum<*>?, context: ConstraintValidatorContext?): Boolean {
logger.info("Accepted values: $acceptedEntries") // 테스트 확인용
logger.info("value: $value") // 테스트 확인용
if (value == null) {
return false
}
return acceptedEntries.contains(value.name)
}
}
크게 2가지 메서드만 구현하였습니다.
- initialize: acceptedEntries 를 선언하여 사전에 Enum 에 속해있는 entry 를 모두 가져와 초기화 합니다.
- isValid: value 가 null 이거나 acceptedEntries 에 속하지 않으면 false 를 리턴합니다.
의도한 목적에 맞게 Enum 에 속한 Entry 에 포함되지 않은 것을 검증하기 위한 validator 로 작성해보았습니다.
여기서 제너릭 타입에 대해 한번 더 설명드릴까 하는데요.
- A: Custom Annotation Class = ValidEnumEntry
- T: Enum<*> = Enum class 에 모두 지정하기 위한 타입
해당 로직은 모든 Enum class 에 공통으로 쓰일 로직이기 때문에 위와 같이 작성해보았습니다.
Example
이제 실질적으로 동작하는 Controller 코드를 작성해보겠습니다.
@RestController
@RequestMapping("/api/enum")
class EnumController {
@PostMapping("/domain")
fun domain(@RequestBody @Valid requestDto: RequestDto): ResponseEntity<String> {
return ResponseEntity.ok(requestDto.enumEntry!!.name)
}
}
data class RequestDto(
@get:ValidEnumEntry(kClass = EnumEntry::class, message = "Invalid enum entry subset on [EnumEntry]")
val enumEntry: EnumEntry?
)
enum class EnumEntry {
A, B, C
}
위에서 작성된 ValidEnumEntry 를 기반으로 검증하는 로직을 작성해보았습니다.
Property Getter 로 어노테이션을 만들었으므로 해당 규칙에 맞게 정의합니다.
이제 이를 핸들링하는 예외 핸들러만 만들면 되는데요.
@ExceptionHandler(MethodArgumentNotValidException::class)
fun methodArgumentNotValidException(e: MethodArgumentNotValidException): ResponseEntity<Map<String, String>> {
logger.info("Constraint violation exception occurred.", e)
val errors = e.bindingResult.fieldErrors.associate {
it.field to (it.defaultMessage ?: "Validation Error")
}
return ResponseEntity.badRequest().body(errors)
}
이런식으로 간단하게 작성해보았고, 한번 테스트해보겠습니다.
HttpFile 을 하나 만들어 Enum 에 속하지 않은 데이터를 요청해볼게요
POST http://localhost:8080/api/enum/domain
Content-Type: application/json
{
"enumEntry": "D"
}
위 요청을 하게 되면, 아래와 같이 의도한대로 badRequest 와 함께 어떤 필드가 잘못되었는지 내려오게 됩니다.
이로써 직접 custom validation annotation 과 validator 를 만들어 우리가 원하는 입맛대로 만들 수 있게 되었네요.!
정리하며

spring boot validation 모듈을 통하여 원하는대로 validator 를 구현할 수 있습니다.
반드시 구현해야되는 클래스는 annotation 클래스와 validator 클래스입니다.
여러분들이 원하는 로직을 자유롭게 구현하고 error response 도 자유롭게 만들어보시면 좋겠네요 ㅎㅎ
'Developer > Spring' 카테고리의 다른 글
Spring Validation 의 종류와 동작하는 방식에 대해 알아보자 (0) | 2025.01.01 |
---|---|
[Gradle] Gradle multi module(project) with spring boot(feat. kotlin) (0) | 2024.09.30 |
[Test] Junit5 에서 제공하는 Tag 에 대해 알아보자 (0) | 2024.06.20 |
Spring Boot API server 성능 테스트(performance test)를 해보자(with python locust) (0) | 2024.02.12 |
[JPA] Hibernate 에서 지원하는 Id generator 에 대해 알아보자 (1) | 2023.12.17 |