[Design Pattern] 내가 보려고 정리하는 Swift 디자인 패턴 (9) - 데코레이터 패턴(Decorator Pattern)

2024. 8. 22. 21:40Architecture, Design Pattern

1️⃣ 데코레이터 패턴(Decorator Pattern) 왜(Why) 쓰는 거야?

💡 데코레이터 패턴(Decorator Pattern)객체에 새로운 기능을 동적으로 추가할 수 있게 해주는 구조 패턴(Structural Pattern)이다.

객체에 새로운 기능을 동적으로 추가하기 위해 쓴다고...?
이미 Swift에서 기능을 추가하는 방법으로 (1) 기존 클래스에서 "상속"을 받아 하위 클래스로 구현하거나, (2) Extension으로 기능을 "확장"시킬 수 있는데 굳이 이 디자인 패턴을 쓰면서까지 기능을 추가할 필요가 있을까..? 하는 것이 나의 첫 생각이었다.

하지만, 데코레이터 패턴(Decorator Pattern)을 사용했을 때 상속과 확장에 비해 확연하게 구분되어 얻을 수 있는 장점이 있었다.
아직 패턴의 구조가 어떻게 되는지를 살펴보지는 않았지만, 이 디자인 패턴을 써야 하는 정당성을 갖기 위해 몇 가지 장점들에 대해서 살펴보고 넘어가 보겠다.
*위에서는 장점이라고 설명했지만, 데코레이터 패턴(Decorator Pattern)의 특징이라고 이해해도 무방하다.


첫째, 데코레이터 패턴은 "동적"으로 기능을 추가한다.

런타임에 기능이 고정되어 있는 상속이나 Extension과는 다르게, 데코레이터 패턴은 런타임 내에 기능을 동적으로 추가하거나 변경할 수 있도록 한다.
즉, 사용자의 설정에 따라 동적으로 UI를 변경할 수 있다는 뜻! (물론 이 역시도 디자인 패턴을 사용하지 않아도 만들 수는 있지만, 보다 직관적이고 기능 추가를 간편하게 할 수 있다는 점에 집중하자.)

둘째, 위의 내용에서 이어져 - 결국 데코레이터 패턴은 기존 객체를 건들지 않는 방식으로 기능을 추가할 수 있다.

이 점은 외부 라이브러리나 프레임워크를 가져다 쓰는 경우, 혹은 수정이 어려운 코드에 대해 기능을 추가한다고 할 때 더욱 유용하게 데코레이터 패턴을 활용할 수 있는 이유이기도 하다.
또한, 단일 상속만 가능한 하위 클래스의 생성이나 / 의존 관계를 맺게 되는 기존 기능 추가 프로세스에 대해서도 해결책을 제시한다.

셋째, 데코레이터 패턴은 단일 책임 원칙(SRP: Single Responsibility Principle)을 준수한다.

한 Decorator당 하나의 책임만을 가지도록 해서 기능을 추가하더라도, 각 객체별 책임은 하나씩만 지도록 만든다는 점에서 SOLID 원칙 중 단일 책임 원칙(SRP)을 준수할 수 있게 된다.


뭐라 뭐라 이 패턴이 이렇게 좋아요라고 길게 설명했지만,

결국 핵심은 어떤 객체에 대해 기능을 확장한다고 할 때, 데코레이터 패턴(Decorator Pattern)을 사용하면 유연성과 재사용성을 높이는 방식으로 코드를 짤 수 있다는 것.
+ 상속이나 확장에서 불가능했던 것들(단일 상속, SRP 위반, 런타임 내 고정)을 가능하도록 만든다는 것 등의 이점을 얻을 수 있다고 생각하면 되겠다.

 

2️⃣ 데코레이터 패턴(Decorator Pattern)에서 사용하는 용어와 예제 살펴보기

