GoF 디자인 패턴: 행위 패턴(2) - 커맨드, 템플릿 메서드 | 타입스크립트 예시

정처기디자인 패턴GoF행위 패턴커맨드 패턴템플릿 메서드 패턴객체지향TypeScriptUndo
읽는데 약 9분 정도 소요
처음 쓰여진 날: 2025-07-05
마지막으로 고쳐진 날: 2025-07-05
이 글을 보러온 횟수: 24

요약

GoF 디자인 패턴 중 행위 패턴의 두 번째 파트입니다. 요청을 객체로 캡슐화하여 실행 취소(Undo) 기능을 구현하는 커맨드(Command) 패턴과, 상속을 통해 알고리즘의 골격을 정의하고 특정 단계를 유연하게 변경하는 템플릿 메서드(Template Method) 패턴의 개념과 TypeScript 예시를 알아봅니다.

지난 행위 패턴 (1)편에서는 전략(Strategy) 패턴옵서버(Observer) 패턴에 대해 알아보았습니다. 전략 패턴이 알고리즘을 동적으로 교체하는 방법을, 옵서버 패턴이 객체 간의 느슨한 소통 방식을 제공했다면, 이번에는 또 다른 강력한 행위 패턴들을 살펴보겠습니다.

이번 포스트에서는 다음과 같은 패턴들을 다룹니다.

  • 🎮 커맨드 (Command) 패턴: 요청 자체를 객체로 감싸서, 요청자와 수신자를 분리합니다.
  • 📝 템플릿 메서드 (Template Method) 패턴: 알고리즘의 뼈대는 부모가, 구체적인 내용은 자식이 정의하도록 합니다.

이 패턴들이 어떻게 복잡한 객체 상호작용을 우아하게 풀어내는지 함께 살펴보시죠! ✨

🎮 3. 커맨드 (Command) 패턴

커맨드 패턴은 '요청(request)' 그 자체를 객체로 캡슐화하여, 요청을 보내는 객체(Invoker)와 요청을 실제로 처리하는 객체(Receiver)를 분리하는 패턴입니다.

가장 흔한 비유는 'TV 리모컨' 📺 입니다. 리모컨(Invoker)의 버튼들은 각각 '전원 켜기', '볼륨 높이기' 같은 기능(Command)을 가지고 있습니다. 우리가 버튼을 누르면, 리모컨은 해당 기능이 담긴 신호를 TV(Receiver)에 보냅니다. 리모컨은 TV가 내부적으로 어떻게 동작하는지 전혀 모르며, 그저 정해진 신호를 보낼 뿐입니다. TV 또한 리모컨이 어떻게 생겼는지, 버튼이 몇 개인지 신경 쓰지 않고 들어온 신호를 처리하기만 하면 됩니다.

이처럼 커맨드 패턴은 '무엇을 할 것인가''누가, 어떻게 할 것인가' 로부터 분리하여 시스템의 유연성을 크게 높입니다.

기본 구조

  • 🎮 Command: 모든 구체적인 커맨드 클래스들이 구현해야 하는 공통 인터페이스입니다. 보통 execute()라는 단일 메서드를 가집니다.
  • ConcreteCommand: Command 인터페이스를 구현하며, Receiver 객체에 대한 참조를 가집니다. execute()가 호출되면, Receiver의 특정 메서드를 호출하여 요청을 실행합니다.
  • 🕹️ Invoker: 사용자(클라이언트)의 요청을 받아 Command 객체를 실행하는 역할을 합니다. (예: 리모컨, 버튼) InvokerConcreteCommand가 아닌 Command 인터페이스에만 의존하므로, 어떤 커맨드든 실행할 수 있습니다.
  • 📺 Receiver: 요청을 실제로 수행하는 객체입니다. 비즈니스 로직을 포함하고 있습니다. (예: TV, 전등)

💻 예시: 스마트홈 리모컨 만들기

전등을 켜고 끄는 간단한 스마트홈 리모컨을 만든다고 가정해봅시다. 여기에 커맨드 패턴의 꽃이라 불리는 '실행 취소(Undo)' 기능까지 추가해 보겠습니다.

먼저, 요청을 실제로 처리할 ReceiverLight 클래스를 정의합니다.

typescript
// Receiver: 요청을 실제로 처리하는 객체
class Light {
  turnOn() {
    console.log("💡 전등이 켜졌습니다.");
  }

  turnOff() {
    console.log("⬛ 전등이 꺼졌습니다.");
  }
}

다음으로 모든 커맨드가 따를 Command 인터페이스를 정의합니다. undo() 메서드를 추가하여 실행 취소 기능을 지원하도록 합니다.

typescript
// Command: 모든 커맨드 객체가 구현할 인터페이스
interface Command {
  execute(): void;
  undo(): void;
}

