이 장은 Kotlin Serialization 가이드의 첫 번째 장입니다. 이 장에서는 Kotlin 직렬화의 기본 사용법 및 핵심 개념에 대해 설명합니다.

기본 사항

객체 트리를 문자열 또는 바이트 배열로 변환하려면 상호 얽혀 있는 두 가지 프로세스를 거쳐야 합니다.

첫 번째 단계에서는 객체는 직렬화(Serialization) 됩니다. 이때 객체는 객체를 구성하는 원시 값(primitive values)의 직렬 시퀀스로 변환됩니다. 이 프로세스는 모든 데이터 형식에 공통적으로 적용되며 그 결과는 직렬화되는 객체에 따라 달라집니다. Serializer가 이 프로세스를 제어합니다.

두 번째 단계는 인코딩(Encoding) 이라고 하며, 해당 기본값의 시퀀스를 출력 형식 표현으로 변환하는 단계입니다. Encoder가 이 프로세스를 제어합니다. 구분이 중요하지 않은 경우에는 인코딩(encoding)과 직렬화(serialization)라는 용어를 모두 같은 의미로 사용합니다.

+---------+  Serialization  +------------+  Encoding  +---------------+
| Objects | --------------> | Primitives | ---------> | Output format |
+---------+                 +------------+            +---------------+

역방향 프로세스는 입력 형식의 파싱과 원시 값의 디코딩(decoding) 으로 시작하여 결과 스트림을 객체로 역직렬화(deserialization) 하는 것으로 이어집니다. 이 프로세스에 대한 자세한 내용은 나중에 살펴보겠습니다.

먼저 JSON 인코딩으로 시작해보겠습니다.

JSON 인코딩

데이터를 특정 형식으로 변환하는 전체 프로세스를 인코딩이라고 합니다. JSON의 경우 Json.encodeToString 확장 함수를 사용하여 데이터를 인코딩합니다. 내부적으로 매개변수로 전달된 객체를 직렬화하여 JSON 문자열로 인코딩합니다.

프로젝트를 설명하는 Project 클래스에서 JSON 문자열을 가져와 보겠습니다.

위의 코드를 실행하면 다음과 같은 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 어노테이션을 추가하여 해결합니다.

@Serializable 어노테이션은 Kotlin 직렬화 플러그인이 해당 클래스의 serializer를 자동으로 생성하고 연결하도록 지시합니다. 이제 위의 코드를 실행하면 다음과 같이 JSON 문자열이 출력됩니다.

{"name":"kotlinx.serialization","language":"Kotlin"}

Info

Serializer에 대한 자세한 설명은 Serializers 챕터를 참고하세요. 지금은 Kotlin 직렬화 플러그인이 serializer를 자동으로 생성한다는 정도만 알아도 충분합니다.

JSON 디코딩

인코딩의 반대 과정을 디코딩이라고 합니다. JSON 문자열을 객체로 디코딩하려면 Json.decodeFromString 확장 함수를 사용합니다. 디코딩의 결과로 얻어올 객체의 타입은 함수의 타입 파라미터로 지정합니다.

나중에 살펴보겠지만 직렬화는 다양한 클래스 형식에서 동작합니다. 지금은 Project 클래스를 data class로 정의했는데, 이는 필수가 아니라 객체의 내용을 출력해서 어떻게 디코딩되는지 확인하기 위해서입니다.

위의 코드를 실행하면 다음과 같이 객체를 리턴합니다.

Project(name=kotlinx.serialization, language=Kotlin)

직렬화 가능한 클래스

이 섹션에서는 @Serializable 클래스를 처리하는 다양한 방식에 대해서 자세히 설명합니다.

Backing fields가 직렬화됨

클래스에서 backing field1가 있는 프로퍼티만 직렬화되므로, 다음 예제에서 볼 수 있듯이 backing field가 없는 getter/setter와 위임된 프로퍼티가 있는 프로퍼티는 직렬화되지 않습니다.

JSON 출력은 namestars 속성만 존재합니다.

{"name":"kotlinx.serialization","stars":9000}

생성자 프로퍼티의 요구사항

다음과 같이 파라미터로 path 문자열을 받아 각각의 프로퍼티로 분해하는 Project 클래스를 만들 수 있습니다.

@Serializable
class Project(path: String) {
    val owner: String = path.substringBefore('/')
    val name: String = path.substringAfter('/')
}

