[모던 C++로 다시 쓰는 GoF 디자인 패턴 총정리 #3] 팩토리 메서드(Factory Method) 패턴: 가상 함수 기반에서 Concepts로!

지난 글에서는 싱글톤(Singleton) 패턴을 모던 C++ 관점에서 재조명하며, 전역 상태 관리와 초기화 안정성, 에러 처리 측면에서 어떤 개선이 가능한지를 살펴보았습니다. 이번 글에서는 팩토리 메서드(Factory Method) 패턴에 주목합니다. 팩토리 메서드는 인스턴스 생성 로직을 서브클래스나 별도 함수로 위임함으로써, 객체 생성 과정을 캡슐화하고, 클라이언트 코드는 구체적인 클래스에 종속되지 않도록 하는 패턴입니다.

고전적 C++에서는 가상 함수 기반 인터페이스를 통해 팩토리 메서드 패턴을 구현했으나, 모던 C++에서는 Concepts를 통해 인터페이스 요구사항을 더 명확히 표현할 수 있습니다. 또한 std::optional, std::expected, std::variant 등을 활용해 생성 실패나 다양한 생성 결과를 직관적으로 처리할 수 있고, std::format으로 디버그 로그를 명확히 출력할 수 있습니다.

패턴 소개: 팩토리 메서드(Factory Method)

의도:
객체 생성 로직을 서브클래스나 별도 메서드로 분리함으로써 클라이언트 코드가 구체 클래스에 의존하지 않고, 확장 가능한 인스턴스 생성 구조를 구축.

특징:

  • 상속 기반: 전통적으로는 추상 팩토리 클래스에 가상 함수(createProduct())를 정의하고, 구체 팩토리들이 이를 오버라이드해 원하는 제품 객체를 생성.
  • 클라이언트는 팩토리 메서드를 호출해 추상 클래스 인터페이스를 통해 객체를 얻고, 어떤 실제 클래스가 생성되는지에 대해 알 필요 없음.

단점:

  • 가상 함수, 클래스 상속에 의존.
  • 새로운 제품 유형을 추가하려면 상속 계층에 변화를 줘야 하며, 컴파일러가 가상 함수 호출 최적화를 하더라도 코드 구조가 복잡해질 수 있음.

기존 C++ 스타일 구현 (C++11/14/17)

아래 예는 "문서(Document)"를 생성하는 팩토리 메서드 패턴 예제입니다. 전통적으로 다음과 같이 추상 클래스를 이용합니다.

#include <memory>
#include <iostream>

// 제품 인터페이스
struct Document {
    virtual ~Document() = default;
    virtual void printType() const = 0;
};

// 구체 제품들
struct PDFDocument : Document {
    void printType() const override {
        std::cout << "PDF Document\n";
    }
};

struct WordDocument : Document {
    void printType() const override {
        std::cout << "Word Document\n";
    }
};

// 팩토리 인터페이스
struct DocumentFactory {
    virtual ~DocumentFactory() = default;
    virtual std::unique_ptr<Document> createDocument() const = 0;
};

// 구체 팩토리들
struct PDFDocumentFactory : DocumentFactory {
    std::unique_ptr<Document> createDocument() const override {
        return std::make_unique<PDFDocument>();
    }
};

struct WordDocumentFactory : DocumentFactory {
    std::unique_ptr<Document> createDocument() const override {
        return std::make_unique<WordDocument>();
    }
};

int main() {
    PDFDocumentFactory pdfFactory;
    auto doc1 = pdfFactory.createDocument();
    doc1->printType(); // "PDF Document"

    WordDocumentFactory wordFactory;
    auto doc2 = wordFactory.createDocument();
    doc2->printType(); // "Word Document"
}

고찰:

  • 가상 함수 테이블을 통해 다형성 구현.
  • 팩토리마다 구체적인 제품 생성 로직을 오버라이드.
  • 코드가 비교적 명확하지만, 상속 계층과 vtable이 필수적.
  • 생성 과정에서 실패 처리가 필요하다면 std::nullptr 반환 또는 예외 활용.

모던 C++20 이상의 개선: Concepts, Variant, Expected

1. Concepts로 인터페이스 제약 명시

C++20 Concepts를 사용하면 "팩토리가 가져야 할 메서드 형태"를 타입 제약으로 표현할 수 있습니다. 예를 들어, 팩토리 타입은 T createDocument() 메서드를 제공해야 한다는 것을 Concept로 정의할 수 있습니다.

#include <concepts>
#include <memory>
#include <iostream>

// 제품 인터페이스를 가상 함수 대신에 값 기반 다형성(variant)나 Concepts로 표현 가능
// 여기서는 일단 Document 인터페이스를 유지하면서도 구현을 변형해봄.

// 개념: 팩토리 타입은 createDocument() 메서드가 있어야 함
template<typename F>
concept DocumentFactoryConcept = requires(const F& f) {
    { f.createDocument() } -> std::convertible_to<std::unique_ptr<Document>>;
};

// 구체 제품
struct JSONDocument : Document {
    void printType() const override {
        std::cout << "JSON Document\n";
    }
};

// Concept를 만족하는 팩토리 구현
struct JSONDocumentFactory {
    std::unique_ptr<Document> createDocument() const {
        return std::make_unique<JSONDocument>();
    }
};

// 클라이언트 코드: 팩토리 타입이 Concept를 만족하면 받아들임
template<DocumentFactoryConcept Factory>
void printFactoryProduct(const Factory& factory) {
    auto doc = factory.createDocument();
    doc->printType();
}

고찰:

  • 이제 JSONDocumentFactory는 별도의 추상 클래스 상속 없이도 "팩토리 메서드"를 제공하고, printFactoryProduct() 함수는 Concepts를 통해 타입 제약을 검사.
  • 상속 기반 다형성 없이도 팩토리 인터페이스를 정의할 수 있어, 템플릿 기반 다형성으로 최적화 가능.
  • 가상 함수 호출 오버헤드 감소, 컴파일 타임 인터페이스 검증으로 타입 안전성 향상.

2. std::optional이나 std::expected로 생성 실패 처리

예를 들어, 문서 생성 중 I/O 오류나 형식 파싱 실패가 발생할 수 있습니다. 기존 C++ 구현에서는 예외를 던지거나 nullptr 반환을 사용했을 겁니다. 모던 C++에서는 std::expected를 활용할 수 있습니다.

#include <expected>

// 새로운 Concept: createDocument()는 실패 시 std::unexpected 반환 가능
template<typename F>
concept RobustDocumentFactory = requires(const F& f) {
    { f.createDocument() } -> std::convertible_to<std::expected<std::unique_ptr<Document>, std::string>>;
};

struct SafePDFDocumentFactory {
    std::expected<std::unique_ptr<Document>, std::string> createDocument() const {
        bool success = loadResource(); // 가상의 리소스 로딩
        if (!success) {
            return std::unexpected("Failed to load PDF resource");
        }
        return std::make_unique<PDFDocument>();
    }

    bool loadResource() const {
        // 가상의 실패 시나리오
        return false;
    }
};

template<RobustDocumentFactory Factory>
void tryPrintFactoryProduct(const Factory& factory) {
    auto result = factory.createDocument();
    if (result) {
        (*result)->printType();
    } else {
        std::cerr << "Error: " << result.error() << "\n";
    }
}

고찰:

  • std::expected로 실패 원인을 문자열 등으로 명확히 전달.
  • 호출자는 에러를 예측 가능한 경로로 처리.
  • 예외나 nullptr에 비해 가독성과 유지보수성이 향상.

3. 값 기반 다형성과 std::variant

가상 함수 대신 std::variant와 std::visit을 사용하면, 런타임 선택에 따라 다른 종류의 문서를 반환하는 팩토리를 구현할 수도 있습니다.

#include <variant>

struct MarkdownDocument : Document {
    void printType() const override {
        std::cout << "Markdown Document\n";
    }
};

using DocumentVariant = std::variant<PDFDocument, WordDocument, MarkdownDocument>;

// 팩토리: 특정 조건에 따라 다른 문서 타입 생성
struct VariantDocumentFactory {
    std::optional<DocumentVariant> createDocument() const {
        // 조건에 따라 문서 타입 결정 (가상의 조건)
        int mode = 2;
        switch(mode) {
            case 0: return PDFDocument{};
            case 1: return WordDocument{};
            case 2: return MarkdownDocument{};
        }
        return std::nullopt; 
    }
};

void printVariantProduct(const VariantDocumentFactory& factory) {
    auto result = factory.createDocument();
    if (result) {
        std::visit([](auto& doc){doc.printType();}, *result);
    } else {
        std::cout << "No Document created.\n";
    }
}

고찰:

  • std::variant로 여러 타입 반환을 하나의 값으로 캡슐화, switch-case 대신 std::visit로 처리
  • 팩토리 메서드가 동적으로 할당하거나 가상 함수를 사용할 필요 없이, 값 기반 다형성을 지원
  • 템플릿 기반 접근, Concepts, std::variant 등의 결합으로 유연성과 성능 개선

4. std::format을 통한 로깅과 디버깅 개선

팩토리 생성 시 로깅 필요하다면 std::format 활용:

#include <format>

struct DebugDocumentFactory {
    std::unique_ptr<Document> createDocument() const {
        std::cout << std::format("[DEBUG]: Creating a WordDocument\n");
        return std::make_unique<WordDocument>();
    }
};

고찰:

  • printf 스타일 형식 지정 대신 타입 안전하고 가독성 높은 형식 문자열 사용
  • Concepts와 결합해, 로그 가능한 팩토리 타입(예: requires { ... })을 별도로 정의할 수도 있음

비교 및 분석

  • 전통적 구현(C++11 전후):
    • 추상 클래스 + 가상 함수 기반 인터페이스, 명확하지만 상속 계층과 vtable 필요
    • 에러 처리 시 nullptr 반환이나 예외 던지기
    • 동적 메모리 관리와 raw pointer 사용 빈발
  • 모던 C++(C++20 이상):
    • Concepts로 인터페이스 요구사항을 템플릿 레벨에서 정적 검사
    • std::expected, std::optional로 실패와 옵션 상황 처리 명확화
    • std::variant를 통해 값 기반 다형성 지원, 가상 함수 없이도 다양한 반환 타입 처리
    • std::format으로 디버깅 정보 가독성 향상
    • 상속 계층 최소화, 템플릿 기반 다형성 활용으로 성능 및 유지보수성 개선

한마디로, 모던 C++에서는 팩토리 메서드가 꼭 vtable 기반 다형성에 의존할 필요가 없어졌으며, 다양한 언어 기능을 통해 확장성과 타입 안전성, 에러 처리 측면을 더 깔끔하게 관리할 수 있습니다.

마무리

팩토리 메서드 패턴은 객체 생성 로직을 추상화해 확장성을 높이는 중요한 패턴입니다. 모던 C++에서는 Concepts를 통해 "팩토리가 무엇을 해야 하는지"를 정적 타입 검사로 표현하고, std::expected나 std::variant를 통해 실패나 다양성을 자연스럽게 처리할 수 있습니다. 이는 기존 가상 함수 기반 구현보다 더 유연하고 유지보수 친화적인 코드를 작성하는 데 도움이 됩니다.

다음 글에서는 추상 팩토리(Abstract Factory) 패턴을 다루며, 여러 연관된 객체 패밀리를 생성하는 과정을 모던 C++로 어떻게 단순화하고 타입 안전하게 만들 수 있는지 살펴보겠습니다.

반응형