jackson에 대한 몇 가지 재밌는 사실

신연우·2023년 12월 16일
0

WIL

목록 보기
13/13

배경

저번에 HttpMessageNotReadableException을 핸들링하면서 역직렬화 하는 과정의 테스트를 위해 jackson에 대해 이것저것 확인해보다 재밌는 사실을 몇 가지 발견하게 되어 정리해봤습니다.

1. 역직렬화 할 때 원시타입에는 기본값이 들어간다

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이 발생하게 됩니다.

2. 정수 값은 문자열 타입으로 역직렬화될 수 있다

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()를 호출하는 것으로 보입니다.

3. 제네릭은 조심해서 사용하자

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.aList<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

  • null value는 역직렬화할 때 요소로 추가하지 않는다. (말 그대로 스킵함)
  • 예) "{\"a\": [ null ]}"를 TestJson으로 역직렬화 시도하면 a는 emptyList가 됩니다.

Nulls.AS_EMPTY

  • null value는 역직렬화할 때 정해진 기본값으로 설정함
  • 예) "{\"a\": [ null ]}"를 TestJson으로 역직렬화 시도하면 a[0]이 됩니다.

인사이트

3번의 경우 open api를 통해 정의된 dto를 codegen 해서 사용하는 서버에서 상당히 위험하게 작용할 수 있습니다. 우리가 String, Int라고 정의해둔 필드에 null이 런타임에 들어올 수 있기 때문입니다. 혹시 모를 클라이언트의 공격에 대비해 한 번 null을 필터링해주는 로직을 넣어주는 것도 고려해볼 수 있을 것 같습니다.

profile
남들과 함께하기 위해서는 혼자 나아갈 수 있는 힘이 있어야 한다.

0개의 댓글