Kotlin 1.4 Online Event: kotlinx.serialization 1.0 영상 정리
Kotlin 1.4 Online Event 영상 정리 시리즈
코틀린 1.4 출시와 함께 진행된 온라인 이벤트 중 kotlinx.serialization 1.0 세션에서 소개된 내용을 요약 정리해 보았습니다.
전체 세션 내용은 아래 영상에서 확인하실 수 있습니다.
이 세션에서 다루는 내용은 크게 다음과 같습니다.
- Introduction to kotlinx.serialization
kotlinx.serialization
라이브러리를 간단히 소개합니다.
- 1.0 라이브러리 살펴보기
kotlinx.serialization
라이브러리 1.0 버전을 살펴봅니다.
- Future plans
- 추후 출시될 기능을 간략히 소개합니다.
- Setup and/or migration
kotlinx.serialization
라이브러리를 프로젝트에 적용하는 방법을 소개합니다.
Introduction to kotlinx.serialization
코틀린에서 JSON 파서가 필요할 때, 다음과 같은 라이브러리를 사용할 수 있습니다.
당장 사용할 수 있는 괜찮은 라이브러리가 이미 있는데, 왜 코틀린을 위한 JSON 라이브러리가 추가로 필요할까요? 이유는 간단합니다. 이들은 모두 자바 라이브러리이기 때문입니다.
코틀린은 대개 자바와 호환되지만, 그렇지 않은 경우도 있습니다. 몇 가지 예를 통해 자세히 살펴보겠습니다.
멀티플랫폼 지원
앞에서 소개한 라이브러리는 자바 라이브러리이므로, 자바를 지원하는 플랫폼 (예: 안드로이드, Spring framework 등)에서만 사용할 수 있습니다.
반면에, kotlinx.serialization
라이브러리는 자바 (JVM), 자바스크립트, 네이티브 (Kotlin/Native)와 같이 다양한 플랫폼을 지원합니다. 따라서. 직렬화 (Serialization)를 담당하는 코드를 각 플랫폼별로 따로 작성할 필요 없이 공용 라이브러리에 구현할 수 있습니다.
코틀린 지향 (Kotlin-oriented)
다음과 같이 name
과 language
프로퍼티로 구성된 Project
클래스가 있다고 가정해 봅시다. language
프로퍼티의 기본값이 "Kotlin"
인 것에 주목해 주세요.
@Serializable
data class Project(
val name: String,
val language: String = "Kotlin"
)
이 때, 다음 코드와 같이 name
프로퍼티만 있고 language
프로퍼티는 없는 JSON 문자열을 파싱하면 어떤 일이 일어날까요?
const val inputString =
"""{"name":"kotlinx.serialization"}"""
이 문자열을 Gson 라이브러리로 파싱하면, name
에는 정상적으로 값이 대입되지만 language
에는 null
이 대입됩니다. 즉, null
이 아닌 프로퍼티가 null
값을 가지게 될 뿐 아니라, language
프로퍼티의 기본값 조차 반영되지 않는 문제가 발생합니다.
// Project(name=kotlinx.serialization, language=null) 이 출력됩니다.
println(Gson().fromJson(inputString, Project::class.java))
반면에, kotlinx.serialization
라이브러리를 사용하면 inputString
내 JSON 문자열이 language
프로퍼티를 포함하고 있지 않음을 확인하고, null
대신 기본값을 대입합니다. 따라서 개발자가 의도한 대로 파싱이 수행되는 것을 확인할 수 있습니다.
// Project(name=kotlinx.serialization, language=Kotlin)
println(Json.decodeFromString<Project>(inputString))
컴파일 안전 보장 (compile-time safe)
다른 라이브러리와 달리, kotlinx.serialization
라이브러리는 @Serializable
어노테이션이 있는 클래스만 직렬화(serialization)를 지원합니다.
다음과 같이 직렬화를 지원하는 클래스인 Project
클래스와 그렇지 않은 클래스인 User
클래스가 있다고 가정해 봅시다.
@Serializable
data class Project(
val name: String,
val language: String = "Kotlin"
)
// @Serializable 어노테이션이 없으므로 직렬화를 지원하지 않습니다.
data class User(val userName: String)
이 떄, 직렬화를 지원하지 않는 클래스인 User
클래스를 직렬화를 지원하는 Project
클래스의 프로퍼티로 넣으면 다음과 같이 컴파일 에러가 발생합니다.
@Serializable
data class Project(
val name: String,
val owner: User, // 컴파일 에러: Serializer for type User has not been found
val language: String = "Kotlin"
)
// @Serializable 어노테이션이 없으므로 직렬화를 지원하지 않습니다.
data class User(val userName: String)
즉, 직렬화가 제대로 수행될 수 않는 경우 런타임 에러 대신 컴파일 에러가 발생하므로 버그를 미연에 방지할 수 있습니다.
명료함 (explicit) 및 간결함 (concise)
명료함과 간결함은 코틀린 언어의 대표적인 특징입니다. kotlinx.serialization
라이브러리 또한 이러한 코틀린 언어의 철학에 부합하게끔 설계되어 있습니다.
다음은 Gson
라이브러리를 사용하여 List<Project>
데이터를 담고 있는 JSON 문자열을 파싱하는 코드를 보여줍니다.
val projectList = Gson().fromJson<List<Project>>(
inputStringList, List::class.java
)
이 때, 파싱된 결과 리스트의 타입을 확인해 보면 Project
가 아닌 LinkedTreeMap
인 것을 알 수 있습니다.
// class com.google.gson.internal.LinkedTreeMap
println(projectList.first()::class.java)
이는 JVM이 제네렉 인자의 타입 정보를 소거 (type erasure) 하기 때문이며, 이러한 이유로 GSON 라이브러리는 Project
라는 타입 대신 어떠한 형태의 데이터도 받아들일 수 있는 LinkedTreeMap
타입으로 객채를 생성합니다.
물론, 다음과 같이 TypeToken
를 넘겨주면 GSON 라이브러리를 사용하더라도 Project
타입으로 객체를 생성하도록 구성할 수 있습니다.
val projectList = Gson().fromJson<List<Project>>(
inputStringList,
(object: TypeToken<List<Project>>() {}).type // 타입 정보를 넘겨줍니다.
)
// class kotlinx.serialization.formats.json.Project
println(projectList.first()::class.java)
의도한 대로 동작하기는 하지만, 위 코드에서 볼 수 있듯이 코드가 꽤나 성가신(?!) 모습을 하고 있습니다.
게다가, 타입 토큰이 왜 들어가게 되었는지 배경을 잘 모른다면 이해가 어렵기에 여러모로 접근성이 좋지 않은 코드라 볼 수 있습니다.
반면에, kotlinx.serialization
라이브러리는 사용하면 GSON 라이브러리와 달리 직관적인 코드만으로 List<Project>
타입으로 정상적으로 파싱할 수 있습니다.
val projectList =
JSON.decodeFromString<List<Project>>(inputStringList)
// class kotlinx.serialization.formats.json.Project
println(projectList.first()::class.java)
이처럼 간단하게 파싱을 할 수 있는 이유는, 코틀린 1.4에 새로 추가된 typeOf()
함수 덕분입니다.
함수 원형은 다음과 같습니다.
public inline fun <reified T> typeOf(): KType
KType
은 타입 토큰과 거의 동일하게 동작하며, 따라서 제네릭 인자 정보를 획득 (capture)할 수 있습니다. 예를 들면, 다음과 같이 복잡한 제네릭 객체 정보도 잘 표현하는 것을 확인할 수 있습니다.
val type = typeOf<Box<List<StringData>>>()
// "kotlinx.serializabion.Box<
// kotlin.collections.List<
// kotlinx.serialization.StringData>>""
println(type)
함수 명명 규칙
kotlinx.serialization
라이브러리는 다양한 포맷을 지원합니다. 현재 지원하는 포맷은 다음과 같습니다.
포맷과 무관하게, 직렬화/역직렬화에 사용하는 함수 이름은 정해진 명명 규칙을 따르고 있습니다. 직렬화(serialization)를 수행하는 함수는 encodeToXXX
로, 역 직렬화(deserialization)를 수행하는 함수는 decodeFromXXX
형식을 따릅니다.
다음은 몇 가지 예를 보여줍니다.
encodeToByteArray
:ByteArray
형식으로 직렬화 수행 (코틀린 클래스 ->ByteArray
)decodeFromByteArray
:ByteArray
형식으로부터 역 직렬화 수행 (ByteArray
-> 코틀린 클래스)encodeToString
:String
형식으로 직렬화 수행 (코틀린 클래스 ->String
)decodeFromString
:String
형식으로부터 역 직렬화 수행 (String
-> 코틀린 클래스)
1.0 라이브러리 삺펴보기
API
다음과 같은 기본 기능들은 안정 버전 API로 제공됩니다.
- 클래스 <->
String
간 직렬화/역직렬화 @Serializable
어노테이션을 포함한 직렬화 관련 어노테이션- 다형성 (polymorphic) 직렬화
- JSON tree API (커스텀 직렬화 포함)
- 간단한 Custom serializer 지원
하지만, 다음과 같은 고급 기능들은 아직 실험 버전 API로만 지원됩니다.
- Custom 직렬화 포맷 (사용자가 원하는 형태로 데이터 인코딩)
- 스키마 확인 (Schema introspection)
- CBOR, Protobuf, HOCON, and Properties 포맷 지원
이 외에, @ExperimentalSerializationApi로 표시된 모든 API는 실험 버전 API로 추후 기능이 예고없이 변경될 수 있습니다.
새 기능 소개
1.0 버전에서 추가된 새로운 기능들은 프로젝트 저장소 내 README에서 자세히 확인할 수 있으며, 여기에서는 대표적인 기능 두 가지를 집중적으로 살펴봅니다.
조금 더 유연한 역 직렬화 지원 (More-flexible deserialization)
kotlinx.serialization
라이브러리는 코틀린 클래스에 정의된 타입을 우선시합니다. 따라서, JSON 문자열 내 포함된 객체가 역 직렬화 (deserialization) 대상 클래스 내 프로퍼티 정의에 부합하지 않으면 예외를 발생시킵니다.
다음과 같이 모든 프로퍼티가 널 값을 가질 수 없는 클래스인 Project
가 있다고 가정해 봅시다.
@Serializable
data class Project(
val name: String
val language: String = "Kotlin"
)
이 때, 다음과 같이 language
값이 null
인 JSON 문자열을 파싱하려 한다면 런타임 오류가 발생합니다.
// 오류: language 프로퍼티에 null 값을 대입할 수 없습니다.
println(Json.decodeFromString<Project>(
"""{"name:"Ktor","language":null}"""
))
이 때, coerceInputValues
플래그를 활성화 하면 런타임 오류를 발생시키는 대신 기본값인 "Kotlin"
문자열을 대입하게끔 설정할 수 있습니다.
// 값을 대입할 수 없을 때 기본값을 대입하도록 설정합니다.
val json = Json { coerceInputValues = true }
// Project(name=Ktor, language=Kotlin)
println(json.decodeFromString<Project>(
"""{"name:"Ktor","language":null}"""
))
다만, 모든 예외 상황을 커버하지는 않고, 현재는 다음 두 가지 상황에서만 기본값을 사용하게끔 처리해줍니다.
null
이 아닌 타입에null
이 대입되는 경우enum
타입에 알려지지 않은 값이 대입되는 경우
이와 관련된 조금 더 자세한 내용은 개발자 문서를 참고하세요.
역 직렬화시 다형성 지원 (Polymorphic deserialization)
REST API의 응답으로 내려오는 JSON 객체를 보면, 응답에 대한 공통 필드 (예: 응답 코드 및 메시지)와 함께 응답 유형별로 다른 필드를 추가로 제공하는 경우가 많습니다.
다음 두 예시를 보면 type
, name
, language
필드는 공통으로 가지고 있지만 마지막 필드만 다른 것을 확인할 수 있습니다.
{
"name" : "Kotlin",
"language" : "Kotlin",
"owner" : "JetBrains"
}
{
"name" : "Kotlin",
"language" : "Kotlin",
"stars" : 1
}
이러한 구조는 코틀린 코드에서 다음과 같이 구성할 수 있습니다. Project
클래스가 sealed
클래스로 선언된 것에 주목해 주세요.
@Serializable
sealed class Project {
abstract val name: String
abstract val language: String
}
@Serializable
class OwnedProject(
override val name: String,
override val language: String,
val owner: String,
): Project()
@Serializable
class StarredProject(
override val name: String,
override val language: String,
val stars: Int,
): Project()
베이스 클래스인 Project
클래스를 sealed
클래스로 선언해야 kotlinx.serialization
라이브러리에서 파생 클래스 (OwnedProject
, StarredProject
)에 대응하는 Serializer를 생성해 줍니다. (참조: 개발자 문서)
따라서, 다음과 같이 Project
타입을 인자로 넘겨주어도 데이터의 입력 형태에 따라 파생 타입으로 올바르게 객체를 만들어 줍니다.
Json.decodeFromString<Project>(
"""{"name":"Kotlin","language":"Kotlin","owner":"JetBrains"}"""
)
=> OwnedProject(name=Kotlin, language=Kotlin, owner=JetBrains)
Json.decodeFromString<Project>(
"""{"name":"kotlinx.serialization","language":"Kotlin","stars":2200}"""
)
=> StarredProject(name=kotlinx.serialization, language=Kotlin, stars=2200)
만약, 위에서 정의된 파생 클래스가 아닌 알려지지 않은 형태의 입력이 들어오는 경우, 이를 어떤 타입으로 변환해야 할지 모르기에 런타임 에러가 발생합니다.
Json.decodeFromString<Project>(
"""{"name":"Kotlin","language":"Kotlin","forks":4100}"""
)
=> Polymorphic serializer was not found for class discriminator 'Forked'
이 때, 런타임 에러를 발생시키는 필수 필드 (여기에서는 name
과 language
) 만으로 구성된 객체로라도 변환하길 원하는 경우, 필드로 구성된 클래스를 정의한 후 SerializerModule
을 설정해 주면 됩니다.
class BaseProject(
override val name: String,
override val language: String,
): Project()
val module = SerializersModule {
polymorphic(Project::class) {
default {
// 적합한 타입을 찾지 못한 경우 BaseProject 타입으로 변환합니다.
// BaseProject.serializer() 함수는 kotlinx.serialization 라이브러리에서 자동으로 생성해줍니ㅏㄷ.
BaseProject.serializer()
}
}
}
향후 계획 (Future plans)
java.io.*
스트림을 지원하는 API가 1.1 버전에서 추가될 예정입니다. (JVM 전용)kotlinx-io
라이브러리 (멀티플랫폼을 위한 I/O 라이브러리)와의 통합도 지원할 예정입니다.- 인라인 클래스 지원 또한 추가될 예정입니다.
라이브러리 설정 방법
kotlinx.serialization
라이브러리를 프로젝트에서 사용하려면 아래 두 가지 방법 중 하나를 따르면 됩니다.
plugins
블록 사용하기
프로젝트 레벨 빌드스크립트에 다음과 같이 플러그인을 선언합니다.
[Kotlin DSL]
plugins {
...
kotlin("plugin.serialization") version "1.4.10"
}
[Groovy DSL]
plugins {
...
id 'org.jetbrains.kotlin.plugin.serialization' version '1.4.10'
}
apply plugin
사용하기
프로젝트 루트 빌드스크립트에 serialization
플러그인을 classpath로 추가합니다.
[Kotlin DSL]
buildscript {
repositories { jcenter() }
dependencies {
...
classpath(kotlin("serialization", version = kotlinVersion))
}
}
[Groovy DSL]
buildscript {
ext.kotlin_version = '1.4.10'
repositories { jcenter() }
dependencies {
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
}
}
다음, 애플리케이션 레벨 빌드크스크립트에 플러그인을 적용해줍니다.
apply plugin: 'kotlinx-serialization'
마지막으로, 사용할 포맷에 맞는 라이브러리를 의존성에 추가합니다. 다음은 JSON용 라이브러리를 추가하는 예를 보여줍니다.
[Kotlin DSL]
repositories {
// Artifacts are also available on Maven Central
jcenter()
}
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.1")
}
[Groovy DSL]
repositories {
// Artifacts are also available on Maven Central
jcenter()
}
dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.0.1"
}