팀 이야기

[스프링] SpringBoot이 ObjectMapper를 구성하는 방법

MASHUP 2024. 10. 16. 00:15

Spring 프로젝트에서는 객체를 Serialize 및 Deserialize 하는 동작들이 프레임워크 내부 동작에서 많이 활용되어지고 있습니다. Spring 을 어느정도 활용해 본 사람이라면 알고 있을 ObjectMapper 를 사용해 객체의 Serialization및 Deserialization을 수행하고 있는데요. 여러 곳에서 사용하다보니 임의로 ObjectMapperObjectMapperBuilder 를 재정의 하는 경우를 종종 볼 수 있습니다. 이러한 방식으로 이 객체들을 재정의 해도 문제가 없을지, 문제가 있다면 어떠한 문제가 있을 수 있을지 구현 코드를 보고 해당 객체들이 어떤 방식으로 구성되는지 알아보겠습니다.

💡 아래에 작성하는 코드는 ObjectMapperLearning 에서 확인 가능합니다.

ObjectMapper 주입 관계 도식화

우리가 Spring Framework 내에서 사용하는 ObjectMapper 는 여러 설정 객체의 정보들을 내려받아 최종적으로 Jackson2ObjectMapperBuilderbuild() 함수에 의해 생성됩니다. 각 설정 객체의 구현 코드를 확인해보기 전에 아래의 도식화를 보시면 이해에 도움이 될 것입니다.

ObjectMapper의 구성 정보를 담은 Builder 객체

먼저 ObjectMapper가 어떤 코드에서 주입 되었는지 확인해보면 아래와 같은 코드를 만날 수 있습니다.

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

 

위의 코드는 JacksonAutoConfiguration.java의 코드이며, JacksonAutoConfigurationJacksonObjectMapperConfiguration 이라는 static으로 생성된 Configuration 객체가 ObjectMapper@Bean을 통해 주입하고 있습니다. 또한 이 메서드는 Jackson2ObjectMapperBuilder 를 주입 받아 ObjectMapper를 생성하고 있습니다. 그럼 Jackson2ObjectMapperBuilder 는 어디서 생성하고 주입하고 있는 걸까요?

같은 파일 내에서 Jackson2ObjectMapperBuilderConfiguration 객체를 살펴보겠습니다.

 

@Configuration(proxyBeanMethods = false)
@ConditionalOnClass({Jackson2ObjectMapperBuilder.class})
static class JacksonObjectMapperBuilderConfiguration {
    ...
    @Bean
    @Scope("prototype")
    @ConditionalOnMissingBean
    Jackson2ObjectMapperBuilder jacksonObjectMapperBuilder(
        ApplicationContext applicationContext, 
        List<Jackson2ObjectMapperBuilderCustomizer> customizers
    ) {
        Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder();
        builder.applicationContext(applicationContext);
        this.customize(builder, customizers);
        return builder;
    }
    ...
}

 

이 코드를 살펴보니 Jackson2ObjectMapperBuilder는 또 Jackson2ObjectMapperBuilderCustomizer List를 주입받아 customize 라는 함수를 호출해주고 있습니다. 우리는 Spring을 학습하면서 같은 타입의 Bean이 여러개 주입될 경우 List 자료구조로 주입 된 Bean을 전부 가져올 수 있다는걸 알고 있습니다. 그럼 여기서 주입받은 customizers는 어디에서 주입되는 것일까요?

또, 같은 파일에서 Jackson2ObjectMapperBuilderCustomizerConfiguration을 살펴보겠습니다.

 

@Configuration(
    proxyBeanMethods = false
)
@ConditionalOnClass({Jackson2ObjectMapperBuilder.class})
@EnableConfigurationProperties({JacksonProperties.class})
static class Jackson2ObjectMapperBuilderCustomizerConfiguration {
        ...
        @Bean
        StandardJackson2ObjectMapperBuilderCustomizer standardJacksonObjectMapperBuilderCustomizer(
            JacksonProperties jacksonProperties, 
            ObjectProvider<Module> modules
        ) {
            return new StandardJackson2ObjectMapperBuilderCustomizer(jacksonProperties, modules.stream().toList());
        }
        ...
}

StandardJackson2ObjectMapperBuilderCustomizer를 주입하는 것을 볼 수 있는데 이 Customizer는 yml 파일 등에서 주입된 설정정보를 입력받아 JacksonProperties 라는 객체에 정보를 담아 StandardJackson2ObjectMapperBuilderCustomizer에게 전달하고 있습니다.

@ConfigurationProperties(
    prefix = "spring.jackson"
)
public class JacksonProperties { ... }

JacksonProperties는 prefix로 spring.jackson을 사용하고 dateFormat, timeZone, naming-strategy 등 과 같은 부분을 설정할 수 있습니다. 사용자는 application.yml 등과 같은 설정 파일에서 Jackson 관련 설정을 추가할 수 있습니다.

