brunch

제22장: 비지터 패턴

새로운 관점으로 바라보는 힘

by jeromeNa

박물관에는 다양한 방문객들이 찾아옵니다. 미술사학자는 작품의 역사적 배경과 기법을 분석하며, 사진작가는 조명과 구도에 주목합니다. 어린 학생은 재미있는 이야기를 찾고, 관광객은 유명한 작품들의 사진을 찍습니다. 보존 전문가는 작품의 상태와 보존 방법을 검토합니다.


똑같은 모나리자 그림을 보고도 각자는 완전히 다른 것을 봅니다. 미술사학자는 르네상스 시대의 기법을, 사진작가는 명암의 절묘한 조화를, 학생은 신비로운 미소의 이야기를, 관광객은 인스타그램에 올릴 멋진 샷을, 보존 전문가는 500년 된 캔버스의 상태를 봅니다.


작품 자체는 변하지 않습니다. 모나리자는 여전히 그 자리에 걸려있지만, 방문객이 바뀔 때마다 작품에서 추출되는 정보와 경험은 달라집니다. 각 방문객은 자신만의 "시선"과 "목적"을 가지고 작품을 바라보며, 작품은 그 시선에 따라 자신의 다른 면을 드러냅니다.


이처럼 기존 객체 구조(모나리자)는 그대로 두고, 새로운 연산이나 기능을 "방문객"으로 만들어 외부에서 주입하는 패턴이 '비지터 패턴(Visitor Pattern)'입니다.




1980년대 말 프로그래밍 언어가 점점 복잡해지면서, 컴파일러도 더 정교한 구조가 필요했습니다. 특히 추상 구문 트리(Abstract Syntax Tree, AST)를 처리하는 과정에서 근본적인 문제가 드러났습니다.


전통적인 방식에서는 각 노드 타입(변수, 연산자, 함수 등)이 자신을 처리하는 모든 방법을 내부에 가지고 있어야 했습니다. 코드 생성, 최적화, 타입 검사, 오류 검출 등의 기능이 모두 각 노드 클래스 안에 섞여 있었습니다. 이는 새로운 기능을 추가할 때마다 모든 노드 클래스를 수정해야 하는 문제를 야기했죠.


1990년대 초, 스탠퍼드 대학의 컴파일러 연구진들이 이 문제를 해결하기 위해 혁신적인 아이디어를 제시합니다. "노드의 구조"와 "노드를 처리하는 알고리즘"을 완전히 분리하는 것입니다. 노드는 단순히 자신의 데이터만 가지고, 처리 로직은 별도의 "방문객" 객체가 담당하도록 했습니다.


GOF 중 Erich Gamma는 ET++ GUI 프레임워크 개발 중 비슷한 문제에 직면했다고 회고합니다. 다양한 GUI 컴포넌트(버튼, 텍스트, 리스트 등)에 대해 그리기, 이벤트 처리, 레이아웃 계산 등의 기능을 추가해야 했는데, 각 컴포넌트 클래스에 모든 기능을 넣으면 클래스가 너무 복잡해집니다.


핵심은 "객체의 정체성(what)과 행동(how)을 분리"하는 것이었습니다. 객체는 자신이 무엇인지만 알면 되고, 그 객체로 무엇을 할지는 외부의 전문가(방문객)가 결정하도록 했습니다. 이는 전통적인 객체지향의 "데이터와 메서드의 결합" 원칙에 도전하는 혁신적인 접근이었습니다.


“Visitor” 패턴이라는 이름은 박물관의 방문객이 바뀜에 따라 같은 작품도 다르게 해석된다는 비유와 같이 구조는 그대로 두고, 방문자 역할을 하는 객체에 따라 동작이 달라지는 구조입니다.




컴퓨터 파일 시스템을 탐색하는 프로그램을 예제로 보겠습니다.

// 파일 시스템 요소 인터페이스
interface FileSystemElement {
void accept(FileSystemVisitor visitor);
String getName();
}

// 방문객 인터페이스
interface FileSystemVisitor {
void visitFile(File file);
void visitDirectory(Directory directory);
}

