danuri
오늘의 기록
danuri
전체 방문자
오늘
어제
  • 오늘의 기록 (307)
    • java (150)
      • java (33)
      • spring (63)
      • jpa (36)
      • querydsl (7)
      • intelliJ (9)
    • kotlin (8)
    • python (24)
      • python (10)
      • data analysis (13)
      • crawling (1)
    • ddd (2)
    • chatgpt (2)
    • algorithm (33)
      • theory (9)
      • problems (23)
    • http (8)
    • git (8)
    • database (5)
    • aws (12)
    • devops (10)
      • docker (6)
      • cicd (4)
    • book (44)
      • clean code (9)
      • 도메인 주도 개발 시작하기 (10)
      • 자바 최적화 (11)
      • 마이크로서비스 패턴 (0)
      • 스프링으로 시작하는 리액티브 프로그래밍 (14)
    • tistory (1)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

인기 글

태그

  • Spring
  • Security
  • Thymeleaf
  • POSTGIS
  • 등가속도 운동
  • docker
  • reactive
  • RDS
  • 자바 최적화
  • 도메인 주도 설계
  • PostgreSQL
  • Kotlin
  • JPA
  • S3
  • SWAGGER
  • Saving Plans
  • ChatGPT
  • Database
  • gitlab
  • 마이크로서비스패턴
  • Bitmask
  • 트랜잭션
  • mockito
  • Jackson
  • nuribank
  • DDD
  • CICD
  • AWS
  • connection
  • Java

최근 댓글

최근 글

hELLO · Designed By 정상우.
danuri

오늘의 기록

java/spring

[Spring] ObjectMapper -> Jackson2ObjectMapperBuilder vs 생성자

2025. 2. 17. 01:23

1. 기본생성자가 없는 객체에 대한 역직렬화 차이

여기 객체 1개가 있다.

@Getter
@AllArgsConstructor
public class TestDto {
    private String id;
}

 
기본생성자가 없는 객체를 만들었다.
 
직렬화/역직렬화 테스트를 해보자.

@RestController
public class TestController {

    @GetMapping
    public TestDto test(@RequestBody TestDto dto) {
        return dto;
    }

}

// input
{
    "id": "1"
}

// output
{
    "id": "1"
}

문제 없이 성공한다.
 
성공하는 이유는 JacksonHttpMessageConvertersConfiguration 클래스에 json을 직렬화/역직렬화할 수 있는 MappingJackson2HttpMessageConverter 빈이 등록되어 있기 때문이다.
<JacksonHttpMessageConvertersConfiguration>

@Bean
// ...
MappingJackson2HttpMessageConverter mappingJackson2HttpMessageConverter(ObjectMapper objectMapper) {
    return new MappingJackson2HttpMessageConverter(objectMapper);
}

MappingJackson2HttpMessageConverter는 ObjectMapper 빈을 주입해서 생성한다.
 
ObjectMapper 빈은 JacksonAutoConfiguration 클래스에 등록되어 있다.
<JacksonAutoConfiguration>

@Bean
@Primary
@ConditionalOnMissingBean
ObjectMapper jacksonObjectMapper(Jackson2ObjectMapperBuilder builder) {
    return builder.createXmlMapper(false).build();
}

 
앞서 TestDto를 역직렬화할 때는 위 ObjectMapper 빈을 사용하는 것이다.
 
중요!! 이 때, Jackson2ObjectMapperBuilder를 통해 ObjectMapper를 생성한다.
-> 기억하고 넘어가자.
 
그런데 잘 보면, 빈 생성 메서드에 @ConditionalOnMissingBean 애노테이션이 붙어 있다.
@ConditionalOnMissingBean은 ObjectMapper 빈이 존재하지 않을 때만 빈 생성 메서드가 실행되어 빈을 등록한다.
 
즉, 개발자가 별도로 ObjectMapper 빈을 생성하지 않으면, Spring이 기본으로 제공하는 ObjectMapper 빈을 사용할 수 있다.
예를 들어, 다음과 같이 빈 주입을 사용할 수도 있다.

@RestController
@RequiredArgsConstructor
public class TestController {
    
    private final ObjectMapper objectMapper;
    
    // ...

}

 

그럼 별도로 ObjectMapper 빈을 생성해서 Spring이 기본으로 제공하는 ObjectMapper 빈을 대체해보자.
 
중요! 이 때, 기본 생성자를 통해 ObjectMapper를 생성해보자.
 

@Configuration
public class ObjectMapperConfig {

    @Bean
    public ObjectMapper objectMapper() {
        return new ObjectMapper();
    }

}

 
이제 다시 테스트를 해보자.

@RestController
public class TestController {

    @GetMapping
    public TestDto test(@RequestBody TestDto dto) {
        return dto;
    }

}

// input
{
    "id": "1"
}

// output
Resolved [org.springframework.http.converter.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)]

오류가 발생한다. (역직렬화할 때 발생하는 오류)
 
왜 이런 차이가 발생할까? 
jackson의 역직렬화 관련 로직은 다음 글에서 자세하게 기록해 놓았다.
(상당히 긴 글이기 때문에, jackson 역직렬화 동작 방식이 특별히 궁금하지 않다면 아래 내용으로 넘어가도 좋다)
https://gksdudrb922.tistory.com/336

 

[Spring] jackson 역직렬화, 필드가 1개일 때 HttpMessageNotReadableException

