스프링에서 기본으로 등록해주는 ObjectMapper가 있다?

Jeonghwa·2025년 2월 16일

서론

평소 개발을 하다보면 ObjectMapper를 사용하게 될 일이 많은데요. 저와 같은 경우엔 RedisCacheConfiguration 설정 중 ObjectMapper를 커스텀 해서 사용할 일이 있었습니다.

그리고 해당 커스텀한 ObjectMapper를 공통적으로 사용할 일이 필요하여 아래와 같이 ObjectMapper를 Bean으로 등록하여 공통화를 시켰습니다.

  @Bean
  public ObjectMapper polymorphicObjectMapper() {
    PolymorphicTypeValidator typeValidator = BasicPolymorphicTypeValidator.builder()
        .allowIfSubType(Object.class)
        .build();

    ObjectMapper polymorphicObjectMapper = new ObjectMapper();
    polymorphicObjectMapper.registerModule(new JavaTimeModule());
    polymorphicObjectMapper.activateDefaultTyping(typeValidator, DefaultTyping.EVERYTHING);
    return polymorphicObjectMapper;
  }

그런데.. 설정 후 SpringBoot를 실행하니 Swagger문서가 제대로 생성이 안됨과 동시에 API호출이 제대로 수행되지 않았습니다. (BadRequest 발생)

찾아보니 SpringBoot에서는 ObjectMapper가 기본적으로 자동 Bean등록이 되고 있었고 Bean으로 등록된 ObjectMapper를 사용하여 RestController, RestTemplate, WebClient 등 Json변환 시 공통으로 사용되고 있었습니다. 제가 멋대로 해당 설정을 변경해버려 오류가 났던것이죠.

따라서 이번 기회에 스프링에서 기본으로 설정해주는 ObjectMapper에 대해서 정확히 알아보고 넘어가야겠다는 마음에 블로그글을 작성하게 되었습니다.

ObjectMapper란?

간단하게 ObjectMapper는 아래와 같은 특징을 갖고있습니다.

  • ObjectMapper는 Jackson라이브러리에서 제공하는 JSON 처리 객체로, Java객체를 JSON 문자열로 변환(직렬화)하거나 JSON문자열을 Java 객체로 변환(역직렬화) 하는데 사용된다.
  • Spring Boot에서는 기본적으로 Jackson의 ObjectMapper를 자동으로 빈(bean)으로 등록하여 JSON 변환을 쉽게 사용할 수 있도록 지원한다.
  • 커스텀 설정을 통해 snake_case 변환, null 값 제외 등 다양한 설정을 적용할 수 있다.

그래서 스프링에서는 어떻게 등록되는걸까?

JacksonAutoConfiguration 클래스를 보면 알 수 있습니다.

@AutoConfiguration
@ConditionalOnClass({ObjectMapper.class})
public class JacksonAutoConfiguration {
	...
    
  @Configuration(
    proxyBeanMethods = false
  )
  @ConditionalOnClass({Jackson2ObjectMapperBuilder.class})
  static class JacksonObjectMapperConfiguration {
    JacksonObjectMapperConfiguration() {
    }

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

@ConditionalOnMissingBean은 특정 빈이 존재하지 않을 때만 새로운 빈을 등록하는 조건부 어노테이션입니다. 이를 통해 ObjectMapper 타입의 Bean이 등록되어있지 않다면 등록이 될 수 있습니다. 그럼 Jackson2ObjectMapperBuilder를 사용하여 어떤 설정으로 등록되는지도 알아봅시다.

기본 설정(Jackson2ObjectMapperBuilder)

Jackson2ObjectMapperBuilder 클래스를 보면 알 수 있습니다.
createXmlMapper를 false로 설정했기 때문에 if문 안에 설정들은 무시가 되고

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

      this.configure(mapper);
      return mapper;
    }
    ...
}

아래 configure 메서드를 수행하게 됩니다.

