본문으로 바로가기

[Design pattern] Visitor pattern

category 개발이야기/Kotlin 2022. 12. 27. 21:52
반응형

Jetpack Composable internal 책을 보던중에 compiler의 checker들은 (정적검사를 하는) visitor pattern으로 동작한다라는 문구가 나옵니다. Compose 얘기를 여기서 할건 아니지만 visitor pattern에 대해서 한번 정리하고 갈까 합니다.

언제 쓰는것인가?

동작구조 분리하기 위해서 사용합니다. 이게 가장 명확한 정의이면서 추상적인 문구 입니다.
이렇게만 얘기하면 블로그를 쓰는 의미가 없기 때문에 예를 들어 설명하면, 어떤 클래스에서 특정 함수를 호출합니다. 이 어떤 클래스는 유사하게 몇개가 존재하고 이 몇개의 class 역시 각각의 특정 함수를 호출하게 됩니다. 특정함수를 호출하는건 맞지만 클래스별로 함수의 동작은 달라집니다. 즉 N개의 클래스(구조)와 N개의 함수(동작)가 존재할때 이를 분리시켜 작성하는 방법이 visitor pattern 입니다.
구조가 구현된 Element를 알고리즘을 구현한 Visitor가 방문하여 해당 Element에 맞는 algorithm을 수행합니다.
자, 이렇게 예시를 들어도 말로는 헷깔립니다. visitor pattern으로 변해가는 시나리오와 코드를 한번 보도록 하겠습니다.

Person 클래스 (person elements )

마블의 영웅들을 class로 만들어 보겠습니다.(제가 잘 사용하는...) 이들은 각각의 공격방법을 가지고 있고, 특화된 능력들이 있습니다. 일단 아이언맨과, 캡틴 아메리카 클래스를 만듭니다.

class IRonMan(val name: String = "Tony", val age: Int = 40) {
    fun attack() = "Suite on"
    fun feature() = "Can fly"
}

class CaptainAmerica(val name: String = "Steve", val age: Int = 40) {
    fun attack() = "Throw shield"
    fun feature() = "String body"
}

이 친구들의 공격하고 특성을 발휘하기 위해 main() 함수는 아래와 같이 만듭니다.

fun main() {
    val iRonman = IRonMan()
    val captainAmerica = CaptainAmerica()

    iRonman.apply {
        println(attack())
        println(feature())
    }
    captainAmerica.apply {
        println(attack())
        println(feature())
    }
}

Person interface의 생성: (Element interface의 생성)

동일한 함수를 호출하고 있습니다. 뭔가 반복이 일어나는것 같기도 하고, 어차피 둘이 동시에 공격해야 하므로 반복문을 쓰고 싶습니다. 이들은 다행이 사람들이네요. Person이란 대표 타이틀을 달아주고 이를 통해서 한번에 묶어 봅니다.
또한 동일한 동작을 하는 함수들을 부모로 올려 main() 함수에서 좀더 예쁘게 호출할수 있도록 만듭니다.

interface Person {
    fun attack(): String
    fun feature(): String
}

class IRonMan(val name: String = "Tony", val age: Int = 40) : Person {
    override fun attack() = "Suite on"
    override fun feature() = "Can fly"
}

class CaptainAmerica(val name: String = "Steve", val age: Int = 40) : Person {
    override fun attack() = "Throw shield"
    override fun feature() = "String body"
}

자 이렇게 되면 아래와 같이 main()함수 변경이 가능해 집니다.

fun main() {
    val people = listOf(IRonMan(), CaptainAmerica())
    
     people.forEach { 
        println(it.attack())
        println(it.feature())
    }
}

이정도 까지만 해도 잘 다듬기는 했지만, Person라는 속성에 attack()feature()라는 함수(algorithm)가 있는게 좀 불편합니다. 어벤져스의 시리즈가 계속될수록 무기와 특성들이 변경되고 추가되는데, 이때마다 Person라는 interface가 변경되어야 할수도 있고, 이를 상속받는 캡틴이나 아이언맨 class 역시 변경되어야 합니다.
사람이 바뀌는것도 아닌데, 계속 특징들이 변경되는건 SRP(Single Reponse Principle) 위반처럼 보입니다. 그래서 Person의 특징들은 Hero라는 interface로 빼내보겠습니다.

