
kotlinx-serialization이 다형성을 특별한 방식으로 처리하는 이유, 그리고 그 원리를 알아봅니다.
프로그램이 실행될 때, 모든 객체는 JVM 힙(heap) 메모리의 특정 주소에 존재합니다. TextMessage(message = ”Hello”)인스턴스를 만들면 메모리 어딘가에 이런 구조가 생깁니다.
힙 메모리 (프로세스 내부):
주소 0x7f3a0c11
├── 클래스 포인터 → TextMessage.class (이 객체의 타입 정보)
└── message → 0x7f3a0c30 → "Hello" (문자열은 다른 곳에 저장)
이 레이아웃은 지금 실행 중인 이 JVM 프로세스에서만 유효합니다. 포인터(주소값)들은 다른 프로세스에서는 의미가 없고, 프로세스가 종료되면 사라집니다.
이 객체를 네트워크로 보내거나, 파일에 저장하거나, 다른 프로세스로 전달하려면 이 메모리 구조를 어디서든 해석 가능한 바이트 시퀀스로 바꿔야 합니다. 이 변환이 직렬화입니다.
직렬화 (객체 → 바이트 시퀀스): TextMessage("Hello") → {"message":"Hello"}
역직렬화 (바이트 시퀀스 → 객체): {"message":"Hello"} → TextMessage("Hello")
이 모습을 통해, 직렬화는 포인터가 여기저기 연결된 2차원적 메모리 그래프를 1차원의 선형 바이트 스트림으로 펼쳐내는 것임을 알 수 있습니다.
직렬화의 결과물인 ‘바이트 시퀀스’가 어떤 형태를 띠는지는 포맷에 따라 크게 달라집니다. 포맷은 크게 텍스트 기반과 바이너리 기반으로 나뉩니다.
텍스트 기반 포맷은 사람이 직접 읽을 수 있습니다.
바이너리 포맷은 사람이 직접 읽을 수 없지만 크기와 속도에서 유리합니다.
.proto 스키마 파일로 구조를 사전 정의하고, 필드 이름 대신 숫자 태그를 사용해 크기가 작습니다. 필드 추가/삭제 같은 스키마 진화(schema evolution) 를 공식 지원하여 버전 호환성 관리에 유리합니다. gRPC의 기본 직렬화 포맷입니다.아래 객체를 각 포맷으로 직렬화하면,
TextMessage(message = "Hello")
결과물이 이렇게 달라집니다.
// 예시
* JSON : {"message":"Hello"} → (텍스트)
* Protobuf: 0x0a 0x05 0x41 0x6c 0x69 ... → (바이너리)
이 글에서 알아볼 kotlinx-serialization은 이 포맷들을 모두 지원합니다.
val message = TextMessage("Hello")
Json.encodeToString(message) // → { "message": "Hello" } (텍스트)
Protobuf.encodeToByteArray(message) // → [0x0a 0x05 0x41 ...] (바이너리)
Cbor.encodeToByteArray(message) // → [0xa2 0x64 ...] (바이너리)
공식 지원 포맷은 JSON, Protobuf, CBOR, Properties이며, 커뮤니티에서 MessagePack, Avro, YAML 등의 구현체를 제공합니다. 이 글에서는 주로 JSON을 예시로 사용하지만, 직렬화의 원리 자체는 포맷에 무관합니다.
런타임에 클래스의 구조를 동적으로 탐색합니다.
// 리플렉션 기반 방식: 런타임에 클래스 구조를 탐색하며 JSON 문자열 생성
fun serialize(obj: Any): String {
val fields = obj.javaClass.declaredFields
val entries = fields.joinToString(", ") { field ->
field.isAccessible = true
val key = field.name
val value = field.get(obj) // 런타임에 필드 이름과 값을 읽음
"\"$key\": \"$value\""
}
return "{$entries}"
}
// serialize(TextMessage("Hello")) → {"message": "Hello"}
Gson, Moshi, Jackson이 이 방식입니다. 별도의 코드를 작성할 필요 없이 클래스에 어노테이션만 붙이면 동작합니다. 단, 런타임 탐색 비용이 있고, Kotlin/Native나 Kotlin/JS처럼 JVM이 없는 환경에서는 사용하기 어렵습니다.
Java에 내장된 직렬화(java.io.Serializable) 역시 리플렉션 기반입니다. Serializable 인터페이스를 구현하기만 하면 별도 라이브러리 없이 객체를 바이트 스트림으로 변환할 수 있습니다. 하지만 내부적으로 리플렉션을 사용해 느리고, 직렬화 형식이 Java에 종속되어 다른 언어나 플랫폼과 호환되지 않는다는 문제가 있습니다.
컴파일 타임에 직렬화 코드를 미리 만들어둡니다.
// 컴파일러가 미리 생성해두는 직렬화 코드 (간소화된 예시)
fun serialize(value: TextMessage): String {
return """{"message":"${value.message}"}"""
}
kotlinx-serialization이 이 방식을 활용합니다. 런타임에 클래스 구조를 탐색할 필요가 없고, 플랫폼에 관계없이 동작하며, 타입 오류를 컴파일 타임에 잡을 수 있습니다. 대신 코드를 미리 생성해야 하므로 컴파일러 플러그인이라는 복잡한 메커니즘이 필요합니다.
컴파일러 플러그인 기반 직렬화/역직렬화 수단
우리가 자주 사용하는 kotlinx-serialization은 Gson을 비롯한 리플렉션 기반 직렬화/역직렬화 라이브러리를 대체하는 수단으로 떠오르고 있습니다. 어째서 이러한 움직임이 있었는지, 그리고 정말 리플렉션으로 처리하면 안 되는 것인지 생각해볼 필요가 있습니다.
// 리플렉션 기반 방식 (kotlin-reflect 의존성 필요)
fun <T : Any> serialize(obj: T): String {
val result = mutableMapOf<String, Any?>()
obj::class.memberProperties.forEach { prop ->
result[prop.name] = prop.get(obj) // 런타임에 클래스 구조 탐색
}
return result.toString()
}
이 리플렉션 기반 코드는 네 가지 문제점을 가지고 있습니다.
obj::class.memberProperties는 런타임에 클래스의 메타데이터를 탐색합니다. 단순히 필드 값에 접근하는 것보다 훨씬 비쌉니다.memberProperties 같은 Kotlin 리플렉션 API는 kotlin-reflect 라이브러리를 추가해야만 사용할 수 있습니다. 이 라이브러리는 용량도 크고, 없으면 컴파일 자체가 되지 않습니다.컴파일러 플러그인은 Kotlin 컴파일러의 파이프라인에 끼어들어 동작합니다. 소스 코드가 바이트코드로 변환되는 과정 중간에 플러그인이 개입하여, @Serializable이 붙은 클래스를 발견하면 직렬화에 필요한 코드를 자동으로 생성합니다.
class SerializationComponentRegistrar : CompilerPluginRegistrar() {
override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) {
Companion.registerExtensions(this, loadDisableIntrinsic(configuration))
}
// ...
companion object {
fun registerExtensions(extensionStorage: ExtensionStorage, intrinsicsState: SerializationIntrinsicsState = SerializationIntrinsicsState.NORMAL) = with(extensionStorage) {
val serializationDescriptorSerializer = SerializationDescriptorSerializerPlugin()
DescriptorSerializerPlugin.registerExtension(serializationDescriptorSerializer)
SyntheticResolveExtension.registerExtension(SerializationResolveExtension(serializationDescriptorSerializer))
IrGenerationExtension.registerExtension(SerializationLoweringExtension(serializationDescriptorSerializer, intrinsicsState))
StorageComponentContainerContributor.registerExtension(SerializationPluginComponentContainerContributor())
FirExtensionRegistrar.registerExtension(FirSerializationExtensionRegistrar())
}
}
}
플러그인이 생성하는 코드는 일반 Kotlin 코드처럼 컴파일됩니다. 리플렉션을 사용하지 않고, 타입 정보는 컴파일 타임에 확정됩니다.
이렇게 생성되는 멤버들을 synthetic 멤버라고 부릅니다. 소스 코드에는 없지만 컴파일러(또는 플러그인)가 자동으로 만들어낸 멤버입니다. Kotlin에서 익숙한 data class의 copy(), equals(), hashCode()도 사실 컴파일러가 생성하는 synthetic 멤버입니다. kotlinx-serialization은 이 메커니즘을 플러그인 차원에서 더 확장합니다.
Kotlin 코드가 실행 가능한 바이트코드로 변환되는 단계와 함께, kotlinx-serialization 플러그인이 어떻게 컴파일 과정에 끼어들어 코드를 추가하는지 살펴보겠습니다.
.kt 소스 파일
│
▼ [1단계] 파싱 (Parsing)
구문 트리 (AST)
│
▼ [2단계] 의미 분석 (Semantic Analysis / FIR)
타입 정보가 붙은 트리
│ **← kotlinx-serialization 플러그인 개입 지점 ①**
▼ [3단계] IR 생성 및 변환 (IR Lowering)
Kotlin IR
│ **← kotlinx-serialization 플러그인 개입 지점 ②**
▼ [4단계] 백엔드 코드 생성
JVM 바이트코드 / JS / Native
소스 코드 텍스트를 읽어서 문장 구조를 분석합니다. if 다음에 조건식이 오는지, 괄호가 짝이 맞는지 같은 문법 검사입니다. 이 단계에서는 타입이 String인지 Int인지 같은 의미는 아직 따지지 않습니다. 결과물은 코드의 구조를 나타내는 트리(AST, Abstract Syntax Tree)입니다.
파싱으로 만들어진 트리에 타입 정보를 붙이는 단계입니다. val message: String이라고 선언된 변수에 Int를 대입하면 이 단계에서 오류가 납니다. "이 함수가 실제로 존재하는가", "이 변수의 타입은 무엇인가"를 확정합니다.
#### 2-1. kotlinx-serialization 플러그인의 첫 번째 개입
kotlinx-serialization 플러그인은 이 단계 이후 생성된 트리(타입 정보 포함)를 기반으로, $serializer 같이 소스에는 없는 멤버를 타입 시스템에 이미 존재해왔던 것처럼 등록합니다. 덕분에 다른 코드에서 TextMessage.serializer()를 호출해도 컴파일러가 오류를 내지 않게 됩니다.
// 원본 코드
@Serializable
data class TextMessage(val message: String)
// 컴파일 과정 변형 후
data class TextMessage(val message: String) {
// 컴파일러가 생성
internal object `$serializer` : GeneratedSerializer<TextMessage> {
// 이후 추가
}
// 컴파일러에 의해 생성되는 serializer 접근 메서드 : 어느 곳에서나 접근 가능
companion object {
fun serializer(): KSerializer<TextMessage> = `$serializer`
}
}
// 직접 정의하지 않았음에도 코드 작성 과정에서 호출 가능
TextMessage.serializer()
의미 분석이 끝난 트리를 IR(Intermediate Representation, 중간 표현) 이라는 형태로 변환합니다. IR은 JVM 바이트코드도, Kotlin 소스도 아닌 중간 단계의 표현으로, 플랫폼(JVM/JS/Native)에 무관하게 동일한 형태를 유지합니다.
#### 3-1. kotlinx-serialization 플러그인의 두 번째 개입 - 실 구현 추가
kotlinx-serialization 플러그인은 이 단계에서 2단계에 등록만 해두었던 $serializer의 실제 구현 코드를 3단계 이후 생성된 IR에 채워 넣습니다.
IR을 실제 실행 가능한 코드로 변환합니다. JVM 타깃이면 .class 바이트코드로, JS 타깃이면 JavaScript로, Native 타깃이면 기계어로 변환됩니다. 앞 단계까지 IR로 통일되어 있었기 때문에, 플러그인이 생성한 코드도 이 단계에서 자동으로 각 플랫폼에 맞게 변환됩니다.
결국 kotlinx-serialization 플러그인 두 개의 단계에 개입하여 코드를 생성하는데, 2단계에서는 ‘무엇이 존재하는지’를 선언하고, 3단계에서는 ‘그것이 실제로 어떻게 동작하는지’를 채웁니다.
컴파일러 플러그인 API의 구조는 kotlinx-serialization 플러그인 소스의 SerializationComponentRegistrar에서 확인할 수 있습니다.
단순 객체 하나를 직렬화하고 역직렬화하는 것은 단순할 수 있지만, 다형성(polymorphism)이 추가되는 순간 고려해야 할 것들이 늘어나며 복잡도가 커집니다.
sealed class Message {
data class TextMessage(val text: String) : Message()
data class ImageMessage(val url: String) : Message()
}
이제 message 변수를 하나 생성합니다. 타입은 Message로 명시했지만, 구체적인 타입인 TestMessage 객체를 생성하여 변수에 대입합니다.
val message: Message = TextMessage("Hello")
message를 JSON으로 직렬화하면 이렇게 됩니다.
{"text": "Hello"}
그런데 이 JSON을 나중에 역직렬화하려 할 때, 문제가 생깁니다. JSON 값만 봐서는 TextMessage로 만들어야 할지, ImageMessage로 만들어야 할지 모르기 때문입니다.
해결책은 타입 정보(”type”)를 JSON에 함께 기록하는 것입니다.
{"type": "TextMessage", "text": "Hello"}
역직렬화할 때 "type" 필드를 먼저 읽고, 그 값을 보고 어떤 클래스를 생성할지 결정합니다. 이 "type" 필드가 타입 판별자(type discriminator) 입니다.
kotlinx-serialization은 컴파일 타임에 이 과정을 수행하는 코드를 생성합니다. 이 역할을 담당하는 것이 SealedClassSerializer입니다. @Serializable을 sealed class에 붙이면 플러그인이 $serializer를 SealedClassSerializer 기반으로 생성하며, 이 안에 타입 판별자를 읽고 쓰는 로직이 담깁니다.
// @Serializable sealed class Message에 생성되는 $serializer (예시)
object `$serializer` : SealedClassSerializer<Message>(
serialName = "com.example.Message",
baseClass = Message::class,
subclasses = arrayOf(TextMessage::class, ImageMessage::class), // 컴파일 타임에 고정
subclassSerializers = arrayOf(TextMessage.`$serializer`, ImageMessage.`$serializer`)
)
subclasses 배열이 컴파일 타임에 하드코딩되는 이유는 sealed class의 특성 때문입니다. Kotlin의 sealed class는 같은 컴파일 단위 안에서만 서브클래스를 가질 수 있으므로, 컴파일러는 모든 서브클래스를 미리 알 수 있습니다. 이 덕분에 런타임에 리플렉션으로 서브클래스를 탐색할 필요 없이, 타입 판별자 값만 보고 바로 해당 $serializer를 찾아낼 수 있습니다.
참고로 sealed class에 @Serializable을 붙이면 서브클래스들도 반드시 @Serializable이어야 합니다. 서브클래스에 어노테이션이 없으면 컴파일 에러가 납니다.
@Serializable
sealed class Message {
@Serializable
data class TextMessage(val text: String) : Message()
@Serializable
data class ImageMessage(val url: String) : Message()
}
단, 부모와 자식에서 이 어노테이션의 역할이 다릅니다.
"type" 필드 추가 및 역직렬화 시 타입 분기를 담당하는 SealedClassSerializer 생성을 지시합니다.text, url 등)를 직렬화하기 위한 $serializer 생성을 지시합니다.이 두 역할이 협력해서 아래에서 설명할 write$Self() 위임 구조가 만들어집니다.
다형성이 추가되었을 때, kotlinx-serialization의 기본 동작은 "type" 키에 클래스의 정규화된 이름을 값으로 쓰는 것입니다.
{"type": "com.example.TextMessage", "text": "Hello"}
이 동작은 어노테이션으로 제어할 수 있습니다.
// 판별자 키 이름 변경
@JsonClassDiscriminator("kind")
sealed class Message
// 특정 서브클래스의 판별자 값 지정
@SerialName("text")
data class TextMessage(val text: String) : Message()
어노테이션을 활용해 이름을 커스터마이징하면, 결과는 아래와 같습니다.
{"kind": "text", "text": "Hello"}
반면 판별자 필드 자체가 없고 특정 필드의 존재 여부로 타입을 구분해야 하는 경우에는 JsonContentPolymorphicSerializer를 직접 구현해 사용할 수도 있습니다. 직접 만들어 사용하는 API와는 달리, 외부 API 같이 클라이언트의 구현을 고려하지 않는 경우가 많은데 이 경우에 유용합니다. 예를 들어, 통신할 때 상대방이 보내는 JSON이 ‘type’ 필드 없이 아래와 같은 데이터를 보낼 수 있습니다.
{"text": "안녕하세요"}
{"url": "https://example.com/img.png"}
이 경우 "text" 키가 있으면 TextMessage, "url" 키가 있으면 ImageMessage라고 필드의 존재 여부로 타입을 추론해야 하는데, 아래와 같이 로직을 직접 구현할 수 있습니다.
object MessageSerializer : JsonContentPolymorphicSerializer<Message>(Message::class) {
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<Message> {
return when {
"text" in element.jsonObject -> TextMessage.serializer()
"url" in element.jsonObject -> ImageMessage.serializer()
else -> throw SerializationException("Unknown message type")
}
}
}
// 사용
val decoded = Json.decodeFromString(MessageSerializer, """{"text":"안녕하세요"}""")
// → TextMessage(text="안녕하세요")
플러그인이 컴파일 과정에 두 번째로 개입하는 과정(IR 트리에 구현 추가)에서 실제로 코드를 생성하는 방식은 꽤나 복잡합니다. 이 과정에서 플러그인은 단순히 코드를 한 번에 다 만들지 않고 두 번에 나눠서 만들기 때문입니다.
앞선 내용으로부터 짐작할 수 있듯, sealed class를 직렬화할 때는 두 가지 사항을 기록해야 합니다.
Message의 자식 클래스인 TextMessage 인스턴스의 예시를 들어보면 아래와 같습니다.
1. {"type": "TextMessage" ← 부모(Message)의 serializer가 담당
2. "text": "Hello"} ← TextMessage 자신이 담당
그 중 두 번째 부분, 즉 자기 자신의 필드(여기서는 text)를 직렬화하는 일을 담당하는 것이 write$Self()라는 메서드입니다. 이 메서드는 sealed class/interface를 선언할 때만, kotlinx-serialization이 각 서브클래스에 자동으로 생성해줍니다.
이를 이해하려면 먼저 serialize() / deserialize() 함수의 이해가 필요합니다.
serialize()는 SerializationStrategy 인터페이스에 선언된 메서드로, 플러그인이 각 클래스의 $serializer object 안에 자동으로 생성하는 핵심 직렬화 메서드입니다. DeserializationStrategy 역시 비슷한 원리입니다.
public interface SerializationStrategy<in T> {
public fun serialize(encoder: Encoder, value: T) // ← serialize()는 여기 선언
}
public interface DeserializationStrategy<out T> {
public fun deserialize(decoder: Decoder): T
}
KSerializer는 SerializationStrategy와 DeserializationStrategy를 합친 인터페이스로, 플러그인이 생성하는 $serializer가 구현합니다.
// serialize()와 deserialize()를 모두 상속받음
public interface KSerializer<T> : SerializationStrategy<T>, DeserializationStrategy<T>
앞서 살펴보았던 코드를 다시 보면, $serializer가 이것의 구현체임을 확인할 수 있습니다.
// 원본 코드
@Serializable
data class TextMessage(val message: String)
// 컴파일 과정 변형 후
data class TextMessage(val message: String) {
internal object `$serializer` : GeneratedSerializer<TextMessage> {
// ...
}
companion object {
// KSerializer 구현
fun serializer(): KSerializer<TextMessage> = `$serializer`
}
}
그리고 우리가 Json.encodeToString(message)를 호출하면 내부적으로 이 메서드가 실행됩니다.
// Json.encodeToString(message) 내부에서 일어나는 일
Message.$serializer.serialize(encoder, message)
// ↑
// 이 메서드가 실제 직렬화 수행
sealed class의 구현체 TextMessage의 경우 이 serialize()는 부모인 Message의 $serializer에 생성됩니다. 즉 TextMessage든 ImageMessage든 어떤 서브클래스가 들어오더라도 직렬화를 위해서는 Message.$serializer.serialize()가 호출됩니다.
그렇다면 왜 serialize() 하나로 처리하지 않고 write$Self()를 따로 만드는 것인지 의문이 들 것입니다. 그 이유는 부모의 serializer는 어떤 서브클래스를 직렬화해야 할지 미리 알 수 없기 때문입니다.
아래 코드와 같이, Message의 serializer가 TextMessage의 text 필드를, ImageMessage의 url 필드를 직접 알고 처리한다면, 새로운 서브클래스가 추가될 때마다 부모의 serializer도 함께 수정해야 할 것입니다.
// serialize pseudo
if (value is TextMessage) encodeString("text", value.text)
if (value is ImageMessage) encodeString("url", value.url)
// 새 서브클래스가 생기면 여기에 계속 추가해야 함 → 확장성 없음
그렇게 하지 않고, 인코딩을 각 서브클래스에 위임하는 것이 write$Self()의 역할입니다.
// serialize pseudo
encode("type", value::class.simpleName) // 부모는 type만 담당
value.write$Self(encoder, descriptor) // 나머지 필드는 서브클래스에 위임
하지만 이 방식에는 또 다른 문제가 있습니다.
TextMessage와 ImageMessage에 생성될 코드인데, 이 객체들은 아직 만들어지지 않은 상태이것이 문제가 된다는 것을 개발자 입장에서는 이해하기 어려울 수 있습니다. 하지만 우리가 코드를 짤 때의 규칙과 컴파일러 플러그인에 적용되는 규칙과 다르다는 것을 이해해야 합니다.
개발자들은 코드 작성 시 함수를 만들지 않고 일단 호출부부터 적어놓은 후, 나중에 클래스에 가서 함수를 만들 수 있습니다.
fun main() {
val textMessage = TextMessage("Hello")
textMessage.doSomething() // 아직 안 만든 함수 호출
}
개발을 하다 보면 이 상태에서 에디터에 빨간 줄이 뜨긴 하지만, 우리가 나중에 함수를 채워 넣으면 그만입니다.
하지만 컴파일러 내부에서 코드를 자동으로 생성하는 플러그인(kotlinx-serialization)은 이렇게 '일단 저지르고 나중에 수습하는' 방식이 불가능합니다. 컴파일러 세계에는 "존재하지 않는(선언되지 않은) 함수나 변수를 참조하는 코드는 아예 생성조차 할 수 없다"는 아주 엄격한 규칙이 있기 때문입니다. Kotlin에서 직접 코드를 짤 때는 컴파일러가 알아서 해결해주지만, 플러그인이 코드를 생성하는 단계에서는 이것이 발생하지 않도록 별도로 처리해주어야 합니다.
이를 해결하기 위해, 아래 코드의 SerializationLoweringExtension에서 두 단계로 나누어 이 문제를 해결하고 있습니다.
// 출처: kotlinx.serialization — SerializationLoweringExtension.kt
class SerializationLoweringExtension : IrGenerationExtension {
override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) {
// 1. 모든 클래스에 대해 이름표만 먼저 붙여둠
// write$Self()가 존재한다고 선언만 하고, 내용은 비워둠
val preGenerator = SerializerClassPreLowering(pluginContext)
moduleFragment.files.forEach { preGenerator.runOnFileInOrder(it) }
// 2. write$Self()가 존재하는 것으로 알려짐
// serialize()에서 write$Self()를 호출하는 코드를 안전하게 생성
val generator = SerializerClassLowering(pluginContext)
moduleFragment.files.forEach { generator.runOnFileInOrder(it) }
}
}
비유하자면 책의 목차를 먼저 만들고(1) 본문을 나중에 채우는(2) 것과 같습니다.
목차를 만들 때는 각 챕터의 제목과 페이지 번호 자리만 잡아두고, 실제 내용은 본문 작성 단계에서 채웁니다. 덕분에 본문에서 "3장을 참조하라"고 쓸 때, 3장이 아직 완성되지 않았더라도 그 자리가 어디인지는 알 수 있습니다. 이 방법으로, 실제 구현이 없어도 그 함수를 호출하는 코드를 먼저 작성할 수 있게 되는 것입니다.
결국 두 클래스의 차이는 타입의 고정성에서 옵니다.
일반 클래스는 직렬화할 때 타입이 항상 하나로 고정되어 있습니다. 따라서 컴파일러는 직렬화 시 $serializer 하나만 만들면 됩니다.
sealed class를 활용하면, 런타임에 어느 타입이 사용될 지 알 수 없습니다. 역직렬화할 때는 어떤 클래스를 만들어야 할지 데이터 안에서 판단해야 하므로, 타입 판별자가 필요하고 그것을 처리하기 위한 추가 구조(SealedClassSerializer, write$Self())가 생깁니다. 그리고 이 구조들이 서로를 참조하기 때문에 2-pass 전략이 필요해집니다.
일반 클래스
→ $serializer 하나
→ 타입 판별자 불필요
→ 코드 생성 1회
sealed class
→ 부모: SealedClassSerializer (타입 판별자 담당)
→ 자식마다: write$Self() (자기 필드 직렬화 담당)
→ 서로 참조하는 코드 → 2-pass 생성 필요
@Serializable 하나를 붙이는 것으로 이 모든 코드가 자동으로 만들어집니다. kotlinx-serialization이 컴파일러 플러그인이어야 하는 이유가 여기에 있습니다.
지금까지 설명한 내용을 TextMessage("안녕하세요")를 직렬화하는 하나의 흐름으로 묶어보겠습니다.
플러그인이 코드를 생성하는 과정은 다음과 같습니다.
@Serializable sealed class Message 발견
│
▼ [의미 분석 단계 — 1차 개입]
│ Message.$serializer, TextMessage.$serializer 선언 등록
│ (내용은 비어있음. 타입 시스템에 "존재한다"고만 알림)
│
▼ [IR 단계 — 2차 개입, 1패스]
│ TextMessage.write$Self() 선언 등록
│ (내용은 비어있음)
│
▼ [IR 단계 — 2차 개입, 2패스]
이제 write$Self()가 존재하는 것으로 알려짐
├── Message.$serializer.serialize() 구현 생성
│ → "type" 기록 후 write$Self() 호출하는 코드
├── TextMessage.write$Self() 구현 생성
│ → text 필드를 encoder에 기록하는 코드
└── TextMessage.$serializer.deserialize() 구현 생성
→ 골든 마스크로 필수 필드 검증 후 TextMessage 생성
아래 코드를 작성했다고 가정해보겠습니다.
val message: Message = TextMessage("안녕하세요")
val json = Json.encodeToString(message)
이 때 런타임 중 직렬화 시 다음과 같은 흐름을 따르게 됩니다.
Json.encodeToString(message)
│
▼ Message.$serializer.serialize(encoder, message) 호출
│
├── 1. "type" 기록
│ encoder.encodeStringElement("type", "com.example.TextMessage")
│
└── 2. 실제 필드는 서브클래스에 위임
message.write$Self(encoder, descriptor)
│
└── encoder.encodeStringElement("text", "안녕하세요")
최종 결과: {"type":"com.example.TextMessage","text":"안녕하세요"}
반대로 역직렬화를 위해 다음과 같은 코드를 작성했다고 가정하겠습니다.
val decoded = Json.decodeFromString<Message>(json)
이 때 흐름은 다음과 같습니다.
Json.decodeFromString<Message>(json)
│
▼ Message.$serializer.deserialize(decoder) 호출
│ (SealedClassSerializer 기반)
│
├── 1. "type" 필드 읽기: "com.example.TextMessage"
├── 2. 컴파일 타임에 고정된 subclasses 배열에서 탐색
├── 3. TextMessage.$serializer 찾기
└── 4. TextMessage.$serializer.deserialize() 위임
│
├── "text" 필드 읽기, seen 비트마스크 갱신
├── 골든 마스크로 필수 필드 존재 여부 검증
└── TextMessage(text = "안녕하세요") 생성
중요한 것은 "컴파일러가 자식 클래스들을 전부 알고 있는가?" 의 여부이다.
지금까지 sealed class를 중심으로 다형성 직렬화를 살펴보았습니다. 그렇다면 일반적인 open class나 abstract class, 혹은 interface에 다형성을 적용할 때는 어떨지 궁금할 것입니다. 결론부터 말하자면 앞서 살펴본 write$Self() 컴파일러 자동 생성 로직이나 2-pass 전략은 이들에게 적용되지 않습니다.
이 차이는 컴파일러가 자식 클래스의 목록을 전부 알 수 있는지 파악하는 '타입의 닫힘(Closed) 여부'에서 비롯됩니다.
같은 컴파일 단위 내에서만 상속이 가능하므로, 컴파일 타임에 모든 서브클래스가 확정됩니다. 플러그인은 이 확정된 목록을 바탕으로 SealedClassSerializer를 구성하고, 각 자식에게 write$Self()를 미리 만들어 연결하는 정적 바인딩이 가능합니다.
외부 모듈이나 라이브러리를 사용하는 클라이언트 측에서 언제든지 새로운 서브클래스를 만들어 상속할 수 있습니다. 플러그인이 코드를 생성하는 컴파일 시점에는 미래에 어떤 자식 클래스가 추가될지 전혀 알 수 없으므로, 존재하지도 않는 서브클래스의 코드를 미리 생성하거나 배열로 묶어둘 수 없습니다.
이러한 '열린 타입'들을 위해 kotlinx-serialization은 컴파일 타임 코드 생성이 아닌, 런타임 동적 탐색 방식을 사용합니다.
해당 타입들의 다형성을 처리하려면 개발자는 부모 클래스에 @Polymorphic 어노테이션을 선언하고, 애플리케이션 초기화 시점에 SerializersModule을 통해 구체적인 서브클래스들을 명시적으로 등록해야 합니다. 이를 통해 라이브러리는 SealedClassSerializer 대신 PolymorphicSerializer를 사용하게 되며, 직렬화/역직렬화가 일어나는 런타임에 이 SerializersModule을 조회하여 알맞은 직렬화기를 동적으로 찾아냅니다.
결과적으로 sealed class가 제공하는 강력한 컴파일 타임 검증과 자동화의 이점을 열린 타입에서는 온전히 누릴 수 없기에, 직렬화 모델에 다형성이 필요할 때 가급적 sealed class를 사용하는 것이 성능과 타입 안정성 면에서 권장되는 것입니다.
지금까지 kotlinx-serialization이 일반 클래스와 sealed class를 어떻게 다르게 처리하는지, 그리고 컴파일러 플러그인으로서 Kotlin의 컴파일 파이프라인에 어떻게 개입하는지 깊이 있게 살펴보았습니다. 다형성을 지원해야 하기 위해 sealed class를 사용하는 경우, 컴파일러가 자식 클래스의 존재를 미리 알고 있다는 언어적 특성을 활용하여 타입 판별자를 안전하게 처리함을 확인할 수 있었습니다. 또한 부모와 자식 클래스 간의 코드 생성 순서로 인해 발생하는 의존성 문제를 해결하기 위해 IR 변환 단계에서 2-pass 전략과 write$Self()라는 우회 수단을 사용하는 구현을 확인할 수 있었습니다. 이 특성에 대한 이해를 바탕으로 kotlinx-serialization을 활용하는 과정에서 더욱 깊이 있는 코드를 작성할 수 있을 것입니다.
https://kotlinlang.org/docs/serialization.html
https://github.com/Kotlin/kotlinx.serialization/tree/master/docs
https://www.revenuecat.com/blog/engineering/kotlinx-serialization/