포스트

Decorator Pattern

상속으로 기능을 조합하면 클래스 수가 폭발한다. Component를 감싸는 Decorator를 위임으로 쌓아 동적으로 기능을 추가하는 구조.

Decorator Pattern

난이도 입문 · 선행 없음

한 줄 요약

객체를 같은 인터페이스를 가진 래퍼로 감싸 기능을 더한다. 상속 대신 위임을 쌓는 방식이라, 런타임에 기능 조합을 자유롭게 만들 수 있다.

어떤 문제를 푸는가

카페 음료 가격표를 만든다고 해보자. 에스프레소·하우스블렌드 같은 베이스에 우유·모카·휘핑을 자유롭게 얹는다. 이걸 상속으로 풀면 이렇게 된다.

1
2
3
4
5
6
7
class Beverage { /* ... */ };
class Espresso : public Beverage { /* ... */ };
class EspressoWithMilk : public Espresso { /* ... */ };
class EspressoWithMilkAndMocha : public EspressoWithMilk { /* ... */ };
class EspressoWithMilkAndMochaAndWhip : public EspressoWithMilkAndMocha { /* ... */ };
class HouseBlendWithMilk : public HouseBlend { /* ... */ };
// 베이스 × 토핑 조합만큼 클래스가 폭발한다

문제:

  • 토핑 N개면 조합이 2ⁿ. 새 토핑 하나 추가할 때마다 기존 클래스 절반을 다시 만든다.
  • 손님이 에스프레소에 휘핑을 두 번 올려달라고 하면? 그 조합용 클래스를 또 만들어야 한다.
  • 컴파일 타임에 모든 조합이 고정된다. 런타임 조합 불가.

패턴 적용 후

베이스 음료와 토핑을 같은 Beverage 인터페이스로 통일하고, 토핑이 베이스를 감싸는 형태로 위임한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
#include <iostream>
#include <memory>
#include <string>

class Beverage {
public:
    virtual std::string description() const = 0;
    virtual int cost() const = 0;
    virtual ~Beverage() = default;
};

class Espresso : public Beverage {
public:
    std::string description() const override { return "에스프레소"; }
    int cost() const override { return 3000; }
};

class HouseBlend : public Beverage {
public:
    std::string description() const override { return "하우스블렌드"; }
    int cost() const override { return 2500; }
};

class CondimentDecorator : public Beverage {
protected:
    std::unique_ptr<Beverage> beverage;
public:
    CondimentDecorator(std::unique_ptr<Beverage> b) : beverage(std::move(b)) {}
};

class Milk : public CondimentDecorator {
public:
    using CondimentDecorator::CondimentDecorator;
    std::string description() const override { return beverage->description() + " + 우유"; }
    int cost() const override { return beverage->cost() + 500; }
};

class Mocha : public CondimentDecorator {
public:
    using CondimentDecorator::CondimentDecorator;
    std::string description() const override { return beverage->description() + " + 모카"; }
    int cost() const override { return beverage->cost() + 700; }
};

class Whip : public CondimentDecorator {
public:
    using CondimentDecorator::CondimentDecorator;
    std::string description() const override { return beverage->description() + " + 휘핑"; }
    int cost() const override { return beverage->cost() + 300; }
};

int main() {
    // 에스프레소 + 모카 + 휘핑 + 휘핑 (휘핑 추가샷)
    auto drink = std::make_unique<Whip>(
        std::make_unique<Whip>(
            std::make_unique<Mocha>(
                std::make_unique<Espresso>()
            )
        )
    );
    std::cout << drink->description() << ": " << drink->cost() << "원" << std::endl;
    // 출력: 에스프레소 + 모카 + 휘핑 + 휘핑: 4300원
}

달라진 점:

  • 새 토핑 추가 = CondimentDecorator 하위 클래스 하나만 추가. 기존 코드 무손상.
  • 토핑을 몇 번이고 중첩할 수 있다 (휘핑 두 번 같은 케이스).
  • 런타임에 조합이 결정된다.

