클래스 직렬화하기

클래스 직렬화하기

@Serializable 어노테이션(annotation)은 backing field를 가진 모든 클래스 프로퍼티의 기본 직렬화를 가능하게 해요. 이 기본 동작을 상황에 맞게 커스터마이즈할 수 있는데, 이 페이지에서는 어떤 프로퍼티를 직렬화할지 지정하는 방법과 직렬화 과정을 관리하는 여러 기법을 차근차근 다뤄 볼게요.

시작하기 전에 필요한 라이브러리 의존성을 잘 가져왔는지 먼저 확인해 주세요.

출처: Serialize classes

본문

@Serializable 어노테이션

@Serializable 어노테이션은 클래스 프로퍼티의 자동 직렬화를 켜 주는 역할을 해요. 덕분에 클래스를 JSON 같은 형식으로 변환하거나, 반대로 JSON에서 다시 클래스로 되돌릴 수 있죠.

Kotlin에서는 backing field가 있는 프로퍼티만 직렬화됩니다.

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

//sampleStart
@Serializable
class Project(
    // Property with a backing field – serialized
    var name: String
) {
    // Property with a backing field – serialized
    var stars: Int = 0

    // Getter-only property without a backing field - not serialized
    val path: String
        get() = "kotlin/$name"

    // Delegated property - not serialized
    var id by ::name
}

fun main() {
    val data = Project("kotlinx.serialization").apply { stars = 9000 }
    // Prints only the name and the stars properties
    println(Json.encodeToString(data))
    // {"name":"kotlinx.serialization","stars":9000}
}
//sampleEnd

클래스 참조 직렬화하기

@Serializable로 표시된 클래스는 다른 클래스를 참조하는 프로퍼티를 가질 수 있어요. 이때 참조되는 클래스 역시 @Serializable로 표시되어 있어야 하고, JSON으로 인코딩하면 중첩된 JSON 객체가 만들어져요.

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

//sampleStart
@Serializable
// The owner property references another serializable class User
class Project(val name: String, val owner: User)

// The referenced class must also be annotated with @Serializable
@Serializable
class User(val name: String)

fun main() {
    val owner = User("kotlin")
    val data = Project("kotlinx.serialization", owner)
    println(Json.encodeToString(data))
    // {"name":"kotlinx.serialization","owner":{"name":"kotlin"}}
}
//sampleEnd

직렬화할 수 없는 클래스를 참조해야 한다면, 해당 프로퍼티를 @Transient로 표시하거나 커스텀 직렬화기를 만들어 주면 돼요.

반복된 객체 참조의 직렬화

Kotlin 직렬화는 일반 데이터를 인코딩/디코딩하도록 설계되어 있어요. 그래서 같은 객체를 여러 번 참조하는 임의의 객체 그래프를 재구성하는 것은 지원하지 않아요. 예를 들어 같은 인스턴스를 두 번 참조하는 객체를 직렬화하면, 그 인스턴스는 두 번 인코딩됩니다.

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

//sampleStart
@Serializable
class Project(val name: String, val owner: User, val maintainer: User)

@Serializable
class User(val name: String)

fun main() {
    val owner = User("kotlin")
    // References owner twice
    val data = Project("kotlinx.serialization", owner, owner)
    println(Json.encodeToString(data))
    // {"name":"kotlinx.serialization","owner":{"name":"kotlin"},"maintainer":{"name":"kotlin"}}
}
//sampleEnd

순환 구조(circular structure)를 직렬화하려고 하면 스택 오버플로(stack overflow)가 발생해요. 참조를 직렬화에서 제외하고 싶다면 @Transient 어노테이션을 쓰면 돼요.

제네릭 클래스 직렬화하기

Kotlin의 제네릭 클래스는 타입 다형성(type-polymorphism)을 지원하는데, Kotlin 직렬화가 컴파일 타임에 이를 강제해요. 예를 들어 다음처럼 제네릭 직렬화 가능 클래스 Payload<T>를 생각해 볼게요.

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

//sampleStart
@Serializable
// The Payload<T> class can be used with built-in types like Int
// or with @Serializable user-defined types like Repository
class Payload<T>(val value: T)

@Serializable
data class Repository(val name: String, val language: String)

@Serializable
class BackupData(
    val issueCount: Payload<Int>,
    val mainRepo: Payload<Repository>
)

fun main() {
    val backup = BackupData(
        Payload(42),
        Payload(Repository("kotlinx.serialization", "Kotlin"))
    )
    println(Json.encodeToString(backup))
    // {"issueCount":{"value":42},"mainRepo":{"value":{"name":"kotlinx.serialization","language":"Kotlin"}}}
}
//sampleEnd

Box<T> 같은 제네릭 클래스를 직렬화하면, JSON 출력은 컴파일 타임에 T에 지정한 실제 타입에 따라 달라져요. 그 타입이 직렬화 가능하지 않으면 컴파일 타임 오류가 발생합니다.

선택적(optional) 프로퍼티

