Typescript로 다시 쓰는 GoF - Bridge

아홉번째태양·2023년 8월 17일
0

Bridge란?

Bridge 패턴은 기능의 클래스 계층구현의 클래스 계층을 연결하는 역할을 한다. 따라서, 브릿지 패턴을 이해하기 위해서는 먼저 클래스 계층이 어떻게 구분되는지를 먼저 이해해야한다.


기능의 클래스 계층

어떤 기능을 구현한 클래스 Something이 있다고 가정하자. 이때 Something에 새로운 기능을 추가하고 싶을 때는 상속을 받아 새로운 클래스 SomethingGood을 만들 수 있다.

Something
└─ SomethingGood

여기서 두 클래스 간에 계층이 생겨나는데 이처럼 상위 클래스의 기능을 가지고 새로운 기능을 만들기 위해 하위 클래스를 새로 만드는 형태를 기능의 클래스 계층이라고 부른다.

만약 SomethingGood을 이용해 다시한번 새로운 기능을 구현하고자 한다면 또 새로운 하위 클래스가 추가되면서 기능의 클래스 계층이 확장된다.

Something
└─ SomethingGood
   └─ SomethingBetter

구현의 클래스 계층

반면에 구현의 클래스 계층은 새로운 하위 클래스 ConcreteClass를 만들 때 상위 클래스의 기능을 사용하는 것이 아니라, 상위 추상 클래스 AbstractClass를 구현하여 새 기능을 만들어 낸다.

AbstractClass
└─ ConcreteClass

이처럼 구현 클래스 계층에서는 각 클래스들은 역할을 분담한다. 그렇기 때문에 또 다른 기능의 AnotherConcreteClass를 새로 만든다고 한다면 구현의 클래스 계층은 수평적으로 확장된다.

AbstractClass
├─ ConcreteClass
└─ AnotherConcreteClass


Bridge 구현

Bridge 패턴은 우선 기능의 클래스 계층을 이룰 객체들과 구현의 클래스 계층을 이룰 객체들을 구분하여야한다. 따라서, 다음의 네 가지 객체가 등장한다.

  1. Implementer 구현자
    구현의 클래스 계층의 최상위 객체다. 아래의 Abstraction에서 인터페이스를 구현하기 위해 사용될 메소드들의 형태만을 정의한다.

  2. ConcreteImplementer 구체적인 구현자
    Implementer의 메소드를 구현한다.

  3. Abstraction 추상화
    기능의 클래스 계층의 최상위 클래스다. Implementer의 메소드들을 사용하여 기본적인 기능을 만들어낸다.

  4. RefinedAbstractor 개선된 추상화
    Abstraction 클래스에 기능이 추가된 클래스다.

이제, 주어진 문자열을 상자에 감싸서 출력하는 프로그램을 만들어보자.


Implementer

우선, rawOpen, rawPrint, rawClose라는 세가지 메소드의 형태만을 먼저 만든다.

interface DisplayImpl {
  rawOpen(): void;
  rawPrint(): void;
  rawClose(): void;
}

ConcreteImplementer

DisplayImpl의 메소드들을 구현하는 클래스 StringDisplayImpl을 만든다.

class StringDisplayImpl implements DisplayImpl {
  private width: number;

  constructor(
    private input: string,
  ) {
    this.input = input;
    this.width = input.length;
  }

  rawOpen(): void {
    this.printLine();
  }

  rawPrint(): void {
    console.log(`|${this.input}|`);
  }

  rawClose(): void {
    this.printLine();
  }

  private printLine(): void {
    const line = '-'.repeat(this.width);
    console.log(`+${line}+`);
  }
}

Abstraction

이제 기능의 클래스 계층을 만들 차례다. 주어진 무엇인가를 출력하는 클래스 Display를 만든다.

class Display {
  constructor(
    private readonly impl: DisplayImpl,
  ) {
    this.impl = impl;
  }

  open(): void {
    this.impl.rawOpen();
  }

  print(): void {
    this.impl.rawPrint();
  }

  close(): void {
    this.impl.rawClose();
  }

  display(): void {
    this.open();
    this.print();
    this.close();
  }
}

Abstraction은 추상적인 무엇인가를 기능적으로 표시하는 역할만을 한다. Display라는 클래스는 DI를 받고 DI로 받은 Implementer를 그저 사용하기만 한다. 그렇기 때문에 Display는 사실 무엇을 print하게 되는 알지 못한다는 점에 주목하자.


RefinedAbstractor

마지막으로 Display를 사용해 새로운 기능을 만들어낸 CountDisplay를 추가한다. CountDisplay는 입력한 숫자만큼 print 메소드를 더 호출하는 새로운 기능, multiDisplay를 갖는다.

class CountDisplay extends Display {
  constructor(
    impl: DisplayImpl,
  ) {
    super(impl);
  }

  multiDisplay(times: number): void {
    this.open();
    for (let i = 0; i < times; i++) {
      this.print();
    }
    this.close();
  }
}

이때 새 기능을 구현하기 위해 Implementer의 메소드가 아닌, Implementer의 메소드를 토대로 기능을 구현한 Display의 메소드들을 사용하고 있다는 점을 눈여겨 보자.


실행

이제 작성한 코드를 실행해본다.

const d1: Display = new Display(new StringDisplayImpl('Hello, Korea.'));
const d2: Display = new CountDisplay(new StringDisplayImpl('Hello, World.'));
const d3: CountDisplay = new CountDisplay(new StringDisplayImpl('Hello, Universe.'));

d1.display();
d2.display();
d3.display();
d3.multiDisplay(5);
+-------------+
|Hello, Korea.|
+-------------+
+-------------+
|Hello, World.|
+-------------+
+----------------+
|Hello, Universe.|
+----------------+
+----------------+
|Hello, Universe.|
|Hello, Universe.|
|Hello, Universe.|
|Hello, Universe.|
|Hello, Universe.|
+----------------+

여기서 의존성 주입DI의 장점이 드러난다.

만약, DisplayDisplayImpl을 위임받는 것이 아닌 StringDisplayImpl을 상속받는 형태로 구현되었다면, 두 객체간에 결합도가 크게 증가하여 Display가 수행할 수 있는 역할이 크게 제한 받는다. 하지만 Display의 구체적인 역할, 즉 구현체는 의존성 주입을 통해 전달받기 때문에 새로운 ConcreteAbstraction을 만들어서 전달하기만 한다면 Display는 코드의 수정 없이도 새로운 기능을 수행할 수 있다.

즉, 기능의 클래스 계층과 구현의 클래스 계층을 분리하였기 때문에 추가적으로 확장을 해야하는 상황이 편해진 것이다.




참고자료

Java언어로 배우는 디자인 패턴 입문 - 쉽게 배우는 Gof의 23가지 디자인패턴 (영진닷컴)

0개의 댓글