범용적인 설계 원칙

Jin Hur·2022년 5월 27일
0

SW 설계와 구현

목록 보기
4/5
post-custom-banner

변경되는 것은 분리하라 (Encapsulate What Varies)

  • 변경되는 부분은 변경되지 않는 부분과 분리해야 한다.
  • 변경되는 부분을 분리하면 변경으로 인한 영향을 최소화 할 수 있다.
  • 또한 코드의 가독성이 높아져서 이해하기 쉬워지고, 수정 범위가 작아지므로 유지보수성이 높아지게 된다.
  • 작게는 함수 설계 시 적용할 수 있고, 크게는 클래스/모듈/서브시스템 설계 시 적용할 수 있다.

함수 분리 예시

함수 분리 적용 전

double CalculateTotalOrderCost(const double price, string country) {
	double totalCost = price;

	if (country.compare("US") == 0) {
		totalCost += totalCost * 0.07;
	}
	else if (country.compare("EU") == 0) {
		totalCost += totalCost * 0.20;
	}
	else {
		totalCost += totalCost * 0.0;
	}

	return totalCost;
}

int main() {
	cout << "Total Cost: " << CalculateTotalOrderCost(100, "US") << endl;

	return 0;
}

함수 분리 적용 후

나라 별 세금은 언제든지 바뀌는 법이다. 위 코드의 함수에서 세금이 포함된 코드는 변경되기 쉬운 코드이다. 이 부분을 분리한다.

double GetTaxRate(const string& country) {
	if (country.compare("US") == 0)
		return 0.07;
	else if (country.compare("EU") == 0)
		return 0.20;
	else
		return 0.0;
}

double CalculateTotalOrderCost(const double price, const string& country) {
	double totalCost = price;

	/*
	if (country.compare("US") == 0) {
		totalCost += totalCost * 0.07;
	}
	else if (country.compare("EU") == 0) {
		totalCost += totalCost * 0.20;
	}	
	else {
		totalCost += totalCost * 0.0;
	}
	*/

	totalCost += totalCost * GetTaxRate(country);

	return totalCost;
}

int main() {
	cout << "Total Cost: " << CalculateTotalOrderCost(100, "US") << endl;

	return 0;
}

클래스 분리 예시

클래스 분리 적용 전

double CalculateTotalOrderCost(const double price, const string& country) {
	double totalCost = price;

	if (country.compare("US") == 0) {
		if(price >= 100.0)
			totalCost += totalCost * 0.10;
		else
			totalCost += totalCost * 0.07;
	}
	else if (country.compare("EU") == 0) {
		if (price >= 100.0)
			totalCost += totalCost * 0.25;
		else
			totalCost += totalCost * 0.20;
	}
	else {
		totalCost += totalCost * 0.0;
	}

	return totalCost;
}

int main() {
	cout << "Total Cost: " << CalculateTotalOrderCost(100, "US") << endl;

	return 0;
}

클래스 분리 적용 후

class TaxCalculator {
public:
	double CalculateTotalOrderCost(const double price, const string& country) {
		double totalCost = price;
		totalCost += totalCost * getTaxRate(price, country);

		return totalCost;
	}

private:
	double getTaxRate(const double price, const string& country) {
		if (country.compare("US") == 0)
			return getUSTaxRate(price);
		else if (country.compare("EU") == 0)
			return getEUTaxRate(price);
		else
			return 0.0;
	}

	double getUSTaxRate(const double price) {
		if (price >= 100.0)
			return 0.10;
		else
			return 0.07;
	}
	double getEUTaxRate(const double price) {
		if (price >= 100.0)
			return 0.25;
		else
			return 0.20;
	}
};

int main() {
	TaxCalculator taxCalculator;

	cout << "Total Cost: " << taxCalculator.CalculateTotalOrderCost(100, "US") << endl;

	return 0;
}

