Sealed class와 Kotlin의 when
1. 개요
값이 정해진 몇 가지 경우만 가질 수 있을 때, 예를 들어 결제 결과가 성공, 실패, 또는 대기 중일 때, 컴파일러가 그 목록을 정확히 알기를 원할 것입니다. Kotlin은 상속 계층을 잠금 처리하는 sealed class와 sealed interface를 제공합니다. when 표현식은 모든 분기를 처리했는지 확인할 수 있습니다.
이 글을 읽고 나면 sealed hierarchy를 선언하고 exhaustive한 when을 작성하는 방법을 알게 될 것입니다. 또한 guard condition을 사용하고, 이 로직에 대한 테스트를 작성하며, sealed class를 사용하지 말아야 할 때를 알아볼 수 있을 것입니다.
2. 개방된 상속 계층의 문제점
일반적인 abstract class로 결제 결과를 모델링해 봅시다:
abstract class PaymentResult
class Success(val transactionId: String) : PaymentResult()
class Failed(val reason: String) : PaymentResult()
fun describe(result: PaymentResult): String = when (result) {
is Success -> "OK: ${result.transactionId}"
is Failed -> "에러: ${result.reason}"
else -> "알 수 없음"
}
else 분기는 필수입니다. 이유는 어떤 모듈에서든 누구나 PaymentResult의 추가 서브클래스를 만들 수 있기 때문입니다. 컴파일러는 서브클래스 목록이 완전한지 알 수 없습니다.
결과적으로 누군가 class Refunded : PaymentResult()를 추가하면, 코드는 여전히 정상적으로 컴파일됩니다. Refunded 값은 조용히 "알 수 없음" 분기로 들어갑니다. 이 종류의 버그는 보통 프로덕션 환경에서만 드러납니다.
enum class는 부분적으로 이 문제를 해결하는데, 상수 목록이 정해지기 때문입니다. 그러나 각 enum 상수는 유일한 인스턴스입니다. 각 성공한 결제마다 다른 transactionId를 저장할 수 없습니다.
3. Sealed class 선언하기
Sealed class는 두 방법의 장점을 결합합니다. 서브클래스 목록은 enum처럼 정해져 있고, 각 서브클래스는 여전히 일반 클래스처럼 데이터를 가질 수 있습니다.
3.1. 의존성
이 글의 예제는 JVM 21에서 Kotlin 2.2를 사용합니다. 테스트 부분은 JUnit 5와 AssertJ를 사용합니다:
<dependencies>
<dependency>
<groupId>org.jetbrains.kotlin</groupId>
<artifactId>kotlin-stdlib</artifactId>
<version>2.2.20</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.4</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.27.3</version>
<scope>test</scope>
</dependency>
</dependencies>
또한 Kotlin 소스 코드를 컴파일하기 위해 같은 버전의 kotlin-maven-plugin을 설정해야 합니다.
3.2. 간단한 sealed hierarchy
sealed class PaymentResult {
data class Success(val transactionId: String, val amount: Long) : PaymentResult()
data class Failed(val reason: String, val retryable: Boolean) : PaymentResult()
data object Pending : PaymentResult()
}
위 코드의 각 부분은 다음을 의미합니다:
sealed class PaymentResult는 암시적으로 abstract 클래스입니다.PaymentResult()를 직접 호출할 수 없습니다. 생성자는 기본값이protected입니다.Success와Failed는data class입니다.equals,hashCode,toString,copy를 자동으로 얻을 수 있으므로 테스트를 작성할 때 매우 편합니다.Pending은 데이터를 가지지 않으므로data object를 사용합니다. 싱글톤이며toString()이PaymentResult$Pending@1b6d3586같은 타입 대신Pending을 출력합니다.
가장 중요한 규칙은: 모든 직접 서브클래스는 sealed class와 같은 패키지와 같은 모듈에 있어야 합니다. 같은 패키지 내의 여러 파일로 분산할 수 있습니다. 그러나 라이브러리를 사용하는 다른 모듈은 새로운 서브클래스를 추가할 수 없습니다. 이 규칙 때문에 컴파일러가 서브클래스 목록을 확실히 알 수 있습니다.
4. Exhaustive when 표현식
4.1. else 분기 제거하기
when의 검사 대상이 sealed 타입일 때, else를 더 이상 작성할 필요가 없습니다:
fun describe(result: PaymentResult): String = when (result) {
is PaymentResult.Success -> "결제 완료 ${result.amount} VND (거래 ID ${result.transactionId})"
is PaymentResult.Failed -> "실패: ${result.reason}"
PaymentResult.Pending -> "처리 대기 중"
}
fun main() {
println(describe(PaymentResult.Success("TX-1001", 250_000)))
println(describe(PaymentResult.Failed("카드 만료", retryable = false)))
println(describe(PaymentResult.Pending))
}
실행 결과:
결제 완료 250000 VND (거래 ID TX-1001)
실패: 카드 만료
처리 대기 중
위 코드에서 주의할 점 세 가지:
- Smart cast:
is PaymentResult.Success분기 내에서 변수result는 자동으로Success로 캐스트됩니다. 따라서result.amount를 수동으로 캐스트하지 않고 접근할 수 있습니다. object의 경우,is없이PaymentResult.Pending으로 직접 비교합니다.is PaymentResult.Pending을 작성해도 되지만 필요하지 않습니다.when은 여기서 표현식이므로 값을 반환하고 함수 본문으로 사용됩니다. 표현식when은 반드시 exhaustive해야 합니다.
4.2. 컴파일러가 새로운 케이스 추가 시 오류를 잡아냄
새로운 환불 상태를 추가했다고 가정합시다:
sealed class PaymentResult {
data class Success(val transactionId: String, val amount: Long) : PaymentResult()
data class Failed(val reason: String, val retryable: Boolean) : PaymentResult()
data object Pending : PaymentResult()
data class Refunded(val transactionId: String) : PaymentResult()
}
describe 함수는 즉시 컴파일되지 않습니다:
e: 'when' expression must be exhaustive. Add the 'is Refunded' branch or an 'else' branch.
이것이 sealed class의 핵심 가치입니다. PaymentResult에 when을 사용하는 코드의 모든 위치에서 컴파일러가 지적합니다. 어떤 곳도 빠뜨릴 수 없습니다.
4.3. 문 형태의 when
Kotlin 1.7부터 when이 문 형태(반환값을 얻지 않음)로 사용되더라도, subject가 sealed, enum, 또는 Boolean 타입이면 컴파일러는 분기가 빠졌을 때 오류를 보고합니다:
fun log(result: PaymentResult) {
when (result) {
is PaymentResult.Success -> println("INFO 거래 ${result.transactionId}")
is PaymentResult.Failed -> println("WARN ${result.reason}")
PaymentResult.Pending -> println("DEBUG 대기 중")
is PaymentResult.Refunded -> println("INFO 환불 ${result.transactionId}")
}
}
Refunded 분기를 생략하면 이전 Kotlin 버전의 경고가 아니라 컴파일 오류를 받습니다.
5. Sealed interface
클래스는 하나의 클래스 부모만 상속할 수 있지만, 여러 interface를 구현할 수 있습니다. 따라서 sealed interface는 하나의 타입이 동시에 여러 그룹에 속할 때 더 유연합니다:
sealed interface OrderEvent
sealed interface Notifiable {
val customerEmail: String
}
data class OrderPlaced(val orderId: String, override val customerEmail: String) : OrderEvent, Notifiable
data class OrderShipped(val orderId: String, val trackingCode: String, override val customerEmail: String) : OrderEvent, Notifiable
data class StockReserved(val orderId: String, val sku: String) : OrderEvent
fun handle(event: OrderEvent): String = when (event) {
is OrderPlaced -> "주문 ${event.orderId}에 대한 인보이스 생성"
is OrderShipped -> "추적 코드 ${event.trackingCode}"
is StockReserved -> "상품 ${event.sku} 예약"
}
fun emailOf(target: Notifiable): String = when (target) {
is OrderPlaced -> "${target.customerEmail}로 주문 확인 메일 전송"
is OrderShipped -> "${target.customerEmail}로 배송 알림 메일 전송"
}
OrderPlaced는 OrderEvent이면서 동시에 Notifiable입니다. emailOf 함수는 두 개의 분기만 필요합니다. 컴파일러가 Notifiable을 구현하는 클래스가 정확히 두 개뿐임을 알기 때문입니다.
Sealed interface는 enum과도 잘 어울립니다. enum class는 sealed interface를 구현할 수 있으며, 이 경우 각 enum 상수는 when 내의 분기로 계산됩니다.
5.1. 여러 레벨의 중첩
서브클래스 자체도 sealed일 수 있습니다. 이렇게 하면 더 자세하게 그룹화할 수 있습니다:
sealed interface PaymentError {
sealed interface Retryable : PaymentError
data object Timeout : Retryable
data object GatewayBusy : Retryable
data class CardDeclined(val code: String) : PaymentError
}
fun shouldRetry(error: PaymentError): Boolean = when (error) {
is PaymentError.Retryable -> true
is PaymentError.CardDeclined -> false
}
is PaymentError.Retryable 분기가 Timeout과 GatewayBusy 모두를 커버하므로, 두 개의 분기만 있어도 when은 여전히 exhaustive합니다.
6. When에서 guard condition
때로는 같은 타입이지만 내부 데이터에 따라 다르게 처리하고 싶을 수 있습니다. Kotlin 2.2는 공식적으로 guard condition (분기 조건 뒤의 if 키워드)을 지원합니다:
fun nextAction(result: PaymentResult): String = when (result) {
is PaymentResult.Success -> "인보이스 전송"
is PaymentResult.Failed if result.retryable -> "30초 후 재시도"
is PaymentResult.Failed -> "고객에게 오류 보고"
PaymentResult.Pending -> "결제 게이트웨이 웹훅 대기"
is PaymentResult.Refunded -> "회계 원장 업데이트"
}
Guard가 있는 분기는 타입과 if 조건이 모두 참일 때만 매칭됩니다. 컴파일러는 guard가 있는 분기가 이미 Failed를 커버했다고 보지 않습니다. 따라서 guard 없는 is PaymentResult.Failed 분기를 빠뜨리면 exhaustive 오류가 발생합니다.
참고: Kotlin 2.1에서는 guard condition이 여전히 preview 기능이며
-Xwhen-guards플래그가 필요합니다. 사용하기 전에 프로젝트의 Kotlin 버전을 확인하세요.
7. 서비스에서 통합하기
실제 코드에서 sealed class는 종종 exception을 던지는 대신 service의 반환 타입입니다:
data class Order(val id: String, val amount: Long)
class PaymentService {
fun charge(order: Order): PaymentResult = when {
order.amount <= 0 -> PaymentResult.Failed("금액이 유효하지 않음", retryable = false)
order.amount > 50_000_000 -> PaymentResult.Pending
else -> PaymentResult.Success("TX-${order.id}", order.amount)
}
}
fun main() {
val service = PaymentService()
when (val result = service.charge(Order("A42", 120_000))) {
is PaymentResult.Success -> println("거래 ID: ${result.transactionId}")
else -> println(describe(result))
}
}
거래 ID: TX-A42
charge 함수에서 when은 subject가 없으므로 else가 필요합니다. 여기서 else는 완전히 합리적입니다. 조건들이 임의의 부울 표현식이기 때문입니다.
main에서 when (val result = ...) 문법은 service를 호출하면서 동시에 변수 result의 범위를 when 블록으로 제한합니다. else 분기는 의도적입니다: 성공한 경우만 신경 씁니다. 섹션 9에서 이 선택의 대가를 설명하겠습니다.
8. 테스트 작성하기
data class 덕분에 isEqualTo로 결과를 비교하는 것이 간단합니다:
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
class PaymentServiceTest {
private val service = PaymentService()
@Test
fun `유효한 주문이 Success 반환`() {
val result = service.charge(Order("A1", 99_000))
assertThat(result).isEqualTo(PaymentResult.Success("TX-A1", 99_000))
}
@Test
fun `음수 금액이 재시도 불가능한 Failed 반환`() {
val result = service.charge(Order("A2", -5))
assertThat(result).isInstanceOf(PaymentResult.Failed::class.java)
assertThat((result as PaymentResult.Failed).retryable).isFalse()
}
@Test
fun `큰 주문이 Pending으로 전환`() {
assertThat(service.charge(Order("A3", 60_000_000))).isSameAs(PaymentResult.Pending)
}
@Test
fun `재시도 가능한 오류가 재시도 작업 유발`() {
val action = nextAction(PaymentResult.Failed("게이트웨이 타임아웃", retryable = true))
assertThat(action).isEqualTo("30초 후 재시도")
}
}
이 테스트 스위트는 세 가지를 확인합니다:
- 첫 번째 테스트는
data class가 생성한equals를 사용하여 전체 데이터를 비교합니다. isSameAs는data object에 사용합니다.Pending은 싱글톤이므로 참조 비교로 충분합니다.- 마지막 테스트는 guard condition이 있는 분기를 확인합니다.
9. 흔한 실수와 피해야 할 경우
Sealed 타입의 when에 else 추가하기. 이것이 가장 흔한 실수입니다. “보험”으로 else 분기를 추가하면 exhaustive 검사 메커니즘이 완전히 비활성화됩니다. 새로운 서브클래스를 추가하면 컴파일러가 조용합니다. 정말로 나머지 모든 경우를 묶고 싶을 때만 else를 사용하세요. 섹션 7의 예제처럼요.
Subject 없는 when 사용하기. when { result is Success -> ... }는 sealed hierarchy에 따른 exhaustive 검사를 받지 않습니다. when (result)로 작성하여 컴파일러가 분석할 수 있게 하세요.
Object에 data 빠뜨리기. object Pending도 대개 잘 작동합니다. 그러나 로그에 클래스 이름과 해시 코드가 출력되어 디버깅할 때 읽기 어려워집니다.
Module 밖에 서브클래스 배치하기. 라이브러리를 작성하고 사용자가 자신의 버전을 추가하도록 하고 싶다면 (예: 플러그인), sealed class는 잘못된 선택입니다. 일반 interface 또는 abstract class를 사용하세요.
참고: Sealed class를 사용하면 안 되는 경우:
- 케이스들이 단순히
LOW,MEDIUM,HIGH처럼 데이터를 가지지 않는 상수인 경우. 이때는enum class가 더 간결하고entries,valueOf를 갖춘 형태입니다.- 서브클래스 목록이 매우 자주 변경되고 여러 module의 많은 팀이 기여하는 경우.
- 동작이 객체 자체에 있어야 하는 경우 (method override를 통한 다형성). 같은
when이 열 곳에 반복되면, 각 서브클래스의 메서드로 로직을 옮기는 것을 고려하세요.
10. 결론
Sealed class와 sealed interface는 상속 계층을 하나의 module로 잠금 처리합니다. 덕분에 컴파일러가 모든 케이스를 알 수 있습니다. Subject가 있는 when과 결합하면 smart cast를 얻고, else를 작성할 필요가 없으며, 새로운 상태를 처리하지 않으면 즉시 컴파일 오류를 받습니다.
데이터를 가진 경우에는 data class를, 데이터를 가지지 않은 경우에는 data object를 사용하세요. 타입이 여러 그룹에 속해야 할 때 sealed interface를 선택하세요. Kotlin 2.2의 guard condition은 내부 데이터에 따라 분기를 나누는 데 도움을 줍니다. 가장 중요한 것은 불필요한 else 분기를 피하는 것입니다. 이것이 바로 여러분이 추구하는 이점을 없애버리기 때문입니다.