cv.cm

코딩 · 디자인 패턴

예제로 이해하는 빌더 패턴

7분 분량

핵심 답변

빌더 패턴은 복잡한 객체의 생성 과정을 최종 표현과 분리해, 같은 단계별 절차로 서로 다른 결과를 만들 수 있게 하는 생성 패턴입니다. 최신 코드에서는 보통 플루언트 API 형태로 나타납니다. 빌더에서 .url(), .header(), .timeout() 같은 메서드를 호출한 뒤 .build()가 검증된 완성 객체를 반환합니다. 선택 매개변수가 많은 긴 생성자를 대체합니다.

의도

복잡한 객체의 생성 과정을 표현과 분리해, 같은 생성 절차로 서로 다른 표현을 만들 수 있게 합니다. 빌더 패턴은 Gang of Four(GoF) 책의 다섯 가지 생성 패턴 중 하나입니다.

해결하는 문제

URL, 메서드, 임의 개수의 헤더, 선택 사항인 본문, 타임아웃, 재시도 횟수를 가진 HTTP 요청 객체를 떠올려 보세요. 이 모든 것을 받는 생성자는 올바르게 호출하기 어렵습니다.

class HttpRequest {
  constructor(
    readonly url: string,
    readonly method: string,
    readonly headers: Record<string, string>,
    readonly body?: string,
    readonly timeoutMs?: number,
    readonly retries?: number,
  ) {}
}

// Which argument is which? Easy to swap 3 and 5000 by mistake.
const req = new HttpRequest("https://api.example.com", "POST", {}, undefined, 3, 5000);

이를 텔레스코핑 생성자라고 합니다. "GET 요청은 본문을 가질 수 없다" 같은 규칙을 넣을 곳이 없고, 전송 전에 여러 곳에서 헤더를 추가하는 것처럼 점진적으로 만들 수도 없습니다.

구조와 참여자

참여자역할예제에서
Builder제품의 각 부분을 만드는 단계를 선언.RequestBuilder의 메서드
ConcreteBuilder단계를 구현하고, 중간 상태를 유지하며, 결과를 반환.RequestBuilder
Director선택 사항. 흔히 쓰는 구성을 만들기 위해 정해진 순서로 단계를 실행.jsonPost()
Product만들어지는 복잡한 객체.HttpRequest

TypeScript 구현

각 단계는 값을 저장하고 this를 반환하므로 호출을 체이닝할 수 있습니다. build()는 값의 조합을 검증하고 불변 제품을 반환합니다.

interface HttpRequest {
  readonly url: string;
  readonly method: "GET" | "POST";
  readonly headers: Readonly<Record<string, string>>;
  readonly body?: string;
  readonly timeoutMs: number;
}

class RequestBuilder {
  private method: "GET" | "POST" = "GET";
  private headers: Record<string, string> = {};
  private body?: string;
  private timeoutMs = 10_000;

  constructor(private readonly url: string) {}

  post(body: string): this { this.method = "POST"; this.body = body; return this; }
  header(name: string, value: string): this { this.headers[name] = value; return this; }
  timeout(ms: number): this { this.timeoutMs = ms; return this; }

  build(): HttpRequest {
    if (this.timeoutMs <= 0) throw new Error("timeout must be positive");
    return Object.freeze({
      url: this.url, method: this.method, headers: { ...this.headers },
      body: this.body, timeoutMs: this.timeoutMs,
    });
  }
}

const request = new RequestBuilder("https://api.example.com/orders")
  .header("Accept", "application/json")
  .timeout(5_000)
  .build();
console.log(request.method, request.timeoutMs, request.headers);
// GET 5000 { Accept: 'application/json' }

Director는 흔히 쓰는 조합법을 아는 코드일 뿐입니다. TypeScript에서는 빌더를 구동하는 평범한 함수인 경우가 많습니다.

interface Steps {
  header(name: string, value: string): this;
  post(body: string): this;
}

// Director: a reusable recipe that drives any builder with these steps.
function jsonPost<B extends Steps>(builder: B, data: unknown): B {
  return builder
    .header("Content-Type", "application/json")
    .post(JSON.stringify(data));
}

class LogBuilder implements Steps {
  log: string[] = [];
  header(name: string, value: string): this { this.log.push(`${name}=${value}`); return this; }
  post(body: string): this { this.log.push(`POST ${body}`); return this; }
}

