본문 바로가기

디자인 패턴 구현

팩토리 메서드와 싱글턴, 어댑터, 옵저버, 전략 패턴을 strict 설정에서 그대로 컴파일되는 타입스크립트 코드로 구현하고, 각 패턴이 객체의 생성과 수명, 인터페이스의 변환, 알림, 알고리즘의 교체 가운데 무엇을 다루는지와, 패턴 없이 함수나 객체로 충분한 경우를 구분합니다.

설계에서 자주 되풀이되는 문제에는 이미 이름이 붙은 해법이 있습니다. 1994년에 에릭 감마(Erich Gamma)를 비롯한 네 저자가 정리한 23가지 디자인 패턴(design pattern)이 대표적이며, 앞 글의 SOLID 원칙을 같은 상황에 되풀이해 적용한 결과로 굳어진 설계의 모양이라고 볼 수 있습니다. 패턴의 이름을 알면 설계의 의도를 짧게 전할 수 있으며, 23가지 패턴을 생성과 구조, 행위로 나눈 분류는 소프트웨어공학 교재의 디자인 패턴의 목적과 선택에서 볼 수 있습니다.

이 글은 자주 쓰는 다섯 패턴을 strict 설정에서 그대로 컴파일되는 코드로 구현하고, 실행 결과로 동작을 확인합니다. 패턴마다 무엇이 바뀌는 문제를 다루는지 먼저 보고, 마지막으로 패턴을 고르는 기준과 패턴 없이도 충분한 경우를 정리합니다. 예제는 패턴마다 ts-first 프로젝트에 새 파일을 만들어 확인합니다.

팩토리 메서드

팩토리 메서드(factory method) 패턴은 객체를 만드는 일을 메서드 하나로 빼내고, 어떤 클래스의 객체를 만들지는 자식 클래스가 정하게 합니다. 부모 클래스는 만들어진 객체를 쓰는 공통의 흐름만 알고, 구체적인 클래스의 이름은 모릅니다.

factory-method.ts
interface Product {
  getName(): string;
}

class ProductA implements Product {
  getName(): string {
    return "Product A";
  }
}

class ProductB implements Product {
  getName(): string {
    return "Product B";
  }
}

abstract class Creator {
  protected abstract createProduct(): Product;

  describe(): string {
    const product = this.createProduct();
    return `Creator worked with ${product.getName()}`;
  }
}

class CreatorA extends Creator {
  protected createProduct(): Product {
    return new ProductA();
  }
}

class CreatorB extends Creator {
  protected createProduct(): Product {
    return new ProductB();
  }
}

for (const creator of [new CreatorA(), new CreatorB()]) {
  console.log(creator.describe());
}
Creator worked with Product A
Creator worked with Product B

describe는 4장에서 본 템플릿 메서드처럼 공통의 흐름을 정하고, 그 흐름 안에서 부르는 createProduct를 자식 클래스가 구현합니다. 새 제품이 생기면 새 제품 클래스와 그것을 만드는 새 Creator를 더하면 되고, describe를 쓰는 코드는 바뀌지 않습니다.

싱글턴

싱글턴(singleton) 패턴은 어떤 클래스의 인스턴스가 하나만 있도록 하고, 그 인스턴스를 얻는 통로를 하나로 정합니다. 설정이나 연결처럼 프로그램 전체가 같은 객체를 써야 할 때 씁니다.

singleton.ts
class AppConfig {
  private static instance: AppConfig | undefined;

  private constructor(private data: string) {}

  static getInstance(data = "Default Data"): AppConfig {
    if (AppConfig.instance === undefined) {
      AppConfig.instance = new AppConfig(data);
    }
    return AppConfig.instance;
  }

  getData(): string {
    return this.data;
  }

  setData(data: string): void {
    this.data = data;
  }
}

const s1 = AppConfig.getInstance("First Data");
const s2 = AppConfig.getInstance("Second Data");
console.log(s1 === s2, s1.getData());

s2.setData("Updated Data");
console.log(s1.getData());
true First Data
Updated Data

