AICosmus

Where tech meets the everyday — AI, fintech, swimming, and cars.
Kotlin Sealed Class 개념을 표현한 밀봉된 타입 일러스트

Kotlin Sealed Class 핵심 정리와 실전 활용 패턴

Kotlin을 사용하다 보면 “이 변수가 가질 수 있는 상태는 딱 세 가지인데, 어떻게 타입으로 안전하게 표현하지?”라는 고민을 하게 됩니다. enum을 쓰자니 각 상태마다 서로 다른 데이터를 담을 수 없고, abstract class를 쓰자니 누군가 엉뚱한 하위 클래스를 추가할까 걱정되죠. 이 딜레마를 깔끔하게 해결해주는 것이 바로 Sealed Class입니다.

Sealed Class는 컴파일 타임에 가능한 모든 하위 타입을 확정하는 제한된 클래스 계층을 만들어줍니다. 덕분에 when 표현식에서 빠뜨린 분기가 있으면 컴파일러가 즉시 경고해주고, 런타임 에러 대신 컴파일 에러로 문제를 미리 잡을 수 있습니다. Android의 UI 상태 관리, 서버 API 응답 처리, 도메인 이벤트 모델링까지 실무 곳곳에서 활약하는 핵심 도구이기도 하죠.

이 글에서는 Sealed Class의 기본 문법부터 시작해서 Sealed Interface와의 차이, when 표현식과의 시너지, 그리고 현업에서 바로 적용할 수 있는 세 가지 실전 패턴까지 차근차근 다뤄보겠습니다.

Sealed Class란 무엇인가

Sealed Class를 한마디로 정의하면 “하위 타입이 컴파일 타임에 확정되는 추상 클래스”입니다. 일반 abstract class와 달리, sealed로 선언된 클래스의 직접 하위 클래스는 반드시 같은 패키지 내에 정의해야 합니다. 외부 모듈이나 다른 패키지에서 마음대로 확장할 수 없다는 뜻이죠.

sealed class NetworkResult {
    data class Success(val data: String) : NetworkResult()
    data class Error(val code: Int, val message: String) : NetworkResult()
    data object Loading : NetworkResult()
}

위 코드에서 NetworkResult는 Success, Error, Loading 이 세 가지 형태만 존재할 수 있습니다. 다른 곳에서 class Timeout : NetworkResult()를 만들려고 하면 컴파일 에러가 발생합니다. 이 “닫힌 계층” 덕분에 컴파일러는 when 표현식에서 모든 경우를 처리했는지 검증할 수 있게 됩니다.

핵심 특징 정리

  • 추상 클래스 기반: sealed class 자체는 인스턴스를 직접 만들 수 없습니다. 반드시 하위 클래스를 통해서만 객체를 생성합니다.
  • 하위 타입 제한: 직접 하위 클래스는 같은 패키지 내에서만 선언 가능합니다. 간접 하위 클래스(하위 클래스의 하위 클래스)는 어디서든 선언할 수 있지만, when의 완전성 검사에는 직접 하위 클래스만 관여합니다.
  • 각 하위 타입이 고유한 프로퍼티 보유: enum과 달리, 각 하위 클래스가 서로 다른 프로퍼티와 메서드를 가질 수 있습니다.
  • data class, object 모두 가능: 하위 타입을 data class로 만들면 equals/hashCode/copy를 자동 생성받고, 상태 없는 싱글턴은 data object(또는 object)로 선언합니다.
Sealed Class vs Enum vs Abstract Class 비교 인포그래픽

Sealed Class vs Enum vs Abstract Class

비슷한 역할을 하는 것처럼 보이는 세 가지 도구를 비교해보면 Sealed Class의 자리가 더 명확해집니다.

Enum Class의 한계

Enum은 고정된 상수 집합을 표현하는 데 최적화되어 있습니다. 요일, 방향, 상태 코드 같은 값들이죠. 하지만 각 상수가 서로 다른 데이터를 담아야 하는 상황에서는 불편합니다.

