본문 바로가기

프로그래밍 언어 노트/JAVA | Kotlin

[Jackson] Subtype 별 Polymorphic De/Serialization 을 제공하는 Deduction

Jackson 은 JVM 진영에서 주로 사용하는 JSON 역/직렬화 라이브러리로

JSON 은 이제 Web 에서 빼놓을수없는 포맷이므로.. 자주 사용하게 된다.

그러나 JSON 은 근본이 동적언어인 js 의 (사실상) 표준 포맷이므로, 이를 완전한 정적언어인 Java/Kotlin 등에서 사용할떄 충돌(불일치) 이 발생하게된다.

JS 는 같은 이름의 필드지만, 안에 내용물은 쓱싹쓱싹 바뀔수있고,
특히 본인의 경우 가장 신경쓰이는것이, 바로 이질 (Heterogeneous) 적인 Array 이다

{"myset":[ {"name":"Tanker","def":100}, "Dealer", 10]}

JS 같은 언어는 Array 에 아무타입이나 전부 들어갈 수 있기 때문에, 막 담아서 보내줄수있는데,

이걸 Jackson 에서 역직렬화를 하려면 머리가 깨지게된다,
기본적으로 타입이 미리 정해져있어야 하기 때문에,

  • 이질리스트에 들어갈수있는 Super set 으로 타입을 정의 (타입들의 Interface 나, 암것도 없다면 Object 로..)
  • 역직렬화 모듈 (Deserializer) 을 직접 작성해서 한땀한땀 역직렬화
  • 직렬화도 필요하다면, Serializer 도 작성하여 한땀한땀 직렬화

아주 귀찮은 작업이 아닐수없다;;
이게 아니면 걍 JsonNode, JsonTree 등으로 그냥 써면 뭐.. 돌아가긴할텐데, 프로덕트환경에서 이렇게 쓰는건 도메인 정의가 안되기 때문에 절대 안된다.

동적언어는 이러한 제약에서 비교적 자유로운데, 어차피 런타임이 중요한거라, 걍 쓰면된다.

fastapi 처럼 Type Hint 로 restristion 을 걸고 싶다고 할지라도, 걍 Type 을 Union (타 언어에서는 Either) 으로 묶으면 된다.

class Poll(BaseModel):
    quiz_id: int
    answer: Union[str,int]

 

그러나 정정언어에서는 이러한 A or B 타입 (즉 합타입) 을 표현할 수 있는 방법이 상속을 통한 방법밖에 없기때문에
공통되는 상위타입으로 타입을 받아야한다. (사용자 정의 클래스라면, 인터페이스나 실드클래스로 부모를, Int 같은 Primitive Type 이 포함되어있다면 Object)

똑같이 제네릭의 Either (Union) 를 쓰면 되는거 아니냐 할수있지만..

정적 언어에서의 Either 는 Left / Right 가 있는 실드클래스이므로, A or B 가 아닌, Left<A> or Right<B> 타입이다. 즉, Left<A> / Right<B> 타입을 A or B Json 으로 변경하는 직렬화 / A or B 를 받아서 Left<A> or Right<B> 로 변경하는 역직렬화가 필요하다.

실제로 arrow-kt 의 either 타입은 다음과 같다. 즉 Either 에 대한 처리는 별도로 Jackson 에 알려주어야한다.

  /*
  * Arrow contains `Either` instances for many useful typeclasses that allows you to use and transform right values.
  * Option does not require a type parameter with the following functions, but it is specifically used for Either.Left
  *
  */
  public sealed class Either<out A, out B>

  /**
   * The left side of the disjoint union, as opposed to the [Right] side.
   */
  public data class Left<out A> constructor(val value: A) : Either<A, Nothing>() 

  /**
   * The right side of the disjoint union, as opposed to the [Left] side.
   */
  public data class Right<out B> constructor(val value: B) : Either<Nothing, B>()

 

Jackson 서브타이핑

아무튼 정적언어를 사용하는 이상 이러한 Polymorphic 제공하기 까다롭기 때문에 Jackson 에서는 서브타입을 위한 어노테이션을 제공해준다.

@JsonTypeInfo    // 서브타이핑을 어떤 정보로 할것인지정하는 어노테이션
@JsonSubTypes   // 실제 서브타이핑 타입을 등록하기위한 어노테이션
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS)
@JsonSubTypes(
    Type(value = Character.Tanker::class), Type(Character.Dealer::class))
sealed class Character{
    abstract val name: String
    data class Tanker(override val name:String, val def: Int) :Character()
    data class Dealer(override val name:String, val atk: Double) :Character()
}

data class World(
    val characters : List<Character> = emptyList(),
    val name: String = ""
)

fun main(args: Array<String>) {

    val world = World(
        characters = listOf(
            Character.Tanker("Tanker",100),
            Character.Dealer("Dealer",100.0)
        ),
        name = "MyWorld"
    )

    //kotlin 으로 작성해서 모듈 추가함
    val objectMapper = JsonMapper.builder()
        .addModule(KotlinModule(strictNullChecks = true))
        .build()

    val serialized = objectMapper.writeValueAsString(world)
    println(serialized)  
    //{"characters":[{"@class":"Character$Tanker","name":"Tanker","def":100},{"@class":"Character$Dealer","name":"Dealer","atk":100.0}],"name":"MyWorld"}

    val deserialized : World = objectMapper.readValue(serialized, World::class.java)
    println(deserialized)
    //World(characters=[Tanker(name=Tanker, def=100), Dealer(name=Dealer, atk=100.0)], name=MyWorld)
}

 

