코딩 · 디자인 패턴
비지터 패턴 예제로 쉽게 이해하기
핵심 답변
비지터(Visitor)는 관련된 클래스 그룹에 그 클래스들을 바꾸지 않고 새 연산을 추가하게 하는 행동 패턴입니다. 각 연산은 요소 타입마다 메서드 하나를 가진 비지터 객체로 작성되고, 각 요소는 알맞은 비지터 메서드를 호출하는 accept(visitor) 메서드를 가집니다. 더블 디스패치라고 하는 이 기법은 요소 타입과 비지터 타입 둘 다에 따라 실행할 코드를 고릅니다. 컴파일러, 린터, 코드 변환기는 비지터로 구문 트리를 순회합니다.
의도와 해결하는 문제
의도: 객체 구조의 요소에 수행할 연산을 표현합니다. 비지터는 연산 대상 요소의 클래스를 바꾸지 않고 새 연산을 정의하게 합니다. GoF(Gang of Four) 책에 실린 행동 패턴 중 하나입니다.
그림 그리기 프로그램에 Circle, Rectangle, Group 세 도형 클래스가 있다고 가정해 봅시다. 시간이 지나면서 전체 넓이 계산, SVG 내보내기, JSON 내보내기, 도형 개수 세기, 겹침 검사 같은 많은 연산이 필요해집니다. 모든 연산이 모든 도형 클래스의 메서드가 되면, 도형 클래스는 도형이라는 것과 상관없는 코드로 비대해지고 새 연산마다 전부 수정해야 합니다.
비지터 패턴에서는 각 연산이 모든 도형 타입에 대한 코드를 담은 클래스 하나(넓이 비지터, SVG 비지터)가 됩니다. 도형에는 일반적인 메서드 accept 하나만 있으면 됩니다. 연산을 추가하려면 새 비지터 하나를 작성하면 되고 도형 클래스는 바뀌지 않습니다.
구조와 참여자
| 역할 | 책임 |
|---|---|
| 비지터(Visitor) | visitCircle, visitRectangle처럼 구체 요소 타입마다 visit 메서드 하나를 가진 인터페이스 |
| 구체 비지터(Concrete visitor) | 모든 요소 타입에 대한 연산 하나를 구현하며 결과를 모을 수 있습니다 |
| 요소(Element) | accept(visitor) 메서드를 가진 인터페이스 |
| 구체 요소(Concrete element) | 자신의 타입에 해당하는 visit 메서드를 자기 자신을 넘겨 호출하는 방식으로 accept를 구현합니다 |
| 객체 구조(Object structure) | 문서나 구문 트리처럼 비지터가 돌아다니는 요소의 컬렉션 또는 트리 |
accept가 필요한 이유: TypeScript, Java, C#에서 메서드 호출은 호출 대상 객체의 타입으로 구현을 고릅니다(싱글 디스패치). accept 호출은 요소 타입으로 디스패치하고, 그 안의 visitor.visitCircle(this) 같은 호출은 비지터 타입으로 디스패치합니다. 둘이 합쳐져 더블 디스패치가 됩니다.
TypeScript 구현
비지터 인터페이스는 반환 타입에 대해 제네릭이므로, 어떤 비지터는 숫자를 계산하고 다른 비지터는 문자열을 만들 수 있습니다.
interface ShapeVisitor<R> {
visitCircle(c: Circle): R;
visitRectangle(r: Rectangle): R;
visitGroup(g: Group): R;
}
interface Shape {
accept<R>(visitor: ShapeVisitor<R>): R;
}
class Circle implements Shape {
constructor(readonly radius: number) {}
accept<R>(v: ShapeVisitor<R>) { return v.visitCircle(this); }
}
class Rectangle implements Shape {
constructor(readonly width: number, readonly height: number) {}
accept<R>(v: ShapeVisitor<R>) { return v.visitRectangle(this); }
}
class Group implements Shape {
constructor(readonly children: Shape[]) {}
accept<R>(v: ShapeVisitor<R>) { return v.visitGroup(this); }
}
class AreaVisitor implements ShapeVisitor<number> {
visitCircle(c: Circle) { return Math.PI * c.radius ** 2; }
visitRectangle(r: Rectangle) { return r.width * r.height; }
visitGroup(g: Group): number {
return g.children.reduce((sum, child) => sum + child.accept(this), 0);
}
}
class SvgVisitor implements ShapeVisitor<string> {
visitCircle(c: Circle) { return '<circle r="' + c.radius + '"/>'; }
visitRectangle(r: Rectangle) { return '<rect width="' + r.width + '" height="' + r.height + '"/>'; }
visitGroup(g: Group): string { return "<g>" + g.children.map((c) => c.accept(this)).join("") + "</g>"; }
}
const drawing = new Group([new Circle(1), new Rectangle(2, 3), new Group([new Rectangle(1, 1)])]);
console.log(drawing.accept(new AreaVisitor()).toFixed(2)); // 10.14
console.log(drawing.accept(new SvgVisitor()));
// <g><circle r="1"/><rect width="2" height="3"/><g><rect width="1" height="1"/></g></g>도형 개수 세기 같은 세 번째 연산은 ShapeVisitor<number>를 구현하는 새 클래스가 됩니다. 도형 클래스는 하나도 바뀌지 않습니다.
TypeScript의 대안: 유니온 타입과 switch
TypeScript에서는 판별 유니온과 완전한 switch가 더 적은 코드로 같은 이점을 많이 줍니다. 케이스가 빠지면 컴파일러가 오류를 알려 주므로 비지터 인터페이스가 주던 안전성을 대신합니다.
type Shape =
| { kind: "circle"; radius: number }
| { kind: "rect"; width: number; height: number }
| { kind: "group"; children: Shape[] };
function area(s: Shape): number {
switch (s.kind) {
case "circle": return Math.PI * s.radius ** 2;
case "rect": return s.width * s.height;
case "group": return s.children.reduce((sum, c) => sum + area(c), 0);
default: {
const unreachable: never = s; // compile error if a kind is not handled
return unreachable;
}
}
}
console.log(area({ kind: "group", children: [{ kind: "rect", width: 2, height: 3 }] })); // 6새 연산은 새 함수가 됩니다. 이 방식은 JSON이나 구문 트리 같은 일반 데이터에 어울리며, 많은 TypeScript 코드베이스가 클래스 기반 비지터 대신 사용합니다.
언제 쓰고 언제 쓰지 말아야 할까
- 요소 클래스 집합이 안정적이고 구문 트리의 평가, 출력, 타입 검사, 최적화처럼 서로 관련 없는 연산이 많이 필요할 때 사용합니다.
- 연산 코드를 한곳에 모으고 싶을 때 사용합니다. SVG 내보내기 전체가 여러 클래스에 흩어지지 않고 하나의 비지터에 있습니다.
- 심볼 테이블이나 누적 합계처럼 비지터가 구조를 돌며 상태를 쌓아야 할 때 사용합니다.
- 요소 타입이 자주 바뀐다면 사용하지 않습니다. 변경이 모든 비지터로 파급됩니다.
- 요소가 적고 연산이 단순하다면 사용하지 않습니다. 클래스의 메서드나
switch를 쓴 함수가 읽기 더 쉽습니다. - 비지터는 보통 요소의 데이터에 접근해야 하므로 필드를 공개하게 될 수 있다는 점에 유의하세요.
실제 사용 사례
- Babel 플러그인은 비지터입니다.
Identifier나CallExpression처럼 노드 타입 이름의 메서드를 가진 객체이며,@babel/traverse가 구문 트리를 순회하며 이를 호출합니다. - ESLint 규칙은 노드 타입을 키로 하는 비지터 메서드 객체를 반환하고, ESLint가 순회 중에 이를 호출합니다.
- Python의
ast.NodeVisitor와ast.NodeTransformer클래스는visit_Name처럼 노드 클래스 이름을 딴visit_메서드를 호출합니다. Files.walkFileTree와 함께 쓰이는 Java의java.nio.file.FileVisitor는 파일 트리의 각 디렉터리와 파일에 대한 콜백을 받습니다.
관련 패턴
| 패턴 | 비지터 패턴과의 관계 |
|---|---|
| 컴포지트 | 비지터는 컴포지트 구조에 자주 적용됩니다. 예제처럼 그룹의 visit 메서드는 자식들을 방문합니다. |
| 인터프리터 | 인터프리터는 구문 트리를 평가하고, 비지터는 그 평가는 물론 같은 트리에 대한 다른 연산도 구현할 수 있습니다. |
| 이터레이터 | 이터레이터는 요소를 돌아다니고, 비지터는 각 요소에 대한 연산을 정의합니다. 둘을 결합할 수 있습니다. |
| 전략 | 전략은 하나의 컨텍스트를 위한 하나의 알고리즘을 달리하고, 비지터는 여러 요소 타입에 대한 하나의 연산을 묶습니다. |
자주 묻는 질문
더블 디스패치란 무엇인가요?
더블 디스패치는 실행되는 코드가 두 객체의 타입에 달려 있다는 뜻입니다. 비지터 패턴에서 element.accept(visitor)가 요소 타입을 고르고, 그 안의 visitor.visitX(this)가 비지터 타입을 고릅니다.
비지터 패턴을 쓰지 말아야 할 때는 언제인가요?
새 요소 타입이 자주 추가될 때는 피하세요. 새 타입마다 모든 비지터를 고쳐야 하기 때문입니다. 간단한 연산 몇 개에는 감수할 만큼 가치가 없는 격식을 더하기도 합니다.
TypeScript에서 비지터가 유용한가요?
클래스 기반 객체 구조에는 유용합니다. 일반 데이터에는 보통 판별 유니온과 완전한 switch가 더 적은 코드와 완전한 타입 검사로 같은 결과를 냅니다.
Babel과 ESLint는 왜 비지터를 사용하나요?
둘 다 노드 타입이 많은 구문 트리를 다루며 서로 독립적인 많은 플러그인이나 규칙을 지원합니다. 비지터는 각 플러그인이나 규칙이 관심 있는 노드 타입만 처리하게 하고, 순회는 도구가 맡게 합니다.