Rlog

Kotlin Spring JSR-303 Issue 해결 본문

Kotlin

Kotlin Spring JSR-303 Issue 해결

dev_roach 2022. 10. 3. 15:36
728x90

Kotlin JSR-303 Issue

Kotlin Spring 을 통해 개발하다보면 생각보다 Spring 에서 Kotlin 스럽게 사용하기 위해 몇가지 Custom 을 해줘야 하는 상황들이 생깁니다.
아래와 같이 Kotlin Coroutines 는 Spring 5.3 부터 지원되는 Spec 으로 Controller 에서 suspend method 를 이용하는것을 가능하게 해줍니다.

suspend modifier 와 hibernate validation Issue

따라서 기존의 Validation 들을 그대로 내비둔채 suspend modifier 를 붙여주게 되면 실제로 테스트 해볼때 아래와 같은 에러를 마주하게 됩니다.

image

이유는 hibernate-validation 이 Coroutines 를 Support 하지 않기 때문입니다. 기본적으로 suspend modifier 가 붙게 되면 Continuation 을 Parameter 로 전달하게 되는데요.
따라서 Library 에서 이 Parameter 를 제대로 Support 해주지 않게 된다면 아래와 같이 Parameters 의 길이의 Continuation 을 찾으려 하기에 IndexOutOfBoundException 이 발생하게 됩니다.
말로하면 어려우니 아래 코드를 한번 같이 보시죠.

@RestController
@Validated
class TestController {

    @GetMapping("/test2")
    fun test2(@RequestParam @Min(0) no: Int) = "Success $no" // 1번 코드

    @GetMapping("/test")
    suspend fun test(@RequestParam @Min(0) no: Int) = withContext(Dispatchers.Default) { // 2번 코드
        return@withContext "Success $no"
    }

    @ExceptionHandler(ConstraintViolationException::class)
    fun exceptionHandler(e: ConstraintViolationException): String? {
        return e.message
    }
}

위의 1번 코드와 2번 코드는 사실상 동일하지만, suspend modifier 를 썼냐 안썼냐의 차이인데요. 둘다 잘 Validation 이 작동해야 할것 같지만 앞서 말했듯이 suspend modifier 를 붙인 경우만 잘 작동하지 않습니다.
테스트를 위해 test2?no=-1 로 먼져 요청을 보내보겠습니다.

image

Validation 이 잘 작동하여 원하는 메세지를 얻었음을 확인할 수 있습니다. 다시끔 suspend modifier 가 붙은 test?no=-1 로 보내면 Error 를 마주하게 되는데요.

image

이를 해결하기 위한 코드를 작성해 봅시다.

@Configuration(proxyBeanMethods = false)
class CoroutineConfiguration {

    @Primary
    @Bean
    @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
    fun validatorForKotlin(): CustomLocalValidatorFactoryBean {
        val factoryBean = CustomLocalValidatorFactoryBean()
        factoryBean.messageInterpolator = MessageInterpolatorFactory().`object`
        return factoryBean
    }
}

class CustomLocalValidatorFactoryBean : LocalValidatorFactoryBean() {
    override fun getClockProvider(): ClockProvider = DefaultClockProvider.INSTANCE

    override fun postProcessConfiguration(configuration: javax.validation.Configuration<*>) {
        super.postProcessConfiguration(configuration)

        val discoverer = PrioritizedParameterNameDiscoverer()
        discoverer.addDiscoverer(SuspendAwareKotlinParameterNameDiscoverer())
        discoverer.addDiscoverer(StandardReflectionParameterNameDiscoverer())
        discoverer.addDiscoverer(LocalVariableTableParameterNameDiscoverer())

        val defaultProvider = configuration.defaultParameterNameProvider
        configuration.parameterNameProvider(object : ParameterNameProvider {
            override fun getParameterNames(constructor: Constructor<*>): List<String> {
                val paramNames: Array<String>? = discoverer.getParameterNames(constructor)
                return paramNames?.toList() ?: defaultProvider.getParameterNames(constructor)
            }

            override fun getParameterNames(method: Method): List<String> {
                val paramNames: Array<String>? = discoverer.getParameterNames(method)
                return paramNames?.toList() ?: defaultProvider.getParameterNames(method)
            }
        })
    }
}

