C/C++ 디자인 패턴 (7) — 브리지(Bridge)

도형(원, 사각형)을 SVG와 ASCII 두 방식으로 그려야 한다고 하자. 상속으로 조합하면 SvgCircle, AsciiCircle, SvgRect, AsciiRect처럼 도형 수 × 렌더러 수만큼 클래스가 생기고, 렌더러 하나를 추가할 때마다 도형 수만큼 클래스를 또 만들어야 한다. 브리지(Bridge)는 추상(무엇을 그리나)과 구현(어떻게 그리나)을 별도의 클래스 계층으로 나누고, 추상이 구현을 참조로 들고 있게 해서 두 계층이 독립적으로 늘어나게 한다. 이 글에서는 기본형과 실행 중 구현 교체, C++의 pimpl 관용구가 브리지라는 점(재컴파일 없이 구현 교체를 실측), 그리고 C의 불투명 포인터와 ops 테이블을 다룬다.

이 시리즈의 다른 글

상속으로 조합할 때

// 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 × 242 + 2 = 4
2 × 362 + 3 = 5
5 × 4205 + 4 = 9

구조

참여자역할예제
Abstraction클라이언트가 쓰는 상위 인터페이스, Implementor 참조 보관Shape
RefinedAbstractionAbstraction 확장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)를 살펴본다.

참고

답글 남기기