저번에 HttpMessageNotReadableException을 핸들링하면서 역직렬화 하는 과정의 테스트를 위해 jackson에 대해 이것저것 확인해보다 재밌는 사실을 몇 가지 발견하게 되어 정리해봤습니다.
fun main() {
val testJsonString = "{}"
val testJson = jacksonOjbectMapper().readValue(testJsonString, TestJson::class.java)
println(testJson) // TestJson(a=0, b=false, c=0.0)
}
data class TestJson(
val a: Long,
val b: Boolean,
val c: Double,
)
jacksonObjectMapper를 아무것도 설정하지 않은채로 사용한다면 Long, Boolean, Double 등 원시타입에는 json에서 값이 파싱되지 않았을 때 기본값이 들어가게 됩니다.
만약 이런 기본값이 들어가는 걸 막고 싶다면 다음 설정을 추가해주면 됩니다.
jacksonObjectMapper().apply {
configure(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES, true)
}
이렇게 설정하고 위의 main 함수를 다시 실행하면 MismatchedInputException
이 발생하게 됩니다.
fun main() {
val testJsonString = "{\"a\": 1, \"b\": \"2\"}"
val testJson = jacksonOjbectMapper().readValue(testJsonString, TestJson::class.java)
println(testJson) // TestJson(a=1, b=2)
}
data class TestJson(
val a: String,
val b: Long,
)
TestJson.a
는 String 타입이지만 실제로 json에서 넘겨졌을 때는 정수형 데이터 1이 넘겨졌습니다. 그럼에도 역직렬화 과정에서 에러가 발생하지 않고 정상적으로 값이 들어가는 것을 확인할 수 있습니다.
반대로 TestJson.b
는 Long 타입이지만 json 데이터가 String "2"임이도 마찬가지로 정상적으로 역직렬화되었습니다.
jackson 코드를 자세히 확인해보지는 않았지만 내부적으로 역직렬화할 때 .toXXX()
를 호출하는 것으로 보입니다.
fun main() {
val testJsonString = "{\"a\": [ null, true ], \"b\": [ null ]"
val testJson = jacksonOjbectMapper().readValue(testJsonString, TestJson::class.java)
println(testJson) // TestJson(a=[null, true], b=[null])
println(testJson.a.first()) // null
println(testJson.a.first() is String) // false
pritnln(testJson.a.first() == null) // true
println(testJson.a.last()) // true
println(testJson.a.last()::class) // class kotlin.String
}
data class TestJson(
val a: List<String>,
val b: List<Long>,
)
제네릭의 경우 런타임에 타입이 소거됩니다. 이로 인해 jackson의 역직렬화 과정에서 json 값을 잘못 전달해도 에러가 발생하지 않고 역직렬화에 성공합니다.
TestJson.a
는 List<String>
으로 정의가 되어 있습니다. 역직렬화 과정에서 true는 "true"로 변환되어서 들어갔지만 null은 null 그대로 값이 들어가게 되는 것을 확인할 수 있습니다.
그래서 다음과 같이 String 타입이라고 생각하고 필드나 메서드를 사용하게 되면 NPE가 발생하게 됩니다.
fun main() {
val testJsonString = "{\"a\": [ null ]"
val testJson = jacksonOjbectMapper().readValue(testJsonString, TestJson::class.java)
println(testJson.first().length) // NPE 발생
}
data class TestJson(
val a: List<String>,
)
아마 jackson이 java 기반의 코드로 구현되어 있어 내부적으로 String이라는 클래스에 null이 들어갈 수 있도록 동작하는 것이 아닐까 추측됩니다.
이러한 케이스를 방지하기 위해 JsonSetter
어노테이션을 활용할 수 있습니다.
data class TestJson(
@field:JsonSetter(contentNulls = Nulls.SKIP)
val a: List<Long>,
)
JsonSetter
어노테이션의 contentNulls
필드를 통해 콜렉션의 요소로 null이 전달될 때 어떻게 동작할지 결정할 수 있습니다.
Nulls.SKIP
"{\"a\": [ null ]}"
를 TestJson으로 역직렬화 시도하면 a
는 emptyList가 됩니다.Nulls.AS_EMPTY
"{\"a\": [ null ]}"
를 TestJson으로 역직렬화 시도하면 a
는 [0]
이 됩니다.3번의 경우 open api를 통해 정의된 dto를 codegen 해서 사용하는 서버에서 상당히 위험하게 작용할 수 있습니다. 우리가 String, Int라고 정의해둔 필드에 null이 런타임에 들어올 수 있기 때문입니다. 혹시 모를 클라이언트의 공격에 대비해 한 번 null을 필터링해주는 로직을 넣어주는 것도 고려해볼 수 있을 것 같습니다.