자바에서는 JSR-303 이라는 표준으로 자바 빈 검증의 자동화를 지원합니다.
컨트롤러 파라미터에 @Valid
를 붙이는 그것인데요.
class UserForm private constructor() {
class Save {
@NotBlank(message = "유저 아이디를 입력해 주세요.")
var username: String? = null
}
}
@RestController
class UserController {
@PostMapping(consumes = [MediaType.APPLICATION_JSON_VALUE])
fun save(
@Valid // 애노테이션을 찾아 검증을 대신 해 줌
@RequestBody
form: UserForm.Save,
): Map<String, Any?> {
// ...
}
}
유용하지만 불편한 점이 있습니다.
이런 경우 애노테이션을 사용한 정적 검증은 그 의미가 무색하게도 쓸모없어집니다.
그래서 혹자는 이렇게 검증을 실행하기도 합니다.
class UserForm private constructor() {
class Save {
var username: String? = null
fun validate() {
// 검증 로직...
}
}
}
@Service
class UserServiceImpl : UserService {
@Transactional
override fun save(form: UserForm.Save): UserDto {
form.validate() // 검증 로직 실행
...
}
}
이런 경우 검증이 필요한 객체를 사용하는 곳에서 수동으로 호출해야 하고, 만약 빠뜨린다면 SQLException
등 보기 싫은 오류 메시지를 사용자에게 노출하게 됩니다.
JSR-303 에서는 사용자가 검증 로직을 커스터마이징 할 수 있게 인터페이스를 제공하고 있는데,
ConstraintValidator<A extends Annotation, T>
를 활용하여 커스터마이징을 지원하며 예를 들어 전화번호 형식의 값을 검증하는 @Phone
애노테이션과 검증 구현체를 작성한다고 하면,
// 검증용 애노테이션
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FIELD)
@Constraint(validatedBy = [PhoneValidator::class])
annotation class Phone(
// 반드시 있어야 하는 항목입니다.
val message: String = "",
val groups: Array<KClass<*>> = [],
val payload: Array<KClass<*>> = [],
// 사용자 정의 항목
val nullable: Boolean = true,
)
// 해당 애노테이션을 가진 프로퍼티 검사 구현체
class PhoneValidator : ConstraintValidator<Phone, String> {
companion object {
private val pattern: Regex = "^(\\d{2,4})-(\\d{3,4})-(\\d{4})$".toRegex()
}
private lateinit var annotation: Phone
// 애노테이션 정보를 저장합니다.
override fun initialize(constraintAnnotation: Phone) {
this.annotation = constraintAnnotation
}
// 검증 결과를 boolean 으로 반환합니다.
override fun isValid(
value: String?,
context: ConstraintValidatorContext,
): Boolean =
value
?.let { pattern.matches(it) }
?: this.annotation.nullable
}
별도 등록 과정이 불필요하며, 사용 가능합니다.
class UserForm private constructor() {
class Save {
@Phone(message = "전화번호 형식을 다시 한 번 확인해 주세요.")
var phone: String? = null
}
}
위 예제는 프로퍼티에 대응하는 검증이므로 자바 빈에 대응하는 검증으로 확장하게 되면,
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS)
@Constraint(validatedBy = [EmbeddedValidationValidator::class])
annotation class EmbeddedValidation(
val message: String = "",
val groups: Array<KClass<*>> = [],
val payload: Array<KClass<*>> = [],
)
class EmbeddedValidationValidator : ConstraintValidator<EmbeddedValidation, Any> {
private lateinit var annotation: EmbeddedValidation
override fun initialize(constraintAnnotation: EmbeddedValidation) {
this.annotation = constraintAnnotation
}
override fun isValid(
target: Any?,
context: ConstraintValidatorContext,
): Boolean {
if (target != null) {
val validate = findValidateMethod(target)
if (validate != null) {
try {
validate.invoke(target)
} catch (e: InvocationTargetException) {
// validation failure
throw e.targetException
}
}
}
return true
}
private fun findValidateMethod(target: Any): Method? {
val type = target::class.java
return try {
type.getDeclaredMethod("validate")
} catch (e: Throwable) {
null
}
}
}
이렇게 작성할 수 있으며, 사용 예로는 다음과 같습니다.
class UserForm private constructor() {
@EmbeddedValidation // 검증 로직이 내장되어 있습니다.
class Save {
...
fun validate() {
val config = ... // 검증 기준을 불러옵니다.
// 동적 검증
if (config.phoneRequired && this.phone == null) {
throw UserErrors.PhoneRequiredException()
}
...
}
}
}
검증에 실패하는 경우, 검증 로직에서 던진 예외는 javax.validation.ValidationException
으로 감싸져 HandlerExceptionResolver
에 전달됩니다. 적절히 처리하여 사용자에게 응답하면 됩니다.