더불어 StandardJackson2ObjectMapperBuilderCustomizer는 Module의 형식으로 의존 주입된 객체들을 찾아 ObjectMapper를 구성하기 위한 설정 정보로 사용합니다. 그리고 Module Bean 주입으로 ObjectMapper를 커스텀하는 방법은 Spring이 권장하는 방법 중 하나입니다. 그렇다면 이 Module이란 무엇일까요?

Module을 이용한 커스터마이징 및 확장

Jackson 라이브러리에서 Module의 역할은 커스터마이징과 기능 확장을 위한 플러그인의 역할을 합니다. Jackson이 ObjectMapper를 구성하는 방법에 대해서 이해하기 위해서 설정을 구성하는 주된 요소인 Module에 대해 이해해야 합니다. 이를 위해 Module의 구현 코드를 먼저 살펴보겠습니다.

public abstract class Module implements Versioned {
    public Module() {
    }

    public abstract String getModuleName();

    public abstract Version version();

    public Object getTypeId() {
        return this.getClass().getName();
    }

    public abstract void setupModule(SetupContext var1);

    public Iterable<? extends Module> getDependencies() {
        return Collections.emptyList();
    }

    public interface SetupContext {
        Version getMapperVersion();
        <C extends ObjectCodec> C getOwner();
        TypeFactory getTypeFactory()

        ...

        boolean isEnabled(MapperFeature var1);
        void addDeserializers(Deserializers var1);
        void addSerializers(Serializers var1);
        void insertAnnotationIntrospector(AnnotationIntrospector var1);
        void appendAnnotationIntrospector(AnnotationIntrospector var1);
        void setMixInAnnotations(Class<?> var1, Class<?> var2);
        ...
    }
}

 

위 코드는 Module.java 파일에 있는 코드입니다. Module은 단순히 Version을 통해 모듈 버저닝 및 의존성을 위한 기능만 제공할 뿐 부가적인 기능들은 SetupContext를 통해 이뤄진다는 것을 알 수 있습니다. 그럼 SetupContext에 해당하는 부분은 어디에서 구현이 될까요? 바로 ObjectMapper에서 수행하게 됩니다. 이를 확인하기 위해 위에서 참고한 StandardJackson2ObjectMapperBuilderCustomizercustomize 하는 코드를 확인해보겠습니다.

 

static final class StandardJackson2ObjectMapperBuilderCustomizer
                implements Jackson2ObjectMapperBuilderCustomizer, Ordered {

            private final JacksonProperties jacksonProperties;

            private final Collection<Module> modules;

            StandardJackson2ObjectMapperBuilderCustomizer(
                JacksonProperties jacksonProperties,
                Collection<Module> modules
            ) {
                this.jacksonProperties = jacksonProperties;
                this.modules = modules;
            }
            ...
            @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());
                }
                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);
                configureModules(builder); // configModules 호출
                configureLocale(builder);
                configureDefaultLeniency(builder);
                configureConstructorDetector(builder);
            }
            ...
}

위의 코드는 JacksonAutoConfigration.java 파일 내에서 StandardJackson2ObjectMapperBuilderCustomizercustomize 하는 로직을 나타낸 것입니다. customize 함수 내에서 builder를 인자로 받아 configureModules를 호출하고 있습니다.

private void configureModules(Jackson2ObjectMapperBuilder builder) {
        builder.modulesToInstall(this.modules.toArray(new Module[0]));
}

configureModules를 통해 builderBean을 통해 주입된 모든 Module을 Install 하는 역할을 수행합니다. 이렇게 Install 받은 Module들은 어떻게 ObjectMapper에 전달이 될까요? 이를 이해하기 위해서는 ObjectMapperBuilderObjectMapperbuild하는 과정을 살펴봐야 합니다.

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());
    }
    configure(mapper);
    return (T) mapper;
}

위의 코드는 JacksonObjectMapperBuilderbuild() 를 통해 ObjectMapper를 생성하는 코드입니다. ObjectMapper를 생성한 후 configure() 를 호출합니다.

public void configure(ObjectMapper objectMapper) {
    ...

    MultiValueMap<Object, Module> modulesToRegister = new LinkedMultiValueMap<>();
    if (this.findModulesViaServiceLoader) {
        ObjectMapper.findModules(this.moduleClassLoader).forEach(module -> registerModule(module, modulesToRegister));
    }
    else if (this.findWellKnownModules) {
        registerWellKnownModulesIfAvailable(modulesToRegister);
    }

    if (this.modules != null) {
        this.modules.forEach(module -> registerModule(module, modulesToRegister));
    }
    if (this.moduleClasses != null) {
        for (Class<? extends Module> moduleClass : this.moduleClasses) {
            registerModule(BeanUtils.instantiateClass(moduleClass), modulesToRegister);
        }
    }
    List<Module> modules = new ArrayList<>();
    for (List<Module> nestedModules : modulesToRegister.values()) {
        modules.addAll(nestedModules);
    }
    objectMapper.registerModules(modules);
    ...
}

configure() 함수 내에서 modules를 탐색 후 objectMapper.registerModules()를 실행합니다.