하지만, 위의 클래스는 컴파일 되지 않습니다. 왜냐하면 @Serializable 어노테이션은 클래스의 기본 생성자의 모든 파라미터를 프로퍼티로 요구하기 때문입니다. 이를 간단히 해결하려면 모든 프로퍼티를 받는 기본 private 생성자를 정의한 다음 원하는 생성자를 보조 생성자로 바꾸면 됩니다.

이 경우 직렬화는 private 기본 생성자로 동작하며 마찬가지로 다음 결과처럼 backing field만 직렬화됩니다.

{"owner":"kotlin","name":"kotlinx.serialization"}

데이터 유효성 검사

클래스에 프로퍼티 선언 없이 생성자의 파라미터로 매개변수를 받는 경우 직렬화할 때의 유효성 검사 로직을 init { ... } 블록으로 옮겨야 합니다.

역직렬화 과정은 Kotlin의 일반 생성자처럼 작동하며 모든 init 블록을 호출하므로 이 경우 잘못된 값을 역직렬화하는 경우 클래스를 생성할 수 없습니다.

이 코드를 실행하면 다음과 같은 예외가 발생합니다.

Exception in thread "main" java.lang.IllegalArgumentException: name cannot be empty

선택적 프로퍼티

객체는 모든 프로퍼티가 입력으로 들어온 경우에만 역직렬화할 수 있습니다. 예를 들어 다음 코드를 실행해 보겠습니다.

결과는 다음과 같이 예외가 발생합니다.

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: $

이 문제는 역직렬화시 자동으로 값이 추가될 수 있도록 프로퍼티에 기본값을 추가하여 해결할 수 있습니다.

위의 예제는 language 프로퍼티에 기본값을 포함하여 다음과 같이 출력됩니다.

Project(name=kotlinx.serialization, language=Kotlin)

선택적 프로퍼티의 초기화 함수 호출

Input으로 선택적 프로퍼티가 지정된 경우 해당 프로퍼티의 초기화 함수는 호출되지 않습니다. 이것은 성능을 위해 설계된 기능이므로 초기화 함수의 부작용에 의존하지 않도록 주의하세요. 아래 예시를 살펴보겠습니다.

language 프로퍼티가 input에 지정되었기 때문에 output에 “Computing” 이라는 문자열은 출력되지 않습니다.

Project(name=kotlinx.serialization, language=Kotlin)

필수 프로퍼티

기본값이 있는 프로퍼티는 @Required 어노테이션을 사용하여 역직렬화할 때 필수값으로 지정할 수 있습니다. 이전 예제에서 language 속성을 @Required로 표시하여 변경해 보겠습니다.

결과는 다음과 같이 예외가 발생합니다.

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: $

Transient 프로퍼티

프로퍼티에 @Transient 어노테이션을 추가하여 직렬화에서 제외할 수 있습니다(kotlin.jvm.Transient와 혼동하지 마세요). Transient 프로퍼티는 기본값이 있어야 합니다.

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.

Info

‘ignoreUnknownKeys’ 키 기능은 Ignoring Unknown Keys section에서 설명합니다.

기본값은 기본적으로 인코딩되지 않음

기본값은 기본적으로 JSON으로 인코딩되지 않습니다. 이러한 동작 방식은 대부분의 실제 시나리오에서 시각적인 혼돈을 줄이고 직렬화되는 데이터의 양을 절약한다는 사실에 기반합니다.

결과는 다음과 같이 기본값을 가지는 language 프로퍼티가 없이 출력됩니다.

{"name":"kotlinx.serialization"}

JSON에서 이 동작을 구성하는 방법은 JSON의 Encoding defaults 섹션을 참조하세요. 또한 이 동작은 EncodeDefault 어노테이션을 사용하면 포맷 설정을 변경하지 않고도 제어할 수 있습니다.

이 어노테이션은 값이나 형식 설정에 관계없이 항상 프레임워크가 프로퍼티를 직렬화하도록 지시합니다. EncodeDefault.Mode 파라미터를 사용하여 반대로 동작하게 할 수도 있습니다:

결과는 다음과 같이 UserA의 Project는 기본값이 아니므로 json으로 저장되며 해당 프로젝트의 language 프로퍼티는 @EncodeDefault 어노테이션에 의해 직렬화됩니다. 그리고 UserB는 Project의 기본값을 사용하므로 @EncodeDefault(EncodeDefault.Mode.NEVER) 어노테이션에 따라 직렬화되지 않습니다.

{"name":"Alice","projects":[{"name":"kotlinx.serialization","language":"Kotlin"}]}
{"name":"Bob"}

Nullable 프로퍼티

Nullable 프로퍼티는 Kotlin 직렬화에서 기본적으로 지원합니다.

기본값은 인코딩 되지 않기 때문에 이 예제에서 JSON에 null을 인코딩하지 않습니다.

{"name":"kotlinx.serialization"}

유형 안전성(Type safety)이 적용됨

Kotlin 직렬화는 Kotlin 언어의 유형 안전성이 강력히 적용됩니다. 특히 JSON 객체에서 null 값을 null을 넣을 수 없는 language 프로퍼티로 디코딩해 보겠습니다.

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: $.language
Use 'coerceInputValues = true' in 'Json {}' builder to coerce nulls if property has a default value.

Info

JSON을 디코딩할 때 기본값으로 null을 강제로 설정하는 기능은 Coercing input values에 설명되어 있습니다.

Object 참조

직렬화 가능한 클래스는 프로퍼티를 통해 다른 직렬화 가능한 클래스를 참조할 수 있습니다. 이때, 참조된 클래스도 @Serializable이 선언되어 있어야 합니다.

JSON으로 인코딩한 결과로 중첩된 JSON 객체가 생성됩니다.

{"name":"kotlinx.serialization","owner":{"name":"kotlin"}}

Info

직렬화가 불가능한 클래스를 참조하는 경우 해당 프로퍼티를 Transient 프로퍼티로 선언하거나, Serializers 챕터에서 설명하는 커스텀 serializer를 제공할 수 있습니다.

반복적인 참조를 압축하지 않음

Kotlin 직렬화는 plain 데이터의 인코딩/디코딩을 위해 설계되었습니다. 따라서, 반복적인 객체 참조가 있는 임의의 객체 그래프의 재구성은 지원하지 않습니다. 예를 들어, 동일한 owner 인스턴스를 두 번 참조하는 객체를 직렬화해 보겠습니다.

결과는 단순히 owner 값이 두 번 인코딩 됩니다.

{"name":"kotlinx.serialization","owner":{"name":"kotlin"},"maintainer":{"name":"kotlin"}}

Info

순환 구조를 직렬화하려고 하면 스택오버플로가 발생합니다. 이 경우 Transient 프로퍼티를 사용하여 일부 참조 항목을 직렬화에서 제외할 수 있습니다.

제네릭 클래스

Kotlin의 제네릭 클래스는 유형 다형성(type-polymorphic) 동작을 제공하며, 이는 컴파일 타임에 Kotlin 직렬화에 의해 적용됩니다. 직렬화 가능한 제네릭 클래스 Box<T>를 예를 들어 보겠습니다.

Box<T> 클래스는 Int와 같은 빌트인 타입은 물론이며, Project와 같은 사용자가 정의한 타입과도 함께 사용할 수 있습니다.

이 때 JSON으로 변환될 때 실제 타입은 Box에 지정된 실제 컴파일 타임 유형 매개 변수에 따라 달라집니다.

{"a":{"contents":42},"b":{"contents":{"name":"kotlinx.serialization","language":"Kotlin"}}}

제네릭 타입의 실제 타입이 컴파일 타임에 직렬화할 수 없는 경우 오류가 발생합니다.

Serial 필드명

위의 예제들에서 인코딩된 JSON에서 프로퍼티의 이름은 기본적으로 소스 코드의 프로퍼티 이름과 동일합니다. 직렬화에 사용되는 이름을 serial 이름 이라고 하며, @SerialName 어노테이션을 사용하여 변경할 수 있습니다. 예를 들어, 소스에서 language 프로퍼티의 이름을 축약된 serial 이름으로 지정할 수 있습니다.

결과를 보면 JSON 출력에 @SerialName으로 정의된 lang이 사용되는 것을 볼 수 있습니다.

{"name":"kotlinx.serialization","lang":"Kotlin"}

다음 장에서는 빌트인 클래스에 대해 설명합니다.

Footnotes

  1. https://kotlinlang.org/docs/properties.html#backing-fields