jackson 2.17 문제 상황개발환경java 21spring 3.3.0jackson 2.17.1 다음과 같은 java 코드가 있다.@RestControllerpublic class TestController { @GetMapping public TestDto test(@RequestBody TestDto dto) { return dto; }}@Getter@AllArgsConstructo

gksdudrb922.tistory.com

 
 
문제가 발생하는 원인을 간단히 요약하면 이렇다.
1. Jackson2ObjectMapperBuilder: property-based 역직렬화 가능
2. 생성자: property-based 역직렬화 불가능
+) property-based 역직렬화: JSON 데이터를 객체의 프로퍼티(필드)와 직접 매핑해서 역직렬화 하는 방식. (ex. 생성자)
 
앞서 첨부한 글은 역직렬화 대상 객체의 필드가 1개일 때 상황에 대해 언급했는데,
본 글에서 다룰 ObjectMapper 생성 방식에 따른 차이는 jackson 버전, 필드 개수와 무관하게 발생하는 이슈다.
 
첨부한 글에서 설명했지만 jackson 2.18 버전 이상 기준,
property-based 역직렬화를 사용하려면 다음 jackson 코드의 주석친 부분의 메서드가 호출되어야 한다.
<BeanDeserializerFactory._constructDefaultValueInstantiator()>

protected ValueInstantiator _constructDefaultValueInstantiator(DeserializationContext ctxt,
                                                               BeanDescription beanDesc)
        throws JsonMappingException
{
    // ...
    
    final PotentialCreators potentialCreators = beanDesc.getPotentialCreators();
    
    // ...
    
    if (potentialCreators.hasPropertiesBased()) {
        PotentialCreator primaryPropsBased = potentialCreators.propertiesBased;

        if (primaryPropsBased.paramCount() == 0) {
            creators.setDefaultCreator(primaryPropsBased.creator());
        } else {
            // property-based 역직렬화 사용하는 메서드
            _addSelectedPropertiesBasedCreator(ctxt, beanDesc, creators,
                    CreatorCandidate.construct(config.getAnnotationIntrospector(),
                            primaryPropsBased.creator(), primaryPropsBased.propertyDefs()));
        }
    }
    
    // ...
}

그리고 _addSelectedPropertiesBasedCreator() 메서드는 if (potentialCreators.hasPropertiesBased()) 조건을 통과해야 호출할 수 있다.
 
<PotentialCreators.hasPropertiesBased()>

public class PotentialCreators {

    public PotentialCreator propertiesBased;
    
    public boolean hasPropertiesBased() {
        return (propertiesBased != null);
    }

}

PotentialCreator는 Jackson이 객체를 역직렬화할 가능성이 있는 생성자를 결정하는데 활용된다.
hasPropertiesBased() 메서드는 property-based 역직렬화가 가능한 생성자가 있는지 확인한다.
 
그럼 propertiedBased 필드가 언제 세팅되는지 추적해보면 되겠다.
결론부터 말하자면,
1. Jackson2ObjectMapperBuilder: propertiesBased를 세팅한다.
2. 생성자: propertiesBased를 세팅하지 않는다.
 

propertiesBased를 세팅하는 코드 분석

이제 왜 두 방식에 따라 property-based 역직렬화 가능/불가능이 갈리게 되는지 알아보자.

protected ValueInstantiator _constructDefaultValueInstantiator(DeserializationContext ctxt,
                                                               BeanDescription beanDesc)
        throws JsonMappingException
{
    // ...
    
    // Go!
    final PotentialCreators potentialCreators = beanDesc.getPotentialCreators();
    
    // ...
    
    if (potentialCreators.hasPropertiesBased()) {
        PotentialCreator primaryPropsBased = potentialCreators.propertiesBased;

        if (primaryPropsBased.paramCount() == 0) {
            creators.setDefaultCreator(primaryPropsBased.creator());
        } else {
            // property-based 역직렬화 사용하는 메서드
            _addSelectedPropertiesBasedCreator(ctxt, beanDesc, creators,
                    CreatorCandidate.construct(config.getAnnotationIntrospector(),
                            primaryPropsBased.creator(), primaryPropsBased.propertyDefs()));
        }
    }
    
    // ...
}

if (potentialCreators.hasPropertiesBased())를 호출하기 전 potentialCreators 변수를 조회하는 Go! 주석 부분으로 가보자.
 
<BasicBeanDescription.getPotentialCreators()>

@Override
public PotentialCreators getPotentialCreators() {
    if (_propCollector == null) {
        return new PotentialCreators();
    }
    // Go!
    return _propCollector.getPotentialCreators();
}

 
<POJOPropertiesCollector.getPotentialCreators()>

public PotentialCreators getPotentialCreators() {
    if (!_collected) {
        // Go!
        collectAll();
    }
    return _potentialCreators;
}

PotentialCreator를 조회하기 전에 전처리 과정을 거친다.
 
<POJOPropertiesCollector.collectAll()>

