7. Decorator — 토핑 추가할 때마다 클래스를 새로 만들 수 없다
작성일:2026.05.21|조회수:1

에이든 피자에서 주문서를 객체로 만들자 취소와 재주문은 한결 편해졌다. 그런데 주문이 편해지자 손님들도 한결 편해졌다. 편해진 손님은 더 많은 요구를 한다. "치즈 추가요", "올리브도 추가요", "소스 많이요", "조금 더 바삭하게 구워주세요" 같은 요청이 주문대 위로 쌓이기 시작했다.
처음에는 상속으로 해결해볼 수 있을 것 같았다. 페퍼로니 피자에 치즈를 추가하면 ExtraCheesePepperoniPizza, 올리브도 추가하면 ExtraCheeseAndOlivePepperoniPizza를 만들면 된다. 이름만 봐도 벌써 배가 부르다. 문제는 배가 부른 쪽이 손님이 아니라 클래스 목록이라는 점이다.
class PepperoniPizza {}
class ExtraCheesePepperoniPizza extends PepperoniPizza {}
class ExtraCheeseAndOlivePepperoniPizza extends ExtraCheesePepperoniPizza {}이 방식은 메뉴가 적을 때는 그럭저럭 버틸 수 있다. 하지만 피자 종류가 N개이고 토핑 옵션이 M개라면 조합은 빠르게 늘어난다. 이쯤 되면 토핑을 추가하는 일이 아니라 클래스 증식을 관찰하는 일이 된다. 생물 시간에도 이렇게까지 빠른 번식은 잘 보지 못했다.
무엇이 불편한가
상속은 정적인 관계를 표현하는 데 강하다. 하지만 손님의 토핑 선택은 런타임에 결정된다. 어떤 손님은 치즈만 추가하고, 어떤 손님은 치즈와 올리브를 추가하고, 어떤 손님은 올리브를 빼고 소스를 더한다. 이 조합을 모두 상속 계층으로 표현하면 클래스 수가 폭발한다.
가격 계산도 문제다. 치즈 추가는 1,500원, 올리브 추가는 1,000원, 웰던은 500원이라고 해보자. 상속 기반 클래스마다 가격을 직접 계산하면 중복이 생긴다. 토핑 가격이 바뀌면 관련 클래스를 전부 찾아야 한다. 피자 가격표를 고치려다가 클래스 다이어그램을 펼치는 상황이 된다.
Decorator 패턴
Decorator 패턴은 객체를 감싸서 기능을 동적으로 추가한다. GoF의 원래 의도는 다음과 같다.
객체에 동적으로 새로운 책임을 추가하고, 기능 확장에 있어 서브클래싱의 유연한 대안을 제공한다.
에이든 피자에서는 기본 피자를 감싸는 토핑 객체들이 Decorator가 된다. 핵심은 Decorator도 Pizza라는 점이다. 치즈를 추가한 피자도 피자이고, 올리브를 추가한 피자도 피자다. 바깥에서는 그것이 기본 피자인지, 몇 겹의 토핑으로 감싸진 피자인지 알 필요가 없다. 그냥 가격과 설명을 물어보면 된다.
interface Pizza {
getDescription(): string;
getPrice(): number;
}기본 피자는 ConcreteComponent다.
class PepperoniPizza implements Pizza {
getDescription(): string {
return '페퍼로니 피자';
}
getPrice(): number {
return 15000;
}
}토핑 Decorator는 내부에 다른 Pizza를 들고 있다. 그리고 자기 자신도 Pizza 인터페이스를 구현한다.
abstract class ToppingDecorator implements Pizza {
constructor(protected readonly pizza: Pizza) {}
abstract getDescription(): string;
abstract getPrice(): number;
}이제 치즈와 올리브는 피자를 감싸며 자기 책임만 더한다.
class ExtraCheese extends ToppingDecorator {
getDescription(): string {
return `${this.pizza.getDescription()} + 엑스트라 치즈`;
}
getPrice(): number {
return this.pizza.getPrice() + 1500;
}
}이 구조에서는 새 토핑이 생겨도 기존 피자 클래스를 수정하지 않는다. ExtraGarlic 같은 Decorator 하나만 추가하면 된다. 마늘을 추가하고 싶다는 손님의 욕망이 기존 클래스 계층을 흔들지 않는다. 이것만으로도 꽤 평화롭다.
감싸고, 또 감싼다
Decorator는 여러 겹으로 쌓을 수 있다.
const pizza = new WellDone(
new ExtraOlive(
new ExtraCheese(
new PepperoniPizza(),
),
),
);pizza.getPrice()를 호출하면 가장 바깥의 WellDone부터 안쪽으로 위임이 이어진다. WellDone은 안쪽 피자의 가격을 구한 뒤 500원을 더한다. ExtraOlive는 그 안에서 1,000원을 더하고, ExtraCheese는 1,500원을 더한다. 맨 안쪽의 PepperoniPizza는 기본 가격을 반환한다. 각 Decorator는 자기 추가분만 알고, 나머지는 안쪽 객체에게 맡긴다.
이 구조의 좋은 점은 조합을 런타임에 만들 수 있다는 것이다. 손님이 치즈를 추가하면 new ExtraCheese(pizza)로 감싸고, 올리브를 추가하면 그 위를 다시 감싸면 된다. 토핑 조합마다 클래스를 만드는 대신, 작은 책임을 가진 객체들을 겹쳐서 표현한다.
TypeScript의 @Decorator와는 다르다
여기서 꼭 짚고 넘어가야 할 것이 있다. GoF의 Decorator 패턴과 TypeScript의 @Decorator 문법은 이름이 같지만 같은 것이 아니다. TypeScript의 @sealed, @Injectable() 같은 문법은 클래스나 메서드 선언에 메타데이터를 붙이거나 정의를 바꾸는 언어 기능이다. 지금 우리가 다루는 Decorator 패턴은 객체를 감싸서 런타임에 책임을 추가하는 설계 패턴이다.
이 둘을 헷갈리면 대화가 이상해진다. "Decorator로 토핑을 추가한다"고 했는데 누군가 @ExtraCheese를 클래스 위에 붙이기 시작하면, 에이든 피자는 갑자기 메타프로그래밍 가게가 된다. 그런 가게도 멋질 수는 있지만, 오늘 우리가 여는 가게는 아니다.
그래서 이게 항상 좋은 선택인가
Decorator는 조합이 많고, 런타임에 기능을 덧붙여야 할 때 유용하다. 토핑처럼 작은 책임을 여러 개 조합하는 문제에 잘 맞는다. 상속으로 표현하면 클래스가 폭발하는 상황에서, Decorator는 작은 클래스들을 조합해 같은 문제를 해결한다.
하지만 너무 많이 감싸면 디버깅이 어려워진다. 가격이 이상할 때 어느 Decorator가 얼마를 더했는지 추적해야 한다. 객체가 여러 겹으로 감싸져 있으면 콘솔에 찍힌 타입만 보고는 실제 구조를 파악하기 어렵다. 피자 한 판을 먹기 위해 포장지를 다섯 겹 벗겨야 하는 상황과 비슷하다. 먹을 수는 있지만 조금 지친다.
전체 코드
interface Pizza {
getDescription(): string;
getPrice(): number;
}
class PepperoniPizza implements Pizza {
getDescription(): string {
return '페퍼로니 피자';
}
getPrice(): number {
return 15000;
}
}
class MargheritaPizza implements Pizza {
getDescription(): string {
return '마르게리타 피자';
}
getPrice(): number {
return 13000;
}
}
abstract class ToppingDecorator implements Pizza {
constructor(protected readonly pizza: Pizza) {}
abstract getDescription(): string;
abstract getPrice(): number;
}
class ExtraCheese extends ToppingDecorator {
getDescription(): string {
return `${this.pizza.getDescription()} + 엑스트라 치즈`;
}
getPrice(): number {
return this.pizza.getPrice() + 1500;
}
}
class ExtraOlive extends ToppingDecorator {
getDescription(): string {
return `${this.pizza.getDescription()} + 올리브`;
}
getPrice(): number {
return this.pizza.getPrice() + 1000;
}
}
class WellDone extends ToppingDecorator {
getDescription(): string {
return `${this.pizza.getDescription()} + 바삭하게 굽기`;
}
getPrice(): number {
return this.pizza.getPrice() + 500;
}
}
const pizza: Pizza = new WellDone(
new ExtraOlive(
new ExtraCheese(
new PepperoniPizza(),
),
),
);
console.log(pizza.getDescription());
console.log(pizza.getPrice());
const simplePizza: Pizza = new ExtraCheese(new MargheritaPizza());
console.log(simplePizza.getDescription());
console.log(simplePizza.getPrice());이제 피자에 토핑을 자유롭게 추가할 수 있다. 그런데 장바구니에는 단품 피자만 들어오지 않는다. 피자 하나, 사이드 하나, 음료 하나가 묶인 세트도 들어오고, 세트 안에 또 작은 세트가 들어갈 수도 있다. 단품과 묶음을 같은 방식으로 계산하려면 다른 구조가 필요하다.
댓글
댓글을 불러오는 중...