Builder Pattern
인자가 많거나 선택적 항목이 많은 객체를 단계별로 조립하는 패턴. 텔레스코핑 생성자를 대체하고 명명된 파라미터처럼 읽힌다.
난이도 입문 · 선행 Factory
한 줄 요약
“필요한 항목을 골라서 하나씩 채워 넣고 마지막에 build()“라는 단계적 조립을 객체로 표현한다. 인자 많은 생성자가 읽히지 않을 때 도입한다.
어떤 문제를 푸는가
HTTP 요청을 표현하는 객체를 만든다. URL은 필수, 나머지(메서드, 헤더, 바디, 타임아웃)는 선택. 생성자로만 풀면 텔레스코핑 지옥에 빠진다.
1
2
3
4
5
6
7
8
9
10
11
class HttpRequest {
public:
HttpRequest(std::string url);
HttpRequest(std::string url, std::string method);
HttpRequest(std::string url, std::string method, std::map<std::string,std::string> headers);
HttpRequest(std::string url, std::string method, std::map<std::string,std::string> headers, std::string body);
HttpRequest(std::string url, std::string method, std::map<std::string,std::string> headers, std::string body, int timeoutMs);
};
HttpRequest req("https://api.example.com", "POST", {}, R"({"name":"x"})", 5000);
// 인자가 무엇을 의미하는지 호출 시점에 안 보인다. {} 가 헤더인지 쿼리인지?
문제:
- 생성자 오버로드 폭발. 추가 옵션 하나 늘면 새 오버로드 또 추가.
- 호출 시점에 각 인자가 무엇을 의미하는지 안 보인다.
- 선택 항목 일부만 지정하려면 앞의 옵션들에 기본값을 넣어줘야 한다.
- 잘못된 순서/타입을 컴파일러가 못 잡는다 (둘 다 string이면 더 위험).
패턴 적용 후
조립 과정을 별도 객체(Builder)에 맡긴다. 필요한 항목만 골라 호출하고 마지막에 build().
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <iostream>
#include <map>
#include <memory>
#include <string>
class HttpRequest {
public:
std::string url;
std::string method = "GET";
std::map<std::string, std::string> headers;
std::string body;
int timeoutMs = 30000;
void send() const {
std::cout << method << " " << url << " (timeout=" << timeoutMs << "ms)\n";
if (!body.empty()) std::cout << " body: " << body << "\n";
}
};
class HttpRequestBuilder {
HttpRequest req;
public:
HttpRequestBuilder(std::string url) { req.url = std::move(url); }
HttpRequestBuilder& method(std::string m) { req.method = std::move(m); return *this; }
HttpRequestBuilder& header(std::string k, std::string v) { req.headers.emplace(std::move(k), std::move(v)); return *this; }
HttpRequestBuilder& body(std::string b) { req.body = std::move(b); return *this; }
HttpRequestBuilder& timeout(int ms) { req.timeoutMs = ms; return *this; }
HttpRequest build() { return std::move(req); }
};
int main() {
auto request = HttpRequestBuilder("https://api.example.com/users")
.method("POST")
.header("Content-Type", "application/json")
.header("Authorization", "Bearer xyz")
.body(R"({"name":"alice"})")
.timeout(5000)
.build();
request.send();
}
달라진 점:
- 호출 시점에 각 값이 무엇을 의미하는지 메서드 이름으로 드러난다.
- 선택 항목은 필요한 것만 호출한다. 나머지는 기본값.
- 새 옵션 추가 = 빌더에 메서드 하나 추가. 호출자 무손상.
- 빌더가 마지막에 검증·정규화도 할 수 있다 (URL 형식 체크 등).
구조
1
2
3
4
5
Client ──▶ Builder
├── stepA()
├── stepB()
├── stepC()
└── build() ─▶ Product
- Builder: 단계별 메서드 (
HttpRequestBuilder) - Product: 최종 만들어지는 객체 (
HttpRequest) - Client: 빌더에 호출을 흘려보내고
build()로 수령
정통 GoF 버전 — Director 분리
위 예는 fluent interface로 단순화한 현대 스타일. GoF 원형은 Director가 단계 호출 순서를 담당한다. 같은 빌더에 다른 Director를 붙여 다양한 변형을 만들 때 의미가 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
class Director {
public:
void buildJsonRequest(HttpRequestBuilder& b, const std::string& url, const std::string& json) {
b.method("POST")
.header("Content-Type", "application/json")
.body(json)
.timeout(5000);
}
void buildAuthRequest(HttpRequestBuilder& b, const std::string& url, const std::string& token) {
b.method("GET")
.header("Authorization", "Bearer " + token);
}
};
실무에서는 Director 분리가 과한 경우가 많다. fluent builder만으로 충분하면 그렇게 가는 게 단순하다.
실전 사례
std::string↔std::ostringstream: stringstream이 일종의 빌더.oss << a << b << c로 조립 후str()로 결과 추출.- OkHttp
Request.Builder: 위 예제와 거의 동일한 형태. StringBuilder(Java/C#): 문자열 효율적 조립.- Lombok
@Builder: Java에서 위와 같은 빌더를 어노테이션으로 자동 생성. - Kotlin / Groovy DSL: 빌더를 언어 차원에서 우아하게 표현.
- SQL 쿼리 빌더 (jOOQ, QueryDSL, Knex.js):
select().from().where()....
빌더 vs 다른 생성 방식
| 그냥 생성자 | 명명된 인자(언어 지원) | Builder | Factory | |
|---|---|---|---|---|
| 적합한 경우 | 인자 2~3개 이하 | Python·Kotlin 등에서 인자 5~6개 | 인자 많고 일부 선택, 복잡한 검증 | “무엇을 만들지” 결정이 핵심 |
| 가독성 | 인자 수 늘면 급격히 떨어짐 | 좋음 (언어 지원 필요) | 좋음 | 인자가 많으면 단점 |
| 검증 시점 | 생성자 | 생성자 | build() 시점에 한 번 | 팩토리 메서드 |
C++에는 명명된 인자가 없어 빌더가 더 유용하다. Python/Kotlin은 키워드 인자로 빌더 없이도 비슷한 가독성을 얻는다.
안티패턴 / 주의
- 필수 인자가 있으면 생성자로 받아라.
HttpRequestBuilder()후 URL을 안 넣고build()하면 비정상 객체. URL은 생성자 인자로 두는 게 안전. - 빌더 재사용 의도면
build()후 상태 처리. 같은 빌더로build()를 두 번 호출했을 때 같은 객체인지 새 객체인지 정책이 분명해야 한다. 위 예제는std::move라 두 번 호출 시 빈 객체. - 빌더가 검증을 안 하면 가치 절반.
build()에서 필수값 누락·잘못된 조합을 잡아라. 그냥 setter 묶음이면 빌더의 의미가 약하다. - 인자가 2~3개면 도입하지 마라.
new User(name, age)가 빌더보다 짧고 명확하다.
스스로 점검
1. 인자 2개짜리 객체(User(name, age))에 Builder를 만들면?
답
과한 적용. 생성자가 더 짧고 명확하다. Builder는 인자가 많거나(5~6개 이상) 선택 항목이 많을 때 가치가 있다.
2. Builder와 단순 setter 묶음의 차이는?
답
build() 시점에 검증·정규화·필수 항목 체크가 가능하다. setter만 있으면 누가 일부만 호출해서 비정상 객체가 생성될 수 있다. 검증 없는 빌더는 가치 절반.
3. Python/Kotlin에는 Builder 패턴이 덜 등장하는 이유는?
답
명명된 인자(keyword argument)를 언어가 지원한다. User(name="alice", age=30, role="admin") — 인자가 많아도 호출 시점에 의미가 드러나서 Builder의 가독성 이점이 작아진다.