Typescript로 작성하는 디자인 패턴

2024년 08월 27일

TOC

Typescript

'바퀴를 다시 발명하지 마라(Don't reinvent the wheel)'라는 유명한 프로그래밍 격언이 있다. 개발 과정 중에 문제가 발생하면 새로 해결책을 구상하는 것보다 문제에 해당하는 디자인 패턴을 참고하여 적용하는 것이 더 효율적이다.

디자인 패턴이란?

디자인 패턴(Design Pattern)은 소프트웨어 개발에서 자주 발생하는 문제들을 해결하기 위한 반복 가능한 솔루션을 제공하는 중요한 개념이다. 이러한 패턴들은 코드의 재사용성을 높이고, 유지보수를 쉽게 하며, 코드의 가독성을 향상시키는 데 도움을 준다. 이번 포스트에서는 TypeScript를 사용해 여러 디자인 패턴을 구현해 보았다. TypeScript는 정적 타입 검사와 객체 지향 프로그래밍 기능을 제공하므로 디자인 패턴을 적용하는 데 매우 적합한 언어다.


GoF 디자인 패턴 목적 분류

GoF 디자인 패턴은 소프트웨어 개발에서 자주 사용되는 설계 패턴을 정의한 것이다. GoF는 'Gang of Four'의 약자로, 이 패턴을 정의한 네 명의 저자들(Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides)을 가리킨다. 이들은 1994년에 출판한 책 "Design Patterns: Elements of Reusable Object-Oriented Software"에서 23개의 디자인 패턴을 소개했는데, 여기서 목적에 따른 세 가지 카테고리로 분류하였다.

  • 생성 패턴(Creational Pattern) : 객체의 생성과 참조 과정을 캡슐화 하여 객체가 생성되거나 변경되어도 프로그램의 구조에 영향을 크게 받지 않도록 하여 프로그램에 유연성을 더해 준다.

    ex ) 추상 팩토리(Abstract Factory), 빌더(Builder), 팩토리 메서드(Factory Method), 프로토타입(Prototype), 싱글톤(Singleton)

  • 행위 패턴(Behavioral Pattern) : 클래스나 객체들이 서로 상호작용하는 방법이나 책임 분배 방법을 정의하는 패턴으로, 하나의 객체로 수행할 수 없는 작업을 여러 객체로 분배하면서 결합도를 최소화 할 수 있도록 도와준다.

    ex ) 책임 연쇄(Chain of Responsibility), 커맨드(Command), 인터프리터(Interpreter), 전략(Strategy), 옵저버(Observer)

  • 구조 패턴(Structural Pattern) : 클래스나 객체들을 조합하여 더 큰 구조로 만들 수 있게 해주는 패턴으로, 구조가 복잡한 시스템을 개발하기 쉽게 도와준다.

    ex ) 어댑터(Adapter), 브리지(Bridge), 컴포지트(Composite), 데코레이터(Decorator), 프록시(Proxy)


생성 패턴

싱글톤 패턴 (Singleton Pattern)

특정 클래스의 인스턴스가 오직 하나만 생성되도록 보장하는 패턴이다. 이 패턴은 주로 애플리케이션 내에서 전역적으로 접근 가능한 하나의 객체가 필요할 때 사용된다.

class Singleton {
  private static instance: Singleton

  private constructor() {
    // private constructor를 사용하여 외부에서 인스턴스 생성을 방지
  }

  public static getInstance(): Singleton {
    if (!Singleton.instance) {
      Singleton.instance = new Singleton()
    }
    return Singleton.instance
  }

  public someMethod() {
    console.log("This is a method in the Singleton class.")
  }
}

const singleton1 = Singleton.getInstance()
const singleton2 = Singleton.getInstance()

console.log(singleton1 === singleton2) // true

Singleton 클래스의 인스턴스를 하나만 생성할 수 있으며, getInstance() 메서드를 통해 언제나 같은 인스턴스를 반환한다. 이를 통해 메모리 사용을 절약하고, 전역 상태를 관리하는 데 유용하다.


팩토리 메서드 패턴 (Factory Method Pattern)

객체 생성의 로직을 서브클래스에서 정의하도록 하여 객체 생성의 유연성을 제공하는 패턴이다. 이 패턴은 객체 생성의 책임을 팩토리 클래스에 위임함으로써 코드의 결합도를 낮추고, 객체 생성 로직을 중앙 집중화할 수 있다.

