In object-oriented programming, the decorator pattern is a design pattern that allows behavior to be added to an individual object, dynamically, without affecting the behavior of other objects from the same class. The decorator pattern is often useful for adhering to the Single Responsibility Principle, as it allows functionality to be divided between classes with unique areas of concern as well as to the Open-Closed Principle, by allowing the functionality of a class to be extended without being modified. Decorator use can be more efficient than subclassing, because an object's behavior can be augmented without defining an entirely new object. - Wikipedia
데코레이터 패턴은 같은 클래스의 다른 객체들의 Behavior에 영향을 주지 않고 동적으로 개별 객체에 Behavior를 추가할 수 있다고 함.
다른 객체들의 Behavior에 영향을 주지 않으면서 Behavior를 추가할 수 있다? 이는 개방 폐쇄 원칙인 OCP를 준수한다는 걸로 들린다. 우선 예제를 살펴보면서 정말 다른 객체에 영향을 주지 않고 Behavior를 추가하고 있는지 살펴보자.
커피 매장은 다음과 같이 모카 커피와 아메리카노 두 종류를 팔고 있다. 가격이 저렴한 걸 보니 빽다방?
protocol Coffee {
func cost() -> Int
}
class MochaCoffee: Coffee {
func cost() -> Int {
return 3000
}
}
class Americano: Coffee {
func cost() -> Int {
return 2000
}
}
이제 사용자의 취향에 맞게 커스텀하는 기능을 추가하려고 한다. "모카 커피 + 얼음 추가", "아메리카노 + 샷 추가" 처럼 말이다.
커스텀을 하게 되면 커피 가격이 별도로 붙게 된다. 물론 다음과 같이 각 커피 클래스가 얼음 추가, 샷 추가와 관련된 프로퍼티를 들고 있는 식으로 코드를 짜도 된다.
class MochaCoffee: Coffee {
var isIce: Bool
var shot: Int
init(isIce: Bool, shot: Int) {
self.isIce = isIce
self.shot = shot
}
func cost() -> Int {
var cost = 3000
if isIce {
cost += 500
}
cost += shot * 300
return cost
}
}
나쁘지는 않지만 문제가 있다. 현실 커피 매장에서는 이외에도 휘핑 크림 여부, 떡 고명 여부(?) 등을 추가해야 하고 커피 종류에 따라 필요한 프로퍼티를 설정해주는 것도 일이다.
또한 앞에서 데코레이터 패턴이 OCP를 준수할 거라고 했는데 위 코드는 OCP를 위반한다. 우리는 OCP를 지키면서 커피에 커스텀된 가격을 더해주고 싶다.
protocol CoffeeHaving: Coffee {
var coffee: Coffee { get }
}
우선 Coffee를 채택하는 CoffeeHaving이라는 프로토콜을 정의했다. 이 프로토콜을 채택하게 되면 Coffee 프로토콜도 준수하면서 CoffeeHaving도 준수하게 된다.
그리고 아이스 커피와 휘핑 크림이 올라간 커피라는 두 클래스를 정의하였다.
class IceCoffee: CoffeeHaving {
var coffee: Coffee
init(_ coffee: Coffee) {
self.coffee = coffee
}
func cost() -> Int {
return coffee.cost() + 500
}
}
class WhipCoffee: CoffeeHaving {
var coffee: Coffee
init(_ coffee: Coffee) {
self.coffee = coffee
}
func cost() -> Int {
return coffee.cost() + 300
}
}
다음과 같이 coffee라는 프로퍼티를 들고 있으면서 cost 메서드에서 얼음에 대한 추가적인 금액을 붙여 반환한다. 휩 커피도 마찬가지.
기존의 근본 커피(모카, 아메리카노)를 아이스 커피, 휩 커피가 감싸고 있는 모양새가 되는데.. 이렇게 하면 뭐가 좋을지 사용한 코드를 살펴보자.
let coffee = WhipCoffee(MochaCoffee())
print("가격: \(coffee.cost())") // 가격: 3300
오..! 기존의 모카 커피 클래스를 수정하지 않으면서 휘핑 크림을 추가해 줄 수 있었다.
그렇다면 휘핑 크림을 추가한 아이스 모카 커피도?
let coffee = IceCoffee(WhipCoffee(MochaCoffee()))
print("가격: \(coffee.cost())") // 가격: 3800
이렇게만 보면 정말 만능이네.. 커피 매장처럼 커피 종류도, 추가해야 할 것들도 많은 경우에는 무.조.건 데코레이터 패턴 쓰면 되겠다. (과연 그럴까?)
디자인 패턴을 사용하는 이유는 결국 유지 보수가 용이한 코드를 짜기 위함이라고 생각한다. 만약 커피 매장처럼 단순히 커피 종류가 많고 그에 따라 추가해야 할 것들이 많다고 데코레이터 패턴을 선택하는 것은 위험할 수 있다.
예를 들어, 아메리카노에 휘핑 크림을 추가할 수는 없다고 하자. 다음은 아이스 아메리카노이다.
let iceAmericano = IceCoffee(Americano())
손님이 이 커피에 휘핑 크림을 추가해 달라고 한다. 당신은 코드를 보면 물론 Americano가 IceCoffee로 래핑되어 있지만 iceAmericano 객체를 보고 있는 사람은 이 커피가 아메리카노라는 것을 알까?
모른다. 그래서 이 커피에 휘핑을 추가해야 하는지 말아야 하는지 알 수 없다.
이는 휘핑 추가라는 Behavior가 아메리카노라는 다른 Behavior에 의존하고 있는 것으로, 위 예시의 경우 IceCoffee로 래핑하기 전에 휘핑 추가 여부를 분기 처리하면 되겠지만 데코레이터 패턴을 사용하려는 곳에서 이러한 케이스가 있을 때 쉽게 대응이 가능한지를 따져봐야 한다.
손님이 아이스 모카 커피에 휘핑을 추가해 달라고 했는데 나중에 아이스 말고 따뜻한 커피를 요구할 경우, 이미 래핑을 해버렸기 때문에 다시 객체를 생성해야 한다.
물론 예시에서의 상황은 로직적으로 수정하면 그만이지만, 사용하려는 곳에서 해당 케이스가 있을 가능성이 있는지, 있다면 쉽게 해결이 가능할지를 고려하고 데코레이터 패턴을 선택하는 것이 현명하다.