C++ Decorator 패턴: 상속 대신 감싸서 기능 붙이기 (스트림·로깅·포매터 예제)

이 글의 핵심

기능마다 하위 클래스를 만들면 조합의 수만큼 클래스가 늘어나는 문제가 생기는데, Decorator는 감싸는 순서만 바꿔 이를 해결합니다. 대신 감싸는 순간 원래 타입의 고유 메서드에 접근할 수 없게 되는 타입 손실과 적용 순서에 따라 결과가 달라지는 문제가 생깁니다. Adapter와의 차이와 성능 오버헤드도 함께 비교합니다.

상속 대신 감싸서 기능을 붙이는 이유

같은 “기능을 덧붙이기” 목적은 Python 데코레이터·JavaScript 패턴과도 맞물려 있습니다. GoF 맥락은 구조 패턴 시리즈를 참고하세요.

커피 옵션 조합마다 클래스를 만들 때

문제: 커피에 우유, 설탕, 휘핑크림을 추가하려면, 모든 조합을 클래스로 만들어야 합니다.

// 나쁜 예: 클래스 폭발
class Coffee {};
class CoffeeWithMilk : public Coffee {};
class CoffeeWithSugar : public Coffee {};
class CoffeeWithMilkAndSugar : public Coffee {};
class CoffeeWithMilkAndSugarAndWhip : public Coffee {};
// 조합이 늘어날수록 클래스 폭발

해결: Decorator Pattern은 기능을 동적으로 추가합니다. Decorator가 Component를 감싸서 기능을 추가합니다.

// 좋은 예: Decorator
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));
// 런타임에 기능 조합
flowchart TD
    component["Component (Coffee)"]
    simple[SimpleCoffee]
    decorator[Decorator]
    milk[MilkDecorator]
    sugar[SugarDecorator]
    
    simple -.->|상속| component
    decorator -.->|상속| component
    milk -.->|상속| decorator
    sugar -.->|상속| decorator
    decorator --> component

클래스 폭발은 과장이 아닙니다. 첨가물이 n가지이고 각각 넣거나 뺄 수 있다면 조합은 2ⁿ개이므로, 첨가물 5가지면 32개, 10가지면 1,024개의 하위 클래스가 필요합니다. “우유 두 번”처럼 같은 첨가물을 여러 번 넣는 경우는 상속으로 표현할 방법조차 없습니다. Decorator는 첨가물마다 클래스 하나만 만들고, 조합은 런타임에 감싸는 순서로 표현하므로 클래스 수가 n개로 줄어듭니다.

이것이 가능한 핵심은 Decorator가 Component를 상속하면서 동시에 Component를 멤버로 가진다는 이중 구조입니다. 상속 덕분에 감싼 결과도 여전히 Coffee로 취급되어 다시 감쌀 수 있고, 멤버로 가진 덕분에 원래 객체에 호출을 위임한 뒤 자기 기능을 덧붙일 수 있습니다. 그림의 decorator --> component 화살표가 “has-a”, decorator -.->|상속| component가 “is-a” 관계입니다.


Component를 감싸 위임하는 기본 구조

#include <iostream>
#include <memory>
#include <string>
class Coffee {
public:
    virtual std::string getDescription() const = 0;
    virtual double cost() const = 0;
    virtual ~Coffee() = default;
};
class SimpleCoffee : public Coffee {
public:
    std::string getDescription() const override {
        return "Simple coffee";
    }
    
    double cost() const override {
        return 2.0;
    }
};
class CoffeeDecorator : public Coffee {
public:
    CoffeeDecorator(std::unique_ptr<Coffee> c)
        : coffee(std::move(c)) {}
    
protected:
    std::unique_ptr<Coffee> coffee;
};
class MilkDecorator : public CoffeeDecorator {
public:
    using CoffeeDecorator::CoffeeDecorator;
    
    std::string getDescription() const override {
        return coffee->getDescription() + " + Milk";
    }
    
