Kotlin - Sealed class 구현 방법 및 예제

Sealed class는 Super class를 상속받는 Child 클래스의 종류 제한하는 특성을 갖고 있는 클래스입니다. 어떤 클래스를 상속받는 하위 클래스는 여러 파일에 존재할 수 있기 때문에 컴파일러는 얼마나 많은 하위 클래스들이 있는지 알지 못합니다. 하지만 Sealed class는 동일 파일에 정의된 하위 클래스 외에 다른 하위 클래스는 존재하지 않는다는 것을 컴파일러에게 알려주는 것과 같습니다.

예를 들어 Color라는 상위 클래스를 만들고, 동일한 파일에 이 클래스를 상속하는 Red, Blue 라는 클래스를 선언했다고 가정해보세요. Sealed class는 이 두개의 클래스 외에 Color 클래스를 상속받는 다른 클래스는 없다라는 것을 컴파일러에게 말해줍니다.

이렇게 하위 클래스가 될 수 있는 클래스를 제한하여 얻을 수 있는 장점 중 하나는 when을 사용할 때 else를 사용하지 않는 것입니다.

이것은 코틀린에서 제공하는 Enum으로도 얻을 수 있는 이점입니다. 하지만 Enum은 Red라는 하위 객체를 Singleton처럼 1개만 생성할 수 있고 복수의 객체는 생성할 수는 없습니다. 반면에 Sealed class는 1개 이상의 객체를 생성할 수 있습니다.

Sealed class 정의 방법

다음과 같이 Sealed class를 정의할 수 있습니다. 클래스 앞에 sealed 키워드를 붙이면 이 클래스는 abstract 클래스가 됩니다. 그리고 하위 클래스가 이 클래스를 상속하도록 선언하면 됩니다.

sealed class Color

object Red: Color()
object Green: Color()
object Blue: Color()

그리고 다음과 같이 객체를 생성할 수 있습니다.

val color : Color = Red

object 클래스로 정의하면 singleton 패턴이 적용되어 single instance로 생성됩니다. object 클래스에 대해서 더 알고 싶으시면 Kotlin - object와 class 키워드의 차이점을 참고하시면 좋습니다.

중첩 클래스로 정의 (Nested class)

Sealed class는 다음과 같이 중첩 클래스로 정의할 수도 있습니다. 상위 클래스 아래에 하위 클래스들이 위치하도록 선언하면 됩니다.

sealed class Color {
    object Red: Color()
    object Green: Color()
    object Blue: Color()
}

그리고 이런 식으로 객체를 생성할 수 있습니다.

val color : Color = Color.Red

Sealed class의 특징

  • 클래스 앞에 sealed keyword를 붙여 정의합니다.
  • Sealed class는 abstract 클래스로, 객체로 생성할 수 없습니다.
  • Sealed class의 생성자는 private입니다. public으로 설정할 수 없습니다.
  • Sealed class와 그 하위 클래스는 동일한 파일에 정의되어야 합니다. 서로 다른 파일에서 정의할 수 없습니다.
  • 하위 클래스는 class, data class, object class으로 정의할 수 있습니다.

Sealed class로 부터 얻는 이점

하위 클래스를 제한해서 얻는 이점 중에 하나는 when을 사용할 때입니다. when은 모든 케이스에 대해서 처리가 되어야 하기 때문에 else 구문이 꼭 들어가야 합니다. 하지만 Sealed class를 사용하면 컴파일 시점에 하위 클래스들이 정해져있기 때문에, 모든 하위 클래스에 대한 케이스를 구현하면 else 구문을 추가하지 않을 수 있습니다.

다음은 Enum이나 Sealed class를 사용하지 않고 String으로 타입을 체크하여 font를 결정하는 코드입니다. 이 코드의 when에는 else가 필요합니다.

val color = "red"
val font = when (color) {
    "red" -> {
        "Noto Sans"
    }
    "green" -> {
        "Open Sanse"
    }
    else -> {
        "Arial"
    }
}

위의 코드는 현재 red와 green에 대해서만 font를 계산하고 있습니다. 만약 blue에 대한 것을 추가하여 확장하고 싶다면 when에 다음과 같이 코드를 추가해야 합니다.

val color = "red"
val font = when (color) {
    "red" -> {
        "Noto Sans"
    }
    "green" -> {
        "Open Sanse"
    }
    "blue" -> {
        "Sans-serif"
    }
    else -> {
        "Arial"
    }
    // No error!
}

하지만 실수로 blue에 대한 코드를 추가하지 않았다고 생각해보세요. 컴파일이 잘 될까요? 네 잘 됩니다. 그 이유는 else 구문이 있기 때문입니다.

이제 Sealed class로 구현한 코드를 확인해보겠습니다. 다음과 같이 클래스들을 정의하였습니다. when을 보면 else가 없지만 컴파일이 잘 됩니다. sealed keyword 때문에 컴파일러는 Color 클래스의 하위 클래스는 Red와 Green 클래스 뿐이라고 생각합니다. 모든 케이스를 처리하고 있기 때문에 else가 없어도 컴파일이 잘 됩니다.

sealed class Color {
    object Red: Color()
    object Green: Color()
}

val color : Color = Color.Red
val font = when (color) {
    is Color.Red -> {
        "Noto Sans"
    }
    is Color.Green -> {
        "Open Sans"
    }
    // No error!
}

여기서 Blue를 추가하여 확장해보겠습니다.

다음과 같이 Blue 클래스를 추가하였습니다. 실수로 when 안에 Blue에 대한 코드를 추가하지 않았다고 생각해보세요. else가 없기 때문에 컴파일 에러가 발생합니다.