protected void collectAll() {

    // ...

    LinkedHashMap<String, POJOPropertyBuilder> props = new LinkedHashMap<String, POJOPropertyBuilder>();

    // ...
    
    if (!_classDef.isNonStaticInnerClass()) { {
        // Go!
        _addCreators(props);
    }
}

 
<POJOPropertiesCollector.addCreators()>

protected void _addCreators(Map<String, POJOPropertyBuilder> props)
{
    final PotentialCreators creators = _potentialCreators;

    //...

    final ConstructorDetector ctorDetector = _config.getConstructorDetector();
    if (!creators.hasPropertiesBasedOrDelegating()
            && !ctorDetector.requireCtorAnnotation()) {
        
        if ((_classDef.getDefaultConstructor() == null)
                || ctorDetector.singleArgCreatorDefaultsToProperties()) {
            // Go!    
            _addImplicitConstructor(creators, constructors, props);
        }
    }

    // ...
    
}

 
<POJOPropertiesCollector._addImplicitConstructor()>

private boolean _addImplicitConstructor(PotentialCreators collector,
                                        List<PotentialCreator> ctors, Map<String, POJOPropertyBuilder> props)
{
    // ...
    
    if (ctor.paramCount() != 1) {
        if (!ctor.hasNameOrInjectForAllParams(_config)) {
            return false;
        }
    } else {
        if ((_annotationIntrospector != null)
                && _annotationIntrospector.findInjectableValue(ctor.param(0)) != null) {
        } else {
            // ...
            if (!ctorDetector.singleArgCreatorDefaultsToProperties()) {
                POJOPropertyBuilder prop = props.get(ctor.implicitNameSimple(0));
                if ((prop == null) || !prop.anyVisible() || prop.anyIgnorals()) {
                    return false;
                }
            }
        }
    }

    ctors.remove(0);
    
    // Go!
    collector.setPropertiesBased(_config, ctor, "implicit");
    return true;
}

코드 맨 하단에서 property-based 생성자를 세팅하는 것 같다.
 
<PotentialCreators.setPropertiesBased()>

public void setPropertiesBased(MapperConfig<?> config, PotentialCreator ctor, String mode)
{
    if (propertiesBased != null) {
        throw new IllegalArgumentException(String.format(
                "Conflicting property-based creators: already had %s creator %s, encountered another: %s",
                mode, propertiesBased.creator(), ctor.creator()));
    }
    // Go!
    propertiesBased = ctor.introspectParamNames(config);
}

맞다. 여기서 propertiesBased를 세팅하기 때문에,
if (potentialCreators.hasPropertiesBased())를 통과해서, addPropertyCreator() 메서드 호출이 가능하다.
 
그런데, 이건 Jackson2ObjectMapperBuilder를 통해 ObjectMapper를 생성할 때 시나리오고,
만약 new ObjectMapper()를 사용한다면 setPropertiesBased()를 호출하기 전 다음 조건에 잡히게 된다.

private boolean _addImplicitConstructor(PotentialCreators collector,
                                        List<PotentialCreator> ctors, Map<String, POJOPropertyBuilder> props)
{
    // ...
    
    if (ctor.paramCount() != 1) {
        if (!ctor.hasNameOrInjectForAllParams(_config)) {
            return false;
        }
    } else {
        if ((_annotationIntrospector != null)
                && _annotationIntrospector.findInjectableValue(ctor.param(0)) != null) {
        } else {
            // ...
            if (!ctorDetector.singleArgCreatorDefaultsToProperties()) {
                // Go!
                POJOPropertyBuilder prop = props.get(ctor.implicitNameSimple(0));
                if ((prop == null) || !prop.anyVisible() || prop.anyIgnorals()) {
                    return false;
                }
            }
        }
    }

    ctors.remove(0);
    
    // propertiesBased 세팅하는 부분
    collector.setPropertiesBased(_config, ctor, "implicit");
    return true;
}

정확히는 prop 값이 null이어서 하단 코드로 진입이 실패한다.
 
prop이 null인 이유 -> ctor.implictiNameSimple(0) = null
<PotentialCreator.implicitNameSimple()>

public String implicitNameSimple(int ix) {
    PropertyName pn = _implicitParamNames[ix];
    return (pn == null) ? null : pn.getSimpleName();
}

1. Jackson2ObjectMapperBuilder: _implicitParamNames에 parameter값 존재. (TestDto.id)
2. 생성자: _implicitParamNames 비어 있다.
 
이제 거의 다 왔다. _implicitParamNames를 세팅해주는 코드를 분석해본다.
<PotentialCreator.introspectParamNames()>

public PotentialCreator introspectParamNames(MapperConfig<?> config)
{
    // ...

    final AnnotationIntrospector intr = config.getAnnotationIntrospector();
    for (int i = 0; i < paramCount; ++i) {
        AnnotatedParameter param = _creator.getParameter(i);

        // Go!
        String rawImplName = intr.findImplicitPropertyName(param);
        if (rawImplName != null && !rawImplName.isEmpty()) {
            _implicitParamNames[i] = PropertyName.construct(rawImplName);
        }
        
        // ...
    }
    return this;
}

Go! 주석 부분의 intr.findImplicitPropertyName() 값이 존재하면 _implicitParamNames를 세팅해준다.
+) 참고
_explicitParamNames: 명시적 매개변수(@JsonPropery 애노테이션이 붙은 매개변수 이름 목록)
_implicitParamNames: 암시적 매개변수(리플렉션 등)
 
