본문 바로가기
Kotlin

[Effective Kotlin] 39 - 태그 클래스보다는 클래스 계층을 사용하라

by 매운돌 2023. 10. 22.

큰 규모의 프로젝트에서는 상수(constant) '모드'를 가진 클래스를 꽤 많이 볼 수 있습니다. 이러한 상수 모드를 태그(tag)라고 부르며, 태그를 포함한 클래스를 태그 클래스(tagged class)라고 부릅니다. 그런데 태그 클래스는 다양한 문제를 내포하고 있습니다. 이러한 문제는 서로 다른 책임을 한 클래스에 태그로 구분해서 넣는다는 것에서 시작합니다.

 

아래에 어떤 값이 기준에 만족하는지 확인하기 위해 사용되는 클래스를 볼 수 있습니다.

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_EQUA -> 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_EQUA,
        LIST_EMPTY,
        LIST_NOT_EMPTY
    }
    
    fun <T> equal(value: T) = 
    	ValueMatcher<T>(value = value, matcher = Matcher.EQUAL)
    
    fun <T> notEqual(value: T) = 
    	ValueMatcher<T>(value = value, matcher = Matcher.NOT_EQUAL)
    
    fun <T> equalList() = 
    	ValueMatcher<T>(matcher = Matcher.LIST_EMPTY)
    
    fun <T> notEmptyList() = 
    	ValueMatcher<T>(matcher = Matcher.LIST_NOT_EMPTY)
}

이러한 접근 방법에는 굉장히 단점이 많이 있습니다.

  • 한 클래스에 여러 모드를 처리하기 위한 사용구(boilerplate)가 추가됩니다.
  • 여러 목적으로 사용해야 하므로 프로퍼티가 일관적이지 않게 사용될 수 있으며, 더 많은 프로퍼티가 필요합니다.
    (예를 들어, 위의 예제에서 value는 모드가 LIST_EMPTY 또는 LIST_NOT_EMPTY일 때 아예 사용되지도 않습니다.)
  • 요소가 여러 목적을 가지고, 요소를 여러 방법으로 설정할 수 있는 경우에는 상태의 일관성과 정확성을 지키기 어렵습니다.
  • 팩토리 메서드를 사용해야 하는 경우가 많습니다. 그러지 않으면 객체가 제대로 생성되었는지 확인하는 것 자체가 굉장히 어렵습니다.

그래서 코틀린은 일반적으로 태크 클래스보다 sealed 클래스를 많이 사용합니다. 한 클래스에 여러 모드를 만드는 방법 대신에 각각의 모드를 여러 클래스로 만들고 타입 시스템과 다형성을 활용하는 것입니다. 그리고 이러한 클래스에는 sealed 한정자를 붙여서 서브클래스의 정의를 제한합니다.

sealed class ValueMatcher<T> {
    abstract 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()
    }
}

위와 같이 구현하면 책임이 분산되므로 훨씬 깔끔합니다. 각각의 객체들은 자신에게 필요한 데이터만 있으며, 적절한 파라미터만 갖습니다.

 

sealed 한정자

반드시 sealed 한정자를 사용해야 하는 것은 아닙니다. 대신 abstract 한정자를 사용할 수도 있지만, sealed 한정자는 외부 파일에서 서브 클래스를 만드는 행위 자체를 모두 제한합니다. 외부에서 추가적인 서브클래스 만들 수 없으므로, 타입이 추가되지 않을 거라는 게 보장됩니다. 따라서 when을 사용할 때 else브랜치를 따로 만들 필요가 없습니다. 이러한 장점을 이용해서 새로운 기능을 쉽게 추가할 수 있으며, when 구문에서 이를 처리하는 것을 잊어버리지 않게 됩니다.

 

when은 모드를 구분해서 다른 처리를 만들 때 굉장히 편리합니다. 예를 들어 어떤 처리를 각각의 서브클래스에 구현할 필요 없이 when을 활용하는 확장 함수로 정의하면 한번에 구현할 수 있습니다. 

아래 코드는 reversed라는 확장 함수를 하나만 정의해서, 클래스의 종류에 따라서 서로 다른 처리를 하게 만듭니다.

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

 

태그 클래스와 상태 패턴의 차이

태그 클래스와 상태 패턴을 혼동하면 안됩니다. 상태 패턴은 객체의 내부 상태가 변화할 때, 객체의 동작이 변하는 소프트웨어 디자인 패턴입니다. 상태 패턴을 사용한다면 서로 다은 상태를 나타내는 클래스 계층 구조를 만들게 됩니다. 그리고 현재 상태를 나타내기 위한 읽고 쓸 수 있는 프러퍼티도 만들게 됩니다.

sealed class WorkoutState

class PrepareState(val exercise: Exercise): WorkoutState()

class ExerciseState(val exercise: Exercise): WorkoutState()

object DoneState(): WorkoutState()

fun List<Exercise>.toStates(): List<WorkoutState> = 
    flatMap { exercise ->
        listOf(PrepareState(exercise), ExerciseSTate(exercise))
    } + DoneState
    
class WorkoutPresenter(/*...*/) {
	private var state: WorkoutState = states.first()
}

여기서 차이점은 다음과 같습니다.

  • 상태는 더 많은 책임을 가진 큰 클래스입니다.
  • 상태는 변경할 수 있습니다.

 

정리

코틀린에서는 태크 클래스보다 타입 계층을 사용하는 것이 좋습니다. 그리고 일반적으로 이러한 타입 계층을 만들 때는 sealed 클래스를 사용합니다. 이는 상태 패턴과 다릅니다.  타입 계층과 상태 패턴은 실질적으로 함께 사용하는 협력 관계라고 할 수 있습니다.