다른 코드 수정 없이 런타임과 프레임워크 버전을 업데이트한 후 에러가 발생했습니다.
- 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 서브트리를 통째로 받는 대신 필요한 값만 프로퍼티로 직접 받습니다. raw에 items 값이 곧바로 담기므로 raw["items"]가 raw로 단순해집니다.
프로퍼티명 raw와 JSON 키 items가 다르므로 @JsonProperty로 매핑을 알려줘야 합니다. JSON에 items 키가 없으면 아무 값도 들어오지 않으므로 타입은 JsonNode?가 됩니다.