비지터 패턴, 이중 디스패치 원리와 sealed 타입 대안
비지터 패턴은 객체 구조와 연산을 분리하는 GoF 행동 패턴으로, accept와 visit 호출이 겹치는 이중 디스패치로 동작한다. 구조와 원리, AST 순회 같은 실무 쓰임새, sealed 타입·패턴 매칭 대안까지 개발자 눈높이로 정리했다.
목차
비지터 패턴은 객체 구조와 그 구조에 수행하는 연산을 떼어 놓는 설계 기법이다. GoF(Gang of Four)가 정리한 23개 디자인 패턴 가운데 행동(behavioral) 패턴에 속한다. 요소 클래스는 accept 메서드 하나만 두고 실제 작업은 방문자(visitor) 객체 쪽에 몰아넣는다. 그러면 요소 코드를 건드리지 않고도 새 연산을 계속 붙일 수 있다. 이때 실행할 메서드는 요소 타입과 방문자 타입 두 가지에 따라 정해지는데, 이 방식을 이중 디스패치라고 부른다.
이 글은 객체지향 언어를 쓰는 개발자를 대상으로 패턴의 구조와 이중 디스패치의 원리, AST 순회 같은 실무 쓰임새, 그리고 sealed 타입과 패턴 매칭이라는 대안까지 다룬다. 특정 프레임워크 사용법이 아니라 언어에 무관한 설계 개념이 초점이다. 개념은 GoF의 정의를 기준으로 삼았고, 클래스 다이어그램을 곁들인 구조 설명은 디자인 패턴 참고 사이트 Refactoring.Guru에서 확인할 수 있다.
비지터 패턴은 어떤 문제를 푸는가
여러 종류의 객체를 순회하며 타입마다 다른 작업을 하되, 그 작업 코드를 객체 안에 두고 싶지 않을 때 쓴다. 전형적인 상황을 하나 떠올려 보자. 원, 사각형, 선을 담은 도형 집합이 있고 여기에 넓이 계산, 화면 렌더링, JSON 내보내기 같은 연산을 붙이고 싶다.
각 도형 클래스에 area(), render(), toJson()을 직접 넣는 방법이 가장 먼저 떠오른다. 하지만 이렇게 하면 도형 클래스가 넓이 계산부터 직렬화까지 온갖 관심사로 부풀고, 새 연산이 하나 생길 때마다 원·사각형·선을 모두 열어 고쳐야 한다. 도형이 열 종류라면 연산 하나 추가에 열 곳을 손대는 셈이다.
비지터는 이 관계를 뒤집는다. 연산 하나가 방문자 클래스 하나가 된다. AreaVisitor, RenderVisitor, JsonExportVisitor를 각각 만들면 도형 클래스는 그대로 둔 채 새 연산을 얼마든지 추가한다. 흩어져 있던 넓이 계산 로직이 AreaVisitor 한 곳에 모이니 관련 코드를 한눈에 읽는 이점도 따라온다.
이중 디스패치는 어떻게 동작하는가
이중 디스패치는 실행할 메서드를 두 객체의 런타임 타입으로 고르는 방식이다. accept 호출 한 번과 visit 호출 한 번, 이렇게 두 번의 동적 디스패치가 겹쳐서 이름이 이중 디스패치다.
왜 두 번이 필요한지는 단일 디스패치의 한계를 봐야 이해가 된다. 자바, C#, 스위프트를 비롯한 대부분의 객체지향 언어는 단일 디스패치만 지원한다. 메서드를 부르면 점을 찍은 왼쪽, 즉 수신 객체의 실제 타입 하나만으로 구현을 고른다. 인자 쪽은 오버로딩 해석 때 컴파일 시점 타입으로만 본다. 그래서 process(shape)에서 shape의 선언 타입이 Shape이면 런타임 값이 Circle이어도 process(Shape)가 불린다.
비지터는 이 벽을 두 단계로 넘는다. 먼저 node.accept(visitor)를 부르면 node의 실제 타입에 따라 알맞은 accept가 선택된다. 이게 첫 번째 디스패치다. 그 accept 안에서 visitor.visit(this)를 부르는데, 이 시점의 this는 이미 구체 타입으로 확정돼 있어 그 타입에 맞는 visit 오버로드로 연결되고, 방문자의 실제 타입에 따라 어느 구현이 실행될지 정해진다. 이게 두 번째 디스패치다.
interface Node {
void accept(Visitor visitor);
}
class NumberNode implements Node {
final double value;
NumberNode(double value) { this.value = value; }
public void accept(Visitor visitor) { visitor.visit(this); }
}
class AddNode implements Node {
final Node left, right;
AddNode(Node left, Node right) { this.left = left; this.right = right; }
public void accept(Visitor visitor) { visitor.visit(this); }
}
interface Visitor {
void visit(NumberNode node);
void visit(AddNode node);
}눈여겨볼 대목은 각 accept 안의 visitor.visit(this)가 글자만 보면 전부 똑같다는 점이다. 그런데도 this의 타입이 클래스마다 달라 서로 다른 visit 오버로드로 갈라진다. 이 한 줄이 이중 디스패치의 핵심 장치다.