굉장히 장황하지만 저희는 여기서 딱 2개의 메서드만 보면됩니다. findWellKnownModules 필드를 제외하고는 전부 false거나 Null이거나 Empty하기 때문에 아래 메서드에서 어떤 모듈과 설정을 추가하는지 자세히 살펴보겠습니다.

  • registerWellKnownModulesIfAvailable
  • customizeDefaultFeatures
  public void configure(ObjectMapper objectMapper) {
    Assert.notNull(objectMapper, "ObjectMapper must not be null");
    MultiValueMap<Object, Module> modulesToRegister = new LinkedMultiValueMap();
    if (this.findModulesViaServiceLoader) {
      ObjectMapper.findModules(this.moduleClassLoader).forEach((modulex) -> {
        this.registerModule(modulex, modulesToRegister);
      });
    } else if (this.findWellKnownModules) {
      this.registerWellKnownModulesIfAvailable(modulesToRegister); // 여기
    }

    if (this.modules != null) {
      this.modules.forEach((modulex) -> {
        this.registerModule(modulex, modulesToRegister);
      });
    }

    if (this.moduleClasses != null) {
      Class[] var3 = this.moduleClasses;
      int var4 = var3.length;

      for(int var5 = 0; var5 < var4; ++var5) {
        Class<? extends Module> moduleClass = var3[var5];
        this.registerModule((Module)BeanUtils.instantiateClass(moduleClass), modulesToRegister);
      }
    }

    List<Module> modules = new ArrayList();
    Iterator var8 = modulesToRegister.values().iterator();

    while(var8.hasNext()) {
      List<Module> nestedModules = (List)var8.next();
      modules.addAll(nestedModules);
    }

    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 {
      objectMapper.addMixIn(ProblemDetail.class, ProblemDetailJacksonMixin.class);
    }

    Map var10000 = this.mixIns;
    Objects.requireNonNull(objectMapper);
    var10000.forEach(objectMapper::addMixIn);
    if (!this.serializers.isEmpty() || !this.deserializers.isEmpty()) {
      SimpleModule module = new SimpleModule();
      this.addSerializers(module);
      this.addDeserializers(module);
      objectMapper.registerModule(module);
    }

    var10000 = this.visibilities;
    Objects.requireNonNull(objectMapper);
    var10000.forEach(objectMapper::setVisibility);
    this.customizeDefaultFeatures(objectMapper); // 여기
    this.features.forEach((feature, enabled) -> {
      this.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);
    }

  }

참고: Jackson2ObjectMapperBuilder의 많은 필드중에서 findWellKnownModules만 true로 설정되어 있습니다.
private boolean findWellKnownModules = true;

registerWellKnownModulesIfAvailable 메서드

private void registerWellKnownModulesIfAvailable(MultiValueMap<Object, Module> modulesToRegister) {
    Class kotlinModuleClass;
    Module kotlinModule;
    try {
      kotlinModuleClass = ClassUtils.forName("com.fasterxml.jackson.datatype.jdk8.Jdk8Module", this.moduleClassLoader);
      kotlinModule = (Module)BeanUtils.instantiateClass(kotlinModuleClass);
      modulesToRegister.set(kotlinModule.getTypeId(), kotlinModule);
    } catch (ClassNotFoundException var7) {
    }

    try {
      kotlinModuleClass = ClassUtils.forName("com.fasterxml.jackson.module.paramnames.ParameterNamesModule", this.moduleClassLoader);
      kotlinModule = (Module)BeanUtils.instantiateClass(kotlinModuleClass);
      modulesToRegister.set(kotlinModule.getTypeId(), kotlinModule);
    } catch (ClassNotFoundException var6) {
    }

    try {
      kotlinModuleClass = ClassUtils.forName("com.fasterxml.jackson.datatype.jsr310.JavaTimeModule", this.moduleClassLoader);
      kotlinModule = (Module)BeanUtils.instantiateClass(kotlinModuleClass);
      modulesToRegister.set(kotlinModule.getTypeId(), kotlinModule);
    } catch (ClassNotFoundException var5) {
    }

    if (KotlinDetector.isKotlinPresent()) {
      try {
        kotlinModuleClass = ClassUtils.forName("com.fasterxml.jackson.module.kotlin.KotlinModule", this.moduleClassLoader);
        kotlinModule = (Module)BeanUtils.instantiateClass(kotlinModuleClass);
        modulesToRegister.set(kotlinModule.getTypeId(), kotlinModule);
      } catch (ClassNotFoundException var4) {
      }
    }

  }

