행동 패턴 - 방문자(Visitor)
- 참조: https://refactoring.guru/design-patterns/visitor
- 참조: GoF의 디자인 패턴
- 방문자(Visitor)
방문자패턴은 동작하는 객체로부터 알고리즘을 분리하는 패턴이다.
- 문제점
하나의 거대한 그래프로 구성된 지리 정보로 동작하는 앱을 개발한다고 가정해보자. 그래프의 각 노드는 도시와 같은 복잡한 엔티티를 표현할 수 있지만 산업단지나 관광지 같은 더 세분화된 요소들도 표현할 수 있다. 이런 노드들은 그들이 표현하고 있는 객체들 사이에 길이 있다면 다른 요소와 연결되어 있을것이다. 이런 상황에서 각 노드의 타입은 클래스로 표현되지만 각 특정 노드는 객체이다.
시간이 지나서 그래프를 XML 형태로 변환하는 기능을 구현할 수도 있다. 언뜻 생각하기에는 별로 어렵지않은 직관적인 기능이라고 생각할 수 있다. 각 노드 클래스에 변환기능을 추가하고 재귀적인 방법으로 그래프의 각 노드를 순회하면서 해당 기능을 수행하려고 생각할것이다. 꽤 괜찮은 방법이다. 다형성 덕분에 노드의 세부 클래스와 호출되는 해당 메소드가 결합되지 않는다.
하지만 시스템 아키텍트는 기존 노드 클래스를 대체하는것게 좋은건지 고민이 될 수 있다. 코드가 이미 운영되고 있고, 이 변화로 인해서 잠재적인 버그가 발생할 수 있는 위험성을 감수하고 싶지 않아 할 수 있다.
단순한 위험감수 뿐만 아니라 노드 클래스들 안에 XML 변환 코드가 있는게 맞는건지 의문을 제기할 수도 있다. 노드 클래스들의 주요 임무는 지리정보를 기반으로 동작하는것이기 때문이다. 해당 클래스에서 XML 내보내는 기능까지 책임져야할 의무는 없다.
또한 이 기능이 구현되고 난 후에 마케팅 부서사람이 다른 포맷으로도 내보내는 기능도 추가해줄 수 있냐고 요청할 수 있다. 이런 추가기능들을 구현하다보면 기존 클래스를 바꾸는것이 강제된다.
- 해결책
방문자 패턴에서는 새로운 행동을 기존 클래스에 통합하지 않고, "visitor" 라고 불리는 분리된 클래스에 따로 구현한다. 해당 기능을 수행해야했던 원본 객체는 방문자 메소드의 인자가 되고, 원본 객체는 필요한 데이터에 접근하는 메소드를 제공한다.
다른 개체에서 행동을 수행하게 되면 어떻게 될까? 예를 들어 XML 내보내기 기능의 경우 실제 구현방식은 노드 클래스들마다 약간 다른 형태가 될것이다. 그래서 방문자 클래스는 단일 메소드가 아닌 여러 메소드를 정의할것이고, 아래와 같이 각 메소드마다 다른 형태의 인자를 취할것이다.
class ExportVisitor implements Visitor is
method doForCity(City c) { ... }
method doForIndustry(Industry f) { ... }
method doForSightSeeing(SightSeeing ss) { ... }
// ...
전체 그래프를 다룰 때 이 메소드들을 어떻게 정확하게 호출해야할까? 위의 코드에서 보다시피 메소드 시그니처가 다르기 때문에 다형성을 사용할 수 없다. 주어진 객체를 처리하는 적절한 방문자 메소드를 매칭하기 위해서는 아래와 같이 Class 형을 체크해야 한다. 벌써부터 코드스멜이 나는것같다.
foreach (Node node in graph)
if (node instanceof City)
exportVisitor.doForCity((City) node)
if (node instanceof Industry)
exportVisitor.doForIndustry((Industry) node)
// ...
}
메소드 오버라이딩을 왜 사용하지 않을까라고 반문할 수도 있다. 파라미터들이 달라도 같은 이름의 메소드를 갖는다면 가능하기 때문이다. 불행하게도 Java나 C# 처럼 프로그래밍언어가 지원한다고 해도 전혀 도움이 되질 않는다. 노드 객체의 정확한 클래스를 미리 알 수 없기 때문에, 오버로딩 매커니즘으로는 정확히 어떤 메소드가 수행되어야 하는지 결정할 수 없다.
방문자 패턴은 이 문제를 해결할 수 있다. 방문자 패턴은 성가신 조건문 없이 객체의 적절한 메소드를 수행하는데 도움을 주는 "Double Dispatch" 라고 기법을 사용한다. 사용자가 적절한 메소드를 호출하지 않고, 이 선택을 인자로서 방문자 패턴에 넘기는 객체로 미룬다. 객체들은 자신의 클래스를 알고 있기 때문에 방문자의 적절한 메소드를 선택할 수 있다. 그들은 방문자를 "accept - 수락" 하고 어떤 방문자 메소드가 수행되어야 하는지 알려준다.
// Client code
foreach (Node node in graph)
node.accept(exportVisitor)
// City
class City is
method accept(Visitor v) is
v.doForCity(this)
// ...
// Industry
class Industry is
method accept(Visitor v) is
v.doForIndustry(this)
// ...
결국엔 노드 클래스를 변경해야했지만 그래도 변경사항은 사소해졌고, 코드를 다시 변경하지 않고 어떤 행동을 추가할 수 있게 되었다.
이제 모든 방문자들의 공통 인터페이스를 추출하면, 모든 존재하는 노드들은 어떤 방문자와도 교류할 수 있게 되었다. 만약 노드와 관련된 새로운 행동을 구현하기 위해 해야할것은 새로운 방문자 클래스를 구현하는것뿐이다.
- 실생활 예제
새로운 고객을 확보하려는 보험 대리점이 있다고 가정해보자. 보험을 판매하기 위해 이웃의 모든 건물을 방문하려고 한다. 빌딩을 소유하고 있는 조직의 형태에 따라서 특별한 보험 정책을 제공해야 할 것이다.
- 주거 건물이라면 판매원은 의료보험을 제안한다.
- 은행이라면 도난보험을 제안한다.
- 커피샵이면 화재 및 홍수보험을 제안한다.
- 구조
방문자 패턴의 구조를 알아보자.
- Visitor: Visitor 인터페이스는 인자로서 객체의 구조를 나타내는 세부 Element 를 취하는 방문자 메소드들을 선언한다. 오버로딩을 지원하는 언어라면 같은 이름이면서 다른 타입을 취하는 메소드들을 정의한다.
- Concrete Visitor: Concrete Element 클래스에 따라 같은 행동을 하는 여러 버전을 구현체 클래스들이다.
- Element: Element 인터페이스는 방문자를 "accepting" 하기 위한 메소드를 정의하고 있다. 이 메소드는 방문자 인터페이스 타입의 파라미터를 가져야 한다.
- Concrete Element: 각 Concrete Element 는 반드시 Element 의 "accepting" 메소드를 구현해야한다. 이 메소드의 목적은 적절한 방문자 메소드 호출을 해당하는 Element 클래스로 넘기는것이다. 비록 기본 Element 클래스가 이 메소드를 구현하고 있다고 해도, 모든 서브클래스들은 자신의 클래스에서 자신의 메소드를 오버라이딩 해야하고, 방문 객체의 적절한 메소드를 호출한다.
- Client: 클라이언트는 대게 collection 이나 복잡 객체를 표현한다. 클라이언트는 추상 인터페이스를 통해 collection 의 객체들과 교류하기 때문에 모든 세부 Element 클래스들을 알지 못한다.
- 시나리오
이번 예제에서는 방문자 패턴을 이용하여 도형의 기하학적 계층 구조를 XML로 내보내는 기능을 구현한다.
- Java 예제
우선 도형을 정의하는 Shape 인터페이스부터 정의해보자. move와 draw는 도형의 특성을 표현한 메소드이고, accept 자신의 클래스에 해당하는 방문자를 호출하기 위한 메소드이다.
public interface Shape {
void move(int x, int y);
void draw();
void accept(Visitor v);
}
Shape 인터페이스를 구현하는 세부 클래스들을 정의해보자. 대표적으로 Dot 클래스 하나만 살펴본다. 크게 복잡하거나 어려운 부분은 없다. accept 메소드에서 Visit 인터페이스에 정의된 4가지 메소드 중 자신의 클래스에 해당하는 visitDot 을 호출하였고, 자기 자신을 넘겨준다. Circle, Rectangle, CompoundShape 클래스들도 이런식으로 구현해준다.
public class Dot implements Shape{
private String shapeId;
public Dot(String shapeId) {
super();
this.shapeId = shapeId;
}
public String getShapeId() {
return shapeId;
}
@Override
public void move(int x, int y) {
System.out.println("Move Dot: " + x + ", " + y);
}
@Override
public void draw() {
System.out.println("Draw dot");
}
@Override
public void accept(Visitor v) {
v.visitDot(this);
}
}
이제 Visit 인터페이스를 정의한다. Shape의 세부 클래스들과 관련된 항목을 모두 선언해준다.
public interface Visitor {
void visitDot(Dot d);
void visitCircle(Circle c);
void visitRectangle(Rectangle r);
void visitCompoundShape(CompoundShape cs);
}
그리고 이를 구현하는 XMLExportVisitor 는 아래와 같다. 넘겨받은 클래스들을 이용하여 XML 내보내기 기능들을 구현한다. 만약 JSON 으로 내보내기를 추가적으로 구현하고 싶다면 새로운 JSONExportVisitor 를 선언하고 적절한 구현을 해주면 된다.
public class XMLExportVisitor implements Visitor{
@Override
public void visitDot(Dot d) {
System.out.println("Export XML Dot: " + d.getShapeId());
}
@Override
public void visitCircle(Circle c) {
System.out.println("Export XML Circle: " + c.getShapeId());
}
@Override
public void visitRectangle(Rectangle r) {
System.out.println("Export XML Rectangle: " + r.getShapeId());
}
@Override
public void visitCompoundShape(CompoundShape cs) {
System.out.println("Export XML CompoundShape: " + cs.getShapeId());
}
}
이제 Test를 해보자. Shape 의 목록을 구성하고, 어떤 기능의 Visitor 인지만 명시해주면 Shape의 세부 클래스들에 따라 적절한 기능을 수행함을 확인할 수 있다.
public static void main(String[] args) {
List<Shape> shapes = new ArrayList<>();
shapes.add(new Dot("Dot#1"));
shapes.add(new Rectangle("Rectangle#1"));
shapes.add(new Dot("Dot#2"));
shapes.add(new Circle("Circle#1"));
shapes.add(new CompoundShape("CompoundShape#1"));
Visitor excelVisitor = new XMLExportVisitor();
shapes.forEach(shape -> {
shape.accept(excelVisitor);
});
}
=======================================
Export XML Dot: Dot#1
Export XML Rectangle: Rectangle#1
Export XML Dot: Dot#2
Export XML Circle: Circle#1
Export XML CompoundShape: CompoundShape#1