22. Visitor — 피자 클래스는 건드리지 않고 새 연산을 추가한다

작성일:2026.05.21|수정일:2026.08.04|조회수:0

22. Visitor — 피자 클래스는 건드리지 않고 새 연산을 추가한다

에이든 피자의 시범 운영이 거의 끝나갈 무렵, 주방과 사무실에서 불만이 터져 나왔다. 원인은 비대해질 대로 비대해진 PizzaOrder 클래스였다.

처음에는 피자 클래스에 getPrice() 하나만 있으면 충분했다. 그런데 곧 영양 정보를 위해 getCalories()가 추가됐고, 마케팅팀의 요청으로 checkAllergens()가, 회계팀의 요청으로 exportToCSV()가 추가됐다. 이제는 영수증을 예쁘게 뽑아주는 printReceipt()까지 넣어달란다.

TS
class Pizza {  // 데이터 필드들...  getPrice() { ... }  getCalories() { ... }  checkAllergens() { ... }  exportToCSV() { ... }  printReceipt() { ... }  // 다음 주에는 또 뭐가 추가될까?}

피자 클래스는 피자의 '데이터'를 담는 곳이어야 하는데, 지금은 온갖 비즈니스 로직을 다 알고 있는 만능 해결사가 되어버렸다. 새로운 분석 기능이 필요할 때마다 피자 클래스의 코드를 열고 수정해야 한다. 이건 객체지향의 대원칙인 개방-폐쇄 원칙(OCP)을 정면으로 들이받는 일이다.

무엇이 불편한가

지금의 가장 큰 문제는 "데이터 구조"와 "연산"이 한곳에 뭉쳐 있다는 점이다. 피자가 어떤 재료로 구성되었는지와 그 피자의 가격을 어떻게 계산할지는 사실 분리될 수 있는 영역이다. 특히 영수증 출력이나 CSV 내보내기 같은 기능은 피자의 핵심 본질과는 거리가 먼 '부수적인 연산'들이다.

더 큰 문제는 8편에서 만든 Composite 구조와의 충돌이다. 단품 피자와 세트 메뉴가 뒤섞인 트리 구조에서 각 노드를 돌며 서로 다른 연산을 수행하기가 매우 까다로워졌다. 가격 계산은 재귀적으로 합쳐야 하고, 알레르기 체크는 중복을 제거해야 한다. 이 모든 복잡한 로직을 각 메뉴 클래스 안에 구현해두면, 메뉴 클래스는 점점 더 읽기 힘든 괴물이 될 것이다.

우리는 메뉴 클래스를 '데이터의 그릇'으로 남겨두고 싶었다. 그리고 그 그릇을 방문하며 필요한 정보만 뽑아 연산을 처리하는 '외부 전문가'를 고용하고 싶었다.

Visitor 패턴

Visitor 패턴은 알고리즘을 객체 구조에서 분리하여, 구조를 수정하지 않고도 새로운 연산을 추가할 수 있게 하는 패턴이다. GoF의 원래 의도는 다음과 같다.

객체 구조의 원소들에 대해 수행할 연산을 별도의 클래스로 캡슐화한다. 이 패턴을 사용하면 원소 클래스를 변경하지 않고도 새로운 연산을 정의할 수 있다.

이 패턴에는 두 명의 주인공이 등장한다.

