빌더 패턴
개요
보고서의 제목과 행을 넣는 순서는 같은데, 텍스트와 HTML을 만드는 코드가 따로 늘어난다고 하자. 조립 순서는 공유하고 각 단계가 어떤 표현을 만드는지만 바꾸고 싶다.
빌더(Builder)는 결과물을 단계별로 조립하는 역할이다. 조립 순서를 가진 쪽은 제목을 넣고 행을 넣으라고 요청하고, 빌더는 그 요청을 받아 자기 형식의 결과물을 쌓는다.
UML
ReportRecipe가 IReportBuilder의 단계를 순서대로 호출한다. TextBuilder와 HtmlBuilder는 같은 단계를 받아 서로 다른 표현을 만들고 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에서 실행한다.