커피 주문 시스템을 만든다고 생각해 보자. 에스프레소만 판매한다면 가격을 계산하는 코드는 간단하다. 하지만 모카, 우유, 휘핑크림 같은 옵션을 추가하기 시작하면 고려할 조합이 많아진다.
에스프레소에 모카만 넣을 수도 있고, 모카와 휘핑크림을 함께 넣을 수도 있다. 모카를 두 번 넣는 주문도 가능하다.
『헤드 퍼스트 디자인 패턴』 3장에서는 이 문제를 데코레이터 패턴으로 풀어낸다.
기본 객체에 필요한 기능을 하나씩 덧붙여 다양한 조합을 만드는 방식이다.

주문 조합마다 클래스를 만들면 어떻게 될까?
처음에는 상속을 이용해 다음과 같이 만들 수 있다.
Espresso
EspressoWithMocha
EspressoWithWhip
EspressoWithMochaAndWhip
EspressoWithDoubleMochaAndWhip
커피 종류나 첨가물이 늘어날 때마다 필요한 조합도 함께 늘어난다. 단순히 옵션 하나를 추가하려던 일이 여러 클래스를 관리하는 문제로 이어진다.
그렇다면 Beverage에 hasMocha, hasWhip 같은 불리언 변수를 두면 어떨까?
클래스 수는 줄어들지만 다른 문제가 남는다.
- 새 첨가물이 생길 때마다 공통 클래스에 변수와 가격 계산 조건을 추가해야 한다.
- true와 false만으로는 모카 한 번과 두 번을 구분할 수 없다.
- 특정 음료에 필요 없는 첨가물 관련 기능까지 함께 상속된다.
수량 변수를 두면 중복 주문은 표현할 수 있다. 하지만 첨가물 종류가 바뀔 때마다 공통 가격 계산 코드를 수정하는 문제는 그대로다.
달라지는 첨가물의 기능을 각각 분리하고, 주문에 따라 조합할 방법이 필요하다.
기본 음료에 필요한 기능을 하나씩 감싼다.
데코레이터 패턴은 원래 객체와 같은 공통 타입을 제공하는 객체로 원래 객체를 감싸고, 작업을 위임하면서 기능을 더하는 패턴이다.
커피 예제에서는 기본 커피와 첨가물을 각각 객체로 만든다. 주문이 들어오면 필요한 객체들을 연결한다.
Beverage coffee = new Espresso();
coffee = new Mocha(coffee);
coffee = new Whip(coffee);
에스프레소 객체를 모카 객체로 감싸고, 그 바깥을 휘핑 객체로 다시 감싼 구조다.
coffee → Whip → Mocha → Espresso
여기서 ‘감싼다’는 것은 다른 객체의 참조를 내부에 보관하고, 그 객체를 통해 작업을 처리한다는 뜻이다.
에스프레소 객체 자체에 모카 기능을 집어넣는 것이 아니다. 모카 기능을 담당하는 별도의 객체를 만들고, 그 안에 에스프레소를 연결한다.
데코레이터와 원래 객체는 공통 타입으로 사용할 수 있어야 한다.
코드로 살펴보면 감싼다는 표현이 더 명확해진다. 아래 예제에서는 가격을 설명하기 쉽게 원 단위 정수로 표현했다. 금액은 임의로 정했다.
교재의 역할을 예제 클래스에 연결하면 다음과 같다.
패턴의 역할 예제 클래스 하는 일
| Component | Beverage | 음료와 첨가물이 함께 사용하는 타입을 정의한다. |
| ConcreteComponent | Espresso | 기본 음료의 가격과 설명을 제공한다. |
| Decorator | CondimentDecorator | 공통 타입을 유지하며 감쌀 음료의 참조를 보관한다. |
| ConcreteDecorator | Mocha, Whip | 안쪽 음료에 작업을 위임하고 자신의 기능을 더한다. |
먼저 음료가 제공할 공통 기능을 정의한다.
abstract class Beverage {
public abstract int cost();
public abstract String getDescription();
}
Beverage를 사용하는 쪽에서는 음료의 구체적인 종류를 몰라도 가격과 설명을 요청할 수 있다.
기본 에스프레소는 자신의 가격과 이름을 반환한다.
class Espresso extends Beverage {
@Override
public int cost() {
return 3000;
}
@Override
public String getDescription() {
return "에스프레소";
}
}
첨가물의 공통 상위 클래스는 CondimentDecorator다. 자신도 Beverage이면서, 내부에 또 다른 Beverage의 참조를 보관한다.
abstract class CondimentDecorator extends Beverage {
protected final Beverage beverage;
protected CondimentDecorator(Beverage beverage) {
this.beverage = beverage;
}
}
여기서는 공통 생성자에서 참조를 보관하도록 교재 코드를 조금 정리했다. cost()와 getDescription()은 아직 구현하지 않았으므로 구체적인 첨가물 클래스가 구현해야 한다.
모카는 이 클래스를 상속하고, 생성자로 받은 음료를 super(beverage)로 전달한다.
class Mocha extends CondimentDecorator {
public Mocha(Beverage beverage) {
super(beverage);
}
@Override
public int cost() {
return beverage.cost() + 500;
}
@Override
public String getDescription() {
return beverage.getDescription() + ", 모카";
}
}
핵심은 다음 코드다.
return beverage.cost() + 500;
안쪽 객체에 가격 계산을 맡긴 뒤 모카 가격을 더한다. getDescription()도 같은 방식으로 기존 설명에 모카를 추가한다.
안쪽 객체에 작업을 위임하면서 자신의 기능을 덧붙이는 것이 데코레이터의 동작 방식이다.
휘핑크림도 같은 방식으로 구현할 수 있다.
class Whip extends CondimentDecorator {
public Whip(Beverage beverage) {
super(beverage);
}
@Override
public int cost() {
return beverage.cost() + 300;
}
@Override
public String getDescription() {
return beverage.getDescription() + ", 휘핑크림";
}
}
이제 주문을 만들어 보자. 위 클래스들과 아래 클래스를 한 파일 DecoratorDemo.java에 넣으면 실행할 수 있다.
public class DecoratorDemo {
public static void main(String[] args) {
Beverage coffee = new Espresso();
coffee = new Mocha(coffee);
coffee = new Whip(coffee);
System.out.println(coffee.getDescription());
System.out.println(coffee.cost());
}
}
실행 결과는 다음과 같다.
에스프레소, 모카, 휘핑크림
3800
가격 계산 요청은 바깥에서 안으로 들어간다.
Whip.cost()
→ Mocha.cost()
→ Espresso.cost()
에스프레소가 기본 가격을 반환하면, 결과가 돌아오면서 각 첨가물이 자신의 가격을 더한다.
Espresso: 3000
Mocha: 3000 + 500 = 3500
Whip: 3500 + 300 = 3800
기존 객체의 가격을 바꾸는 것은 아니다.
다음 코드를 보면 객체의 관계를 구분할 수 있다.
Beverage original = new Espresso();
Beverage decorated = new Mocha(original);
System.out.println(original.cost()); // 3000
System.out.println(decorated.cost()); // 3500
System.out.println(decorated.cost()); // 3500
Mocha는 기존 에스프레소 객체의 참조를 보관한다. 에스프레소의 가격을 3,500원으로 수정하는 코드는 없다.
decorated.cost()를 호출할 때마다 에스프레소의 가격을 받아 500원을 더한 결과를 반환한다. 여러 번 호출해도 가격이 계속 쌓이지 않는 이유다.
이 차이를 이해하면 다음 대입문도 해석하기 쉽다.
coffee = new Mocha(coffee);
오른쪽의 coffee가 가리키던 객체를 새 Mocha 객체에 전달하고, 왼쪽의 coffee는 새로 만들어진 모카 객체를 가리키게 된다.
기존 객체는 모카 객체 안에 연결된 채로 남아 있다.
같은 첨가물도 여러 번 추가할 수 있다.
모카를 두 번 추가하는 주문은 이렇게 표현한다.
Beverage coffee = new Espresso();
coffee = new Mocha(coffee);
coffee = new Mocha(coffee);
System.out.println(coffee.cost());
// 4000
System.out.println(coffee.getDescription());
// 에스프레소, 모카, 모카
이때는 에스프레소 하나와 모카 두 개, 총 세 개의 객체가 연결돼 있다. 두 모카는 같은 클래스로 만든 서로 다른 객체다.
coffee → 바깥 Mocha → 안쪽 Mocha → Espresso
가격 계산을 풀어 쓰면 다음과 같다.
바깥 Mocha.cost()
= 안쪽 Mocha.cost() + 500
= (Espresso.cost() + 500) + 500
= (3000 + 500) + 500
= 4000
이런 연결이 가능한 이유는 Mocha의 생성자가 Beverage 타입을 받기 때문이다.
Espresso도 Mocha도 Beverage이므로, 모카 객체 안에 또 다른 모카 객체를 넣을 수 있다. 생성자가 Espresso만 받았다면 이런 조합은 만들기 어려웠을 것이다.
상속과 구성은 이 구조에서 서로 다른 역할을 한다.
‘상속보다 구성을 활용한다’는 원칙을 배우고 나면 다음 코드가 헷갈릴 수 있다.
abstract class CondimentDecorator extends Beverage
상속을 사용하고 있기 때문이다.
두 코드의 역할을 나눠 보면 이해하기 쉽다.
코드 역할
| CondimentDecorator extends Beverage | 첨가물 객체도 Beverage가 필요한 자리에 사용할 수 있게 한다. |
| protected final Beverage beverage | 실제로 작업을 맡길 객체를 내부에 보관한다. |
모카는 Espresso를 상속해서 에스프레소의 가격 계산을 물려받지 않는다. 자신이 보관한 beverage의 메서드를 호출해서 가격을 얻는다.
공통 타입은 상속으로 맞추고, 구체적인 기능 조합은 객체를 연결해서 만든다. 이 예제에서 상속과 구성이 함께 쓰이는 이유다.
공통 타입을 반드시 추상 클래스로 만들 필요는 없다. 인터페이스를 구현하는 방법도 가능하다. 중요한 것은 기본 객체와 데코레이터를 같은 계약으로 사용할 수 있어야 한다는 점이다.
‘기능을 동적으로 추가한다’는 표현도 여기에서 이해할 수 있다. 사용자의 주문에 따라 프로그램 실행 중에 필요한 데코레이터 객체들을 만들고 연결할 수 있다는 뜻이다.
새로운 옵션을 추가할 때는 기존 코드에 미치는 영향을 줄일 수 있다.
캐러멜 옵션을 추가한다고 해 보자.
Caramel도 CondimentDecorator를 상속하고 가격과 설명을 구현하면 된다. 그러면 기존 음료를 캐러멜 객체로 감싸서 사용할 수 있다.
이 과정에서 Espresso와 Mocha의 구현은 그대로 사용할 수 있다. 새로운 클래스와 그 객체를 연결하는 코드는 추가해야 하지만, 기존 음료의 가격 계산 코드까지 수정할 필요는 줄어든다.
이 점이 OCP(Open-Closed Principle, 개방-폐쇄 원칙)와 연결된다.
확장에는 열려 있고, 변경에는 닫혀 있어야 한다.
이 예제에서는 ‘새 첨가물을 추가하는 확장’을 별도 클래스로 처리할 수 있도록 설계한 것이다.
모든 종류의 변경을 막겠다는 의미보다는, 예상되는 변화가 기존 코드에 미치는 영향을 줄이는 방향으로 이해하는 편이 낫다.
예를 들어 위 코드에서 모카 가격 자체가 바뀌면 Mocha의 가격 정보는 변경해야 한다. OCP는 기존 코드를 영원히 고치지 말라는 규칙이 아니다.
이 설계가 기존 음료와 첨가물의 구현을 보호하는 범위는 새 첨가물 종류를 추가하는 변경이다.
Java I/O에서도 같은 구조를 볼 수 있다.
책에서는 커피 예제를 Java I/O로 연결한다. 파일에서 바이트를 읽는 FileInputStream에 버퍼링 기능을 더할 때도 객체를 감싼다.
아래는 IOException을 처리하거나 선언한 메서드 안에서 사용하는 부분 코드다. 각 스트림 클래스는 java.io에서 가져오며, order.txt는 미리 준비한 파일이라고 가정한다.
try (InputStream input =
new BufferedInputStream(
new FileInputStream("order.txt"))) {
int firstByte = input.read();
System.out.println(firstByte);
}
FileInputStream은 파일을 읽는 기본 객체이고, BufferedInputStream은 내부 버퍼를 이용하는 읽기 기능을 제공한다.
둘 다 InputStream 타입으로 사용할 수 있으므로, 사용하는 쪽에서는 read()를 호출하면 된다. try 블록이 끝나면 바깥 스트림을 닫으면서 안쪽 스트림도 닫힌다.
커피 예제와 비교하면 구조가 비슷하다.
coffee → Whip → Mocha → Espresso
input → BufferedInputStream → FileInputStream
이 구조에서 InputStream은 공통 구성 요소 타입이고, FilterInputStream은 감싼 스트림에 작업을 위임하는 데코레이터의 기반 클래스다. BufferedInputStream은 이를 상속해 버퍼링을 구현한다.
파일 읽기와 버퍼링을 각각의 객체가 담당하고, 필요한 기능을 객체 연결로 조합하는 것이다.
데코레이터의 순서도 동작의 일부다.
첨가물 가격을 단순히 더하는 경우에는 순서가 바뀌어도 합계가 같다.
하지만 책의 예제에서 조금 더 나아가, 20% 할인 기능을 추가한다고 생각해 보면 결과가 달라질 수 있다.
처리 순서 계산 결과
| 모카를 추가한 전체 음료에 할인 | (3000 + 500) × 0.8 | 2,800원 |
| 기본 커피를 할인하고 모카 추가 | 3000 × 0.8 + 500 | 2,900원 |
여기서는 바깥 객체가 안쪽 객체의 처리 결과를 받아 자신의 작업을 수행한다. 따라서 어떤 객체를 먼저 감싸고, 어떤 객체를 바깥에 둘지도 요구사항에 맞게 결정해야 한다.
항상 호출 결과에 무언가를 더해야 하는 것은 아니다. 안쪽 객체를 호출하기 전에 작업하거나, 호출한 뒤 결과를 변환하는 방식으로도 기능을 확장할 수 있다.
조합이 유연해지는 만큼 관리할 부분도 생긴다.
기능마다 작은 클래스가 생기고, 여러 겹으로 감싸면 최종 동작을 이해하기 위해 연결된 객체들을 따라가야 한다.
객체를 조립하는 코드가 길어질 수도 있다. 반복되는 조립 과정은 별도의 생성 코드나 팩토리, 빌더로 모을 수 있다.
또한 감싼 객체를 사용할 때는 Beverage라는 공통 타입을 기준으로 다루는 것이 자연스럽다.
예를 들어 다음 객체의 바깥쪽 타입은 Mocha다.
Beverage coffee = new Mocha(new Espresso());
이를 Espresso로 형변환하려 하면 실행 중 오류가 발생한다. 안쪽에 에스프레소가 있다는 것과 바깥 객체 자체가 에스프레소라는 것은 다르다.
앞서 공부한 패턴과 비교하면 각 패턴이 해결하려는 문제가 보인다.
패턴 주로 해결하려는 문제 이 책의 예제
| 전략 패턴 | 작업을 수행하는 알고리즘을 분리하고 교체한다. | 오리의 나는 행동과 우는 행동 |
| 옵저버 패턴 | 한 객체의 변경을 관심 있는 여러 객체에 알린다. | 기상 데이터와 여러 디스플레이 |
| 데코레이터 패턴 | 객체를 감싸서 부가 기능을 추가하고 조합한다. | 음료와 여러 첨가물 |
기능의 종류와 조합이 계속 늘어나는 상황이라면 데코레이터를 고려할 수 있다. 반대로 기능이 고정돼 있고 단순한 코드로 충분하다면, 여러 객체로 나누는 비용까지 함께 생각해야 한다.
'Design Pattern' 카테고리의 다른 글
| 데코레이터 패턴, 날씨 API 예제로 이해하기 (0) | 2026.10.05 |
|---|---|
| 옵저버 패턴으로 회원 탈퇴 후 남는 데이터 정리하기 (0) | 2026.09.30 |
| [디자인 패턴] 옵저버 패턴, 값이 바뀌면 여러 객체에 알리기 (0) | 2026.09.28 |
| 디자인 패턴 1장: 전략 패턴(Strategy Pattern) (0) | 2026.09.20 |
| 객체지향이란 무엇인가 ? + 프로젝트 분석 (0) | 2026.09.09 |