다른 코드 수정 없이 런타임과 프레임워크 버전을 업데이트한 후 에러가 발생했습니다.

  • Java 17 → Java 25
  • Spring Boot 3.3.3 → 3.5.16
Instantiation of [simple type, class ItemsWrapper<Item>] value failed
for JSON property node due to missing (therefore NULL) value
for creator parameter node which is a non-nullable type

node라는 프로퍼티를 JSON에서 찾는데 없다는 뜻인데, 잘 동작하던 코드에서 이런 에러가 발생했습니다.

의심: Jackson 라이브러리 버전에 따른 문제인가?

Gradle의 init script를 쓰면 프로젝트 파일을 건드리지 않고 의존성만 바꿔 끼울 수 있습니다.

downgrade-jackson.init.gradle.kts

allprojects {
  configurations.all {
    resolutionStrategy.eachDependency {
      if (requested.group.startsWith("com.fasterxml.jackson")) {
        useVersion("2.17.2")
      }
    }
  }
}
./gradlew -I downgrade-jackson.init.gradle.kts test --tests "*WrapperTest*"

같은 브랜치, 같은 코드, 같은 Java 25에서 Jackson만 변경해서 테스트했습니다.

Java 25 + Jackson 2.21.4  → FAILED (2 tests)
Java 25 + Jackson 2.17.2  → BUILD SUCCESSFUL
Jackson 2.17.2 Jackson 2.21.4
Java 25 성공 실패

Jackson 버전에 따라 동작이 달라진다는 걸 확인했습니다.

문제의 코드

외부 API가 목록을 어떤 때는 배열로, 어떤 때는 단일 객체로 내려주는 일이 있습니다. 이걸 흡수하려고 원본 JSON 서브트리를 통째로 들고 있다가 나중에 꺼내 쓰는 래퍼가 있습니다.

class ItemsWrapper<T>(
  node: JsonNode,
) {
  val raw: JsonNode = node

  fun items(): JsonNode? = raw["items"]
}

생성자가 JsonNode를 하나 받는데, 생성자 매개변수 node 앞에 val이나 var가 없다는 점이 중요합니다.

이 파라미터는 프로퍼티가 아니라 그냥 생성자 인자이고 {"items": ...} 객체 전체가 node로 들어오길 기대합니다.

Jackson 용어로는 delegating creator입니다.

왜 지금까지 동작했나

jackson-module-kotlin 2.17까지는 이 생성자가 암묵적인 creator로 등록됐습니다. 그 결과 단일 JsonNode 인자를 받는 생성자가 delegating creator로 해석되어 @JsonCreator 없이도 JSON 객체 전체가 node에 전달됐습니다.

2.18부터 creator 탐색 방식이 변경되면서 같은 생성자가 properties-based creator로 처리됩니다. Jackson은 JSON에서 "node" 키를 찾지만 해당 키가 없으므로 Kotlin의 non-nullable 타입 검사에서 예외가 발생합니다.

어느 버전부터 깨지나

Spring Boot는 Jackson BOM 버전을 의존성 관리로 지정합니다.

Spring Boot jackson-bom 동작
3.3.3 2.17.2 OK
3.4.0 2.18.1 FAIL
3.5.0 2.19.0 FAIL
3.5.16 2.21.4 FAIL

해법 두 가지

생성자에 의도를 명시하는 방법

class ItemsWrapper<T> @JsonCreator(mode = JsonCreator.Mode.DELEGATING) constructor(
  node: JsonNode,
) {
  val raw: JsonNode = node

  fun items(): JsonNode? = raw["items"]
}

암묵적으로 추론되던 것을 명시해 기존 동작을 되돌립니다. 어노테이션 한 줄만 추가하고 나머지 코드는 그대로입니다.

생성자를 없애는 방법

class ItemsWrapper<T> {
  @JsonProperty("items")
  val raw: JsonNode? = null

  fun items(): JsonNode? = raw
}

JSON 서브트리를 통째로 받는 대신 필요한 값만 프로퍼티로 직접 받습니다. rawitems 값이 곧바로 담기므로 raw["items"]raw로 단순해집니다.

프로퍼티명 raw와 JSON 키 items가 다르므로 @JsonProperty로 매핑을 알려줘야 합니다. JSON에 items 키가 없으면 아무 값도 들어오지 않으므로 타입은 JsonNode?가 됩니다.