Hero Interface의 생성: (Visitor의 생성)

Hero라는 특성은 영웅이라는 특성에 따른 기술들을 정의하도록 합니다. 즉 Person에서 attack()feature()를 따로 분리시킵니다.

interface Person

class IRonMan(val name: String = "Tony", val age: Int = 40) : Person
class CaptainAmerica(val name: String = "Steve", val age: Int = 40) : Person

interface Hero {
    fun attack(person: Person): String
    fun feature(person: Person): String
}

이제 Person 객체에서는 특정동작을 하는 algorithm이 분리되었습니다. 그럼 이 속성을 가지게된 Hero라는 interface의 구현체를 아래와 같이 만듭니다.

class HeroAbility : Hero {
    override fun attack(person: Person): String {
        return when(person) {
            is IRonMan ->  "Suite on"
            is CaptainAmerica -> "Throw shield"
            else -> ""
        }
    }

    override fun feature(person: Person): String {
        return when(person) {
            is IRonMan -> "Can fly"
            is CaptainAmerica ->"Strong body"
            else -> ""
        }
    }
}

그리고 이를 호출하는 main() 함수를 다시 수정합니다.

fun main() {
    val people = listOf(IRonMan(), CaptainAmerica())
    val heroAbility = HeroAbility()

    people.forEach {
        heroAbility.attack(it)
        heroAbility.feature(it)
    }
}

이제 불필요한 함수들을 (Algorithm)을 Person에서 분리해 냄으로써 SRP를 유지할수 있습니다. Hero에 추가적인 기능이 생기거나, 기능이 변경되더라도 Person 객체는 변경되지 않습니다. 다만 Hero interface와 이를 구현한 HeroAbility 클래스에서만 변경이 발생합니다.

HeroAbility의 문제: (Visitor의 OCP 위반)

HeroAbility 클래스의 경우 when문을 이용하여 class에 대한 분기를 처리합니다. 이는 java의 경우 instanceof를 통해서 분기하는것과 동일한 역할을 수행합니다. (물론 kotlin의 is의 경우 smart cast까지 처리해 줍니다.)
또한 attack()feature()에 동일한 코드의 반복도 보입니다. (코드의 중복까지..)
디자인 패턴에서는 instanceof의 사용을 꺼립니다. polymorphism이라는 OOP의 장점을 이용했으나, 그걸 다시 상세 객체로 분리하는것 자체가 OOP의 장점을 제대로 활용하지 못하기 때문입니다. 언급한 두가지 문제는 아래와 같은 시나리오에서 좀더 명확하게 들어납니다.
추가요구사항: Person에 Hulk가 추가되고, 이 Hero에게 추가 무기 weapon() 함수가 추가 되었습니다.
아래와 같이 HeroAbility 코드를 변경합니다.

interface Hero {
    fun attack(person: Person): String
    fun feature(person: Person): String
    fun weapon(person: Person): String
}

class HeroAbility : Hero {
    override fun attack(person: Person): String {
        return when (person) {
            is IRonMan -> "Suite on"
            is CaptainAmerica -> "Throw shield"
            else -> "" //Hulk 구현 누락
        }
    }

    override fun feature(person: Person): String {
        return when (person) {
            is IRonMan -> "Can fly"
            is CaptainAmerica -> "Strong body"
            is Hulk -> "Monster body"
            else -> ""
        }
    }

    override fun weapon(person: Person): String {
        return when (person) {
            is IRonMan -> "Can fly"
            is CaptainAmerica -> "Strong body"
            else -> "" //Hulk 구현 누락
        }
    }
}
  • weapon() 함수가 추가되고 또 한번의 코드 중복이 발생
  • attack(), weapon()에는 Hulk를 누락했지만 compile 오류가 발생하지 않아, Human error 가능

