[소프트웨어공학] OOD와 SOLID/객체지향프로그래밍(OOP)의 5대 원리
OOD와 SOLID
일반적으로 소프트웨어 개발에서, 개발 비용보다 유지 보수 비용이 2배가 넘어간다.소프트웨어 개발의 최종 목적은 개발 그 자체가 아닌, 지속적인 운영이기 때문이다.
만약 개발 완료가 다 되어갈 무렵 새로운 버튼 하나를 추가하라는 명령을 들으면 어떨까? 만일 모든 클래스, 객체가 심하게 연동되어있으며 다양한 기능이 단 하나의 함수에 들어가 있다면 변경 작업 중 문제가 생길 확률이 크다.
이러한 것들을 해결하기 위한(유지 보수와 확장을 쉽게 하기 위한) OOD(객체 지향 디자인)의 원칙이 바로 SOLID이다. 이 SOLID를 이용하여 소스코드를 고쳐나가면 코드에서 smell을 제거할 수 있다. 또한 응집도, 결합도, 복잡도 등을 해결할 수 있다.
*code smell : 문제를 일으킬 가능성이 있는 코드
SOLID는 각각 아래의 약어이다.
S : Single responsibility principle : 단일 책임 원칙
O : Open/Closed principle : 개방-폐쇄 원칙
L : Liskov substitution princople :리스코프 치환 원칙
I : Interface seregation principle : 인터페이스 분리 원칙
D : Dependency inversion principle : 의존관계 역전 원칙
단일 책임 원칙
단일 책임 원칙(Single responsibility principle)의 특징은 아래와 같다.
- 한 클래스, 함수 등은 하나의 책임만 가져야한다.
- 클래스를 변경하는데 하나 이상의 이유가 있어서는 안 된다.
단일 책임 원칙 예시
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
struct Rental{
int bookKind;
int timeDelayed;
}
void rental(Rental* rental){
int charge, point;
switch(Rental->bookKind){
case 0:
charge = rental->timeDelayed * 3;
point = 1;
break;
case 1:
charge = rental->timeDelayed * 4;
point = 2;
break;
default:break;
}
printf("the charge is %d, point is %d", charge, point);
}
|
cs |
위의 rental 함수는 구조체를 받아와 비용과 포인트를 print한다.
이 함수 내에서 실행되는 각 연산이 관련성이 적다. 즉, charge를 계산하는 것과 point를 계산하는 것은 관련성이 없으나, 한 함수가 해당 연산을 모두 책임지고 있다. 즉 응집도가 낮다고 할 수 있다.
위의 상황에 SRP를 적용하면 아래와 같은 결과가 된다.
1
2
3
4
5
6
7
8
9
10
11
12
|
int getRentalCharge(Rental* rental){...};
int getRentalPoint(Rental* rental){...};
void rental(Rental* rental){
int charge, point;
switch(Rental->bookKind){
charge = getRentalCharge(Rental);
point = getRentalPoint(Rental);
printf("the charge is %d, point is %d", charge, point);
}
|
cs |
charge를 계산하는 함수와 point를 계산하는 함수로 나누어 각각 하나의 책임을 지도록 하였다.
이러한 방식은 유지 보수를 무척 용이하게 해 주지만, 프로그램의 속도를 저하한다는 단점이 존재한다. 일장일단이 있기에 그 사이에서 고민해야 할 것이다.
개방-폐쇄 원칙
개방-폐쇄 원칙(Open/Closed principle)의 특징은 아래와 같다.
- 소프트웨어 요소는 확장에는 열려있으나 변경에는 닫혀있어야한다.
- 즉 변경 요청에 따라 새로운 함수, 클래스, 모듈 등을 추가할 수는 있으나 기존의 것을 변경해서는 안 된다.
- 한 소스코드의 변경이 그것과 연관된 많은 작동의 결과를 바꿀 가능성이 있기 때문이다.
이 개방 폐쇄 원칙을 적용한 예시 중 하나가 바로 c++의 sort함수 혹은 priority_queue등이 된다.
std::sort(data.begin(), data.end(), descending);
std::sort(data.begin(), data.end(), ascending);
이처럼 sort함수의 세 번째 인자 값을 추가하므로서 그 기능이 확장된다(내림차순-오름차순)
bool operator()(int a, int b){
return a > b;
}
};
std::priority_queue<int, vector<int>, cmp> pq = {1, 2, 3, 4, 5};
위의 경우 priority_queue의 기본적 내용은 변경하지 않고, cmp를 오버라이딩하므로서 기능이 확장되었다.
리스코프 치환 원칙
리스코프치환원칙(Liskov substitution principle)의 특징은 아래와 같다.
- 프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야한다.
- 예를 들어 T가 상위 클래스, S가 하위클래스라고 하자. 그렇담 T의 객체는 속성의 변경 없이 S의 객체로 치환 가능하여야한다.
객체지향 언어에서는 객체의 상속이 빈번이 일어난다. 여기서 부모/자식 관계가 정의되는데, 자식 객체는 부모 객체의 특성을 가지며 확장이 가능하다. 하지만 이 과정에서 설계 의도와 어긋나게 상속/확장하는 것을 막기 위한 원칙이 리스코프 치환 원칙이라고 하겠다.
리스코프 치환원칙의 예 직사각형-정사각형 문제
정사각형은 직사각형으 하나이다. 즉, 각 변의 값이 똑같은 직사각형이다. 그러므로 상속으로 정사각형을 구현하리라고 생각한다. 그 예가 아래와 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
|
#include <iostream>
using namespace std;
class Rectangle {
protected:
int height;
int width;
public:
void setHeight(int h) {
height = h;
}
void setWidth(int w) {
width = w;
}
void getArea() {
cout << width * height << endl;
}
};
class Square : public Rectangle {
public:
void setHeight(int h) {//overriding
height = h;
width = h;
}
void setWidth(int w) {//overriding
height = w;
width = w;
}
};
int main() {
Rectangle* rec = new Square();//up casting
rec->setHeight(5);
rec->setWidth(10);
rec->getArea(); // 50
Square* rec2 = (Square*)rec;
rec2->setHeight(5);
rec2->setWidth(10);
rec2->getArea();//100
return 0;
}
|
cs |
33 : 직사각형 객체를 생성한다.
36 : 직사각형의 크기를 구한다. 50이 나온다.
정사각형은 setHeight든 setWidth든 변의 값이 똑같도록 초기화한다.
38 : 위의 직사각형 객체를 정사각형 객체로 캐스팅해서 rec2로 초기화한다.
41 : 크기가 100이 나온다.
즉, 같은 함수, 같은 값을 사용했음에도 결과값이 달라진 것을 볼 수 있다. 이와 같이 상위 클래스에서 하위 클래스로의 변환이 불가한 경우 리스코프치환원칙을 위배했다
고 한다.
인터페이스 분리 원칙
인터페이스 분리 원칙(Interface seregation principle)의 특징은 아래와 같다.
인터페이스에는 다양한 오퍼레이션이 존재할 수 있다. 각 클라이언트는 인터페이스에 접근해 자신에게 필요한 오퍼레이션을 사용할 것이다.
그런데 각 클라이언트가 필요한 오퍼레이션이 하나의 인터페이스에 있는 경우, 이것을 fat interface 혹은 polluted interface 혹은 non-cohesive interface라고 한다.
이런 경우에는 각 오퍼레이션별로 인터페이스를 분리해 주는 것이 바람직하다.(즉, 응집도를 높이라는 것)
의존관계 역전 원칙
의존관계 역전 원칙(Dependency inversion principle)의 특징은 아래와 같다.
다른 말로 하자면 인터페이스나 추상 클래스 등을 사용하라는 말이 된다.
모듈간의 의존성이 강하면 다양한 문제가 발생한다.(결합도가 높을 경우 문제 발생) 우선, 하나의 변화가 다른 많은 부분에 큰 영향을 끼치게 된다. 그리고 변화가 예상치 못한 부분을 망가뜨릴 수 있다. DIP는 이런 문제에 대한 해결 방법이 된다.
함수 단위에서의 예시
1
2
3
4
5
6
7
|
void printPay(int id){
int pay = getDefaultPay(id);
printf("pay is %d", pay);
}
void getDefaultPay(id){...};
|
cs |
이 경우 각 함수는 하나의 역할만을 한다. 즉, printPay는 pay를 print하고, getDefaultPay는 id에 따른 pay를 반환한다.
따라서, 각 부분은 하나의 역할만을 가지라는 단일 책임 원칙은 따르고 있다.
다만, 여기서 의존관계 역전 원칙을 적용하면 아래와 같아진다.
1
2
3
4
5
6
7
8
9
|
void printPay(int id, int (*getPay)(int){
int pay = getPay(id);
printf("pay is %d", pay);
}
void getDefaultPay(id){...};
void getPay2(id){...};
void getPay3(id){...};
|
cs |
이렇게 함으로써, printPay함수는 일종의 청사진이 된다. 후에 다른 함수를 추가하더라도, printPay는 변경하지 않고 인자로 전달하는 함수에따라 다양한 사용이 가능하게 된다.
이렇듯 구체화하지 않은 추상화된 것에만 접근하게 함으로서 의존성의 약화를 목적으로 하는 것이 DIP이다.