기본값(default value)이 있는 프로퍼티는 역직렬화 시 선택적(optional)이 되고, 형식(format) 설정에 따라 직렬화 시 생략될 수도 있어요. 예를 들어 기본 JSON 설정은 기본값을 인코딩에서 제외합니다.

선택적 프로퍼티의 기본값 설정하기

Kotlin에서는 입력에 모든 프로퍼티가 들어 있어야만 객체를 역직렬화할 수 있어요. 프로퍼티를 직렬화에서 선택적으로 만들려면, 입력에 값이 없을 때 사용할 기본값을 지정하면 됩니다.

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

//sampleStart
@Serializable
// Sets a default value for the optional language property
data class Project(val name: String, val language: String = "Kotlin")

fun main() {
    val data = Json.decodeFromString<Project>("""
        {"name":"kotlinx.serialization"}
    """)
    println(data)
    // Project(name=kotlinx.serialization, language=Kotlin)
}
//sampleEnd

nullable 프로퍼티 직렬화하기

Kotlin 직렬화는 nullable 프로퍼티를 기본적으로 지원해요. 다른 기본값과 마찬가지로 null 값은 JSON 출력에 인코딩되지 않습니다.

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

//sampleStart
@Serializable
// Defines a class with the renamedTo nullable property that has a null default value
class Project(val name: String, val renamedTo: String? = null)

fun main() {
    val data = Project("kotlinx.serialization")
    // The renamedTo property isn't encoded because its value is null
    println(Json.encodeToString(data))
    // {"name":"kotlinx.serialization"}
}
//sampleEnd

추가로 Kotlin의 null 안전성은 역직렬화 과정에서도 강하게 적용돼요. JSON 객체가 non-nullable 프로퍼티에 null 값을 담고 있으면, 그 프로퍼티가 기본값을 갖고 있더라도 예외가 발생합니다.

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

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

fun main() {
    val data = Json.decodeFromString<Project>("""
        {"name":"kotlinx.serialization","language":null}
    """)
    println(data)
    // JsonDecodingException
}
//sampleEnd

외부 서드파티 JSON에서 온 null 값을 처리해야 한다면, 그 값을 기본값으로 강제 변환(coerce)할 수 있어요.

또한 explicitNulls 프로퍼티를 쓰면 인코딩된 JSON에서 명시적인 null 값들을 생략할 수도 있어요.

선택적 프로퍼티의 초기화와 부수 효과

직렬화 입력에 선택적 프로퍼티의 값이 포함되어 있으면, 해당 프로퍼티의 초기화 코드(initializer)는 호출되지 않아요. 그래서 프로퍼티 초기화 코드에 부수 효과(side effect)가 있는 로직을 넣지 않는 게 좋아요.

예시를 볼게요.

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

//sampleStart
fun computeLanguage(): String {
    println("Computing")
    return "Kotlin"
}

@Serializable
// Skips the initializer if language is in the input
data class Project(val name: String, val language: String = computeLanguage())

fun main() {
    val data = Json.decodeFromString<Project>("""
        {"name":"kotlinx.serialization","language":"Java"}
    """)
    println(data)
    // Project(name=kotlinx.serialization, language=Java)
}
//sampleEnd

이 예시에서는 입력에 language 프로퍼티가 지정되어 있으므로, 출력에 Computing 문자열이 찍히지 않아요.

@EncodeDefault로 기본값 프로퍼티의 직렬화 관리하기

기본적으로 JSON 직렬화는 기본값이 있는 프로퍼티를 제외해요. 이렇게 하면 직렬화된 데이터의 크기를 줄이고 불필요한 시각적 잡음도 피할 수 있죠.

다음 예시에서 language 프로퍼티는 값이 기본값과 같기 때문에 출력에서 제외돼요.

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

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

fun main() {
    val data = Project("kotlinx.serialization")
    println(Json.encodeToString(data))
    // {"name":"kotlinx.serialization"}
}
//sampleEnd

Json 인스턴스를 구성해 모든 프로퍼티의 기본값을 기본적으로 인코딩하도록 만들 수도 있어요.

값이나 형식 설정과 관계없이 특정 프로퍼티를 항상 직렬화하려면 @EncodeDefault 어노테이션을 쓰면 돼요. 또는 EncodeDefault.Mode 파라미터를 설정해 동작을 바꿀 수도 있어요.

다음 예시에서는 language 프로퍼티는 항상 직렬화 출력에 포함되고, projects 프로퍼티는 빈 리스트일 때만 제외되는 모습을 볼게요.

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

//sampleStart
@Serializable
data class Project(
    val name: String,
    // Always includes the language property in the serialized output
    // even if it has the default value "Kotlin"
    @EncodeDefault val language: String = "Kotlin"
)

@Serializable
data class User(
    val name: String,
    // Excludes projects when it's an empty list, even if it has a default value
    @EncodeDefault(NEVER) val projects: List<Project> = emptyList()
)