Person(Element)Hero(Visitor)라는 이 두가지 동작은 분리했으나, 이제 Hero에서 지저분한 refactoring 대상이 되는 코드들이 발생합니다. 이제 이를 해결하기 위한 마지막 작업을 진행합니다.

HeroAbility를 각각의 class로 분리: (Concrete visitor의 구현)

대부분의 디자인 패턴에서는 instanceof를 없애고 중복 코드를 제거하기 위해서는 분기문을 class로 변경하는 작업을 진행합니다.visitor pattern이 아니더라도, 다른 여러 패턴들에서 항상 하는 작업들이죠. (물론 class의 개수가 늘어나게 되는 디자인 패턴의 공통적인 단점이 존재합니다.)
먼저 아래의 방법을 구상해 봅니다.

interface Hero {
    fun attack(ironMan: IRonMan): String
    fun feature(ironMan: IRonMan): String
    fun attack(captain: CaptainAmerica): String
    fun feature(captain: CaptainAmerica): String
}

class HeroAbility : Hero {
    override fun attack(ironMan: IRonMan) = "Suite on"
    override fun attack(captain: CaptainAmerica) = "Throw shield"
    override fun feature(ironMan: IRonMan) = "Can fly"
    override fun feature(captain: CaptainAmerica) = "Strong body"
}

Hero interface(Visitor)에 각각의 Person(Element)에 대응하는 함수를 정의 합니다. 이때 인자로는 Person interface가 아닌, 실제 객체를 받습니다. 문제는 이럴경우 main()함수에서 해당 함수를 호출할때 부모 type인 Person을 각 함수의 인자로 넘겨줄수 없다는 사실입니다.
즉 Person의 concrete한 객체를 넣어 호출해야 되기에 아래와 같이 compile 오류가 발생합니다.

Person type이 아닌 각 개별 타입이(concrete class) param으로 입력되어야함.

그렇다고 main()함수에서 intanceof를 이용하여 구현 class를 넘겨주는 형태를 다시 사용할수 없습니다. 만약 그리 한다면 instanceof의 위치만 바꾼격이 됩니다. 하지만 여러 디자인 패턴을 봐왔다면 refactoring의 hint를 얻을수 있습니다.

  1. Hero에서 제공하는 기능들을 (attack, feature)를 class로 각각 분리하여 이 두개의 동작을 코드로 분기하는것을 막고
  2. 각각의 Person을 구현한 concrete한 class 내부에서 hero의 함수를 호출하여 Class가 분기되는걸 막습니다.

먼저 1번작업을 수행합니다.

interface Hero { //각 Element를 인자로 받는 abstract function을 정의 
    fun ability(ironMan: IRonMan): String
    fun ability(captain: CaptainAmerica): String
}

class AttackAbility : Hero {
    override fun ability(ironMan: IRonMan) = "Suite on"
    override fun ability(captain: CaptainAmerica) = "Throw shield"
}

class FeatureAbility : Hero {
    override fun ability(ironMan: IRonMan) = "Can fly"
    override fun ability(captain: CaptainAmerica) = "Strong body"
}

각각의 기능(Algorithm)을 class로 분기 합니다. 각 class에는 Person(Element)를 인자로 받아 수행하는 함수를 각각 정의합니다.
다음으로 2번 작업을 수행합니다.

interface Person {
    fun action(hero: Hero): String
}

class IRonMan(val name: String = "Tony", val age: Int = 40) : Person {
    override fun action(hero: Hero) = hero.ability(this)
}

class CaptainAmerica(val name: String = "Steve", val age: Int = 40) : Person {
    override fun action(hero: Hero) = hero.ability(this)
}

Person(element) interface에 각각의 ability를 수행할수 있는 action() 이란 함수를 만듭니다. 이때 함수의 인자에는 실제 동작(algorithm)을 담고있는 객체(hero)가 담겨져서 넘어 옵니다. 각 Person의 concrete한 객체들에서는 hero.ability() 함수를 함수를 호출하면서 자신 (this)를 넘겨 주도록 합니다.
마지막으로 이를 호출하는 Main 함수를 포함한 위 코드들에 대한 전체 코드(visitor pattern을 구현한) 입니다.