생성자가 private이므로 바깥에서 new AppConfig("x")를 쓰면 진단되고, 인스턴스는 getInstance로만 얻습니다. 두 번째 호출은 이미 만들어진 인스턴스를 돌려주므로 넘긴 "Second Data"는 쓰이지 않고, 두 변수가 같은 객체를 가리키므로 s2로 바꾼 값이 s1에서도 보입니다.

이 패턴에는 대가가 있습니다. 공유하는 상태가 테스트 사이에 남아 한 테스트의 변경이 다음 테스트에 영향을 줄 수 있고, private 생성자는 타입 검사에서만 막을 뿐입니다. 또 하나라는 범위는 같은 클래스 정의가 불러와진 실행 환경 안이어서, 다른 프로세스나 워커(worker)까지 하나로 묶지는 않습니다. ES 모듈은 한 번만 실행되므로, 생성자를 private로 막지 않은 평범한 클래스로 모듈의 최상위에 export const config = new AppConfig(...)를 두는 것만으로 같은 효과를 얻는 경우가 많으며, 다음 글의 의존성 주입으로 하나의 객체를 넘겨주는 방법도 있습니다.

어댑터

어댑터(adapter) 패턴은 쓰는 쪽이 기대하는 인터페이스와 실제 구현의 인터페이스가 다를 때, 그 사이를 바꾸어 이어 주는 객체를 둡니다. 전원 플러그의 모양을 바꾸어 주는 어댑터와 같은 역할입니다.

adapter.ts
class OldPaymentGateway {
  processOldPayment(amount: number, currency: string): boolean {
    console.log(`Old Gateway: Processing ${amount} ${currency} payment.`);
    return true;
  }
}

interface PaymentGateway {
  pay(amount: number): boolean;
}

class PaymentGatewayAdapter implements PaymentGateway {
  constructor(private oldGateway: OldPaymentGateway, private currency: string) {}

  pay(amount: number): boolean {
    return this.oldGateway.processOldPayment(amount, this.currency);
  }
}

class PaymentService {
  constructor(private gateway: PaymentGateway) {}

  makePayment(amount: number): void {
    const succeeded = this.gateway.pay(amount);
    console.log(succeeded ? "Payment completed." : "Payment failed.");
  }
}

new PaymentService(new PaymentGatewayAdapter(new OldPaymentGateway(), "USD")).makePayment(150);
Old Gateway: Processing 150 USD payment.
Payment completed.

PaymentService는 pay(amount)만 아는데, 예전 게이트웨이는 금액과 통화를 함께 받는 processOldPayment를 가졌습니다. 어댑터는 PaymentGateway를 구현하면서 받은 호출을 예전 메서드의 모양으로 바꾸어 넘기고, 빠진 통화는 생성할 때 받은 값으로 채웁니다. 어댑터는 호출의 모양을 이어 줄 뿐 금액을 환산하지 않으므로, 통화가 다르다면 환산은 따로 다루어야 합니다.

옵저버

옵저버(observer) 패턴은 한 객체의 상태가 바뀔 때, 그 변화를 알고 싶어 등록한 여러 객체에게 알려 줍니다. 상태를 가진 쪽을 주제(subject), 알림을 받는 쪽을 관찰자(observer)라고 하며, 발행과 구독(publish-subscribe)이라고도 부릅니다.

observer.ts
interface StateObserver {
  update(state: number): void;
}

class StateSubject {
  private observers: StateObserver[] = [];
  private state = 0;

  attach(observer: StateObserver): void {
    if (this.observers.includes(observer)) {
      console.log("Subject: Observer has been attached already.");
      return;
    }
    this.observers.push(observer);
    console.log("Subject: Attached an observer.");
  }

  detach(observer: StateObserver): void {
    const index = this.observers.indexOf(observer);
    if (index === -1) {
      console.log("Subject: Nonexistent observer.");
      return;
    }
    this.observers.splice(index, 1);
    console.log("Subject: Detached an observer.");
  }

