본문으로 바로가기

[Kotlinx serialization] 기본 사용법#2

category 개발이야기/Kotlin 2020. 11. 3. 14:28
반응형

이 글은 하기 링크를 기준으로 설명합니다.

https://github.com/Kotlin/kotlinx.serialization/blob/master/docs/basic-serialization.md

또한 예제에서 사용된 LogInfo(TAG) { "print log"}의 표현은 Log.d(TAG, "print log")와 동일한 표현입니다.

이 글은 여러개의 series로 구성되었습니다.

2020/10/22 - [개발이야기/Kotlin] - [Kotlinx serialization] Json 직렬화/역직렬화 - Fast apply #1

2020/11/17 - [개발이야기/Kotlin] - [Kotlinx serialization] Json 직렬화/역직렬화 - Builtin classes #3

2020/11/27 - [개발이야기/Kotlin] - [Kotlinx serialization] Json 직렬화/역직렬화 - Serializers #4

2020/12/04 - [개발이야기/Kotlin] - [Kotlinx serialization] Json 직렬화/역직렬화 -Polymorphism #5

2020/12/15 - [개발이야기/Kotlin] - [Kotlinx serialization] Json 직렬화/역직렬화 -JSON features #6


Basic

object tree를 string 또는 byte로 바꾸기 위해서 두단계의 과정이 필요합니다.

첫번째로 primitive value로 object를 serialize 하는 과정이며, 두번재로는 이렇게 serialize된 값을 표현하고자 하는 형태로 encoding하는 작업 입니다. 구분이 중요하지 않을 경우 이 두가지 용어는 서로 바꿔가며 사용될수 있습니다. 또한 반대의 작업을 decoding, deserialize라고 합니다.

Serialize / Deserialize

이전 글에서 이미 사용하는 방법에 대해 언급했지만 간단하게 여기서도 집고 넘어갑니다.

2020/10/22 - [개발이야기/Kotlin] - [Kotlinx serialization] Json 직렬화/역직렬화 - Fast apply #1

private fun makeDeptJson() {
    //object -> Json string
    val dept = Dept(1, "Marketing", "USA/Seattle")
    val deptJson = Json.encodeToString(dept)
    Log.d(TAG, deptJson)
    
    // Json string -> Object
    val deptFromJson = Json.decodeFromString<Dept>(deptJson)
    Log.d(TAG) { deptFromJson.toString() }
}

@Serializable
data class Dept(val no: Int, val name: String, val location: String)

 

Backing fileds are serialized

@Serializable annotation을 통하여 serialize할 객체를 간단하게 만들수 있습니다.

이때 class에서 serialize 대상이 되는 field는 backing fields를 갖는 properties 가 됩니다.

backing fields에 접근 할 수 없도록 구현되어 있거나, deleation된 property는 serialize 되지 않습니다.

/**
 * @param no name is a property with backing field -- serialized
 * @property name name is a property with backing field -- serialized
 * @property location name is a property with backing field -- serialized
 * @property floor name is a property with backing field -- serialized
 * @property buildingName Only getter, No backing field - Serialize NOT OK
 * @property additionalInfo name is a property with backing field -- serialized
 * @property id delegated property - Serialize NOT OK     *
 */
@Serializable
data class Dept2(val no: Int) { // Serialize OK
    var name: String? = null // Serialize OK
    var location: String? = null // Serialize OK
    var floor = 1 // Serialize OK

    val buildingName: String // Serialize NOT OK
        get() = "Nice building"

    var additionalInfo: String = "NA" // Serialize OK
        set(value) {
            field = value + "_wow!"
        }

    var id by ::floor // Delegated property - Serialize NOT OK
}  
private fun makeDept2Json() {       
    val dept2 = Dept2(1).apply {    
        name = "Sales"              
        location = "Busan/Korea"    
        additionalInfo = "add info" 
    }                               

    val customJson = Json { prettyPrint = true }
    val dept3Json = customJson.encodeToString(dept2)
    LogInfo(TAG) { dept2Json }
}                                   

주석에 언급한 대로 결과는 아래와 같습니다. 

(샘플 코드에서는 이해를 돕기 위해 prettyPrint를 enable 시킨 JSON 객체를 사용합니다.)

