[모던 C++로 다시 쓰는 GoF 디자인 패턴 총정리 #22] 브리지(Bridge) 패턴: 추상과 구현을 함수 합성과 Concepts로 독립 확장하기

이전 글에서는 어댑터(Adapter) 패턴을 모던 C++ 관점에서 재해석하며, 기존 인터페이스를 원하는 다른 인터페이스로 변환하기 위해 상속 없이도 람다, Concepts, std::expected, std::format, coroutine, Ranges 등을 활용할 수 있음을 확인했습니다. 이번에는 구조적 패턴 중 브리지(Bridge) 패턴을 다룹니다.

브리지 패턴은 추상(Abstraction)과 구현(Implementation)을 분리하여 둘을 독립적으로 확장 가능하게 만드는 패턴입니다. 전통적으로는 추상 클래스 계층과 구현 클래스 계층을 상속 기반으로 구분하고, 추상 클래스가 구현 객체를 참조하는 구조를 사용했습니다. 이 방식은 새로운 추상이나 구현 타입 추가 시 클래스 증가와 복잡성 상승을 초래합니다. 모던 C++20 이상에서는 Concepts, 람다, std::function, std::expected, coroutine, Ranges, std::format 등을 활용해 상속 없이도 추상과 구현을 유연하게 연결하고, 조건부 기능, 비동기 처리, 로깅, 에러 처리 등 다양한 상황에도 쉽게 대응할 수 있습니다.

패턴 소개: 브리지(Bridge)

의도:

  • 추상화(Abstraction) 부분과 구현(Implementation) 부분을 분리하여, 둘 다 독립적으로 확장 가능하게 함.
  • 예: Shape(추상)와 Renderer(구현) 분리, Shape는 Renderer에 의존하지만 상속 계층 증가 없이 각각 독립적 확장.

전통적 구현 문제점:

  • Abstraction 인터페이스 + Implementor 인터페이스 + 각각의 하위 클래스 계층 필요
  • 새로운 형태나 렌더러 추가 시 클래스 증가
  • 비동기 처리나 조건부 로직, 로깅, 에러 처리 등 추가 시 복잡성 증가

기존 C++ 스타일 구현 (전통적 방식)

예를 들어, Shape(추상)와 Renderer(구현) 분리:

#include <iostream>

// 전통적: Renderer 인터페이스
struct Renderer {
    virtual ~Renderer()=default;
    virtual void renderCircle(float x, float y, float radius)=0;
};

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

struct Shape {
    Renderer* renderer;
    Shape(Renderer* r):renderer(r){}
    virtual ~Shape()=default;
    virtual void draw()=0;
};

struct Circle : Shape {
    float x,y,radius;
    Circle(Renderer* r,float X,float Y,float R):Shape(r),x(X),y(Y),radius(R){}
    void draw() override {
        renderer->renderCircle(x,y,radius);
    }
};

int main() {
    RasterRenderer rr;
    Circle c(&rr,0,0,5);
    c.draw(); // Raster circle at (0,0), r=5
}

문제점:

  • Renderer 인터페이스, Circle 클래스로 상속 기반 구조 필요
  • 구현 추가 시 클래스 증가, 상속 계층 복잡
  • 비동기 처리, 조건부 렌더링, 로깅 등 기능 추가 시 장황해짐

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

1. Concepts로 Renderer 요구사항 정의

Renderer가 renderCircle(float x,float y,float radius) 호출 시 std::expected<void,std::string> 반환한다고 가정해 에러 처리 가능:

template<typename R>
concept RendererConcept = requires(R& r, float x,float y,float radius) {
    { r.renderCircle(x,y,radius) } -> std::convertible_to<std::expected<void,std::string>>;
};

2. 구현을 람다로 표현

Raster 렌더러를 람다로 정의:

auto RasterRendererLambda = [](float x,float y,float radius) -> std::expected<void,std::string> {
    if (radius<0) return std::unexpected("Invalid radius");
    std::cout << "Raster circle at ("<<x<<","<<y<<"), r="<<radius<<"\n";
    return {};
};

static_assert(RendererConcept<decltype(RasterRendererLambda)>);

3. 추상을 람다 합성으로 구현

Shape는 draw 호출 시 Renderer의 함수 호출. 상속 없이 shape를 함수 합성으로 구현할 수 있다:

#include <memory>

struct Shape2 {
    std::function<std::expected<void,std::string>(float,float,float)> rendererFunc;
    float x,y,radius;

    std::expected<void,std::string> draw() {
        return rendererFunc(x,y,radius);
    }
};

int main() {
    Shape2 c{RasterRendererLambda, 0,0,5};
    auto res = c.draw();
    if(!res) std::cerr << "Error: " << res.error() << "\n";
}

비교:

  • 전통적: Circle 클래스 상속 필요
  • C++20: Shape를 단순 구조체/클래스로 하고 rendererFunc를 함수로 주입, 상속 없이 구현

4. 조건부 렌더링, 비동기 처리

조건부 렌더링 추가:

auto conditionalRenderer = [&](auto baseRenderer) {
    return [=](float x,float y,float radius)->std::expected<void,std::string> {
        if(radius>10) return std::unexpected("Too large radius");
        return baseRenderer(x,y,radius);
    };
};

auto safeRaster = conditionalRenderer(RasterRendererLambda);
Shape2 c2{safeRaster, 0,0,20};
auto res2 = c2.draw(); // "Too large radius"
if(!res2) std::cerr << "Error: " << res2.error() << "\n";

비동기 구현 시 coroutine 사용 가능(가상 예):

// co_renderer(...)로 co_await 비동기 I/O 가능,
// draw 호출 시 coroutine으로 비동기 렌더

5. 로깅과 Ranges 파이프라인 적용

로깅 추가 예:

auto loggingRenderer = [&](auto baseRenderer) {
    return [=](float x,float y,float radius)->std::expected<void,std::string> {
        std::cout << std::format("[LOG] Rendering circle at {},{} radius {}\n", x,y,radius);
        auto res = baseRenderer(x,y,radius);
        if(!res) std::cout << std::format("[LOG] Error: {}\n", res.error());
        else std::cout << "[LOG] Success.\n";
        return res;
    };
};

auto loggedRaster = loggingRenderer(RasterRendererLambda);
Shape2 c3{loggedRaster, 1,1,3};
c3.draw(); // 로깅 및 렌더링 실행

여러 도형을 Ranges로 처리:

std::vector<Shape2> shapes = {
    {RasterRendererLambda, 0,0,5},
    {safeRaster, 2,2,20}, // 에러 발생
    {loggedRaster, 1,1,3}
};

for (auto res : shapes | std::views::transform([](auto& s){return s.draw();})) {
    if(!res) std::cerr << "Error: " << res.error() << "\n";
}

비교:

  • 전통적: 새로운 구현/조건부/비동기/로깅 기능 추가 시 하위 클래스 증폭
  • C++20: 람다 합성으로 기능 추가, coroutine, Ranges, std::expected로 다양한 요구 대응, 상속 없음

전통적 구현 vs C++11/14/17 vs C++20 이상 비교

전통적(C++98/03):

  • Abstraction, Implementor 인터페이스, 하위 클래스 상속 필요
  • 새로운 Abstraction/Implementor 추가 시 클래스 증가
  • 조건부 로직, 비동기 렌더, 로깅 등 복잡한 요구사항 구현 번거로움

C++11/14/17:

  • 람다, std::function 도입으로 일부 단순화 가능
  • Concepts, coroutine, std::expected, Ranges 미지원 → 정적 타입 제약, 비동기/에러 처리 개선 한계

C++20 이상(모던 C++):

  • Concepts로 구현(Implementor) 요구사항 정의, 상속 없이 타입 안전한 인터페이스 보장
  • 람다/함수 합성으로 Abstraction과 Implementation 연결, std::expected로 에러 처리 명확
  • std::format으로 로깅, coroutine으로 비동기 처리, Ranges로 다수 도형/렌더러 파이프라인 처리
  • 클래스 증가 없이 확장 가능, 유지보수성, 확장성, 타입 안전성 대폭 향상

결론

브리지(Bridge) 패턴은 추상화와 구현을 분리해 독립적으로 확장 가능하도록 하는 패턴이지만, 전통적 구현은 상속 기반으로 클래스 증가와 유지보수 어려움을 유발했습니다. C++20 이상에서는 Concepts, 람다, std::function, std::expected, std::format, coroutine, Ranges 등을 활용해 상속 없이도 추상과 구현을 쉽게 연결할 수 있으며, 조건부 처리, 비동기 처리, 로깅, 에러 처리 등 다양한 요구에도 쉽게 대응할 수 있습니다. 이를 통해 전통적 구현 대비 유지보수성과 확장성이 크게 향상됩니다.

반응형