본문 바로가기

study/design pattern

[디자인패턴][생성패턴] 빌더 Builder - C++

728x90

[모던 C++ 디자인 패턴] 책을 바탕으로 공부하는 내용을 정리한 내용이다.


Builder pattern

생성이 까다로운 객체를 쉽게 처리하기 위한 패턴이다. 즉, 생성자 호출 코드 단 한 줄로 생성할 수 없는 객체를 쉽게 다루는 패턴이다. 코드 한줄로 생성할 수 없는 객체는 다른 객체들의 조합이거나, 상식적인 것을 벗어난 까다로운 로직이 요구된다. 이런 객체는 생성하는 코드를 따로 분리해야 한다.

책에 나오는 예제는 그렇게 까다로워 보이지 않지만, 빌더 패턴을 어떻게 구현하는지, 빌더 패턴을 사용하는 의미가 무엇인지에 대해 설명할 수 있는 정도다.


시나리오

HTML 웹 페이지에 입력할 컴포넌트를 생성한다고 가정하자. 단순하게 "hello"와 "world"를 비순차(<ul>) 리스트(<li>) 태그로 출력하려고 한다.

 

가장 단순한 형태로 아래와 같이 구현이 가능하다.

int main() {
	std::vector<std::string> vecTexts;
	vecTexts.push_back("hello");
	vecTexts.push_back("world");

	std::string sRet;
	sRet.append("<ul>\n");
	for ( std::vector<std::string>::iterator itText = vecTexts.begin(); itText != vecTexts.end(); ++itText ) {
		sRet.append("	<li>").append(*itText).append("</li>").append("\n");
	}
	sRet.append("</ul>\n");

	printf("%s", sRet.c_str());

	return 0;
}

 

하지만 위와 같은 코드는 유연하지 못하다. 비순차가 아닌 순차(<ol>) 리스트 출력을 원하거나, 새로운 데이터 입력에도 코드가 변경이 되어야 한다.

이러한 문제는 객체 지향(OOP)로 아래와 같이 충분히 대응이 가능하다. 태그나 출력 내용을 유연하게 사용할 수 있다.

struct HtmlElement {
	HtmlElement() {}
	HtmlElement(const std::string &sName, const std::string &sText) : m_name(sName), m_text(sText) {}
	virtual ~HtmlElement() {}

	std::string toString(int indent = 0) const {
		// 양식에 맞춰 출력하는 코드
	}

	std::string m_name;
	std::string m_text;
	std::vector <HtmlElement> m_elements;
};

int main() {
	std::vector<std::string> vecTexts;
	vecTexts.push_back("hello");
	vecTexts.push_back("world");

	HtmlElement htmlRet("ui", "");
	for ( std::vector<std::string>::iterator itText = vecTexts.begin(); itText != vecTexts.end(); ++itText ) {
		htmlRet.m_elements.push_back(HtmlElement("li", *itText));
	}

	printf("%s", htmlRet.toString().c_str());

	return 0;
}

 

위 코드는 양식을 제어하기 좀 더 쉬우면서도 원하는 출력을 할 수 있다. 하지만 HtmlElement 클래스(구조체)를 생성하는 과정이 그렇게 편리하다고 볼 수는 없다.

아래 빌더들을 작성한 내용을 보면, 확실히 그렇다고 생각하게 된다.


단순 빌더

빌더 패턴의 가장 단순한 형태로, 개별 객체의 생성을 별도의 다른 클래스에 위임하는 것을 의미한다.

 

아래 코드는 바로 직전 코드에서 HtmlBuilder를 추가 작성한 것이다. 확실히 main 안에서 HtmlElement 클래스(구조체)를 생성하는 과정이 없어 깔끔해진 것을 볼 수 있다.

struct HtmlElement {
	HtmlElement() {}
	HtmlElement(const std::string &sName, const std::string &sText) : m_name(sName), m_text(sText) {}
	virtual ~HtmlElement() {}

	std::string toString(int indent = 0) const {
		// 양식에 맞춰 출력하는 코드
	}

	std::string m_name;
	std::string m_text;
	std::vector <HtmlElement> m_elements;
};

struct HtmlBuilder {
	HtmlBuilder(std::string m_name) {
		m_root.m_name = m_name;
	}

	void addChild(std::string sName, std::string sText) {
		// 내부 태그/데이터 입력
		HtmlElement child(sName, sText);
		m_root.m_elements.push_back(child);
	}

	std::string toString() {
		return m_root.toString();
	}

	HtmlElement m_root;
};

int main() {
	HtmlBuilder builder("ui");
	builder.addChild("li", "hello");
	builder.addChild("li", "world");

	printf("%s", builder.toString().c_str());

	return 0;
}

