직렬화기(Serializer) 만들고 사용하기

직렬화기(Serializer) 만들고 사용하기

Kotlin 직렬화에서 직렬화기가 타입의 구조를 정의하는 방식과, 직접 직렬화기를 만들고 응용하는 방법을 소개할게요.

출처: Create and use serializers

본문

직렬화기는 Kotlin 타입의 구조를 직렬화된 형태로 정의하며, Json 같은 포맷 구현은 그 구조를 어떻게 인코딩할지를 제어해요.

직렬화기는 KSerializer 인터페이스를 통해 타입의 직렬화·역직렬화 전략을 정의해요. Kotlin 직렬화는 내장 타입, 컬렉션 등의 직렬화기를 제공해요.

이런 직렬화기를 사용해 값을 직렬화하거나 그 직렬화된 형태의 구조를 검사할 수 있고, 커스텀 직렬화기로는 그 구조를 직접 정의할 수도 있어요.

직렬화기 얻기

클래스에 @Serializable을 붙이면, Kotlin 직렬화 플러그인이 그 클래스에 대한 KSerializer를 생성해요.

자동 생성된 직렬화기를 가져오려면 생성된 .serializer() 함수를 호출하세요. 직렬화기를 Json.encodeToString() 같은 함수와 직접 사용하거나, 그 descriptor 속성에 접근해 타입의 직렬화된 형태 구조를 검사할 수 있어요.

다음은 정수 속성 하나를 가진 Color 클래스를 정의하고 그 직렬화된 형태의 구조를 검사하는 예시예요.

import kotlinx.serialization.*

//sampleStart
@Serializable
data class Color(val rgb: Int)

fun main() {
    // Retrieves the generated serializer for the Color class
    val colorSerializer: KSerializer<Color> = Color.serializer()

    println(colorSerializer.descriptor)
    // Color(rgb: kotlin.Int)
}
//sampleEnd

내장 기본 타입과 StringInt.serializer(), String.serializer()처럼 .serializer() 함수를 통해 직렬화기를 제공해요.

최상위 serializer<T>() 함수를 사용하면 파라미터화된 타입을 포함해 어떤 타입에 대해서도 직렬화기를 얻을 수 있어요.

import kotlinx.serialization.*

//sampleStart
@Serializable
@SerialName("Color")
class Color(val rgb: Int)

fun main() {
    // Retrieves the serializer for the Map<String, Color>
    val stringToColorMapSerializer: KSerializer<Map<String, Color>> = serializer()

    // Prints: kotlin.collections.LinkedHashMap(PrimitiveDescriptor(kotlin.String), Color(rgb: kotlin.Int))
    println(stringToColorMapSerializer.descriptor)
}
//sampleEnd

제네릭 클래스에서 생성된 .serializer() 함수를 사용하려면 각 타입 파라미터마다 KSerializer 인자를 하나씩 제공하세요.

import kotlinx.serialization.*

//sampleStart
@Serializable
@SerialName("Color")
class Color(val rgb: Int)

@Serializable
@SerialName("Box")
class Box<T>(val contents: T)    

fun main() {
    // Calls .serializer() using a KSerializer for the type parameter
    val boxedColorSerializer = Box.serializer(Color.serializer())

    println(boxedColorSerializer.descriptor)
    // Box(contents: Color)
}
//sampleEnd

컬렉션 타입의 직렬화기 얻기

@Serializable을 붙인 클래스와 달리, List<T> 같은 컬렉션 타입은 생성된 .serializer() 함수가 없어요.

컬렉션 타입의 직렬화기를 얻으려면 ListSerializer(), SetSerializer() 또는 MapSerializer()로 만들고, 컬렉션의 타입 파라미터 직렬화기를 지정하세요.

다음은 ListSerializer() 함수로 List<String>용 직렬화기를 만드는 예시예요.

import kotlinx.serialization.*
import kotlinx.serialization.builtins.*

//sampleStart
fun main() {   
    val stringListSerializer: KSerializer<List<String>> = ListSerializer(String.serializer()) 
    println(stringListSerializer.descriptor)
    // kotlin.collections.ArrayList(PrimitiveDescriptor(kotlin.String))
}
//sampleEnd

커스텀 직렬화기 만들기

직렬화된 데이터의 구조를 더 세밀하게 제어하고 싶다면 커스텀 직렬화기를 만들 수 있어요. 커스텀 직렬화기는 타입이 직렬화된 형태에서 어떻게 표현되는지 정의하게 해 줘요.

생성된 직렬화기와 마찬가지로, 커스텀 직렬화기도 KSerializer 인터페이스를 통해 타입의 직렬화와 역직렬화를 모두 정의해요. 둘 다 지원하기 위해 KSerializerSerializationStrategyDeserializationStrategy를 확장해요.

JSON 직렬화의 경우 JsonTransformingSerializer를 사용해 기존 직렬화기가 생성하는 JSON 출력을 수정할 수도 있고, 그 구조를 다시 정의할 필요는 없어요.

커스텀 기본(primitive) 직렬화기 만들기

기본 직렬화기를 사용하면 클래스를 문자열이나 정수 같은 단일 기본 값으로 표현할 수 있어요.

커스텀 기본 직렬화기를 만들려면 다음을 따르세요.

  • 직렬화 대상 클래스에 대해 KSerializer를 구현하는 object로 커스텀 직렬화기를 만드세요.
object YourSerializer : KSerializer<Type>
  • descriptor 속성을 오버라이드해 직렬화된 데이터의 스키마를 정의하세요.

PrimitiveSerialDescriptor(serialName, kind)로 직렬화된 형태의 구조를 단일 기본 값으로 정의하세요. 정규화된 이름 같은 고유한 serialName을 지정하고, 직렬화기에서 사용하는 인코더·디코더 함수와 일치하는 PrimitiveKind를 사용하세요.

