객체 구조는 그대로 두고 연산만 밖으로 빼는 패턴.
interface Visitor {
void visit(Circle c);
void visit(Square s); // 요소 종류마다 메서드
}
interface Shape { void accept(Visitor v); }
class Circle implements Shape {
public void accept(Visitor v) { v.visit(this); } // ← 더블 디스패치
}
// 새 연산은 Visitor 구현만 추가하면 된다 — Shape 는 안 건드린다
class AreaCalculator implements Visitor { ... }
class XmlExporter implements Visitor { ... }
더블 디스패치가 핵심이다
자바는 단일 디스패치다 — 오버로딩은 '컴파일 시점 정적 타입' 으로 정해진다
- void render(Shape s) { visitor.visit(s); } // ✗ 항상 visit(Shape) 가 불린다
Visitor 는 두 번 위임해 이를 우회한다
① shape.accept(visitor) → 실제 타입(Circle) 의 accept 가 불린다 (동적)
② v.visit(this) → this 의 정적 타입이 Circle 이라 visit(Circle) 선택
두 번 거쳐야 '실제 타입에 맞는 연산' 에 도달한다
확장의 방향이 반대다
| 연산 추가가 쉽다 | 새 Visitor 클래스 하나 | ✓ |
|---|---|---|
| 요소 추가가 어렵다 | Visitor 인터페이스와 모든 구현 수정 | ✗ |
- 일반적인 OOP 는 반대다 (요소 추가가 쉽고 연산 추가가 어렵다)
- 이것이 '표현 문제(expression problem)' 다
그래서 요소 종류가 안정적이고 연산이 자주 느는 경우에만 쓴다
- 예) 컴파일러 AST — 노드 종류는 고정, 연산(타입검사·최적화·생성)은 계속 는다
자바에서는 대안이 생겼다
// sealed + 패턴 매칭 switch — 완전성을 컴파일러가 검사한다
String describe(Shape s) {
return switch (s) {
case Circle c -> "원 " + c.r();
case Square q -> "사각 " + q.side();
}; // 새 하위 타입이 생기면 컴파일 오류로 잡힌다
}
Visitor의 보일러플레이트 없이 같은 효과를 낸다. 모던 자바에서는 이쪽이 더 흔한 선택이다.
면접 함정
- ❌ "요소 추가가 쉽다" → 반대다. 연산 추가가 쉽고 요소 추가가 어렵다.
- ❌ 더블 디스패치를 설명 못 함 → 이 패턴의 존재 이유가 그것이다.
왜 accept가 필요한가
// accept 없이 하려면 instanceof 분기가 필요하다
void visit(Shape s) {
if (s instanceof Circle c) ...
else if (s instanceof Square q) ... // 요소가 늘 때마다 여기를 고친다
}
s.accept(visitor); // 가상 함수 호출로 대체 — JVM 이 실제 타입에 맞게 부른다
상태를 들고 다닐 수 있다
class TotalAreaVisitor implements Visitor {
private double total;
public void visit(Circle c) { total += Math.PI * c.r() * c.r(); }
double result() { return total; }
}
연산 상태와 타입별 처리를 한 클래스에 모은다는 점이 외부 반복자와 다르다.
요소 추가가 어렵다는 것의 실제 의미
Shape 에 Triangle 을 추가하면
- Visitor 인터페이스에 visit(Triangle) 추가
- 기존 Visitor 구현 전부에 메서드 추가 (컴파일 오류로 강제된다)
구현이 10개면 10곳을 고친다 — 요소가 안정적일 때만 쓰는 이유다