흐름식 빌더

직전 코드에서 addChild() 메서드가 void return이라 반복해서 호출하는 점을 개선하고자 한다. HtmlBuilderaddchild() 메서드를 다음과 같이 빌더 자신을 반환하도록 수정하면, 꼬리를 무는 호출이 가능하다. 이러한 형태로 호출하는 것을 흐름식 인터페이스(fluent interface)라 부른다.

struct HtmlBuilder {
	HtmlBuilder(std::string m_name) {
		m_root.m_name = m_name;
	}

	/* 
	 * 변경된 부분
	 * - reference return / pointer return은 개발자의 자유
	 */
	HtmlBuilder &addChild(std::string sName, std::string sText) {
		// 내부 태그/데이터 입력
		HtmlElement child(sName, sText);
		m_root.m_elements.push_back(child);

		return *this;
	}

	std::string toString() {
		return m_root.toString();
	}

	HtmlElement m_root;
};

int main() {
	HtmlBuilder builder("ui");
	builder.addChild("li", "hello").addChild("li", "world"); // use fluent interface

	printf("%s", builder.toString().c_str());

	return 0;
}

의도 알려주기

위와 같이 HTML 구성 요소를 생성하는 빌더 클래스를 만들었다고 하자. 이때 사용자가 빌더 클래스를 사용해야 하는 것을 어떻게 알 수 있을까? 한 가지 방법은 빌더를 통하지 않으면 객체 생성이 불가능하게 하는 것이다.

 

해당 접근 방법은 크게 두 가지로 구성된다.

  1. 모든 생성자를 뭇겨서 사용자가 접근할 수 없게 한다.
  2. 생성자를 숨긴 대신 HtmlElement 객체에 Factory 메서드를 두어 빌더를 생성한다.
struct HtmlBuilder;

struct HtmlElement {
	// 예제와 달리 스마트 포인터를 사용하지 않았음
	virtual ~HtmlElement() {}

	std::string toString(int indent = 0) const {
		// 양식에 맞춰 출력하는 코드
	}

	// 팩터리(Factory) 메서드
	static HtmlBuilder build(const std::string &sName);

	std::string m_name;
	std::string m_text;
	std::vector<HtmlElement> m_elements;

	// friend 선언
	friend HtmlBuilder;

protected:
	/* 외부에 생성자 숨기기 */
	HtmlElement() {}
	HtmlElement(const std::string &sName, const std::string &sText) : m_name(sName), m_text(sText) {}
};

struct HtmlBuilder {
	HtmlBuilder(std::string m_name) {
		m_root.m_name = m_name;
	}

	HtmlBuilder &addChild(std::string sName, std::string sText) {
		HtmlElement child(sName, sText);
		m_root.m_elements.push_back(child);

		return *this;
	}

	std::string toString() {
		return m_root.toString();
	}

	// 이동 생성자(Since C++11)
	operator HtmlElement() const {
		return m_root;
	}

	HtmlElement m_root;
};

// 팩터리(Factory) 메서드
// HtmlBuilder 타입의 정의를 알 수 없어 아래로 이동해 작성
HtmlBuilder HtmlElement::build(const std::string &sName) {
	return HtmlBuilder(sName);
}

int main() {
	// 최종 목적은 HtmlElement를 만드는 것이지, HtmlElement의 빌더를 만드는 것이 아니다.
	HtmlElement html = HtmlElement::build("ui")
		.addChild("li", "hello")
		.addChild("li", "world");

	printf("%s", html.toString().c_str());

	return 0;
}

 

안타깝게도 사용자가 main에서 사용한 방식처럼 어떻게 API를 사용하라고 명시적으로 알려줄 방법은 없다. 다만, 생성자에 대한 제약 및 static build() 함수(팩터리 메서드)의 존재로부터 눈치 있게 알아낼 것을 기대해야 한다. 만약 연산자의 추가와 별도로 HtmlBuilder 안에 연관된 build() 메서드를 추가하면 좀 더 상식적일 수 있게 된다.


그루비-스타일(Groovy-style) 빌더

Groovy-style 빌더와 아래의 Composite 빌더는 빌더 패턴에서 약간 벗어난 주제다. 사실 빌더 자체가 명시적으로 드러나지 않고, 객체를 생성하는 다른 접근 방법을 보여준다.

 