기본적으로 데코레이터 패턴(Decorator Pattern)은 Component와 Decorator 두 가지 핵심 개념이 사용된다.
*각 핵심개념은 다시 Interface(= Protocol)와 Concrete(= Interface의 구현부)로 나누어지기는 하지만.. 아무튼 핵심은 2개!

  • Component : 기존 객체에 해당하는 부분이다. (= Decorator를 통한 기능 추가가 필요한 객체!)
  • Decorator : 말 그대로 기존 객체를 decorate(장식)할 수 있도록, Component에서 구현하지 못한 추가적인 기능을 담고 있는 부분이다.

이 두 개념을 바탕으로 데코레이터 패턴의 동작을 아래와 같이 다시 설명해 볼 수도 있다.

💡 데코레이터 패턴(Decorator Pattern)Decorator가 Component를 감싸는(wrapping) 방식으로 기능을 확장시킨다.
= Concrete Decorator가 component.operation( )을 호출하는 것이 Wrapping이라고 이해하면 된다.

Decorator Pattern #1. 기본 데코레이터 패턴의 클래스 다이어그램

단순히 딱딱한 구조로 보면 이해가 안 되니 쉽게 예제를 하나 들어서 설명해보고자 한다.

"케이크를 주문 제작하고자 하는 상황"이다.
기본 생크림 케이크는 10,000원에 주문할 수 있지만 / 주문 시 이 기본 생크림 케이크에 딸기(+5.0)나 초콜릿(+2.0) 같은 토핑을 얹어서 케이크를 더 풍성하게 꾸며줄 수도 있다고 가정해 보겠다.

이를 위의 데코레이터 패턴 구조에 넣어서 이해해 보면,

  • Component는 "기본 생크림 케이크"가 될 것이고 (Cake의 핵심 기능은 Component Interface로 분리하고, 기본 생크림 케이크는 실질적인 구현부가 담길 것이다.)
  • Decorator는 "딸기나 초콜릿과 같은 토핑"이 될 것이다. (Interface는 Component를 감싸고 있는 역할, 구현부에는 실질적인 토핑에 관한 내용이 담길 것이다.)

 

3️⃣ 데코레이터 패턴(Decorator Pattern) 예제 코드로 이해하기

🤔 데코레이터 패턴 어떤 순서대로 만들어? (How to Implements)

1. 객체의 핵심 기능(Core Function)을 담당하는 Component Interface(protocol)를 정의한다.

2. 이 Component Interface(protocol)를 채택해서 구현하는 Concrete Component를 만든다.
3. Component Interface에 부합하는(= conforms) Decorator Interface(protocol)를 정의한다.
4. 이 Decorator Interface(protocol)를 채택해서, Component에 새로운 동작을 추가할 수 있는 구현부인 Concrete Decorator를 만든다.
5. Decorator를 사용해서 Component 객체를 동적으로 래핑하고, 필요에 따라 기능을 추가하거나 변경한다.
6. Client는 장식된 객체(Decorated Object)를 핵심 구성요소인 것처럼 여기며, Decorator를 구체적으로 알지는 못한다.

무슨 말인지 이해가 되지 않아도 좋다.
어차피 지금부터 위에서 설명한 예제와 이 구현 순서를 바탕으로 조금 더 자세하게 차근차근 설명해 볼 거니까!


Components (Interface/Concrete)

Component인 Cake에는 핵심 기능(Core Function)이 두 가지다.
케이크의 가격이 얼마인지를 Double 타입의 값으로 반환하는 cost( ) 메서드와, 케이크에 대한 내용을 설명하고 있는 description( ) 메서드.
이 핵심 기능을 채택해서 다른 클래스에서 구현할 수 있도록 인터페이스화 시켜 Cake라는 프로토콜을 먼저 만들었다.

이 Cake Component (Interface)를 채택한 Concrete Component로 "기본 생크림 케이크" 클래스인 FreshCreamCake를 다음으로 만들었다.
기본 생크림 케이크 클래스에는 구체적인 가격과 설명이 모두 설명되어있다.

// MARK: - Component Interface
protocol Cake {
    func cost() -> Double
    func description() -> String
}

// MARK: - Concrete Component
class FreshCreamCake: Cake {
    func cost() -> Double {
        return 10.0
    }
    func description() -> String {
        return "기본 생크림 케이크"
    }
}