// Enum으로 표현하려니 어색한 경우
enum class Result {
    SUCCESS,  // 여기에 응답 데이터를 담고 싶은데...
    ERROR     // 에러 메시지도 필요한데...
}

Enum의 각 항목은 모두 같은 프로퍼티 구조를 공유해야 합니다. SUCCESS에는 data가 필요하고 ERROR에는 message가 필요한 상황이라면, 둘 다 nullable로 선언하는 꼼수를 부려야 하고, 이는 타입 안전성을 해칩니다.

Abstract Class의 한계

Abstract class는 하위 타입마다 다른 프로퍼티를 가질 수 있지만, 누구든 어디서든 상속할 수 있습니다. 컴파일러 입장에서는 가능한 하위 타입의 범위를 알 수 없으므로, when 표현식에서 else 분기를 강제합니다.

abstract class Shape
class Circle(val radius: Double) : Shape()
class Rectangle(val width: Double, val height: Double) : Shape()

// 컴파일러: "혹시 다른 Shape이 더 있을지 모르니 else 넣으세요"
fun describe(shape: Shape): String = when (shape) {
    is Circle -> "반지름 ${shape.radius}인 원"
    is Rectangle -> "${shape.width}x${shape.height} 사각형"
    else -> "알 수 없는 도형"  // 빼면 컴파일 에러
}

이 else 분기가 문제입니다. 나중에 Triangle을 추가해도 컴파일러가 경고해주지 않거든요. else가 조용히 처리해버리니까, 새 타입 추가 시 누락되는 분기를 눈으로 찾아야 합니다.

Sealed Class가 두 세계의 장점을 결합합니다

Sealed Class는 하위 타입마다 다른 프로퍼티를 가질 수 있으면서(enum의 한계 극복), 가능한 하위 타입이 컴파일 타임에 확정되므로 when에서 else 없이도 완전한 분기 처리가 가능합니다(abstract class의 한계 극복).

sealed class Shape {
    data class Circle(val radius: Double) : Shape()
    data class Rectangle(val width: Double, val height: Double) : Shape()
}

// else 없이도 컴파일 OK — 모든 경우를 다루고 있으니까
fun describe(shape: Shape): String = when (shape) {
    is Shape.Circle -> "반지름 ${shape.radius}인 원"
    is Shape.Rectangle -> "${shape.width}x${shape.height} 사각형"
}

// 나중에 Triangle을 추가하면?
// → 이 when 표현식에서 즉시 컴파일 에러 발생!

새 하위 타입을 추가했을 때 관련된 모든 when 표현식에서 컴파일 에러가 발생하므로, 누락 없이 코드를 업데이트할 수 있습니다. 이것이 Sealed Class의 핵심 가치입니다.

Sealed Interface: 더 유연한 확장

Kotlin 1.5부터 Sealed Interface가 도입되었습니다. 클래스와 달리 인터페이스는 다중 구현이 가능하므로, sealed interface를 사용하면 하나의 클래스가 여러 sealed 계층에 동시에 속할 수 있습니다.

sealed interface Loggable {
    val logMessage: String
}

sealed interface Recoverable {
    fun recover()
}

// 하나의 클래스가 두 sealed interface를 모두 구현
class NetworkError(
    override val logMessage: String,
    val retryUrl: String
) : Loggable, Recoverable {
    override fun recover() {
        println("$retryUrl 로 재시도합니다")
    }
}

class DiskError(
    override val logMessage: String
) : Loggable {
    // Recoverable은 구현하지 않음 — 복구 불가능한 에러
}

Sealed Class vs Sealed Interface 선택 기준

언제 class를 쓰고 언제 interface를 쓸지 고민된다면, 다음 기준을 참고하세요.

  • Sealed Class를 선택: 하위 타입들이 공통 상태(프로퍼티)를 공유하고, 생성자에서 초기화가 필요할 때. 예를 들어 모든 하위 타입이 timestamp 필드를 가져야 한다면 sealed class의 생성자에 넣는 것이 깔끔합니다.
  • Sealed Interface를 선택: 하위 타입이 이미 다른 클래스를 상속하고 있거나, 여러 sealed 계층에 걸쳐야 할 때. 또는 공유 상태 없이 타입 분류만 필요할 때 적합합니다.
