GoF 디자인 패턴: 행위 패턴(5) - 중재자, 방문자 | 타입스크립트 예시

정처기디자인 패턴GoF행위 패턴중재자 패턴방문자 패턴객체지향TypeScript
읽는데 약 11분 정도 소요
처음 쓰여진 날: 2025-07-05
마지막으로 고쳐진 날: 2025-07-05
이 글을 보러온 횟수: 23

요약

GoF 디자인 패턴 중 행위 패턴의 마지막 파트입니다. 객체 간의 복잡한 상호작용을 중앙에서 관리하는 중재자(Mediator) 패턴과, 객체 구조와 처리 기능을 분리하는 방문자(Visitor) 패턴의 개념과 TypeScript 예시를 알아봅니다.

지난 행위 패턴 (4)편에서는 이터레이터(Iterator) 패턴상태(State) 패턴을 통해 컬렉션을 순회하고 객체의 상태에 따라 행동을 바꾸는 방법을 알아보았습니다. 드디어 행위 패턴 시리즈의 마지막 편입니다. 이번에는 복잡하게 얽힌 객체들의 관계를 풀어내는 우아한 방법들을 살펴보겠습니다.

  • ✈️ 중재자 (Mediator) 패턴: 여러 객체 간의 복잡한 통신(M:N 관계)을 중재자 객체를 통해 중앙에서 관리(M:1 관계)하도록 만듭니다.
  • 👨‍💼 방문자 (Visitor) 패턴: 실제 데이터 구조(객체)는 변경하지 않으면서, 해당 구조에 새로운 기능을 추가할 수 있도록 합니다.

이 패턴들이 어떻게 복잡성을 낮추고 시스템의 확장성을 높이는지 함께 확인해 보시죠!

✈️ 9. 중재자 (Mediator) 패턴

중재자 패턴은 수많은 객체(Colleague)들 간의 복잡한 상호작용과 의존성을 하나의 중재자(Mediator) 객체에 캡슐화하여, 객체들이 서로 직접 통신하지 않고 오직 중재자를 통해서만 소통하도록 만드는 패턴입니다.

가장 대표적인 비유는 '항공 관제탑' 🗼 입니다. 공항에는 수많은 비행기들이 이착륙합니다. 만약 모든 비행기가 다른 모든 비행기와 직접 통신하여 착륙 순서나 항로를 조율한다면 엄청난 혼란이 발생할 것입니다. 대신, 모든 비행기(Colleague)는 오직 관제탑(Mediator)하고만 통신합니다. 관제탑은 모든 비행기의 상태를 파악하고, 각 비행기에게 언제, 어디로 가야 할지 지시하며 모든 상호작용을 중앙에서 통제합니다.

이처럼 중재자 패턴은 객체 간의 직접적인 연결(M:N 관계)을 중재자를 통한 간접적인 연결(M:1 관계)로 바꿔주어, 시스템의 결합도를 극적으로 낮춥니다.

🏗️ 기본 구조

  • ✈️ Mediator: 동료(Colleague) 객체 간의 통신을 위한 인터페이스를 정의합니다.
  • ConcreteMediator: Mediator 인터페이스를 구현하며, 실제로 동료 객체들을 조정하는 역할을 합니다. 각 동료 객체를 알고 있으며, 그들 간의 통신을 중재합니다.
  • 👨‍✈️ Colleague: 중재자를 통해 다른 동료와 통신하는 객체들의 공통 인터페이스입니다.
  • ConcreteColleague: Colleague 인터페이스를 구현한 구체적인 클래스입니다. 다른 동료와 통신이 필요할 때, 직접 호출하는 대신 자신의 Mediator에게 요청을 보냅니다.

💻 예시: 채팅방 만들기

여러 명의 사용자가 참여하는 채팅방을 만들어 보겠습니다. 각 사용자(Colleague)는 다른 사용자를 몰라도, 채팅방(Mediator)을 통해 메시지를 보내면 채팅방이 모든 사용자에게 메시지를 전달해 줍니다.

