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

Reference