C/C++ 디자인 패턴 (3) — 팩토리 메서드(Factory Method)

내보내기 기능을 만든다고 하자. “헤더를 쓰고, 행을 하나씩 쓰고, 마무리한다”는 절차는 CSV든 JSON이든 같은데, 어떤 Writer 객체를 쓸지만 다르다. 이 절차를 가진 부모 클래스가 new CsvWriter를 직접 부르면 JSON을 지원할 때마다 부모를 고쳐야 한다. 팩토리 메서드(Factory Method)는 객체를 만드는 연산을 가상 함수로 빼서, 어떤 클래스를 생성할지는 서브클래스가 결정하게 한다. 이 글에서는 기본형, 기본 구현이 있는 팩토리 메서드, 매개변수화된 팩토리 메서드, 생성자에서 부를 때의 함정, CRTP 변형, 그리고 흔히 같은 이름으로 불리는 정적 팩토리 함수와 C 구현까지 다룬다.

이 시리즈의 다른 글

구조

참여자역할예제
Product팩토리 메서드가 만드는 객체의 인터페이스Writer
ConcreteProduct실제 제품CsvWriter, JsonWriter
Creator팩토리 메서드를 선언하고, 이를 호출하는 절차를 가짐Exporter (run(), createWriter())
ConcreteCreator팩토리 메서드를 재정의해 ConcreteProduct를 반환CsvExporter, JsonExporter

기본형

// exporter.cpp — 프레임워크(run)는 고정, 무엇을 만들지는 서브클래스가 정한다
#include <iostream>
#include <memory>
#include <string>
#include <vector>

struct Row { std::string name; int score; };

struct Writer {
	virtual ~Writer() = default;
	virtual void begin() {}
	virtual void write(const Row &r) = 0;
	virtual void end() {}
};

class Exporter {
public:
	virtual ~Exporter() = default;
	void run(const std::vector<Row> &rows)          // 변하지 않는 절차
	{
		auto w = createWriter();                       // 팩토리 메서드 호출
		w->begin();
		for (const auto &r : rows)
			w->write(r);
		w->end();
	}
protected:
	virtual std::unique_ptr<Writer> createWriter() = 0;   // 팩토리 메서드
};

struct CsvWriter : Writer {
	void begin() override { std::cout << "name,score\n"; }
	void write(const Row &r) override { std::cout << r.name << ',' << r.score << '\n'; }
};
struct JsonWriter : Writer {
	bool first = true;
	void begin() override { std::cout << '['; }
	void write(const Row &r) override
	{
		std::cout << (first ? "" : ",") << "{\"name\":\"" << r.name << "\",\"score\":" << r.score << '}';
		first = false;
	}
	void end() override { std::cout << "]\n"; }
};

struct CsvExporter : Exporter {
	std::unique_ptr<Writer> createWriter() override { return std::make_unique<CsvWriter>(); }
};
struct JsonExporter : Exporter {
	std::unique_ptr<Writer> createWriter() override { return std::make_unique<JsonWriter>(); }
};

int main()
{
	std::vector<Row> rows = {{"kim", 90}, {"lee", 85}};
	CsvExporter{}.run(rows);
	JsonExporter{}.run(rows);
}
$ g++ -std=c++20 -Wall -Wextra -O2 -o exporter exporter.cpp && ./exporter
name,score
kim,90
lee,85
[{"name":"kim","score":90},{"name":"lee","score":85}]

run()은 어떤 Writer가 만들어지는지 모른다. 새 형식은 ConcreteCreator와 ConcreteProduct 한 쌍을 추가하는 것으로 끝난다.

기본 구현이 있는 팩토리 메서드

팩토리 메서드가 꼭 순수 가상일 필요는 없다. 부모가 합리적인 기본 제품을 만들고, 다른 제품이 필요한 서브클래스만 재정의한다.

// default_fm.cpp — 기본 구현이 있는 팩토리 메서드: 필요한 서브클래스만 재정의
#include <iostream>
#include <memory>
#include <string>