나라 별 세금 제도가 비용에 따라 또 다를 수 있다. 이러한 부분도 별도로 변경되는 부분이다. 이를 분리한다.


상속보다는 조합을 선호하자 (Favor Composition over Inheritance)

  • 상속이 적합한 경우를 제외한 대부분의 환경에서는 조합이 적합하다.
  • 상속 대신에 조합을 사용하면 코드의 유연성을 높일 수 있다.

상속 예시


class Person {
public:
	string firstName;
	string lastName;
};

class Student : public Person {
public:
	string studentID;
};

int main() {
	Student student;
	student.firstName = "Jin";
	student.lastName = "Hur";
	student.studentID = "32174937";

	return 0;
}

조합 예시

class Person {
public:
	string firstName;
	string lastName;
};

/*
class Student : public Person {
public:
	string studentID;
};
*/
class Student {
public:
	Person name;
	string studentID;
};

int main() {
	Student student;
	student.name.firstName = "Jin";
	student.name.lastName = "Hur";
	student.studentID = "32174937";

	return 0;
}

상속보다 조합을 해야하는 이유

reference:
1) https://tecoble.techcourse.co.kr/post/2020-05-18-inheritance-vs-composition/
2) https://joanne.tistory.com/106

상속을 통해 다른 클래스의 기능을 재사용하면서 추가 기능을 확장할 수 있다는 장점이 있다. 기능의 재사용이라는 관점에서 상속은 좋은 솔루션이다. 하지만 상속은 변경의 유연함이라는 측면에서 단점을 갖는다.

먼저 상속을 통한 재사용은 단점은 상위 클래스의 변경이 어렵다는 점에서 기인한다. 상위 클래스에서 무언가 변경이 발생할 때, 변경에 대한 영향의 전파는 하위 클래스까지 이어진다. 특히 계층이 깊을 수록 상위 클래스의 변경은 더더욱 어려워진다.

두 번째 단점은 클래스가 불필요하게 증가할 수 있다는 점에서 기인한다. 예를 들어 파일 보관소를 구현한 Storage 클래스가 있을 때, 다양한 요구사항에 따라 클래스들이 추가될 수 있다.

  • 압축 기능 추가 -> CompressedStorage 클래스
  • 보안 기능 추가 -> EncryptedStorage 클래스

문제는 압축 기능과 보안 기능이 동시에 요구되는 저장소를 구현할 때이다. CompressedStorage 클래스와 EncryptedStorage 클래스가 있음에도 불구하고 동시에 해당 기능을 수행하기 위해서 상속의 방식에서는 CompressedAndEncryptedStorage 클래스를 추가로 생성해야 한다.
이외에도 여러 요구사항에 따라 불필요한 클래스가 추가될 수 있다.

세 번째 단점으로 캡슐화를 깨뜨린다는 것이다. 상위 클래스의 구현이 하위 클래스에게 노출되는 상속은 캡슐화를 깨뜨린다. 캡슐화가 깨진다는 것은 하위 클래스가 상위 클래스에게 강하게 결합, 의존되고, 이로 인해 상위 클래스의 변화에 하위 클래스가 유연하게 대처하기 어려워진다는 것이다. 사실 이는 첫 번째 단점과 거의 유사하다.

예제를 살펴보겠다. 로또 클래스(Lotto)와 실제 당첨번호를 가지는 당첨 로또 클래스(WinningLotto)를 다음과 같이 상속 형태로 구현하였다.

#include <vector>
#include <iostream>
using namespace std;

class Lotto {

protected:
	vector<int> lottoNumbers;

public:
	Lotto(const vector<int>& lottoNumbers) {
		this->lottoNumbers = lottoNumbers;
	}

	int getLottoNumLen() {
		return lottoNumbers.size();
	}

	int getLottoNum_byIdx(int idx) {
		return lottoNumbers[idx];
	}
};

