
최근 우아한 테크코스의 미션들을 진행하면서 크루들 사이에 sealed Class의 사용법에 대한 많은 토론이 있었습니다. 필자는 안드로이드를 개발하면서 sealed Class, sealed Interface를 적극 사용해 왔기에 이 경험을 토대로 저만의 사용 기준을 새워왔습니다. 오늘은 이펙티브 코틀린의 Item39 태그 클래스보다는 클래스 계층을 사용하라에서 주장하는 내용과 개인적인 경험을 녹여 sealed class를 사용하는 기준에 대해서 이야기하고 우아한 테크코스에서 진행한 미션에서 에러 처리 파트를 sealed class 사용한 부분을 소개해 보겠습니다.
Enum vs Sealed
Enum과 sealed class는 모두 "행동"과 "상태"를 가질 수 있습니다. 즉, 독립적인 하나의 객체로 존중받을 수 있으며 추상 메서드와 프로퍼티를 상속하여 구현할 수 있습니다.
sealed class
sealed class는 추상 클래스로 특정 클래스 계층을 한정된 범위 내에서 정의할 수 있도록 합니다. 이를 통해 각 서브 클래스기 독립적인 상태와 행동을 가질 수 있게 해 주며, 데이터 클래스, 일반 클래스, 싱글톤으로 다양한 형태를 가질 수 있습니다.
enum class
enum class는 고정된 상수 객체들의 집합으로 미리 정의된 값만을 가질 수 있습니다. 즉, 컴파일 타임에 값이 결정되며 실행 중 변경되지 않기 때문에 "상수 객체"라고 할 수 있습니다.
상수 객체: 한 번 생성된 후에 값을 변경할 수 없는 객체
Enum과 sealed class의 가장 큰 차이는 enum이 상수 객체라는 점입니다. 이를 비교하기 위해 음악을 재생하고 일시정지하는 상황을 sealed class와 enum class 두 가지 방식으로 구현해 보겠습니다.
Sealed Class를 활용한 구현
Sealed class를 사용하면 각 상태를 구현체마다 독립적으로 가질 수 있습니다. 예제에서는 음악의 이름을 공통 속성으로 두고 일시정지 상태에서는 추가적으로 중지된 시간을 저장합니다.
sealed class SealedPlayerState {
abstract fun getAction(): String
abstract val musicName: String
data class Playing(override val musicName: String) : SealedPlayerState() {
override fun getAction() = "$musicName is playing."
}
data class Paused(override val musicName: String, val playTime: Int) : SealedPlayerState() {
override fun getAction() = "$musicName is paused at $playTime seconds."
}
}
Enum Class를 활용한 구현
Enum class는 컴파일 타임에 상수 값이 결정되므로, 상태마다 동적인 속성을 가지기 어렵습니다. 아래 예제처럼 playTime을 다루려면 고정된 값을 할당해야 하므로, 상태 변경이 유연하지 못합니다.
enum class EnumPlayerState(val musicName: String, val playTime: Int) {
PLAYING("Unknown", 0) {
override fun getAction() = "$musicName is playing."
},
PAUSED("Unknown", 0) {
override fun getAction() = "$musicName is paused at $playTime seconds."
};
abstract fun getAction(): String
}
결론적으로 Sealed class와 Enum class는 각각의 용도가 다릅니다. 불변 객체를 사용할 때는 enum을, 상태별로 동적인 속성과 동작이 필요할 경우 sealed class를 사용하는 것이 적절합니다.
Sealed vs Abstract Class
sealed class와 abstract class는 모두 클래스 계층을 설계하는 데 사용됩니다. 두 키워드의 주요 차이점은 상속 범위입니다. Effective Kotlin에서 제시하는 abstract class와 seald class를 사용하는 기준은 다음과 같습니다.
- sealed 한정자는 외부에서 서브 클래스를 만드는 행위 자체를 모두 제한해 서브 클래스를 만들 수 없으므로 타입이 추가되지 않는 것이 보장
- abstract 클래스는 계층에 새로운 클래스를 추가할 수 있는 여지를 남김
- 컴파일러가 상속 중인 클래스를 모두 인지하기 때문에 when을 사용할 때 else 브랜치를 분기하지 않아도 됩니다.
- 모드나 이벤트를 구분해서 각각 다른 처리를 만들 때 굉장히 편리합니다.
abstract class LoginState
class LoggedIn : LoginState()
class LoggedOut : LoginState()
fun checkLogin(state: LoginState) {
when (state) {
is LoggedIn -> println("사용자가 로그인했습니다!")
is LoggedOut -> println("사용자가 로그아웃했습니다!")
else -> println("알 수 없는 상태입니다.") // 새로운 상태가 추가될 가능성 있음
}
}
sealed class LoginState {
object LoggedIn : LoginState()
object LoggedOut : LoginState()
}
fun checkLogin(state: LoginState) {
when (state) {
is LoginState.LoggedIn -> println("사용자가 로그인했습니다!")
is LoginState.LoggedOut -> println("사용자가 로그아웃했습니다!")
}
}
제가 생각하는 abstract과 sealed 한정자의 가장 큰 장점은 "개발 편의성"입니다. when절에서 구현체 타입으로 분기하는 코드를 작성할 때 개발자가 실수로 누락한 타입이 있으면 해당 타입에 대하여 컴파일러는 에러를 발생시켜 실수를 방지할 수 있고 IDE에서 자동으로 분기하지 않은 타입을 추가해 줍니다.

