최근, Java 17 + Spring Boot 3.0.2 버전을 사용하는 프로젝트에서 Jackson과 관련된 이슈를 발견했다.
글 작성의 편의를 위해 아래에서는 회원가입 API를 구현해야 하는 상황을 가정한다.
Jackson의 동작원리와 Introspection이라는 개념에 대해 알고 있어야 한다.
- [Java] 처음 들어보는 java.beans.Introspector
1. 발단
회원가입에 필요한 정보를 request body로 받기 위해, 아래와 같이 Java 16부터 정식으로 도입된 record 타입으로 생성했다.
record SignUpRequest(
@NotEmpty String name
@NotEmpty String address,
@NotEmpty String phoneNumber
) {
public SignUpCommand toCommand() {
return new SignUpCommand(name, address, phoneNumber);
}
}
그리고 해당 프로젝트에서는 클라이언트와의 네이밍 전략을 Snake Case로 맞추었기 때문에 yml 설정 파일에 아래 내용이 명시되어 있다.
spring:
jackson:
property-naming-strategy: SNAKE_CASE
따라서 클라이언트는 아래와 같은 요청을 보낸다.
POST /api/v1/auth/signup
Content-Type: application/json
{
"name": "대흉근",
"address": "서울시 강남구",
"phone_number": "01012345678"
}
2. 전개
postman으로 API를 콜하면(테스트 코드 작성하기 귀찮은거 아님) 400 Bad Request가 발생한다.
INFO 레벨에서의 로깅은 별 정보를 주지 않으니, yml 설정 파일에서 TRACE 레벨 로깅으로 바꾼다.
logging:
level:
root: trace
다시 API를 콜해보면, 발생하고 있는 에러를 친절하게 알려준다.
org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Can not set final java.lang.String field com.example.test.adapter.in.web.SignUpRequest.phoneNumber to java.lang.String
...
Caused by: com.fasterxml.jackson.databind.JsonMappingException: Can not set final java.lang.String field com.example.test.adapter.in.web.SignUpRequest.phoneNumber to java.lang.String
at com.fasterxml.jackson.databind.JsonMappingException.from(JsonMappingException.java:276) ~[jackson-databind-2.14.1.jar:2.14.1]
at com.fasterxml.jackson.databind.deser.SettableBeanProperty._throwAsIOE(SettableBeanProperty.java:627) ~[jackson-databind-2.14.1.jar:2.14.1]
at com.fasterxml.jackson.databind.deser.SettableBeanProperty._throwAsIOE(SettableBeanProperty.java:615) ~[jackson-databind-2.14.1.jar:2.14.1]
at com.fasterxml.jackson.databind.deser.SettableBeanProperty._throwAsIOE(SettableBeanProperty.java:638) ~[jackson-databind-2.14.1.jar:2.14.1]
at com.fasterxml.jackson.databind.deser.impl.FieldProperty.set(FieldProperty.java:193) ~[jackson-databind-2.14.1.jar:2.14.1]
at com.fasterxml.jackson.databind.deser.impl.PropertyValue$Regular.assign(PropertyValue.java:60) ~[jackson-databind-2.14.1.jar:2.14.1]
at com.fasterxml.jackson.databind.deser.impl.PropertyBasedCreator.build(PropertyBasedCreator.java:211) ~[jackson-databind-2.14.1.jar:2.14.1]
at com.fasterxml.jackson.databind.deser.BeanDeserializer._deserializeUsingPropertyBased(BeanDeserializer.java:519) ~[jackson-databind-2.14.1.jar:2.14.1]
at com.fasterxml.jackson.databind.deser.BeanDeserializerBase.deserializeFromObjectUsingNonDefault(BeanDeserializerBase.java:1405) ~[jackson-databind-2.14.1.jar:2.14.1]
at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserializeFromObject(BeanDeserializer.java:352) ~[jackson-databind-2.14.1.jar:2.14.1]
at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:185) ~[jackson-databind-2.14.1.jar:2.14.1]
at com.fasterxml.jackson.databind.deser.DefaultDeserializationContext.readRootValue(DefaultDeserializationContext.java:323) ~[jackson-databind-2.14.1.jar:2.14.1]
at com.fasterxml.jackson.databind.ObjectReader._bindAndClose(ObjectReader.java:2105) ~[jackson-databind-2.14.1.jar:2.14.1]
at com.fasterxml.jackson.databind.ObjectReader.readValue(ObjectReader.java:1481) ~[jackson-databind-2.14.1.jar:2.14.1]
at org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter.readJavaType(AbstractJackson2HttpMessageConverter.java:395) ~[spring-web-6.0.4.jar:6.0.4]
...
Caused by: java.lang.IllegalAccessException: Can not set final java.lang.String field com.example.test.adapter.in.web.SignUpRequest.phoneNumber to java.lang.String
at java.base/jdk.internal.reflect.UnsafeFieldAccessorImpl.throwFinalFieldIllegalAccessException(UnsafeFieldAccessorImpl.java:76) ~[na:na]
at java.base/jdk.internal.reflect.UnsafeFieldAccessorImpl.throwFinalFieldIllegalAccessException(UnsafeFieldAccessorImpl.java:80) ~[na:na]
at java.base/jdk.internal.reflect.UnsafeQualifiedObjectFieldAccessorImpl.set(UnsafeQualifiedObjectFieldAccessorImpl.java:79) ~[na:na]
at java.base/java.lang.reflect.Field.set(Field.java:799) ~[na:na]
at com.fasterxml.jackson.databind.deser.impl.FieldProperty.set(FieldProperty.java:190) ~[jackson-databind-2.14.1.jar:2.14.1]
Jackson 라이브러리의 FieldProperty 클래스에서 값을 매핑하는 setter 메서드를 실행하던 중, final String인 phoneNumber에 값을 매핑할 수 없는 JsonMappingException이 발생한 것으로 보인다.
가장 아래의 로그를 보면, 그 과정에서 java reflect 기능을 통해 값을 매핑하면서 exception이 던져진 것 같다. 로그에 찍힌 UnsafeQualifiedObjectFieldAccessorImpl.set()을 들어가보면, 아래처럼 isReadOnly라는 값이 true인 경우, throwFinalFieldIllegalAccessException을 던지고 있는 것을 알 수 있다.
public void set(Object obj, Object value)
throws IllegalArgumentException, IllegalAccessException
{
ensureObj(obj);
if (isReadOnly) {
throwFinalFieldIllegalAccessException(value);
}
...
}
isReadOnly는 UnsafeQualifiedObjectFieldAccessorImpl가 상속하고 있는 추상 클래스인 UnsafeQualifiedFieldAccessorImpl에 선언되어 있다.
/**
* Base class for jdk.internal.misc.Unsafe-based FieldAccessors for fields with
* final or volatile qualifiers. These differ from unqualified
* versions in that (1) they check for read-only status (2) they use
* the volatile forms of Unsafe get/put methods. (When accessed via
* reflection, finals act as slightly "lighter" forms of volatiles. So
* the volatile forms are heavier than necessary in terms of
* underlying reordering rules and memory barriers, but preserve
* correctness.)
*/
abstract class UnsafeQualifiedFieldAccessorImpl
extends UnsafeFieldAccessorImpl
{
protected final boolean isReadOnly;
UnsafeQualifiedFieldAccessorImpl(Field field, boolean isReadOnly) {
super(field);
this.isReadOnly = isReadOnly;
}
}
주석 내용을 보아하니 record 타입이 가지는 특징 중 하나인 불변성이 슬슬 의심되는데, 좀 더 파본다.
UnsafeQualifiedObjectFieldAccessorImpl의 생성자 파라미터로 받고 있는 isReadOnly 값을 어디서 어떻게 세팅해주는지 따라간다.
class UnsafeFieldAccessorFactory {
static FieldAccessor newFieldAccessor(Field field, boolean isReadOnly) {
Class<?> type = field.getType();
boolean isStatic = Modifier.isStatic(field.getModifiers());
boolean isFinal = Modifier.isFinal(field.getModifiers());
boolean isVolatile = Modifier.isVolatile(field.getModifiers());
boolean isQualified = isFinal || isVolatile;
if (isStatic) {
// This code path does not guarantee that the field's
// declaring class has been initialized, but it must be
// before performing reflective operations.
...
} else {
if (!isQualified) {
...
} else {
...
} else {
return new UnsafeQualifiedObjectFieldAccessorImpl(field, isReadOnly);
}
}
}
}
}
한 번 더!!
/** <P> The master factory for all reflective objects, both those in
java.lang.reflect (Fields, Methods, Constructors) as well as their
delegates (FieldAccessors, MethodAccessors, ConstructorAccessors).
</P>
<P> The methods in this class are extremely unsafe and can cause
subversion of both the language and the verifier. For this reason,
they are all instance methods, and access to the constructor of
this factory is guarded by a security check, in similar style to
{@link jdk.internal.misc.Unsafe}. </P>
*/
public class ReflectionFactory {
...
//--------------------------------------------------------------------------
//
// Routines used by java.lang.reflect
//
//
/*
* Note: this routine can cause the declaring class for the field
* be initialized and therefore must not be called until the
* first get/set of this field.
* @param field the field
* @param override true if caller has overridden accessibility
*/
public FieldAccessor newFieldAccessor(Field field, boolean override) {
checkInitted();
Field root = langReflectAccess.getRoot(field);
if (root != null) {
// FieldAccessor will use the root unless the modifiers have
// been overridden
if (root.getModifiers() == field.getModifiers() || !override) {
field = root;
}
}
boolean isFinal = Modifier.isFinal(field.getModifiers());
boolean isReadOnly = isFinal && (!override || langReflectAccess.isTrustedFinalField(field));
return UnsafeFieldAccessorFactory.newFieldAccessor(field, isReadOnly);
}
...
}
아아.. 드디어 찾았다.
결국 위에서 추측했던 대로, record 타입으로 인해 isReadOnly 값이 true로 넘어올 것이라는 것을 확인할 수 있다.
IntelliJ에서 제공하는 기능으로 record 타입인 SignUpRequest를 class 타입으로 변환해보면, 아래처럼 final 범벅 불변 클래스가 만들어짐을 알 수 있다.
final class SignUpRequest {
private final @NotEmpty String name;
private final @NotEmpty String address;
private final @NotEmpty String phoneNumber;
SignUpRequest(String name, String address, String phoneNumber) {
this.name = name;
this.address = address;
this.phoneNumber = phoneNumber;
}
...
}
정리하자면,
1. Jackson 라이브러리는 HTTP Request Body를 SignUpRequest로 deserialize(역직렬화)한다.
2. 그 과정 중 Java의 reflect 기능을 사용하여 SignUpRequest의 property에 값을 setting 한다.
3. 하지만 SignUpRequest는 record 타입이기 때문에 가지는 불변성이라는 특징 덕에 오류가 발생한다.
3. 위기
오케이, 그럼 SignUpRequest를 class 타입으로 변환하면 될 것 같다.
실제로 잘 동작한다.
그런데 이전에 만들었던 토이 프로젝트에서는 아래 예시처럼 request 객체를 record 타입으로 만들었지만 별 이슈가 없었던 것이 불현듯 떠올랐다.
해당 프로젝트는 Java 17 + Spring Boot 3.2.0 버전을 사용하고 있다.
record ChangeUserInfoRequest(
@NotEmpty String name,
@NotEmpty String phoneNumber
) {
public ChangeUserInfoCommand toCommand() {
return new ChangeUserInfoCommand(name, phoneNumber);
}
}
API 호출도 정상적으로 동작하고, 로깅 레벨을 TRACE로 바꿔봐도 에러가 보이지 않는다.

