C++ 디자인 패턴 | Adapter·Decorator

C++ 디자인 패턴 | Adapter·Decorator

이 글의 핵심

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();  // 에러!

Adapter로 해결: VLCAdapterMediaPlayer 인터페이스를 구현하면서 내부에 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 패턴으로 인터페이스를 변환할 수 있습니다.
  • Decorator 패턴으로 기능을 동적으로 추가할 수 있습니다.
  • Proxy 패턴으로 접근을 제어할 수 있습니다.
  • Bridge 패턴으로 추상과 구현을 분리할 수 있습니다.
  • Composite 패턴으로 트리 구조를 통일된 인터페이스로 다룰 수 있습니다.
  • 실전에서 구조 패턴을 활용할 수 있습니다.

목차

  1. Adapter 패턴
  2. Decorator 패턴
  3. Proxy 패턴
  4. Bridge 패턴
  5. Composite 패턴
  6. 실전 활용
  7. 일반적인 실수
  8. 모범 사례와 선택 가이드
  9. 프로덕션 패턴

1. Adapter 패턴

Class Adapter (상속)

Class AdapterTarget(우리가 원하는 인터페이스)을 상속하고, 동시에 Adaptee(기존 비호환 클래스)를 private 상속해 “is-a” 관계로 감쌉니다. request()를 구현할 때 내부적으로 specificRequest()를 호출해 반환값을 그대로 넘기면, 클라이언트는 Target*로만 사용할 수 있습니다. 다만 C++에서 다중 상속은 복잡해질 수 있으므로, 구성(다음 예제)을 쓰는 경우가 많습니다.

// 기존 인터페이스
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 AdapterAdaptee를 상속하지 않고 포인터나 참조로 갖는 방식입니다. 생성자에서 기존 객체를 받아 두고, 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;
}

2. 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_ptr<Coffee> 로 감싸면 “이 Decorator가 다음 Coffee를 소유한다”가 명확해지고, 바깥에서 delete를 호출할 필요가 없어 누수와 이중 해제를 줄일 수 있습니다. 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;
    }
};

// 사용
auto coffee = std::make_unique<SimpleCoffee>();
coffee = std::make_unique<MilkDecorator>(std::move(coffee));
coffee = std::make_unique<SugarDecorator>(std::move(coffee));

실전 예제: 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() {
    auto client = std::make_unique<RealHttpClient>();
    client = std::make_unique<LoggingDecorator>(std::move(client));
    client = std::make_unique<RetryDecorator>(std::move(client), 3);
    
    std::cout << client->get("https://api.example.com/data") << "\n";
    return 0;
}

3. 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 (접근 제어)

RealDocumentdisplay()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* 인터페이스만 보므로 로컬/원격/캐시 여부를 알 필요 없이 getData()만 호출하면 됩니다. 네트워크 재시도·타임아웃 같은 부가 로직도 Proxy에 두기 좋습니다.

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

4. 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;
}

5. Composite 패턴

문제 시나리오: 폴더·파일 계층 구조

상황: 파일 시스템처럼 폴더 안에 폴더·파일이 중첩되어 있고, “전체 크기 계산”, “이름으로 검색” 같은 연산을 폴더든 파일이든 동일한 인터페이스로 호출하고 싶습니다. Composite는 Component 인터페이스를 정의하고, Leaf(파일)와 Composite(폴더)가 이를 구현합니다. Composite는 자식 Component 목록을 갖고, getSize()는 자식들의 합을, search()는 자식을 순회해 반환합니다.

기본 구현

FileSystemComponentgetSize(), 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;
}

6. 실전 활용

패턴 조합: Adapter + Decorator

OldLoggerwriteLog(const char*)만 있는 구식 API입니다. LoggerAdapter가 우리 log(std::string) 인터페이스로 감싸고, 그 위에 TimestampDecorator를 올려 “로그 메시지 앞에 현재 시각을 붙이는” 기능을 덧씌웁니다. 이렇게 하면 외부 라이브러리 코드는 수정하지 않고, Adapter로 인터페이스를 맞추고 Decorator로 동작만 확장할 수 있습니다.

// 외부 라이브러리
class OldLogger {
public:
    void writeLog(const char* msg) {
        std::cout << msg << "\n";
    }
};

// Adapter
class LoggerAdapter {
    OldLogger* oldLogger;
    
public:
    LoggerAdapter(OldLogger* logger) : oldLogger(logger) {}
    
    virtual void log(const std::string& message) {
        oldLogger->writeLog(message.c_str());
    }
};

// Decorator
class TimestampDecorator : public LoggerAdapter {
    LoggerAdapter* logger;
    
public:
    TimestampDecorator(LoggerAdapter* l) : LoggerAdapter(nullptr), logger(l) {}
    
    void log(const std::string& message) override {
        auto now = std::time(nullptr);
        std::string timestamped = std::string(std::ctime(&now)) + ": " + message;
        logger->log(timestamped);
    }
};

패턴 선택 가이드