AST 순회, 비지터가 가장 빛나는 자리
비지터가 가장 자연스럽게 맞아떨어지는 곳은 컴파일러와 정적 분석 도구의 AST 순회다. AST는 추상 구문 트리(소스 코드를 파싱해 만든 트리 구조)를 말하며, 리터럴·이항 연산·함수 호출·조건문처럼 노드 종류가 수십 가지에 이른다.
이 하나의 트리를 두고 타입 검사, 최적화, 코드 생성, 보기 좋게 출력하기 같은 여러 패스가 돌아간다. 패스마다 노드 타입별 처리가 다르고, 노드 종류는 언어 문법이 정해지면 잘 바뀌지 않는다. 연산은 계속 늘고 타입은 안정적인 이 구조가 비지터에 딱 맞는다. AST도 결국 트리라서 순회 자체는 이진 트리를 순회하는 방식과 같은 재귀 구조를 따른다. 위 예제에서 AddNode를 만나면 좌우 자식을 다시 방문하는데, 이 반복이 바로 문제를 잘게 쪼개는 재귀 호출이다.
실제 API에서도 확인된다. 자바 표준 라이브러리의 javax.lang.model.element 패키지는 애너테이션 프로세서가 프로그램 요소를 순회하도록 ElementVisitor 인터페이스를 제공한다. 오라클 자바 문서에 이 인터페이스가 요소 종류별 visit 메서드를 갖춘 방문자 계약으로 정의돼 있다. 여러 컴파일러와 인터프리터가 각 패스를 별도 방문자 클래스로 나눠 구현하는 것도 같은 발상이다.
요소를 추가할 때 치르는 비용
장점만 있는 패턴은 없다. 비지터의 약점은 방향이 반대인 변경에서 드러난다. 새 연산은 방문자 하나만 추가하면 끝나지만, 새 요소 타입을 추가하면 기존 방문자를 전부 고쳐야 한다.
앞의 도형 예제에 삼각형을 넣는다고 해 보자. AreaVisitor, RenderVisitor, JsonExportVisitor에 모두 visit(Triangle)을 구현해야 한다. 하나라도 빠뜨리면 컴파일 에러가 나거나, 인터페이스 설계에 따라서는 조용히 처리가 누락된다. 연산 추가는 싸고 타입 추가는 비싼 이 비대칭을 표현 문제(expression problem)라고 부른다. 각 클래스에 메서드를 직접 넣는 평범한 방식은 정확히 반대다. 타입 추가는 싸고 연산 추가가 비싸다.
비용은 또 있다. 방문자가 요소의 private 필드에 닿지 못해 접근자를 열어야 하면 캡슐화가 느슨해진다. accept와 visit을 오가는 왕복 흐름도 코드를 따라 읽기 어렵게 만든다. 신입에게 설명할 때 한 번에 이해시키기 까다로운 패턴에 든다. 그래서 선택 기준은 분명하다. 요소 타입이 안정적이고 연산이 꾸준히 늘어나는 구조라면 비지터가 값을 한다. 반대라면 오히려 짐이 된다.
sealed 타입과 패턴 매칭이라는 대안
요즘 언어에서는 sealed 타입과 패턴 매칭 switch로 비지터가 하던 일을 더 짧게 처리한다. 오라클 자바 문서 기준으로 sealed 타입은 자바 17, switch의 패턴 매칭은 자바 21에서 정식 기능이 됐다. 여기에 record까지 곁들이면 트리 구조를 훨씬 간결하게 표현한다.
sealed interface Node permits NumberNode, AddNode {}
record NumberNode(double value) implements Node {}
record AddNode(Node left, Node right) implements Node {}
static double eval(Node node) {
return switch (node) {
case NumberNode n -> n.value();
case AddNode a -> eval(a.left()) + eval(a.right());
};
}핵심은 sealed로 Node의 구현 목록을 컴파일 시점에 못 박는다는 데 있다. 그러면 switch가 모든 경우를 덮었는지 컴파일러가 검사한다. 새 노드 타입을 추가하고 switch에 case를 안 넣으면 컴파일이 실패한다. 비지터가 런타임까지 미루던 누락 처리를 컴파일 시점에 잡아 주는 셈이다. accept와 visit을 오가는 왕복도 사라지고 분기 로직이 switch 한 곳에 모인다. 코틀린도 sealed class와 when 조합으로 같은 결과를 낸다.
| 기준 | 비지터 패턴 | sealed 타입 + 패턴 매칭 |
|---|---|---|
| 연산 추가 | 방문자 클래스 하나 추가 | switch 블록 하나 추가 |
| 요소 타입 추가 | 모든 방문자 수정 | 관련 switch에 case 추가 |
| 누락 감지 시점 | 런타임 또는 수동 확인 | 컴파일 타임 |
| 필요 언어 기능 | 대부분의 객체지향 언어 | sealed·패턴 매칭 지원 언어 |
그렇다고 비지터가 사라진 것은 아니다. 언어가 sealed 타입이나 패턴 매칭을 지원하지 않거나, 방문자가 순회 도중 상태를 누적하며 복잡한 계산을 이어 가야 할 때는 여전히 제값을 한다. 다만 자바 21 이상이나 코틀린처럼 도구가 갖춰진 환경에서 단순히 타입별로 분기하는 것이 목적이라면, sealed 타입과 switch가 더 단순하고 안전한 선택이다.





