Visitor Pattern
객체 구조는 그대로 두고, 그 위에서 수행할 연산을 별도 Visitor로 분리하는 패턴. 새 연산을 추가할 때마다 모든 클래스를 고치는 일을 막는다.
난이도 중급 · 선행 Composite
한 줄 요약
도형·노드 같은 요소들의 클래스는 건드리지 않고, 그 위에서 돌릴 연산(넓이 계산, 내보내기, 렌더링…)을 Visitor라는 별도 객체로 빼낸다. 연산을 추가해도 요소 클래스들을 다시 열지 않는다.
어떤 문제를 푸는가
도형 계층이 있고 연산을 하나씩 더해간다고 하자.
1
2
3
4
5
6
7
8
9
class Shape {
public:
virtual double area() const = 0; // 1. 넓이
virtual std::string toSvg() const = 0; // 2. SVG로 내보내기 — 또 추가
virtual std::string toJson() const = 0; // 3. JSON으로 — 또또 추가
};
class Circle : public Shape { /* area, toSvg, toJson 모두 구현 */ };
class Square : public Shape { /* area, toSvg, toJson 모두 구현 */ };
이 코드의 통증:
- 새 연산(예: 둘레 계산)을 더하려면
Shape와 모든 자식 클래스를 다시 열어야 한다. - 무관한 관심사(렌더링·직렬화·계산)가 도형 클래스 안에 한데 뒤섞인다 (SRP 위반).
toSvg,toJson같은 출력 형식이 늘수록 도형 클래스가 비대해진다.
패턴 적용 후
도형은 “방문을 받아들이는” accept만 갖고, 연산은 Visitor 쪽에 모은다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class Circle;
class Square;
class Visitor {
public:
virtual void visit(const Circle& c) = 0;
virtual void visit(const Square& s) = 0;
virtual ~Visitor() = default;
};
class Shape {
public:
virtual void accept(Visitor& v) const = 0;
virtual ~Shape() = default;
};
class Circle : public Shape {
public:
double radius = 1.0;
void accept(Visitor& v) const override { v.visit(*this); } // 이중 디스패치
};
class Square : public Shape {
public:
double side = 1.0;
void accept(Visitor& v) const override { v.visit(*this); }
};
// 새 연산 = 새 Visitor 클래스 하나. 도형 클래스는 손대지 않는다.
class AreaVisitor : public Visitor {
public:
double total = 0;
void visit(const Circle& c) override { total += 3.14159 * c.radius * c.radius; }
void visit(const Square& s) override { total += s.side * s.side; }
};
int main() {
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>());
shapes.push_back(std::make_unique<Square>());
AreaVisitor area;
for (auto& s : shapes) s->accept(area);
std::cout << "총 넓이: " << area.total << std::endl;
}
달라진 점:
- 둘레·렌더링·내보내기를 더하려면
Visitor구현체를 하나씩 추가하면 된다. 도형 클래스는 그대로. - 한 연산의 모든 도형 처리가 한 Visitor에 모여, 그 연산의 로직이 한눈에 읽힌다.
구조와 이중 디스패치
- Visitor: 요소 타입마다
visit(ConcreteElement&)를 선언 - ConcreteVisitor: 실제 연산 (
AreaVisitor) - Element:
accept(Visitor&)를 선언 - ConcreteElement:
accept에서v.visit(*this)호출
1
2
shape->accept(visitor) // ① 어떤 도형인지 결정 (Shape의 가상함수)
└─ visitor.visit(*this) // ② 어떤 연산인지 결정 (Visitor의 오버로드)
핵심은 이중 디스패치다. accept가 “어떤 도형인가”를, 그 안의 visit가 “어떤 연산인가”를 푼다. 두 번의 다형성으로 (도형 × 연산) 조합이 정확히 찾아간다.
실전 사례
- 컴파일러 AST 순회: 타입 검사·최적화·코드 생성이 각각 Visitor. 노드 클래스는 고정, 패스(pass)만 늘린다.
- 문서 내보내기: 같은 문서 트리를 HtmlVisitor / PdfVisitor / MarkdownVisitor로 변환.
- 파일 시스템 집계: 디렉터리 트리를 돌며 총 크기·파일 수를 계산.
Composite로 만든 트리 위에 Visitor를 얹는 조합이 특히 자연스럽다.
트레이드오프 — 무엇을 늘리기 쉬운가
Visitor는 보통의 OOP와 정반대 방향으로 확장이 쉽다.
| 추가하려는 것 | 보통의 다형성 | Visitor |
|---|---|---|
| 새 연산 | 모든 클래스 수정 😞 | Visitor 하나 추가 🙂 |
| 새 요소 타입 | 클래스 하나 추가 🙂 | 모든 Visitor 수정 😞 |
요소 타입은 고정인데 연산이 자주 느는 구조(AST가 대표)에 맞다. 반대로 요소가 자주 추가되면 Visitor는 오히려 짐이 된다.
안티패턴 / 주의
- 요소 계층이 자주 바뀌는 곳에 쓰지 말 것. 도형 종류가 늘 때마다 모든 Visitor를 고쳐야 해 손해다.
- 연산이 하나뿐이면 도입하지 말 것. 이중 디스패치의 보일러플레이트가 가독성을 해친다.
- 현대 언어에선 패턴 매칭(
std::variant+std::visit, Rustmatch, Kotlinsealed)이 같은 문제를 더 간결히 푼다. 고전 Visitor를 쓰기 전에 언어 기능을 먼저 보라.
스스로 점검
1. Visitor가 “이중 디스패치”라 불리는 이유는?
답
shape->accept(visitor)에서 먼저 어떤 도형인지가 결정되고(첫 번째 다형성), 그 안의 visitor.visit(*this)에서 어떤 연산인지가 결정된다(두 번째 다형성). 두 번의 동적 분기로 (요소 × 연산) 조합이 정확히 선택된다.
2. Visitor는 무엇을 늘리기 쉽고 무엇을 늘리기 어렵나?
답
새 연산은 Visitor 하나만 추가하면 돼 쉽다. 반대로 새 요소 타입을 더하면 모든 Visitor에 visit 오버로드를 추가해야 해 어렵다. 보통의 다형성과 정확히 반대.
3. 어떤 구조에 Visitor가 잘 맞나?
답
요소 타입은 안정적인데 그 위에서 돌릴 연산이 자주 느는 구조 — 컴파일러 AST가 대표적. 요소가 자주 추가되는 곳에는 오히려 부담이 된다.