컴파일러를 짠다고 하자. 소스 코드를 파싱하면 추상 구문 트리(AST)가 나온다 — 변수 참조, 상수, 대입, 덧셈 같은 노드들이 부모-자식으로 엮인 트리다. 노드의 종류는 언어 문법이 정해 놓아서 좀처럼 바뀌지 않는다. VariableRef, Constant, Assignment… 문법이 안정되면 이 목록도 안정된다. 그런데 이 트리를 놓고 하는 일은 계속 늘어난다. 타입 검사를 해야 하고, 코드 최적화를 돌려야 하고, 예쁘게 출력(pretty-print)해야 하고, 나중엔 정적 분석까지 붙는다. 노드 종류는 굳어 있는데 그 위에서 돌릴 연산이 자꾸 새로 생기는 것이다.
가장 자연스러운 첫 수는 각 노드 클래스에 메서드를 하나씩 다는 것이다. VariableRef.typeCheck(), VariableRef.optimize(), VariableRef.print()… 그런데 연산을 하나 추가할 때마다 모든 노드 클래스를 열어 메서드를 한 개씩 심어야 한다. 타입 검사 로직은 열 개 노드 파일에 흩어지고, 그 열 조각을 다 봐야 타입 검사 하나를 이해한다. 방문자(Visitor) 패턴은 이 흩어진 연산을 연산 하나당 객체 하나로 걷어 모아, 노드 구조는 그대로 둔 채 새 연산을 밖에서 더할 수 있게 한다.
무엇이 문제인가 — 동기
연산을 노드 안에 넣는 방식이 왜 아픈지 구체적으로 세어 보자. 노드가 M종류, 연산이 N개라면 이 설계는 M×N개의 메서드를 노드 쪽에 흩뿌린다. 여기서 세 가지가 동시에 곪는다.
첫째, 하나의 연산이 M개 파일로 흩어진다. 타입 검사라는 한 가지 관심사가 VariableRef, Constant, Assignment 등 모든 노드 클래스에 조각조각 나뉘어 산다. 타입 검사 규칙을 이해하거나 고치려면 노드 파일 열 개를 오가야 한다. 서로 관련 없는 연산들(타입 검사·최적화·출력)이 한 노드 클래스 안에 나란히 눌러앉아, 노드 클래스는 자기 본분(트리의 한 조각을 표현)과 무관한 코드로 부푼다.
둘째, 새 연산 추가가 모든 노드의 수정을 강요한다. 정적 분석이라는 연산을 새로 붙이려면 안정되어 있던 노드 클래스를 M개 전부 다시 열어 staticAnalyze()를 심어야 한다. 굳어 있어야 할 구조가 연산이 늘 때마다 흔들린다. 개방-폐쇄 원칙이 “연산 확장” 축에서 무너진다 — 새 연산을 더하려고 기존 노드 전부를 수정하게 된다.
셋째, 관련 없는 연산이 노드의 응집을 깬다. 최적화에만 쓰이는 무거운 라이브러리를, 그 최적화 메서드가 노드 안에 있다는 이유로 노드 클래스가 의존하게 된다. 출력에나 필요한 상태가 노드에 얹힌다. 노드는 “무엇인가”를 표현하는 자리인데, “무엇을 당하는가”의 코드까지 떠안아 비대해진다.
판단 기준: “객체 구조(노드 종류)는 안정적인데 그 위에서 돌릴 연산이 자주 늘어난다”가 보이면 방문자를 의심한다. 함정: 반대로 노드 종류가 자꾸 바뀌는데 연산은 고정이라면 방문자는 최악의 선택이다 — 뒤에서 보듯 방문자는 정확히 그 축에 취약하다. 두 축 중 어느 쪽이 자주 변하는지를 먼저 판별하지 않고 방문자를 꺼내면 대가만 치른다.
패턴의 구조 — 누가 무엇을 하나
방문자는 배역이 둘로 갈린다. 트리를 이루는 원소(Element) 쪽과, 원소를 돌며 일하는 방문자(Visitor) 쪽이다. 핵심은 이 둘이 accept와 visit이라는 두 번의 호출로 만난다는 점이다.
Element(원소 인터페이스). 모든 노드가 공유하는 계약은 놀랍게도 연산이 아니라 void accept(Visitor v) 하나다. “나를 방문할 방문자를 받아들인다”는 뜻이다. 각 구체 노드(VariableRef, Constant…)는 이 accept를 자기 자신을 넘기며 방문자의 대응 메서드를 호출하도록 구현한다 — v.visitVariableRef(this)처럼.
Visitor(방문자 인터페이스). 노드 종류마다 하나씩, 방문 메서드를 선언한다 — visitVariableRef(VariableRef v), visitConstant(Constant c), visitAssignment(Assignment a)… 노드가 M종류면 방문자 인터페이스에 M개의 메서드가 있다.
ConcreteVisitor(구체 방문자들). 한 연산이 곧 한 방문자 클래스다. TypeCheckVisitor는 M개의 visitXxx에 각 노드에 대한 타입 검사 로직을 담고, OptimizeVisitor는 같은 M개 메서드에 최적화 로직을 담는다. 흩어져 있던 “타입 검사 하나”가 이제 한 클래스 안에 다 모인다. 새 연산은 새 방문자 클래스 하나로 끝난다 — 노드는 한 줄도 안 바뀐다.
여기서 결정적 장치가 **더블 디스패치(double dispatch)**다. 보통의 메서드 호출은 싱글 디스패치다 — x.foo()는 x의 실제 타입 하나만 보고 어떤 foo를 부를지 정한다. 그런데 방문자가 풀려는 문제는 “어떤 노드에 어떤 연산을 적용할까”라는 두 타입의 교차점을 고르는 것이다. 한 번의 가상 호출로는 축이 하나뿐이라 이걸 못 고른다. 그래서 호출을 두 번 튕긴다. 첫 번째 디스패치 node.accept(visitor)는 노드의 실제 타입을 결정한다(VariableRef.accept가 불린다). 그 안에서 두 번째 디스패치 visitor.visitVariableRef(this)가 방문자의 실제 타입을 결정한다(TypeCheckVisitor.visitVariableRef가 불린다). 두 번 튕겨서 (노드 타입 × 방문자 타입) 두 축을 다 반영한 정확한 메서드에 도달한다. accept의 몸통이 노드마다 겨우 한 줄(v.visitXxx(this))인데도 반드시 각 노드에 있어야 하는 이유가 이것이다 — 그 한 줄이 컴파일 시점에 this의 정적 타입을 알아 올바른 visitXxx를 지목한다.
판단 기준: “노드 타입과 연산 타입, 두 가지에 동시에 따라 동작이 갈려야 한다”가 문제의 본질이면 더블 디스패치가 필요하고, 방문자가 그 표준 구현이다. 함정: accept 안에서 if (this instanceof VariableRef) ...로 노드 타입을 손수 분기하면 더블 디스패치를 흉내만 낸 것이다 — 그 분기 덩어리가 곧 우리가 없애려던 흩어짐이다. 반드시 각 노드의 accept가 자기 짝 visitXxx를 정적으로 지목하게 두어야 컴파일러가 대신 분기해 준다.
코드로 — 패턴이 자라나는 과정
노드에 흩어진 연산에서 출발해, 연산을 방문자로 걷어 모으고, 더블 디스패치로 잇는 과정을 단계로 따라간다.
// 노드 종류는 안정적이지만, 연산(print·typeCheck)이 모든 노드에 박혀 있다.// 연산을 하나 더 붙이면 아래 세 클래스를 전부 열어 메서드를 심어야 한다.public interface Node { }public class Constant implements Node { final int value; Constant(int value) { this.value = value; } public String print() { return Integer.toString(value); } public String typeCheck(){ return "int"; }}public class VariableRef implements Node { final String name; VariableRef(String name) { this.name = name; } public String print() { return name; } public String typeCheck(){ return lookupType(name); } // 환경에서 타입 조회}public class Addition implements Node { final Node left, right; Addition(Node left, Node right) { this.left = left; this.right = right; } public String print() { return "(" + left.print() + " + " + right.print() + ")"; } public String typeCheck() { // 좌우가 모두 int여야 int, 아니면 오류 — 자식으로 재귀 return left.typeCheck().equals("int") && right.typeCheck().equals("int") ? "int" : "error"; }}// print 하나가 세 클래스에 흩어져 있다. typeCheck도 마찬가지.// '최적화'를 추가하려면 세 클래스를 다시 열어 optimize()를 세 번 심어야 한다.
마지막 스텝이 방문자의 값과 대가를 한 화면에서 보여 준다. OptimizeVisitor 하나를 추가하는 것으로 새 연산이 끝났다 — 노드 세 클래스는 불변이다. 이것이 방문자가 사는 값이다. 그러나 시선을 반대로 돌려 보라. 만약 새 노드 Multiplication을 추가한다면? Visitor 인터페이스에 visitMultiplication을 더해야 하고, 그러면 PrintVisitor·TypeCheckVisitor·OptimizeVisitor 모든 방문자가 그 새 메서드를 구현하도록 강제로 다시 열린다. 연산 추가는 한 곳, 노드 추가는 모든 곳 — 이 비대칭이 방문자의 정체다.
판단 기준: 리팩터링 후 “새 연산 추가 = 새 방문자 하나, 노드 0줄 수정”이 성립하고, 그 대칭항인 “새 노드 추가 = 모든 방문자 수정”의 대가를 감수할 수 있으면 방문자가 제자리에 앉은 것이다. 함정: accept의 반환을 void로만 두고 결과를 방문자의 필드에 쌓는 옛 스타일은, 방문자가 상태를 품어 재사용·병렬에 취약해진다 — 위처럼 accept가 결과를 반환하게 하면 방문자를 무상태로 유지하기 쉽다.
결과 — 무엇을 얻고 무엇을 내주나
방문자가 사는 값은 연산의 응집과 연산 축의 개방-폐쇄다. 열 노드에 흩어져 있던 한 연산이 한 클래스로 모여, 타입 검사를 이해하려 노드 파일을 순례할 필요가 없다. 서로 관련된 연산 코드가 한자리에 있으니 그 연산이 쓰는 상태·헬퍼도 그 방문자 안에 함께 산다(최적화용 무거운 의존성이 노드를 오염시키지 않는다). 새 연산은 노드를 건드리지 않고 방문자 클래스 하나로 더해진다. 게다가 방문자는 트리를 훑으며 상태를 누적하기 좋다 — 노드 개수를 세거나, 전체 타입 오류를 모으는 일은 방문자가 자기 필드에 쌓으면 된다. 흩어진 연산 방식으로는 이 누적 상태를 어디에 둘지부터 곤란하다.
대가는 방문자의 본질이자 가장 아픈 지점이다. 첫째, 새 원소(노드) 종류 추가가 취약하다. 노드를 하나 더하면 Visitor 인터페이스가 커지고 모든 구체 방문자가 깨져 다시 열린다. 방문자는 “연산은 자주, 노드는 드물게 바뀐다”는 가정에 목숨을 건다 — 그 가정이 틀린 곳(노드가 자주 느는 도메인)에서는 최악의 패턴이다. 개방-폐쇄를 한 축(연산)에서만 얻고 반대 축(노드)에서는 오히려 포기한다. 둘째, 캡슐화를 깬다. 방문자가 노드 밖에서 노드의 데이터를 읽어 일을 하려면, 노드는 자기 내부(Addition.left, right)를 방문자에게 열어 줘야 한다. 연산을 노드 안에 두었다면 private로 감췄을 상태가 밖으로 새어 나온다. 셋째, 더블 디스패치의 배선 비용이 늘 따라온다. 노드마다 거의 똑같은 accept 한 줄을 반복해 두어야 하고, 방문자 인터페이스가 노드 목록과 손으로 동기화된 채 유지돼야 한다 — 이 짝이 어긋나면 컴파일 에러나 누락된 방문으로 드러난다.
운영비용 렌즈로 보면 순이득의 조건이 칼같이 갈린다. 연산 축이 변화의 주축이고 노드 축이 안정적일 때만 방문자는 이득이다(컴파일러·AST가 교과서 사례인 이유 — 문법은 굳고 분석 패스는 는다). 두 축이 다 변하거나 노드 축이 더 자주 변하면, 방문자는 노드 추가마다 전 방문자를 흔드는 세금만 물린다. 이때는 차라리 연산을 노드에 두는 원래 방식이 정직하다.
판단 기준: 얻는 것(연산 응집·연산 확장·누적 상태)과 내주는 것(노드 추가 취약·캡슐화 파괴·배선 비용)을 세되, 결정타는 “연산과 노드 중 무엇이 더 자주 변하는가” 한 문항이다. 연산이면 방문자, 노드면 방문자 금지. 함정: 유연성이라는 말에 홀려 방문자를 넣고 보면, 정작 그 프로젝트에서 자주 바뀌는 게 노드 쪽이라 매 스프린트 전 방문자를 고치게 된다 — 방문자는 변화의 방향을 거꾸로 짚으면 이득이 통째로 마이너스가 된다.
언제 쓰고, 언제 과용인가
방문자가 정확히 맞는 자리는 이렇다. 서로 다른 종류의 노드로 이뤄진 안정된 객체 구조가 있고(컴포지트로 짠 트리가 대표적이다), 그 위에서 돌릴 연산이 자꾸 늘어나며, 그 연산들이 서로 무관해 각각 한자리에 모여 있길 원할 때. 컴파일러의 AST 순회(타입 검사·최적화·코드 생성 패스가 계속 는다), 파일 시스템 트리 위의 여러 처리(용량 집계·권한 검사·백업), 문서 트리의 내보내기(HTML·PDF·평문)가 그렇다. 트리 순회 자체는 반복자가 원소를 하나씩 꺼내 주고, 그 원소들에 타입별로 다른 연산을 먹이는 일은 방문자가 맡는 식으로 둘이 협력하기도 한다. 문법을 클래스 계층으로 세우는 인터프리터와는 특히 붙어 다닌다 — 인터프리터가 만든 구문 트리에 해석 외의 연산(타입 검사·예쁜 출력)을 얹을 때 방문자가 그 연산들을 노드 밖으로 걷어 낸다.
과용의 냄새는 세 방향에서 난다. 하나, 노드 종류가 아직 안 굳었을 때. 도메인이 초기라 노드가 계속 추가·변경되는데 방문자를 깔면, 노드 하나 더할 때마다 전 방문자를 수정하는 세금을 문다 — 구조가 안정되기 전에는 연산을 노드에 두는 편이 싸다. 둘, 연산이 하나뿐이거나 늘 일이 없을 때. 트리를 딱 한 방식으로만 출력한다면 accept/visit의 이중 배선은 순수한 오버헤드다. 셋, 캡슐화를 꼭 지켜야 할 때. 노드의 내부를 절대 밖으로 열 수 없는 설계라면, 방문자가 요구하는 데이터 접근이 그 원칙과 충돌한다.
여기서 이 시리즈가 되풀이한 축을 다시 못박아 두자. 연산을 노드에 넣는 방식과 방문자는 개방-폐쇄의 반대 축을 산다. 전자는 노드 추가가 쉽고(새 노드 클래스에 연산 메서드를 다 담으면 끝, 기존 노드 불변) 연산 추가가 어렵다(전 노드 수정). 방문자는 정확히 뒤집혀 연산 추가가 쉽고 노드 추가가 어렵다. 어떤 설계도 두 축을 동시에 열어 주지는 못한다 — 이것이 “표현 문제(expression problem)“라 불리는 근본 긴장이고, 방문자는 그 긴장에서 “연산 축을 열고 노드 축을 닫는” 한쪽 선택을 명시적으로 고른 패턴이다.
판단 기준: 두 축 중 실제로 자주 변하는 쪽을 열어 주는 설계를 고른다 — 연산이 주축이면 방문자, 노드가 주축이면 연산을 노드에 두기. 함정: “방문자는 확장에 유리한 고급 패턴”이라는 인상만으로 집으면, 노드가 자주 느는 도메인에서 매번 전 방문자를 깨뜨리며 확장에 불리해진다 — 방문자의 유불리는 절대적이지 않고 어느 축이 변하느냐에 통째로 뒤집힌다.
다음으로 결론