Groovy, Kotlin 등의 프로그래밍 언어는 도메인에 특화된 언어(DSL, Domain Specific Language)의 생성을 지원한다. 어떤 절차를 기능적 성격에 맞춰 쉽게 기술할 수 있도록 새로운 언어를 정의하는 문법적 기능을 제공하는 것을 말한다. 말이 어렵긴 한데, 특정 산업 분야(도메인)에 특화된 언어를 말하는 것이 DSL이며, 문제의 영역을 해결하기 위해 적절한 언어를 사용하자는 데에서 시작했다.

C++도 DSL에 있어서 뒤떨어지지 낳는다. 초기화 리스트 기능(initializer_list, C++11 이후 지원)을 이용하면, 일반적인 클래스로 앞의 예제를 활용해 DSL을 효과적으로 만들 수 있다.

 

먼저, HTML 태그를 정의하고, 아래 두 가지 상황에 맞는 생성자를 생성해보자.

  • 태그가 이름과 텍스트로 초기화되는 경우(ex. <li>)
  • 태그가 이름과 자식의 집합으로 초기화 되는 경우(ex. <ul>)
struct Tag {
public:
	friend std::ostream &operator << (std::ostream &os, const Tag &tag) {
		// 생략
	}

protected:
	Tag(const std::string &sName, const std::string &sText) : m_name(sName), m_text(sText) {}

	Tag(const std::string &sName, const std::vector<Tag> &vecChildren) : m_name(sName), m_children(vecChildren) {}

public:
	std::string m_name;
	std::string m_text;
	std::vector<Tag> m_children;
	std::vector<std::pair<std::string, std::string> > attributes;
};

 

Tag를 상속받는 HTML 태그 클래스를 만들어보자. 하나는 문단, 다른 하나는 이미지다.

struct Paragraph : Tag {
	explicit Paragraph(const std::string &sText) : Tag("p", sText) {}

	Paragraph(std::initializer_list<Tag> &vecChildren) : Tag("p", vecChildren) {}
};

struct Image : Tag {
	explicit Image(const std::string &sURL) : Tag("img", "") {
		attributes.push_back(std::make_pair("src", sURL));
	}
};

// 컴파일 오류가 나타나기는 하지만, 아래와 같이 C++ DSL로 HTML 표현 가능
int main() {
	std::count << 
		Paragraph(
			Image("http://pokemon.com/pikachu.png")
		)
	<< std::endl;

	return 0;
}

 

위와 같이 addChild() 호출 없이 HTML 환경처럼 표현할 수 있다. 다른 태그들도 쉽게 확장이 가능하다.


컴포지트(Composite) 빌더

객체 하나를 생성하는데 복수의 빌더가 사용되는 경우를 살펴본다. 개인 신상 정보를 저장하는 프로그램이 있다고 해보자.

아래 블로그를 참고해서 예제를 완성했으며, move() 및 이동 생성자로 머리가 아팠다.. 결국 이동 생성자로 인해 컴파일에 실패하긴 했으나, 아래와 같이 가능하다.

#include<iostream>
#include<string>

class PersonAddressBuilder;
class PersonJobBuilder;
class PersonBuilder;
class PersonBuilderBase;

/* 개인 정보를 저장 */
class Person {
public:
    static PersonBuilder create();
    
    Person() {}

    Person(Person &&other) : 
        m_address(other.m_address),
        m_postCode(other.m_postCode),
        m_city(other.m_city),
        m_company(other.m_company),
        m_position(other.m_position),
        m_annualIncome(other.m_annualIncome) {
    }

    ~Person() {}

    // 복사 생성자 (이동시멘틱)
    Person &operator=(Person &&other) {
        if ( this == &other ) {
            return *this;
        }

        m_address = other.m_address;
        m_postCode = other.m_postCode;
        m_city = other.m_city;
        m_company = other.m_company;
        m_position = other.m_position;
        m_annualIncome = other.m_annualIncome;

        return *this;
    }

    // 출력
    friend std::ostream &operator<<(std::ostream &os, const Person &person) {
        os << "street_address: " << person.m_address
            << " post_code: " << person.m_postCode
            << " city: " << person.m_city
            << " company_name: " << person.m_company
            << " position: " << person.m_position
            << " annual_income: " << person.m_annualIncome;
        return os;
    }

    friend class PersonJobBuilder;
    friend class PersonAddressBuilder;
    friend class PersonBuilder;

private:
    // 주소
    std::string m_address, m_postCode, m_city;

    // 직업
    std::string m_company, m_position;
    int m_annualIncome = 0;
};