상황추천 패턴이유
API 시그니처가 다름Adapter인터페이스 변환 필요
기능을 조합해서 붙이고 싶음Decorator상속 폭발 방지
생성 비용이 큼Proxy (Virtual)지연 로딩
권한·접근 제어Proxy (Protection)실제 객체 보호
원격·캐시Proxy (Remote)네트워크·캐시 추상화
추상×구현 조합 폭발Bridge추상과 구현 독립 확장
트리·계층 구조 통일 처리Composite부분-전체 동일 인터페이스

7. 일반적인 실수

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로 수명을 공유.

// ✅ 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에서 다중 상속 충돌

증상: TargetAdaptee가 같은 가상 베이스를 가질 때 다이아몬드 상속·모호성 발생.

// ❌ Adaptee와 Target이 같은 베이스를 상속하면 모호
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 체인에서 이중 해제

증상: Coffee*를 여러 Decorator가 공유하다가 한 곳에서 delete하면 다른 곳에서 접근 시 크래시.

// ❌ 위험: coffee를 두 Decorator가 공유
Coffee* base = new SimpleCoffee();
auto* d1 = new MilkDecorator(base);
auto* d2 = new SugarDecorator(base);  // base를 공유!
delete d1;  // base도 delete됨
d2->getDescription();  // 크래시: base가 이미 해제됨

해결: 각 Decorator가 내부 객체를 단독 소유. 체인으로 감쌀 때 std::move로 이전.

// ✅ 체인: 각자가 다음을 소유
auto coffee = std::make_unique<SimpleCoffee>();
coffee = std::make_unique<MilkDecorator>(std::move(coffee));
coffee = std::make_unique<SugarDecorator>(std::move(coffee));

실수 4: Decorator가 Component의 모든 메서드를 위임하지 않음

증상: 새 메서드 getSize()를 Component에 추가했는데, Decorator에서 위임을 깜빡해 기본값만 반환.

// ❌ Decorator가 getSize() 위임 누락
class CoffeeDecorator : public Coffee {
    // ...
    // getSize() override 없음 → 기본 구현 또는 잘못된 값
};

해결: Decorator는 Component 인터페이스의 모든 메서드를 오버라이드하고 내부 객체에 위임. 인터페이스 변경 시 모든 Decorator 검토.

Proxy 관련

실수 5: 스레드 안전하지 않은 지연 로딩

증상: 여러 스레드가 동시에 display()를 처음 호출하면 RealImage가 여러 번 생성될 수 있음.

// ❌ 스레드 불안전
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();
}

실수 6: Protection Proxy에서 역할 검사 누락

증상: display()는 허용했는데, export() 같은 위험한 메서드는 검사 없이 통과.

// ❌ export()에 권한 검사 없음
void export() override {
    realDoc->export();  // 모든 역할이 호출 가능
}

해결: 권한이 필요한 모든 메서드에 역할 검사 추가. 공통 헬퍼로 검사 로직 중복 제거.

Bridge·Composite 관련

실수 7: Bridge에서 추상과 구현 강결합

증상: Shape가 VectorRenderer를 직접 생성하면 새 렌더러 추가 시 Shape 수정 필요. 해결: 생성자에서 shared_ptr<Renderer> 주입.

실수 8: Bridge와 Adapter 혼동

증상: 기존 API를 맞추는 것은 Adapter, 추상×구현 분리는 Bridge. 목적이 다름.

실수 9: Composite Leaf에서 add 처리 누락

증상: Leaf의 add()가 의미 없을 때 빈 구현 없으면 버그. 해결: (void)child;로 무시하거나 예외.

실수 10: Composite 순환 참조

증상: 자기 자신을 자식으로 추가하면 무한 루프. 해결: add 시 child == this 또는 부모 체인 검사.


8. 모범 사례와 선택 가이드

인터페이스 일관성

  • Target 인터페이스는 클라이언트가 기대하는 형태로 설계. Adapter/Decorator/Proxy 모두 이 인터페이스를 구현.
  • 새 메서드 추가 시 모든 래퍼 클래스에서 위임/변환 처리 여부 확인.

메모리 관리

  • std::unique_ptr 로 소유권 명확화. Raw pointer는 “소유하지 않음”일 때만 사용.
  • Decorator 체인은 한 방향 소유: 바깥 Decorator가 안쪽을 소유, std::move로 이전.

테스트 용이성

  • Adapter: Adaptee를 인터페이스로 추상화해 Mock으로 교체 가능하게.
  • Decorator: 각 Decorator를 단위 테스트로 검증. 조합은 통합 테스트.
  • Proxy: Real 객체를 주입받도록 하면 테스트 시 Fake로 대체 가능.

성능 고려

  • Adapter: 호출당 한 번의 간접 호출. 대부분 무시 가능.
  • Decorator: 체인 길이만큼 호출 스택 증가. 과도한 중첩(10단계 이상)은 피할 것.
  • Proxy: 지연 로딩 시 첫 호출만 비용. 캐시 Proxy는 TTL·무효화 정책 필요.
  • Bridge: 구현 객체를 shared_ptr로 공유하면 메모리 절약. 생성 시 한 번만 주입.
  • Composite: 깊은 트리에서 getSize() 등은 재귀 호출. 필요 시 캐싱 또는 지연 계산.

