이전 글에서는 추상 팩토리(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나 람다 기반 파이프라인 조합으로 어떻게 단순화할 수 있는지 살펴보겠습니다.