[모던 C++로 다시 쓰는 GoF 디자인 패턴 총정리 #4] 추상 팩토리(Abstract Factory) 패턴: 계층적 상속에서 Concepts와 조합 가능한 팩토리로

이전 글에서는 팩토리 메서드(Factory Method) 패턴을 살펴보며, 가상 함수 기반의 고전적 구현을 어떻게 모던 C++20 이상의 기능(Concepts, std::expected, std::variant, std::optional, std::format)으로 개선할 수 있는지 논했습니다. 이번에는 추상 팩토리(Abstract Factory) 패턴으로 한 걸음 더 나아갑니다.

추상 팩토리는 "연관되거나 의존적인 객체 패밀리를 생성하는 인터페이스"를 제공하는 패턴입니다. 즉, 서로 관련 있는 여러 종류의 객체를 일관성 있게 생성할 수 있는 팩토리를 추상화하는 것입니다. 전통적인 C++ 구현에서는 인터페이스 클래스를 상속받아 구체 팩토리를 제공했고, 다양한 제품 계열(class family)을 다룰 때 복잡한 상속 계층이 필연적으로 생겼습니다.

모던 C++에서는 이러한 복잡성을 Concepts와 조합 가능한 팩토리(컴포지션), std::variant, std::expected 등의 기능을 통해 줄이고, 더 선언적이며 타입 안전한 방식으로 추상 팩토리를 구현할 수 있습니다.

패턴 소개: 추상 팩토리(Abstract Factory)

의도:

  • 연관된 제품 객체(예: GUI 툴킷에서 Window, ScrollBar, Button)가 한 패밀리를 이루며, 이들의 생성 방식을 하나의 추상 팩토리 인터페이스로 캡슐화.
  • 클라이언트는 구체적인 제품 클래스에 의존하지 않고, 추상 팩토리를 통해 해당 패밀리의 객체를 얻을 수 있음.
  • 패밀리를 교체하려면 팩토리만 변경하면 되므로, 서로 다른 실행 환경(예: Windows용, Linux용 GUI)이 존재할 때 유용.

고전적 구현 문제점:

  • 상속 기반 인터페이스 클래스(예: GUIFactory)와 이를 상속하는 구체 팩토리(WinFactory, LinuxFactory)를 만들어야 함.
  • 새로운 제품 계열 추가 시 계층 구조 수정, 다수의 가상 함수 오버라이드 필요.
  • 객체 생성 실패나 다양한 변형 옵션 처리 시 예외나 nullptr 반환 등 단순한 수단에 의존.

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

예를 들어, GUI를 구성하는 Window, Button, ScrollBar를 한 패밀리로 묶어보겠습니다.

#include <memory>
#include <iostream>

// 추상 제품 인터페이스
struct Window {
    virtual ~Window() = default;
    virtual void render() const = 0;
};

struct Button {
    virtual ~Button() = default;
    virtual void click() = 0;
};

struct ScrollBar {
    virtual ~ScrollBar() = default;
    virtual void scroll(int delta) = 0;
};

// 구체 제품들 (Windows 스타일)
struct WinWindow : Window {
    void render() const override {
        std::cout << "Render WinWindow\n";
    }
};
struct WinButton : Button {
    void click() override { std::cout << "WinButton clicked\n"; }
};
struct WinScrollBar : ScrollBar {
    void scroll(int delta) override { std::cout << "WinScrollBar scroll: " << delta << "\n"; }
};

// 추상 팩토리 인터페이스
struct GUIFactory {
    virtual ~GUIFactory() = default;
    virtual std::unique_ptr<Window> createWindow() const = 0;
    virtual std::unique_ptr<Button> createButton() const = 0;
    virtual std::unique_ptr<ScrollBar> createScrollBar() const = 0;
};

// 구체 팩토리
struct WinFactory : GUIFactory {
    std::unique_ptr<Window> createWindow() const override {
        return std::make_unique<WinWindow>();
    }
    std::unique_ptr<Button> createButton() const override {
        return std::make_unique<WinButton>();
    }
    std::unique_ptr<ScrollBar> createScrollBar() const override {
        return std::make_unique<WinScrollBar>();
    }
};

int main() {
    WinFactory factory;
    auto w = factory.createWindow();
    auto b = factory.createButton();
    auto s = factory.createScrollBar();

    w->render();
    b->click();
    s->scroll(10);
}

고찰:

  • 상속 계층 명확하나, 제품 유형 추가나 변형 시 인터페이스 수정 필요.
  • 구체 팩토리마다 모든 제품을 전부 구현해야 하는 부담.
  • 런타임 다형성(vtable)을 사용, 컴파일 타임 체크 제한.

모던 C++20 이상의 개선: Concepts, 조합 가능한 팩토리, Variant

1. Concepts로 추상 팩토리 요건 정의

Concepts를 사용해 추상 팩토리 요구사항을 타입 레벨에서 정의할 수 있습니다. 예를 들어, 팩토리가 createWindow(), createButton(), createScrollBar()를 제공해야 한다는 것을 Concept로 표현할 수 있습니다.

#include <concepts>
#include <memory>
#include <optional>

// 제품 인터페이스를 최소화하거나 값 기반 다형성으로 바꿀 수도 있지만,
// 여기서는 기존처럼 가상 함수 기반 제품 인터페이스를 그대로 둡니다.

template<typename F>
concept GUIFactoryConcept = requires(const F& f) {
    { f.createWindow() } -> std::convertible_to<std::unique_ptr<Window>>;
    { f.createButton() } -> std::convertible_to<std::unique_ptr<Button>>;
    { f.createScrollBar() } -> std::convertible_to<std::unique_ptr<ScrollBar>>;
};

고찰:

  • 별도 추상 클래스 필요 없이, 팩토리 타입이 특정 메서드를 제공하는지 템플릿으로 검사 가능.
  • 컴파일 타임에 조건 미충족 시 명확한 에러 메시지.

2. 조합 가능한 팩토리: 컨셉 충족 팩토리들을 합성

각 제품 타입별로 별도의 "미니 팩토리"를 만들고, 이를 조합해 전체를 구성할 수도 있습니다.

struct WindowFactory {
    std::unique_ptr<Window> createWindow() const {
        return std::make_unique<WinWindow>(); // 쉽게 WinWindow 고정
    }
};

struct ButtonFactory {
    std::unique_ptr<Button> createButton() const {
        return std::make_unique<WinButton>();
    }
};

struct ScrollBarFactory {
    std::unique_ptr<ScrollBar> createScrollBar() const {
        return std::make_unique<WinScrollBar>();
    }
};

// 조합 팩토리: 하나의 클래스에서 세 메서드를 구현하기 위해 구조체들을 상속/조합
struct ComposedWinFactory : WindowFactory, ButtonFactory, ScrollBarFactory {
    // 이 경우 WindowFactory, ButtonFactory, ScrollBarFactory가
    // 각자 필요한 메서드를 제공하므로
    // 별도 코드 없이 세 메서드를 모두 갖춘 팩토리 완성
    // 단, 다중 상속에 주의. 또는 팩토리 객체를 멤버로 갖는 식도 가능.
};

// 또 다른 접근: 템플릿 매개변수로 팩토리 객체를 조합
template<typename WF, typename BF, typename SF>
struct CombinedFactory {
    WF wf;
    BF bf;
    SF sf;

    std::unique_ptr<Window> createWindow() const { return wf.createWindow(); }
    std::unique_ptr<Button> createButton() const { return bf.createButton(); }
    std::unique_ptr<ScrollBar> createScrollBar() const { return sf.createScrollBar(); }
};

static_assert(GUIFactoryConcept<ComposedWinFactory>);
static_assert(GUIFactoryConcept<CombinedFactory<WindowFactory,ButtonFactory,ScrollBarFactory>>);

// 이제 CombinedFactory를 통해 런타임에 다른 팩토리 조합 가능

고찰:

  • 모던 C++은 상속 없이도 Concepts로 요구사항을 정의하고, 여러 팩토리를 합성(컴포지션)해 추상 팩토리 역할을 하는 객체를 만들 수 있음.
  • 필요하다면 std::variant를 사용해 런타임에 다른 팩토리로 전환하는 것도 가능.

3. std::expected로 생성 실패 처리

연관된 제품 중 하나를 생성할 때 실패 가능성이 있다면 std::expected를 사용해 실패를 명확히 처리할 수 있습니다.

#include <expected>

struct RobustWindowFactory {
    std::expected<std::unique_ptr<Window>,std::string> createWindow() const {
        bool success = loadWindowResources();
        if(!success) return std::unexpected("Failed to load Window resources");
        return std::make_unique<WinWindow>();
    }

    bool loadWindowResources() const { return false; /* simulate fail */ }
};

struct RobustComposedFactory {
    RobustWindowFactory wf;
    ButtonFactory bf;
    ScrollBarFactory sf;

    std::expected<std::unique_ptr<Window>,std::string> createWindow() const { return wf.createWindow(); }
    std::unique_ptr<Button> createButton() const { return bf.createButton(); }
    std::unique_ptr<ScrollBar> createScrollBar() const { return sf.createScrollBar(); }
};

// now the user can handle failure gracefully

고찰:

  • 특정 제품 생성이 실패하면 std::unexpected로 에러 메시지 반환.
  • 호출자 코드에서 이를 체크해 대안 시나리오를 구현 가능.

4. 값 기반 다형성(std::variant)으로 다양한 제품 계열 지원

여러 GUI 테마(Win, Linux, Mac)를 지원하는 추상 팩토리를 생각해보자. 전통적으로는 if/else나 팩토리 클래스 상속을 통해 처리했을 텐데, std::variant와 std::visit를 활용하면 런타임에 다양한 팩토리를 하나의 변이에 담을 수 있다.

#include <variant>

// Linux 제품 예제
struct LinuxWindow : Window { void render() const override { std::cout << "Linux Window\n"; } };
struct LinuxButton : Button { void click() override { std::cout << "Linux Button click\n"; } };
struct LinuxScrollBar : ScrollBar { void scroll(int delta) override { std::cout << "Linux Scroll\n"; } };

struct LinuxFactory {
    std::unique_ptr<Window> createWindow() const { return std::make_unique<LinuxWindow>(); }
    std::unique_ptr<Button> createButton() const { return std::make_unique<LinuxButton>(); }
    std::unique_ptr<ScrollBar> createScrollBar() const { return std::make_unique<LinuxScrollBar>(); }
};

using AnyGUIFactory = std::variant<WinFactory, LinuxFactory>;

static_assert(GUIFactoryConcept<WinFactory>);
static_assert(GUIFactoryConcept<LinuxFactory>);

void renderWindowFromAnyFactory(const AnyGUIFactory& factory) {
    std::visit([](auto& f) {
        auto w = f.createWindow();
        w->render();
    }, factory);
}

고찰:

  • std::variant를 사용해 런타임에 다양한 팩토리를 선택할 수 있음.
  • 상속이나 공통 추상 클래스를 강제하지 않아도 Concepts로 타입 제약을 검사할 수 있으나, std::visit 시 구체 타입별 처리가 필요.
  • 이를 통해 코드를 유연하고 확장 가능하게 유지하면서 상속 계층과 vtable 의존을 최소화.

5. std::format을 통한 로깅

디버그나 로깅 상황에서 std::format을 사용해 제품 생성 과정을 명확히 기록할 수 있습니다.

#include <format>

struct LoggingButtonFactory {
    std::unique_ptr<Button> createButton() const {
        std::cout << std::format("[DEBUG]: Creating a WinButton\n");
        return std::make_unique<WinButton>();
    }
};

고찰:

  • 단순한 개선점이지만, 코드 가독성과 유지보수성에 기여.

비교 및 분석

  • 전통적 구현(C++11 전후):
    • 추상 팩토리 인터페이스(가상 함수) + 구체 팩토리 상속 -> 상속 계층 복잡
    • 제품 패밀리 추가 시 인터페이스 클래스 수정, vtable 기반 다형성
    • 에러 처리 시 예외나 nullptr 반환 등 단순한 수단
  • 모던 C++(C++20 이상):
    • Concepts로 팩토리 요건 정적 검사: 상속 없이도 타입 요구사항 명시
    • 개별 팩토리를 합성(조합)해 추상 팩토리 구성 -> 유연한 코드 구조
    • std::expected로 실패를 명확히 처리, std::variant로 런타임에 다양한 팩토리를 선택
    • 상속/다형성 의존도 감소, 템플릿 기반 접근으로 성능 및 유지보수성 증대

결국, 모던 C++ 환경에서는 추상 팩토리도 "상속 기반" 패턴에서 "콘셉트 기반" 패턴으로 진화할 수 있습니다. 다양한 언어 기능을 결합해 상속 없이, 또는 상속 최소화로도 패턴 구현이 가능해지며, 타입 안전성과 확장 용이성을 모두 챙길 수 있습니다.

마무리

추상 팩토리 패턴은 연관된 객체들의 일관적 생성 인터페이스를 제공하는 중요한 패턴입니다. 모던 C++20 이상에서 Concepts, std::expected, std::variant, std::format 등을 활용하면, 상속 계층에 의존하지 않고도 이 패턴을 구현할 수 있고, 에러 처리나 다양한 제품 계열 관리가 훨씬 단순하고 타입 안전해집니다.

다음 글에서는 구조적 패턴 중 하나인 브리지(Bridge) 패턴을 살펴보며, 구현과 인터페이스를 분리하는 이 전통적 패턴이 모던 C++에서 어떻게 간결하고 확장성 있게 재구성될 수 있는지 탐구해보겠습니다.

반응형