도형(원, 사각형)을 SVG와 ASCII 두 방식으로 그려야 한다고 하자. 상속으로 조합하면 SvgCircle, AsciiCircle, SvgRect, AsciiRect처럼 도형 수 × 렌더러 수만큼 클래스가 생기고, 렌더러 하나를 추가할 때마다 도형 수만큼 클래스를 또 만들어야 한다. 브리지(Bridge)는 추상(무엇을 그리나)과 구현(어떻게 그리나)을 별도의 클래스 계층으로 나누고, 추상이 구현을 참조로 들고 있게 해서 두 계층이 독립적으로 늘어나게 한다. 이 글에서는 기본형과 실행 중 구현 교체, C++의 pimpl 관용구가 브리지라는 점(재컴파일 없이 구현 교체를 실측), 그리고 C의 불투명 포인터와 ops 테이블을 다룬다.
이 시리즈의 다른 글
- C/C++ 디자인 패턴 (5) — 싱글톤(Singleton)
- C/C++ 디자인 패턴 (6) — 어댑터(Adapter)
- C/C++ 디자인 패턴 (8) — 컴포지트(Composite)
- C/C++ 디자인 패턴 (9) — 데코레이터(Decorator)
상속으로 조합할 때
// explosion.cpp — 도형 × 렌더러를 상속으로 조합하면 클래스가 곱으로 늘어난다
#include <iostream>
struct Shape { virtual ~Shape() = default; virtual void draw() const = 0; };
struct Circle : Shape {};
struct Rect : Shape {};
struct SvgCircle : Circle { void draw() const override { std::cout << "<circle r=\"5\"/>\n"; } };
struct AsciiCircle : Circle { void draw() const override { std::cout << "( o )\n"; } };
struct SvgRect : Rect { void draw() const override { std::cout << "<rect w=\"4\" h=\"2\"/>\n"; } };
struct AsciiRect : Rect { void draw() const override { std::cout << "[____]\n"; } };
// 렌더러 하나(Canvas)를 추가하면 CanvasCircle, CanvasRect ... 도형 수만큼 또 늘어난다
int main()
{
SvgCircle{}.draw();
AsciiRect{}.draw();
}$ g++ -std=c++20 -Wall -Wextra -O2 -o explosion explosion.cpp && ./explosion
<circle r="5"/>
[____]
| 도형 × 렌더러 | 상속 조합 클래스 수 | 브리지 클래스 수 |
|---|---|---|
| 2 × 2 | 4 | 2 + 2 = 4 |
| 2 × 3 | 6 | 2 + 3 = 5 |
| 5 × 4 | 20 | 5 + 4 = 9 |
구조
| 참여자 | 역할 | 예제 |
|---|---|---|
| Abstraction | 클라이언트가 쓰는 상위 인터페이스, Implementor 참조 보관 | Shape |
| RefinedAbstraction | Abstraction 확장 | Circle, Rect |
| Implementor | 구현 쪽 기본 연산 인터페이스 | Renderer |
| ConcreteImplementor | 실제 구현 | SvgRenderer, AsciiRenderer |
기본형과 실행 중 교체
// bridge.cpp — 추상(Shape)과 구현(Renderer)을 두 계층으로 나누고 참조로 잇는다
#include <iostream>
#include <memory>
#include <string>
struct Renderer { // Implementor
virtual ~Renderer() = default;
virtual void circle(double r) = 0;
virtual void rect(double w, double h) = 0;
};
struct SvgRenderer : Renderer {
void circle(double r) override { std::cout << "<circle r=\"" << r << "\"/>\n"; }
void rect(double w, double h) override { std::cout << "<rect w=\"" << w << "\" h=\"" << h << "\"/>\n"; }
};
struct AsciiRenderer : Renderer {
void circle(double r) override { std::cout << "(" << std::string(int(r), 'o') << ")\n"; }
void rect(double w, double) override { std::cout << "[" << std::string(int(w), '_') << "]\n"; }
};
class Shape { // Abstraction
public:
explicit Shape(Renderer &r) : r_(&r) {}
virtual ~Shape() = default;
virtual void draw() const = 0;
virtual void scale(double f) = 0;
void setRenderer(Renderer &r) { r_ = &r; } // 구현을 실행 중에 교체
protected:
Renderer *r_;
};
class Circle : public Shape { // RefinedAbstraction
public:
Circle(Renderer &r, double rad) : Shape(r), rad_(rad) {}
void draw() const override { r_->circle(rad_); }
void scale(double f) override { rad_ *= f; }
private:
double rad_;
};
class Rect : public Shape {
public:
Rect(Renderer &r, double w, double h) : Shape(r), w_(w), h_(h) {}
void draw() const override { r_->rect(w_, h_); }
void scale(double f) override { w_ *= f; h_ *= f; }
private:
double w_, h_;
};
int main()
{
SvgRenderer svg;
AsciiRenderer ascii;
Circle c(svg, 3);
Rect r(ascii, 4, 2);
c.draw();
r.draw();
c.scale(2); // 추상 쪽 변경
c.setRenderer(ascii); // 구현 쪽 변경 — 서로 독립
c.draw();
}$ g++ -std=c++20 -Wall -Wextra -O2 -o bridge bridge.cpp && ./bridge
<circle r="3"/>
[____]
(oooooo)
원을 두 배로 키운 것(추상 쪽 변경)과 렌더러를 ASCII로 바꾼 것(구현 쪽 변경)이 서로 영향을 주지 않는다. Implementor는 “원을 그려라” 같은 저수준 연산만 제공하고, 크기 조절 같은 상위 로직은 Abstraction에 남는다.
pimpl: 컴파일 방화벽으로서의 브리지
C++의 pimpl(pointer to implementation) 관용구는 Implementor가 하나뿐인 브리지다. 공개 헤더에는 전방 선언된 Impl을 가리키는 포인터만 두고, 실제 멤버는 .cpp에 숨긴다.
// widget.hpp — 공개 헤더: 구현 세부가 전혀 없다
#pragma once
#include <memory>
#include <string>
class Widget {
public:
explicit Widget(std::string name);
~Widget(); // Impl이 완전한 타입인 .cpp에서 정의
Widget(Widget &&) noexcept;
Widget &operator=(Widget &&) noexcept;
std::string render() const;
private:
struct Impl; // 전방 선언만
std::unique_ptr<Impl> impl_;
};// widget_v1.cpp — 구현 1
#include "widget.hpp"
struct Widget::Impl {
std::string name;
std::string render() const { return "v1:" + name; }
};
Widget::Widget(std::string name) : impl_(std::make_unique<Impl>(Impl{std::move(name)})) {}
Widget::~Widget() = default;
Widget::Widget(Widget &&) noexcept = default;
Widget &Widget::operator=(Widget &&) noexcept = default;
std::string Widget::render() const { return impl_->render(); }// client.cpp — 헤더만 보고 컴파일된다
#include <iostream>
#include "widget.hpp"
int main()
{
Widget w("button");
std::cout << w.render() << '\n' << w.render() << '\n'
<< "sizeof(Widget)=" << sizeof(Widget) << '\n';
}$ cd pimpl && F="-std=c++20 -Wall -Wextra -O2" && g++ $F -c client.cpp -o client.o && g++ $F -c widget_v1.cpp -o widget.o && g++ client.o widget.o -o app && ./app && md5sum client.o
v1:button
v1:button
sizeof(Widget)=8
f34c840018bbb4e2b7c027982ce57126 client.o
이제 구현을 멤버가 늘어난 v2로 바꾸고, client.o는 다시 컴파일하지 않은 채 링크만 다시 한다.
// widget_v2.cpp — 구현 2: 멤버가 늘고 내부 구조가 바뀌었다
#include <vector>
#include "widget.hpp"
struct Widget::Impl {
std::string name;
std::vector<std::string> cache; // 새 멤버
int renders = 0;
std::string render()
{
++renders;
cache.push_back(name);
return "v2:" + name + " (#" + std::to_string(renders) + ")";
}
};
Widget::Widget(std::string name) : impl_(std::make_unique<Impl>(Impl{std::move(name), {}, 0})) {}
Widget::~Widget() = default;
Widget::Widget(Widget &&) noexcept = default;
Widget &Widget::operator=(Widget &&) noexcept = default;
std::string Widget::render() const { return impl_->render(); }$ cd pimpl && F="-std=c++20 -Wall -Wextra -O2" && g++ $F -c widget_v2.cpp -o widget.o && g++ client.o widget.o -o app && ./app && md5sum client.o
v2:button (#1)
v2:button (#2)
sizeof(Widget)=8
f34c840018bbb4e2b7c027982ce57126 client.o
client.o의 해시가 같고 sizeof(Widget)도 8바이트 그대로다. 라이브러리라면 내부 구조를 바꿔도 사용자를 재컴파일시키지 않는(ABI 안정) 효과가 있다.
소멸자를 .cpp에서 정의하지 않으면, 컴파일러가 Impl이 불완전한 헤더 쪽에서 소멸자를 만들려다 실패한다.
// widget_bad.hpp — 소멸자를 선언하지 않으면 컴파일러가 헤더에서 암묵적으로 만든다
#pragma once
#include <memory>
class WidgetBad {
public:
WidgetBad();
private:
struct Impl;
std::unique_ptr<Impl> impl_;
};$ cd pimpl && g++ -std=c++20 -c client_bad.cpp 2>&1 | grep -E 'error' | head -3
/usr/include/c++/13/bits/unique_ptr.h:97:23: error: invalid application of ‘sizeof’ to incomplete type ‘WidgetBad::Impl’
C 구현: 불투명 포인터 + ops 테이블
C에서는 헤더에 struct storage;만 선언해 내부를 숨기고(추상), 실제 저장 방식은 함수 포인터 테이블(구현)로 고른다.
/* storage.h — 공개 API: 구조체 정의가 없는 불투명 핸들 */
#ifndef STORAGE_H
#define STORAGE_H
#include <stddef.h>
struct storage; /* 불투명 타입 */
struct storage *storage_open_memory(void);
struct storage *storage_open_file(const char *path);
int storage_put(struct storage *s, const char *key, const char *val);
const char *storage_backend(const struct storage *s);
void storage_close(struct storage *s);
#endif/* storage.c — 추상(storage)과 구현(backend ops)을 분리한 C 브리지 */
#include <stdio.h>
#include <stdlib.h>
#include "storage.h"
struct backend_ops { /* Implementor */
const char *name;
int (*put)(void *ctx, const char *k, const char *v);
void (*close)(void *ctx);
};
struct storage { /* Abstraction: 사용자는 내부를 모른다 */
const struct backend_ops *ops;
void *ctx;
int puts;
};
static int mem_put(void *ctx, const char *k, const char *v)
{
(void)ctx;
printf(" mem: %s=%s\n", k, v);
return 0;
}
static void mem_close(void *ctx) { (void)ctx; }
static const struct backend_ops mem_ops = { "memory", mem_put, mem_close };
static int file_put(void *ctx, const char *k, const char *v)
{
return fprintf(ctx, "%s=%s\n", k, v) < 0 ? -1 : 0;
}
static void file_close(void *ctx) { fclose(ctx); }
static const struct backend_ops file_ops = { "file", file_put, file_close };
static struct storage *storage_new(const struct backend_ops *ops, void *ctx)
{
struct storage *s = calloc(1, sizeof(*s));
if (!s)
return NULL;
s->ops = ops;
s->ctx = ctx;
return s;
}
struct storage *storage_open_memory(void) { return storage_new(&mem_ops, NULL); }
struct storage *storage_open_file(const char *path)
{
FILE *f = fopen(path, "w");
return f ? storage_new(&file_ops, f) : NULL;
}
int storage_put(struct storage *s, const char *k, const char *v)
{
s->puts++; /* 공통 로직은 추상 쪽에 */
return s->ops->put(s->ctx, k, v);
}
const char *storage_backend(const struct storage *s) { return s->ops->name; }
void storage_close(struct storage *s)
{
printf(" close %s after %d puts\n", s->ops->name, s->puts);
s->ops->close(s->ctx);
free(s);
}/* main.c — storage.h만 안다 */
#include <stdio.h>
#include "storage.h"
static void save(struct storage *s)
{
printf("[%s]\n", storage_backend(s));
storage_put(s, "user", "kim");
storage_put(s, "lang", "ko");
storage_close(s);
}
int main(void)
{
struct storage *m = storage_open_memory();
struct storage *f = storage_open_file("out.txt");
if (!m || !f)
return 1;
save(m);
save(f);
return 0;
}$ gcc -std=c11 -Wall -Wextra -O2 -fsanitize=address -o storage main.c storage.c && ./storage && cat out.txt
[memory]
mem: user=kim
mem: lang=ko
close memory after 2 puts
[file]
close file after 2 puts
user=kim
lang=ko
/* peek.c — 불투명 타입의 내부에 접근하려 하면 */
#include "storage.h"
int peek(struct storage *s) { return s->puts; }$ gcc -std=c11 -c peek.c 2>&1 | grep error
peek.c:3:39: error: invalid use of undefined type ‘struct storage’
호출 횟수 집계 같은 공통 로직은 추상 쪽(storage_put)에 있고, 백엔드는 저장만 한다. 리눅스 커널의 struct file과 struct file_operations가 같은 구조로, VFS는 공통 처리를 하고 실제 읽기·쓰기는 파일시스템별 ops가 맡는다.
어댑터·전략과 비교
| 패턴 | 언제 | 초점 |
|---|---|---|
| 어댑터 | 이미 있는 클래스를 나중에 맞출 때 | 인터페이스 불일치 해소 |
| 브리지 | 처음 설계할 때 두 변화 축을 분리 | 추상 계층과 구현 계층의 독립 확장 |
| 전략 | 알고리즘 하나를 교체 | 행위 교체(추상 쪽 계층이 없어도 됨) |
주의사항
- 변화 축이 정말 둘인지: 렌더러가 하나뿐이고 늘어날 계획도 없다면 간접층만 추가된다.
- Implementor 인터페이스는 저수준으로: Implementor에 상위 연산이 섞이면 새 구현마다 같은 로직을 반복하게 된다.
- 구현 객체의 수명: 예제의
Shape는Renderer를 포인터로 빌려 쓴다. 렌더러가 먼저 사라지면 댕글링이 되므로 소유 관계를 정해 둔다. - pimpl의 비용: 객체마다 힙 할당 하나와 간접 참조 한 단계가 추가된다. 작은 값 타입을 대량으로 만드는 곳에는 맞지 않는다.
- pimpl과 const:
unique_ptr은 const를 전파하지 않아, v2처럼render() const안에서Impl의 비const 멤버를 바꿀 수 있다. 의도하지 않았다면Impl함수에const를 붙여 스스로 막는다. - pimpl의 특수 멤버: 소멸자뿐 아니라 이동 생성자·이동 대입도
.cpp에서= default로 정의해야 한다. 복사가 필요하면Impl을 깊은 복사하는 복사 생성자를 직접 작성한다.
마무리
브리지는 “상속은 한 축만, 다른 축은 합성으로”라는 원칙을 구조로 만든 패턴이다. C++에서는 pimpl로 컴파일 의존성을 끊는 데 매일 쓰이고, C에서는 불투명 포인터와 ops 테이블이 그대로 브리지다. 다음 글에서는 부분과 전체를 같은 인터페이스로 다루는 컴포지트(Composite)를 살펴본다.
참고
- Erich Gamma 외, 『Design Patterns: Elements of Reusable Object-Oriented Software』(GoF), Bridge
- cppreference — PImpl
- Linux kernel docs — Overview of the Linux Virtual File System