OOP - 객체지향 설계 5대 원칙 SOLID

rizz·2024년 1월 12일
0

디자인 패턴

목록 보기
3/4

📒 객체지향 설계 5대 원칙(SOLID)

📌 단일 책임 원칙(Single Responsibility Principle)

  • 하나의 객체는 하나의 책임만 가져야 한다.
  • 객체가 변경되는 이유는 한가지 이유만을 가져야 한다.

ex)

  • 고양이 클래스를 통해 고양이의 상태를 출력하는 프로그램을 개발한다고 하자.
  • 고양이 클래스에 고양이가 할 수 있는 여러 상태를 메소드로 만들었다.
  • 그런데 출력하는 기능까지 고양이 클래스에 넣어버렸다.
  • 이렇게 되면 고양이 클래스는 고양이의 상태와 출력이라는 두 가지 책임을 지게 되는데,
  • 만일 출력에 관련된 수정이 발생하게 되면 별 상관없어 보이는 고양이 클래스를 고치게 되는 일이 생긴다.
  • 이처럼 하나의 클래스가 두 가지 이상의 책임을 지게 되면 클래스의 목적이 모호해지고, 기능을 수정할 때 영향을 받는 범위도 커져서 유지보수가 힘들어진다.
// C++
// SRP를 준수하지 못한 예제
#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 함수를 빼주어야 한다.

// C++
// SRP를 준수한 예제
#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)

  • 스타크래프트 게임의 유닛을 만든다고 가정하자.
  • 유닛을 만들기 위해 이런저런 공통 사항을 생각하며 메서드와 필드를 정의한다.
  • 이 중엔 이동 메서드도 있다. 이동 메서드는 대상 위치를 인수로 받아 속도에 따라 대상 위치까지 유닛을 길찾기 인공지능을 사용해 이동한다.
  • 하지만 곰곰이 생각해 보니 이러면 브루들링 같은 유닛의 기묘한 움직임을 구현할 때 애로사항이 생길 것 같다.
  • 고민하다가 이동 메서드에서 이동 패턴을 나타내는 코드를 별도의 메서드로 분리하고, 구현을 하위 클래스에 맡긴다.
  • 그러면 브루들링 클래스에서는 이동 패턴 메서드만 재정의하면 유닛 클래스의 변경 없이 색다른 움직임을 보여줄 수 있다.
  • '유닛' 클래스의 '이동' 메서드는 수정할 필요가 없다. (수정 폐쇄)
  • 브루들링 클래스의 이동 패턴 메서드만 재정의하면 된다. (확장 개방)
// C++
// OCP를 준수하지 못한 예제
#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를 수정해야 한다. (기존 코드 수정)

// C++
// OCP를 준수한 예제
#include <iostream>
using namespace std;

class IAnimal { // 추상 클래스를 활용하여 규칙 설계
public:
	virtual void speak() = 0;
};

// 모든 동물은 IAnimal로 부터 파생되어야 한다.
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를 전혀 지키지 못한 것이 된다.
// C++
// 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 { // 정사각형 클래스
	// Square 클래스에게는 width와 height 중 하나만 있어도 무방하다.
	// LSP 위반
};
  • 위 예제는 직사각형 클래스를 상속받아 정사각형 클래스를 만들려고 한다.
  • 그런데 정사각형 클래스는 width와 height 중 하나만 있어도 무방하다.
  • 즉, 퇴화함수가 발생하는데 이것은 잠정적인 LSP 위반이다.
  • 또한 정사각형 클래스의 넓이를 구하려고 시도해도 정상적인 값이 나오지 않는다.
  • 정사각형 클래스의 메서드를 수정하여 정사각형의 불변 조건(width와 height가 같음)을 유지하면, 이 메서드는 크기를 독립적으로 변경할 수 있다고 설명한 직사각형의 사후조건을 위반한다.
  • 곰곰이 생각해 보면 직사각형과 정사각형은 상속 관계가 될 수 없다.
  • 사각형의 특징을 서로 갖고 있긴 하지만, 두 사각형 모두 사각형의 한 종류일 뿐, 하나가 다른 하나를 완전히 포함하지 못하는 구조다.

// C++
// LSP를 준수한 예제
#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 인터페이스는 남겨두되, '이펙트전달가능'과 '충돌전달가능' 이 둘을 상속하면 된다.
// C++
// ISP를 준수하지 못한 예제
#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는 이처럼 하나의 큰 인터페이스가 아니라 작은 인터페이스로 분리할 필요가 있다는 것이다.

// C++
// 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)

  • '강아지'라는 클래스가 '동물'이라는 인터페이스를 상속받는다고 가장하자.
  • '동물'에는 '짖기'와 '걷기'의 기능이 있다.
  • 그런데, '고양이'라는 동물을 추가해야 하는데 '고양이'는 '짖기'는 할 수 없고 '걷기'만 가능하다면?
  • '고양이'가 '동물' 인터페이스를 상속받게 되면 '짖기' 기능까지 구현해야 한다.
  • 그렇기 때문에 '동물'처럼 일반적인 기능을 가진 인터페이스를 사용하기보다는 '짖기'와 '걷기'를 각각 인터페이스로 분리하여 각 필요한 기능만 상속받아서 구현하면 된다.
// C++
// DIP를 준수하지 못한 예제
#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 클래스에서도 수정이 필요하게 되고, 의존할 모듈이 더 많아진다.
  • 이렇게 의존할 게 많아지면 추후에 코드의 유지보수가 힘들어진다.

// C++
// DIP를 준수한 예제
#include <iostream>
#include <vector>
using namespace std;

class IAnimal {
public:
	virtual void speak() = 0;
};

class Cat : public IAnimal { // IAnimal 클래스에만 의존
public:
	virtual void speak() { cout << "meow" << endl; }
};

class Bird : public IAnimal { // IAnimal 클래스에만 의존
public:
	virtual void speak() { cout << "tweet" << endl; }
};

class Zoo { // IAnimal 클래스에만 의존
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는 메시징, 로컬 보존 및 보호 및 상태 프로세스 숨기기, 모든 것의 극단적인 자연 바인딩만을 의미합니다.
  • 오래전, 이 주제를 표현하는데 "객체"라는 용어를 만들어 죄송합니다. 많은 사람이 작은 아이디어에 집중하게 해야 했기 때문입니다. 가장 큰 아이디어는 메시징입니다.
profile
복습하기 위해 쓰는 글

0개의 댓글

관련 채용 정보