데이터를 저장할 때 압축하고, 암호화하고, 바이트 수를 세는 기능을 필요에 따라 조합해 쓴다고 하자. 조합마다 서브클래스를 만들면 CompressedEncryptedCountingSink 같은 클래스가 끝없이 늘어나고, 조합 순서도 상속 구조에 고정된다. 데코레이터(Decorator)는 같은 인터페이스를 구현하는 래퍼 객체로 원래 객체를 감싸, 실행 시점에 기능을 원하는 순서대로 덧붙이게 한다. 이 글에서는 스트림 데코레이터와 쌓는 순서의 영향, 람다로 만든 함수 데코레이터, 템플릿 믹스인, 감싼 객체의 정체성 문제, 그리고 C의 writer 체인을 다룬다.
이 시리즈의 다른 글
- C/C++ 디자인 패턴 (7) — 브리지(Bridge)
- C/C++ 디자인 패턴 (8) — 컴포지트(Composite)
- C/C++ 디자인 패턴 (10) — 퍼사드(Facade)
- C/C++ 디자인 패턴 (11) — 플라이웨이트(Flyweight)
구조
| 참여자 | 역할 | 예제 |
|---|---|---|
| Component | 원래 객체와 데코레이터의 공통 인터페이스 | Sink |
| ConcreteComponent | 기능을 덧붙일 원래 객체 | MemorySink |
| Decorator | Component를 감싸고 요청을 전달 | SinkDecorator |
| ConcreteDecorator | 전달 전후에 기능 추가 | RleCompress, XorCipher, Counter |
// sink.hpp — Component와 ConcreteComponent, 그리고 데코레이터 기반 클래스
#pragma once
#include <memory>
#include <string>
struct Sink { // Component
virtual ~Sink() = default;
virtual void write(const std::string &data) = 0;
};
struct MemorySink : Sink { // ConcreteComponent
std::string stored;
void write(const std::string &d) override { stored += d; }
};
class SinkDecorator : public Sink { // Decorator
public:
explicit SinkDecorator(std::unique_ptr<Sink> inner) : inner_(std::move(inner)) {}
protected:
void forward(const std::string &d) { inner_->write(d); }
private:
std::unique_ptr<Sink> inner_;
};스트림 데코레이터와 순서
압축(RLE)과 바이트 변환(위치마다 키가 바뀌는 XOR)을 두 순서로 쌓아 같은 64바이트를 써 본다.
// decorators.cpp — 기능을 감싸서 덧붙이고, 순서를 바꿔 쌓아 본다
#include <iostream>
#include "sink.hpp"
struct RleCompress : SinkDecorator { // 같은 문자의 연속을 "개수+문자"로
using SinkDecorator::SinkDecorator;
void write(const std::string &d) override
{
std::string out;
for (size_t i = 0; i < d.size();) {
size_t j = i;
while (j < d.size() && d[j] == d[i] && j - i < 9)
++j;
out += char('0' + (j - i));
out += d[i];
i = j;
}
forward(out);
}
};
struct XorCipher : SinkDecorator { // 데모용 바이트 단위 변환(암호 아님)
XorCipher(std::unique_ptr<Sink> in, unsigned char key) : SinkDecorator(std::move(in)), key_(key) {}
void write(const std::string &d) override
{
std::string out = d;
for (size_t i = 0; i < out.size(); ++i)
out[i] ^= key_ + i; // 위치마다 다른 키 → 반복 패턴이 사라짐
forward(out);
}
unsigned char key_;
};
struct Counter : SinkDecorator { // 지나가는 바이트 수만 센다
using SinkDecorator::SinkDecorator;
void write(const std::string &d) override { bytes += d.size(); forward(d); }
size_t bytes = 0;
};
int main()
{
const std::string payload(64, 'A');
auto m1 = std::make_unique<MemorySink>(); auto *s1 = m1.get();
RleCompress compressThenCipher(std::make_unique<XorCipher>(std::move(m1), 0x5a));
compressThenCipher.write(payload);
auto m2 = std::make_unique<MemorySink>(); auto *s2 = m2.get();
XorCipher cipherThenCompress(std::make_unique<RleCompress>(std::move(m2)), 0x5a);
cipherThenCompress.write(payload);
auto m3 = std::make_unique<MemorySink>(); auto *s3 = m3.get();
Counter counted(std::move(m3));
counted.write(payload);
std::cout << "plain : " << s3->stored.size() << " bytes (counter=" << counted.bytes << ")\n"
<< "compress -> cipher : " << s1->stored.size() << " bytes\n"
<< "cipher -> compress : " << s2->stored.size() << " bytes\n";
}$ g++ -std=c++20 -Wall -Wextra -O2 -o decorators decorators.cpp && ./decorators
plain : 64 bytes (counter=64)
compress -> cipher : 16 bytes
cipher -> compress : 128 bytes
압축을 먼저 하면 64바이트가 16바이트가 되지만, 변환을 먼저 하면 반복 패턴이 사라져 압축이 오히려 두 배로 부풀린다. 실제 암호화·압축 스트림을 조합할 때도 “압축 후 암호화”가 원칙인 이유다.
함수 데코레이터
감쌀 대상이 객체가 아니라 함수라면 람다를 반환하는 함수 템플릿으로 충분하다. 재시도와 시간 측정을 각각 만들고 합성했다.
// func_deco.cpp — 함수를 감싸는 함수: 재시도·시간 측정 데코레이터
#include <chrono>
#include <functional>
#include <iostream>
#include <stdexcept>
#include <string>
#include <thread>
template <class F>
auto retry(F f, int attempts)
{
return [f, attempts](auto &&...args) {
for (int i = 1;; ++i) {
try {
return f(args...);
} catch (const std::exception &e) {
std::cout << " attempt " << i << " failed: " << e.what() << '\n';
if (i == attempts)
throw;
}
}
};
}
template <class F>
auto timed(std::string name, F f)
{
return [name, f](auto &&...args) {
auto t0 = std::chrono::steady_clock::now();
auto r = f(args...);
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - t0).count();
std::cout << " " << name << " took ~" << ms << " ms\n";
return r;
};
}
int main()
{
int calls = 0;
auto flaky = [&calls](int x) { // 처음 두 번은 실패하는 네트워크 호출 흉내
std::this_thread::sleep_for(std::chrono::milliseconds(20));
if (++calls < 3)
throw std::runtime_error("timeout");
return x * 2;
};
auto robust = timed("fetch", retry(flaky, 5)); // 데코레이터 합성
int r = robust(21);
std::cout << "result=" << r << " calls=" << calls << '\n';
calls = 0;
try {
retry(flaky, 2)(1);
} catch (const std::exception &e) {
std::cout << "gave up: " << e.what() << '\n';
}
}$ g++ -std=c++20 -Wall -Wextra -O2 -o func_deco func_deco.cpp && ./func_deco
attempt 1 failed: timeout
attempt 2 failed: timeout
fetch took ~60 ms
result=42 calls=3
attempt 1 failed: timeout
attempt 2 failed: timeout
gave up: timeout
timed(retry(f))라서 재시도 두 번을 포함한 전체 시간(20ms × 3회)이 한 번 찍혔다. 두 번째 호출처럼 시도 횟수를 넘기면 마지막 예외가 그대로 밖으로 전달된다.
템플릿 믹스인: 컴파일 시점 데코레이터
조합이 컴파일 시점에 정해진다면 템플릿 인자로 받은 기반 클래스를 상속하는 믹스인으로 쌓을 수 있다. 가상 함수와 힙 할당이 없다.
// mixin.cpp — 템플릿 상속으로 컴파일 시점에 쌓는 데코레이터(믹스인)
#include <iostream>
#include <string>
struct ConsoleLog {
void log(const std::string &m) { std::cout << m << '\n'; }
};
template <class Base>
struct WithLevel : Base {
void log(const std::string &m) { Base::log("[INFO] " + m); }
};
template <class Base>
struct WithCounter : Base {
int n = 0;
void log(const std::string &m) { Base::log("#" + std::to_string(++n) + " " + m); }
};
int main()
{
WithCounter<WithLevel<ConsoleLog>> a; // 번호 → 레벨 → 출력
WithLevel<WithCounter<ConsoleLog>> b; // 레벨 → 번호 → 출력
a.log("started"); a.log("ready");
b.log("started");
std::cout << "sizeof a=" << sizeof(a) << ", no vtable: "
<< std::boolalpha << !std::is_polymorphic_v<decltype(a)> << '\n';
}$ g++ -std=c++20 -Wall -Wextra -O2 -o mixin mixin.cpp && ./mixin
[INFO] #1 started
[INFO] #2 ready
#1 [INFO] started
sizeof a=4, no vtable: true
타입 이름의 중첩 순서가 그대로 처리 순서가 된다. 대신 실행 중에 데코레이터를 붙이거나 뗄 수는 없다.
감싼 객체의 정체성
// identity.cpp — 감싼 객체는 원래 타입이 아니다
#include <iostream>
#include "sink.hpp"
struct Counter : SinkDecorator {
using SinkDecorator::SinkDecorator;
void write(const std::string &d) override { bytes += d.size(); forward(d); }
size_t bytes = 0;
};
int main()
{
auto mem = std::make_unique<MemorySink>();
MemorySink *raw = mem.get();
std::unique_ptr<Sink> s = std::make_unique<Counter>(std::move(mem));
s->write("hello");
std::cout << "dynamic_cast<MemorySink*>(decorated) = " << dynamic_cast<MemorySink *>(s.get()) << '\n'
<< "same address as inner? " << std::boolalpha << (static_cast<void *>(s.get()) == raw) << '\n'
<< "inner still got the data: " << raw->stored << '\n';
}$ g++ -std=c++20 -Wall -Wextra -O2 -o identity identity.cpp && ./identity
dynamic_cast<MemorySink*>(decorated) = 0
same address as inner? false
inner still got the data: hello
감싼 뒤에는 dynamic_cast로 원래 타입을 찾을 수 없고 주소도 다르다. 타입 검사나 주소 비교에 의존하는 코드는 데코레이터와 함께 쓰면 깨진다.
C 구현: writer 체인
/* chain.c — C 데코레이터: 같은 시그니처의 writer를 next로 연결한다 */
#include <ctype.h>
#include <stdio.h>
#include <string.h>
struct writer {
void (*write)(struct writer *w, const char *s);
struct writer *next; /* 감싼 대상 */
int count;
};
static void stdout_write(struct writer *w, const char *s) { (void)w; fputs(s, stdout); }
static void upper_write(struct writer *w, const char *s)
{
char buf[256];
size_t i;
for (i = 0; s[i] && i < sizeof(buf) - 1; i++)
buf[i] = (char)toupper((unsigned char)s[i]);
buf[i] = '\0';
w->next->write(w->next, buf);
}
static void counting_write(struct writer *w, const char *s)
{
w->count += (int)strlen(s);
w->next->write(w->next, s);
}
int main(void)
{
struct writer out = { stdout_write, NULL, 0 };
struct writer upper = { upper_write, &out, 0 };
struct writer counter = { counting_write, &upper, 0 }; /* counter -> upper -> stdout */
counter.write(&counter, "hello, ");
counter.write(&counter, "decorator\n");
printf("counted %d bytes\n", counter.count);
return 0;
}$ gcc -std=c11 -Wall -Wextra -O2 -o chain chain.c && ./chain
HELLO, DECORATOR
counted 17 bytes
모든 writer가 같은 함수 포인터 시그니처를 갖고 next로 이어진다. 체인을 구성하는 쪽만 순서를 알고, 쓰는 쪽은 맨 앞 writer 하나만 안다.
상속·프록시와 비교
| 구분 | 상속 | 데코레이터 | 프록시 |
|---|---|---|---|
| 조합 시점 | 컴파일 | 실행(믹스인은 컴파일) | 보통 생성 시 고정 |
| 조합 수 | 조합마다 클래스 | 기능마다 클래스 하나 | – |
| 목적 | is-a 관계 표현 | 기능 추가 | 접근 제어 |
| 여러 겹 | 불가 | 자연스러움 | 드묾 |
주의사항
- 인터페이스가 크면 전달 코드가 많다: Component의 모든 메서드를 데코레이터가 전달해야 한다. 기반 데코레이터 클래스에서 기본 전달을 한 번만 구현하고, ConcreteDecorator는 바꿀 메서드만 재정의한다.
- 순서는 의미다: 위 실행 결과처럼 같은 데코레이터라도 쌓는 순서에 따라 결과가 완전히 달라진다. 조립 코드를 한 곳(팩토리·빌더)에 모아 순서를 통제한다.
- flush와 close의 전파: 버퍼링하는 데코레이터는 소멸이나
close()때 남은 데이터를 안쪽으로 내보내야 한다. 바깥 데코레이터부터 차례로 닫히도록 소유 구조를 맞춘다. - 함수 데코레이터의 캡처: 예제의
retry는f를 값으로 캡처한다. 상태를 가진 함수 객체를 감싸면 복사본이 생겨 원본의 상태가 바뀌지 않을 수 있으니, 공유가 필요하면std::ref나 참조 캡처를 쓴다. - 예제의 XOR는 암호가 아니다: 순서 효과를 보이기 위한 바이트 변환일 뿐이다. 실제 암호화는 검증된 라이브러리를 쓴다.
마무리
데코레이터는 “기능 하나당 클래스 하나”로 조합 폭발을 막고, 조합을 실행 시점으로 미룬다. 객체 래퍼, 람다 래퍼, 템플릿 믹스인 모두 같은 생각의 다른 표현이다. 다음 글에서는 복잡한 하위 시스템 앞에 단순한 창구를 두는 퍼사드(Facade)를 다룬다.
참고
- Erich Gamma 외, 『Design Patterns: Elements of Reusable Object-Oriented Software』(GoF), Decorator
- cppreference — Lambda expressions