Typescript로 다시 쓰는 GoF - Builder

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

Builder란?

Builder란 복잡한 객체를 만들 때 마치 건물처럼 각 프로퍼티를 하나하나 순서대로 쌓아가면서 객체를 완성해나가는 디자인 패턴의 일종이다. 여기서 "복잡한 객체"라는 말이 굉장히 추상적인데, 이는 그만큼 Builder 패턴이 쓰이는 경우가 다양하기 때문이다.



언제 쓸까?

Builder는 정말 많은 곳에서 쓰일 수 있다.

  • 어느 객체의 생성자가 많은 매개변수를 받아서 인스턴스를 초기화하여야 할 때
  • 불변 객체를 만들어야할 때. 즉, 객체의 생성과 객체의 표현을 분리하고 싶을 때
  • 객체의 생성 순서가 복잡할 때
  • 동일한 구조를 여러 객체에서 재사용하려 할 때

이 이외의 상황에서도, 그리고 다른 디자인패턴과 혼합하여서도 사용될 수 있으며, 공통적으로 Builder에 의해 복잡한 객체를 생성하는 과정이 가독성 있고 유지보수가 쉬운 형태로 바뀌게 된다.



Builder 구현

Builder 패턴에는 3가지 객체가 등장한다.

  1. Builder 건축가
    인스턴스를 생성하기 위한 인터페이스의 모양을 정의한다.

  2. ConcreteBuilder 구체적인 건축가
    Builder의 인터페이스를 구현하는 역할이다. 실제 생성되는 인스턴스를 만드는 역할을 하며, 만일 불변객체를 만들거나 생성과 출력을 분리한다면 결과를 꺼내주는 메소드가 추가로 필요하다.

  3. Director 감독관
    Builder의 인터페이스를 이용해 인스턴스를 생성하며, ConcreteBuilder에 의존하지 않도록 한다.


주어진 텍스트를 정해진 포멧의 문서로 출력하는 프로그램을 만들어보자.


Builder

우선, 문서를 구성하는 각 포멧에 맞는 입력을 받는 메소드들의 형태를 먼저 Builder에서 정의한다.

interface Builder {
  makeTitle(title: string): Builder;
  makeString(str: string): Builder;
  makeItems(items: string[]): Builder;
  close(): void;
  getResult(): string;
}

이때, return 타입은 Builder 자신으로 하여 추후 메소드 체이닝을 통해 간편하게 인스턴스를 만들어낼 수 있다.


ConcreteBuilder

일반 텍스트 문서로 출력해주는 객체와 html 코드로 문서를 출력해주는 객체 두 가지를 만든다. 이 객체들은 앞서 Builder에서 정의한 메소드들을 실제 내부로직을 가지고 구현한다.

class TextBuilder implements Builder {
  private buffer: string[] = [];

  makeTitle(title: string): Builder {
    this.buffer.push('==============================');
    this.buffer.push(`[${title}]`);
    this.buffer.push('');
    return this;
  }

  makeString(str: string): Builder {
    this.buffer.push(`* ${str}`);
    this.buffer.push('');
    return this;
  }

  makeItems(items: string[]): Builder {
    items.forEach(item => {
      this.buffer.push(`  - ${item}`);
    });
    this.buffer.push('');
    return this;
  }

  close(): void {
    this.buffer.push('==============================');
  }

  getResult(): string {
    return this.buffer.join('\n');
  }
}

class HTMLBuilder implements Builder {
  private buffer: string[] = [];
  private filename: string | undefined;

  makeTitle(title: string): Builder {
    this.filename = `${title}.html`;

    this.buffer.push('<!DOCTYPE html>');
    this.buffer.push('<html>');
    this.buffer.push(`<head><title>${title}</title></head>`);
    this.buffer.push('<body>');
    this.buffer.push(`<h1>${title}</h1>`);
    return this;
  }

  makeString(str: string): Builder {
    this.buffer.push(`<p>${str}</p>`);
    return this;
  }

  makeItems(items: string[]): Builder {
    this.buffer.push('<ul>');
    items.forEach(item => {
      this.buffer.push(`<li>${item}</li>`);
    });
    this.buffer.push('</ul>');
    return this;
  }

  close(): void {
    this.buffer.push('</body>');
    this.buffer.push('</html>');
  }

  getResult(): string {
    return this.buffer.join('\n');
  }
}

여기서 자바에서는 한번 선언된 String객체는 수정이 어렵기 때문에 StringBuilder라는 또다른 Builder 패턴을 사용하여 문자열을 조합하는데, 이 동작을 흉내내기 위해 버퍼를 만들었다.

