C/C++ 디자인 패턴 (2) — 빌더(Builder)

HTTP 요청 객체 하나에 URL, 메서드, 타임아웃, 재시도 횟수, 헤더가 붙기 시작하면 생성자 인자 목록이 길어지고, 같은 타입 인자의 순서를 바꿔 써도 컴파일러는 알아채지 못한다. 빌더(Builder)는 복잡한 객체의 생성 과정을 이름 있는 단계로 나누고, 모든 단계가 끝난 뒤 완성된 객체를 한 번에 돌려준다. GoF 원형은 여기에 “같은 조립 절차로 서로 다른 표현을 만든다”는 Director 개념을 더한다. 이 글에서는 메서드 체이닝 빌더, Director, C++20 지정 초기화자, 필수 단계를 타입으로 강제하는 빌더, 그리고 C의 지정 초기화자와 pthread_attr_t를 차례로 살펴본다.

이 시리즈의 다른 글

긴 생성자의 문제

// telescoping.cpp — 인자가 많은 생성자는 순서 실수를 잡지 못한다
#include <iostream>
#include <string>

struct HttpRequest {
	std::string url, method;
	int timeout_ms, retries;
	bool follow_redirects;

	HttpRequest(std::string u, std::string m, int timeout, int retry, bool follow)
		: url(std::move(u)), method(std::move(m)), timeout_ms(timeout),
		  retries(retry), follow_redirects(follow) {}
};

int main()
{
	// 의도: timeout 3000ms, retries 3 — 그런데 두 int의 순서를 바꿔 씀
	HttpRequest r("https://example.com", "GET", 3, 3000, true);
	std::cout << "timeout_ms=" << r.timeout_ms << " retries=" << r.retries << '\n';
}
$ g++ -std=c++20 -Wall -Wextra -O2 -o telescoping telescoping.cpp && ./telescoping
timeout_ms=3 retries=3000

경고 하나 없이 타임아웃 3ms, 재시도 3000번짜리 요청이 만들어졌다.

구조

참여자역할예제
Builder부품을 만드는 단계별 인터페이스ReportBuilder, HttpRequest::Builder
ConcreteBuilder단계를 구현하고 결과물을 보관·반환TextReport, MarkdownReport
Director단계를 어떤 순서로 호출할지 결정buildLatencyReport()
Product완성된 객체HttpRequest, 보고서 문자열

메서드 체이닝 빌더

C++에서 가장 흔한 형태다. 각 설정 메서드가 *this를 돌려주고, build()가 불변식을 검증한다. HttpRequest의 생성자는 private이라 빌더를 거치지 않고는 만들 수 없다.

// fluent.cpp — 이름 있는 단계로 조립하고 build()에서 검증한다
#include <iostream>
#include <map>
#include <stdexcept>
#include <string>

class HttpRequest {
public:
	class Builder;
	void print() const
	{
		std::cout << method_ << ' ' << url_ << " timeout=" << timeout_ms_
			  << "ms retries=" << retries_ << " headers=" << headers_.size() << '\n';
	}
private:
	HttpRequest() = default;          // Builder만 만들 수 있다
	std::string url_, method_ = "GET";
	int timeout_ms_ = 1000, retries_ = 0;
	std::map<std::string, std::string> headers_;
};

class HttpRequest::Builder {
public:
	Builder &url(std::string u)        { r_.url_ = std::move(u); return *this; }
	Builder &method(std::string m)     { r_.method_ = std::move(m); return *this; }
	Builder &timeoutMs(int t)          { r_.timeout_ms_ = t; return *this; }
	Builder &retries(int n)            { r_.retries_ = n; return *this; }
	Builder &header(std::string k, std::string v)
	{ r_.headers_[std::move(k)] = std::move(v); return *this; }

	HttpRequest build() const
	{
		if (r_.url_.empty())
			throw std::invalid_argument("url is required");
		if (r_.timeout_ms_ <= 0)
			throw std::invalid_argument("timeout must be positive");
		return r_;
	}
private:
	HttpRequest r_;
};

