[모던 C++ 디자인 패턴] 책을 바탕으로 공부하는 내용을 정리한 내용이다.
Factory pattern
두 개의 GoF(Gang of Four) 패턴, 팩터리 메서드와 추상 팩터리를 동시에 알아본다. 이 두 패턴은 긴밀하게 연관이 되어있다.
시나리오
직교 좌표계의 좌표점 정보를 저장한다고 가정하자. 다음과 같이 쉽게 구현할 수 있을 것이다.
struct CPoint {
CPoint(const float x, const float y) : m_x(x), m_y(y) {}
float m_x, m_y; //< 직교 좌표계의 좌표
}
여기서 극좌표계로 좌표점 정보를 저장해야 한다면 어떻게 할까? 쉽게 다음과 같이 극좌표계용 생성자를 추가할 것이다.
CPoint(const float r, const float theta) {
m_x = r * std::cos(theta);
m_y = r * std::sin(theta)
}
그러나, 여기서 문제가 발생한다. 직교 좌표계 생성자와 극좌표계 생성자 모두 두 개의 float 타입을 파라미터로 하고 있다. 즉, 두 생성자의 시그니처가 같아 두 생성자를 호출하는 쪽은 구분을 할 수 없다.
이를 해결하기 위해서는 다음과 같이 좌표계를 구분하는 enum 타입을 파라미터에 추가하는 방법이 있을 것이다.
enum POINT_TYPE_e {
POINT_CARTESIAN,
POINT_POLAR
};
CPoint(const float a, const float b, POINT_TYPE_e type = POINT_TYPE_e::POINT_CARTESIAN) {
switch(POINT_TYPE_e) {
case POINT_CARTESIAN:
m_x = a;
m_y = b;
break;
case POINT_POLAR:
m_x = a * std::cos(b);
m_y = a * std::sin(b);
break;
default:
// exception
break;
}
}
위와 같이 변경했을 때, 좌표값을 저장하는 변수의 이름을 \(x\), \(y\)에서 \(a\), \(b\)로 변경했다. \(x\), \(y\)는 직교 좌표계의 좌표를 의미하기 때문에 극좌표계의 값을 표현하기에는 무리가 있어, 중립적인 표현을 사용한 것이다. 그러나, 이 부분은 생성자의 사용법을 직관적이지 못하게 만든다. 확실히 \(x\), \(y\) 또는 \(r\), \(theata\)라고 이름을 지정할 수 있다면, 쉽게 의미를 전달할 수 있다.
결론적으로 기능적으로는 문제가 없지만, 훌륭하다고는 말할 수 없는 것이다. 이를 개선할 것이다.
팩터리 메서드
위의 예제에서 생성자를 protected로 숨기고, 대신 CPoint 객체를 만들어 반환하는 static 함수를 제공하는 것은 어떨까?
struct CPoint {
protected:
CPoint(const float x, const float y) : m_x(x), m_y(y) {}
public:
static CPoint NewCartesian(const float x, const float y) {
return CPoint(x, y);
}
static CPoint NewPolar(const float r, const float theta) {
return CPoint(r * std::cos(theta), r * std::sin(theta));
}
private:
float m_x, m_y;
};
위 코드에서 static 함수들을 팩터리 메서드라 부른다. 팩터리 메서드가 하는 역할은 CPoint 객체를 생성하여 반환하는 것뿐이다. 함수의 이름과 파라미터의 이름 모두 정확한 의미를 나타내고 있고, 어떤 값이 주어져야 하는지 명확하게 나타내고 있다.
이 팩터리 메서드를 이용해 다음과 같이 명료하게 작성할 수 있다.
CPoint point = CPoint::NewPolar(5, M_PI_4);
팩터리
빌더와 마찬가지로 CPoint를 생성하는 함수를 별도의 클래스로 몰아넣을 수 있다. 그런 클래스를 바로 팩터리라고 한다. 팩터리 클래스를 만들기 위해 CPoint 클래스는 다시 아래와 같이 정의한다.
struct CPoint {
public:
float m_x, m_y;
friend CPointFactory;
private:
CPoint(const float x, const float y) : m_x(x), m_y(y) {}
};
눈여겨볼 부분이 두 가지 있다.
- CPoint 생성자는 private으로 선언되어, 사용자가 직접 호출할 수 없도록 한다. 필수 사항은 아니지만, 해당 클래스의 객체를 생성하는 방법을 여러 개 제공해 사용자를 혼란스럽게 하지 않기 위함이다.
- CPoint는 CPointFactory를 friend 클래스로 선언한다. 팩터리가 CPoint의 생성자에 접근할 수 있게 하기 위함이다. 따라서 팩터리 클래스만 나중에 따로 만들 수 없고, 생성할 클래스(CPoint)와 함께 개발이 되어야 한다.
해당 CPoint 클래스를 바탕으로 만들어진 팩터리는 다음과 같다.
struct CPointFactory {
public:
static CPoint NewCartesian(const float x, const float y) {
return CPoint(x, y);
}
static CPoint NewPolar(const float r, const float theta) {
return CPoint(r * std::cos(theta), r * std::sin(theta));
}
};
사용자는 CPoint의 객체 생성을 전담하는 별도 클래스(팩터리)를 이용해 다음과 같이 객체를 생성할 수 있다.
CPoint point = CPointFactory::NewCartesian(3, 4);
내부 팩터리
내부 팩터리는 생성할 타입의 내부 클래스로 존재하는 간단한 팩터리를 말한다. 보통 C++의 friend 키워드에 해당하는 문법이 없는 언어에서 주로 사용한다.
내부 팩터리는 생성할 타입의 내부 클래스이기 때문에 private 멤버에 자유롭게 접근이 가능하다는 장점이 있다. 거꾸로 생성할 타입의 클래스(외부 클래스)에서도 내부 클래스의 private 멤버에 쉽게 접근할 수 있다.
내부 팩터리를 이용한 모습은 아래와 같다.
struct CPoint {
private:
CPoint(const float x, const float y) : m_x(x), m_y(y) {}
struct CPointFactory {
public:
static CPoint NewCartesian(const float x, const float y) {
return CPoint(x, y);
}
static CPoint NewPolar(const float r, const float theta) {
return CPoint(r * std::cos(theta), r * std::sin(theta));
}
};
public:
float m_x, m_y;
static CPointFactory Factory;
};
이때 내부 팩터리를 이용해 CPoint 객체를 생성하는 방법은 다음과 같다.
CPoint point = CPoint::Factory.NewCartesian(3, 4);
내부 팩터리 방법은 팩터리가 생성할 클래스가 한 종류일 때는 유용한다. 만약 팩터리가 여러 타입을 활용해 객체를 생성해야 한다면, 객체 생성에 필요한 다른 타입의 private 멤버에 접근하기 불가능하기 때문에 내부 팩터리 방식을 사용하는 것은 부적합하다.
추상 팩터리
앞의 예제들과 달리, 여러 종류의 연관된 객체들을 생성해야 할 때가 있다. 추상 팩터리를 이용하면 그런 경우를 대응할 수 있다. 따라서, 단순 팩터리 패턴과 달리 추상 팩터리는 복잡한 시스템에서만 필요성이 떠오른다.
뜨거운 차와 커피를 판매하는 카페를 운영한다고 가정해보자. 두 음료는 다른 장비를 이용해 만들어지지만, 이 부분은 팩터리 모델링이 가능하다.
차와 커피를 뜨겁게만 제공한다고 해보자. 다음과 같이 뜨거운 음료를 추상화하는 클래스 CHotDrink
를 정의한다.
struct CHotDrink {
virtual void prepare(int iVolume) = 0;
};
추상화 클래스 CHotDrink
를 상속받아 아래와 같이 차 타입과 커피 타입을 구현할 수 있다.
struct CTea : CHotDrink {
void prepare(int iVolume) override {
printf("Take tea bag, boil water, pour %dml add some lemon\n", iVolume);
}
};
struct CCoffee : CHotDrink {
void prepare(int iVolume) override {
printf("Take coffee bag, boil water, pour %dml\n", iVolume);
}
};
차 타입과 커피 타입 각각을 준비했으면, make_drink()
함수를 만들 수 있다. 이 함수는 음료의 이름을 받아 해당하는 음료를 생성해 반환(팩터리 메서드)한다. 만들어야 할 음료의 종류가 단 두 가지이기 때문에 아래와 같이 구현할 수 있다.
CHotDrink *makeDrink(std::string sType) {
CHotDrink *pDrink = NULL;
if ( 0 == sType.compare("tea") ) {
pDrink = new CTea();
pDrink->prepare(200);
}
else {
pDrink = new CCoffee();
pDrink->prepare(50);
}
return pDrink;
}
여기서, 차와 커피를 만드는 장비가 다르기 때문에 팩터리를 각각 만들어보자. 차 타입과 커피 타입 모두 CHotDrink
클래스를 상속받고 있기 때문에 아래와 같이 CHotDrinkFactory
를 만들어보자.
struct CHotDrinkFactory {
virtual CHotDrink *make() const = 0;
};
위의 CHotDrinkFactory
가 바로 추상 팩터리다. 어떤 특정 인터페이스를 규정하고는 있지만, 구현 클래스가 아닌 추상 클래스다. 즉, 실제 음료 객체를 만들어내려면 구체화된 구현 클래스가 별도로 필요한 것이다. 아래와 같이 자식 클래스를 구현 클래스로서 만들 수 있다.
struct CTeaFactory : CHotDrinkFactory {
CHotDrink *make() const override {
return new CTea();
}
};
struct CCoffeFactory : CHotDrinkFactory {
CHotDrink *make() const override {
return new CCoffee();
}
};
좀 더 상위 수준에서 다른 음료도 만든다고 가정해보자. 이제 차가운 음료도 만들 수 있어야 한다. 따라서, CDrinkFactory
를 두어 사용 가능한 다양한 팩터리들에 대한 참조를 내부에 갖도록 할 수 있다.
이름으로 음료를 선택할 수 있다고 가정해보자. 문자열을 팩터리에 연관시킨 map을 만들어 각 음료를 생성할 팩터리를 지정할 수 있다. map에 저장할 팩터리 타입은 추상 팩터리인 CHotDrinkFactory
로 하고, 객체 자체가 아닌 포인터로 저장한다. (책에서는 다른 예제와 함께 여기서도 스마트 포인터를 사용한다. 포인터가 아닌 객체를 직접 저장하면, 저장소 타입에 따라 객체 슬라이싱 문제가 발생할 수 있다.)
class CDrinkFactory {
public:
CDrinkFactory() {
m_hotFactories.insert(std::make_pair("coffee", new CCoffeFactory()));
m_hotFactories.insert(std::make_pair("tea", new CTeaFactory()));
}
CHotDrink *makeDrink(std::string sType) {
CHotDrink *pDrink = m_hotFactories.find(sType)->second->make();
pDrink->prepare(200);
return pDrink;
}
private:
std::map<std::string, CHotDrinkFactory *> m_hotFactories;
};
함수형 팩터리
보통 팩터리라 말할 때 다음 두 가지 중 하나를 의미한다.
- 객체를 어떻게 생성하는지 알고 있는 클래스
- 호출했을 대 객체를 생성하는 함수
두 번째 경우를 팩터리 메서드의 하나처럼 볼 수 있을 것이다. 하지만, 사실 다르다. 어떤 타입 T를 반환하는 std::function을 어떤 함수의 인자로 넘겨서 객체를 생성하는 것이 두 번째 경우에 해당하는 것이다. 기르고 이는 그냥 "팩터리"라 한다.
팩터리 메서드라 하지 않는 이유는 메서드의 의미에 있다. "메서드"는 어떤 클래스의 멤버 함수를 나타내는 말이다. 따라서, "팩터리"라고만 부르는 것이다.
C++11부터는 함수를 Callable 객체로 관리할 수 있는 방법을 제공한다. 이를 이용해 앞서 소개한 포인터를 저장하는 방식이 아닌, 200ml 음료를 생성하는 절차 자체를 팩터리에 내장시킬 수 있다. (스마트 포인터는 사용하지 않았다.)
class CDrinkWithVolumeFactory {
public:
CDrinkWithVolumeFactory() {
// 함수형
m_factories.insert(std::make_pair("tea", [] {
CTea *pTea = new CTea();
pTea->prepare(200);
return pTea;
}));
}
// 접근 방법
CHotDrink *makeDrink(const std::string sName) {
return m_factories.find(sName)->second();
}
private:
std::map<std::string, std::function<CHotDrink *()> > m_factories; // 함수를 객체로 관리
};
요약
- 팩터리 메서드는 생성할 타입의 멤버 함수로 있으며, 해당 타입의 객체를 생성하여 반환한다.
- 팩터리는 별도의 클래스로 존재하며, 목적하는 객체의 생성 방법을 알고 있다. 클래스 대신 함수의 형태로 존재하여 인자로서 사용될 수 있는 경우도 팩터리라 부른다.
- 추상 팩터리는 구현 클래스에서 상속받는 추상 클래스다. 어떤 타입 하나가 아니라 여러 타입의 패밀리를 생성할 때 사용된다. 극히 드물게 사용되기는 한다.
다음은, 일반 생성자에 비교해 팩터리를 사용했을 때의 장점들이다.
- 팩터리는 객체의 생성을 거부할 수 있다. 생성자는 Exception 발생 외에는 방법이 없지만, 팩터리는 NULL, nullptr 반환 등의 방법으로 객체 생성에 대한 실패를 자연스럽게 제공할 수 있다.
- 가독성 높은 명명을 제공한다. 용도를 잘 설명하는 이름을 부여할 수 있다.
- 단일 팩터리가 서로 다른 여러 타입의 객체를 생성할 수 있다.
- 추상 팩터리를 이용해 팩터리에 다형성을 부여할 수 있다. 서브 클래스에서 인스턴스를 만들고 베이스 클래스에서는 인스턴스의 참조나 포인터를 반환할 수 있다.
- 팩터리는 캐싱과 같은 메모리 최적화 구현이 가능하다. 풀링이나 싱글턴 패턴을 적용하기 자연스러운 코드 구조를 제공한다.
팩터리는 빌더와 다르다. 팩터리는 객체를 한 번에 생성하지만, 빌더는 각 구성요소마다 필요한 정보를 제공하며 여러 단계를 거쳐 객체를 생성한다.
'study > design pattern' 카테고리의 다른 글
[디자인패턴][구조패턴] 어댑터 Adapter - C++ (0) | 2022.05.11 |
---|---|
[디자인패턴][생성패턴] 싱글턴 Singleton - C++ (0) | 2022.04.30 |
[디자인패턴][생성패턴] 프로토타입 Prototype - C++ (0) | 2022.04.28 |
[디자인패턴][생성패턴] 빌더 Builder - C++ (0) | 2022.03.09 |
[디자인패턴] 공부를 시작하면서 (개요) (0) | 2022.03.08 |