/* 
 * 첫 번째 빌더 클래스
 * - 참조 멤버 변수를 두어, 하위 빌더 들에서 하나의 참조값만 볼 수 있도록 강제한다. (여러 인스턴스가 생성되는 것을 막음)
 * - 즉, 생성된 객체 자체는 가지지 않고, 참조 변수만 갖는다.
 * - 참조 대입을 하는 생성자는 protected로 선언해 자식 클래스들에서만 사용할 수 있도록 한다.
 * - 이동 생성자를 사용한다.
 * - lives()와 works() 함수는 하위 빌더의 인터페이스를 반환하는 역할을 수행한다. (하위 빌더로 세부 정보 입력 가능)
 */
class PersonBuilderBase {
protected:
    Person &m_person;

    explicit PersonBuilderBase(Person &person)
        : m_person(person) {
    }

public:
    // TODO: 이동 생성자에서 컴파일 오류
    operator Person() const {
        return m_person;
    }

    // builder facets
    PersonAddressBuilder lives() const;
    PersonJobBuilder works() const;
};

/*
 * 실제 사용자가 이용할 클래스
 * - BuilderBase에서 참조하고 있는 실제 객체를 갖는 클래스
 */
class PersonBuilder : public PersonBuilderBase {
public:
    PersonBuilder(Person person) : PersonBuilderBase(person) {}
};

/*
 * 주소 빌더
 * - Person의 주소를 생성할때 fluent interface style 지원
 */
class PersonAddressBuilder : public PersonBuilderBase {
    typedef PersonAddressBuilder Self;

public:
    explicit PersonAddressBuilder(Person &person) : PersonBuilderBase(person) {
    }

    Self &at(std::string street_adress) {
        m_person.m_address = street_adress;
        return *this;
    }
    
    Self &with_postcode(std::string post_code) {
        m_person.m_postCode = post_code;
        return *this;
    }

    Self &in(std::string city) {
        m_person.m_city = city;
        return *this;
    }
};

/*
 * 직장 빌더
 * - Person의 직장을 생성할때 fluent interface style 지원
 */
class PersonJobBuilder : public PersonBuilderBase {
    typedef PersonJobBuilder Self;
public:
    explicit PersonJobBuilder(Person &person) : PersonBuilderBase(person) {
    }

    Self &at(std::string company_name) {
        m_person.m_company = company_name;
        return *this;
    }

    Self &as_a(std::string position) {
        m_person.m_position = position;
        return *this;
    }

    Self &earning(int annual_income) {
        m_person.m_annualIncome = annual_income;
        return *this;
    }
};

// 팩토리 메서드??
PersonBuilder Person::create() {
    return PersonBuilder(Person());
}

// 하위 빌더 반환
PersonAddressBuilder PersonBuilderBase::lives() const {
    return PersonAddressBuilder(m_person);
}

// 하위 빌더 반환
PersonJobBuilder PersonBuilderBase::works() const {
    return PersonJobBuilder(m_person);
}

int main() {
	// Composite builder 사용 형태
    Person p = Person::create()
        .lives().at("123 London Road")
                .with_postcode("SW1 1GB")
                .in("London")
        .works().at("PragmaSoft")
                .as_a("Consultant")
                .earning(10e6);

    return 0;
}

요약

빌더 패턴의 목적은 여러 복잡한 요소의 조합이 필요한 객체를 생성할 때, 객체의 생성만을 전담하는 컴포넌트를 정의해 객체 생성을 간편하게 할 수 있도록 하는 것이다. 다시 한번 말하지만, 객체의 생성 과정이 충분히 복잡할 때 빌더 패턴이 의미가 있다. 간단하게 종속성 주입(DI) 기술을 이용해 생성 가능한 객체라면 굳이 빌더를 사용할 필요가 없다.

 

빌더 패턴의 특징은 다음과 같다.

  • fluent interface를 이용해 복잡한 생성 작업을 한 번의 호출 체인으로 처리한다.
    • fluent interface를 위해 빌더 함수가 this 또는 *this 반환
  • 빌더 API를 강제하기 위해 타겟 객체의 생성자를 외부에서 접근하지 못하도록 방지하며, 동시에 static create() 함수를 통해서만 생성된 객체를 반환하도록 강제한다.
  • 적절한 연산자를 정의해 객체 자체적으로 빌더의 사용을 강제할 수도 있다.
  • 유니폼 초기화 문법(initializer_list<T>)를 이용하면 C++에서도 Groovy-style 빌더를 만들 수 있고, 이를 이용해 다양한 DSL을 만들 수 있다.
  • 빌더 하나의 인터페이스가 여러 하위 빌더를 노출하도록 할 수 있다.
    • 상속과 fluent interface를 잘 활용하면 여러 빌더를 거치는 객체 생성을 쉽게 할 수 있다.

 

빌더부터.. 너무 어렵다..

728x90