하지만 이렇게 배열을 사용해 문자열을 조합하는 것은 실제 성능상으로도 유용하다. 일반 string 타입의 변수에 + 연산자를 사용해 계속 확장해나가는 것은 변수를 재할당할 때마다 전체 문자열 데이터를 다시 써야한다. 하지만, 배열에 추가를 할 경우 새로 추가하는만큼의 데이터만 쓰면 되기 때문에 훨씬 빠르게 문자열의 길이를 확장할 수 있다.


Director

마지막으로 구현하는 프로그램이 ConcreteBuilder에 의존하지 않도록 해주는 Director 클래스를 구현한다. 여기서 실제 Builder 메소드들을 메소드 체이닝을 통해 호출한다.

class Director {
  constructor(
    private readonly builder: Builder,
  ) {
    this.builder = builder;
  }

  construct(): void {
    this.builder
      .makeTitle('Greeting')
      .makeString('From morning to afternoon')
      .makeItems([
        'Good morning',
        'Good afternoon',
      ])
      .close();
  }
}

construct 메소드는 단순히 Builder에서 정의한 메소드를 호출하기만 할 뿐이다. 따라서, ConcreteBuilder에서 내부로직을 어떻게 구현하는지는 Director의 관심사가 아니다. 즉, ConcreteBuilder는 자유롭게 교체가 가능하다.

여기서 Dependency Injection 의존성 주입이라는 개념도 함께 쓰였는데, 위처럼 constructor에서 Builder 인스턴스가 직접 생성자를 통해 전달되는 형태를 DI라고하며, 클래스간 결합도를 낮추고 코드의 재사용성을 높히기 위한 기법이다. 다만, 여기서는 자세히 다루지 않기로 한다.

또한, 일반적인 경우에는 보통 constuct 메소드에서 사용한 메소드 체이닝 정도까지만을 빌더 패턴의 목적으로 사용하곤한다. 메소드 체이닝을 사용해서 클래스 생성자에 각 인자가 무슨 의미인지 이해하기 힘든 형태로 인자들을 나열하지 않아도 되기 때문이다. 예를들어 아래 예시처럼 말이다.

const doc = new TextDocument('Greeting', 'From morning to afternoon', ['Good morning', 'Good afternoon']);

Builder 사용

이제 작성한 코드를 호출해보자.

const main = (...args: string[]) => {
  if (
    args.length !== 1
    || !['text', 'html'].includes(args[0])
  ) {
    usage();
    process.exit(1);
  }

  const mode = args[0];
  const builder = getBuilder(mode);
  const director = new Director(builder);
  director.construct();

  const result = builder.getResult();
  console.log(result);
}

const usage = () => {
  console.log('Usage: ts-node Builder text');
  console.log('Usage: ts-node Builder html');
}

const getBuilder = (mode: string): Builder => {
  const modeToBuilder: Record<string, Builder> = {
    text: new TextBuilder(),
    html: new HTMLBuilder(),
  };

  return modeToBuilder[mode];
}

main(...process.argv.slice(2));
$ ts-node Builder hello
Usage: ts-node Builder text
Usage: ts-node Builder html

$ ts-node Builder text 
==============================
[Greeting]

* From morning to afternoon

  - Good morning
  - Good afternoon

==============================

$ ts-node Builder html
<!DOCTYPE html>
<html>
<head><title>Greeting</title></head>
<body>
<h1>Greeting</h1>
<p>From morning to afternoon</p>
<ul>
<li>Good morning</li>
<li>Good afternoon</li>
</ul>
</body>
</html>


각 객체의 책임

객체지향 프로그래밍에서 "어느 객체가 무엇을 알고 있는가?"에 대한 이해는 매우 중요하다. 그리고 Builder 패턴은 이런 각 객체의 책임 소재를 확인하기 좋은 예시다.

우선, main함수에서는 Builder에 어떤 메소드가 있는지 알지 못한다. 그저 ConcreteBuilder의 인스턴스를 만들어서 Director에 넘겨주고, Directorconstruct 메소드를 실행할 뿐이다.

DirectorBuilder의 메소드는 알지만 각 메소드가 어떤 로직을 내부적으로 구현하고 있는지에 대해서는 무지하다. Director의 역할은 그저 각 메소드를 순서에 맞게 호출하는 것까지이기 때문이다.

마지막으로 TextBuilderHTMLBuilderBuilder의 각 메소드를 구현하기만 하고, 이 메소드들이 어떻게 사용되는지는 알지 못한다.

이렇게 책임소재를 나눈다는 것은 불필요하게 코드의 복잡성을 증가시키는 것으로 여길수도있지만, 각 객체들간의 결합이 약하기 때문에 오히려 코드의 수정이 필요할 때 작은 부분만을 수정해도 나머지 객체들의 동작이 보장을 받을 수 있다. 즉, 각 객체의 책임을 벗어나는 객체는 자유롭게 교체가 가능해지는 것이다.




참고자료

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

0개의 댓글