typescript
// Colleague
class User {
  constructor(public name: string, private chatRoom: ChatRoom) {}

  send(message: string): void {
    this.chatRoom.showMessage(this, message);
  }
}

// Mediator
class ChatRoom {
  showMessage(user: User, message: string): void {
    const time = new Date().toLocaleTimeString();
    const sender = user.name;
    console.log(`${time} [${sender}]: ${message}`);
  }
}

// 클라이언트 코드
const chatRoom = new ChatRoom();

const user1 = new User("김재현", chatRoom);
const user2 = new User("이영희", chatRoom);

user1.send("안녕하세요, 영희 씨!");
// 출력: 10:30:15 PM [김재현]: 안녕하세요, 영희 씨!

user2.send("네, 안녕하세요! 재현 씨.");
// 출력: 10:30:18 PM [이영희]: 네, 안녕하세요! 재현 씨.

위 예시는 가장 기본적인 형태입니다. 실제로는 ChatRoomUser 목록을 가지고 있으면서, 한 Usersend를 호출하면 ChatRoom이 다른 모든 User에게 메시지를 전달하는 방식으로 구현되어야 합니다.

User 객체는 다른 User 객체의 존재를 전혀 알 필요가 없습니다. 오직 ChatRoom이라는 중재자만 알고 있으면 됩니다. 만약 새로운 사용자가 추가되거나 채팅방의 규칙(예: 욕설 필터링)이 바뀌어도 User 클래스는 전혀 수정할 필요가 없습니다. 모든 로직은 ChatRoom에 집중되어 있기 때문입니다.

중재자 패턴 중요 키워드 🔑

  • ✈️ 객체 간의 복잡한 통신을 중앙에서 관리합니다.
  • 🕸️ M:N의 복잡한 의존 관계를 M:1 관계로 단순화하여 결합도를 낮춥니다.
  • 🔄 동료(Colleague) 객체는 재사용하기 쉬워지고, 중재자(Mediator)의 로직만 바꾸면 전체 시스템의 행동을 쉽게 변경할 수 있습니다.
  • ⚠️ 모든 로직이 중재자에 집중되어, 중재자 자체가 너무 복잡해지는 단점이 있을 수 있습니다.

👨‍💼 10. 방문자 (Visitor) 패턴

방문자 패턴은 데이터 구조(객체)와 해당 구조에서 수행할 처리(기능)를 분리하는 패턴입니다. 이 패턴을 사용하면, 데이터 구조를 변경하지 않으면서도 새로운 기능을 쉽게 추가할 수 있습니다.

'세무사' 👨‍💼 를 비유로 들 수 있습니다. 개인의 자산에는 집, 자동차, 주식 등 여러 종류(데이터 구조)가 있습니다. 이때 '세금 계산'이라는 기능이 필요하다고 해서, 집 클래스에 calculateTax() 메서드를, 자동차 클래스에 calculateTax() 메서드를 각각 추가하는 것은 비효율적입니다. 자산의 종류가 늘어날 때마다 모든 클래스를 수정해야 하기 때문입니다.

대신, '세무사'라는 방문자(Visitor)를 만듭니다. 이 세무사는 각 자산(집, 자동차, 주식)을 '방문'하면서 세금을 계산하는 방법을 모두 알고 있습니다. 자산 객체들은 그저 세무사를 '받아들이기(accept)'만 하면 됩니다. 나중에 '자산 감정 평가'라는 새로운 기능이 필요하면, '감정평가사'라는 새로운 방문자를 만들어 추가하기만 하면 됩니다. 기존의 자산 클래스들은 전혀 건드릴 필요가 없습니다.

