"이 타입이 가질 수 있는 모든 경우가 여기 모여 있다." —
sealed를 한 줄로 요약하는 문장입니다. Kotlin의sealed class와sealed interface를 왜 쓰는지, 어떻게 쓰는지, 어떻게 잘 쓰는지, 그리고 언제 쓰면 안 되는지까지 한 번에 정리합니다.
대상 독자: Kotlin 기본 문법(클래스, 인터페이스,
when,data class, 제네릭)을 한 번이라도 다뤄본 개발자. 기초 개념이 부족하다면 먼저 코틀린 특징을 훑어보세요.
sealed는 왜 필요한가sealed(봉인된)는 말 그대로 "상속 계층을 봉인한다" 는 의미입니다. 일반 open 클래스나 interface는 누구나, 어디서나 상속·구현할 수 있지만, sealed는 컴파일 시점에 직접 자식의 종류가 정해져 있는 닫힌 계층(closed hierarchy)을 만듭니다.
예를 들어 네트워크 요청 결과를 표현한다고 해봅시다.
// 일반 인터페이스 — 누가 어떤 구현을 만들지 모름
interface NetworkResult
class Success(val body: String) : NetworkResult
class Failure(val code: Int) : NetworkResult
class Loading : NetworkResult
// 누군가 어디선가 또 만들 수 있음
이렇게 두면 when을 쓸 때 항상 else 분기를 강제당하고, 새 케이스가 추가되어도 컴파일러가 경고해주지 않습니다.
fun render(result: NetworkResult) = when (result) {
is Success -> "OK: ${result.body}"
is Failure -> "ERR: ${result.code}"
is Loading -> "..."
else -> "??" // 절대 도달하지 않아야 하는데도 강제됨
}
sealed를 쓰면 컴파일러가 "이 타입의 자식은 이게 전부" 라고 확신할 수 있어, else 없이도 모든 경우를 안전하게 처리할 수 있고, 나중에 케이스가 추가되면 기존 when을 모두 컴파일 에러로 알려줍니다.
핵심 한 줄:
sealed는 타입 시스템에 "유한한 가능성"을 가르쳐주는 도구입니다.
sealed class 기본 문법sealed class NetworkResult {
data class Success(val body: String) : NetworkResult()
data class Failure(val code: Int, val message: String) : NetworkResult()
data object Loading : NetworkResult()
}
특징을 하나씩 짚어봅시다.
sealed class는 그 자체로 추상입니다. NetworkResult()를 직접 생성할 수 없습니다.protected(기본값) 또는 private만 허용됩니다. public이나 internal을 명시하면 컴파일 에러가 납니다. 외부에서 직접 호출할 수 없으므로 봉인이 새지 않습니다.data class, class, object, data object 무엇이든 가능합니다.// File: NetworkResult.kt
package com.example.net
sealed class NetworkResult
// File: Success.kt (같은 패키지)
package com.example.net
data class Success(val body: String) : NetworkResult()
// File: Failure.kt
package com.example.net
data class Failure(val code: Int) : NetworkResult()
테스트하기 좋게 자식을 분리할 때 유용합니다. 다만 한눈에 계층을 파악하려면 한 파일에 두는 것을 권장합니다.
sealed class NetworkResult(open val timestamp: Long) {
data class Success(val body: String, override val timestamp: Long) : NetworkResult(timestamp)
data class Failure(val code: Int, override val timestamp: Long) : NetworkResult(timestamp)
data object Loading : NetworkResult(timestamp = 0L)
fun isTerminal(): Boolean = this !is Loading
}
sealed class에, 케이스별 상태는 자식에 둡니다.abstract도 OK).sealed interface 기본 문법sealed interface는 Kotlin 1.5에서 preview로 도입되었고 Kotlin 1.6부터 stable로 승격되었습니다. 개념은 sealed class와 동일하지만, 인터페이스이므로 다중 상속이 가능합니다.
sealed interface NetworkResult {
data class Success(val body: String) : NetworkResult
data class Failure(val code: Int) : NetworkResult
data object Loading : NetworkResult
}
sealed class와 비교한 핵심 차이:
| 항목 | sealed class |
sealed interface |
|---|---|---|
| 상속/구현 개수 | 단일 상속만 | 여러 인터페이스 동시 구현 가능 |
| 상태(필드) | 가질 수 있음 | 없음(인터페이스 제약) |
| 생성자 | 가질 수 있음 | 없음 |
| 권장 용도 | 공통 상태가 있는 계층 | 순수한 "케이스 분기"만 필요한 경우 |
실무 팁: 상태(필드)나 생성자가 필요 없으면 기본은
sealed interface로 시작하세요. 더 유연하고, 다중 상속도 가능합니다.
enum · abstract class와의 비교enum과의 차이enum class도 "유한한 케이스"를 표현하지만 한계가 분명합니다.
enum class Direction { NORTH, SOUTH, EAST, WEST }
| 항목 | enum class |
sealed class/interface |
|---|---|---|
| 각 케이스 | 단일 인스턴스(싱글톤) | 여러 인스턴스 가능 (예: Success("a"), Success("b")) |
| 케이스별 다른 필드 | 어렵고 어색함 | 자연스럽게 표현 |
when 망라성 |
지원 | 지원 |
| 타입 계층 | 없음 (평면적) | 계층 구조 가능 (sealed 안에 sealed 가능) |
선택 기준: 각 케이스가 동일한 "라벨"에 가깝다면 enum, 케이스마다 다른 데이터를 가지면 sealed.
enum 적합: 요일, 방향, 색상처럼 값 자체가 중요한 경우sealed 적합: API 응답 결과처럼 Success(data), Error(message), Loading처럼 상태와 데이터를 함께 표현하는 경우abstract class와의 차이abstract class Shape // 어디서든 누구나 상속 가능
sealed class SealedShape // 같은 모듈+패키지에서만 상속 가능
abstract는 확장에 열려있다(open). 라이브러리 사용자가 자신만의 자식을 추가하길 원할 때 사용.sealed는 확장이 닫혀있다(closed). 가능한 케이스가 한정되어 있고, 컴파일 시점에 그것을 알리고 싶을 때 사용.sealed의 핵심은 "컴파일러가 모든 자식을 알 수 있어야 한다" 는 원칙입니다. 이 원칙을 지키기 위해 다음 규칙이 적용됩니다.
| Kotlin 버전 | 자식 선언 가능 위치 |
|---|---|
| 1.0 | 부모 sealed 클래스의 중첩(nested) 클래스로만 가능 |
| 1.1 ~ 1.4 | 같은 파일 안 어디든 |
| 1.5+ | 같은 컴파일 모듈 + 같은 패키지 안 어디든 |
현재 사실상의 표준은 1.5+ 규칙입니다.
// File A: Result.kt
package com.example
sealed interface Result
// File B: Success.kt (같은 패키지, 같은 모듈)
package com.example
data class Success(val data: String) : Result // OK: 직접 자식
// File C: 다른 모듈 또는 다른 패키지
class Hacked : Result // 컴파일 에러
핵심 규칙:
sealed의 제약은 직접 자식에만 적용된다. 같은 모듈+패키지 안에서만 직접 자식을 선언할 수 있다.final(예: data class)이면 그 아래로 더 내려갈 수 없으므로 계층이 완전히 봉인된다.open이나 abstract이면 손자 레벨은 sealed의 제약 바깥이다. 다른 모듈에서도 그 손자를 만들 수 있어, 망라성은 직접 자식 수준에서만 유지되고 손자 이하 분기는 통제력을 잃는다.베스트 프랙티스:
sealed의 직접 자식은data class,data object, 또는 명시적으로final class로 두자. 직접 자식을open으로 두면 손자 계층의 봉인이 풀린다.
sealed로 만들기 (중첩 sealed)sealed interface Animal {
sealed interface Mammal : Animal {
data class Dog(val name: String) : Mammal
data class Cat(val name: String) : Mammal
}
sealed interface Bird : Animal {
data object Sparrow : Bird
data object Eagle : Bird
}
}
이렇게 하면 Animal은 Mammal | Bird로, 다시 각각 그 아래 케이스로 닫혀 있습니다. 계층적 ADT를 만드는 강력한 방법입니다.
when 식과 망라성(exhaustiveness)sealed의 가장 큰 실용적 이득은 when을 식(expression)으로 쓸 때의 망라성 검사입니다. when 자체에 대한 기본 사용법은 코틀린 특징 — 구문 문서를 참고하세요.
when이 식(expression)으로 사용되면 — 즉, 값을 반환하거나 변수에 대입하거나 함수의 마지막 표현식이면 — 컴파일러는 모든 분기를 커버하는지 확인합니다.
fun describe(result: NetworkResult): String = when (result) {
is NetworkResult.Success -> "Got ${result.body}"
is NetworkResult.Failure -> "Error ${result.code}"
NetworkResult.Loading -> "Loading"
// else 없음 — 컴파일러가 모든 케이스를 인지하기 때문
}
여기서 is NetworkResult.Failure 분기를 지우면 즉시 컴파일 에러가 납니다.
when이 값으로 쓰이지 않고 문장(statement)으로 쓰이면 과거에는 망라성 검사가 면제됐습니다.
fun log(result: NetworkResult) {
when (result) { // 식이 아닌 문장
is NetworkResult.Success -> println("OK")
// Failure, Loading 빼먹어도 컴파일됨!
}
}
망라성 정책의 변화 흐름:
-Xnon-exhaustive-when-statements 컴파일러 플래그 도입 — 활성화 시 sealed/enum/Boolean에 대한 비망라 when 문장에 경고.when 문장이 컴파일 에러로 승격.정책이 점점 엄격해져 왔으므로, 습관적으로
when을 식으로 쓰는 것이 가장 안전합니다.
망라성을 강제하는 흔한 트릭은 let을 활용하는 것입니다.
result.let { r -> when (r) { /* ... */ } }
sealed interface NetworkResult {
data class Success(val body: String) : NetworkResult
data class Failure(val code: Int) : NetworkResult
data object Loading : NetworkResult
data object Timeout : NetworkResult // 새로 추가!
}
Timeout을 추가하면 기존의 모든 when 식이 즉시 컴파일 에러가 됩니다. 이는 거대한 코드베이스에서 "이 변경의 영향 범위"를 컴파일러가 정확히 알려주는 막대한 가치를 제공합니다.
sealed class vs sealed interface 선택 기준자주 헷갈리는 부분입니다. 다음 표를 기준으로 결정하세요.
| 상황 | 권장 |
|---|---|
| 케이스마다 **공통 상태(필드)**가 있다 | sealed class |
| 공통 메서드가 필요하고 그 구현에 상태가 필요하다 | sealed class |
| 여러 계층(예: 에러를 IOError이면서 동시에 NetworkError로 분류)을 다중 분류해야 한다 | sealed interface |
| 단순히 케이스 분기를 만들고 싶다 | sealed interface (더 유연) |
기존 클래스(예: Throwable)도 그 계층에 포함시키고 싶다 |
sealed interface (다중 상속 가능) |
sealed interface DomainError {
val message: String
}
sealed class NetworkError(override val message: String) : DomainError {
class Timeout(message: String) : NetworkError(message)
class NoConnection(message: String) : NetworkError(message)
}
sealed class StorageError(override val message: String) : DomainError {
class NotFound(message: String) : StorageError(message)
class Corrupted(message: String) : StorageError(message)
}
최상위 그룹은 sealed interface로 두고, 각 하위 분류는 공통 상태가 필요하면 sealed class로 구체화합니다.
sealed interface의 가장 강력한 무기입니다. 하나의 타입이 여러 분류 체계에 동시에 속할 수 있게 해 줍니다.
sealed interface AppError
sealed interface Recoverable : AppError // 재시도 가능
sealed interface Critical : AppError // 즉시 종료
sealed interface UserVisible : AppError // 사용자에게 표시
// 한 에러가 여러 분류에 속함
class NetworkTimeout : Recoverable, UserVisible
class DiskFull : Critical, UserVisible
class InternalBug : Critical
class RetryableRpc : Recoverable
이렇게 두면 호출부에서 분류 축별로 처리할 수 있습니다.
fun handle(err: AppError) {
if (err is Recoverable) scheduleRetry(err)
if (err is UserVisible) showToast(err)
if (err is Critical) crashReport(err)
}
enum이나 sealed class로는 이런 교차 분류(orthogonal classification) 가 불가능합니다.
Result<T> 같은 컨테이너 타입을 만들 때, 변성 표기를 잘 써야 합성이 자연스럽습니다.
sealed interface Result<out T> {
data class Success<T>(val value: T) : Result<T>
data class Failure(val error: Throwable) : Result<Nothing>
}
핵심 포인트:
out T: 공변(covariant). Result<String>을 Result<Any>로 다룰 수 있게 됨.Failure는 값(value)을 갖지 않으므로 Result<Nothing>. Nothing은 모든 타입의 하위 타입이라 어떤 Result<T>에도 대입 가능.이 패턴 덕분에 다음이 컴파일됩니다.
fun fetch(): Result<User> {
if (offline) return Result.Failure(IOException("offline")) // Result<Nothing> → Result<User> OK
return Result.Success(loadUser())
}
map, flatMap 같은 콤비네이터 정의고차 함수와 람다에 익숙해졌다면 다음과 같은 콤비네이터를 자연스럽게 정의할 수 있습니다.
inline fun <T, R> Result<T>.map(transform: (T) -> R): Result<R> = when (this) {
is Result.Success -> Result.Success(transform(value))
is Result.Failure -> this // Result<Nothing>은 out T에 의해 Result<R>의 서브타입이므로 안전
}
inline fun <T, R> Result<T>.flatMap(transform: (T) -> Result<R>): Result<R> = when (this) {
is Result.Success -> transform(value)
is Result.Failure -> this
}
여기서 is Result.Failure -> this가 컴파일되는 이유는 변성(out T)에 의한 서브타입 관계 때문입니다. Failure의 정적 타입은 Result<Nothing>이고, Nothing은 모든 타입의 하위 타입이므로 공변 Result<out T>에서 Result<Nothing> <: Result<R>이 성립합니다. 타입 추론이 아니라 명시적인 서브타입 관계로 안전하게 업캐스트됩니다.
Kotlin 표준 라이브러리의
kotlin.Result<T>도 비슷한 아이디어지만@JvmInline value class기반이라when (r) { is Success -> ... }형태의 sealed 패턴 매칭이 불가능합니다. 도메인 결과를 표현하려면 직접sealed로 정의한Result가 다루기 쉽습니다.
함수형 프로그래밍의 ADT는 두 가지로 구성됩니다. 이 시각은 함수형 프로그래밍의 클로져와 같은 함수형 사고와 자연스럽게 이어집니다.
data classsealed class/interface즉, sealed + data class는 Kotlin의 ADT 표현 방식입니다.
sealed interface Expr {
data class Num(val v: Int) : Expr
data class Add(val l: Expr, val r: Expr) : Expr
data class Mul(val l: Expr, val r: Expr) : Expr
data class Neg(val e: Expr) : Expr
}
fun eval(e: Expr): Int = when (e) {
is Expr.Num -> e.v
is Expr.Add -> eval(e.l) + eval(e.r)
is Expr.Mul -> eval(e.l) * eval(e.r)
is Expr.Neg -> -eval(e.e)
}
// (2 + 3) * (-4) = -20
val tree = Expr.Mul(Expr.Add(Expr.Num(2), Expr.Num(3)), Expr.Neg(Expr.Num(4)))
println(eval(tree)) // -20
새로운 연산 Sub를 추가하면 eval이 즉시 컴파일 에러가 나서 계산 누락을 방지합니다.
sealed interface Lst<out A> {
data object Nil : Lst<Nothing>
data class Cons<A>(val head: A, val tail: Lst<A>) : Lst<A>
}
sealed interface DataResult<out T> {
data class Success<T>(val data: T) : DataResult<T>
data class Error(val throwable: Throwable) : DataResult<Nothing>
data object Empty : DataResult<Nothing>
}
코루틴과 함께 쓰는 결과 표현에서는 취소 예외(CancellationException)의 처리도 같이 고려해야 합니다 — 코루틴과 CancellationException 그리고 RestControllerAdvice를 참고하세요.
sealed interface UiState<out T> {
data object Idle : UiState<Nothing>
data object Loading : UiState<Nothing>
data class Success<T>(val data: T) : UiState<T>
data class Error(val message: String, val retryable: Boolean = true) : UiState<Nothing>
}
@Composable
fun Screen(state: UiState<List<Post>>) {
when (state) {
UiState.Idle, UiState.Loading -> Spinner()
is UiState.Success -> PostList(state.data)
is UiState.Error -> ErrorView(state.message, state.retryable)
}
}
화면 이동, 토스트 등 1회성 사이드 이펙트를 표현할 때.
sealed interface NavEvent {
data class Navigate(val route: String) : NavEvent
data object Back : NavEvent
data class ShowToast(val message: String) : NavEvent
}
sealed interface Action {
data object LoadPosts : Action
data class Like(val postId: String) : Action
data class Comment(val postId: String, val text: String) : Action
}
fun reduce(state: AppState, action: Action): AppState = when (action) {
Action.LoadPosts -> state.copy(loading = true)
is Action.Like -> state.toggleLike(action.postId)
is Action.Comment -> state.addComment(action.postId, action.text)
}
sealed interface DomainError {
val message: String
}
sealed interface Retryable : DomainError
sealed interface Fatal : DomainError
data class NetworkTimeout(override val message: String) : Retryable
data class NoInternet(override val message: String) : Retryable
data class InvalidToken(override val message: String) : Fatal
data class ServerCrash(override val message: String) : Fatal
sealed 계층은 다형성(polymorphism) 직렬화의 자연스러운 대상이 됩니다.
@Serializable
sealed interface ApiResponse {
@Serializable
@SerialName("ok")
data class Ok(val data: String) : ApiResponse
@Serializable
@SerialName("err")
data class Err(val code: Int, val reason: String) : ApiResponse
}
직렬화 결과(JSON):
{ "type": "ok", "data": "hello" }
{ "type": "err", "code": 500, "reason": "internal" }
"type")에 @SerialName 값이 들어갑니다.Json { classDiscriminator = "kind" }로 디스크리미네이터 이름을 바꿀 수 있습니다.sealed 계층은 컴파일러 플러그인이 모든 자식을 알 수 있으므로 SerializersModule 수동 등록 없이 자동 처리됩니다. 반면 open class 기반의 일반 다형성은 SerializersModule { polymorphic(...) }로 등록해야 합니다.자식이 같은 모듈/패키지에 있어야 한다는 sealed 규칙이 그대로 적용됩니다. 직렬화 대상 자식을 다른 모듈에 두려면 sealed가 아닌 일반 다형성으로 전환하고 모듈 등록을 해야 합니다.
① 직접 자식을 open class로 두기
sealed interface Event
open class Click : Event // Click의 손자는 다른 모듈에서도 만들 수 있음
→ 직접 자식 수준의 망라성은 유지되지만, 손자 계층의 봉인은 풀립니다. 직접 자식이 Click이므로 when (e) { is Click -> ... }은 여전히 망라적이지만, Click의 손자에 대한 분기를 잡으려는 순간 통제가 불가능해집니다. 자식은 data class, data object, 혹은 명시적 final class로.
② 거대한 when 안에 비즈니스 로직 전부 박기
when (event) {
is Click -> { /* 50줄 */ }
is Swipe -> { /* 60줄 */ }
/* ... */
}
→ 분기마다 별도 함수로 추출하거나, 객체별 메서드(다형성)로 옮기는 것을 고려.
③ else 분기로 망라성 검사를 무력화
when (state) {
is UiState.Success -> ...
else -> Unit // 새 케이스 추가 시 컴파일러가 못 잡아냄
}
→ 정말로 무시해야 하는 경우라면 모든 케이스를 명시한 뒤, 무시할 케이스에 -> Unit을 두자.
④ 한 sealed 안에 케이스를 100개씩 욱여넣기
→ 너무 평평하면 도메인 구조가 죽습니다. 의미상 묶이는 그룹이 있으면 **중첩 sealed**로 계층화하세요(§5.3).
sealed interface로 시작, 공통 상태가 필요해지면 sealed class로 승격.data object(Kotlin 1.9+ stable)를 쓰자. toString이 클래스명을 반환하도록 자동 생성되어 의도가 명확해진다.UiState.Loading, Result.Success 형태로 자연스럽게 읽히도록 네이밍.when을 항상 식으로 쓰자. 변수 대입이나 반환에 사용.sealed를 노출할 때는 신중하게. 라이브러리 사용자가 새 케이스를 추가하지 못하게 막는 것이 의도라면 OK, 그게 아니라면 일반 interface/abstract class가 적절.sealed를 사용하면 안 되는 케이스sealed는 "유한한 가능성"을 컴파일러에게 알려주는 도구입니다. 따라서 가능성을 유한하게 닫지 못하는 상황이나, 닫는 비용이 이득보다 큰 상황에서는 오히려 해롭습니다.
라이브러리·프레임워크의 사용자가 자신만의 케이스를 추가할 수 있어야 한다면 sealed는 부적합합니다.
// ❌ 잘못된 선택: 사용자가 자신만의 PaymentMethod를 추가할 수 없음
sealed interface PaymentMethod {
data object Card : PaymentMethod
data object Bank : PaymentMethod
}
// ✅ 올바른 선택: 사용자 모듈에서 새 결제 수단을 등록 가능
interface PaymentMethod { fun pay(amount: Long): Result<Unit> }
→ 확장 포인트/SPI에는 일반 interface + 등록 패턴(ServiceLoader, DI 컨테이너 등)을 사용하세요.
sealed 계층에 자식을 추가하는 변경은 binary-compatible하지 않습니다. 라이브러리 사용자가 망라적 when을 작성한 경우, 새 케이스가 추가되면 사용자 코드가 컴파일 에러가 납니다.
→ 공개 SDK에서 자유로운 확장을 허용해야 한다면 sealed 대신 open 계층이나 enum-with-default를 검토하세요.
when 분기 자체가 비대해져 가독성과 유지보수성이 떨어집니다.→ 상태 머신의 케이스가 매우 많다면 다형성(각 타입에 메서드), 전략 패턴, 또는 단순 Map<Key, Handler> 디스패치가 더 깔끔합니다.
각 케이스가 추가 데이터를 갖지 않고, 단지 라벨 역할만 한다면 enum이 더 가볍고 명확합니다.
// ❌ 과도한 sealed
sealed interface Direction {
data object North : Direction
data object South : Direction
data object East : Direction
data object West : Direction
}
// ✅ enum이 더 적절
enum class Direction { NORTH, SOUTH, EAST, WEST }
→ values(), valueOf(), name, ordinal 같은 enum 전용 기능을 공짜로 얻을 수 있고, 직렬화/리플렉션도 더 단순합니다.
sealed의 핵심 가치는 when 망라성입니다. 코드 어디서도 when (x) { is A -> ...; is B -> ... } 형태로 케이스별 분기를 하지 않는다면, 굳이 sealed로 묶을 이유가 없습니다.
→ 단순히 "관련 있는 데이터들"이라면 별도 data class들로 두고, 필요할 때만 공통 interface로 묶으세요.
Kotlin sealed는 JVM 바이트코드 레벨에서 PermittedSubclasses attribute를 사용하지 않습니다. 대신 Kotlin Metadata 어노테이션으로만 봉인을 표현합니다. 그래서 Java 17+ 코드에서 Kotlin sealed 타입을 switch 패턴 매칭으로 다뤄도 망라성 검사를 받지 못합니다. Java 입장에서는 일반 abstract 클래스/인터페이스로 보입니다.
→ Java 클라이언트가 주된 사용처라면, Java 17 sealed로 직접 선언하거나 별도 자바 친화적 분기 API(visitor 패턴 등)를 제공해야 합니다.
kotlinx.serialization으로 sealed를 자동 다형성 직렬화하려면 디스크리미네이터 키("type" 등)가 필요합니다. 외부 API의 JSON 스키마가 디스크리미네이터 없이 필드 존재 여부로 종류를 구분한다면 sealed 자동 직렬화가 잘 맞지 않습니다.
→ 별도 커스텀 KSerializer를 작성하거나, 내부 도메인 모델과 외부 와이어 모델을 분리하세요.
| 상황 | 적합한 도구 |
|---|---|
| 외부에서 케이스 확장 필요 | 일반 interface + 등록 패턴 |
| 케이스가 라벨뿐 | enum class |
| 케이스가 런타임에 결정 | Map/DI/전략 패턴 |
| Java 클라이언트가 주 사용처 | Java 17 sealed 또는 Visitor |
| 외부 ABI 안정성이 핵심 | open 계층 + 기본 분기 |
| 분기 없이 데이터만 묶기 | 일반 data class + 공통 interface |
"
sealed를 쓰면 좋을까?" 보다 먼저 "케이스가 정말로 유한하고 닫혀 있는가?"를 자문하세요. 답이 "아니다" 또는 "모르겠다"라면 sealed는 부적합합니다.
Q1. sealed class와 abstract class의 차이가 결국 "상속 제한"뿐인가요?
표면적으로는 그렇지만, 그 제한 때문에 컴파일러가 망라성 검사를 해줄 수 있다는 결정적 차이가 생깁니다. 이건 단순 제약이 아니라 타입 시스템의 능력 증가입니다.
Q2. sealed인데 else를 꼭 안 써도 되나요?
when이 식으로 쓰이고 컴파일러가 모든 자식을 알면 else는 불필요합니다. 안 쓰는 게 권장입니다 — 그래야 케이스 추가 시 컴파일러가 알려줍니다.
Q3. 자식 클래스가 라이브러리 사용자 측에 있어도 되나요?
안 됩니다. sealed의 직접 자식은 반드시 선언된 모듈 + 패키지 안에 있어야 합니다. 라이브러리 사용자가 케이스를 추가할 수 있게 하고 싶다면 sealed를 쓰면 안 됩니다 (§14.1 참고).
Q4. sealed도 object 자식을 가질 수 있나요?
가능합니다. 상태가 없는 케이스는 data object를 권장합니다(toString이 클래스명을 반환).
Q5. sealed interface의 자식을 인터페이스로 다시 둘 수 있나요?
가능합니다(§5.3). 단, 자식 인터페이스 역시 sealed로 두지 않으면 거기서부터는 봉인이 풀립니다.
Q6. Java에서 Kotlin sealed를 다룰 수 있나요?
직접 호환되지 않습니다. Kotlin 컴파일러는 기본적으로 JVM PermittedSubclasses attribute를 출력하지 않고, Kotlin Metadata 어노테이션으로만 봉인을 표현합니다. 따라서 Java 17+ switch 패턴 매칭은 Kotlin sealed 타입을 망라적으로 인식하지 못하고, Java 입장에서는 일반 abstract 클래스/인터페이스로 보입니다. Java 친화적인 API가 필요하다면 Java 쪽에서 sealed로 직접 모델링하거나 Visitor 패턴을 함께 제공하세요.
Q7. 성능 페널티가 있나요?
없습니다. sealed는 컴파일 시점의 제약일 뿐, 런타임에는 일반 클래스/인터페이스와 동일합니다. when의 is 검사도 JIT가 잘 최적화합니다.
Q8. sealed vs Kotlin 표준 Result<T>?
표준 kotlin.Result<T>는 @JvmInline value class 로 구현되어 있고, 내부적으로 Success/Failure가 sealed 계층이 아닌 단일 value class에 Any? 필드로 인코딩되어 있습니다. 따라서 when (r) { is Success -> ... } 형태의 직접 패턴 매칭은 불가하고, isSuccess / isFailure / getOrNull / exceptionOrNull / fold 같은 API로만 분기해야 합니다. 도메인 결과를 풍부하게 표현하거나 직접 분기해서 다루려면 sealed Result가 더 낫습니다.
정답은 각 문제 끝의 토글에 두었습니다. 먼저 직접 풀어보세요.
다음 코드가 컴파일 에러가 나는 이유는?
sealed interface Light {
data object Red : Light
data object Yellow : Light
data object Green : Light
}
fun action(light: Light): String = when (light) {
Light.Red -> "Stop"
Light.Green -> "Go"
}
when이 식(반환값)으로 쓰였는데 Yellow 케이스가 빠져 있어 망라성 검사를 통과하지 못합니다. Light.Yellow -> "Slow" 추가하거나, Yellow를 의도적으로 무시한다면 명시적으로 분기에 두고 -> Unit(반환 타입이 Unit인 함수일 때)으로 처리해야 합니다.
다음 요구사항을 sealed interface로 모델링하세요.
Info, Warning, Critical 세 단계Sound(소리 발생) / Silent 두 분류로도 나뉨sealed interface Notification { val message: String }
sealed interface SeverityInfo : Notification
sealed interface SeverityWarning : Notification
sealed interface SeverityCritical : Notification
sealed interface Sound : Notification
sealed interface Silent : Notification
data class CriticalAlarm(override val message: String) : SeverityCritical, Sound
data class WarningBanner(override val message: String) : SeverityWarning, Silent
data class InfoToast(override val message: String) : SeverityInfo, Silent
data class WarningBeep(override val message: String) : SeverityWarning, Sound
이러면 if (n is Sound) play() 식으로 한 축을 따로 처리할 수 있습니다.
다음 코드를 변성 표기를 활용해 자연스럽게 동작하게 고치세요.
sealed interface Box<T> {
data class Full<T>(val v: T) : Box<T>
data object Empty : Box<???> // ???
}
val b: Box<String> = Box.Empty // 컴파일 되도록
sealed interface Box<out T> {
data class Full<T>(val v: T) : Box<T>
data object Empty : Box<Nothing>
}
val b: Box<String> = Box.Empty // Box<Nothing>은 Box<String>의 하위 타입 (out 덕분)
out T로 공변을 부여하고, 값이 없는 Empty는 Box<Nothing>으로 두는 것이 표준 패턴입니다.
Expr 타입을 확장해 변수와 변수 환경을 지원하세요. 즉, Var("x") 같은 노드를 추가하고, Map<String, Int> 환경에서 변수의 값을 찾아 평가하도록 eval 함수를 일반화하세요.
sealed interface Expr {
data class Num(val v: Int) : Expr
data class Var(val name: String) : Expr
data class Add(val l: Expr, val r: Expr) : Expr
data class Mul(val l: Expr, val r: Expr) : Expr
}
fun eval(e: Expr, env: Map<String, Int>): Int = when (e) {
is Expr.Num -> e.v
is Expr.Var -> env[e.name] ?: error("undefined: ${e.name}")
is Expr.Add -> eval(e.l, env) + eval(e.r, env)
is Expr.Mul -> eval(e.l, env) * eval(e.r, env)
}
새 노드 Var를 추가하자 eval이 컴파일 에러가 나며 누락을 알려줍니다 — 이것이 ADT + 망라성의 위력입니다.
처음 배운다면 다음 순서로 접근하면 이해가 쉽습니다.
sealed class 문법으로 작은 상태 머신(예: 신호등, 결제 결과)을 만든다.when을 식으로 쓰면 else 없이도 컴파일되는지 직접 확인한다.enum과 비교해 "언제 sealed를 써야 하는지" 판단한다.sealed interface로 넘어가 다중 구현과 계층 설계를 본다.sealed 사고방식 한 줄 정리"이 타입이 가질 수 있는 모든 경우가 여기 모여 있다."
이 한 줄을 코드로 강제하는 도구가 sealed입니다. 그 결과 새로운 케이스가 추가되어도 영향 범위를 컴파일러가 빠짐없이 알려주는 안전한 코드를 만들 수 있습니다.
sealed interfacesealed classsealed interface의 다중 상속그러나 강력한 도구일수록 쓰지 말아야 할 자리를 분별하는 능력이 중요합니다. 외부 확장이 필요하거나 케이스가 동적이거나 단순 라벨에 가깝다면 다른 도구를 선택하는 안목이 필요합니다.
처음에는 enum처럼 단순해 보이지만, 익숙해질수록 타입 시스템과 컴파일러를 협력자로 만드는 강력한 도구임을 느끼게 될 것입니다.
가장 빠른 학습은 본인 코드를 직접 리팩터링하면서 컴파일러와 대화해보는 것입니다. 실제 프로젝트의 if/else 사슬이나 String 타입 상태 코드(예: status = "success")부터 sealed로 옮겨보세요.