본문 바로가기
스프링부트

jackson for kotlin part2. 커스터마이징

by pius712 2024. 3. 10.

 

앞선 글에 이어서, 추가로 커스터마이징에 대해서 글을 쓰려고 한다. 

https://pius712.tistory.com/11

 

jackson for kotlin part1. 동작 원리

kotlin 을 사용하면, kotlin 용의 jackson library를 설치해야한다. implementation("com.fasterxml.jackson.module:jackson-module-kotlin") spring initializer 로 스프링 프로젝트를 구성하다보면, 자동으로 해당 라이브러리가

pius712.tistory.com

 

 

Spring 부트에, JSON 형식의 요청을 하거나, 응답을 할 때는 ObjectMapper 인스턴스를 사용하게 된다.

ObjectMapper 를 통해 marshalling, unmarshalling 을 할 때, 기존의 객체를 커스텀할 수 있는데, 이에 대해서 조금 알아보고자 한다.

1. StdSerializer, StdDeserializer

아래와 같은 enum class 가 있다고 가정하자.

이 enum 객체를 전달할 때, vendor 라는 키에, 값을 소문자 + 언더스코어 형식으로 일괄 변환해야할 수 있는데 이에 대해서 알아보고 자한다.

// enum 클래스
enum class Vendor(
) {
    TOSS_PAY,
    KAKAO_PAY,
    NAVER_PAY
}

// json 으로 나가야하는 포맷
{
	vendor: "toss_pay" 
}

우선 StdSerializer 를 상속을 하고, serialize 메서드를 오버라이드 해야한다.

class VendorSerializer : StdSerializer<Vendor>(Vendor::class.java) {
    override fun serialize(value: Vendor, 
				    gen: JsonGenerator, serializers: SerializerProvider) {
        gen.writeStartObject()
        gen.writeFieldName("vendor")
        gen.writeString(value.name.lowercase())
        gen.writeEndObject()
    }
}

JsonGenerator에 보면 여러가지 메서드들이 있다. 메서드 시그니처 자체가 직관적이어서 어렵지 않다.

  • writeStartXXX
  • writeFieldName
  • write{Type}
  • writeEndXXX

이렇게 작성을하고, serializer 를 등록을 해야한다.

1.1 Customizer 를 통한 등록

Spring 부트의 AutoConfiguration 과정에서, 이미 ObjectMapper 를 등록을 하게 된다.

따라서, 두가지 선택을 해야한다.

  • AutoConfiguration 을 통해 등록된 objectMapper 를 커스터마이징
  • custom configuration 을 통해, AutoConfiguration 하지 않기

여기서는 커스터마이징에 대해 알아보려고 한다.

1.1.1 사용법

아래와 같이, Jackson2ObjectMapperBuilderCustomizer 를 등록을 하는 것이다.

@Configuration
class SerializerConfig {
  @Bean
  fun vendorSerializer(): Jackson2ObjectMapperBuilderCustomizer {
      return Jackson2ObjectMapperBuilderCustomizer { builder ->
          builder.serializerByType(Vendor::class.java, VendorSerializer())
      }
  }
}

1.1.2 동작 원리

jacksonAutoConfiguration 내부에는 아래와 같은 코드가 있다.

Jackson2ObjectMapperBuilder 를 통해 ObjectMapper 를 생성하는데,

이 Builder 를 Bean으로 등록 시점에, 커스터이마이징을 할 수 있다.

위에서 등록한 customizer Bean 이 이 시점에 등록되는 것이다.

@Bean
@Scope("prototype")
@ConditionalOnMissingBean
Jackson2ObjectMapperBuilder jacksonObjectMapperBuilder(ApplicationContext applicationContext,
// 커스터마이저를 주입받음
		List<Jackson2ObjectMapperBuilderCustomizer> customizers) {
	Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder();
	builder.applicationContext(applicationContext);
	customize(builder, customizers);
	return builder;
}

1.2 AutoConfiguration 하지 않기.

이 방법의 경우 별로 추천하고 싶지는 않은데, autoconfiguration 을 통해서 objectMapper 가 등록될 때, 생각보다 많은 모듈들이 등록된다는 것이다.

예를들어, JavaTimeModule, 코틀린을 사용한다면 KotlinModule 과 같은 모듈들이 등록이 된다. 만약 CustomConfiguration 을 한다면, 이렇게 등록되는 모듈을 찾아서 등록해줘야하는데 언어의 버전을 올린다거나 프레임워크의 버전을 올리는 등의 이유로 autoconfiguration 내부에서 사용되는 모듈이 변경되면 이를 하나하나 찾아서 등록해줘야한다는 것이다.

라이브러리의 의존성을 일일이 관리한다는 것은 생각보다 쉽지 않고, 추후에 버전업이 어려워서 빠르게 레거시가 될 수 있으니 조심해야한다.

