저는 동적 검증이 더 좋아요

Dierslair·2022년 5월 22일
1

스프링

목록 보기
3/5

자바에서는 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 에 전달됩니다. 적절히 처리하여 사용자에게 응답하면 됩니다.

profile
Java/Kotlin Backend Developer

0개의 댓글