// 파일 클래스
class File implements FileSystemElement {
private String name;
private long size;
private String extension;

public File(String name, long size) {
this.name = name;
this.size = size;
this.extension = name.contains(".") ?
name.substring(name.lastIndexOf(".") + 1) : "";
}

@Override
public void accept(FileSystemVisitor visitor) {
visitor.visitFile(this);
}

@Override
public String getName() { return name; }
public long getSize() { return size; }
public String getExtension() { return extension; }
}

// 디렉토리 클래스
class Directory implements FileSystemElement {
private String name;
private List<FileSystemElement> children = new ArrayList<>();

public Directory(String name) {
this.name = name;
}

public void addElement(FileSystemElement element) {
children.add(element);
}

@Override
public void accept(FileSystemVisitor visitor) {
visitor.visitDirectory(this);
// 자식 요소들도 방문
for (FileSystemElement child : children) {
child.accept(visitor);
}
}

@Override
public String getName() { return name; }
public List<FileSystemElement> getChildren() { return children; }
}

// 크기 계산 방문객
class SizeCalculatorVisitor implements FileSystemVisitor {
private long totalSize = 0;
private int depth = 0;

@Override
public void visitFile(File file) {
totalSize += file.getSize();
printIndent();
System.out.printf("� %s (%s)\n",
file.getName(), formatSize(file.getSize()));
}

@Override
public void visitDirectory(Directory directory) {
printIndent();
System.out.println("� " + directory.getName() + "/");
depth++;

// 자식 처리는 Directory.accept()에서 자동으로 됨

depth--;
}

public long getTotalSize() { return totalSize; }

private void printIndent() {
for (int i = 0; i < depth; i++) {
System.out.print(" ");
}
}

private String formatSize(long size) {
if (size < 1024) return size + "B";
if (size < 1024 * 1024) return (size / 1024) + "KB";
return (size / (1024 * 1024)) + "MB";
}
}

// 검색 방문객
class SearchVisitor implements FileSystemVisitor {
private String searchTerm;
private List<String> results = new ArrayList<>();
private String currentPath = "";

public SearchVisitor(String searchTerm) {
this.searchTerm = searchTerm.toLowerCase();
}

@Override
public void visitFile(File file) {
if (file.getName().toLowerCase().contains(searchTerm)) {
results.add(currentPath + "/" + file.getName() + " (파일)");
}
}

@Override
public void visitDirectory(Directory directory) {
String oldPath = currentPath;
currentPath += "/" + directory.getName();

if (directory.getName().toLowerCase().contains(searchTerm)) {
results.add(currentPath + " (폴더)");
}

// 자식 처리 후 경로 복원
// (실제로는 Directory.accept()에서 자식들이 처리됨)
currentPath = oldPath;
}

public List<String> getResults() { return results; }
}

// 통계 수집 방문객
class StatisticsVisitor implements FileSystemVisitor {
private int fileCount = 0;
private int directoryCount = 0;
private Map<String, Integer> extensionCount = new HashMap<>();
private long totalSize = 0;

@Override
public void visitFile(File file) {
fileCount++;
totalSize += file.getSize();

String ext = file.getExtension().toLowerCase();
if (!ext.isEmpty()) {
extensionCount.put(ext, extensionCount.getOrDefault(ext, 0) + 1);
}
}

@Override
public void visitDirectory(Directory directory) {
directoryCount++;
}

public void printStatistics() {
System.out.println("\n� 파일 시스템 통계");
System.out.println("=".repeat(30));
System.out.println("� 폴더 수: " + directoryCount);
System.out.println("� 파일 수: " + fileCount);
System.out.println("� 총 크기: " + formatSize(totalSize));

System.out.println("\n� 확장자별 파일 수:");
extensionCount.entrySet().stream()
.sorted((e1, e2) -> e2.getValue().compareTo(e1.getValue()))
.forEach(entry ->
System.out.printf(" .%s: %d개\n", entry.getKey(), entry.getValue()));
}

private String formatSize(long size) {
if (size < 1024) return size + "B";
if (size < 1024 * 1024) return (size / 1024) + "KB";
return (size / (1024 * 1024)) + "MB";
}
}