struct Connection {
	virtual ~Connection() = default;
	virtual std::string describe() const { return "TCP connection"; }
};
struct TlsConnection : Connection {
	std::string describe() const override { return "TLS connection"; }
};

class Client {
public:
	virtual ~Client() = default;
	void request(const std::string &path)
	{
		auto c = makeConnection();
		std::cout << "GET " << path << " over " << c->describe() << '\n';
	}
protected:
	virtual std::unique_ptr<Connection> makeConnection()
	{ return std::make_unique<Connection>(); }               // 기본값
};

struct SecureClient : Client {
protected:
	std::unique_ptr<Connection> makeConnection() override
	{ return std::make_unique<TlsConnection>(); }
};

int main()
{
	Client{}.request("/health");
	SecureClient{}.request("/login");
}
$ g++ -std=c++20 -Wall -Wextra -O2 -o default_fm default_fm.cpp && ./default_fm
GET /health over TCP connection
GET /login over TLS connection

매개변수화된 팩토리 메서드

식별자를 받아 여러 종류의 제품 중 하나를 만드는 형태다. 서브클래스는 새 식별자만 처리하고 나머지는 부모에게 넘긴다.

// param_fm.cpp — 매개변수화된 팩토리 메서드: 식별자로 제품을 고른다
#include <iostream>
#include <memory>
#include <string_view>

struct Codec {
	virtual ~Codec() = default;
	virtual std::string_view name() const = 0;
};
struct Gzip : Codec { std::string_view name() const override { return "gzip"; } };
struct Zstd : Codec { std::string_view name() const override { return "zstd"; } };
struct Identity : Codec { std::string_view name() const override { return "identity"; } };

class Decoder {
public:
	virtual ~Decoder() = default;
	virtual std::unique_ptr<Codec> createCodec(std::string_view encoding) const
	{
		if (encoding == "gzip") return std::make_unique<Gzip>();
		if (encoding == "" || encoding == "identity") return std::make_unique<Identity>();
		return nullptr;                                   // 모르는 인코딩
	}
};

// 서브클래스는 식별자 하나만 추가하고 나머지는 부모에게 넘긴다
class ModernDecoder : public Decoder {
public:
	std::unique_ptr<Codec> createCodec(std::string_view encoding) const override
	{
		if (encoding == "zstd") return std::make_unique<Zstd>();
		return Decoder::createCodec(encoding);
	}
};

int main()
{
	ModernDecoder d;
	for (std::string_view e : {"gzip", "zstd", "", "br"}) {
		auto c = d.createCodec(e);
		std::cout << '"' << e << "\" -> " << (c ? c->name() : "unsupported") << '\n';
	}
	std::cout << "base zstd -> " << (Decoder{}.createCodec("zstd") ? "ok" : "unsupported") << '\n';
}
$ g++ -std=c++20 -Wall -Wextra -O2 -o param_fm param_fm.cpp && ./param_fm
"gzip" -> gzip
"zstd" -> zstd
"" -> identity
"br" -> unsupported
base zstd -> unsupported

같은 "zstd"라도 부모 Decoder는 지원하지 않고, ModernDecoder만 지원한다. 지원 범위를 서브클래스가 넓힌 것이다.

생성자에서 부르면 안 되는 이유

C++에서는 부모 생성자가 실행되는 동안 객체의 동적 타입이 아직 부모다. 그래서 생성자 안에서 부른 팩토리 메서드는 재정의된 버전이 아니라 부모 버전으로 디스패치된다.

// ctor_trap.cpp — 생성자에서 부른 팩토리 메서드는 파생 클래스 버전이 아니다
#include <iostream>
#include <memory>
#include <string>

struct Logger {
	virtual ~Logger() = default;
	virtual std::string name() const { return "ConsoleLogger"; }
};
struct FileLogger : Logger {
	std::string name() const override { return "FileLogger"; }
};

