JSON 요소(JSON elements)

JSON 요소(JSON elements)

Kotlin 직렬화 라이브러리는 JSON을 구조적(structural) 수준에서 다루는 것도 지원해요. JsonElement API를 쓰면 JSON 구조를 Kotlin 타입이나 문자열로 변환하기 전에 직접 검사·수정·구축할 수 있습니다.

JsonElement에는 핵심 JSON 구조를 나타내는 세 가지 직접 하위 타입이 있어요.

  • JsonPrimitive는 문자열, 숫자, boolean, null 같은 프리미티브 JSON 요소를 다뤄요. null 값은 JsonNull이라는 JsonPrimitive의 특수 하위 클래스로 표현돼요. 각 JsonPrimitive는 값의 문자열 표현을 저장하고, 이것을 JsonPrimitive.content 프로퍼티로 접근할 수 있어요.
  • JsonArray는 JSON 배열을 나타내고, JsonElement 항목들의 Kotlin List예요.
  • JsonObject는 JSON 객체를 나타내고, String 키와 JsonElement 값을 가진 Kotlin Map이에요.

출처: JSON elements

본문

문자열을 JSON 요소로 파싱하기

문자열을 JsonElement로 파싱하면, Kotlin 타입이나 문자열로 변환하기 전에 JSON 구조로 작업할 수 있어요.

Json.parseToJsonElement() 함수를 쓰면 입력을 디코딩·역직렬화하지 않고 JSON 요소 트리로 파싱할 수 있습니다.

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

//sampleStart
fun main() {
    val element = Json.parseToJsonElement("""
        {"name":"kotlinx.serialization","language":"Kotlin"}
    """)
    // JsonElement.toString() gives you a valid JSON string
    println(element)
    // {"name":"kotlinx.serialization","language":"Kotlin"}
}
//sampleEnd

JSON 요소의 내용에 접근하기

JsonElement API의 확장 프로퍼티로 JSON 요소의 내용에 직접 접근할 수 있어요. 이 확장 프로퍼티들은 요소를 특정 하위 타입으로 캐스팅하며, 요소가 예상한 JSON 구조가 아니면 IllegalArgumentException을 던져요.

사용할 수 있는 확장 프로퍼티는 다음과 같아요.

마찬가지로 JsonPrimitive에도 값을 Kotlin 프리미티브 타입으로 파싱하는 확장 프로퍼티가 있어요. 예를 들어 int, intOrNull, long, longOrNull 같은 것들이 있죠.

다음은 구조를 알고 있는 JSON 데이터를 처리할 때 이 확장 프로퍼티들을 어떻게 쓰는지 보여 주는 예시예요.

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

//sampleStart
fun main() {
    val element = Json.parseToJsonElement("""
        {
            "name": "kotlinx.serialization",
            "forks": [{"votes": 42}, {"votes": 9000}, {}]
        }
    """)
    val sum = element
        // Accesses the forks key from the JsonObject
        .jsonObject["forks"]!!

        // Accesses the value as a JsonArray and sums the votes values from each JsonObject as Int
        .jsonArray.sumOf { it.jsonObject["votes"]?.jsonPrimitive?.int ?: 0 }
    println(sum)
    // 9042
}
//sampleEnd

JSON 구조를 미리 알 수 없다면 요소 타입을 확인하고 각 JsonElement 하위 타입을 명시적으로 처리하면 돼요. 예를 들어 when 표현식을 쓰는 헬퍼 함수를 만들 수 있죠.

fun processElement(element: JsonElement): String = when (element) {
    is JsonObject -> "JsonObject with keys: ${element.keys}"
    is JsonArray -> "JsonArray with ${element.size} elements"
    is JsonPrimitive -> "JsonPrimitive with content: ${element.content}"
}

JSON 요소 만들기

특정 JsonElement 하위 타입의 인스턴스를 직접 만들 수 있어요.

JsonPrimitive를 만들려면 JsonPrimitive() 함수를 사용하면 됩니다.

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

//sampleStart
fun main() {
    // Creates JsonPrimitive values from different Kotlin primitives
    val number = JsonPrimitive(42)
    val text = JsonPrimitive("kotlinx.serialization")

    println(number)
    // 42
    println(text)
    // "kotlinx.serialization"
}
//sampleEnd

JsonArrayJsonObject 요소는 직접 생성자를 호출하거나 빌더 함수를 사용해 만들 수 있어요.

빌더 함수는 Kotlin 표준 라이브러리의 컬렉션 빌더와 비슷한 DSL을 제공하며, JSON 전용 오버로드와 내부 빌더 함수가 포함돼 있어요.

다음은 JSON 빌더 DSL의 핵심 기능을 보여 주는 예시예요.

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

//sampleStart
fun main() {
    val element = buildJsonObject {
        // Adds a simple key-value pair to the JsonObject
        put("name", "kotlinx.serialization")
        // Adds a nested JsonObject under the owner key
        putJsonObject("owner") {
            put("name", "kotlin")
        }
        // Adds a JsonArray with multiple JsonObjects
        putJsonArray("forks") {
            // Adds a JsonObject to the JsonArray
            addJsonObject {
                put("votes", 42)
            }
            addJsonObject {
                put("votes", 9000)
            }
        }
    }
    // Prints the resulting JSON string
    println(element)
    // {"name":"kotlinx.serialization","owner":{"name":"kotlin"},"forks":[{"votes":42},{"votes":9000}]}
}
//sampleEnd

