본문 바로가기
ETC

Double Dispatch를 통해 유연하게 확장하기

by devson 2022. 2. 10.

(해당 내용은 토비님의 유튜브영상을 정리한 것임을 알립니다)

 

GoF Design Pattern 에 행동 관련(Behavioral) 패턴 중 Visitor Pattern이 있는데

이 패턴을 알기 위해서 Double Dispatch 기법을 알면 보다 쉽게 이해할 수 있다.

(사실 상 Visitor Pattern의 기반이 Double Dispatch 기법이다)

 

Double Dispatch 기법은 말 그대로 디스패치를 두 번 한다는 것인데,

다형성을 통한 유연성은 높이되 각 구현체에 대해 특화된 로직을 적용해야하는 경우에 사용할 수 있다.

코드로 Double Dispatch에 대해 알아보자.

 

예제 코드

예제는 SNSPost를 올리는 간단한 코드이다.

interface SNS {
    fun getName(): String
}

class Facebook : SNS {
    override fun getName(): String = "Facebook"
}

class Twitter : SNS {
    override fun getName(): String = "Twitter"
}

 

package com.tistory.devs0n.visitor.doubledispatch

interface Post {
    fun postOn(sns: SNS)
}

class Text : Post {
    override fun postOn(sns: SNS) {
        println("Text is posted on ${sns.getName()}")
    }
}

class Picture : Post {
    override fun postOn(sns: SNS) {
        println("Picture is posted on ${sns.getName()}")
    }
}

 

SNSPost class를 사용하여 SNSPost를 올리는 Client 코드는 아래와 같이 짤 수 있다.

fun main() {
    val snsList = listOf<SNS>(Facebook(), Twitter())
    val posts = listOf<Post>(Text(), Picture())

    posts.forEach { post ->
        snsList.forEach { sns -> post.postOn(sns) }
    }
}

 

현재는 SNSPost를 추상화를 하였고

Post에서 추상화된 SNS 객체를 사용하여 구현체의 타입에 상관없이 동일한 처리를 하도록 하였다.

 

분기문을 통한 인터페이스 타입 별 로직 처리

하지만 나중에 시스템이 복잡해져 Post 객체가 SNS의 타입에 따라 다른 처리가 필요한 경우가 생길 수 있다.

그러면 이를 어떻게 처리하면 좋을까?

가장 원시적인 방법은 분기문을 통해 처리하는 것이다.

package com.tistory.devs0n.visitor.doubledispatch

interface Post {
    fun postOn(sns: SNS)
}

class Text : Post {
    override fun postOn(sns: SNS) {
//        println("Text is posted on ${sns.getName()}")

        when (sns) {
            is Facebook -> {
                // do Facebook process
                println("Text is posted on ${sns.getName()}")
            }
            is Twitter -> {
                // do Twitter process
                println("Text is posted on ${sns.getName()}")
            }
        }
    }
}

class Picture : Post {
    override fun postOn(sns: SNS) {
//        println("Picture is posted on ${sns.getName()}")

        when (sns) {
            is Facebook -> {
                // do Facebook process
                println("Picture is posted on ${sns.getName()}")
            }

            is Twitter -> {
                // do Twitter process
                println("Picture is posted on ${sns.getName()}")
            }
        }
    }
}

 

지금은 지원하는 SNS가 2개 뿐이기 때문에 코드 자체는 어렵지 않다.

하지만 Instagram 등 다른 SNS를 새로 지원하게 된다면 어떻게 될까?

그러면 분기문에 추가하면 되긴하지만 문제는 추가를 빼먹을 수 있다는 것이다.

즉, 휴먼에러의 가능성이 있다는 것이다.

 

구현체를 직접 받아 인터페이스 타입 별 로직 처리

그러면 분기로 처리하기 보다 SNS 타입 별로 메서드를 만들어 처리할 수 있도록 해보는건 어떨까?

기존에 SNS 인터페이스를 메서드의 인자로 받아 처리하던 코드를 아래와 같이 각 구현체 별로 처리할 수 있도록 해보자.

package com.tistory.devs0n.visitor.doubledispatch

interface Post {
//    fun postOn(sns: SNS)

    fun postOn(facebook: Facebook)

    fun postOn(twitter: Twitter)
}

