HTTP 200 OK

Memento mori & Carpe diem

Spring

JsonTypeInfo와 함께하는 다형성 구현

sjoongh 2024. 5. 11. 16:49

서론

요구사항에 따라 여러 API를 만들다 보면 자동적으로 request dto도 변화가 발생합니다.

 

API를 추가할 때 기존에 사용하던 dto와 별반 차이가 없다면 기존 것을 수정하는 방식을 통해 관리해 볼 수도 있겠죠

 

겹치는것이 많아진다면 공통 DTO를 만들어볼 수도 있겠습니다. 공통 DTO를 사용하기 애매할 경우에는 dto class를 늘리는 방법을 선택하고 있습니다.

 

하지만 비슷한 동작을 수행함에도 불구하고 약간의 차이가 존재한다는 이유만으로 새로운 API와 DTO를 추가하다보니 중복아닌 중복이 발생하는 느낌을 받았습니다. 앞으로 규모가 더욱 커진다면 유지보수가 복잡해지겠다는 느낌도 받았습니다.

 

앞선 문제를 해결할 수 있는 방법중 하나가 JsonTypeInfo 라고 생각합니다.

 

개요

JsonTypeInfo는 Jackson 라이브러리에서 제공하는 기능 중 하나로, 객체의 타입 정보를 JSON으로 표현할 때 사용됩니다. 이를 통해 JSON 데이터를 직렬/역직렬화할 때 객체의 실제 타입을 알 수 있게 됩니다.

 

직렬화를 통해 JSON 데이터를 자바 객체로 serialize 시킬수 있습니다. 하지만 이펙티브 자바의 저자 조슈아 블로크는 자바의 직렬화 기능을 사용하지 않을 것을 강력히 권장했습니다. 직렬화가 가능하다는 정도만 아셔도 될 것 같습니다.

 

사용목적

JsonTypeInfo를 사용하는 주된 목적은 다형성(polymorphism)을 지원하는 것입니다. 서로 다른 클래스의 객체를 동일한 부모 클래스로 다루는 경우에 유용하게 활용됩니다. 또한, API에서 클라이언트와 서버 간에 객체를 주고받을 때 객체의 실제 타입을 보존하고 관리할 수 있습니다.

 

@JsonTypeInfo

클래스의 타입 정보를 JSON으로 표현하는 방법을 지정하며 인터페이스나 추상 클래스에 적용할 수 있고 구현 클래스를 정의하기 위한 어노테이션입니다. 또한 서브 타이핑을 어떤 정보로 할 것 인지 정할 수 있습니다.

 

다음 예시를 살펴보겠습니다.

@JsonTypeInfo(
	use = JsonTypeInfo.Id.NAME,
	include = JsonTypeInfo.As.PROPERTY,
	property = "type",
	visible = true
	defaultImpl = exampleClass::class
)
  • use: 타입 정보를 표현하는 방식을 결정합니다. 클래스의 이름을 사용할지, 클래스의 속성을 사용할지 등을 설정할 수 있습니다.
  • include: JSON에 타입 정보를 어떻게 포함시킬지 결정합니다.
  • property: 타입 정보를 표현할 때 사용할 속성의 이름을 지정합니다. "type"을 key 값으로 지정 했으므로 JSON 데이터의 "type" 속성을 통해 객체의 타입을 식별할 수 있습니다.
  • visible : 해당 속성은 클래스의 타입 정보가 JSON 데이터에 표시됨을 나타냅니다. 즉 역직렬화된 JSON 데이터에 객체의 타입이 포함됩니다.
  • defaultImpl: 지정된 속성(property)에 대한 타입 정보가 없는 경우 사용할 기본 구현 클래스를 지정합니다. 여기서는 exampleClass::class로 지정되어 있으며, 기본적으로 exampleClass 클래스가 사용됩니다.

 

JsonTypeInfo.Id

클래스의 이름을 사용해 JSON 데이터에 대한 타입 정보를 표현하는 방식을 지정합니다.

 

  1. JsonTypeInfo.Id.NONE
    • 객체의 타입을 추론하지 않고, 단순히 JSON 데이터의 구조에 따라 객체를 역직렬화합니다.
    • 타입 정보를 사용하지 않아도 되는 경우에 유용하게 사용됩니다.
  2. JsonTypeInfo.Id.NAME
    • 타입 정보를 클래스 이름으로 표현합니다.
    • JSON 데이터에 타입 정보를 명시적으로 포함해야 합니다.
    • JSON 데이터에서 속성을 읽어 해당 클래스의 이름을 찾습니다.
  3. JsonTypeInfo.Id.CLASS
    • 타입 정보를 클래스 이름으로 표현합니다.
    • 사용되는 클래스의 패키지명을 포함한 클래스 전체 경로가 나타납니다.
    • JSON 데이터에 타입 정보를 명시적으로 포함해야 합니다.
    • JSON 데이터의 속성을 읽어 해당 클래스의 이름을 찾습니다.
  4. JsonTypeInfo.Id.DEDUCTION
    • Jackson은 JSON 데이터의 구조를 분석하여 타입 정보를 추론합니다. 즉, 명시적으로 타입 정보를 표현하지 않아도 됩니다. 대신 Jackson은 JSON 데이터의 구조와 클래스의 계층 구조를 기반으로 객체의 타입을 ‘추론’합니다.
    • 이 속성은 주로 JSON 데이터의 구조가 명확하고 일관적인 경우에 사용됩니다. JSON 데이터가 특정한 패턴을 따르고, 이를 통해 객체의 타입을 식별할 수 있는 경우에 유용하게 활용될 수 있습니다.

 