  setState(state: number): void {
    this.state = state;
    console.log(`Subject: state changed to ${state}`);
    for (const observer of this.observers) {
      observer.update(this.state);
    }
  }
}

class ObserverA implements StateObserver {
  update(state: number): void {
    if (state < 3) console.log("ObserverA: reacted");
  }
}

class ObserverB implements StateObserver {
  update(state: number): void {
    if (state === 0 || state >= 2) console.log("ObserverB: reacted");
  }
}

const subject = new StateSubject();
const observerA = new ObserverA();
const observerB = new ObserverB();

subject.attach(observerA);
subject.attach(observerB);
subject.setState(2);
subject.setState(1);
subject.detach(observerA);
subject.setState(0);
Subject: Attached an observer.
Subject: Attached an observer.
Subject: state changed to 2
ObserverA: reacted
ObserverB: reacted
Subject: state changed to 1
ObserverA: reacted
Subject: Detached an observer.
Subject: state changed to 0
ObserverB: reacted

상태가 2일 때는 두 관찰자가 모두 알림을 받고, A는 3보다 작아서, B는 2 이상이어서 반응합니다. 상태가 1일 때도 둘 다 알림을 받지만 조건에 맞는 A만 반응하며, A를 떼어 낸 뒤의 상태 0에서는 B만 알림을 받습니다. 알림을 받고도 반응하지 않는 것과 아예 알림을 받지 않는 것은 다르다는 점을 출력에서 구분할 수 있습니다.

주제는 관찰자의 구체적인 클래스를 모르고 StateObserver라는 모양만 알기 때문에, 새 관찰자를 더해도 StateSubject는 바뀌지 않습니다. 이 구현은 등록된 순서대로 하나씩 동기적으로 부르므로, 한 관찰자가 예외를 던지면 그 뒤의 관찰자는 알림을 받지 못합니다.

더 깊이: 실행 환경에 들어 있는 옵저버

옵저버 패턴은 Node.js와 브라우저에 이미 들어 있습니다. Node.js의 이벤트 문서에 따르면 EventEmitter는 이 글의 구현처럼 등록된 순서대로 모든 리스너를 동기적으로 부르고, 리스너 안에서 비동기로 넘어가고 싶을 때는 setImmediate()나 process.nextTick()을 쓰라고 안내합니다. 'error' 이벤트는 특별해서, 리스너가 하나도 없는 상태에서 'error'를 내보내면 그 오류가 던져지고 프로세스가 끝납니다. 또 한 이벤트에 리스너가 10개를 넘으면 메모리 누수일 수 있다는 경고를 출력하는데, 이것은 막는 한도가 아니라 경고일 뿐입니다.

웹 표준에서 온 EventTarget은 예외를 다르게 다룹니다. 같은 문서에 따르면 Node.js의 EventTarget에서는 한 리스너가 예외를 던져도 나머지 리스너는 그대로 불리고, 그 예외는 process.nextTick()에서 처리되지 않은 예외로 다시 드러나 기본적으로 프로세스를 끝냅니다. EventTarget에는 EventEmitter의 'error' 이벤트 같은 특별한 처리도 없습니다. 그래서 직접 만든 옵저버든 내장 옵저버든, 관찰자 하나의 실패가 다른 관찰자와 주제에 어떻게 퍼지는지를 먼저 정해 두어야 합니다.

전략

전략(strategy) 패턴은 바꾸어 끼울 수 있는 알고리즘을 같은 인터페이스의 객체로 만들고, 쓰는 쪽은 실행할 때 그 가운데 하나를 받아 씁니다. 앞 글의 할인 정책이 이 패턴의 예였습니다.

strategy.ts
interface PaymentStrategy {
  pay(amount: number): void;
}

class CreditCardPayment implements PaymentStrategy {
  pay(amount: number): void {
    console.log(`Paid ${amount} using Credit Card.`);
  }
}

class BankTransferPayment implements PaymentStrategy {
  pay(amount: number): void {
    console.log(`Paid ${amount} using Bank Transfer.`);
  }
}

class ShoppingCart {
  private paymentStrategy?: PaymentStrategy;