{
    "no": 1,
    "name": "Sales",
    "location": "Busan/Korea",
    "additionalInfo": "add info_wow!"
}

Constructor properties requirement

class의 생성자로 받는 인자를 부모 클래스로 pass through 하거나 단순 가공용이라면 property로 가지고 있을 필요가 없습니다.

따라서 아래와 같은 형태의 class를 만들기도 합니다.

@Serializable
class Dept3(locationWithCounty: String) {
    val location = locationWithCounty.substringBefore('/')
    val country = locationWithCounty.substringAfter('/')
}

@Serializable annotation은 primary constructor로 지정되는 모든 값들을 property로만 취급해야 하기 때문에 위와 같은 class를 compile error가 발생합니다.

위 코드가 실제로 심플하긴 하지만 @Serializable 을 사용하기 위해서는 안타깝지만 아래와 같은 우회방법을 사용해야 합니다. 

@Serializable
data class Dept3(val location: String, val country: String) {   
    
    constructor(locationWithCounty: String): this(
        location = locationWithCounty.substringBefore('/'),
        country = locationWithCounty.substringAfter('/')
    )
}

아래와 같이 호출하면 정상적으로 출력되는걸 확인할 수 있습니다.

private fun makeDept3Json() {
    val dept3 = Dept3("Busan/Korea")

    val customJson = Json { prettyPrint = true }
    val dept3Json = customJson.encodeToString(dept3)
    LogInfo(TAG) { dept3Json }
}

{
    "location": "Busan",
    "country": "Korea"
}

Data validation

상황에 따라 property를 저장하기 전에 validation check를 해야하는 경우가 생깁니다.

primary constructor에는 항상 property가 들어가야 하기 때문에 check logic은 init{..}을 사용하여 수행합니다.

private fun makeDept4Json() {
    val customJson = Json { prettyPrint = true }
    val dept4Json = customJson.decodeFromString<Dept4>("""
        {"name":""}
    """.trimIndent())
    LogInfo(TAG) { dept4Json.toString() }
}

@Serializable
data class Dept4(val name: String)

먼저 위와 같이 name에 아무것도 없는 값을 parsing하면 모델에도 아무값이 들어가 있지 않습니다.

Dept4(name=)

이런경우를 막기위해 validation check logic을 아래와 같이 추가합니다.

@Serializable
data class Dept4(val name: String) {
    init {
        require(name.isNotEmpty()) { "name is mandatory field"}
    }
}
FATAL EXCEPTION: main
java.lang.RuntimeException:... java.lang.IllegalArgumentException: name is mandatory field
	...
Caused by: java.lang.IllegalArgumentException: name is mandatory field
	...

Json 객체를 파싱하여 해당 class를 만들면서 init{}에서 에러를 일으키므로 해당 결과로 객체가 생성되지 않습니다.

Optional properties 

Json을 Object로 변경하는 deserialization 작업은 반드시 class의 모든 property를 포함하고 있어야 합니다.

따라서 class의 property가 json에 누락되어 있을경우 exception이 발생됩니다.

@Serializable
data class Dept(val no: Int, val name: String, val location: String)

private fun makeDeptJson() {
    val deptJson = """
        {
            "no":1,
            "name":"John"
        }
    """.trimIndent()
    val deptFromJson = Json.decodeFromString<Dept>(deptJson)
    LogInfo(TAG) { deptFromJson.toString() }
}

예제에서 json string에는 location 정보가 없으나, Dept class에서는 넣도록 되어 있습니다.

따라서 이를 수행시키면 kotlinx.serialization.MissingFieldException 이 발생합니다.

따라서 이를 해결하기 위해서는 아래와 같이 kotlin의 default value를 사용합니다.

data class Dept(val no: Int, val name: String, val location: String = "Seoul/Korea")

Dept(no=1, name=John, location=Seoul/Korea)

이 경우는 property가

  • JsonString에는 없음
  • class에는 있음

즉 Json의 구성요소에 optional key가 포함된 경우입니다.

아래와 같이 이 반대의 경우는 아래 링크에서 나와 있듯이 Json 객체에 ignoreUnknownKeys 속성을 사용해야 합니다.

2020/10/22 - [개발이야기/Kotlin] - [Kotlinx serialization] Json 직렬화/역직렬화 - Fast apply #1

  • JsonString에는 있음
  • class에는 없음
