데코레이터 패턴, 상속 없이 객체에 기능 덧붙이기
데코레이터 패턴은 객체를 같은 인터페이스의 포장 객체로 감싸 원래 코드를 고치지 않고 런타임에 기능을 덧붙이는 구조 패턴이다. 상속의 클래스 폭발을 막는 원리와 자바 입출력 스트림 사례, 포장 순서 주의점을 코드로 정리했다.
데코레이터 패턴은 객체를 같은 인터페이스의 '포장 객체'로 감싸서, 원래 클래스를 고치지 않고 기능을 덧붙이는 구조 설계 패턴이다. 기능 조합마다 하위 클래스를 새로 만드는 상속과 달리 실행 시점에 필요한 기능만 겹겹이 끼워 넣는다. GoF(Gang of Four)의 책 『Design Patterns』(1994)가 정리한 구조 패턴 가운데 하나이고, 자바 표준 라이브러리의 입출력 스트림이 교과서적 사례다. 이 글은 객체지향 언어에서 클래스를 설계하는 관점의 데코레이터 패턴만 다룬다. 이름이 같은 파이썬의 @ 데코레이터 문법이나 자바스크립트 데코레이터 문법은 언어 차원의 별개 기능이라 여기서는 짚지 않는다.
데코레이터 패턴은 무엇을 해결하나
기능 조합이 폭발하는 문제를 푼다. 커피 주문을 떠올려 보자. 에스프레소 한 잔에 우유, 시럽, 휘핑을 마음대로 얹을 수 있다. 이걸 상속으로 풀면 우유에스프레소, 시럽우유에스프레소, 휘핑시럽우유에스프레소처럼 조합 개수만큼 클래스가 생긴다. 옵션이 하나 늘 때마다 클래스는 곱절로 불어난다.
데코레이터는 옵션 하나하나를 독립된 포장으로 만든다. 우유 포장, 시럽 포장, 휘핑 포장을 따로 두고 주문할 때 원하는 것만 골라 감싼다. GoF는 이 패턴을 “객체에 동적으로 책임을 덧붙이며, 상속을 대신할 유연한 방법”이라고 정의했다. 여기서 핵심은 포장 객체가 원본과 똑같은 인터페이스를 구현한다는 점이다. 그래서 감싼 뒤에도 바깥 코드는 대상이 원본인지 포장인지 구분하지 않고 같은 방식으로 호출한다.
상속과 무엇이 다른가
조합을 결정하는 시점이 다르다. 상속은 컴파일 시점에 클래스 계층이 고정되지만, 데코레이터는 프로그램이 도는 중에 객체를 조립한다. 사용자 설정이나 요청 값에 따라 어떤 기능을 몇 겹 쌓을지 그때그때 정할 수 있어, 플러그인처럼 기능을 갈아 끼우는 설계에 잘 맞는다.
로그 출력에 타임스탬프, 색상, 파일 저장을 붙이는 상황을 보자. 상속이라면 세 기능의 조합마다 클래스를 미리 만들어 둬야 한다. 데코레이터라면 기본 로거를 색상 포장으로 감싸고 다시 파일 포장으로 감싸는 식으로, 필요한 순서대로 런타임에 쌓으면 그만이다. 포장을 겹겹이 쌓으면 각 포장이 자기가 감싼 다음 객체를 참조하는데, 이는 노드가 다음 노드를 가리키는 연결 리스트 구조와 닮았다. 호출은 바깥 포장에서 안쪽 원본으로 흘러 들어가고, 결과는 반대로 거슬러 올라온다.

코드로 보는 네 가지 구성 요소
구조는 네 조각으로 나뉜다. 공통 인터페이스, 실제 일을 하는 원본, 원본을 품는 데코레이터 기반, 기능을 더하는 구체 데코레이터다. 데코레이터 기반을 별도로 두는 이유는 위임 코드를 한곳에 모아, 구체 데코레이터가 자기가 더할 동작에만 집중하게 하기 위해서다. 아래는 알림 전송에 기능을 덧붙이는 최소 예제다.
interface Notifier {
void send(String message);
}
class BaseNotifier implements Notifier {
public void send(String message) {
// 기본 전송
}
}
// 데코레이터 기반: 같은 인터페이스를 구현하고 원본을 품는다
abstract class NotifierDecorator implements Notifier {
protected final Notifier wrappee;
NotifierDecorator(Notifier wrappee) {
this.wrappee = wrappee;
}
}
class SlackNotifier extends NotifierDecorator {
SlackNotifier(Notifier wrappee) {
super(wrappee);
}
public void send(String message) {
wrappee.send(message); // 원본 동작 먼저 위임
// 슬랙 전송 추가
}
}바깥에서는 new SlackNotifier(new BaseNotifier())처럼 감싸기만 하면 된다. send를 호출하면 슬랙 포장이 먼저 원본 전송을 위임한 다음 자기 동작을 더한다. 포장을 하나 더 씌우면 기능이 한 겹 더 쌓이고, 원본 클래스 코드는 그대로 남는다.
언제 쓰고 무엇을 조심하나
런타임에 기능을 자유롭게 조합해야 하고 상속으로는 조합이 감당 안 될 때 쓴다. 자바 입출력이 대표적이다. FileInputStream을 BufferedInputStream으로 감싸 버퍼링을 더하는 식으로 스트림 클래스들이 서로를 포장한다. 오라클의 자바 학습 문서(dev.java)는 이 스트림 계층을 데코레이터로 설명하고, java.io의 FilterInputStream이 원본 스트림을 품는 데코레이터 기반 역할을 맡는다.
대가도 분명하다. Refactoring.Guru의 데코레이터 문서가 지적하듯 작은 클래스가 여러 개 생기고, 데코레이터 스택 중간의 특정 포장만 걷어내기가 번거롭다. 무엇보다 포장 순서에 동작이 좌우된다. 압축 포장과 암호화 포장의 순서를 바꾸면 결과가 완전히 달라진다. 옵션이 두세 개로 고정이고 앞으로 늘 일이 없다면, 데코레이터가 만드는 간접 계층은 오히려 코드를 읽기 어렵게 한다. 그럴 땐 단순한 조건 분기나 상속이 더 정직한 선택이다.