우선 이 방식을 사용하려면 여러가지 방법이 있다.

JacksonAutoConfiguration 파일에서 ConditionalOnMissingBean 어노테이션을 보면

크게 아래 두가지를 custom configuration 을 통해 대체할 수 있다.

  • ObjectMapper
  • Jackson2ObjectMapperBuilder

mapper builder 를 반환하는 방법

@Configuration
class SerializerConfig {
  @Bean
  fun mapperBuilder(): Jackson2ObjectMapperBuilder {
      return Jackson2ObjectMapperBuilder()
			      .serializerByType(Vendor::class.java, VendorSerializer())
  }
}

1.3 . @JsonComponent 를 통한 등록

조금 더 쉽게 등록하는 방법이 있다. 바로 @JsonComponent 어노테이션을 통해 등록하는 방법이다.

1.3.1 사용법

enum class Vendor(
) {
    TOSS_PAY,
    KAKAO_PAY,
    NAVER_PAY
}

@Configuration
class VendorSerializer : StdSerializer<Vendor>(Vendor::class.java) {
    override fun serialize(value: Vendor, 
				    gen: JsonGenerator, serializers: SerializerProvider) {
        gen.writeStartObject()
        gen.writeFieldName("vendor")
        gen.writeString(value.name.lowercase())
        gen.writeEndObject()
    }
}

1.3.2 동작원리

JacksonAutoConfiguration 에 보면, JsonComponentModule 이 등록된다.

이 모듈은 @JsonComponent 어노테이션을 추가한 빈들을 추가하는 모듈인데,

beanFactory 에서, 해당 어노테이션이 추가된 빈들을 가져와서 추가하게 된다.

클래스의 타입에 따라, Serializer, Deserializer 등을 별도로 등록하게 된다.

정리하자면

  1. JsonComponent 어노테이션이 달린 빈들을 조회한다.
  2. Type 별로 분류해서, serializer, deserializer 들을 등록해준다.

1.4 @JsonFormat

JsonFormat 어노테이션을 enum 에 추가하게 되면, shape 설정을 할 수 있다.

아래와 같이 Shape.OBJECT 를 설정을 할 수 있는데, 적용하게 되면 enum 객체의 key, value 형태로 json 을 생성할 수 있다.

 

@JsonFormat(shape = JsonFormat.Shape.OBJECT)

enum class PostStatus(
    val value: String
) {
    PUBLISHED("published"),
    DRAFT("draft"),
    DELETED("deleted")
}

 

@JsonFormat(shape = JsonFormat.Shape.OBJECT)
enum class PostStatus(
    val value: String
) {
    PUBLISHED("published"),
    DRAFT("draft"),
    DELETED("deleted")
}
class PostStatusTest {
  @Test
  fun `PostStatus 테스트`() {

      val writeValueAsString = jacksonObjectMapper()
						      .writeValueAsString(PostStatus.PUBLISHED)
      Assertions.assertThat(writeValueAsString)
	      .isEqualTo("""{"value":"published"}""")
  }
}

그리고 다른 옵션 중에, Shape.STRING 옵션이 있는데 이는 default 값으로 enum 의 이름이 json 으로 변경된다.

 

 

@JsonFormat(shape = JsonFormat.Shape.STRING)

