C++ Factory 패턴 비교: Simple Factory, Factory Method, Abstract Factory, 자동 등록
이 글의 핵심
객체 생성을 캡슐화하는 세 가지 Factory 변형을 비교하고, 새 타입을 추가할 때 코드를 고치지 않아도 되는 자동 등록 팩토리를 플러그인 시스템 예제로 구현합니다.
생성 로직을 한곳에 모아야 하는 이유
클라이언트가 구체 클래스에 직접 의존할 때
문제: 클라이언트 코드가 구체 클래스에 직접 의존하면, 새 타입 추가 시 모든 클라이언트 코드를 수정해야 합니다.
// 클라이언트 코드 (나쁜 예)
std::unique_ptr<Logger> logger;
if (config == "console") {
logger = std::make_unique<ConsoleLogger>();
} else if (config == "file") {
logger = std::make_unique<FileLogger>();
} else if (config == "network") {
logger = std::make_unique<NetworkLogger>();
}
// 새 타입 추가 시 모든 클라이언트 수정 필요
해결: Factory Pattern은 객체 생성 로직을 캡슐화해, 클라이언트는 인터페이스만 의존하며, Factory가 구체 클래스를 결정합니다.
// Factory
// 타입 정의
class LoggerFactory {
public:
static std::unique_ptr<Logger> create(const std::string& type) {
if (type == "console") return std::make_unique<ConsoleLogger>();
if (type == "file") return std::make_unique<FileLogger>();
if (type == "network") return std::make_unique<NetworkLogger>();
return nullptr;
}
};
// 클라이언트 코드 (좋은 예)
auto logger = LoggerFactory::create(config);
logger->log("Hello");
// 새 타입 추가 시 Factory만 수정
flowchart TD
client[Client]
factory["LoggerFactory create(type)"]
console[ConsoleLogger]
file[FileLogger]
network[NetworkLogger]
client --> factory
factory --> console
factory --> file
factory --> network
여기서 짚어 둘 점은 Factory가 if-else 분기를 없애는 것이 아니라 한 곳으로 모은다는 것입니다. 설정 문자열을 보고 구체 클래스를 고르는 판단은 어딘가에서 반드시 해야 합니다. 그 판단이 클라이언트 코드 여러 곳에 흩어져 있으면 NetworkLogger를 추가할 때 열 군데를 고쳐야 하고, 한 곳이라도 빠뜨리면 그 경로에서만 로거가 nullptr이 되는 버그가 생깁니다. Factory로 모으면 수정 지점이 하나가 되고, 클라이언트는 Logger 인터페이스만 알면 되므로 ConsoleLogger.h 같은 구체 클래스 헤더를 include하지 않아도 되어 컴파일 의존성도 줄어듭니다.
반대로 생성 지점이 한 군데뿐이고 구체 타입이 앞으로도 늘어날 일이 없다면 Factory는 과한 추상화입니다. 패턴을 적용하기 전에 “이 생성 로직이 실제로 몇 군데에서 반복되는가, 타입이 얼마나 자주 추가되는가”를 먼저 따져 보는 것이 좋습니다.
Simple Factory: 문자열로 분기하는 정적 생성 함수
#include <memory>
#include <string>
#include <iostream>
class Shape {
public:
virtual void draw() const = 0;
virtual ~Shape() = default;
};
class Circle : public Shape {
public:
void draw() const override {
std::cout << "Drawing Circle\n";
}
};
class Rectangle : public Shape {
public:
void draw() const override {
std::cout << "Drawing Rectangle\n";
}
};
class ShapeFactory {
public:
static std::unique_ptr<Shape> create(const std::string& type) {
if (type == "circle") {
return std::make_unique<Circle>();
} else if (type == "rectangle") {
return std::make_unique<Rectangle>();
}
return nullptr;
}
};
int main() {
auto shape = ShapeFactory::create("circle");
if (shape) {
shape->draw(); // "Drawing Circle"
}
}
반환 타입을 std::unique_ptr<Shape>로 둔 것이 중요합니다. 팩토리가 만든 객체의 소유권이 호출자에게 넘어간다는 사실이 타입에 드러나고, 호출자가 delete를 잊을 여지가 없습니다. 호출자가 공유가 필요하면 std::shared_ptr<Shape> s = ShapeFactory::create(...)처럼 unique_ptr에서 shared_ptr로 바로 변환할 수 있으므로, 팩토리는 기본적으로 unique_ptr을 반환하는 것이 가장 유연합니다. 반대로 shared_ptr을 반환하면 unique_ptr로 되돌릴 수 없습니다.
알 수 없는 타입에 nullptr을 반환하는 설계는 호출자에게 검사 책임을 넘깁니다(아래 “알 수 없는 타입에 nullptr을 반환함”). 설정 파일 오타처럼 사실상 복구할 수 없는 상황이라면 std::invalid_argument를 던지는 편이 “조용한 nullptr”보다 원인을 찾기 쉽고, 호출자가 대체 동작을 정할 수 있어야 한다면 std::optional이나 C++23 std::expected로 실패를 타입에 드러낼 수도 있습니다. 문자열 대신 enum class ShapeType을 인자로 받으면 오타 자체가 컴파일 에러가 되고, switch에서 빠뜨린 값을 -Wswitch 경고로 잡을 수 있습니다. 문자열이 필요한 것은 설정 파일이나 네트워크처럼 외부 입력에서 타입을 정할 때입니다.
Factory Method: 서브클래스가 생성할 타입을 정함
#include <memory>
#include <iostream>
class Document {
public:
virtual void open() = 0;
virtual ~Document() = default;
};
class PDFDocument : public Document {
public:
void open() override {
std::cout << "Opening PDF\n";
}
};
class WordDocument : public Document {
public:
void open() override {
std::cout << "Opening Word\n";
}
};
// Creator (Factory Method 패턴)
class Application {
public:
virtual std::unique_ptr<Document> createDocument() = 0;
void newDocument() {
auto doc = createDocument();
doc->open();
}
virtual ~Application() = default;
};
class PDFApplication : public Application {
public:
std::unique_ptr<Document> createDocument() override {
return std::make_unique<PDFDocument>();
}
};
class WordApplication : public Application {
public:
std::unique_ptr<Document> createDocument() override {
return std::make_unique<WordDocument>();
}
};
int main() {
std::unique_ptr<Application> app = std::make_unique<PDFApplication>();
app->newDocument(); // "Opening PDF"
}
Factory Method의 핵심은 newDocument()입니다. 이 함수는 문서를 만들고 여는 공통 흐름을 기반 클래스에 한 번만 구현하고, “무엇을 만들지”만 파생 클래스의 createDocument()에 맡깁니다. 템플릿 메서드 패턴의 생성 버전이라고 볼 수 있습니다. Simple Factory가 “타입 문자열 → 객체”를 한 함수에서 분기한다면, Factory Method는 어떤 Creator 객체를 쓰는가로 생성 대상을 바꿉니다. 테스트에서 MockApplication을 만들어 가짜 문서를 반환하게 하는 식으로 교체 지점을 제공하는 것이 이 패턴의 실질적인 쓰임새입니다.
단점은 제품 종류마다 Creator 서브클래스가 하나씩 필요해 클래스 수가 두 배가 된다는 것입니다. 제품과 Creator가 1:1로만 대응하고 공통 흐름이 거의 없다면, 상속 대신 std::function<std::unique_ptr<Document>()>을 생성자로 받아 저장하는 편이 훨씬 가볍습니다. 또 createDocument()를 기반 클래스의 생성자 안에서 호출하면 안 됩니다. 생성자가 실행되는 동안에는 파생 클래스 부분이 아직 만들어지지 않아 가상 호출이 파생 클래스로 디스패치되지 않고, 순수 가상 함수라면 pure virtual method called 에러로 프로그램이 종료됩니다.
Abstract Factory: 관련 객체 군을 함께 만들기
#include <memory>
#include <iostream>
// 제품군
class Button {
public:
virtual void render() = 0;
virtual ~Button() = default;
};
class Checkbox {
public:
virtual void render() = 0;
virtual ~Checkbox() = default;
};
// Windows 제품
class WindowsButton : public Button {
public:
void render() override {
std::cout << "Rendering Windows Button\n";
}
};
class WindowsCheckbox : public Checkbox {
public:
void render() override {
std::cout << "Rendering Windows Checkbox\n";
}
};
// Mac 제품
class MacButton : public Button {
public:
void render() override {
std::cout << "Rendering Mac Button\n";
}
};
class MacCheckbox : public Checkbox {
public:
void render() override {
std::cout << "Rendering Mac Checkbox\n";
}
};
// Abstract Factory
class GUIFactory {
public:
virtual std::unique_ptr<Button> createButton() = 0;
virtual std::unique_ptr<Checkbox> createCheckbox() = 0;
virtual ~GUIFactory() = default;
};
class WindowsFactory : public GUIFactory {
public:
std::unique_ptr<Button> createButton() override {
return std::make_unique<WindowsButton>();
}
std::unique_ptr<Checkbox> createCheckbox() override {
return std::make_unique<WindowsCheckbox>();
}
};
class MacFactory : public GUIFactory {
public:
std::unique_ptr<Button> createButton() override {
return std::make_unique<MacButton>();
}
std::unique_ptr<Checkbox> createCheckbox() override {
return std::make_unique<MacCheckbox>();
}
};
int main() {
std::unique_ptr<GUIFactory> factory;
#ifdef _WIN32
factory = std::make_unique<WindowsFactory>();
#else
factory = std::make_unique<MacFactory>();
#endif
auto button = factory->createButton();
auto checkbox = factory->createCheckbox();
button->render();
checkbox->render();
}
Abstract Factory가 보장하는 것은 제품끼리의 일관성입니다. 클라이언트는 GUIFactory 하나만 받으므로, Windows 버튼과 Mac 체크박스가 한 화면에 섞이는 조합을 실수로 만들 수 없습니다. 팩토리를 고르는 #ifdef가 main에 한 번만 있고, 나머지 코드는 플랫폼을 전혀 모릅니다. 실무에서는 UI 테마 외에도 “MySQL용 커넥션·쿼리 빌더·트랜잭션” 묶음과 “SQLite용” 묶음을 교체하거나, 테스트용 가짜 구현 묶음을 주입할 때 이 구조가 쓰입니다.
이 예제의 #else 분기는 Windows가 아닌 모든 플랫폼에서 MacFactory를 고르므로, Linux에서 빌드하면 Mac 위젯이 나옵니다. 실제로는 __APPLE__, __linux__을 각각 확인해야 합니다. 더 근본적인 약점은 제품 종류를 추가하기 어렵다는 것입니다. createSlider()를 추가하려면 GUIFactory 인터페이스와 모든 구체 팩토리를 함께 고쳐야 합니다. 즉 Abstract Factory는 “제품군(Windows/Mac/Linux)“을 추가하기는 쉽지만 “제품 종류(버튼/체크박스/슬라이더)“를 추가하기는 어려운 방향으로 설계되어 있으므로, 어느 쪽이 더 자주 늘어날지 먼저 따져 보고 선택해야 합니다.
정적 등록으로 팩토리 수정 없이 타입 추가하기
#include <memory>
#include <string>
#include <map>
#include <functional>
#include <iostream>
class Product {
public:
virtual void use() = 0;
virtual ~Product() = default;
};
class ProductA : public Product {
public:
void use() override {
std::cout << "Using Product A\n";
}
};
class ProductB : public Product {
public:
void use() override {
std::cout << "Using Product B\n";
}
};
// 자동 등록 Factory
class ProductFactory {
public:
using Creator = std::function<std::unique_ptr<Product>()>;
static void registerProduct(const std::string& type, Creator creator) {
registry()[type] = creator;
}
static std::unique_ptr<Product> create(const std::string& type) {
auto it = registry().find(type);
if (it != registry().end()) {
return it->second();
}
return nullptr;
}
private:
static std::map<std::string, Creator>& registry() {
static std::map<std::string, Creator> reg;
return reg;
}
};
// 자동 등록 헬퍼
template<typename T>
class AutoRegister {
public:
AutoRegister(const std::string& type) {
ProductFactory::registerProduct(type, [] {
return std::make_unique<T>();
});
}
};
// 전역 변수로 자동 등록
static AutoRegister<ProductA> registerA("A");
static AutoRegister<ProductB> registerB("B");
int main() {
auto product = ProductFactory::create("A");
if (product) {
product->use(); // "Using Product A"
}
}
자동 등록은 전역 객체의 생성자가 main() 이전에 실행된다는 점을 이용합니다. registerA가 초기화될 때 생성자가 ProductFactory에 람다를 등록하므로, 새 타입을 추가할 때는 그 타입의 .cpp 파일에 static AutoRegister<ProductC> registerC("C"); 한 줄만 쓰면 되고 팩토리 코드는 건드리지 않습니다.
registry()를 정적 멤버 변수가 아니라 함수 안의 static 변수로 만든 데는 이유가 있습니다. 서로 다른 .cpp 파일에 있는 전역 객체의 초기화 순서는 C++ 표준이 정하지 않습니다(static initialization order fiasco). 레지스트리를 static std::map<...> reg;로 클래스 밖에 두면, ProductA.cpp의 registerA가 초기화될 때 ProductFactory.cpp의 맵이 아직 생성되지 않았을 수 있고, 아직 생성되지 않은 맵에 삽입하다가 크래시가 나거나 등록이 사라집니다. 함수 지역 static은 처음 호출될 때 초기화되므로 누가 먼저 호출하든 항상 준비된 맵을 돌려줍니다.
이 기법에는 실무에서 자주 겪는 함정이 하나 더 있습니다. 등록 코드를 정적 라이브러리(.a/.lib)에 넣으면 등록이 통째로 사라질 수 있습니다. 링커는 정적 라이브러리에서 “다른 곳에서 참조하는 심볼이 있는 오브젝트 파일”만 가져오는데, ProductC.o는 누구도 직접 참조하지 않고 전역 등록 객체만 가지고 있으므로 링크 대상에서 빠집니다. 그러면 create("C")가 이유 없이 nullptr을 반환합니다. 제가 이 패턴을 쓸 때 가장 시간을 많이 쓴 버그도 이것이었는데, 같은 코드가 실행 파일에 직접 넣으면 동작하고 라이브러리로 분리하면 동작하지 않아 원인을 찾기 어려웠습니다. GNU ld라면 -Wl,--whole-archive, MSVC라면 /WHOLEARCHIVE, CMake 3.24 이상이라면 $<LINK_LIBRARY:WHOLE_ARCHIVE,mylib>로 라이브러리 전체를 링크하거나, CMake의 OBJECT 라이브러리로 만들어 오브젝트 파일을 직접 링크하는 방법으로 해결합니다.
nullptr 반환·소유권·if-else 확장 문제
알 수 없는 타입에 nullptr을 반환함
증상: 크래시.
원인: Factory가 nullptr을 반환할 수 있는데 검사하지 않았습니다.
// ❌ 잘못된 사용: nullptr 검사 없음
auto product = Factory::create("unknown");
product->use(); // Crash: nullptr 역참조
// ✅ 올바른 사용: nullptr 검사
auto product = Factory::create("unknown");
if (product) {
product->use();
} else {
std::cerr << "Unknown product type\n";
}
raw 포인터를 반환해 누수가 생김
증상: 메모리 누수.
원인: new로 생성한 객체를 delete하지 않았습니다.
// ❌ 잘못된 사용: raw pointer
Product* Factory::create(const std::string& type) {
return new ConcreteProduct(); // 누가 delete?
}
// ✅ 올바른 사용: unique_ptr
std::unique_ptr<Product> Factory::create(const std::string& type) {
return std::make_unique<ConcreteProduct>();
}
새 타입마다 팩토리를 고쳐야 함
증상: 새 타입 추가 시 Factory 수정 필요.
원인: if-else 체인.
// ❌ 잘못된 사용: if-else 체인
std::unique_ptr<Product> Factory::create(const std::string& type) {
if (type == "A") return std::make_unique<ProductA>();
if (type == "B") return std::make_unique<ProductB>();
// 새 타입 추가 시 여기 수정
return nullptr;
}
// ✅ 올바른 사용: 등록 기반
// 자동 등록 Factory 사용 (위 예제 참조)
다만 “새 타입마다 팩토리를 고쳐야 함”이 항상 문제는 아닙니다. if-else 체인은 모든 타입이 한 화면에 보이고, 디버거로 따라가기 쉬우며, 앞서 본 초기화 순서나 링커 문제가 없습니다. 타입이 대여섯 개이고 한 팀이 모두 관리한다면 체인이 오히려 나은 선택입니다. 등록 기반 팩토리는 타입을 추가하는 사람이 팩토리 코드에 접근할 수 없거나(플러그인, 다른 팀의 모듈), 타입 수가 수십 개 이상으로 늘어날 때 제값을 합니다. 그 대가로 “어떤 타입이 등록되어 있는가”를 코드만 보고 알 수 없게 되므로, 아래 플러그인 예제의 listPlugins()처럼 등록 목록을 확인하는 수단을 함께 제공하는 것이 좋습니다.
생성 인자 전달과 싱글톤 팩토리
생성 인자를 넘기는 파라미터화된 팩토리
#include <memory>
#include <string>
#include <iostream>
class Logger {
public:
virtual void log(const std::string& msg) = 0;
virtual ~Logger() = default;
};
class FileLogger : public Logger {
public:
FileLogger(const std::string& path) : filepath(path) {}
void log(const std::string& msg) override {
std::cout << "[File:" << filepath << "] " << msg << '\n';
}
private:
std::string filepath;
};
class LoggerFactory {
public:
static std::unique_ptr<Logger> createFileLogger(const std::string& path) {
return std::make_unique<FileLogger>(path);
}
};
int main() {
auto logger = LoggerFactory::createFileLogger("/var/log/app.log");
logger->log("Application started");
}
싱글톤 레지스트리 팩토리
class Factory {
public:
static Factory& instance() {
static Factory inst;
return inst;
}
std::unique_ptr<Product> create(const std::string& type) {
auto it = creators.find(type);
if (it != creators.end()) {
return it->second();
}
return nullptr;
}
void registerCreator(const std::string& type, Creator creator) {
creators[type] = creator;
}
private:
Factory() = default;
std::map<std::string, Creator> creators;
};
싱글톤 팩토리는 레지스트리를 한 곳에 두는 가장 쉬운 방법이지만, 전역 상태이기 때문에 테스트마다 등록 내용을 초기화하기 어렵고, 여러 스레드에서 registerCreator와 create를 동시에 호출하면 std::map에 대한 데이터 레이스가 생깁니다. 등록이 main() 이전의 자동 등록에서만 일어나고 이후에는 조회만 한다면 문제가 없지만, 런타임에 플러그인을 로드하며 등록한다면 std::shared_mutex로 읽기·쓰기를 보호해야 합니다. 테스트 용이성이 중요하다면 싱글톤 대신 팩토리 객체를 만들어 필요한 곳에 주입하는 방식이 낫습니다.
플러그인 시스템 만들기
#include <memory>
#include <string>
#include <map>
#include <functional>
#include <iostream>
class Plugin {
public:
virtual void execute() = 0;
virtual std::string getName() const = 0;
virtual ~Plugin() = default;
};
class PluginFactory {
public:
using Creator = std::function<std::unique_ptr<Plugin>()>;
static PluginFactory& instance() {
static PluginFactory inst;
return inst;
}
void registerPlugin(const std::string& name, Creator creator) {
creators_[name] = creator;
}
std::unique_ptr<Plugin> create(const std::string& name) {
auto it = creators_.find(name);
if (it != creators_.end()) {
return it->second();
}
std::cerr << "Plugin not found: " << name << '\n';
return nullptr;
}
void listPlugins() const {
std::cout << "Available plugins:\n";
for (const auto& [name, _] : creators_) {
std::cout << " - " << name << '\n';
}
}
private:
PluginFactory() = default;
std::map<std::string, Creator> creators_;
};
// 자동 등록 헬퍼
template<typename T>
class PluginRegistrar {
public:
PluginRegistrar(const std::string& name) {
PluginFactory::instance().registerPlugin(name, [] {
return std::make_unique<T>();
});
}
};
// 플러그인 구현
class ImagePlugin : public Plugin {
public:
void execute() override {
std::cout << "Processing image...\n";
}
std::string getName() const override {
return "ImagePlugin";
}
};
class VideoPlugin : public Plugin {
public:
void execute() override {
std::cout << "Processing video...\n";
}
std::string getName() const override {
return "VideoPlugin";
}
};
// 자동 등록
static PluginRegistrar<ImagePlugin> registerImage("image");
static PluginRegistrar<VideoPlugin> registerVideo("video");
int main() {
PluginFactory::instance().listPlugins();
auto plugin = PluginFactory::instance().create("image");
if (plugin) {
std::cout << "Loaded: " << plugin->getName() << '\n';
plugin->execute();
}
}
출력:
Available plugins:
- image
- video
Loaded: ImagePlugin
Processing image...
listPlugins()의 출력이 등록 순서가 아니라 알파벳순인 것은 std::map이 키를 정렬해 저장하기 때문입니다. 등록 순서가 중요하다면 std::vector<std::pair<...>>를, 조회 성능이 중요하고 순서가 상관없다면 std::unordered_map을 쓰면 됩니다. 같은 이름을 두 번 등록하면 creators_[name] = creator가 앞의 것을 조용히 덮어쓰므로, 플러그인이 많아지면 emplace의 반환값으로 중복을 감지해 경고를 남기는 편이 안전합니다.
이 예제는 “플러그인 시스템”이라는 이름과 달리 모든 플러그인이 같은 실행 파일에 컴파일되어 있습니다. 진짜 플러그인처럼 실행 중에 공유 라이브러리(.so/.dll)를 불러오려면 dlopen/LoadLibrary로 라이브러리를 열어야 하고, 라이브러리가 로드될 때 그 안의 전역 PluginRegistrar가 실행되며 등록됩니다. 이때 라이브러리와 본체가 같은 PluginFactory::instance()를 공유하는지 확인해야 합니다. 정적 링크된 팩토리가 양쪽에 각각 들어가면 레지스트리가 두 벌이 되어, 라이브러리가 등록한 플러그인이 본체에서 보이지 않습니다. 또 라이브러리를 언로드하기 전에 그 라이브러리가 만든 객체와 등록된 람다를 모두 제거해야 합니다. 코드가 사라진 뒤에 소멸자나 람다를 호출하면 크래시가 납니다.
네 가지 팩토리 비교 정리
| 패턴 | 설명 |
|---|---|
| Simple Factory | 정적 메서드로 객체 생성 |
| Factory Method | 상속으로 팩토리 확장 |
| Abstract Factory | 관련 객체 군 생성 |
| 자동 등록 Factory | 전역 변수로 타입 자동 등록 |
| 장점 | 캡슐화, 확장성, 의존성 역전 |
| 단점 | 클래스 증가, 복잡도 증가 |
Factory Pattern은 객체 생성 로직을 캡슐화해 확장성과 유지보수성을 높이는 핵심 디자인 패턴입니다.
FAQ
Q1: Factory Pattern은 언제 쓰나요?
A: 객체 생성 로직이 복잡하거나, 새 타입 추가가 빈번하거나, 클라이언트가 구체 클래스에 의존하지 않아야 할 때 사용합니다.
Q2: Simple Factory vs Factory Method?
A: Simple Factory는 정적 메서드로 간단하며, Factory Method는 상속으로 확장 가능합니다.
Q3: Abstract Factory는 언제 쓰나요?
A: 관련 객체 군을 함께 생성해야 할 때 (예: Windows UI vs Mac UI).
Q4: 자동 등록 Factory의 장점은?
A: 새 타입 추가 시 Factory 수정 불필요, 전역 변수로 자동 등록됩니다.
Q5: 단점은?
A: 클래스 수 증가, 간접 참조로 복잡도 증가.
Q6: Factory Pattern 학습 리소스는?
A:
- “Design Patterns” by Gang of Four
- “Head First Design Patterns” by Freeman & Freeman
- Refactoring Guru: Factory Pattern Factory Pattern으로 객체 생성 로직을 캡슐화하고 확장성을 높일 수 있습니다. 다음으로 Observer Pattern을 읽어보면 좋습니다.
같이 보면 좋은 글
- C++ 가상 함수 심화 가이드 | vtable·vptr, 가상 상속, 추상 클래스, 가상 소멸자
- C++ 스마트 포인터 | 3일 동안 찾지 못한 순환 참조 버그 해결법
- C++ Observer 패턴
- C++ CRTP: 가상 함수 없이 정적 다형성 구현하기와 C++23 deducing this
- C++ Strategy 패턴
- C++ Visitor Pattern
- C++ Adapter 패턴
- C++ Command 패턴