본문으로 바로가기
반응형

Photo by unsplash

Sealed class / interface의 개념은 kotlin 초반 버전부터 진작에 나왔습니다. 따라서 이에 대한 활용법도 이미 많이 나와있는 상태인데, 대부분이 network response를 다루는 예제를 이용하여 설명하고 있습니다.

여기서는 enum을 대체할 수 있는 장점, compose에서 사용 예제까지 전체적인 내용을 정리해 볼까 합니다.

Enum의 한계

Java 시절로 거슬러 올라가면 기존에 상수로 정의했던 특정 값들의 집합들을 enum이라는 클래스로 대체해서 사용하라는 권고가 나오게 됩니다. 이는 if-else문에서 const로 구성된  값들이 분기에서 누락될 수 있고, 이런 상수들을 param으로 받는 함수들의 경우에도 정의된 값만 입력받기를 의도 하지만 const로 정의된 제한된 타입이 아닌 엉뚱한 값이 들어오는걸 enum을 통해서 막을 수 있기 때문입니다. 

// 전통적인 방식
const val SUCCESS = 0
const val FAILED = 1

fun checkResult(result: Int) {
    if (result == SUCCESS) {
        // do something
    else if (result == FAILED) {
        // do something
    }
    // 만약 result로 3이 들어온다면?
    ...
}

// Enum으로 교체시
enum class Result {
    SUCCESS,
    FAILED,
}

fun checkResult(result: Result) {
    if (result == SUCCESS) {
        // do something
    else if (result == FAILED) {
        // do something
    }
    // result에는 enum 이외의 값이 들어올 수 없다.
    ...
}

Enum 만으로도 type을 제한하고 when문을 사용할 경우 누락된 값에 대한 처리를 IDE를 통해서 알수 있지만 이는 내부적으로 각 value당 하나의 single instance를 사용하기 때문에 서로 다른 형태를 가질 수 없습니다.

예를 들어 SUCCESS인 경우 성공에 대한 결과값을 넣고, FAIL인 경우 exception에 대한 정보를 넣고 싶은 needs가 있더라도 담고 싶은 정보가 서로 다른 type 이기 때문에 불가능합니다.

enum class Result {
    SUCCESS,
    FAILED(val exception: Exception) // 이런 형태는 불가함.
}

따라서 이를 꼭 구현해야겠다면 아래와 같이 abstract class로 사용해야 합니다.

abstract class Result<out T : Any>

data class Success(out T: Any>(val data: T) : Result<T>()
data class Failed(val exception: Exception) : Result<Nothing>()

하지만 abstract class를 어떤 클래스가 상속받을지 complier가 알 수 없기 때문에 확장성은 생겼지만 Enum처럼 type의 제한을 할 수 없습니다.

Sealed class의 기본 사용법

Enum과 같은 value당 제한 (소속을 갖는 set으로의 정의)를 가지면서 각 value의 형태를 다르게 확장성 있도록 가져가기 위해서 sealed class를 사용합니다.

sealed class Result<out T : Any> {
    data class Success<out T: Any>(val data: T) : Result<T>()
    data class Failed(val exception: Exception) : Result<Nothing>()
    object InProgress : Result<Nothing>()
}

세 가지 타입이 Result란 범위로 묶입니다. 일반 class, data class, object 등의 타입이 모두 가능하며, 각각의 요소가 되는 항목들 역시 다른 형태의 값을 지닐 수 있습니다. [1]

단, sealed class를 상속받는 클래스들은 같은 kt 파일 또는 package 안에 존재해야 합니다. 이는 내부적으로 Result 클래스가 private 한 constructor를 갖기 때문입니다. (외부에서 sealed class를 상속받는다면 compile error가 발생합니다.)

 

When의  사용

when문은 아래와 같이 사용할 수 있습니다.

fun start() {
    val result = getResult()
    processResult(result)
}

fun processResult(result: Result<String>) {
    when (result) {
        is Result.Success -> println(result.data)
        is Result.Failed -> println(result.exception)
        is Result.InProgress -> println("processing!!")
    }
}

fun getResult(): Result<String> {
    return Result.Success("result is success")
    //return Result.Failed(Exception("Wow Exception!!"))
}

안전한 When 구문의 사용

kotlin에 오면서 if나 when, try-catch 등이 statement나 expression 두 가지 모두로 사용될 수 있습니다.

when문을 expression으로 사용 시 누락된 값이 있다면 compile 에러가 발생합니다. 즉 추후에 sealed class에 값이 추가된다면, 이를 사용하는 when문에서 모두 compile 에러가 발생하기 때문에 쉽게 인지하여 compile time에 누락을 방지할 수 있습니다.

다만 statement 형태로 쓰는 경우 error가 발생하지 않습니다. IDE에는 녹색 줄로 warning만 줄 뿐 compile error까지 노출시키지 않기 때문에 sealed class의 값을 변경 시 이를 when문으로 처리하는 모든 코드들을 인지하기 어렵습니다. [1][4]

따라서 이런 runtime error를 방지하기 위해서 아래와 같이 extension property를 추가합니다.

val <T> T.exhaustive: T
    get() = this

그리고 위의 statement 구문을 강제로 위 extension property로 expression으로 바꿔 누락된 option value를 compile time에 체크하도록 할 수 있습니다.

When 구문의 제거

when문을 사용하면 sealed class/interface를 안전하고 쉽게 구문 할 수 있습니다. 다만 sealed class의 여러 곳에서 when문을 사용하는 경우 extension function을 이용하여 when문 없이 사용할 수 있습니다. [2]

inline fun <reified T : Any> Result<T>.onFailed(action: (exception: Exception) -> Unit) {
    if (this is Result.Failed) {
        action(exception)
    }
}

inline fun <reified T: Any> Result<T>.onSuccess(action: (data: T) -> Unit) {
    if (this is Result.Success) {
        action(data)
    }
}

inline fun <reified T: Any> Result<T>.onProgress(action: () -> Unit) {
    if (this is Result.InProgress) {
        action()
    }
}

위와 같이 각 요소에 대한 inline function을 생성할 경우 아래와 같이 when 문 없이 좀 더 심플하게 사용이 가능해집니다.

fun processResult(result: Result<String>) {
//    when (result) {
//        is Result.Success -> println(result.data)
//        is Result.Failed -> println(result.exception)
//        is Result.InProgress -> println("processing!!")
//    }
    result.onSuccess { data -> println(data)}
    result.onFailed { exception -> println(exception)}
    result.onProgress { println("progress!!")}
}

Extension function for Transformation

위의 when문 제거와 더불에 extension function을 추가하여 기존 형태를 변경하는 형태도 추가할 수 있습니다. [2]

inline fun <reified T: Any, reified R: Any> Result<T>.map(transform: (T) -> R): Result<R> {
    return when (this) {
        is Result.Success -> Result.Success(transform(data))
        is Result.Failed -> this
        is Result.InProgress -> this
    }
}

...

 fun processResult(result: Result<String>) {
...
        result.map { it.length }.onSuccess { data ->
            println(data)
        }
...
    }
반응형

Subset을 갖는 sealed class

enum과는 다르게 sealed class는 내부에 subset을 갖도록 만들 수도 있습니다.

sealed class Result<out T : Any> {
    data class Success<out T : Any>(val data: T) : Result<T>()

    //        data class Failed(val exception: Exception) : Result<Nothing>()
    sealed class Failed(val exception: Exception, val message: String?) : Result<Nothing>() {
        class NetworkError(exception: Exception, message: String?) : Failed(exception,message)
        class StorageIsFull(exception: Exception, message: String?) : Failed(exception, message)
    }

    object InProgress : Result<Nothing>()
}

fun start() {
    val result = getResult()
    processResult(result)
}

fun processResult(result: Result<String>) {
    when (result) {
        is Result.Success -> println(result.data)
        is Result.Failed -> {
            println(result.exception, result.exception)
        }
        is Result.InProgress -> println("processing!!")

    }.exhaustive
}

fun getResult(): Result<String> {
      //return Result.Success("result is success")
    return Result.Failed.NetworkError(Exception("Wow Exception!!"), "Network connection is failed")
}

Failed의 원인을 좀 더 세부 화하여 표기하고 싶을 때 위 코드처럼 sealed class 내부에 sealed class를 만들어 subset을 지정할 수 있습니다. [3]

Reflection을 이용한 sealed class의 sub class 반환

Enum에서는 values()를 통하여 모든 원소들을 반환 받을수 있습니다. 그리고 종종 이런 구문들이 필요한 경우들이 생깁니다. sealed class는 내부적으로 abstract class가 정의되고 이를 상속받기 때문에 원소의 리스트를 반환하는 함수가 따로 존재하지 않습니다. 이를 해결하기 위한 방법으로 sealedSubClasses라는 속성이 kotlin v1.3 부터 추가되어 reflection을 통해 값을 얻어올 수 있습니다.

먼저 kotlin reflection을 사용하기 위해 아래 구문을 build.gradle에 추가합니다.

// kotlin reflection
implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"

그리고 아래와 같은 구문으로 sealed class의 subclass list를 얻어올 수 있습니다.

val list = SealedClass명::class.sealedSubclasses

실제 용례는 아래와 같습니다.

 - 일반적인 경우

// 샘플 sealed class
sealed class SealedClassTest {
    object AObject: SealedClassTest()
    data class BDataClass(val text: String): SealedClassTest()
    object CObject: SealedClassTest()
}

private fun test() {
    val list1 = SealedClassTest::class.sealedSubclasses
    LogError(TAG) {"list1:$list1"}
}
list1:[
class com.mytest.sealed.SealedClassTest$AObject,
class com.mytest.sealed.SealedClassTest$BDataClass,
class com.mytest.sealed.SealedClassTest$CObject,
]

모든 항목들을 추출해 주는것을 알수 있습니다.

- object만 뽑고 싶은 경우

// 샘플 sealed class
private fun test() {
    val list2 = SealedClassTest::class.sealedSubclasses.mapNotNull { it.objectInstance }
    LogError(TAG) {"list2:$list2"}
}
list2:[
class com.mytest.sealed.SealedClassTest$AObject,
class com.mytest.sealed.SealedClassTest$CObject,
]

- sub sealed class를 포함한 경우

sealed class SealedClassTest {
    object AObject: SealedClassTest()
    data class BDataClass(val text: String): SealedClassTest()
    object CObject: SealedClassTest()
    sealed class DSealedClass: SealedClassTest() {
        object EObject: DSealedClass()
        object FObject: DSealedClass()
    }
}

private fun test() {
    val list3 = SealedClassTest::class.sealedSubclasses
    LogError(TAG) {"list3:$list3"}
}
list3:[
class com.mytest.sealed.SealedClassTest$AObject,
class com.mytest.sealed.SealedClassTest$BDataClass,
class com.mytest.sealed.SealedClassTest$CObject,
class com.mytest.sealed.SealedClassTest$DSealedClass
]

sub sealed class의 내부 원소까지 출력해 주지는 않습니다.

위 예제는 전부 sealed class를 사용했지만 sealed interface에서도 동일하게 사용할 수 있습니다.

만약 proguard를 사용한다면 refelction을 이용하는 sealedSubClasses가 정상적으로 동작하지 않습니다. 따라서 아래와 같이 @Keep annotation을 사용하여 proguard를 회피하도록 처리해야 합니다.

// 샘플 sealed class
sealed class SealedClassTest {
    @Keep
    object AObject: SealedClassTest()
    
    data class BDataClass(val text: String): SealedClassTest()
    
    @Keep
    object CObject: SealedClassTest()
}

private fun test() {
    val list2 = SealedClassTest::class.sealedSubclasses.mapNotNull { it.objectInstance }
    LogError(TAG) {"list2:$list2"}
}

 

만약 proguard에서 제외되지 않는다면 수동으로 proguard 설정 파일 ex) probuard-rules.pro 에 아래와 같이 예외 처리를 해야 합니다. 다만 하기 구문 추가시 빌드후 2%정도의 용량이 증가하는 이슈가 있었습니다. 얼마 안되는 용량 같지만 안드로이드의 경우 100MB 짜리 apk 라면 2MB가 증가되므로 용량 이슈가 있을수 있습니다. 단. 이건 제가 개발 하던 기능에서 발생한 상황이라 다를 수 있습니다.

# @Keep을 사용하는 경우 최적화에서 제외 ex) sealed class에서 reflection을 사용하여 sealedSubclasses를 읽는 경우
-keep @android.support.annotation.Keep class *
-keep @android.support.annotation.Keep class * { *;}
-keep class * {@android.support.annotation.Keep *;}

 

Android Compose에서 Sealed interface의 사용

Compose에서는 composable function이 또 다른 composable function을 호출할 수 있습니다.  따라서 중첩된 composable function에서 click에 대한 처리를 해야 하는 경우가 발생합니다.

@Composable
fun MainScreen(onClick: () -> Unit) {
    Column {         
        Button(onClick = {}) {
            Text("button #1 - clicked!")
        }

        Button(onClick = {}) {
            Text("button #2 - clicked")
        }

        Button(onClick = {}) {
            Text("button # - send 'wow' text")
        }
    }
}

위와 같이 MainScreen이 세 개의 Button을 갖습니다. 이 버튼을 클릭시 각각 아래 viewModel의 함수가 클릭되어야 한다고 가정합니다.

@HiltViewModel
class TestViewModel : ViewModel() {
    fun onButton1Cilcked() {
        //do something
    }
    fun onButton2Cilcked() {
        //do something
    }
    fun onButton3Cilcked(value: String) {
        //do something
    }
}

event를 넘겨주기 위해서는 MainScreen의 click처리 param을 각각 세개 만들 수도 있지만, 이는 추가적인 click 이벤트 처리가 생길 때마다 함수의 param을 변경하는 작업을 해야 합니다. 따라서 android에서는 아래와 같이 직접 composable function 내부에서 viewmodel에 접근 가능하도록 지원합니다. [5]

@Composable
fun MainScreen(testViewModel: TestViewModel = viewModel()) {
    Column {
        Button(onClick = {testViewModel.onButton1Cilcked()}) {
            Text("button #1 - clicked!")
        }

        Button(onClick = {testViewModel.onButton2Cilcked()}) {
            Text("button #2 - clicked")
        }

        Button(onClick = {testViewModel.onButton3Cilcked("click value")}) {
            Text("button # - send 'wow' text")
        }
    }
}

이런 식으로 작성하면 composable function 어디에서든 viewmodel에 접근할 수 있기 때문에 쉽게 click 및 외부 리소스에 접근할 수 있습니다.

하지만 이렇게 viewModel을 composable function에 고정시킨다면 MainScreen의 공용성은 떨어지게 됩니다. 즉, MainScreen이 TestViewModel에 종속되기 때문에 다른 곳에서 재활용하기 어려워집니다. 

따라서 아래와 같이 sealed interface를 사용하여 click을 구분하도록 아래와 같이 코드를 구성할 수 있습니다.

sealed interface MainButton
object Button1Click : MainButton
object Button2Click : MainButton
class Button3Click(val value: String) : MainButton

@Composable
fun MainScreen(onButtonClick: (MainButton) -> Unit) {
    Button(onClick = { Button1Click }) {
        Text("button #1 - clicked!")
    }
    Button(onClick = { Button2Click }) {
        Text("button #2 - clicked")
    }
    Button(onClick = { Button3Click("Button 3 clicked!!") }) {
        Text("button #3 - send 'wow' text")
    }
}

@Composable
fun Main() {
    MainScreen {
        when(it) {
            is Button1Click -> testViewModel.onButton1Cilcked()
            is Button2Click -> testViewModel.onButton2Cilcked()
            is Button3Click -> testViewModel.onButton3Cilcked(it.value)
        }
    }
}

마치며

Enum에서 동일한 형태만 갖는 속성들의 제약에서 벗어나, 다른 형태의 param이나, 클래스의 타입을 (class, data class, object..) 갖도록 허용한다는점에서 기존에 좀더 불편하게 썼어야 하는 제약에서 크게 확장성을 열어줍니다. 하지만 enum에서 제공하는 oridinal 이나, 모든 원소를 values로 반환한다던가 하는 편의를 제공하는 부분에서는 아직 부족함이 많습니다.

하지만 kotlin 버전이 점점 올라가면서 불편했던 기존 부분들을 시원하게 긁어주는 형태로 발전하리라 믿어 봅니다.^^[6][7]

References

[1]  https://medium.com/androiddevelopers/sealed-with-a-class-a906f28ab7b5

[2] https://medium.com/swlh/kotlin-sealed-class-for-success-and-error-handling-d3054bef0d4e

[3] https://medium.com/halcyon-mobile/simplify-your-android-code-by-delegating-to-sealed-classes-99304c509321

[4] https://blog.karumi.com/kotlin-android-development-6-months-into-it/

[5] https://developer.android.com/jetpack/compose/libraries?hl=ko#viewmodel 

[6] https://www.baeldung.com/kotlin/subclasses-of-sealed-class

[7] https://medium.com/@selali.adobor/using-sealedsubclasses-in-kotlin-bdf9abae186

반응형