    double cost() const override {
        return coffee->cost() + 0.5;
    }
};
class SugarDecorator : public CoffeeDecorator {
public:
    using CoffeeDecorator::CoffeeDecorator;
    
    std::string getDescription() const override {
        return coffee->getDescription() + " + Sugar";
    }
    
    double cost() const override {
        return coffee->cost() + 0.3;
    }
};
int main() {
    std::unique_ptr<Coffee> coffee = std::make_unique<SimpleCoffee>();
    std::cout << coffee->getDescription() << ": $" << coffee->cost() << '\n';
    
    coffee = std::make_unique<MilkDecorator>(std::move(coffee));
    std::cout << coffee->getDescription() << ": $" << coffee->cost() << '\n';
    
    coffee = std::make_unique<SugarDecorator>(std::move(coffee));
    std::cout << coffee->getDescription() << ": $" << coffee->cost() << '\n';
}

출력:

Simple coffee: $2
Simple coffee + Milk: $2.5
Simple coffee + Milk + Sugar: $2.8

main에서 변수 타입을 auto가 아니라 std::unique_ptr<Coffee>로 명시한 데는 이유가 있습니다. auto coffee = std::make_unique<SimpleCoffee>();로 쓰면 coffee의 타입이 std::unique_ptr<SimpleCoffee>로 고정되어, 다음 줄에서 MilkDecorator를 대입할 때 MilkDecorator*를 SimpleCoffee*로 변환할 수 없다는 컴파일 에러가 납니다. 데코레이터로 계속 감쌀 변수는 처음부터 인터페이스 타입으로 선언해야 합니다.

CoffeeDecorator는 추상 클래스로 남아 있고(getDescription, cost를 구현하지 않음), 공통으로 쓰는 coffee 멤버와 생성자만 제공합니다. 파생 데코레이터가 using CoffeeDecorator::CoffeeDecorator;로 생성자를 물려받으므로 새 첨가물을 추가할 때는 두 메서드만 쓰면 됩니다. 데코레이터가 unique_ptr로 내부 객체를 소유하므로, 가장 바깥 객체 하나를 해제하면 안쪽 객체들이 연쇄적으로 해제됩니다. 소유권이 한 방향으로만 흐르니 순환 참조가 생길 여지가 없습니다.

금액을 double로 계산하는 것은 예제에서만 괜찮습니다. 0.1이나 0.3 같은 값은 이진 부동소수점으로 정확히 표현되지 않아 여러 번 더하면 2.8000000000000003 같은 결과가 나오므로, 실제 가격 계산에서는 센트 단위 정수를 쓰는 것이 원칙입니다.


스트림에 압축·암호화 데코레이터 겹치기

#include <iostream>
#include <memory>
#include <string>
#include <algorithm>
class DataStream {
public:
    virtual void write(const std::string& data) = 0;
    virtual std::string read() = 0;
    virtual ~DataStream() = default;
};
class FileStream : public DataStream {
public:
    void write(const std::string& data) override {
        buffer = data;
        std::cout << "[File] Written: " << data << '\n';
    }
    
    std::string read() override {
        std::cout << "[File] Reading\n";
        return buffer;
    }
    
private:
    std::string buffer;
};
class StreamDecorator : public DataStream {
public:
    StreamDecorator(std::unique_ptr<DataStream> s)
        : stream(std::move(s)) {}
    
protected:
    std::unique_ptr<DataStream> stream;
};
class EncryptionDecorator : public StreamDecorator {
public:
    using StreamDecorator::StreamDecorator;
    
    void write(const std::string& data) override {
        std::string encrypted = encrypt(data);
        std::cout << "[Encryption] Encrypting\n";
        stream->write(encrypted);
    }
    
    std::string read() override {
        std::string encrypted = stream->read();
        std::cout << "[Encryption] Decrypting\n";
        return decrypt(encrypted);
    }
    
private:
    std::string encrypt(const std::string& data) {
        std::string result = data;
        std::reverse(result.begin(), result.end());
        return result;
    }
    
