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 |
- 1. 기본생성자가 없는 객체에 대한 역직렬화 차이
- propertiesBased를 세팅하는 코드 분석
- ParameterNamesAnnotationIntrospector를 세팅하는 코드 분석
- 정리
- 2. Jackson2ObjectMapperBuilder -> 기본 configure 세팅
- 2-1. 모듈 세팅
- 2-2. ProbleDetail 직렬화/역직렬화 정책 세팅
- 2-3. 기본 피처 세팅
- 3. JacksonAutoConfiguration의 ObjectMapper를 그대로 사용한다면?
- 3-1. 기본 직렬화 피처 세팅
- 3-2. 모듈 세팅
- 3-3. SpringHandlerInstantiator 세팅
- 정리