C++ Bridge 패턴: 추상화와 구현을 분리해 클래스 폭발 막기 (렌더러·플랫폼 교체 예제)
이 글의 핵심
Bridge 패턴이 상속 조합 폭발을 어떻게 막는지, 렌더러 교체와 플랫폼 독립 설계 예제, 자주 생기는 문제와 프로덕션 패턴을 정리합니다.
Bridge 패턴이란? 왜 필요한가
구조 패턴 시리즈에서 Adapter·Composite 등과 나란히 보면 “추상과 구현 분리”가 어디에 해당하는지 구분하기 쉽습니다.
Shape × Renderer 상속 조합이 폭발하는 이유
문제: Shape(원, 사각형)와 Renderer(OpenGL, Vulkan)를 상속으로 조합하면 클래스가 폭발합니다.
// 나쁜 설계: 조합 폭발
class OpenGLCircle : public Shape { };
class VulkanCircle : public Shape { };
class OpenGLRectangle : public Shape { };
class VulkanRectangle : public Shape { };
// Shape 3개 × Renderer 2개 = 6개 클래스
// Color 추가 시 3 × 2 × 3 = 18개...
해결: Bridge 패턴은 추상화(Shape)와 구현(Renderer)을 분리합니다. Shape는 Renderer를 참조만 하며, 둘을 독립적으로 확장할 수 있습니다.
// 좋은 설계: Bridge
// 타입 정의
class Shape {
protected:
std::shared_ptr<Renderer> renderer_;
public:
explicit Shape(std::shared_ptr<Renderer> r) : renderer_(std::move(r)) {}
virtual void draw() = 0;
};
class Circle : public Shape {
void draw() override { renderer_->drawCircle(...); }
};
// Shape 추가 시 1개 클래스만, Renderer 추가 시 1개 클래스만
flowchart LR
client[Client]
abstraction["Abstraction<br/>(Shape)"]
implementor["Implementor<br/>(Renderer)"]
refined["RefinedAbstraction<br/>(Circle, Rectangle)"]
concrete["ConcreteImplementor<br/>(OpenGLRenderer, VulkanRenderer)"]
client --> abstraction
abstraction --> implementor
refined -.extends.-> abstraction
concrete -.implements.-> implementor
조합 폭발의 본질은 서로 무관한 두 가지 변화 축을 하나의 상속 계층에 욱여넣은 것입니다. “무엇을 그리는가(도형)“와 “어떻게 그리는가(그래픽 API)“는 독립적으로 바뀌는데, 상속은 한 방향으로만 가지를 칠 수 있으니 두 축의 곱만큼 클래스가 필요해집니다. Bridge는 한 축을 상속으로, 다른 축을 합성(멤버 포인터)으로 표현해서 곱을 합으로 바꿉니다. 도형 M개와 렌더러 N개라면 M×N개가 아니라 M+N개 클래스면 됩니다.
다이어그램에서 abstraction --> implementor 화살표가 이름의 유래인 “다리”입니다. 이 다리를 건너는 호출은 Implementor 인터페이스에 정의된 저수준 기본 연산(drawCircle, drawRect)뿐이고, “도형을 어디에 어떤 크기로 그릴지” 같은 고수준 로직은 Abstraction 쪽에 남습니다. 이 역할 분담이 흐려져서 Implementor 인터페이스에 drawCircleWithShadow 같은 고수준 함수가 쌓이기 시작하면, 새 렌더러를 추가할 때마다 구현할 함수가 늘어나 Bridge의 이점이 줄어듭니다.
Shape가 Renderer 포인터를 들고 위임하는 기본 구조
#include <memory>
#include <iostream>
// 구현부 인터페이스 (Implementor)
class Renderer {
public:
virtual void drawCircle(float x, float y, float r) = 0;
virtual void drawRect(float x, float y, float w, float h) = 0;
virtual ~Renderer() = default;
};
// 구체적 구현 1
class OpenGLRenderer : public Renderer {
public:
void drawCircle(float x, float y, float r) override {
std::cout << "[OpenGL] Circle at (" << x << "," << y << ") r=" << r << '\n';
}
void drawRect(float x, float y, float w, float h) override {
std::cout << "[OpenGL] Rect at (" << x << "," << y << ") " << w << "x" << h << '\n';
}
};
// 구체적 구현 2
class VulkanRenderer : public Renderer {
public:
void drawCircle(float x, float y, float r) override {
std::cout << "[Vulkan] Circle at (" << x << "," << y << ") r=" << r << '\n';
}
void drawRect(float x, float y, float w, float h) override {
std::cout << "[Vulkan] Rect at (" << x << "," << y << ") " << w << "x" << h << '\n';
}
};
// 추상화부 (Abstraction) — 구현을 참조
class Shape {
protected:
std::shared_ptr<Renderer> renderer_;
public:
explicit Shape(std::shared_ptr<Renderer> r) : renderer_(std::move(r)) {}
virtual void draw() = 0;
virtual ~Shape() = default;
};
class Circle : public Shape {
float x_, y_, r_;
public:
Circle(std::shared_ptr<Renderer> r, float x, float y, float radius)
: Shape(std::move(r)), x_(x), y_(y), r_(radius) {}
void draw() override { renderer_->drawCircle(x_, y_, r_); }
};
class Rectangle : public Shape {
float x_, y_, w_, h_;
public:
Rectangle(std::shared_ptr<Renderer> r, float x, float y, float w, float h)
: Shape(std::move(r)), x_(x), y_(y), w_(w), h_(h) {}
void draw() override { renderer_->drawRect(x_, y_, w_, h_); }
};
int main() {
auto gl = std::make_shared<OpenGLRenderer>();
auto vk = std::make_shared<VulkanRenderer>();
Circle c1(gl, 0, 0, 10);
Circle c2(vk, 5, 5, 3);
Rectangle r1(gl, 10, 10, 50, 30);
c1.draw(); // [OpenGL] Circle
c2.draw(); // [Vulkan] Circle
r1.draw(); // [OpenGL] Rect
return 0;
}
여기서 렌더러를 shared_ptr로 들고 있는 이유는 여러 도형이 같은 렌더러 하나를 공유하기 때문입니다. c1과 r1은 같은 gl 객체를 가리킵니다. 실제 그래픽 코드에서 렌더러는 GPU 컨텍스트, 셰이더, 버퍼 같은 무거운 자원을 들고 있어서 도형마다 새로 만들면 안 되므로 공유가 자연스럽습니다. 다만 렌더러의 수명이 도형들보다 확실히 길다는 보장이 있다면(예: 애플리케이션이 렌더러를 소유하고 종료 시에만 해제) Renderer& 참조나 원시 포인터로 받아 참조 카운트 비용과 “누가 마지막에 해제하는지 모르는” 문제를 피할 수도 있습니다.
draw()가 가상 함수를 두 번 거친다는 점(Shape::draw → Renderer::drawCircle)도 기억해 둘 만합니다. 도형 하나당 두 번의 간접 호출은 대부분 무시할 수준이지만, 수십만 개의 입자를 매 프레임 그리는 경우라면 도형 단위가 아니라 배치 단위(drawCircles(span<const Circle>))로 Implementor 인터페이스를 설계하는 편이 좋습니다. 가상 호출 비용보다 드로콜 수가 훨씬 큰 병목이기 때문입니다.
런타임에 OpenGL·Vulkan 렌더러 바꿔 끼우기
#include <memory>
#include <iostream>
class Renderer {
public:
virtual void render(const std::string& content) = 0;
virtual ~Renderer() = default;
};
class HTMLRenderer : public Renderer {
public:
void render(const std::string& content) override {
std::cout << "<html><body>" << content << "</body></html>\n";
}
};
class MarkdownRenderer : public Renderer {
public:
void render(const std::string& content) override {
std::cout << "# " << content << "\n";
}
};
class Document {
protected:
std::shared_ptr<Renderer> renderer_;
std::string content_;
public:
Document(std::shared_ptr<Renderer> r, std::string content)
: renderer_(std::move(r)), content_(std::move(content)) {}
void setRenderer(std::shared_ptr<Renderer> r) {
renderer_ = std::move(r);
}
virtual void display() = 0;
virtual ~Document() = default;
};
class Article : public Document {
public:
using Document::Document;
void display() override {
std::cout << "=== Article ===\n";
renderer_->render(content_);
}
};
int main() {
auto html = std::make_shared<HTMLRenderer>();
auto md = std::make_shared<MarkdownRenderer>();
Article article(html, "Hello World");
article.display(); // HTML 렌더링
article.setRenderer(md);
article.display(); // Markdown 렌더링
return 0;
}
핵심: 런타임에 setRenderer()로 구현을 교체할 수 있습니다.
런타임 교체는 편리하지만 두 가지를 신경 써야 합니다. 첫째, 멀티스레드 환경에서의 교체입니다. 한 스레드가 display() 안에서 renderer_->render()를 호출하는 동안 다른 스레드가 setRenderer()로 renderer_를 바꾸면, shared_ptr 객체 하나를 동시에 읽고 쓰는 데이터 레이스가 됩니다. shared_ptr의 참조 카운트는 스레드 안전하지만 같은 shared_ptr 인스턴스에 대한 동시 대입은 안전하지 않습니다. 교체가 동시에 일어날 수 있다면 뮤텍스로 보호하거나 C++20 std::atomic<std::shared_ptr<T>>를 써야 합니다. 둘째, 상태 이전입니다. 이 예제의 렌더러는 상태가 없어서 바꿔 끼우기만 하면 되지만, 실제 렌더러가 캐시나 로드된 리소스를 들고 있다면 교체 시 무엇을 새로 만들고 무엇을 버릴지 정해야 합니다.
“렌더러를 런타임에 바꾼다”는 설명만 보면 Strategy 패턴과 구분이 어렵습니다. 코드 모양은 거의 같고, 차이는 의도에 있습니다. Strategy는 알고리즘 하나(정렬 방식, 압축 방식)를 갈아 끼우는 것이 목적이고 교체되는 쪽이 하나의 함수에 가깝습니다. Bridge는 추상화 쪽도 Article, Report처럼 자기만의 계층으로 확장된다는 것이 전제입니다. 추상화 쪽에 하위 클래스가 하나뿐이라면 사실상 Strategy라고 보면 됩니다.
크로스 플랫폼 파일 시스템을 Bridge로 설계하기
#include <memory>
#include <iostream>
#include <string>
// 구현부: 플랫폼별 파일 연산
class FileSystemImpl {
public:
virtual bool exists(const std::string& path) = 0;
virtual std::string read(const std::string& path) = 0;
virtual void write(const std::string& path, const std::string& data) = 0;
virtual ~FileSystemImpl() = default;
};
class WindowsFileSystem : public FileSystemImpl {
public:
bool exists(const std::string& path) override {
std::cout << "[Windows] Checking: " << path << '\n';
return true;
}
std::string read(const std::string& path) override {
return "[Windows] File content";
}
void write(const std::string& path, const std::string& data) override {
std::cout << "[Windows] Writing to " << path << '\n';
}
};
class LinuxFileSystem : public FileSystemImpl {
public:
bool exists(const std::string& path) override {
std::cout << "[Linux] Checking: " << path << '\n';
return true;
}
std::string read(const std::string& path) override {
return "[Linux] File content";
}
void write(const std::string& path, const std::string& data) override {
std::cout << "[Linux] Writing to " << path << '\n';
}
};
// 추상화부: 플랫폼 독립적 API
class File {
protected:
std::shared_ptr<FileSystemImpl> fs_;
std::string path_;
public:
File(std::shared_ptr<FileSystemImpl> fs, std::string path)
: fs_(std::move(fs)), path_(std::move(path)) {}
bool exists() { return fs_->exists(path_); }
std::string read() { return fs_->read(path_); }
void write(const std::string& data) { fs_->write(path_, data); }
};
class ConfigFile : public File {
public:
using File::File;
void load() {
if (exists()) {
std::cout << "Config loaded: " << read() << '\n';
}
}
};
int main() {
#ifdef _WIN32
auto fs = std::make_shared<WindowsFileSystem>();
#else
auto fs = std::make_shared<LinuxFileSystem>();
#endif
ConfigFile config(fs, "/etc/app.conf");
config.load();
return 0;
}
핵심: 컴파일 타임에 플랫폼 구현을 선택하며, 추상화 계층은 동일한 API를 제공합니다.
플랫폼 구현이 컴파일 시점에 하나로 정해진다면 가상 함수를 쓰는 런타임 Bridge가 꼭 필요하지는 않습니다. 한 번 빌드된 바이너리에서 Windows 구현과 Linux 구현이 동시에 쓰일 일은 없기 때문입니다. 이런 경우 흔히 쓰는 대안은 두 가지입니다. 하나는 헤더에 공통 인터페이스만 선언하고, file_win.cpp와 file_linux.cpp 중 하나만 빌드 시스템에서 골라 컴파일하는 방식입니다. 가상 호출이 없고 #ifdef가 소스 곳곳에 흩어지지 않습니다. 다른 하나는 Pimpl 관용구로 구현 세부를 .cpp에 숨기는 방식인데, Pimpl 글에서 자세히 다룹니다.
그럼에도 이 예제처럼 인터페이스로 분리해 두는 가치는 테스트에 있습니다. FileSystemImpl을 인터페이스로 두면 단위 테스트에서 실제 디스크 대신 메모리에 파일을 흉내 내는 FakeFileSystem을 주입할 수 있습니다. 저는 실무에서 플랫폼 교체보다 이 테스트 목적 때문에 Bridge 형태를 택하는 경우가 더 많았습니다. 참고로 실제 파일 시스템 추상화가 필요하다면 C++17 std::filesystem이 이미 플랫폼 차이를 대부분 흡수해 줍니다.
역방향 참조·구체 타입 누수·과한 분리
Renderer가 Shape를 참조하는 순환 의존
// ❌ 나쁜 예: Renderer가 Shape를 참조
class Renderer {
std::vector<Shape*> shapes_; // 순환 의존!
};
해결: 구현부(Implementor)는 추상화부(Abstraction)를 알면 안 됩니다. 단방향 의존만 유지하세요.
이런 역방향 참조는 보통 “렌더러가 그릴 목록을 관리하면 편하겠다”는 생각에서 생깁니다. 하지만 그 순간 Renderer.h가 Shape.h를 include해야 하고, 새 도형을 추가하면 모든 렌더러를 다시 컴파일해야 하며, 헤더끼리 서로를 include하면 “incomplete type” 에러를 전방 선언으로 땜질하게 됩니다. 그릴 목록이 필요하다면 도형도 렌더러도 아닌 제3의 객체(씬, 드로 큐)가 관리하게 하고, 렌더러는 좌표와 크기 같은 기본 데이터만 받게 하세요.
// ✅ 좋은 예: Shape만 Renderer를 참조
class Shape {
std::shared_ptr<Renderer> renderer_; // 단방향
};
추상화 쪽이 구체 Renderer 타입에 의존
// ❌ 나쁜 예: 추상화가 구체 타입에 의존
class Circle : public Shape {
OpenGLRenderer* gl_; // 구체 타입!
};
해결: 추상화는 Implementor 인터페이스만 알아야 합니다.
// ✅ 좋은 예: 기반 클래스 Shape가 가진 renderer_(Renderer 인터페이스)만 사용
class Circle : public Shape {
void draw() override { renderer_->drawCircle(x_, y_, r_); }
};
구체 타입이 새어 들어오는 전형적인 경로는 “OpenGL에서만 되는 기능을 잠깐 쓰려고” dynamic_cast<OpenGLRenderer*>를 하는 것입니다. 한 번 들어오면 그 도형은 Vulkan 렌더러에서 조용히 기능이 빠지거나 크래시가 납니다. 특정 구현에만 있는 기능이 정말 필요하다면 Implementor 인터페이스에 supportsFeature() 같은 질의 함수를 두고, 지원하지 않는 구현은 대체 경로를 쓰게 만드는 편이 낫습니다.
구현이 하나뿐인데 Bridge를 도입함
간단한 경우 Bridge는 과도합니다.
// ❌ 과도한 설계: 구현이 1개뿐
class Logger {
std::shared_ptr<LoggerImpl> impl_; // 불필요
};
해결: 구현이 2개 이상 필요하거나, 플랫폼/드라이버 교체가 예상될 때만 Bridge를 쓰세요.
“나중에 바뀔지도 모르니 미리 분리해 두자”는 판단은 대부분 빗나갑니다. 실제로 두 번째 구현이 생길 때쯤이면 요구사항이 처음 예상과 달라서, 미리 만들어 둔 인터페이스가 맞지 않아 결국 다시 설계하게 되는 경우가 많습니다. 인터페이스 하나를 추출하는 리팩터링은 그리 어렵지 않으니, 두 번째 구현이 실제로 필요해진 시점에 분리하는 편이 대개 더 좋은 인터페이스를 만듭니다. 예외는 테스트 대역(fake)이 필요한 경우로, 이때는 “두 번째 구현”이 처음부터 존재하는 셈입니다.
팩토리와 의존성 주입으로 구현 선택하기
설정 문자열로 렌더러를 만드는 팩토리
class RendererFactory {
public:
static std::shared_ptr<Renderer> create(const std::string& type) {
if (type == "opengl") return std::make_shared<OpenGLRenderer>();
if (type == "vulkan") return std::make_shared<VulkanRenderer>();
return nullptr;
}
};
int main() {
auto renderer = RendererFactory::create("opengl");
Circle c(renderer, 0, 0, 10);
c.draw();
}
이 팩토리는 알 수 없는 타입에 nullptr을 반환합니다. 설정 파일에 “OpenGL”처럼 대소문자만 다르게 적혀 있어도 nullptr이 도형에 들어가고, 한참 뒤 draw()에서 segfault가 납니다. 실패 지점과 증상 지점이 멀어지는 전형적인 패턴이므로, 팩토리에서 예외를 던지거나 std::optional/std::expected로 실패를 명시하고, Shape 생성자에서도 nullptr을 거부하는 편이 안전합니다.
생성자로 렌더러를 주입받는 Application
class Application {
std::shared_ptr<Renderer> renderer_;
public:
Application(std::shared_ptr<Renderer> r) : renderer_(std::move(r)) {}
void run() {
Circle c(renderer_, 0, 0, 10);
c.draw();
}
};
int main() {
auto renderer = std::make_shared<OpenGLRenderer>();
Application app(renderer); // DI
app.run();
}
크로스 플랫폼 윈도우 시스템 만들기
#include <memory>
#include <iostream>
#include <string>
// 구현부: 플랫폼별 윈도우 생성
class WindowImpl {
public:
virtual void createWindow(const std::string& title, int w, int h) = 0;
virtual void show() = 0;
virtual void hide() = 0;
virtual ~WindowImpl() = default;
};
class Win32Window : public WindowImpl {
std::string title_;
public:
void createWindow(const std::string& title, int w, int h) override {
title_ = title;
std::cout << "[Win32] CreateWindow: " << title << " " << w << "x" << h << '\n';
}
void show() override { std::cout << "[Win32] ShowWindow: " << title_ << '\n'; }
void hide() override { std::cout << "[Win32] HideWindow: " << title_ << '\n'; }
};
class X11Window : public WindowImpl {
std::string title_;
public:
void createWindow(const std::string& title, int w, int h) override {
title_ = title;
std::cout << "[X11] XCreateWindow: " << title << " " << w << "x" << h << '\n';
}
void show() override { std::cout << "[X11] XMapWindow: " << title_ << '\n'; }
void hide() override { std::cout << "[X11] XUnmapWindow: " << title_ << '\n'; }
};
// 추상화부: 플랫폼 독립적 Window API
class Window {
protected:
std::shared_ptr<WindowImpl> impl_;
std::string title_;
int width_, height_;
public:
Window(std::shared_ptr<WindowImpl> impl, std::string title, int w, int h)
: impl_(std::move(impl)), title_(std::move(title)), width_(w), height_(h) {
impl_->createWindow(title_, width_, height_);
}
virtual void open() { impl_->show(); }
virtual void close() { impl_->hide(); }
virtual ~Window() = default;
};
class DialogWindow : public Window {
public:
using Window::Window;
void open() override {
std::cout << "Opening dialog...\n";
Window::open();
}
};
class MainWindow : public Window {
public:
using Window::Window;
void open() override {
std::cout << "Opening main window...\n";
Window::open();
}
};
int main() {
#ifdef _WIN32
auto impl = std::make_shared<Win32Window>();
#else
auto impl = std::make_shared<X11Window>();
#endif
MainWindow mainWin(impl, "My App", 800, 600);
mainWin.open();
DialogWindow dialog(impl, "Settings", 400, 300);
dialog.open();
dialog.close();
return 0;
}
출력 (Windows):
[Win32] CreateWindow: My App 800x600
Opening main window...
[Win32] ShowWindow: My App
[Win32] CreateWindow: Settings 400x300
Opening dialog...
[Win32] ShowWindow: Settings
[Win32] HideWindow: Settings
이 예제에는 Bridge를 처음 적용할 때 흔히 만드는 버그가 숨어 있습니다. mainWin과 dialog가 같은 impl 객체를 공유하는데, Win32Window는 title_이라는 상태를 가집니다. dialog를 생성하는 순간 createWindow가 title_을 “Settings”로 덮어쓰므로, 그 뒤에 mainWin.open()을 다시 호출하면 “[Win32] ShowWindow: Settings”가 출력됩니다. 위 출력이 정상으로 보이는 것은 메인 창을 대화상자 생성 전에 열었기 때문일 뿐입니다. 실제 Win32라면 title_ 대신 창 핸들(HWND)이 덮어쓰여, 대화상자를 닫으려다 메인 창이 사라지는 식의 버그가 됩니다.
원인은 이 Implementor가 앞의 렌더러와 달리 창 하나에 대응하는 상태를 가진 객체라는 데 있습니다. 상태가 없는 구현(렌더러, 파일 시스템 연산)은 공유해도 되지만, 상태를 가진 구현은 추상화 객체마다 하나씩 있어야 합니다. 이 경우 Window가 std::unique_ptr<WindowImpl>로 구현을 소유하게 하고, 생성 시 팩토리 함수(makePlatformWindowImpl())로 새 구현을 받도록 바꾸는 것이 맞습니다. shared_ptr을 기본값처럼 쓰면 이런 공유 여부 판단을 건너뛰게 되므로, 소유 관계를 먼저 정하고 그에 맞는 스마트 포인터를 고르는 습관이 중요합니다.
생성자 안에서 impl_->createWindow()를 호출하는 부분도 주의가 필요합니다. 이 호출 자체는 impl_의 가상 함수라 문제가 없지만, Window 생성자에서 Window 자신의 가상 함수(open() 등)를 호출하면 파생 클래스의 오버라이드가 아니라 Window 버전이 호출됩니다. 생성 중에는 객체가 아직 파생 클래스가 아니기 때문입니다.
Bridge 패턴 요약
| 항목 | 설명 |
|---|---|
| 목적 | 추상화와 구현을 분리해 둘을 독립적으로 확장 |
| 장점 | 플랫폼·드라이버 교체 용이, 상속 조합 폭발 방지, 런타임 구현 교체 가능 |
| 단점 | 클래스 수 증가, 설계 복잡도, 간단한 경우 과도할 수 있음 |
| 사용 시기 | 플랫폼/렌더러/드라이버가 2개 이상, 런타임 교체 필요, 조합 폭발 방지 |
관련 글: Adapter 패턴, Decorator 패턴, Proxy 패턴, Strategy 패턴, Facade 패턴. Bridge 패턴으로 렌더러·플랫폼·드라이버를 독립적으로 확장하고 런타임에 교체할 수 있습니다.
같이 보면 좋은 글
- C++ Adapter 패턴
- C++ Decorator 패턴
- C++ Proxy 패턴
- C++ Strategy 패턴
- C++ Composite 패턴
- C++ Facade 패턴
- C++ Flyweight 패턴
- C++ 디자인 패턴 | Adapter·Decorator
- 배열과 연결 리스트
자주 묻는 질문 (FAQ)
Q. Bridge 패턴에서 구현 쪽(Renderer)이 추상화 쪽(Shape)을 참조하면 왜 문제가 되나요?
A. Bridge 패턴의 목적은 추상화 계층과 구현 계층을 서로 독립적으로 늘릴 수 있게 하는 것인데, 구현 쪽이 추상화 타입을 알게 되면 두 계층이 서로를 참조하는 순환 의존이 생깁니다. 그러면 새 Shape를 추가할 때 Renderer도 함께 수정해야 해서 패턴을 쓰는 의미가 사라집니다. Shape가 Renderer 인터페이스를 소유하거나 참조하고, Renderer는 그리기에 필요한 기본 연산만 제공하는 단방향 의존을 유지해야 합니다.