// 당첨 번호를 가지고 있는 WinnigLotto 클래스
class WinnigLotto : public Lotto {

public:
	WinnigLotto(const vector<int>& winnigNumbers)
		: Lotto(winnigNumbers)
	{}

	// 일반 로또 번호와 비교하는 메서드
	bool compareLotto(Lotto& lotto) {
		if (lottoNumbers.size() != lotto.getLottoNumLen())
			return false;

		for (int i = 0; i < lottoNumbers.size(); i++) {
			if (lottoNumbers[i] != lotto.getLottoNum_byIdx(i))
				return false;
		}

		return true;
	}
};


int main() {
	vector<int> lottoNums = { 1, 2, 3, 4, 5 };
	vector<int> lottoNums2 = { 1, 2, 3, 5, 4 };
	vector<int> winningNums = { 1, 2, 3, 5, 4 };

	Lotto lotto(lottoNums);
	Lotto lotto2(lottoNums2);
	WinnigLotto winningLotto(winningNums);

	cout << "1번 로또 => ";
	if (winningLotto.compareLotto(lotto))
		cout << "당첨!" << endl;
	else
		cout << "꽝!" << endl;

	cout << "2번 로또 => ";
	if (winningLotto.compareLotto(lotto2))
		cout << "당첨!" << endl;
	else
		cout << "꽝!" << endl;
}

이러한 상황에 요구사항이 변경되어 로또 번호를 담는 자료구조의 자료형이 연속형 구조인 vector<int>에서 연결형 자료구조로 변경되었다 가정한다. 그렇다면 아래의 자식 클래스가 망가지게 된다.

이러한 상황에서의 해결책은 문제가 되는 모든 하위 클래스들을 일일이 수정해주는 방법 밖에 없다.

따라서 이러한 문제는 조합을 통해 해결할 수 있다. WinningLotto 클래스가 Lotto를 상속하는 것이 아닌 조합을 사용하면 된다.

class WinningLotto {
protected:
    Lotto lotto;
    
    ...
    ...
}

여기에서 WinningLotto 클래스는 Lotto 객체의 메서드를 호출하는 방식으로 동작하게 된다. 이를 통해 다음과 같은 장점을 얻을 수 있다.

1) 메서드를 호출하는 방식으로 동작하기에 캡슐화를 깨뜨리지 않는다.
2) Lotto 클래스 같은 기존 클래스의 변화에 영향이 적어진다.

상속을 사용해야 하는 경우
1. 확장을 고려하고 설계한 확실한 'IS-A' 관계일 때
2. API에 아무런 결함이 없는 경우, 결함이 있다면 하위 클래스까지 전파되어도 괜찮은 경우

ex)

public class 포유류 extends 동물 {
	// 오버라이딩
    protected void 숨을쉬다() {
        ...
    }
    protected void 새끼를낳다() {
        ...
    }
}

포유류가 동물이라는 사실은 변할 가능성이 거의 없고, 숨을 쉬고, 새끼를 낳는다는 행동이 변할 가능성도 거의 없다. 이처럼 확실한 is-a 관계일 때는 상위 클래스가 변할 일이 거의 없다.

확실한 is - a 관계인지 곰곰이 고민해보고
상위 클래스가 변화에 의해서 결함이 생기는 등 어떤 결함이 생겼을 경우, 하위 클래스까지 전파돼도 괜찮은지 철저하게 확인했다면 상속을 사용해도 좋다고 생각한다.

사실 이런 조건을 만족한 경우에도 상속은 조합과 달리 캡슐화를 깨뜨리기 때문에 100% 정답은 없다.

확실한 건 상속을 코드 재사용만을 위한 수단으로 사용하면 안 된다.
상속은 반드시 확장이라는 관점에서 사용해야 한다.

상황에 맞는 최선의 방법을 선택하면 된다. 다만, 애매할 때는 조합(Composition)을 사용하는 것이 좋다.