    std::string decrypt(const std::string& data) {
        return encrypt(data);  // 대칭
    }
};
class CompressionDecorator : public StreamDecorator {
public:
    using StreamDecorator::StreamDecorator;
    
    void write(const std::string& data) override {
        std::string compressed = compress(data);
        std::cout << "[Compression] Compressing\n";
        stream->write(compressed);
    }
    
    std::string read() override {
        std::string compressed = stream->read();
        std::cout << "[Compression] Decompressing\n";
        return decompress(compressed);
    }
    
private:
    std::string compress(const std::string& data) {
        return "[COMPRESSED]" + data;
    }
    
    std::string decompress(const std::string& data) {
        return data.substr(12);  // "[COMPRESSED]" 제거
    }
};
int main() {
    std::unique_ptr<DataStream> stream = std::make_unique<FileStream>();
    stream = std::make_unique<EncryptionDecorator>(std::move(stream));
    stream = std::make_unique<CompressionDecorator>(std::move(stream));
    
    stream->write("Hello, World!");
    std::string data = stream->read();
    std::cout << "Result: " << data << '\n';
}

write는 바깥에서 안으로, read는 안에서 바깥으로 흐릅니다. 가장 바깥의 CompressionDecorator가 먼저 압축하고, 그 결과를 EncryptionDecorator가 암호화해 FileStream에 씁니다. 읽을 때는 파일에서 꺼낸 데이터를 복호화한 뒤 압축을 풉니다. Java의 new BufferedInputStream(new FileInputStream(...))이 바로 이 구조이고, C++ 표준 라이브러리의 std::streambuf 계층도 비슷한 방식으로 버퍼링·변환을 쌓을 수 있습니다.

이 예제의 감싸는 순서는 우연이 아니라 올바른 선택입니다. 압축은 데이터의 반복 패턴을 이용하는데, 제대로 된 암호화는 결과를 난수처럼 보이게 만들어 패턴을 없애므로 암호화한 뒤에는 거의 압축되지 않습니다. 그래서 “압축 후 암호화”가 원칙이고, 이 예제처럼 압축 데코레이터를 바깥에 두어야 합니다. 순서를 바꿔도 컴파일도 되고 결과도 맞지만 압축 효과만 사라지므로, 이런 순서 제약은 코드 리뷰에서 놓치기 쉽습니다. 조립 순서를 뒤의 빌더나 팩토리 한 곳에 고정해 두는 이유가 이것입니다. 참고로 여기의 encrypt는 문자열을 뒤집을 뿐이고 compress는 접두어를 붙일 뿐인 흉내이며, 실제로는 zlib·zstd와 AES-GCM 같은 검증된 구현을 써야 합니다.


로거에 타임스탬프·레벨 데코레이터 붙이기

#include <iostream>
#include <memory>
#include <chrono>
#include <iomanip>
class Logger {
public:
    virtual void log(const std::string& message) = 0;
    virtual ~Logger() = default;
};
class ConsoleLogger : public Logger {
public:
    void log(const std::string& message) override {
        std::cout << message << '\n';
    }
};
class LoggerDecorator : public Logger {
public:
    LoggerDecorator(std::unique_ptr<Logger> l)
        : logger(std::move(l)) {}
    
protected:
    std::unique_ptr<Logger> logger;
};
class TimestampDecorator : public LoggerDecorator {
public:
    using LoggerDecorator::LoggerDecorator;
    
    void log(const std::string& message) override {
        auto now = std::chrono::system_clock::now();
        auto time = std::chrono::system_clock::to_time_t(now);
        std::cout << "[" << std::put_time(std::localtime(&time), "%Y-%m-%d %H:%M:%S") << "] ";
        logger->log(message);
    }
};
class LevelDecorator : public LoggerDecorator {
public:
    LevelDecorator(std::unique_ptr<Logger> l, const std::string& level)
        : LoggerDecorator(std::move(l)), level_(level) {}
    