Decorator (Interface/Concrete)

이제 Component의 확장되는 기능을 담고 있는 Decorator를 만들어줄 차례다.

Decorator Interface는 Component 객체를 감싸고 있는 형태다.
이 Decorator Interface를 채택하는 구현부 Concrete Decorator에서 감싸고 있는 Component의 기능을 확장시켜주기 위해서다.
내 코드를 보더라도 cost( )나 description( ) 같은 구현부에는 Component인 decoratedCake의 메서드를 내부적으로 호출해서 기능을 확장시켜 준 것을 확인할 수 있다.

*cost( ) 메서드 기준, Choco는 decoratedCake.cost() + 2.0으로 / Strawberry는 decoratedCake.cost() + 5.0으로 각기 다른 가격을 추가해서 구현한 것이 보일 거다!

// MARK: - Decorator
protocol CakeDecorator: Cake {
    var decoratedCake: Cake { get set }
}

// MARK: - Concrete Decorators
class ChocoDecorator: CakeDecorator {
    var decoratedCake: Cake
    
    init(decoratedCake: Cake) {
        self.decoratedCake = decoratedCake
    }
    
    func cost() -> Double {
        return decoratedCake.cost() + 2.0
    }
    
    func description() -> String {
        return "\(decoratedCake.description()) + 초코 추가"
    }
}

class StrawberryDecorator: CakeDecorator {
    var decoratedCake: Cake
    
    init(decoratedCake: Cake) {
        self.decoratedCake = decoratedCake
    }
    
    func cost() -> Double {
        return decoratedCake.cost() + 5.0
    }
    
    func description() -> String {
        return "\(decoratedCake.description()) + 딸기 추가"
    }
}


Use Client

실제 사용하는 상황을 살펴보자.
기본적으로 Client(= 사용자)는 Component를 사용해서 인스턴스(객체)를 만들어 활용하고 있을 것이다.

하지만 이 Component 객체를 런타임 중에 기능을 추가해주고 싶은 경우, 미리 만들어둔 Decorator에 이 Component 객체를 감싸는 방식으로 기능을 추가해주고 있다.
*Wrapping 되는 방식으로 만들어진 Component 객체를 장식된 객체(= Decorated Object)라고 부르는 것.

이때 이 Decorator는 Component Interface를 따르도록 선언했기 때문에, 정확하게 Component와 동일한 방식으로 사용할 수 있게 된다.
cost( )나 description( )과 같은 내부 메서드를 모두 사용할 수도 있고 / Decorator 위에 또 다른 Decorator를 얹어서 사용하는 것도 가능하다!

let myCake = FreshCreamCake()
print("My cake: \(myCake.description()), Price: \(myCake.cost())")
// My cake: 기본 생크림 케이크, Price: 10.0

let chocoDecorator = ChocoDecorator(decoratedCake: myCake)
print("My cake: \(chocoDecorator.description()), Price: \(chocoDecorator.cost())")
// My cake: 기본 생크림 케이크 + 초코 추가, Price: 12.0

let strawberryDecorator = StrawberryDecorator(decoratedCake: chocoDecorator)
print("My cake: \(strawberryDecorator.description()), Price: \(strawberryDecorator.cost())")
// My cake: 기본 생크림 케이크 + 초코 추가 + 딸기 추가, Price: 17.0

 

💡 Reference

 

Decorator in Swift / Design Patterns

Usage examples: The Decorator is pretty standard in Swift code, especially in code related to streams. Identification: Decorator can be recognized by creation methods or constructors that accept objects of the same class or interface as a current class. Co

refactoring.guru

 

Swift Solutions: Decorator Pattern

Swift Solutions is a series of articles covering design patterns. In each post we discuss what the pattern is, when it applies, and how to…

medium.com

 

The Decorator Pattern in Swift: A Comprehensive Guide

The Decorator Pattern is a structural design pattern that allows you to add new behaviors or responsibilities to objects dynamically…

o-kaledin.medium.com