public ObjectMapper registerModule(Module module)
{
    //     ... (중략) ...
    // And then call registration
    module.setupModule(new Module.SetupContext()
    {
      ...
        @SuppressWarnings("unchecked")
        @Override
        public <C extends ObjectCodec> C getOwner() {
            // why do we need the cast here?!?
            return (C) ObjectMapper.this;
        }

        @Override
        public TypeFactory getTypeFactory() {
            return _typeFactory;
        }

        @Override
        public boolean isEnabled(MapperFeature f) {
            return ObjectMapper.this.isEnabled(f);
        }

        // ... (중략) ...

        // // // Methods for registering handlers: deserializers

        @Override
        public void addDeserializers(Deserializers d) {
            DeserializerFactory df = _deserializationContext._factory.withAdditionalDeserializers(d);
            _deserializationContext = _deserializationContext.with(df);
        }

        @Override
        public void addSerializers(Serializers s) {
            _serializerFactory = _serializerFactory.withAdditionalSerializers(s);
        }

        @Override
        public void insertAnnotationIntrospector(AnnotationIntrospector ai) {
            _deserializationConfig = _deserializationConfig.withInsertedAnnotationIntrospector(ai);
            _serializationConfig = _serializationConfig.withInsertedAnnotationIntrospector(ai);
        }

        @Override
        public void appendAnnotationIntrospector(AnnotationIntrospector ai) {
            _deserializationConfig = _deserializationConfig.withAppendedAnnotationIntrospector(ai);
            _serializationConfig = _serializationConfig.withAppendedAnnotationIntrospector(ai);
        }

        @Override
        public void setMixInAnnotations(Class<?> target, Class<?> mixinSource) {
            addMixIn(target, mixinSource);
        }
        ...
    });

    return this;
}

objectMapperregisterModules() 함수 내에서 module를 순차적으로 돌며 modulesetupModule를 실행합니다.

즉, setupModule() 이라는 메서드는 ModuleObjectMapper를 연결해주는 브릿지 역할을 수행합니다. 우리는 이 메서드 안에서 우리가 필요한 동작들(Serializer 등록, Deserializer 등록 등)을 수행할 수 있습니다. SetupContext의 역할을 조금 살펴보자면 다음 4가지 정도의 역할을 꼽아볼 수 있습니다.

  • addSerializers(): Module에서 제공하는 Serializer를 추가하는 작업을 수행합니다.
  • addDeserializers(): Module에서 제공하는 Deserializer를 추가하는 작업을 수행합니다.
  • insertAnnotationIntrospector(), appendAnnotationIntrospector(): 어노테이션 기반 커스터마이징 된 Serialization, Deserialization을 수행하는 AnnotationIntrospector를 추가합니다.
  • setMixInAnnotations(): 원본 클래스를 대체한 믹스인을 만들어 원본 클래스에 적용할 Jackson 어노테이션을 추가합니다.

이런식으로 Spring은 Jackson의 기본 제공 클래스(ObjectMapper, ObjectMapperBuilder 등)을 건들지 않고 Module을 추가하는 방식으로 대부분의 Jackson 설정 정보를 커스터마이징 할 수 있습니다. 이는 Spring에서 제공하는 기본 동작을 방해하지 않고 독립적인 환경에서 커스터마이징함으로써 캡슐화의 이점을 갖을 수 있습니다.

그렇다면 StandardJackson2ObjectMapperBuilderCustomizer를 주입받을 때 AutoConfiguration에 의해 자동 주입된 Module 들은 어떤 것이 있을까요?

Spring Boot이 기본으로 제공하는 Jackson Module

JsonComponentModule

JsonComponentModuleJacksonAutoConfiguration에서 자동 설정을 통해 주입되고 있으며 @JsonComponent 어노테이션을 통해 등록된 Bean 객체를 주입받아 JsonComponentModule의 Serializer 및 Deserializer로 등록합니다.

@AutoConfiguration
@ConditionalOnClass({ObjectMapper.class})
public class JacksonAutoConfiguration {
    ...
    @Bean
    public JsonComponentModule jsonComponentModule() {
        return new JsonComponentModule();
    }
    ...
}

위의 코드는 JacksonAutoConfiguration.java 파일 내에서 JsonComponentModule 객체를 Bean 등록하는 코드입니다.

JsonComponentModule은 코드에서 살펴보시다시피 JacksonAutoConfiguration에서 자동 설정을 통해 주입되고 있으며 @JsonComponent 어노테이션을 통해 등록된 Configuration 객체를 주입받아 JsonComponentModule의 Serializer 및 Deserializer를 등록합니다. Bean 객체의 주입 대상은 @JsonComponent 어노테이션의 대상이 된 객체 뿐 아니라 해당 클래스 내에 존재하는 inner class 또한 등록 대상이 됩니다.

ParameterNamesModule

@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(ParameterNamesModule.class)
static class ParameterNamesModuleConfiguration {

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

}

