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;
    }
};

위와 같이 작성되면 새로운 도형 삼각형, 오각형, 타원 등이 추가될 때마다 함수 AreaCalculatorBadsum()을 계속 수정해야 한다. 즉, 확장을 위해 항상 기존 코드를 열어야 하는 구조 이다.

개선된 예 – 추상 클래스(인터페이스)에 의존

#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 하고 있지는 않은가?
    • 상위 모듈과 하위 모듈 모두 “추상 클래스/인터페이스”에만 의존하게 만들 수 없는가?