이전 글에서는 추상 팩토리(Abstract Factory) 패턴을 모던 C++ 관점에서 재해석하며, 상속 없이도 Concepts, 람다, std::expected, coroutine, Ranges, std::format 등을 활용해 다양한 제품군 생성 로직을 유연하게 구현할 수 있음을 확인했습니다. 이번에는 생성(Creational) 패턴 중 빌더(Builder) 패턴을 다룹니다.
빌더 패턴은 복잡한 객체를 단계별로 생성하기 위한 패턴으로, 전통적으로는 Builder 인터페이스와 구체 빌더, 그리고 Director 클래스를 통해 생성 과정과 제품 구성을 분리했습니다. 그러나 이는 클래스 증가, 유지보수 어려움, 에러 처리나 비동기 처리, 조건부 단계 추가 등 다양한 요구사항에 대응하기 어렵습니다.
C++20 이상에서는 Concepts로 빌더 단계 요구사항을 정의하고, 람다와 함수 합성으로 단계별 빌드 로직을 상속 없이 구현할 수 있습니다. std::expected로 에러 처리 명확화, std::format으로 로깅, coroutine으로 비동기 빌드, Ranges로 단계 파이프라인 처리 등 다양한 고급 기능을 자연스럽게 도입할 수 있어, 전통적 구현 대비 유지보수성과 확장성이 크게 개선됩니다.
패턴 소개: 빌더(Builder)
의도:
- 복잡한 객체를 단계별로 생성하는 과정을 분리, 동일한 빌드 과정으로 다양한 객체 변형 가능.
- 예: 복잡한 문서(HTML, PDF), 복잡한 Meal(샌드위치, 음료, 디저트) 구성 시 단계별 빌드.
전통적 구현 문제점:
- Builder 인터페이스, ConcreteBuilder, Director 클래스 필요
- 빌드 단계 변경, 조건부 단계 추가, 비동기 생성, 로깅 시 클래스 증가 및 복잡성 상승
기존 C++ 스타일 구현 (전통적 방식)
예를 들어, Meal(식사) 빌드 예:
#include <string>
#include <iostream>
struct Meal {
std::string mainDish;
std::string drink;
std::string dessert;
};
struct MealBuilder {
virtual ~MealBuilder()=default;
virtual void buildMain()=0;
virtual void buildDrink()=0;
virtual void buildDessert()=0;
virtual Meal getResult()=0;
};
struct VegMealBuilder : MealBuilder {
Meal meal;
void buildMain() override { meal.mainDish="VegBurger"; }
void buildDrink() override { meal.drink="AppleJuice"; }
void buildDessert() override { meal.dessert="FruitSalad"; }
Meal getResult() override { return meal; }
};
struct Director {
void construct(MealBuilder& builder) {
builder.buildMain();
builder.buildDrink();
builder.buildDessert();
}
};
int main() {
VegMealBuilder vb;
Director dir;
dir.construct(vb);
Meal m = vb.getResult();
std::cout << m.mainDish << "," << m.drink << "," << m.dessert << "\n";
}
문제점:
- Builder 인터페이스, ConcreteBuilder, Director 클래스 필요
- 단계 변경 시 Builder/Director 수정 필요
- 조건부 단계, 비동기 처리, 로깅 등 추가 시 복잡성 증가
모던 C++20 이상 개선: Concepts, 람다, 함수 합성
1. Concepts로 빌드 단계 요구사항 정의
빌드 단계마다 (Meal&) -> std::expected<void,std::string> 반환 형태로 가정, 에러 발생 가능.
#include <expected>
struct Meal2 {
std::string mainDish;
std::string drink;
std::string dessert;
};
template<typename S>
concept MealStep = requires(S& s, Meal2& meal) {
{ s(meal) } -> std::convertible_to<std::expected<void,std::string>>;
};
2. 단계별 람다 정의
auto buildMainVeg = [](Meal2& meal)->std::expected<void,std::string> {
meal.mainDish = "VegBurger";
return {};
};
auto buildDrinkVeg = [](Meal2& meal)->std::expected<void,std::string> {
meal.drink="AppleJuice";
return {};
};
auto buildDessertVeg = [](Meal2& meal)->std::expected<void,std::string> {
meal.dessert="FruitSalad";
return {};
};
static_assert(MealStep<decltype(buildMainVeg)>);
3. Director 없이도 파이프라인 구성
단계 리스트를 벡터나 array에 담고 순회하면서 meal을 조립:
#include <vector>
#include <functional>
auto buildMeal = [](std::vector<std::function<std::expected<void,std::string>(Meal2&)>> steps, Meal2& meal)
-> std::expected<void,std::string> {
for (auto& step : steps) {
auto res = step(meal);
if(!res) return res; // 첫 에러 시 중단
}
return {};
};
int main() {
Meal2 meal;
std::vector<std::function<std::expected<void,std::string>(Meal2&)>> steps = {
buildMainVeg, buildDrinkVeg, buildDessertVeg
};
auto res = buildMeal(steps,meal);
if(!res) std::cerr<<"Error:"<<res.error()<<"\n";
else std::cout<<meal.mainDish<<","<<meal.drink<<","<<meal.dessert<<"\n"; // VegBurger,AppleJuice,FruitSalad
}
비교:
- 전통적: Director와 Builder 필요, 단계 변경 시 클래스 수정
- C++20: 람다와 벡터로 단계 파이프라인 구성, 상속 없음, std::expected로 에러 처리 명확
조건부 단계, 비동기 빌드, 로깅, Ranges 적용
조건부 단계(예: 디저트 제외):
auto conditionalDessert = [&](auto baseDessert) {
return [=](Meal2& meal)->std::expected<void,std::string> {
bool addDessert=false; // 가상 조건
if(!addDessert) return {}; // dessert skipped
return baseDessert(meal);
};
};
auto optionalDessert = conditionalDessert(buildDessertVeg);
비동기 빌드: coroutine으로 단계를 비동기 처리 가능.
로깅(std::format):
auto loggingStep = [&](auto stepFunc, std::string stepName) {
return [=](Meal2& meal)->std::expected<void,std::string> {
std::cout << std::format("[LOG] Executing step: {}\n", stepName);
auto res = stepFunc(meal);
if(!res) std::cout << std::format("[LOG] Error in {}: {}\n", stepName, res.error());
else std::cout << std::format("[LOG] {} success\n", stepName);
return res;
};
};
auto loggedSteps = std::vector<std::function<std::expected<void,std::string>(Meal2&)>>{
loggingStep(buildMainVeg,"BuildMain"),
loggingStep(buildDrinkVeg,"BuildDrink"),
loggingStep(optionalDessert,"OptionalDessert")
};
Ranges로 단계 파이프라인 변환 가능:
#include <ranges>
Meal2 meal2;
for (auto res : loggedSteps | std::views::transform([&](auto& s){return s(meal2);})){
if(!res) std::cerr<<"Error:"<<res.error()<<"\n";
}
비교:
- 전통적: 조건부 단계, 비동기 빌드, 로깅 추가 시 Builder나 Director 수정 필요
- C++20: 람다 합성으로 기능 추가, coroutine으로 비동기 처리, Ranges로 파이프라인 변환, std::expected로 에러 처리 용이
전통적 구현 vs C++11/14/17 vs C++20 이상 비교
전통적(C++98/03):
- Builder 인터페이스, ConcreteBuilder, Director 클래스로 복잡한 객체 생성 과정을 캡슐화
- 단계 변경, 조건부 단계 추가, 비동기 처리, 로깅 등 요구사항 추가 시 클래스 증가
C++11/14/17:
- 람다, std::function으로 부분적 단순화
- Concepts, std::expected, coroutine, Ranges 미지원 → 정적 타입 제약, 비동기/에러 처리 어려움
C++20 이상(모던 C++):
- Concepts로 단계 요구사항 정의, 상속 없이 빌드 과정 표현
- 람다 합성과 std::expected로 에러 처리, std::format로 로깅, coroutine으로 비동기 빌드, Ranges로 단계 파이프라인 처리
- 클래스 증가 없이 확장 가능, 유지보수성과 확장성, 타입 안전성 대폭 향상
결론
빌더(Builder) 패턴은 복잡한 객체 생성 과정을 단계별로 정의하고, 동일한 빌드 절차로 다양한 객체를 만들 수 있게 하는 패턴이지만, 전통적 구현은 Builder 인터페이스, ConcreteBuilder, Director 등 클래스로 인한 복잡성을 안고 있었습니다.
C++20 이상에서는 Concepts, 람다, std::function, std::expected, coroutine, Ranges, std::format 등을 활용해 상속 없는 빌드 로직을 함수 합성 형태로 구현할 수 있습니다. 조건부 단계, 비동기 빌드, 로깅, 에러 처리 등 다양한 요구사항에 쉽게 대응 가능하며, 전통적 구현 대비 유지보수성과 확장성이 크게 개선됩니다.