class SuspendAwareKotlinParameterNameDiscoverer : ParameterNameDiscoverer {

    private val defaultProvider = KotlinReflectionParameterNameDiscoverer()

    override fun getParameterNames(constructor: Constructor<*>): Array<String>? =
        defaultProvider.getParameterNames(constructor)

    override fun getParameterNames(method: Method): Array<String>? {
        val defaultNames = defaultProvider.getParameterNames(method) ?: return null
        val function = method.kotlinFunction
        return if (function != null && function.isSuspend) {
            defaultNames + ""
        } else defaultNames
    }
}

(틀린 내용이 있을 수 있으니 있다면 댓글로 피드백 부탁드립니다~!)
참고로, 위의 코드는 모두 ISSUE 의 코드를 복사한 것이다.

일단 그래도 조금이라도 코드를 알아보자, LocalValidatorFactoryBean 의 경우 BootStrap 시에 JSR-303 의 기능을 확장할 수 있는 Interface 이다.
우리의 목표는 일단 Suspend Modifier 의 Continuation Parameter 를 식별할 수 있는 대상으로 만드는 것 이다. PrioritizedParameterNameDiscoverer
Parameter 의 Name 을 찾을 수 있는 ParameterNameDiscoverer 에 적절하게 위임해주는 Component 로 addDiscover 를 통해서 ParameterNameDiscoverer 를 등록할 수 있다.

suspend modifier 가 붙은 함수를 Java code 로 Decompile 해보면 아래처럼 받는 인자쪽이 변하게 된다. 이를 어떻게 해결하였는지 아래에서 결과적으로 설명하겠다.

fun test(no: Int, continuation: Continuation)

이 상황에서 continuation Parameter 를 제대로 찾지 못하는게 문제로 실제로 Debug 를 돌려서 PrioritizedParameterNameDiscoverer.java 쪽 getParameters() 를 보면,
Parameter 가 No 만 존재한다고 나오게 되고 이로 인해 Continuation 이 있는 인덱스(parameter.length - 1) 를 사용 하려고 하자 Error 가 발생하게 되는 것이다.
위에서 설명한대로 Continuation 이 붙게 되는 경우가 문제이므로 해당 경우 "" 를 붙여주어 아래 사진 처럼 ["no", ""] 를 만들어 주는 것이다.

이제 해결은 됬으니 한번 요청을 보내 실제로 잘 동작하는지 살펴보자

첫번째 이슈 동작 결과

image

이제 문제없이 잘 동작하는걸 확인할 수 있다. 하지만 두번째 이슈가 있는데 요건 좀 복잡하다.
아래 글에서 함께 보도록 하자

@NotNull Issue

Kotlin 에서 javax 의 @NotNull(message = "") Annotation 을 붙이게 되면 아래와 같이 사용하면 완벽할 것만 같다.

@Validated
data class User(
    @field:NotNull(message = "사용자의 이름은 필수 입력 값 입니다.")
    val name: String,
    @field:Min(0)
    val age: Int
)
{
    "age": 10
}

그래서 직접 검증하기 위해 위와 같은 JSON 형태로 Postman 에서 요청을 날려보게 되면 예상하지 못한 에러를 마주하게 된다.

image

왜 이런 문제가 발생하게 될까?

문제 찾기

일단 Kotlin 에서 Json 형태의 Body 를 Object 로 변환할때, Jackson Library 를 이용해서 진행하게 된다.
이 과정에서 Json 을 먼져 Object 로 생성하고 그 이후 Validation 을 진행하게 된다. 이 과정에서 Json 을 Object 로 만드는 과정이 선행과정이므로
앞쪽에서 name 이 null 을 허용하지 않는 타입임에도 불구하고, Null 을 넣으려고 해서 KotlinMissingParameterException 예외가 발생하게 됩니다.