console.log(jsonPost(new LogBuilder(), { id: 7 }).log);
// [ 'Content-Type=application/json', 'POST {"id":7}' ]

여기서 GoF의 원래 아이디어를 볼 수 있습니다. 같은 director가 구동하는 같은 생성 단계가 서로 다른 빌더에 전달되어 서로 다른 표현을 만들 수 있습니다. 여기서는 요청 대신 로그가 만들어집니다.

언제 쓰고 언제 쓰지 말아야 하나

다음과 같을 때 빌더 패턴을 사용합니다.

  • 객체에 선택적인 부분이 많거나 부분을 하나씩 추가해야 할 때.
  • 생성에 여러 필드가 얽힌 규칙이 있어 build()에서 한 번에 검사하고 싶을 때.
  • 단계별로 조립하면서도 완성된 객체는 불변으로 만들고 싶을 때.
  • HTML, Markdown, PDF로 내보내는 문서처럼 같은 생성 절차가 서로 다른 표현을 만들어야 할 때.

다음 경우에는 피하세요.

  • 객체의 필드가 몇 개뿐일 때. 생성자가 더 짧고 똑같이 명확합니다.
  • 사용하는 언어에 이미 이름 있는 매개변수나 선택 매개변수가 있을 때. TypeScript에서는 옵션 객체 하나로 빌더가 아예 필요 없어지는 경우가 많습니다.
interface RequestOptions {
  url: string;
  method?: "GET" | "POST";
  headers?: Record<string, string>;
  timeoutMs?: number;
}

function createRequest({ url, method = "GET", headers = {}, timeoutMs = 10_000 }: RequestOptions) {
  return { url, method, headers, timeoutMs };
}

console.log(createRequest({ url: "/orders", timeoutMs: 5_000 }).method); // GET

실제 사용 사례

  • Java: java.net.http.HttpRequest.newBuilder()는 HttpRequest.Builder를 반환합니다. uri(), header(), timeout()을 체이닝한 뒤 build()를 호출합니다.
  • Java: Locale.Builder는 언어, 지역, 문자 체계 부분으로 로캘을 조립하고 검증합니다.
  • Java와 C#: StringBuilder는 덧붙인 여러 조각으로 문자열을 조립한 뒤 최종 불변 문자열을 만듭니다.
  • 많은 SQL 쿼리 빌더와 테스트 데이터 빌더가 같은 플루언트 스타일을 씁니다.

관련 패턴

  • 추상 팩토리 패턴은 제품군 전체를 만들어 각각을 즉시 반환하고, 빌더 패턴은 복잡한 제품 하나를 단계별로 구성해 마지막에 반환합니다.
  • 컴포지트 트리는 빌더로 만드는 경우가 많습니다. 트리를 노드 하나씩 쌓아 가는 것이 자연스러운 일련의 단계이기 때문입니다.
  • 플루언트 인터페이스는 this를 반환하는 호출을 체이닝하는 API 스타일입니다. 빌더가 자주 사용하지만 같은 것은 아닙니다.
  • 대부분의 새 객체가 기성 템플릿을 조금 변형한 것이라면 프로토타입 패턴이 대안이 될 수 있습니다.

자주 묻는 질문

빌더의 각 메서드는 왜 this를 반환하나요?

빌더를 반환하면 .header(...).timeout(...).build()처럼 한 표현식에서 호출을 체이닝할 수 있습니다. TypeScript에서 this를 반환하면 서브클래스에서도 올바른 타입이 유지됩니다.

Director 클래스가 꼭 필요한가요?

아니요. GoF 설명에서도 Director는 선택 사항입니다. 실무에서는 흔한 단계 순서를 적용하는 함수 하나면 보통 충분합니다.

TypeScript에서도 빌더 패턴이 유용한가요?

가끔은 그렇습니다. 기본값이 있는 옵션 객체가 대부분의 단순한 경우를 해결합니다. 빌더는 객체를 점진적으로 조립할 때, 필드 간 검증이 필요할 때, 불변 결과를 원할 때 여전히 유용합니다.

빌더 패턴과 팩토리 메서드 패턴은 무엇이 다른가요?

팩토리 메서드는 어떤 클래스를 인스턴스화할지 결정하고 한 번의 호출로 객체를 반환합니다. 빌더는 클라이언트가 여러 번의 호출을 통해 객체 하나가 어떻게 조립되는지 제어하게 합니다.

코딩 튜토리얼 전체