객체 구조(트리, 컬렉션 등)를 이루는 원소에 적용할 연산을 표현합니다.
연산을 보유하는 클래스를 방문자라고 하며, 객체 구조의 요소를 방문하여 연산을 실행합니다.
방문자는 추상화되어 있어 유연하게 변경가능합니다.
1️⃣ 동기
여러가지 요소들로 이루어진 특정 객체 구조에 적용할 연산을 추상화하고 싶을 때가 있습니다.
이 때 새로운 연산을 별도로 추가할 수 있지만, 추가된 연산에 상관없이 기존 코드들이 변형되지 않으면 좋을 것입니다.
객체 적용할 연산들을 하나로 묶어서 관리하는 객체를 해당 패턴에서 방문자라고 칭합니다.
새로운 연산을 원하는 경우 방문자 타입을 서브 클래싱하여 객체 구조에 주입하는 구조로 동작함으로써 기존 코드에 변형을 주지않고 유연하게 연산을 변경할 수 있습니다.
2️⃣ 활용성
- 각각의 특징이 있지만 관련되어 있지 않은 연산들이 하나의 객체에 존재하는 경우
- 객체 구조를 정의한 클래스는 거의 변하지 않지만 전체 구조에 적용할 연산을 유연하게 추가하고 싶은 경우
- 주의, 객체 구조가 자주 변경된다면 방문자 인터페이스 및 서브 클래스가 자주 변동됨으로 해당 패턴 사용이 적절하지 않습니다.
3️⃣ 패턴 참여자
- Visitor(방문자): Visit연산들을 구현합니다. 해당 연산은 보통 인자로 객체 구조의 요소들을 전달받습니다. 해당 요소의 타입 따라 각기다른 방문 함수를 구현함으로써 서로다른 타입들에 대한 연산을 각각 처리합니다.
- ConcreteVisitor: 구체적인 연산을 정의합니다.
- 방문자는 상태를 보유할 수 있습니다. 순회를 통해 값을 누적하는 것에 활용가능합니다.
- Element: 객체 구조를 이루는 요소의 인터페이스로 방문자의 접근을 허용하는 Accept(visitor)함수를 정의합니다.
- 해당 함수의 필요성은 이후 언급하겠습니다.
- ObjectStructure(객체 구조): 방문자가 자신의 요소들에 접근할 수 있는 인터페이스를 제공합니다.
4️⃣ 패턴 참여자 협력 방법
구체 방문자 타입은 객체 구조를 구성하는 모든 요소에 접근하여 accept연산을 호출합니다.
accept연산 내부에는 자신의 타입에 따라 알맞은 visit함수를 호출하는 코드를 구현하고 실질적인 연산은 방문자 내부에서 실행되도록 합니다.
accept함수를 호출하는 이유가 무엇인지?
아래 다이어그램을 보시면 대체 방식을 사용하지 않는 이유에 대해 궁금하실 것이라고 생각합니다.
이는 언어의 특성에 따라 달라집니다.
C++, Kotilin, Swift언어의 경우 단일 디스패치 방식을 사용하여 호출할 구체적인 함수를 결정합니다.
단일 디스패치란 매개변수로 다형적인 객체를 전달받는 경우 수신자의 다형성만 고려하지 인자의 실제 타입을 고려한 함수호출은 일어나지 않음을 의미합니다.
자, 말이 굉장히 어렵습니다. 코드로 한번 살펴보겠습니다.
아래 코드에서 호출되는 play 함수는 무엇일까요? 위말을 다시 살펴보면 수신자의 다형성만을 고려하여 호출함수를 선택하지 인자의 실제타입은 고려하지 않습니다.
따라서 호출되는 함수는 play(shape: Shape)가 되는 것입니다.
class Shape {
func play(shape: Shape) { ... }
func play(shape: Circle) { ... }
}
class Circle: Shape { ... }
let shape1 = Shape()
let shape2: Shape = Circle()
shape1.play(shape: shape2)
이렇기 때문에 방문자가 Element요소만 보고 해당 객체의 실제 타입에 맞는 함수를 호출할 수 없습니다. Swift의 경우 타입 캐스팅을 통해 가능하긴 하지만 여러 분기문을 만들어 내기에 좋지 못합니다.
다중 디스패치 방식을 사용하는 언어의 경우 런타임 객체의 실제타입을 반영하기 때문에 위 코드와 같은 상황에서 객체별로 서로다른 play함수 호출이 가능합니다. 방문자 패턴은 단일 디스패치 방식만 지원하는 언어가 마치 다중 디스패치를 사용하는 것과 같은 효과를 냅니다.
5️⃣ 결과
- 새로운 연산을 쉽게 추가합니다.
- 관련되지 않는 행동들은 서로다른 방문자 객체로 격리되어 가독성이 높아집니다.
- 방문자를 통해 요소들을 순회하며 필요한 상태를 누적할 수 있습니다.
- 단점, 새로운 ConcreteElement를 추가하기 힘듭니다.
- 앞서 볼 수 있든, 방문자 인터페이스는 구체적인 Element타입에 대한 정보를 포함합니다.
- 위 단점으로 인해 방문자 패턴은 적용시 반드시 고려해야할 두가지 사항이 있습니다.
- 객체 구조에 적용될 알고리즘이 자주 변경되는가? (다형성이 필요한가?)
- ConcreteElement가 자주 추가되는가? (객체 구조가 자주 변형되는가?)
- 단점, 캡슐화 위반
- 방문자는 ConcreteElement에 직접 접근합니다. 따라서 두 객체간 결합도가 상승할 수 있습니다.
6️⃣ Swift구현
구현시 고려할점
- 언어가 다중 디스패치를 지원한다면 방문자 패턴의 필요성은 많이 줄어듭니다.
- 객체 구조의 순회를 담당할 객체
- 객체 구조가 요소들을 재귀적으로 순화하면서 Accept를 호출하는 것이 가능합니다.
- 객체 구조가 내부적으로 요소들에 대한 반복자를 가져 순회하는 방법이 있습니다.
- 방문자는 직접적으로 요소에 접근함으로 방문자가 직접 순회를 담당할 수 있습니다.
- 해당 경우 연산 결과에 따라 동적으로 순회방법을 변경할 수 있다는 장점이 있습니다.
방문자 인터페이스와 구현체를 구현합니다.
정수를 수집하는 구체 방문자와 문자열을 수집하는 구체 방문자를 각각 구현합니다.
protocol Visitor {
func visit(element: ElementA)
func visit(element: ElementB)
}
class IntVisitor: Visitor {
var state: Int = 0
func visit(element: ElementA) {
state += element.value1
}
func visit(element: ElementB) {
state += element.value1
}
}
class StrVisitor: Visitor {
var state: String = ""
func visit(element: ElementA) {
state += element.value2
}
func visit(element: ElementB) {
state += element.value2
}
}
Element인터페이스와 구체 요소를 구현합니다.
class Element {
func accept(visitor: Visitor) { }
}
final class ElementA: Element {
var value1: Int = 1
var value2: String = "A"
override func accept(visitor: any Visitor) {
visitor.visit(element: self)
}
}
final class ElementB: Element {
var value1: Int = 2
var value2: String = "B"
override func accept(visitor: any Visitor) {
visitor.visit(element: self)
}
}
방문자를 통해 객체구조(아래 배열)의 Element를 순회합니다.
let objectStructure = [ElementA(), ElementB()]
let visitor1 = IntVisitor()
objectStructure.forEach { element in
element.accept(visitor: visitor1)
}
print(visitor1.state) // 3
let visitor2 = StrVisitor()
objectStructure.forEach { element in
element.accept(visitor: visitor2)
}
print(visitor2.state) // AB
7️⃣ 느낀점
구조가 잘 변하지 않는 객체구조에 적용할 연산을 유연하게 변경할 수 있다는 점이 인상적이다.
하지만 실제로 구조가 잘 변하지 않는 구조라는게 많이 존재할지는 의문이다.
당장에 생각나는 것은 모든 요소가 동일하지만 그 수만 변하는 트리나 컬렉션들 정도 밖에 떠오리지 않는다.
해당 자료구조들의 요소를 순회하는 방법들은 고차함수들을 이용하여 손쉽게 연산을 적용할 수 있다.
하지만, 연산내용을 은닉화해야 한다거나 연산 객체 주입을 통해 연산을 동적으로 변경하려면 아무래도 방문자 패턴이 활용될 수 있을 것 같다.
그리고 단일 다중 디스패치 개념에 대한 학습할 수 있어서 좋았다.
구현 코드는 아래 저장소에서 확인할 수 있습니다.
GitHub - J0onYEong/GOF-design-pattern: GOF디자인 패턴 실습코드 레포지토리입니다.
GOF디자인 패턴 실습코드 레포지토리입니다. Contribute to J0onYEong/GOF-design-pattern development by creating an account on GitHub.
github.com
'디자인 패턴' 카테고리의 다른 글
[디자인 패턴] 행동, Template method pattern(템플릿 메서드 패턴) with Swift (0) | 2025.04.30 |
---|---|
[디자인 패턴] 행동, Strategy pattern(전략 패턴) with Swift (0) | 2025.04.30 |
[디자인 패턴] 행동, State pattern(상태 패턴) with Swift (0) | 2025.04.28 |
[디자인 패턴] 행동, 감시자 패턴(Observer pattern) (0) | 2025.04.28 |
[디자인 패턴] 행동, Memento pattern(메멘토 패턴) with Swift (0) | 2025.04.26 |