SpringBoot ObjectMapper customize 하는 방법
개요
우리는 객체를 직렬화, 역직렬화를 수행할 때 ObjectMapper를 활용하곤 합니다.
하지만 저는 ObjectMapper를 활용하면서 다음과 같이 예상하지 못했던 에러들을 만난 적이 있습니다.
- Spring Bean으로 신규 ObjectMapper를 등록했고, 기존의 ObjectMapper가 오버라이드되어 수정하지 않은 api에서 역직렬화 시 예외 발생
- Java를 사용할 때는 @AllArgumentConsturtor 어노테이션을 추가함에 따라 기본생성자가 사라져 역직렬화 시 예외 발생
첫 번째의 경우에는 내가 수정한 범위가 아닌 곳에서 에러가 발생하여 파악하기가 힘들었습니다.
두 번째의 경우에는 역직렬화의 경우 보통 외부 api 호출을 통해 일어나기 때문에 인지하기가 어려웠습니다.
이번기회에 Jackson의 ObjectMapper에 대해서 알아보고 어떻게 customize 하면 좋을지 알아보고자 합니다.
직렬화(Serialization)와 역직렬화(Deserialization)이란?
ObjectMapper에 대해 알아보기 전에 사전지식으로 직렬화와 역직렬화에 대해 알아야 합니다.
위키백과에 따르면 직렬화란 컴퓨터 과학에서 파일, 디스크에 저장하거나 네트워크로 전송하기 위해서 객체의 데이터 포맷을 적합한 형태로 변환하여 메모리에 표현하는 과정입니다.
반대로 역직렬화란 디스크에 저장한 데이터를 읽거나, 네트워크 통신으로 받은 데이터를 메모리에 쓸 수 있도록 변환합니다.
유사 용어로 직렬화는 마샬링(Marshalling) 역직렬화는 언마샬링(UnMarshalling)이라고도 부릅니다.
간단하게 말하자면 객체(Object) 데이터를 통신하기 쉬운 포맷(Byte, CSV, Json) 형태로 만들어주는 작업을 직렬화,
역으로 (Byte, CSV, Json) 형태에서 객체(Object)로 변화하는 작업을 역직렬화라고 볼 수 있습니다.
직렬화는 왜 필요할까?
보통 언어를 활용하면서 객체를 선언하고 활용합니다.
객체와 같은 참조 형식 변수를 선언하면 힙에 메모리가 할당되고, 스택에서는 이 힙 메모리를 참조하는 구조로 되어 있습니다.
보통 객체 A를 만들고 해당 주소의 값이 0x123456이라면 프로그램이 종료되고 다시 해당 값을 가져오더라도 기존에 할당되었던 메모리 0x123456는 해제되고 없습니다.
혹은 외부 네트워크에 0x123456라는 주소값을 보냈는데 전송받은 기기의 전달받은 메모리 주소에 내가 전송하려고 했던 데이터가 존재할 수 없습니다. (존재한다고 하더라도 다른 값이 들어있을 확률이 매우 큽니다.)
따라서 객체의 주소를 저장하는 것이 아닌 실제 객체가 가지는 값(name = John, age = 0)에 대해 컴퓨터가 이해할 수 있는 Binary 형태로 변환해 주어야 유의미한 데이터가 만들어지기 때문에 직렬화가 필요합니다.
Json 직렬화
위의 설명 및 예제는 Binary, ByteStream으로 직렬화에 대한 이야기를 하였습니다.
하지만 우리는 주로 웹 애플리케이션에서 인간이 읽고 쓰기에 용이한 JSON(Javascript Object Notation)을 활용하여 데이터를 교환합니다.
특정 언어에 종속되지 않으며, 대부분의 프로그래밍 언어에서 JSON 포맷의 데이터를 핸들링할 수 있는 라이브러리를 제공합니다.
JSON의 예시
{
"name": "김준우",
"age": 27,
"email": "bababrll@naver.com"
}
다음과 같은 사실을 직관적으로 파악할 수 있습니다.
- 이름(name)은 김준우
- 나이(age)는 27
- 이메일(email)은 bababrll@naver.com
JSON 직렬화란?
데이터 구조를 JSON 형식으로 직렬화하게 되면 네트워크 통신 시 전송하거나 파일에 저장할 때 사용됩니다.
예를 들어 위의 예제 JSON을 직렬화 하면 다음과 같이 문자열 형태로 변환됩니다.
"{\"name\":\"김준우\",\"age\":27,\"email\":\"bababrll@naver.com\"}"
Spring Framework과 JSON 직렬화 라이브러리
Spring Famework의 JSON 직렬화 라이브러리를 찾아보면 대표적으로 Jackson과 Gson이 존재합니다.
Spring Framework에서는 기본적으로 spring-boot-starter-json을 통하여 Jackson의 auto-configuration을 제공합니다.
spring-boot-start-web에도 포함되어 있어 일반적인 web applciation에는 라이브러리를 추가할 필요가 없습니다.
몰라도 될 것 같지만 한 가지 궁금즘이 듭니다.
“왜 Spring은 Jackson을 직렬화/역직렬화의 기본 전략으로 선택할 걸까요?”
왜 Spring은 Jackson을 직렬화/역직렬화의 기본 전략으로 선택했을까?
Gson, Jackson, Moshi 등을 비교해 보았을 때 Jackson with Annotations이 좋은 성능결과를 보여줍니다.
여러 가지 아티클들을 조사 후 Spring이 Gson대신 Jackson을 채택한 것에 대해 개인적으로 두 가지 결론을 내렸습니다.
- 벤치마크 결과 Jackson이 좋은 성능을 보여준다
- Jackson은 Json뿐만 아니라 XML도 지원한다. (더 범용적이다)
하지만 실제 Spring Issue에 질문을 통해 알아보았을 때는 다음과 같은 답변을 받았습니다.
https://github.com/spring-projects/spring-boot/issues/38562
Spring Boot predates Spring Framework's support for GSON so it wasn't an option to begin with. Since then, we have seen no reason to change the default.
해석해 보면 다음과 같습니다.
스프링 부트는 스프링 프레임워크가 GSON을 지원하기 이전부터 있었기 때문에 처음부터 선택할 수 있는 옵션이 아니었습니다. 그 이후로 기본값을 변경할 이유가 없었습니다.
즉, Spring Framwork가 GSON을 지원하기 이전인 Jackson만 지원할 때 Spring Boot가 출시되었기 때문에 처음부터 Jackson이 기본값이었고, 그 이후에 GSON을 지원하고 나서는 기본값인 Jackson을 변경할 필요가 없었습니다.
실제로 디버깅을 통해 Spring의 내부 클래스를 타고 들어가 보았습니다.
JacksonAutoConfiguration 클래스는 1.1부터 존재하였습니다.
반면에 GsonAutoConfiguration 클래스는 1.2부터 존재하였습니다.
Jackson의 ObjectMapper란?
이제 직렬화/역직렬화 Jackson에 대해 알아보았으니 ObjectMapper에 대해 알아볼 시간입니다.
ObjectMapper는 Jackson 라이브러리에서 제공되는 핵심 클래스 중 하나입니다.
실제로 직렬화/역직렬화를 수행하며 ObjectMapper에 여러 가지 사용자 정의를 설정할 수 있습니다.
실습을 통해 ObjectMapper에 대해 더 알아보겠습니다.
kotlin으로 MyInformation data class를 정의해 보겠습니다.
data class MyInformation(
val name: String = "김준우",
val age: Int = 27,
val email: String = "bababrll@naver.com",
)
Spring 환경에서 한번 실행해 보겠습니다.
@Component
@Order(1)
class JsonParseApplicationRunner(
private val objectMapper: ObjectMapper,
) : ApplicationRunner {
override fun run(args: ApplicationArguments) {
println("JsonParseApplicationRunner Start")
val payload = objectMapper.writeValueAsString(MyInformation())
println(payload)
val parse = objectMapper.readValue(payload, MyInformation::class.java)
println(parse)
}
}
//payload = {"name":"김준우","age":27,"email":"bababrll@naver.com"}
//parse = MyInformation(name=김준우, age=27, email=bababrll@naver.com)
writeValueAsString 메서드는 직렬화과정을 의미합니다.
readValue는 역직렬화과정을 의미합니다.
이를 통해 ObjectMapper는 Jackson 라이브러리의 핵심 클래스이며 역직렬화/ 직렬화를 담당한다는 것을 이해할 수 있습니다.
Spring Boot과 Jackson Auto-Configuration
Auto-Configuration이란 자동설정을 의미하며 Spring Boot에서 자동으로 적용하는 설정 클래스를 의미합니다.
@ConditionalOnClass가 보이며 해당 어노테이션은 특정 Class 파일이 존재하면 Bean을 등록한다는 것을 의미합니다.
ObjectMapper 클래스가 존재할 때만 빈이 등록되고, Javadocs를 읽어보면 이미 ObjectMapper가 존재하거나 Jackson2ObjectMapperBuilder가 존재하는 경우에는 Auto-Configuration이 동작하지 않습니다.
JacksonAutoConfiguration클래스를 살펴보면 내부에 JacksonObjectMapperConfiguration클래스가 존재합니다.
@ConditionalOnClass가 다시 보이며 @ConditionalOnMissingBean을 통해 ObjectMapper가 존재하지 않을 때만 jacksonObjectMapper 메서드가 호출됩니다.
실제로 ObjectMapper를 Bean으로 등록해 버리면 해당 메서드가 디버깅 시 호출되지 않습니다.
jacksonObjectMapper의 메서드 내부를 따라가 보면 Jackson2ObjectMapperBuilder 클래스의 build()가 호출될 때 configure() 메서드를 통해 실제 objectMapper에 대한 내부 기본 설정들이 추가됩니다.
디버깅을 통해 알아본 내부 기본 설정중 중요한 하나를 공유드리면 다음과 같습니다.
- FAIL_ON_UNKNOWN_PROPERTIES 옵션이 기본 설정이 false여서 역직렬화 시 객체에 없는 json filed가 존재해도 무시합니다. 만약 true라면 역직렬화 시 json field가 존재하지 않을 때 예외가 발생하게 됩니다.
ObjectMapper 등록 시 주의사항
@Configuration
class JacksonConfig{
@Bean
fun myJacksonConfig(): ObjectMapper{
return ObjectMapper()
}
}
실제로 ObjectMapper를 하나 Custom 하게 생성해 보았습니다.
이렇게 되면 Spring의 Auto-Configuration이 동작하지 않아 내가 만든 ObjectMapper가 Bean으로 등록되게 됩니다.
kotlin의 data class는 생성자에 필드를 선언하는 식으로 작성하며, 파라미터가 없는 기본 생성자는 존재하지 않습니다.
따라서 ObjectMapper를 적용하면 아래와 같은 에러 문구가 출력됩니다.
no Creators, like default constructor, exist): cannot deserialize from Object value (no delegate- or property-based Creator
해결책 1 - @JsonProperty 사용하기
data class MyInformation(
@JsonProperty("name")
val name: String,
@JsonProperty("age")
val age: Int,
@JsonProperty("email")
val email: String,
)
하지만 모든 필드에 @JsonProperty를 붙이는 건 불편할 수 있습니다.
해결책 2 - jackson-module-kotlin 의존성 추가
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
Java에서는 Lombok을 이용해서 @NoArgsConsturtor를 달아주면 해결되며, kotlin에서는 라이브러리를 이용하여 깔끔하게 해결할 수 있습니다.
해결책 3 - objectMapper를 직접 등록한 경우에는 kotlinModule() 추가
return ObjectMapper().registerModule(kotlinModule())
jackson-module-kotlin 의존성을 추가한 경우에도 수동으로 ObjectMapper를 등록해 주는 경우에는 kotlinModule을 추가해주어야 합니다.
이런 경우 기존에는 잘 동작하다가 신규 ObjectMapper 빈을 등록하고 kotlinModule을 등록해주지 않는다면 어마어마한 장애로 이어질 수 있습니다.
ObjectMapper에 기본생성자가 필요한 이유?
ObjectMapper에는 왜 기본생성자가 필요할 걸까요?
이에 대한 답변을 하기 위해서는 ObjectMapper의 동작원리에 대해서 알아야 합니다.
- ObjectMapper는 getter를 사용하여 직렬화한다.
- ObjectMapper는 내부적으로 reflection을 활용하는데 reflection은 기본 생성자가 필요하다.
ObjectMapper는 직렬화 시 getXXX 메서드로 필드 값을 읽어서 JSON으로 만들어내고, 역직렬화 시 reflection을 활용하여 메서드로 필드에 값을 주입합니다.
reflection을 간단하게 소개하면 구체적인 클래스 타입을 알지 못하더라도 그 클래스의 메서드, 타입, 변수에 접근할 수 있도록 하는 API입니다. 기본 생성자로 객체를 생성하고, 클래스의 필드 정보를 가져옵니다.
kotlin dataclass bytecode로 분석해 보기
data class MyInformation(
val name: String,
val age: Int,
val email: String,
)
kotlin의 val은 setter를 막고 getter만 생성합니다.
bytecode를 보면 getName이 생성되었으며 이에 따라 objectMapper가 동작합니다.
주의사항 1 - kotlin에서 private을 사용하면 getter가 생기지 않는다.
data class MyInformation(
private val name: String,
val age: Int,
val email: String,
)
//직렬화 결과
{"age":27,"email":"bababrll@naver.com"}
name을 private 하면 getter가 생기지 않기 때문에 직렬화를 수행할 때 name을 찾을 수 없습니다.
따라서 age, email만 직렬화됩니다.
주의사항 2 - getXXX가 존재하면 같이 직렬화된다
data class MyInformation(
val name: String,
val age: Int,
val email: String,
){
fun getMyEmail(): String{
return email
}
}
//직렬화 결과
{"name":"김준우","age":27,"email":"bababrll@naver.com","myEmail":"bababrll@naver.com"}
주의사항 3 - 많은 글들과 공식문서에서 setXXX도 직렬화된다고 하는데 kotlin 환경에서 직접 테스트시에는 직렬화되지 않았습니다.
data class MyInformation1(
val name: String,
val age: Int,
val email: String,
){
fun setMyTest(): String{
return email
}
}
//직렬화 결과
{"name":"김준우","age":27,"email":"bababrll@naver.com"}
myTest의 경우는 직렬화되지 않았습니다.
“나는 private를 같이 사용하면서 getter를 정의하기 싫어”라고 한다면 어떻게 해야 할까요?
방법 1 - ObjectMapper 설정을 통해 접근제어자가 private이라도 읽을 수 있습니다.
ObjectMapper()
.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY)
.registerModule(kotlinModule())
ObjectMapper 적절하게 커스텀해보기
다음과 같은 상황에 ObjectMapper를 커스텀해야 할 수 있습니다.
- 역직렬화 시 누락된 필드가 있으면 예외발생시키고 싶을 때
- LocalDateTime 직렬화 시 예외발생을 해결하고 싶을 때
- getter 없이 private 필드도 직렬화하고 싶을 때
LocalDateTime 직렬화 시 예외발생
objectMapper.registerModule(JavaTimeModule())
별도로 JavaTimeModule()을 등록해주지 않으면 LocalDateTime 직렬화 시 다음과 같은 예외가 발생할 수 있습니다.
ObjectMapper를 별도로 등록한 경우에만 발생하며 Spring Boot의 Auto-Configuration 내부 동작에서는 해당 모듈을 자동으로 등록해 줍니다.
registerWellKnownModulesIfAvailable 메서드를 살펴보면 특정 클래스들이 있는 경우에 모듈을 추가시켜 주며
JavaTimeModule 클래스가 존재하는 경우에는 JavaTimeModule 모듈을 추가합니다.
역직렬화 시 누락된 필드가 있으면 예외발생
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true)
Spring Boot에서 지원하는 ObjectMapper의 기본값은 false여서 누락된 필드가 있으면 무시합니다.
하지만 true를 주게 되면 UnrecognizedPropertyException 예외가 발생하게 됩니다.
방법 1 - ObjectMapper를 빈으로 등록한다.
@Configuration
class JacksonConfig {
@Bean
fun objectMapperConfig(): ObjectMapper {
return ObjectMapper()
.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, true)
.registerModule(kotlinModule(),JavaTimeModule())
}
}
기존 자동구성은 어떻게 되었는지 꼼꼼히 살펴서 누락되지 않도록 해야 합니다.
예를 들면 만약 kotlinModule()을 빠트리면 장애가 발생할 수 있습니다.
방법 2 - Application properties 설정을 통해 커스텀 (. yml,. properties)
spring.jackson.<category_name>.<feature_name>=true,false
이 방식에선 regiterModule 하는 방법은 찾지 못하였습니다.
방법 3 - Jackson2 ObjectMapperBuilderCustomizer 활용
@Bean
fun jsonCustomizer(): Jackson2ObjectMapperBuilderCustomizer {
return Jackson2ObjectMapperBuilderCustomizer { builder: Jackson2ObjectMapperBuilder ->
builder
.visibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY)
.featuresToEnable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) //누락된 필드를 무시하지 않고 예외를 발생시킴 기본값: false
.modules(JavaTimeModule(), kotlinModule())
}
}
Jackson2ObjectMapperBuilderCustomizer 클래스를 활용해 준다면 기본 자동 구성을 유지한 채로 추가 설정을 구성할 수 있습니다.
가장 안전한 이 방법을 가장 추천합니다!
만약 .featuresToEnable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) 이 부분을 누락하더라도 auto-configuration의 기본 설정을 유지해 줍니다. (물론 기본설정은 예외 없이 누락된 필드를 무시합니다)
만약 방법 1을 활용하는 경우 해당 코드에는 무엇이 빠져있는지 바로 알 수 있을까요?
val objectMapper = ObjectMapper()
.registerModule(JavaTimeModule())
.registerModule(kotlinModule())
.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) 부분이 누락되어 있었고 이 부분을 사용자가 의도하지 않았다면 바로 장애로 이어질 수 있습니다.
주의할 점으로는 module을 추가하는 경우에는 builder 패턴에서 아래 module으로 override 되어 버리기 때문에 주의해서 위와 같이 사용해야 합니다.
//[주의]::이렇게 사용하면 아래 module인 kotlinModule()만 적용됩니다
.modules(JavaTimeModule())
.modules(kotlinModule())
//[주의]::이렇게 사용해야 합니다.
.modules(JavaTimeModule(), kotlinModule())
javadocs의 설명을 보면 여러 번의 호출이 addtivie 되지 않으며 last one의 설정이 등록된다고 합니다.
하지만 이 방법을 활용할 때에도 기본 모듈들이 전부 무시되고 사용자가 설정으로 등록한 모듈들만 사라지게 돼서 위험합니다.
따라서 모듈을 등록할 때는 modules 메서드 대신 다른 방법을 사용하는 게 좋습니다.
대안 - Spring Bean으로 모듈을 등록해서 사용하자
@Configuration
class MyModuleConfiguration {
@Bean
fun myModule(): Module {
return MyModule()
}
}
class MyModule : SimpleModule() {}
이렇게 되면 MyModule Bean이 ApplicationContext에 등록되고 ObjectMapper에 모듈이 구성될 때 자동적으로 반영됩니다.
Spring Bean으로 등록해서 모듈을 등록하게 되면 기본적으로 지원하는 Module들 + 내가 등록한 Module이 등록되기 때문에 기본 모듈들이 사라지지 않아 보다 안전하게 사용할 수 있습니다.
테스트를 통한 안전장치 마련
직렬화/역직렬화를 잘 사용하기 위해 알아야 할 내부동작이 너무 많은 것처럼 느껴집니다.
data class Person(
private val name: String,
val age: Int,
val birthDay: LocalDateTime,
)
data class Person1(
val name: String,
val age: Int,
)
object PersonFixture{
fun get(): Person{
return Person(name = "김준우", age = 27, birthDay = LocalDateTime.of(1997,12,18,0,0,0))
}
}
@Test
fun `직렬화 역직렬화가 정상적으로 동작해야 한다`(){
val serializedPerson = objectMapper.writeValueAsString(PersonFixture.get())
Assertions.assertEquals(serializedPerson, """
{"name":"김준우","age":27,"birthDay":[1997,12,18,0,0]}
""".trimIndent())
val person = objectMapper.readValue(serializedPerson, Person1::class.java)
Assertions.assertEquals(person.age, 27)
Assertions.assertEquals(person.name, "김준우")
}
ObjectMapper도 테스트를 통해 검증하면 조금 더 안전한 코드가 될 수 있습니다.
하지만 요구사항이 변경되어 객체가 추가되거나 변경되면 메인 정책과는 관계없는 세부사항인 직렬화/역직렬화로 인해 테스트를 지속적으로 수정해줘야 하는 치명적인 단점이 있습니다.
개인적인 생각으로는 초기에 학습테스트용 정도로만 작성해 보면 좋을 것 같습니다.
개인적으로 내린 결론
- Spring Boot를 사용하면 Jackson auto-configuration으로 ObjectMapper 빈이 세팅된다.
- 이때 내부 동작을 잘 모르면 낭패를 볼 수 있다. (기본생성자, getter로 동작, objectMapper 빈을 등록하면 기존 설정 사라짐)
- ObjectMapper를 커스텀해야 한다면 기본 설정을 유지하는 Jackson2ObjectMapperBuilderCustomizer를 활용해 보자(팀과 협의하여 컨벤션으로 가져가도 좋을 것 같다)
- 만약 ObjectMapper에 모듈을 추가하고 싶다면 Spring Bean으로 모듈을 등록해서 사용하자
- 자신이 없다면 테스트를 통해 최소한의 안전장치를 가져보자