객체에 기능을 더하는 가장 익숙한 방법은 상속이다. 하지만 조합해야 할 기능이 여러 개면 상속은 금방 한계에 부딪힌다. 커피에 우유, 설탕, 휘핑을 얹는 경우를 생각해보자. 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 인터페이스를 유지하므로 얼마든지 겹칠 수 있다.

언어별 구현

Javajava.io 가 교과서적 사례다. new BufferedInputStream(new FileInputStream(file)) 는 파일 스트림에 버퍼링을 더한 데코레이터다. BufferedInputStreamInputStream 을 구현하면서 다른 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 와는 푸는 문제로 갈린다.

참고

  • Factory — 객체 생성을 다루는 같은 GoF 시리즈
  • Builder — 패턴이 언어 기능으로 흡수되는 흐름의 다른 사례
  • GoF — Design Patterns: Elements of Reusable Object-Oriented Software (1994)