abstract class Product {
  abstract use(): void
}

class ConcreteProductA extends Product {
  use(): void {
    console.log("Using Product A")
  }
}

class ConcreteProductB extends Product {
  use(): void {
    console.log("Using Product B")
  }
}

abstract class Creator {
  abstract factoryMethod(): Product

  createProduct(): Product {
    return this.factoryMethod()
  }
}

class ConcreteCreatorA extends Creator {
  factoryMethod(): Product {
    return new ConcreteProductA()
  }
}

class ConcreteCreatorB extends Creator {
  factoryMethod(): Product {
    return new ConcreteProductB()
  }
}

const creatorA = new ConcreteCreatorA()
const productA = creatorA.createProduct()
productA.use()

const creatorB = new ConcreteCreatorB()
const productB = creatorB.createProduct()
productB.use()

Product 클래스를 상속받아 ConcreteProductA와 ConcreteProductB를 구현했다. 각각의 Creator 서브클래스에서 팩토리 메서드를 통해 특정 제품의 인스턴스를 생성하게 된다. 이 패턴은 새로운 제품이 추가될 때 기존 코드를 수정하지 않고도 확장할 수 있도록 도와준다.


행위 패턴

전략 패턴 (Strategy Pattern)

알고리즘 군을 정의하고, 각 알고리즘을 캡슐화하여 교환 가능하게 만드는 패턴이다. 이 패턴은 런타임 시에 알고리즘을 선택할 수 있도록 유연성을 제공한다.

interface Strategy {
  execute(a: number, b: number): number
}

class AddStrategy implements Strategy {
  execute(a: number, b: number): number {
    return a + b
  }
}

class SubtractStrategy implements Strategy {
  execute(a: number, b: number): number {
    return a - b
  }
}

class Context {
  private strategy: Strategy

  constructor(strategy: Strategy) {
    this.strategy = strategy
  }

  setStrategy(strategy: Strategy) {
    this.strategy = strategy
  }

  executeStrategy(a: number, b: number): number {
    return this.strategy.execute(a, b)
  }
}

const context = new Context(new AddStrategy())
console.log(context.executeStrategy(10, 5)) // 15

context.setStrategy(new SubtractStrategy())
console.log(context.executeStrategy(10, 5)) // 5

Strategy 인터페이스를 통해 다양한 알고리즘을 정의하고, Context 클래스에서 이들을 실행할 수 있다. 이를 통해 서로 다른 알고리즘을 쉽게 교체할 수 있으며, 새로운 전략을 추가할 때 기존 코드를 수정하지 않아도 된다.


옵저버 패턴 (Observer Pattern)

객체의 상태 변화를 관찰하는 옵저버들에게 알리는 패턴이다. 이 패턴은 이벤트 기반 시스템에서 자주 사용되며, 상태 변화에 따라 여러 객체가 동기화되어야 하는 경우 유용하다.

interface Observer {
  update(data: any): void
}

class ConcreteObserver implements Observer {
  private observerState: any

  update(data: any) {
    this.observerState = data
    console.log(`Observer state updated with data: ${data}`)
  }
}

class Subject {
  private observers: Observer[] = []

  addObserver(observer: Observer) {
    this.observers.push(observer)
  }

  removeObserver(observer: Observer) {
    this.observers = this.observers.filter((obs) => obs !== observer)
  }

  notifyObservers(data: any) {
    this.observers.forEach((observer) => observer.update(data))
  }
}

const subject = new Subject()

const observer1 = new ConcreteObserver()
const observer2 = new ConcreteObserver()

subject.addObserver(observer1)
subject.addObserver(observer2)

subject.notifyObservers("Hello, Observers!")

subject.removeObserver(observer1)
subject.notifyObservers("Observer1 removed.")

Observer 인터페이스를 구현한 ConcreteObserver가 주체(Subject)의 상태 변화를 감지하고, 주체는 notifyObservers 메서드를 통해 모든 옵저버들에게 변경 사항을 알린다. 옵저버 패턴은 이벤트 핸들링, 데이터 스트리밍 등 다양한 상황에서 유용하게 활용된다.


구조 패턴

데코레이터 패턴 (Decorator Pattern)