// Sealed Class — 공통 상태 공유
sealed class DatabaseEvent(val tableName: String) {
    class Insert(tableName: String, val rowId: Long) : DatabaseEvent(tableName)
    class Update(tableName: String, val rowId: Long,
                 val fields: Map<String, Any>) : DatabaseEvent(tableName)
    class Delete(tableName: String, val rowId: Long) : DatabaseEvent(tableName)
}

// Sealed Interface — 타입 분류만
sealed interface Printable {
    fun toPrintString(): String
}

실무에서는 sealed interface가 점점 더 많이 사용되는 추세입니다. 특히 Android의 MVI 아키텍처에서 Intent나 SideEffect를 정의할 때 sealed interface를 쓰면 기존 클래스 계층과 충돌 없이 깔끔하게 설계할 수 있습니다.

when 표현식과 완전성(Exhaustiveness) 보장

Sealed Class의 진정한 위력은 when 표현식과 만났을 때 발휘됩니다. when을 표현식(expression)으로 사용하면 — 즉 결과값을 변수에 할당하거나 return 값으로 쓰면 — 컴파일러가 모든 하위 타입을 처리했는지 자동으로 검증합니다.

sealed class PaymentMethod {
    data class CreditCard(val number: String, val expiryDate: String) : PaymentMethod()
    data class BankTransfer(val accountNumber: String) : PaymentMethod()
    data object Cash : PaymentMethod()
}

// 표현식으로 사용 → 완전성 검사 발동
fun processPayment(method: PaymentMethod): String = when (method) {
    is PaymentMethod.CreditCard -> "카드 결제: ${method.number}"
    is PaymentMethod.BankTransfer -> "계좌 이체: ${method.accountNumber}"
    PaymentMethod.Cash -> "현금 결제"
    // 하나라도 빠뜨리면 컴파일 에러!
}

else를 쓰지 않는 것이 핵심

Sealed Class를 when과 함께 쓸 때 가장 중요한 원칙이 있습니다. else 분기를 쓰지 마세요. else를 넣는 순간, 새로운 하위 타입을 추가해도 컴파일러가 경고해주지 않습니다. Sealed Class를 쓰는 이유 자체가 사라지는 셈이죠.

// ❌ 이렇게 하면 sealed class의 의미가 퇴색
fun processPayment(method: PaymentMethod): String = when (method) {
    is PaymentMethod.CreditCard -> "카드 결제"
    else -> "기타 결제"  // 새 타입 추가해도 여기서 조용히 처리됨
}

// ✅ 이렇게 모든 케이스를 명시
fun processPayment(method: PaymentMethod): String = when (method) {
    is PaymentMethod.CreditCard -> "카드 결제"
    is PaymentMethod.BankTransfer -> "계좌 이체"
    PaymentMethod.Cash -> "현금 결제"
}

when을 문(statement)으로 쓸 때의 주의점

when을 표현식이 아닌 문(statement)으로 사용하면, 기본적으로 컴파일러가 완전성을 강제하지 않습니다. 이 경우 결과를 변수에 할당하는 간단한 트릭으로 완전성 검사를 활성화할 수 있습니다.

// 문(statement) — 기본적으로 완전성 검사 안 함
when (method) {
    is PaymentMethod.CreditCard -> handleCard(method)
    is PaymentMethod.BankTransfer -> handleTransfer(method)
    // Cash를 빼도 컴파일은 됨 (위험!)
}

// 표현식으로 바꾸는 간단한 트릭
val exhaustive = when (method) {
    is PaymentMethod.CreditCard -> handleCard(method)
    is PaymentMethod.BankTransfer -> handleTransfer(method)
    PaymentMethod.Cash -> handleCash()
    // 이제 하나라도 빠뜨리면 컴파일 에러
}

