jackson 2.17 문제 상황
개발환경
- java 21
- spring 3.3.0
- jackson 2.17.1
다음과 같은 java 코드가 있다.
@RestController
public class TestController {
@GetMapping
public TestDto test(@RequestBody TestDto dto) {
return dto;
}
}
@Getter
@AllArgsConstructor
public class TestDto {
private String id;
}
1. TestDto는 필드가 1개 & 기본 생성자 없이 전체 생성자만 갖고 있다.
2. 컨트롤러에서는 필드가 1개인 TestDto를 @RequestBody로 역직렬화해야 한다.
<결과>
HttpMessageNotReadableException: JSON parse error: Cannot construct instance of com.example.jackson_deserialize_java.TestDto (although at least one Creator exists): cannot deserialize from Object value (no delegate- or property-based Creator)]
(대충 TestDto를 역직렬화 할 수 없다는 내용)
jackson 설정 문제려나 싶었는데,,,
이상한 점은 TestDto에 필드가 2개 이상이면, 역직렬화가 잘된다.(?)
본 이슈 관련하여 구글링을 조금 해보니,
역직렬화 대상 객체의 필드가 1개일 때는,
1. 기본생성자 추가
2. @JsonCreator가 붙은 전체생성자 사용
-> 이 정도의 해결책을 제공하는 글들이 많았다.
일단 해결책은 위와 같지만, 본 글은 필드가 1개일 때 역직렬화가 되지 않는 이유 분석에 집중한 글이므로,
만약 해결책만 궁금하다면 전체 글을 읽을 필요 없이, 다음 해결방안 단락만 읽고 나가도 좋다.
해결 방안
다음 3개의 방안 중 하나를 사용하면 된다.
1. 기본 생성자 추가
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class TestDto {
private String id;
}
2. @JsonCreator 생성자 사용
@Getter
public class TestDto {
private String id;
@JsonCreator
public TestDto(String id) {
this.id = id;
}
}
3. record class 사용
public record TestDto(String id) {
}
컨트롤러 request dto는 record class를 습관화하는 것도 방법일 것 같다.
4. Jackson 2.18 이상 버전 사용
사실 본 이슈는 Jackson 2.18 이상 버전에서는 발생하지 않는다.
최근 Spring Initializer에서 제공하는 Spring Boot 버전에서,
Spring Web 의존성을 추가하면 jackson 2.18 버전 이상으로 주입해주기 때문에,
앞으로 생성할 신규 프로젝트에서는 본 이슈와 같은 현상이 발생하지 않을 것이다.
jackson 2.17 문제 원인
앞서 언급했듯, 앞으로 신규 프로젝트에서는 발생하지 않을 문제지만,
비교적 구버전 jackson 사용자 or 단순 호기심으로 문제 원인이 궁금하다면 다음 내용을 읽어봐도 좋다.
예외 발생 지점 추적
우선 예외가 어떤 흐름으로 어디에서 발생하는지부터 확인해보자.
출발 지점은 HandlerMethodArgumentResolver
+) @RequestMapping이 붙은 컨트롤러 메서드에서 사용할 파라미터(메서드 인자)를 커스텀하게 변환하거나 주입하는 역할을 한다.
<HandlerMethodArgumentResolver.resolveArgument()>
@Override
@Nullable
public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
HandlerMethodArgumentResolver resolver = getArgumentResolver(parameter);
if (resolver == null) {
throw new IllegalArgumentException("Unsupported parameter type [" +
parameter.getParameterType().getName() + "]. supportsParameter should be called first.");
}
// Go!
return resolver.resolveArgument(parameter, mavContainer, webRequest, binderFactory);
}
+) //Go! 라고 주석친 부분으로 흘러간다는 뜻
resolver는 @RequestBody를 사용했기 때문에, RequestResponseBodyMethodProcessor를 사용한다.
<RequestResponseBodyMethodProcessor.resolveArgurment()>
@Override
@Nullable
public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
parameter = parameter.nestedIfOptional();
// Go!
Object arg = readWithMessageConverters(webRequest, parameter, parameter.getNestedGenericParameterType());
if (binderFactory != null) {
String name = Conventions.getVariableNameForParameter(parameter);
ResolvableType type = ResolvableType.forMethodParameter(parameter);
WebDataBinder binder = binderFactory.createBinder(webRequest, arg, name, type);
if (arg != null) {
validateIfApplicable(binder, parameter);
if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) {
throw new MethodArgumentNotValidException(parameter, binder.getBindingResult());
}
}
if (mavContainer != null) {
mavContainer.addAttribute(BindingResult.MODEL_KEY_PREFIX + name, binder.getBindingResult());
}
}
return adaptArgumentIfNecessary(arg, parameter);
}
readWithMessageConverters() 코드를 타고 들어가다 보면, 다음 코드에 도착하게 된다.
<AbstractMessageConverterMethodArgumentResolver.readWithMessageConverters()>
@Nullable
@SuppressWarnings({"rawtypes", "unchecked"})
protected <T> Object readWithMessageConverters(HttpInputMessage inputMessage, MethodParameter parameter,
Type targetType) throws IOException, HttpMediaTypeNotSupportedException, HttpMessageNotReadableException {
// ...
try {
message = new AbstractMessageConverterMethodArgumentResolver.EmptyBodyCheckingHttpInputMessage(inputMessage);
for (HttpMessageConverter<?> converter : this.messageConverters) {
Class<HttpMessageConverter<?>> converterType = (Class<HttpMessageConverter<?>>) converter.getClass();
GenericHttpMessageConverter<?> genericConverter =
(converter instanceof GenericHttpMessageConverter ghmc ? ghmc : null);
if (genericConverter != null ? genericConverter.canRead(targetType, contextClass, contentType) :
(targetClass != null && converter.canRead(targetClass, contentType))) {
if (message.hasBody()) {
// ...
// Go!
body = (genericConverter != null ? genericConverter.read(targetType, contextClass, msgToUse) :
((HttpMessageConverter<T>) converter).read(targetClass, msgToUse));
// ...
}
// ...
}
}
// ...
}
// ...
return body;
}
// Go! 부분의 read() 메서드로 들어가보자.
messageConverter는 AbstractJackson2HttpMessageConverter를 사용한다.
<AbstractJackson2HttpMessageConverter.read()>
@Override
public Object read(Type type, @Nullable Class<?> contextClass, HttpInputMessage inputMessage)
throws IOException, HttpMessageNotReadableException {
JavaType javaType = getJavaType(type, contextClass);
// Go!
return readJavaType(javaType, inputMessage);
}
<AbstractJackson2HttpMessageConverter.readJavaType()>
private Object readJavaType(JavaType javaType, HttpInputMessage inputMessage) throws IOException {
// ...
boolean isUnicode = ENCODINGS.containsKey(charset.name()) ||
"UTF-16".equals(charset.name()) ||
"UTF-32".equals(charset.name());
// ...
ObjectReader objectReader = objectMapper.reader().forType(javaType);
objectReader = customizeReader(objectReader, javaType);
if (isUnicode) {
// Go!
return objectReader.readValue(inputStream);
}
// ...
}
// ...
}
이 다음부터는 jackson 라이브러리 영역이다.
<ObjectReader.readValue()>
public <T> T readValue(InputStream src) throws IOException
{
if (_dataFormatReaders != null) {
return (T) _detectBindAndClose(_dataFormatReaders.findFormat(src), false);
}
// Go!
return (T) _bindAndClose(_considerFilter(createParser(src), false));
}
들어가다 보면 다음 코드에 도착한다.
<DefaultDeserializationContext.readRootValue()>
public Object readRootValue(JsonParser p, JavaType valueType,
JsonDeserializer<Object> deser, Object valueToUpdate)
throws IOException
{
if (_config.useRootWrapping()) {
return _unwrapAndDeserialize(p, valueType, deser, valueToUpdate);
}
if (valueToUpdate == null) {
// Go!
return deser.deserialize(p, this);
}
return deser.deserialize(p, this, valueToUpdate);
}
JsonDeserializer는 BeanDeserializer를 사용한다.
<BeanDeserializer.deserialize()>
@Override
public Object deserialize(JsonParser p, DeserializationContext ctxt) throws IOException
{
// common case first
if (p.isExpectedStartObjectToken()) {
if (_vanillaProcessing) {
return vanillaDeserialize(p, ctxt, p.nextToken());
}
// 23-Sep-2015, tatu: This is wrong at some many levels, but for now... it is
// what it is, including "expected behavior".
p.nextToken();
if (_objectIdReader != null) {
return deserializeWithObjectId(p, ctxt);
}
// Go!
return deserializeFromObject(p, ctxt);
}
return _deserializeOther(p, ctxt, p.currentToken());
}
<BeanDeserializer.deserializeFromObject()>
@Override
public Object deserializeFromObject(JsonParser p, DeserializationContext ctxt) throws IOException
{
// ...
if (_nonStandardCreation) {
// ...
// Go!
Object bean = deserializeFromObjectUsingNonDefault(p, ctxt);
// ...
return bean;
}
final Object bean = _valueInstantiator.createUsingDefault(ctxt);
// ...
if (p.hasTokenId(JsonTokenId.ID_FIELD_NAME)) {
String propName = p.currentName();
do {
p.nextToken();
SettableBeanProperty prop = _beanProperties.find(propName);
if (prop != null) { // normal case
try {
prop.deserializeAndSet(p, ctxt, bean);
} catch (Exception e) {
wrapAndThrow(e, bean, propName, ctxt);
}
continue;
}
handleUnknownVanilla(p, ctxt, bean, propName);
} while ((propName = p.nextFieldName()) != null);
}
return bean;
}
기본 생성자가 없기 때문에, _nonStandardCreation 분기문을 통과한다.
<BeanDeserializerBase.deserializerFromObjectUsingNonDefault()>
protected Object deserializeFromObjectUsingNonDefault(JsonParser p,
DeserializationContext ctxt) throws IOException
{
final JsonDeserializer<Object> delegateDeser = _delegateDeserializer();
if (delegateDeser != null) {
final Object bean = _valueInstantiator.createUsingDelegate(ctxt,
delegateDeser.deserialize(p, ctxt));
if (_injectables != null) {
injectValues(ctxt, bean);
}
return bean;
}
if (_propertyBasedCreator != null) {
return _deserializeUsingPropertyBased(p, ctxt);
}
// ...
// 여기서 예외 발생!
return ctxt.handleMissingInstantiator(raw, getValueInstantiator(), p,
"cannot deserialize from Object value (no delegate- or property-based Creator)");
}
주석 친 곳으로 들어가 보면 MismatchedInputException을 throw하는 코드를 발견할 수 있다.
예외 원인 분석
예외가 발생할 때까지의 흐름을 파악했으니, 이제 예외의 원인을 분석해본다.
다시 예외 발생 지점을 확인해보자.
protected Object deserializeFromObjectUsingNonDefault(JsonParser p,
DeserializationContext ctxt) throws IOException
{
// 1. Delegate-based 역직렬화
final JsonDeserializer<Object> delegateDeser = _delegateDeserializer();
if (delegateDeser != null) {
final Object bean = _valueInstantiator.createUsingDelegate(ctxt,
delegateDeser.deserialize(p, ctxt));
if (_injectables != null) {
injectValues(ctxt, bean);
}
return bean;
}
// 2. Property-based 역직렬화
if (_propertyBasedCreator != null) {
return _deserializeUsingPropertyBased(p, ctxt);
}
// ...
// 여기서 예외 발생!
return ctxt.handleMissingInstantiator(raw, getValueInstantiator(), p,
"cannot deserialize from Object value (no delegate- or property-based Creator)");
}
deserializeFromObjectUsingNonDefault() 메서드는 역직렬화 방식에 대해 주석 친 부분과 같이 크게 두 part로 나눌 수 있다.
1. Delegate-based 역직렬화
객체가 하나의 값(value) 만을 포함하는 경우, 해당 값을 객체 자체로 매핑하는 방식.
ex) json을 { "id": "1" }이 아닌 "1"로만 보내도 객체로 매핑할 수 있음.
-> 일반적으로 { "id": "1" }와 같은 형식으로 json 통신을 하기 때문에 본 방식은 깊게 분석하지 않겠다.
2. Property-based 역직렬화
JSON의 속성(property) 을 객체의 생성자 매개변수에 매핑하여 객체를 생성하는 방식.
-> 일반적으로 위 방식을 사용하게 되고, 필드 개수와 상관 없이 Property-based 역직렬화가 되기를 기대해야 하는 것 같다.
결국 문제 원인은,
"역직렬화 대상 객체의 필드가 1개인 경우, _propertyBasedCreator = null이어서 _deserializeUsingPropertyBased() 메서드가 호출되지 않아 코드가 다음 step으로 흘러 예외가 발생한 것."
-> 실제로 필드가 2개 이상이거나, 필드가 1개여도 @JsonCreator가 존재하는 경우 _propertyBasedCreator != null 취급되어 역직렬화가 정상 처리된다.
_propertyBasedCreator를 세팅하는 코드 분석
그럼 이제 _propertyBasedCreator를 세팅하는 곳을 찾아보자.
예외 추적을 할 때, 호출했던 메서드로 다시 돌아가보자.
<AbstractMessageConverterMethodArgumentResolver.readWithMessageConverters()>
@Nullable
@SuppressWarnings({"rawtypes", "unchecked"})
protected <T> Object readWithMessageConverters(HttpInputMessage inputMessage, MethodParameter parameter,
Type targetType) throws IOException, HttpMediaTypeNotSupportedException, HttpMessageNotReadableException {
// ...
try {
message = new AbstractMessageConverterMethodArgumentResolver.EmptyBodyCheckingHttpInputMessage(inputMessage);
for (HttpMessageConverter<?> converter : this.messageConverters) {
Class<HttpMessageConverter<?>> converterType = (Class<HttpMessageConverter<?>>) converter.getClass();
GenericHttpMessageConverter<?> genericConverter =
(converter instanceof GenericHttpMessageConverter ghmc ? ghmc : null);
// Go!
if (genericConverter != null ? genericConverter.canRead(targetType, contextClass, contentType) :
(targetClass != null && converter.canRead(targetClass, contentType))) {
if (message.hasBody()) {
// ...
// 역직렬화 하는 부분
body = (genericConverter != null ? genericConverter.read(targetType, contextClass, msgToUse) :
((HttpMessageConverter<T>) converter).read(targetClass, msgToUse));
// ...
}
// ...
}
}
// ...
}
// ...
return body;
}
이전에 "// 역직렬화 하는 부분" 주석친 코드를 타고 들어가면서 예외가 발생했던 코드를 확인할 수 있었다.
이제 바뀐 "// Go!" 주석 > canRead() 메서드에 집중해보자.
<AbstractJackson2HttpMessageConverter.canRead()>
@Override
public boolean canRead(Type type, @Nullable Class<?> contextClass, @Nullable MediaType mediaType) {
if (!canRead(mediaType)) {
return false;
}
JavaType javaType = getJavaType(type, contextClass);
ObjectMapper objectMapper = selectObjectMapper(javaType.getRawClass(), mediaType);
if (objectMapper == null) {
return false;
}
AtomicReference<Throwable> causeRef = new AtomicReference<>();
// Go!
if (objectMapper.canDeserialize(javaType, causeRef)) {
return true;
}
logWarningIfNecessary(javaType, causeRef.get());
return false;
}
여기서부터 jackson 라이브러리 코드다.
<ObjectMapper.canDeserialize()>
public boolean canDeserialize(JavaType type, AtomicReference<Throwable> cause)
{
return createDeserializationContext(null,
getDeserializationConfig()).hasValueDeserializerFor(type, cause);
}
<DeserializationContext.hasValueDeserializerFor()>
public boolean hasValueDeserializerFor(JavaType type, AtomicReference<Throwable> cause) {
try {
// Go!
return _cache.hasValueDeserializerFor(this, _factory, type);
} catch (DatabindException e) {
if (cause != null) {
cause.set(e);
}
} catch (RuntimeException e) {
if (cause == null) { // earlier behavior
throw e;
}
cause.set(e);
}
return false;
}
<DeserializerCache.hasValueDeserializerFor()>
public boolean hasValueDeserializerFor(DeserializationContext ctxt,
DeserializerFactory factory, JavaType type)
throws JsonMappingException
{
JsonDeserializer<Object> deser = _findCachedDeserializer(type);
if (deser == null) {
// Go!
deser = _createAndCacheValueDeserializer(ctxt, factory, type);
}
return (deser != null);
}
적절한 Deserializer가 있는지 확인하는 메서드.
프로젝트 구동 초기에는 캐시에 없어서 새로운 Deserializer를 생성한다.
코드를 타고 들어가면 다음 메서드를 만난다.
protected JsonDeserializer<Object> _createAndCache2(DeserializationContext ctxt,
DeserializerFactory factory, JavaType type)
throws JsonMappingException
{
JsonDeserializer<Object> deser;
try {
deser = _createDeserializer(ctxt, factory, type);
}
// ...
if (deser instanceof ResolvableDeserializer) {
_incompleteDeserializers.put(type, deser);
try {
// Go!
((ResolvableDeserializer)deser).resolve(ctxt);
} finally {
_incompleteDeserializers.remove(type);
}
}
if (addToCache) {
_cachedDeserializers.put(type, deser);
}
return deser;
}
JsonDeserializer를 생성하는 factory 인자가 BeanDeserializerBase를 생성하고,
BeanDeserializerBase는 ResolvableDeserializer를 구현하기 때문에,
Deserializer를 초기 세팅하는 // Go! 코드를 탈 수 있다.
<BeanDeserializerBase.resolve()>
@Override
public void resolve(DeserializationContext ctxt) throws JsonMappingException
{
// ...
SettableBeanProperty[] creatorProps;
if (_valueInstantiator.canCreateFromObjectWith()) {
// creatorProps 세팅
creatorProps = _valueInstantiator.getFromObjectArguments(ctxt.getConfig());
// ...
} else {
creatorProps = null;
}
// ...
if (creatorProps != null) {
// _propertyBasedCreator 세팅
_propertyBasedCreator = PropertyBasedCreator.construct(ctxt, _valueInstantiator,
creatorProps, _beanProperties);
}
// ...
}
바로 여기서 _propertyBasedCreator를 세팅하고 있었다.
- creatorProps 값이 null이면 세팅 X(필드 1개케이스)
- creatorProps 값이 null이 아니면 세팅 O(그 외 케이스)
위와 같이 추측을 하고 creatorProps 값을 세팅하는 상단 코드를 살펴보자.
_valueInstantiator.canCreateFromObjectWith() 값이 true면 creatorProps를 세팅해준다.
ValueInstantiator는 JSON을 역직렬화할 때 특정 클래스의 인스턴스를 생성하는 방법을 커스터마이징할 수 있도록 해주는 인터페이스다.
<StdValueInstantiator.canCreateFromObjectWith()>
@Override
public boolean canCreateFromObjectWith() {
return (_withArgsCreator != null);
}
다시 정리해보면,
- _withArgsCreator 값이 null이면 Property-based 역직렬화 불가능(필드 1개 케이스)
- _withArgsCreator 값이 null이 아니면 Property-based 역직렬화 가능(그 외 케이스)
거의 다 온 것 같다.
이제 _withArgsCreator를 세팅하는 부분을 살펴보자.
<StdValueInstantiator.configureFromObjectSettings()>
public void configureFromObjectSettings(AnnotatedWithParams defaultCreator,
AnnotatedWithParams delegateCreator, JavaType delegateType, SettableBeanProperty[] delegateArgs,
AnnotatedWithParams withArgsCreator, SettableBeanProperty[] constructorArgs)
{
_defaultCreator = defaultCreator;
_delegateCreator = delegateCreator;
_delegateType = delegateType;
_delegateArguments = delegateArgs;
_withArgsCreator = withArgsCreator; // 찾았다
_constructorArguments = constructorArgs;
}
위 메서드 호출부는 다음과 같다.
<Creatorcollector.constructValueInstantiator()>
public ValueInstantiator constructValueInstantiator(DeserializationContext ctxt)
throws JsonMappingException
{
// ...
inst.configureFromObjectSettings(_creators[C_DEFAULT], _creators[C_DELEGATE],
delegateType, _delegateArgs, _creators[C_PROPS],
_propertyBasedArgs);
// ...
return inst;
}
_creators[C_PROPS]를 _withArgsCreator 값으로 넣어준다.
_creators[C_PROPS]는 보통 null인데, 다음 메서드에 의해 세팅된다.
<Creatorcollector.addPropertyCreator()>
public void addPropertyCreator(AnnotatedWithParams creator,
boolean explicit, SettableBeanProperty[] properties)
{
if (verifyNonDup(creator, C_PROPS, explicit)) {
// ...
}
}
자, 그럼 더 추상적으로 정리해보자.
- 역직렬화 과정 중 addPropertyCreator()를 호출하지 않으면 Property-based 역직렬화 불가능(필드 1개 케이스)
- 역직렬화 과정 중 addPropertyCreator()를 호출하면 Property-based 역직렬화 가능(그 외 케이스)
addPropertyCreator() 호출하는 부분 분석
다시 Deserializer를 생성하던 코드로 돌아오자.
protected JsonDeserializer<Object> _createAndCache2(DeserializationContext ctxt,
DeserializerFactory factory, JavaType type)
throws JsonMappingException
{
JsonDeserializer<Object> deser;
try {
// Go!
deser = _createDeserializer(ctxt, factory, type);
}
// ...
if (deser instanceof ResolvableDeserializer) {
_incompleteDeserializers.put(type, deser);
try {
// _propertyBasedCreator 세팅하는 부분
((ResolvableDeserializer)deser).resolve(ctxt);
} finally {
_incompleteDeserializers.remove(type);
}
}
if (addToCache) {
_cachedDeserializers.put(type, deser);
}
return deser;
}
좀 전에는 _propertyBasedCreator를 생성하는 부분을 분석하기 위해, resolve() 메서드로 들어갔었는데,
이번에는 Deserializer를 생성하는 // Go! 주석 코드를 타고 들어가보자.
<DeserializerCache._createDeserializer2()>
protected JsonDeserializer<?> _createDeserializer2(DeserializationContext ctxt,
DeserializerFactory factory, JavaType type, BeanDescription beanDesc)
throws JsonMappingException
{
// ...
// Go!
return factory.createBeanDeserializer(ctxt, type, beanDesc);
}
<BeanDeserializerFactory.createBeanDeserializer>
@SuppressWarnings("unchecked")
@Override
public JsonDeserializer<Object> createBeanDeserializer(DeserializationContext ctxt,
JavaType type, BeanDescription beanDesc)
throws JsonMappingException
{
// ...
// Go!
return buildBeanDeserializer(ctxt, type, beanDesc);
}
<BeanDeserializerFactory.buildBeanDeserializer()>
@SuppressWarnings("unchecked")
public JsonDeserializer<Object> buildBeanDeserializer(DeserializationContext ctxt,
JavaType type, BeanDescription beanDesc)
throws JsonMappingException
{
// ...
try {
// Go!
valueInstantiator = findValueInstantiator(ctxt, beanDesc);
}
// ...
}
<BasicDeserializerFactory.findValueInstantiator()>
@Override
public ValueInstantiator findValueInstantiator(DeserializationContext ctxt,
BeanDescription beanDesc)
throws JsonMappingException
{
// ...
ValueInstantiator instantiator = null;
// ...
if (instDef != null) {
instantiator = _valueInstantiatorInstance(config, ac, instDef);
}
if (instantiator == null) {
// Second: see if some of standard Jackson/JDK types might provide value
// instantiators.
instantiator = JDKValueInstantiators.findStdValueInstantiator(config, beanDesc.getBeanClass());
if (instantiator == null) {
// Go!
instantiator = _constructDefaultValueInstantiator(ctxt, beanDesc);
}
}
// ...
return instantiator;
}
이전 코드에서 _withArgsCreator 필드를 갖고 있던 ValueInstantiator를 생성한다.
<BasicDeserializerFactory._constructDefaultValueInstantiator()>
protected ValueInstantiator _constructDefaultValueInstantiator(DeserializationContext ctxt,
BeanDescription beanDesc)
throws JsonMappingException
{
// ...
if (ccState.hasImplicitFactoryCandidates()
&& !ccState.hasExplicitFactories() && !ccState.hasExplicitConstructors()) {
// Go!
_addImplicitFactoryCreators(ctxt, ccState, ccState.implicitFactoryCandidates());
}
return ccState.creators.constructValueInstantiator(ctxt);
}
<BasicDeserializerFactory._addImplicitconstructorCreators()>
protected void _addImplicitConstructorCreators(DeserializationContext ctxt,
BasicDeserializerFactory.CreatorCollectionState ccState, List<CreatorCandidate> ctorCandidates)
throws JsonMappingException
{
// ...
final boolean preferPropsBased = config.getConstructorDetector().singleArgCreatorDefaultsToProperties()
// [databind#3968]: Only Record's canonical constructor is allowed
// to be considered for properties-based creator to avoid failure
&& !beanDesc.isRecordType();
for (CreatorCandidate candidate : ctorCandidates) {
final int argCount = candidate.paramCount();
// ...
// some single-arg factory methods (String, number) are auto-detected
if (argCount == 1) {
// ...
final boolean useProps = preferPropsBased
|| _checkIfCreatorPropertyBased(beanDesc, intr, ctor, propDef);
if (useProps) {
// ...
creators.addPropertyCreator(ctor, false, properties);
} else {
// 1. Go! 필드가 1개일 때
/*boolean added = */ _handleSingleArgumentCreator(creators,
ctor, false,
vchecker.isCreatorVisible(ctor));
// ...
}
// regardless, fully handled
continue;
}
// arg 2개 이상
//...
if ((implicitCtors != null) && !creators.hasDelegatingCreator()
&& !creators.hasPropertyBasedCreator()) {
// 2. Go! 필드가 2개 이상일 때
_checkImplicitlyNamedConstructors(ctxt, beanDesc, vchecker, intr,
creators, implicitCtors);
}
}
// 1. Go! 필드가 1개일 때 (정확히는 필드가 1개 & userProps가 false일 때)
userProps를 세팅하는 코드를 보면 @JsonProperty 생성자를 갖고 있는지 or record type인지 등을 판단하는 것처럼 보인다.
아무튼 중요한 것은 필드 1개 & 전체 생성자 케이스의 // Go! 코드를 타고 들어가다 보면 addPropertyCreator()를 호출하는 부분은 없다는 것이다.
// 2. Go! 필드가 2개 이상일 때
<BeanDeserializerFactory._checkImplicitlyNameConstructors()>
private void _checkImplicitlyNamedConstructors(DeserializationContext ctxt,
BeanDescription beanDesc, VisibilityChecker<?> vchecker,
AnnotationIntrospector intr, CreatorCollector creators,
List<AnnotatedWithParams> implicitCtors) throws JsonMappingException
{
// ...
if (found != null) {
// addPropertyCreator()를 호출한다!
creators.addPropertyCreator(found, /*isCreator*/ false, foundProps);
// ...
}
}
addPropertyCreator()를 호출하는 부분을 찾을 수 있다.
원인 분석 정리
만약 역직렬화 대상 객체의 필드가 2개 이상일 때,
1. 메시지 컨버터 > 역직렬화 가능 여부 확인(canRead)
<AbstractMessageConverterMethodArgumentResolver.readWithMessageConverters()>
@Nullable
@SuppressWarnings({"rawtypes", "unchecked"})
protected <T> Object readWithMessageConverters(HttpInputMessage inputMessage, MethodParameter parameter,
Type targetType) throws IOException, HttpMediaTypeNotSupportedException, HttpMessageNotReadableException {
// ...
try {
message = new AbstractMessageConverterMethodArgumentResolver.EmptyBodyCheckingHttpInputMessage(inputMessage);
for (HttpMessageConverter<?> converter : this.messageConverters) {
Class<HttpMessageConverter<?>> converterType = (Class<HttpMessageConverter<?>>) converter.getClass();
GenericHttpMessageConverter<?> genericConverter =
(converter instanceof GenericHttpMessageConverter ghmc ? ghmc : null);
// Go!
if (genericConverter != null ? genericConverter.canRead(targetType, contextClass, contentType) :
(targetClass != null && converter.canRead(targetClass, contentType))) {
if (message.hasBody()) {
// ...
// 역직렬화 하는 부분
body = (genericConverter != null ? genericConverter.read(targetType, contextClass, msgToUse) :
((HttpMessageConverter<T>) converter).read(targetClass, msgToUse));
// ...
}
// ...
}
}
// ...
}
// ...
return body;
}
2. Deserializer 생성
<DeserializerCache._createAndCache2()>
protected JsonDeserializer<Object> _createAndCache2(DeserializationContext ctxt,
DeserializerFactory factory, JavaType type)
throws JsonMappingException
{
JsonDeserializer<Object> deser;
try {
// Go!
deser = _createDeserializer(ctxt, factory, type);
}
// ...
if (deser instanceof ResolvableDeserializer) {
_incompleteDeserializers.put(type, deser);
try {
// _propertyBasedCreator 생성하는 부분
((ResolvableDeserializer)deser).resolve(ctxt);
} finally {
_incompleteDeserializers.remove(type);
}
}
if (addToCache) {
_cachedDeserializers.put(type, deser);
}
return deser;
}
3. ValueInstantiator 세팅 > addPropertyCreator() > _withArgsCreator 값 세팅
<Beandeserializerfactory._checkimplicitlynameconstructors()>
private void _checkImplicitlyNamedConstructors(DeserializationContext ctxt,
BeanDescription beanDesc, VisibilityChecker<?> vchecker,
AnnotationIntrospector intr, CreatorCollector creators,
List<AnnotatedWithParams> implicitCtors) throws JsonMappingException
{
// ...
if (found != null) {
// addPropertyCreator()를 호출한다!
creators.addPropertyCreator(found, /*isCreator*/ false, foundProps);
// ...
}
}
필드 1개 케이스는 위 코드를 타지 못한다.
4. Deserializer 초기 세팅
<DeserializerCache._createAndCache2()>
protected JsonDeserializer<Object> _createAndCache2(DeserializationContext ctxt,
DeserializerFactory factory, JavaType type)
throws JsonMappingException
{
JsonDeserializer<Object> deser;
try {
// Deserializer 생성
deser = _createDeserializer(ctxt, factory, type);
}
// ...
if (deser instanceof ResolvableDeserializer) {
_incompleteDeserializers.put(type, deser);
try {
// Go!
((ResolvableDeserializer)deser).resolve(ctxt);
} finally {
_incompleteDeserializers.remove(type);
}
}
if (addToCache) {
_cachedDeserializers.put(type, deser);
}
return deser;
}
5. Property-based 역직렬화 세팅
<BeanDeserializerBase.resolve()>
@Override
public void resolve(DeserializationContext ctxt) throws JsonMappingException
{
// ...
SettableBeanProperty[] creatorProps;
// _withArgsCreator 값 null 여부 확인
if (_valueInstantiator.canCreateFromObjectWith()) {
// creatorProps 세팅
creatorProps = _valueInstantiator.getFromObjectArguments(ctxt.getConfig());
// ...
} else {
creatorProps = null;
}
// ...
if (creatorProps != null) {
// _propertyBasedCreator 세팅
_propertyBasedCreator = PropertyBasedCreator.construct(ctxt, _valueInstantiator,
creatorProps, _beanProperties);
}
// ...
}
6. 메시지 컨버터 > 역직렬화
<AbstractMessageConverterMethodArgumentResolver.readWithMessageConverters()>
@Nullable
@SuppressWarnings({"rawtypes", "unchecked"})
protected <T> Object readWithMessageConverters(HttpInputMessage inputMessage, MethodParameter parameter,
Type targetType) throws IOException, HttpMediaTypeNotSupportedException, HttpMessageNotReadableException {
// ...
try {
message = new AbstractMessageConverterMethodArgumentResolver.EmptyBodyCheckingHttpInputMessage(inputMessage);
for (HttpMessageConverter<?> converter : this.messageConverters) {
Class<HttpMessageConverter<?>> converterType = (Class<HttpMessageConverter<?>>) converter.getClass();
GenericHttpMessageConverter<?> genericConverter =
(converter instanceof GenericHttpMessageConverter ghmc ? ghmc : null);
// 역직렬화 생성 및 가능 여부 확인
if (genericConverter != null ? genericConverter.canRead(targetType, contextClass, contentType) :
(targetClass != null && converter.canRead(targetClass, contentType))) {
if (message.hasBody()) {
// ...
// Go!
body = (genericConverter != null ? genericConverter.read(targetType, contextClass, msgToUse) :
((HttpMessageConverter<T>) converter).read(targetClass, msgToUse));
// ...
}
// ...
}
}
// ...
}
// ...
return body;
}
7. Property-based 역직렬화 수행
<BeanDeserializerBase.deserializerFromObjectUsingNonDefault()>
protected Object deserializeFromObjectUsingNonDefault(JsonParser p,
DeserializationContext ctxt) throws IOException
{
final JsonDeserializer<Object> delegateDeser = _delegateDeserializer();
if (delegateDeser != null) {
final Object bean = _valueInstantiator.createUsingDelegate(ctxt,
delegateDeser.deserialize(p, ctxt));
if (_injectables != null) {
injectValues(ctxt, bean);
}
return bean;
}
// Go! Property-based deserialize
if (_propertyBasedCreator != null) {
return _deserializeUsingPropertyBased(p, ctxt);
}
// ...
// 아무 정책이 없다면 여기서 예외 발생!
return ctxt.handleMissingInstantiator(raw, getValueInstantiator(), p,
"cannot deserialize from Object value (no delegate- or property-based Creator)");
}
해결 방안 케이스 분석
필드 1개 & 전체 생성자 케이스 때, 왜 역직렬화가 안되는지는 정밀하게 분석해봤다.
이제 글 초기에 해결 방안에서 언급한 4가지 케이스는 왜 역직렬화가 가능한지도 분석해보자.
1. 기본 생성자 추가
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class TestDto {
private String id;
}
기본 생성자가 있다 하더라도 필드가 1개이기 때문에,
이 케이스 역시 addPropertyCreator()를 호출하지 않는다.
그러나 역직렬화를 할 때,
<BeanDeserializer.deserializeFromObject()>
@Override
public Object deserializeFromObject(JsonParser p, DeserializationContext ctxt) throws IOException
{
// ...
if (_nonStandardCreation) {
// ...
// 여기로 빠지지 않는다.
Object bean = deserializeFromObjectUsingNonDefault(p, ctxt);
// ...
return bean;
}
final Object bean = _valueInstantiator.createUsingDefault(ctxt);
// ...
if (p.hasTokenId(JsonTokenId.ID_FIELD_NAME)) {
String propName = p.currentName();
do {
p.nextToken();
SettableBeanProperty prop = _beanProperties.find(propName);
if (prop != null) { // normal case
try {
// Go!
prop.deserializeAndSet(p, ctxt, bean);
} catch (Exception e) {
wrapAndThrow(e, bean, propName, ctxt);
}
continue;
}
handleUnknownVanilla(p, ctxt, bean, propName);
} while ((propName = p.nextFieldName()) != null);
}
return bean;
}
기본 생성자가 있기 때문에, _nonStandardCreation 분기를 통과하지 않아서, 아래 메서드를 호출하지 않는다.
<BeanDeserializerBase.deserializerFromObjectUsingNonDefault()>
protected Object deserializeFromObjectUsingNonDefault(JsonParser p,
DeserializationContext ctxt) throws IOException
{
final JsonDeserializer<Object> delegateDeser = _delegateDeserializer();
if (delegateDeser != null) {
final Object bean = _valueInstantiator.createUsingDelegate(ctxt,
delegateDeser.deserialize(p, ctxt));
if (_injectables != null) {
injectValues(ctxt, bean);
}
return bean;
}
// Property-based deserialize
if (_propertyBasedCreator != null) {
return _deserializeUsingPropertyBased(p, ctxt);
}
// ...
// 아무 정책이 없다면 여기서 예외 발생!
return ctxt.handleMissingInstantiator(raw, getValueInstantiator(), p,
"cannot deserialize from Object value (no delegate- or property-based Creator)");
}
대신, // Go! 부분의 FiledProperty > deserializeAndSet() 메서드를 호출해서 reflection을 통해 역직렬화를 시도하게 된다.
2. @JsonCreator 생성자 사용
@Getter
public class TestDto {
private String id;
@JsonCreator
public TestDto(String id) {
this.id = id;
}
}
코드를 타고 들어가다 보면 다음 메서드를 호출하게 된다.
<BasicDeserializerFactory._addExplicitConstructorCreators()>
protected void _addExplicitConstructorCreators(DeserializationContext ctxt,
BasicDeserializerFactory.CreatorCollectionState ccState, boolean findImplicit)
throws JsonMappingException
{
// ...
for (AnnotatedConstructor ctor : beanDesc.getConstructors()) {
JsonCreator.Mode creatorMode = intr.findCreatorAnnotation(ctxt.getConfig(), ctor);
if (JsonCreator.Mode.DISABLED == creatorMode) {
continue;
}
if (creatorMode == null) {
if (findImplicit && vchecker.isCreatorVisible(ctor)) {
// @JsonCreator 없을 때
ccState.addImplicitConstructorCandidate(CreatorCandidate.construct(intr,
ctor, creatorParams.get(ctor)));
}
continue;
}
switch (creatorMode) {
// ...
default:
// Go! @JsonCreator 있을 때
_addExplicitAnyCreator(ctxt, beanDesc, creators,
CreatorCandidate.construct(intr, ctor, creatorParams.get(ctor)),
ctxt.getConfig().getConstructorDetector());
break;
}
// ...
}
}
@JsonCreator가 있을 때는 아래 switch 문을 타게 된다.
<BasicDeserializerFactory._addExplicitAnyCreator()>
protected void _addExplicitAnyCreator(DeserializationContext ctxt,
BeanDescription beanDesc, CreatorCollector creators,
CreatorCandidate candidate, ConstructorDetector ctorDetector)
throws JsonMappingException
{
// ...
boolean useProps;
switch (ctorDetector.singleArgMode()) {
// ...
default:
{
// ...
useProps = (paramName != null);
if (!useProps) {
// Otherwise, `@JsonValue` suggests delegation
if (beanDesc.findJsonValueAccessor() != null) {
;
} else if (injectId != null) {
// But Injection suggests property-based (for legacy reasons?)
useProps = true;
} else if (paramDef != null) {
// ...
// 여기서 true 세팅
useProps = (paramName != null) && paramDef.couldSerialize();
}
}
}
}
if (useProps) {
SettableBeanProperty[] properties = new SettableBeanProperty[] {
constructCreatorProperty(ctxt, beanDesc, paramName, 0, param, injectId)
};
// Go! addPropertyCreator() 호출
creators.addPropertyCreator(candidate.creator(), true, properties);
return;
}
// ...
}
userProps = true -> addProperyCreator()를 호출한다.
3. record class 사용
public record TestDto(String id) {
}
record class 케이스는 다음 코드를 보면 된다.
<BasicDeserializerFactory._addImplicitconstructorCreators()>
protected void _addImplicitConstructorCreators(DeserializationContext ctxt,
BasicDeserializerFactory.CreatorCollectionState ccState, List<CreatorCandidate> ctorCandidates)
throws JsonMappingException
{
// ...
final boolean preferPropsBased = config.getConstructorDetector().singleArgCreatorDefaultsToProperties()
// [databind#3968]: Only Record's canonical constructor is allowed
// to be considered for properties-based creator to avoid failure
&& !beanDesc.isRecordType();
for (CreatorCandidate candidate : ctorCandidates) {
final int argCount = candidate.paramCount();
// ...
// some single-arg factory methods (String, number) are auto-detected
if (argCount == 1) {
// ...
final boolean useProps = preferPropsBased
|| _checkIfCreatorPropertyBased(beanDesc, intr, ctor, propDef);
if (useProps) {
// ...
creators.addPropertyCreator(ctor, false, properties);
} else {
// 1. Go! 필드가 1개일 때
/*boolean added = */ _handleSingleArgumentCreator(creators,
ctor, false,
vchecker.isCreatorVisible(ctor));
// ...
}
// regardless, fully handled
continue;
}
// arg 2개 이상
//...
if ((implicitCtors != null) && !creators.hasDelegatingCreator()
&& !creators.hasPropertyBasedCreator()) {
// 2. Go! 필드가 2개 이상일 때
_checkImplicitlyNamedConstructors(ctxt, beanDesc, vchecker, intr,
creators, implicitCtors);
}
}
앞서 보았듯이 필드 개수에 따라 addPropertyCreator() 호출 여부를 판단하는 코드다.
그동안 필드가 2개 이상이어야 addPropertyCreator()를 호출하는 것으로 이해했지만,
사실 코드를 자세히 보면 필드가 1개여도 userProps가 true면 addPropertyCreator()를 호출할 수 있다.
userProps 값을 세팅할 때 호출하는 다음 메서드를 살펴보자.
<BasicDeserializerFactory._checkIfCreatorPropertyBased()>
private boolean _checkIfCreatorPropertyBased(BeanDescription beanDesc,
AnnotationIntrospector intr,
AnnotatedWithParams creator, BeanPropertyDefinition propDef)
{
// ...
if (propDef != null) {
// One more thing: if implicit name matches property with a getter
// or field, we'll consider it property-based as well
String implName = propDef.getName();
if (implName != null && !implName.isEmpty()) {
if (propDef.couldSerialize()) {
return true;
}
}
// ...
}
// ...
}
여기서 if(propDef != null) 부분만 보면 된다.
proDef 값을 외부에서 주입할 때, CreatorCandidate.Param 클래스의 값을 주입한다.
proDef는 생성자 매개변수와 JSON 속성(property)간의 연결 정보를 저장하는 필드다.
일반 클래스에서는 생성자 매개변수가 JSON 속성으로 간주하지 않기 때문에 propDef = null 즉, 위 분기문을 타지 않는다.
그러나 record class에서는 생성자 매개변수와 필드가 동일한 개념으로 취급, 즉 필드를 모두 역직렬화 가능한 JSON 속성으로 간주한다고 한다.
따라서, propDef != null -> userProps 값을 true로 세팅하여 addPropertyCreator()를 호출할 수 있게 된다.
4. Jackson 2.18 이상 버전 사용
앞서 분석한 일련의 과정들을 Jackson 2.18에서는 어떻게 가능하게 했을까?
@Getter
@AllArgsConstructor
public class TestDto {
private String id;
}
ValueInstantiator를 생성하던 다음 코드가 일부 수정되었다.
<Basicdeserializerfactory._constructdefaultvalueinstantiator()>
protected ValueInstantiator _constructDefaultValueInstantiator(DeserializationContext ctxt,
BeanDescription beanDesc)
throws JsonMappingException
{
// ...
final PotentialCreators potentialCreators = beanDesc.getPotentialCreators();
// ...
if (potentialCreators.hasPropertiesBased()) {
PotentialCreator primaryPropsBased = potentialCreators.propertiesBased;
// 12-Nov-2024, tatu: [databind#4777] We may have collected a 0-args Factory
// method; and if so, may need to "pull it out" as default creator
if (primaryPropsBased.paramCount() == 0) {
creators.setDefaultCreator(primaryPropsBased.creator());
} else {
// Start by assigning the primary (and only) properties-based creator
// Go!
_addSelectedPropertiesBasedCreator(ctxt, beanDesc, creators,
CreatorCandidate.construct(config.getAnnotationIntrospector(),
primaryPropsBased.creator(), primaryPropsBased.propertyDefs()));
}
}
// ...
}
생성자 파라미터가 1개 이상 있으면 _addSelectedPropertiesBasedCreator()를 호출한다.
<Basicdeserializerfactory. _addSelectedPropertiesBasedCreator()>
private void _addSelectedPropertiesBasedCreator(DeserializationContext ctxt,
BeanDescription beanDesc, CreatorCollector creators,
CreatorCandidate candidate)
throws JsonMappingException
{
// ...
// Go! addPropertyCreator() 호출
creators.addPropertyCreator(candidate.creator(), true, properties);
}
여기서 addPropertyCreator()를 호출한다.
2.18 버전 부터는 필드가 1개라도 있으면 무조건 addPropertyCraetor()를 호출하는 것으로 보인다.
ObjectMapper 주의할 점
ObjectMapper는 Jackson2ObjectMapperBuilder를 통해 생성해야 addPropertyCreator() 메서드 호출이 가능하다.
그렇지 않고, new ObjectMapper() 등으로 기본 생성자를 통해 ObjectMapper를 사용하면,
if) 전체생성자만 존재할 때,
jackson의 버전 & 역직렬화 대상 객체의 필드 개수에 상관 없이, 오류가 발생한다.
자세한 내용은 다음 글에 기록했다.
[Spring] ObjectMapper -> Jackson2ObjectMapperBuilder vs 생성자
마무리
단순 궁금증에서 출발했는데, 이렇게 글이 길어질 줄은 몰랐다.
앞으로 신규 프로젝트에서는 jackson 2.18 이상을 사용하니 위에서 분석한 내용을 몰라도 크게 영향은 없겠지만,
jackson 내부 코드를 자세하게 들여다볼 수 있어서 학습이 많이 됐고, 앞으로 jackson 관련 디버깅을 할 때 본 글이 많은 도움이 될 것 같다.
'java > spring' 카테고리의 다른 글
[Spring] ObjectMapper -> Jackson2ObjectMapperBuilder vs 생성자 (2) | 2025.02.17 |
---|---|
[Spring] Pointcut 유형에 따라 Proxy 생성 방식이 달라진다? (CGLIB or JDK Proxy) (0) | 2024.11.02 |
[Spring] junit test에서 lombok 사용하는 방법 (0) | 2024.10.16 |
[Spring] ControllerAdvice - 예외 처리 (2) | 2023.07.16 |
[Spring] @SpringBootTest vs @DataJpaTest (0) | 2023.07.15 |