intr.findImplicitPropertyName()에서 intr은 AnnotationIntrospector는 인터페이스인데, 각 상황에 따라 선택하는 구현체가 다른다.
1. Jackson2ObjectMapperBuilder: ParameterNamesAnnotationIntrospector
2. 생성자:  JacksonAnnotationIntrospector
+) 두 케이스 모두 기본 Introspector는 JacksonAnnotationIntrospector
그러나 Jackson2ObjectMapperBuilder 케이스의 경우에만 ParameterNamesAnnotationIntrospector 구현체를 추가해서 우선으로 사용한다.
 
ParameterNamesAnnotationIntrospector는 리플렉션 사용으로 파라미터 이름을 조회할 수 있고,
JacksonAnnotationIntrospector는 파라미터 이름을 조회하지 못해, _implicitParamNames 세팅에 차이가 발생하게 된다.
 

ParameterNamesAnnotationIntrospector를 세팅하는 코드 분석

그럼 Jackson2ObjectMapperBuilder를 사용하는 경우에만 ParameterNamesAnnotationIntrospector를 세팅하는 이유를 알아보자.
 
<Jackson2ObjectMapperBuilder.build()>
Jackson2ObjectMapperBuilder로 ObjectMapper를 생성하는 build() 메서드

public <T extends ObjectMapper> T build() {
    ObjectMapper mapper;
    
    // ...
    
    // Go!
    configure(mapper);
    return (T) mapper;
}

 
<Jackson2ObjectMapperBuilder.configure()>

public void configure(ObjectMapper objectMapper) {
    // ...

    List<Module> modules = new ArrayList<>();
    for (List<Module> nestedModules : modulesToRegister.values()) {
        modules.addAll(nestedModules);
    }

    // Go!
    objectMapper.registerModules(modules);

    // ...
}

ObjectMapper를 초기 세팅하기 위해 모듈을 등록한다.
new ObjectMapper()에서는 모듈 등록하는 코드가 없다. (여기서 차이가 난다)
 
<ObjectMapper.registerModule()>

public ObjectMapper registerModule(Module module)
{
    // ...
         
    // Go!
    module.setupModule(new Module.SetupContext()
    {
        // ...
    });

    return this;
}

setupModule()은 추상메서드인데, 다음 모듈 구현체를 살펴보자.
 
JacksonAutoConfiguration에는 다음과 같은 모듈 빈이 등록되어 있다. (리플렉션을 통해 파라미터 이름 조회를 가능하게 하는 모듈 같다)
<JacksonAutoConfiguration>

@Bean
@ConditionalOnMissingBean
ParameterNamesModule parameterNamesModule() {
    return new ParameterNamesModule(JsonCreator.Mode.DEFAULT);
}

 
해당 구현체의 setUpModule() 메서드로 이동해본다.
<ParameterNamesModule.setUpModule()>

@Override
public void setupModule(SetupContext context) {
    super.setupModule(context);
    
    // Go! 여기서 ParameterNamesAnnotationIntrospector 등록
    context.insertAnnotationIntrospector(new ParameterNamesAnnotationIntrospector(creatorBinding, new ParameterExtractor()));
}

찾았다. 여기서 ParameterNamesAnnotationIntrospector를 등록한다.
 

정리

1. Jackson2ObjectMapperBuilder을 통해 ObjectMapper를 생성하면 리플렉션을 통해 생성자 파라미터를 찾을 수 있는 ParameterNamesAnnotationIntrospector를 등록한다.

@Override
public void setupModule(SetupContext context) {
    super.setupModule(context);
    
    // Go! 여기서 ParameterNamesAnnotationIntrospector 등록
    context.insertAnnotationIntrospector(new ParameterNamesAnnotationIntrospector(creatorBinding, new ParameterExtractor()));
}

 
2. ParameterNamesAnnotationIntrospector를 통해 암시적 매개변수 목록인 PotentialCreator._implicitParamNames을 세팅한다.

public PotentialCreator introspectParamNames(MapperConfig<?> config)
{
    // ...

    final AnnotationIntrospector intr = config.getAnnotationIntrospector();
    for (int i = 0; i < paramCount; ++i) {
        AnnotatedParameter param = _creator.getParameter(i);

        // Go!
        String rawImplName = intr.findImplicitPropertyName(param);
        if (rawImplName != null && !rawImplName.isEmpty()) {
            _implicitParamNames[i] = PropertyName.construct(rawImplName);
        }
        
        // ...
    }
    return this;
}

 
3. _implicitParamNames가 존재하면 PotentialCreators.propertiesBased를 세팅한다.

private boolean _addImplicitConstructor(PotentialCreators collector,
                                        List<PotentialCreator> ctors, Map<String, POJOPropertyBuilder> props)
{
    // ...
    
    if (ctor.paramCount() != 1) {
        if (!ctor.hasNameOrInjectForAllParams(_config)) {
            return false;
        }
    } else {
        if ((_annotationIntrospector != null)
                && _annotationIntrospector.findInjectableValue(ctor.param(0)) != null) {
        } else {
            // ...
            if (!ctorDetector.singleArgCreatorDefaultsToProperties()) {
                POJOPropertyBuilder prop = props.get(ctor.implicitNameSimple(0));
                if ((prop == null) || !prop.anyVisible() || prop.anyIgnorals()) {
                    return false;
                }
            }
        }
    }

    ctors.remove(0);
    
    // Go! propertiesBased 세팅하는 부분
    collector.setPropertiesBased(_config, ctor, "implicit");
    return true;
}

 
4. propertiesBased가 존재하면 property-based 역직렬화 사용하는 메서드를 호출할 수 있다.