ParameterNamesModule 또한 JacksonAutoConfiguration.java에서 Bean 등록을 수행합니다. ParameterNamesModule은 기본적인 Serializer, Deserializer를 추가하지 않고 클래스의 메서드 및 생성자의 파라미터 이름을 사용하여 Serialization, Deserialization을 지원합니다. 여기서 사용한 JsonCreator.Mode.DEFAULT는 기본 모드로, 주 생성자가 아닌 생성자에 대해서도 파라미터 이름을 사용하여 Deserialization을 지원합니다.

JsonMixinModule

@Configuration(proxyBeanMethods = false)
static class JacksonMixinConfiguration {

        @Bean
        static JsonMixinModuleEntries jsonMixinModuleEntries(ApplicationContext context) {
            List<String> packages = AutoConfigurationPackages.has(context) ? AutoConfigurationPackages.get(context)
                    : Collections.emptyList();
            return JsonMixinModuleEntries.scan(context, packages);
        }

        @Bean
        JsonMixinModule jsonMixinModule(ApplicationContext context, JsonMixinModuleEntries entries) {
            JsonMixinModule jsonMixinModule = new JsonMixinModule();
            jsonMixinModule.registerEntries(entries, context.getClassLoader());
            return jsonMixinModule;
        }

}

JsonMixinModule 또한 동일하게 JacksonAutoConfiguration에 의해 자동 설정 시 Bean 등록을 수행합니다. JsonMixinModule은 기본적으로 ApplicationContext 내에서 지정된 패키지를 스캔하여 @JsonMixin 이라는 어노테이션이 지정된 믹스인 클래스를 찾아 JsonMixinModule에 등록합니다.

이처럼 Spring Boot에서는 properties 파일 및 기본적으로 제공하는 Module 들에 의해 ObjectMapper가 생성됩니다. 그래서 ObjectMapper를 임의로 재정의할 때에는 이러한 기본 동작이 제대로 이뤄지지 않을 수 있음을 알고 Jackson 설정을 커스터마이징 해야 합니다. 그럼 이러한 기본 동작을 방해하지 않는 선에서 JacksonObjectMapperBuilder 및 Serializer, Deserializer를 커스터마이징 하는 방법에 대해 알아보겠습니다.

Jackson2ObjectMapperBuilder 직접 커스터마이징

@Configuration(proxyBeanMethods = false)
@ConditionalOnClass({Jackson2ObjectMapperBuilder.class})
static class JacksonObjectMapperBuilderConfiguration {
    ...
    @Bean
    @Scope("prototype")
    @ConditionalOnMissingBean
    Jackson2ObjectMapperBuilder jacksonObjectMapperBuilder(
        ApplicationContext applicationContext, 
        List<Jackson2ObjectMapperBuilderCustomizer> customizers
    ) {
        Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder();
        builder.applicationContext(applicationContext);
        this.customize(builder, customizers);
        return builder;
    }
    ...
}

Jackson2ObjectMapperBuilder는 다음과 같이 주입된 customizerscustomize 하는 동작을 내포하고 있습니다. Jackson2ObjectMapperBuilder를 기본적인 동작을 해치지 않고 재정의 하기 위해서는 위의 코드와 동일하게 기본적으로 제공하는 customizerscustomize() 하는 작업이 필요합니다. 예시 코드는 다음과 같습니다.

@Configuration
class JacksonConfig {

    @Bean
    fun jackson2ObjectMapperBuilder(customizers: List<Jackson2ObjectMapperBuilderCustomizer>): Jackson2ObjectMapperBuilder {
        val builder = Jackson2ObjectMapperBuilder()
            .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            .featuresToDisable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
            .serializerByType(LocalDateTime::class.java, LocalDateTimeSerializer(DateTimeFormatter.ISO_LOCAL_DATE_TIME))
            .serializerByType(LocalDate::class.java, LocalDateSerializer(DateTimeFormatter.ISO_LOCAL_DATE))
            .serializerByType(LocalTime::class.java, LocalTimeSerializer(DateTimeFormatter.ISO_LOCAL_TIME))
        customizers.forEach { customizer -> customizer.customize(builder) }
        return builder
    }
}

위의 코드는 Jackson2ObjectMapperBuilder의 필요 없는 기본 설정을 비활성화 하고 특정 타입에 대한 Serializer를 추가하는 코드입니다.

  • featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) : Jackson은 기본적으로 날짜 정보를 Timestamp로 표시합니다. 이를 비활성화 합니다.
  • featuresToDisable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) : JSON에 매핑되지 않은 알 수 없는 속성이 존재해도 오류를 발생시키지 않도록 설정합니다.
  • serializerByType(): 날짜 및 시간 타입의 객체를 com.fasterxml.jackson.datatype.jsr310.ser 에서 제공하는 Serializer들로 대체합니다.
  • customizer.customize(builder) : Customizer를 순회하면서 Customizer의 설정 정보들을 내려받습니다. StandardJackson2ObjectMapperBuilderCustomizer 가 그 중 하나이며 해당 Customizer가 포함하는 JacksonProperties기본 제공 Module의 설정 정보를 주입합니다.

