JSON 구조 변환하기

JSON 구조 변환하기

직렬화 중 생성되는 JSON의 구조와 내용을 제어하려면 커스텀 직렬화기를 만들 수 있어요. 값 감싸기나 배열 풀기 같은 작은 조정에는 JsonTransformingSerializer 클래스를 쓰는 게 더 간단한데, EncoderDecoder를 직접 다루는 대신 JSON 요소 트리로 직접 작업하기 때문이에요.

이 절들은 직렬화기 만들고 사용하기에서 설명한 개념에 기반해요. 커스텀 직렬화기에 익숙하지 않다면 그 페이지를 먼저 읽어 보시길 권장해요.

JsonTransformingSerializerKSerializer 인터페이스를 구현하는 JSON 전용 abstract 직렬화기예요. 직렬화·역직렬화 전에 JSON 요소 트리를 조정하도록 오버라이드할 수 있는 transformSerialize()transformDeserialize() 함수를 제공해요.

JSON 구조를 변환하는 것 외에도 JsonContentPolymorphicSerializer를 사용해 JSON 내용에 기반해 적절한 다형성 클래스를 선택할 수 있습니다.

출처: Transform JSON structure

본문

JSON 구조 수정하기

JSON 요소 트리를 변환해 JSON 구조를 조정할 수 있어요. 다음 예시들은 배열 감싸기·풀기, 특정 프로퍼티 생략 같은 일반적인 사용 사례를 보여 줍니다.

역직렬화 중 단일 객체를 배열로 감싸기

일부 API는 항목 하나에 대해서는 단일 JSON 객체를, 여러 항목에 대해서는 JSON 배열을 반환해요. 두 경우 모두 List로 역직렬화하려면:

  1. JsonTransformingSerializer의 하위 클래스를 만들고 생성자에 직렬화기를 지정합니다. 표준 변환 로직을 쓰려면 리스트의 경우 ListSerializer()처럼 대상 타입의 기본 직렬화기를 넘겨주세요.
  2. transformDeserialize() 함수를 오버라이드합니다.

예시를 볼게요.

// Imports declarations from the serialization library
import kotlinx.serialization.*
import kotlinx.serialization.builtins.*
import kotlinx.serialization.json.*

//sampleStart
@Serializable
data class Project(
    val name: String,
    // Specifies UserListSerializer to handle the serialization of the users property
    @Serializable(with = UserListSerializer::class)
    val users: List<User>
)

@Serializable
data class User(val name: String)

// Creates a serializer that transforms the results of the default List serializer
object UserListSerializer : JsonTransformingSerializer<List<User>>(ListSerializer(User.serializer())) {
    override fun transformDeserialize(element: JsonElement): JsonElement =
        // If the element is not a JsonArray, wraps it into a single-element array
        if (element !is JsonArray) JsonArray(listOf(element)) else element
}

fun main() {
    println(Json.decodeFromString<Project>("""
        {"name":"kotlinx.serialization","users":{"name":"kotlin"}}
    """))
    // Project(name=kotlinx.serialization, users=[User(name=kotlin)])
   
    println(Json.decodeFromString<Project>("""
        {"name":"kotlinx.serialization","users":[{"name":"kotlin"},{"name":"jetbrains"}]}
    """))
    // Project(name=kotlinx.serialization, users=[User(name=kotlin), User(name=jetbrains)])
}
//sampleEnd

직렬화 중 단일 요소 배열 풀기

직렬화 중 단일 요소 리스트를 단일 JSON 객체로 풀려면 transformSerialize() 함수를 오버라이드하면 돼요.

// Imports declarations from the serialization library
import kotlinx.serialization.*
import kotlinx.serialization.builtins.*
import kotlinx.serialization.json.*

//sampleStart
@Serializable
data class Project(
    val name: String,
    // Specifies UserListSerializer to handle serialization of the users property
    @Serializable(with = UserListSerializer::class)
    val users: List<User>
)

@Serializable
data class User(val name: String)

// Creates a serializer that transforms the results of the default List serializer
object UserListSerializer : JsonTransformingSerializer<List<User>>(ListSerializer(User.serializer())) {

    override fun transformSerialize(element: JsonElement): JsonElement {
        require(element is JsonArray)
        // Unwraps single-element lists into a single JSON object
        return element.singleOrNull() ?: element
    }
}
  
fun main() {
    val data = Project("kotlinx.serialization", listOf(User("kotlin")))
    println(Json.encodeToString(data))
    // {"name":"kotlinx.serialization","users":{"name":"kotlin"}}
}
//sampleEnd

직렬화 중 특정 프로퍼티 생략하기

