포스트

Visitor Pattern

객체 구조는 그대로 두고, 그 위에서 수행할 연산을 별도 Visitor로 분리하는 패턴. 새 연산을 추가할 때마다 모든 클래스를 고치는 일을 막는다.

Visitor Pattern

난이도 중급 · 선행 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, Rust match, Kotlin sealed)이 같은 문제를 더 간결히 푼다. 고전 Visitor를 쓰기 전에 언어 기능을 먼저 보라.

스스로 점검

1. Visitor가 “이중 디스패치”라 불리는 이유는?

shape->accept(visitor)에서 먼저 어떤 도형인지가 결정되고(첫 번째 다형성), 그 안의 visitor.visit(*this)에서 어떤 연산인지가 결정된다(두 번째 다형성). 두 번의 동적 분기로 (요소 × 연산) 조합이 정확히 선택된다.

2. Visitor는 무엇을 늘리기 쉽고 무엇을 늘리기 어렵나?

새 연산은 Visitor 하나만 추가하면 돼 쉽다. 반대로 새 요소 타입을 더하면 모든 Visitor에 visit 오버로드를 추가해야 해 어렵다. 보통의 다형성과 정확히 반대.

3. 어떤 구조에 Visitor가 잘 맞나?

요소 타입은 안정적인데 그 위에서 돌릴 연산이 자주 느는 구조 — 컴파일러 AST가 대표적. 요소가 자주 추가되는 곳에는 오히려 부담이 된다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.