Post

SOLID 디자인 원칙2 : OCP

전체 SOLID 디자인 원칙의 링크들

  1. SRP : 단일 책임 원칙
  2. OCP : 열림-닫힘 원칙
  3. LSP : 리스코프 치환 원칙
  4. ISP : 인터페이스 분리 원칙
  5. DIP : 의존성 역전 원칙

SOLID 디자인 원칙의 두 번째, 열림-닫힘 원칙(Open-Closed Principle, OCP)

데이터베이스에 어떤 제품군에 대한 정보가 저장되어 있다고 하자. 개별 제품은 서로 다른 색상과 크기를 가지며 아래와 같이 정의된다.

1
2
3
4
5
6
7
8
enum class Color { Red, Green, Blue };
enum class Size { Small, Medium, Large };

class Product {
	string name;
	Color color;
	Size size;
};

이제 주어진 제품 집합을 조건에 따라 필터링하는 기능을 만들고 싶다고 하자.
다음과 같이 어떤 필터가 조건에 합치하는 제품들의 집합을 가지도록 구현할 수 있다.

1
2
3
class ProductFilter {
	typedef vector<Product*> Items;
};

이제 필터링 조건으로 색상을 기준으로 삼는 필터를 만들려고 한다고 하자. 색상만을 기준으로 제품을 구분하는 멤버 함수를 아래와 같이 정의할 수 있다.

1
2
3
4
5
6
7
Items by_color(Items _items, Color _color) {
	Items result;
	for (Product* item : _items)
		if (item->color == _color)
			result.push_back(item);
	return result;
}

이렇게 색상을 기준으로 필터링하는 접근방법은 잘 동작하고 나쁘지 않다.
이러한 코드가 상용 제품으로 배포되었다고 하자. 불행하게도, 어느 정도 시간이 흐른 후, 크기를 기준으로 한 필터링 기능도 구현해 달라는 요구 사항이 들어올 수 있다. 그 요구사항을 구현하기 위해 ProductFilter.cpp 파일을 찾아서 다음과 같은 코드를 추가하고 다시 컴파일 한다.

1
2
3
4
5
6
7
Items by_size(Items _items, Size _size) {
	Items result;
	for (auto& item : _items)
		if (item->size == _size)
			result.push_back(item);
	return result;
}

확실히 뭔가 같은 작업을 반복하는 느낌이 들지 않는가? 왜 범용적으로 임의의 조건(함수로 만들어진)을 지정받는 필터 함수를 만들지 않는가? 첫 번째 이유는 필터조건마다 처리 형태가 다를 수 있기 때문이다. 예를 들어 어떤 레코드 타입은 인덱싱으로 접근되어 특정한 방식으로 탐색 되어야 하고, 어떤 레코드는 GPU에서 병렬로 탐색이 가능할 수도 있다. 그리고 다른 레코드 타입들은 어느 것에도 해당하지 않을 수 있다.
또다시 사용자가 다른 요구 사항을 전달했다고 하자. 이번에는 색상과 크기를 모두 지정하여 필터링하길 원한다. 아래와 같이 함수를 또 추가하는 것 말고 다른 방법이 있을까?

1
2
3
4
5
6
7
Items by_color_and_size(Items _items, Size _size, Color _color) {
	Items result;
	for (auto& item : _items)
		if (item->size == _size && item->color == _color)
			result.push_back(item);
	return result;
}

이러한 시나리오에서 우리가 필요한 것이 열림-닫힘 원칙이다. 열림-닫힘 원칙은 타입이 확장에는 열려 있지만 수정에는 닫혀 있도록 강제하는 것을 뜻한다.
다르게 말하면, 기존 코드의 수정 없이(이미 고객에게 배포된 잘 동작하던 코드를 다시 컴파일할 필요 없이) 필터링을 확장할 수 있는 방법이 필요하다.
어떻게 그렇게 할 수 있을까? 먼저, 필터링 절차를 개념적으로 두 개의 부분으로 나누어야 한다.(여기서 앞에서 배운 SRP 원칙을 적용한다.)(SRP 원칙은 여기를 참조)

  1. 필터 : 항목 집합을 넘겨받아 그 일부를 리턴
  2. 명세 : 데이터 항목을 구분하기 위한 조건을 정의