혹은 Unit 타입의 확장 프로퍼티를 만들어서 더 명시적으로 표현할 수도 있습니다. val <T> T.exhaustive: T get() = this를 정의한 뒤 when 블록 뒤에 .exhaustive를 붙이면 의도가 코드에 드러나죠. 다만 최신 Kotlin에서는 컴파일러 옵션으로 when 문에서도 sealed class 완전성 경고를 받을 수 있으니, 프로젝트 설정을 먼저 확인해보세요.

Sealed Class를 활용한 UI 상태 관리 흐름도

실전 패턴 1: UI 상태 관리

Android 앱이든 Compose Multiplatform 앱이든, 화면의 상태를 관리하는 것은 모든 UI 개발의 핵심 과제입니다. Sealed Class는 이 문제를 타입 안전하게 해결하는 가장 인기 있는 패턴이며, 현업에서 사실상 표준으로 자리 잡았습니다.

기본 UiState 패턴

sealed interface UiState<out T> {
    data object Loading : UiState<Nothing>
    data class Success<T>(val data: T) : UiState<T>
    data class Error(val exception: Throwable) : UiState<Nothing>
}

제네릭 타입 파라미터 T를 사용해서 어떤 데이터 타입이든 담을 수 있게 만들었습니다. out 키워드로 공변성을 부여해서, UiState<String>UiState<Any> 타입의 변수에 대입할 수도 있습니다. Loading과 Error는 데이터를 담지 않으므로 Nothing 타입을 사용합니다.

ViewModel에서 활용하기

class ArticleViewModel(
    private val repository: ArticleRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow<UiState<List<Article>>>(UiState.Loading)
    val uiState: StateFlow<UiState<List<Article>>> = _uiState.asStateFlow()

    fun loadArticles() {
        viewModelScope.launch {
            _uiState.value = UiState.Loading
            try {
                val articles = repository.getArticles()
                _uiState.value = UiState.Success(articles)
            } catch (e: Exception) {
                _uiState.value = UiState.Error(e)
            }
        }
    }
}

StateFlow에 UiState를 담아서 Composable 함수에서 구독하는 전형적인 패턴입니다. 상태 변경이 일어나면 UI가 자동으로 리컴포지션되어 화면이 갱신됩니다.

Composable에서 상태별 UI 렌더링

@Composable
fun ArticleScreen(viewModel: ArticleViewModel) {
    val uiState by viewModel.uiState.collectAsState()

    when (val state = uiState) {
        is UiState.Loading -> {
            CircularProgressIndicator(
                modifier = Modifier.fillMaxSize().wrapContentSize()
            )
        }
        is UiState.Success -> {
            LazyColumn {
                items(state.data) { article ->
                    ArticleCard(article)
                }
            }
        }
        is UiState.Error -> {
            ErrorMessage(
                message = state.exception.localizedMessage ?: "오류가 발생했습니다",
                onRetry = { viewModel.loadArticles() }
            )
        }
    }
}

when의 val state = uiState 패턴에 주목하세요. 이렇게 하면 각 분기 안에서 스마트 캐스트가 적용되어, state.datastate.exception에 별도 캐스팅 없이 바로 접근할 수 있습니다. 만약 when (uiState)로 쓰면 프로퍼티가 var이므로 스마트 캐스트가 적용되지 않을 수 있으니, 습관적으로 val에 바인딩하는 것을 권장합니다.

더 정교한 상태 설계

실무에서는 로딩 중에도 이전 데이터를 보여주거나, 에러 상태에서 부분적인 데이터를 유지해야 하는 경우가 있습니다. 이런 요구사항에 맞게 sealed class를 확장할 수 있습니다.

sealed interface ArticleState {
    data object Initial : ArticleState
    data class Loading(val previousData: List<Article>? = null) : ArticleState
    data class Loaded(
        val articles: List<Article>,
        val isRefreshing: Boolean = false,
        val hasMore: Boolean = true
    ) : ArticleState
    data class Failed(
        val error: Throwable,
        val cachedData: List<Article>? = null
    ) : ArticleState
}

