← Posts

GoF · Behavioral · Visitor

Visitor

객체 구조는 그대로 두고, 그 위를 도는 새 연산을 바깥의 방문자로 추가하는 행위 패턴.

목차
  1. 새 연산을 넣으려면 요소를 다 건드린다
  2. 방문자 = 연산을 밖으로 빼낸다
  3. accept와 visit
  4. 더블 디스패치
  5. 새 연산 = 새 방문자
  6. 요소는 안 바뀐다
  7. 약점: 새 요소는 어렵다
  8. 실무: AST 순회
  9. 정리

새 연산을 넣으려면 요소를 다 건드린다

파일 트리가 있다. 요소는 FileFolder 두 종류다. 여기에 “전체 크기 계산”을 넣으려면 각 클래스에 메서드를 단다.

java
class File {
    int size() { ... }        // 파일 크기
}
class Folder {
    int size() { ... }        // 자식들 크기 합
}

여기까진 괜찮다. 그런데 “구조를 텍스트로 출력”이 필요해진다. 또 “이름 규칙 검증”도 필요해진다. 연산이 하나 늘 때마다 FileFolder둘 다 열어 메서드를 추가해야 한다. 요소가 열 종류라면 연산 하나에 열 클래스를 건드린다. 크기·출력·검증처럼 성격이 다른 관심사가 요소 클래스마다 뒤섞여 쌓인다. 요소를 바꾸는 게 아니라 요소 위에서 도는 연산을 늘리고 싶을 뿐인데, 매번 요소를 다 여는 게 문제다.

방문자 = 연산을 밖으로 빼낸다

비지터 패턴이 이걸 뒤집는다.

요소마다 연산을 심는 대신, 연산을 방문자(Visitor) 라는 별도 객체로 밖에 빼낸다. 새 연산은 새 방문자 하나로 추가한다.

크기 계산은 SizeVisitor, 출력은 PrintVisitor가 된다. 요소 클래스는 연산이 늘어도 열리지 않는다. 연산이 요소 안에 흩어지는 대신, 방문자 하나에 모인다.

accept와 visit

방문자는 요소 종류마다 visit 메서드를 갖는다.

java
public interface Visitor {
    void visitFile(File file);
    void visitFolder(Folder folder);
}

요소는 연산 대신 딱 하나, accept만 갖는다. “방문자를 받아들여, 나에 맞는 visit을 부르라”는 메서드다.

java
public interface Node {
    void accept(Visitor v);
}

public class File implements Node {
    final String name;
    final int size;
    // 생성자 생략

    @Override
    public void accept(Visitor v) {
        v.visitFile(this);        // 나는 File이니 visitFile을 부른다
    }
}

public class Folder implements Node {
    final String name;
    final List<Node> children = new ArrayList<>();

    @Override
    public void accept(Visitor v) {
        v.visitFolder(this);      // 나는 Folder이니 visitFolder를 부른다
    }
}

accept 안에서 무슨 일이 일어나는지가 이 패턴의 심장이다.

더블 디스패치

v.visitFile(this) 이 한 줄이 더블 디스패치다.

호출 하나로 두 번 갈래가 갈린다. 먼저 node.accept(v)에서 node가 File이냐 Folder냐에 따라 어느 accept가 불릴지 정해진다(첫 번째 디스패치). 그 accept 안에서 다시 vSizeVisitorPrintVisitor냐에 따라 어느 구현의 visitFile이 불릴지 정해진다(두 번째 디스패치).

diagramdiagram

자바의 메서드 호출은 인자 타입이 아니라 수신 객체 타입으로만 갈래를 정한다(단일 디스패치). “요소 종류 × 방문자 종류”라는 두 축을 동시에 맞추려면, accept로 한 번 튕기고 visitXxx로 또 한 번 튕겨 두 축을 이어 붙여야 한다. 그 두 번 튕김이 더블 디스패치다.

새 연산 = 새 방문자

이제 크기 계산은 방문자 하나다.

java
public class SizeVisitor implements Visitor {
    int total = 0;

    @Override
    public void visitFile(File file) {
        total += file.size;
    }

    @Override
    public void visitFolder(Folder folder) {
        for (Node child : folder.children) {
            child.accept(this);       // 자식으로 재귀
        }
    }
}

출력이 필요하면 PrintVisitor를 새로 만들 뿐, FileFolder는 한 줄도 안 건드린다.

java
public class PrintVisitor implements Visitor {
    @Override
    public void visitFile(File file)     { System.out.println("파일: " + file.name); }
    @Override
    public void visitFolder(Folder folder) {
        System.out.println("폴더: " + folder.name);
        for (Node child : folder.children) child.accept(this);
    }
}

쓰는 쪽은 방문자를 갈아끼우기만 한다.

java
SizeVisitor size = new SizeVisitor();
root.accept(size);
System.out.println(size.total);

root.accept(new PrintVisitor());

요소는 안 바뀐다

값어치는 요소 불변에 있다. 연산을 아무리 늘려도 요소 클래스는 열리지 않는다. 크기·출력·검증이 각자의 방문자로 갈라져, 서로 다른 관심사가 요소 안에서 뒤엉키지도 않는다.

성격이 다른 연산끼리 한데 모이는 이점도 있다. 크기 계산 로직이 FileFolder에 흩어지지 않고 SizeVisitor 한 곳에 있다. 연산을 읽고 고칠 때 한 파일만 보면 된다.

약점: 새 요소는 어렵다

공짜는 아니다. 비지터는 연산 추가는 쉽게, 요소 추가는 어렵게 만든다.

요소 종류를 하나 더하면(예: SymbolicLink) Visitor 인터페이스에 visitSymbolicLink가 생기고, 기존 방문자를 전부 열어 그 메서드를 구현해야 한다. 연산 추가와 정반대다.

그래서 이건 트레이드오프다. 요소 종류는 웬만해선 고정이고 연산이 자꾸 느는 구조라면 비지터가 맞다. 반대로 요소가 자주 느는 구조라면, 요소에 연산을 다형 메서드로 두는 평범한 방식이 낫다. 어느 축이 자주 변하느냐로 고른다.

실무: AST 순회

실무에서 비지터가 가장 빛나는 곳은 AST(추상 구문 트리) 순회다. 컴파일러와 인터프리터는 노드 종류(Literal, BinaryOp, If…)가 좀처럼 안 바뀌는 반면, 그 위를 도는 연산(타입 검사, 최적화, 코드 생성, 예쁜 출력)은 계속 는다. 요소는 고정, 연산은 증가 - 비지터가 정확히 겨냥하는 상황이다.

문서 트리 연산, 파일 트리 집계, 정적 분석 도구도 같은 이유로 비지터를 쓴다. 요소 종류가 안정적이고 그 위의 처리를 계속 붙여야 할 때다.

정리

방문자란연산을 요소 밖 방문자로 빼내 구조를 안 건드리고 추가한다
문제연산을 넣을 때마다 모든 요소 클래스를 열어 고쳐야 한다
accept·visit요소는 accept만, 방문자는 종류별 visit을 갖는다
더블 디스패치accept로 한 번, visitXxx로 또 한 번 튕겨 두 축을 맞춘다
효과새 연산은 새 방문자 하나, 요소는 불변
약점새 요소 종류를 더하면 모든 방문자를 고쳐야 한다

요소 구조는 그대로 두고, 그 위를 도는 연산만 바깥 방문자로 빼내 자유롭게 늘리는 것 - 그게 비지터다.

관련 글