빌더 패턴
개요
보고서의 제목과 행을 넣는 순서는 같은데, 텍스트와 HTML을 만드는 코드가 따로 늘어난다고 하자. 조립 순서는 공유하고 각 단계가 어떤 표현을 만드는지만 바꾸고 싶다.
빌더(Builder)는 결과물을 단계별로 조립하는 역할이다. 조립 순서를 가진 쪽은 제목을 넣고 행을 넣으라고 요청하고, 빌더는 그 요청을 받아 자기 형식의 결과물을 쌓는다.
UML
ReportRecipe가 IReportBuilder의 단계를 순서대로 호출한다. TextBuilder와 HtmlBuilder는 같은 단계를 받아 서로 다른 표현을 만들고 Finish()로 결과를 반환한다. GoF 원형에서는 결과를 돌려주는 GetResult가 각 ConcreteBuilder에 있고 Client가 결과를 받아 간다. 이 예제는 두 표현이 모두 string이라 Finish()를 공통 인터페이스에 두었다.
역사
GoF는 1994년 Design Patterns에서 Builder를 생성 패턴으로 정리했다. 복잡한 객체를 조립하는 과정과 그 표현을 나누는 데 초점을 둔다.1
왜 사용하는가?
형식마다 제목·행을 수집하는 흐름을 복사하면 조립 규칙 하나를 바꿀 때 구현들을 함께 고치게 된다. 순서를 따로 두면 형식별 코드는 표현을 만드는 단계에 집중한다.
메서드를 이어 부른다는 문법만으로 GoF Builder가 되는 것은 아니다. 이 예제에서 나누는 책임은 조립 순서와 결과 표현이다.
예제
ReportRecipe는 제목과 행을 차례로 넣고 텍스트 결과를 받는다.
using System;
using System.Collections.Generic;
using System.Linq;
Console.WriteLine(ReportRecipe.Build(new TextBuilder()));
// 조립 순서를 형식별 클래스에 복사하지 않도록 공통 단계만 정한다.
interface IReportBuilder
{
void Title(string title);
void Row(string value);
string Finish();
}
sealed class TextBuilder : IReportBuilder
{
private readonly List<string> parts = new();
public void Title(string title) => parts.Add(title);
public void Row(string value) => parts.Add("- " + value);
public string Finish() => string.Join("\n", parts);
}
sealed class HtmlBuilder : IReportBuilder
{
private readonly List<string> parts = new();
public void Title(string title) => parts.Add("<h1>" + System.Net.WebUtility.HtmlEncode(title) + "</h1>");
public void Row(string value) => parts.Add("<p>" + System.Net.WebUtility.HtmlEncode(value) + "</p>");
public string Finish() => string.Concat(parts);
}
// 순서는 이곳에서 정하고, 실제 표현은 전달받은 빌더에 맡긴다.
static class ReportRecipe
{
public static string Build(IReportBuilder builder)
{
builder.Title("Report");
builder.Row("A < B");
return builder.Finish();
}
}
Report
- A < B빌더는 작업 중인 parts를 가지고 있으므로 보고서마다 새 객체를 만든다. Finish()는 조립한 결과를 읽으며 상태를 자동 초기화하지 않는다.
범위 넓힌 예제
같은 조립 절차에 HtmlBuilder를 연결한다. HTML 구현은 외부 문자열이 태그로 해석되지 않도록 텍스트를 인코딩한다.
using System;
using System.Collections.Generic;
using System.Linq;
Console.WriteLine(ReportRecipe.Build(new HtmlBuilder()));
// 조립 순서를 형식별 클래스에 복사하지 않도록 공통 단계만 정한다.
interface IReportBuilder
{
void Title(string title);
void Row(string value);
string Finish();
}
sealed class TextBuilder : IReportBuilder
{
private readonly List<string> parts = new();
public void Title(string title) => parts.Add(title);
public void Row(string value) => parts.Add("- " + value);
public string Finish() => string.Join("\n", parts);
}
sealed class HtmlBuilder : IReportBuilder
{
private readonly List<string> parts = new();
public void Title(string title) => parts.Add("<h1>" + System.Net.WebUtility.HtmlEncode(title) + "</h1>");
public void Row(string value) => parts.Add("<p>" + System.Net.WebUtility.HtmlEncode(value) + "</p>");
public string Finish() => string.Concat(parts);
}
// 순서는 이곳에서 정하고, 실제 표현은 전달받은 빌더에 맡긴다.
static class ReportRecipe
{
public static string Build(IReportBuilder builder)
{
builder.Title("Report");
builder.Row("A < B");
return builder.Finish();
}
}
<h1>Report</h1><p>A < B</p>둘의 출력 모양은 다르지만 제목·행을 넣는 순서는 ReportRecipe 하나가 정한다. 복잡한 단계나 여러 조립 절차가 없다면 생성자나 간단한 함수로도 충분하다.
TypeScript 예제
제목과 행을 넣는 순서는 ReportRecipe에, 각 단계를 텍스트나 HTML로 만드는 방법은 빌더에 둔다. 그래서 순서를 바꾸는 일과 출력 표현을 바꾸는 일을 따로 고칠 수 있다.
// 조립 순서를 형식별 클래스에 복사하지 않도록 공통 단계만 정한다.
interface IReportBuilder {
title(value: string): void;
row(value: string): void;
finish(): string;
}
class TextBuilder implements IReportBuilder {
private readonly parts: string[] = [];
title(value: string): void { this.parts.push(value); }
row(value: string): void { this.parts.push("- " + value); }
finish(): string { return this.parts.join("\n"); }
}
function htmlText(value: string): string {
return value.replaceAll("&", "&").replaceAll("<", "<")
.replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
}
class HtmlBuilder implements IReportBuilder {
private readonly parts: string[] = [];
title(value: string): void { this.parts.push("<h1>" + htmlText(value) + "</h1>"); }
row(value: string): void { this.parts.push("<p>" + htmlText(value) + "</p>"); }
finish(): string { return this.parts.join(""); }
}
// 순서는 이곳에서 정하고, 실제 표현은 전달받은 빌더에 맡긴다.
class ReportRecipe {
static build(builder: IReportBuilder): string {
builder.title("Report");
builder.row("A < B");
return builder.finish();
}
}
console.log(ReportRecipe.build(new TextBuilder()));
console.log(ReportRecipe.build(new HtmlBuilder()));Report
- A < B
<h1>Report</h1><p>A < B</p>HTML은 텍스트 위치에 넣을 값을 인코딩한다. 각 빌더는 보고서마다 새로 만들며 finish()는 상태를 초기화하지 않는다.
같이 보기
C# 예제는 각각 독립된 .NET 10 콘솔 프로그램이다.
TypeScript 예제는 ES2022 이상으로 컴파일한 JavaScript를 Node.js에서 실행한다.