Sealed Class를 활용한 에러 처리
Sealed Class를 사용해 에러 처리는 코드를 구현해 보겠습니다.성공과 실패를 나타내는 상태 클래스를 정의하고 제네릭 T를 사용하여
다양한 타입의 데이터를 처리할 수 있는 결과 타입을 정의합니다.
sealed class ResultState<out T> {
data class Success<T>(val data: T) : ResultState<T>()
data class Error(val message: String?) : ResultState<Nothing>()
}
- Success <T>는 제네릭 타입 T를 받아서 성공했을 때 결과를 담는 데이터 클래스로 data라는 프로퍼티로 전달합니다.
- Error는 에러 메시지를 담는 데이터 클래스로 message라는 프로퍼티를 통해 오류 메시지를 전달합니다.
- Nothing을 타입 파라미터로 가지며 이는 Error가 성공적인 값을 가질 수 없다는 의미입니다.

ResultState를 테스트했을 때 예외가 반환되는 것을 확인했습니다. 하지만 현재 코드에선 매번 에러처리할 위치에 runCatching을 구문으로 인한 보일러 플레이트 코드가 늘어나므로 하나의 함수로 추출하겠습니다.
inline fun <T> execute(block: () -> T): ResultState<T> =
runCatching {
ResultState.Success(block())
}.getOrElse {
ResultState.Error(it.message)
}

현재 코드 또한 ResultState 분기로 인한 보일러 플레이트가 존재합니다. Effective Kotlion에선 sealed 한정자의 또 다른 장점으로 하위 클래스들에 대한 처리를 sealed class의 확장 함수를 구현해 손쉽게 구현할 수 있다고 합니다.
이를 기반으로 확장함수를 구현해 마지막으로 코드를 최적화 해보겠습니다. 확장 함수의 네이밍은 코틀린 공식 문서를 참고했습니다.
fun <T> ResultState<T>.recover(onError: (String?) -> T): T {
return when (this) {
is ResultState.Success -> data
is ResultState.Error -> onError(message)
}
}

'KOTLIN' 카테고리의 다른 글
Kotlin Generic Type System (1) | 2025.04.10 |
---|---|
Abstract class vs interface in Kotlin (2) | 2025.04.01 |
다시 읽는 Effective Kotlin - Item33. 생성자 대신 팩토리 함수를 사용하라 (2) | 2025.03.02 |
Channel 내부 동작 분석을 분석해보자 (0) | 2025.02.07 |
Kotlin Value Class With Project Valhalla (4) | 2024.11.22 |