Kotlin

[Kotlin] sealed 로 클래스 계층 확장을 제한하기 + 태그 클래스 VS 상태 패턴

sh1mj1 2024. 1. 6. 18:30

Kotlin in Action 을 공부하고 Effective kotlin 의 내용을 조금 참조하여 정리한 글입니다.

 

이미지 출처         https://commons.wikimedia.org/wiki/File:Kotlin_Icon.svg

 

이전 글에서 `Expr` 인터페이스와 `Num`, `Sum` 이라는 하위 클래스를 가지고 덧셈을 구현했었다.

(이전 글: [Kotlin] enum & when & smart cast(스마트 캐스트)

interface Expr
class Num(val value: Int) : Expr
class Sum(val left: Expr, val right: Expr) : Expr

fun evaluate(e: Expr): Int =
    when (e) {
        is Num -> e.value
        is Sum -> evaluate(e.right) + evaluate(e.left)
        else -> throw IllegalArgumentException("Unknown expression")
    }

이 때 `when` 을 사용하면 꼭 디폴트 분기인 `else` 분기를 넣어야 한다. 

 

그런데 이러한 `else` 를 붙이는 것이 깔끔하지 않다.

`when` 을 사용할 때마다 항상 `else` 를 붙인다면, 만약 `Expr` 를 구현하는 새로운 클래스를 추가하더라도, 컴파일러가 `when` 이 모든 경우를 처리하는지 제대로 검사할 수 없다.

또한 실수로 새로운 클래스를 추가한 후에, `when` 에 새로운 분기 추가를 잊어버린다면, 디폴트 분기가 선택되어 버그가 발생하기 쉽다.

sealed class

위와 같은 경우, 우리는 코틀린에서 제공하는 `sealed class` 를 사용하여 문제를 해결할 수 있다.

  • `sealed class` 는 확장에 대해서 더 제한된 클래스 계층 구조를 만들 수 있다.

상위 클래스에 `sealed` 변경자를 붙이면 그 상위 클래스를 상속한 하위 클래스의 정의를 제한할 수 있다.

그래서 컴파일 타임에 컴파일러가 `sealed class` 의 모든 Direct subclass 들을 알 수 있다. 

(Direct, Indirect subclass 는 아래에서 설명한다.)

  • `sealed class` 의 하위 클래스는 반드시 상위 클래스 안에 중첩시켜야 한다.(코틀린 1.1 이전 버전 기준)
    • 코틀린 1.1 이후부터는 하위 클래스는 상위 클래스와 같은 파일에 있기만 하면 된다.

코틀린 1.5 부터는 더 아래에서 설명한다.

  • `sealed class` 는 자동으로 `open` 이다. 

이제 코드를 `sealed class` 를 사용하여 수정해보자.

sealed class Expr {
    class Num(val value: Int) : Expr()
    class Sum(val left: Expr, val right: Expr) : Expr()
}

fun evaluate(e: Expr): Int =
    when (e) {
        is Expr.Num -> e.value
        is Expr.Sum -> evaluate(e.left) + evaluate(e.right) // 별도의 else 분기가 없음.
    }

이제 `when` 식에서 디폴트 분기(`else` 분기)가 필요없다. 

 

sealed class 는 클래스 외부에 자신을 상속한 클래스를 둘 수 없다.

 

`when` 을 사용하면 `sealed class` 에 속한 값에 대해 디폴트 분기를 사용하지 않을 수 있다.

그로 인해 나중에 `sealed class` 의 하위 클래스를 추가하고 `when` 에 추가된 하위 클래스 분기를 넣어주지 않으면 컴파일이 되지 않는다. 

그래서 개발자가 쉽게 `when` 식을 고쳐야 한다는 것을 알 수 있다.

직접 sealed class 를 상속받는 클래스 추가해보자

아래에서 `sealed class Expr` 내부에 이를 확장하는 `Product` 클래스를 추가하고 `when` 에는 해당하는 분기를 추가하지 않았다. 

컴파일러가 `is Product` 분기 혹은 `else` 분기를 추가해야 한다는 에러를 보여준다.

  • 내부적으로 `Expr` 클래스는 `private` 생성자를 가진다.(코틀린 1.5 이전 버전 기준)

생성자는 클래스 내부에서만 호출할 수 있다.

  • `sealed interface` 를 정의할 수 없다.(코틀린 1.5 이전 버전 기준)

`sealed interface` 를 만들면, 그 `interface` 를 자바 쪽에서 구현하지 못하게 막을 수 있는 수단이 코틀린 컴파일러에게는 없기 때문이다. 

코틀린 1.5 버전 이후에서 sealed clas

  • `sealed class` 는 자동으로 `abstract` 클래스가 된다.
  • `sealed class` 뿐 아니라 `sealed interface` 도 사용할 수 있다.
sealed interface ExprInterface {
    class Num(val value: Int) : ExprInterface // ()가 없다.
    class Sum(val left: ExprInterface, val right: ExprInterface) : ExprInterface
}

fun evaluate(e: ExprInterface): Int =
    when (e) {
        is ExprInterface.Num -> e.value
        is ExprInterface.Sum -> evaluate(e.left) + evaluate(e.right)
    }
  •  `protected` 생성자와 `private` 생성자를 가질 수 있으며 디폴트로는 `protected` 생성자가 된다.
sealed class IOError {
    constructor() { /*...*/ } // protected by default
    private constructor(description: String): this() { /*...*/ } // private is OK
    public constructor(code: Int): this() {} // Error: public and internal are not allowed
}
  • 코틀린 1.5 버전 이후부터는 꼭 `sealed class` 내부가 아닌 곳에서도 그 `sealed class` 를 상속받을 수 있다.
    • 그 `sealed class` 가 정의된 같은 모듈, 패키지의 최상위에서 가능
    • 그 `sealed class` 가 정의된 같은 모듈, 패키지의 다른 named 클래스, named 객체, named 인터페이스 내부 위치에서 가능(익명 클래스 등 에는 불가능)

아래 코드를 통해 확인해보자 `Expr` 이 선언된 패키지의 위치는 `inaction.chap4.sealed.Expr` 이다.

 

다른 패키지에서 상속받으면 컴파일 에러

sealed class Expr 가 정의된 패키지와 다른 패키지에서 Expr 를 상속받을 때

같은 모듈, 패키지에서는 상속이 가능

package inaction.chap4.sealed

// 같은 패키지에서 Expr 를 확장
class ExtendExprInSamePackage(val someField1: Int) : Expr() {}

// 같은 패키지의 어떤 클래스 내부에서 Expr 를 확장
class SomeClass(val someField2: Int) {
    class ExtendExprInSomeClass(val someField3: Int) : Expr() {}
}

sealed 와 enum 비교

언뜻 `sealed class` 는 `enum` 과 비슷해보인다.

하지만 분명히 다르다.

`enum` 타입에 대한 값 집합 또한 제한되어 있지만, 각 `enum` 상수들은 오직 Single instance 로 존재한다.

반면, `sealed class` 의 subclass 는 여러 instance 를 가질 수 있다.

참고로 `enum class` 는 `sealed class` 를 확장할 수 없지만, `sealed interface` 는 구현할 수 있다.

Direct subclass 와 Indirect subclass

Direct subclass 라는 것은 어떤 클래스를 바로 아래 계층에서 상속한 하위 클래스를 말한다.

`A` 가 `B` 를 상속받고 `C` 가 `B` 를 상속받는다면 `B` 는 `A` 의 Direct subclass 이고, `C` 는 `A` 의 Direct subclass 가 아닌 Indirect subclass 이다.

 

위에서 계속 `sealed class` 를 확장할 수 있는 위치에 대해 설명했다. 이러한 제한은 오직 Direct subclass 일 때만 해당한다.

`sealed class` 의 Direct subclass 가 `sealed` 가 아니면, access modifier(`open` 같은 상속 제어 변경자) 가 허용하는 한 어디서든 확장이 가능하다.

 

코드로 확인해보자

package inaction.chap4.sealed

sealed interface Error

sealed class IOError(): Error // 같은 모듈, 패키지에서 확장 가능
open class CustomError(): Error // 어디든 확장 가능

`IOError` 자체가 sealed class 이기 때문에 이 위치에서 확장이 불가능하다.

하지만 `CustomError` 는 sealed interface 인 `Error` 를 구현하고 있지만, `CustomError` 자체는 sealed 가 아니기 때문에 이 위체에서 확장할 수 있다.

라이브러리를 만들 때 sealed 사용

우리가 라이브러리의 API 를 만든다고 고려해보자.

그 라이브러리 사용자가 던질 수 있는 에러를 다룰 수 있도록 에러에 대한 클래스를 포함하고 있다고 하자.

만약 각 error 클래스들의 계층 구조가 일반 `interface` 나 `추상 class` 를 public API 에서 가진다면, 누구든 client code 에서 그것들을 구현하거나 확장할 수 있다.

 

하지만, `sealed class/interface` 를 사용한다고 해보자.

sealed interface Error

sealed class IOError(): Error

class FileReadError(val file: File): IOError()
class DatabaseError(val source: DataSource): IOError()

object RuntimeError : Error

그렇다면 해당 클래스/인터페이스의 확장/구현은 같은 모듈, 패키지 내에서만 가능하다.

즉, `Error` 의 `sealed` 계층 구조를 통해 라이브러리 작성자는 사용 가능한 모든 Error 의 타입들을 알고 있으면서, 추후에 라이브러리 사용자가 다른 타입의 Error 를 추가하지 못하게 할 수 있다.

 

sealed class 가 사용되는 케이스를 아래에서 더 확인해보자.

여기서부터 아래 내용은 Effective kotlin 의 내용을 참조한 내용이다.

태그 클래스보다는 클래스 계층을 사용하라

tagged class(태그 클래스)

먼저 태그 클래스가 무엇인지부터 알아야 한다.

tagged class(태그 클래스)는 태그를 포함하는 클래스를 말한다.

tag(태그)는 constant(상수) 모드이며 이러한 것은 보통 큰 규모의 프로젝트에 존재한다.

 

바로 태그 클래스의 예시를 코드로 보자. 

아래 코드는 테스트에 사용되는 클래스이며, 어떤 값이 기준에 만족하는지 확인하기 위해 사용한하는 클래스이다. 

 

태그 클래스를 사용한 `ValueMatcher`

class ValueMatcher<T> private constructor(
    private val value: T? = null,
    private val matcher: Matcher,
) {

    fun match(value: T) = when (matcher) {
        Matcher.EQUAL -> value == this.value
        Matcher.NOT_EQUAL -> value != this.value
        Matcher.LIST_EMPTY -> value is List<*> && value.isEmpty()
        Matcher.LIST_NOT_EMPTY -> value is List<*> && value.isNotEmpty()
    }

    enum class Matcher {
        EQUAL,
        NOT_EQUAL,
        LIST_EMPTY,
        LIST_NOT_EMPTY
    }

    // ValueMatcher 객체를 만드는 팩토리 메서드
    companion object {
        fun <T> equal(value: T) = ValueMatcher(value = value, matcher = Matcher.EQUAL)
        fun <T> notEqual(value: T) = ValueMatcher(value = value, matcher = Matcher.NOT_EQUAL)
        fun <T> emptyList() = ValueMatcher<T>(matcher = Matcher.LIST_EMPTY)
        fun <T> notEmptyList() = ValueMatcher<T>(matcher = Matcher.LIST_NOT_EMPTY)
    }
}

실제 큰 규모의 프로젝트에서 일부를 발췌한 것이다.

실제로는 훨씬 더 많은 모드를 가지고 있다.

 

`ValueMatcher` 의 간단한 테스트 `ValueMatcherTest

class ValueMatcherTest {

    @Test
    fun testEqualMatcher() {
        val valueMatcher = ValueMatcher.equal(5)
        assertTrue(valueMatcher.match(5))
    }

    //...
    @Test
    fun testEmptyListMatcher() {
        val valueMatcher = ValueMatcher.emptyList<List<Int>>()
        assertTrue(valueMatcher.match(listOf()))
    }
    
    // ...
}

그런데 위처럼 태그 클래스를 사용하는 방법에는 많은 단점이 존재한다.

  • 한 클래스에 여러 모드를 처리하기 위한 boilerplate 가 추가된다.
  • 여러 모드에 대해 사용하므로 프로퍼티가 일관적이지 않게 사용되며, 더 많은 프로퍼티가 필요하다.
    • 위 예제에서는 팩토리메서드 중 `emptyList()` 와 `notEmptyList()` 는 `value` 가 아예 사용되지 않는다. 
  • 컴포넌트가 여러 목적을 가지고, 컴포넌트가 여러 방법으로 설정될 수 있다.
    이 때는 상태의 일관성과 정확성을 지키기 어렵다.
    • 위 예제의 `emptyList()` 와 `notEmptyList()` 는 `value` 가 아예 사용되지 않는다. 
      이 경우에는 `value` 가 null 이어야 한다.
      즉, `value` 를 nullable 로 설정할 수 밖에 없다.
  • 어러 팩토리 메서드를 사용해야 하는 경우가 많다.

그렇다면 이러한 단점들을 해소하는 방법은 무엇일까?

sealed  로 리팩토링

코틀린에서는 일반적으로 태그 클래스보다 sealed class 를 많이 사용한다.

한 클래스에 여러 모드를 만드는 방법 대신에, 각각의 모드를 여러 클래스로 만들고 타입 시스템과 다형성을 활용할 수 있다!

그리고 sealed 를 사용하여 서브클래스를 제한한다.

 

`sealed` 를 사용하여 리팩토링한 `ValueMatcher`

sealed interface ValueMatcher<T> {
    fun match(value: T): Boolean

    class Equal<T>(val value: T) : ValueMatcher<T> {
        override fun match(value: T): Boolean = value == this.value
    }

    class NotEqual<T>(val value: T) : ValueMatcher<T> {
        override fun match(value: T): Boolean = value != this.value
    }

    class EmptyList<T>(val value: T) : ValueMatcher<T> {
        override fun match(value: T): Boolean = value is List<*> && value.isEmpty()
    }

    class NotEmptyList<T>(val value: T) : ValueMatcher<T> {
        override fun match(value: T): Boolean = value is List<*> && value.isNotEmpty()
    }
}

이전에는 한 클래스 안에 많은 팩토리 메서드들을 가지면서 여러 책임을 가졌지만, 리팩토링 후 책임이 분산되어 훨씬 깔끔해졌다.

 

리팩토링한 `ValueMatcher` 의 테스트 `ValueMatcherTest`

class ValueMatcherTest {
    @Test
    fun testEqualMatcher() {
        val valueMatcher = ValueMatcher.Equal(5)
        assertTrue(valueMatcher.match(5))
    }
    //...
    @Test
    fun testEmptyListMatcher() {
        val valueMatcher = ValueMatcher.EmptyList(listOf<Int>())
        assertTrue(valueMatcher.match(listOf()))
    }
}

이제 각 객체들은 자신에게 필요한 데이터만 있으며, 적절한 파라미터만 갖는다.

 

이렇게 태그 클래스의 단점을 모두 해소할 수 있다.

 

이제 만약 생성된 `ValueMatcher` 객체를 반대 모드의 `ValueMatcher` 로 만드는 기능이 필요하다고 해보자.

sealed 로 구현한 `ValueMatcher` 에서는 아래처럼 확장 함수를 하나 구현하면 된다.

 

리팩토링 후의 `ValueMatcher` 에 확장함수 `reversed` 추가

fun <T> ValueMatcher<T>.reversed(): ValueMatcher<T> = when (this) {
    is ValueMatcher.Equal -> ValueMatcher.NotEqual(value)
    is ValueMatcher.NotEqual -> ValueMatcher.Equal(value)
    is ValueMatcher.EmptyList -> ValueMatcher.NotEmptyList(value)
    is ValueMatcher.NotEmptyList -> ValueMatcher.EmptyList(value)
}

@Test
fun testReversed(){
    val valueMatcher = ValueMatcher.Equal(5)
    val result = valueMatcher.reversed()
    assert(result is ValueMatcher.NotEqual)
}

리팩토링 전, 태그 클래스 `ValueMatcher` 에서는 아래처럼 할 수 있을 것이다.

 

리팩토링 전의 `ValueMatcher` 에 확장 함수 `reversed` 추가

class ValueMatcher<T> private constructor(
    val value: T? = null,
    val matcher: Matcher,
) { /* ... */ }

fun <T> ValueMatcher<T>.reversed(): ValueMatcher<T?> = when (this.matcher) {
    ValueMatcher.Matcher.EQUAL -> ValueMatcher.notEqual(this.value)
    ValueMatcher.Matcher.NOT_EQUAL -> ValueMatcher.equal(this.value)
    ValueMatcher.Matcher.LIST_EMPTY -> ValueMatcher.notEmptyList()
    ValueMatcher.Matcher.LIST_NOT_EMPTY -> ValueMatcher.emptyList()
}

@Test
fun testReversed() {
    val valueMatcher = ValueMatcher.equal(5)
    val result = valueMatcher.reversed()
    assert(result.matcher == ValueMatcher.Matcher.NOT_EQUAL)
}

제대로 동작은 한다.

하지만 `reversed()` 확장 함수를 추가하는 과정에서 private 이었던 `value`, `matcher` 프로퍼티가 public 이 될 수밖에 없어진다.

또한 구현이 약간 더 어려웠다. 

실제로 내가 `reversed()` 확장 함수를 직접 구현하면서 리턴 타입이 `ValueMatcher<T>`  가 아닌 `ValueMatcher<T?>` 여야 한다는 점 때문에 조금 헤맸다...

Tagged class(태그 클래스) VS State pattern(상태 패턴)

Tagged class(태그 클래스)와 State pattern(상태 패턴)은 다르다.

상태 패턴은 객체의 내부 상태가 변화할 때, 객체의 동작이 바뀌는 소프트웨어 디자인 패턴이며 MVC, MVP, MVVM 아키텍처에서 많이 사용된다.

상태 패턴은 아래와 같은 특징을 가진다.

  • 객체의 상태에 따라 동적으로 행동이 변화한다
  • 상태를 나타내는 클래스들이 있으며, 이들은 상태에 따라 다른 행동을 정의한다
  • 상태 객체는 변경 가능하며, 객체의 상태 변화에 따라 교체된다
  • 주로 상태가 여러 개 있고, 각 상태마다 다른 행동을 정의해야 할 때 사용된다

상태 패턴에 대한 간단한 예시를 위해 아침 운동을 위한 앱에서 운동 전 준비 상태, 운동 중 상태, 운동 끝 상태를 코드로 구현한다고 해보자.

// model 부분 --------------------------------
class Exercise(val name: String) // 운동에 대한 정보를 포함하는 클래스

sealed class WorkoutState
class PrepareState(val exercise: Exercise) : WorkoutState()
class ExerciseState(val exercise: Exercise) : WorkoutState()
data object DoneState : WorkoutState()  // data 키워드 제거 (object 자체가 싱글톤임)

fun List<Exercise>.toStates(): List<WorkoutState> =
    this.flatMap { exercise ->
        listOf(PrepareState(exercise), ExerciseState(exercise))
    } + DoneState

// presenter 부분 --------------------------------
class WorkoutPresenter(private val states: List<WorkoutState>) {
    private var currentStateIndex = 0

    // 현재 상태를 반환하고, 다음 상태로 이동
    fun getCurrentStateAndMoveToNext(): WorkoutState {
        val currentState = states.getOrElse(currentStateIndex) { DoneState }
        currentStateIndex++
        return currentState
    }

    // 다른 로직들...
}

// view 부분 --------------------------------

class ExerciseView(private val presenter: WorkoutPresenter) {
    private var state: WorkoutState by Delegates.observable<WorkoutState>(DoneState) { _, _, newState ->
        updateView(newState)
    }

    init {
        state = presenter.getCurrentStateAndMoveToNext()  // 초기 상태 설정
    }

    // 상태에 따른 뷰 업데이트 로직
    private fun updateView(newState: WorkoutState) {
        when (newState) {
            is PrepareState -> println("준비: ${newState.exercise.name}")
            is ExerciseState -> println("운동 중: ${newState.exercise.name}")
            is DoneState -> println("운동 끝")
        }
    }

    // 다음 상태로 이동
    fun moveToNextState() {
        state = presenter.getCurrentStateAndMoveToNext()
    }
}

상태 패턴에서 구현체 상태(`PrepareState` 등)는 객체를 활용해서 표현하는 것이 일반적이며, 보통 sealed 클래스 계층으로 만든다.

또한 이 `state` 를 immutable 객체로 만들고 변경할 때마다 `state` 프로퍼티를 변경한다. 

View 에서는 이러한 `state` 의 변화를 observing(관찰) 한다.

 

참고로 위에서 구현한 sealed 를 사용하던(리팩토링 후의) `ValueMatcher` 는 상태 패턴의 일부 개념을 사용하지만 상태 패턴은 아니다. 

  • `ValueMatcher` 에서는 객체의 상태 변화에 따른 동적인 행동 변화보다는, 다양한 매칭 조건을 표현하는 데 중점을 두고 있으다.
  • `ValueMatcher` 의 인스턴스는 상태 변화를 위해 교체되는 것이 아니라, 특정 조건에 따른 매칭 로직을 정의한다.

 

참조

https://kotlinlang.org/docs/sealed-classes.html#sealed-classes-and-when-expression