Jackson2ObjectMapperBuilderCustomizer 주입

사실 위와 같이 설정하는건 Jackson2ObjectMapperBuilderCustomizer를 어떻게 주입 시켜야 하는지 알고 있어야 채택할 수 있는 방법이죠. 더불어 위의 로직은 Jackson2ObjectMapperBuilder를 자동으로 주입하는 로직에 포함된 로직이니 중복이라 할 수 있습니다. Spring Framework는 이를 위해 Jackson2ObjectMapperBuilderCustomizer를 주입하는 방식으로 Jackson2ObjectMapperBuilder를 커스터마이징 할 수 있는 방법을 제공합니다.

Customizer들을 customize 하는 로직을 중복으로 작성하지 않아도 되니 위의 방식보다 더 우아한 방식이라고 생각합니다.

class SimpleObjectMapperBuilderCustomizer : Jackson2ObjectMapperBuilderCustomizer {
    override fun customize(jacksonObjectMapperBuilder: Jackson2ObjectMapperBuilder) {
        jacksonObjectMapperBuilder.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            .featuresToDisable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
            .serializerByType(LocalDateTime::class.java, LocalDateTimeSerializer(DateTimeFormatter.ISO_LOCAL_DATE_TIME))
            .serializerByType(LocalDate::class.java, LocalDateSerializer(DateTimeFormatter.ISO_LOCAL_DATE))
            .serializerByType(LocalTime::class.java, LocalTimeSerializer(DateTimeFormatter.ISO_LOCAL_TIME))
    }
}
@TestConfiguration
class ObjectMapperBuilderCustomizerConfiguration {

    @Bean
    fun objectMapperBuilderCustomizer(): SimpleObjectMapperBuilderCustomizer {
        return SimpleObjectMapperBuilderCustomizer()
    }
      ...
}

Serializer, Deserializer를 커스터마이징 하는 방법

기본으로 제공하는 Module 사용

Serializer 및 Deserializer를 추가하는 방법은 매우 다양합니다. 기본 제공 ModuleJsonComponentModule, JsonMixinModule을 사용해서 Json 관련 어노테이션을 사용해서 Serializer 및 Deserializer를 추가할 수 있습니다. 하지만 본문에서는 Module을 통한 커스터마이징 방법에 대해 기술하겠습니다.

Module 구현 및 빈 주입

가장 간단한 방법은 SimpleModule이라고 하는 간편화된 Module 객체를 상속하는 방법입니다.

아래의 예제는 기본적인 User 데이터 클래스의 Serializer, Deserializer를 추가하는 예제입니다.

data class User(
    val id: Int,
    val name: String,
    val password: String
)

이제 User에 대한 Serializer를 구현합니다.

import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.JsonSerializer
import com.fasterxml.jackson.databind.SerializerProvider

class UserSerializer : JsonSerializer<User>() {
    override fun serialize(user: User, gen: JsonGenerator, serializers: SerializerProvider) {
        gen.writeStartObject()
        gen.writeNumberField("user_id", user.id)
        gen.writeStringField("name", user.name)
        // Password 필드는 직렬화하지 않음
        gen.writeEndObject()
    }
}

다음 User에 대한 Deserializer를 구현합니다.

import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonDeserializer

class UserDeserializer : JsonDeserializer<User>() {
    override fun deserialize(p: JsonParser, ctxt: DeserializationContext): User {
        val node = p.codec.readTree<com.fasterxml.jackson.databind.node.ObjectNode>(p)
        val id = node.get("user_id").asInt()
        val name = node.get("name").asText()
        // 역직렬화할 때 패스워드 필드는 기본 값으로 설정
        return User(id, name, "")
    }
}

마지막으로 위에서 구현한 UserSerializer, UserDeserializer를 포함하는 Module를 구현합니다.

import com.fasterxml.jackson.databind.module.SimpleModule

class UserModule : SimpleModule() {
    init {
        addSerializer(User::class.java, UserSerializer())
        addDeserializer(User::class.java, UserDeserializer())
    }
}

SimpleModule을 사용하면 구태여 setupModule() 을 오버라이딩 해서 context에 직접 접근해 addSerializer()addDeserializer()를 추가할 필요없이 바로 추가가 가능합니다.

이렇게 만들어진 UserModule을 단순히 Bean 으로 주입해주면 알아서 Module들이 ObjectMapper에 Install 되어 실행될 것입니다.

import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
class JacksonConfig {

    @Bean
    fun userModule(): UserModule {
            return UserModule()
    }
}

테스트

위의 설정 방법대로 설정 할 경우 제대로 동작을 하는지 확인 하기 위해 테스트 코드를 작성해보겠습니다.

우리가 중점적으로 테스트 해야할 부분은 다음과 같습니다.

  • 커스터마이징한 Jackson2ObjectMapperBuilder의 설정 정보가 제대로 주입되는가?
  • yml에 작성한 jackson 설정 정보가 제대로 주입되는가?
  • UserModule이 제대로 주입되는가?

