C++ 디자인 패턴 | Adapter·Decorator
들어가며: 기존 코드를 수정할 수 없다
외부 라이브러리의 인터페이스가 우리 코드와 맞지 않는데 그 라이브러리를 고칠 수는 없는 경우, 또는 플랫폼(Windows/Linux)과 도형 종류(원/사각형)의 조합마다 클래스를 만들다 보니 클래스 수가 곱으로 늘어나는 경우가 있습니다. 구조 패턴은 기존 타입을 감싸거나 조합해서 우리가 원하는 인터페이스로 쓰게 해 주는 패턴들입니다.
이 글에서 다루는 다섯 가지는 목적이 각각 다릅니다. Adapter는 외부 API를 우리 인터페이스에 맞추고, Decorator는 같은 인터페이스를 유지한 채 동작을 한 겹씩 덧붙입니다. Proxy는 같은 인터페이스 뒤에서 접근 제어나 지연 로딩을 넣고, Bridge는 추상(무엇을 할지)과 구현(어떻게 할지)을 분리해 따로 확장하게 하며, Composite는 폴더·파일 같은 트리를 하나의 인터페이스로 다루게 합니다. 생성·행동 패턴과의 관계는 생성 패턴 가이드, 행동 패턴 가이드와 함께 보면 전체 그림이 잡힙니다. JavaScript에서는 프록시·데코레이터 등을 다른 문법으로 다룹니다. 구조 패턴 역할을 한눈에 보면 아래와 같습니다.
flowchart LR
subgraph A[Adapter]
A1[기존 API] --> A2[우리 인터페이스로 감싸기]
end
subgraph D[Decorator]
D1[동일 인터페이스] --> D2[기능 단계적 추가]
end
subgraph P[Proxy]
P1[대리 객체] --> P2[접근 제어·지연 로딩]
end
subgraph B[Bridge]
B1[추상] --> B2[구현 분리]
end
subgraph C[Composite]
C1[트리 구조] --> C2[부분-전체 동일 처리]
end
증상별로 맞는 구조 패턴 찾기
| 시나리오 | 증상 | 적합한 패턴 |
|---|---|---|
| 레거시 통합 | 써드파티 로거가 writeLog(int, char*)만 제공하는데 우리는 log(string)으로 통일하고 싶음 | Adapter |
| 기능 확장 | HTTP 클라이언트에 재시도·로깅·캐싱을 조합해서 붙이고 싶음 | Decorator |
| 지연 로딩 | 대용량 이미지 100장을 리스트에 넣을 때 전부 로딩되면 메모리 부족 | Proxy |
| 권한 제어 | 문서 객체는 그대로 두며, 역할에 따라 edit()만 막고 싶음 | Proxy |
| 다중 라이브러리 | VLC, FFmpeg, GStreamer 등 서로 다른 API를 MediaPlayer::play()로 통일 | Adapter |
우리 쪽은 MediaPlayer::play(filename) 형태로 쓰고 싶은데, 외부 라이브러리(VLC)는 playVLC(file)만 제공합니다. 라이브러리 소스를 수정할 수 없다면, 우리 인터페이스를 구현한 Adapter 클래스가 내부적으로 VLC를 감싸고 play() 안에서 playVLC()를 호출해 주면 됩니다.
// 우리 인터페이스
class MediaPlayer {
public:
virtual void play(const std::string& filename) = 0;
};
// 외부 라이브러리 (수정 불가)
class VLCPlayer {
public:
void playVLC(const std::string& file) {
std::cout << "Playing VLC: " << file << "\n";
}
};
// ❌ 인터페이스가 맞지 않음
// MediaPlayer* player = new VLCPlayer(); // 에러!
VLCAdapter는 MediaPlayer 인터페이스를 구현하면서 내부에 VLCPlayer 인스턴스를 갖습니다. 클라이언트는 MediaPlayer*만 보므로 player->play("movie.mp4")로 통일해서 쓸 수 있으며, Adapter가 내부에서 vlc.playVLC(filename)으로 변환해 줍니다. 이렇게 하면 외부 라이브러리 코드는 건드리지 않고 우리 인터페이스만 맞출 수 있습니다.
// 복사해 붙여넣은 뒤: g++ -std=c++17 -o adapter_demo adapter_demo.cpp && ./adapter_demo
#include <iostream>
#include <string>
#include <memory>
class MediaPlayer {
public:
virtual ~MediaPlayer() = default;
virtual void play(const std::string& filename) = 0;
};
class VLCPlayer {
public:
void playVLC(const std::string& file) {
std::cout << "Playing VLC: " << file << "\n";
}
};
class VLCAdapter : public MediaPlayer {
VLCPlayer vlc;
public:
void play(const std::string& filename) override {
vlc.playVLC(filename);
}
};
int main() {
std::unique_ptr<MediaPlayer> player = std::make_unique<VLCAdapter>();
player->play("movie.mp4");
return 0;
}
실행하면 Playing VLC: movie.mp4 한 줄이 출력됩니다.
Adapter: 맞지 않는 인터페이스 감싸기
Class Adapter (상속)
Class Adapter는 Target(우리가 원하는 인터페이스)을 public 상속하고, Adaptee(기존 비호환 클래스)는 private 상속합니다. private 상속은 “is-a”가 아니라 “Adaptee를 이용해 구현한다(implemented-in-terms-of)“는 뜻이라서, 외부에서는 Adapter를 Adaptee로 변환할 수 없고 Target으로만 쓸 수 있습니다. request() 안에서 물려받은 specificRequest()를 호출해 결과를 넘깁니다. Adaptee의 protected 멤버에 접근하거나 가상 함수를 오버라이드해야 할 때는 이 방식이 필요하지만, 다중 상속이 얽히기 쉬워 보통은 다음의 구성 방식을 씁니다.
// 기존 인터페이스
class Target {
public:
virtual ~Target() = default;
virtual std::string request() = 0;
};
// 호환되지 않는 클래스
class Adaptee {
public:
std::string specificRequest() {
return "Adaptee's specific request";
}
};
// Adapter
class Adapter : public Target, private Adaptee {
public:
std::string request() override {
return specificRequest(); // 인터페이스 변환
}
};
// 사용
Target* target = new Adapter();
std::cout << target->request() << "\n";
Object Adapter (구성)
Object Adapter는 Adaptee를 상속하지 않고 포인터나 참조로 갖는 방식입니다. 생성자에서 기존 객체를 받아 두며, request() 호출 시 그 객체의 specificRequest()를 호출해 결과만 변환해 줍니다. Adaptee를 런타임에 바꿀 수 있으며, 상속 구조가 단순해져서 실무에서 더 자주 사용됩니다.
class Adapter : public Target {
Adaptee* adaptee;
public:
Adapter(Adaptee* a) : adaptee(a) {}
std::string request() override {
return adaptee->specificRequest();
}
};
// 사용
Adaptee adaptee;
Target* target = new Adapter(&adaptee);
std::cout << target->request() << "\n";
Adapter 시퀀스 다이어그램
sequenceDiagram
participant Client
participant Adapter
participant Adaptee
Client->>Adapter: request()
Adapter->>Adaptee: specificRequest()
Adaptee-->>Adapter: 결과
Adapter-->>Client: 변환된 결과
실전 예제: 로깅 어댑터
우리 코드는 Logger::log(std::string) 형태로 통일하고 싶은데, 써드파티 로거는 writeLog(int level, const char* msg)만 제공할 수 있습니다. LoggerAdapter가 우리 Logger 인터페이스를 구현하면서 내부에 써드파티 로거를 두며, log(message) 안에서 writeLog(1, message.c_str())처럼 레벨을 고정해 호출하면, 나머지 코드는 Logger*만 사용하면 됩니다.
// 우리 인터페이스
class Logger {
public:
virtual ~Logger() = default;
virtual void log(const std::string& message) = 0;
};
// 외부 라이브러리
class ThirdPartyLogger {
public:
void writeLog(int level, const char* msg) {
std::cout << "[Level " << level << "] " << msg << "\n";
}
};
// Adapter
class LoggerAdapter : public Logger {
ThirdPartyLogger* thirdParty;
public:
LoggerAdapter(ThirdPartyLogger* logger) : thirdParty(logger) {}
void log(const std::string& message) override {
thirdParty->writeLog(1, message.c_str());
}
};
// 사용
ThirdPartyLogger thirdParty;
Logger* logger = new LoggerAdapter(&thirdParty);
logger->log("Application started");
완전한 Adapter 예제: JSON 파서 통합
여러 JSON 라이브러리(nlohmann/json, RapidJSON, Qt Json 등)를 우리 DataStore::load(const std::string&) 인터페이스로 통일하는 예제입니다.
#include <iostream>
#include <string>
#include <memory>
// 우리 애플리케이션의 공통 인터페이스
class DataStore {
public:
virtual ~DataStore() = default;
virtual std::string load(const std::string& path) = 0;
};
// 외부 라이브러리 (다른 API 시그니처)
class LegacyJsonParser {
public:
std::string parseFile(const char* filepath) {
// 실제로는 파일 읽기 + 파싱
return std::string("{\"data\": \"from ") + filepath + "\"}";
}
};
// Adapter: LegacyJsonParser를 DataStore 인터페이스로 감싸기
class JsonParserAdapter : public DataStore {
std::unique_ptr<LegacyJsonParser> parser;
public:
JsonParserAdapter() : parser(std::make_unique<LegacyJsonParser>()) {}
std::string load(const std::string& path) override {
return parser->parseFile(path.c_str());
}
};
int main() {
std::unique_ptr<DataStore> store = std::make_unique<JsonParserAdapter>();
std::cout << store->load("config.json") << "\n";
return 0;
}
Decorator: 기능을 겹겹이 덧씌우기
기본 Decorator
커피를 기본으로 두며, 우유·설탕 같은 옵션을 “겹쳐 씌우는” 구조입니다. Coffee가 공통 인터페이스(getDescription, cost)를 정의하며, CoffeeDecorator는 다른 Coffee*를 한 개 들고 자신의 설명·가격을 그 위에 더합니다. MilkDecorator(coffee)는 “기존 커피 + 우유”가 되고, 그 결과를 다시 SugarDecorator로 감싸면 “기본 + 우유 + 설탕”이 됩니다. 상속으로 모든 조합을 만드는 것보다 유연합니다.
// Component
class Coffee {
public:
virtual ~Coffee() = default;
virtual std::string getDescription() = 0;
virtual double cost() = 0;
};
// Concrete Component
class SimpleCoffee : public Coffee {
public:
std::string getDescription() override {
return "Simple Coffee";
}
double cost() override {
return 2.0;
}
};
// Decorator
class CoffeeDecorator : public Coffee {
protected:
Coffee* coffee;
public:
CoffeeDecorator(Coffee* c) : coffee(c) {}
virtual ~CoffeeDecorator() { delete coffee; }
};
// Concrete Decorators
class MilkDecorator : public CoffeeDecorator {
public:
MilkDecorator(Coffee* c) : CoffeeDecorator(c) {}
std::string getDescription() override {
return coffee->getDescription() + ", Milk";
}
double cost() override {
return coffee->cost() + 0.5;
}
};
class SugarDecorator : public CoffeeDecorator {
public:
SugarDecorator(Coffee* c) : CoffeeDecorator(c) {}
std::string getDescription() override {
return coffee->getDescription() + ", Sugar";
}
double cost() override {
return coffee->cost() + 0.2;
}
};
// 사용
Coffee* coffee = new SimpleCoffee();
coffee = new MilkDecorator(coffee);
coffee = new SugarDecorator(coffee);
std::cout << coffee->getDescription() << "\n"; // Simple Coffee, Milk, Sugar
std::cout << "Cost: $" << coffee->cost() << "\n"; // $2.7
delete coffee;
Decorator 체인 구조
flowchart TB
subgraph Client
C[Client]
end
C --> D3[SugarDecorator]
D3 --> D2[MilkDecorator]
D2 --> D1[SimpleCoffee]
스마트 포인터 사용
위 예제처럼 Decorator가 Coffee*를 직접 소유하면, 가장 바깥에서 delete coffee 한 번만 해도 내부 체인이 연쇄적으로 소멸해야 해서 소유권이 헷갈릴 수 있습니다. std::unique_ptrdelete를 호출할 필요가 없어 누수와 이중 해제를 줄일 수 있습니다. std::move(coffee)로 넘기면 소유권이 다음 Decorator로 이전됩니다.
class Coffee {
public:
virtual ~Coffee() = default;
virtual std::string getDescription() = 0;
virtual double cost() = 0;
};
class CoffeeDecorator : public Coffee {
protected:
std::unique_ptr<Coffee> coffee;
public:
CoffeeDecorator(std::unique_ptr<Coffee> c) : coffee(std::move(c)) {}
};
class MilkDecorator : public CoffeeDecorator {
public:
MilkDecorator(std::unique_ptr<Coffee> c) : CoffeeDecorator(std::move(c)) {}
std::string getDescription() override {
return coffee->getDescription() + ", Milk";
}
double cost() override {
return coffee->cost() + 0.5;
}
};
// 사용 (SugarDecorator도 같은 방식으로 정의했다고 가정)
std::unique_ptr<Coffee> coffee = std::make_unique<SimpleCoffee>();
coffee = std::make_unique<MilkDecorator>(std::move(coffee));
coffee = std::make_unique<SugarDecorator>(std::move(coffee));
변수 타입을 std::unique_ptr<Coffee>로 명시하는 것이 중요합니다. auto coffee = std::make_unique<SimpleCoffee>();로 쓰면 타입이 std::unique_ptr<SimpleCoffee>가 되어, 다음 줄에서 std::unique_ptr<MilkDecorator>를 대입할 수 없어 컴파일 오류가 납니다.
실전 예제: HTTP 클라이언트 Decorator
HTTP 요청에 재시도·로깅·압축을 조합해서 붙이는 예제입니다.
#include <iostream>
#include <string>
#include <memory>
class HttpClient {
public:
virtual ~HttpClient() = default;
virtual std::string get(const std::string& url) = 0;
};
class RealHttpClient : public HttpClient {
public:
std::string get(const std::string& url) override {
return "Response from " + url;
}
};
class HttpClientDecorator : public HttpClient {
protected:
std::unique_ptr<HttpClient> client;
public:
HttpClientDecorator(std::unique_ptr<HttpClient> c) : client(std::move(c)) {}
};
class LoggingDecorator : public HttpClientDecorator {
public:
using HttpClientDecorator::HttpClientDecorator;
std::string get(const std::string& url) override {
std::cout << "[LOG] GET " << url << "\n";
auto result = client->get(url);
std::cout << "[LOG] Response length: " << result.size() << "\n";
return result;
}
};
class RetryDecorator : public HttpClientDecorator {
int maxRetries;
public:
RetryDecorator(std::unique_ptr<HttpClient> c, int retries = 3)
: HttpClientDecorator(std::move(c)), maxRetries(retries) {}
std::string get(const std::string& url) override {
for (int i = 0; i < maxRetries; ++i) {
try {
return client->get(url);
} catch (...) {
if (i == maxRetries - 1) throw;
std::cout << "[RETRY] Attempt " << (i+2) << "\n";
}
}
return "";
}
};
int main() {
std::unique_ptr<HttpClient> client = std::make_unique<RealHttpClient>();
client = std::make_unique<LoggingDecorator>(std::move(client));
client = std::make_unique<RetryDecorator>(std::move(client), 3); // 바깥: Retry → Logging → Real
std::cout << client->get("https://api.example.com/data") << "\n";
return 0;
}
Proxy: 지연 로딩, 접근 제어, 원격 호출
Virtual Proxy (지연 로딩)
RealImage는 생성 시점에 loadFromDisk()로 파일을 읽어서 비용이 큽니다. ImageProxy는 파일명만 들고 있으며, display()가 처음 호출될 때만 RealImage를 생성해 로딩합니다. 그 후에는 같은 realImage를 재사용하므로, “이미지 객체는 많이 만들어도 실제 로딩은 필요할 때만” 이루어져 메모리와 I/O를 아낄 수 있습니다.
class Image {
public:
virtual ~Image() = default;
virtual void display() = 0;
};
class RealImage : public Image {
std::string filename;
void loadFromDisk() {
std::cout << "Loading " << filename << " from disk\n";
// 무거운 작업...
}
public:
RealImage(const std::string& file) : filename(file) {
loadFromDisk();
}
void display() override {
std::cout << "Displaying " << filename << "\n";
}
};
class ImageProxy : public Image {
std::string filename;
std::unique_ptr<RealImage> realImage;
public:
ImageProxy(const std::string& file) : filename(file) {}
void display() override {
if (!realImage) {
realImage = std::make_unique<RealImage>(filename); // 지연 로딩
}
realImage->display();
}
};
// 사용
Image* image = new ImageProxy("large_photo.jpg");
// 여기까지는 로딩 안 됨
image->display(); // 이제 로딩됨
image->display(); // 이미 로딩되며, 재사용
Protection Proxy (접근 제어)
RealDocument는 display()와 edit()를 모두 제공하지만, DocumentProxy는 사용자 역할(userRole)에 따라 edit() 호출을 막을 수 있습니다. display()는 그대로 넘기고, edit()에서는 admin·editor일 때만 실제 문서의 edit()를 호출하며, 그 외에는 “Access denied” 메시지를 출력합니다. 이렇게 하면 실제 문서 객체는 수정하지 않으며, 접근 제어만 Proxy 한 겹으로 처리할 수 있습니다.
class Document {
public:
virtual ~Document() = default;
virtual void display() = 0;
virtual void edit() = 0;
};
class RealDocument : public Document {
public:
void display() override {
std::cout << "Displaying document\n";
}
void edit() override {
std::cout << "Editing document\n";
}
};
class DocumentProxy : public Document {
std::unique_ptr<RealDocument> realDoc;
std::string userRole;
public:
DocumentProxy(const std::string& role) : userRole(role) {
realDoc = std::make_unique<RealDocument>();
}
void display() override {
realDoc->display(); // 모두 허용
}
void edit() override {
if (userRole == "admin" || userRole == "editor") {
realDoc->edit();
} else {
std::cout << "Access denied: insufficient permissions\n";
}
}
};
// 사용
Document* doc1 = new DocumentProxy("admin");
doc1->edit(); // OK
Document* doc2 = new DocumentProxy("viewer");
doc2->edit(); // Access denied
Remote Proxy (원격 객체)
원격 Proxy는 네트워크 너머의 객체를 로컬 객체처럼 보이게 하는 대리자입니다. 아래 예제는 여기에 캐시를 더해, 첫 호출에서만 실제 RemoteService를 부르고 이후에는 저장해 둔 값을 반환합니다. 클라이언트는 Service* 인터페이스만 보므로 로컬인지 원격인지, 캐시된 값인지 알 필요가 없습니다. 실제로는 캐시를 언제 무효화할지(TTL, 변경 알림)를 함께 정해야 오래된 데이터를 계속 돌려주지 않습니다.
class Service {
public:
virtual ~Service() = default;
virtual std::string getData() = 0;
};
class RemoteService : public Service {
public:
std::string getData() override {
// 실제 네트워크 호출...
return "Remote data";
}
};
class ServiceProxy : public Service {
std::unique_ptr<RemoteService> remoteService;
std::string cachedData;
bool cached = false;
public:
std::string getData() override {
if (!cached) {
if (!remoteService) {
remoteService = std::make_unique<RemoteService>();
}
cachedData = remoteService->getData();
cached = true;
}
return cachedData; // 캐시된 데이터 반환
}
};
Proxy vs Decorator vs Adapter 비교
flowchart TB
subgraph Adapter
A1[인터페이스 변환] --> A2[다른 API 호출]
end
subgraph Decorator
D1[동일 인터페이스] --> D2[기능 추가 후 위임]
end
subgraph Proxy
P1[동일 인터페이스] --> P2[접근 제어/지연/캐시]
end
Bridge: 도형과 렌더러 조합 폭발 막기
도형×렌더러 조합이 폭발하는 문제
GUI 라이브러리에서 도형(원, 사각형)을 Windows/Linux에서 각각 다르게 그려야 합니다. 상속으로 해결하면 CircleWin, CircleLinux, RectWin, RectLinux… 플랫폼과 도형이 늘어날 때마다 클래스가 곱해져 조합 폭발이 발생합니다. Bridge는 추상(도형)과 구현(렌더러)을 분리해, 도형 종류와 렌더링 방식을 독립적으로 확장할 수 있게 합니다.
기본 구현
Shape(추상)는 Renderer(구현) 인터페이스를 참조로 갖고, draw()에서 renderer->renderCircle() 등을 호출합니다. Circle, Rectangle은 Shape를 상속하며, VectorRenderer, RasterRenderer는 Renderer를 구현합니다. 도형을 추가해도 렌더러는 그대로이며, 렌더러를 추가해도 도형은 그대로입니다.
#include <iostream>
#include <memory>
#include <string>
// 구현 인터페이스 (Implementor)
class Renderer {
public:
virtual ~Renderer() = default;
virtual void renderCircle(double x, double y, double radius) = 0;
virtual void renderRect(double x, double y, double w, double h) = 0;
};
// 구체적 구현
class VectorRenderer : public Renderer {
public:
void renderCircle(double x, double y, double radius) override {
std::cout << "Vector: circle at (" << x << "," << y << ") r=" << radius << "\n";
}
void renderRect(double x, double y, double w, double h) override {
std::cout << "Vector: rect at (" << x << "," << y << ") " << w << "x" << h << "\n";
}
};
class RasterRenderer : public Renderer {
public:
void renderCircle(double x, double y, double radius) override {
std::cout << "Raster: circle at (" << x << "," << y << ") r=" << radius << "\n";
}
void renderRect(double x, double y, double w, double h) override {
std::cout << "Raster: rect at (" << x << "," << y << ") " << w << "x" << h << "\n";
}
};
// 추상 (Abstraction)
class Shape {
protected:
std::shared_ptr<Renderer> renderer;
public:
Shape(std::shared_ptr<Renderer> r) : renderer(r) {}
virtual ~Shape() = default;
virtual void draw() = 0;
};
class Circle : public Shape {
double x, y, radius;
public:
Circle(std::shared_ptr<Renderer> r, double x, double y, double radius)
: Shape(r), x(x), y(y), radius(radius) {}
void draw() override {
renderer->renderCircle(x, y, radius);
}
};
class Rectangle : public Shape {
double x, y, width, height;
public:
Rectangle(std::shared_ptr<Renderer> r, double x, double y, double w, double h)
: Shape(r), x(x), y(y), width(w), height(h) {}
void draw() override {
renderer->renderRect(x, y, width, height);
}
};
int main() {
auto vector = std::make_shared<VectorRenderer>();
auto raster = std::make_shared<RasterRenderer>();
std::unique_ptr<Shape> circle = std::make_unique<Circle>(vector, 5, 5, 10);
circle->draw(); // Vector: circle...
std::unique_ptr<Shape> rect = std::make_unique<Rectangle>(raster, 0, 0, 20, 10);
rect->draw(); // Raster: rect...
return 0;
}
Composite: 폴더와 파일 같은 트리 구조
폴더·파일 계층을 같은 인터페이스로 다루기
파일 시스템처럼 폴더 안에 폴더·파일이 중첩되어 있으며, “전체 크기 계산”, “이름으로 검색” 같은 연산을 폴더든 파일이든 동일한 인터페이스로 호출하고 싶습니다. Composite는 Component 인터페이스를 정의하며, Leaf(파일)와 Composite(폴더)가 이를 구현합니다. Composite는 자식 Component 목록을 갖고, getSize()에서 자식들의 크기를 재귀적으로 합산합니다. 이름 검색 같은 다른 연산도 같은 방식으로 자식을 순회해 구현합니다.
기본 구현
FileSystemComponent가 getSize(), getName() 등을 제공하며, File은 Leaf로 직접 값을 반환하며, Folder는 자식 목록을 순회해 합산·검색합니다. 클라이언트는 Component*만으로 폴더·파일을 구분 없이 다룰 수 있습니다.
#include <iostream>
#include <memory>
#include <string>
#include <vector>
// Component
class FileSystemComponent {
public:
virtual ~FileSystemComponent() = default;
virtual std::string getName() const = 0;
virtual size_t getSize() const = 0;
virtual void add(std::unique_ptr<FileSystemComponent> child) {
(void)child; // Leaf는 무시
}
};
// Leaf
class File : public FileSystemComponent {
std::string name;
size_t size;
public:
File(const std::string& n, size_t s) : name(n), size(s) {}
std::string getName() const override { return name; }
size_t getSize() const override { return size; }
};
// Composite
class Folder : public FileSystemComponent {
std::string name;
std::vector<std::unique_ptr<FileSystemComponent>> children;
public:
explicit Folder(const std::string& n) : name(n) {}
std::string getName() const override { return name; }
size_t getSize() const override {
size_t total = 0;
for (const auto& c : children) total += c->getSize();
return total;
}
void add(std::unique_ptr<FileSystemComponent> child) override {
children.push_back(std::move(child));
}
};
int main() {
auto root = std::make_unique<Folder>("root");
root->add(std::make_unique<File>("a.txt", 100));
root->add(std::make_unique<File>("b.txt", 200));
auto sub = std::make_unique<Folder>("sub");
sub->add(std::make_unique<File>("c.txt", 50));
root->add(std::move(sub));
std::cout << "Total size: " << root->getSize() << "\n"; // 350
return 0;
}
Adapter와 Decorator를 함께 쓰는 예
패턴 조합: Adapter + Decorator
OldLogger는 writeLog(const char*)만 있는 구식 API입니다. LoggerAdapter가 이를 우리 Logger 인터페이스로 감싸고, 그 위에 TimestampDecorator를 올려 메시지 앞에 현재 시각을 붙입니다. 외부 라이브러리 코드는 그대로 두고, Adapter로 인터페이스를 맞춘 뒤 Decorator로 동작만 확장하는 구조입니다. 핵심은 Decorator가 Adapter를 상속하지 않고 공통 인터페이스 Logger를 구현한다는 점입니다. 그래야 Decorator를 어떤 Logger 구현 위에도 올릴 수 있습니다.
#include <ctime>
#include <iostream>
#include <memory>
#include <string>
// 우리 인터페이스
class Logger {
public:
virtual ~Logger() = default;
virtual void log(const std::string& message) = 0;
};
// 외부 라이브러리 (수정 불가)
class OldLogger {
public:
void writeLog(const char* msg) { std::cout << msg << "\n"; }
};
// Adapter
class OldLoggerAdapter : public Logger {
OldLogger& oldLogger; // 소유하지 않음: OldLogger가 더 오래 살아야 함
public:
explicit OldLoggerAdapter(OldLogger& logger) : oldLogger(logger) {}
void log(const std::string& message) override {
oldLogger.writeLog(message.c_str());
}
};
// Decorator
class TimestampDecorator : public Logger {
std::unique_ptr<Logger> inner;
public:
explicit TimestampDecorator(std::unique_ptr<Logger> l) : inner(std::move(l)) {}
void log(const std::string& message) override {
std::time_t now = std::time(nullptr);
char buf[32];
std::strftime(buf, sizeof buf, "%Y-%m-%d %H:%M:%S", std::localtime(&now));
inner->log(std::string(buf) + " " + message);
}
};
int main() {
OldLogger old;
std::unique_ptr<Logger> logger =
std::make_unique<TimestampDecorator>(std::make_unique<OldLoggerAdapter>(old));
logger->log("Application started"); // 예: 2026-10-15 09:00:00 Application started
}
시각 문자열을 std::ctime으로 만들면 결과 끝에 개행 문자가 붙어 있어서 로그가 두 줄로 쪼개집니다. 형식을 직접 지정하는 std::strftime을 쓰는 편이 안전합니다. (std::localtime은 내부 정적 버퍼를 돌려주므로 여러 스레드에서 동시에 부르면 안전하지 않다는 점도 기억해 둘 만합니다.)
패턴 선택 가이드
| 상황 | 추천 패턴 | 이유 |
|---|---|---|
| API 시그니처가 다름 | Adapter | 인터페이스 변환 필요 |
| 기능을 조합해서 붙이고 싶음 | Decorator | 상속 폭발 방지 |
| 생성 비용이 큼 | Proxy (Virtual) | 지연 로딩 |
| 권한·접근 제어 | Proxy (Protection) | 실제 객체 보호 |
| 원격·캐시 | Proxy (Remote) | 네트워크·캐시 추상화 |
| 추상×구현 조합 폭발 | Bridge | 추상과 구현 독립 확장 |
| 트리·계층 구조 통일 처리 | Composite | 부분-전체 동일 인터페이스 |
패턴별로 자주 하는 실수
Adapter 관련
실수 1: Adaptee 소유권 관리 누락
Adapter가 Adaptee*를 받아 두었는데 Adaptee가 먼저 소멸되면, 그 뒤의 호출은 이미 해제된 객체를 가리키는 포인터(dangling pointer)를 따라가는 정의되지 않은 동작이 됩니다.
// ❌ 위험: adaptee가 외부에서 소멸될 수 있음
class Adapter : public Target {
Adaptee* adaptee; // 소유권 불명확
public:
Adapter(Adaptee* a) : adaptee(a) {}
};
void bad_usage() {
Adaptee a;
Target* t = new Adapter(&a);
// a가 스코프 끝에서 소멸 → t->request() 호출 시 크래시
}
Adapter가 Adaptee를 소유하게 하거나, 여러 곳이 함께 써야 한다면 std::shared_ptr로 수명을 공유합니다. 소유하지 않는 것이 의도라면 참조나 포인터를 쓰되, Adaptee가 Adapter보다 오래 산다는 전제를 주석으로 남겨 둡니다.
// ✅ Adapter가 Adaptee 소유
class Adapter : public Target {
std::unique_ptr<Adaptee> adaptee;
public:
Adapter(std::unique_ptr<Adaptee> a) : adaptee(std::move(a)) {}
std::string request() override { return adaptee->specificRequest(); }
};
실수 2: Class Adapter에서 다중 상속 충돌
Target과 Adaptee가 같은 베이스 클래스를 (virtual 상속이 아닌 일반 상속으로) 물려받고 있으면, Class Adapter 안에 그 베이스가 두 벌 생깁니다. 그러면 Stream*으로의 변환이나 베이스 멤버 호출이 모호하다는 컴파일 오류가 납니다.
// ❌ FileStream과 NetworkStream이 각자 Stream을 일반 상속 → Stream이 두 벌
class Stream { virtual void read() = 0; };
class FileStream : public Stream { ... };
class NetworkStream : public Stream { ... };
class Adapter : public FileStream, private NetworkStream { ... }; // 다이아몬드
Object Adapter(구성)로 바꿔 Adaptee를 멤버로 두면 상속 관계 자체가 사라지므로 이 문제가 없습니다.
Decorator 관련
실수 3: Decorator 체인에서 이중 해제
소멸자에서 내부 객체를 delete하는 Decorator 두 개가 같은 Coffee*를 감싸면, 하나를 지울 때 공유 객체가 해제되고 다른 하나는 해제된 객체를 가리키게 됩니다.
// ❌ 위험: coffee를 두 Decorator가 공유
Coffee* base = new SimpleCoffee();
auto* d1 = new MilkDecorator(base);
auto* d2 = new SugarDecorator(base); // base를 공유!
delete d1; // base도 delete됨
d2->getDescription(); // 정의되지 않은 동작: base가 이미 해제됨 (delete d2 때는 이중 해제)
각 Decorator가 내부 객체를 단독으로 소유하게 하고, 체인으로 감쌀 때 std::move로 소유권을 넘기면 이런 공유가 타입 수준에서 막힙니다.
// ✅ 체인: 각자가 다음을 소유
std::unique_ptr<Coffee> coffee = std::make_unique<SimpleCoffee>();
coffee = std::make_unique<MilkDecorator>(std::move(coffee));
coffee = std::make_unique<SugarDecorator>(std::move(coffee));
실수 4: Decorator가 Component의 모든 메서드를 위임하지 않음
Component에 기본 구현이 있는 가상 함수 getSize()를 새로 추가했는데 Decorator에서 위임을 빠뜨리면, 데코레이터로 감싼 객체는 안쪽 객체의 값 대신 기본 구현의 값을 돌려줍니다. 컴파일러는 아무 경고도 하지 않습니다.
// ❌ Decorator가 getSize() 위임 누락
class CoffeeDecorator : public Coffee {
// ...
// getSize() override 없음 → Coffee의 기본 구현이 호출됨
};
새 함수를 순수 가상 함수로 추가하면 위임을 빠뜨린 Decorator가 추상 클래스가 되어 생성 시점에 컴파일 오류가 나므로 누락을 놓치지 않습니다. 기본 구현이 꼭 필요하다면, 공통 베이스 Decorator에서 모든 함수를 안쪽 객체로 위임해 두고 구체 Decorator는 바꾸고 싶은 함수만 오버라이드하는 구조가 안전합니다.
Proxy 관련
실수 5: 스레드 안전하지 않은 지연 로딩
여러 스레드가 동시에 처음으로 display()를 호출하면, 둘 다 realImage가 비어 있다고 보고 각자 RealImage를 만들 수 있습니다. 이는 단순히 두 번 로딩하는 낭비가 아니라 같은 unique_ptr에 동시에 쓰는 데이터 경쟁이므로 정의되지 않은 동작입니다.
// ❌ 스레드 불안전
void display() override {
if (!realImage) { // 스레드 A, B 동시 진입 가능
realImage = std::make_unique<RealImage>(filename);
}
realImage->display();
}
초기화를 정확히 한 번만 수행하려면 std::call_once나 뮤텍스를 씁니다.
// ✅ std::call_once 사용 (멤버로 std::once_flag initFlag; 선언)
void display() override {
std::call_once(initFlag, [this]() {
realImage = std::make_unique<RealImage>(filename);
});
realImage->display();
}
std::once_flag는 복사도 이동도 할 수 없으므로, 이 멤버를 넣으면 Proxy 클래스도 복사·이동이 불가능해진다는 점은 알아 두어야 합니다.
실수 6: Protection Proxy에서 일부 메서드의 권한 검사 누락
edit()에는 역할 검사를 넣었는데, 나중에 추가한 내보내기 함수 같은 민감한 메서드는 검사 없이 그대로 위임하는 실수입니다.
// ❌ exportPdf()에 권한 검사 없음
void exportPdf() override {
realDoc->exportPdf(); // 모든 역할이 호출 가능
}
(export는 C++ 키워드라 함수 이름으로 쓸 수 없으므로 예제에서는 exportPdf로 이름을 붙였습니다.) 권한이 필요한 메서드마다 검사를 넣되, 검사 로직은 requireRole("editor") 같은 공통 헬퍼로 모아 두면 새 메서드를 추가할 때 빠뜨리기 어렵습니다. 근본적으로는 Proxy의 권한 검사는 같은 프로세스 안의 실수를 막는 장치일 뿐, 신뢰할 수 없는 입력을 막는 보안 경계는 서버 측 인가에서 다시 해야 합니다.
Bridge·Composite 관련
실수 7: Bridge에서 추상이 구현을 직접 생성
Shape가 생성자 안에서 VectorRenderer를 직접 만들면, 새 렌더러를 추가할 때마다 Shape를 고쳐야 해서 Bridge로 분리한 의미가 사라집니다. 렌더러는 앞의 예제처럼 생성자 인자로 주입받습니다.
실수 8: Bridge와 Adapter 혼동
둘 다 한 객체가 다른 객체에 일을 넘기는 구조라 코드 모양이 비슷합니다. 차이는 시점과 목적입니다. Adapter는 이미 있는, 고칠 수 없는 API를 나중에 우리 인터페이스에 맞추는 것이고, Bridge는 처음 설계할 때부터 추상과 구현을 따로 확장하려고 둘을 나누는 것입니다.
실수 9: Composite에서 Leaf의 add()를 조용히 무시
위 예제처럼 add()를 Component에 두고 Leaf에서 아무것도 하지 않게 하면, 파일에 자식을 추가하는 잘못된 호출이 에러 없이 사라집니다. 이는 GoF가 말하는 투명성(모든 노드를 같은 인터페이스로 다룸)과 안전성(잘못된 호출을 막음) 사이의 선택입니다. 안전성을 원하면 add()를 Folder에만 두고 Component에는 두지 않거나, Leaf에서 예외를 던져 잘못된 호출을 드러냅니다.
실수 10: Composite의 순환 구조
폴더 A 안에 B를, B 안에 A를 넣으면 getSize() 같은 재귀 연산이 끝나지 않습니다. 위 예제처럼 자식을 std::unique_ptr로 소유하면, 한 객체를 동시에 두 부모가 소유할 수 없으므로 이런 순환은 구조적으로 만들 수 없습니다. 자식을 shared_ptr나 원시 포인터로 들고 있는 설계라면, add()에서 추가하려는 노드가 자기 자신이나 조상인지 부모 체인을 따라 검사해야 합니다.
인터페이스 일관성, 메모리 관리, 테스트
Adapter, Decorator, Proxy는 모두 클라이언트가 기대하는 인터페이스를 구현합니다. 그래서 그 인터페이스에 함수가 추가되면 모든 래퍼 클래스가 영향을 받습니다. 실수 4에서 보았듯이 새 함수를 순수 가상으로 추가해 컴파일러가 누락을 잡게 하는 편이 안전합니다.
소유권은 std::unique_ptr로 표현하고, 원시 포인터나 참조는 소유하지 않는다는 뜻으로만 쓰는 것이 기본 원칙입니다. Decorator 체인은 바깥이 안쪽을 소유하는 한 방향 구조이고, Bridge의 렌더러처럼 여러 객체가 함께 쓰는 구현 객체는 std::shared_ptr로 공유하거나, 수명이 확실히 더 긴 경우 참조로 넘깁니다.
테스트 측면에서는 래퍼가 감싸는 대상을 인터페이스로 받으면 가짜 구현으로 바꿔 끼울 수 있습니다. Decorator는 각각 따로 단위 테스트하고, 조합은 통합 테스트에서 확인하면 됩니다.
성능은 대부분의 경우 문제가 되지 않습니다. 래퍼 한 겹은 가상 함수 호출 하나를 더하는 정도이고, Decorator 체인은 겹 수만큼 호출이 늘어납니다. Composite의 getSize() 같은 연산은 트리 전체를 재귀로 순회하므로, 자주 호출되는 큰 트리라면 결과를 캐시하고 자식이 바뀔 때 무효화하는 방식을 고려합니다.
로깅 Decorator, 연결 풀 Proxy, 다중 백엔드 Adapter
메트릭 Decorator
비즈니스 로직은 그대로 두고, 호출 지연 시간을 수집하는 Decorator를 겹쳐 씌울 수 있습니다.
#include <chrono>
class MetricsHttpClient : public HttpClientDecorator {
public:
using HttpClientDecorator::HttpClientDecorator;
std::string get(const std::string& url) override {
auto start = std::chrono::steady_clock::now();
auto result = client->get(url);
auto elapsed = std::chrono::steady_clock::now() - start;
recordLatency(url, elapsed); // 메트릭 수집기로 전송 (구현 생략)
return result;
}
};
연결 풀 Proxy
DB 연결은 생성 비용이 크므로, Proxy가 첫 쿼리 때 풀에서 연결을 빌리고 소멸할 때 돌려주게 할 수 있습니다. 아래는 구조만 보여 주는 스케치입니다.
class ConnectionProxy : public DatabaseConnection {
ConnectionPool& pool;
std::unique_ptr<RealConnection> conn;
public:
explicit ConnectionProxy(ConnectionPool& p) : pool(p) {}
void query(const std::string& sql) override {
if (!conn) conn = pool.acquire();
conn->query(sql);
}
~ConnectionProxy() override {
if (conn) pool.release(std::move(conn));
}
};
이 구조에서는 풀이 모든 Proxy보다 오래 살아야 하고, Proxy를 복사하면 같은 연결을 두 번 반환하게 되므로 복사를 금지해야 합니다(unique_ptr 멤버 덕분에 복사 생성자는 자동으로 삭제됩니다).
다중 백엔드 Adapter
S3, 로컬 파일, 메모리 같은 여러 저장소를 Storage 인터페이스 하나로 통일하는 것도 Adapter의 전형적인 쓰임입니다.
#include <cstdint>
#include <string>
#include <vector>
class Storage {
public:
virtual ~Storage() = default;
virtual void put(const std::string& key, const std::vector<std::uint8_t>& data) = 0;
virtual std::vector<std::uint8_t> get(const std::string& key) = 0;
};
class S3StorageAdapter : public Storage { /* AWS SDK 호출을 감쌈 */ };
class LocalFileStorageAdapter : public Storage { /* std::filesystem과 파일 스트림을 감쌈 */ };
Bridge는 그래픽 API(OpenGL, Direct3D, Metal)를 Renderer 인터페이스 뒤로 숨기고 도형·장면 코드는 그대로 두는 렌더링 엔진 구조에서, Composite는 버튼·패널·레이아웃 같은 UI 위젯 트리에 layout(), paint()를 똑같이 적용하는 GUI 프레임워크에서 흔히 볼 수 있습니다.