다수의 코드에서 Enum 역할을 하는 곳에 Sealed class를 사용하는걸 보게 됐고, 한 번 정리하고 넘어갈 필요성을 느꼈다. 간단하게 얘기하자면, Sealed class는 enum class처럼 동일하게 사용가능하면서 보다 유연하고 확장성을 가진다.
Enum class
먼저 간단히 Enum class를 살펴보자. enum은 같은 카테고리의 상수들을 나열한 것이다. Kotlin 공식사이트의 예제를 가져오면 다음과 같이 사용한다.
enum class Direction {
NORTH, SOUTH, WEST, EAST
}
기존 언어들에서도 사용하는 가장 간단한 형태이다. 위와같이 사용하면, 실제로는 Direction 타입으로 NORTH, SOUTH등 각각 인스턴스가 생성된다. 그래서 다음과 같이 when문으로 처리가 가능하다.
val direction:Direction = Direction.EAST
when(direction){
Direction.NORTH -> println("NORTH")
Direction.SOUTH -> println("SOUTH")
Direction.WEST -> println("WEST")
Direction.EAST -> println("EAST")
}
각각의 enum 인스턴스들은 enum class의 인스턴스로 값을 가질수도 있다.
enum class Color(val rgb: Int) {
RED(0xFF0000),
GREEN(0x00FF00),
BLUE(0x0000FF)
}
Color enum class에 rgb값을 넣게 되어있고, 각 enum 항목들이 이를 따라 초기값을 부여하고 있다. 이를 사용하는 건 보통의 클래스와 같다.
val color: Color = Color.BLUE
println("My color is ${color.rgb}")
자주 접하진 않았지만, enum class에는 유용한 메소드도 존재한다.
EnumClass.valueOf(value: String): EnumClass
EnumClass.values(): Array<EnumClass>
valueOf()는 문자열로 enum 객체를 찾을 수 있다. 예를 들면 다음과 같다.
val color2 = Color.valueOf("RED")
println("$color2")
보여주기식 예제를 쓰다보니 좀 어색한 코드가 됐지만, 위와 같이 “RED”라는 문자열로 Color.RED 객체를 가져오고 있다. 만약, 존재하지 않는 이름을 사용시에는 IllegalArgumentException이 발생한다.
또하나의 유용한 메소드인 values()는 enum 항목들의 Array를 반환해준다. 이를 이용해 다음과 같은 처리가 가능하다.
println("${ Color.values().forEach { println(it.name) }}")
이렇게 쓸일이 얼마나 있을지 모르겠지만, values()로 받아온 enum 배열을 모두 출력하고 있다.
Sealed class
Sealed class는 원래 클래스의 상속에 관련된 제약이다. sealed 로 선언된 클래스나 인터페이스는 같은 패키지 안에서 컴파일 타임에만 상속을 사용할 수 있다. 일단, sealed class가 컴파일 되면, 외부에선 이를 상속하지 못한다. 예를들어 라이브러리를 만들되, 이 라이브러리에서 클래스를 상속받아 사용하는걸 막고싶다면, sealed class를 사용하면 된다.
sealed class가 컴파일타임에 상속을 모두 결정한다는 사실 때문에, 흥미롭게도 enum을 대체하여 사용가능하다. 컴파일 타임에 상속받은 타입들이 확정되므로, Kotlin에서는 when문에서 sealed class의 서브클래스 타입을 허용하기 때문이다.
잠깐 투덜대자면, 프로그래밍 관련해서 구글링을 했을 때 수 많은 블로그 포스트들을 만나지만, 대부분의 한글 포스팅들이 크게 도움이 안된다. 이걸 정리하면서도 ‘근데, 왜 sealed class로 enum을 대체하는거지?’란 근본적인 의문이 들었는데, 한글 블로그들은 이해를 하고 적은건지 피상적인 내용만 영어원문에서 따온느낌이 많았다. 그냥 그렇다고 아쉽다고. 위와 같은 기본적인 내용을 딱 알려주는 글이 잘 안보여서 말이지.
먼저 앞에서 살펴본 enum과 동일한 구현을 해보자.
sealed class Color(val rgb: Int){
object RED: Color(0xFF0000)
object GREEN: Color(0x00FF00)
object BLUE: Color(0x0000FF)
}
sealed class의 nested 형태로 object들을 정의했다. 앞서, enum 항목이 각각 객체임을 얘기했었다. 그와 동일하게 object를 사용하여 위와같이 정의가 가능하다. 다만 enum에 있는 valueOf()나 values() 메소드가 없기 때문에, 이를 제외하곤 동일하게 사용가능하다.
sealed class는 상속을 이용하는 것이기 때문에, 일반적인 class나 data class도 사용이 가능하다. 이 부분이 enum class와의 결정적인 차이점인데, enum class가 상수들의 나열에 적합하다면, sealed class는 class들을 사용하기 때문에 같은 카테고리 타입들의 나열에 적합하다. 예를들면, 단순한 코드값외에 여러 정보를 포함하는 에러코드라던지, 추가적인 정보들을 갖는 state 머신의 상태들이 있다.
enum class와 sealed class의 차이점을 항목별로 잘 정리한 스택오버플로우 글이 있다. 한 번 살펴봐도 좋을듯. Sealed class vs Enum Stackoverflow 참조.
Sealed class vs Enum class
여기서부터는 언급한 스택 오버플로우의 예제들을 이용할 것이다. 내가 새로 만들고 정리하기엔 시간이 많이 드니까 ㅋㅋ 어쨌든, 핵심은 enum은 상수 객체들이고, sealed class는 object를 사용하면 enum과 동일하지만, subclass들이 가능하다는 점. 이로서 보다 유연하고 확장성이 생긴다는 부분이다.
Properties & Functions
enum은 클래스에 정의된 공통 property만 가능하다. 반면에 sealed class는 클래스를 사용하면 제각각 다른 property를 가질 수도 있다. 이는 function에 대해서도 마찬가지다. 스택 오버플로우의 예제를 가져와보면 다음과 같다.
sealed class DeliveryStatus{
class Preparing() : DeliveryStatus(){
fun cancelOrder() = println("Cancelled.")
}
class Dispatched(val trackingId: String) : DeliveryStatus(){
fun rejectDelivery() = println("Delivery rejected.")
}
class Delivered(val trackingId: String, val receiversName: String) : DeliveryStatus(){
fun returnItem() = println("Return initiated.")
}
}
각각 자신만의 property들을 가지며, 자신만의 함수들을 사용하고 있다. 이 경우, 다음과같이 when문을 사용하여 처리가 가능하다.
class DeliveryManager {
fun cancelOrder(status: DeliveryStatus) = when(status) {
is DeliveryStatus.Preparing -> status.cancelOrder()
is DeliveryStatus.Dispatched -> status.rejectDelivery()
is DeliveryStatus.Delivered -> status.returnItem()
}
}
enum과 달라진점은 클래스 타입을 체크해야하기 때문에, ‘is’를 사용하고 있다.
위의 예제에서 이렇게 사용이 가능한걸 보여주기위해 제각각 다른 함수를 사용했지만, 배달 상태가 어떻든, 동일한 cancelOrder() 인터페이스를 쓰는게 맞을 것이다. sealed class는 abstract class이므로 abstract 멤버들도 가질 수 있다. 이를 이용하여 위의 코드를 고쳐보자.
sealed class DeliveryStatus{
abstract fun cancelOrder()
class Preparing() : DeliveryStatus(){
override fun cancelOrder() = println("Cancelled.")
}
class Dispatched(val trackingId: String) : DeliveryStatus(){
fun rejectDelivery() = println("Delivery rejected.")
override fun cancelOrder() {
rejectDelivery()
}
}
class Delivered(val trackingId: String, val receiversName: String) : DeliveryStatus(){
fun returnItem() = println("Return initiated.")
override fun cancelOrder() {
returnItem()
}
}
}
class DeliveryManager {
fun cancelOrder(status: DeliveryStatus) = status.cancelOrder()
}
sealed class에 abstract 함수를 선언하고, 각 서브클래스에서 해당 함수를 오버라이딩 하는걸 볼 수 있다.
Inheritance
enum 값들은 객체이기 때문에 상속이 불가능하다. enum class도 명시되진 않지만 final 클래스로 상속이 불가능하다. sealed class나 subclass들은 뭐 당연히 상속이 가능하다. 다만, 이렇게 사용하는 실용적인 예는 조금 더 생각해봐야 할거 같아서 예제는 스킵.
Number of Instances
enum은 각각 객체이므로 singleton으로 생각할 수 있으며 여러 인스턴스를 생성하지 못한다. 반면, sealed class의 서브클래스들은 클래스이므로 당연히 인스턴스를 여러개 생성할 수 있다.
val dispatched1 = DeliveryStatus.Dispatched("23451")
val dispatched2 = DeliveryStatus.Dispatched("546788")
앞의 예에서 Dispatched 클래스로부터 다른값의 id를 갖는 객체를 두 개 생성하고 있다.
Serializable and Comparable
아마도 sealed class의 유일한 약점일거 같다. Kotlin의 enum class는 java.lang.Enum으로부터 왔다. 그래서 모든 enum 값들에 equals(), toString(), hashCode(), Serializable, Comparable이 구현되어 있다. sealed class에서는 직접 구현해야한다.
너무 낙심할 필요는 없는게, subclass로 data class를 사용하면 equals(), toString(), hashcode()는 자동으로 따라온다. 그래도 Serializable과 Comparable은 따로 구현해야 한다.

Conclusion
레퍼런스에서는 성능에 대한 언급도 있긴한데, 그닥 신경쓸만한 부분은 아닌거같다. 이 포스팅을 정리하면서 확실해전건, 상수들의 나열에는 enum class를 사용하고, type들을 구분해야 한다면 sealed class를 사용하면 된다는 점이다. 상수들의 사용에 굳이 sealed class를 사용해서 오버헤드를 발생시킬 필요가 없고, state machine의 상태, 정보를 많이 포함하는 에러들의 처리등에선 sealed class를 사용하는게 여러모로 유리하다. 실사용에서 sealed class가 enum의 형태로 많이 사용되고 있으니 도움이 되길. 여기까지.
2 thoughts on “Kotlin : Sealed class vs Enum”