이 설계에서는 Loading 상태에서도 이전 데이터를 들고 있을 수 있고, Loaded 상태에서 pull-to-refresh 중인지, 다음 페이지가 있는지 추적할 수 있습니다. Failed 상태에서도 캐시된 데이터가 있으면 보여줄 수 있죠. 각 상태가 자신에게 필요한 데이터만 가지고 있으므로, nullable 프로퍼티를 남용하지 않으면서 타입 안전하게 관리됩니다.

Sealed Class Result 패턴의 에러 처리 흐름도

실전 패턴 2: Result 패턴으로 에러 처리

네트워크 호출, 파일 처리, 데이터베이스 쿼리 등 실패할 수 있는 연산의 결과를 표현할 때 Sealed Class 기반의 Result 패턴이 매우 유용합니다. Kotlin 표준 라이브러리에도 kotlin.Result가 있지만, 도메인에 특화된 에러 타입을 세분화하려면 직접 만드는 것이 더 좋습니다.

도메인 특화 Result 설계

sealed interface ApiResult<out T> {
    data class Success<T>(val data: T) : ApiResult<T>
    sealed interface Failure : ApiResult<Nothing> {
        data class HttpError(val code: Int, val body: String) : Failure
        data class NetworkError(val exception: Throwable) : Failure
        data class ParseError(val rawResponse: String) : Failure
        data object Unauthorized : Failure
    }
}

Failure를 다시 sealed interface로 선언한 것이 핵심입니다. 이렇게 중첩 sealed 계층을 만들면, 상위 레벨에서는 is Failure 한 줄로 모든 실패를 묶어서 처리할 수 있고, 필요할 때는 세부 에러 타입별로 분기할 수도 있습니다.

Repository 레이어에서 사용

class UserRepository(private val api: UserApi) {

    suspend fun getUser(id: String): ApiResult<User> {
        return try {
            val response = api.fetchUser(id)
            when {
                response.isSuccessful -> {
                    val user = response.body()
                    if (user != null) {
                        ApiResult.Success(user)
                    } else {
                        ApiResult.Failure.ParseError(response.raw().toString())
                    }
                }
                response.code() == 401 -> ApiResult.Failure.Unauthorized
                else -> ApiResult.Failure.HttpError(
                    code = response.code(),
                    body = response.errorBody()?.string() ?: ""
                )
            }
        } catch (e: java.io.IOException) {
            ApiResult.Failure.NetworkError(e)
        }
    }
}

Repository가 예외를 던지는 대신 ApiResult를 반환하므로, 호출 측에서 try-catch 없이 when으로 깔끔하게 처리할 수 있습니다. 예외가 발생할 수 있는 경계(여기서는 네트워크 호출)에서 한 번만 catch하고, 그 이후로는 모두 타입 기반 분기로 흘러가는 것이죠.

호출 측에서 처리

fun loadUser(userId: String) {
    viewModelScope.launch {
        _uiState.value = UiState.Loading

        _uiState.value = when (val result = userRepository.getUser(userId)) {
            is ApiResult.Success -> UiState.Success(result.data)
            is ApiResult.Failure.Unauthorized -> {
                _navigationEvent.emit(NavigationEvent.GoToLogin)
                UiState.Error(Exception("인증이 만료되었습니다"))
            }
            is ApiResult.Failure.NetworkError -> {
                UiState.Error(Exception("네트워크 연결을 확인해주세요"))
            }
            is ApiResult.Failure.HttpError -> {
                UiState.Error(Exception("서버 오류 (${result.code})"))
            }
            is ApiResult.Failure.ParseError -> {
                UiState.Error(Exception("데이터 처리 오류"))
            }
        }
    }
}

각 에러 타입에 따라 다른 사용자 경험을 제공할 수 있습니다. 인증 만료면 로그인 화면으로, 네트워크 에러면 연결 확인 메시지를, HTTP 에러면 상태 코드를 포함한 안내를 보여주는 식이죠. 새로운 에러 타입이 추가되면 이 when에서 바로 컴파일 에러가 발생하니 놓칠 일이 없습니다.