[{"@class":"Character$Tanker","name":"Tanker","def":100},{"@class":"Character$Dealer","name":"Dealer","atk":100.0}]

직렬화로 생성된 필드를 보면 @class 라는것이 있는것을 확인할 수 있다.

@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) 을 사용해서 자동으로 클래스 필드가 생성된것이다.

보통  @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") 으로 클래스내 존재하는 property 로 서브타입을 하는데 그러면 아래처럼 Json 이 직렬화 된다.

[{"type":"Character$Tanker","name":"Tanker","def":100},{"type":"Character$Dealer","name":"Dealer","atk":100.0}]

 

그러나 이러한 방식의 문제는 Json에 추가로 타입정보가 명시된다는것이다.

Java <-> Java <-> Java 만 통신하는 우리끼리만 쓰는 클래스라면 상관이없지만..

이미 이질적으로 존재하는 JSON 을 받아서는 처리할 수 가없다.

만약 이미 아래와 같이 JSON 스키마가 정해져있다면?

[{"name":"Tanker","def":100},{"name":"Dealer","atk":100.0}]

type 정보가 없기때문에 Jackson 은 서브타이핑을 사용하지 못한다.

 

Deduction 서브타이핑

여기서 우리는 의문점이 하나 존재한다.

"아니 def 있으면 Tanker 고 atk 있으면 Dealder 인게 당연한거 아님?"

그렇다 실제 필드가 다른 서브타입이라면, 필드로 구분할 수가 있는데,

이게 Jackson 에 한동안 없다가..  2.12 버전부터 Deduction 기반의 Subtype 구분이 가능해졌다.

@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION)
@JsonSubTypes(
    Type(value = Character.Tanker::class), Type(Character.Dealer::class))
sealed class Character{
    abstract val name: String
    data class Tanker(override val name:String, val def: Int) :Character()
    data class Dealer(override val name:String, val atk: Double) :Character()
}

​사용은 간단하게 DEDUCTION 으로 쓰면된다.

아래와 같은 JSON 으로 역/직렬화가 가능해진다

[{"name":"Tanker","def":100},{"name":"Dealer","atk":100.0}]

얼마나 깔끔!
(참고로 직렬화는 서브타입 어노테이션이 없어도 원래 된다, Jackson 이 생성하는 Field 가 없고, 직렬화는 이미 타입구분이 이루어진 객체 상태이므로)

 

정적타입의 정확성

단, 필드들의 이름으로 구분되는 만큼 서브타입간에 필드들에 차이가 있어야 한다.

예를들어서 다음과 같은건 불가능하다.

@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION)
@JsonSubTypes(
    Type(Character.NPC::class), Type(Character.Tanker::class), Type(Character.Dealer::class))
sealed class Character{
    abstract val name: String
    data class NPC(override val name: String) :Character()
    data class Tanker(override val name:String, val def: Int) :Character()
    data class Dealer(override val name:String, val atk: Double) :Character()
}

data class World(
    val characters : List<Character> = emptyList(),
    val name: String = ""
)

fun main(args: Array<String>) {

    val world = World(
        characters = listOf(
            Character.Tanker("Tanker",100),
            Character.Dealer("Dealer",100.0),
            Character.NPC("NPC")
        ),

        name = "MyWorld"
    )

    val objectMapper = JsonMapper.builder()
        .addModule(KotlinModule(strictNullChecks = true))
        .build()

    val serialized = objectMapper.writeValueAsString(world)
    println(serialized)

    val deserialized : World = objectMapper.readValue(serialized, World::class.java)
    println(deserialized)
}

기존에 비해서 NPC 가 추가된것을 확인할 수 있다.

이런경우 

[{"name":"Tanker","def":100},{"name":"Dealer","atk":100.0},{"name":"NPC"}]

와 같이 Json 생성은 가능한데, 역직렬화는 되지 않는다.

이유는 간단한데.. {"name" : "NPC"} -> 이놈이 NPC("NPC") 인지, 아니면 Tanker("NPC", default값) 인지 알수 없기 때문...

아니 필드가 없으면 당연히 NPC 아니냐.. 할수도 있지만..

일단 Tanker 나 Dealer 같은 클래스에 Tanker("NPC", default값) 을 받은 생성자를
니가 깜빡하고 못짠건지, 안짠건지 모르기때문에 에러를 뿜는다. 물론 저런 생성자를 만들면 실제로 어느걸 호출할지 모르기때문에 또 안된다

DefaultImpl 값

JsonTypeInfo 에 defaultImpl 를 추가할수있다. 그럼 기본으로 정해진 Impl 로 구현해준다.

이게 정녕 올바른 방법인가.. 하면 아직 모르겠는데.. (이런 상황이 많아지면, 실제 도메인에 구현할 내용보다 서브타이핑이 굉장히 복잡해짐)

@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION, defaultImpl = Character.NPC::class)
@JsonSubTypes(
    Type(Character.NPC::class), Type(Character.Tanker::class), Type(Character.Dealer::class))
sealed class Character{
    abstract val name: String
    data class NPC(override val name: String) :Character()
    data class Tanker(override val name:String, val def: Int = 5) :Character()
    data class Dealer(override val name:String, val atk: Double) :Character()
}

이러면된다.

728x90