    void log(const std::string& message) override {
        logger->log("[" + level_ + "] " + message);
    }
    
private:
    std::string level_;
};
int main() {
    std::unique_ptr<Logger> logger = std::make_unique<ConsoleLogger>();
    logger = std::make_unique<TimestampDecorator>(std::move(logger));
    logger = std::make_unique<LevelDecorator>(std::move(logger), "INFO");

    logger->log("Application started");
}

출력은 [2026-09-24 10:00:00] [INFO] Application started 형태가 됩니다. LevelDecorator가 메시지 앞에 [INFO]를 붙여 TimestampDecorator에 넘기고, 그다음 타임스탬프가 붙기 때문입니다. 감싸는 순서를 바꾸면 [INFO]와 시간의 위치가 바뀝니다.

TimestampDecorator에는 데코레이터 설계로서 결함이 하나 있습니다. 타임스탬프를 메시지에 붙여 내부 로거로 넘기지 않고 std::cout에 직접 출력합니다. 지금은 내부 로거도 콘솔이라 결과가 같아 보이지만, ConsoleLogger를 파일 로거로 바꾸면 타임스탬프만 콘솔에 찍히고 파일에는 시간이 빠진 메시지가 기록됩니다. 데코레이터는 자기 부수 효과를 따로 만들지 말고 입력을 변환해 내부 객체에 위임해야 조합이 안전합니다. 올바른 형태는 시간 문자열을 만들어 logger->log("[" + time + "] " + message)로 넘기는 것입니다. 또 std::localtime은 내부 정적 버퍼를 반환해 여러 스레드에서 동시에 호출하면 결과가 섞일 수 있으므로, 멀티스레드 로거에서는 localtime_r(POSIX)이나 localtime_s(Windows), C++20의 std::chrono::zoned_time과 std::format을 쓰는 것이 안전합니다.


원본 타입 손실과 감싸는 순서 문제

감싼 뒤 원본 타입의 기능에 접근할 수 없음

증상: Decorator로 감싸면 원본 타입 정보 손실.

// ❌ 감싼 뒤에는 원본의 고유 메서드에 접근할 수 없음
std::unique_ptr<Coffee> decorated =
    std::make_unique<MilkDecorator>(std::make_unique<SimpleCoffee>());
// decorated->specificMethod();  // 컴파일 에러: Coffee에 없는 메서드

// ⚠️ dynamic_cast로는 복원되지 않음: 가장 바깥 객체는 MilkDecorator
if (auto* simple = dynamic_cast<SimpleCoffee*>(decorated.get())) {
    simple->specificMethod();  // 여기에 도달하지 않음 (결과는 nullptr)
}

타입 손실은 Decorator의 구조적인 대가입니다. 감싼 결과는 Coffee 인터페이스로만 보이므로, SimpleCoffee에만 있는 메서드는 호출할 수 없습니다. 흔히 dynamic_cast로 해결하라고 하지만, 위 코드처럼 가장 바깥 객체의 실제 타입은 MilkDecorator라서 SimpleCoffee*로의 캐스트는 항상 실패합니다. 안쪽 객체에 닿으려면 각 데코레이터가 inner()를 제공해 한 겹씩 벗겨 내야 하는데, 그러면 클라이언트가 감싼 구조를 알아야 하므로 패턴의 장점이 사라집니다.

그래서 실무적인 해결책은 설계 단계에 있습니다. 클라이언트가 호출해야 하는 기능이라면 처음부터 Coffee 인터페이스에 올리고(데코레이터는 그냥 위임), 인터페이스에 올릴 수 없는 고유 기능이라면 감싸기 전에 원본에 대한 참조를 따로 보관해 둡니다. == 비교나 typeid로 객체를 식별하는 코드도 감싸는 순간 동작이 바뀌므로, 데코레이터를 적용할 객체는 식별이 아니라 인터페이스로만 다뤄지는 것이 전제 조건입니다. 제가 기존 코드에 데코레이터를 끼워 넣을 때 가장 자주 부딪힌 것도 이 부분으로, 어딘가에서 dynamic_cast로 구체 타입을 확인하던 코드가 로깅 데코레이터를 씌운 뒤부터 조용히 다른 분기를 타기 시작했습니다.