override val descriptor: SerialDescriptor =
    PrimitiveSerialDescriptor("com.example.Type", PrimitiveKind.STRING)

descriptor가 인코딩·디코딩 함수와 일치하지 않으면, kotlinx.serialization 업데이트 시 일부 포맷에서 직렬화기가 예측할 수 없게 동작할 수 있어요.

  • 값이 어떻게 직렬화된 형태로 변환되는지 정의하려면 serialize() 함수를 구현하세요. descriptorPrimitiveKind와 일치하는 Encoder 함수를 선택하세요.
override fun serialize(encoder: Encoder, value: Type) {
    val encodedValue: String = // convert value to a primitive representation
    encoder.encodeString(encodedValue)
}
  • 직렬화된 데이터를 다시 클래스의 인스턴스로 변환하는 방법을 정의하려면 deserialize() 함수를 구현하세요. DecoderdecodeString() 같은 데이터를 읽는 함수를 제공해요.
override fun deserialize(decoder: Decoder): Type {
    val decodedValue: String = decoder.decodeString()
    // Converts decodedValue back to Type
    return ...
}
  • 클래스에 커스텀 직렬화기를 지정하려면 @Serializable 어노테이션을 사용하세요.
@Serializable(YourSerializer::class)
data class Type(val stringValue: String)

Color를 16진수 문자열로 직렬화하는 커스텀 기본 직렬화기 예시를 볼게요.

import kotlinx.serialization.*
import kotlinx.serialization.encoding.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.json.*

//sampleStart
// Creates the custom serializer for the Color class
object ColorAsStringSerializer : KSerializer<Color> {
    // Defines the schema for the serialized data as a single string
    override val descriptor: SerialDescriptor =
        // Specifies a unique name and a PrimitiveKind
        PrimitiveSerialDescriptor("my.app.Color", PrimitiveKind.STRING)

    // Defines how a Color value is serialized as a string
    override fun serialize(encoder: Encoder, value: Color) {
        // Converts the RGB value to a hexadecimal string
        val hexValue = value.rgb.toString(16).padStart(6, '0')
        // Encodes the serialized value as a string
        encoder.encodeString(hexValue)
    }

    // Defines how a Color value is deserialized from a string
    override fun deserialize(decoder: Decoder): Color {
        // Decodes the serialized string value
        val hexValue = decoder.decodeString()
        // Converts the decoded value back into a Color
        return Color(hexValue.toInt(16))
    }
}
// Specifies ColorAsStringSerializer as the custom serializer for the Color class
@Serializable(ColorAsStringSerializer::class)
data class Color(val rgb: Int)

fun main() {
    val color = Color(0x00FF00)
    // Serializes a Color value to JSON
    val jsonString = Json.encodeToString(color)

    println(jsonString)
    // "00ff00"

    // Deserializes the JSON string into a Color value
    val deserializedColor = Json.decodeFromString<Color>(jsonString)

    println(deserializedColor.rgb)
    // 65280
}
//sampleEnd

이진 데이터를 Base64 문자열로 직렬화하기

커스텀 기본 직렬화기로 단순 값 변환을 넘어서는 일반적인 직렬화 문제도 해결할 수 있어요. 그런 작업 중 하나가 이진 데이터를 Base64 문자열로 표현하는 거예요.

API마다 기본으로 사용하는 Base64 변형이 달라요. 예상되는 변형과 일치하는 Kotlin Base64 인코더를 선택하세요. 예를 들어 Base64.DefaultBase64.Mime을 사용할 수 있어요.

다음은 JSON 포맷에서 Base64.DefaultByteArray를 Base64 문자열로 직렬화하는 예시예요.

import kotlinx.serialization.*
import kotlinx.serialization.json.*
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.descriptors.*
import kotlin.io.encoding.*

//sampleStart
// Creates a custom primitive serializer,
// which represents ByteArray as a Base64 string
object ByteArrayAsBase64Serializer : KSerializer<ByteArray> {

    // Uses the default Base64 variant
    private val base64 = Base64.Default

    // Defines the serialized form as a single STRING primitive
    override val descriptor: SerialDescriptor
        get() = PrimitiveSerialDescriptor(
            "ByteArrayAsBase64Serializer",
            PrimitiveKind.STRING
        )

    // Encodes the ByteArray as a Base64 string
    override fun serialize(encoder: Encoder, value: ByteArray) {
        val base64Encoded = base64.encode(value)
        encoder.encodeString(base64Encoded)
    }

    // Decodes the Base64 string back into a ByteArray
    override fun deserialize(decoder: Decoder): ByteArray {
        val base64Decoded = decoder.decodeString()
        return base64.decode(base64Decoded)
    }
}

@Serializable
data class Value(
    // Specifies the custom serializer for this property
    @Serializable(ByteArrayAsBase64Serializer::class)
    val base64Input: ByteArray
) {

    // Implements value-based equality for ByteArray
    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (javaClass != other?.javaClass) return false
        other as Value
        return base64Input.contentEquals(other.base64Input)
    }

    // Computes hashCode based on array contents
    override fun hashCode(): Int {
        return base64Input.contentHashCode()
    }
}

fun main() {
    val string = "PNG_IMAGE_DATA"
    val value = Value(string.toByteArray())

    // Serializes Value to JSON
    val encoded = Json.encodeToString(value)

    println(encoded)
    // {"base64Input":"UE5HX0lNQUdFX0RBVEE="}

    // Deserializes JSON back into Value
    val decoded = Json.decodeFromString<Value>(encoded)

    println(decoded.base64Input.decodeToString())
    // PNG_IMAGE_DATA
}
//sampleEnd

직렬화를 다른 직렬화기에 위임하기

직렬화 로직을 해당 타입의 직렬화기에 위임함으로써 클래스를 다른 타입으로 직렬화할 수 있어요. 예를 들어 이 방식을 사용해 클래스를 IntArray 같은 비기본 타입으로 직렬화할 수 있어요.