class Text : Post {
    override fun postOn(facebook: Facebook) {
        // do Facebook process
        println("Text is posted on ${facebook.getName()}")
    }

    override fun postOn(twitter: Twitter) {
        // do Twitter process
        println("Text is posted on ${twitter.getName()}")
    }
}

class Picture : Post {
    override fun postOn(facebook: Facebook) {
        // do Facebook process
        println("Picture is posted on ${facebook.getName()}")
    }

    override fun postOn(twitter: Twitter) {
        // do Twitter process
        println("Picture is posted on ${twitter.getName()}")
    }
}

 

SNS 인터페이스의 구현체는 Facebook, Twitter 두 개가 있으니 이 구현체를 처리할 수 있도록 Post#postOn 메서드도 두 개 만들었다.

그러니 문제없이 동작할 것 같다라고 생각할 수 있지만, 기존 Client 코드 기준으로 위와 같이 구현체를 처리하도록 하면 코드는 컴파일 에러를 뱉는다.

postOn메서드는 구체적인 타입인 FacebookTwitter를 인자로 받도록 하였지만 인자에 들어온 값은 그 상위 타입인 SNS이다.

postOn(sns: SNS)postOn(facebook: Facebook), postOn(sns: SNS)postOn(sns: SNS)는 엄연히 다른 메서드 시그니처이다.

그렇기 때문에 위 컴파일 에러는 SNS에 해당하는 메서드 시그니처가 없기 때문에 나는 컴파일 에러이다.

 

(Clojure와 같은 dynamic dispatch를 지원하는 언어는 위 코드가 문제없이 동작한다고한다)

 

Double Dispatch를 통해 인터페이스 타입 별 로직 처리

위 문제를 해결하기 위해 조금 다르게 접근할 필요가 있다.

Post이 처리하던 로직을 SNS가 처리하도록 위임해주는 것이다.

 

아래와 같이 SNSPost 인터페이스를 구현체를 처리하도록 코드를 추가해주자.

package com.tistory.devs0n.visitor.doubledispatch

interface SNS {
    fun getName(): String

    fun post(text: Text)

    fun post(picture: Picture)
}

class Facebook : SNS {
    override fun getName(): String = "Facebook"

    override fun post(text: Text) {
        // Facebook Text process
        println("Text is posted on ${this.getName()}")
    }

    override fun post(picture: Picture) {
        // Facebook Picture process
        println("Picture is posted on ${this.getName()}")
    }
}

class Twitter : SNS {
    override fun getName(): String = "Twitter"

    override fun post(text: Text) {
        // Twitter Text process
        println("Text is posted on ${this.getName()}")
    }

    override fun post(picture: Picture) {
        // Twitter Picture process
        println("Picture is posted on ${this.getName()}")
    }
}

 

그리고 Post는 다시 SNS 인터페이스를 인자로 받는 메서드를 사용하도록 하고,

기존에 Post 내부에서 처리하던 postOn 로직을 인자인 SNS에 위임하도록 변경한다.

package com.tistory.devs0n.visitor.doubledispatch

interface Post {
    fun postOn(sns: SNS)

//    fun postOn(facebook: Facebook)
//
//    fun postOn(twitter: Twitter)
}

class Text : Post {
    override fun postOn(sns: SNS) {
//        println("Text is posted on ${sns.getName()}")
        sns.post(this)
    }
}

class Picture : Post {
    override fun postOn(sns: SNS) {
//        println("Picture is posted on ${sns.getName()}")
        sns.post(this)
    }
}

 

다시 Client 코드로 돌아오면 컴파일 오류가 나지 않고 동작도 잘 하는 것을 확인할 수 있다.

이런 식으로 Client -> Post -> SNS로 두 번의 디스패치가 일어나기 때문에 Double Dispatch라고 한다.

(Visitor Pattern에서 사용되는 용어를 사용하면 Client -> Element -> Visitor)

 

처음 Post 내부에서 분기문으로 SNS 타입에 따라 다르게 처리하던 코드를 돌이켜보면

새로운 구현체가 생겼을 때 이를 핸들링하는 코드를 빼먹을 수 있다는 큰 단점이 있었다.