데코레이터 순서에 따라 결과가 달라짐

증상: Decorator 순서에 따라 결과가 다름.

// Encryption -> Compression vs Compression -> Encryption
// 결과가 다를 수 있음

순서 의존성은 두 종류로 나눠 생각하면 다루기 쉽습니다. 텍스트 포매터처럼 결과 자체가 달라지는 경우(<b><i>…</i></b>와 <i><b>…</b></i>)는 대개 눈에 보여 금방 발견됩니다. 위험한 것은 스트림 예제처럼 결과는 같지만 성질이 달라지는 경우입니다. 압축률, 성능, 보안 특성이 바뀌어도 기능 테스트는 통과하므로, 순서가 중요하다는 사실을 주석이나 빌더의 메서드 순서로 강제해야 합니다. 인증 데코레이터와 캐싱 데코레이터를 함께 쓴다면 캐시가 인증보다 안쪽에 있어야 권한 없는 요청이 캐시된 결과를 받지 못합니다.


빌더 스타일로 데코레이터 조립하기

class CoffeeBuilder {
    std::unique_ptr<Coffee> coffee;
public:
    CoffeeBuilder() : coffee(std::make_unique<SimpleCoffee>()) {}
    
    CoffeeBuilder& addMilk() {
        coffee = std::make_unique<MilkDecorator>(std::move(coffee));
        return *this;
    }
    
    CoffeeBuilder& addSugar() {
        coffee = std::make_unique<SugarDecorator>(std::move(coffee));
        return *this;
    }
    
    std::unique_ptr<Coffee> build() {
        return std::move(coffee);
    }
};
auto coffee = CoffeeBuilder()
    .addMilk()
    .addSugar()
    .build();

빌더는 std::make_unique<SugarDecorator>(std::make_unique<MilkDecorator>(...)) 같은 중첩 호출을 읽기 쉬운 메서드 체인으로 바꿔 줍니다. 더 중요한 이점은 조립 규칙을 한 곳에 모을 수 있다는 것입니다. 압축을 암호화보다 먼저 적용해야 한다면 StreamBuilder::build() 안에서 요청된 데코레이터를 정해진 순서로 감싸도록 구현하면, 사용자가 addEncryption()과 addCompression()을 어떤 순서로 호출하든 올바른 결과가 나옵니다.

build()가 std::move(coffee)로 내부 포인터를 내주므로, 한 번 build()한 빌더를 다시 쓰면 coffee가 비어 있어 addMilk()가 nullptr을 감싸고 이후 호출에서 크래시가 납니다. 이런 오용을 막으려면 build()를 && 한정자(std::unique_ptr<Coffee> build() &&)로 선언해 임시 빌더에서만 호출할 수 있게 하는 방법이 있습니다.


텍스트 포매터 만들기

#include <iostream>
#include <memory>
#include <string>
#include <algorithm>
class TextFormatter {
public:
    virtual std::string format(const std::string& text) = 0;
    virtual ~TextFormatter() = default;
};
class PlainTextFormatter : public TextFormatter {
public:
    std::string format(const std::string& text) override {
        return text;
    }
};
class FormatterDecorator : public TextFormatter {
public:
    FormatterDecorator(std::unique_ptr<TextFormatter> f)
        : formatter(std::move(f)) {}
    
protected:
    std::unique_ptr<TextFormatter> formatter;
};
class BoldDecorator : public FormatterDecorator {
public:
    using FormatterDecorator::FormatterDecorator;
    
    std::string format(const std::string& text) override {
        return "<b>" + formatter->format(text) + "</b>";
    }
};
class ItalicDecorator : public FormatterDecorator {
public:
    using FormatterDecorator::FormatterDecorator;
    
    std::string format(const std::string& text) override {
        return "<i>" + formatter->format(text) + "</i>";
    }
};
class UpperCaseDecorator : public FormatterDecorator {
public:
    using FormatterDecorator::FormatterDecorator;
    