🏗️ 기본 구조

  • 👨‍💼 Visitor: 각 ConcreteElement를 방문하는 visit() 메서드를 선언합니다. (예: visitHouse(h), visitCar(c))
  • ConcreteVisitor: Visitor 인터페이스를 구현하며, 각 visit() 메서드에 실제 처리 로직을 담습니다.
  • 🏠 Element: 방문자를 받아들이는 accept(visitor) 메서드를 정의합니다.
  • ConcreteElement: Element 인터페이스를 구현하며, accept() 메서드 내부에서 visitor.visit(this)를 호출하여 방문자가 자신을 처리하도록 합니다.
  • ObjectStructure: Element들의 컬렉션이며, 방문자가 모든 요소를 순회하며 방문할 수 있도록 합니다.

💻 예시: 동물원 동물의 행동 관찰하기

동물원의 동물(Element)들에게 '소리내기'와 '먹이주기'라는 행동(Visitor)을 적용해 보겠습니다.

typescript
// Visitor 인터페이스
interface AnimalVisitor {
  visitLion(lion: Lion): void;
  visitDolphin(dolphin: Dolphin): void;
}

// Element 인터페이스
interface Animal {
  accept(visitor: AnimalVisitor): void;
}

// ConcreteElement 구현
class Lion implements Animal {
  accept(visitor: AnimalVisitor): void {
    visitor.visitLion(this);
  }
  roar(): void {
    console.log("어흥!");
  }
}

class Dolphin implements Animal {
  accept(visitor: AnimalVisitor): void {
    visitor.visitDolphin(this);
  }
  speak(): void {
    console.log("끼이익!");
  }
}

// ConcreteVisitor 구현
class SpeakVisitor implements AnimalVisitor {
  visitLion(lion: Lion): void {
    lion.roar();
  }
  visitDolphin(dolphin: Dolphin): void {
    dolphin.speak();
  }
}

class FeedVisitor implements AnimalVisitor {
  visitLion(lion: Lion): void {
    console.log("사자에게 고기를 줍니다.");
  }
  visitDolphin(dolphin: Dolphin): void {
    console.log("돌고래에게 생선을 줍니다.");
  }
}

// 클라이언트 코드 (ObjectStructure 역할)
const animals: Animal[] = [new Lion(), new Dolphin()];

const speakVisitor = new SpeakVisitor();
console.log("--- 소리내기 ---");
animals.forEach((animal) => animal.accept(speakVisitor));
// 출력:
// 어흥!
// 끼이익!

const feedVisitor = new FeedVisitor();
console.log("\n--- 먹이주기 ---");
animals.forEach((animal) => animal.accept(feedVisitor));
// 출력:
// 사자에게 고기를 줍니다.
// 돌고래에게 생선을 줍니다.

Lion이나 Dolphin 클래스를 전혀 수정하지 않고도, SpeakVisitor, FeedVisitor라는 새로운 기능을 계속해서 추가할 수 있습니다. 이것이 방문자 패턴의 가장 큰 장점입니다.

방문자 패턴 중요 키워드 🔑

  • 🏛️ 데이터 구조와 기능을 분리합니다.

  • 개방-폐쇄 원칙(OCP): 기존 데이터 구조(Element)는 수정하지 않으면서, 새로운 기능(Visitor)을 쉽게 추가할 수 있습니다.

  • 🔄 더블 디스패치(Double Dispatch): accept()visit() 두 번의 호출을 통해, 실행 시점에 어떤 Element의 메서드를 호출할지, 그리고 어떤 Visitor의 메서드를 호출할지가 결정됩니다.

  • ⚠️ 데이터 구조가 자주 변경되면 모든 Visitor를 수정해야 하므로, 구조가 안정적일 때 사용하는 것이 좋습니다.

📜 11. 인터프리터 (Interpreter) 패턴

인터프리터 패턴은 특정 언어의 문법을 정의하고, 그 문법으로 작성된 문장을 해석하는 해석기(Interpreter)를 만드는 패턴입니다. 간단한 언어(DSL, Domain-Specific Language)를 만들고 그 언어의 구문을 해석해야 할 때 사용됩니다.

