본문 바로가기
프로그래밍/kotlin

[Kotlin] Sealed class 와 Enum class 의 차이가 무엇일까?

by dev_gyu 2025. 2. 18.
728x90

면접의 단골 주제로 나오는 Sealed class 와 Enum class 의 차이점에 대해 글을 작성해보려고 한다.

 

흔히 UiState 와 같이 Ui 상태를 다룰 때 sealed class 를 사용하고 고정된 값을 가진 데이터 집합을 구현할 때 enum class 를 사용하는 것은 알고 있을테지만, 정확히 그렇게 나눠서 사용하고 있는 이유에 대해서 깊게 생각해보지는 않았을 것이다.

나의 경우 실무에서는 알고 썻지만 이를 이론적으로 설명하지 못하여 다시 한 번 글을 써내리는 중이다.

 

글을 보고 다른 이들도 나와 같이 실무에서 적용하는 것 뿐만 아니라 정확히 두 개 사이에서 어떤 차이점이 있는지를 알아내고 이를 설명할 수 있다면 좋겠다.


# Sealed Class 란?

흔히 데이터 상태의 집합을 나타낼 때 사용하는 구조로 가장 흔히 사용되는 것은 Ui 에 대한 상태이다.

# 특징1. 서브 클래스간 서로 다른 데이터 가질 수 있음

Ui 상태를 나타내는 클래스

 

위는 Ui 의 상태를 집합시켜놓은 클래스로 Loading, Empty, Failure, Success 가 존재한다.

이러한 상태들에서 가져야 하는 데이터는 서로 다른데, sealed class 의 경우 각 서브 클래스가 서로 다른 형태의 데이터를 가질 수 있기 때문에 위와 같이 설정이 가능하다.

 

# 특징2. 컴파일 시 abstract sealed class 로 변환된다.

sealed class 의 경우 컴파일 시 abstract sealed class 로 변환되며 내부 subclass 들은 이 abstract sealed class 를 상속받는 final 클래스로 컴파일 된다.

참고로 자바 17 부터 abstract sealed class, 이전은 abstract class 로 컴파일된다고 하니 이 점 참고하자

 

아래는 위의 UiState 를 컴파일 한 결과물이다.

public abstract sealed class UiState<T> permits UiState.Loading, UiState.Empty, UiState.Failure, UiState.Success {
    private UiState() {}

    // Kotlin의 data object Loading에 대응 (싱글턴)
    public static final class Loading extends UiState<Object> {
        public static final Loading INSTANCE = new Loading();
        private Loading() {}

        @Override
        public String toString() {
            return "Loading";
        }

        @Override
        public boolean equals(Object obj) {
            return this == obj;
        }

        @Override
        public int hashCode() {
            return System.identityHashCode(this);
        }
    }

    // Kotlin의 data object Empty에 대응 (싱글턴)
    public static final class Empty extends UiState<Object> {
        public static final Empty INSTANCE = new Empty();
        private Empty() {}

        @Override
        public String toString() {
            return "Empty";
        }

        @Override
        public boolean equals(Object obj) {
            return this == obj;
        }

        @Override
        public int hashCode() {
            return System.identityHashCode(this);
        }
    }

    // Kotlin의 data class Failure(val message: String?, val throwable: Throwable)
    public static final class Failure extends UiState<Object> {
        private final String message;
        private final Throwable throwable;

        public Failure(String message, Throwable throwable) {
            this.message = message;
            this.throwable = throwable;
        }

        public String getMessage() {
            return message;
        }

        public Throwable getThrowable() {
            return throwable;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof Failure)) return false;
            Failure that = (Failure) o;
            return java.util.Objects.equals(message, that.message) &&
                   java.util.Objects.equals(throwable, that.throwable);
        }

        @Override
        public int hashCode() {
            return java.util.Objects.hash(message, throwable);
        }

        @Override
        public String toString() {
            return "Failure(message=" + message + ", throwable=" + throwable + ")";
        }
    }

    // Kotlin의 data class Success<T>(val data: T)
    public static final class Success<T> extends UiState<T> {
        private final T data;

        public Success(T data) {
            this.data = data;
        }

        public T getData() {
            return data;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof Success)) return false;
            Success<?> that = (Success<?>) o;
            return java.util.Objects.equals(data, that.data);
        }

        @Override
        public int hashCode() {
            return java.util.Objects.hash(data);
        }

        @Override
        public String toString() {
            return "Success(data=" + data + ")";
        }
    }
}

 