    std::string format(const std::string& text) override {
        std::string result = formatter->format(text);
        std::transform(result.begin(), result.end(), result.begin(), ::toupper);
        return result;
    }
};
int main() {
    std::unique_ptr<TextFormatter> formatter = std::make_unique<PlainTextFormatter>();
    formatter = std::make_unique<BoldDecorator>(std::move(formatter));
    formatter = std::make_unique<ItalicDecorator>(std::move(formatter));
    formatter = std::make_unique<UpperCaseDecorator>(std::move(formatter));
    
    std::cout << formatter->format("Hello, World!") << '\n';
    // <I><B>HELLO, WORLD!</B></I>
}

출력의 태그까지 대문자가 된 것이 순서 의존성의 좋은 예입니다. UpperCaseDecorator가 가장 바깥에 있어 안쪽에서 이미 붙인 <i><b> 태그까지 변환했기 때문입니다. HTML은 태그 대소문자를 구분하지 않아 브라우저에서는 문제가 없지만, XML이나 대소문자를 구분하는 마크업이라면 결과가 깨집니다. UpperCaseDecorator를 가장 안쪽(PlainTextFormatter 바로 바깥)에 두면 본문만 대문자가 되고 태그는 소문자로 남습니다.

std::transform에 ::toupper를 넘기는 코드에도 함정이 있습니다. toupper는 int를 받는데 char가 부호 있는 타입인 플랫폼에서 한글 같은 UTF-8 바이트(128 이상)는 음수로 전달되고, EOF가 아닌 음수를 넘기는 것은 정의되지 않은 동작입니다. [](unsigned char c) { return std::toupper(c); }처럼 unsigned char로 바꿔 넘기는 것이 안전한 관용구입니다. 이렇게 해도 바이트 단위 변환이라 ASCII 외의 문자는 대문자로 바뀌지 않으며, 유니코드 대소문자 변환이 필요하면 ICU 같은 라이브러리를 써야 합니다.


Decorator 패턴 요약

개념설명
Decorator Pattern기능을 동적으로 추가
목적상속 없이 기능 확장
구조Component, ConcreteComponent, Decorator
장점조합 유연, OCP 준수, 런타임 추가
단점타입 손실, 순서 의존, 복잡도 증가
사용 사례스트림, 로깅, UI, 텍스트 포맷

Decorator Pattern은 기능을 동적으로 조합하는 강력한 패턴입니다.


FAQ

Q1: Decorator Pattern은 언제 쓰나요?

A: 기능을 동적으로 추가하며, 조합이 많아 상속으로 해결하기 어려울 때 사용합니다.

Q2: 상속 vs Decorator?

A: 상속은 정적, Decorator는 동적 조합이 가능합니다.

Q3: Adapter와 차이는?

A: Adapter는 인터페이스 변환, Decorator는 기능 추가에 집중합니다.

Q4: 성능 오버헤드는?

A: 데코레이터 한 겹마다 가상 함수 호출 한 번과 힙 할당 한 번이 추가됩니다. 대부분의 경우 무시할 수 있지만, 초당 수백만 번 호출되는 경로라면 가상 호출이 인라인을 막는 비용이 보일 수 있습니다. 조합이 컴파일 시점에 정해진다면 템플릿으로 감싸는 정적 데코레이터(Milk<Sugar<SimpleCoffee>>)나 믹스인으로 가상 호출 없이 같은 구조를 만들 수 있습니다.

Q5: 타입 손실 문제는?

A: dynamic_cast로 원본 타입을 복원하거나, Visitor Pattern을 사용하세요.

Q6: Decorator Pattern 학습 리소스는?

A:

  • “Design Patterns” by Gang of Four
  • “Head First Design Patterns” by Freeman & Freeman
  • Refactoring Guru: Decorator Pattern Decorator Pattern으로 기능을 동적으로 조합할 수 있습니다. 다음으로 Adapter Pattern을 읽어보면 좋습니다.

같이 보면 좋은 글