  1. Element (요소): 방문자를 받아들이는 accept(visitor) 메서드를 가진 객체들이다. 여기선 SingleItem, ComboSet 같은 메뉴 클래스들이다.
  2. Visitor (방문자): 각 요소 타입에 대해 수행할 연산을 정의한다. PriceCalculator, AllergenChecker 등이 여기에 해당한다.

핵심은 **더블 디스패치(Double Dispatch)**다. 요소는 방문자를 받아들이고(element.accept(visitor)), 방문자는 그 요소의 구체 타입에 맞는 메서드를 호출해 실제 연산을 수행한다(visitor.visit(element)). 이 과정을 통해 런타임에 요소의 타입과 방문자의 타입을 결합하여 알맞은 로직을 실행할 수 있게 된다.

손님(Visitor) 맞이할 준비 하기

이제 에이든 피자의 메뉴들을 '방문 가능'한 구조로 리팩토링해보자. 먼저 모든 메뉴가 구현해야 할 인터페이스다.

TS
interface OrderComponent {  accept(visitor: OrderVisitor): void;}

단품 메뉴인 SingleItem은 방문자에게 "나는 단품이야"라고 알려주며 자신을 넘긴다.

TS
class SingleItem implements OrderComponent {  accept(visitor: OrderVisitor) {    visitor.visitSingleItem(this);  }}

세트 메뉴인 ComboSet은 더 친절하다. 자신을 방문하게 한 뒤, 자신이 품고 있는 자식들도 방문하라고 길을 안내한다.

TS
class ComboSet implements OrderComponent {  accept(visitor: OrderVisitor) {    visitor.visitComboSet(this);    this.items.forEach(item => item.accept(visitor));  }}

이제 주방이나 사무실에서는 원하는 연산이 생길 때마다 방문자만 새로 만들면 된다. 가격을 계산하고 싶다면 PriceCalculator를 만들어 장바구니에 던져주면 된다. 영수증을 뽑고 싶다면 ReceiptPrinter를 만들면 된다. 메뉴 클래스(SingleItem, ComboSet)는 단 한 줄도 고칠 필요가 없다. 그들은 그저 accept()라는 문을 열어두었을 뿐이다.

TypeScript 포인트

Visitor 패턴을 TypeScript로 구현할 때는 오버로드의 성격을 구분해야 한다. TypeScript는 visit(item: SingleItem)visit(item: ComboSet) 같은 오버로드 시그니처를 지원한다. 다만 JavaScript 런타임에는 구현이 하나뿐이므로, 그 구현에서 유니온 타입이나 타입 가드로 분기해야 한다.

에이든 피자에서는 그 런타임 분기를 한 visit 구현에 몰아넣지 않기 위해 메서드 이름을 명확히 구분하는 방식(visitSingleItem, visitComboSet)을 택했다. 만약 더 'TypeScript다운' 방식을 원한다면, 유니온 타입과 타입 가드를 활용해 하나의 visit(item: OrderComponent) 메서드 안에서 처리할 수도 있다. 하지만 이는 방문자 로직이 다시 거대한 switch 문으로 변질될 위험이 있어, 패턴의 본래 의도인 '타입에 따른 분산 처리'를 살리기 위해 명시적인 메서드 이름을 사용하는 것이 권장된다.

또한 Visitor 패턴은 8편의 Composite 패턴과 잘 맞는다. 트리 구조의 모든 노드를 순회하며 복합적인 연산을 수행할 때, Visitor는 꽤 강력한 도구가 된다.

트레이드오프

Visitor 패턴은 확장성의 방향을 뒤바꾼다. 새로운 연산을 추가하는 것은 매우 쉬워지지만(새로운 Visitor 클래스만 만들면 됨), 새로운 요소 타입을 추가하는 것은 매우 고통스러워진다(모든 Visitor 인터페이스와 클래스에 해당 타입 방문 메서드를 추가해야 함).

따라서 이 패턴은 "객체 구조는 안정적인데 연산이 자주 추가될 때" 써야 한다. 에이든 피자처럼 메뉴의 종류(단품, 세트)는 고정되어 있는데, 이를 활용한 통계나 분석 기능이 계속 늘어나는 상황에 딱 맞다.

또한, 방문자가 요소의 내부 데이터를 속속들이 알아야 하므로 캡슐화가 다소 약해질 수 있다는 점도 기억해야 한다. 하지만 비대해진 클래스를 쪼개고 로직을 한곳에 모을 수 있다는 이점이 주는 달콤함은, 약간의 데이터 노출 정도는 눈감아줄 만큼 강력하다.

전체 코드

TS
/** * Element: 방문자를 받아들이는 인터페이스 */interface OrderComponent {  accept(visitor: OrderVisitor): void;  getName(): string;}/** * Concrete Element: 단품 메뉴 */class SingleItem implements OrderComponent {  constructor(    private name: string,    public price: number,    public calories: number  ) {}  getName() { return this.name; }  accept(visitor: OrderVisitor) {    visitor.visitSingleItem(this);  }}/** * Concrete Element: 세트 메뉴 (Composite 구조) */class ComboSet implements OrderComponent {  private items: OrderComponent[] = [];  constructor(private name: string, public discount: number = 0) {}  getName() { return this.name; }  add(item: OrderComponent) {    this.items.push(item);  }  accept(visitor: OrderVisitor) {    visitor.visitComboSet(this);    for (const item of this.items) {      item.accept(visitor);    }  }}/** * Visitor: 연산 정의 인터페이스 */interface OrderVisitor {  visitSingleItem(item: SingleItem): void;  visitComboSet(set: ComboSet): void;}/** * Concrete Visitor 1: 가격 계산기 */class PriceCalculator implements OrderVisitor {  private total = 0;  visitSingleItem(item: SingleItem) {    this.total += item.price;  }  visitComboSet(set: ComboSet) {    this.total -= set.discount;  }  getTotal() { return Math.max(0, this.total); }}/** * Concrete Visitor 2: 칼로리 계산기 */class CalorieCalculator implements OrderVisitor {  private totalCalories = 0;  visitSingleItem(item: SingleItem) {    this.totalCalories += item.calories;  }  visitComboSet(set: ComboSet) {} // 세트는 칼로리 할인 없음  getTotalCalories() { return this.totalCalories; }}/** * 시뮬레이션 */function runVisitorDemo() {  const pepperoni = new SingleItem("페퍼로니 피자", 18000, 1200);  const coke = new SingleItem("콜라", 2500, 150);  const fries = new SingleItem("감자튀김", 5000, 400);  const combo = new ComboSet("에이든 런치 세트", 2000);  combo.add(pepperoni);  combo.add(coke);  const cart = new ComboSet("최종 장바구니");  cart.add(combo);  cart.add(fries);  // 연산 수행  const priceCalc = new PriceCalculator();  const calorieCalc = new CalorieCalculator();  cart.accept(priceCalc);  cart.accept(calorieCalc);  console.log(`총 가격: ${priceCalc.getTotal().toLocaleString()}원`);  console.log(`총 칼로리: ${calorieCalc.getTotalCalories()}kcal`);}runVisitorDemo();

비지터 패턴 덕분에 에이든 피자의 핵심 클래스들은 다시 홀가분해졌다. 연산 로직이 각자의 전담 클래스로 빠져나가니 코드의 책임도 더 분명해졌다.

이제 우리는 마지막 난관만을 남겨두고 있다. 배달 앱을 통해 들어오는 손님들의 기상천외한 특수 요청들이다. "치즈 빼고 소스는 많이, 도우는 바짝 구워서" 같은 자연어에 가까운 문자열을 우리 시스템이 이해할 수 있는 객체 구조로 변환해야 한다. 이 복잡한 '문장'을 어떻게 해석하고 실행할 수 있을까?

댓글

댓글을 불러오는 중...