Spring Boot + Kotlin 깔끔한 validation 처리 (feat. Jackson)
스프링 부트 의존성 라이브러리를 확인했을 때 별도의 라이브러리를 추가하지 않더라도 기본 starter 킷에 Jackson 라이브러리가 포함되어 있는 것을 확인할 수 있습니다.
Spring Boot를 이용한 HTTP API 서버 구현시 요청의 body 값을 대부분 JSON으로 받게됩니다. 또한 응답을 Class Instance로 반환하면 JSON으로 변환하여 응답하게 됩니다. 때문에 직렬화와 역직렬화가 수행되는데, 스프링부트에서는 @RestController 어노테이션이 달린경우 Jackson 라이브러리가 직렬화와 역직렬화를 담당하게 됩니다.
대부분의 변환 과정에서 큰 문제는 없지만 스프링부트와 Kotlin을 함께 사용하게 된다면 이야기가 조금 달라집니다.
@RestController
class ExampleController {
@PostMapping("/example")
fun example(@RequestBody request: ExampleRequest): ExampleRequest {
return request
}
}
data class ExampleRequest(
val exampleNumber: Int
)
Kotlin으로 작성된 위 API는 ExampleRequest 객체를 JSON으로 받아 그대로 응답해주는 역할을 수행합니다.
하지만 exampleNumber를 null 값으로 요청하게 된다면 exampleNumber는 0으로 응답이 오게됩니다.
@PostMapping("/example")
fun example(@RequestBody request: ExampleRequest): ExampleRequest {
println(request.exampleNumber) // 0
return request
}
위 처럼 로그를 찍어보면 exampleNumber의 값이 0인 것을 확인할 수 있습니다. 즉, 역직렬화에서 값이 주입되었다고 추론할 수 있습니다. 그렇다면 jackson에서 값이 주입되었는지 테스트 코드 작성을 통해 확인해보겠습니다.
data class IntValue(val value: Int)
class ObjectMapperTest : FreeSpec({
val objectMapper = jacksonObjectMapper()
"빈 JSON을 역직렬화하면 Int타입은 0이다." {
// given
val json = "{}"
// when
val result = objectMapper.readValue<IntValue>(json)
// then
result.value shouldBe 0
}
})
위 상황에서 역시 0이 주입됩니다. 결론적으로 원인은 Jackson을 이용한 역직렬화에서 기본 값이 주입된 것이었습니다. 따라서 jacksonObjectMapper의 옵션 중 FAIL_ON_NULL_FOR_PRIMITIVES 옵션을 적용시킨 후 테스트해보겠습니다.
class ObjectMapperTest : FreeSpec({
val objectMapper = jacksonObjectMapper()
.enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
// 옵션 추가
"빈 JSON을 Int타입이 존재하는 클래스로 역직렬화하면 MismatchedInputException이 발생한다." {
// given
val json = "{}"
// when & then
shouldThrow<MismatchedInputException> {
objectMapper.readValue<IntValue>(json)
}
}
})
위와 같이 옵션을 킨 경우 에러가 발생함을 보실 수 있습니다. 그렇다면 다른 타입도 작동이 되는지 테스트 코드로 검증해보겠습니다.
data class IntValue(val value: Int)
data class DoubleValue(val value: Double)
data class LongValue(val value: Long)
data class BooleanValue(val value: Boolean)
data class ParentClass(val value: NestedClass)
data class NestedClass(val value: Int)
class ObjectMapperTest : FreeSpec({
val objectMapper = jacksonObjectMapper()
.enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
"IntValue" {
// given
val json = "{}"
// when & then
shouldThrow<MismatchedInputException> {
objectMapper.readValue<IntValue>(json)
}
}
"DoubleValue" {
// given
val json = "{}"
// when & then
shouldThrow<MismatchedInputException> {
objectMapper.readValue<DoubleValue>(json)
}
}
"LongValue" {
// given
val json = "{}"
// when & then
shouldThrow<MismatchedInputException> {
objectMapper.readValue<LongValue>(json)
}
}
"BooleanValue" {
// given
val json = "{}"
// when & then
shouldThrow<MismatchedInputException> {
objectMapper.readValue<BooleanValue>(json)
}
}
"ParentClass" {
// given
val json = """
{
"value": {}
}
""".trimIndent()
// when & then
shouldThrow<MismatchedInputException> {
objectMapper.readValue<ParentClass>(json)
}
}
})
그렇다면 nullable한 타입도 null 값으로 잘 들어가는지 확인해보겠습니다.
data class NullIntValue(val value: Int?)
data class NullDoubleValue(val value: Double?)
data class NullLongValue(val value: Long?)
data class NullBooleanValue(val value: Boolean?)
class ObjectMapperTest : FreeSpec({
val objectMapper = jacksonObjectMapper()
.enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
"NullIntValue" {
// given
val json = "{}"
// when
val result = objectMapper.readValue<NullIntValue>(json)
// then
result.value shouldBe null
}
"NullDoubleValue" {
// given
val json = "{}"
// when
val result = objectMapper.readValue<NullDoubleValue>(json)
// then
result.value shouldBe null
}
"NullLongValue" {
// given
val json = "{}"
// when
val result = objectMapper.readValue<NullLongValue>(json)
// then
result.value shouldBe null
}
"NullBooleanValue" {
// given
val json = "{}"
// when
val result = objectMapper.readValue<NullBooleanValue>(json)
// then
result.value shouldBe null
}
})
null 값으로 잘 들어가는지 확인이 끝났으니, 이제 Spring Boot에 적용해보겠습니다. 아래와 같이 application.yml 파일의 설정을 적용합니다.
spring:
jackson:
deserialization:
FAIL_ON_NULL_FOR_PRIMITIVES: true
이제 nullable 하지않은 타입에 null 값이 들어왔을 때 예외를 발생시키게 됩니다. 이로써 exceptionHandler를 이용해 MismatchedInputException을 정형화된 메세지로 처리할 수 있습니다. 하지만 Spring Boot에서는 HttpMessageConverter가 역/직렬화를 담당하게 됩니다. 따라서 아래와 같이 handler를 작성할 수 있습니다.
@RestControllerAdvice
class ApiGlobalExceptionHandler {
@ExceptionHandler(HttpMessageNotReadableException::class)
fun handleHttpMessageNotReadableException(ex: HttpMessageNotReadableException): ResponseEntity<BadRequestMessage> {
val message: String = when (val cause = ex.cause) {
is JsonParseException -> "올바른 JSON 형식이 아닙니다."
is JsonMappingException -> {
when (cause) {
is InvalidFormatException -> "[${cause.path.joinToString(", ") { it.fieldName }}] 타입이 올바르지 않습니다."
else -> "[${cause.path.joinToString(", ") { it.fieldName }}] 값은 필수입니다."
}
}
else -> "JSON 파싱 중 알 수 없는 오류입니다."
}
return ResponseEntity.ok(
BadRequestMessage(message = message)
)
}
}
또한 비슷한 validation으로 아래와 같은 API가 있을 때 PathVariale 값이 잘못된 타입으로 넘어오는 경우가 있습니다.
@PostMapping("/example/{id}")
fun example(
@PathVariable("id") id: Long,
@RequestBody request: ExampleRequest
): ExampleRequest {
return request
}
위 id 값이 문자열로 넘어왔을 때도 exception handler를 통해 함께 정형화된 에러메세지를 응답해보겠습니다.
// PathVariable, RequestParam 값 타입이 잘못되었을 때
@ExceptionHandler(MethodArgumentTypeMismatchException::class)
fun handleMethodArgumentTypeMismatchException(ex: MethodArgumentTypeMismatchException): ResponseEntity<BadRequestMessage> {
val message = "${ex.parameter.parameterName} 값은 ${ex.parameter.parameterType} 타입이어야 합니다."
return ResponseEntity.ok(
BadRequestMessage(message = message)
)
}
'Spring boot' 카테고리의 다른 글
SpringBoot 모니터링 하기 (feat. Grafana, Prometheus) (1) | 2023.03.06 |
---|---|
Kotest를 활용해 Spring Boot에서 테스트코드 작성하기 (0) | 2023.01.29 |
Hikari Connection Pool 확인 (0) | 2022.12.09 |
SpringBoot ResponseBodyAdvice 특정 응답 값 암호화하기 (0) | 2022.09.25 |
댓글
이 글 공유하기
다른 글
-
SpringBoot 모니터링 하기 (feat. Grafana, Prometheus)
SpringBoot 모니터링 하기 (feat. Grafana, Prometheus)
2023.03.06 -
Kotest를 활용해 Spring Boot에서 테스트코드 작성하기
Kotest를 활용해 Spring Boot에서 테스트코드 작성하기
2023.01.29 -
Hikari Connection Pool 확인
Hikari Connection Pool 확인
2022.12.09 -
SpringBoot ResponseBodyAdvice 특정 응답 값 암호화하기
SpringBoot ResponseBodyAdvice 특정 응답 값 암호화하기
2022.09.25