2. Builder — 생성자 인자가 일곱 개 넘으면 읽기 싫어진다
작성일:2026.05.21|조회수:8

에이든 피자의 시범 운영 둘째 날, 역시 내 피자가 맛있었는지 어제 왔던 손님들이 또 왔다. 그런데 어제는 마르게리타와 페퍼로니 중 하나를 고르던 사람들이 이제는 도우를 바꾸고, 소스를 줄이고, 치즈를 더하고, 올리브는 빼고, 굽기는 바삭하게 해달라고 말하기 시작했다. 손님 입장에서는 자연스러운 요구다. 돈을 냈으니 자기 피자에 대한 주권을 행사하고 싶을 것이다. 문제는 그 주권이 코드에서는 생성자 인자 여덟 개로 나타난다는 점이다.
처음에는 CustomPizzaOrderDesk 클래스가 손님의 요청을 받아 CustomPizza를 바로 생성했다. 클래스 안에 있으니 얼핏 객체지향처럼 보인다. 하지만 객체지향처럼 보인다는 것과 읽기 좋은 코드는 별개의 문제다. 이 코드를 읽는 순간 우리는 작은 암호 해독자가 된다. 네 번째 true가 올리브인지, 여섯 번째 true가 페퍼로니인지, 마지막 false가 웰던인지 알기 위해 생성자 정의를 찾아 떠나야 한다.
class CustomPizzaOrderDesk {
orderHouseSpecial(): CustomPizza {
return new CustomPizza(
'thin',
'tomato',
'mozzarella',
true,
false,
true,
'large',
false,
);
}
}무엇이 불편한가
문제는 인자가 많다는 사실 자체가 아니라, 인자의 의미가 호출부에서 사라진다는 데 있다. 문자열 몇 개와 boolean 몇 개가 줄줄이 놓이면 호출부는 더 이상 설명하지 않는다. 옵셔널 파라미터와 기본값으로 버틸 수도 있다. 하지만 선택지가 늘어날수록 생성자는 점점 더 많은 사정을 떠안는다. 어떤 값은 필수이고, 어떤 값은 선택이며, 어떤 값은 다른 값이 있을 때만 의미가 있다. 이런 규칙을 생성자 하나에 몰아넣으면 객체 생성은 점점 주문 제작이 아니라 서류 심사가 된다.
class CustomPizza {
constructor(
readonly dough: string,
readonly sauce: string,
readonly cheese: string,
readonly hasOlive: boolean,
readonly hasMushroom: boolean,
readonly hasPepperoni: boolean,
readonly size: string,
readonly wellDone: boolean,
) {}
}Builder 패턴
Builder 패턴은 복잡한 객체를 한 번에 만들지 않고 단계적으로 조립한다. GoF의 원래 의도는 다음과 같다.
"복잡한 객체의 생성 과정과 표현을 분리해, 같은 생성 과정으로 서로 다른 표현을 만들 수 있게 한다"
에이든 피자식으로 말하면, 피자를 만들 때 도우부터 소스, 토핑, 크기, 굽기 정도를 차례대로 정하게 만드는 방식이다. 핵심은 호출부가 읽히게 만드는 것이다. 인자 위치로 의미를 추측하는 대신, 메서드 이름으로 무엇을 설정하는지 드러낸다. 손님이 말한 요구사항과 코드의 모양이 비슷해질수록, 코드는 덜 수상해진다.
const pizza = new CustomPizzaBuilder()
.setDough('thin')
.setSauce('tomato')
.addTopping('mozzarella')
.addTopping('pepperoni')
.setSize('large')
.setWellDone()
.build();이제 호출부는 설명을 되찾는다. 위에서 아래로 읽으면 피자가 조립되는 과정 자체가 된다. 생성자 인자 목록을 보며 추리하던 시간이 사라진다. 그 시간에 피자를 한 조각 더 먹을 수 있다면, 그것이야말로 진정한 의미에서의 생산성 향상이다.
Builder 클래스를 만든다
먼저 피자의 옵션을 타입으로 정리한다. 문자열을 그대로 써도 되지만, 선택 가능한 값이 제한되어 있다면 유니언 타입으로 잡아두는 편이 좋다. 손님이 thin 대신 thing이라고 입력하는 순간 피자가 아니라 오타가 구워질 수 있기 때문이다.
type DoughType = 'regular' | 'thin' | 'cheese-crust';
type SauceType = 'tomato' | 'bbq' | 'cream';
type PizzaSize = 'small' | 'medium' | 'large';
class CustomPizza {
constructor(
readonly dough: DoughType,
readonly sauce: SauceType,
readonly toppings: readonly string[],
readonly size: PizzaSize,
readonly wellDone: boolean,
) {}
describe(): string {
const toppings = this.toppings.length > 0 ? this.toppings.join(', ') : '토핑 없음';
return `${this.size} ${this.dough} 피자 / ${this.sauce} 소스 / ${toppings}`;
}
}이제 Builder가 생성 과정을 맡는다. Builder는 완성 전의 임시 상태를 들고 있다가, build()가 호출되는 순간 CustomPizza를 만든다.
class CustomPizzaBuilder {
private dough: DoughType = 'regular';
private sauce: SauceType = 'tomato';
private toppings: string[] = [];
private size: PizzaSize = 'medium';
private wellDone = false;
setDough(dough: DoughType): this {
this.dough = dough;
return this;
}
setSauce(sauce: SauceType): this {
this.sauce = sauce;
return this;
}
addTopping(topping: string): this {
this.toppings.push(topping);
return this;
}
setSize(size: PizzaSize): this {
this.size = size;
return this;
}
setWellDone(wellDone = true): this {
this.wellDone = wellDone;
return this;
}
build(): Readonly<CustomPizza> {
return Object.freeze(new CustomPizza(
this.dough,
this.sauce,
[...this.toppings],
this.size,
this.wellDone,
));
}
}build()에서 토핑 배열을 복사하는 점도 중요하다. Builder 내부 배열을 그대로 넘기면, 나중에 Builder를 재사용할 때 이미 만든 피자의 토핑이 함께 흔들릴 수 있다. 양자역학도 아닌데 피자 토핑이 관측할 때마다 바뀌면 곤란하니까.
Director는 꼭 필요할까
Builder 패턴에는 Director라는 등장인물이 자주 함께 나온다. Director는 Builder를 사용해 정해진 조립 순서를 실행하는 객체다. 에이든 피자에서는 인기 메뉴 프리셋을 만드는 역할로 볼 수 있다.
class PizzaRecipeDirector {
makeMeatLovers(builder: CustomPizzaBuilder): Readonly<CustomPizza> {
return builder
.setDough('regular')
.setSauce('bbq')
.addTopping('mozzarella')
.addTopping('pepperoni')
.addTopping('chicken')
.setSize('large')
.build();
}
makeLightMargherita(builder: CustomPizzaBuilder): Readonly<CustomPizza> {
return builder
.setDough('thin')
.setSauce('tomato')
.addTopping('mozzarella')
.setSize('medium')
.build();
}
}Director는 필수는 아니다. 생성 과정이 자주 재사용되고, "항상 이 순서로 이 조합을 만든다"는 규칙이 있다면 유용하다. 하지만 커스텀 피자처럼 호출부가 직접 조립하는 것이 더 자연스러운 경우에는 Builder만으로 충분하다. 패턴 책에 등장한다고 해서 모든 등장인물을 캐스팅할 필요는 없다. 조연이 필요 없는 장면에 조연을 억지로 세우면 무대만 좁아진다.
TypeScript로 구현할 때 알아야 할 것들
메서드 체이닝을 하려면 각 설정 메서드가 this를 반환해야 한다. 반환 타입을 CustomPizzaBuilder로 적어도 동작은 하지만, 상속 가능한 Builder를 만들 때는 this 타입이 더 유연하다. 하위 Builder에서 메서드를 이어 붙여도 타입이 하위 클래스로 유지되기 때문이다.
build()가 반환하는 객체는 가능하면 더 이상 수정되지 않게 만드는 편이 좋다. Builder는 조립 과정이고, 완성된 피자는 결과물이다. 완성된 피자를 누군가 몰래 수정하면 Builder가 제공한 질서가 무너진다. 예제에서는 Readonly<CustomPizza>와 Object.freeze()를 함께 사용했다. 물론 깊은 불변까지 보장하려면 배열 내부도 신경 써야 한다. 이 이야기는 4편 Prototype에서 얕은 복사 문제와 함께 다시 만난다.
필수값 누락을 컴파일 타임에 막고 싶다면 Step Builder라는 변형도 있다. 예를 들어 도우와 소스는 반드시 설정한 뒤에만 build()가 보이게 타입을 나누는 방식이다. 다만 이 글에서는 깊게 들어가지 않는다. 나중에 기회가 되면 따로 다뤄보기로 하자.
그래서 이게 항상 좋은 선택인가
Builder는 선택지가 많고 조립 과정이 읽혀야 할 때 강력하다. 특히 객체 생성 규칙이 복잡하고, 기본값과 선택값이 섞여 있으며, 호출부의 가독성이 중요한 경우에 잘 맞는다. 커스텀 피자처럼 손님의 요구가 계속 바뀌는 도메인에서는 꽤 자연스럽다.
하지만 필드가 두세 개뿐인 객체라면 Builder는 과하다. 그런 경우에는 옵션 객체 하나가 더 단순하다. 패턴은 복잡도를 없애는 도구가 아니라, 복잡도를 더 견딜 만한 모양으로 바꾸는 도구다.
전체 코드
type DoughType = 'regular' | 'thin' | 'cheese-crust';
type SauceType = 'tomato' | 'bbq' | 'cream';
type PizzaSize = 'small' | 'medium' | 'large';
class CustomPizza {
constructor(
readonly dough: DoughType,
readonly sauce: SauceType,
readonly toppings: readonly string[],
readonly size: PizzaSize,
readonly wellDone: boolean,
) {}
describe(): string {
const toppings = this.toppings.length > 0 ? this.toppings.join(', ') : '토핑 없음';
const bake = this.wellDone ? '바삭하게' : '기본 굽기';
return `${this.size} ${this.dough} 피자 / ${this.sauce} 소스 / ${toppings} / ${bake}`;
}
}
class CustomPizzaBuilder {
private dough: DoughType = 'regular';
private sauce: SauceType = 'tomato';
private toppings: string[] = [];
private size: PizzaSize = 'medium';
private wellDone = false;
setDough(dough: DoughType): this {
this.dough = dough;
return this;
}
setSauce(sauce: SauceType): this {
this.sauce = sauce;
return this;
}
addTopping(topping: string): this {
this.toppings.push(topping);
return this;
}
setSize(size: PizzaSize): this {
this.size = size;
return this;
}
setWellDone(wellDone = true): this {
this.wellDone = wellDone;
return this;
}
build(): Readonly<CustomPizza> {
return Object.freeze(new CustomPizza(
this.dough,
this.sauce,
[...this.toppings],
this.size,
this.wellDone,
));
}
}
class PizzaRecipeDirector {
makeMeatLovers(builder: CustomPizzaBuilder): Readonly<CustomPizza> {
return builder
.setDough('regular')
.setSauce('bbq')
.addTopping('mozzarella')
.addTopping('pepperoni')
.addTopping('chicken')
.setSize('large')
.build();
}
makeLightMargherita(builder: CustomPizzaBuilder): Readonly<CustomPizza> {
return builder
.setDough('thin')
.setSauce('tomato')
.addTopping('mozzarella')
.setSize('medium')
.build();
}
}
const customPizza = new CustomPizzaBuilder()
.setDough('thin')
.setSauce('tomato')
.addTopping('mozzarella')
.addTopping('pepperoni')
.setSize('large')
.setWellDone()
.build();
console.log(customPizza.describe());
const director = new PizzaRecipeDirector();
const meatLovers = director.makeMeatLovers(new CustomPizzaBuilder());
console.log(meatLovers.describe());다음 편에서는 피자 하나를 조립하는 문제를 넘어, 피자와 사이드와 음료가 한 세트로 맞아야 하는 문제를 다룬다. 이탈리아 세트에는 화덕 피자와 브루스케타와 에스프레소가 어울린다. 여기에 콜라를 붙이면 맛의 문제 이전에 세계관의 문제가 된다.
댓글
댓글을 불러오는 중...