기본값을 지정할 수는 없지만 그 값이 특정 값일 때 프로퍼티를 생략하고 싶다면 JsonTransformingSerializer를 사용하면 돼요.

  1. JsonTransformingSerializer의 하위 클래스를 만들고 생성자에 직렬화기를 지정합니다.
  2. transformSerialize() 함수를 오버라이드합니다.

다음은 Project 클래스에 language 프로퍼티가 있고, 그 값이 "Kotlin"일 때 생략되는 예시예요.

// Imports declarations from the serialization library
import kotlinx.serialization.*
import kotlinx.serialization.builtins.*
import kotlinx.serialization.json.*

//sampleStart
@Serializable
class Project(val name: String, val language: String)

// Creates a custom serializer that omits the language property if it's equal to "Kotlin"
object ProjectSerializer : JsonTransformingSerializer<Project>(Project.serializer()) {
    override fun transformSerialize(element: JsonElement): JsonElement =
        // Omits the language property if its value is "Kotlin"
        JsonObject(element.jsonObject.filterNot {
            (k, v) -> k == "language" && v.jsonPrimitive.content == "Kotlin"
        })
}

fun main() {
    val data = Project("kotlinx.serialization", "Kotlin")

    // Uses the default serializer
    println(Json.encodeToString(data))
    // {"name":"kotlinx.serialization","language":"Kotlin"}

    // Applies the custom serializer to omit the language property 
    println(Json.encodeToString(ProjectSerializer, data))
    // {"name":"kotlinx.serialization"}
}
//sampleEnd

객체를 직접 직렬화할 때는 커스텀 직렬화 로직이 적용되도록 encodeToString() 함수에 커스텀 직렬화기를 명시적으로 넘겨야 해요. 자세한 내용은 직렬화기 수동으로 넘기기를 참고해 주세요.

JSON 내용에 기반해 적절한 다형성 클래스 선택하기

다형성 직렬화에서 JSON은 역직렬화 중 구체 하위 타입을 식별하는 전용 클래스 구분자 프로퍼티를 흔히 포함해요.

JSON 입력에 클래스 구분자가 없다면 JsonContentPolymorphicSerializer를 사용해 JSON 구조에서 타입을 추론할 수 있어요. 이 직렬화기는 selectDeserializer() 함수를 오버라이드해 JSON 내용에 기반해 올바른 직직렬화기를 선택하게 합니다.

다음은 모든 값이 name 프로퍼티를 가진 공통 기본 타입을 공유하는 예시예요.

@Serializable
abstract class Project {
    abstract val name: String
}

@Serializable
data class BasicProject(override val name: String): Project()

@Serializable
data class OwnedProject(override val name: String, val owner: String) : Project()

이 예시에서 직렬화기는 JSON 내용에 기반해 어떤 하위 타입을 쓸지 선택하므로, 클래스 계층에 sealed 클래스가 필요 없어요.

BasicProjectOwnedProject를 구분하려면 selectDeserializer() 함수를 오버라이드하면 돼요. 이 함수로 JSON 객체에 owner 키가 있는지 확인하고 해당 직렬화기를 반환할 수 있습니다.

// Creates a custom serializer that selects deserializer based on the presence of "owner"
object ProjectSerializer : JsonContentPolymorphicSerializer<Project>(Project::class) {
    override fun selectDeserializer(element: JsonElement) = when {
        // Selects the OwnedProject serializer if the JSON object contains an "owner" key
        "owner" in element.jsonObject -> OwnedProject.serializer()
        else -> BasicProject.serializer()
    }
}

이 직렬화기로 데이터를 직렬화하면 Kotlin 직렬화는 값의 실제 런타임 타입의 직렬화기를 사용해요. 이는 SerializersModule에 지정된 직렬화기거나 기본 직렬화기일 수 있어요.

// Imports declarations from the serialization library
import kotlinx.serialization.*
import kotlinx.serialization.builtins.*
import kotlinx.serialization.json.*


@Serializable
abstract class Project {
    abstract val name: String
}

@Serializable
data class BasicProject(override val name: String): Project()

@Serializable
data class OwnedProject(override val name: String, val owner: String) : Project()

// Creates a custom serializer that selects deserializer based on the presence of "owner"
object ProjectSerializer : JsonContentPolymorphicSerializer<Project>(Project::class) {
    override fun selectDeserializer(element: JsonElement) = when {
        // Selects the OwnedProject serializer if the JSON object contains an "owner" key
        "owner" in element.jsonObject -> OwnedProject.serializer()
        else -> BasicProject.serializer()
    }
}