직렬화를 위임하려면 위임 대상 타입의 직렬화기 속성을 정의하는 커스텀 직렬화기를 만드세요.

  • 위임 대상 직렬화기의 descriptor를 감싸도록 descriptor 속성을 오버라이드하세요.

직렬화를 위임할 때는 원래 클래스의 descriptor나 위임 대상 타입의 descriptor를 직접 사용할 수 없어요. 대신 위임 대상 직렬화기의 구조를 재사용하는 새 descriptor를 만드세요.

  • serialize() 함수를 오버라이드해 클래스의 인스턴스를 위임 대상 타입으로 변환하고, encoder.encodeSerializableValue() 함수로 위임 대상 직렬화기를 사용해 인코딩하세요.

  • deserialize() 함수를 오버라이드해 위임 대상 직렬화기로 값을 디코딩하는 decoder.decodeSerializableValue() 함수를 사용하고, 이를 다시 클래스의 인스턴스로 변환하세요.

다음은 직렬화 로직을 IntArraySerializer에 위임해 Color 클래스를 IntArray로 직렬화하는 예시예요.

import kotlinx.serialization.*
import kotlinx.serialization.encoding.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.builtins.IntArraySerializer
import kotlinx.serialization.json.*

//sampleStart
// Creates a custom serializer that delegates to IntArraySerializer
class ColorIntArraySerializer : KSerializer<Color> {
    private val delegateSerializer = IntArraySerializer()
    override val descriptor = SerialDescriptor("my.app.Color", delegateSerializer.descriptor)

    // Delegates serialization logic to IntArraySerializer
    override fun serialize(encoder: Encoder, value: Color) {
        val data = intArrayOf(
            (value.rgb shr 16) and 0xFF,
            (value.rgb shr 8) and 0xFF,
            value.rgb and 0xFF
        )
        encoder.encodeSerializableValue(delegateSerializer, data)
    }

    // Delegates deserialization and converts IntArray back to Color
    override fun deserialize(decoder: Decoder): Color {
        val array = decoder.decodeSerializableValue(delegateSerializer)
        return Color((array[0] shl 16) or (array[1] shl 8) or array[2])
    }
}

@Serializable(ColorIntArraySerializer::class)
class Color(val rgb: Int)

fun main() {
    val green = Color(0x00ff00)

    println(Json.encodeToString(green))
    // [0,255,0]
}
//sampleEnd

배열 표현은 JSON에서는 관례적이지 않지만, ByteArray와 이진 포맷과 함께 사용하면 직렬화된 데이터의 크기를 줄일 수 있어요.

비-JSON 직렬화 포맷이 배열을 어떻게 취급하는지 자세한 내용은 대안 및 커스텀 직렬화 포맷을 참고하세요.

대리(surrogate) 클래스로 클래스 직렬화하기

다른 클래스의 직렬화된 형태와 일치하는 클래스인 대리(surrogate) 클래스를 다음 상황에서 사용할 수 있어요.

  • 클래스 자체를 수정하지 않고 클래스가 직렬화되는 방식을 변경할 때.
  • 컴포지트 직렬화기 만들기를 피하고 싶을 때.
  • 원래 클래스를 만들기 전에 직렬화된 형태를 검증하고 싶을 때.
  • 직접 직렬화가 클래스의 규칙에 맞지 않는 경우를 처리할 때.

대리 클래스를 private으로 만들고 init 블록을 사용해 클래스의 직렬 표현에 제약을 적용할 수 있어요. 직렬화된 타입 이름을 유지하도록 커스텀 직렬 이름을 정의할 수도 있어요.

Colorr, g, b 속성에 특정 범위를 가진 JSON 객체로 직렬화하는 예시를 볼게요.

// Defines a surrogate that matches the serialized form
@Serializable
@SerialName("Color")
private class ColorSurrogate(val r: Int, val g: Int, val b: Int) {
    init {
        // Enforces constraints on the serialized form
        require(r in 0..255 && g in 0..255 && b in 0..255)
    }
}

다른 직렬화기에 직렬화를 위임하는 것과 마찬가지로, 커스텀 직렬화기는 원래 클래스를 다른 표현으로 변환하고 그 표현의 자동 생성된 SerialDescriptor를 감싸요.

대리 클래스는 자체 serialName을 가진 SerialDescriptor를 정의해요. 각 SerialDescriptor는 고유한 serialName을 가져야 하므로, 대리 클래스의 SerialDescriptor 구조는 재사용할 수 있지만 그 serialName은 재사용할 수 없어요.

대리 클래스의 생성된 직렬화기를 가져오려면 ColorSurrogate.serializer()를 사용하세요.

object ColorSerializer : KSerializer<Color> {
    // The serialNames of descriptors must be unique
    override val descriptor: SerialDescriptor = SerialDescriptor("my.app.Color", ColorSurrogate.serializer().descriptor)

    // Converts the original class to the surrogate representation
    override fun serialize(encoder: Encoder, value: Color) {
        val surrogate = ColorSurrogate((value.rgb shr 16) and 0xff, (value.rgb shr 8) and 0xff, value.rgb and 0xff)
        encoder.encodeSerializableValue(ColorSurrogate.serializer(), surrogate)
    }

    // Converts the surrogate representation back to the original class
    override fun deserialize(decoder: Decoder): Color {
        val surrogate = decoder.decodeSerializableValue(ColorSurrogate.serializer())
        return Color((surrogate.r shl 16) or (surrogate.g shl 8) or surrogate.b)
    }
}

마지막으로 클래스에 커스텀 직렬화기를 지정하세요.

import kotlinx.serialization.*
import kotlinx.serialization.encoding.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.builtins.IntArraySerializer
import kotlinx.serialization.json.*