protected ValueInstantiator _constructDefaultValueInstantiator(DeserializationContext ctxt,
                                                               BeanDescription beanDesc)
        throws JsonMappingException
{
    // ...
    
    final PotentialCreators potentialCreators = beanDesc.getPotentialCreators();
    
    // ...
    
    if (potentialCreators.hasPropertiesBased()) {
        PotentialCreator primaryPropsBased = potentialCreators.propertiesBased;

        if (primaryPropsBased.paramCount() == 0) {
            creators.setDefaultCreator(primaryPropsBased.creator());
        } else {
            // property-based 역직렬화 사용하는 메서드
            _addSelectedPropertiesBasedCreator(ctxt, beanDesc, creators,
                    CreatorCandidate.construct(config.getAnnotationIntrospector(),
                            primaryPropsBased.creator(), primaryPropsBased.propertyDefs()));
        }
    }
    
    // ...
}

 
5. 생성자 방식을 사용하면 위 과정을 수행하지 않는다.
 
6. ObjectMapper를 사용할 때는, 스프링이 제공하는 JacksonAutoConfiguration의 기본 빈을 사용하거나, 직접 정의한다고 해도 Jackson2ObjectMapperBuilder.build() 를 통해 생성하는 것이 역직렬화 안정성을 높여준다.
 


 

2. Jackson2ObjectMapperBuilder -> 기본 configure 세팅 

Jackson2ObjectMapperBuilder.build()를 통해 ObjectMapper를 생성하면 생성자로 ObjectMapper를 생성하는 것에 비해 추가적인 기본 세팅을 해준다.

 

<Jackson2ObjectMapperBuilder.build()>

public <T extends ObjectMapper> T build() {
    ObjectMapper mapper;
    if (this.createXmlMapper) {
        mapper = (this.defaultUseWrapper != null ?
                new Jackson2ObjectMapperBuilder.XmlObjectMapperInitializer().create(this.defaultUseWrapper, this.factory) :
                new Jackson2ObjectMapperBuilder.XmlObjectMapperInitializer().create(this.factory));
    }
    else {
        mapper = (this.factory != null ? new ObjectMapper(this.factory) : new ObjectMapper());
    }
    configure(mapper);
    return (T) mapper;
}

Builder에 별도 세팅을 하지 않았다면, else 블록에 new ObjectMapper()로 기본 생성자를 호출하는 것 같다.

그러나 그 다음 라인에 configure() 메서드를 추가로 호출하고 있다 -> 여기서 기본 생성자와의 차이가 발생한다.

 

<Jackson2ObjectMapperBuilder.configure()>

public void configure(ObjectMapper objectMapper) {
    Assert.notNull(objectMapper, "ObjectMapper must not be null");

    MultiValueMap<Object, com.fasterxml.jackson.databind.Module> modulesToRegister = new LinkedMultiValueMap<>();
    if (this.findModulesViaServiceLoader) {
        ObjectMapper.findModules(this.moduleClassLoader).forEach(module -> registerModule(module, modulesToRegister));
    }
    else if (this.findWellKnownModules) {
        // 2-1. 모듈 세팅 - Jdk8Module, ParameterNamesModule, JavaTimeModule
        registerWellKnownModulesIfAvailable(modulesToRegister);
    }

    if (this.modules != null) {
        this.modules.forEach(module -> registerModule(module, modulesToRegister));
    }
    if (this.moduleClasses != null) {
        for (Class<? extends com.fasterxml.jackson.databind.Module> moduleClass : this.moduleClasses) {
            registerModule(BeanUtils.instantiateClass(moduleClass), modulesToRegister);
        }
    }
    List<com.fasterxml.jackson.databind.Module> modules = new ArrayList<>();
    for (List<Module> nestedModules : modulesToRegister.values()) {
        modules.addAll(nestedModules);
    }
    
    // 2-1. 모듈 세팅 - ObjectMapper에 모듈 등록
    objectMapper.registerModules(modules);

    if (this.dateFormat != null) {
        objectMapper.setDateFormat(this.dateFormat);
    }
    if (this.locale != null) {
        objectMapper.setLocale(this.locale);
    }
    if (this.timeZone != null) {
        objectMapper.setTimeZone(this.timeZone);
    }

    if (this.annotationIntrospector != null) {
        objectMapper.setAnnotationIntrospector(this.annotationIntrospector);
    }
    if (this.propertyNamingStrategy != null) {
        objectMapper.setPropertyNamingStrategy(this.propertyNamingStrategy);
    }
    if (this.defaultTyping != null) {
        objectMapper.setDefaultTyping(this.defaultTyping);
    }
    if (this.serializationInclusion != null) {
        objectMapper.setDefaultPropertyInclusion(this.serializationInclusion);
    }

    if (this.filters != null) {
        objectMapper.setFilterProvider(this.filters);
    }

    if (jackson2XmlPresent) {
        objectMapper.addMixIn(ProblemDetail.class, ProblemDetailJacksonXmlMixin.class);
    }
    else {
        // 2-2. ProbleDetail 직렬화/역직렬화 정책 세팅
        objectMapper.addMixIn(ProblemDetail.class, ProblemDetailJacksonMixin.class);
    }
    this.mixIns.forEach(objectMapper::addMixIn);

    if (!this.serializers.isEmpty() || !this.deserializers.isEmpty()) {
        SimpleModule module = new SimpleModule();
        addSerializers(module);
        addDeserializers(module);
        objectMapper.registerModule(module);
    }

    this.visibilities.forEach(objectMapper::setVisibility);

    // 2-3. 기본 피처 세팅
    customizeDefaultFeatures(objectMapper);
    this.features.forEach((feature, enabled) -> configureFeature(objectMapper, feature, enabled));

    if (this.handlerInstantiator != null) {
        objectMapper.setHandlerInstantiator(this.handlerInstantiator);
    }
    else if (this.applicationContext != null) {
        objectMapper.setHandlerInstantiator(
                new SpringHandlerInstantiator(this.applicationContext.getAutowireCapableBeanFactory()));
    }

    if (this.configurer != null) {
        this.configurer.accept(objectMapper);
    }
}