//sampleStart
fun main() {
    val data = listOf(
        OwnedProject("kotlinx.serialization", "kotlin"),
        BasicProject("example")
    )
    // No class discriminator in the JSON output
    val string = Json.encodeToString(ListSerializer(ProjectSerializer), data)

    println(string)
    // [{"name":"kotlinx.serialization","owner":"kotlin"},{"name":"example"}]

    println(Json.decodeFromString(ListSerializer(ProjectSerializer), string))
    // [OwnedProject(name=kotlinx.serialization, owner=kotlin), BasicProject(name=example)]
}
//sampleEnd

기본 직렬화기에 커스텀 동작 추가하기

Kotlin 직렬화가 생성한 기본 직렬화기에 커스텀 동작을 추가할 수 있어요. 기본 직렬화기를 위임자(delegate)로 사용하면 됩니다.

이렇게 하려면 직렬화 가능한 클래스를 Experimental @KeepGeneratedSerializer로 표시하고, 자동 생성된 generatedSerializer()를 커스텀 JsonTransformingSerializer의 기본 직렬화기로 사용하면 돼요.

다음은 역직렬화 중 여러 입력 프로퍼티를 대상 클래스가 기대하는 단일 name 프로퍼티로 합쳐 JSON 구조를 갱신하는 예시예요.

// Imports declarations from the serialization library
import kotlinx.serialization.*
import kotlinx.serialization.json.*

//sampleStart
@OptIn(ExperimentalSerializationApi::class)
@KeepGeneratedSerializer
@Serializable(with = UserNameSerializer::class)
// Defines a type with a name property
data class User(val name: String)

// Adds custom logic to the default serializer to combine input properties during deserialization
object UserNameSerializer : JsonTransformingSerializer<User>(User.generatedSerializer()) {
    override fun transformDeserialize(element: JsonElement): JsonElement {
        val jsonObject = element.jsonObject
        val first = jsonObject["firstName"]?.jsonPrimitive?.content
        val last = jsonObject["lastName"]?.jsonPrimitive?.content

        // Combines input properties into the name property
        // if the input doesn't match the expected structure
        return if (first != null && last != null) {
            JsonObject(mapOf("name" to JsonPrimitive("$first $last")))
        } else {
            jsonObject
        }
    }
}

fun main() {
    // Deserializes JSON where the name property is split across multiple input properties
   val fromExternalData = Json.decodeFromString<User>(
        """{"firstName":"John","lastName":"Smith"}"""
    )
    println(fromExternalData)
    // User(name=John Smith)

    // Deserializes JSON where the name property matches the expected structure
    val fromInternalData = Json.decodeFromString<User>(
        """{"name":"John Smith"}"""
    )
    println(fromInternalData)
    // User(name=John Smith)
}
//sampleEnd

JSON에서 커스텀 직렬화 로직 구현하기

JsonTransformingSerializerJsonContentPolymorphicSerializer가 제공하는 변환 함수로 충분하지 않다면, 직접 KSerializer 클래스를 정의해 커스텀 직렬화 로직을 구현할 수 있어요.

serialize()deserialize() 함수를 직접 오버라이드해 값이 직렬화·역직렬화되는 방식을 완전히 제어할 수 있습니다.

JSON용 커스텀 직렬화 로직을 구현할 때는 EncoderJsonEncoder로, DecoderJsonDecoder로 캐스팅해 JSON 전용 함수 decodeJsonElement()encodeToJsonElement()를 호출할 수 있어요. 이 함수들로 디코더가 현재 처리 중인 값에서 JSON 요소를 가져오거나 JSON 요소를 삽입할 수 있습니다.

JsonDecoderJsonEncoder는 모두 json 프로퍼티를 노출해 활성 Json 인스턴스에 접근하게 해 주는데, 이 인스턴스가 값이 인코딩·디코딩되는 방식을 제어해요. 이 인스턴스를 통해 encodeToJsonElement()decodeFromJsonElement()을 사용해 JsonElement 인스턴스와 Kotlin 객체 사이를 변환할 수 있어요.

이 API들을 사용해 2단계 변환(two-stage conversion)을 구현할 수 있습니다.

  • 먼저 입력을 JsonElement로 디코딩한 다음 그 요소를 Kotlin 값으로 변환.
  • 먼저 Kotlin 값을 JsonElement로 변환한 다음 그 요소를 인코더로 인코딩.

Response 타입의 값이 JSON에서 인코딩·디코딩되는 방식을 완전히 제어하는 커스텀 KSerializer 예시를 볼게요. 이 직렬화기는 Ok 응답을 JSON 값으로 직접 인코딩하고, Error 응답을 오류 메시지를 담은 JSON 객체로 인코딩합니다.

// Imports declarations from the serialization library
import kotlinx.serialization.*
import kotlinx.serialization.builtins.*
import kotlinx.serialization.json.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.*