// Defines a private surrogate class with custom properties
@Serializable
@SerialName("Color")
private class ColorSurrogate(val r: Int, val g: Int, val b: Int) {
    init {
        // Enforces constraints on the serialized form
        require(r in 0..255 && g in 0..255 && b in 0..255)
    }
}

// Creates a custom serializer that converts to and from the surrogate
object ColorSerializer : KSerializer<Color> {
    // Defines a unique serialName for the wrapped SerialDescriptor
    override val descriptor: SerialDescriptor =
        SerialDescriptor("my.app.Color", ColorSurrogate.serializer().descriptor)

    // Converts the original class to the surrogate representation
    override fun serialize(encoder: Encoder, value: Color) {
        val surrogate = ColorSurrogate((value.rgb shr 16) and 0xff, (value.rgb shr 8) and 0xff, value.rgb and 0xff)
        encoder.encodeSerializableValue(ColorSurrogate.serializer(), surrogate)
    }

    // Converts the surrogate representation back to the original class
    override fun deserialize(decoder: Decoder): Color {
        val surrogate = decoder.decodeSerializableValue(ColorSurrogate.serializer())
        return Color((surrogate.r shl 16) or (surrogate.g shl 8) or surrogate.b)
    }
}

//sampleStart
// Specifies ColorSerializer as the custom serializer for the class
@Serializable(ColorSerializer::class)
class Color(val rgb: Int)

fun main() {
    val green = Color(0x00ff00)

    println(Json.encodeToString(green))
    // {"r":0,"g":255,"b":0}
}
//sampleEnd

커스텀 컴포지트 직렬화기 만들기

컴포지트 직렬화기를 사용하면 여러 속성을 가진 클래스 같은 복잡한 데이터 구조를 표현할 수 있어요.

대리 클래스 사용과 비교하면 커스텀 컴포지트 직렬화기는 추가 변환 단계 없이 원래 클래스의 직렬화된 구조를 직접 정의할 수 있어요. 이는 경우에 따라 성능도 개선할 수 있지만, 직렬화 로직을 더 많이 직접 작성해야 해요.

커스텀 컴포지트 직렬화기를 만들려면 다음을 따르세요.

  • 대상 클래스에 대해 KSerializer를 구현하는 object로 직렬화기를 만드세요.
object YourSerializer : KSerializer<Type>
override val descriptor: SerialDescriptor =
    buildClassSerialDescriptor("com.example.Type") {
        // ...
    }
  • element() 함수로 buildClassSerialDescriptor() 안의 각 속성을 지정하세요. 요소의 순서가 0부터 시작하는 인덱스를 결정해요.

직렬화기 descriptorSerialKind는 각 element()가 무엇을 나타내는지 결정해요. 클래스 descriptor에서 element()는 속성을 나타내고, enum descriptor에서는 REDGREEN 같은 상수를 나타내요.

override val descriptor: SerialDescriptor =
    buildClassSerialDescriptor("com.example.Type") {
        element<Int>("first")
        element<Int>("second")
    }
override fun serialize(encoder: Encoder, value: Type) =
    encoder.encodeStructure(descriptor) {
        encodeIntElement(descriptor, 0, value.first)
        encodeIntElement(descriptor, 1, value.second)
    }

대부분의 포맷은 데이터를 임의의 순서로 인코딩할 수 있어서, 직렬화기 descriptor의 요소 순서와 다를 수 있어요. decodeElementIndex() 함수로 어떤 element()를 디코딩할지 식별하세요. 더 이상 요소가 없으면 CompositeDecoder.DECODE_DONE을 반환하며, 이를 사용해 현재 구조의 디코딩을 중단해요.

override fun deserialize(decoder: Decoder): Type =
    decoder.decodeStructure(descriptor) {
        var first = 0
        var second = ""

        // Uses decodeElementIndex to ensure correct decoding regardless of order
        while (true) {
            when (val index = decodeElementIndex(descriptor)) {
                0 -> first = decodeStringElement(descriptor, 0)
                1 -> second = decodeIntElement(descriptor, 1)
                CompositeDecoder.DECODE_DONE -> break
                else -> error("Unexpected index: $index")
            }
        }

        Type(first, second)
    }
  • 클래스에 커스텀 직렬화기를 지정하려면 @Serializable(YourSerializer::class) 어노테이션을 사용하세요.

각 요소를 수동으로 다루지 않고 데이터를 변환하거나 재구성하기만 하면 된다면, 더 간단한 접근을 위해 직렬화를 다른 직렬화기에 위임하거나 대리 클래스를 사용하는 것을 고려해 보세요.

여러 속성을 가진 Color 클래스를 직렬화하는 예시를 볼게요.

import kotlinx.serialization.*
import kotlinx.serialization.encoding.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.json.*

//sampleStart
// Creates a custom serializer for the Color class with multiple properties
object ColorAsObjectSerializer : KSerializer<Color> {
    // Defines the schema for the Color class
    override val descriptor: SerialDescriptor =
        buildClassSerialDescriptor("my.app.Color") {
            // Specifies each property with its type and name with the element() function
            element<Int>("r")
            element<Int>("g")
            element<Int>("b")
        }

    // Serializes the Color in the order specified in the descriptor
    override fun serialize(encoder: Encoder, value: Color) =
        encoder.encodeStructure(descriptor) {
            encodeIntElement(descriptor, 0, (value.rgb shr 16) and 0xff)
            encodeIntElement(descriptor, 1, (value.rgb shr 8) and 0xff)
            encodeIntElement(descriptor, 2, value.rgb and 0xff)
        }