물론 로깅 등을 통해 사후에 처리할 순 있겠지만 가장 좋은 방법은 컴파일 에러와 같이 강제화할 수 있는 수단이 있는 것이다.

Double Dispatch 기법은 객체 간의 관계를 더 복잡하게 만드는 것처럼 보일 수 있지만 OCP 관점에서 새로운 구현체를 추가했을 때 빛을 발한다.

 

예를 들어 서비스에 새로 Instagram을 지원해야한다고 하자.

그러면 아래와 같이 새로운 SNS를 하나 만들어 주면 된다.

package com.tistory.devs0n.visitor.doubledispatch

interface SNS {
    fun getName(): String

    fun post(text: Text)

    fun post(picture: Picture)
}

class Facebook : SNS {
    // ....
}

class Twitter : SNS {
    // ....
}

// New Implementation of SNS
class Instagram: SNS {
    override fun getName(): String = "Instagram"

    override fun post(text: Text) {
        // Instagram Text process
        println("Text is posted on ${this.getName()}")
    }

    override fun post(picture: Picture) {
        // Instagram Picture process
        println("Picture is posted on ${this.getName()}")
    }
}

 

Post#postOn변경없이 새로운 SNS를 처리할 수 있다.

package com.tistory.devs0n.visitor.doubledispatch

fun main() {
//    val snsList = listOf<SNS>(Facebook(), Twitter())
    val snsList = listOf<SNS>(Facebook(), Twitter(), Instagram()) // <== 새로운 SNS 추가
    val posts = listOf<Post>(Text(), Picture())

    posts.forEach { post ->
        snsList.forEach { sns -> post.postOn(sns) }
    }
}

 

지금과 같이 인터페이스의 각 구현체에 따라 특별한 로직을 적용해야하는 경우

Double Dispatch 기법을 적용하면 확장에 유연하고 안정적으로 대응할 수 있게된다.

 

Double Dispatch의 단점

하지만 좋은 점이 있으면 그에 따른 사이드 이펙트가 있기 마련이다.

Visitor Pattern의 용어를 사용해 설명을 하면 지금까지는 Visitor(SNS) 구현체가 추가되는 경우에 효과적인 것을 확인하였다.

하지만 반대로 Element(Post)의 구현체가 추가되는 경우 손을 봐주어야하는 것이 많다.

왜냐면 Visitor(SNS)에서는 Element(Post) 인터페이스가 아니라 인터페이스의 모든 구현체 처리하기 때문이다.

 

방금의 예제로 설명하자면 지금까지는 SNS 만 새로 추가되었는데, 서비스가 성장하다보면 Post도 Video 등이 추가될 수 있을 것이다.

그러면 이에 따른 변경의 여파는 어떻게 될까?

(새로운 Post 구현체도 각 SNS 별 특화 로직이 필요하다는 전제 하에) SNS 인터페이스와 모든 SNS 구현체이다.

 

interface SNS {
    fun getName(): String

    fun post(text: Text)

    fun post(picture: Picture)
    
    // 새로운 API
    fun post(video: Video)
}

class Facebook : SNS {
	override fun getName(): String = "Facebook"
    override fun post(text: Text) { /* ... */ }
    override fun post(picture: Picture) { /* ... */ }

    // 새로운 API
    override fun post(video: Video) {
    	// ...
    }
}

class Twitter : SNS {
	override fun getName(): String = "Twitter"
    override fun post(text: Text) { /* ... */ }
    override fun post(picture: Picture) { /* ... */ }

    // 새로운 API
    override fun post(video: Video) {
    	// ...
    }
}

class Instagram : SNS {
	override fun getName(): String = "Instagram"
    override fun post(text: Text) { /* ... */ }
    override fun post(picture: Picture) { /* ... */ }

    // 새로운 API
    override fun post(video: Video) {
    	// ...
    }
}

 

하지만 지원해야하는 경우의 수가 늘어나는 것이므로 피할 수 없는 부분이고,

적어도 분기로 처리하는 것 보다는 안전하게 확장이 가능한 방법이라고 생각한다.

 


 

지금까지 Double Dispatch에 대해 알아보았다.

Visitor Pattern을 학습할 때 Double Dispatch에 대해 먼저 파악한다면 Visitor Pattern의 실용성이 좀 더 와닿을 것이다.

댓글