@JsonFormat(shape = JsonFormat.Shape.STRING)
enum class PostStatus(
    val value: String
) {
    PUBLISHED("published"),
    DRAFT("draft"),
    DELETED("deleted")
}
class PostStatusTest {
    @Test
    fun `PostStatus 테스트`() {

        val writeValueAsString = jacksonObjectMapper()
		        .writeValueAsString(PostStatus.PUBLISHED)
        Assertions.assertThat(writeValueAsString)
		        .isEqualTo(""""PUBLISHED"""")
    }
}

1.5 @JsonProperty

enum 객체가, 어노테이션에 명시한 값으로 변경됨.

enum class PostStatus(
    val value: String
) {
    @JsonProperty("opened")
    PUBLISHED("published"),
}
class PostStatusTest {
    @Test
    fun `PostStatus 테스트`() {

        val writeValueAsString = jacksonObjectMapper()
			        .writeValueAsString(PostStatus.PUBLISHED)
        Assertions.assertThat(writeValueAsString)
			        .isEqualTo(""""opened"""")
    }
}

1.6 @JsonSerialize

앞서 만들었던, VendorSerializer 를 클래스에 직접 등록하는 방법이 있다.

이 경우에는 VendorSerializer 를 별도로 빈으로 등록하지 않아도 된다.

@JsonSerialize(using = VendorSerializer::class)
enum class Vendor(
) {
    TOSS_PAY,
    KAKAO_PAY,
    NAVER_PAY
}

2. 활용 예시

아래는 간단한 활용 예시인데, 그 외에도 많은 경우들이 있을 것 같다.

2.1 enum 표기법이 다른 경우

java 나 코틀린의 경우 일반적으로 enum 에 대한 컨벤션은 대문자 + 언더스코어(_) 로 작성을 하게 된다.

그런데, 일부 다른 언어 혹은 팀간의 http 통신에서 다른 방식을 사용하고자 할 수도 있다.

예를들어, 내부적으로 아래처럼 enum 을 사용하는데 camel case 로 통신을 하고자할 때, custom 하게 처리할 수 있다.

enum class {
 KAKAO_PAY,
 NAVER_PAY
}

2.2 date 표기법이 다른 경우

LocalDateTime 을 통해서 처리를 하는데, 통신에서는 yyyy-MM-dd 형태로 date 를 받고자 하는 경우가 있을 수도 있다. 이럴 때도 활용할 수 있다.

3. 개인적인 생각

jackson 의 기능들을 사용해보면, 생각보다 많은 기능들을 내장하고 있다.

하지만, 이 기능들이 실제로 얼마나 유용한가? 라는 질문을 했을때, 생각보다 그렇지 않다는 것이다.

왜 그렇게 생각하는지 아래에 적어보고자 한다.

3.1 계층간 강결합 문제

도메인에 정의된 모델은 순수해야한다.

즉, 도메인 모델은 presentation layer 가 json 으로 통신하는지, xml 로 통신하는지 알 필요가 없다.

뿐만 아니라, 이 데이터가 http client 를 통해 전송을 하던가, 어떤 database 에 읽히는지도 알 필요가 없다.

아래는 간단한 enum class 지만, 복잡한 도메인 모델이라고 가정을해보자.

만약 도메인 모델에 JsonFormat, JsonValue 와 같은 어노테이션이 붙어있다고 가정하자.

도메인 모델을 변경해야할 때, 자칫 잘못하면 DTO 가 변경될 수 있다.

또한 DTO 변경으로 인해서 도메인의 모델에 수정을 해야할 수 있다.

⇒ 도메인 모델이 다른 계층과 강 결합된다.

// 통신 규칙이 바뀔 때마다 
// @JsonFormat(shape = JsonFormat.Shape.STRING)
enum class PostStatus {
 PUBLISHED, PROGRESS
}

3.2 MSA 환경에서의 고민

사실 MSA 라서 생기는 문제라기 보다는, 3.1 에서 다루었던 강결합 문제로 인해 발생하는 것이다.

근데, client 가 하나라면 사실 크게 문제가 없을 수 있는데, MSA 환경에서는 하나의 서버는 많은 서버와 통신을 하게 된다.

그래서, 최초에는 카멜 케이스로만 전달해도 되는데 다른 팀과 협업을 할 때 여기는 다른 표기법으로 전달해달라고 한다. 기존의 맥락을 모르던 개발자가 이걸 수정하면, 문제가 발생할 수 있다.

이때, 여러가지 방식을 선택할 수 있다.

  1. 해당 팀에게 우리가 사용하는 표기법을 사용해달라고 한다.
  2. 혹은 기술적인 방식으로 어떻게든 풀어본다.

사실 두가지 모두 좋은 방식은 아닌거 같다.

1번의 경우 요구사항에 유연하지 못하다.

2번의 경우 암묵적이고, 이에 대한 맥락이 많이 쌓이면 서비스를 더 이해하기 어려워진다.

3.3 대안1: 모델을 분리하자

아래와 같이 모델을 분리하면 문제가 쉽게 해결된다.

표현 계층의 모델과 도메인 계층의 모델을 분리한다면, 결합으로 인한 문제가 줄어들 것이다.

// DTO
data class XXXResponseDto() {}

// 도메인 모델
class XXX (){}

// controller
fun getXXX(): ApiResponse<XXXResponseDto> {
	val xxx = xxxService.getXXX();
	return ApiResponse.ok(XXXResponseDto(xxx));
}

여기서 필요하다면, 데이터 교환 모델에 @JsonFormat 과 같은 어노테이션을 통해서 쉽게 변환을 할 수 있을 것이다.

3.4 대안: 설정을 분리, 세밀하게 적용

예를들어, feign client 를 사용하는 경우를 생각해보자. 통신과 관련된 configuration을 전역에서 사용하면, 예기치 못한 문제가 발생할 수 있다. 이럴 때는 특정 client 에 configuration 을 별도로 할 수가 있는데, 이를 통해서 configuration 을 분리하는 것이다.

ObjectMapper 를 통해 marshalling, unmarshalling 을 할 때, 기존의 객체를 커스텀할 수 있는데, 이에 대해서 조금 알아보고자 한다.