정보 은닉 (Information Hiding)

  • 클래스의 내부 동작이나 상세 구조는 숨겨야 한다.
  • 정보 은닉을 적용하면 클래스의 인터페이스가 단순하게 변경되므로 사용자는 클래스를 쉽게 사용할 수 있다.
  • 또한 클래스의 내부 기능이 변경되더라도 사용자는 영향을 받지 않으므로 클래스의 기능 확장이 쉬워진다.
  • 정보 은닉은 클래스의 인터페이스 설계뿐만 아니라 함수의 인터페이스 설계에도 적용된다.

정보 은닉 적용 전

enum class State {
	closed = 1,
	opening,
	open,
	closing
};

class AutomaticDoor {
public:
	State state = State::closed;
};

int main() {
	AutomaticDoor door;
	if (door.state == State::closed)
		cout << "Door is closed" << endl;

	return 0;
}

정보 은닉 적용 후

class AutomaticDoor {
public:
	bool isClosed() const {
		return  state == State::closed;
	}

private:
	enum class State {
		closed = 1,
		opening,
		open,
		closing
	};

	State state = State::closed;
};

int main() {
	AutomaticDoor door;
	//if (door.state == State::closed)
	if(door.isClosed())
		cout << "Door is closed" << endl;

	return 0;
}

의존성 주입 (Dependency Injection)

  • 느슨한 결합(Loose Coupling)을 가진 코드를 개발하도록 도와주는 설계 원칙과 패턴의 집합이다.
  • 의존 관계를 코드 내부가 아니라 외부에서 설정하므로 재사용성, 유지보수성, 시험용이성을 높일 수 있다.
  • 다른 디자인 패턴(데코레이터, 어뎁터 패턴 등)들과 결합하여 기능을 쉽게 확장할 수 있다.
  • 의존성 주입이 가능하려면 먼저, DIP를 따른 설계가 먼저 반영되어야 한다.
  • DIP를 만족하려면 어떤 클래스가 다른 클래스에 도움을 받을 때, 그(다른) 클래스가 구체적인 클래스보다는 추상 클래스나 인터페이스에 의존 관계를 맺도록 설계하는 것이다.

의존성 주입 적용 전

class Service {
public:
	void Run() {
		cout << "ProductionService Called!" << endl;
	}
};

class Customer {
public:
	Customer() {
    	this->service = Customer가 이용하고자 하는 서비스;
        // 객체의 생성이 비즈니스 로직과 분리되어 있지 않다.
        // 새로운 서비스로 변경하거나, 동적으로 서비스 변경이 이루어질 때 기존에 있는 코드(Customer 클래스)를 건들여야 한다. 
    }
    

	void UseService() {
		service.Run();
	}

private:
	Service service;
};

int main() {
	Customer customer;
	customer.UseService();
	return 0;
}

의존성 주입 적용 후

class IService {
public:
	virtual ~IService() {}
	virtual void Run() = 0;
};

class TestService : public IService {
	virtual void Run() {
		cout << "TestService Run!" << endl;
	}
};

class ProductionService : public IService {
	virtual void Run() {
		cout << "ProductionService Run!" << endl;
	}
};

class Customer {
public:
	// 객체 생성과 분리하였고, 외부에서 생성된 객체를 주입할 수 있도록 인터페이스를 만들었다.
    // 이러한 방식을 통해 객체의 생성과 비즈니스 로직 구현을 분리함으로써 해당 코드에선 비즈니스 로직 구현에만 집중하도록 한다. 
	Customer(IService* iservicePtr) 
		: servicePtr(iservicePtr)
	{}

	void UseService() {
		servicePtr->Run();
	}

private:
	IService* servicePtr;
};

int main() {
	// 납품용 코드
	ProductionService productionService;
	Customer productionCustomer(&productionService);
	productionCustomer.UseService();

	// 테스트용 코드
	TestService testService;
	Customer testCustomer(&testService);
	testCustomer.UseService();

	return 0;
}
post-custom-banner

0개의 댓글