"커피 주문 시스템을 만드는데, 에스프레소에 물을 추가하면 아메리카노, 우유를 추가하면 라떼, 거기에 시럽을 추가하면 바닐라 라떼가 됩니다."
이 모든 조합을 EspressoWithWaterAndMilkAndSyrup 같은 클래스로 상속받아 만들면 클래스 개수가 수백 개로 폭발합니다. 이를 해결하기 위해 객체에 장식(Decorate)을 덧입히듯 기능을 동적으로 추가하는 것이 데코레이터 패턴입니다.
1. 방식 1: 클래식 데코레이터 (Object Wrapping) - "마트료시카 인형"
GoF 디자인 패턴의 정석이자, Java I/O (InputStream)의 근간이 되는 방식입니다. 객체가 다른 객체를 감싸고, 그 객체가 또 다른 객체를 감싸는 양파 껍질 같은 구조를 가집니다.
특징
- 투명성: 데코레이터와 실제 객체가 같은 인터페이스를 구현하므로, 클라이언트는 자신이 껍질을 다루는지 알맹이를 다루는지 모릅니다.
- 유연성: 실행 시점(Runtime)에 마음대로 기능을 조합할 수 있습니다.
코드 예시 (커피 제조)
// 1. 공통 인터페이스
interface Coffee {
String getDescription();
double getCost();
}
// 2. 기본 객체 (알맹이)
class BasicCoffee implements Coffee {
public String getDescription() { return "커피"; }
public double getCost() { return 5.0; }
}
// 3. 데코레이터 (장식자들의 부모)
abstract class CoffeeDecorator implements Coffee {
protected Coffee coffee; // 감싸질 대상
public CoffeeDecorator(Coffee coffee) { this.coffee = coffee; }
}
// 4. 구체적 장식 (우유 추가)
class MilkDecorator extends CoffeeDecorator {
public MilkDecorator(Coffee coffee) { super(coffee); }
@Override
public String getDescription() {
return coffee.getDescription() + ", 우유"; // 기존 기능 + 장식
}
@Override
public double getCost() {
return coffee.getCost() + 1.5; // 기존 가격 + 장식 가격
}
}
// 5. 사용 (Client)
Coffee myCoffee = new BasicCoffee(); // 그냥 커피
myCoffee = new MilkDecorator(myCoffee); // 우유 추가
myCoffee = new SugarDecorator(myCoffee); // 설탕 추가 (또 감쌈)
System.out.println(myCoffee.getDescription()); // "커피, 우유, 설탕"
장단점
- 장점: 상속 없이도 기능을 무한대로 확장할 수 있습니다. (OCP 준수)
- 단점:
- 자잘한 클래스 폭발: Milk, Sugar, Syrup, Whip 등 장식용 클래스를 일일이 다 만들어야 합니다.
- 디버깅 어려움: 객체가 겹겹이 쌓여 있어서, 에러가 났을 때 어느 껍질에서 문제가 생겼는지 추적하기 힘듭니다.
2. 방식 2: 함수형 데코레이터 (Functional Chaining) - "람다 활용"
Java 8 이후 등장한 현대적인 방식입니다. 굳이 데코레이터 클래스를 만들지 않고, 함수(Function)의 결합을 통해 똑같은 효과를 냅니다.
특징
- Function<T, R> 인터페이스의 andThen()이나 compose() 메서드를 활용하여 기능을 체이닝합니다.
- 클래스 파일을 만들 필요가 없습니다.
코드 예시
import java.util.function.Function;
public class FunctionalCoffee {
public static void main(String[] args) {
// 1. 기본 커피 가격 (Function 정의)
Function<Integer, Integer> basicCoffee = cost -> cost + 5000;
// 2. 장식 정의 (람다)
Function<Integer, Integer> addMilk = cost -> cost + 1000;
Function<Integer, Integer> addSyrup = cost -> cost + 500;
// 3. 기능 조합 (데코레이터 패턴의 함수형 버전)
Function<Integer, Integer> myLatte = basicCoffee
.andThen(addMilk)
.andThen(addSyrup);
// 4. 실행
System.out.println("가격: " + myLatte.apply(0)); // 6500
}
}
장단점
- 장점: 코드가 압도적으로 간결해지며, 클래스 관리가 필요 없습니다. 단순히 로직을 이어 붙이는 경우에 최적입니다.
- 단점: 상태(State)를 가지는 복잡한 데코레이터(예: BufferedInputStream처럼 내부에 버퍼를 저장해야 하는 경우)를 구현하기에는 람다만으로는 한계가 있습니다.
3. 실무 예시: 어디서 쓰이나요?
1) Java I/O (Classic)
// 파일 -> 버퍼링 -> 객체 변환 (3단 합체)
ObjectInputStream ois = new ObjectInputStream(
new BufferedInputStream(
new FileInputStream("data.txt")
)
);
이 코드가 복잡해 보이는 이유는 BufferedInputStream이 FileInputStream을 장식(Decorate)하고 있기 때문입니다.
2) Spring Security / AOP (Proxy is Dynamic Decorator)
사실 프록시 패턴과 구조는 거의 같지만 목적이 다릅니다.
Spring AOP가 @Transactional을 처리할 때, 실제 서비스 객체를 감싸서 트랜잭션 시작/종료 기능을 덧붙입니다. 이는 기술적으로 동적 데코레이터에 해당합니다.
요약: 클래스를 만들까, 함수를 엮을까?
| 구분 | Classic Decorator (Object) | Functional Decorator (Lambda) |
| 구조 | 클래스 상속 및 생성자 주입 (new A(new B())) | 함수 합성 (funcA.andThen(funcB)) |
| 상태 관리 | 가능 (멤버 변수 활용 용이) | 불가능/어려움 (Stateless 로직 위주) |
| 복잡도 | 높음 (클래스 파일 다수 생성) | 낮음 (메서드/람다로 해결) |
| 추천 상황 | Java I/O 처럼 기능과 상태가 함께 추가될 때 | 데이터 변환, 필터링 등 순수 로직을 덧붙일 때 |
결론
객체에 상태(필드)가 필요한 기능을 추가해야 한다면 정석적인 클래식 데코레이터 패턴을 사용하세요.
하지만 단순히 데이터를 입력받아 가공하는 필터링 로직이라면, 거창하게 클래스를 만들지 말고 **함수형 인터페이스(Function)**를 활용해 가볍게 체이닝하는 것이 현대적인 코드 스타일입니다.
'프로그래밍 > 디자인패턴' 카테고리의 다른 글
| 중재자 패턴(Mediator Pattern) 완벽 정리 (0) | 2025.12.07 |
|---|---|
| 퍼사드 패턴(Facade Pattern) 완벽 정리 (1) | 2025.12.07 |
| 프로토타입 패턴(Prototype Pattern) 완벽 정리 (0) | 2025.12.07 |
| 커맨드 패턴(Command Pattern) 총정리 (0) | 2025.12.07 |
| 책임 연쇄 패턴(Chain of Responsibility) 총정리 (0) | 2025.12.07 |