가장 대표적인 예시는 '계산기'나 '정규표현식' 입니다. 3 + 5 - 2 라는 텍스트가 있을 때, 우리는 이를 숫자(Terminal Expression)와 연산자(Non-terminal Expression)로 나누어 해석하고, 정해진 문법 규칙(우선순위 등)에 따라 최종 결과인 6을 도출합니다. 인터프리터 패턴은 이처럼 문법 규칙을 클래스로 표현하여 문장을 해석하는 구조를 제공합니다.

🏗️ 기본 구조

  • 📜 AbstractExpression: 모든 표현식(터미널, 논터미널)이 구현해야 할 공통 인터페이스입니다. 보통 interpret() 메서드를 정의합니다.
  • TerminalExpression: 문법의 최소 단위(종단 표현식)를 나타냅니다. 예를 들어, 변수나 숫자 같은 것들입니다.
  • NonterminalExpression: 문법 규칙을 나타냅니다. 다른 표현식들을 자식으로 가질 수 있으며, 이들을 조합하여 새로운 결과를 만들어냅니다. (예: 덧셈 표현식, 뺄셈 표현식)
  • Context: 인터프리터가 문장을 해석하는 데 필요한 전역 정보(예: 변수의 값)를 담고 있습니다.
  • Client: 해석할 문장을 만들고, 파싱하여 인터프리터에게 전달하는 역할을 합니다.

💻 예시: 간단한 덧셈/뺄셈 계산기

x + y - z 와 같은 간단한 수식을 해석하는 계산기를 만들어 보겠습니다.

typescript
// Context
const context = { x: 10, y: 5, z: 2 };

// AbstractExpression
interface Expression {
  interpret(context: { [key: string]: number }): number;
}

// TerminalExpression
class NumberExpression implements Expression {
  constructor(private variable: string) {}

  interpret(context: { [key: string]: number }): number {
    return context[this.variable];
  }
}

// NonterminalExpression
class AddExpression implements Expression {
  constructor(private left: Expression, private right: Expression) {}

  interpret(context: { [key: string]: number }): number {
    return this.left.interpret(context) + this.right.interpret(context);
  }
}

class SubtractExpression implements Expression {
  constructor(private left: Expression, private right: Expression) {}

  interpret(context: { [key: string]: number }): number {
    return this.left.interpret(context) - this.right.interpret(context);
  }
}

// 클라이언트 코드
// 목표: x + y - z 를 해석하기
const x = new NumberExpression("x");
const y = new NumberExpression("y");
const z = new NumberExpression("z");

// 1. x + y
const sum = new AddExpression(x, y);

// 2. (x + y) - z
const resultExpression = new SubtractExpression(sum, z);

// 최종 해석
const result = resultExpression.interpret(context);
console.log(`x + y - z = ${result}`); // 10 + 5 - 2 = 13
// 출력: x + y - z = 13

resultExpressionnew SubtractExpression(new AddExpression(new NumberExpression("x"), new NumberExpression("y")), new NumberExpression("z")) 와 같은 트리 구조를 형성합니다. interpret 메서드를 호출하면, 이 트리를 후위 순회(post-order traversal)하며 최종 결과가 계산됩니다.

인터프리터 패턴 중요 키워드 🔑

  • 📜 간단한 언어의 문법을 클래스 계층으로 표현합니다.
  • 🌳 문장을 AST(추상 구문 트리, Abstract Syntax Tree) 로 구성하여 해석합니다.
  • ➕ 문법이 단순하고 자주 변하지 않을 때 유용합니다.
  • ⚠️ 문법이 복잡해지면 클래스 계층이 매우 방대해지고 관리하기 어려워지는 단점이 있습니다.

이것으로 GoF의 핵심 행위 패턴 11가지에 대한 여정을 마무리합니다. 이 패턴들은 복잡한 소프트웨어의 상호작용을 명확하고, 유연하며, 유지보수하기 쉽게 만드는 강력한 도구입니다. 상황에 맞는 적절한 패턴을 선택하여 코드의 품질을 한 단계 높여보세요!