1. 객체 지향의 기본 원칙
1) 추상화(Abstraction)
- 복잡한 시스템이나 데이터를 단순화하여 필요한 부분만을 강조
- 공통적인 특성이나 행동을 찾아내어 클래스나 객체 모델을 만드는 과정
2) 캡슐화(Encapsulation)
- 데이터와 메서드를 하나의 단위로 묶어 외부로부터 보호하는 것
- 정보 은닉의 핵심 원리 중 하나
3) 다형성(Polymorphism)
- 같은 이름의 함수가 서로 다른 동작을 수행할 수 있는 능력(재사용성과 유연성이 높아진다.)
- 상속, 오버라이딩, 인터페이스 구현
4) 상속(Inheritance, Generalization)
- is-a 관계
- 기존의 클래스 특성 및 동작을 다른 클래스에서 재사용하거나 확장하기 위한 매커니즘
5) 구성(Composition)
- has-a 관계
- 다형성을 구현하는 방법
- 두 객체 사이의 의존성을 런타임에 처리할 수 있음
- 내부에 포함되는 객체의 구현이 아닌 인터페이스에 의존
- 스트래티지 패턴에서 상속 대신 구성을 활용해 런타임에 알고리즘을 교체
6) 위임(Delegation)
- 한 객체가 다른 객체에게 자신이 필요한 기능을 수행하도록 하는 것
- 객체간의 의존성을 줄이고 유연성과 코드 재사용성을 높이며 모듈화시킬 수 있다.
2. 그 외 원칙
- 최소 지식 원칙(The Principle of Least Knowledge)
- 헐리우드 원칙(The Hollywood Principle)
1) 최소 지식 원칙
- 객체가 다른 객체와 직접적으로 상호작용하는 관련성을 최소화해야 한다.
- 객체는 해당 객체와 직접 관련된 정보만을 알아야 하고 다른 객체의 내부 동작에 대해 알 필요가 없다.
- 객체와 다른 객체는 인터페이스를 통해 간접적으로만 상호작용을 유지해야 한다.
2) 헐리우드 원칙
- 높은 수준의 모듈 간의 상호작용을 관리하기 위한 원칙
- 상위 모듈이 하위 모듈에 의존하고, 하위 모듈이 또 상위 모듈에 의존하는 의존성 부패를 막기 위한 원칙
상위 모듈이 하위 모듈을 호출하려면 추상화된 인터페이스만 이용해야 한다.(템플릿 메서드 패턴)
3. 객체 지향의 설계 원칙
5대 원칙이 있으며 앞자리를 따서 SOLID라고도 부른다.
- 단일 책임 원칙(SRP, Single Responsibility Principle)
- 개방 폐쇄 원칙(OCP, Open Closed Principle)
- 리스코프 치환 원칙(LCP, Liskov Substitution Principle)
- 인터페이스 분리 원칙(ISP, Interface Segregation Principle)
- 의존 역전 원칙(DIP, Dependency Inversion Principle)
1) 단일 책임 원칙
- 소프트웨어의 모듈 또는 클래스는 단 하나의 책임만 가져야 한다.
- 클래스의 내용을 수정하는 이유는 한가지뿐이어야 한다.(예를들어 DB때문에도 바뀌고, UI때문에도 바뀌고 하지 않고, 오직 DB때문에만 변경할 일이 생기는 것처럼)
- 유지 보수, 재사용성, 테스트 용이성에 이점이 있다.
예시를 들자면 파일시스템을 읽고쓰는 클래스가 있다면 파일시스템 관리 클래스 하나라고 보는게 아니라 읽는 기능, 쓰는 기능 둘을 하는 클래스이므로 분리해야 한다.
다만 너무 세부적으로 다 나누다 보면 함수 하나마다 다른 클래스로 나눠야 할 수준이 되고 관리가 힘들어진다.
그래서 어느정도 연관성이 있는것끼리는 묶어서 구현해도 단일책임원칙을 준수했다고 판단할 수 있다.
2) 개방 폐쇄 원칙
- Open : 소프트웨어의 기능을 추가할때 기존의 코드를 변경하지 않고 확장할 수 있어야 한다.
- Closed : 이미 동작하는 코드에 대한 변경없이 새로운 기능을 추가할 수 있어야 한다.
- 즉 클래스가 확장에 대해서는 열려있어야 하지만 코드 변경에 대해서는 닫혀있어야 한다.
클래스에 기능을 추가할때 기존의 클래스 코드를 수정하는것이 아니라 상속을 받아서 확장하거나, 공통된 부분들은 인터페이스로 구현 후 상속받은 곳들에서 구현하는식으로 확장한다.
3) 리스코프 치환 원칙
- 하위 객체는 상위 객체로 대체될 수 있어야 한다.
- 자식클래스는 부모클래스의 기능을 완전히 대체해야 한다.
- 확장을 위해 상속을 받고 기능을 추가하다가 객체의 의의에 어긋나게 되는 것을 방지하는 원칙
올바른 상속을 위해 자식객체의 확장이 부모객체의 방향을 온전히 따르도록 권고하는 원칙이다.
보통 부모클래스의 함수를 오버라이딩하는것은 자식클래스 객체만의 동작을 추가하기 위함인 경우가 많으므로 준수하기 매우 까다로운 원칙이다.
리스코프 치환 원칙 위반사례로는 직사각형과 정사각형 예시가 유명하다.
정사각형은 수학적으로 직사각형의 한 종류라고 볼 수 있다. 직사각형 중 w와 h가 같으면 정사각형이기 때문이다.
그러므로 정사각형을 구현할때 직사각형을 상속받아서 구현하는 예시이다.
#include <iostream>
using namespace std;
class Rectangle
{
protected:
int width, height;
public:
virtual void setWidth(int w) { width = w; }
virtual void setHeight(int h) { height = h; }
int getWidth() const { return width; }
int getHeight() const { return height; }
int getArea() const { return width * height; }
};
class Square : public Rectangle
{
public:
void setWidth(int w) override { width = height = w; }
void setHeight(int h) override { width = height = h; }
};
int main()
{
Rectangle* rectangle = new Rectangle();
rectangle.setWidth(10);
rectangle.setHeight(5);
cout << rectangle.getArea(); // 50
/*
반면 main함수의 첫줄이
Rectangle* rectangle = new Square(); 였다면
출력 결과는 25가 된다.
Square가 Rectangle을 완전히 대체했다면 동일한 결과가 나와야 한다.
*/
return 0;
}
위처럼 사각형의 특징을 둘 다 갖고는 있지만 하나가 다른 하나를 완전히 포함하지 못한다면 잘못된 상속구조이다.
두 사각형 모두 사각형의 한 종류일 뿐이므로 공통 부모클래스 Shape를 만들고 그 하위에 Rectangle과 Squre 두개의 클래스가 Shape 클래스를 상속받도록 구현해야 한다.
4) 인터페이스 분리 원칙
- 클라이언트는 자신이 사용하지 않는 인터페이스에 의존하지 않아야 한다.
- 일반적인 인터페이스들은 더 작고, 구체적으로 분리해야 한다.
예시코드를 보면 바로 이해가 된다.
class Worker
{
public:
virtual void work() = 0;
virtual void eat() = 0;
virtual void sleep() = 0;
};
class Programmer : public Worker
{
public:
void work() override { cout << "Writing code..." << endl; }
void eat() override { cout << "Eating pizza..." << endl; }
void sleep() override { cout << "Sleeping on keyboard..." << endl; }
};
class Robot : public Worker
{
public:
void work() override { cout << "Executing tasks..." << endl; }
void eat() override { cout << "I don't need to eat." << endl; }
void sleep() override { cout << "I don't need to sleep." << endl; }
};
노동자 클래스를 만들고 일하고, 밥먹고, 잠자는 기능을 만든다.
프로그래머도 노동자이므로 Worker를 상속받고 인터페이스를 구현한다.
로봇도 노동자이므로 Worker를 상속받고 인터페이스를 구현하는데 로봇은 먹고 자는 기능이 필요 없으므로 쓸데없는 코드구현이 생긴다.
그러므로 인터페이스를 분리해서 아래처럼 구현한다.
class Worker
{
public:
virtual void work() = 0;
};
class EatableWorker
{
public:
virtual void eat() = 0;
};
class SleepableWorker
{
public:
virtual void sleep() = 0;
};
class Programmer : public Worker, public EatableWorker, public SleepableWorker
{...}
class Robot : public Worker
{...}
5) 의존 역전 원칙
- 구체화에 의존하지 말고 추상화에 의존해야 한다.
- 상위 모듈은 하위 모듈에 의존해선 안된다, 모든 모듈은 추상화된 것에 의존해야 한다.
- 이 원칙을 지켜야 하위 모듈의 변경이 상위 모듈에 영향을 주지 않고, 상위 모듈이 하위모듈에 종속되지 않는다.
의존 역전 원칙 위반코드는 아래와 같다.
#include <iostream>
using namespace std;
// 하위 모듈
class Database
{
public:
void connect() { cout << "Connecting to database..." << endl; }
// 그 외 기능...
};
// 상위 모듈
class Application
{
private:
Database db;
public:
void processData()
{
db.connect(); // 상위 Application 모듈이 하위 DB 모듈에 직접 의존
cout << "Processing data..." << endl;
}
};
int main()
{
Application* app = new Application();
app->processData();
return 0;
}
Application클래스가 상위클래스이지만 하위클래스인 Database클래스에 의존하고 있다.
즉 만약 하위클래스인 Database모듈이 변경된다면 상위클래스인 Application 클래스도 영향을 받게 된다.
의존 역전 원칙 준수를 위해 상위 모듈은 하위 모듈에 직접 의존해선 안되며 모든 모듈은 추상화된 것에 의존해야 한다.
그러므로 Database 클래스를 인터페이스로 만들어서 추상화하고 인터페이스에만 의존하게 해야한다.
class Database
{
public:
virtual ~Database() {}
virtual void connect() = 0;
};
class MySQLDatabase : public Database
{
public:
void connect() override { cout << "Connecting to MySQL database..." << endl; }
};
class Application {...} // 기존과 동일하지만 직접구현이 아닌 인터페이스에 의존
int main()
{
Database* mysqlDb = new MySQLDatabase();
Application* app = new Application(mysqlDb);
app->processData();
delete mysqlDb;
return 0;
}
이처럼 사용함으로써 다른 DB클래스들의 구현도 간단해지고 Application클래스도 Database클래스의 변경에 영향을 받지 않는다.
※ 참고 문헌
https://blog.itcode.dev/posts/2021/08/15/liskov-subsitution-principle
https://bloodstrawberry.tistory.com/1334
https://bloodstrawberry.tistory.com/1375
https://bloodstrawberry.tistory.com/1376