구조

1
2
3
4
5
6
7
Beverage (interface)
    ▲
    ├──── Espresso, HouseBlend  (ConcreteComponent: 베이스)
    │
    └──── CondimentDecorator   ──has──▶ Beverage
              ▲
              ├── Milk, Mocha, Whip   (ConcreteDecorator: 래퍼)
  • Component: 공통 인터페이스 (Beverage)
  • ConcreteComponent: 기능을 갖는 실체 (Espresso)
  • Decorator: Component를 보유하면서 같은 인터페이스를 구현 (CondimentDecorator)
  • ConcreteDecorator: 호출 전·후에 기능을 덧붙임 (Milk)

핵심은 Decorator도 Beverage라는 점이다. 그래서 Decorator를 다시 다른 Decorator로 감쌀 수 있다.

출처: Decorator Pattern - Wikipedia

실전 사례

  • Java java.io: new BufferedReader(new InputStreamReader(new FileInputStream(...)))Reader 인터페이스 위에 버퍼링·인코딩 변환 데코레이터를 쌓는 정통 데코레이터.
  • C++ 스트림 조작자(manipulator): std::hex·std::setw는 stream formatting 상태를 바꾸는 관련 조합 기법이지, 같은 Component 인터페이스로 객체를 감싸는 GoF Decorator의 직접 사례는 아니다.
  • HTTP 미들웨어: 인증·로깅·압축을 핸들러에 차례로 덧붙이는 구조도 같은 형태.

Proxy Pattern과의 차이

구조가 똑같다. 둘 다 같은 인터페이스를 가진 객체로 감싼다. 차이는 의도:

 DecoratorProxy
무엇을 하나기능을 추가접근을 제어
누가 결정클라이언트가 어떤 데코를 쌓을지 정함프록시 자체가 접근 규칙을 가짐
중첩 의도여러 겹 쌓는 게 정상보통 한 겹

클래스 이름이 의도를 드러내야 한다. LoggingBeverage는 데코레이터, AuthorizedFileAccess는 프록시.

자세한 비교는 Proxy 패턴 글 참고.

안티패턴 / 주의

  • 순서 의존성이 강하면 위험. Encrypt(Compress(x))Compress(Encrypt(x))는 결과가 다르다. 데코레이터를 쌓는 순서가 의미를 갖는 패턴이면 사용자 실수가 잦다.
  • 디버깅 비용. 호출 스택이 데코레이터마다 한 칸씩 깊어진다. 5겹 쌓으면 스택 트레이스가 길어진다.
  • 타입이 같아 보이지만 동작이 다름. Beverage*를 받는 함수는 그게 단순 에스프레소인지 휘핑 두 번 얹은 에스프레소인지 구분 못 한다. 의도된 추상화지만 디버깅 때 헷갈린다.
  • 데코레이터가 한 종류뿐이면 굳이 도입하지 마라. 그냥 멤버 변수가 낫다.

스스로 점검

1. 손님이 “에스프레소에 휘핑을 두 번 + 모카 한 번” 주문했다. 위 코드로 어떻게 표현?

Whip(Whip(Mocha(Espresso()))) — 같은 데코레이터를 중첩해서 쌓을 수 있다. 상속이라면 이런 조합용 클래스를 또 만들어야 하지만, 데코레이터는 런타임 조합이 자유롭다.

2. 구조는 똑같은데 Decorator인지 Proxy인지 구분하는 기준은?

의도. 기능을 추가하면 Decorator(LoggingBeverage), 접근을 제어하면 Proxy(AuthorizedFileAccess). 같은 코드 구조라도 클래스 이름이 의도를 드러내야 한다.

3. Encrypt(Compress(data))Compress(Encrypt(data))가 결과가 다른 이유는?

데코레이터는 순서 의존적. 압축은 패턴 있는 데이터에서 효율적인데, 암호화된 데이터는 패턴이 없어서 압축이 안 된다. 데코레이터 순서가 의미를 갖는 경우 사용자 실수 위험이 크다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.