객체에 기능을 더하는 가장 익숙한 방법은 상속이다. 하지만 조합해야 할 기능이 여러 개면 상속은 금방 한계에 부딪힌다. 커피에 우유, 설탕, 휘핑을 얹는 경우를 생각해보자. MilkCoffee, SugarCoffee, MilkSugarCoffee, MilkSugarWhipCoffee 처럼 조합마다 서브클래스를 만들면 클래스 수가 조합적으로 폭발한다. Decorator 는 상속 대신 조합으로 이 문제를 푼다.
공통 의도
Decorator 의 의도는 세 가지다.
- 동일 인터페이스 유지 — 데코레이터는 감싸는 대상과 같은 인터페이스를 구현한다. 클라이언트는 원본인지 데코레이터인지 구분하지 않는다.
- 런타임 조합 — 어떤 기능을 얹을지 컴파일 시점이 아니라 런타임에 결정한다. 데코레이터를 겹겹이 감싸 조합을 만든다.
- 단일 책임 — 각 데코레이터는 하나의 기능만 더한다. 조합은 감싸는 순서로 표현된다.
GoF 원형
GoF 는 네 역할로 정의한다. Component, ConcreteComponent, Decorator, ConcreteDecorator.
Component 는 공통 인터페이스다. ConcreteComponent 가 원본 객체이고, Decorator 는 Component 를 구현하면서 내부에 다른 Component 를 참조로 들고 있다. ConcreteDecorator 가 그 참조에 자기 기능을 더해 위임한다. 감싸고, 위임하고, 더하는 구조가 재귀적으로 겹쳐진다.
flowchart LR
C["Whip"] --> B["Milk"]
B --> A["Espresso"]
A -.cost 3000.-> B
B -.+ 500.-> C
C -.+ 700.-> R["4200"]
interface Coffee {
int cost();
}
class Espresso implements Coffee {
public int cost() { return 3000; }
}
abstract class CoffeeDecorator implements Coffee {
protected final Coffee inner;
CoffeeDecorator(Coffee inner) { this.inner = inner; }
}
class Milk extends CoffeeDecorator {
Milk(Coffee inner) { super(inner); }
public int cost() { return inner.cost() + 500; }
}
new Milk(new Espresso()) 는 우유를 얹은 에스프레소다. new Whip(new Milk(new Espresso())) 처럼 겹치면 조합이 감싸는 순서로 표현된다. 각 래퍼가 같은 Coffee 인터페이스를 유지하므로 얼마든지 겹칠 수 있다.
언어별 구현
Java 의 java.io 가 교과서적 사례다. new BufferedInputStream(new FileInputStream(file)) 는 파일 스트림에 버퍼링을 더한 데코레이터다. BufferedInputStream 은 InputStream 을 구현하면서 다른 InputStream 을 감싸므로, 인터페이스를 그대로 유지한 채 기능만 더한다. 같은 FilterInputStream 계열을 겹치면 데코레이터 체인이 된다.
Python 은 데코레이터가 두 가지라 혼동하기 쉽다. 언어의 @decorator 문법은 함수를 감싸는 고차함수로, GoF 의 객체 Decorator 와 의도는 같지만 형태가 다르다.
def with_logging(func):
def wrapper(*args, **kwargs):
print(f"calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@with_logging
def process(): ...
@with_logging 은 함수 수준에서 기능을 감싼다. 객체 단위 Decorator 가 필요하면 같은 인터페이스를 만족하는 래퍼 클래스를 따로 만든다. 함수 데코레이터는 함수 수준, GoF Decorator 는 객체 수준이라는 차이가 있다.
TypeScript 는 클래스를 래핑하거나 미들웨어 체인으로 표현한다. Express 의 미들웨어가 요청 객체를 겹겹이 통과시키며 처리하는 흐름이 사실상 Decorator 다. 각 미들웨어가 요청을 받아 기능을 더하고 다음으로 넘긴다.
Proxy, Adapter 와의 구분
셋 다 객체를 감싸지만 의도가 다르다.
- Decorator — 기능을 더한다. 인터페이스는 그대로 유지한다.
- Proxy — 접근을 제어한다. 지연 로딩, 권한 검사, 캐싱이 목적이고 기능 추가가 아니다.
- Adapter — 인터페이스를 변환한다. 호환되지 않는 두 인터페이스를 잇는 게 목적이다.
감싸는 형태는 비슷해도 푸는 문제가 다르다. Decorator 는 기능, Proxy 는 접근, Adapter 는 호환이다.
결론
Decorator 는 상속의 조합 폭발을 조합으로 대체한다. 같은 인터페이스를 유지하는 래퍼를 런타임에 겹쳐 기능을 동적으로 더한다. 선택 기준은 단순하다.
- 기능 조합이 여러 갈래이고 런타임에 결정되는가. 그렇다면 상속보다 Decorator.
- 감싸는 목적이 기능 추가가 아니라 접근 제어나 인터페이스 변환인가. 그렇다면 Proxy 나 Adapter.
Java 의 java.io, Python 의 함수 데코레이터, 웹 미들웨어 체인이 모두 같은 의도의 변주다. 형태가 비슷한 Proxy·Adapter 와는 푸는 문제로 갈린다.