val customJson = Json { ignoreUnknownKeys = true }

 위와같이 customJson 객체를 생성해서 사용합니다.

Optional property initializer call

위에서 언급했듯이 Optional field를 처리하기 위해서 default value를 사용합니다.

다만 Optional 값이 jsonString에 들어 있다면 성능상 이점을 위해 class에 선언된 default value를 호출조차 되지 않습니다.

따라서 혹시라도 default value에 어떤 필수적인 작업을 수행하도록 해 놓을경우 문제가 될수 있으므로 주의해야 합니다.

companion object {
    fun getDefaultLocation(): String {
        LogInfo(TAG) { "Seoul/Korea" }
        return "Seoul/Korea"
    }
}
 
@Serializable   
data class Dept(val no: Int, val name: String, val location: String = getDefaultLocation())

private fun makeDeptJson() {
    val customJson = Json { prettyPrint = true }
    val deptJson = """
        {
            "no":1,
            "name":"John"
            "location":"Seattle/USA"
        }
    """.trimIndent()
    val deptFromJson = customJson.decodeFromString<Dept>(deptJson)
    LogInfo(TAG) { deptFromJson.toString() }
}

결과

Dept(no=1, name=John, location=Seattle/USA)

위에서 정의한 getDefaultLacation() 함수 자체가 호출되지 않기 때문에 "Seoul/Korea" 문구는 아예 로그에 출력되지 않습니다.

Required properties

Optional과는 반대로 반드시 들어가야 하는 property일 경우 @Required annotation을 이용합니다.

만약 json에 해당 field가 포함되어있지 않은채로 parsing 된다면 kotlinx.serialization.MissingFieldException이 발생합니다.

@Serializable
data class Dept(val no: Int, val name: String, @Required val location: String = "Seoul/Korea")

private fun makeDeptJson() {
    val customJson = Json { prettyPrint = true }
    val deptJson = """
        {
            "no":1,
            "name":"John"                
        }
    """.trimIndent()
    val deptFromJson = customJson.decodeFromString<Dept>(deptJson)
    LogInfo(TAG) { deptFromJson.toString() }
}

예제에서 location 값을 필수 값으로 선정합니다.

이는 default value로 값을 넣어놓더라도 @Required 로 필수 설정을 해 놓았기 때문에 예제처럼 Json string에 location이 누락되어 있다면 Exception이 발생합니다.

Transient properties

class에 선언된 property중에 serialize에서 제외해야 할 경우 @Transient를 사용합니다.

(kotlin.jvm.Transient가 아닙니다. kotlinx.serialization.Transient를 import 해야 합니다.)

serialize에서 제외되므로 반드시 default value가 설정 되어야 합니다.

@Serializable
data class Dept(val no: Int, val name: String, @Transient val location: String = "Seoul/Korea")

private fun makeDeptJson() {
    val dept = Dept(1, "Marketing")
    val customJson = Json { prettyPrint = true }
    val deptJson = customJson.encodeToString(dept)
    LogInfo(TAG) {deptJson}


    val deptFromJson = customJson.decodeFromString<Dept>(deptJson)
    LogInfo(TAG) { deptFromJson.toString() }       
}

결과에서 보면 serialize 작업에서는 location field가 빠졌고, deserialize시에는 기본값이 세팅되어 들어가 있는걸 볼수 있습니다.

{
    "no": 1,
    "name": "Marketing"
}

Dept(no=1, name=Marketing, location=Seoul/Korea)

위 예제는 pair로 작업했기 때문에 문제없이 동작합니다. 만약 아래와 같이 예제를 작성한다면 exception이 발생합니다.

@Serializable
data class Dept(val no: Int, val name: String, @Transient val location: String = "Seoul/Korea")

private fun makeDeptJson() {
    val deptFromJson2 = customJson.decodeFromString<Dept>("""
        {
            "no": 1,
            "name": "Marketing",
            "location":"Seattle/USA"
        }
    """.trimIndent())
    LogInfo(TAG) { deptFromJson2.toString() }
}

@Transient로 선언된 property의 값이 json에도 선언되어 있습니다.

따라서 아래와 같은 에러가 발생합니다.