4. 절정
우선 토이 프로젝트의 Boot 버전을 3.1.0으로 낮춰서 다시 테스트 해보았다.
잘 된다.
Spring Boot 3.1 릴리즈 노트를 찾아봤더니, Jackson에 대한 업데이트 내용이 있다.
https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.1-Release-Notes#jackson-215
Spring Boot 3.1 Release Notes
Spring Boot. Contribute to spring-projects/spring-boot development by creating an account on GitHub.
github.com
Jackson 2.15 버전의 변경 내역이 담긴 wiki를 들어가본다.
https://github.com/FasterXML/jackson/wiki/Jackson-Release-2.15
Jackson Release 2.15
Main Portal page for the Jackson project. Contribute to FasterXML/jackson development by creating an account on GitHub.
github.com
record로 검색을 해보니 아래와 같은 이슈들이 발견된다.
사실 이때의 난 네이밍 전략과 관련된 이슈로 생각했기 때문에 이 중 2992번째 이슈를 들어가보았다.
https://github.com/FasterXML/jackson-databind/issues/2992
`@JsonNaming` does not work with Records · Issue #2992 · FasterXML/jackson-databind
Hello, When I try to use a @JsonNaming annotation on a record, I cannot unmarshall json to an object because a mapping exception occurs. I use jackson 2.12.0 with JDK 15. A Test example can be some...
github.com
이슈 작성자의 증상이 나와 동일하다.
작성자와 이야기 나누고 있는 있는 레포지토리 멤버의 말에 따르자면, 네이밍 전략은 deserialization 과정 중에 동적으로 사용되는 것이 아니기 때문에 원인일 것 같지는 않다고 한다.
그러나 꽤나 골 깨지는 이슈라고 말하며 아래처럼 이슈 내용에 대해 정리한다.
마지막에 언급된 discussion에 대해 더 읽고 싶다면 다음 링크로 이동하면 된다.
(https://github.com/FasterXML/jackson-databind/discussions/3719)

멤버들이 소처럼 일해준 덕에 아래 PR에서 수정되었음을 확인할 수 있다.
https://github.com/FasterXML/jackson-databind/pull/3724
Change Records deserialization to use the same mechanism as POJO deserialization. by yihtserns · Pull Request #3724 · FasterXM
New Supports @JsonCreator.mode=DISABLED on canonical constructor. Supports deserialization using "implicit" (i.e. without @JsonCreator annotation) non-canonical constructor when 1/more of its para...
github.com
변경 내역을 완벽히 이해하긴 어렵지만, record 타입인 케이스에 대해 분기 처리를 해준 것으로 보인다.
5. 결말
Spring Boot 3.0.x 버전에서 사용하는 Jackson 2.14.x 버전에서는 record 타입으로 request 객체를 만들면 역직렬화가 실패한다.
Spring Boot 3.1.0 버전부터 사용되는 Jackson 2.15.0 버전부터는 해당 이슈가 수정되었다.
끝!
'Trouble Shooting' 카테고리의 다른 글
최근, Java 17 + Spring Boot 3.0.2 버전을 사용하는 프로젝트에서 Jackson과 관련된 이슈를 발견했다.
글 작성의 편의를 위해 아래에서는 회원가입 API를 구현해야 하는 상황을 가정한다.
Jackson의 동작원리와 Introspection이라는 개념에 대해 알고 있어야 한다.
- [Java] 처음 들어보는 java.beans.Introspector
1. 발단
회원가입에 필요한 정보를 request body로 받기 위해, 아래와 같이 Java 16부터 정식으로 도입된 record 타입으로 생성했다.
record SignUpRequest(
@NotEmpty String name
@NotEmpty String address,
@NotEmpty String phoneNumber
) {
public SignUpCommand toCommand() {
return new SignUpCommand(name, address, phoneNumber);
}
}
그리고 해당 프로젝트에서는 클라이언트와의 네이밍 전략을 Snake Case로 맞추었기 때문에 yml 설정 파일에 아래 내용이 명시되어 있다.
spring:
jackson:
property-naming-strategy: SNAKE_CASE
따라서 클라이언트는 아래와 같은 요청을 보낸다.
POST /api/v1/auth/signup
Content-Type: application/json
{
"name": "대흉근",
"address": "서울시 강남구",
"phone_number": "01012345678"
}
2. 전개
postman으로 API를 콜하면(테스트 코드 작성하기 귀찮은거 아님) 400 Bad Request가 발생한다.
INFO 레벨에서의 로깅은 별 정보를 주지 않으니, yml 설정 파일에서 TRACE 레벨 로깅으로 바꾼다.
logging:
level:
root: trace
다시 API를 콜해보면, 발생하고 있는 에러를 친절하게 알려준다.
org.springframework.http.converter.HttpMessageNotReadableException: JSON parse error: Can not set final java.lang.String field com.example.test.adapter.in.web.SignUpRequest.phoneNumber to java.lang.String
...
Caused by: com.fasterxml.jackson.databind.JsonMappingException: Can not set final java.lang.String field com.example.test.adapter.in.web.SignUpRequest.phoneNumber to java.lang.String
at com.fasterxml.jackson.databind.JsonMappingException.from(JsonMappingException.java:276) ~[jackson-databind-2.14.1.jar:2.14.1]
at com.fasterxml.jackson.databind.deser.SettableBeanProperty._throwAsIOE(SettableBeanProperty.java:627) ~[jackson-databind-2.14.1.jar:2.14.1]
at com.fasterxml.jackson.databind.deser.SettableBeanProperty._throwAsIOE(SettableBeanProperty.java:615) ~[jackson-databind-2.14.1.jar:2.14.1]
at com.fasterxml.jackson.databind.deser.SettableBeanProperty._throwAsIOE(SettableBeanProperty.java:638) ~[jackson-databind-2.14.1.jar:2.14.1]
at com.fasterxml.jackson.databind.deser.impl.FieldProperty.set(FieldProperty.java:193) ~[jackson-databind-2.14.1.jar:2.14.1]
at com.fasterxml.jackson.databind.deser.impl.PropertyValue$Regular.assign(PropertyValue.java:60) ~[jackson-databind-2.14.1.jar:2.14.1]
at com.fasterxml.jackson.databind.deser.impl.PropertyBasedCreator.build(PropertyBasedCreator.java:211) ~[jackson-databind-2.14.1.jar:2.14.1]
at com.fasterxml.jackson.databind.deser.BeanDeserializer._deserializeUsingPropertyBased(BeanDeserializer.java:519) ~[jackson-databind-2.14.1.jar:2.14.1]
at com.fasterxml.jackson.databind.deser.BeanDeserializerBase.deserializeFromObjectUsingNonDefault(BeanDeserializerBase.java:1405) ~[jackson-databind-2.14.1.jar:2.14.1]
at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserializeFromObject(BeanDeserializer.java:352) ~[jackson-databind-2.14.1.jar:2.14.1]
at com.fasterxml.jackson.databind.deser.BeanDeserializer.deserialize(BeanDeserializer.java:185) ~[jackson-databind-2.14.1.jar:2.14.1]
at com.fasterxml.jackson.databind.deser.DefaultDeserializationContext.readRootValue(DefaultDeserializationContext.java:323) ~[jackson-databind-2.14.1.jar:2.14.1]
at com.fasterxml.jackson.databind.ObjectReader._bindAndClose(ObjectReader.java:2105) ~[jackson-databind-2.14.1.jar:2.14.1]
at com.fasterxml.jackson.databind.ObjectReader.readValue(ObjectReader.java:1481) ~[jackson-databind-2.14.1.jar:2.14.1]
at org.springframework.http.converter.json.AbstractJackson2HttpMessageConverter.readJavaType(AbstractJackson2HttpMessageConverter.java:395) ~[spring-web-6.0.4.jar:6.0.4]
...
Caused by: java.lang.IllegalAccessException: Can not set final java.lang.String field com.example.test.adapter.in.web.SignUpRequest.phoneNumber to java.lang.String
at java.base/jdk.internal.reflect.UnsafeFieldAccessorImpl.throwFinalFieldIllegalAccessException(UnsafeFieldAccessorImpl.java:76) ~[na:na]
at java.base/jdk.internal.reflect.UnsafeFieldAccessorImpl.throwFinalFieldIllegalAccessException(UnsafeFieldAccessorImpl.java:80) ~[na:na]
at java.base/jdk.internal.reflect.UnsafeQualifiedObjectFieldAccessorImpl.set(UnsafeQualifiedObjectFieldAccessorImpl.java:79) ~[na:na]
at java.base/java.lang.reflect.Field.set(Field.java:799) ~[na:na]
at com.fasterxml.jackson.databind.deser.impl.FieldProperty.set(FieldProperty.java:190) ~[jackson-databind-2.14.1.jar:2.14.1]
Jackson 라이브러리의 FieldProperty 클래스에서 값을 매핑하는 setter 메서드를 실행하던 중, final String인 phoneNumber에 값을 매핑할 수 없는 JsonMappingException이 발생한 것으로 보인다.
가장 아래의 로그를 보면, 그 과정에서 java reflect 기능을 통해 값을 매핑하면서 exception이 던져진 것 같다. 로그에 찍힌 UnsafeQualifiedObjectFieldAccessorImpl.set()을 들어가보면, 아래처럼 isReadOnly라는 값이 true인 경우, throwFinalFieldIllegalAccessException을 던지고 있는 것을 알 수 있다.
public void set(Object obj, Object value)
throws IllegalArgumentException, IllegalAccessException
{
ensureObj(obj);
if (isReadOnly) {
throwFinalFieldIllegalAccessException(value);
}
...
}
isReadOnly는 UnsafeQualifiedObjectFieldAccessorImpl가 상속하고 있는 추상 클래스인 UnsafeQualifiedFieldAccessorImpl에 선언되어 있다.
/**
* Base class for jdk.internal.misc.Unsafe-based FieldAccessors for fields with
* final or volatile qualifiers. These differ from unqualified
* versions in that (1) they check for read-only status (2) they use
* the volatile forms of Unsafe get/put methods. (When accessed via
* reflection, finals act as slightly "lighter" forms of volatiles. So
* the volatile forms are heavier than necessary in terms of
* underlying reordering rules and memory barriers, but preserve
* correctness.)
*/
abstract class UnsafeQualifiedFieldAccessorImpl
extends UnsafeFieldAccessorImpl
{
protected final boolean isReadOnly;
UnsafeQualifiedFieldAccessorImpl(Field field, boolean isReadOnly) {
super(field);
this.isReadOnly = isReadOnly;
}
}
주석 내용을 보아하니 record 타입이 가지는 특징 중 하나인 불변성이 슬슬 의심되는데, 좀 더 파본다.
UnsafeQualifiedObjectFieldAccessorImpl의 생성자 파라미터로 받고 있는 isReadOnly 값을 어디서 어떻게 세팅해주는지 따라간다.
class UnsafeFieldAccessorFactory {
static FieldAccessor newFieldAccessor(Field field, boolean isReadOnly) {
Class<?> type = field.getType();
boolean isStatic = Modifier.isStatic(field.getModifiers());
boolean isFinal = Modifier.isFinal(field.getModifiers());
boolean isVolatile = Modifier.isVolatile(field.getModifiers());
boolean isQualified = isFinal || isVolatile;
if (isStatic) {
// This code path does not guarantee that the field's
// declaring class has been initialized, but it must be
// before performing reflective operations.
...
} else {
if (!isQualified) {
...
} else {
...
} else {
return new UnsafeQualifiedObjectFieldAccessorImpl(field, isReadOnly);
}
}
}
}
}
한 번 더!!
/** <P> The master factory for all reflective objects, both those in
java.lang.reflect (Fields, Methods, Constructors) as well as their
delegates (FieldAccessors, MethodAccessors, ConstructorAccessors).
</P>
<P> The methods in this class are extremely unsafe and can cause
subversion of both the language and the verifier. For this reason,
they are all instance methods, and access to the constructor of
this factory is guarded by a security check, in similar style to
{@link jdk.internal.misc.Unsafe}. </P>
*/
public class ReflectionFactory {
...
//--------------------------------------------------------------------------
//
// Routines used by java.lang.reflect
//
//
/*
* Note: this routine can cause the declaring class for the field
* be initialized and therefore must not be called until the
* first get/set of this field.
* @param field the field
* @param override true if caller has overridden accessibility
*/
public FieldAccessor newFieldAccessor(Field field, boolean override) {
checkInitted();
Field root = langReflectAccess.getRoot(field);
if (root != null) {
// FieldAccessor will use the root unless the modifiers have
// been overridden
if (root.getModifiers() == field.getModifiers() || !override) {
field = root;
}
}
boolean isFinal = Modifier.isFinal(field.getModifiers());
boolean isReadOnly = isFinal && (!override || langReflectAccess.isTrustedFinalField(field));
return UnsafeFieldAccessorFactory.newFieldAccessor(field, isReadOnly);
}
...
}
아아.. 드디어 찾았다.
결국 위에서 추측했던 대로, record 타입으로 인해 isReadOnly 값이 true로 넘어올 것이라는 것을 확인할 수 있다.
IntelliJ에서 제공하는 기능으로 record 타입인 SignUpRequest를 class 타입으로 변환해보면, 아래처럼 final 범벅 불변 클래스가 만들어짐을 알 수 있다.
final class SignUpRequest {
private final @NotEmpty String name;
private final @NotEmpty String address;
private final @NotEmpty String phoneNumber;
SignUpRequest(String name, String address, String phoneNumber) {
this.name = name;
this.address = address;
this.phoneNumber = phoneNumber;
}
...
}
정리하자면,
1. Jackson 라이브러리는 HTTP Request Body를 SignUpRequest로 deserialize(역직렬화)한다.
2. 그 과정 중 Java의 reflect 기능을 사용하여 SignUpRequest의 property에 값을 setting 한다.
3. 하지만 SignUpRequest는 record 타입이기 때문에 가지는 불변성이라는 특징 덕에 오류가 발생한다.
3. 위기
오케이, 그럼 SignUpRequest를 class 타입으로 변환하면 될 것 같다.
실제로 잘 동작한다.
그런데 이전에 만들었던 토이 프로젝트에서는 아래 예시처럼 request 객체를 record 타입으로 만들었지만 별 이슈가 없었던 것이 불현듯 떠올랐다.
해당 프로젝트는 Java 17 + Spring Boot 3.2.0 버전을 사용하고 있다.
record ChangeUserInfoRequest(
@NotEmpty String name,
@NotEmpty String phoneNumber
) {
public ChangeUserInfoCommand toCommand() {
return new ChangeUserInfoCommand(name, phoneNumber);
}
}
API 호출도 정상적으로 동작하고, 로깅 레벨을 TRACE로 바꿔봐도 에러가 보이지 않는다.

