본문 바로가기

study/design pattern

[디자인패턴][구조패턴] 데코레이터 Decorator - C++

728x90

[모던 C++ 디자인 패턴] 책을 바탕으로 공부하는 내용을 정리한 내용이다.


Decorator pattern

동료가 작성한 클래스 기반으로 기능 확장을 한다고 해보자. 원본 코드를 수정하지 않고 기능을 추가하기 위한 방법 중에서 상속이 생각날 수 있다. 하지만 항상 이렇게 작업할 수 있지는 않다. 상속을 할 수 없는 여러 사정이 있을 수 있다. 무엇보다 수정하는 이유가 여러 가지인 경우에는 단일 책임 원칙(SRP)을 위반하는 것이기 때문이다.

Decorator 패턴은 이미 존재하는 타입에 새로운 기능을 추가하면서도 원래 타입의 코드에 수정을 피할 수 있도록 해준다. OCP를 준수하는 것이다.


시나리오

여러 개선 작업이 필요한 상황을 생각해보자. 도형을 기반으로 색상이 있는 도형과 투명한 도형을 상속받아 추가했다고 해보자. 나중에 두 가지 특징을 모두 필요로 하는 경우가 발생하여 추가로 색상이 있는 투명한 도형을 만들어싿고 하자. 결과적으로 두 가지 기능을 추가하기 위해 클래스를 3가지를 만들었다.

마찬가지로 한 가지 기능이 더 추가된다면, 총 7개의 클래스가 필요할 수도 있다. 점차 감당할 수 없어지게 될 것이다.

 

평범한 상속으로는 효율적으로 새로운 기능을 도형에 추가할 수 없다는 것을 알 수 있다. 따라서 접근 방법을 달리 컴포지션을 활용한다. 컴포지션은 Decorator 패턴에서 객체들에 새로운 기능을 확장할 때 활용되는 메커니즘이다. 이 접근방식은 다시 두 가지 서로 다른 방식으로 나누어진다.(다른 패턴까지 고려하면 몇 가지 더 늘어난다.)

  1. 동적 컴포지션
    • 참조를 주고 받으면서 런타임에 동적으로 무언가를 합성할 수 있게 한다.
    • 최대한이 유연성을 제공하게 된다.
    • 예를 들어, 사용자 입력에 따라 런타임에 반응해 컴포지션을 만들 수 있다.
  2. 정적 컴포지션
    • 템플릿을 이용해 컴파일 시점에 추가 기능이 합성되게 한다.
    • 코드 작성 시점에 객체에 대한 정확한 추가 기능 조합이 결정되어야만 한다는 것을 암시하며, 나중에 수정될 수 없음을 의미한다.

동적 데코레이터

도형에 색을 입히려고 한다고 가정해보자. 상속 대신 컴포지션으로 ColoredShape를 만들 수 있다.

struct Shape {
	virtual std::string str() const = 0;
};

struct ColoredShape : Shape {
public:
	ColoredShape(Shape &shape, const std::string &color) : m_shape(shape), m_color(color) {
	}

	std::string str() const override {
		std::ostringstream oss;
		oss << m_shape.str() << " has the color " << m_color;
		return oss.str();
	}

public:
	Shape &m_shape;
	std::string m_color;
};

 

위의 코드에서 볼 수 있듯이 COloredShape는 그 자체로 Shape이기도 하다. 여기서 투명도를 가지게 하고 싶다면 마찬가지 방법으로 구현할 수 있을 것이다.

struct TransparentShape : Shape {
public:
	TransparentShape(Shape &shape, const uint8_t transparency) : m_shape(shape), m_transparency(transparency) {
	}

	std::string str() const override {
		std::ostringstream oss;
		oss << m_shape.str() << " has " << static_cast<float> (m_transparency) / 255.f * 100.f << "% transparency";
		return oss.str();
	}

public:
	Shape &m_shape;
	uint8_t m_transparency;
};

 