// 파일 시스템 데모
public class FileSystemDemo {
public static void main(String[] args) {
// 파일 시스템 구조 생성
Directory root = new Directory("프로젝트");

Directory src = new Directory("src");
src.addElement(new File("Main.java", 1500));
src.addElement(new File("Utils.java", 800));
src.addElement(new File("Config.properties", 200));

Directory docs = new Directory("docs");
docs.addElement(new File("README.md", 1200));
docs.addElement(new File("API.pdf", 5000));

Directory images = new Directory("images");
images.addElement(new File("logo.png", 15000));
images.addElement(new File("banner.jpg", 25000));

root.addElement(src);
root.addElement(docs);
root.addElement(images);
root.addElement(new File("build.xml", 600));

System.out.println("�️ 파일 시스템 비지터 패턴 데모");
System.out.println("=".repeat(50));

// 1. 크기 계산 방문객
System.out.println("\n1️⃣ 파일 구조 및 크기 분석");
System.out.println("-".repeat(30));
SizeCalculatorVisitor sizeVisitor = new SizeCalculatorVisitor();
root.accept(sizeVisitor);
System.out.printf("\n� 총 크기: %s\n",
formatSize(sizeVisitor.getTotalSize()));

// 2. 검색 방문객
System.out.println("\n2️⃣ 'java' 파일 검색");
System.out.println("-".repeat(30));
SearchVisitor searchVisitor = new SearchVisitor("java");
root.accept(searchVisitor);
List<String> results = searchVisitor.getResults();
if (results.isEmpty()) {
System.out.println("검색 결과가 없습니다.");
} else {
results.forEach(result -> System.out.println("� " + result));
}

// 3. 통계 수집 방문객
System.out.println("\n3️⃣ 상세 통계 분석");
System.out.println("-".repeat(30));
StatisticsVisitor statsVisitor = new StatisticsVisitor();
root.accept(statsVisitor);
statsVisitor.printStatistics();

System.out.println("\n" + "=".repeat(50));
System.out.println("✅ 파일 시스템 분석 완료!");
}

private static String formatSize(long size) {
if (size < 1024) return size + "B";
if (size < 1024 * 1024) return (size / 1024) + "KB";
return (size / (1024 * 1024)) + "MB";
}
}


코드가 조금 길지만, 위 코드에서 중요한 부분은 동일한 파일 시스템 구조에 대해 완전히 다른 정보를 추출한다는 점입니다.


파일과 디렉토리 클래스는 매우 단순합니다. 이름, 크기, 자식 요소 정도의 기본 정보만 가지고 있습니다. 크기 계산, 검색, 통계 분석 등의 복잡한 기능은 모두 외부의 방문객이 담당합니다.


새로운 분석 기능(예: 보안 검사, 백업 대상 선별, 중복 파일 찾기)을 추가하려면 새로운 방문객 클래스만 만들면 됩니다. 기존 파일 시스템 클래스들은 전혀 수정하지 않아도 됩니다.


기존 방식이라면 크기 계산 로직이 File 클래스에, Directory 클래스에 각각 흩어져 있을 것이지만, 비지터 패턴에서는 크기 계산과 관련된 모든 로직이 `SizeCalculatorVisitor` 한 곳에 모여있습니다.


각 방문객은 파일과 디렉토리를 다르게 처리할 수 있습니다. 컴파일 시점에 타입이 확정되므로, 런타임에 타입을 확인하는 불안전한 캐스팅이 필요 없습니다.




박물관에서 미술사학자와 일반 관광객이 같은 작품을 보지만 전혀 다른 것을 봅니다. 전문가는 자신의 특별한 "시선"을 통해 일반인이 놓치는 가치를 발견하죠. 같은 상황이라도 전문성과 경험에 따라 전혀 다른 기회와 의미를 발견할 수 있습니다.


‘비지터 패턴’은 하나의 문제를 여러 관점에서 바라보는 것의 중요성을 보여줍니다. 파일 시스템을 크기 관점에서 보면 용량 관리가, 확장자 관점에서 보면 파일 타입 분석이, 검색 관점에서 보면 정보 검색이 가능합니다. 이렇듯 어느 하나의 문제도 다양한 각도에서 접근하면 더 나은 해결책을 찾을 수 있습니다.


박물관 방문객처럼, 같은 현실이라도 어떤 관점으로 바라보느냐에 따라 전혀 다른 의미와 가치를 발견할 수 있습니다. 중요한 것은 자신만의 전문적 시선을 기르면서도, 다른 사람들의 관점에도 귀를 기울이면 더 많은 것을 볼 수 있습니다.




keyword