📒 객체지향 설계 5대 원칙(SOLID)
📌 단일 책임 원칙(Single Responsibility Principle)
- 하나의 객체는 하나의 책임만 가져야 한다.
- 객체가 변경되는 이유는 한가지 이유만을 가져야 한다.
ex)
- 고양이 클래스를 통해 고양이의 상태를 출력하는 프로그램을 개발한다고 하자.
- 고양이 클래스에 고양이가 할 수 있는 여러 상태를 메소드로 만들었다.
- 그런데 출력하는 기능까지 고양이 클래스에 넣어버렸다.
- 이렇게 되면 고양이 클래스는 고양이의 상태와 출력이라는 두 가지 책임을 지게 되는데,
- 만일 출력에 관련된 수정이 발생하게 되면 별 상관없어 보이는 고양이 클래스를 고치게 되는 일이 생긴다.
- 이처럼 하나의 클래스가 두 가지 이상의 책임을 지게 되면 클래스의 목적이 모호해지고, 기능을 수정할 때 영향을 받는 범위도 커져서 유지보수가 힘들어진다.
#include <iostream>
using namespace std;
class Cat {
private:
int age;
string name;
public:
Cat(int _age, string _name) : age(_age), name(_name) {}
void walk() {
}
void eat() {
}
void print() {
cout << "age : " << age << " name : " << name << endl;
}
};
- 우리가 흔히 아는 고양이는 걷기도 가능하고 먹는 것 또한 가능하다.
- 그러나 '출력'은 우리가 흔히 아는 고양이가 하는 일이 아니다.
- 이러한 경우에 Cat 클래스에서 print 함수를 빼주어야 한다.
#include <iostream>
using namespace std;
class Cat {
private:
int age;
string name;
public:
Cat(int _age, string _name) : age(_age), name(_name) {}
void walk() {
}
void speak() {
}
int getAge() const { return age; }
string getName() const { return name; }
};
int main() {
Cat c(10, "golgol");
cout << "고양이의 나이 : " << c.getAge() << endl;
cout << "고양이의 이름 : " << c.getName() << endl;
return 0;
}
- 고양이의 상태를 반환하는 getter 함수를 만들고, 고양이를 사용하는 클라이언트 코드에서는 getter를 활용하여 출력할 수 있다. (원한다면 출력뿐만 아니라 다른 행동도 가능)
- 이렇게 하면 Cat 클래스에는 고양이에 관한 함수들만 남게 되고, SRP를 준수할 수 있게 된다.
📌 개방-폐쇄 원칙(Open-Closed Principle)
- 객체는 확장에 대해서는 개방적이고 수정에 대해서는 폐쇄적이어야 한다.
- 즉, 객체 기능의 확장을 허용하고 스스로의 변경은 피해야 한다. (기존 코드의 수정 없이 기능을 확장할 수 있어야 한다.)
ex)
- 스타크래프트 게임의 유닛을 만든다고 가정하자.
- 유닛을 만들기 위해 이런저런 공통 사항을 생각하며 메서드와 필드를 정의한다.
- 이 중엔 이동 메서드도 있다. 이동 메서드는 대상 위치를 인수로 받아 속도에 따라 대상 위치까지 유닛을 길찾기 인공지능을 사용해 이동한다.
- 하지만 곰곰이 생각해 보니 이러면 브루들링 같은 유닛의 기묘한 움직임을 구현할 때 애로사항이 생길 것 같다.
- 고민하다가 이동 메서드에서 이동 패턴을 나타내는 코드를 별도의 메서드로 분리하고, 구현을 하위 클래스에 맡긴다.
- 그러면 브루들링 클래스에서는 이동 패턴 메서드만 재정의하면 유닛 클래스의 변경 없이 색다른 움직임을 보여줄 수 있다.
- '유닛' 클래스의 '이동' 메서드는 수정할 필요가 없다. (수정 폐쇄)
- 브루들링 클래스의 이동 패턴 메서드만 재정의하면 된다. (확장 개방)
#include <iostream>
using namespace std;
class Animal {
private:
string type;
public:
Animal(string _type) : type(_type) {}
string getType() {
return type;
}
};
void soundWords(Animal animal) {
if (animal.getType() == "cat")
cout << "meow" << endl;
else if (animal.getType() == "bird")
cout << "tweet" << endl;
else
cout << "wrong a type" << endl;
}
int main() {
Animal cat("cat");
Animal bird("bird");
soundWords(cat);
soundWords(bird);
return 0;
}
- 위 예제는 동물 별로 울음소리를 출력한다.
- 그런데 위 코드에서 다른 동물을 추가해야 한다면 soundWords를 수정해야 한다. (기존 코드 수정)
#include <iostream>
using namespace std;
class IAnimal {
public:
virtual void speak() = 0;
};
class Cat : public IAnimal {
public:
void speak() {
cout << "meow" << endl;
}
};
class Bird : public IAnimal {
public:
void speak() {
cout << "tweet" << endl;
}
};
void soundWords(IAnimal* animal) {
animal->speak();
}
int main() {
Cat cat;
Bird bird;
soundWords(&cat);
soundWords(&bird);
return 0;
}
- 위 코드처럼 IAnimal 추상 클래스를 통해 규칙을 먼저 설계한다.
- 모든 동물은 IAnimal로 부터 파생되도록 구현한다.
- 이러한 규칙을 잘 지켜 설계한다면 클라이언트와 동물 간의 결합도가 낮아져 교체가 가능하고 확장성이 좋아진다.
- 위 코드에서 다른 동물을 추가 하여도 soundWords 함수 수정 없이 추가할 수 있다. (수정 폐쇄)
📌 리스코프 치환 원칙(Liskov Substitution Principle)
- 자식 클래스는 언제나 자신의 부모 클래스를 대체할 수 있어야 한다.
- 즉, 부모 클래스가 들어갈 자리에 자식 클래스를 넣어도 계획대로 잘 동작해야 한다는 것이다.
- 이것이 상속의 본질인데, 이를 지키지 않으면 부모 클래스 본래의 의미가 변해서 is-a 관계가 망가져 다형성을 지킬 수 없게 된다.
ex)
- 컴퓨터용 '마우스' 클래스가 있다고 가정하자.
- 컴퓨터에 있는 PS/2 포트나 USB 포트를 통해 연결할 수 있고, 마우스를 바닥에 대고 움직이면 컴퓨터가 신호를 받아들인다는 것을 안다.
- 사용 면에서는 왼쪽 버튼, 오른쪽 버튼, 휠이 있어 사용자가 누르거나 굴릴 수 있을 것이다.
- 마우스가 볼마우스든 광마우스든, 아니면 GPS를 이용하건 간에 사용자는 바닥에 착 붙여 움직일 것이고, 모든 마우스는 예상대로 신호를 보내줄 것이다.
- 또한 만약 추가적인 특별한 버튼이 있는 마우스(상속)라도 그 버튼의 사용을 제외한 다른 부분은 보통의 마우스와 다를 바 없으므로 사용자는 그 마우스의 그 버튼이 어떤 역할을 하든 간에 문제없이 잘 사용할 수 있다.
- 여기까지 나온 마우스들은 LSP를 잘 지킨다고 볼 수 있다.
- 하지만 만약, 오른쪽/왼쪽 버튼 대신 옆쪽 버튼을 사용하는 펜마우스를 처음으로 접하게 되면 사용자는 평소 보던 버튼을 누를 수 없다면 이상을 호소할 것이다. 이런 경우 LSP를 전혀 지키지 못한 것이 된다.
#include <iostream>
using namespace std;
class Rectangle {
private:
int width;
int height;
public:
int getWidth() const { return width; }
void setWidth(const int _widht) { width = _widht; }
int getHeight() const { return height; }
void setHeight(const int _height) { height = _height; }
int getArea() { return width * height; }
};
class Square : public Rectangle {
};
- 위 예제는 직사각형 클래스를 상속받아 정사각형 클래스를 만들려고 한다.
- 그런데 정사각형 클래스는 width와 height 중 하나만 있어도 무방하다.
- 즉, 퇴화함수가 발생하는데 이것은 잠정적인 LSP 위반이다.
- 또한 정사각형 클래스의 넓이를 구하려고 시도해도 정상적인 값이 나오지 않는다.
- 정사각형 클래스의 메서드를 수정하여 정사각형의 불변 조건(width와 height가 같음)을 유지하면, 이 메서드는 크기를 독립적으로 변경할 수 있다고 설명한 직사각형의 사후조건을 위반한다.
- 곰곰이 생각해 보면 직사각형과 정사각형은 상속 관계가 될 수 없다.
- 사각형의 특징을 서로 갖고 있긴 하지만, 두 사각형 모두 사각형의 한 종류일 뿐, 하나가 다른 하나를 완전히 포함하지 못하는 구조다.
#include <iostream>
using namespace std;
class Shape {
private:
int width;
int height;
public:
int getWidth() const { return width; }
void setWidth(const int _widht) { width = _widht; }
int getHeight() const { return height; }
void setHeight(const int _height) { height = _height; }
int getArea() { return width * height; }
};
class Rectangle : public Shape {
public:
Rectangle(int _width, int _height) {
setWidth(_width);
setHeight(_height);
}
};
class Square : public Shape {
Square(int _length) {
setWidth(_length);
setHeight(_length);
}
};
- 위 예제는 Shape 클래스에 width와 height를 선언하고 두 사각형은 Shape를 상속받는다.
- 이렇게 하면 자식 클래스가 부모 클래스를 대체할 수 있고(LSP), 있고 위 문제들도 자연스럽게 해결할 수 있다.
📌 인터페이스 분리 원칙(Interface Segregation Principle)
- 클라이언트에서 사용하지 않는 메서드는 사용해선 안 된다. 그러므로 인터페이스를 다시 작게 나누어 만든다.
- OCP와 비슷한 느낌도 들지만, 엄연히 다른 원칙이다.
- 하지만 ISP를 잘 지키면 OCP도 잘 지키게 될 확률이 높아진다.
- 정확히는 인터페이스의 SRP라고 할 수 있다.
ex)
- 게임을 만들 때, 충돌 처리와 이펙트 처리를 하는 서버를 각각 두고 이 처리 결과를 모두 클라이언트에게 보내야 한다고 가정하자.
- 그렇다면 아마 Client라는 인터페이스를 정의하고 그 안에 '충돌전달()'과 '이펙트전달(이펙트)'를 넣어놓을 것이다.
- 그리고 충돌 서버와 이펙트 서버에서 이 인터페이스를 구현하는 객체들을 모아두고 있으며, 때에 따라 적절히 신호를 보낸다.
- 하지만 이렇게 해두면 충돌 서버에겐 쓸모없는 이펙트 전달 인터페이스가 제공되며, 이펙트 서버에겐 쓸모없는 충돌전달 인터페이스가 제공된다.
- 이를 막기 위해서는 Client 인터페이스를 쪼개 '이펙트전달가능' 인터페이스와 '충돌전달가능' 인터페이스로 나눈 뒤, 충돌에는 충돌만, 이펙트에는 이펙트만 전달하면 될 것이다.
- 또한 Client 인터페이스는 남겨두되, '이펙트전달가능'과 '충돌전달가능' 이 둘을 상속하면 된다.
#include <iostream>
using namespace std;
class ICarBoat
{
void drive();
void trunLeft();
void turnRight();
void steer();
void steerLeft();
void steerRight();
};
class Genesis : public ICarBoat {
void drive() {}
void turnLeft() {}
void turnRight() {}
};
class Avante : public ICarBoat {
void drive() {}
void turnLeft() {}
void turnRight() {}
};
- 수륙양용차 인터페이스를 통해 자동차를 만들게 되면 자동차에는 필요하지 않은 정보들까지 오게 된다.
- ISP는 이처럼 하나의 큰 인터페이스가 아니라 작은 인터페이스로 분리할 필요가 있다는 것이다.
#include <iostream>
using namespace std;
class ICar
{
void drive();
void trunLeft();
void turnRight();
};
class Car : public ICar {
void drive() {}
void turnLeft() {}
void turnRight() {}
};
class IBoat{
void steer();
void steerLeft();
void steerRight();
};
class Boat : public IBoat{
void steer() {}
void steerLeft() {}
void steerRight() {}
};
class CarBoat : public ICar, IBoat {
void drive() {}
void turnLeft() {}
void turnRight() {}
void steer();
void steerLeft();
void steerRight();
};
- 위 예제처럼 자동차는 ICar 인터페이스로, 보트는 IBoat 인터페이스, 수륙양용차는 ICar와 IBoat를 조합하여서 만들면 된다.
📌 의존성 역전 원칙(Dependency inversion Principle)
- 고수준 모듈 : 어떤 의미 있는 단일 기능을 제공하는 모듈
- 저수준 모듈 : 고수준 모듈의 기능을 구현하기 위해 필요한 하위 기능의 실제 구현
- 고수준 모듈은 저수준 모듈의 구현에 의존해서는 안 되며, 저수준 모듈이 고수준 모듈에 의존해야 한다.
- 의존 관계를 맺을 때는 변화하기 쉬운 것 또는 자주 변환하는 것보다는 변화하기 어려운 것, 거의 변화가 없는 것에 의존해야 한다.
- 즉, 구체적인 클래스보다는 인터페이스나 추상 클래스와 관계를 맺어야 한다는 것이다.
ex)
- '강아지'라는 클래스가 '동물'이라는 인터페이스를 상속받는다고 가장하자.
- '동물'에는 '짖기'와 '걷기'의 기능이 있다.
- 그런데, '고양이'라는 동물을 추가해야 하는데 '고양이'는 '짖기'는 할 수 없고 '걷기'만 가능하다면?
- '고양이'가 '동물' 인터페이스를 상속받게 되면 '짖기' 기능까지 구현해야 한다.
- 그렇기 때문에 '동물'처럼 일반적인 기능을 가진 인터페이스를 사용하기보다는 '짖기'와 '걷기'를 각각 인터페이스로 분리하여 각 필요한 기능만 상속받아서 구현하면 된다.
#include <iostream>
using namespace std;
class Cat {
public:
void speak() { cout << "meow" << endl; }
};
class Bird {
public:
void speak() { cout << "tweet" << endl; }
};
class Zoo {
private:
Cat cat;
Bird bird;
public:
void setZoo(Cat _cat, Bird _bird) {
cat = _cat;
bird = _bird;
}
};
- 위 예제는 동물원 클래스에서 여러 동물에 의존하고 있다.
- 만약, 다른 동물이 추가된다면 Zoo 클래스에서도 수정이 필요하게 되고, 의존할 모듈이 더 많아진다.
- 이렇게 의존할 게 많아지면 추후에 코드의 유지보수가 힘들어진다.
#include <iostream>
#include <vector>
using namespace std;
class IAnimal {
public:
virtual void speak() = 0;
};
class Cat : public IAnimal {
public:
virtual void speak() { cout << "meow" << endl; }
};
class Bird : public IAnimal {
public:
virtual void speak() { cout << "tweet" << endl; }
};
class Zoo {
private:
vector<IAnimal*> animals;
public:
void addAnimal(IAnimal* _animal) {
animals.push_back(_animal);
}
void speakAll() {
for (IAnimal* animal : animals) {
animal->speak();
}
}
};
int main() {
Zoo zoo;
Cat cat;
Bird bird;
zoo.addAnimal(&cat);
zoo.addAnimal(&bird);
zoo.speakAll();
return 0;
}
- 위 예제는 IAnimal 추상 클래스를 만들어 동물들의 규칙을 정의하고 모든 동물은 IAnimal의 규칙을 토대로 파생되어 만들어진다.
- 또한 직접적인 의존을 하지 않고 추상적인 IAnimal의 의존을 가지게 된다.
- 이렇게 하면 다른 동물이 추가되더라도 Zoo 클래스의 코드를 수정하지 않아도 된다.
- 이처럼 추상화 모듈을 만들고 고수준 모듈과 저수준 모듈 모두 추상화 모듈에 의존하게 만들어야 한다.
📌 앨런케이가 말하는 OOP(객체라는 개념을 창시한 사람)
- 나에게 OOP는 메시징, 로컬 보존 및 보호 및 상태 프로세스 숨기기, 모든 것의 극단적인 자연 바인딩만을 의미합니다.
- 오래전, 이 주제를 표현하는데 "객체"라는 용어를 만들어 죄송합니다. 많은 사람이 작은 아이디어에 집중하게 해야 했기 때문입니다. 가장 큰 아이디어는 메시징입니다.