데코레이터 패턴은 GoF의 디자인패턴 중 하나로, 객체에 추가 요소를 동적으로(런타임에) 더할 수 있는 패턴입니다.
새로운 요소가 필요할 때 마다 객체를 새로 정의하거나 기존 코드를 변경하지 않고도 유연하게 기능을 확장할 수 있습니다.
abstract class Pizza {
abstract fun price(): Int
open fun description(): String = "pizza"
}
class Margherita : Pizza() {
override fun price() = 15000
override fun description(): String = "tomato based pizza with basil, cheese"
}
class PepperoniPizza : Pizza() {
override fun price() = 17000
override fun description(): String = "tomato based pizza with salami, cheese"
}
class Gorgonzola : Pizza() {
override fun price() = 16000
override fun description(): String = "cream based pizza with blue cheese"
}
피자 판매 서비스가 있다고 가정하겠습니다. 해당 서비스는 인기있는 스테디셀러 메뉴들을 시작으로 빠르게 성장했습니다.
처음에는 적은 수의 메뉴만 있어도 문제가 없었지만, 서비스의 확장과 여러가지 니즈로 인해 고객이 재료를 직접 커스텀해서 주문할 수 있도록 서비스를 변경하기로 했습니다.
치즈, 토마토, 페퍼로니, 루꼴라 등등 재료를 마음대로 추가할 수 있고 베이스 소스도 고를 수 있게 확장해야 합니다.
하지만 기존의 시스템은 Pizza 클래스를 상속받은 서브클래스들이 하나의 메뉴를 담당했는데, 재료를 동적으로 추가하기는 힘들었습니다.
val order = "Margherita"
var pizza: Pizza =
if (order.lowercase() == "margherita")
Margherita()
else if (order.lowercase() == "pepperoni")
PepperoniPizza()
else if (order.lowercase() == "gorgonzola")
Gorgonzola()
else throw Exception()
println("The price of the pizza you ordered is ${pizza.price()}.")
심지어 기존의 시스템은 새로운 피자 종류가 추가되거나, 재료 가격이 변경되면 주문과 관련된 코드를 전부 수정해야 했습니다.
토마토 재료 가격이 두 배 올라서 토마토 소스를 사용하는 피자의 가격을 일괄 1000원씩 인상한다고 가정해보겠습니다.
기존 메뉴 중 토마토소스가 베이스인 피자는 마르게리따, 페퍼로니여서, 두 피자의 가격이 1000원씩 인상됐습니다.
이러한 이유로 인해 기존 시스템은 두 가지 객체지향 원칙을 위반하고 있었습니다.
데코레이터 패턴의 구성요소를 설명하는 클래스 다이어그램입니다.
데코레이터 패턴을 적용해서 새롭게 설계해보겠습니다.
완성된 설계의 다이어그램은 위와 같습니다.
interface Pizza { // Component
fun price(): Int
fun description(): String
}
class SimplePizza : Pizza { // ConcreteComponent
override fun price(): Int = 8000
override fun description(): String = "simple pizza"
}
class TomatoPizza : Pizza { // ConcreteComponent
override fun price(): Int = 10000
override fun description(): String = "tomato pizza"
}
abstract class PizzaDecorator(val pizza: Pizza) : Pizza // Decorator
class Cheese(pizza: Pizza) : PizzaDecorator(pizza) { // ConcreteDecorator
override fun price(): Int = pizza.price() + 2000
override fun description(): String = pizza.description() + ", with cheese"
}
class Basil(pizza: Pizza) : PizzaDecorator(pizza) { // ConcreteDecorator
override fun price(): Int = pizza.price() + 3000
override fun description(): String = pizza.description() + ", with basil"
}
class Pepperoni(pizza: Pizza) : PizzaDecorator(pizza) { // ConcreteDecorator
override fun price(): Int = pizza.price() + 3500
override fun description(): String = pizza.description() + ", with pepperoni"
}
fun main() {
var pizza: Pizza = TomatoPizza()
pizza = Cheese(pizza)
pizza = Basil(pizza)
println("The price of the pizza you ordered is ${pizza.price()}.")
println("The ingredients for the pizza are ${pizza.description()}.")
}
이처럼 여러 기능을 선택적으로 적용하고 싶을 때 데코레이터로 원래 객체를 감싸면 됩니다.
개발 중에는 기존 코드의 수정 없이 행동을 확장해야 하는 경우가 생깁니다. 이 때 디자인 유연성 관점에서 상속보다는 구성을 활용하는 편이 좋습니다.
상속을 이용하는 대신 데코레이터 패턴을 적용하면 행동을 확장할 수 있습니다.
데코레이터 패턴은 구상 구성 요소를 감싸주는 데코레이터를 활용하고, 데코레이터 클래스의 형식은 그 클래스가 감싸는 클래스 형식을 반영합니다.
또한 자기가 감싸고 있는 구성 요소의 메서드를 호출한 결과에 새로운 기능을 더함으로써 행동을 확장할 수 있습니다.
하지만 데코레이터 패턴을 남용하면 코드가 필요 이상으로 복잡해질 수 있다는 것에 유의해야 합니다.