null 로 설정된 값을 Jackson mapper 가 멋대로 변경하는 현상: Java Primitives
Kotlin 스프링 부트를 사용해 API 를 작성 중에 예상치 못한 에러를 경험했다.
아래의 RequestBody class 를 보면 알 수 있듯이 blocked 는 not nullable 이다.
RequestBody data class
data class ReqBody(
val demoId: String,
val demoName: String,
val openDate: LocalDate?,
val blocked: Boolean
)
json 으로 request 를 보낼 때, 아래와 같이 보내면 어떻게 될까?
json 형태의 request
{
"demoId": "demo1",
"demoName": "demo name",
"openDate": "2021-10-22"
}
not nullable 인 blocked 를 null 로 보내기 때문에, BAD REQUEST 에러가 발생할 것으로 예상한다. 하지만 그렇지 않고 아래와 같이 blocked 는 false 값을 할당받았다.
println(reqBody) 실행 결과
ReqBody(demoId=demo1, demoName=demo name, openDate=2021-10-22, blocked=false)
spring boot 의 jackson object mapper 가 멋대로 blocked 를 false 로 정해 ReqBody 에 값을 할당한 것이다. 어찌된 일일까?
결론부터 말하자면, java primitive types 의 경우 jackson object mapper 가 default 값을 멋대로 할당한다 (Java 환경에서도 똑같은지는 별도의 실험이 필요하다). 위의 예시에서는 Boolean 이 Java primitive 이기 때문에 default 값인 false 가 할당되는 것이다.
Jackson mapper 작동 실험
정확한 실험을 위해 jackson object mapper 를 사용하는 환경을 구성했다.
build.gradle.kts
plugins {
val kotlinVersion = "1.4.32"
kotlin("jvm") version kotlinVersion
}
group = "org.example"
version = "1.0-SNAPSHOT"
repositories {
mavenCentral()
}
dependencies {
implementation(kotlin("stdlib"))
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.13.+")
}
RequestBodyOfJavaPrimitives data class
data class RequestBodyOfJavaPrimitives(
// 8개의 Java Primitive Types
val intField: Int,
val byteField: Byte,
val shortField: Short,
val longField: Long,
val floatField: Float,
val doubleField: Double,
val booleanField: Boolean,
val charField: Char,
// Java Reference Type
val stringField: String
)
main.kt
fun main() {
val objectMapper = jacksonObjectMapper()
val jsonStringExample = """{"stringField": "stringField"}"""
val exampleToDto = objectMapper.readValue(jsonStringExample, RequestBodyOfJavaPrimitives::class.java)
println(exampleToDto)
}
RequestBodyOfJavaPrimitives(intField=0, byteField=0, shortField=0, longField=0, floatField=0.0, doubleField=0.0, booleanField=false, charField= , stringField=stringField)
REM 주의: charField 는 encoding 문제 때문에 값이 제대로 보이지 않음
위의 출력에서 확인할 수 있듯이, Java primitives 인 Int, Byte, Short, Long, Float, Double, Boolean, Char 모두가 jackson object mapper 에서 null 임에도 RequestBodyOfJavaPrimitives data class 에서 default 값을 할당받았다.
이 문제를 해결하는 방법은 두 가지로, 첫째는 jackson object mapper 에 특정 설정을 하는 것이고, 둘째는 특정 Kotlin class 의 필드에만 설정을 하는 것이다. 먼저 첫 번째 방법은 아래와 같다.
main.kt
fun main() {
val objectMapper = jacksonObjectMapper()
// 첫 번째 방법
objectMapper.enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
val jsonStringExample = """{"stringField": "stringField"}"""
val exampleToDto = objectMapper.readValue(jsonStringExample, RequestBodyOfJavaPrimitives::class.java)
println(exampleToDto)
}
Exception in thread "main" com.fasterxml.jackson.databind.exc.MismatchedInputException: Missing required creator property 'intField' (index 0)
at [Source: (String)"{"stringField": "stringField"}"; line: 1, column: 30] (through reference chain: com.dto.RequestBodyOfJavaPrimitives["intField"])
at com.fasterxml.jackson.databind.exc.MismatchedInputException.from(MismatchedInputException.java:59)
at com.fasterxml.jackson.databind.DeserializationContext.reportInputMismatch(DeserializationContext.java:1755)
at com.fasterxml.jackson.databind.deser.impl.PropertyValueBuffer._findMissing(PropertyValueBuffer.java:192)
at com.fasterxml.jackson.databind.deser.impl.PropertyValueBuffer.getParameter(PropertyValueBuffer.java:130)
at com.fasterxml.jackson.module.kotlin.KotlinValueInstantiator.createFromObjectWith(KotlinValueInstantiator.kt:98)
at com.fasterxml.jackson.databind.deser.impl.PropertyBasedCreator.build(PropertyBasedCreator.java:202)
at com.fasterxml.jackson.databind.deser.BeanDeserializer._deserializeUsingPropertyBased(BeanDeserializer.java:518)
at com.fasterxml.jackson.databind.deser.BeanDeserializerBase.deserializeFromObjectUsingNonDefault(BeanDeserializerBase.java:1405)
at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserializeFromObject(BeanDeserializer.java:351)
at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:184)
at com.fasterxml.jackson.databind.deser.DefaultDeserializationContext.readRootValue(DefaultDeserializationContext.java:322)
at com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:4675)
at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3630)
at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3598)
at com.MainKt.main(main.kt:13)
at com.MainKt.main(main.kt)
이제 Exception 이 발생하는 것을 확인할 수 있으며, Spring Boot 에서는 이것이 BAD REQUEST 로 반환될 것이다. 성공이다!
이제 두 번째 방법을 알아보자. 두 번째 방법은 @JsonProperty(required = true) 애너테이션을 활용하는 방법이다.
RequestBodyOfJavaPrimitives data class
data class RequestBodyOfJavaPrimitives(
// 두 번째 방법
@JsonProperty(required = true)
val intField: Int,
@JsonProperty(required = true)
val byteField: Byte,
@JsonProperty(required = true)
val shortField: Short,
@JsonProperty(required = true)
val longField: Long,
@JsonProperty(required = true)
val floatField: Float,
@JsonProperty(required = true)
val doubleField: Double,
@JsonProperty(required = true)
val booleanField: Boolean,
@JsonProperty(required = true)
val charField: Char,
val stringField: String
)
main.kt
fun main() {
val objectMapper = jacksonObjectMapper()
val jsonStringExample = """{"stringField": "stringField"}"""
val exampleToDto = objectMapper.readValue(jsonStringExample, RequestBodyOfJavaPrimitives::class.java)
println(exampleToDto)
}
Exception in thread "main" com.fasterxml.jackson.databind.exc.MismatchedInputException: Missing required creator property 'intField' (index 0)
at [Source: (String)"{"stringField": "stringField"}"; line: 1, column: 30] (through reference chain: com.dto.RequestBodyOfJavaPrimitives["intField"])
at com.fasterxml.jackson.databind.exc.MismatchedInputException.from(MismatchedInputException.java:59)
at com.fasterxml.jackson.databind.DeserializationContext.reportInputMismatch(DeserializationContext.java:1755)
at com.fasterxml.jackson.databind.deser.impl.PropertyValueBuffer._findMissing(PropertyValueBuffer.java:192)
at com.fasterxml.jackson.databind.deser.impl.PropertyValueBuffer.getParameter(PropertyValueBuffer.java:130)
at com.fasterxml.jackson.module.kotlin.KotlinValueInstantiator.createFromObjectWith(KotlinValueInstantiator.kt:98)
at com.fasterxml.jackson.databind.deser.impl.PropertyBasedCreator.build(PropertyBasedCreator.java:202)
at com.fasterxml.jackson.databind.deser.BeanDeserializer._deserializeUsingPropertyBased(BeanDeserializer.java:518)
at com.fasterxml.jackson.databind.deser.BeanDeserializerBase.deserializeFromObjectUsingNonDefault(BeanDeserializerBase.java:1405)
at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserializeFromObject(BeanDeserializer.java:351)
at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:184)
at com.fasterxml.jackson.databind.deser.DefaultDeserializationContext.readRootValue(DefaultDeserializationContext.java:322)
at com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:4675)
at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3630)
at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3598)
at com.MainKt.main(main.kt:13)
at com.MainKt.main(main.kt)
Exception 이 발생하는 것을 확인할 수 있으며, Spring Boot 에서는 이것이 BAD REQUEST 로 반환될 것이다. 성공이다!
사실 두 번째 방법을 사용할 일은 별로 없을 것 같다. 왜냐하면, 애초에 Data Transfer Object 를 작성할 때 not nullable 로 Field 를 지정하는 이유는 해당 Field 를 json 으로 받지 못 할 경우 exception 을 발생시키기 위해서기 때문이다.
첫 번째 해결 방법을 spring boot 에 적용하면, application.properties 또는 application.yml 파일에 jackson mapper 설정을 추가하는 것이다. 아래와 같이 말이다.
application.properties
spring.jackson.deserialization.fail-on-null-for-primitives=true