이제 구체적인 커맨드, LightOnCommandLightOffCommand를 만듭니다.

typescript
// ConcreteCommand: 전등 켜기
class LightOnCommand implements Command {
  private light: Light;

  constructor(light: Light) {
    this.light = light;
  }

  execute(): void {
    this.light.turnOn();
  }

  undo(): void {
    this.light.turnOff();
  }
}

// ConcreteCommand: 전등 끄기
class LightOffCommand implements Command {
  private light: Light;

  constructor(light: Light) {
    this.light = light;
  }

  execute(): void {
    this.light.turnOff();
  }

  undo(): void {
    this.light.turnOn();
  }
}

마지막으로, 커맨드를 받아 실행하고, 실행 내역을 기록하여 Undo 기능을 처리할 InvokerRemoteControl 클래스를 만듭니다.

typescript
// Invoker: 커맨드를 실행하고, 실행 취소를 지원하는 리모컨
class RemoteControl {
  private command: Command | null = null;
  private commandHistory: Command[] = [];

  setCommand(command: Command): void {
    this.command = command;
  }

  pressButton(): void {
    if (this.command) {
      this.command.execute();
      this.commandHistory.push(this.command); // 실행 기록 저장
    } else {
      console.log("설정된 커맨드가 없습니다.");
    }
  }

  pressUndoButton(): void {
    const lastCommand = this.commandHistory.pop();
    if (lastCommand) {
      console.log("--- 실행 취소 ---");
      lastCommand.undo();
    } else {
      console.log("취소할 작업이 없습니다.");
    }
  }
}

// 클라이언트 코드
const remote = new RemoteControl();
const livingRoomLight = new Light();

const lightOn = new LightOnCommand(livingRoomLight);
const lightOff = new LightOffCommand(livingRoomLight);

// 전등 켜기
remote.setCommand(lightOn);
remote.pressButton(); // 출력: 💡 전등이 켜졌습니다.

// 전등 끄기
remote.setCommand(lightOff);
remote.pressButton(); // 출력: ⬛ 전등이 꺼졌습니다.

// 실행 취소 (가장 마지막 작업인 '전등 끄기'를 취소)
remote.pressUndoButton();
// 출력:
// --- 실행 취소 ---
// 💡 전등이 켜졌습니다.

// 다시 실행 취소 (그 이전 작업인 '전등 켜기'를 취소)
remote.pressUndoButton();
// 출력:
// --- 실행 취소 ---
// ⬛ 전등이 꺼졌습니다.

RemoteControlLight 객체의 존재를 전혀 모릅니다. 그저 Command 인터페이스의 execute()undo() 메서드를 호출할 뿐입니다. 만약 오디오를 제어하는 기능을 추가하고 싶다면, Audio 클래스와 AudioOnCommand 등을 새로 만들어 리모컨에 설정하기만 하면 됩니다. 리모컨 코드는 변경할 필요가 없습니다.

커맨드 패턴 중요 키워드 🔑

  • 📦 요청을 객체로 캡슐화합니다.
  • 🤝 요청자(Invoker)와 수신자(Receiver)를 분리하여 결합도를 낮춥니다.
  • 🔄 높은 재사용성: InvokerCommand 인터페이스에만 의존하므로, 어떤 기능(Command)이든 실행할 수 있는 재사용 가능한 클래스가 됩니다. 예시의 RemoteControlLight뿐만 아니라 Audio, Heater 등 어떤 Receiver의 커맨드든 받아 실행할 수 있습니다.
  • ↩️ 실행 취소(Undo/Redo) 기능을 구현하는 데 매우 유용합니다.
  • 📋 작업 큐, 트랜잭션, 로깅 등 다양한 곳에 활용될 수 있습니다.

📝 4. 템플릿 메서드 (Template Method) 패턴

템플릿 메서드 패턴은 알고리즘의 골격(뼈대)은 상위 클래스에서 정의하고, 알고리즘의 특정 단계들은 하위 클래스에서 재정의(override)할 수 있도록 하는 패턴입니다. 즉, 전체적인 로직의 흐름(템플릿)은 부모가 통제하되, 세부적인 내용은 자식에게 위임하는 방식입니다.

앞서 비유로 들었던 '라면 끓이기' 🍜 가 바로 이 패턴의 완벽한 예시입니다. 라면을 끓이는 과정은 대체로 정해져 있습니다.

  1. 물을 끓인다.
  2. 면과 스프를 넣는다.
  3. (선택) 계란, 치즈, 만두 등 추가 재료를 넣는다.
  4. 그릇에 담아낸다.