// Defines a sealed class for API responses
@Serializable(with = ResponseSerializer::class)
sealed class Response<out T> {
    data class Ok<out T>(val data: T) : Response<T>()
    data class Error(val message: String) : Response<Nothing>()
}

// Implements custom serialization logic for Response
class ResponseSerializer<T>(
    private val dataSerializer: KSerializer<T>
) : KSerializer<Response<T>> {
    override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Response") {
        element("Ok", dataSerializer.descriptor)
        element("Error", buildClassSerialDescriptor("Error") {
            element<String>("message")
        })
    }
    // Deserializes a Response value from JSON
    override fun deserialize(decoder: Decoder): Response<T> {
        // Ensures that the decoder is a JsonDecoder
        require(decoder is JsonDecoder)

        // Decodes the input into a JsonElement
        val element = decoder.decodeJsonElement()

        // Converts the JsonElement into the corresponding Response value
        return if (element is JsonObject && "error" in element) {
            Response.Error(element["error"]!!.jsonPrimitive.content)
        } else {
            Response.Ok(
                decoder.json.decodeFromJsonElement(dataSerializer, element)
            )
        }
    }

    // Serializes a Response value to JSON
    override fun serialize(encoder: Encoder, value: Response<T>) {
        // Ensures that the encoder is a JsonEncoder
        require(encoder is JsonEncoder)

        // Converts the Response value into a JsonElement
        val element = when (value) {
            is Response.Ok ->
                encoder.json.encodeToJsonElement(dataSerializer, value.data)
            is Response.Error ->
                buildJsonObject { put("error", value.message) }
        }

        // Encodes the JsonElement using the encoder
        encoder.encodeJsonElement(element)
    }
}

@Serializable
data class Project(val name: String)

fun main() {
    val responses = listOf(
        Response.Ok(Project("kotlinx.serialization")),
        Response.Error("Not found")
    )

    val json = Json.encodeToString(responses)
    println(json)
    // [{"name":"kotlinx.serialization"},{"error":"Not found"}]

    println(Json.decodeFromString<List<Response<Project>>>(json))
    // [Ok(data=Project(name=kotlinx.serialization)), Error(message=Not found)]
}

알 수 없는 JSON 속성 보존하기

커스텀 JSON 전용 직렬화기의 일반적인 사용 사례는 직렬화 가능한 클래스가 정의하지 않은 입력의 JSON 프로퍼티를 보존하는 거예요. 기본적으로 이 프로퍼티들은 역직렬화 중 무시됩니다.

이 JSON 프로퍼티들을 보존하려면, 역직렬화 중 대상 클래스에 정의되지 않은 모든 프로퍼티를 전용 JsonObject 필드로 모으는 커스텀 JSON 전용 직렬화기를 구현하면 돼요. 이렇게 하면 원래 JSON 구조를 수정하지 않고도 직렬화 가능한 클래스에 이 프로퍼티들을 보존할 수 있어요.

예시를 볼게요.

// Imports declarations from the serialization library
import kotlinx.serialization.*
import kotlinx.serialization.builtins.*
import kotlinx.serialization.json.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.*

//sampleStart
data class UnknownProject(val name: String, val details: JsonObject)

object UnknownProjectSerializer : KSerializer<UnknownProject> {
    override val descriptor: SerialDescriptor = buildClassSerialDescriptor("UnknownProject") {
        element<String>("name")
        element<JsonElement>("details")
    }

    override fun deserialize(decoder: Decoder): UnknownProject {
        // Ensures the decoder is JSON-specific
        val jsonInput = decoder as? JsonDecoder ?: error("Can be deserialized only by JSON")

        // Reads the entire content as JSON
        val json = jsonInput.decodeJsonElement().jsonObject

        // Extracts and removes the name property
        val name = json.getValue("name").jsonPrimitive.content
        val details = json.toMutableMap()
        details.remove("name")
        return UnknownProject(name, JsonObject(details))
    }

    override fun serialize(encoder: Encoder, value: UnknownProject) {
        error("Serialization is not supported")
    }
}

fun main() {
    // Deserializes JSON with properties not defined in the serializable class into UnknownProject
    println(Json.decodeFromString(UnknownProjectSerializer, """{"type":"unknown","name":"example","maintainer":"Unknown","license":"Apache 2.0"}"""))
    // UnknownProject(name=example, details={"type":"unknown","maintainer":"Unknown","license":"Apache 2.0"})

}
//sampleEnd

이 예시에서 보존된 JSON 프로퍼티는 직렬화 가능한 클래스에 정의된 프로퍼티와 같은 입력 JSON 객체의 같은 수준에 남아 있어요.

더 알아보기