Spring 프로젝트에서는 객체를 Serialize 및 Deserialize 하는 동작들이 프레임워크 내부 동작에서 많이 활용되어지고 있습니다. Spring 을 어느정도 활용해 본 사람이라면 알고 있을 ObjectMapper
를 사용해 객체의 Serialization및 Deserialization을 수행하고 있는데요. 여러 곳에서 사용하다보니 임의로 ObjectMapper
및 ObjectMapperBuilder
를 재정의 하는 경우를 종종 볼 수 있습니다. 이러한 방식으로 이 객체들을 재정의 해도 문제가 없을지, 문제가 있다면 어떠한 문제가 있을 수 있을지 구현 코드를 보고 해당 객체들이 어떤 방식으로 구성되는지 알아보겠습니다.
💡 아래에 작성하는 코드는 ObjectMapperLearning 에서 확인 가능합니다.
ObjectMapper 주입 관계 도식화
우리가 Spring Framework 내에서 사용하는 ObjectMapper
는 여러 설정 객체의 정보들을 내려받아 최종적으로 Jackson2ObjectMapperBuilder
의 build()
함수에 의해 생성됩니다. 각 설정 객체의 구현 코드를 확인해보기 전에 아래의 도식화를 보시면 이해에 도움이 될 것입니다.
ObjectMapper의 구성 정보를 담은 Builder 객체
먼저 ObjectMapper
가 어떤 코드에서 주입 되었는지 확인해보면 아래와 같은 코드를 만날 수 있습니다.
@Bean
@Primary
@ConditionalOnMissingBean
ObjectMapper jacksonObjectMapper(Jackson2ObjectMapperBuilder builder) {
return builder.createXmlMapper(false).build();
}
위의 코드는 JacksonAutoConfiguration.java의 코드이며, JacksonAutoConfiguration
의 JacksonObjectMapperConfiguration
이라는 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
에서 수행하게 됩니다. 이를 확인하기 위해 위에서 참고한 StandardJackson2ObjectMapperBuilderCustomizer
를 customize
하는 코드를 확인해보겠습니다.
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 파일 내에서 StandardJackson2ObjectMapperBuilderCustomizer
가 customize
하는 로직을 나타낸 것입니다. customize
함수 내에서 builder
를 인자로 받아 configureModules를 호출하고 있습니다.
private void configureModules(Jackson2ObjectMapperBuilder builder) {
builder.modulesToInstall(this.modules.toArray(new Module[0]));
}
configureModules
를 통해 builder
에 Bean을 통해 주입된 모든 Module
을 Install 하는 역할을 수행합니다. 이렇게 Install 받은 Module
들은 어떻게 ObjectMapper
에 전달이 될까요? 이를 이해하기 위해서는 ObjectMapperBuilder
가 ObjectMapper
를 build
하는 과정을 살펴봐야 합니다.
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;
}
위의 코드는 JacksonObjectMapperBuilder
가 build()
를 통해 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;
}
objectMapper
는 registerModules()
함수 내에서 module
를 순차적으로 돌며 module
의 setupModule
를 실행합니다.
즉, setupModule()
이라는 메서드는 Module
과 ObjectMapper
를 연결해주는 브릿지 역할을 수행합니다. 우리는 이 메서드 안에서 우리가 필요한 동작들(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
JsonComponentModule
은 JacksonAutoConfiguration
에서 자동 설정을 통해 주입되고 있으며 @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
는 다음과 같이 주입된 customizers
를 customize
하는 동작을 내포하고 있습니다. Jackson2ObjectMapperBuilder
를 기본적인 동작을 해치지 않고 재정의 하기 위해서는 위의 코드와 동일하게 기본적으로 제공하는 customizers
를 customize()
하는 작업이 필요합니다. 예시 코드는 다음과 같습니다.
@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 주입
사실 위와 같이 설정하는건 Jackson2ObjectMapperBuilder
에 Customizer
를 어떻게 주입 시켜야 하는지 알고 있어야 채택할 수 있는 방법이죠. 더불어 위의 로직은 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를 추가하는 방법은 매우 다양합니다. 기본 제공 Module
인 JsonComponentModule
, 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()
과정을 거쳐야 한다는 단점이 존재합니다. Customizers
를 customize()
하는 로직은 사실 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 |