객체에 새로운 기능을 동적으로 추가할 수 있는 패턴이다. 이 패턴은 상속 대신 컴포지션을 사용하여 기능 확장이 가능하도록 도와준다.

interface Component {
  operation(): string
}

class ConcreteComponent implements Component {
  operation(): string {
    return "ConcreteComponent"
  }
}

class Decorator implements Component {
  protected component: Component

  constructor(component: Component) {
    this.component = component
  }

  operation(): string {
    return this.component.operation()
  }
}

class ConcreteDecoratorA extends Decorator {
  operation(): string {
    return `ConcreteDecoratorA(${super.operation()})`
  }
}

class ConcreteDecoratorB extends Decorator {
  operation(): string {
    return `ConcreteDecoratorB(${super.operation()})`
  }
}

const simple = new ConcreteComponent()
console.log(simple.operation())

const decoratorA = new ConcreteDecoratorA(simple)
console.log(decoratorA.operation())

const decoratorB = new ConcreteDecoratorB(decoratorA)
console.log(decoratorB.operation())

Component 인터페이스를 구현한 ConcreteComponent에 Decorator 클래스를 통해 기능을 동적으로 추가할 수 있다. 데코레이터 패턴을 사용하면 기존 객체에 새로운 기능을 추가하면서도 코드의 유연성을 유지할 수 있다.


프록시 패턴 (Proxy Pattern)

실제 객체에 대한 접근을 제어하기 위해 대리 객체를 사용하는 패턴이다. 프록시는 원래 객체의 기능을 확장하거나, 접근 제어를 위한 용도로 자주 사용된다.

interface Subject {
  request(): void
}

class RealSubject implements Subject {
  request(): void {
    console.log("RealSubject: Handling request.")
  }
}

class Proxy implements Subject {
  private realSubject: RealSubject

  constructor(realSubject: RealSubject) {
    this.realSubject = realSubject
  }

  request(): void {
    if (this.checkAccess()) {
      this.realSubject.request()
      this.logAccess()
    }
  }

  private checkAccess(): boolean {
    console.log("Proxy: Checking access prior to firing a real request.")
    return true
  }

  private logAccess(): void {
    console.log("Proxy: Logging the time of request.")
  }
}

const realSubject = new RealSubject()
const proxy = new Proxy(realSubject)
proxy.request()

Proxy 클래스가 실제 객체인 RealSubject에 대한 접근을 제어한다. 프록시 패턴은 원격 서버와의 통신, 캐싱, 접근 제어와 같은 다양한 시나리오에서 유용하게 사용된다.


어댑터 패턴 (Adapter Pattern)

기존 인터페이스를 클라이언트가 원하는 인터페이스로 변환해주는 패턴이다. 이 패턴은 서로 다른 인터페이스를 사용하는 클래스 간의 협력을 가능하게 해준다.

class Target {
  request(): string {
    return "Target: The default target's behavior."
  }
}

class Adaptee {
  specificRequest(): string {
    return ".eetpadA eht fo roivaheb laicepS"
  }
}

class Adapter extends Target {
  private adaptee: Adaptee

  constructor(adaptee: Adaptee) {
    super()
    this.adaptee = adaptee
  }

  request(): string {
    const result = this.adaptee.specificRequest().split("").reverse().join("")
    return `Adapter: (TRANSLATED) ${result}`
  }
}

const adaptee = new Adaptee()
console.log(`Adaptee: ${adaptee.specificRequest()}`)

const adapter = new Adapter(adaptee)
console.log(`Adapter: ${adapter.request()}`)

Adaptee 클래스의 인터페이스를 Adapter를 통해 Target 인터페이스로 변환한다. 어댑터 패턴은 기존 클래스를 수정하지 않고도 서로 다른 인터페이스를 사용하는 클래스들을 통합할 수 있는 강력한 방법이다.

여기까지 TypeScript를 사용하여 여러가지 디자인 패턴을 구현해 보았다. 각각의 패턴이 해결하는 문제와 구현 방법을 이해함으로써 더 나은 소프트웨어 개발에 많은 도움이 될 것이다.


Reference

Software design pattern - Wikipedia

Design Patterns - Wikipedia

Gof 디자인 패턴

타입스크립트로 작성된 디자인 패턴들

[Design pattern] 많이 쓰는 14가지 핵심 GoF 디자인 패턴의 종류 - 한빛출판네트워크

Written by

yhuj79

🌱 Junior Developer