정말 많은 세팅을 해주고 있는데 대부분 build() 메서드를 호출하기 전, Builder에 특정 값을 세팅했을 때에 대한 동작을 정의한다.

본 내용은 기본 생성자와의 명확한 차이를 비교하기 위해, Builder에 어떠한 세팅도 하지 않고 build() 메서드를 호출했을 때 발생하는 차이를 작성한다.

+) 아래 2-x. 항목과 관련된 코드를 위 코드의 주석에 표시해 놓음

 

2-1. 모듈 세팅

Jdk8Module: Java 8에서 도입된 Optional 등을 올바르게 직렬화/역직렬화 가능.

ParameterNamesModule: 생성자 및 파라미터 이름을 기반으로 역직렬화 가능. (앞서 살펴본 "기본생성자 없는 역직렬화"를 가능하게 하는 모듈)

JavaTimeModule: java.time 패키지 내 LocalDate 등 클래스를 직렬화/역직렬화 가능.

 

2-2. ProbleDetail 직렬화/역직렬화 정책 세팅

ProblemDetail: RFC 7807 사양을 구현하기 위한 클래스.

+) RFC 7807: HTTP API 오류 응답에 대한 표준 형식, API 오류 응답에 대해 서비스마다 서로 다른 형식이 아닌 일관된 구조로 처리하기 위함. (꼭 따를 필요는 없지만, HTTP에서는 이러한 표준 오류 형식을 제공한다는 것 정도는 알아두면 좋겠다)

 

<ProblemDetail>

public class ProblemDetail {

	private static final URI BLANK_TYPE = URI.create("about:blank");


	private URI type = BLANK_TYPE;

	@Nullable
	private String title;

	private int status;

	@Nullable
	private String detail;

	@Nullable
	private URI instance;

	@Nullable
	private Map<String, Object> properties;

    ...

}

 

<ProblemDetail 사용 예제>

ProblemDetail problem = new ProblemDetail();
problem.setStatus(404);
problem.setTitle("Resource Not Found");
problem.setDetail("The requested resource was not found on the server.");
problem.setType(URI.create("https://example.com/errors/resource-not-found"));

return ResponseEntity.status(HttpStatus.NOT_FOUND).body(problem);


// JSON 결과
{
  "type": "https://example.com/errors/resource-not-found",
  "title": "Resource Not Found",
  "status": 404,
  "detail": "The requested resource was not found on the server."
}

 

Jackson2ObjectMapperBuilder는 ProbleDetailJacksonMixin 인터페이스를 통해 ProbleDeatil의 properties 필드를 JSON 필드로 직렬화/역직렬화할 수 있도록 한다.

 

ex) properties 필드에 ("extraField", "Extra Value") 항목이 저장되어 있는 경우 직렬화 예시

{
  "type": "https://example.com/errors/custom-error",
  "title": "Custom Error",
  "status": 400,
  "detail": "Something went wrong",
  "extraField": "Extra Value"
}

 

 

2-3. 기본 피처 세팅

1. DEFAULT_VIEW_INCLUSION:  직렬화 시, @JsonView가 붙지 않아도 직렬화할지 결정하는 피처. (default: true)

-> Jackson2ObjectMapperBuilder가 false로 변경한다. (@JsonView가 붙은 필드만 직렬화하도록 설정.)

 

2. FAIL_ON_UNKNOWN_PROPERTIES: JSON 데이터에 클래스에 정의되지 않은 속성이 포함될 경우 예외를 발생시킬지 결정하는 피처. (default: true)

-> Jackson2ObjectMapperBuilder가 false로 변경한다. (클래스에 정의되지 않은 속성이 JSON에 있어도 예외 발생 X)

 

+)

2번은 이해가 되지만, 1번을 보고 의아한 사람이 있을 것이다.

1번 세팅대로라면 @JsonView가 붙지 않은 필드는 직렬화가 안된다는 건데, 그동안 경험상 @JsonView를 사용해본적이 별로 없기도 하고, 실제로 @JsonView를 붙이지 않은 필드도 API 응답 시 직렬화가 잘되고 있다.

사실 Spring의 MappingJackson2HttpMessageConverter는 @JsonView가 적용되지 않은 컨트롤러에 대해서는 모든 필드를 직렬화한다. (그동안 우리가 알고 있던 방식)

 

만약 다음과 같이 세팅하면 @JsonView 필드만 직렬화가 될 것이다.

@RestController
public class TestController {


    @GetMapping
    @JsonView(Views.Public.class)
    public TestDto test(@RequestBody TestDto dto) {
        return dto;
    }

}