총 4개의 모듈이 자동으로 등록되는 것을 볼 수 있습니다. 하나하나 살펴보자면~

  1. Jdk8Module : Java8에 추가된 기능(Optional, Stream 등)을 Jackson이 직렬화/역직렬화 할 수 있도록 지원하는 모듈
  • 위 모듈이 없다면 Optional 타입 필드를 처리할 수 없습니다.
  1. ParameterNamesModule : 클래스의 생성자에서 파라미터명을 활용한 역직렬화를 지원하는 모듈
  • 기본적으로 Jackson은 기본생성자와 setter를 통해 역직렬화를 수행하지만, 위 모듈을 사용하면 setter 없이도 생성자를 통해 객체를 역직렬화 할 수 있습니다.
  1. JavaTimeModule : Java8의 java.time 패키지(LocalDate, LocalDateTime 등)을 Jackson이 직렬화/역직렬화 할 수 있도록 지원하는 모듈
  • 위 모듈을 등록하면 ISO 8601 형식으로 직렬화/역직렬화가 가능합니다.

예시: {"name":"Conference","dateTime":"2025-02-16T12:34:56.789"}

  1. KotlinModule : Kotlin에서 Jackson을 사용할 때 직렬화/역직렬화를 원활하게 해주는 모듈.

customizeDefaultFeatures 메서드

  private void customizeDefaultFeatures(ObjectMapper objectMapper) {
    if (!this.features.containsKey(MapperFeature.DEFAULT_VIEW_INCLUSION)) {
      this.configureFeature(objectMapper, MapperFeature.DEFAULT_VIEW_INCLUSION, false);
    }

    if (!this.features.containsKey(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)) {
      this.configureFeature(objectMapper, DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
    }

  }

ObjectMapper의 기본 설정을 커스터마이징하는 메서드 입니다. 기본적으로 제공되는 설정값이 features에 존재하지 않으면, 해당 설정을 false 처리하는 것을 볼 수 있습니다.

  1. DEFAULT_VIEW_INCLUSION
  • @JsonView를 사용할 때 기본적으로 모든 속성을 직렬화할지 여부를 결정하는 설정
  • true : 모든 필드가 기본적으로 직렬화됨 (기본값)
  • false : @JsonView가 적용된 필드만 포함됨
  1. FAIL_ON_UNKNOWN_PROPERTIES
  • JSON을 Java객체로 변환(역직렬화)할 때 Java클래스에 정의되지 않은 속성이 포함된 경우 오류를 발생시킬지 여부를 결정하는 설정
  • true : Json에 정의되지 않은 필드가 있다면 예외 발생 (기본값)
  • false : 알 수 없는 필드가 있어도 무시하고 정상적으로 역직렬화 됨

마무리

  • Spring에서 기본적으로 필요한 설정들이 추가되어있으므로 Bean으로 등록하기 보다는 가져다가 써야겠다는 것을 깨달았습니다.
  • 만약 커스텀한 ObjectMapper가 Bean으로 등록이 필요하다면? AutoConfiguration처럼 Jackson2ObjectMapperBuilder를 사용해서 build를 해준 ObjectMapper를 @Primay Bean으로 등록한 후 Custom한 Bean을 등록해준다면 스프링에 기본동작에 문제가 없을 것으로 생각됩니다.

읽어주셔서 감사합니다!

profile
backend-developer🔥

0개의 댓글