객체 지향 설계의 5가지 원칙 - SOLID
이 글은 제 개인적인 공부를 위해 작성한 글입니다. 틀린 내용이 있을 수 있고, 피드백은 환영합니다.
개요
디자인 패턴과 좋은 아키텍처의 근간이 되는 SOLID 5원칙에 대해 공부해보자. SOLID는 다섯 가지 객체지향 설계 원칙의 앞 글자를 딴 단어이다.
- SRP (Single Responsibility Principle) : 단일 책임 원칙
- OCP (Open Closed Principle) : 개방 폐쇄 원칙
- LSP (Liskov Substitution Principle) : 리스코프 치환 원칙
- ISP (Interface Segregation Principle) : 인터페이스 분리 원칙
- DIP (Dependency Inversion Principle) : 의존성 역전 원칙
이 원칙들을 지키면 유지보수가 쉽고, 확장성이 뛰어나며, 변경에 유연한 코드를 작성할 수 있다. SOLID 설계 원칙은 OOP의 4가지 특징(추상화, 상속, 다형성, 캡슐화)와 더불어 단골 면접 질문 중 하나이다.
SOLID는 어떠한 특정 프로그래밍 언어 혹은 프레임워크를 위해 만든 원칙이 아니다. 그리고 이 5가지 원칙들은 서로 독립된 개별적인 개념이 아니라 서로 개념적으로 연관되어 있다. 원칙끼리 서로가 서로를 이용하기도 하고 포함하기도 한다.
단일 책임 원칙 (SRP, Single Responsibility Principle)
하나의 클래스(객체)는 단 하나의 책임만 가져야 한다.
여기서 책임이라는 의미는 하나의 기능 담당으로 보면 된다. 즉, 하나의 클래스는 하나의 기능 담당하여 하나의 책임을 수행하는데 집중되도록 클래스를 따로따로 여러개 설계하라는 원칙이다.
만약 하나의 클래스에 기능이 여러 개 있다면 기능 수정 혹은 변경이 일어났을 때 수정해야 할 코드가 많아진다. 예를 들어 A를 고쳤더니 B를 수정해야 하고 또 C를 수정해야 하고, C를 수정했더니 다시 A로 돌아가서 수정해야 하는, 마치 책임이 순환되는 형태가 되어 버린다. SRP 원칙을 따름으로써 한 책임의 변경으로부터 다른 책임의 변경으로의 연쇄작용을 극복할 수 있게 된다.
최종적으로 SRP의 목적은 프로그램의 유지보수성을 높이기 위한 설계 기법이다. 책임의 범위는 딱 정해져 있는 것이 아니고, 어떤 프로그램을 개발하느냐에 따라 기준이 달라 질 수 있다.
1
2
3
4
5
6
class Player
{
public:
void Attack() { /* 공격 */ }
void SavePlayerData() { /* 플레이어 데이터 저장. 책임 초과 */ }
};
1
2
3
4
5
6
7
8
9
10
11
class Player
{
public:
void Attack() { /* 공격 */ }
};
class SaveSystem
{
public:
void Save() { /* 플레이어 데이터 저장. 책임 분리 */ }
};
개방 폐쇄 원칙 (OCP, Open Closed Principle)
확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다.
새로운 기능을 추가할 때 기존 코드를 수정하지 않고 기능을 확장할 수 있어야 한다. 추상화(인터페이스, 상속)의 핵심 원칙이다.
- 확장에 열려있다 : 새로운 변경 사항이 발생했을 때 유연하게 코드를 추가함으로써 큰 힘을 들이지 않고 기능 확장이 가능
- 변경에 닫혀있다 : 새로운 변경 사항이 발생했을 때 객체를 직접적으로 수정하는걸 제한 -
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Weapon
{
public:
std::string type;
};
class DamageCalculator
{
public:
void Calculate(Weapon w)
{
if (w.type == "Sword") { /* 검 데미지 계산 */ }
else if (w.type == "Bow") { /* 활 데미지 계산 */ }
// 새로운 무기가 추가될 때마다 이 함수를 수정 -> 수정에 열려 있음
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Weapon
{
public:
virtual ~Weapon() = default;
virtual int CalculateDamage() = 0;
};
class Sword : public Weapon
{
public:
int CalculateDamage() override { /* 검 데미지 계산 */ }
};
// 새로운 무기가 추가되어도 기존 코드는 건드리지 않음
class Bow : public Weapon
{
public:
int CalculateDamage() override { /* 활 데미지 계산 */ }
};
리스코프 치환 원칙 (LSP, Liskov Substitution Principle)
자식 클래스는 언제나 자신의 부모 클래스로 교체할 수 있어야 한다.
쉽게 말해 다형성 원리를 이용하기 위한 원칙 개념으로 보면 된다. 다형성의 특징을 이용하기 위해 상위 클래스 타입으로 객체를 선언하여 하위 클래스의 인스턴스를 받으면, 업캐스팅된 상태에서 부모의 메서드를 사용해도 동작이 의도대로 흘러가야 하는 것을 의미한다.
1
2
3
4
5
6
7
8
9
10
11
class Bird
{
public:
virtual void Fly() { /* 날다 */ }
};
class Penguin : public Bird
{
public:
void Fly() override { /* 펭귄은 날 수 없으므로 예외 처리. 부모의 규약을 깨버림! */ }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
class Bird { /* 공통 속성만 정의 */ };
class FlyingBird : public Bird
{
public:
virtual void Fly() { /* 날다 */ }
};
class Penguin : public Bird
{
// 펭귄은 날 수 없으므로 Fly 메서드가 없음. 부모의 규약을 깨지 않음!
void Swim() { /* 헤엄치다 */ }
};
인터페이스 분리 원칙 (ISP, Interface Segregation Principle)
자신이 사용하지 않는 메서드에 의존하지 않아야 한다.
하나의 거대한 인터페이스보다, 꼭 필요한 기능만 모아둔 구체적인 인터페이스 여러 개가 훨씬 낫다는 말이다. SRP 원칙이 클래스의 단일 책임을 강조한다면, ISP 원칙은 인터페이스의 단일 책임을 강조하는 원칙이다.
UI 이벤트, 물리 충돌, 오디오 이벤트 등을 하나의 거대한 인터페이스에 다 때려 넣으면, 오디오만 쓰고 싶은 클래스조차 사용하지도 않는 다른 함수들을 의무적으로 상속받아 구현해야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
class IMultiFunction
{
public:
virtual void Interact() = 0;
virtual void UIUpdate() = 0;
};
class Box : public IMultiFunction
{
public:
void Interact() override { /* 박스 여는 상호 작용 */ }
void UIUpdate() override { /* 불필요한 의존성 발생 */ }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class IInteractable
{
public:
virtual void Interact() = 0;
};
class IUIUpdatable
{
public:
virtual void UIUpdate() = 0;
};
// 상자는 필요한 인터페이스만 상속 받기
class Box : public IInteractable
{
public:
void Interact() override { /* 박스 여는 상호 작용 */ }
};
의존성 역전 원칙 (DIP, Dependency Inversion Principle)
고수준 모듈은 저수준 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 한다.
어떤 클래스를 참조해서 사용해야 하는 상황이 생긴다면, 그 클래스를 직접 참조하는 것이 아니라 그 대상의 상위 요소(추상 클래스나 인터페이스)로 참조하라는 말이다. 쉽게 말해서 구현 클래스에 의존하지 말고, 인터페이스에 의존하라는 뜻이다.
DIP는 각 클래스 간의 결합도를 낮추는 것이 목표다.
1
2
3
4
5
6
7
8
9
10
11
12
13
class SteamNetwork
{
public:
void Connect() { /* 스팀 네트워크 연결 */ }
};
class NetworkManager
{
private:
SteamNetwork* network; // 구체적인 모듈에 직접 의존
public:
NetworkManager() { network = new SteamNetwork(); }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 추상화 레이어 (인터페이스) 구성
class INetworkSubsystem {
public:
virtual ~INetworkSubsystem() = default;
virtual void Connect() = 0;
};
// 고수준 모듈은 인터페이스만 바라봄
class NetworkManager {
private:
INetworkSubsystem* network;
public:
// 외부에서 주입받음 (의존성 주입, DI)
NetworkManager(INetworkSubsystem* sub) : network(sub) {}
};
참고