@Getter
@AllArgsConstructor
@NoArgsConstructor
public class TestDto {
    @JsonView(Views.Public.class)
    private String id;
    private String name;
}

public class Views {
    public static class Public {}
}

// 결과
{
    "id": "1"
}

 


 

3. JacksonAutoConfiguration의 ObjectMapper를 그대로 사용한다면?

앞서 본 Jackson2ObjectMapperBuilder가 기본으로 제공해주는 기능은,

개발자가 직접 Jackson2ObjectMapperBuilder.build()를 통해 ObjectMapper를 생성했을 때 제공하는 기능만을 알아보았다.

 

글 초반부에 언급했듯이, jackson은 JacksonAutoConfiguration에서 기본 ObjectMapper를 제공한다.

@Bean
@Primary
@ConditionalOnMissingBean
ObjectMapper jacksonObjectMapper(Jackson2ObjectMapperBuilder builder) {
    return builder.createXmlMapper(false).build();
}

이 때, Jackson2ObjectMapperBuilder를 사용하기 때문에 앞서 언급한 2-1 ~ 2-3 기능은 모두 제공하게 된다.

 

그러나, 이번 장에서는 2-1 ~ 2-3에 더해 JacksonAutoConfiguration이 제공하는 ObjectMapper를 사용했을 때 추가로 제공하는 기능들을 알아볼 것이다.

 

JacksonAutoConfiguration은 Jackson2ObjectMapperBuilder도 기본 빈으로 제공하고 있다.

@Bean
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
@ConditionalOnMissingBean
Jackson2ObjectMapperBuilder jacksonObjectMapperBuilder(ApplicationContext applicationContext,
                                                       List<Jackson2ObjectMapperBuilderCustomizer> customizers) {
    Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder();
    builder.applicationContext(applicationContext);
    customize(builder, customizers);
    return builder;
}

1. applicationContext를 세팅한다.

2. customize를 호출한다.

 

1번은 나중에 다시 언급하기로 하고, 2번 customize부터 분석해본다.

private void customize(Jackson2ObjectMapperBuilder builder,
                       List<Jackson2ObjectMapperBuilderCustomizer> customizers) {
    for (Jackson2ObjectMapperBuilderCustomizer customizer : customizers) {
        customizer.customize(builder);
    }
}

모든 customizer들을 가져와서 각각의 customize 메서드를 호출한다.

 

+) customizer 함수형 인터페이스

@FunctionalInterface
public interface Jackson2ObjectMapperBuilderCustomizer {
	void customize(Jackson2ObjectMapperBuilder jacksonObjectMapperBuilder);
}

 

개발자가 따로 정의하지 않는 이상 jackson이 제공하는 customizer는 StandardJackson2ObjectMapperBuilderCustomizer 1개이다.

+) 다르게 말하면 개발자가 추가로 customzier 빈을 정의하면 Jackson2ObjectMapperBuilder를 만들 때 추가 커스터마이징이 가능하다는 뜻.

<StandardJackson2ObjectMapperBuilderCustomizer.customize()>

@Override
public void customize(Jackson2ObjectMapperBuilder builder) {
    if (this.jacksonProperties.getDefaultPropertyInclusion() != null) {
        builder.serializationInclusion(this.jacksonProperties.getDefaultPropertyInclusion());
    }
    if (this.jacksonProperties.getTimeZone() != null) {
        builder.timeZone(this.jacksonProperties.getTimeZone());
    }
    
    // 3-1. 기본 직렬화 피처 세팅
    configureFeatures(builder, FEATURE_DEFAULTS);
    configureVisibility(builder, this.jacksonProperties.getVisibility());
    configureFeatures(builder, this.jacksonProperties.getDeserialization());
    configureFeatures(builder, this.jacksonProperties.getSerialization());
    configureFeatures(builder, this.jacksonProperties.getMapper());
    configureFeatures(builder, this.jacksonProperties.getParser());
    configureFeatures(builder, this.jacksonProperties.getGenerator());
    configureFeatures(builder, this.jacksonProperties.getDatatype().getEnum());
    configureFeatures(builder, this.jacksonProperties.getDatatype().getJsonNode());
    configureDateFormat(builder);
    configurePropertyNamingStrategy(builder);
    
    // 3-2. 모듈 세팅
    configureModules(builder);
    configureLocale(builder);
    configureDefaultLeniency(builder);
    configureConstructorDetector(builder);
}

대부분 jacksonProperties 값에 대해 세팅하는 로직이다.

만약 application.properties 파일에 spring.jackson.xxx 등의 설정을 한다면 초기 ObjectMapper에 대한 커스터마이징이 가능하다.

-> 여기서는 jacksonProperties에 아무런 설정을 하지 않았을 때, 기본적으로 추가해주는 피처를 분석해본다.

 

+) 역시 앞으로 설명할 내용과 관련된 코드를 주석으로 표시했다.

 

3-1. 기본 직렬화 피처 세팅

1. WRITE_DATES_AS_TIMESTAMPS: 

  • true: default, 날짜/시간을 타임스탬프 형태로 직렬화 (ex. 1708656789123)
  • false: customzier 세팅, 날짜/시간을 ISO-8601 형식으로 직렬화 (ex. 2025-02-23T14:33:09.123+0000)

 