확장 함수로 편의성 높이기

Result 패턴을 자주 사용한다면, 확장 함수를 만들어서 반복 코드를 줄일 수 있습니다.

inline fun <T, R> ApiResult<T>.map(
    transform: (T) -> R
): ApiResult<R> = when (this) {
    is ApiResult.Success -> ApiResult.Success(transform(data))
    is ApiResult.Failure -> this
}

fun <T> ApiResult<T>.getOrDefault(default: T): T = when (this) {
    is ApiResult.Success -> data
    is ApiResult.Failure -> default
}

// 사용 예시
val userName = userRepository.getUser("user-123")
    .map { it.displayName }
    .getOrDefault("익명 사용자")

이런 확장 함수 패턴은 함수형 프로그래밍의 Either 모나드와 비슷한 인터페이스를 제공하면서도, Kotlin의 sealed class 덕분에 타입 안전성을 완벽하게 유지합니다. Failure의 타입 파라미터가 Nothing이므로, map에서 별도의 에러 변환 없이 자연스럽게 타입이 흘러가는 것도 눈여겨볼 포인트입니다.

실전 패턴 3: 이벤트와 액션 모델링

MVI(Model-View-Intent) 아키텍처나 Redux 스타일의 상태 관리에서는 사용자 액션, 시스템 이벤트, 사이드 이펙트를 명확하게 정의해야 합니다. Sealed class는 이런 이벤트 모델링에 이상적입니다.

사용자 액션 정의

sealed interface TodoAction {
    data class AddTodo(val title: String, val priority: Priority) : TodoAction
    data class ToggleComplete(val todoId: Long) : TodoAction
    data class Delete(val todoId: Long) : TodoAction
    data class UpdateFilter(val filter: TodoFilter) : TodoAction
    data object ClearCompleted : TodoAction
    data object Refresh : TodoAction
}

enum class TodoFilter { ALL, ACTIVE, COMPLETED }
enum class Priority { LOW, MEDIUM, HIGH }

각 액션이 자신에게 필요한 데이터만 정확하게 담고 있습니다. AddTodo는 제목과 우선순위가 필요하고, ToggleComplete과 Delete는 대상 아이템의 ID만 필요합니다. ClearCompleted와 Refresh는 추가 데이터가 필요 없으므로 data object로 선언했습니다.

Reducer에서 액션 처리

class TodoReducer {

    fun reduce(currentState: TodoState, action: TodoAction): TodoState {
        return when (action) {
            is TodoAction.AddTodo -> currentState.copy(
                todos = currentState.todos + Todo(
                    id = generateId(),
                    title = action.title,
                    priority = action.priority
                )
            )
            is TodoAction.ToggleComplete -> currentState.copy(
                todos = currentState.todos.map { todo ->
                    if (todo.id == action.todoId)
                        todo.copy(isCompleted = !todo.isCompleted)
                    else todo
                }
            )
            is TodoAction.Delete -> currentState.copy(
                todos = currentState.todos.filter { it.id != action.todoId }
            )
            is TodoAction.UpdateFilter -> currentState.copy(
                filter = action.filter
            )
            is TodoAction.ClearCompleted -> currentState.copy(
                todos = currentState.todos.filter { !it.isCompleted }
            )
            is TodoAction.Refresh -> currentState.copy(
                isRefreshing = true
            )
        }
    }
}

모든 액션이 sealed interface로 정의되어 있으므로, 새로운 액션을 추가하면 reduce 함수에서 즉시 컴파일 에러가 발생합니다. 처리하지 않은 액션이 조용히 무시되는 일은 절대 없습니다.

사이드 이펙트 분리

UI 업데이트와 별개로 발생해야 하는 일회성 이벤트도 sealed class로 모델링할 수 있습니다. 토스트 메시지, 화면 전환, 외부 앱 실행 같은 것들이죠.