4. 절정
우선 토이 프로젝트의 Boot 버전을 3.1.0으로 낮춰서 다시 테스트 해보았다.
잘 된다.
Spring Boot 3.1 릴리즈 노트를 찾아봤더니, Jackson에 대한 업데이트 내용이 있다.
https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.1-Release-Notes#jackson-215
Spring Boot 3.1 Release Notes
Spring Boot. Contribute to spring-projects/spring-boot development by creating an account on GitHub.
github.com
Jackson 2.15 버전의 변경 내역이 담긴 wiki를 들어가본다.
https://github.com/FasterXML/jackson/wiki/Jackson-Release-2.15
Jackson Release 2.15
Main Portal page for the Jackson project. Contribute to FasterXML/jackson development by creating an account on GitHub.
github.com
record로 검색을 해보니 아래와 같은 이슈들이 발견된다.
사실 이때의 난 네이밍 전략과 관련된 이슈로 생각했기 때문에 이 중 2992번째 이슈를 들어가보았다.
https://github.com/FasterXML/jackson-databind/issues/2992
`@JsonNaming` does not work with Records · Issue #2992 · FasterXML/jackson-databind
Hello, When I try to use a @JsonNaming annotation on a record, I cannot unmarshall json to an object because a mapping exception occurs. I use jackson 2.12.0 with JDK 15. A Test example can be some...
github.com
이슈 작성자의 증상이 나와 동일하다.
작성자와 이야기 나누고 있는 있는 레포지토리 멤버의 말에 따르자면, 네이밍 전략은 deserialization 과정 중에 동적으로 사용되는 것이 아니기 때문에 원인일 것 같지는 않다고 한다.
그러나 꽤나 골 깨지는 이슈라고 말하며 아래처럼 이슈 내용에 대해 정리한다.
마지막에 언급된 discussion에 대해 더 읽고 싶다면 다음 링크로 이동하면 된다.
(https://github.com/FasterXML/jackson-databind/discussions/3719)

멤버들이 소처럼 일해준 덕에 아래 PR에서 수정되었음을 확인할 수 있다.
https://github.com/FasterXML/jackson-databind/pull/3724
Change Records deserialization to use the same mechanism as POJO deserialization. by yihtserns · Pull Request #3724 · FasterXM
New Supports @JsonCreator.mode=DISABLED on canonical constructor. Supports deserialization using "implicit" (i.e. without @JsonCreator annotation) non-canonical constructor when 1/more of its para...
github.com
변경 내역을 완벽히 이해하긴 어렵지만, record 타입인 케이스에 대해 분기 처리를 해준 것으로 보인다.
5. 결말
Spring Boot 3.0.x 버전에서 사용하는 Jackson 2.14.x 버전에서는 record 타입으로 request 객체를 만들면 역직렬화가 실패한다.
Spring Boot 3.1.0 버전부터 사용되는 Jackson 2.15.0 버전부터는 해당 이슈가 수정되었다.
끝!