SOLID 원칙이란 객체지향 설계에서 유지 보수성과 확장성을 높이기 위한 5가지 원칙을 의미합니다.
SOLID 원칙의 주요 목적은 다음과 같습니다.
- 유지보수성 및 확장성 향상
- 변경에 유연한 설계 제공
단일 책임 원칙 SRP
각 클래스는 하나의 책임만 가져야 한다는 원칙입니다.
즉, 클래스 하나의 변경 이유만을 가져야 하며, 특정 기능이나 역학을 수행하는 데 집중해야 합니다.
아래 상황을 구현해 봅시다.
- 학생의 이름을 받을 수 있어야 합니다
- 학생의 이름을 출력할 수 있어야 합니다
- 학생의 점수를 받고 성적을 계산할 수 있어야 합니다
잘못 적용된 사례
아래와 같이 Student 클래스에 모든 메서드가 구현된 경우입니다.
Student 클래스는 Sudent 정보만 있는 게 최선입니다.
#include <iostream>
#include <string>
class Student {
public:
void setName(const std::string& name) {
this->name = name;
}
void displayDetails() {
std::cout << "Student Name: " << name << std::endl;
}
void calculateGrade(int score) {
if (score >= 90) {
std::cout << "Grade: A" << std::endl;
} else if (score >= 80) {
std::cout << "Grade: B" << std::endl;
} else {
std::cout << "Grade: C" << std::endl;
}
}
private:
std::string name;
};
제대로 적용된 사례
각 기능을 나눠서 클래스를 구현했습니다.
Student는 학생 정보만 담고 있고 성적 계산은 GradeCalculator에서 하고 있습니다.
그리고 출력은 SudenetPrinter 에서 맡고 있습니다.
#include <iostream>
#include <string>
// 학생 정보 관리 클래스
class Student {
public:
void setName(const std::string& name) {
this->name = name;
}
std::string getName() const {
return name;
}
private:
std::string name;
};
// 성적 계산 클래스
class GradeCalculator {
public:
void calculateGrade(int score) {
if (score >= 90) {
std::cout << "Grade: A" << std::endl;
} else if (score >= 80) {
std::cout << "Grade: B" << std::endl;
} else {
std::cout << "Grade: C" << std::endl;
}
}
};
// 출력 클래스
class StudentPrinter {
public:
void displayDetails(const Student& student) {
std::cout << "Student Name: " << student.getName() << std::endl;
}
};
개방 폐쇄 원칙 OCP
확장에는 열려 있어야 하고, 수정에는 닫혀있어야 한다는 개념입니다.
즉, 기존 코드를 최소한으로 변경하면서 새로운 기능을 추가할 수 있도록 설계해야 합니다.
아래 상황을 구현해 봅시다
- 도형에 해당되는 번호를 받고, 해당 도형을 그려주는 클래스 제작
잘못 적용된 사례
아래와 같이 ShapeManager 클래스 하나가 모든 도형을 다 관리하고 있는 경우는 잘못된 겁니다.
어떤 도형이 추가된다면 drawShape의 코드가 수정됩니다.
즉 ShapeManager는 계속해서 영향을 받습니다.
class ShapeManager {
public:
void drawShape(int shapeType) {
if (shapeType == 1) {
// 원 그리기
} else if (shapeType == 2) {
// 사각형 그리기
}
}
};
제대로 적용된 사례
ShapeManager는 Shape의 인터페이스를 인자로 받습니다.
도형이 추가된다고 해도 ShapeManager는 전혀 영향을 받지 않습니다.
해당 도형 관련 클래스가 수정되고 Shape 인터페이스만 구현해 주면 됩니다.
class Shape {
public:
virtual void draw() = 0; // 순수 가상 함수
};
class Circle : public Shape {
public:
void draw() {
// 원 그리기
}
};
class Square : public Shape {
public:
void draw() {
// 사각형 그리기
}
};
class ShapeManager {
public:
void drawShape(Shape& shape) {
shape.draw(); // 다형성 활용
}
};
리스 코프 치환 원칙 LSP
자식 클래스는 부모 클래스에서 기대되는 행동을 보장해야 합니다
객체지향 프로그래밍에서 다향성을 활용할 때,
부모 클래스를 사용하는 코드가 자식 클래스로 대체되더라도 정상적으로 동작해야 합니다.
이를 위해 자식 클래스는 부모 클래스의 동작을 유지해야 합니다.
아래 상황을 구현해 봅시다.
- 모든 도형은 넓이를 계산할 수 있어야 합니다
- Rectangle 클래스와 이를 상속받는 Square (정사각형) 클래스를 설계해 봅시다
잘못 적용된 사례
아래와 같이 Rectangle 인터페이스가 존재하고 Square는 이를 상속받습니다.
하지만! Square 는 정사각형입니다.
너비와 높이를 따로 설정할 필요가 없습니다
따라서 Rectangle 에서 기대하는 행동을 보장하지 못합니다.
#include <iostream>
class Rectangle {
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; }
private:
int width = 0;
int height = 0;
};
class Square : public Rectangle {
public:
void setWidth(int w) override {
Rectangle::setWidth(w);
Rectangle::setHeight(w); // 정사각형은 너비와 높이가 같아야 함
}
void setHeight(int h) override {
Rectangle::setHeight(h);
Rectangle::setWidth(h); // 정사각형은 너비와 높이가 같아야 함
}
};
void testRectangle(Rectangle& rect) {
rect.setWidth(5);
rect.setHeight(10);
std::cout << "Expected area: 50, Actual area: " << rect.getArea() << std::endl;
}
int main() {
Rectangle rect;
testRectangle(rect); // Expected area: 50
Square square;
testRectangle(square); // Expected area: 50, Actual area: 100 (문제 발생)
return 0;
}
제대로 적용된 사례
ShapeManager는 Shape의 인터페이스를 인자로 받습니다
도형이 추가된다고 해도 ShapeManger는 전혀 영향을 받지 않습니다
해당 도형 관련 클래스가 수정되고 Shape 인터페이스만 구현해 주면 됩니다.
#include <iostream>
class Shape {
public:
virtual int getArea() const = 0; // 넓이를 계산하는 순수 가상 함수
};
class Rectangle : public Shape {
public:
void setWidth(int w) { width = w; }
void setHeight(int h) { height = h; }
int getWidth() const { return width; }
int getHeight() const { return height; }
int getArea() const override { return width * height; }
private:
int width = 0;
int height = 0;
};
class Square : public Shape {
public:
void setSide(int s) { side = s; }
int getSide() const { return side; }
int getArea() const override { return side * side; }
private:
int side = 0;
};
void testShape(Shape& shape) {
std::cout << "Area: " << shape.getArea() << std::endl;
}
int main() {
Rectangle rect;
rect.setWidth(5);
rect.setHeight(10);
testShape(rect); // Area: 50
Square square;
square.setSide(7);
testShape(square); // Area: 49
return 0;
}
인터페이스 분리 원칙 ISP
클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 한다는 원칙입니다.
즉, 하나의 거대한 인터페이스보다는 역할별로 세분화된 인터페이스를 만들어, 필요한 기능만 구현하도록 설계해야 합니다.
아래 요구사항에 맞는 설계를 해봅시다.
- 프린트, 스캔이 가능해야 한다
잘못 적용된 사례
프린터, 스캔을 하나의 클래스에서 모두 구현하게 되면, 이를 상속받는 클래스에서는 필요가 없는 경우에도 이를 모두 구현해야 합니다.
class Machnine {
private:
public:
Machnine() {}
void print() {
//세부 기능 구현
}
void scan() {
//세부 기능 구현
}
};
제대로 적용된 사례
프린터, 스캔, 팩스 클래스를 따로 구현하게 되면 각각 필요한 클래스만 가져다가 사용하면 됩니다.
class Printer {
public:
virtual void print() = 0;
};
class Scanner {
public:
virtual void scan() = 0;
};
class BasicPrinter : public Printer {
public:
void print() override {
// 문서 출력
}
};
class MultiFunctionDevice {//
private:
Printer* printer;
Scanner* scanner;
public:
MultiFunctionDevice(Printer* p, Scanner* s) : printer(p), scanner(s) {}
void print() {
if (printer) printer->print();
}
void scan() {
if (scanner) scanner->scan();
}
};
의존 역전 원칙 DIP
고수준 모듈은 저 수준 모듈에 직접 의존하는 것이 아니라, 두 모듈 모두 추상화에 의존해야 합니다.
쉽게 말하면, 구체적인 구현(저 수준 모듈)에 의존하는 것이 아니라, 인터페이스나 추상 클래스 같은 추상화 계층을 두어 결합도를 낮추는 것이 좋은 설계입니다.
아래 요구사항에 맞는 설계를 해봅시다
- 컴퓨터는 키보드와 모니터가 있습니다
- 키보드는 입력을 받을 수 있고 모니터는 출력할 수 있습니다
잘못 적용된 사례
현재 Computer 클래스는 Keyboard 클래스와 Monitor 클래스에 강하게 결합되어 있습니다.
즉 Keyboard 종류가 여러 개가 되거나 Monitor 종류가 여러 개가 되면 변경량이 많아집니다.
#include<string>
class Keyboard {
public:
std::string getInput() {
return "입력 데이터";
}
};
class Monitor {
public:
void display(const std::string& data) {
// 출력
}
};
class Computer {
Keyboard keyboard;
Monitor monitor;
public:
void operate() {
std::string input = keyboard.getInput();
monitor.display(input);
}
};
제대로 적용된 사례
키보드와 모니터를 인터페이스화해서 강한 결합이 아닌 약한 결합으로 했습니다.
이런 설계는 다른 입 / 출력장치로 쉽게 교체 가능합니다.
#include<string>
class InputDevice {
public:
virtual std::string getInput() = 0;
};
class OutputDevice {
public:
virtual void display(const std::string& data) = 0;
};
class Keyboard : public InputDevice {
public:
std::string getInput() override {
return "키보드 입력 데이터";
}
};
class Monitor : public OutputDevice {
public:
void display(const std::string& data) override {
// 화면에 출력
}
};
class Computer {
private:
InputDevice* inputDevice;
OutputDevice* outputDevice;
public:
Computer(InputDevice* input, OutputDevice* output)
: inputDevice(input), outputDevice(output) {}
void operate() {
std::string data = inputDevice->getInput();
outputDevice->display(data);
}
};