Table of contents
- 상태를 코드로 표현하기
- enum class
- enum에 프로퍼티와 메서드 추가
- enum의 한계
- sealed class
- when 완전 매칭
- sealed interface
- enum vs sealed class 선택 기준
- 실전 패턴: UI 상태 관리
상태를 코드로 표현하기
프로그래밍을 하다 보면 “이 값은 A, B, C 중 하나”라는 상황이 자주 생긴다. HTTP 상태 코드, 결제 상태, 네트워크 요청 결과 같은 것들이다. 문자열이나 정수 상수로 표현할 수도 있지만, 오타 하나로 버그가 생기기 쉽다.
Kotlin은 이런 “한정된 선택지”를 타입 시스템으로 표현하는 두 가지 도구를 제공한다. enum class와 sealed class다.
enum class
가장 단순한 형태부터 시작하자. 선택지가 고정되어 있고, 각 항목이 동일한 구조일 때 쓴다.
enum class Direction {
NORTH, SOUTH, EAST, WEST
}
fun move(direction: Direction) {
when (direction) {
Direction.NORTH -> println("위로 이동")
Direction.SOUTH -> println("아래로 이동")
Direction.EAST -> println("오른쪽으로 이동")
Direction.WEST -> println("왼쪽으로 이동")
}
}
fun main() {
move(Direction.NORTH) // 위로 이동
}
Java의 enum과 크게 다르지 않다. 다만 Kotlin에서는 when과 함께 쓸 때 진가를 발휘하는데, 이건 뒤에서 자세히 다룬다.
enum에 프로퍼티와 메서드 추가
enum 상수에 값을 붙이거나 메서드를 정의할 수 있다.
enum class Color(val hex: String) {
RED("#FF0000"),
GREEN("#00FF00"),
BLUE("#0000FF");
fun rgb(): Triple<Int, Int, Int> {
val r = hex.substring(1, 3).toInt(16)
val g = hex.substring(3, 5).toInt(16)
val b = hex.substring(5, 7).toInt(16)
return Triple(r, g, b)
}
}
fun main() {
val color = Color.RED
println("${color.name}: ${color.hex}") // RED: #FF0000
println("RGB: ${color.rgb()}") // RGB: (255, 0, 0)
// enum의 유틸 함수들
println(Color.entries) // [RED, GREEN, BLUE]
println(Color.valueOf("BLUE").hex) // #0000FF
}
주의할 점이 하나 있다. 마지막 상수 뒤에 세미콜론(;)을 찍어야 한다는 거다. 상수 목록과 메서드를 구분하기 위한 문법인데, 깜빡하면 컴파일 에러가 난다.
entries는 Kotlin 1.9부터 도입된 프로퍼티로, 기존의 values()를 대체한다. 배열 대신 리스트를 반환해서 더 안전하고 편리하다.
enum의 한계
enum은 강력하지만 한 가지 근본적인 제약이 있다. 모든 상수가 동일한 형태여야 한다는 점이다.
네트워크 요청 결과를 생각해보자. 성공하면 데이터가 있고, 실패하면 에러 메시지가 있고, 로딩 중이면 아무것도 없다. 각 상태가 들고 있는 데이터가 다르다.
// 이런 건 enum으로 표현할 수 없다
// enum class Result {
// SUCCESS(data: Any), // 데이터가 있음
// ERROR(message: String), // 에러 메시지가 있음
// LOADING // 데이터 없음
// }
이 문제를 해결하는 게 sealed class다.
sealed class
sealed class는 “이 클래스의 하위 타입이 정해져 있다”는 선언이다. enum처럼 선택지가 한정적이면서도, 각 항목이 서로 다른 데이터를 가질 수 있다.
sealed class Result<out T> {
data class Success<T>(val data: T) : Result<T>()
data class Error(val message: String, val code: Int = -1) : Result<Nothing>()
data object Loading : Result<Nothing>()
}
fun handleResult(result: Result<String>) {
when (result) {
is Result.Success -> println("성공: ${result.data}")
is Result.Error -> println("실패(${result.code}): ${result.message}")
is Result.Loading -> println("로딩 중...")
}
}
fun main() {
handleResult(Result.Success("Hello Kotlin"))
// 성공: Hello Kotlin
handleResult(Result.Error("서버 에러", 500))
// 실패(500): 서버 에러
handleResult(Result.Loading)
// 로딩 중...
}
Success는 제네릭 데이터를, Error는 메시지와 코드를, Loading은 아무 데이터도 들고 있지 않다. 이 세 가지가 Result의 전부라는 사실을 컴파일러가 알고 있다.
when 완전 매칭
sealed class의 핵심 강점은 when에서 드러난다. 모든 하위 타입을 처리하면 else가 필요 없고, 새 하위 타입을 추가하면 처리하지 않은 곳에서 컴파일 에러가 난다.
sealed class PaymentStatus {
data object Pending : PaymentStatus()
data object Approved : PaymentStatus()
data object Rejected : PaymentStatus()
data class Refunded(val amount: Int) : PaymentStatus()
}
fun describeStatus(status: PaymentStatus): String {
// else 없이 모든 케이스를 처리
return when (status) {
is PaymentStatus.Pending -> "결제 대기 중"
is PaymentStatus.Approved -> "결제 완료"
is PaymentStatus.Rejected -> "결제 거부됨"
is PaymentStatus.Refunded -> "${status.amount}원 환불됨"
}
}
fun main() {
println(describeStatus(PaymentStatus.Pending)) // 결제 대기 중
println(describeStatus(PaymentStatus.Refunded(5000))) // 5000원 환불됨
}
만약 여기에 Cancelled라는 새 상태를 추가하면 어떻게 될까?
// data object Cancelled : PaymentStatus() // 이걸 추가하면
// describeStatus 함수에서 컴파일 에러 발생 — Cancelled를 처리하지 않았으니까
else로 뭉뚱그려 처리했다면 새 상태가 조용히 무시됐을 것이다. sealed class와 완전 매칭은 이런 실수를 원천 차단해준다. 실무에서 상태가 자주 바뀌는 도메인일수록 이 장점이 빛난다.
sealed interface
Kotlin 1.5부터 sealed interface도 지원한다. sealed class와의 차이는 하위 타입이 여러 sealed 인터페이스를 동시에 구현할 수 있다는 점이다.
sealed interface Error {
val message: String
}
sealed interface Recoverable
data class NetworkError(
override val message: String,
val retryAfter: Int
) : Error, Recoverable
data class AuthError(
override val message: String
) : Error, Recoverable
data class FatalError(
override val message: String,
val stackTrace: String
) : Error // Recoverable이 아님
fun handleError(error: Error) {
when (error) {
is NetworkError -> println("네트워크 에러 (${error.retryAfter}초 후 재시도)")
is AuthError -> println("인증 에러 — 재로그인 필요")
is FatalError -> println("치명적 에러: ${error.stackTrace}")
}
}
fun retryIfPossible(error: Error) {
if (error is Recoverable) {
println("'${error.message}' — 복구 시도 가능")
} else {
println("'${error.message}' — 복구 불가")
}
}
fun main() {
val errors: List<Error> = listOf(
NetworkError("타임아웃", 30),
AuthError("토큰 만료"),
FatalError("OOM", "java.lang.OutOfMemoryError...")
)
errors.forEach { error ->
handleError(error)
retryIfPossible(error)
println()
}
}
NetworkError와 AuthError는 Error이면서 동시에 Recoverable이다. 클래스는 하나만 상속할 수 있지만, 인터페이스는 여러 개를 구현할 수 있으므로 이런 교차 분류가 가능해진다.
enum vs sealed class 선택 기준
둘 다 “한정된 선택지”를 표현하지만, 쓰임새가 다르다.
| 기준 | enum class | sealed class/interface |
|---|---|---|
| 각 항목의 데이터 | 모두 동일한 구조 | 각각 다를 수 있음 |
| 인스턴스 개수 | 항목당 정확히 하나 | 항목당 여러 개 가능 |
entries / valueOf | 지원 | 미지원 |
| 직렬화 | 간단 | 구조에 따라 다름 |
경험적으로, 단순한 플래그성 값(방향, 색상, 등급)은 enum이 맞고, 각 항목이 서로 다른 데이터를 갖는 경우(API 응답, 에러 타입, UI 상태)는 sealed class가 적합하다.
실전 패턴: UI 상태 관리
Android나 Compose에서 흔히 보이는 패턴을 하나 보자.
sealed class 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 throwable: Throwable) : UiState<Nothing>()
}
// ViewModel에서
class UserViewModel {
private var state: UiState<List<String>> = UiState.Idle
fun loadUsers() {
state = UiState.Loading
try {
val users = listOf("Kim", "Lee", "Park") // API 호출 가정
state = UiState.Success(users)
} catch (e: Exception) {
state = UiState.Error(e)
}
}
fun render() {
when (val s = state) {
is UiState.Idle -> println("대기 중")
is UiState.Loading -> println("로딩 중...")
is UiState.Success -> println("유저: ${s.data.joinToString()}")
is UiState.Error -> println("에러: ${s.throwable.message}")
}
}
}
fun main() {
val vm = UserViewModel()
vm.render() // 대기 중
vm.loadUsers()
vm.render() // 유저: Kim, Lee, Park
}
UiState는 화면이 가질 수 있는 모든 상태를 sealed class로 정의한다. 새 상태가 추가되면 when에서 빠뜨린 곳을 컴파일러가 잡아준다. 이 패턴은 MVI 아키텍처의 핵심이기도 하다.
sealed class와 enum은 Kotlin 타입 시스템의 표현력을 극적으로 높여주는 도구다. “이 변수는 이 상태들 중 하나”라는 제약을 컴파일러가 검증해주기 때문에, 런타임에 예상치 못한 상태가 끼어드는 걸 방지할 수 있다.
다음 편에서는 코루틴의 기초를 다룬다. 비동기 프로그래밍의 세계로 들어가 보자.
Loading comments...