  constructor(private amount: number) {}

  setPaymentStrategy(strategy: PaymentStrategy): void {
    this.paymentStrategy = strategy;
  }

  checkout(): void {
    if (this.paymentStrategy === undefined) {
      console.log("No payment strategy selected!");
      return;
    }
    this.paymentStrategy.pay(this.amount);
  }
}

const cart = new ShoppingCart(250);
cart.checkout();
cart.setPaymentStrategy(new CreditCardPayment());
cart.checkout();
cart.setPaymentStrategy(new BankTransferPayment());
cart.checkout();
No payment strategy selected!
Paid 250 using Credit Card.
Paid 250 using Bank Transfer.

paymentStrategy는 처음에 정해지지 않으므로 선택적 속성으로 선언했고, checkout은 undefined인지 확인한 뒤에만 전략을 부릅니다. 같은 장바구니 객체가 실행 중에 전략만 바꾸어 다른 방식으로 결제하며, 새 결제 방식은 ShoppingCart를 고치지 않고 새 클래스로 더합니다.

타입스크립트에서는 함수도 값이므로, 메서드가 하나뿐인 전략은 type PaymentStrategy = (amount: number) => void;처럼 함수 타입으로 두는 편이 더 간단할 때가 많습니다. 그러면 전략마다 클래스를 만들 필요 없이 cart.setPaymentStrategy((amount) => console.log(...))처럼 화살표 함수를 넘기면 됩니다.

패턴 고르기

Factory Method, Singleton, Adapter, Observer, Strategy와 단순 구성의 선택 조건을 6카드로 비교한다.

TYPE / RUNTIME / CONTRACT

다섯 패턴을 바꾸려는 책임과 적용 범위로 비교한다

객체 생성·공유 수명·API 변환·일대다 통지·알고리즘 교체 중 필요한 책임부터 고릅니다. 단순한 함수나 객체로 충분한 경우에는 추가 구조의 비용을 피할 수 있습니다.

CREATE

Factory Method · 생성 선택

Creator가 정의한 factoryMethod를 구체 Creator가 구현해 Product 종류를 정합니다. 소비 코드는 구체 제품의 생성 과정을 몰라도 됩니다.

LIFETIME

Singleton · 공유 인스턴스

같은 클래스 정의가 로드된 실행 범위에서 인스턴스를 재사용합니다. 별도 process·worker·중복 모듈까지 하나로 만드는 보장은 아닙니다.

API

Adapter · 계약 변환

기대하는 pay(amount)를 기존 processOldPayment(amount, currency)에 연결합니다. 교재는 USD를 고정하는 변환입니다.

NOTIFY

Observer · 일대다 통지

Subject는 등록한 Observer에 update를 호출하고 detach로 통지를 끊습니다. 별도 broker나 비동기 stream을 전제하는 패턴은 아닙니다.

BEHAVIOR

Strategy · 알고리즘 교체

ShoppingCart는 PaymentStrategy를 사용합니다. 결제 방식의 구현을 바꿔도 같은 pay 계약으로 실행합니다.

COST

단순 구성으로 충분한가?

반복되는 변화 비용이 없다면 먼저 작은 함수·객체로 요구를 해결합니다. 패턴은 이름을 많이 쓰기 위한 목표가 아닙니다.

구현을 컴파일할 때는 상태 초기화와 타입 가드도 함께 검증합니다. 패턴 선택만으로 실행 정확성을 보장하지 않습니다.

위 그림처럼 패턴은 이름이 아니라 바꾸려는 것으로 고릅니다. 어떤 객체를 만들지가 바뀐다면 팩토리 메서드를, 하나의 객체를 함께 써야 한다면 싱글턴이나 그것을 대신할 모듈과 주입을, 이미 있는 구현의 모양이 맞지 않는다면 어댑터를 떠올립니다. 상태의 변화를 여러 곳에 알려야 한다면 옵저버를, 같은 일을 하는 알고리즘을 바꾸어 끼워야 한다면 전략을 씁니다.