이 두가지로 구별해보자.

명세는 다음과 같이 매우 단순하게 정의할 수 있다.

1
2
3
4
template <typename T>
class Specification {
	virtual bool is_satisfied(T* item) = 0;
};

위 코드에서, 타입 T는 임의로 선택할 수 있다. 여기서는 맥락상 Product 타입이어야 하지만 다른 타입일 수도 있다. 이 부분이 전체적인 접근 방법을 재사용 가능하게 만든다. 또한 이 클래스는 인터페이스임으로 이 클래스를 상속받아서 is_satisfied() 함수를 사용하는 명세를 만들어야 한다.

다음으로 Specification<T>에 기반하여 필터링을 수행할 방법이 필요하다. 이를 위해 다음과 같이 Filter<T>를 정의한다.

1
2
3
4
template <typename T>
class Filter {
	virtual vector<T*> filter(vector<T*> _items, Specification<T>& spec) = 0;
};

이 함수는 ItemSpecification을 인자로 받아, 명세에 합치되는 항목들을 리턴한다. 하지만 실제 환경에서는 좀 더 유연하게 임의의 제품 타입을 지원할 수 있도록, 반복자 또는 별도로 준비한 인터페이스를 filter()에 넘겨줄 수 있다.

1
2
3
4
5
6
7
8
9
class BettterFilter : Filter<Product> {
	vector<Product*> filter(vector<Product*> _items, Specification<Product>& _spec) override {
		vector<Product*> result;
		for (auto& item : _items)
			if (_spec.is_satisfied(item))
				result.push_back(item);
		return result;
	}
};

인자로 전달되는 Specification`는 타입이 강하게 규정된(가능한 필터가 제한된) std::function이라고 볼 수 있다.
아래는 색상 필터에 대한 명세 `ColorSpecification`의 작성 예시이다.

1
2
3
4
5
6
7
8
9
class ColorSpecification : Specification<Product> {
	Color color;

	explicit ColorSpecification(const Color color) : color(color) {}

	bool is_satisfied(Product* _item) override {
		return _item->color == color;
	}
};

이러한 명세를 활용하면 주어진 제품 목록을 다음과 같이 필터링할 수 있다.

1
2
3
4
5
6
7
8
9
Product apple{ "Apple", Color::Green, Size::Small };
Product tree{ "Tree", Color::Green, Size::Large };
Product house{ "House", Color::Blue, Size::Large };
vector<Product*> all{ &apple, &tree, &house };
BetterFilter bf;
ColorSpecification green(Color::Green);
auto green_things = bf.filter(all, green);
for (auto& x : green_things)
	cout << x->name << " is green" << "\n";

이 방식은 색으로만 제품을 필터링한다. 크기를 제품으로 필터링하고 싶거나, 색과 크기를 동시에 필터링 하고 싶은 경우 AND 논리 연산을 사용하는 복합 명세를 만들면 된다.

다시 OCP 원칙을 되새겨 보자. 이 예에서 어떻게 OCP 원칙이 강제되고 있을까? 기본적으로 OCP에서는 기존에 작성하고 테스트했던 코드에 다시 손을 대는 일이 있어서는 안 된다는 것을 의미한다.
이 예는 정확히 그러한 원칙을 구현하고 있다. Specification<T>Filter<T>를 만들었기 때문에 인터페이스 자체에는 전혀 손을 대지 아니하고도 이들 인터페이스의 구현을 통해 새로운 필터링 방식을 추가할 수 있다. 바로 이것이 확장에는 열려 있지만 수정에는 닫혀 있다는 것이다.

요약

OCP는 확장에는 열려 있지만 수정에는 닫혀 있다.를 목표로 하는 원칙이다.
이 목표를 쉽게 풀면 기존에 작성하고 테스트했던 코드에 다시 손을 못대게 하되, 추가 기능 구현은 가능하다이다.
이를 구현하기 위해 인터페이스를 사용해서 구현할 수 있다.

This post is licensed under CC BY 4.0 by the author.