yml이 제대로 설정 정보에 주입되는지 확인하기 위해 yml 설정을 추가하겠습니다.

spring:
  jackson:
    property-naming-strategy: SNAKE_CASE

ObjectMapperBuilder를 직접 생성한 경우

우선 ObjectMapperBuilder를 직접 생성한 경우에 대한 테스트를 진행해보겠습니다.

@TestConfiguration
class PlainObjectMapperBuilderConfiguration {

    @Bean
    fun objectMapperBuilder(): Jackson2ObjectMapperBuilder {
        return Jackson2ObjectMapperBuilder()
            .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            .featuresToDisable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
            .serializerByType(LocalDateTime::class.java, LocalDateTimeSerializer(DateTimeFormatter.ISO_LOCAL_DATE_TIME))
            .serializerByType(LocalDate::class.java, LocalDateSerializer(DateTimeFormatter.ISO_LOCAL_DATE))
            .serializerByType(LocalTime::class.java, LocalTimeSerializer(DateTimeFormatter.ISO_LOCAL_TIME))
    }

    @Bean
    fun userModule(): UserModule {
        return UserModule()
    }
}
@SpringBootTest
@Import(PlainObjectMapperBuilderConfiguration::class)
class PlainObjectMapperTest {

    @Autowired
    private lateinit var objectMapper: ObjectMapper

    @DisplayName("Customized ObjectMapperBuilder 의 주입을 통해 Customized 설정 정보가 제대로 설정된다.")
    @Test
    fun testObjectMapperBuilderCustomizerSettings() {
        assertThat(objectMapper.serializationConfig.isEnabled(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)).isFalse()
        assertThat(objectMapper.deserializationConfig.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)).isFalse()

        // Verify LocalDateTime serializer
        val dateTime = LocalDateTime.of(2020, 1, 1, 12, 0)
        val dateTimeJson = objectMapper.writeValueAsString(dateTime)
        assertThat(dateTimeJson).isEqualTo("\"2020-01-01T12:00:00\"")

        // Verify LocalDate serializer
        val date = LocalDate.of(2020, 1, 1)
        val dateJson = objectMapper.writeValueAsString(date)
        assertThat(dateJson).isEqualTo("\"2020-01-01\"")

        // Verify LocalTime serializer
        val time = LocalTime.of(12, 0)
        val timeJson = objectMapper.writeValueAsString(time)
        assertThat(timeJson).isEqualTo("\"12:00:00\"")
    }

    @DisplayName("ObjectMapperBuilder를 직접 생성하면 yml 세팅을 무시한다.")
    @Test
    fun testPlainObjectMapperCreationIgnoresYmlSettings() {
        assertThat(objectMapper.propertyNamingStrategy).isNull()
    }

    @DisplayName("ObjectMapperBuilder를 직접 생성하면 직접 주입한 Module의 Serializer를 Instsall 할 수 없다.")
    @Test
    fun testPlainObjectMapperBuilderCreationDoesNotInstallInjectedModuleSerializers() {
        val user = User(1, "John Doe", "secret")
        val json = objectMapper.writeValueAsString(user)

        val expectedJson = """{"user_id":1,"name":"John Doe"}"""
        assertThat(expectedJson).isNotEqualTo(json)
    }

    @DisplayName("ObjectMapperBuilder를 직접 생성하면 직접 주입한 Module의 Deserializer를 Instsall 할 수 없다.")
    @Test
    fun testPlainObjectMapperBuilderCreationDoesNotInstallInjectedModuleDeserializers() {
        val json = """{"user_id":1,"name":"John Doe","password":""}"""

        val user: User = objectMapper.readValue(json)

        val expectedUser = User(1, "John Doe", "")
        assertThat(expectedUser).isNotEqualTo(user)
        assertThat(expectedUser.id).isNotEqualTo(user.id)
    }
}

 

테스트의 결과는 다음과 같습니다.

 

Customizing 한 Jackson2ObjectMapperBuilder의 설정 정보가 제대로 주입되는가? -> Yes

yml에 작성한 jackson 설정 정보가 제대로 주입되는가? -> No

User Module이 제대로 주입되는가? -> No

 

위와 같이 설정을 하면 Jackson2ObjectMapperBuilder를 직접 생성해 작성한 설정 정보는 제대로 주입되지만 JacksonAutoConfiguration이 제공해주는 기본 yml 세팅 및 주입된 Module Install 작업이 생략된 것을 확인할 수 있습니다.

ObjectMapperBuilder와 주입된 customizers를 직접 세팅한 경우

@TestConfiguration
class CustomizedObjectMapperBuilderConfiguration {

    @Bean
    fun objectMapperBuilder(customizers: List<Jackson2ObjectMapperBuilderCustomizer>): Jackson2ObjectMapperBuilder {
        val builder = Jackson2ObjectMapperBuilder()
            .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            .featuresToDisable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
            .serializerByType(LocalDateTime::class.java, LocalDateTimeSerializer(DateTimeFormatter.ISO_LOCAL_DATE_TIME))
            .serializerByType(LocalDate::class.java, LocalDateSerializer(DateTimeFormatter.ISO_LOCAL_DATE))
            .serializerByType(LocalTime::class.java, LocalTimeSerializer(DateTimeFormatter.ISO_LOCAL_TIME))
        customizers.forEach { customizer -> customizer.customize(builder) }
        return builder
    }

