데코레이터 패턴이란?
데코레이터 패턴(Decorator Pattern)은 객체의 기능을 동적으로 확장할 수 있도록 해주는 구조적 디자인 패턴입니다. 상속을 사용하지 않고도, 기존 코드를 수정하지 않으면서 새로운 행동을 추가할 수 있습니다. 이는 "상속보다 조합(composition)을 우선하라"는 객체 지향 원칙과 일치합니다.
특히 다음 상황에서 유용하게 사용됩니다:
- 클래스를 상속하지 않고 객체에 새로운 책임을 추가해야 할 때
- 기능을 동적으로 추가하거나 제거할 필요가 있을 때
- 다양한 조합으로 인해 하위 클래스 수가 폭발적으로 증가할 위험이 있을 때
패턴 구성 요소
데코레이터 패턴은 주로 네 가지 핵심 요소로 구성됩니다:
- 컴포넌트 인터페이스(Component Interface): 기본 기능을 정의하는 인터페이스입니다.
- 구체 컴포넌트(Concrete Component): 컴포넌트 인터페이스를 실제로 구현하는 기본 객체입니다.
- 데코레이터 추상 타입(Decorator Base): 컴포넌트 인터페이스를 구현하며, 내부에 컴포넌트 인스턴스를 포함합니다.
- 구체 데코레이터(Concrete Decorator): 실제 추가 기능을 제공하는 클래스로, 데코레이터를 확장합니다.
Go를 이용한 실습 예제
예제로 음료와 그에 첨가물을 조합하는 시나리오를 고려해 보겠습니다. 기본 음료에 설탕이나 얼음을 추가하여 가격과 설명을 동적으로 변경하고자 합니다.
1. 컴포넌트 인터페이스 정의
package beverage
type Drink interface {
GetPrice() int
GetDescription() string
}
2. 구체적인 음료 구현
package beverage
type GreenTea struct {
price int
description string
}
func NewGreenTea() *GreenTea {
return &GreenTea{
price: 40,
description: "녹차",
}
}
func (g *GreenTea) GetPrice() int {
return g.price
}
func (g *GreenTea) GetDescription() string {
return g.description
}
3. 데코레이터 기본 구조
package topping
import "golang-patterns/beverage"
type Additive struct {
drink beverage.Drink
}
여기서 Additive는 모든 첨가물의 기본 구조이며, 내부에 Drink 인터페이스를 포함함으로써 어떤 음료든 감쌀 수 있습니다.
4. 구체적인 첨가물 구현
설탕 추가 기능을 구현합니다.
package topping
type Sugar struct {
Additive
cost int
description string
}
func NewSugar(drink beverage.Drink) *Sugar {
return &Sugar{
Additive: Additive{drink: drink},
cost: 2,
description: "설탕",
}
}
func (s *Sugar) GetPrice() int {
return s.drink.GetPrice() + s.cost
}
func (s *Sugar) GetDescription() string {
return s.drink.GetDescription() + " + " + s.description
}
얼음 추가 기능도 동일한 방식으로 구현합니다.
package topping
type Ice struct {
Additive
cost int
description string
}
func NewIce(drink beverage.Drink) *Ice {
return &Ice{
Additive: Additive{drink: drink},
cost: 3,
description: "얼음",
}
}
func (i *Ice) GetPrice() int {
return i.drink.GetPrice() + i.cost
}
func (i *Ice) GetDescription() string {
return i.description + "이 든 " + i.drink.GetDescription()
}
5. 클라이언트 코드에서 사용하기
package main
import (
"fmt"
"golang-patterns/beverage"
"golang-patterns/topping"
)
func main() {
// 기본 음료 생성
tea := beverage.NewGreenTea()
fmt.Printf("주문: %s, 가격: %d원\n", tea.GetDescription(), tea.GetPrice())
// 녹차에 설탕 추가
sweetTea := topping.NewSugar(tea)
fmt.Printf("주문: %s, 가격: %d원\n", sweetTea.GetDescription(), sweetTea.GetPrice())
// 설탕 + 얼음 추가
icedSweetTea := topping.NewIce(sweetTea)
fmt.Printf("주문: %s, 가격: %d원\n", icedSweetTea.GetDescription(), icedSweetTea.GetPrice())
}
출력 결과:
주문: 녹차, 가격: 40원
주문: 녹차 + 설탕, 가격: 42원
주문: 얼음이 든 녹차 + 설탕, 가격: 45원
핵심 아이디어: 위임을 통한 조합
데코레이터 패턴의 핵심은 "감싸기(wrapping)"와 "위임(delegation)"에 있습니다. 각 데코레이터는 자신이 감싼 객체의 메서드를 호출한 후, 그 결과에 자신의 변화를 더합니다. 이렇게 하면 기존 객체의 로직을 건드리지 않으면서도, 외부에서는 마치 하나의 통합된 객체처럼 사용할 수 있습니다.
이는 상속 기반의 접근 방식보다 훨씬 유연합니다. 예를 들어, 상속을 사용하면 SweetGreenTea, IcedSweetGreenTea, SweetIcedBlackTea 등 조합마다 별도의 클래스를 만들어야 하지만, 데코레이터를 사용하면 Sugar와 Ice만 있으면 모든 조합을 자유롭게 만들 수 있습니다.
왜 데코레이터는 '감싼다'고 표현할까?
초기에는 왜 객체를 전달받아야 하는지 직관적으로 이해되지 않을 수 있습니다. 하지만 이를 "요리사에게 재료를 건네는 것"이라고 생각하면 됩니다. 음료 자체는 단순한 상태를 유지하고, 데코레이터(요리사)가 그 위에 새로운 속성을 추가하는 것입니다. 이 방식은 SRP(Single Responsibility Principle)를 지키며, 각 구성 요소가 자신의 역할에만 집중할 수 있게 해줍니다.
결론
Go 언어는 인터페이스와 구조체 임베딩을 통해 데코레이터 패턴을 매우 자연스럽게 표현할 수 있습니다. 특히, 런타임 시점에 기능을 조합해야 하거나, 정적 상속 구조로는 관리가 어려운 복잡한 확장성이 요구될 때 이 패턴은 강력한 해결책이 됩니다.