int main()
{
	HttpRequest::Builder()
		.url("https://example.com/api")
		.method("POST")
		.timeoutMs(3000)
		.retries(3)
		.header("Content-Type", "application/json")
		.build()
		.print();

	try {
		HttpRequest::Builder().timeoutMs(3000).build();
	} catch (const std::exception &e) {
		std::cout << "build failed: " << e.what() << '\n';
	}
}
$ g++ -std=c++20 -Wall -Wextra -O2 -o fluent fluent.cpp && ./fluent
POST https://example.com/api timeout=3000ms retries=3 headers=1
build failed: url is required

Director: 같은 절차, 다른 표현

GoF가 말하는 빌더의 본래 목적은 조립 순서(Director)와 표현 방식(ConcreteBuilder)을 분리하는 것이다. 보고서 구성 절차는 한 함수에 두고, 출력 형식만 바꿔 끼운다.

// director.cpp — 같은 조립 절차(Director)로 서로 다른 표현(Builder)을 만든다
#include <iostream>
#include <string>
#include <vector>

struct ReportBuilder {
	virtual ~ReportBuilder() = default;
	virtual void title(const std::string &t) = 0;
	virtual void row(const std::string &k, double v) = 0;
	virtual void footer(int rows) = 0;
};

class TextReport : public ReportBuilder {
public:
	void title(const std::string &t) override { out_ += "== " + t + " ==\n"; }
	void row(const std::string &k, double v) override
	{ out_ += k + std::string(10 - k.size(), ' ') + std::to_string(v) + '\n'; }
	void footer(int rows) override { out_ += "(" + std::to_string(rows) + " rows)\n"; }
	std::string result() const { return out_; }
private:
	std::string out_;
};

class MarkdownReport : public ReportBuilder {
public:
	void title(const std::string &t) override { out_ += "## " + t + "\n\n| key | value |\n|---|---|\n"; }
	void row(const std::string &k, double v) override
	{ out_ += "| " + k + " | " + std::to_string(v) + " |\n"; }
	void footer(int rows) override { out_ += "\n_" + std::to_string(rows) + " rows_\n"; }
	std::string result() const { return out_; }
private:
	std::string out_;
};

// Director: 어떤 순서로 무엇을 넣을지는 여기 한 곳에만 있다
void buildLatencyReport(ReportBuilder &b, const std::vector<std::pair<std::string, double>> &data)
{
	b.title("Latency (ms)");
	for (const auto &[k, v] : data)
		b.row(k, v);
	b.footer(static_cast<int>(data.size()));
}

int main()
{
	std::vector<std::pair<std::string, double>> data = {{"p50", 1.8}, {"p99", 12.5}};

	TextReport text;
	buildLatencyReport(text, data);
	std::cout << text.result() << '\n';

	MarkdownReport md;
	buildLatencyReport(md, data);
	std::cout << md.result();
}
$ g++ -std=c++20 -Wall -Wextra -O2 -o director director.cpp && ./director
== Latency (ms) ==
p50       1.800000
p99       12.500000
(2 rows)

## Latency (ms)

| key | value |
|---|---|
| p50 | 1.800000 |
| p99 | 12.500000 |

_2 rows_

새 형식(HTML, CSV)이 필요하면 ConcreteBuilder만 추가하면 되고, 보고서에 행을 추가하는 변경은 Director 한 곳만 고치면 된다.

가벼운 대안: C++20 지정 초기화자

검증 로직이 없고 기본값만 필요하다면 집합체(aggregate)와 지정 초기화자로 충분하다.

// designated.cpp — 단순한 경우는 C++20 지정 초기화자로 충분하다
#include <iostream>
#include <string>

struct HttpOptions {
	std::string url;
	std::string method = "GET";
	int timeout_ms = 1000;
	int retries = 0;
};

void send(const HttpOptions &o)
{
	std::cout << o.method << ' ' << o.url << " timeout=" << o.timeout_ms
		  << "ms retries=" << o.retries << '\n';
}

int main()
{
	send({.url = "https://example.com", .timeout_ms = 3000, .retries = 3});
	send({.url = "https://example.com/health"});
#ifdef SHOW_ERROR
	send({.retries = 3, .url = "https://example.com"});   // 선언 순서와 다름
#endif
}
$ g++ -std=c++20 -Wall -Wextra -O2 -o designated designated.cpp && ./designated
GET https://example.com timeout=3000ms retries=3
GET https://example.com/health timeout=1000ms retries=0
$ g++ -std=c++20 -DSHOW_ERROR -c designated.cpp 2>&1 | grep error
designated.cpp:23:13: error: designator order for field ‘HttpOptions::url’ does not match declaration order in ‘const HttpOptions’

