포스트

Builder Pattern

인자가 많거나 선택적 항목이 많은 객체를 단계별로 조립하는 패턴. 텔레스코핑 생성자를 대체하고 명명된 파라미터처럼 읽힌다.

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::stringstd::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 다른 생성 방식

 그냥 생성자명명된 인자(언어 지원)BuilderFactory
적합한 경우인자 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의 가독성 이점이 작아진다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.