sealed interface SideEffect {
    data class ShowToast(val message: String) : SideEffect
    data class Navigate(val route: String) : SideEffect
    data class ShareContent(val text: String, val url: String) : SideEffect
    data object ScrollToTop : SideEffect
}

class TodoViewModel : ViewModel() {
    private val _sideEffect = Channel<SideEffect>(Channel.BUFFERED)
    val sideEffect: Flow<SideEffect> = _sideEffect.receiveAsFlow()

    fun onAction(action: TodoAction) {
        val newState = reducer.reduce(_state.value, action)
        _state.value = newState

        viewModelScope.launch {
            when (action) {
                is TodoAction.ClearCompleted ->
                    _sideEffect.send(SideEffect.ShowToast("완료된 항목을 삭제했습니다"))
                is TodoAction.Delete ->
                    _sideEffect.send(SideEffect.ShowToast("할 일이 삭제되었습니다"))
                else -> { /* 사이드 이펙트 없음 */ }
            }
        }
    }
}

여기서 사이드 이펙트의 when에는 else를 쓰는 것이 적절합니다. 모든 액션이 사이드 이펙트를 발생시키는 것은 아니니까요. 반면 TodoAction의 reduce에서는 else를 쓰지 않는 것이 좋습니다. 모든 액션은 반드시 상태 변환 로직이 있어야 하니까요. 이처럼 같은 sealed class라도 사용하는 맥락에 따라 else의 적절한 사용 여부가 달라진다는 점을 기억해두세요.

실무에서 더 잘 쓰기 위한 팁

sealed class를 본격적으로 활용하면서 알아두면 좋은 실용적인 팁들을 정리합니다.

data object를 적극 활용하세요

Kotlin 1.9부터 도입된 data object는 상태가 없는 싱글턴 하위 타입에 최적화된 선언입니다. 일반 object와 달리 의미 있는 toString()을 자동 생성해주므로, 로깅이나 디버깅에서 큰 차이를 만듭니다.

sealed interface ConnectionState {
    data object Connecting : ConnectionState  // toString() = "Connecting"
    data object Connected : ConnectionState   // toString() = "Connected"
    data class Disconnected(val reason: String) : ConnectionState
}

println("현재 상태: $connectionState")
// data object: "현재 상태: Connecting"
// 일반 object: "현재 상태: ConnectionState$Connecting@1a2b3c"

중첩 sealed 계층으로 대분류와 세분류를 나누세요

앞서 ApiResult에서 보았듯이, sealed 안에 다시 sealed를 넣으면 계층적인 타입 분류가 가능합니다. 이 패턴은 권한 시스템처럼 복잡한 상태를 모델링할 때 특히 빛을 발합니다.

sealed interface Permission {
    sealed interface Granted : Permission {
        data object Full : Granted
        data class Limited(val allowedActions: Set<String>) : Granted
    }
    sealed interface Denied : Permission {
        data object NeverAsked : Denied
        data object PermanentlyDenied : Denied
        data class TemporarilyDenied(val retryAfter: Long) : Denied
    }
}

// 대분류로 처리
fun hasAccess(permission: Permission): Boolean = when (permission) {
    is Permission.Granted -> true
    is Permission.Denied -> false
}

// 세분류로 처리
fun handleDenied(denied: Permission.Denied) = when (denied) {
    Permission.Denied.NeverAsked -> showPermissionRationale()
    Permission.Denied.PermanentlyDenied -> showSettingsGuide()
    is Permission.Denied.TemporarilyDenied -> scheduleRetry(denied.retryAfter)
}

상위 레벨에서는 Granted/Denied 두 가지만 보고 빠르게 분기하고, 하위 레벨에서는 세부 사항에 맞는 정밀한 처리를 할 수 있습니다. 각 레벨의 when 표현식은 자신이 다루는 sealed 계층의 완전성만 보장하면 되므로, 코드 변경의 영향 범위도 최소화됩니다.

직렬화할 때는 타입 식별자를 명시하세요

