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()
}
이러면된다.
'프로그래밍 언어 노트 > JAVA | Kotlin' 카테고리의 다른 글
[Spring Boot] + Kotlin 에서 Route + Beans DSL 을 사용해보자 (0) | 2023.02.25 |
---|---|
Kotlin SQL DSL 을 구축해보자! 쓸 수 있는 방법을 전부 동원해봐서! (0) | 2023.02.18 |
Kotlin 의 Lambda 문법으로 DSL 을 구축해보자 (0) | 2022.11.18 |
[JVM] Typereference 와 Type Erasure (타입 소거) (0) | 2021.12.07 |
JVM GC와 Reference (0) | 2019.11.26 |