Shutting down VM
FATAL EXCEPTION: main
java.lang.RuntimeException:..: kotlinx.serialization.json.internal.JsonDecodingException: Unexpected JSON token at offset 68: Encountered an unknown key location.
Use ignoreUnknownKeys = true in Json {} builder to ignore unknown keys.
JSON input: {
    "no": 1,
    "name": "Marketing",
    "location":"Seattle/USA"
}

설명에서 나와 있듯이 이를 방지하려면 ignoreUnknownKeys 속성을 사용해야 합니다. (사용한다면 기본값이 들어가 있겠죠?) 

Default are not encoded

Default로 선언된 값은 encoding시 JSON에 포함되지 않습니다.

이는 실사용 시나리오에서 대부분 필요 없었기 때문이며, json 데이터 크기를 감소시키는 이점도 있기 때문입니다.

즉 default value으로 정해놓은 property는 encoding시에는 json에  들어가지 않으나, decoding시에는 기본값으로 포함되므로 문제가 되지 않습니다.

@Serializable
data class Dept(val no: Int, val name: String, val location: String = "Seoul/Korea")

private fun makeDeptJson() {
    val dept = Dept(1, "Marketing")

    //Kotlinx
    val deptJson = Json.encodeToString(dept)
    LogInfo(TAG) { deptJson }

    val deptFromJson = Json.decodeFromString<Dept>(deptJson)
    LogInfo(TAG) { deptFromJson.toString() }


    //Gson
    val gson = GsonBuilder().create()
    val gsonDeptJson = gson.toJson(dept)
    LogInfo(TAG) { gsonDeptJson }

    val gsonDeptFromJson = gson.fromJson(gsonDeptJson, Dept::class.java)
    LogInfo(TAG) { gsonDeptFromJson!!.toString() }
}

먼저 kotlinx.serialize로 encoding/decoding한 결과는 아래와 같습니다.

{"no":1,"name":"Marketing"}
Dept(no=1, name=Marketing, location=Seoul/Korea)

실제로 location의 default values는 json에 포함되지 않습니다만, decoding시에는 정상적으로 값이 저장되어 있음을 알 수 있습니다.

Gson으로 처리한 경우 정직하게(??) 모든 값을 json으로 만듭니다.

{"location":"Seoul/Korea","name":"Marketing","no":1}
Dept(no=1, name=Marketing, location=Seoul/Korea)

만약 아래 코드처럼 dept에 default값이 아닌 다른값이 설정되었다면 어떻게 될까요?

private fun makeDeptJson() {
    val dept = Dept(1, "Marketing", "Melbourne/Australia")

    //Kotlinx
    val deptJson = Json.encodeToString(dept)
    LogInfo(TAG) { deptJson }

    val deptFromJson = Json.decodeFromString<Dept>(deptJson)
    LogInfo(TAG) { deptFromJson.toString() }
}

{"no":1,"name":"Marketing","location":"Melbourne/Australia"}
Dept(no=1, name=Marketing, location=Melbourne/Australia)

위와같이 default value가 아니라면 당연히 location field가 추가 됩니다.

만약 특정 사정에 의해서 default value일지라도 반드시 포함해야하는 경우는 어떻게 해야 할까요?

이미 위에서 언급했듯이 @Required 를 사용하면 됩니다.

@Serializable
data class Dept(val no: Int, val name: String, @Required val location: String = "Seoul/Korea")

 

Nullable properties

property가 null인 경우 json으로 encoding시 아예 포함되지 않습니다.

@Serializable
data class Dept(val no: Int, val name: String, val location: String? = null)

private fun makeDeptJson() {
    val dept = Dept(1, "Marketing")
    val customJson = Json { prettyPrint = true }
    val deptJson = customJson.encodeToString(dept)
    LogInfo(TAG) {deptJson}

    val deptFromJson = customJson.decodeFromString<Dept>(deptJson)
    LogInfo(TAG) { deptFromJson.toString() }


}

결과

{
    "no": 1,
    "name": "Marketing"
}


Dept(no=1, name=Marketing, location=null)

이는 위에서 언급한 "Default are not encoded"의 영향으로 default value는 json에 포함되지 않습니다.

또한 동일하게 @Requried를 이용하면 null값을 넣어서 보낼수도 있습니다.