리터럴 JSON 내용 인코딩하기

JSON 명세는 숫자의 크기나 정밀도를 제한하지 않지만, JsonPrimitive() 함수로 임의 크기의 숫자를 직렬화하면 몇 가지 문제가 생길 수 있어요.

예를 들어 큰 숫자에 Double을 쓰면 값이 잘려 정밀도를 잃을 수 있어요. Kotlin/JVM의 BigDecimal을 쓰면 값은 정밀하게 유지되지만, JsonPrimitive()가 그 값을 숫자가 아니라 문자열로 인코딩합니다.

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

//sampleStart
val format = Json { prettyPrint = true }

fun main() {
    val pi = BigDecimal("3.141592653589793238462643383279")
    
    // Converts the BigDecimal to a Double, causing potential truncation
    val piJsonDouble = JsonPrimitive(pi.toDouble())
    // Converts the BigDecimal to a String, preserving the precision but treating it as a string in JSON
    val piJsonString = JsonPrimitive(pi.toString())
  
    val piObject = buildJsonObject {
        put("pi_double", piJsonDouble)
        put("pi_string", piJsonString)
    }

    println(format.encodeToString(piObject))
    // "pi_double": 3.141592653589793,
    // "pi_string": "3.141592653589793238462643383279"
}
//sampleEnd

이 예시에서 pi는 소수점 30자리 숫자로 정의됐지만, 결과 JSON은 그 정밀도를 보존하지 못해요. Double 값은 소수점 15자리로 잘리고, String 값은 따옴표로 감싸져서 숫자가 아니라 JSON 문자열이 됩니다.

이런 문제를 피하려면 JsonUnquotedLiteral() 함수로 이 예시의 pi 문자열 값 같은 따옴표 없는 임의 값을 인코딩할 수 있어요.

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

//sampleStart
val format = Json { prettyPrint = true }

fun main() {
    val pi = BigDecimal("3.141592653589793238462643383279")

    // Encodes the raw JSON content using JsonUnquotedLiteral()
    @OptIn(ExperimentalSerializationApi::class)
    val piJsonLiteral = JsonUnquotedLiteral(pi.toString())

    // Converts to Double and String
    val piJsonDouble = JsonPrimitive(pi.toDouble())
    val piJsonString = JsonPrimitive(pi.toString())

    val piObject = buildJsonObject {
        put("pi_literal", piJsonLiteral)
        put("pi_double", piJsonDouble)
        put("pi_string", piJsonString)
    }

    // pi_literal now accurately matches the value defined
    println(format.encodeToString(piObject))
    // "pi_literal": 3.141592653589793238462643383279,
    // "pi_double": 3.141592653589793,
    // "pi_string": "3.141592653589793238462643383279"
}
//sampleEnd

pi를 다시 BigDecimal로 디코딩하려면 JsonPrimitive의 문자열 내용을 추출하면 돼요.

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

//sampleStart
fun main() {
    val piObjectJson = """
          {
              "pi_literal": 3.141592653589793238462643383279
          }
      """.trimIndent()

    // Decodes the JSON string into a JsonObject
    val piObject: JsonObject = Json.decodeFromString(piObjectJson)

    // Extracts the string content from the JsonPrimitive
    val piJsonLiteral = piObject["pi_literal"]!!.jsonPrimitive.content

    // Converts the string to a BigDecimal
    val pi = BigDecimal(piJsonLiteral)
    // Prints the decoded value of pi, preserving all 30 decimal places
    println(pi)
    // 3.141592653589793238462643383279
}
//sampleEnd

이 예시는 단순함을 위해 JsonPrimitive를 사용했어요. 더 재사용 가능한 방법은 JSON 변환하기를 참고해 주세요.

JSON null 리터럴

일관되지 않은 상태를 만들지 않도록, JsonUnquotedLiteral() 함수로 "null" 문자열을 인코딩할 수는 없어요. 시도하면 예외가 발생합니다.

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

//sampleStart
@OptIn(ExperimentalSerializationApi::class)
fun main() {
    JsonUnquotedLiteral("null")
    // Exception in thread "main" kotlinx.serialization.json.internal.JsonEncodingException
}
//sampleEnd

JSON null 리터럴 값을 표현하려면 JsonNull을 사용하면 돼요.

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

//sampleStart
fun main() {
    val possiblyNull = JsonNull
  
    println(possiblyNull)
    // null
}
//sampleEnd

Json 요소 디코딩하기

JsonElement 클래스의 인스턴스를 직렬화 가능한 객체로 디코딩하려면 Json.decodeFromJsonElement() 함수를 사용하면 됩니다.

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

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

fun main() {
    val element = buildJsonObject {
        put("name", "kotlinx.serialization")
        put("language", "Kotlin")
    }

    // Decodes the JsonElement into a Project object
    val data = Json.decodeFromJsonElement<Project>(element)
    println(data)
    // Project(name=kotlinx.serialization, language=Kotlin)
}
//sampleEnd

더 알아보기

  • 직렬화·역직렬화 중 JSON을 변환하는 방법은 JSON 변환하기에서 더 제어할 수 있어요.
  • 클래스 직렬화하기에서 클래스를 직렬화하고 @Serializable 어노테이션의 기본 동작을 수정하는 방법을 배울 수 있어요.