    // Deserializes the data back into a Color object
    override fun deserialize(decoder: Decoder): Color =
        decoder.decodeStructure(descriptor) {
            // Temporary variables to hold the decoded values
            var r = -1
            var g = -1
            var b = -1
            // Uses decodeElementIndex() since element order may vary by format
            while (true) {
                when (val index = decodeElementIndex(descriptor)) {
                    0 -> r = decodeIntElement(descriptor, 0)
                    1 -> g = decodeIntElement(descriptor, 1)
                    2 -> b = decodeIntElement(descriptor, 2)
                    CompositeDecoder.DECODE_DONE -> break
                    else -> error("Unexpected index: $index")
                }
            }
            // Validates values and reconstructs Color
            require(r in 0..255 && g in 0..255 && b in 0..255)
            Color((r shl 16) or (g shl 8) or b)
        }
}

// Specifies the custom serializer for Color
@Serializable(ColorAsObjectSerializer::class)
data class Color(val rgb: Int)

fun main() {
    val color = Color(0x00ff00)
    val string = Json.encodeToString(color)

    println(string)
    // {"r":0,"g":255,"b":0}

    require(Json.decodeFromString<Color>(string) == color)
}
//sampleEnd

커스텀 직렬화기에서 기본값 인코딩하기

플러그인 생성 직렬화기는 인코더가 기본값과 같은 값을 인코딩해야 하는지 확인해요. 예를 들어 JSON에서는 encodeDefaults 속성이 이를 제어해요.

커스텀 직렬화기에서도 같은 동작을 하려면 직렬화기의 descriptor와 인코딩할 요소의 인덱스와 함께 shouldEncodeElementDefault() 함수를 사용하세요.

속성에 @EncodeDefault가 붙어 있으면 플러그인 생성 직렬화기는 shouldEncodeElementDefault()를 호출하지 않아요.

다음은 encodeDefaults가 활성화됐을 때 커스텀 직렬화기가 기본 Color 값을 인코딩하는 예시예요.

import kotlinx.serialization.*
import kotlinx.serialization.encoding.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.json.*

//sampleStart
// Creates a custom serializer for the Color class with multiple properties
object ColorAsObjectSerializer : KSerializer<Color> {
    // Defines the schema for the Color class
    override val descriptor: SerialDescriptor =
        buildClassSerialDescriptor("my.app.Color") {
            // Specifies each property with its type and name with the element() function
            element<Int>("r", isOptional = true)
            element<Int>("g", isOptional = true)
            element<Int>("b", isOptional = true)
        }
   
    override fun serialize(encoder: Encoder, value: Color) =
        encoder.encodeStructure(descriptor) {
            val r = (value.rgb shr 16) and 0xff
            val g = (value.rgb shr 8) and 0xff
            val b = value.rgb and 0xff

            // Encodes r if it differs from its default value,
            // or if the encoder needs to encode the first element's default value
            if (r != 0 || shouldEncodeElementDefault(descriptor, 0)) {
                encodeIntElement(descriptor, 0, r)
            }
            if (g != 255 || shouldEncodeElementDefault(descriptor, 1)) {
                encodeIntElement(descriptor, 1, g)
            }
            if (b != 0 || shouldEncodeElementDefault(descriptor, 2)) {
                encodeIntElement(descriptor, 2, b)
            }
        }

    // Deserializes the data back into a Color object
    override fun deserialize(decoder: Decoder): Color =
        decoder.decodeStructure(descriptor) {
            var r = 0
            var g = 255
            var b = 0

            while (true) {
                when (val index = decodeElementIndex(descriptor)) {
                    0 -> r = decodeIntElement(descriptor, 0)
                    1 -> g = decodeIntElement(descriptor, 1)
                    2 -> b = decodeIntElement(descriptor, 2)
                    CompositeDecoder.DECODE_DONE -> break
                    else -> error("Unexpected index: $index")
                }
            }
            require(r in 0..255 && g in 0..255 && b in 0..255)
            Color((r shl 16) or (g shl 8) or b)
        }
}

// Specifies the custom serializer for Color
@Serializable(ColorAsObjectSerializer::class)
data class Color(val rgb: Int = 0x00ff00)

fun main() {
    val color = Color()
    val stringWithDefaults = Json { encodeDefaults = true }.encodeToString(color)
   
    println(stringWithDefaults)
    // {"r":0,"g":255,"b":0}
}
//sampleEnd

순차 디코딩으로 역직렬화 최적화하기

일부 포맷은 엄격히 정렬된 스키마를 사용해 순차적 디코딩을 지원해요. 이런 포맷에서는 decodeSequentially() 함수로 역직렬화를 최적화할 수 있어요. 이 함수는 현재 구조를 순서대로 디코딩할 수 있으면 true를 반환해요. 이 경우를 별도로 처리해 요소를 순서 없이 개별 디코딩하는 더 복잡한 로직을 건너뛸 수 있어요.

kotlinx.serialization 플러그인이 생성한 직렬화기는 이 최적화를 사용해요.

다음은 가능할 때 decodeSequentially()로 역직렬화를 최적화하는 예시예요.

import kotlinx.serialization.*
import kotlinx.serialization.encoding.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.json.*

object ColorAsObjectSerializer : KSerializer<Color> {

    override val descriptor: SerialDescriptor =
        buildClassSerialDescriptor("my.app.Color") {
            element<Int>("r")
            element<Int>("g")
            element<Int>("b")
        }

