코틀린 컴파일러는 `when`에서 모든 Sealed 서브클래스를 어떻게 파악할까
코틀린 컴파일러는 when에서 모든 Sealed 서브클래스를 어떻게 파악할까
sealed class에 대해 when 표현식을 작성하고, 모든 서브클래스를 빠짐없이 처리하면 컴파일러가 else 분기 없이도 코드를 통과시켜 줍니다. 그런데 같은 팀 동료가 다른 파일에 새로운 서브클래스를 하나 추가하는 순간, 프로젝트 내 모든 when 표현식에 "when expression must be exhaustive"라는 빨간 오류가 발생합니다. 테스트가 실패하기도 전에 컴파일 타임에서 누락된 케이스를 잡아낸 것입니다. 하지만 sealed 서브클래스는 모듈 내 여러 파일에 흩어져 있을 수 있습니다. 컴파일러는 이 모든 서브클래스를 어떻게 알아내고, when 분기가 전부 커버되었는지 어떻게 검증하는 것일까요?
이 글에서는 컴파일러의 sealed 서브클래스 수집 단계, when 분기와 전체 서브클래스 집합을 비교하는 전수 검사(exhaustiveness checking) 알고리즘, enum class와 Boolean 및 nullable 타입에 대한 특별 처리, 그리고 최종 바이트코드가 런타임에서 sealed when을 어떻게 표현하는지까지 전 과정을 추적해 보겠습니다.
근본적인 문제: 서브클래스가 모듈 전체에 흩어져 있다
enum class의 경우, 전수 검사는 단순합니다. enum class는 모든 엔트리를 한 곳에서 선언하므로, 컴파일러가 선언부에서 바로 읽어들일 수 있기 때문입니다. 반면 sealed class는 다릅니다. 같은 모듈 내에 있기만 하면 서브클래스를 서로 다른 파일에서 선언할 수 있습니다.
// Shape.kt
sealed interface Shape
// Circle.kt
data class Circle(val radius: Double) : Shape
// Rectangle.kt
data class Rectangle(val width: Double, val height: Double) : Shape
// Triangle.kt
data class Triangle(val base: Double, val height: Double) : Shape
when (shape) { is Circle -> ... is Rectangle -> ... }라고 작성했을 때, 컴파일러는 다른 파일에 Triangle이 존재하며 이를 빠뜨렸다는 사실을 알아야 합니다. 이를 위해 전수 검사 이전에 먼저 **전역 수집 패스(global collection pass)**가 필요합니다.
sealed class에 "같은 모듈" 제약이 존재하는 이유도 바로 여기에 있습니다. 만약 서브클래스를 다른 모듈(다른 Gradle 의존성)에서 선언할 수 있다면, 현재 모듈을 컴파일하는 컴파일러가 해당 서브클래스를 인식하지 못하게 됩니다. 그러면 전수 검사 보장이 깨져 버립니다. 이 제약은 결코 임의적인 것이 아니라, 컴파일러가 컴파일 시점에 접근 가능한 파일들로부터 완전한 서브클래스 목록을 구축할 수 있도록 하기 위해 의도적으로 설계된 것입니다.
1단계: Sealed 상속자 수집
컴파일러가 when 표현식을 검사하려면 먼저 각 sealed class의 직접 서브클래스 전체 목록이 필요합니다. 이 작업은 FirSealedClassInheritorsProcessor라는 전용 resolve 단계에서 수행됩니다.
이 프로세서는 모듈 내 모든 파일을 순회하면서 각 클래스 선언의 상위 타입(supertype) 목록을 살펴봅니다. 특정 클래스가 sealed class 또는 sealed interface를 상속하고 있다면, 해당 상속자를 기록합니다. 핵심 로직을 살펴보겠습니다.
override fun visitRegularClass(regularClass: FirRegularClass, data: Any?) {
super.visitRegularClass(regularClass, data)
for (parent in regularClass.superTypeRefs) {
val parentClassId = parent.resolvedClassId ?: continue
val parentClass = /* look up the sealed parent */
// sealed가 아니면 건너뛴다
if (parentClass.modality != Modality.SEALED) continue
// 패키지가 다르면 sealed 계약 위반이므로 건너뛴다
if (parent.classId.packageFqName != regularClass.classId.packageFqName)
continue
sealedClassInheritors
.getOrPut(parentClassId) { mutableSetOf() }
.add(regularClass.classId)
}
}
6번째 줄의 패키지 검사는 sealed 계약을 강제하는 부분으로, sealed interface의 상속자는 반드시 같은 패키지에 위치해야 합니다. Kotlin 1.5 이후에는 sealed class의 제약이 "같은 파일"에서 "같은 모듈"로 완화되었지만, 패키지는 여전히 일치해야 합니다.