C++의 지정 초기화자는 멤버 선언 순서를 지켜야 하고, url이 비어도 막을 방법이 없다. 순서 자유와 검증이 필요하면 빌더로 간다.

필수 단계를 타입으로 강제하기

build()의 런타임 검증 대신, “URL을 넣었는가”를 템플릿 인자로 들고 다니면 누락을 컴파일 시점에 잡을 수 있다.

// typestate.cpp — 필수 단계를 빠뜨리면 컴파일이 안 되는 빌더
#include <iostream>
#include <string>

struct Request { std::string url; int timeout_ms; };

template <bool HasUrl>
class RequestBuilder {
public:
	RequestBuilder() = default;
	RequestBuilder<true> url(std::string u) const { return {std::move(u), timeout_ms_}; }
	RequestBuilder timeoutMs(int t) const { auto b = *this; b.timeout_ms_ = t; return b; }

	Request build() const requires HasUrl { return {url_, timeout_ms_}; }

private:
	template <bool> friend class RequestBuilder;
	RequestBuilder(std::string u, int t) : url_(std::move(u)), timeout_ms_(t) {}
	std::string url_;
	int timeout_ms_ = 1000;
};

int main()
{
	Request r = RequestBuilder<false>{}.timeoutMs(3000).url("https://example.com").build();
	std::cout << r.url << " timeout=" << r.timeout_ms << "ms\n";
#ifdef SHOW_ERROR
	Request bad = RequestBuilder<false>{}.timeoutMs(3000).build();   // url() 누락
#endif
}
$ g++ -std=c++20 -Wall -Wextra -O2 -o typestate typestate.cpp && ./typestate
https://example.com timeout=3000ms
$ g++ -std=c++20 -DSHOW_ERROR -c typestate.cpp 2>&1 | grep -E 'error|note' | head -4
typestate.cpp:28:68: error: no matching function for call to ‘RequestBuilder<false>::build()’
typestate.cpp:14:17: note: candidate: ‘Request RequestBuilder<HasUrl>::build() const requires  HasUrl [with bool HasUrl = false]’
typestate.cpp:14:17: note: constraints not satisfied
typestate.cpp:14:17: note: the expression ‘HasUrl [with HasUrl = false]’ evaluated to ‘false’

url()이 RequestBuilder<true>를 돌려주고, build()는 requires HasUrl일 때만 존재한다. 필수 필드가 여러 개면 조합이 늘어나므로 1~2개일 때 쓸 만하다.

C 구현

C99 지정 초기화자는 순서 제약이 없어서, 기본값을 먼저 쓰고 사용자 값으로 덮어쓰는 매크로가 흔히 쓰인다.

/* opts.c — C99 지정 초기화자 + 기본값 매크로로 "인자 이름 붙이기" */
#include <stdio.h>

struct http_opts {
	const char *url;
	const char *method;
	int timeout_ms;
	int retries;
};

#define HTTP_OPTS(...) \
	((struct http_opts){ .method = "GET", .timeout_ms = 1000, __VA_ARGS__ })

static int http_send(struct http_opts o)
{
	if (!o.url) {
		printf("error: url is required\n");
		return -1;
	}
	printf("%s %s timeout=%dms retries=%d\n", o.method, o.url, o.timeout_ms, o.retries);
	return 0;
}

int main(void)
{
	http_send(HTTP_OPTS(.url = "https://example.com", .retries = 3));
	http_send(HTTP_OPTS(.url = "https://example.com/upload", .method = "POST"));
	return http_send(HTTP_OPTS(.timeout_ms = 3000)) == -1 ? 0 : 1;
}
$ gcc -std=c11 -Wall -Wextra -O2 -c opts.c 2>&1 | grep warning
opts.c:27:76: warning: initialized field overwritten [-Woverride-init]
opts.c:28:50: warning: initialized field overwritten [-Woverride-init]

같은 필드를 두 번 초기화하는 것이 이 매크로의 의도인데, -Wextra는 이를 -Woverride-init 경고로 알린다. 이 파일에서는 경고를 끄고 빌드한다.