9. 프로덕션 패턴

로깅·메트릭 Decorator

실제 비즈니스 로직은 그대로 두고, 호출 횟수·지연 시간을 수집하는 Decorator를 겹쳐 씌웁니다.

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;
        // Prometheus, StatsD 등에 메트릭 전송
        return result;
    }
};

연결 풀 Proxy

DB 연결 생성 비용이 크므로, Proxy가 풀에서 연결을 빌려 주고 반환받는 패턴.

class ConnectionProxy : public DatabaseConnection {
    ConnectionPool* pool;
    std::unique_ptr<RealConnection> conn;
public:
    ConnectionProxy(ConnectionPool* p) : pool(p) {}
    
    void query(const std::string& sql) override {
        if (!conn) conn = pool->acquire();
        conn->query(sql);
    }
    
    ~ConnectionProxy() {
        if (conn) pool->release(std::move(conn));
    }
};

다중 백엔드 Adapter

여러 스토리지(S3, 로컬 파일, 메모리)를 Storage::put(key, data) 하나의 인터페이스로 통일.

class Storage {
public:
    virtual void put(const std::string& key, const std::vector<uint8_t>& data) = 0;
    virtual std::vector<uint8_t> get(const std::string& key) = 0;
};

class S3StorageAdapter : public Storage { /* AWS SDK 래핑 */ };
class LocalFileStorageAdapter : public Storage { /* std::filesystem 래핑 */ };

Bridge·Composite 프로덕션 활용

  • Bridge: Windows/Linux/macOS에서 OpenGL, DirectX, Metal 등 그래픽 API를 Renderer 인터페이스로 추상화해 도형 코드는 그대로 두고 렌더러만 교체.
  • Composite: UI 위젯 트리(버튼, 패널, 레이아웃)에서 layout(), paint()를 모든 위젯에 동일하게 적용.

구현 체크리스트

  • Adapter: Adaptee 소유권을 Adapter 또는 shared_ptr로 명확히
  • Decorator: unique_ptr로 체인 구성, 이중 소유 금지
  • Proxy: 지연 로딩 시 std::call_once 또는 뮤텍스로 스레드 안전 확보
  • 모든 래퍼가 Target 인터페이스의 새 메서드에 대응하는지 주기적 검토
  • 테스트에서 Mock/Fake로 실제 의존성 교체 가능한지 확인

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ 디자인 패턴 | Singleton·Factory·Builder·Prototype 생성 패턴 가이드
  • C++ 디자인 패턴 | Observer·Strategy
  • C++ PIMPL과 브릿지 패턴 | 구현 숨기기와 추상화 [#19-3]

이 글에서 다루는 키워드 (관련 검색어)

C++ 구조 패턴, Adapter Decorator Proxy Bridge Composite, 디자인 패턴, 구조 설계 등으로 검색하시면 이 글이 도움이 됩니다.

정리

패턴용도예제
Adapter인터페이스 변환외부 라이브러리 통합
Decorator기능 동적 추가커피 옵션, 로깅
Proxy접근 제어, 지연 로딩이미지, 권한
Bridge추상×구현 분리도형×렌더러
Composite트리 구조 통일폴더·파일, UI 위젯

핵심 원칙:

  1. Adapter: 기존 코드 수정 없이 통합
  2. Decorator: 상속 대신 구성
  3. Proxy: 실제 객체 보호
  4. Bridge: 추상과 구현 독립 확장
  5. Composite: 부분-전체 동일 인터페이스
  6. 인터페이스 일관성 유지
  7. 스마트 포인터로 메모리 관리

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. 클래스와 객체의 구조를 효과적으로 구성하는 Adapter, Decorator, Proxy, Bridge, Composite 패턴과 실전에서 활용하는 방법을 다룹니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.

한 줄 요약: Adapter·Decorator·Proxy·Bridge·Composite로 구조를 유연하게 바꿀 수 있습니다. 다음으로 PIMPL·Bridge(#19-3)를 읽어보면 좋습니다.

이전 글: [C++ 실전 가이드 #19-1] 생성 패턴: Singleton, Factory, Builder

다음 글: [C++ 실전 가이드 #19-3] PIMPL과 브릿지 패턴: 구현 숨기기와 추상화


관련 글

  • C++ 디자인 패턴 | Singleton·Factory·Builder·Prototype 생성 패턴 가이드
  • C++ PIMPL과 브릿지 패턴 | 구현 숨기기와 추상화 [#19-3]
  • C++ 이동 의미론 완벽 가이드 | rvalue 참조·std::move
  • C++ Perfect Forwarding 완벽 가이드 | 유니버설 참조·std::forward
  • C++ 디자인 패턴 | Observer·Strategy