객체 트리를 문자열 또는 바이트 배열로 변환하려면 상호 얽혀 있는 두 가지 프로세스를 거쳐야 합니다.
첫 번째 단계에서는 객체는 직렬화(Serialization) 됩니다. 이때 객체는 객체를 구성하는 원시 값(primitive values)의 직렬 시퀀스로 변환됩니다. 이 프로세스는 모든 데이터 형식에 공통적으로 적용되며 그 결과는 직렬화되는 객체에 따라 달라집니다. Serializer가 이 프로세스를 제어합니다.
두 번째 단계는 인코딩(Encoding) 이라고 하며, 해당 기본값의 시퀀스를 출력 형식 표현으로 변환하는 단계입니다. Encoder가 이 프로세스를 제어합니다. 구분이 중요하지 않은 경우에는 인코딩(encoding)과 직렬화(serialization)라는 용어를 모두 같은 의미로 사용합니다.
데이터를 특정 형식으로 변환하는 전체 프로세스를 인코딩이라고 합니다. JSON의 경우 Json.encodeToString 확장 함수를 사용하여 데이터를 인코딩합니다. 내부적으로 매개변수로 전달된 객체를 직렬화하여 JSON 문자열로 인코딩합니다.
프로젝트를 설명하는 Project 클래스에서 JSON 문자열을 가져와 보겠습니다.
// https://github.com/Kotlin/kotlinx.serialization/blob/master/guide/example/example-basic-01.kt
package example.exampleBasic01
import kotlinx.serialization.*
import kotlinx.serialization.json.*
//sampleStart
class Project(val name: String, val language: String)
fun main() {
val data = Project("kotlinx.serialization", "Kotlin")
println(Json.encodeToString(data))
}
//sampleEnd
위의 코드를 실행하면 다음과 같은 Exception이 발생합니다.
Exception in thread "main" kotlinx.serialization.SerializationException: Serializer for class 'Project' is not found.Please ensure that class is marked as '@Serializable' and that the serialization compiler plugin is applied.
직렬화가 가능한 클래스는 명시적으로 표시해야 합니다. Kotlin 직렬화는 리플렉션을 사용하지 않기 때문에 명시적으로 직렬화할 수 있도록 선언한 클래스외에는 역직렬화할 수 없습니다. 위의 오류는 @Serializable 어노테이션을 추가하여 해결합니다.
// https://github.com/Kotlin/kotlinx.serialization/blob/master/guide/example/example-basic-02.kt
package example.exampleBasic02
import kotlinx.serialization.*
import kotlinx.serialization.json.*
//sampleStart
@Serializable
class Project(val name: String, val language: String)
fun main() {
val data = Project("kotlinx.serialization", "Kotlin")
println(Json.encodeToString(data))
}
//sampleEnd
@Serializable 어노테이션은 Kotlin 직렬화 플러그인이 해당 클래스의 serializer를 자동으로 생성하고 연결하도록 지시합니다. 이제 위의 코드를 실행하면 다음과 같이 JSON 문자열이 출력됩니다.
Serializer에 대한 자세한 설명은 Serializers 챕터를 참고하세요. 지금은 Kotlin 직렬화 플러그인이 serializer를 자동으로 생성한다는 정도만 알아도 충분합니다.
JSON 디코딩
인코딩의 반대 과정을 디코딩이라고 합니다. JSON 문자열을 객체로 디코딩하려면 Json.decodeFromString 확장 함수를 사용합니다. 디코딩의 결과로 얻어올 객체의 타입은 함수의 타입 파라미터로 지정합니다.
나중에 살펴보겠지만 직렬화는 다양한 클래스 형식에서 동작합니다. 지금은 Project 클래스를 data class로 정의했는데, 이는 필수가 아니라 객체의 내용을 출력해서 어떻게 디코딩되는지 확인하기 위해서입니다.
// https://github.com/Kotlin/kotlinx.serialization/blob/master/guide/example/example-basic-03.kt
package example.exampleBasic03
import kotlinx.serialization.*
import kotlinx.serialization.json.*
//sampleStart
@Serializable
data class Project(val name: String, val language: String)
fun main() {
val data = Json.decodeFromString<Project>("""
{"name":"kotlinx.serialization","language":"Kotlin"}
""")
println(data)
}
//sampleEnd
이 섹션에서는 @Serializable 클래스를 처리하는 다양한 방식에 대해서 자세히 설명합니다.
Backing fields가 직렬화됨
클래스에서 backing field1가 있는 프로퍼티만 직렬화되므로, 다음 예제에서 볼 수 있듯이 backing field가 없는 getter/setter와 위임된 프로퍼티가 있는 프로퍼티는 직렬화되지 않습니다.
// https://github.com/Kotlin/kotlinx.serialization/blob/master/guide/example/example-classes-01.kt
package example.exampleClasses01
import kotlinx.serialization.*
import kotlinx.serialization.json.*
//sampleStart
@Serializable
class Project(
// backing field가 있는 property -- 직렬화가능
var name: String
) {
var stars: Int = 0 // backing field가 있는 property -- 직렬화가능
val path: String // getter만 존재, backing field가 없음 -- 직렬화불가
get() = "kotlin/$name"
var id by ::name // 위임된 property -- 직렬화불가
}
fun main() {
val data = Project("kotlinx.serialization").apply { stars = 9000 }
println(Json.encodeToString(data))
}
//sampleEnd
JSON 출력은 name과 stars 속성만 존재합니다.
{"name":"kotlinx.serialization","stars":9000}
생성자 프로퍼티의 요구사항
다음과 같이 파라미터로 path 문자열을 받아 각각의 프로퍼티로 분해하는 Project 클래스를 만들 수 있습니다.
@Serializableclass Project(path: String) { val owner: String = path.substringBefore('/') val name: String = path.substringAfter('/')}
하지만, 위의 클래스는 컴파일 되지 않습니다. 왜냐하면 @Serializable 어노테이션은 클래스의 기본 생성자의 모든 파라미터를 프로퍼티로 요구하기 때문입니다. 이를 간단히 해결하려면 모든 프로퍼티를 받는 기본 private 생성자를 정의한 다음 원하는 생성자를 보조 생성자로 바꾸면 됩니다.
package example.exampleClasses02
import kotlinx.serialization.*
import kotlinx.serialization.json.*
//sampleStart
@Serializable
class Project private constructor(val owner: String, val name: String) {
constructor(path: String) : this(
owner = path.substringBefore('/'),
name = path.substringAfter('/')
)
val path: String
get() = "$owner/$name"
}
fun main() {
println(Json.encodeToString(Project("kotlin/kotlinx.serialization")))
}
//sampleEnd
이 경우 직렬화는 private 기본 생성자로 동작하며 마찬가지로 다음 결과처럼 backing field만 직렬화됩니다.
{"owner":"kotlin","name":"kotlinx.serialization"}
데이터 유효성 검사
클래스에 프로퍼티 선언 없이 생성자의 파라미터로 매개변수를 받는 경우 직렬화할 때의 유효성 검사 로직을 init { ... } 블록으로 옮겨야 합니다.
역직렬화 과정은 Kotlin의 일반 생성자처럼 작동하며 모든 init 블록을 호출하므로 이 경우 잘못된 값을 역직렬화하는 경우 클래스를 생성할 수 없습니다.
// https://github.com/Kotlin/kotlinx.serialization/blob/master/guide/example/example-classes-03.kt
package example.exampleClasses03
import kotlinx.serialization.*
import kotlinx.serialization.json.*
//sampleStart
@Serializable
class Project(val name: String) {
init {
require(name.isNotEmpty()) { "name cannot be empty" }
}
}
fun main() {
val data = Json.decodeFromString<Project>("""
{"name":""}
""")
println(data)
}
//sampleEnd
이 코드를 실행하면 다음과 같은 예외가 발생합니다.
Exception in thread "main" java.lang.IllegalArgumentException: name cannot be empty
선택적 프로퍼티
객체는 모든 프로퍼티가 입력으로 들어온 경우에만 역직렬화할 수 있습니다. 예를 들어 다음 코드를 실행해 보겠습니다.
// https://github.com/Kotlin/kotlinx.serialization/blob/master/guide/example/example-classes-04.kt
package example.exampleClasses04
import kotlinx.serialization.*
import kotlinx.serialization.json.*
//sampleStart
@Serializable
data class Project(val name: String, val language: String)
fun main() {
val data = Json.decodeFromString<Project>("""
{"name":"kotlinx.serialization"}
""")
println(data)
}
//sampleEnd
결과는 다음과 같이 예외가 발생합니다.
Exception in thread "main" kotlinx.serialization.MissingFieldException: Field 'language' is required for type with serial name 'example.exampleClasses04.Project', but it was missing at path: $
이 문제는 역직렬화시 자동으로 값이 추가될 수 있도록 프로퍼티에 기본값을 추가하여 해결할 수 있습니다.
// https://github.com/Kotlin/kotlinx.serialization/blob/master/guide/example/example-classes-05.kt
package example.exampleClasses05
import kotlinx.serialization.*
import kotlinx.serialization.json.*
//sampleStart
@Serializable
data class Project(val name: String, val language: String = "Kotlin")
fun main() {
val data = Json.decodeFromString<Project>("""
{"name":"kotlinx.serialization"}
""")
println(data)
}
//sampleEnd
기본값이 있는 프로퍼티는 @Required 어노테이션을 사용하여 역직렬화할 때 필수값으로 지정할 수 있습니다. 이전 예제에서 language 속성을 @Required로 표시하여 변경해 보겠습니다.
// https://github.com/Kotlin/kotlinx.serialization/blob/master/guide/example/example-classes-07.kt
package example.exampleClasses07
import kotlinx.serialization.*
import kotlinx.serialization.json.*
//sampleStart
@Serializable
data class Project(val name: String, @Required val language: String = "Kotlin")
fun main() {
val data = Json.decodeFromString<Project>("""
{"name":"kotlinx.serialization"}
""")
println(data)
}
//sampleEnd
결과는 다음과 같이 예외가 발생합니다.
Exception in thread "main" kotlinx.serialization.MissingFieldException: Field 'language' is required for type with serial name 'example.exampleClasses07.Project', but it was missing at path: $
// https://github.com/Kotlin/kotlinx.serialization/blob/master/guide/example/example-classes-08.kt
package example.exampleClasses08
import kotlinx.serialization.*
import kotlinx.serialization.json.*
//sampleStart
@Serializable
data class Project(val name: String, @Transient val language: String = "Kotlin")
fun main() {
val data = Json.decodeFromString<Project>("""
{"name":"kotlinx.serialization","language":"Kotlin"}
""")
println(data)
}
//sampleEnd
Input에 직렬화에서 제외되는 값을 명시적으로 지정하면 해당 값이 기본값과 같더라도 다음과 같이 예외가 발생합니다.
Exception in thread "main" kotlinx.serialization.json.internal.JsonDecodingException: Encountered an unknown key 'language' at offset 42 at path: $Use 'ignoreUnknownKeys = true' in 'Json {}' builder or '@JsonIgnoreUnknownKeys' annotation to ignore unknown keys.
기본값은 기본적으로 JSON으로 인코딩되지 않습니다. 이러한 동작 방식은 대부분의 실제 시나리오에서 시각적인 혼돈을 줄이고 직렬화되는 데이터의 양을 절약한다는 사실에 기반합니다.
// https://github.com/Kotlin/kotlinx.serialization/blob/master/guide/example/example-classes-09.kt
package example.exampleClasses09
import kotlinx.serialization.*
import kotlinx.serialization.json.*
//sampleStart
@Serializable
data class Project(val name: String, val language: String = "Kotlin")
fun main() {
val data = Project("kotlinx.serialization")
println(Json.encodeToString(data))
}
//sampleEnd
이 어노테이션은 값이나 형식 설정에 관계없이 항상 프레임워크가 프로퍼티를 직렬화하도록 지시합니다. EncodeDefault.Mode 파라미터를 사용하여 반대로 동작하게 할 수도 있습니다:
// https://github.com/Kotlin/kotlinx.serialization/blob/master/guide/example/example-classes-10.kt
package example.exampleClasses10
import kotlinx.serialization.*
import kotlinx.serialization.json.*
//sampleStart
@Serializable
@OptIn(ExperimentalSerializationApi::class) // EncodeDefault는 현재 실험적인 어노테이션입니다
data class Project(
val name: String,
@EncodeDefault val language: String = "Kotlin"
)
@Serializable
@OptIn(ExperimentalSerializationApi::class) // EncodeDefault는 현재 실험적인 어노테이션입니다
data class User(
val name: String,
@EncodeDefault(EncodeDefault.Mode.NEVER) val projects: List<Project> = emptyList()
)
fun main() {
val userA = User("Alice", listOf(Project("kotlinx.serialization")))
val userB = User("Bob")
println(Json.encodeToString(userA))
println(Json.encodeToString(userB))
}
//sampleEnd
결과는 다음과 같이 UserA의 Project는 기본값이 아니므로 json으로 저장되며 해당 프로젝트의 language 프로퍼티는 @EncodeDefault 어노테이션에 의해 직렬화됩니다. 그리고 UserB는 Project의 기본값을 사용하므로 @EncodeDefault(EncodeDefault.Mode.NEVER) 어노테이션에 따라 직렬화되지 않습니다.
Kotlin 직렬화는 Kotlin 언어의 유형 안전성이 강력히 적용됩니다. 특히 JSON 객체에서 null 값을 null을 넣을 수 없는 language 프로퍼티로 디코딩해 보겠습니다.
// https://github.com/Kotlin/kotlinx.serialization/blob/master/guide/example/example-classes-12.kt
package example.exampleClasses12
import kotlinx.serialization.*
import kotlinx.serialization.json.*
//sampleStart
@Serializable
data class Project(val name: String, val language: String = "Kotlin")
fun main() {
val data = Json.decodeFromString<Project>("""
{"name":"kotlinx.serialization","language":null}
""")
println(data)
}
//sampleEnd
language 프로퍼티에 기본값이 있음에도 불구하고 null을 하려고 하면 Exception이 발생합니다.
Exception in thread "main" kotlinx.serialization.json.internal.JsonDecodingException: Unexpected JSON token at offset 52: Expected string literal but 'null' literal was found at path: $.languageUse 'coerceInputValues = true' in 'Json {}' builder to coerce nulls if property has a default value.
순환 구조를 직렬화하려고 하면 스택오버플로가 발생합니다. 이 경우 Transient 프로퍼티를 사용하여 일부 참조 항목을 직렬화에서 제외할 수 있습니다.
제네릭 클래스
Kotlin의 제네릭 클래스는 유형 다형성(type-polymorphic) 동작을 제공하며, 이는 컴파일 타임에 Kotlin 직렬화에 의해 적용됩니다. 직렬화 가능한 제네릭 클래스 Box<T>를 예를 들어 보겠습니다.
Box<T> 클래스는 Int와 같은 빌트인 타입은 물론이며, Project와 같은 사용자가 정의한 타입과도 함께 사용할 수 있습니다.
// https://github.com/Kotlin/kotlinx.serialization/blob/master/guide/example/example-classes-15.kt
package example.exampleClasses15
import kotlinx.serialization.*
import kotlinx.serialization.json.*
//sampleStart
@Serializable
class Box<T>(val contents: T)
@Serializable
data class Project(val name: String, val language: String)
@Serializable
class Data(
val a: Box<Int>,
val b: Box<Project>
)
fun main() {
val data = Data(Box(42), Box(Project("kotlinx.serialization", "Kotlin")))
println(Json.encodeToString(data))
}
//sampleEnd
이 때 JSON으로 변환될 때 실제 타입은 Box에 지정된 실제 컴파일 타임 유형 매개 변수에 따라 달라집니다.
위의 예제들에서 인코딩된 JSON에서 프로퍼티의 이름은 기본적으로 소스 코드의 프로퍼티 이름과 동일합니다. 직렬화에 사용되는 이름을 serial 이름 이라고 하며, @SerialName 어노테이션을 사용하여 변경할 수 있습니다. 예를 들어, 소스에서 language 프로퍼티의 이름을 축약된 serial 이름으로 지정할 수 있습니다.
// https://github.com/Kotlin/kotlinx.serialization/blob/master/guide/example/example-classes-16.kt
package example.exampleClasses16
import kotlinx.serialization.*
import kotlinx.serialization.json.*
//sampleStart
@Serializable
class Project(val name: String, @SerialName("lang") val language: String)
fun main() {
val data = Project("kotlinx.serialization", "Kotlin")
println(Json.encodeToString(data))
}
//sampleEnd
결과를 보면 JSON 출력에 @SerialName으로 정의된 lang이 사용되는 것을 볼 수 있습니다.