이제 ColoredShape와 TransparentShape를 합성해 색상과 투명도 두 기능 모두 도형에 적용되도록 할 수 있다.

int main(void) {
	TransparentShape myCircle(
		ColoredShape(Circle(23), "green"),
		64
	);

	std::cout << myCircle.str();
	// 출력결과 : "A circle of radius 23 has the color green has 25.098% transparency"
}

 

이렇듯 모두 즉석에서 객체를 생성하고 있다. 편리하게 사용할 수 있는 만큼 비상식적인 합성도 가능하다. 예를 들어, 같은 데코레이터를 중복해서 적용해버릴 수도 있다. 그렇다고 하더라도 동작하기 때문에 빨간색이면서도 노란색인 도형을 나타낼 수 있는 위험이 있다. OOP의 테크닉을 통해 중복 합성 방지를 할 수 있을 텐데, 재귀적으로 한참 아래에서 중복이 발생한다면 찾아내기 어려울 것이다.

하지만, 보통의 경우 프로그래머가 상식적으로 사용할 것으로 가정한다면 별 문제는 없다.


정적 데코레이터

Circle에는 resize()라는 멤버 함수가 있따. 이 함수는 Shape 인터페이스와 관계가 없다. 따라서 resize()는 Shape 인터페이스에 없기 때문에 데코레이터에서 호출할 수 없다.

	Circle circle(3);
	ColoredShape redCircle(circle, "red");
	redCircle.resize(2); // 컴파일 불가

 

데코레이션 된 객체의 멤버 함수와 필드에 모두 접근 가능해야 하는 경우에는 어떻게 해야 할까? 다소 난해한 테크닉을 사용하지만 가능하다. 템플릿과 상속을 활용하면 되며, 여기서의 상속은 상속은 가은 한 조합의 수가 폭발적으로 증가하지 않는다. 보통의 상속 대신 믹스인(MixIn) 상속이라 불리는 방식을 사용할 것이다.

믹스인 상속은 템플릿 인자로 받은 클래스를 부모 클래스로 지정하는 방식을 말한다.

 

기본 아이디어는 다음과 같다. 새로운 클래스 ColoredShape을 만들고 템플릿 인자로 받은 클래스를 상속받게 한다. 템플릿 파라미터를 제약할 방법은 없다. 따라서 static_assert를 이용해 Shape 이외의 타입 지정을 막아야 한다.

template <typename T>
struct ColoredShape : T {
public:
	static_assert(is_base_of<Shape, T>::value, "Template argument must be a Shape");

public:
	std::string str() const override {
		std::ostringstream oss;
		oss << T::str() << " has the color " << m_color;
		return oss.str();
	}

public:
	std::string m_color;
};

 

 

 

이제 ColoredShape<T>와 TransparentShape<T>의 구현을 기반으로 색상이 있는 투명한 도형을 합성 가능하다.

	ColoredShape<TransparentShapre<Square>> squrae("blue");
	square.size = 2;
	square.transparency = 0.5;
	std::cout << square.str();

	// square의 멤버 접근 가능
	square.resize(3);

 

동적 데코레이터에서 모든 생성자를 한 번에 편리하게 호출하던 부분을 잃었다. 가장 바깥 클래스는 생성자로 초기화할 수 있지만, 안 쪽 도형의 크기, 색상, 투명도까지 한 번에 설정할 수는 없다.

 

따라서, 데코레이션을 완성하기 위해 ColoredShape와 TransparentShape에 생성자를 전달한다. 두 종류의 인자를 받도록 했다. 첫 번째 인자는 현재 템플릿 클래스에 적용되는 것들이고 두 번째 인자들은 부모 클래스에 전달될 제네렉 파라미터 팩이다.

template <typename T>
struct TransparentShape : T {
public:
	template <typename...Args>
	TransparentShape(const uint8_t transparency, Args ...args) : T(std::forward<Args>(args)...), m_transparency(transparency) {
	}

public:
	uint8_t m_transparency;
};

 