    override fun serialize(encoder: Encoder, value: Color) =
        encoder.encodeStructure(descriptor) {
            encodeIntElement(descriptor, 0, (value.rgb shr 16) and 0xff)
            encodeIntElement(descriptor, 1, (value.rgb shr 8) and 0xff)
            encodeIntElement(descriptor, 2, value.rgb and 0xff)
        }

//sampleStart
    override fun deserialize(decoder: Decoder): Color =
        decoder.decodeStructure(descriptor) {
            var r = -1
            var g = -1
            var b = -1
            // Decodes values directly in order if the format stores data sequentially
            @OptIn(ExperimentalSerializationApi::class)
            if (decodeSequentially()) {
                r = decodeIntElement(descriptor, 0)           
                g = decodeIntElement(descriptor, 1)  
                b = decodeIntElement(descriptor, 2)
            } else while (true) {
                // Ensures correct decoding for formats where elements may be unordered
                when (val index = decodeElementIndex(descriptor)) {
                    0 -> r = decodeIntElement(descriptor, 0)
                    1 -> g = decodeIntElement(descriptor, 1)
                    2 -> b = decodeIntElement(descriptor, 2)
                    CompositeDecoder.DECODE_DONE -> break
                    else -> error("Unexpected index: $index")
                }
            }
            require(r in 0..255 && g in 0..255 && b in 0..255)
            Color((r shl 16) or (g shl 8) or b)
        }
}
//sampleEnd

@Serializable(ColorAsObjectSerializer::class)
data class Color(val rgb: Int)

fun main() {
    val color = Color(0x00ff00)
    val string = Json.encodeToString(color)

    println(string)
    // {"r":0,"g":255,"b":0}

    require(Json.decodeFromString<Color>(string) == color)
}

제네릭 타입용 커스텀 직렬화기 만들기

제네릭 클래스용 커스텀 직렬화기를 만들려면 직렬화기를 object가 아닌 class로 선언하고, 각 제네릭 타입 파라미터마다 KSerializer 생성자 파라미터를 하나씩 두세요.

각 타입 파라미터의 직렬화 로직을 해당 KSerializer에 위임하면, 자신의 직렬화 규칙에 따라 인코딩돼요.

제네릭 Box<T> 클래스를 사용하는 예시를 볼게요.

import kotlinx.serialization.*
import kotlinx.serialization.encoding.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.json.*

//sampleStart
@Serializable(BoxSerializer::class)
data class Box<T>(val contents: T)

// Creates a custom serializer as a class for Box<T>
class BoxSerializer<T>(private val dataSerializer: KSerializer<T>) : KSerializer<Box<T>> {
    // Defines a unique serialName for the Box<T> descriptor
    override val descriptor: SerialDescriptor =
        SerialDescriptor("my.app.Box", dataSerializer.descriptor)

    // Delegates serialization and deserialization
    override fun serialize(encoder: Encoder, value: Box<T>) = dataSerializer.serialize(encoder, value.contents)
    override fun deserialize(decoder: Decoder) = Box(dataSerializer.deserialize(decoder))
}

@Serializable
data class Project(val name: String)

fun main() {
    val box = Box(Project("kotlinx.serialization"))
    val string = Json.encodeToString(box)

    println(string)
    // {"name":"kotlinx.serialization"}

    println(Json.decodeFromString<Box<Project>>(string))
    // Box(contents=Project(name=kotlinx.serialization))
}
//sampleEnd

플러그인 생성 직렬화기를 커스텀 직렬화기와 함께 사용하기

기본적으로 @Serializable(YourSerializer::class)로 커스텀 직렬화기를 지정하면 Kotlin 직렬화 플러그인은 직렬화기를 생성하지 않아요.

그래도 플러그인 생성 직렬화기를 사용하고 싶을 수 있어요. 예를 들어:

  • 플러그인 생성 직렬화기를 폴백(fallback) 전략으로 사용하기.
  • 플러그인 생성 descriptor를 검사해 기본 구조에 접근하기.
  • 커스텀 직렬화기를 사용하지 않는 하위 클래스에서 기본 직렬화 동작을 재사용하기.

직렬화 가능한 클래스에 @KeepGeneratedSerializer를 붙이면 자동 생성된 직렬화기를 커스텀 직렬화기와 함께 유지할 수 있어요. 플러그인 생성 직렬화기에 접근하려면 직렬화 가능한 클래스의 컴패니언 객체에서 .generatedSerializer() 함수를 사용하세요.

이는 JsonTransformingSerializer를 사용해 JSON 구조를 조정하고 기본 직렬화 로직에는 플러그인 생성 직렬화기를 재사용할 때도 유용해요.

다음은 커스텀 직렬화기와 플러그인 생성 직렬화기를 모두 사용하는 예시예요.

import kotlinx.serialization.*
import kotlinx.serialization.json.*
import kotlinx.serialization.encoding.*
import kotlinx.serialization.descriptors.*

object ColorAsStringSerializer : KSerializer<Color> {
    override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("my.app.ColorAsString", PrimitiveKind.STRING)

    override fun serialize(encoder: Encoder, value: Color) {
        val string = value.rgb.toString(16).padStart(6, '0')
        encoder.encodeString(string)
    }

    override fun deserialize(decoder: Decoder): Color {
        val string = decoder.decodeString()
        return Color(string.toInt(16))
    }
}

//sampleStart
@OptIn(ExperimentalSerializationApi::class)
@KeepGeneratedSerializer
@Serializable(ColorAsStringSerializer::class)
class Color(val rgb: Int)

fun main() {
    val green = Color(0x00ff00)

    // Uses the custom serializer
    println(Json.encodeToString(green))
    // "00ff00"

    // Uses the plugin-generated serializer
    println(Json.encodeToString(Color.generatedSerializer(), green))
    // {"rgb":65280}

}
//sampleEnd

직렬화기 적용하기

커스텀 직렬화기를 자신의 클래스와 타사 타입에 적용할 수 있어요. java.util.Date 같은 타사 타입은 소스 코드를 수정할 수 없어 @Serializable을 직접 붙일 수 없어요.

직렬화기를 수동으로 전달하기

커스텀 직렬화기로 타입을 직렬화하려면 직렬화기를 만들고 Json.encodeToString()Json.decodeFromString() 같은 함수의 오버로드에 명시적으로 전달하세요.

다음은 Date 값을 Unix epoch 이후의 밀리초 수로 직렬화하는 예시예요.

