생성자 매개변수가 많아지면 호출부가 읽기 어려워진다. new Pizza(true, false, true, false, true, "cheese", 12) 같은 호출에서 각 인자의 의미를 알려면 생성자 정의를 다시 봐야 한다. 매개변수 중 일부가 선택적이라면 흔히 매개변수 수가 다른 생성자를 여러 개 둔다 — Telescoping Constructor 안티패턴이다. Builder 가 등장한 배경.
Builder 는 세 가지 한계가 동시에 모일 때 등장한다. 매개변수 수가 많고, 일부가 선택적이고, 단계적 검증이 필요한 경우. 셋 중 하나만 있으면 다른 도구로 충분하지만 — 생성자 오버로딩, Static Factory Method, setter — 셋이 같이 모이면 Builder 가 가장 명확하다. 단 언어가 named/default 매개변수를 제공하면 같은 한계가 줄어 Builder 의 필요도 자연스럽게 줄어든다.
공통 의도
Builder 의 의도는 세 가지다.
- 단계적 생성 — 객체를 한 번에 만들지 않고 여러 메서드 호출로 점진적으로 구성한다.
- 매개변수 검증 — 모든 매개변수가 모인 시점 (
build()호출) 에 일관성을 검증한다. 생성자에서는 검증 시점이 매개변수마다 흩어진다. - immutable 결과 — 최종 객체는 setter 없이 만들어진다. setter 기반 빈 객체가 만드는 부분 초기화 상태를 피한다.
세 의도가 한 패턴에 모이는 게 Builder 의 정체성이다. 셋 중 하나가 빠지면 다른 도구가 더 단순하다.
GoF 원형과 Fluent Builder
GoF 의 원형은 Director, Builder, ConcreteBuilder, Product 네 역할이다.
Director 가 생성 순서를 결정하고 Builder 인터페이스의 메서드를 호출한다. ConcreteBuilder 가 그 인터페이스를 구현해서 실제 Product 를 생성한다. 같은 Director 가 다른 ConcreteBuilder 를 받으면 다른 Product 가 나온다. 같은 생성 절차로 다른 표현을 만드는 게 GoF 의 핵심이었다.
실무에서는 이 원형보다 Effective Java 의 Item 2 가 정리한 fluent builder 가 더 흔하다.
Pizza pizza = new Pizza.Builder()
.size(12)
.cheese(true)
.pepperoni(true)
.build();
Director 가 사라지고, Builder 가 setter 와 비슷한 모양의 메서드를 fluent 하게 노출한다. build() 가 호출되는 시점에 모든 매개변수가 일관성을 갖춰 검증되고 immutable Product 가 반환된다.
GoF 원형은 여러 표현을 같은 절차로 생성 하는 데 강점이 있고, Fluent Builder 는 매개변수가 많은 단일 객체의 가독성 에 강점이 있다. 실무 빈도는 후자가 압도적이다.
언어별 구현
언어마다 같은 의도를 다른 방식으로 표현한다.
Java 는 가장 풍부한 Builder 생태계를 가진다. 수동 작성도 가능하지만 Lombok 의 @Builder 가 코드 생성을 자동화한다. @Builder.Default 로 기본값을 표현하고, @Singular 로 컬렉션 추가 메서드를 받는다. 다만 Lombok 의존이 추상화 비용으로 따라온다.
Python 은 Builder 를 직접 만드는 일이 드물다. dataclass 와 기본값이 같은 한계를 대부분 해결한다.
@dataclass
class Pizza:
size: int
cheese: bool = False
pepperoni: bool = False
호출은 Pizza(size=12, cheese=True) 처럼 named argument 로 한다. 매개변수가 많고 선택적이어도 Telescoping 문제가 발생하지 않는다. __post_init__ 으로 검증도 한곳에 모은다. Builder 클래스를 별도로 두는 경우는 검증이 복잡하거나 단계적 의존이 있을 때 정도다.
TypeScript 도 비슷하다. Object literal + interface 가 같은 역할을 한다.
interface PizzaOptions {
size: number;
cheese?: boolean;
pepperoni?: boolean;
}
const pizza = new Pizza({ size: 12, cheese: true });
선택적 필드는 ? 로 표현하고, 호출자는 named 형태로 객체를 전달한다. Fluent Builder 도 가능하지만 Object literal 이 더 간결하다.
언어가 named/default 매개변수를 풍부하게 제공할수록 명시적 Builder 의 자리가 줄어든다는 게 공통 흐름이다.
생성자, Static Factory Method, Builder
세 도구를 매개변수 수와 선택성으로 비교하면 결정이 명확해진다.
- 매개변수 적음 (1~3개) — 생성자. 추가 도구가 오히려 noise.
- 매개변수 중간 + 명명이 필요 — Static Factory Method (
Pizza.cheesePizza()같은 의미 있는 이름). - 매개변수 많음 + 일부 선택 + 검증 필요 — Builder.
Effective Java Item 2 가 제시한 기준은 매개변수 네 개 이상 + 일부 선택 이다. 실무에서는 더 보수적으로 — 생성자/Static Factory 로 안 되는 명백한 이유가 있을 때만 Builder 로 — 가는 편이 단순도를 유지한다.
세 도구는 배타적이지 않다. 한 클래스가 Static Factory Method 와 Builder 를 같이 노출하는 경우도 흔하다. Stream.builder() 같은 호출이 그렇다.
언어 기능이 흡수하는 경우
Builder 의 세 가지 한계 중 매개변수 수와 선택성 은 언어 기능으로 흡수될 수 있다.
Kotlin 의 named parameter + default value 가 대표다.
data class Pizza(
val size: Int,
val cheese: Boolean = false,
val pepperoni: Boolean = false
)
val pizza = Pizza(size = 12, cheese = true)
호출부의 가독성이 fluent builder 와 동등하면서 코드는 더 짧다. Telescoping Constructor 문제 자체가 사라진다. Scala 의 case class, Swift 의 named argument 도 같은 방향이다.
검증이 단계적이거나 의존성이 있는 경우 — 한 필드의 값이 다른 필드의 검증 조건을 결정하는 경우 — 에는 Builder 가 여전히 필요하다. 단순 매개변수 수와 선택성 문제는 named/default 로 해결되므로 Builder 가 자연스럽게 줄어든다.
이게 디자인 패턴이 시간이 지나며 언어 기능으로 흡수 되는 사례다. 패턴이 사라지는 게 아니라, 그 패턴이 풀던 문제가 언어 차원에서 해결된다.
결론
Builder 는 생성자의 세 가지 한계 — 매개변수 수, 일부 선택성, 단계적 검증 — 가 동시에 모일 때의 선택이다. 셋 중 하나만 있으면 다른 도구가 더 단순하다.
선택은 두 가지 질문으로 좁혀진다.
- 매개변수가 네 개 이상이고 일부가 선택적이며 검증이 한곳에 모여야 하는가. 그렇다면 Builder.
- 사용 중인 언어가 named/default 매개변수를 풍부하게 제공하는가. 그렇다면 Builder 의 자리는 단계적 검증이 필요한 좁은 경우로 한정된다.
GoF 원형의 Director/Builder/Product 보다 Effective Java 의 Fluent Builder 가 실무 빈도가 압도적이다. 패턴의 형태도 시대와 언어에 따라 진화한다.