이 전체 과정이 템플릿 메서드입니다. 여기서 '물을 끓인다'나 '면과 스프를 넣는다' 같은 단계는 모든 라면에서 동일하지만, '추가 재료를 넣는다'는 단계는 어떤 라면을 끓이느냐에 따라 달라집니다. 템플릿 메서드 패턴은 이처럼 변하지 않는 부분은 상위 클래스에 두고, 변하는 부분만 하위 클래스에서 구현하도록 하여 코드의 중복을 줄이고 일관된 구조를 유지하게 해줍니다.

🏗️ 기본 구조

  • 📜 AbstractClass: 템플릿 메서드를 정의하는 추상 클래스입니다. 템플릿 메서드는 알고리즘의 각 단계를 나타내는 여러 메서드를 순서대로 호출합니다. 이 중 일부는 하위 클래스에서 구현해야 하는 추상 메서드(abstract method)이거나, 선택적으로 재정의할 수 있는 hook 메서드일 수 있습니다.
  • ConcreteClass: AbstractClass를 상속받아, 알고리즘의 특정 단계를 실제로 구현하는 하위 클래스입니다.

💻 예시: 세상의 모든 라면 만들기

다양한 종류의 라면을 만드는 과정을 템플릿 메서드 패턴으로 구현해 보겠습니다.

먼저, 라면 제조의 전체적인 흐름을 정의하는 AbstractClassRamenRecipe를 만듭니다.

typescript
// AbstractClass: 알고리즘의 뼈대를 정의
abstract class RamenRecipe {
  // 템플릿 메서드: 전체적인 흐름을 제어하며, 재정의를 막는 것이 좋음
  cook(): void {
    this.boilWater();
    this.addNoodlesAndSoup();
    this.addExtraIngredients(); // 이 단계가 자식 클래스마다 달라짐
    this.serve();
  }

  // 하위 클래스에서 반드시 구현(오버라이드)해야 하는 추상(abstract) 메서드
  // protected 접근 제어자는 하위 클래스에서만 접근 가능하도록 합니다.`
  protected abstract addExtraIngredients(): void;

  // 모든 클래스에서 동일하게 사용되는 구체적인 메서드
  private boilWater(): void {
    console.log("냄비에 물을 끓입니다.");
  }

  private addNoodlesAndSoup(): void {
    console.log("면과 분말 스프, 후레이크를 넣습니다.");
  }

  private serve(): void {
    console.log("맛있게 보이도록 그릇에 옮겨 담습니다.");
  }
}

이제 RamenRecipe를 상속받아 구체적인 라면 레시피를 만드는 ConcreteClassCheeseRamenDumplingRamen을 구현합니다.

typescript
// ConcreteClass: 치즈 라면
class CheeseRamen extends RamenRecipe {
  protected addExtraIngredients(): void {
    console.log("체다 치즈 한 장을 사르르 녹여줍니다.");
  }
}

// ConcreteClass: 만두 라면
class DumplingRamen extends RamenRecipe {
  protected addExtraIngredients(): void {
    console.log("냉동실에 있던 왕만두 세 개를 투하합니다.");
  }
}

// 클라이언트 코드
console.log("--- 치즈 라면 만들기 ---");
const cheeseRamen = new CheeseRamen();
cheeseRamen.cook();
// 출력:
// 냄비에 물을 끓입니다.
// 면과 분말 스프, 후레이크를 넣습니다.
// 체다 치즈 한 장을 사르르 녹여줍니다.
// 맛있게 보이도록 그릇에 옮겨 담습니다.

console.log("
--- 만두 라면 만들기 ---");
const dumplingRamen = new DumplingRamen();
dumplingRamen.cook();
// 출력:
// 냄비에 물을 끓입니다.
// 면과 분말 스프, 후레이크를 넣습니다.
// 냉동실에 있던 왕만두 세 개를 투하합니다.
// 맛있게 보이도록 그릇에 옮겨 담습니다.

cook()이라는 템플릿 메서드 덕분에 어떤 라면이든 일관된 순서로 만들어집니다. 개발자는 addExtraIngredients()처럼 달라지는 부분에만 집중하면 됩니다. 만약 '계란 라면'을 추가하고 싶다면, addExtraIngredients()에서 계란을 추가하는 EggRamen 클래스를 새로 만들기만 하면 됩니다.

템플릿 메서드 패턴 중요 키워드 🔑

  • 🏛️ 알고리즘의 골격을 상위 클래스에서 정의합니다.
  • 👨‍👦 상속을 통해 동작을 확장합니다. (전략 패턴은 위임을 사용)
  • 🔄 코드 중복을 제거하고 일관된 구조를 강제할 수 있습니다.
  • 상위 작업의 구조를 바꾸지 않으면서 서브 클래스로 작업 일부분을 수행할 수 있습니다.