import kotlinx.serialization.*
import kotlinx.serialization.encoding.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.json.*
import java.util.Date
import java.text.SimpleDateFormat

//sampleStart
// Can't use @Serializable on Date without access to its source code
object DateAsLongSerializer : KSerializer<Date> {
    override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("my.app.DateAsLong", PrimitiveKind.LONG)
    override fun serialize(encoder: Encoder, value: Date) = encoder.encodeLong(value.time)
    override fun deserialize(decoder: Decoder): Date = Date(decoder.decodeLong())
}

fun main() {
    val kotlin10ReleaseDate = SimpleDateFormat("yyyy-MM-ddX").parse("2016-02-15+00") 

    // Serializes Date as a Long in milliseconds
    println(Json.encodeToString(DateAsLongSerializer, kotlin10ReleaseDate))    
    // 1455494400000
}
//sampleEnd

속성에 직렬화기 지정하기

타입이 직렬화 가능한 클래스의 속성으로 사용될 때는 @Serializable 어노테이션으로 그 속성에 커스텀 직렬화기를 지정하세요.

import kotlinx.serialization.*
import kotlinx.serialization.encoding.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.json.*
import java.util.Date
import java.text.SimpleDateFormat

object DateAsLongSerializer : KSerializer<Date> {
    override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("my.app.DateAsLong", PrimitiveKind.LONG)
    override fun serialize(encoder: Encoder, value: Date) = encoder.encodeLong(value.time)
    override fun deserialize(decoder: Decoder): Date = Date(decoder.decodeLong())
}

//sampleStart
@Serializable
class ProgrammingLanguage(
    val name: String,
    // Specifies the custom serializer for the Date property
    @Serializable(DateAsLongSerializer::class)
    val stableReleaseDate: Date
)

fun main() {
    val data = ProgrammingLanguage("Kotlin", SimpleDateFormat("yyyy-MM-ddX").parse("2016-02-15+00"))

    println(Json.encodeToString(data))
    // {"name":"Kotlin","stableReleaseDate":1455494400000}
}
//sampleEnd

타입에 직렬화기 지정하기

@Serializable 어노테이션을 타입에 직접 적용할 수도 있어요. 타입이 제네릭 타입 인자로 사용될 때, 예를 들어 List<Date>에서 커스텀 직렬화기를 지정하는 데 사용할 수 있어요.

import kotlinx.serialization.*
import kotlinx.serialization.encoding.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.json.*
import java.util.Date
import java.text.SimpleDateFormat

object DateAsLongSerializer : KSerializer<Date> {
    override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("my.app.DateAsLong", PrimitiveKind.LONG)
    override fun serialize(encoder: Encoder, value: Date) = encoder.encodeLong(value.time)
    override fun deserialize(decoder: Decoder): Date = Date(decoder.decodeLong())
}

//sampleStart
@Serializable          
class ProgrammingLanguage(
    val name: String,
     // Specifies the custom serializer for Date as a generic type argument
    val releaseDates: List<@Serializable(DateAsLongSerializer::class) Date>
)

fun main() {
    val df = SimpleDateFormat("yyyy-MM-ddX")
    val data = ProgrammingLanguage("Kotlin", listOf(df.parse("2023-07-06+00"), df.parse("2023-04-25+00"), df.parse("2022-12-28+00")))
 
   println(Json.encodeToString(data))
    // {"name":"Kotlin","releaseDates":[1688601600000,1682380800000,1672185600000]}
}
//sampleEnd

파일에 직렬화기 지정하기

소스 파일에서 주어진 타입의 모든 속성에 직렬화기를 적용하려면 파일 시작 부분에 @UseSerializers 어노테이션을 추가하세요.

@file:UseSerializers(DateAsLongSerializer::class)

이렇게 하면 파일 내에서 해당 타입의 모든 인스턴스에 DateAsLongSerializer가 적용되므로, 각 속성을 개별적으로 어노테이션할 필요가 없어요.

예시를 볼게요.

// Applies the custom serializer to all properties of that type in the file
@file:UseSerializers(DateAsLongSerializer::class)

import kotlinx.serialization.*
import kotlinx.serialization.encoding.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.json.*
import java.util.Date
import java.text.SimpleDateFormat

object DateAsLongSerializer : KSerializer<Date> {
    override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("my.app.DateAsLong", PrimitiveKind.LONG)
    override fun serialize(encoder: Encoder, value: Date) = encoder.encodeLong(value.time)
    override fun deserialize(decoder: Decoder): Date = Date(decoder.decodeLong())
}

// Uses the file-level serializer for the Date property
@Serializable
class ProgrammingLanguage(val name: String, val stableReleaseDate: Date)

fun main() {
    val data = ProgrammingLanguage("Kotlin", SimpleDateFormat("yyyy-MM-ddX").parse("2016-02-15+00"))
 
   println(Json.encodeToString(data))
    // {"name":"Kotlin","stableReleaseDate":1455494400000}
}

타입 별칭으로 직렬화기 지정하기

Kotlin 직렬화에서는 보통 @Serializable 어노테이션으로 직렬화 전략을 명시적으로 지정해요. 컨텍스트 직렬화를 제외하면 전역 직렬화기 구성을 제공하지 않아요.

같은 직렬화기를 여러 곳에서 반복해서 사용한다면, 직렬화기 어노테이션이 붙은 typealias를 정의할 수 있어요.

이렇게 하면 사용하는 곳마다 @Serializable 어노테이션을 추가하지 않고도 어노테이션된 타입을 재사용할 수 있어요.

다음은 typealiasDateDateAsLongSerializerDateAsSimpleTextSerializer를 적용하는 예시예요.

import kotlinx.serialization.*
import kotlinx.serialization.encoding.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.json.*
import java.util.Date
import java.text.SimpleDateFormat
import java.util.TimeZone