위와 같이 가변 길이 템플릿을 이용해 가변 인자를 상위 클래스에 전달해주고 있다. 생성자들에 전달되는 인자의 타입과 개수, 순서가 맞지 않으면 컴파일 에러가 발생하기 땜누에 올바르게 맞춰질 수밖에 없다. 클래스에 디폴트 생성자를 추가하면 파라미터 설정에 훨씬 더 융통성이 생긴다. 하지만 여전히 인자 배분에 혼란과 모호성이 발생할 수 있다.

이러한 생성자들에 explicit 지정자를 부여하지 않도록 주의해야 한다. explicit 지정 시 복수의 데코레이터 합성할 때 C++의 복제 리스트 초기화 규칙 위반 에러를 만나게 된다.

 

이제 위 구현을 이용해 사용하는 코드를 보도록 하자.

	ColoredShape2<TransparentShape2<Square>> sq = { "red", 51, 5 };
	std::cout << sq.str() << std::endl;
	// 출력 결과 "A square with side 5 ahs 20% transparency has the colored red"

함수형 데코레이터

데코레이터 패턴은 클래스를 적용 대상으로 하는 것이 보통이지만 함수에도 동등하게 적용될 수 있다.

 

함수 동작을 검사하기 위해 동작 앞 뒤로 로그를 남긴다고 해보자. 이러한 방식은 잘 동작하지만 이해관계를 분리하는 디자인 철학 관점에서는 바람직하지 않다.

 

여러 접근 방법이 있을 수 있는데, 한 가지 방법은 문제의 동작과 관련된 전체 코드 단위를 통째로 어떤 로깅 컴포넌트에 람다로서 넘기는 것이다.

struct Logger {
public:
	Logger(const std::function<void()> &func, const std::string &name) : m_func(func), m_name(name) {}

	void operator()() const {
		std::cout << "Entering " << m_name << std::endl;
		m_func();
		std::cout << "Exiting " << m_name << std::endl;
	}

public:
	std::function<void()> m_func;
	std::string m_name;
};

int main(void) {
	Logger([]() {
		std::cout << "Hello" << std::endl;
		   }, 
		   "HelloFunction")();
	/** 출력 결과
	* Entering HelloFunction
	* Hello
	* Exiting HelloFunction
	*/
}

 

 

코드 블록을 std::function으로서 전달하지 않고 아래와 같이 템플릿 인자로 전달할 수도 있다. 아래는 편의 함수를 추가적으로 만들어주었고, 최종적으로 동일하게 활용할 수 있다.

template <typename Func>
struct Logger2 {
public:
	Logger2(const Func &func, const std::string &name) : m_func(Func), m_name(name) {
	}

	void operator()() const {
		std::cout << "Entering " << m_name << std::endl;
		m_func();
		std::cout << "Exiting " << m_name << std::endl;
	}

public:
	Func m_func;
	std::string m_name;
};

// 로깅 인터페인스 생성을 위한 편의 함수
template <typename Func>
auto make_logger2(Func func, const std::string &name) {
	return Logger2<Func> {
		func, name
	}; // () = call now
}

int main(void) {
	auto call = make_logger2([]() {	
		std::cout << "Hello!" << std::endl; 
							 }, 
							 "HelloFunction");
	/** 출력 결과
	* Entering HelloFunction
	* Hello
	* Exiting HelloFunction
	*/
}

데코레이터를 생성할 수 있는 기반이 마련되었다는 데 의미가 있다. 임의의 코드  블록을 데코레이션  할 수 있고 데코레이션 된 코드 블록을 필요할 때 호출할 수 있기 때문에 코드가 더 좋아졌다.

 