sealed class Color {
    object Red: Color()
    object Green: Color()
    object Blue: Color()
}

val color : Color = Color.Red
val font = when (color) {
    is Color.Red -> {
        "Noto Sans"
    }
    is Color.Green -> {
        "Open Sans"
    }
    // compile error!
}

아래와 같이 Blue에 대한 코드를 추가해야 컴파일 에러가 발생하지 않습니다.

val font = when (color) {
    is Color.Red -> {
        "Noto Sans"
    }
    is Color.Green -> {
        "Open Sans"
    }
    is Color.Blue -> {
        "sans-serif"
    }
    // No error!
}

정리하면, sealed class는 컴파일 시점에 존재할 수 있는 클래스 타입이 정해져 있기 때문에 when을 사용할 때 else를 사용하지 않아도 됩니다. else를 사용하지 않았기 때문에 기능을 확장할 때 위의 예제와 같이 실수로 코드를 추가하지 않는 일이 발생하지 않게 됩니다.

Sealed class와 Enum의 차이점

코틀린에서 제공하는 Enum도 하위 클래스의 타입들이 정해져있다는 점에서 Sealed class와 비슷합니다. 가장 큰 차이점은 Enum은 Single instance만 만들 수 있는 반면에 Sealed class는 객체를 여러개 생성할 수 있다는 것입니다.

지금까지의 예제에서 Sealed class는 모두 object를 사용하여 정의했었습니다. 그렇기 때문에 Enum과 차이점이 없다고 생각할 수 있는데요.

다음 예제는 data 키워드를 사용하여 하위 클래스들을 정의하였습니다. 이 클래스들은 1개 이상의 객체가 생성될 수 있습니다.

sealed class Expr

data class Const(val number: Double) : Expr()
data class Sum(val e1: Expr, val e2: Expr) : Expr()
object NotANumber : Expr()

다음은 위의 클래스를 이용하여 숫자의 합을 구하는 예제입니다. eval() 함수는 Expr의 타입별로 다르게 처리하며, 숫자의 합을 구하고 있습니다.

fun eval(expr: Expr): Double = when(expr) {
    is Const -> {
        expr.number
    }
    is Sum -> {
        eval(expr.e1) + eval(expr.e2)
    }
    NotANumber -> {
        Double.NaN
    }
}

val num1 = Const(10.0)
val num2 = Const(20.0)
val sum = Sum(num1, num2)
val result = eval(sum)
println("result : $result")

코드를 실행하면 Const(10.0)Const(20.0)의 합이 출력됩니다.

result : 30.0

위의 예제에서 서로 다른 Const(10.0), Const(20.0) 객체가 생성되는 것을 볼 수 있었습니다.

Generics로 Sealed class를 정의하는 방법

먼저 Sealed class를 소개하고, 이것을 Generic으로 변경해보겠습니다.

다음은 어떤 요청에 대한 결과의 상태를 갖고 있는 Result 클래스입니다.

sealed class Result {
    data class Success(val data: String) : Result()
    data class Error(val exception: Exception) : Result()
    object InProgress : Result()
}

App이 Webserver에 어떤 데이터에 대한 요청을 하고 성공하면 Success, 실패하면 Error를 리턴하고 아직 처리가 안끝났다면 InProgress를 리턴한다는 시나리오를 생각해보세요.

다음과 같이 각각의 케이스에 대해서 다른 객체들이 리턴될 수 있습니다.

var parsedResult : Result =
        Result.Success("Sealed classes are used for representing...")
showResult(parsedResult)

parsedResult =
        Result.Error(Exception("Got error while parsing this url"))
showResult(parsedResult)

parsedResult = Result.InProgress
showResult(parsedResult)

fun showResult(result: Result) {
    when (result) {
        is Result.Success -> {
            println("Success: ${result.data}")
        }
        is Result.Error -> {
            println("Error: ${result.exception}")
        }
        is Result.InProgress -> {
            println("In progress")
        }
    }
}

showResult() 는 요청에 대한 결과를 화면에 출력해주는 함수입니다. when으로 각 케이스 별로 결과를 출력하도록 구현하였습니다.

출력해보면 다음과 같이 출력됩니다.

Success: Sealed classes are used for representing...
Error: java.lang.Exception: Got error while parsing this url
In progress

이제 이 코드를 Generic으로 변경해보겠습니다. 사실 Generic으로 변경하는 것은 어렵지 않습니다. 클래스 이름 옆에 <out T : Any>처럼 Generic type을 써주면 됩니다.

각각의 클래스에 모두 Generic 타입을 입력해주면 다음과 같은 코드가 됩니다.

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

그리고 다음과 같이 객체를 생성하거나 사용하면 됩니다.

var parsedResult : Result<String> =
        Result.Success("Sealed classes are used for representing...")
showResult1(parsedResult)

parsedResult =
        Result.Error(Exception("Got error while parsing this url"))
showResult1(parsedResult)

parsedResult = Result.InProgress
showResult1(parsedResult)

fun showResult1(result: Result<String>) {
    when (result) {
        is Result.Success -> {
            println("Success: ${result.data}")
        }
        is Result.Error -> {
            println("Error: ${result.exception}")
        }
        is Result.InProgress -> {
            println("In progress")
        }
    }
}

<out T : Any>에서 out은 상속관계에 있는 클래스들의 관계가 Invariance일 때, Covariance로 만들어주는 키워드입니다. 더 자세히 알고 싶으시다면 Kotlin - Generics 클래스, 함수를 정의하는 방법를 참고하시면 좋습니다.

참고

Loading script...
codechachaCopyright ©2019 codechacha