# 특징3. 런타임 때 매번 새로운 객체 생성이 가능하다. (object 제외)

fun uiState(){
    // 새로운 객체 생성 가능
    val state = UiState.Success(true)
    val state2 = UiState.Success(false)

    // 실패.
    // Success 는 data class 이므로, 프로퍼티 값이 다른 객체는 서로 다른 객체로 인식한다.
    assert(state == state2)
}

fun uiState2(){
    // Object 는 싱글턴으로 메모리가 하나만 올라감
    val state = UiState.Loading
    val state2 = UiState.Loading

    // 성공
    // 서로 하나의 값, 메모리이기 때문에 무조건 성공
    assert(state == state2)
}

# 특징4. 확장이 가능하다.

sealed class UiState <out T> {
    data object Loading : UiState<Nothing>()
    data object Empty : UiState<Nothing>()
    data class Failure(val message: String?, val throwable: Throwable) : UiState<Nothing>()
    data class Success<T>(val data: T) : UiState<T>()
}

data object None: UiState<Nothing>()

 

위처럼 같은 파일 내라면 새로운 서브 클래스를 생성할 수 있다.

# 특징5. when 에서 else 없이도 안전하게 사용이 가능하다

#특징4 를 시도했다 생각하고, UiState 를 활용하여 변수에 할당하고 when 문으로 조건 검사를 실행해보자.

class DevGyuTest {
    fun uiState(){
        val uiState: UiState<Boolean> = getUiState()

        // 'when' expression must be exhaustive, 
        // add necessary 'None' branch or 'else' branch instead
        // 확장한 None 이 없어서 뱉는 에러
        when(uiState){
            is UiState.Success -> true
            is UiState.Failure -> false
            UiState.Loading -> false
            UiState.Empty -> false
        }
    }
}

fun getUiState(): UiState<Boolean> = UiState.Success(true)

 

 

위와 같이 when 문으로 검사를 할 때 서브 클래스 중 하나라도 검사문에 추가하지 않는다면 에러를 내뱉게 되어 개발자가 실수로 조건을 빠뜨리지 않게 해줄 수 있다. (else 를 사용하지 않는다면 말이다.)

# Enum Class 란?

"열거형 클래스" 라고 불리며 내부적으로 상수들을 정의해놓은 상수들의 집합 클래스

내부에 선언된 열거형 상수들은 모두 고정된 값을 가진다.

 

# 특징1. enum 은 프로퍼티와 메소드를 가질 수 있다.

각 상수마다 서로 다른 동작(메서드 오버라이딩)이 가능하지만, 모든 상수들이 공통된 프로퍼티를 가져야한다.

enum class Animal(val sound: String) {
    DOG("멍") {
        override fun makeSound() = "멍멍!"
    },
    CAT("냥") {
        override fun makeSound() = "냐옹!"
    };

    abstract fun makeSound(): String
}

 

# 특징2. 컴파일 시 각 Enum 상수들은 static final 객체로 선언되고, enum class 는 java.lang.Enum 로 변환된다.

컴파일 시 내부 상수들을 모두 static final 객체로 선언하기 때문에 싱글턴 패턴을 따른다.

즉, 런타임 때 객체가 새로 생성될 수 없다.

 

enum class DatabaseConnection {
    INSTANCE;

    fun connect() = "Connected to Database"
}

// 자바
public enum DatabaseConnection {
    INSTANCE;

    public String connect() {
        return "Connected to Database";
    }
}

# 특징3. 확장이 불가능하다.

sealed class 의 경우 같은 파일 내에서 새롭게 확장이 가능하지만 enum class 의 경우 열거형 클래스 내에서만 설정이 가능하다.


# 정리

1. 컴파일 시 변환

  • sealed class : abstract sealed class 로 변환 후 서브 클래스들은 각 class 형식으로 변환
  • enum class : java.lang.enum 으로 변환 후 각 상수들은 final static 선언되어 싱글턴 패턴을 따른다.

2. 서로 다른 타입의 데이터를 가질 수 있는지

  • sealed class : 서브 클래스별로 서로 다른 메소드 및 프로퍼티를 가질 수 있다.
  • enum class : 각 상수는 공통된 프로퍼티나 메소드를 가진다. 오버라이딩은 가능함.

3. 확장 여부

  • sealed class : 같은 파일 내에서 가능
  • enum class : 확장 불가능
728x90