2. WRITE_DURATIONS_AS_TIMESTAMPS:

  • true: default, Duration을 숫자로 직렬화 (ex. 300)
  • false: customzier 세팅, Duration을  ISO-8601 형식으로 직렬화 (ex. PT5M)

 

3-2. 모듈 세팅

ParamerNamesModule: 생성자 및 파라미터 이름을 기반으로 역직렬화 가능. (앞서 살펴본 "기본생성자 없는 역직렬화"를 가능하게 하는 모듈)

-> 2-1에서 언급했듯이 Jackson2ObjectMapperBuilder.build()에서도 기본으로 지원하는 모듈이다.

 

JsonMixInModule: @JsonIgnore, @JsonProperty 등 jackson 애노테이션을 추가할 수 있도록 지원하는 모듈

-> 종종 개발하면서 사용하는 애노테이션들인데, JsonMixInModule이 지원해줬었던 것!

 

JsonComponentModule: @JsonComponent 애노테이션이 붙은 Serializer, Deserializer 클래스를 Spring Boot가 자동으로 등록.

ex)

@JsonComponent
public class CustomJsonComponent {

    // Serializer
    public static class CustomSerializer extends JsonSerializer<String> {
        @Override
        public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
            gen.writeString("**" + value + "**"); // 감싸는 형태로 직렬화
        }
    }

    // Deserializer
    public static class CustomDeserializer extends JsonDeserializer<String> {
        @Override
        public String deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
            return p.getText().replace("**", ""); // 감싸는 부분 제거 후 역직렬화
        }
    }
}

 

3-3. SpringHandlerInstantiator 세팅

앞서 JacksonAutoConfiguration에서 Jackson2ObjectMapperBuilder 빈을 생성할 때, applicationContext를 세팅했었다.

applicationContext는 Jackson2ObjectMapperBuilder.build() 메서드에서 다음 기능을 수행하게 한다.

else if (this.applicationContext != null) {
    objectMapper.setHandlerInstantiator(
                new SpringHandlerInstantiator(this.applicationContext.getAutowireCapableBeanFactory()));
}

 

1. SpringHandlerInstantiator는 Spring의 ApplicationContext에 Jackson이 생성하는 객체를 빈으로 등록할 수 있도록 한다.

2. SpringHandlerInstantiator를 생성할 때 인자로 applicationContext.getAutowireCapableBeanFactory()를 제공함으로서, Jackson 객체에 Spring 빈을 주입할 수 있도록 지원한다.

 

ex)

// 1. Spring Bean으로 세팅 가능
@Component
public class CustomStringSerializer extends JsonSerializer<String> {

    @Autowired
    private SomeSpringService someSpringService;  // 2. Spring 빈 주입 가능

    @Override
    public void serialize(String value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
        String transformed = someSpringService.transform(value);
        gen.writeString(transformed);
    }
}

 


 

정리

지금까지 ObjectMapper를 생성하는 케이스와, 각 케이스별 차이점을 알아봤다.

 

ObjectMapper는 크게 3가지로 사용할 수 있겠다.

1. 생성자로 직접 생성

2. Jackson2ObjectMapperBuilder로 직접 생성

3. JacksonAutoConfiguration이 기본 제공하는 ObjectMapper 빈 그대로 사용.

 

그래서 어떤걸 사용하면 좋을까?

우선 스프링 컨트롤러 등 프로젝트 전체적으로 공통 사용하는 ObjectMapper는 3번 방식이 좋겠다.

jacksonProperties로 spring 친화적인 설정 옵션들을 제공하고, Customzier 빈 추가로 손쉽게 ObjectMapper를 커스텀할 수 있다.

(Spring Boot + Jackson이 기본 제공하는 ObjectMapper인 이유가 있을 것)

 

만약, 외부 서버 호출 등 특정 서버와 매핑 스펙을 맞춰야하는 경우라면, 공통 ObjectMapper를 건드리기기 보다는 2번 방식으로 특정 상황만을 위한 ObjectMapper를 사용하는 것이 좋겠다.

Builder를 사용하는 것이, 1번 방식보다 더 직관적으로 ObjectMapper의 속성들을 세팅할 수 있다.

개인적으로 역직렬화 시 유연성을 제공하는 옵션인 FAIL_ON_UNKNOWN_PROPERTIES = false를 기본 제공하는 것이 좋은 것 같다.

 

저작자표시 비영리 동일조건 (새창열림)

'java > spring' 카테고리의 다른 글

[Spring] @Transactional -> 같은 클래스 내 메서드 호출 시 트랜잭션 전파 안되는 이슈  (0) 2025.03.04
[Spring] jackson 역직렬화, 필드가 1개일 때 HttpMessageNotReadableException  (4) 2025.02.09
[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
    'java/spring' 카테고리의 다른 글
    • [Spring] @Transactional -> 같은 클래스 내 메서드 호출 시 트랜잭션 전파 안되는 이슈
    • [Spring] jackson 역직렬화, 필드가 1개일 때 HttpMessageNotReadableException
    • [Spring] Pointcut 유형에 따라 Proxy 생성 방식이 달라진다? (CGLIB or JDK Proxy)
    • [Spring] junit test에서 lombok 사용하는 방법
    danuri
    danuri
    IT 관련 정보(컴퓨터 지식, 개발)를 꾸준히 기록하는 블로그입니다.

    티스토리툴바