그리고 그 변화가 실제로 되풀이되는지부터 확인합니다. 결제 방식이 하나뿐이고 앞으로도 늘어날 일이 없다면 인터페이스와 클래스를 여러 개 두는 것은 읽을 거리만 늘리므로, 함수 하나나 객체 리터럴 하나로 충분합니다. 패턴은 반복되는 변경의 비용을 줄이려고 구조를 더하는 것이므로, 그 비용이 아직 없다면 구조도 아직 필요 없습니다.

정리

  • 팩토리 메서드는 객체를 만드는 일을 메서드로 빼내고, 어떤 클래스를 만들지는 자식 클래스가 정하게 합니다.
  • 싱글턴은 인스턴스를 하나로 제한하지만 공유 상태가 테스트에 남는 대가가 있으며, ES 모듈이나 의존성 주입으로 대신하는 경우가 많습니다.
  • 어댑터는 맞지 않는 인터페이스 사이에서 호출의 모양만 바꾸어 이어 주며, 값의 의미까지 바꾸지는 않습니다.
  • 옵저버는 상태의 변화를 등록한 관찰자들에게 알리고, 전략은 같은 인터페이스의 알고리즘을 실행 중에 바꾸어 끼웁니다.
  • 패턴은 바꾸려는 것과 그 변화가 되풀이되는지로 고르며, 그렇지 않다면 함수나 객체로 충분합니다.

연습 문제

1. 알림의 순서 바꾸기

새 StateSubject에 observerB를 먼저, observerA를 나중에 붙이고 setState(2)를 부르면 반응의 순서는 어떻게 되나요? 이어서 observerB를 한 번 더 attach하면 무엇이 출력되나요?

해설 보기

관찰자는 등록한 순서대로 알림을 받으므로, ObserverB: reacted가 먼저, ObserverA: reacted가 나중에 출력됩니다. observerB는 이미 등록되어 있으므로 attach의 includes 검사에 걸려 Subject: Observer has been attached already.가 출력되고, 목록은 그대로입니다. 알림의 순서가 등록 순서에 기대고 있다는 점은, 관찰자끼리 순서에 의존하는 코드를 쓰지 않아야 하는 이유이기도 합니다.

2. 다시 부른 getInstance

위 실행 뒤에 AppConfig.getInstance("Third Data").getData()를 부르면 무엇을 돌려주나요? 테스트마다 새 설정으로 시작하고 싶다면 어떤 문제가 있나요?

해설 보기

이미 인스턴스가 있으므로 "Third Data"는 쓰이지 않고, 마지막에 바꾼 "Updated Data"를 돌려줍니다. 인스턴스가 클래스의 정적 속성에 남아 있어서, 한 테스트에서 바꾼 값이 다음 테스트에도 그대로 이어집니다. 테스트마다 새로 시작하려면 인스턴스를 비우는 메서드를 따로 두어야 하는데, 이런 불편함 때문에 설정 객체를 만들어 필요한 곳에 넘겨주는 의존성 주입을 함께 고려합니다.

3. 함수로 바꾼 전략

PaymentStrategy를 type PaymentStrategy = (amount: number) => void;로 바꾸면 ShoppingCart와 사용하는 코드는 각각 어떻게 바뀌나요?

해설 보기

checkout에서 this.paymentStrategy.pay(this.amount) 대신 this.paymentStrategy(this.amount)처럼 함수를 바로 부릅니다. 사용하는 쪽은 클래스를 만들지 않고 cart.setPaymentStrategy((amount) => console.log("Paid " + amount + " using Credit Card."));처럼 화살표 함수를 넘기며, 출력은 이전과 같습니다. 전략이 상태를 가지지 않고 메서드 하나뿐이라면 이렇게 함수 타입으로 두는 편이 짧고 읽기 쉽습니다.

다음 글 의존성 주입과 IoC 컨테이너에서는 이 글에서 여러 번 쓴, 필요한 객체를 바깥에서 넘겨받는 방식을 정리하고, 그 조립을 자동으로 해 주는 컨테이너를 만들어 봅니다.