조금 더 어려운 경우를 보자. 함수에 로그도 남기고 리턴 값도 넘겨야 한다면 어떻게 해야 할까? 그냥 Logger에서 값을 리턴하면 되는 거 아닌가 싶지만 실제로는 그렇게 되지 않는다. 다음과 같이 Logger를 구성했다고 해보자.

template <typename R, typename... Args>
struct Logger3<R(Args...)> { // 템플릿 클래스 선언에서 템플릿 인자를 사용할 수 없다는 에러가 뜸
public:
	Logger3(std::function<R(Args...)> func, const std::string &name) : m_func(func), m_name(name) {
	}

	R operator() (Args... args) {
		std::cout << "Entering " << m_name << std::endl;
		R result = m_func(args...);
		std::cout << "Exiting " << m_name << std::endl;
		return result;
	}

public:
	std::function<R(Args...)> m_func;
	std::string m_name;
};

 

템플릿 인자 R은 리턴 값의 타입을 의미한다. Args는 파라미터 팩이다. 이 데코레이터도 마찬가지로 함수를 갖고 있다가 필요할 때 호출해준다. 다만, operator()가 R 타입의 리턴 값을 갖는다는 것이다.

데코레이터 생성 편의 함수를 이에 맞게 수정해 실제 사용 예가 아래와 같다.

template <typename R, typename... Args>
auto make_logger3(R(*func)(Args...), const std::string &name) {
	return Logger3<R(Args...)>(
		std::function<R(Args...)>(func),
		name
		);
}

int main(void) {
	auto logged_add = make_logger3(add, "Add");
	auto result = logged_add(2, 3);
}

 

make_logger3을 종속성 주입으로 대체할 수도 있다. 종속성 주입을 선택하면 다음과 같은 장점이 생긴다.

  • 실제 로깅 컴포넌트 대신 NULL 객체 전달해 로깅 작업을 동적으로 끄거나 킬 수 있다.
  • 로깅 코드의 실제 실행을 막을 수도 있다. 로깅 컴포넌트를 다르게 제공함으로써 구현할 수 있다.

종속성 주입은 추가로 다루지 않는다.


요약

Decorator 패턴은 OCP 원칙을 준수하면서도 클래스에 새로운 기능을 추가할 수 이게 해준다. 핵심적인 특징은 데코레이터들을 합성할 수 있다는 것이다. 객체 하나에 복수 개의 데코레이터를 순서와 무관하게 적용할 수 있다. 이번에 다룬 데코레이터 패턴 종류는 아래와 같다.

  1. 동적 데코레이터
    • 데코레이션 할 객체의 참조를 저장하고 런타임에 동적으로 합성한다.
    • 원본 객체가 가진 멤버들에 접근할 수 없다.
  2. 정적 데코레이터
    • 믹스인 상속(템플릿 파라미터 상속)을 이용해 컴파일 시점에 데코레이터를 합성한다.
    • 런타임 융통성을 가질 수 없다.
    • 원본 객체의 멤버들에 접근할 수 있다.
    • 생성자 포워딩을 통해 객체를 완전히 초기화할 수 있다. (인자 순서의 중요성)
  3. 함수형 데코레이터
    • 코드 블록이나 특정 함수에 다른 동작을 덧씌워 합성할 수 있다.

다중 상속이 지원되지 않는 프로그래밍 언어에서는 데코레이터 패턴이 다형성을 제공해주는 역할을 한다.

 

이번 장에서는 C++11 이후 추가된 기능들을 많이 사용했다. 그래서 찾아보느라 공부들을 많이 하게 됐다. 게다가 템플릿 함수/클래스들을 많이 사용했는데, 맞게 사용했다고 생각하는데도 컴파일 에러가 많이 떠서 찾느라 애를 먹었다. 논리적으로 맞게 짠 거 같고 책도 동일한데 빨간 줄들이 많이 떴다. 그래서 템플릿 부분은 내가 많이 부족하다는 것을 알았다. C++ 템플릿 관련해서 공부를 더 해야겠다.

728x90