Builder 패턴: 복잡한 객체 생성을 단계별로 캡슐화하기
선택적 파라미터와 유효성 검증이 얽힌 객체 생성을 다루는 Builder 패턴의 구조, Java 구현 예시, 텔레스코핑 생성자 대비 트레이드오프를 정리한다.
2026-08-13 · 최초 발행 2025-10-14
생성자 하나로는 감당이 안 되는 순간
메서드, URL, 헤더, 타임아웃, 바디까지 옵션을 하나씩 늘려온 HTTP 요청 객체를 생성자 하나로 받으려 하면 어느 시점부터는 인자 순서를 외우지 않고서는 호출할 수 없는 코드가 된다. 선택적 파라미터가 여러 개 얽히고 파라미터 간 상호 제약(GET 요청에는 body를 허용하지 않는다든지)까지 검증해야 하면 생성자 하나로는 감당이 안 된다. Builder 패턴은 이런 상황에서 객체의 생성(Construction)과 표현(Representation)을 분리해, 동일한 생성 절차로 서로 다른 표현의 객체를 만들 수 있게 하는 생성 패턴이다.
Director, Builder, Product가 나누는 역할
구성 요소는 Director(선택적, 절차 조정), Builder(구성 단계 인터페이스), ConcreteBuilder(구체 구현과 유효성 검증), Product(최종 객체)로 나뉜다. 다만 현대 Java 등에서는 Director를 생략하고 Product 내부에 정적 Builder를 두는 방식을 더 선호하는 경향이 있다. 목표는 가독성 향상, 불변 객체 구성, 선택적 파라미터 처리, 그리고 도메인 규칙을 build 시점에 검증하는 것이다.
단계적 조립과 불변성을 함께 지키는 방법
Builder의 핵심은 set → validate → build 단계를 분리하고, 메서드 체이닝으로 선언적인 구성 방식을 제공하는 데 있다. 필수 파라미터와 선택 파라미터를 구분하고 기본값을 채우면서 조합 가능성을 관리한다.
Product는 불변(immutable)으로 설계하고, 상태 변이는 Builder 내부에서만 허용한다. build() 시점에 필수 필드를 검증하고 상호 제약 조건을 점검한 뒤, 실패하면 예외를 던진다. 필드를 추가하거나 옵션을 확장할 때도 기존 호출부에 영향이 거의 없어 생성자 난립(telescoping)을 방지하고 이진 호환성을 지키는 데 유리하다. 같은 구조는 테스트에도 그대로 적용된다 — Test Data Builder 패턴으로 테스트 픽스처를 표준화하고, 공통 기본값 프리셋과 변형 빌더를 조합해 중복을 줄일 수 있다.
생성 절차의 흐름
Java로 구현한 HTTP 요청 빌더
전제조건은 JDK 17+, 외부 라이브러리는 필요 없고 단일 파일로 컴파일할 수 있다.
// File: HttpRequest.java
import java.util.*;
public final class HttpRequest {
public enum Method { GET, POST, PUT, DELETE }
private final Method method;
private final String url;
private final Map<String, String> headers;
private final byte[] body;
private final int timeoutMs;
private HttpRequest(Builder b) {
this.method = b.method;
this.url = b.url;
this.headers = Collections.unmodifiableMap(new LinkedHashMap<>(b.headers));
this.body = b.body == null ? null : Arrays.copyOf(b.body, b.body.length);
this.timeoutMs = b.timeoutMs;
}
public static class Builder {
private Method method;
private String url;
private Map<String, String> headers = new LinkedHashMap<>();
private byte[] body;
private int timeoutMs = 5000; // default
public Builder method(Method method) { this.method = method; return this; }
public Builder url(String url) { this.url = url; return this; }
public Builder header(String k, String v) { this.headers.put(k, v); return this; }
public Builder timeoutMs(int ms) { this.timeoutMs = ms; return this; }
public Builder body(byte[] body) { this.body = body; return this; }
private void validate() {
if (method == null) throw new IllegalArgumentException("method 필수");
if (url == null || !(url.startsWith("http://") || url.startsWith("https://")))
throw new IllegalArgumentException("url 형식 오류");
if (timeoutMs <= 0 || timeoutMs > 120_000)
throw new IllegalArgumentException("timeoutMs 범위 오류");
if (method == Method.GET && body != null)
throw new IllegalArgumentException("GET 메서드는 body 허용 불가");
}
public HttpRequest build() {
validate();
return new HttpRequest(this);
}
}
@Override public String toString() {
return "HttpRequest{" +
"method=" + method + ", url='" + url + '\'' +
", headers=" + headers + ", timeoutMs=" + timeoutMs +
", body=" + (body == null ? "null" : (body.length + " bytes")) + '}';
}
// Demo
public static void main(String[] args) {
HttpRequest req = new HttpRequest.Builder()
.method(Method.POST)
.url("https://api.example.com/v1/items")
.header("Content-Type", "application/json")
.timeoutMs(8000)
.body("{\"name\":\"builder\"}".getBytes())
.build();
System.out.println(req);
}
}
빌드·실행은 javac HttpRequest.java && java HttpRequest로 한다. 이 예시의 포인트는 내부 상태를 불변화하고(Map을 unmodifiable로 감싸고 body를 방어적으로 복사), build 시점에 검증하고, 합리적인 기본값을 제공한다는 점이다.
HTTP 요청부터 도메인 애그리게이트까지
HTTP 클라이언트나 요청 객체를 구성할 때는 메서드, URL, 헤더, 타임아웃, 바디 등의 옵션 조합을 관리하고 GET+body 금지, URL 스킴 검증, 타임아웃 범위 체크 같은 유효성 규칙을 build 시점에 강제할 수 있다. 데이터 파이프라인 잡이나 워크플로 설정에서는 소스·싱크 커넥터, 리트라이 정책, 체크포인팅, 파티셔닝 조합을 구성하고, 빌더 프리셋으로 환경별(Dev/Stage/Prod) 프로파일을 관리하는 데 쓴다. 도메인 애그리게이트를 생성할 때는 불변 애그리게이트 루트를 구성하고 연관 규칙을 build 시점에 검증하며, 값 객체(Value Object)와 함께 일관성 경계를 강화한다. 조건·정렬·조인을 조합하는 DSL이나 쿼리 빌더에서는 SQL 인젝션을 방지하면서 안전하게 조합을 구성하는 데도 쓰인다.
도입했을 때 달라지는 것
다양한 파라미터 조합에 대응하던 생성자 오버로드는 하나의 Builder로 전환할 수 있다. Test Data Builder를 도입하면 테스트 픽스처를 구성하는 코드 라인이 경험치 기준으로 30~50% 정도 줄어드는 것으로 보고된다. 필수·제약 검증이 내장돼 있으므로 런타임 NPE나 잘못된 파라미터 조합으로 인한 결함 발생률도 줄어든다. 정성적으로는 의도가 드러나는 선언적 API로 가독성이 올라가고, 불변 객체를 기반으로 한 스레드 안정성과 부수효과 최소화로 안정성이 높아지며, 신규 필드를 추가해도 이진 호환성과 호출부 영향이 최소화돼 확장이 쉬워진다.
텔레스코핑 생성자·팩토리 메서드와 비교하면
| 항목 | 텔레스코핑 생성자 | 팩토리 메서드 | 빌더 패턴 |
|---|---|---|---|
| 성능 | 호출 오버헤드 최소 | 간접 호출 미미 | 메서드 체이닝·검증 비용 미미 |
| 확장성 | 낮음, 오버로드 폭발 | 중간, 시그니처 관리 필요 | 높음, 옵션 추가 용이 |
| 일관성 | 호출 실수 위험 | 명명으로 일부 완화 | 검증 내장으로 일관성 높음 |
| 안정성 | 가독성 저하로 리스크 | 팩토리 분산 시 추적 어려움 | 불변·검증 결합으로 높음 |
| 운영 편의 | API 학습 부담 높음 | 명명 규칙 의존 | DSL 유사 인터페이스로 우수 |
언제 채택하고 무엇을 감수할 것인가
파라미터가 4개 이상이고 선택 옵션이 다수이며 파라미터 간 상호 제약이 존재한다면 빌더를 채택할 만한 임계 조건이다. Product는 불변으로 설계하고 컬렉션은 불변 래핑과 방어적 복사를 적용한다. build() 내부에서 모든 도메인 제약을 검증하고 에러 메시지에 필드명과 규칙을 포함시킨다. defaultProd()나 forTest() 같은 프리셋 빌더를 제공하면 재사용성이 올라간다.
물론 대가도 있다. 클래스 수가 늘고 초기 구현 비용이 든다. 성능에 민감한 구간이라면 불필요한 객체 생성과 검증 비용을 고려해 핫패스는 피하는 게 낫다. Builder는 상태를 가지므로 스레드 안전하지 않다 — 스레드 간에 공유하지 말고 요청 단위로 새 인스턴스를 만들어야 한다. Lombok의 @Builder 같은 도구를 쓸 때는 직렬화, 기본값, 유효성 검증 훅(verify) 처리 전략을 별도로 명시해야 한다.