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 |