fun main() {
    val people = listOf(IRonMan(), CaptainAmerica())
    val attackAbility = AttackAbility()
    val featureAbility = FeatureAbility()

    people.forEach {
        it.action(attackAbility)
        it.action(featureAbility)
    }
}

interface Person {
    fun action(hero: Hero): String
}

class IRonMan(val name: String = "Tony", val age: Int = 40) : Person {
    override fun action(hero: Hero) = hero.ability(this)
}

class CaptainAmerica(val name: String = "Steve", val age: Int = 40) : Person {
    override fun action(hero: Hero) = hero.ability(this)
}

interface Hero {
    fun ability(ironMan: IRonMan): String
    fun ability(captain: CaptainAmerica): String
}

class AttackAbility : Hero {
    override fun ability(ironMan: IRonMan) = "Suite on"
    override fun ability(captain: CaptainAmerica) = "Throw shield"
}

class FeatureAbility : Hero {
    override fun ability(ironMan: IRonMan) = "Can fly"
    override fun ability(captain: CaptainAmerica) = "Strong body"
}

장황하게 돌아오긴 했지만 위 코드가 전체 코드 입니다. SRPOCP를 유지하면서 분기문을 제거하고, instanceof (여기서는 when ~ is 구분을 전부 제거했습니다.

Visitor pattern class diagram

자! 먼저 위에서 생성한 코드의 class diagram은 아래와 같습니다.

실제 Visitor pattern의 Class diagram과 아래와 같습니다. 두개의 Diagram이 서로 mapping 되는지요?

visitor pattern의 class diagram

Summary

Visitor pattern은 구조를 나타내는 Element와 동작을 나타내는 Visitor를 분리하는 pattern 입니다. 클래스의 단일책임의 원칙(SRP)를 준수하면서 OCP(Open closed principle) 역시 만족할수 있습니다. 예를들어 위 예제에서 Hero의 weapon 기능이 추가된다면 Hero를 상속받는 WeaponAbility class를 새로이 생성하면 되고, Person이 추가된다면 역시 Person을 상속받는 객체를 추가하면 됩니다.
또한 이 pattern 사용시 코드가 일부 누락되더라도 compile time에 체크할수 있으므로 human error역시 줄일수 있습니다.
이 pattern은 Elements: Visitors = N:N 의 관계를 가질때 로직을 분리하고, 추가적인 분기없이 mapping하여 사용할수 있는 pattern 입니다. class diagram만으로는 다소 복잡해 보일수 있으나, 마지막에 정리된 최종 코드를 보면 정말 간단한 구조로 작성할수 있습니다.
사실 예제 하나만으로는 실제 본인이 개발하고 있는 코드나 시나리오에 대입하여 사용하기 어려울수 있습니다. 다만 새로운 기술도 아니고, 이미 여러곳에 알게 모르게 적용되어 사용되고 있습니다. (서문에 언급했듯이 Jetpack compose의 static checker는 visitor pattern으로 구현되어 있습니다.)
좀더 다양한 상황에 대한 적응을 위하여 커피머신 예제를 하나 더 만들까 생각했습니다만, 여기서 문제제기와 class diagram 해놓고 마무리하려 합니다. (생각보다 정리하는데 시간이 많이 걸렸네요.)
시나리오

커피숍에 두대의 커피 머신이 있다. 이 커피 머신들은 아메리카노, 라떼를 만들수 있는 기능이 각각 있으나, 각 기계별로 아메리카노에 들어가는 원두량이 조금씩 다르고, 라떼는 우유와 커피를 배합하는 방법이 다르다.

또한 각 커피머신은 SW download를 통해 추후 여러 커피 만드는 기능이 추가될 수 있다. e.g. 카푸치노, 헤이즐넛등등.

구조의 정의

Elements: 커피머신A, 커피머신B....
Visitor: 아메리카노, 라떼, 카푸치노, 해이즐럿..

이 그림이 떠올랐다면 충분히 코드도 작성할수 있을리라고 봅니다. 코드는 생략하도록 하겠습니다.

반응형