Builder란 복잡한 객체를 만들 때 마치 건물처럼 각 프로퍼티를 하나하나 순서대로 쌓아가면서 객체를 완성해나가는 디자인 패턴의 일종이다. 여기서 "복잡한 객체"라는 말이 굉장히 추상적인데, 이는 그만큼 Builder 패턴이 쓰이는 경우가 다양하기 때문이다.
Builder는 정말 많은 곳에서 쓰일 수 있다.
이 이외의 상황에서도, 그리고 다른 디자인패턴과 혼합하여서도 사용될 수 있으며, 공통적으로 Builder에 의해 복잡한 객체를 생성하는 과정이 가독성 있고 유지보수가 쉬운 형태로 바뀌게 된다.
Builder 패턴에는 3가지 객체가 등장한다.
Builder 건축가
인스턴스를 생성하기 위한 인터페이스의 모양을 정의한다.
ConcreteBuilder 구체적인 건축가
Builder의 인터페이스를 구현하는 역할이다. 실제 생성되는 인스턴스를 만드는 역할을 하며, 만일 불변객체를 만들거나 생성과 출력을 분리한다면 결과를 꺼내주는 메소드가 추가로 필요하다.
Director 감독관
Builder의 인터페이스를 이용해 인스턴스를 생성하며, ConcreteBuilder에 의존하지 않도록 한다.
주어진 텍스트를 정해진 포멧의 문서로 출력하는 프로그램을 만들어보자.
우선, 문서를 구성하는 각 포멧에 맞는 입력을 받는 메소드들의 형태를 먼저 Builder에서 정의한다.
interface Builder {
makeTitle(title: string): Builder;
makeString(str: string): Builder;
makeItems(items: string[]): Builder;
close(): void;
getResult(): string;
}
이때, return 타입은 Builder
자신으로 하여 추후 메소드 체이닝을 통해 간편하게 인스턴스를 만들어낼 수 있다.
일반 텍스트 문서로 출력해주는 객체와 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 타입의 변수에 +
연산자를 사용해 계속 확장해나가는 것은 변수를 재할당할 때마다 전체 문자열 데이터를 다시 써야한다. 하지만, 배열에 추가를 할 경우 새로 추가하는만큼의 데이터만 쓰면 되기 때문에 훨씬 빠르게 문자열의 길이를 확장할 수 있다.
마지막으로 구현하는 프로그램이 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']);
이제 작성한 코드를 호출해보자.
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
에 넘겨주고, Director
의 construct
메소드를 실행할 뿐이다.
Director
는 Builder
의 메소드는 알지만 각 메소드가 어떤 로직을 내부적으로 구현하고 있는지에 대해서는 무지하다. Director
의 역할은 그저 각 메소드를 순서에 맞게 호출하는 것까지이기 때문이다.
마지막으로 TextBuilder
와 HTMLBuilder
는 Builder
의 각 메소드를 구현하기만 하고, 이 메소드들이 어떻게 사용되는지는 알지 못한다.
이렇게 책임소재를 나눈다는 것은 불필요하게 코드의 복잡성을 증가시키는 것으로 여길수도있지만, 각 객체들간의 결합이 약하기 때문에 오히려 코드의 수정이 필요할 때 작은 부분만을 수정해도 나머지 객체들의 동작이 보장을 받을 수 있다. 즉, 각 객체의 책임을 벗어나는 객체는 자유롭게 교체가 가능해지는 것이다.
Java언어로 배우는 디자인 패턴 입문 - 쉽게 배우는 Gof의 23가지 디자인패턴 (영진닷컴)