iOS & Swift

Swift 비지터 패턴(Visitor Pattern), 더블 디스패치가 필요한 순간

iOS 개발하다 보면 도형이든 노드든, 종류가 여러 개인 객체에 연산을 계속 얹어야 하는 순간이 옵니다.

이석우iOS Developer5분 읽기
Swift 비지터 패턴(Visitor Pattern), 더블 디스패치가 필요한 순간 대표 이미지

iOS 개발하다 보면 도형이든 노드든, 종류가 여러 개인 객체에 연산을 계속 얹어야 하는 순간이 옵니다.

흔히 if let, as?로 타입을 하나씩 까보는 코드를 짜게 되는데, 이런 코드는 종류가 늘 때마다 그 분기문을 또 열어야 합니다.

이 글은 바로 그럴 때 등장하는 Swift 비지터 패턴, 그리고 그 핵심인 더블 디스패치가 정확히 언제 필요한지 예제로 정리한 글입니다.

Swift는 메서드를 고를 때 리시버(메시지를 받는 객체)의 동적 타입 하나만 봅니다.

“어떤 도형인가”와 “어떤 연산인가”, 두 축이 동시에 바뀌어야 할 때 이 단일 디스패치만으론 부족합니다. 이때 두 번의 호출로 두 타입을 모두 런타임에 결정하는 게 더블 디스패치예요.

Swift 비지터 패턴, 결국 이 두 번의 호출 이야기입니다
Swift 비지터 패턴, 결국 이 두 번의 호출 이야기입니다

Swift의 메서드 디스패치, 왜 하나로는 부족할까?

Swift에서 shape.draw()를 부르면, shape의 실제 타입에 맞는 draw()가 실행됩니다.

여기까지는 단일 디스패치예요. 리시버 타입 하나로 메서드를 고르는 거죠.

문제는 연산이 여러 개일 때 생깁니다.

도형은 원·사각형·삼각형, 연산은 그리기·넓이계산·JSON내보내기. 이렇게 두 축이 곱해지면 조합이 확 늘어납니다.

그래서 흔히 이런 코드를 쓰게 돼요.

// 도형마다 연산이 늘 때 if-else가 계속 자라납니다
func export(_ shape: Shape) -> String {
    if let c = shape as? Circle { return drawCircle(c) }
    if let r = shape as? Rect   { return drawRect(r) }
    return "" // 새 도형이 생길 때마다 여기를 또 고쳐야 해요
}

새 도형이 하나 추가되면요? 이 함수를 열어서 분기를 또 끼워 넣어야 합니다.

연산이 다섯 개면 이런 함수가 다섯 군데 있는 셈이고요.

if-else가 이만큼 쌓이면 저는 신호로 봐요
if-else가 이만큼 쌓이면 저는 신호로 봐요

더블 디스패치가 필요한 순간은 언제일까?

제가 세운 기준은 단순합니다.

메서드가 실행될 동작이 두 개의 타입에 동시에 의존할 때, 그때가 더블 디스패치가 필요한 순간이에요.

앞의 예시가 딱 그렇습니다. 실행할 코드가 “도형 타입 × 연산 타입” 둘 다에 따라 달라지잖아요.

Swift의 기본 디스패치는 리시버 타입 하나만 보기 때문에, 나머지 한 축은 결국 as? 같은 타입 검사로 손수 처리하게 됩니다.

이 수동 분기가 바로 냄새(code smell)예요.

더블 디스패치는 이 두 번째 타입 결정마저 언어의 오버로딩에 맡깁니다. 개발자가 직접 타입을 까보지 않게 되는 거죠.

반대로 연산이 딱 하나거나, 타입이 앞으로 늘 일이 없다면 굳이 이 패턴을 꺼낼 필요는 없습니다.


Swift 비지터 패턴, 어떻게 구현하나요?

핵심은 호출을 두 번 나누는 겁니다. 그래서 이름도 더블 디스패치예요.

protocol Shape { func accept(_ v: Visitor) -> String }
struct Circle: Shape { func accept(_ v: Visitor) -> String { v.visit(self) } }
struct Rect: Shape   { func accept(_ v: Visitor) -> String { v.visit(self) } }

protocol Visitor {
    func visit(_ c: Circle) -> String   // 여기서 도형 타입이 결정됩니다
    func visit(_ r: Rect) -> String      // 오버로딩으로 연산이 결정되고요
}

흐름을 따라가 볼게요.

첫 번째 호출 shape.accept(v)에서 도형의 실제 타입이 정해집니다. Circleaccept가 불리는지, Rect의 것이 불리는지 런타임이 골라주죠.

두 번째 호출 v.visit(self)에서 self의 정적 타입이 이미 Circle이나 Rect로 확정돼 있어요. 그래서 오버로딩된 visit이 올바르게 선택됩니다.

이 두 번의 호출이 겹치면서 두 타입이 모두 정확히 결정되는 겁니다.

두 번의 호출이 겹치는 지점, 더블 디스패치
두 번의 호출이 겹치는 지점, 더블 디스패치

새 연산을 추가하고 싶으면요? Visitor를 채택한 구조체를 하나 새로 만들면 끝입니다. 기존 도형 코드는 한 줄도 안 건드려요.


비지터 패턴, 쓸까 말까? 장단점 비교

좋기만 한 패턴은 없죠. 트레이드오프를 짚고 가겠습니다.

구분 타입 분기(as? / switch) 비지터 패턴
새 연산 추가 곳곳의 분기문 수정 새 Visitor 하나 추가
새 타입 추가 분기 한 줄 추가 모든 Visitor 수정 필요
코드 가독성 분기 늘면 복잡 연산별로 깔끔히 분리
초기 작성 비용 낮음 상대적으로 높음

표에서 보이듯 비지터 패턴은 연산이 자주 늘고 타입은 안정적일 때 가장 빛납니다.

반대로 타입(도형)이 계속 추가되는 구조라면 오히려 손이 더 갑니다. 모든 Visitor를 다 고쳐야 하니까요.

accept 하고 visit 하고, 딱 두 번이면 끝납니다
accept 하고 visit 하고, 딱 두 번이면 끝납니다

자주 묻는 질문 (Q&A)

Q. Swift에는 제네릭이 있는데 그래도 비지터가 필요한가요?

제네릭은 컴파일 타임에 타입이 정해질 때 강력해요. 하지만 [Shape] 배열처럼 런타임에 실제 타입이 섞여 있을 때는 여전히 더블 디스패치가 제 역할을 합니다.

Q. enum과 switch로도 되지 않나요?

됩니다. 타입이 고정적이라면 enum + switch가 오히려 간결해요. 비지터는 연산이 계속 늘어나는 상황에 더 어울립니다.


두 타입에 동시에 의존하는 동작을 만날 때가 바로 더블 디스패치, 그리고 Swift 비지터 패턴을 떠올릴 순간입니다.

지금 as? 분기가 자꾸 늘어나는 코드가 있다면, 오늘 예제를 참고해 한 번 리팩터링해 보세요. 훨씬 손볼 맛 나는 구조가 될 거예요 🙂

함께 보면 좋은 글