Visitor 패턴: 데이터 구조는 그대로 두고 연산만 늘리는 법
Visitor 패턴의 이중 디스패치 메커니즘으로 Element 계층을 건드리지 않고 새 연산을 추가하는 방법과 AST 처리 등 실무 적용, 트레이드오프를 정리한다.
2026-08-13 · 최초 발행 2025-10-14
파일 트리 하나에 전체 크기를 합산하는 기능과 경로를 나열하는 기능을 둘 다 붙이려 하면, 보통은 Folder와 FileNode 클래스 안에 메서드를 하나씩 추가하게 된다. 연산이 하나 늘 때마다 데이터 구조 자체를 건드려야 하는 셈이다. Visitor 패턴은 이 연산을 구조 밖으로 꺼내, Element 계층은 그대로 둔 채 새 연산을 Visitor 형태로 추가할 수 있게 한다. Open-Closed Principle을 연산과 데이터 구조의 분리라는 축에서 실현하는 방식이다.
이중 디스패치가 하는 일
element.accept(visitor)를 호출하면 런타임에 결정되는 실제 Element 타입과, 컴파일 타임에 오버로드로 결정되는 Visitor의 visit(ConcreteElement) 시그니처가 맞물려 실행 경로가 정해진다. 이 이중 디스패치(Double Dispatch)가 핵심 메커니즘이다. 참여자는 다섯 갈래로 나뉜다 — accept(Visitor)를 제공하는 Element, 구체 데이터를 들고 accept에서 visitor.visit(this)로 위임하는 ConcreteElement, 각 ConcreteElement별 visit 오버로드를 정의하는 Visitor, 구체 연산을 구현하는 ConcreteVisitor, 그리고 요소 컬렉션을 보유하고 순회를 관리하는 ObjectStructure다. 메서드 오버로딩 기반 타입 분기이므로 캐스팅이 사라지고 컴파일 타임에 검증된다.
확장이 쉬워지는 대신 치르는 대가
새로운 비즈니스 연산을 추가할 때는 Visitor만 새로 만들면 되고, Element 계층은 안정적으로 유지된다. 반대 방향은 트레이드오프다 — 새로운 Element 타입을 추가하면 기존 Visitor를 전부 수정해야 한다. 그래서 도입 전에 모델이 얼마나 자주 바뀔지부터 판단해야 한다. Element는 상태와 구조 표현에 집중하고 Visitor는 정책·알고리즘을 담당하는 식으로 관심사가 갈리기 때문에, 로깅·검증·내보내기·통계 집계 같은 횡단 관심사를 모듈화하기에도 유리하다. Composite 같은 트리 구조와 결합하면 순회 패턴이 강력해지고, ObjectStructure가 순회 책임을 갖고 있으면 테스트하기도, 방문 순서를 제어하기도 쉬워진다.
안정성 측면에서는 타입 안정성과 컴파일 오류로 조기에 문제를 잡을 수 있어 리플렉션 기반 분기보다 우수하지만, 상태를 가진(Stateful) Visitor를 쓸 때는 동시성 제약이 생기므로 방문 인스턴스의 스코프를 관리해야 한다.
방문 흐름
파일 트리로 보는 Visitor 구현
Java 17 표준 JDK만으로 실행 가능하다. 파일명은 VisitorDemo.java이고, javac --release 17 VisitorDemo.java로 컴파일한 뒤 java VisitorDemo로 실행한다.
// VisitorDemo.java (Java 17)
import java.util.*;
public class VisitorDemo {
// Element 계층
interface Element {
void accept(Visitor v);
String name();
}
static final class FileNode implements Element {
private final String name;
private final long size;
FileNode(String name, long size) { this.name = name; this.size = size; }
public void accept(Visitor v) { v.visit(this); }
public String name() { return name; }
long size() { return size; }
}
static final class Folder implements Element {
private final String name;
private final List<Element> children = new ArrayList<>();
Folder(String name) { this.name = name; }
public void add(Element e) { if (e != null) children.add(e); }
public List<Element> children() { return Collections.unmodifiableList(children); }
public void accept(Visitor v) {
v.visit(this); // 폴더 방문
for (Element c : children) c.accept(v); // 순회
}
public String name() { return name; }
}
// Visitor 계층
interface Visitor {
void visit(FileNode file);
void visit(Folder folder);
}
// 크기 합산 방문자
static final class SizeVisitor implements Visitor {
private long total = 0L;
public void visit(FileNode file) { total += file.size(); }
public void visit(Folder folder) { /* 폴더 자체 크기 없음 */ }
long totalSize() { return total; }
}
// 경로 나열 방문자
static final class PathListingVisitor implements Visitor {
private final Deque<String> stack = new ArrayDeque<>();
private final List<String> paths = new ArrayList<>();
public void visit(FileNode file) {
paths.add(String.join("/", stack) + "/" + file.name());
}
public void visit(Folder folder) {
if (stack.isEmpty()) stack.push(folder.name());
else stack.push(stack.peek() + "/" + folder.name());
// children 방문은 Folder.accept에서 처리됨
// 폴더 방문 종료 후 pop 필요
// 단, Folder.accept는 동기식이므로 호출 직후 하위 방문이 완료된 상태
// 따라서 pop은 visit 이후 호출자 관점에서 수행 불가 → 훅 제공 필요
}
// Folder 방문 스코프 종료 훅을 위해 어댑터 제공
void onFolderExit(Folder folder) { if (!stack.isEmpty()) stack.pop(); }
List<String> paths() { return Collections.unmodifiableList(paths); }
}
// Folder.accept에서 exit 훅 호출을 위해 데코레이터 사용
static final class FolderAwareTraversal {
static void traverse(Element root, Visitor v) {
traverseInternal(root, v, null);
}
private static void traverseInternal(Element e, Visitor v, Folder parent) {
if (e instanceof Folder f) {
v.visit(f);
for (Element c : f.children()) traverseInternal(c, v, f);
if (v instanceof PathListingVisitor p) p.onFolderExit(f);
} else {
e.accept(v);
}
}
}
public static void main(String[] args) {
Folder root = new Folder("root");
root.add(new FileNode("a.txt", 120));
Folder img = new Folder("img");
img.add(new FileNode("logo.png", 2048));
root.add(img);
SizeVisitor sizeVisitor = new SizeVisitor();
FolderAwareTraversal.traverse(root, sizeVisitor);
System.out.println("Total size = " + sizeVisitor.totalSize());
PathListingVisitor pathVisitor = new PathListingVisitor();
FolderAwareTraversal.traverse(root, pathVisitor);
pathVisitor.paths().forEach(System.out::println);
}
}
순회 책임을 Element(Folder.accept)에서 분리해두면 PathListingVisitor의 onFolderExit 같은 방문자 스코프 훅을 별도로 구성할 수 있다. SizeVisitor처럼 상태를 갖는 방문자는 스레드 간 공유를 피하고, 요청·트래버설 단위로 새 인스턴스를 만드는 편이 안전하다.
적용 절차와 버전 호환
먼저 안정된 Element 계층 구조를 정의하고 앞으로 추가될 연산 목록을 뽑는다. 그다음 Visitor 인터페이스와 기본 visit 오버로드를 설계하고, ObjectStructure·Traversal에서 순회 정책·방문 순서·예외 처리를 정한 뒤 ConcreteVisitor 단위로 연산을 캡슐화하고 테스트한다. 결과적으로 연산이 추가될 때는 신규 Visitor만 배포하면 되고, Element 변경은 최소화되며 컴파일 타임 검증이 강화된다.
신규 Element가 추가되면 기존 Visitor는 컴파일에 실패하므로, 이 실패 자체가 빌드 시점의 즉각적인 보완 신호로 작동한다. Java의 default method를 활용하면 Visitor 인터페이스의 후방 호환을 어느 정도 완화할 수 있지만, 그 경우 누락된 visit 로직이 조용히 실패할 위험이 남는다. null-safe 순회와 보호 절을 추가하고, 미지원 Element 타입에 대비한 예비 visit(Object) 어댑터나 예외 전략을 정해두는 것이 권장된다. 동시성 측면에서는 Visitor 인스턴스의 스코프를 요청·스레드 로컬로 제한해 데이터 경합을 막고, 트랜잭션 경계 밖의 부수효과는 최소화하되 필요하면 Visitor 내부에서 불변으로 수집한 뒤 커밋 단계에서 일괄 처리하는 전략을 쓴다.
이 구조가 맞아떨어지는 실무 상황
- AST 처리 및 컴파일러/인터프리터 구현 — 타입 검사, 코드 생성, 최적화 단계 분리
- 도메인 모델 검증/정책 적용 — 복합 객체 그래프에 정책 규칙을 적용하고 위반 사항 수집
- 문서/그래픽 트리 변환 — PDF/HTML/이미지 렌더링, 내보내기, 요약 생성
- 관측/메트릭 수집 — 이기종 노드 구성 요소에서 공통 관측 로직 분리
- 시리얼라이제이션/포맷 변환 — 버전 간 매핑 로직을 Visitor로 캡슐화
다른 접근과 비교하면
| 접근 방식 | 성능 | 확장성(연산) | 일관성(타입 안정성) | 안정성(런타임 오류) | 운영 편의(테스트/분리) |
|---|---|---|---|---|---|
| Visitor 패턴 | 높음(가상 호출 1~2단) | 높음 | 높음(컴파일 검증) | 높음 | 높음 |
| switch/instanceof 분기 | 높음 | 낮음(분기 분산) | 중간(캐스팅 의존) | 중간 | 낮음(산재 코드) |
| Strategy 패턴 | 높음 | 중간(전략 교체) | 높음 | 높음 | 중간(전략 조합 필요) |
| 패턴 매칭(sealed) | 높음 | 중간~높음 | 높음 | 높음 | 높음(언어 지원) |
성능 차이는 구현·언어 특성에 따라 미미한 편이며, 위 표의 확장성 평가는 "새 연산 추가"를 기준으로 한 것이다.
도입 판단과 설계 팁
Element 계층이 상대적으로 안정적이고 연산 추가가 빈번한 상황이라면 적합하지만, Element 타입이 자주 바뀐다면 도입 비용이 커지므로 sealed class와 패턴 매칭 쪽을 고려하는 편이 낫다. Visitor 인터페이스에 기본(default) 메서드를 두면 후방 호환성을 확보할 수 있지만 누락된 로직을 탐지하기 위한 로깅·메트릭을 병행해야 하고, 순회 책임을 ObjectStructure로 분리해두면 방문 순서·에러 정책·병렬화 전략을 한곳에서 관리할 수 있다. 기본은 무상태(Stateless)로 가되, 상태가 필요하면 불변으로 수집한 뒤 커밋하는 2단계 방식을 쓴다. 의존 순환이나 인터페이스 변화 비용이 부담스럽다면 Acyclic Visitor, Reflective Visitor 같은 변형도 있지만, 이는 성능·안정성과 트레이드오프 관계에 있다.
정량적으로는 신규 연산 추가 시 Element를 바꾸지 않아도 되므로 관련 변경 라인 수가 2040% 감소하는 것으로 기대되고(코드베이스·언어에 따라 변동), 연산 단위 테스트가 가능해지면서 커버리지가 1025%p 개선될 수 있다고 제시된다. 그 외에 캐스팅이 사라지고 런타임 오류가 컴파일 타임 오류로 전환되면서 ClassCastException이 줄고, 횡단 관심사가 분리되면서 코드 가독성과 리뷰 효율도 오르는 것으로 보고된다.