$ gcc -std=c11 -Wall -Wextra -Wno-override-init -O2 -o opts opts.c && ./opts
GET https://example.com timeout=1000ms retries=3
POST https://example.com/upload timeout=1000ms retries=0
error: url is required

시스템 라이브러리에도 빌더가 있다. POSIX 스레드 속성 객체는 초기화 → 단계별 설정 → 생성 → 해제 순서를 그대로 따른다.

/* pthread_attr.c — POSIX 스레드 속성 객체는 C 라이브러리에 이미 있는 빌더다 */
#define _GNU_SOURCE   /* pthread_getattr_np */
#include <pthread.h>
#include <stdio.h>

static void *worker(void *arg)
{
	pthread_attr_t self;
	size_t stack;
	int detach;

	(void)arg;
	pthread_getattr_np(pthread_self(), &self);
	pthread_attr_getstacksize(&self, &stack);
	pthread_attr_getdetachstate(&self, &detach);
	printf("worker: stack=%zu KiB, joinable=%d\n", stack / 1024,
	       detach == PTHREAD_CREATE_JOINABLE);
	pthread_attr_destroy(&self);
	return NULL;
}

int main(void)
{
	pthread_attr_t attr;
	pthread_t tid;

	pthread_attr_init(&attr);                          /* 기본값으로 시작 */
	pthread_attr_setstacksize(&attr, 256 * 1024);      /* 단계별 설정 */
	pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_JOINABLE);
	pthread_create(&tid, &attr, worker, NULL);         /* 완성된 속성으로 생성 */
	pthread_attr_destroy(&attr);
	pthread_join(tid, NULL);

	pthread_create(&tid, NULL, worker, NULL);          /* 속성 없이: 기본값 */
	pthread_join(tid, NULL);
	return 0;
}
$ gcc -std=c11 -Wall -Wextra -O2 -pthread -o pthread_attr pthread_attr.c && ./pthread_attr
worker: stack=256 KiB, joinable=1
worker: stack=8192 KiB, joinable=1

속성을 지정한 스레드는 256KiB 스택으로, NULL을 넘긴 스레드는 기본값(ulimit -s와 같은 8MiB)으로 생성됐다.

언제 무엇을 쓰나

상황선택
필드가 몇 개뿐이고 검증이 없음지정 초기화자(C99/C++20)
필드가 많고 조합 검증이 필요메서드 체이닝 빌더 + build() 검증
필수 필드 누락을 컴파일 시점에 막고 싶음타입 상태(type-state) 빌더
같은 절차로 여러 출력 형식을 만듦GoF 빌더 + Director
관련 객체 세트를 일관되게 만듦빌더가 아니라 추상 팩토리

주의사항

  • 체이닝은 참조를 돌려준다: Builder&를 반환하므로 임시 빌더에 체인을 걸고 마지막에 build()까지 한 표현식에서 끝내야 한다. 중간 결과를 auto& b = Builder().url(...)처럼 붙잡으면 댕글링 참조가 된다.
  • build()를 여러 번 부를 수 있는가: 예제의 build() const는 복사본을 돌려줘서 같은 빌더로 여러 객체를 만들 수 있다. 무거운 리소스를 옮겨 담는(std::move) 빌더라면 두 번째 호출이 빈 객체를 내므로 build() &&처럼 rvalue 전용으로 제한한다.
  • 검증은 build() 한 곳에: 설정 메서드마다 검증하면 “타임아웃은 재시도보다 커야 한다” 같은 필드 간 조건을 순서에 따라 잘못 판정한다.
  • C 기본값 매크로와 경고: 위처럼 -Woverride-init이 뜬다. 프로젝트 전체에서 끄기보다 해당 파일만 끄거나 #pragma GCC diagnostic으로 범위를 좁힌다.
  • Director가 필요 없으면 쓰지 않는다: 출력 형식이 하나뿐이라면 Director와 추상 Builder는 간접층만 늘린다.

마무리

빌더는 “인자 순서를 외우지 않아도 되는 생성”과 “조립 절차와 표현의 분리”라는 두 가지 문제를 푼다. C++20 이후에는 단순한 경우를 지정 초기화자가 가져갔으니, 검증·조합·여러 표현이 필요한 곳에 빌더를 남겨 두면 된다. 다음 글에서는 객체 생성을 서브클래스에 맡기는 팩토리 메서드(Factory Method)를 다룬다.

참고

답글 남기기