의도(Intent)
Visitor는 알고리즘을 해당 알고리즘이 작동하는 객체에서 분리할 수 있도록 하는 행동 디자인 패턴입니다.
Visitor 디자인 패턴
문제(Problem)
어떤 팀이 지리 정보를 거대한 그래프 형태로 다루는 애플리케이션을 개발하고 있습니다. 그래프의 각 노드는 도시와 같은 복잡한 개체뿐만 아니라 산업, 관광지 등 다양한 요소를 나타낼 수 있습니다. 이러한 노드들은 실제 객체 간의 도로를 기준으로 연결됩니다.
그래프를 XML로 내보내기
팀은 그래프를 XML 형식으로 내보내야 하는 기능을 추가해야 했습니다. 처음에는 각 노드 클래스에 export
메서드를 추가하고 재귀를 사용해 그래프의 모든 노드에서 해당 메서드를 실행하는 방식으로 해결하려 했습니다. 그러나 시스템 아키텍트는 기존 노드 클래스를 수정하는 것을 반대했습니다.
이유는 다음과 같습니다:
- 이미 운영 중인 코드이며, 변경으로 인해 버그가 발생할 위험이 있음.
- 노드 클래스는 지리 데이터를 처리하는 것이 주 역할이며, XML 내보내기 기능이 적절하지 않음.
- 이후 다른 형식(예: JSON)으로 내보내는 기능이 필요할 가능성이 높음. 이 경우, 다시 기존 코드를 변경해야 하는 문제가 발생할 것임.
해결책(Solution)
Visitor 패턴은 새로운 동작을 기존 클래스에 통합하는 대신 별도의 Visitor
클래스로 분리하는 방식을 제안합니다. 노드 객체는 Visitor
의 메서드에 인수로 전달되며, Visitor는 객체 내부의 데이터를 활용해 동작을 수행합니다.
Visitor 패턴 적용 방식
객체의 클래스에 따라 다른 동작을 수행해야 할 경우, Visitor 클래스에서 여러 개의 메서드를 정의할 수 있습니다.
class ExportVisitor implements Visitor {
method doForCity(City c) { ... }
method doForIndustry(Industry i) { ... }
method doForSightSeeing(SightSeeing s) { ... }
}
하지만, 그래프의 모든 노드를 순회하면서 적절한 Visitor 메서드를 호출하는 것은 어렵습니다.
foreach (Node node in graph) {
if (node instanceof City)
exportVisitor.doForCity((City) node);
if (node instanceof Industry)
exportVisitor.doForIndustry((Industry) node);
}
이러한 문제를 해결하기 위해 더블 디스패치(Double Dispatch) 기법을 활용할 수 있습니다. 노드 객체가 스스로 방문자를 받아들이고 적절한 메서드를 호출하도록 하는 것입니다.
// 클라이언트 코드
foreach (Node node in graph) {
node.accept(exportVisitor);
}
// City 클래스
class City {
method accept(Visitor v) {
v.doForCity(this);
}
}
// Industry 클래스
class Industry {
method accept(Visitor v) {
v.doForIndustry(this);
}
}
이제 Visitor 인터페이스를 구현하는 새로운 Visitor 클래스가 추가되더라도 기존 코드를 변경하지 않고 확장할 수 있습니다.
현실 세계의 비유
보험 설계사
보험 설계사는 다양한 조직에 맞는 보험 상품을 제공합니다.
- 주거 건물: 의료 보험
- 은행: 도난 보험
- 카페: 화재 및 홍수 보험
Visitor 패턴은 위와 같은 방식으로 다양한 객체에 맞는 동작을 실행할 수 있도록 해줍니다.
구조(Structure)
- Visitor 인터페이스: 방문할 대상 요소(객체)에 대한 메서드들을 선언합니다.
- Concrete Visitor(구체적인 방문자): 각 요소 클래스에 맞는 동작을 구현합니다.
- Element 인터페이스: 방문자를 받아들이는
accept
메서드를 선언합니다. - Concrete Element(구체적인 요소):
accept
메서드를 구현하여 적절한 Visitor 메서드를 호출합니다. - Client: 요소 객체의 집합을 관리하고 Visitor를 적용합니다.
예제 코드
// 요소 인터페이스
interface Shape {
void move(int x, int y);
void draw();
void accept(Visitor v);
}
// 구체적인 요소 클래스
class Dot implements Shape {
@Override
public void accept(Visitor v) {
v.visitDot(this);
}
}
class Circle implements Shape {
@Override
public void accept(Visitor v) {
v.visitCircle(this);
}
}
// Visitor 인터페이스
interface Visitor {
void visitDot(Dot d);
void visitCircle(Circle c);
}
// 구체적인 Visitor
class XMLExportVisitor implements Visitor {
@Override
public void visitDot(Dot d) {
// Dot을 XML 형식으로 변환
}
@Override
public void visitCircle(Circle c) {
// Circle을 XML 형식으로 변환
}
}
// 클라이언트 코드
class Application {
List<Shape> shapes;
void export() {
Visitor exportVisitor = new XMLExportVisitor();
for (Shape shape : shapes) {
shape.accept(exportVisitor);
}
}
}
적용 사례(Applicability)
- 복잡한 객체 구조에서 특정 작업을 수행할 때 (예: 객체 트리 순회)
- 핵심 로직에서 보조 기능을 분리할 때 (예: 데이터 내보내기, 검증 등)
- 일부 클래스에서만 특정 동작이 필요한 경우 (관련 없는 클래스에는 빈 메서드를 둠)
구현 방법(How to Implement)
- Visitor 인터페이스에 방문 메서드를 선언합니다.
- 기존 요소(Element) 클래스에
accept
메서드를 추가합니다. - 구체적인 요소 클래스에서
accept
메서드를 구현하여 Visitor를 호출하도록 합니다. - 새로운 동작이 필요할 때마다 Visitor 클래스를 추가하여 확장합니다.
장점과 단점(Pros and Cons)
✅ 장점
- OCP(Open/Closed Principle) 준수: 기존 클래스를 변경하지 않고 새 기능을 추가할 수 있음.
- SRP(Single Responsibility Principle) 준수: 핵심 비즈니스 로직과 부가 기능을 분리할 수 있음.
- 객체 구조 탐색 기능: 복잡한 객체 구조를 방문하며 데이터를 수집할 수 있음.
❌ 단점
- 새로운 요소 클래스 추가 시 Visitor 수정 필요
- Visitor가 요소의 비공개 멤버에 접근할 수 없음
관련 패턴(Relations with Other Patterns)
- Command: Visitor는 다양한 객체에 대한 명령을 실행하는 강력한 Command 패턴으로 볼 수 있음.
- Composite: Visitor는 Composite 구조에서 전체 객체 트리를 탐색하며 동작할 수 있음.
- Iterator: Visitor는 복잡한 데이터 구조를 순회하며 특정 연산을 수행하는 데 사용될 수 있음.