@Serializable
data class Dept(val no: Int, val name: String, @Required val location: String? = null)

{
    "no": 1,
    "name": "Marketing",
    "location": null
}

Type safety is enforced

Kotlin은 null type을 강력하게 체크합니다.

따라서 nullable하지 않은 property에 json 결과값이 null인 형태를 넣게 되면 exception이 발생하게 됩니다.

@Serializable
data class Dept(val no: Int, val name: String, val location: String="Seoul/Korea")

private fun makeDeptJson() {
    val deptFromJson2 = Json.decodeFromString<Dept>("""
        {
            "no": 1,
            "name": "Marketing",
            "location":null
        }
    """.trimIndent())
    LogInfo(TAG) { deptFromJson2.toString() }
}

location은 nonnull한 property로 default value가 있더라도 json에서 null을 할당한 경우 아래와 같이 exception이 발생합니다.

FATAL EXCEPTION: main

java.lang.RuntimeException: ...: kotlinx.serialization.json.internal.JsonDecodingException: Unexpected JSON token at offset 55: Expected string literal but null literal was found.
Use coerceInputValues = true in Json {}` builder to coerce nulls to default values.
JSON input: {
    "no": 1,
    "name": "Marketing",
    "location":null
}

Gson은 어떻게 동작할까요?

 private fun makeDeptJson() {
    //Gson
    val gson = GsonBuilder().create()
    val gsonDeptFromJson = gson.fromJson("""
         {
          "no": 1,
          "name": "Marketing",
          "location":null
      }
  """.trimIndent(), Dept::class.java)
    LogInfo(TAG) { gsonDeptFromJson.toString() }
}

당황스럽게도 Gson은 Exception 없이 아래와 같이 처리 됩니다.

Dept(no=1, name=Marketing, location=null)

Nullable하지 않은 property에 null을 할당하네요.

하지만 이렇게 될 경우 이 object를 사용하는 다른 부분에서 NPE가 발생하기 때문에 너무나 위험한 코드 입니다.

차라리 Kotlinx 처럼  해당 시점에 exception을 발생시키는게 디버깅하기에는 훨씬더 좋습니다.

이를 방지하기 위해 Json을 custom으로 생성뒤 coerce 옵션을 줄수 있습니다.

이 내용은 추후 다른 포스트에서 정리합니다.

Referenced objects

Serializable class들은 또다른 Serializable class를 참조할수 있습니다.

@Serializable
data class Dept(val no: Int, val name: String, val employee: Employee)

@Serializable
data class Employee(val no: Int, val name: String)

private fun makeDeptJson() {
    val employee = Employee(1, "Smith")
    val dept = Dept(1, "Marketing", employee)

    val customJson = Json { prettyPrint = true }
    val deptJson = customJson.encodeToString(dept)
    LogInfo(TAG) { deptJson }

    val deptFromJson = customJson.decodeFromString<Dept>(deptJson)
    LogInfo(TAG) { deptFromJson.toString() }
}

Employee와 Dept 두개의 serializable한 class를 생성합니다.

여기서 Dept class는 Employee를 param으로 받습니다.

위와 같이 코드를 작성하면 아래와 같은 결과를 얻습니다.

{
    "no": 1,
    "name": "Marketing",
    "employee": {
        "no": 1,
        "name": "Smith"
    }
}

Dept(no=1, name=Marketing, employee=Employee(no=1, name=Smith))

만약 포함된 param이 serializable한 class가 아니라며 @Transient 를 이용하여 encoding을 생략하도록 처리해야 합니다.

아니라면 해당 object를 serialize할 수 있는 custom serializer를 따로 제공해야 합니다.

이 내용은 추후 serializer 포스팅에서 따로 설명 합니다. (하지만 순서대로 차례로 포스팅한 글을 읽어야 이해가 쉽습니다.)

2020/11/27 - [개발이야기/Kotlin] - [Kotlinx serialization] Json 직렬화/역직렬화 - Serializers #4

No compression of repeated references

Kotlin serialization은 data 그대로를 json으로 표현합니다.

따라서 동일한 객체가 여러번 참조되면 해당 횟수만큼 반복하여 출력하며, 어떤한 한 임의의 표현으로 반복을 표현하도록 구조를 재구성하지는 않습니다.

설명은 어렵지만, 주어진 대로 data를 json으로 표현한다는 당연한 얘기 입니다.

@Serializable
data class Dept(val no: Int, val name: String, val employee1: Employee, val employee2: Employee)

@Serializable
data class Employee(val no: Int, val name: String)

private fun makeDeptJson() {
    val employee = Employee(1, "Smith")
    val dept = Dept(1, "Marketing", employee, employee)

    val customJson = Json { prettyPrint = true }
    val deptJson = customJson.encodeToString(dept)
    LogInfo(TAG) { deptJson }

    val deptFromJson = customJson.decodeFromString<Dept>(deptJson)
    LogInfo(TAG) { deptFromJson.toString() }
}

emplyoee 객체를 하나를 생성하여 동일한 객체를 두번 사용하도록 했습니다.

당연히 어떤 임의표현으로 표현을 압축하여 표기하지 않고, 주어진 데이터 그대로를 표기 합니다.

{
    "no": 1,
    "name": "Marketing",
    "employee1": {
        "no": 1,
        "name": "Smith"
    },
    "employee2": {
        "no": 1,
        "name": "Smith"
    }
}

Generic classes

Generic이 사용된 class도 serializable 하나 이때 Generic값은 compile time에 결정된 type이 사용됩니다.

만약 compile time에 실제 type이 정해지지 않는 형태라면 compile time에 에러가 발생됩니다.

@Serializable
data class Dept(val no: Int, val name: String)

@Serializable
data class Employee(val no: Int, val name: String)

@Serializable
class Company<T>(val content: T)

@Serializable
data class CompanyInfo(val no: Company<Int>,
                       val department: Company<Dept>,
                       val employee: Company<Employee>)

먼저 Simple하게 Dept, Emplyee class를 만듭니다.

Company class를 type을 Generic으로 받으며, CompanyInfo class는 실제 type이 지정된 Company class를 param으로 받습니다.

private fun makeDeptJson() {
    val employee = Employee(1, "Smith")
    val dept = Dept(1, "Marketing")
    val company = CompanyInfo(Company(1), Company(dept), Company(employee))

    val customJson = Json { prettyPrint = true }
    val companyJson = customJson.encodeToString(company)
    LogInfo(TAG) { companyJson }

    val companyFromJson = customJson.decodeFromString<CompanyInfo>(companyJson)
    LogInfo(TAG) { """no: ${companyFromJson.no.content}
        dept: ${companyFromJson.department.content}
        employee: ${companyFromJson.employee.content}
    """.trimMargin() }
}

Kotlin serialzation은 Generic의 지정된 type에 맞춰 해당 클래스 타입으로 json을 생성합니다.

결과

{
    "no": {
        "content": 1
    },
    "department": {
        "content": {
            "no": 1,
            "name": "Marketing"
        }
    },
    "employee": {
        "content": {
            "no": 1,
            "name": "Smith"
        }
    }
}

no: 1
dept: Dept(no=1, name=Marketing)
employee: Employee(no=1, name=Smith)

Serial field names

위 모든 예제에서는 class의 property의 이름이 json의 key value로 사용되었습니다.

하지만 필요에 따라 json에서 사용되는 key value와 class의 property를 다르게 사용하고 싶을수 도 있습니다

이때 @SerialName을 이용하여 json key와 property 이름을 다르게 해줄 수 있습니다.

@Serializable
data class Dept(val no: Int, val name: String, val employee: Employee)

@Serializable
data class Employee(@SerialName("employee_no") val no: Int, val name: String)

private fun makeDeptJson() {
    val employee = Employee(1, "Smith")
    val dept = Dept(1, "Marketing", employee)

    val customJson = Json { prettyPrint = true }
    val deptJson = customJson.encodeToString(dept)
    LogInfo(TAG) { deptJson }

    val companyFromJson = customJson.decodeFromString<Dept>(deptJson)
    LogInfo(TAG) { companyFromJson.toString() }
}

Emplyoee class의 property는 no 이지만 실제 json에서는 employee_no를 사용하도록 합니다.

{
    "no": 1,
    "name": "Marketing",
    "employee": {
        "employee_no": 1,
        "name": "Smith"
    }
}

Dept(no=1, name=Marketing, employee=Employee(no=1, name=Smith))

반응형