Sealed class를 JSON으로 직렬화/역직렬화할 때는 타입 정보가 함께 저장되어야 합니다. kotlinx.serialization을 사용한다면 @SerialName으로 안정적인 타입 식별자를 지정하는 것이 좋습니다.

@Serializable
sealed interface ChatMessage {
    @Serializable
    @SerialName("text")
    data class TextMessage(val content: String) : ChatMessage

    @Serializable
    @SerialName("image")
    data class ImageMessage(val url: String, val caption: String) : ChatMessage
}

@SerialName을 명시하지 않으면 클래스의 정규화된 이름(fully qualified name)이 사용되는데, 패키지 이동이나 클래스 이름 변경 시 기존 데이터와의 호환성이 깨집니다. 직렬화가 필요한 sealed class에는 반드시 @SerialName을 붙이는 습관을 들이세요.

테스트에서 sealed class가 주는 이점

Sealed class는 테스트 커버리지를 완벽하게 달성하기 쉽게 만들어줍니다. 가능한 모든 하위 타입이 확정되어 있으므로, IDE에서 하위 타입 목록을 확인하고 각각에 대한 테스트 케이스를 작성하면 됩니다.

class PaymentProcessorTest {

    @Test
    fun `CreditCard 결제 시 카드 번호를 포함한 결과 반환`() {
        val method = PaymentMethod.CreditCard("1234-5678", "12/26")
        val result = processPayment(method)
        assertTrue(result.contains("1234-5678"))
    }

    @Test
    fun `BankTransfer 결제 시 계좌번호를 포함한 결과 반환`() {
        val method = PaymentMethod.BankTransfer("110-123-456789")
        val result = processPayment(method)
        assertTrue(result.contains("110-123-456789"))
    }

    @Test
    fun `Cash 결제 시 현금 결제 메시지 반환`() {
        val result = processPayment(PaymentMethod.Cash)
        assertEquals("현금 결제", result)
    }
}

새 결제 수단이 추가되면 processPayment 함수에서 컴파일 에러가 나고, 자연스럽게 해당 케이스의 테스트도 추가하게 됩니다. 코드와 테스트가 함께 진화하는 선순환 구조가 만들어지는 셈입니다.

마무리: 타입으로 의도를 표현하는 습관

Sealed Class와 Sealed Interface는 단순히 편리한 문법 설탕이 아닙니다. “이 값이 가질 수 있는 형태는 정확히 이것들이다”라는 개발자의 의도를 타입 시스템에 새겨넣는 도구입니다. 컴파일러가 이 의도를 이해하고, when 표현식에서 빠진 분기가 있으면 코드가 빌드되기도 전에 알려주죠.

정리하면 이렇습니다. Enum은 동일한 구조의 상수 집합에, Abstract Class는 열린 상속 계층에, 그리고 Sealed Class는 닫힌 계층에서 하위 타입마다 서로 다른 데이터를 담아야 할 때 사용합니다. UI 상태 관리, API 응답 처리, 이벤트 모델링 등 실무의 수많은 장면에서 sealed class는 코드를 더 안전하고 읽기 쉽게 만들어줍니다.

처음에는 “그냥 String이나 Int 플래그로 하면 안 되나?” 싶을 수 있습니다. 하지만 프로젝트가 커지고 상태가 복잡해질수록 sealed class로 얻는 컴파일 타임 안전성의 가치는 기하급수적으로 커집니다. 아직 써보지 않으셨다면, 지금 진행 중인 프로젝트에서 when + else 패턴이나 nullable 프로퍼티 뭉치를 한번 살펴보세요. 그곳이 sealed class로 리팩토링할 최적의 시작점입니다.

이미지는 Leonardo AI 로 생성되었습니다.

이미지는 Claude AI 로 생성되었습니다.

답글 남기기

Your email address will not be published. Required fields are marked *.

Warning: Undefined array key "cookies" in /var/www/html/wp-content/themes/personal-cv-resume/class/class-post-related.php on line 212
*
*

최신 댓글