kotlinx-serialization을 활용하여 서버와 클라이언트 간 주고 받을 메시지 포맷을 직렬화 및 역직렬화하는 기능 개발을 한 이후, CI 파이프라인이 터졌습니다. 로컬에서는 빌드가 정상적으로 이뤄졌지만, CI에서 컴파일 에러가 났습니다.
결론부터 말하면, 사용하던 라이브러리 버전을 업그레이드하는 단 한 줄의 코드 수정으로 이 문제를 해결할 수 있었습니다. 하지만 원인은 생각보다 훨씬 깊은 곳에 있었습니다. 이 문제는 Kotlin 컴파일러 플러그인 생태계가 현재 가지고 있는 구조적인 특성에서 비롯된 것이었습니다.
이 글에서는 그 원인을 단계적으로 파고들어보고, 이와 관련하여 라이브러리를 활용하여 개발할 때 고려해야 할 점들을 제시해보고자 합니다.
@Serializable sealed class를 컴파일할 때 어떤 코드가 생성되는지 알아야 합니다. 이와 관련된 자세한 사항은 kotlinx-serialization이 sealed class를 다르게 처리하는 이유를 살펴보면 좋습니다.
위 글을 먼저 읽지 않더라도, 아래 내용을 미리 알고 있다면 이해가 쉽습니다.
@Serializable을 붙이면 컴파일러 플러그인이 FIR 단계와 IR 단계에 개입해 코드를 생성하며, 컴파일 이후에 실제 클래스 멤버처럼 존재data class는 $serializer 하나만 생성sealed class는 이에 더해 SealedClassSerializer, 서브클래스별 write$Self(), 타입 판별자 처리 로직 등 훨씬 복잡한 synthetic 멤버들 생성Kotlin 버전 : 2.2.21
redacted 플러그인 버전 : 1.14.1
이 조합에서 @Serializable을 붙인 sealed class를 새롭게 추가하니, CI에서 빌드가 실패했습니다.
@Serializable이 붙은 sealed class를 추가하는 작업 이후 알 수 없는 사유로 CI 파이프라인이 실패하는 상황을 마주하였습니다. 에러는 런타임이 아니라 컴파일 타임이었고, 스택 트레이스는 아래와 같은 오류를 보여주고 있었습니다.
java.lang.NoSuchMethodError:
'boolean org.jetbrains.kotlin.fir.symbols.impl.FirCallableSymbolKt
.isExtension(org.jetbrains.kotlin.fir.symbols.impl.FirCallableSymbol)'
at dev.zacsweers.redacted.compiler.fir.FirRedactedDeclarationChecker
.isToStringFromAny(RedactedFirExtensionRegistrar.kt:262)
메시지를 살펴보니, Redacted 플러그인 관련하여 문제가 생긴 것으로 판단됩니다.
현재 프로젝트에서 사용하고 있던 redacted 플러그인은 @Redacted가 붙은 필드를 toString()로 출력하면 정보가 가려지게 됩니다. 전화번호, 카드번호처럼 로그에 노출되어서는 안 되는 민감한 정보를 보호하기 위한 플러그인입니다.
@Serializable
data class UserInfo(
val name: String,
@Redacted val phoneNumber: String,
@Redacted val cardNumber: String // 로그에 노출 금지
)
// toString() 결과
// 적용 전: UserInfo(name=Alice, phoneNumber=010-1234-5678, cardNumber=1234-5678-9012-3456)
// 적용 후: UserInfo(name=Alice, phoneNumber=██████████, cardNumber=██████████)
사실 이 플러그인은 CI에서만 활성화되어 있도록 하였습니다. CI/CD 파이프라인은 자동화된 빌드와 배포를 담당하므로, 파이프라인 로그에 민감한 정보가 노출되어서는 안 되기 때문입니다. 반면 로컬 디버그 환경에서는 개발자가 실제 값을 직접 확인할 수 있어야 문제를 추적하고 디버깅할 수 있습니다. 배포 환경에서는 가리고, 개발 환경에서는 보이게 하는 것이 의도된 동작입니다.
// gradlePlugins/plugins/src/main/kotlin/.../RedactedPlugin.kt
project.extensions.configure<RedactedPluginExtension> {
enabled = project.isCI // CI(배포 파이프라인)에서만 활성화, 로컬 디버그에서는 비활성
}
코틀린 컴파일러 내부 API의 변경
다시 정리하면, 문제의 모듈은 다음과 같은 상황이었습니다.
@Redacted를 사용하고 있어 플러그인이 필요한 상태@Serializable sealed class 사용: 직렬화가 필요한 다형성 메시지 타입을 위함주목할 만한 점은 다른 모듈에서는 이 두 가지가 한 모듈에 함께 존재한 적이 없었다는 것입니다. 문제의 모듈에서 redacted 플러그인이 추가된 상태에서 @Serializable sealed class를 처음으로 도입한 것입니다.
참고로 새로 추가한 @Serializable sealed class에는 @Redacted를 사용하지 않았습니다. 하지만, 후술하겠지만 FirRedactedDeclarationChecker는 @Redacted 사용 여부와 무관하게 모듈 내 모든 클래스를 순회한다는 것이 문제였습니다. 따라서 새로 추가한 @Serializable sealed class도 순회 대상이 되었고, 그때까지 발견되지 않았던 버전 불일치 문제가 드러나며 문제가 발생한 것이었습니다.
에러 메시지를 다시 보겠습니다.
NoSuchMethodError: 'boolean FirCallableSymbolKt.isExtension(FirCallableSymbol)'
…
JVM에서 FirCallableSymbolKt.isExtension(symbol) 형태의 바이트코드는 Kotlin extension property의 표현입니다. Kotlin 2.2.0 시절에는 이렇게 정의되어 있었습니다.
// Kotlin 2.2.0 컴파일러 내부
val FirCallableSymbol<*>.isExtension: Boolean // extension property
get() = ...
// → JVM 바이트코드: FirCallableSymbolKt.isExtension(symbol)
Kotlin 2.2.20에서 이것이 클래스의 직접 멤버로 바뀌었습니다.
// Kotlin 2.2.20+
interface FirCallableSymbol<*> {
val isExtension: Boolean // 클래스 멤버
}
// → JVM 바이트코드: symbol.getIsExtension()
JVM 입장에서 이 둘은 완전히 다른 메서드입니다. redacted 1.14.1은 Kotlin 2.2.0으로 컴파일되어 있어서 FirCallableSymbolKt.isExtension(symbol)을 호출하는 바이트코드를 가지고 있습니다. 하지만 Kotlin 2.2.21 런타임에는 그 메서드가 더 이상 없습니다.
실제로 redacted 플러그인 소스에서 확인할 수 있습니다.
// redacted-compiler-plugin/.../RedactedFirExtensionRegistrar.kt
private fun FirNamedFunctionSymbol.isToStringFromAny(session: FirSession): Boolean =
name == OperatorNameConventions.TO_STRING &&
dispatchReceiverType != null &&
!isExtension && // ← 이 한 줄이 문제
valueParameterSymbols.isEmpty() &&
resolvedReturnType.fullyExpandedType(session).isString
!isExtension은 2.2.20 이후 API 기준으로 작성된 코드입니다. 1.14.1은 2.2.0 기준으로 컴파일되어 있어서 이것이 JVM 바이트코드에 FirCallableSymbolKt.isExtension(symbol) 호출로 들어가 있습니다.
이제 두 플러그인이 어떻게 충돌했는지를 이해할 수 있습니다.
redacted 플러그인의 FirRedactedDeclarationChecker는 FIR 단계에서 모든 클래스의 모든 멤버를 순회하며 @Redacted가 붙은 필드를 찾습니다. 즉 이 순회 과정은 해당 어노테이션이 없는 클래스도 예외가 아닙니다.
@Redacted가 붙지 않은 클래스도 순회 대상인 이유는, 플러그인이 동작하는 방식 때문입니다. FirRedactedDeclarationChecker는 FirClassChecker를 구현하는데, 이 인터페이스의 check() 메서드는 모듈 내 모든 클래스 선언에 대해 호출됩니다. 플러그인은 각 클래스의 멤버를 먼저 전부 순회한 뒤, 그 중 @Redacted가 붙은 것을 골라냅니다. "이 클래스에 @Redacted가 있는가"를 먼저 확인하고 건너뛰는 구조가 아니라, 순회하면서 찾는 구조입니다. 따라서 @Redacted를 전혀 사용하지 않은 Message 클래스도 순회 대상에 포함됩니다.
그런데 kotlinx-serialization 플러그인도 같은 FIR/IR 단계에서 개입해 클래스에 synthetic 멤버를 추가합니다. 이 때 sealed class는 일반적인 클래스에 비해 복잡한 구조를 갖는 만큼, synthetic 멤버의 수는 일반 클래스에 비해 더 많을 수밖에 없게 됩니다.
@Serializable
data class Foo(val bar: String)
$serializer 하나가 생성됩니다. FirRedactedDeclarationChecker가 이 멤버를 순회해도 isToStringFromAny()가 문제되는 경로를 타지 않습니다.
@Serializable
sealed class Message {
@Serializable
data class TextMessage(val text: String) : Message()
@Serializable
data class ImageMessage(val url: String) : Message()
}
$serializer 외에 SealedClassSerializer, 서브클래스별 write$Self(), 타입 판별자 처리 로직 등 훨씬 많은 synthetic 멤버가 생성됩니다. FirRedactedDeclarationChecker는 이 멤버들도 모두 순회하며, 그 과정에서 isToStringFromAny() → isExtension() 경로를 타게 됩니다.
FirRedactedDeclarationChecker.check(Message)
↓ ← 모든 멤버 순회 (@Redacted 없어도)
↓ ← sealed class는 kotlinx-serialization이 생성한 synthetic 멤버 포함
isToStringFromAny(각 synthetic 멤버)
↓
FirCallableSymbolKt.isExtension(symbol) ← 존재하지 않는 메서드 → CRASH
결국 두 플러그인이 서로를 모르는 채로 같은 FIR 트리를 건드리다가 충돌한 것입니다. kotlinx-serialization이 sealed class에 생성하는 복잡한 synthetic 멤버가 없었다면, redacted의 버전 불일치는 이 방식으로는 드러나지 않았을 것입니다.
원인을 파악한 뒤 redacted 플러그인의 릴리즈 노트를 확인했습니다. 역시나 Kotlin 버전 업그레이드에 대응한 릴리즈가 이미 나와 있었습니다.
| 버전 | 릴리즈 노트 |
|---|---|
1.15.0 | "Update to Kotlin 2.2.20. This release requires 2.2.20 or later." |
1.15.1 | "Compile against Kotlin 2.2.21" |
프로젝트의 Kotlin 버전이 2.2.21이므로, 이에 대응하는 1.15.1로 업그레이드하면 된다는 것을 확인했습니다.
# gradle/libs.versions.toml
zacsweers_redact = "1.15.1" # 1.14.1 → 1.15.1
결국 해당 문제는 한 줄 수정으로 해결할 수 있었습니다.
여기서 아래와 같은 의문을 가질 수 있습니다.
"Kotlin이 backward compatibility를 어긴 것 아닌가?"
하지만 이는 Kotlin 측의 잘못이라고 할 수는 없습니다. 그 이유는 컴파일러 플러그인 API가 아직 안정적이지 않기 때문입니다.
Kotlin은 컴포넌트마다 다른 안정성 수준을 명시하고 있습니다.
Kotlin 공식 K2 마이그레이션 가이드는 이것을 명확히 합니다.
"Custom compiler plugins use the plugin API, which is Experimental. As a result, the API may change at any time, so we can't guarantee backward compatibility."
— Kotlin K2 migration guide — Compiler plugins
Kotlin의 안정성 수준 분류 전반에 대한 내용은 Stability levels of Kotlin components에서 확인할 수 있습니다.
FirCallableSymbolKt.isExtension()이 바뀐 것은 Kotlin이 약속을 어긴 것이 아닙니다. 처음부터 보장하지 않았던 영역에서 변경이 일어난 것입니다.
사실 이 문제는 그 어느 측의 책임이라고도 볼 수 없는 문제입니다.
1.15.0, 1.15.1)를 릴리즈하여 대응하였습니다.하지만 프로젝트 상에서 Kotlin을 2.2.21로 올렸지만 의존하는 플러그인을 함께 올리지 않았습니다. 결국 Kotlin과 플러그인을 사용하는 측에서 적절히 대응했어야 하는 문제입니다.
책임 소재를 따지는 것보다 중요한 것은 이 구조가 왜 위험한가를 이해하는 것입니다.
컴파일러 플러그인은 컴파일 타임에 동작합니다. 버전이 맞지 않으면 컴파일 자체가 깨집니다. 그리고 이 에러는 특정 코드 조합이 트리거되기 전까지는 드러나지 않을 수 있습니다.
plugins.redacted가 적용된 모듈과 @Serializable sealed class가 처음으로 한 모듈에 공존하기 전까지, 버전 불일치로 인한 문제는 전혀 인식되지 않았습니다.
특정 코드 패턴이 트리거될 때까지 숨어있으며, 로컬/CI 환경 차이로 인해 더 늦게 발견됨
이 글에서 설명하듯, @Serializable sealed class는 일반 클래스보다 훨씬 많은 synthetic 멤버를 생성합니다. 그 복잡한 synthetic 멤버들이 다른 플러그인과의 충돌 가능성을 높인 것입니다. @Serializable을 붙이는 것이 단순한 마커가 아니라는 것은, 이런 곳에서도 드러납니다.
따라서 Kotlin 버전을 올릴 때는 아래 사항들을 고려해야 합니다.
프로젝트에서 사용하는 모든 컴파일러 플러그인을 파악해두는 것이 중요합니다. 쉽게 놓치는 것들이 있습니다.
직접 보이는 것:
kotlinx-serialization, kotlin-parcelize, ksp
간접적으로 포함되는 것:
redacted (개인정보 보호용 toString 생성)
kotlin-power-assert
compose compiler
arrow-meta
...
플러그인마다 "어떤 Kotlin 버전과 함께 써야 하는지"를 명시하는 방식이 다릅니다.
ksp_version-kotlin_version 형태로 버전을 묶음Kotlin에 의존하는 플러그인 버전도 함께 올리는 것이 안전합니다. 따로따로 올리면 특정 코드 패턴이 트리거될 때까지 문제가 숨어있을 수 있기 때문입니다.
org_jetbrains_kotlin = "2.2.21"
zacsweers_redact = "1.15.1" # Kotlin 2.2.21 대응 버전
ksp = "2.2.21-1.0.31" # Kotlin 버전과 맞춤
redacted 관련 라이브러리에서 발생한 컴파일 에러의 이면에는 kotlinx-serialization이 sealed class에 생성하는 복잡한 synthetic 멤버들, FIR 단계에서 모든 멤버를 순회하는 redacted 플러그인, 그리고 두 플러그인이 같은 FIR 트리에서 충돌하는 상황 등이 있었습니다.
backward compatibility를 지켜야 한다는 원칙은 중요합니다. 하지만 그 원칙이 어디까지 적용되는지, 어디서부터는 적용되지 않는지를 아는 것도 마찬가지로 중요합니다. Kotlin 컴파일러 플러그인 API는 후자에 속한다는 것을 고려한다면, Kotlin 버전을 올릴 때 플러그인 버전도 함께 확인하는 것을 잊지 말아야 할 것입니다.