//sampleStart
object DateAsLongSerializer : KSerializer<Date> {
    override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("my.app.DateAsLong", PrimitiveKind.LONG)
    override fun serialize(encoder: Encoder, value: Date) = encoder.encodeLong(value.time)
    override fun deserialize(decoder: Decoder): Date = Date(decoder.decodeLong())
}

// Defines a serializer that encodes Date as a formatted string (yyyy-MM-dd)
object DateAsSimpleTextSerializer: KSerializer<Date> {
    override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("my.app.DateAsSimpleText", PrimitiveKind.LONG)
    private val format = SimpleDateFormat("yyyy-MM-dd").apply {
        // Sets the time zone to UTC for consistent output
        setTimeZone(TimeZone.getTimeZone("UTC"))
    }
    override fun serialize(encoder: Encoder, value: Date) = encoder.encodeString(format.format(value))
    override fun deserialize(decoder: Decoder): Date = format.parse(decoder.decodeString())
}

// Applies global serializers using typealias to avoid annotating each occurrence
typealias DateAsLong = @Serializable(DateAsLongSerializer::class) Date

typealias DateAsText = @Serializable(DateAsSimpleTextSerializer::class) Date

// Uses typealiases to apply custom serializers for Date properties
@Serializable          
class ProgrammingLanguage(val stableReleaseDate: DateAsText, val lastReleaseTimestamp: DateAsLong)

fun main() {
    val format = SimpleDateFormat("yyyy-MM-ddX")
    val data = ProgrammingLanguage(format.parse("2016-02-15+00"), format.parse("2022-07-07+00"))

    println(Json.encodeToString(data))
    // {"stableReleaseDate":"2016-02-15","lastReleaseTimestamp":1657152000000}
}
//sampleEnd

컨텍스트 직렬화 구현하기

기본적으로 직렬화 전략은 컴파일 타임에 정의돼요. 컨텍스트 직렬화는 객체 트리 깊숙이 중첩된 타입이라도 런타임에 특정 타입의 직렬화 전략을 조정할 수 있게 해 줘요.

예를 들어 컨텍스트 직렬화를 사용해 java.util.Date를 프로토콜 버전에 따라 JSON에서 ISO 8601 String으로 또는 Long으로 직렬화할 수 있어요. 이 접근 방식은 내장된 ContextualSerializer 클래스가 지원해요.

컨텍스트 직렬화는 SerializersModule에서 런타임에 타입에 대한 커스텀 직렬화기를 선택해요.

컨텍스트 직렬화를 구현하려면:

  • 직렬화 가능한 클래스의 속성을 @Contextual 어노테이션으로 표시하세요.

@Contextual 어노테이션은 ContextualSerializer 클래스의 약칭이에요. 같은 파일에서 여러 속성에 컨텍스트 직렬화를 적용하려면 @UseContextualSerialization 어노테이션을 사용하세요.

SerializersModule이 없으면, 기본 직렬화기가 없는 컨텍스트 어노테이션 타입을 직렬화·역직렬화할 때 SerializationException이 발생해요.

  • Json 인스턴스를 만들고 SerializersModuleserializersModule 속성에 전달하세요.

예시를 볼게요.

import kotlinx.serialization.*
import kotlinx.serialization.encoding.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.json.*
import java.util.Date
import java.text.SimpleDateFormat

//sampleStart
// Creates a custom serializer for Date
object DateAsLongSerializer : KSerializer<Date> {
    override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("my.app.Date", PrimitiveKind.LONG)
    override fun serialize(encoder: Encoder, value: Date) = encoder.encodeLong(value.time)
    override fun deserialize(decoder: Decoder): Date = Date(decoder.decodeLong())
}

@Serializable
class ProgrammingLanguage(
    val name: String,
    // Specifies contextual serialization for Date
    @Contextual
    val stableReleaseDate: Date
)

// Defines a SerializersModule and registers the contextual serializer for Date
private val module = SerializersModule { 
    contextual(DateAsLongSerializer)
}

// Creates a Json instance with the custom SerializersModule
val format = Json { serializersModule = module }

fun main() {
    val data = ProgrammingLanguage("Kotlin", SimpleDateFormat("yyyy-MM-ddX").parse("2016-02-15+00"))
 
   println(format.encodeToString(data))
    // {"name":"Kotlin","stableReleaseDate":1455494400000}
}
//sampleEnd
제네릭 클래스 컨텍스트 직렬화하기

제네릭 클래스를 컨텍스트 직렬화하려면 SerializersModule에 함수를 등록할 수 있어요. 이 함수는 제네릭 타입 인자의 직렬화기를 받아 런타임에 그에 해당하는 직렬화기를 만들어요.

제네릭 타입 인자는 달라질 수 있으므로 제네릭 클래스에는 단일 직렬화기 인스턴스를 사용할 수 없어요. 예를 들어 다음 방식은 Box<Int>에서만 동작하고 Box<String> 같은 다른 타입에서는 동작하지 않아요.

val incorrectModule = SerializersModule {
    // This only works for Box<Int>, but not for Box<String> or other types
    contextual(BoxSerializer(Int.serializer()))
}

대신 타입 인자의 직렬화기를 기반으로 Box<T> 같은 제네릭 타입용 직렬화기를 만드는 함수를 등록하세요. 예시를 볼게요.

val correctModule = SerializersModule {
    // args[0] is the serializer for T,
    // for example Int.serializer() or String.serializer()
    contextual(Box::class) { args -> BoxSerializer(args[0]) }
}

plus 연산자로 여러 SerializersModule 인스턴스를 결합할 수 있어요. 예를 들어 제네릭 클래스용 모듈과 비제네릭 클래스용 모듈을 합칠 수 있어요. 자세한 내용은 여러 SerializersModule 인스턴스 병합을 참고하세요.

다음 단계

더 알아보기