서버와 JSON을 주고받는 코드는 안드로이드에서 보통 Gson이나 Moshi로 처리합니다. 두 라이브러리 모두 리플렉션이나 어노테이션 프로세서에 의존하는데, 이 방식은 JVM 바이트코드를 전제로 하기 때문에 iOS 타겟인 commonMain에서는 그대로 쓸 수 없습니다. JetBrains가 만든 kotlinx-serialization은 컴파일 타임에 컴파일러 플러그인이 직렬화 코드를 생성하는 방식이라, 순수 Kotlin 코드만으로 안드로이드와 iOS 양쪽에서 동일하게 동작합니다.

왜 필요한가

Gson은 리플렉션으로 필드에 접근하는데, Kotlin/Native에는 JVM 리플렉션 API 자체가 없어서 애초에 동작하지 않습니다. Moshi는 코드 생성 방식(moshi-kotlin-codegen)을 쓰면 리플렉션 문제는 피할 수 있지만, 생성된 어댑터가 KSP/kapt 산출물이라 commonMain에서 멀티플랫폼 타겟으로 공유하도록 설계돼 있지 않습니다. kotlinx-serialization은 Kotlin 컴파일러 플러그인이 @Serializable 클래스마다 직렬화기를 직접 만들어주기 때문에, 이 문제 자체가 발생하지 않습니다.

핵심 개념

Gradle에 컴파일러 플러그인과 런타임 라이브러리를 함께 등록합니다.

// build.gradle.kts
plugins {
    kotlin("multiplatform")
    kotlin("plugin.serialization") version "2.0.21"
}

kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
        }
    }
}

클래스에 @Serializable을 붙이면 컴파일 시점에 직렬화기가 생성되고, Json 인스턴스로 인코딩/디코딩합니다.

// commonMain/model/UserProfile.kt
import kotlinx.serialization.Serializable

@Serializable
data class UserProfile(
    val id: String,
    val nickname: String,
    val followerCount: Int = 0,
)
import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json

val json = Json {
    ignoreUnknownKeys = true
    encodeDefaults = true
}

val encoded = json.encodeToString(UserProfile(id = "u1", nickname = "kim"))
// {"id":"u1","nickname":"kim","followerCount":0}

val decoded = json.decodeFromString<UserProfile>(encoded)

ignoreUnknownKeys를 켜두지 않으면 서버가 새 필드를 추가해서 배포할 때마다 클라이언트가 파싱 에러를 던집니다. 클라이언트 배포 주기가 서버보다 느린 경우가 많으므로 기본적으로 켜두는 편이 안전합니다.

실전 예시: 다형성 응답 처리하기

피드 API가 텍스트 게시물과 이미지 게시물을 같은 배열에 섞어서 내려주는 경우가 있습니다. 각 항목의 type 필드로 실제 타입을 구분해야 합니다.

[
  { "type": "text", "id": "1", "content": "오늘 배운 것" },
  { "type": "image", "id": "2", "imageUrl": "https://example.com/1.jpg" }
]

sealed interface에 @SerialName으로 각 하위 타입의 판별 값을 지정하면, 별도의 SerializersModule 등록 없이 Json이 type 필드를 보고 알아서 적절한 하위 클래스로 디코딩합니다.

// commonMain/model/FeedItem.kt
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
sealed interface FeedItem {
    val id: String

    @Serializable
    @SerialName("text")
    data class TextPost(override val id: String, val content: String) : FeedItem

    @Serializable
    @SerialName("image")
    data class ImagePost(override val id: String, val imageUrl: String) : FeedItem
}
val items = json.decodeFromString<List<FeedItem>>(responseBody)
items.forEach { item ->
    when (item) {
        is FeedItem.TextPost -> println("텍스트: ${item.content}")
        is FeedItem.ImagePost -> println("이미지: ${item.imageUrl}")
    }
}

판별 필드로 쓰는 키 이름은 Json { classDiscriminator = "type" }로 바꿀 수 있는데, 기본값이 이미 "type"이라 서버 응답 필드명과 일치한다면 별도 설정이 필요 없습니다.

겪은 문제: 필드가 사라지면서 발생한 MissingFieldException

서버 쪽에서 followerCount 필드를 응답에서 제거하고 배포한 적이 있습니다. 처음에는 이 필드에 기본값을 주지 않고 val followerCount: Int로 선언해뒀는데, 배포 직후부터 파싱이 실패했습니다.

kotlinx.serialization.MissingFieldException: Field 'followerCount' is required for type with serial name 'UserProfile', but it was missing

ignoreUnknownKeys는 응답에 "모르는 필드가 더 있을 때"를 위한 옵션이라 이 상황에는 적용되지 않습니다. 반대로 "정의된 필드가 응답에 없을 때"는 그 필드에 기본값이 있어야만 디코딩이 통과됩니다. followerCount: Int = 0처럼 기본값을 채워두는 것으로 해결했고, 이후로는 클라이언트에서 언젠가 사라질 수 있는 필드는 처음부터 기본값을 붙여서 선언하도록 정했습니다.

장단점 정리

장점

  • 리플렉션 없이 컴파일 타임에 직렬화기를 생성하므로 안드로이드·iOS 양쪽에서 동일한 DTO 코드를 그대로 씁니다.
  • sealed interface + @SerialName만으로 다형성 응답을 처리할 수 있어서, Gson의 RuntimeTypeAdapterFactory 같은 별도 어댑터를 만들 필요가 없습니다.
  • Ktor Client의 ContentNegotiation 플러그인과 바로 연동돼서 네트워킹 레이어에 추가 변환 코드가 필요 없습니다.

단점

  • @Serializable을 빠뜨리거나 컴파일러 플러그인 버전이 Kotlin 버전과 어긋나면 원인을 바로 알기 어려운 컴파일 에러가 납니다.
  • Gson처럼 런타임에 임의 클래스를 직렬화할 수 없고, 대상 클래스에 반드시 @Serializable이 붙어 있어야 합니다. 외부 라이브러리가 제공하는 클래스를 직렬화하려면 커스텀 시리얼라이저를 별도로 작성해야 합니다.

서버와 주고받는 DTO를 commonMain에 두고 안드로이드·iOS 양쪽에서 공유해야 한다면, 처음부터 kotlinx-serialization으로 시작하는 편이 나중에 Gson·Moshi에서 옮겨오는 것보다 수월합니다.