공부기록
객체지향설계 : SOLID 원칙 본문
SOLID 원칙이 필요한 이유
SOLID 원칙은 객체지향 프로그래밍을 하면서 모듈간의 잘못된 의존성을 줄이기 위해서 필요하다.
처음에 프로그램을 설계할 때 자신만의 구조가 있을 것이다. 하지만 이 프로그램이 변경되면서 처음의 구조가 무너지면서 점차 잘못된 의존성이 생겨나게 된다. 이 잘못된 의존성 에 의해 나타나는 4가지 문제는 다음과 같다.
- Rigidity
Rigitiy(이하 경직성)은 프로그램이 변경되기 어려워지는 것을 말한다. 모듈간의 결합도가 강해지면서, 한 모듈을 수정하면 다른 모듈을 수정하고, 그 수정에 의해 또 다른 모듈의 수정이 필요해지면서 점차 프로그램을 변경하는데 너무 많은 공수가 필요하게 된다. - Fragility
Fragility(이하 취약성)은 프로그램이 변경될 때마다 그 변경이 프로그램의 다른 부분을 망가트릴 가능성이 커지는 것을 말한다. 모듈간의 잘못된 의존성에 의해 한 모듈의 수정이 다른 모듈에 오류를 발생시킴으로써 프로그램의 변경이 부담스러워지게 된다. - Immobility
Immobility (이하 부동성)은 다른 프로젝트나 그 프로젝트의 모듈을 재사용할 수 없는 것을 말한다. 하나의 모듈을 떼오는데 필요한 비용이 다시 만드는데 비해서 너무 커서, 결국 프로그래머는 재사용하지 않고 다시 만들게 된다. - Viscosity
Viscosity(이하 점성)은 설계의 점성, 환경의 점성이 있다.
설계의 점성은 프로그래머가 프로그램의 설계를 유지하면서 변경하는 것이 그렇지 않을 때 보다 어려울 때 점성이 높다고 말한다.
환경의 점성은 개발환경이 너무 느리고 비효율적일때 발생한다. 예를 들어 프로그램의 설계를 지키면서 컴파일하거나 형상관리도구에서 체크인하는 것이 너무 비효율적일때, 프로그래머는 결국 설계를 위반하면서 컴파일을 더 빠르게, 체크인을 더 빠르게 프로그램을 변경하게 된다.
의존성 관리
결국, 위의 4가지 문제는 모듈 간의 부적절한 의존성 의해 발생하게 된다. 따라서 이 의존성을 관리할 필요가 생기게 된다.
모듈 간의 의존성 관리는 의존성 방화벽을 통해 할 수 있게된다. 방화벽 덕분에 의존성은 전파되지 않는다.
이러한 방화벽을 세우기 위해서 SOLID 원칙이 사용되고 또 필요하다.
객체지향설계의 원칙 SOLID
Single Responsibility
하나의 클래스는 오직 하나의 책임만을 가져야 한다.
'One class should have one and only one responsibility.'
이 말은, 우리는 하나의 클래스를 오직 하나의 목적을 위해 만들고, 변경해야된다는 말이다. 하나의 클래스는 하나의 컨테이너와 같다. 우리는 어떠한 데이터, 필드, 메서드를 추가할 수 있다. 하지만, 우리가 하나의 클래스에 너무 많은 것을 넣어버리면, 클래스가 너무 커져버린다. 하나의 클래스를 다시 컴파일하는데 너무 많은 시간이 걸리게 될 것이다. SRP를 따름으로써 하나의 클래스가 하나의 문제를 다룸으로써 클래스가 단순해지고 가벼워지게 된다.
예를 들어, 한 클래스가 모델 클래스라면 그것은 프로그램에서 오직 하나의 역할을 나타내도록 강력하게 제한되어야 한다. 이러한 방식의 설계는 한 클래스의 수정이 다른 클래스들에 영향을 주지 않도록 하는 유연함을 주게 된다.
마찬가지로, 만약 우리가 서비스 클래스를 작성하고 있다면 그 클래스는 오직 그 부분의 메서드만을 포함해야 한다. 이 서비스 클래스는 그 모듈에 관련된 어떠한 전역 함수조차 포함해서는 안된다.
전역함수를 다른 전역 클래스에 담는 것이 더 나은 방안이 될 것이다. 이것은 한 클래스를 특정한 목적을 위해 유지하는 것에 도움을 주고 우리는 그 클래스를 특정한 모듈에만 보이도록 결정할 수 있다.
The Open Closed Principle
한 모듈은 확장에는 열려있고 수정에는 닫혀있어야 한다.
'A module should be open for extension but closed for modification.'
객체지향설계 원칙중 가장 중요한 항목이다. 모듈을 변경시키지 않고서 확장시켜야 한다는 것, 모듈의 소스코드를 변경시키지 않고서 모듈이 무엇을 할 지를 변경시킬 수 있어야 된다는 말이다.
OCP를 구현하기위한 2가지 방법이 있는데, 이는 모두 추상화 *_에 기반한다. 이 방법들에는 *_Dynamic Polymorphism, Static Polymorphism 이 있다.
Dynamic Polymorphism
아래의 LogOn()
를 보자, 이 함수는 프로그램에 새로운 종류의 modem
더해질때마다 변경되어야 한다. 이는 수정에 닫혀있지 못한 예시이다.
struct Modem
{
enum Type {hayes, courrier, ernie) type;
};
struct Hayes
{
Modem::Type type;
// Hayes related stuff
};
struct Courrier
{
Modem::Type type;
// Courrier related stuff
};
struct Ernie
{
Modem::Type type;
// Ernie related stuff
};
void LogOn(Modem& m,string& pno, string& user, string& pw)
{
if (m.type == Modem::hayes)
DialHayes((Hayes&)m, pno);
else if (m.type == Modem::courrier)
DialCourrier((Courrier&)m, pno);
else if (m.type == Modem::ernie)
DialErnie((Ernie&)m, pno)
// ...you get the idea
}
아래의 예시는 변경에 닫히고, 확장에 열리도록 수정된 LogOn()
의 예시이다.
class Modem
{
public:
virtual void Dial(const string& pno) = 0;
virtual void Send(char) = 0;
virtual char Recv() = 0;
virtual void Hangup() = 0;
};
void LogOn(Modem& m,string& pno, string& user, string& pw)
{
m.Dial(pno);
// you get the idea.
}
<그림>
위 그림처럼 다양한 종류의 모뎀들은 Modem 인터페이스를 상속받아 구현이 된다. LogOn()
은 각 유형의 모뎀을 직접 취급하지 않고 Modem
인터페이스를 인자로 받음으로써 모뎀을 더 추가해도 LogOn()
을 수정하지 않게되었다.
Static Polymorphisim
템플릿, 또는 지네릭을 사용하여 OCP를 구현할 수 있다. LogOn()
메서드가 수정없이 다양한 종류의 Modem
에 확장될 수 있게 되었다.
template <typename MODEM>
void LogOn(MODEM& m, string& pno, string& user, string& pw)
{
m.Dial(pno);
// you get the idea.
}
OCP를 사용함으로써 우리는 모듈을 그 자체를 수정하지 않고 확장가능하게 만들었다. 즉 OCP는 이미 존재하는 코드를 수정하지 않고 새로운 코드를 추가하는 것으로 코드에 새로운 넣기 위해 사용될 수 있다.
The Liskov Substitution Principle (LSP)
자식 클래스들은 그들의 조상클래스로 대체될 수 있어야 한다.
'Subclasses should be substitutable for their base classes'
자식 클래스는 그들의 조상클래스로 대체될 수 있어야 한다는 말은, 부모 클래스를 사용하는 모듈은 자식 클래스를 받더라도 잘 기능될 수 있어야한다는 말이다.
위의 그림을 예시로 하면, 만약 User 함수가 Base 자료형의 인자를 받는다면, 그 함수에 Derived 클래스의 객체를 넘기는 것이 가능해야 된다는 말이다. 코드의 예시는 아래와 같다.
void User(Base& b);
Derived d;
User(d);
모든 자식 클래스는 조상 클래스로 형변환 될 수 있기에 당연한것 같지만, 생각해봐야할 문제가 있다. 바로 Circle/Ellipse 딜레마이다.
The Circle/Ellipse Dilemma
먼저 원은 타원에 포함된다는 것을 상기하자. 원은 두 초점이 같은 타원이다. 상속관계로 표현하면 원은 타원을 상속한다. 이 관계는 아래의 그림과 같다.
타원의 초점을 설정하는 SetFoci()
메서드가 있고 아래와 같다고 하자.
void Ellipse::SetFoci(const Point& a, const Point& b)
{
itsFocusA = a;
itsFocusB = b;
}
원의 초점을 설정하는 SetFoci()
메서드는 아래와 같이 만들 수 있다.
void Circle::SetFoci(const Point& a, const Point& b)
{
itsFocusA = a;
itsFocusB = a;
}
아래는 이제 문제가 될 수 있는 상황이다. 타원을 매개변수로 받는 함수 f()
가 있다고 해보자. 코드는 다음과 같다.
void f(Ellipse& e)
{
Point a(-1,0);
Point b(1,0);
e.SetFoci(a,b);
e.SetMajorAxis(3);
assert(e.GetFocusA() == a);
assert(e.GetFocusB() == b);
assert(e.GetMajorAxis() == 3);
}
타원의 경우 assert(e.GetFocusB() == b )
에서 문제가 일어나지 않을 것이다. 하지만 원의 경우 문제가 일어날 것이다. 초점 b의 정보를 잃어버리기 때문이다. 즉 이러한 경우에는 LSP 를 지킬 수 없게 된다. 단순히 조상클래스를 상속받는 자식클래스를 만든다고 LSP가 지켜지지 않는다는 것이다.
자식클래스와 조상클래스가 대체가능하기 위해, 조상클래스의 명세는 자식클래스에 의해 반드시 지켜져야 한다. 위의 예시에서 Circle
이 Ellipse
의 ''초점이 두개이다'' 라는 명세를 지키지 않았기에 원은 타원을 대체할 수 없고 LSP를 위반하게 된다.
LSP를 지키기위해 자식 클래스는 메서드의 명세를 지켜야 한다. 이 명세에는 precondition (선행조건) 과 postcondition(사후조건)이 있다. precondition은 메서드가 호출될 때 반드시 참이어야 하는 사실이고, postcondition은 메서드가 완료된 후 반드시 참이어야 하는 사실이다.
명세의 측면에서 다음을 만족할 때 자식클래스는 조상클래스를 대체할 수 있다.
- 자식클래스의 precondition이 조상클래스 메서드보다 더 강하지 않아야 한다.
- 자식클래스의 postcondition이 조상클래스 메서드보다 더 약해서는 안된다.
LSP 위반의 악영향
위에서의 함수 f()
를 고치기 위해 다음과 같이 코드를 수정할 수 있다.
void f(Ellipse& e)
{
if (typeid(e) == typeid(Ellipse))
{
Point a(-1,0);
Point b(1,0);
e.SetFoci(a,b);
e.SetMajorAxis(3);
assert(e.GetFocusA() == a);
assert(e.GetFocusB() == b);
assert(e.GetMajorAxis() == 3);
}
else
throw NotAnEllipse(e);
}
당장 문제는 막을 수 있지만, 이제 Ellipse
의 다른 자식클래스가 생겨나면 그것을 위한 예외처리를 다시 구현해주어야 한다. 결국 LSP의 위반은 OCP의 위반으로 이어지게 된다.
Interface Segregation Principle
사용자들은 그들이 사용하지 않을 필요치 않은 인터페이스를 구현하도록 강요받아서는 안된다.
"Clients should not be forced to implement unnecessary methods which they will not use."
바로 예시를 들어보자. 개발자가 인터페이스 Reportable
을 만들고 generatedExcel()
과 generatedPdf()
라는 두 메서드를 추가했다. 이제 사용자가 이 인터페이스를 사용하고 싶은데 이 인터페이스의 메서드중 Excel만 사용하고 싶다고 하자. 문제는 사용자가 인터페이스를 사용하려면 두 메서드를 둘 다 구현해야 한다는 것이다.
또 다른 문제가 있다. 한 인터페이스가 이렇게 너무 많은 책임을 가지게 되었을때, 추후에 excel을 위한 특정 메서드가 추가되면 pdf관련 메서드의 사용자도 쓸데없이 새로운 excel 메서드를 구현해야 한다. 이것은 좋은 설계가 아니다.
이를 해결하기 위해서는 Reportable
인터페이스를 excel과 pdf에 관한 두 인터페이스로 나누면 된다. ExcelReportable
과 PdfReportable
로 나눈다면 사용자는 자신이 원하는 기능에 대해서만 구현할 수 있게 된다.
즉 인터페이스를 SRP를 지켜 구현하여 단 하나의 책임만을 가지게 구현하면 ISP를 지킬 수 있는 것이다.
구체적인 예시는 JAVA의 AWT 이벤트 핸들러이다. 각 IO 컴포넌트및 이벤트에 대한 리스너에 대해 각각 인터페이스를 구현하여 필요한 이벤트에 대해서만 사용자가 처리를 하도록 만들었다.
리스너의 종류에는 FocusListener, KeyListener, MouseMotionListener, MouseWheelListener, TextListener, WindowFocusListener 등이 있다.
public class MouseMotionListenerImpl implements MouseMotionListener
{
@Override
public void mouseDragged(MouseEvent e) {
//handler code
}
@Override
public void mouseMoved(MouseEvent e) {
//handler code
}
}
Dependency Inversion Principle
구체화에 의존하지 말고, 추상화에 의존하라
"Depend upon Abstractions. Do not depend upon concretions."
만약 OCP가 OO 구조의 목표를 나타낸다면, DIP는 주요한 메커니즘을 나타낸다. 의존성 역전은 구체적인 함수나 클래스에 의존하기 보다는 인터페이스, 추상 메서드, 추상 클래스에 의존하는 전략이다.
순차적 설계는 의존성 구조의 특정한 형태를 보여준다. 그림에서 보는 것 처럼, 이 구조는 위에서 시작하여 아래로 이어진다. 상위 계층의 모듈은 하위 계층의 모듈에 종속되며, 이것이 반복된다.
이러한 의존성은 좋지 않다. 상위 계층 모듈들은 프로그램의 상위 정책을 처리한다. 이러한 정책들은 하위 계층 모듈들을 크게 생각하지 않는다. 그러면 왜 상위 계층의 모듈들이 이러한 하위계층에 직접 의존해야 될까?
OO 구조는 다른 형태의 의존성 구조를 보여준다. 이 구조에서 대부분의 의존성이 추상화에 의존한다. 더 나아가, 세부적으로 구현된 하위 모듈들은 의존당하지 않고 추상화에 의존한다. 따라서 "의존성이 역전되었다." 라고 말할 수 있다.
아래 코드는 구체적인 예시이다.
먼저 다음과 같은 컴퓨터 클래스가 있다고 하자.
public class Windows98Machine {}
그리고 컴퓨터에 모니터와 키보드를 추가하는 상황이라고 하자.
public class Windows98Machine {
private final StandardKeyboard keyboard;
private final Monitor monitor;
public Windows98Machine() {
monitor = new Monitor();
keyboard = new StandardKeyboard();
}
}
만약 코드를 이렇게 짠다면 Windows98Machine
은 Monitor
과 StandardKeyboard
에 강하게 종속되게 된다. 그 말인 즉 Windows98Machine
은 오직 Monitor
클래스와 StandardKeyboard
클래스 밖에 쓸 수 없다는 것이다. 만약 이 컴퓨터에 키보드 인터페이스를 확장한 RazorKeyboard
를 사용한다고 하면, 무조건 컴퓨터 클래스를 수정해야 된다. 즉 확장에 닫혀있고 수정에 열리게 된다.
다음과 같은 인터페이스를 만들었다고 생각해보자.
public interface Keyboard { }
그리고 키보드 클래스들을 다음과 같이 수정한다.
public class StandardKeyboard implements Keyboard { }
그리고 DI를 따르도록 컴퓨터 클래스를 다음과 같이 수정한다.
public class Windows98Machine{
private final Keyboard keyboard;
private final Monitor monitor;
public Windows98Machine(Keyboard keyboard, Monitor monitor) {
this.keyboard = keyboard;
this.monitor = monitor;
}
}
컴퓨터가 Keyboard
인터페이스에 종속되면서 이제 키보드 인터페이스를 확장한 RazorKeyboard
도 사용할 수 있게 된다. 즉 확장에 열리고 수정에는 닫힌 설계를 만들 수 있게된다.
Depending upon Abstractions
이 원칙이 나타내는 것은 꽤 간단하다. 설계 내에서의 모든 의존성은 구체적인 클래스가 아닌, 추상 클래스 또는 인터페이스를 향하고 있어야 한다는 것이다. 이러한 제약조건은 참으로 어렵고, 실현하기 까다로운 경우가 있다. 하지만 가능한 한, 이 원칙은 지켜져야 한다. 이유는 간단하다. 구체화된 것들은 자주 바뀌지만, 추상화된 것들은 더 적게 바뀌기 때문이다. 더욱이, 추상적인 것들은 그들을 수정하지 않고도 설계가 변형되거나 확장될 수 있는 것이다.
Mitigating Forces
DIP는 변화할 가능성이 높은 모듈에 의존하는 것을 막아준다. DIP는 구체화된 것은 금방 수정될 수 있다는 것을 전제로 한다. 반대로 변화할 가능성이 높지 않은 것에 의존하는 것은 그렇게 나쁘지 않을 수 있다. 예를 들어, C 라이브러리의 string.h
는 구체적이지만 변화할 가능성이 높지 않다. 따라서 이것에 의존하는 것은 그렇게 위험하지 않을 수 있다.
하지만, stirng.h
를 다른 라이브러리로 바꿔야 되는 경우, 이것에 의존하는 코드를 짜는 것은 매우 위험한 일이 될 수있다. 즉, 변화할 가능성이 없는 것은 추상화된 인터페이스의 대체물이 될 수 는 없다.
출처
https://www.baeldung.com/solid-principles
https://howtodoinjava.com/best-practices/solid-principles/
https://fi.ort.edu.uy/innovaportal/file/2032/1/design_principles.pdf
'Programming > Design Pattern' 카테고리의 다른 글
MVC Pattern (0) | 2021.05.30 |
---|