class Service {
public:
	Service() : log_(createLogger()) {}               // 함정
	virtual ~Service() = default;
	std::string loggerName() const { return log_->name(); }
protected:
	virtual std::unique_ptr<Logger> createLogger() { return std::make_unique<Logger>(); }
private:
	std::unique_ptr<Logger> log_;
};

struct FileService : Service {
protected:
	std::unique_ptr<Logger> createLogger() override { return std::make_unique<FileLogger>(); }
};

// 해결: 생성이 끝난 뒤 팩토리 메서드를 부르는 2단계 생성
template <class T>
std::unique_ptr<T> make_initialized()
{
	auto p = std::make_unique<T>();
	p->init();
	return p;
}

class Service2 {
public:
	virtual ~Service2() = default;
	void init() { log_ = createLogger(); }
	std::string loggerName() const { return log_->name(); }
protected:
	virtual std::unique_ptr<Logger> createLogger() { return std::make_unique<Logger>(); }
private:
	std::unique_ptr<Logger> log_;
};
struct FileService2 : Service2 {
protected:
	std::unique_ptr<Logger> createLogger() override { return std::make_unique<FileLogger>(); }
};

int main()
{
	FileService s;
	std::cout << "ctor call : " << s.loggerName() << '\n';
	std::cout << "two-phase : " << make_initialized<FileService2>()->loggerName() << '\n';
}
$ g++ -std=c++20 -Wall -Wextra -O2 -o ctor_trap ctor_trap.cpp && ./ctor_trap
ctor call : ConsoleLogger
two-phase : FileLogger

FileService인데도 ConsoleLogger가 만들어졌다. 생성이 끝난 뒤 init()에서 부르는 2단계 생성이나, 필요할 때 만드는 지연 생성으로 피한다.

팩토리 메서드가 순수 가상이면 더 나쁘다. 생성자에서 직접 부르면 링크가 안 되고, 다른 멤버 함수를 거쳐 부르면 런타임에 중단된다.