JsonTypeInfo.As

JSON 데이터에 타입 정보를 포함하는 방법을 지정합니다.

 

  1. JsonTypeInfo.As.PROPERTY
    • 타입 정보를 JSON 데이터의 속성으로 포함합니다.
    • 객체의 필드 중 속성값과 동일한 필드가 존재하는 경우에 해당 값을 통해 Type을 구분할 수 있습니다.
    • 예를 들어, { "type": "exampleClass" ... }와 같이 JSON 데이터에 타입 정보를 포함시킬 수 있습니다.
  2. JsonTypeInfo.As.WRAPPER_OBJECT
    • 타입 정보를 JSON 데이터의 객체로 래핑합니다.
    • 래핑된 객체에는 타입 정보를 표현하는 속성과 실제 데이터를 포함합니다.
    • 예를 들어, { "exampleClass": { // 객체의 실제 데이터 } }와 같이 JSON 데이터에 래핑된 객체로 타입 정보를 포함시킵니다.
  3. JsonTypeInfo.As.EXTERNAL_PROPERTY
    • 타입 정보를 외부 속성으로 분리하여 포함합니다.
    • 예를 들어, { "type": "exampleClass", "data": { // 객체의 실제 데이터 } }와 같이 객체의 타입 정보를 JSON 데이터와 별도의 속성으로 분리합니다.
  4. JsonTypeInfo.As.EXTSTING_PROPERTY
    • 객체가 이미 존재하는 속성을 사용해 타입 정보를 결정함을 나타냅니다.
    • 예를 들어 “type” 이라는 속성이 이미 객체에 있다면 이를 사용하여 타입을 결정합니다.

 

EXTSTING_PROPERTY과 PROPERTY의 차이

 

둘의 가장 큰 차이점은 사용되는 '속성이 이미 정의되어 있는지 여부' 이며 EXTSTING_PROPERTY는 들어오는 JSON데이터에 이미 해당 속성이 존재해야 한다는 점이고 PROPERTY는 역직렬화를 수행할때 해당 속성이 존재해야 한다는 점입니다.

 

즉 들어오는 JSON데이터에 해당 속성이 반드시 필요하다면 EXTSTING_PROPERTY를 사용하고 해당 속성이 없어도 괜찮거나 dafault 값으로 처리했다면 PROPERTY를 사용하시면 됩니다.

 

@JsonSubTypes

구현 클래스에 대한 정보를 정의하기 위한 어노테이션으로 실제 서브타이핑 타입을 등록하기 위한 어노테이션입니다.

 

하위 타입에 대한 정보를 포함하는 배열을 만들 수 있으며 이 때 value 속성에는 해당 하위 타입의 클래스를 지정하고 name 속성에는 이 하위 타입을 식별할 수 있는 이름을 지정합니다. 또한 names를 사용해 배열형식으로 만들수도 있습니다.

@JsonSubTypes(
    JsonSubTypes.Type(value = exampleClass::class, name = "example"),
    JsonSubTypes.Type(value = subTypeExampleClass::class, name = "subTypeExample")
)
  • 해당 서브타입들은 name에 지정된 이름으로 식별됩니다.
  • Json 데이터에 example이라는 값이 type정보로 포함되면 Jackson은 이를 exampleClass의 인스턴스로 역직렬화 합니다. JSON 데이터에 type이 없어도 문제가 발생하지 않는데 그 이유는 아래에서 설명드리겠습니다.

 

동작 과정

JSON 데이터가 다음과 같이 들어온다고 가정했을 때

{
	"type": "exampleClass",
	"col1": "value1",
	"col2": "value2"
}

 

다음과 같이 구성해놓을 수 있겠습니다.

@JsonTypeInfo(
    use = JsonTypeInfo.Id.NAME,
    include = JsonTypeInfo.As.PROPERTY,
    property = "type"
    visible = true
)
@JsonSubTypes({
    @JsonSubTypes.Type(value = exampleClass.class, name = "example"),
    @JsonSubTypes.Type(value = subTypeExampleClass.class, name = "subTypeExample")
})

interface CommonRequest {
	val type: String
}
data class ExampleClass(
        val col1: String,
        val col2: String,
        override val type: String,
): CommonRequest

data class SubTypeExampleClass(
        val col3: String,
        val col4: String,
        val col5: String,
        override val type: String,
): CommonRequest
  1. @JsonTypeInfo을 활용하여 직접 클래스의 type 프로퍼티를 보고 결정할 수 있도록 정보를 명시합니다.
  2. @JsonSubTypes를 활용하여 어떤 하위 타입들이 존재하고 type의 값이 name과 일치하는 경우에 value의 클래스로 직렬화를 수행할 수 있도록 명시합니다.
  3. commonRequest의 type이 example라면 exampleClass로 직렬화가 수행되고, type이 subTypeExample라면 subTypeExampleClass로 직렬화가 수행됩니다.

 

이쯤되니 드는 의문은 type 이라는 값을 필수로 받지 않는 방법은 없을까?? 라는 생각이었습니다. 다행스럽게도 Jackson 라이브러리에서는 이를 해결할 수 있는 방법을 가지고 있었습니다.

 

type 없이 어떻게 동작하는가?

 

타입이 없는 아래와 같은 JSON 데이터를 받으려면 어떻게 해야할까요?

{
	"col3": "value1",
	"col4": "value2",
	"col5": "value3"
}

 

아래와 같이 변경해보겠습니다.

interface CommonRequest {
	val col3: String,
	val col4: String
}
data class ExampleClass(
    override val col3: String,
    override val col4: String
): CommonRequest

data class SubTypeExampleClass(
    override val col3: String,
    override val col4: String,
    val col5: String
): CommonRequest

위의 예시처럼 ExampleClass에서 사용되는 변수가 col3 col4로 변경해보겠습니다.

 

결과적으로는 JSON 데이터가 들어오면 JsonTypeInfo는 SubTypeExampleClass 객체로 역직렬화를 진행합니다. 어떻게 이런 동작을 할 수 있을까요?

 

그 이유는 바로 JSON 데이터가 들어올때 type이 존재하지 않는다면 JSON 데이터의 필드 구조와 class의 필드 구조를 비교해 가장 적합한 class를 추론하여 찾기 때문입니다. 때문에 type이 있던 없던간에 동일한 동작을 수행하기에는 문제가 없습니다. 물론 메모리 사용과 역직렬화 속도 측면에서는 약간의 차이가 존재하지만 상황에 맞는 방법을 취하는것이 최선이라고 생각합니다.

 

하지만 optional로 처리된 값들 중 자식들끼리 필드가 겹친다면 값에 대한 명확한 판단을 내릴 수 없어 오류를 내뱉습니다. 때문에 이럴 경우 부모 클래스에서 처리해버리거나 공통타입으로 변수를 만드는 방법을 사용해 볼 수 있겠습니다.

 

JsonTypeInfo.Id.DEDUCTION에서 타입 정보를 명시하지 않아도 타입을 추론하는 과정도 위의 동작을 기반으로 타입 정보를 추론하는 과정을 거치기 때문입니다.

 

이때 JsonTypeInfo.Id.NONE속성은 @JsonSubTypes를 사용하지 않으므로 type을 추론하는 과정 자체가 없기 때문에 객체에 다형성을 적용할 수 없습니다. 그저 들어오는 JSON 데이터의 키들이 클래스의 필드와 일치하는지 판단하고 Jackson은 간단히 키와 값의 매핑을 통해 객체를 생성했습니다.

 

사용예제

그렇다면 위에서 구현한 다형성 객체를 어떻게 활용해볼 수 있을까요??

 

필자 같은 경우에는 이벤트를 전달, 알림전송, 히스토리 테이블 저장, 파편화된 비즈니스 로직 등 다양한 상황에서 API 를 여러개 생성하지 않고 하나의 API로 통합 처리하며 활용해볼 수 있을 것 같았습니다.

fun exampleFunction(common: CommonRequest) {
        when (common) {
            is ExampleClass -> {
                // 원하는 동작
            }
            is SubTypeExampleClass-> {
                // 원하는 동작
            }
            else -> { throw RuntimeException("error") }
        }
    }
  • 위의 예제 코드에서는 CommonRequest 인터페이스를 활용해 ExampleClass 와 SubTypeExampleClass클래스가 이를 상속받게 구성하였습니다.
  • 이처럼 객체의 타입을 알지 못해도 올바르게 객체를 생성하고 사용할 수 있도록 도와줄 수 있었습니다.
  • JSON을 통해 type이 들어온다면 type을 활용한 분기 처리를 구현해도 좋을 것 같습니다.

 

TIP

Jackson은 가장 먼저 등록된 클래스를 먼저 선택하므로 중복이 존재하고 필드 구조가 비슷한 클래스가 존재한다면 부모 클래스로 빼두어 상속을 받게하는 식으로 명확하게 구분지으면 되겠습니다.