fun main() {
    val adminUser = User("Alice", listOf(Project("kotlinx.serialization")))
    val guestUser = User("Bob")
    // Serializes projects because it contains a value
    // language is always serialized
    println(Json.encodeToString(adminUser))
    // {"name":"Alice","projects":[{"name":"kotlinx.serialization","language":"Kotlin"}]}

    // Excludes projects because it's an empty list
    // and EncodeDefault.Mode is set to NEVER, so it's not serialized
    println(Json.encodeToString(guestUser))
    // {"name":"Bob"}
}
//sampleEnd

@Required 어노테이션으로 프로퍼티 필수 지정하기

프로퍼티에 @Required를 붙이면 그 프로퍼티가 입력에 반드시 존재해야 해요. 기본값이 있더라도 입력에 해당 프로퍼티가 들어 있어야 한다는 뜻이에요.

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

//sampleStart
@Serializable
// Marks the language property as required
data class Project(val name: String, @Required val language: String = "Kotlin")

fun main() {
    val data = Json.decodeFromString<Project>("""
        {"name":"kotlinx.serialization"}
    """)
    println(data)
    // MissingFieldException
}
//sampleEnd

클래스 직렬화 커스터마이즈하기

Kotlin 직렬화는 클래스가 직렬화되는 방식을 수정하는 여러 방법을 제공해요. 이 절에서는 프로퍼티 이름 바꾸기, 프로퍼티 포함 여부 제어 등을 다뤄 볼게요.

직렬 이름(serial name) 커스터마이즈하기

기본적으로 JSON 같은 직렬화 출력의 프로퍼티 이름은 소스 코드의 이름과 같아요.

이 이름들(직렬 이름, serial name)을 @SerialName 어노테이션으로 바꿀 수 있어요. 프로퍼티 이름을 직렬화 출력에서 더 설명적으로 만들고 싶을 때 쓰면 됩니다.

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

//sampleStart
@Serializable
// Changes the lang property to language using @SerialName
class Project(val name: String, @SerialName("language") val lang: String)

fun main() {
    val data = Project("kotlinx.serialization", "Kotlin")
    // Prints the more descriptive property name in the JSON output
    println(Json.encodeToString(data))
    // {"name":"kotlinx.serialization","language":"Kotlin"}
}
//sampleEnd

직렬화용 생성자 프로퍼티 정의하기

@Serializable로 표시된 클래스는 주 생성자(primary constructor)의 모든 파라미터를 프로퍼티로 선언해야 해요.

프로퍼티 값을 할당하기 전에 추가 초기화 로직이 필요하다면, 보조 생성자(secondary constructor)를 사용하면 돼요. 주 생성자는 private으로 두고 프로퍼티 초기화를 담당하게 할 수 있어요.

다음 예시에서는 보조 생성자가 문자열을 두 값으로 파싱해서, 그 값들을 직렬화 담당인 주 생성자에 넘기는 구조예요.

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

//sampleStart
@Serializable
class Project private constructor(val owner: String, val name: String) {
    // Creates a Project object using a path string
    constructor(path: String) : this(
        owner = path.substringBefore('/'),
        name = path.substringAfter('/')
    )

    val path: String
        get() = "$owner/$name"
}
fun main() {
    println(Json.encodeToString(Project("kotlin/kotlinx.serialization")))
    // {"owner":"kotlin","name":"kotlinx.serialization"}
}
//sampleEnd

주 생성자에서 데이터 검증하기

역직렬화 후 kotlinx.serialization 플러그인은 인스턴스를 만들 때처럼 클래스의 초기화 블록(initializer blocks)을 실행해요. 그래서 생성자 파라미터를 검증하고, 역직렬화 중에 잘못된 데이터를 거부할 수 있어요.

예시를 볼게요.

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

//sampleStart
@Serializable
class Project(val name: String) {
    // Validates that the name is not empty
    init {
        require(name.isNotEmpty()) { "name cannot be empty" }
    }
}

fun main() {
    val data = Json.decodeFromString<Project>("""
        {"name":""}
    """)
    println(data)
    // Exception in thread "main" java.lang.IllegalArgumentException: name cannot be empty
}
//sampleEnd

@Transient 어노테이션으로 프로퍼티 제외하기

@Transient 어노테이션으로 프로퍼티를 직렬화에서 뺄 수 있어요. transient 프로퍼티는 반드시 기본값을 가져야 합니다.

입력에 transient 프로퍼티 값을 명시적으로 넣으면, 그 값이 기본값과 같더라도 JsonDecodingException이 발생해요.

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

//sampleStart
@Serializable
// Excludes the language property from serialization
data class Project(val name: String, @Transient val language: String = "Kotlin")

fun main() {
    // Throws an exception even though input matches the default value
    val data = Json.decodeFromString<Project>("""
        {"name":"kotlinx.serialization","language":"Kotlin"}
    """)
    println(data)
    // JsonDecodingException
}
//sampleEnd

@Transient로 표시된 키를 포함해 JSON에 모르는 키(unknown key)가 있어도 예외가 나지 않게 하려면, ignoreUnknownKeys 설정 프로퍼티를 켜면 돼요. 자세한 내용은 알 수 없는 키 무시하기 절을 참고해 주세요.

더 알아보기