// pure_direct.cpp — 생성자에서 직접 호출
#include <memory>
struct P { virtual ~P() = default; };
struct Base {
	Base() { p = create(); }
	virtual ~Base() = default;
	virtual std::unique_ptr<P> create() = 0;
	std::unique_ptr<P> p;
};
struct Derived : Base {
	std::unique_ptr<P> create() override { return std::make_unique<P>(); }
};
int main() { Derived d; }
$ g++ -std=c++20 -Wall -Wextra -O0 -o pure_direct pure_direct.cpp 2>&1 | grep -E 'warning|undefined|error'
pure_direct.cpp:5:28: warning: pure virtual ‘virtual std::unique_ptr<P> Base::create()’ called from constructor
pure_direct.cpp:(.text._ZN4BaseC2Ev[_ZN4BaseC5Ev]+0x4d): undefined reference to `Base::create()'
collect2: error: ld returned 1 exit status
// pure_trap.cpp — 생성자에서 순수 가상 팩토리 메서드를 부르면
#include <memory>
struct P { virtual ~P() = default; };
struct Base {
	Base() { init(); }                    // 간접 호출
	void init() { p = create(); }
	virtual ~Base() = default;
	virtual std::unique_ptr<P> create() = 0;
	std::unique_ptr<P> p;
};
struct Derived : Base {
	std::unique_ptr<P> create() override { return std::make_unique<P>(); }
};
int main() { Derived d; }
$ g++ -std=c++20 -Wall -Wextra -O0 -o pure_trap pure_trap.cpp && ./pure_trap; echo "exit=$?"
pure virtual method called
terminate called without an active exception
bash: line 1: 33232 Aborted                 (core dumped) ./pure_trap
exit=134

CRTP: 가상 함수 없는 팩토리 메서드

Creator의 종류가 컴파일 시점에 정해진다면 CRTP로 정적 디스패치할 수 있다.

// crtp_fm.cpp — 가상 함수 없이 컴파일 시점에 팩토리 메서드를 고른다(CRTP)
#include <iostream>
#include <memory>
#include <string>

struct Parser { virtual ~Parser() = default; virtual std::string kind() const = 0; };
struct IniParser : Parser { std::string kind() const override { return "ini"; } };
struct TomlParser : Parser { std::string kind() const override { return "toml"; } };

template <class Derived>
class ConfigLoader {
public:
	void load(const std::string &path)
	{
		auto p = static_cast<Derived *>(this)->createParser();   // 정적 디스패치
		std::cout << path << " parsed with " << p->kind() << '\n';
	}
};

struct IniLoader : ConfigLoader<IniLoader> {
	std::unique_ptr<Parser> createParser() { return std::make_unique<IniParser>(); }
};
struct TomlLoader : ConfigLoader<TomlLoader> {
	std::unique_ptr<Parser> createParser() { return std::make_unique<TomlParser>(); }
};

int main()
{
	IniLoader{}.load("app.ini");
	TomlLoader{}.load("Cargo.toml");
}
$ g++ -std=c++20 -Wall -Wextra -O2 -o crtp_fm crtp_fm.cpp && ./crtp_fm
app.ini parsed with ini
Cargo.toml parsed with toml

대신 IniLoader와 TomlLoader는 공통 부모가 없는 서로 다른 타입이 되어, 하나의 컨테이너에 담아 다룰 수 없다.

정적 팩토리 함수와 구별하기

실무에서 “팩토리 메서드”라고 부르는 것의 상당수는 GoF의 그것이 아니라, 생성자 대신 이름 있는 정적 함수로 객체를 만드는 관용구(named constructor)다. 상속이 없고, 실패를 std::optional 등으로 표현할 수 있다는 장점이 있다.

// named_ctor.cpp — 정적 팩토리 함수(named constructor): GoF 팩토리 메서드와는 다른 관용구
#include <charconv>
#include <cstdint>
#include <iostream>
#include <optional>
#include <string_view>

class Color {
public:
	static Color fromRgb(std::uint8_t r, std::uint8_t g, std::uint8_t b) { return {r, g, b}; }
	static std::optional<Color> fromHex(std::string_view s)
	{
		if (s.size() != 7 || s[0] != '#')
			return std::nullopt;
		unsigned v = 0;
		auto [p, ec] = std::from_chars(s.data() + 1, s.data() + 7, v, 16);
		if (ec != std::errc{} || p != s.data() + 7)
			return std::nullopt;
		return Color(v >> 16, (v >> 8) & 0xff, v & 0xff);
	}
	void print() const { std::cout << "rgb(" << +r_ << ',' << +g_ << ',' << +b_ << ")\n"; }
private:
	Color(std::uint8_t r, std::uint8_t g, std::uint8_t b) : r_(r), g_(g), b_(b) {}
	std::uint8_t r_, g_, b_;
};

int main()
{
	Color::fromRgb(255, 128, 0).print();
	for (auto s : {"#1e90ff", "#zzzzzz", "1e90ff"}) {
		auto c = Color::fromHex(s);
		std::cout << s << " -> ";
		c ? c->print() : void(std::cout << "invalid\n");
	}
}
$ g++ -std=c++20 -Wall -Wextra -O2 -o named_ctor named_ctor.cpp && ./named_ctor
rgb(255,128,0)
#1e90ff -> rgb(30,144,255)
#zzzzzz -> invalid
1e90ff -> invalid
구분GoF 팩토리 메서드정적 팩토리 함수
형태가상 멤버 함수static 멤버 함수
무엇을 바꾸나서브클래스가 생성할 타입생성 방법(입력 형식, 검증)
상속필요불필요
예Exporter::createWriter()Color::fromHex(), std::make_unique

C 구현

C에서는 생성 콜백을 담은 구조체가 Creator다. 절차 함수는 콜백이 무엇을 만드는지 모른다.

/* exporter.c — C에서는 "생성 콜백"을 가진 구조체가 팩토리 메서드 역할을 한다 */
#include <stdio.h>
#include <stdlib.h>

struct writer {
	void (*write)(struct writer *w, const char *name, int score);
	void (*finish)(struct writer *w);
	int count;
};

struct exporter {
	const char *label;
	struct writer *(*create_writer)(void);       /* 팩토리 메서드 */
};

static void csv_write(struct writer *w, const char *n, int s) { printf("%s,%d\n", n, s); w->count++; }
static void csv_finish(struct writer *w) { printf("# %d rows\n", w->count); free(w); }
static void tsv_write(struct writer *w, const char *n, int s) { printf("%s\t%d\n", n, s); w->count++; }
static void tsv_finish(struct writer *w) { free(w); }

static struct writer *new_writer(void (*wr)(struct writer *, const char *, int),
				 void (*fin)(struct writer *))
{
	struct writer *w = calloc(1, sizeof(*w));
	if (!w)
		abort();
	w->write = wr;
	w->finish = fin;
	return w;
}
static struct writer *csv_create(void) { return new_writer(csv_write, csv_finish); }
static struct writer *tsv_create(void) { return new_writer(tsv_write, tsv_finish); }

/* 변하지 않는 절차: 어떤 writer인지 모른다 */
static void exporter_run(const struct exporter *e)
{
	struct writer *w = e->create_writer();

	printf("[%s]\n", e->label);
	w->write(w, "kim", 90);
	w->write(w, "lee", 85);
	w->finish(w);
}

int main(void)
{
	const struct exporter csv = { "csv", csv_create };
	const struct exporter tsv = { "tsv", tsv_create };

	exporter_run(&csv);
	exporter_run(&tsv);
	return 0;
}
$ gcc -std=c11 -Wall -Wextra -O2 -fsanitize=address -o exporter_c exporter.c && ./exporter_c
[csv]
kim,90
lee,85
# 2 rows
[tsv]
kim	90
lee	85

리눅스 커널의 struct file_system_type도 비슷한 모양이다. 마운트 절차는 VFS가 갖고 있고, 각 파일시스템은 int (*init_fs_context)(struct fs_context *) 콜백에서 자기 전용 데이터와 연산 테이블을 채워 넣는다.

추상 팩토리와 비교

구분팩토리 메서드추상 팩토리
만드는 것제품 하나관련 제품 패밀리
확장 수단상속(Creator 서브클래스)합성(팩토리 객체를 주입)
쓰는 쪽Creator 자신의 절차(run())팩토리를 받은 외부 클라이언트

주의사항

  • 생성자·소멸자에서 호출하지 않는다: 소멸자도 마찬가지로, 파생 부분이 이미 파괴된 뒤라 부모 버전이 불린다.
  • 제품마다 Creator 서브클래스가 생긴다: 제품 종류만큼 클래스가 늘어난다. 생성만 바꾸려는 경우라면 팩토리 함수를 std::function으로 주입하는 편이 가볍다.
  • 매개변수화된 형태는 nullptr 처리: 모르는 식별자에 대한 반환을 정해 두지 않으면 호출자가 null을 역참조한다. 예외를 던지거나 기본 제품을 돌려주는 것도 방법이다.
  • 반환은 std::unique_ptr: 공유가 필요할 때만 호출자가 std::shared_ptr로 바꾼다. 반대 방향 변환은 안 된다.
  • 공변 반환 타입은 원시 포인터만: Derived* create() override처럼 반환 타입을 좁히는 것은 원시 포인터·참조에서만 허용되고 unique_ptr에는 적용되지 않는다.

마무리

팩토리 메서드는 “절차는 부모가, 재료는 자식이”라는 분담을 생성에 적용한 것이다. 템플릿 메서드 패턴과 함께 쓰이는 경우가 많고, 추상 팩토리의 각 생성 연산도 대개 팩토리 메서드로 구현된다. 다음 글에서는 기존 객체를 복제해 새 객체를 만드는 프로토타입(Prototype)을 다룬다.

참고

답글 남기기