해결방법은 여러가지가 있습니다. 아래와 같이 objectMapper 에 nullIsSameAsDefault = true 값을 넣어주는 방법도 있는데요.
하지만 이 방법도 Default 값이 있는 Type 인 String 이나 Long 등등의 값에만 이용하고 객체에는 사용이 불가능해서, 완전한 해결책은 아닙니다.

그리고 저는 좋지 않은 해결법이라고 생각하는 ?(Nullable) 을 붙이는 방법이 있습니다. 하지만 이 방법의 가장 큰 문제는 사용하는 측에서 ? 에 대한 조건을 계속해서 체크하거나,
일련의 NotNullable 하게 Return 해주는 getter 와 같은 수단이 필요하게 됩니다. 거추장 스럽고 Kotlin 스럽지 못하다고 생각하여 좋은 Solution 은 아니라고 생각합니다.

@Validated
data class User(
    // 좋은 Solution 일까..?
    @field:NotNull(message = "사용자의 이름은 필수 입력 값 입니다.")
    val name: String? = null,
    @field:Min(0)
    val age: Int
) {
    fun getName(): String {
        return name ?: throw Error("!!!")
    }
}

MissingParameterException Handler 작성

참고로 아래 방법이 좋은 방법인지는 고민이 필요합니다.

MissingParameterException 에서는 Parameter 정보를 이용할 수 있는데, 그 정보를 통해 Class 의 이름과 에러가난 Parameter 의 이름을 아래처럼 추출 할 수 있다.

    @ExceptionHandler(MissingKotlinParameterException::class)
    fun missingParameterExceptionHandler(e: MissingKotlinParameterException): String? {
        val errorParameterName = e.parameter.name
        // using java classLoader
        val errorParameterClassResult = runCatching {
            Class.forName((e.path[0].from as Class<*>).name)
        }.onFailure {
            // 실패시 Jackson KotlinParameterException 그대로 Return
            return e.message
        }

        errorParameterClassResult.getOrNull()?.let {
            for (field in it.declaredFields) {
                if (field.name == errorParameterName) {
                    val constraint = field.getAnnotation(NotNull::class.java)
                    return constraint?.message
                }
            }
        }

        return e.message
    }

코드를 간략하게 설명하자면 e.parameter.name 을 통해 에러가 발생한 parameter 의 이름을 알아 낸뒤에, Class.forName() 을 통해 Deserialization 의 TargetClass 를 동적으로 Load 합니다.
만약 동적으로 로드가 실패했다면 아래 로직을 태울 수 없으므로 MissingKotlinParameterException 를 리턴하고, 성공했다면 field 의 NotNull Annotation 의 message 정보를 가져와서 Return 해주게 됩니다.

위의 코드를 적용한 뒤 한번 name 을 제거한 뒤 Request 를 날려보도록 하겠습니다.

@Validated
data class User(
    @field:NotNull(message = "사용자의 이름은 필수 입력 값 입니다.")
    val name: String,
    @field:Min(0)
    val age: Int
)

image

이제 우리가 원하는 ErrorMessage 가 잘 등록되는 것을 확인할 수 있습니다.

Class.forName(...) 과 GC

사실 가장 마음에 걸렸던건 ClassLoader 를 통해 동적으로 Class 를 Load 할때, 해당 Class 가 GC 의 Target 이 되냐 안되냐 였습니다.
그래서 Visual JVM 을 킨 뒤, 여러번 Request 를 수행해봤으니 Load 된 Classes 수가 변하지 않는 모습을 확인할 수 있었습니다.
따라서, 이미 Load 된 것의 Cache 를 이용하는 건가? 라는 생각이 들긴했는데.. 요 부분은 좀 더 공부를 해볼 필요가 있는것 같습니다.
(만약 아시는 분 있으시다면 댓글로 답변 부탁드립니다.)

image

Github

https://github.com/tmdgusya/kotlin-spring-jsr-303-issue

 

GitHub - tmdgusya/kotlin-spring-jsr-303-issue

Contribute to tmdgusya/kotlin-spring-jsr-303-issue development by creating an account on GitHub.

github.com