    @Bean
    fun userModule(): UserModule {
        return UserModule()
    }
}
@Import(CustomizedObjectMapperBuilderConfiguration::class)
@SpringBootTest
class CustomizedObjectMapperBuilderTest {

    @Autowired
    private lateinit var objectMapper: ObjectMapper

    @DisplayName("Customized ObjectMapperBuilder 의 주입을 통해 Customized 설정 정보가 제대로 설정된다.")
    @Test
    fun testObjectMapperBuilderCustomizerSettings() {
        assertThat(objectMapper.serializationConfig.isEnabled(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)).isFalse()
        assertThat(objectMapper.deserializationConfig.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)).isFalse()

        // Verify LocalDateTime serializer
        val dateTime = LocalDateTime.of(2020, 1, 1, 12, 0)
        val dateTimeJson = objectMapper.writeValueAsString(dateTime)
        assertThat(dateTimeJson).isEqualTo("\"2020-01-01T12:00:00\"")

        // Verify LocalDate serializer
        val date = LocalDate.of(2020, 1, 1)
        val dateJson = objectMapper.writeValueAsString(date)
        assertThat(dateJson).isEqualTo("\"2020-01-01\"")

        // Verify LocalTime serializer
        val time = LocalTime.of(12, 0)
        val timeJson = objectMapper.writeValueAsString(time)
        assertThat(timeJson).isEqualTo("\"12:00:00\"")
    }

    @DisplayName("customizers를 customize 하면 yml의 세팅값을 그대로 적용할 수 있다.")
    @Test
    fun testYmlSettingsAppliedWithCustomizedCustomizers() {
        assertThat(objectMapper.propertyNamingStrategy).isEqualTo(PropertyNamingStrategies.SNAKE_CASE)
    }

    @DisplayName("customizers를 customize 하면 Bean으로 등록된 Module의 Serializer가 자동으로 Install 된다.")
    @Test
    fun testBeanRegisteredModuleSerializersAutoInstallWithCustomizedCustomizers() {
        val user = User(1, "John Doe", "secret")
        val json = objectMapper.writeValueAsString(user)

        val expectedJson = """{"user_id":1,"name":"John Doe"}"""
        assertThat(expectedJson).isEqualTo(json)
    }

    @DisplayName("customizers를 customize 하면 Bean으로 등록된 Module의 Deserializer가 자동으로 Install 된다.")
    @Test
    fun testBeanRegisteredModuleDeserializersAutoInstallWithCustomizedCustomizers() {
        val json = """{"user_id":1,"name":"John Doe"}"""
        val user: User = objectMapper.readValue(json)

        val expectedUser = User(1, "John Doe", "")
        assertThat(expectedUser).isEqualTo(user)
    }
}

테스트의 결과는 다음과 같습니다.

  Customizing 한 Jackson2ObjectMapperBuilder의 설정 정보가 제대로 주입되는가? -> Yes
  yml에 작성한 jackson 설정 정보가 제대로 주입되는가? -> Yes
  User Module이 제대로 주입되는가? -> Yes

위와 같이 작성한 경우 바라던 대로 제대로 설정이 이루어지는 것을 확인할 수 있습니다. 다만, ObjectMapperBuilder를 주입하는 코드에서 customizers를 주입받아 customize() 과정을 거쳐야 한다는 단점이 존재합니다. Customizerscustomize() 하는 로직은 사실 ObjectMapperBuilder를 커스터마이징 할 때 알기에는 과한 관심사로 보입니다.

ObjectMapperBuilderCustomizer를 주입하는 경우

ObjectMapperBuilder를 커스터마이징 할 때 가장 깔끔한 방법은 Jackson2ObjectMapperBuilderCustomizer를 직접 구현해 ObJectMapperBuilder 커스터마이징만 하는 것입니다.

class SimpleObjectMapperBuilderCustomizer : Jackson2ObjectMapperBuilderCustomizer {
    override fun customize(jacksonObjectMapperBuilder: Jackson2ObjectMapperBuilder) {
        jacksonObjectMapperBuilder.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            .featuresToDisable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
            .serializerByType(LocalDateTime::class.java, LocalDateTimeSerializer(DateTimeFormatter.ISO_LOCAL_DATE_TIME))
            .serializerByType(LocalDate::class.java, LocalDateSerializer(DateTimeFormatter.ISO_LOCAL_DATE))
            .serializerByType(LocalTime::class.java, LocalTimeSerializer(DateTimeFormatter.ISO_LOCAL_TIME))
    }
}
@TestConfiguration
class ObjectMapperBuilderCustomizerConfiguration {

