SOLID Design Principles
Robotics Systems Lab 에서 작성된 Best Practices cpp 를 보고 객체 지향 설계(OOD) 에서 사용되는 원칙들 중 하나인 SOLID 방법에 대해 알아보았다.
참고 사이트
0. SOLID란?
SOLID는 객체지향 설계에서 자주 언급되는 다섯 가지 원칙의 약자이다.
- S – Single Responsibility Principle (단일 책임 원칙)
- O – Open–Closed Principle (개방–폐쇄 원칙)
- L – Liskov Substitution Principle (리스코프 치환 원칙)
- I – Interface Segregation Principle (인터페이스 분리 원칙)
- D – Dependency Inversion Principle (의존성 역전 원칙)
이 원칙들은 프로젝트 규모가 커지더라도 수정하기 쉽고, 확장하기 쉬운 코드가 될 수 있게 해준다.
C++에서는 템플릿, 상속, 스마트 포인터 등등 섞이면 구조가 복잡해지기 쉬운데, SOLID를 적용하면 어디까지 기능을 분할해야 할지 기준이 생긴다.
요즘은 Claude Code 나 Codex 가 잘되어있기 때문에, 이 원칙들을 지시문에 넣으면 알아서 잘 작성해준다.
1. Single Responsibility Principle
클래스는 변경될 이유가 단 하나만 있어야 한다.
이는, “메서드 1개만 있어야 한다”가 아니라 “역할 축이 하나여야 한다” 에 가깝다.
예를 들어, UserService라는 “유저 등록 비즈니스 로직” 의 경우 “유저 등록 + DB 저장 + 메일 전송 + 로그 기록”까지 전부 하면 이 클래스가 수정되어야 할 이유가 여러 개라 SRP 위반에 가깝다.
나쁜 예 – 한 클래스가 모든 걸 다 할 때
#include <string>
#include <fstream>
#include <iostream>
class UserManager {
public:
void registerUser(const std::string& name, const std::string& email) {
// (1) 비즈니스 로직
if (!isValidEmail(email)) {
throw std::runtime_error("Invalid email");
}
// (2) DB 저장
saveToDatabase(name, email);
// (3) 환영 이메일 전송
sendWelcomeEmail(email);
// (4) 로그 파일 기록
logToFile(name, email);
}
};이렇게 한 클래스가 여러 개의 기능을 가진 경우 DB 저장소가 바뀌거나, 메일 대신 카카오 알림으로 환영 알림을 전송하는 등 다른 기능에 대한 수정사항이 생길 때마다 이 클래스를 수정해야 한다.
한 클래스가 너무 많은 책임을 갖고 있어서, “바뀔 이유”가 4개 이상
개선된 예 – 책임 분리
역할별로 클래스를 나눠 위 문제를 해결한다.
#include <string>
#include <fstream>
#include <iostream>
class UserRepository {
public:
void save(const std::string& name, const std::string& email) {
std::cout << "Saving user to DB: " << name << ", " << email << "\n";
}
};
class UserNotifier {
public:
void sendWelcomeEmail(const std::string& email) {
std::cout << "Sending welcome email to: " << email << "\n";
}
};
class UserLogger {
public:
void logRegistration(const std::string& name, const std::string& email) {
std::ofstream ofs("user.log", std::ios::app);
ofs << "Registered user: " << name << ", " << email << "\n";
}
};
class UserService {
public:
UserService(UserRepository& repo, UserNotifier& notifier, UserLogger& logger)
: repo_(repo), notifier_(notifier), logger_(logger) {}
void registerUser(const std::string& name, const std::string& email) {
if (!isValidEmail(email)) {
throw std::runtime_error("Invalid email");
}
repo_.save(name, email);
notifier_.sendWelcomeEmail(email);
logger_.logRegistration(name, email);
}
private:
bool isValidEmail(const std::string& email) {
return email.find('@') != std::string::npos;
}
UserRepository& repo_;
UserNotifier& notifier_;
UserLogger& logger_;
};UserRepository: 저장 책임UserNotifier: 알림 책임UserLogger: 로그 책임UserService: “등록”이라는 도메인 규칙만 묶어서 orchestration
✅ 이제 각 클래스는 “하나의 역할 축”만 맡기 때문에 SRP에 더 가깝다.
2. Open–Closed Principle
“확장에는 열려 있고, 수정에는 닫혀 있어야 한다.”
새로운 기능을 추가할 때 기존 코드를 가능한 한 수정하지 않는 구조.
나쁜 예 – 새로운 도형이 생길 때마다 if/else를 수정
#include <vector>
#include <memory>
#include <cmath>
class Square {
public:
explicit Square(double length) : length_(length) {}
double length_;
};
class Circle {
public:
explicit Circle(double radius) : radius_(radius) {}
double radius_;
};
class AreaCalculatorBad {
public:
double sum(const std::vector<std::shared_ptr<void>>& shapes) {
double total = 0.0;
if (Square) {
//...
}
else if (Circle) {
// ...
}
return total;
}
};위와 같이 작성되면 새로운 도형 삼각형, 오각형, 타원 등이 추가될 때마다 함수 AreaCalculatorBad의 sum()을 계속 수정해야 한다. 즉, 확장을 위해 항상 기존 코드를 열어야 하는 구조 이다.
개선된 예 – 추상 클래스(인터페이스)에 의존
#include <vector>
#include <memory>
#include <cmath>
class Shape {
public:
virtual ~Shape() = default;
virtual double area() const = 0;
};
class Square : public Shape {
public:
explicit Square(double length) : length_(length) {}
double area() const override { return length_ * length_; }
private:
double length_;
};
class Circle : public Shape {
public:
explicit Circle(double radius) : radius_(radius) {}
double area() const override { return M_PI * radius_ * radius_; }
private:
double radius_;
};
class Triangle : public Shape {
public:
Triangle(double base, double height) : base_(base), height_(height) {}
double area() const override { return 0.5 * base_ * height_; }
private:
double base_, height_;
};
class AreaCalculator {
public:
explicit AreaCalculator(std::vector<std::shared_ptr<Shape>> shapes)
: shapes_(std::move(shapes)) {}
double sum() const {
double total = 0.0;
for (const auto& s : shapes_) {
total += s->area();
}
return total;
}
private:
std::vector<std::shared_ptr<Shape>> shapes_;
};이렇게 작성하면, 새 도형을 추가하고 싶으면 Shape를 상속해서 area()만 구현하면 된다. AreaCalculator는 수정할 필요가 없다.
✅ “기존 코드를 고치지 않고, 새 클래스를 추가하는 방식으로 확장” → OCP를 만족하는 구조.
3. Liskov Substitution Principle
상위 타입(T)을 사용하는 코드에서, 하위 타입(S)로 대체해도 프로그램 의미가 깨지지 않아야 한다.
예제 – 합계를 구하는 계산기
class CalculatorBase {
public:
virtual ~CalculatorBase() = default;
virtual double sum() const = 0; // “합계를 double로 반환한다”는 계약
};
class AreaCalculator2D : public CalculatorBase {
public:
explicit AreaCalculator2D(const std::vector<std::shared_ptr<Shape>>& shapes)
: shapes_(shapes) {}
double sum() const override {
double total = 0.0;
for (const auto& s : shapes_) {
total += s->area();
}
return total;
}
private:
std::vector<std::shared_ptr<Shape>> shapes_;
};여기까지는 그냥 평범한 상속.
LSP를 깨는 나쁜 예
#include <vector>
class VolumeCalculatorBad : public CalculatorBase {
public:
explicit VolumeCalculatorBad(const std::vector<double>& volumes)
: volumes_(volumes) {}
double sum() const override {
// 부모는 “합계”를 기대하는데,
// 여기서 첫 번째 요소만 돌려주거나, NaN을 돌려주면 의미가 깨짐
if (volumes_.empty()) return 0.0;
return volumes_[0]; // “합계”가 아니라 그냥 첫 번째 값
}
private:
std::vector<double> volumes_;
};위 구조에서:
void printTotal(const CalculatorBase& calc) {
std::cout << "Total: " << calc.sum() << "\n";
}AreaCalculator2D를 넘기면 “넓이 합계”가 잘 찍히지만 VolumeCalculatorBad를 넘기면 “합계”라는 의미가 깨진다.
❌ 타입은 맞지만, “계약(semantic)”이 깨졌기 때문에 LSP 위반.
LSP를 지키는 개선 예
class VolumeCalculator : public CalculatorBase {
public:
explicit VolumeCalculator(const std::vector<double>& volumes)
: volumes_(volumes) {}
double sum() const override {
double total = 0.0;
for (double v : volumes_) {
total += v;
}
return total; // 여전히 "합계" 의미
}
private:
std::vector<double> volumes_;
};이제
AreaCalculator2D areaCalc(shapes);
VolumeCalculator volCalc(volumes);
printTotal(areaCalc); // OK
printTotal(volCalc); // OK, 의미 그대로 "부피 합계"✅ 부모가 약속한 의미(“합계”)를 자식도 유지하므로 LSP 가 지켜짐.
4. Interface Segregation Principle
클라이언트는 자신이 사용하지 않는 메서드에 강제로 의존하면 안 된다.
나쁜 예 – 2D/3D를 하나의 인터페이스로 묶기
class BadShape {
public:
virtual ~BadShape() = default;
virtual double area() const = 0;
virtual double volume() const = 0; // 2D 도형에도 강제로 요구
};
class SquareBad : public BadShape {
public:
explicit SquareBad(double length) : length_(length) {}
double area() const override { return length_ * length_; }
double volume() const override {
// 2D 도형이라 volume이 의미 없음
// 0을 반환하거나 예외를 던지게 되는데, 둘 다 어색
return 0.0;
}
private:
double length_;
};SquareBad는 volume이 필요 없는데도 volume()를 구현해야 해서, 0을 반환하거나 예외를 던지게 된다. 또한, 나중에 BadShape*를 쓰는 코드가 “volume도 항상 쓸 수 있다”라고 오해할 수 있다.
❌ “사용하지 않는 메서드에 의존하도록 강요”하고 있으므로 ISP 위반.
개선 예 – 기능별로 인터페이스 분리
class AreaShape {
public:
virtual ~AreaShape() = default;
virtual double area() const = 0;
};
class VolumeShape {
public:
virtual ~VolumeShape() = default;
virtual double volume() const = 0;
};- 2D 도형:
AreaShape만 구현 - 3D 도형: 필요하면
AreaShape+VolumeShape둘 다 구현
class Square2D : public AreaShape {
public:
explicit Square2D(double length) : length_(length) {}
double area() const override { return length_ * length_; }
private:
double length_;
};
class Cuboid3D : public AreaShape, public VolumeShape {
public:
Cuboid3D(double w, double h, double d)
: w_(w), h_(h), d_(d) {}
double area() const override {
return 2.0 * (w_ * h_ + w_ * d_ + h_ * d_);
}
double volume() const override {
return w_ * h_ * d_;
}
private:
double w_, h_, d_;
};✅ 이제 각 클래스는 자신이 실제로 필요한 기능(면적/부피)에 해당하는 인터페이스만 구현. → ISP에 부합.
5. Dependency Inversion Principle
상위 모듈과 하위 모듈 모두 추상(인터페이스)에 의존해야 한다.
나쁜 예 – 상위 모듈이 구체 DB 클래스에 붙어 있을 때
class MySQLConnection {
public:
void connect() {
// MySQL DB 연결
}
};
class PasswordReminderBad {
public:
explicit PasswordReminderBad(MySQLConnection& db)
: db_(db) {}
void remind() {
db_.connect();
// 비밀번호 리마인더 로직...
}
private:
MySQLConnection& db_;
};PasswordReminderBad는 오직 MySQLConnection에만 의존함. 따라서, PostgreSQL, SQLite 등으로 바꾸고 싶으면 PasswordReminderBad 코드를 뜯어고쳐야 한다.
개선 예 – 추상 DB 인터페이스 도입
a. 추상 인터페이스 정의
class DBConnection {
public:
virtual ~DBConnection() = default;
virtual void connect() = 0;
};b. 구현체들
class MySQLConnection : public DBConnection {
public:
void connect() override {
// MySQL 연결
}
};
class PostgreSQLConnection : public DBConnection {
public:
void connect() override {
// PostgreSQL 연결
}
};c. 상위 모듈은 추상에만 의존
class PasswordReminder {
public:
explicit PasswordReminder(DBConnection& db)
: db_(db) {}
void remind() {
db_.connect();
// 비밀번호 리마인더 로직...
}
private:
DBConnection& db_;
};사용할 때:
int main() {
MySQLConnection mysql;
PasswordReminder reminder(mysql);
reminder.remind();
PostgreSQLConnection pg;
PasswordReminder reminder2(pg);
reminder2.remind();
}PasswordReminder는 DB가 MySQL인지 PostgreSQL인지 상관없이, 오직 DBConnection이라는 추상에만 의존한다. 이렇게 작성되면 실제 구현체 교체는 main이나 DI 컨테이너에서만 처리하면 된다.
✅ 상위/하위 모듈 모두 추상(인터페이스)에 의존하게 만들어 의존 방향을 뒤집었기 때문에 DIP에 부합.
6. SOLID 체크리스트
이를 막상 적용하자니 불필요한 추상화나 클래스화가 너무 자주 일어나는 것이 아닌지 어려운데, C++ 에서 깔끔한 코드와 유지보수를 위해 많이 적용된다고 한다. Robotics 에서도 예시로 grid_map 이 있다.
보통 논문 오픈소스에서는 전체 패키지에서 ROS Interface 와 알고리즘이 혼합되어 사용되는 경우가 잦고, 간단하게 사용가능한 걸 우선시 하는 것 같다.
코드가 SOLI 한지 점검하기 위해서는 아래 체크리스트로 확인하면 좋을 것 같다.
- S (SRP)
- 이 클래스가 “어떤 역할을 하는지” 한 문장으로 설명이 되는가?
- “이 기능 바꾸면 이 클래스도 바꾸고, 저 기능 바꿔도 또 이 클래스를 건드린다”면 분리 후보.
- O (OCP)
- 새 기능을 넣을 때
if (type == X)분기문만 계속 늘리고 있지는 않은가? - 인터페이스/추상을 통해 “새 클래스 추가”만으로 확장이 가능하게 할 수 없는지?
- 새 기능을 넣을 때
- L (LSP)
- 부모 포인터/참조를 쓰는 코드에 자식 객체를 넣었을 때,
- 반환 타입, 예외, 의미(semantic)가 깨지지 않는가?
- “부모가 약속한 계약”을 자식이 지키고 있는지?
- 부모 포인터/참조를 쓰는 코드에 자식 객체를 넣었을 때,
- I (ISP)
- 인터페이스가 너무 커서, 구현 클래스가 “쓰지도 않는 메서드”를 억지로 구현하고 있지 않은가?
- 기능별로 인터페이스를 쪼갤 수 없는지?
- D (DIP)
- 비즈니스 로직이 구체 클래스를 직접
new하고 있지는 않은가? - 상위 모듈과 하위 모듈 모두 “추상 클래스/인터페이스”에만 의존하게 만들 수 없는가?
- 비즈니스 로직이 구체 클래스를 직접