[모던 C++로 다시 쓰는 GoF 디자인 패턴 총정리 #5] 브리지(Bridge) 패턴: 상속 대신 합성과 Concepts로 구현 체계 확장하기

이전 글에서는 추상 팩토리(Abstract Factory) 패턴을 모던 C++로 재해석하며, 상속 기반의 전통적 구현 방식이 어떻게 Concepts, std::expected, std::variant 등을 활용해 보다 탄탄하고 유연한 구조로 바뀔 수 있는지 알아보았습니다. 이번에는 브리지(Bridge) 패턴에 주목합니다.

브리지 패턴의 핵심 의도는 "추상(Abstraction)과 구현(Implementation)을 분리하여 둘을 독립적으로 확장할 수 있게 하는 것"입니다. 전통적인 C++ 구현에서는 추상 클래스 계층과 구현 클래스 계층을 상속으로 연결하는 경향이 많았으나, 모던 C++에서는 Concepts, 컴포지션(합성), std::function, 그리고 Ranges나 람다를 통한 파이프라인형 사고로 인해 더 간결하고 확장성 높은 코드를 작성할 수 있습니다.

패턴 소개: 브리지(Bridge)

의도:

  • 클래스 계층의 한 축(추상화)과 다른 축(구현)을 별도의 계층으로 분리하고, 런타임에 이 둘을 연결(bridge)하여 상속 계층 폭발을 방지.
  • 예: 형태(Shape)와 렌더링 방식(Renderer)을 분리. Shape은 추상화 부분, Renderer는 구현 부분. Rectangle + RasterRenderer, Rectangle + VectorRenderer 등을 조합 가능.

고전적 구현 문제점:

  • 전통적으로는 Shape라는 추상 클래스와 Renderer라는 추상 클래스를 정의한 뒤, 각각 구체 클래스(Rectangle, Circle), 구체 렌더러(Raster, Vector) 클래스를 상속 계층으로 구성.
  • 확장할 때마다 상속을 통한 계층 확대가 필요하고, 가상 함수 기반 다형성에 의존.

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

아래 예는 브리지 패턴의 전통적 형태를 단순화한 예제입니다.

#include <iostream>
#include <memory>

// 구현 부분(Implementation)
struct Renderer {
    virtual ~Renderer() = default;
    virtual void renderCircle(float x, float y, float radius) const = 0;
};

struct RasterRenderer : Renderer {
    void renderCircle(float x, float y, float radius) const override {
        std::cout << "Rasterizing circle at (" << x << "," << y << ") with radius " << radius << "\n";
    }
};

struct VectorRenderer : Renderer {
    void renderCircle(float x, float y, float radius) const override {
        std::cout << "Drawing vector circle at (" << x << "," << y << ") radius " << radius << "\n";
    }
};

// 추상 부분(Abstraction)
struct Shape {
    virtual ~Shape() = default;
    virtual void draw() const = 0;
};

struct Circle : Shape {
    Circle(std::unique_ptr<Renderer> r, float x, float y, float radius)
        : renderer(std::move(r)), x(x), y(y), radius(radius) {}

    void draw() const override {
        renderer->renderCircle(x, y, radius);
    }
private:
    std::unique_ptr<Renderer> renderer;
    float x, y, radius;
};

int main() {
    auto rasterCircle = Circle(std::make_unique<RasterRenderer>(), 0,0,5);
    rasterCircle.draw();

    auto vectorCircle = Circle(std::make_unique<VectorRenderer>(), 1,1,10);
    vectorCircle.draw();
}

고찰:

  • Shape는 Renderer에 대한 포인터/레퍼런스를 포함해 구현을 위임.
  • 상속 기반 다형성과 가상 함수 호출로 동적 다형성을 구현.
  • 새로운 Renderer나 Shape를 추가하면 상속 계층 확장이 필요하나, 코드 구조가 명료하기는 하다.

모던 C++20 이상의 개선: Concepts, 합성, 람다, 함수형 스타일

1. Concepts로 Renderer 요구사항 정의하기

Renderer를 가상 함수 기반 인터페이스가 아니라 Concepts로 정의해보자. Renderer가 반드시 renderCircle(float x, float y, float radius) 메서드를 제공한다는 것을 Concept로 표현할 수 있다.

#include <concepts>
#include <iostream>
#include <utility>

// Concepts 기반 Renderer 정의
template<typename R>
concept RendererConcept = requires(const R& r, float x, float y, float radius) {
    { r.renderCircle(x, y, radius) } -> std::same_as<void>;
};

// 기존 Raster, Vector 구현을 함수 객체나 람다로 표현 가능
struct RasterRendererLambda {
    void renderCircle(float x, float y, float radius) const {
        std::cout << "Raster circle (" << x << "," << y << ") r=" << radius << "\n";
    }
};

struct VectorRendererLambda {
    void renderCircle(float x, float y, float radius) const {
        std::cout << "Vector circle (" << x << "," << y << ") r=" << radius << "\n";
    }
};

// 이제 Renderer로 사용할 수 있는 타입은 RendererConcept를 만족하기만 하면 됨.
static_assert(RendererConcept<RasterRendererLambda>);
static_assert(RendererConcept<VectorRendererLambda>);

고찰:

  • 상속 없이도 Renderer 타입을 정의할 수 있음.
  • 어떤 타입이든 renderCircle 메서드만 구현하면 Renderer로 사용 가능, template 기반 정적 다형성.

2. Shape도 Concepts로 정의해볼 수 있다?

Shape 자체는 Renderer에 의존한다. 전통적으로는 Shape가 Renderer를 참조했지만, 이제는 템플릿으로 Shape를 정의하고, RendererConcept를 만족하는 타입을 주입받을 수 있다.

template<RendererConcept R>
struct Circle2 {
    Circle2(R renderer, float x, float y, float radius)
        : renderer(std::move(renderer)), x(x), y(y), radius(radius) {}

    void draw() const {
        renderer.renderCircle(x, y, radius);
    }
private:
    R renderer;
    float x, y, radius;
};

고찰:

  • Circle2는 템플릿으로 Renderer 타입을 받으므로, vtable 없이 컴파일 타임에 정적 바인딩 가능.
  • renderer를 직접 멤버로 두어 합성(Composition) 사용, 상속 최소화.

3. std::function 또는 람다로 구현 바인딩하기

Renderer를 함수 하나로 표현해도 된다면 std::function을 사용해도 좋다.

#include <functional>
#include <functional>

using RenderCircleFn = std::function<void(float,float,float)>;

struct Circle3 {
    Circle3(RenderCircleFn fn, float x, float y, float radius)
        : renderCircleFn(std::move(fn)), x(x), y(y), radius(radius) {}

    void draw() const {
        renderCircleFn(x, y, radius);
    }
private:
    RenderCircleFn renderCircleFn;
    float x, y, radius;
};

고찰:

  • 브리지 패턴에서 추상화와 구현을 분리하는 또 다른 방법: 구현 부분을 단순한 함수(또는 람다)로 캡슐화.
  • std::function으로 런타임에 다른 구현을 쉽게 교체 가능.

4. Ranges, Pipeline 스타일로 Renderer 확장

만약 Renderer가 단순히 renderCircle만 하는 게 아니라, 렌더링 파이프라인에서 다양한 변환, 필터, 레이어를 적용한다면? C++20 Ranges와 람다를 조합해 파이프라인 스타일로 구현체를 구성할 수 있다.

#include <ranges>
#include <vector>
#include <format>

struct PipelineRenderer {
    // 예: 여러 단계(람다)로 구성된 파이프라인, 각 람다는 (x,y,radius) -> (x',y',r')
    // 최종적으로 std::function<void(float,float,float)> 형태로 처리 가능
    std::vector<std::function<std::tuple<float,float,float>(float,float,float)>> steps;
    std::function<void(float,float,float)> finalRender;

    void renderCircle(float x, float y, float r) const {
        for (auto& step : steps) {
            std::tie(x,y,r) = step(x,y,r);
        }
        finalRender(x,y,r);
    }
};

// 예: 파이프라인 정의
auto pipeline = PipelineRenderer{
    {
        [](float x,float y,float r){ return std::tuple{x*2,y*2,r}; }, // scale position
        [](float x,float y,float r){ return std::tuple{x,y,r+5}; }    // increase radius
    },
    [](float x,float y,float r) {
        std::cout << std::format("Pipeline rendered circle at ({},{}), r={}\n", x,y,r);
    }
};

고찰:

  • 브리지 패턴에서 구현 부분을 pipeline 형태로 조립 가능.
  • Ranges나 함수 객체의 합성으로 구현을 모듈화해, 상속 대신 함수 조합으로 해결.
  • 유지보수성과 확장성 증대: 새로운 렌더링 단계 추가 시 람다 추가만 하면 됨.

5. 에러 처리와 로깅

std::expected를 사용해 렌더링 실패를 반영하거나, std::format으로 디버깅 정보를 찍을 수도 있음:

std::expected<void,std::string> safeRender(float x, float y, float r) {
    if(r < 0) return std::unexpected("Negative radius");
    std::cout << std::format("Safely rendered at ({},{}) radius {}\n", x,y,r);
    return {};
}

고찰:

  • 브리지 패턴에서 구현체 부분이 복잡한 로직을 갖는다면 std::expected로 에러 처리, 호출부에서 에러 확인 가능.
  • std::format으로 디버깅 메시지 가독성 향상.

비교 및 분석

  • 전통적 구현(C++11 전후):
    • 추상 클래스(Abstraction) + 구현 클래스(Implementation) 계층을 vtable 기반 상속으로 연결
    • 새로운 구현을 추가할 때마다 클래스 상속 필요
    • 런타임 다형성만 가능, 정적 타입 검사 한계
  • 모던 C++(C++20 이상):
    • Concepts로 구현 요구사항을 정적 표현, 상속 없이도 조건 충족 타입을 Renderer로 사용 가능
    • 람다, std::function으로 구현 부분을 합성, 파이프라인화 가능
    • std::expected, std::format 등으로 에러 처리, 로깅 개선
    • 상속/다형성 의존 최소화, 템플릿 기반 정적 다형성 가능 -> 성능 및 유지보수성 개선
    • 구현과 추상을 "함수 조합"이나 "템플릿 인자"로 연결, 브리지 패턴의 의도를 더 유연하고 선언적으로 달성

결국, 모던 C++에서는 브리지 패턴이 꼭 가상 함수 테이블을 쓰지 않아도 되고, Concept, 람다, 함수 객체 조합으로 더 단순하고 모듈화된 구조를 만들 수 있습니다. 상속 계층을 줄이고, 템플릿 기반 다형성을 활용해 확장성, 타입 안전성, 성능까지 얻을 수 있습니다.

마무리

브리지 패턴은 추상과 구현을 분리하여 확장성을 높이는 대표적 패턴입니다. 모던 C++20 시대에는 상속과 vtable에 의존하지 않고도 Concept, 함수 합성, Ranges 스타일 파이프라인, std::expected 등의 기능을 통해 더 표현력 있는 브리지 구현이 가능합니다. 더 이상 하나의 인터페이스 추상 클래스와 구현 클래스 계층에 묶일 필요 없이, 템플릿 기반 접근으로 유연하고 깔끔한 코드를 작성할 수 있습니다.

다음 글에서는 구조 패턴 중 하나인 데코레이터(Decorator) 패턴을 다루며, 기존 상속 기반 장식(Decoration)을 Ranges나 람다 기반 파이프라인 조합으로 어떻게 단순화할 수 있는지 살펴보겠습니다.

반응형