    @Bean
    fun objectMapperBuilderCustomizer(): SimpleObjectMapperBuilderCustomizer {
        return SimpleObjectMapperBuilderCustomizer()
    }

    @Bean
    fun userModule(): UserModule {
        return UserModule()
    }
}
@Import(ObjectMapperBuilderCustomizerConfiguration::class)
@SpringBootTest
class ObjectMapperBuilderCustomizerTest {
    @Autowired
    private lateinit var objectMapper: ObjectMapper

    @DisplayName("customized ObjectMapperBuilderCustomizer의 주입을 통해 Customized 설정 정보가 제대로 설정된다.")
    @Test
    fun testObjectMapperBuilderCustomizerSettings() {
        assertThat(objectMapper.serializationConfig.isEnabled(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)).isFalse()
        assertThat(objectMapper.deserializationConfig.isEnabled(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)).isFalse()

        // Verify LocalDateTime serializer
        val dateTime = LocalDateTime.of(2020, 1, 1, 12, 0)
        val dateTimeJson = objectMapper.writeValueAsString(dateTime)
        assertThat(dateTimeJson).isEqualTo("\"2020-01-01T12:00:00\"")

        // Verify LocalDate serializer
        val date = LocalDate.of(2020, 1, 1)
        val dateJson = objectMapper.writeValueAsString(date)
        assertThat(dateJson).isEqualTo("\"2020-01-01\"")

        // Verify LocalTime serializer
        val time = LocalTime.of(12, 0)
        val timeJson = objectMapper.writeValueAsString(time)
        assertThat(timeJson).isEqualTo("\"12:00:00\"")
    }

    @DisplayName("customized ObjectMapperBuilderCustomizer를 주입하면 yml의 세팅값을 그대로 적용할 수 있다.")
    @Test
    fun testYmlSettingsAppliedWithCustomizedCustomizers() {
        assertThat(objectMapper.propertyNamingStrategy).isEqualTo(PropertyNamingStrategies.SNAKE_CASE)
    }

    @DisplayName("customized ObjectMapperBuilderCustomizer를 주입하면 Bean으로 등록된 Module의 Serializer가 자동으로 Install 된다.")
    @Test
    fun testBeanRegisteredModuleSerializersAutoInstallWithCustomizedCustomizers() {
        val user = User(1, "John Doe", "secret")
        val json = objectMapper.writeValueAsString(user)

        val expectedJson = """{"user_id":1,"name":"John Doe"}"""
        assertThat(expectedJson).isEqualTo(json)
    }

    @DisplayName("customized ObjectMapperBuilderCustomizer를 주입하면 Bean으로 등록된 Module의 Deserializer가 자동으로 Install 된다.")
    @Test
    fun testBeanRegisteredModuleDeserializersAutoInstallWithCustomizedCustomizers() {
        val json = """{"user_id":1,"name":"John Doe"}"""
        val user: User = objectMapper.readValue(json)

        val expectedUser = User(1, "John Doe", "")
        assertThat(expectedUser).isEqualTo(user)
    }
}

테스트의 결과는 다음과 같습니다.

  Customizing 한 Jackson2ObjectMapperBuilder의 설정 정보가 제대로 주입되는가? -> Yes
  yml에 작성한 jackson 설정 정보가 제대로 주입되는가? -> Yes
  User Module이 제대로 주입되는가? -> Yes

위의 방식으로 작성할 때 비로소 Configuration 하는 로직이 매우 간단해졌으며 불필요한 관심에 대해 자유로워졌습니다.

즉, Jackson2ObjectMapperBuilder를 커스터마이징 할 때는 Jackson2ObjectMapperBuilderCustomizer를 구현한 Bean을 주입하고 새로운 Serializer 및 Deserializer를 추가할 때에는 Module을 구현한 Bean을 주입하면 JacksonAutoConfiguration 에서 제공하는 기본 기능은 물론 커스터마이징 또한 자유롭게 할 수 있을것입니다.

마무리

이렇게 해서 Spring Boot 에서 Jackson이 AutoConfiguration을 통해 ObjectMapper를 어떻게 구성하는지에 대해 알아보았습니다. Spring Boot이 기본적으로 제공하는 Module들의 자세한 구현부는 추후 다른 포스트에서 구술하도록 하겠습니다.

Spring Boot은 이렇게 생성한 ObjectMapper를 REST API 내에서 ResponseBody로 들어온 Json 객체를 Deserialization 하거나 응답 값을 Serialization 하는 등의 역할을 수행하게 됩니다. 추후에 Spring 에서 RequestBody를 ObjectMapper를 통해 Deserialize 하는 방법에 대해 기술하도록 하겠습니다.

감사합니다.

'팀 이야기' 카테고리의 다른 글

[스프링팀] PK 숨기기  (8) 2024.10.18
[스프링팀] Coroutine 찍어먹기  (0) 2024.10.18
[스프링팀] gRPC  (1) 2024.10.15
[스프링팀] Druid  (0) 2023.08.31
[스프링팀] 검색 기술의 원리 발표 세션  (1) 2022.09.14