CS/Software Engineering

[Software Engineering] 객체지향 개발 원칙(SOLID)

hanseongbugi 2024. 2. 1. 19:20

SOLID란?

  • 객체지향 디자인 원리들을 사용하면 유지보수가 쉽고, 유연하고, 확장이 쉬운 소프트웨어를 개발할 수 있다.
  • 이 원리들은 디자인 패턴보다 범위는 작지만 표준화 작업에서 아키텍처 설계에 이르기까지 다양하게 적용되고 있다.
  • SOLID에는 5가지 원칙이 존재한다.
    1. SRP (Single Responsiblity Principle) - 단일 책임 원칙
    2. OCP (Open Closed Principle) - 개방 폐쇄 원칙
    3. LSP (Listov Substiution Principle) - 리스코브 치환 원칙
    4. ISP (Interface Segreation Principle) - 인터페이스 분리 원칙
    5. DIP (Dependency Inverstion Principle) - 의존성 역전 원칙
  • SOLID를 통해 디자인 패턴들을 만들었다.
  • SOLID의 5가지 원칙은 서로 독립된 개념이 아닌 서로 개념적으로 연관되어 있다.
    • 원칙끼리 서로가 서로를 이용하기도하며 포함하기도 한다.

SRP(Single Responsiblity Principle)

  • 클래스(객체)는 단 하나의 책임만 가져야 한다는 원칙
  • 책임이라는 의미는 하나의 기능 담당을 뜻한다.
  • 하나의 클래스는 하나의 기능을 담당하여 하나의 책임을 수행하는데 집중되도록 클래스를 따로 설계하라는 원칙이다.
  • 만약 하나의 클래스에 기능(책임)이 여러개인 경우 기능 변경(수정)이 일어났을 때 수정해야할 코드가 많아진다.
    • A 코드를 고쳤을 때 B 코드, C 코드를 연쇄적으로 수정해야할 일이 생길 수 있다.
    • 마치, 책임이 순환되는 형태가 될 수 있다.
  • SRP 원칙을 통해 한 책임의 변경으로부터 다른 책임의 변경으로 연쇄 작용을 극복할 수 있다.
  • 단일 책임 원칙은 소프트웨어의 유지 보수 성을 높이기 위한 설계 기법이다.
  • 이때 책임의 범위는 정해져 있는 것이 아닌 어떤 프로그램을 개발하냐에 따라 달라질 수 있다.
class Journal {
    string          m_title;
    vector<string>  m_entries;
public:
    explicit Journal(const string &title) : m_title{title} {}
    void add_entries(const string &entry) {
        static uint32_t count = 1;
        m_entries.push_back(to_string(count++) + ": " + entry);
    }
    auto get_entries() const { return m_entries; }
    void save(const string &filename) {
        ofstream ofs(filename); 
        for (auto &s : m_entries) ofs << s << endl;
    }
};
int  main() {
    Journal journal{"Dear XYZ"};
    journal.add_entries("I ate a bug");
    journal.add_entries("I cried today");
    journal.save("diary.txt");
    return EXIT_SUCCESS;
}
  • 위 코드는 Journal이라는 클래스를 구현한 코드이다.
  • Journal 클래스는 save() 함수의 기능을 변경할 때 SRP 원칙을 위배한다.
    • 만약, File이나 Database에 데이터를 save하지 않을 때 Journal 클래스를 수정해야하는 문제가 생긴다.
    • 이는 Journal 클래스의 title, entries를 담고 있는 책임을 넘어서게 된다.
  • Journal 클래스에 저장 기능을 다른 객체로 옮겨 저장하는 책임을 없애야한다.
class Journal {
    string          m_title;
    vector<string>  m_entries;
public:
    explicit Journal(const string &title) : m_title{title} {} 
    void add_entries(const string &entry) {
        static uint32_t count = 1;
        m_entries.push_back(to_string(count++) + ": " + entry);
    } 
    auto get_entries() const { return m_entries; }
    //void save(const string &filename)
    //{
    //    ofstream ofs(filename); 
    //    for (auto &s : m_entries) ofs << s << endl;
    //}
};
struct SavingManager {
    static void save(const Journal &j, const string &filename) {
        ofstream ofs(filename);
        for (auto &s : j.get_entries())
            ofs << s << endl;
    }
};
SavingManager::save(journal, "diary.txt");
  • SavingManager 라는 구조체를 통해 File 및 Database에 저장하는 책임을 부여하여 다른 저장 매체를 이용할 때 Journal 클래스는 수정되지 않도록 구현 하였다.

OCP(Open Closed Principle)

  • 클래스(객체)는 확장에는 열려있고, 수정에는 닫혀있어야한다는 원칙
  • 기능 추가 요청이 들어왔을 때 클래스의 확장을 손쉽게 구현하면서, 확장에 따른 클래스의 변경은 최소화 하도록 소프트웨어를 구현하는 설계 기법이다.
    • Open - 새로운 변경 사항이 발생하였을 때 유연하게 코드를 추가함으로써 큰 힘을 들이지 않고 소프트웨어의 기능을 확장하는 것
    • Closed - 새로운 변경 사항이 발생하였을 때 객체를 직접적으로 수정하지 않는 것 
  • OCP는 추상화를 통한 관계 구축을 권장하는 원칙이다.
  • 다형성과 확장을 가능하게 하는 객체지향의 장점을 극대화하는 기본적인 설계 원칙
enum class COLOR { RED, GREEN, BLUE };
enum class SIZE { SMALL, MEDIUM, LARGE };

struct Product {
    string  m_name;
    COLOR   m_color;
    SIZE    m_size;
};

using Items = vector<Product*>;
#define ALL(C)  begin(C), end(C)

struct ProductFilter {
    static Items by_color(Items items, const COLOR e_color) {
        Items result;
        for (auto &i : items)
            if (i->m_color == e_color)
                result.push_back(i);
        return result;
    }
    static Items by_size(Items items, const SIZE e_size) {
        Items result;
        for (auto &i : items)
            if (i->m_size == e_size)
                result.push_back(i);
        return result;
    }
    static Items by_size_and_color(Items items, const SIZE e_size, const COLOR e_color) {
        Items result;
        for (auto &i : items)
            if (i->m_size == e_size && i->m_color == e_color)
                result.push_back(i);
        return result;
    }
};

int main() {
    const Items all{
        new Product{"Apple", COLOR::GREEN, SIZE::SMALL},
        new Product{"Tree", COLOR::GREEN, SIZE::LARGE},
        new Product{"House", COLOR::BLUE, SIZE::LARGE},
    };

    for (auto &p : ProductFilter::by_color(all, COLOR::GREEN))
        cout << p->m_name << " is green\n";

    for (auto &p : ProductFilter::by_size_and_color(all, SIZE::LARGE, COLOR::GREEN))
        cout << p->m_name << " is green & large\n";

    return EXIT_SUCCESS;
}

// 출력
Apple is green
Tree is green
Tree is green & large
  • 위는 Product의 속성을 Filter를 통해 분리하여 출력해본 코드이다.
  • 만약 요구사항이 변경되면 Product와 Filter를 수정해야한다.
    • 기존에는 2가지 속성(color, size)와 3가지 기능(color, size, color와 size의 조합)이 있다.
    • 만약 1가지 속성이 추가된다면 Product에 1개의 속성을 추가하고 Filter에 8가지 기능을 구현해야한다.
  • 이는 기존의 코드를 계속해서 수정해야하며 이로인해 코드의 다른 부분에 의도하지 않은 에러가 발생할 수 있다.
    • 이러한 상황은 OCP를 위반하는 기존 코드를 수정하는 일을 하게 된다.
template <typename T>
struct Specification {
    virtual ~Specification() = default;
    virtual bool is_satisfied(T *item) const = 0;
};
struct ColorSpecification : Specification<Product> {
    COLOR e_color;
    ColorSpecification(COLOR e_color) : e_color(e_color) {}
    bool is_satisfied(Product *item) const { return item->m_color == e_color; }
};
struct SizeSpecification : Specification<Product> {
    SIZE e_size;
    SizeSpecification(SIZE e_size) : e_size(e_size) {}
    bool is_satisfied(Product *item) const { return item->m_size == e_size; }
};
template <typename T>
struct Filter {
    virtual vector<T *> filter(vector<T *> items, const Specification<T> &spec) = 0;
};
struct BetterFilter : Filter<Product> {
    vector<Product *> filter(vector<Product *> items, const Specification<Product> &spec) {
        vector<Product *> result;
        for (auto &p : items)
            if (spec.is_satisfied(p))
                result.push_back(p);
        return result;
    }
};
// ------------------------------------------------------------------------------------------------
BetterFilter bf;
for (auto &x : bf.filter(all, ColorSpecification(COLOR::GREEN)))
    cout << x->m_name << " is green\n";
  • 위는 추상화를 통해 확장성을 고려한 코드이다.
  • BetterFilter의 filter() 함수를 수정하지 않아도 된다.
  • Specification을 변경된 요구사항에 따라 변경하면 된다.

LSP(Listov Substiution Principle)

  • 자식 타입은 언제 어디서나 부모 타입으로 교체될 수 있어야한다는 원칙
  • LSP는 다형성 원리를 이용하기 위한 원칙 개념이다.
  • 다형성 특징을 이용하기 위해 상위 클래스 타입으로 객체를 선언하여 하위 클래스의 인스턴스를 받으면, 업케스팅된 상태에서 부모의 메소드를 사용해도 동작이 의도한대로 흘러가야한다는 의미
  • 기본적으로 LSP는 부모 메소드의 오버라이딩을 따져가며 해야한다.
    • 부모 클래스의 동일한 선행 조건을 기대하고 사용하는 코드에서 예상하지 못한 오류를 이르킬 수 있다.
struct Rectangle {
    Rectangle(const uint32_t width, const uint32_t height) : m_width{width}, m_height{height} {}

    uint32_t get_width() const { return m_width; }
    uint32_t get_height() const { return m_height; }

    virtual void set_width(const uint32_t width) { this->m_width = width; }
    virtual void set_height(const uint32_t height) { this->m_height = height; }

    uint32_t area() const { return m_width * m_height; }

protected:
    uint32_t m_width, m_height;
};

struct Square : Rectangle {
    Square(uint32_t size) : Rectangle(size, size) {}
    void set_width(const uint32_t width) override { this->m_width = m_height = width; }
    void set_height(const uint32_t height) override { this->m_height = m_width = height; }
};

void process(Rectangle &r) {
    uint32_t w = r.get_width();
    r.set_height(10);

    assert((w * 10) == r.area()); // Fails for Square <--------------------
}

int main() {
    Rectangle r{5, 5};
    process(r);
    Square s{5};
    process(s);
    return EXIT_SUCCESS;
}
  • 위는 Rectangle is a Square라는 수학적 관점에서 구현한 코드이다.
  • Square은 어디에서나 Rectangle로 치환되어야한다.
  • 하지만 Square의 process() 함수에서 LSP를 위반한 것을 볼 수 있다.
    • Square은 Rectangle에 치환되지 않는 문제를 가지고 있다.
    • Square는 width와 height가 항상 같은 값을 가지고 있어야하지만 Rectangle로 치환 시 같은 값을 가지게 되지 못한다.
    • 쉽게 말하면 Square는 size정보만 가지고 있어도 area를 구할 수 있지만 Rectangle로 치환시 이를 만족하지 못함
struct Shape {
    virtual uint32_t area() const = 0;
};

struct Rectangle : Shape {
    Rectangle(const uint32_t width, const uint32_t height) : m_width{width}, m_height{height} {}

    uint32_t get_width() const { return m_width; }
    uint32_t get_height() const { return m_height; }

    virtual void set_width(const uint32_t width) { this->m_width = width; }
    virtual void set_height(const uint32_t height) { this->m_height = height; }

    uint32_t area() const override { return m_width * m_height; }

private:
    uint32_t m_width, m_height;
};

struct Square : Shape {
    Square(uint32_t size) : m_size(size) {}
    void set_size(const uint32_t size) { this->m_size = size; }
    uint32_t area() const override { return m_size * m_size; }

private:
    uint32_t m_size;
};

void process(Shape &s) {
    // Use polymorphic behaviour only i.e. area()
}
  • Rectangle과 Square의 부모를 Shape 클래스로 하여 LSP를 만족하도록 구현하였다.
    • Rectangle와 Square의 area를 구하는 방식을 Shape의 area를 overriding 하도록 변경하여 언제 어디서나 Shape로 치환되도록 하였다.
  • Shape 클래스를 구현하지 않고 process 자체에서 Rectangle인지 Square인지 구분하여 LSP를 만족하도록 할 수도 있다.

ISP(Interface Segregation Principle)

  • 인터페이스를 각각 사용에 맞게 끔 잘게 분리해야한다는 설계 원칙이다.
  • SRP가 클래스의 단일 책임을 강조한다면, ISP는 인터페이스의 단일 책임을 강조한다.
    • SRP의 목표는 클래스 분리를 통해 이루어진다면, ISP의 목표는 인터페이스의 분리를 통해 이루어진다.
  • 인터페이스를 사용하는 클라이언트를 기준으로 분리함으로써, 클라이언트의 목적과 용도에 적합한 인터페이스만을 제공하는 것이 목표이다.
  • 단, 한번 인터페이스를 분리하여 구성해놓고 나중에 무언가 수정 사항이 생겨 또 인터페이스를 분리하는 행위를 해서는 않된다.
    • 인터페이스는 한번 구성하면 왠만하면 수정해서는 안된다는 정책 개념
struct Document;

struct IMachine {
    virtual void print(Document &doc) = 0;
    virtual void fax(Document &doc) = 0;
    virtual void scan(Document &doc) = 0;
};

struct MultiFunctionPrinter : IMachine {      // OK
    void print(Document &doc) override { }
    void fax(Document &doc) override { }
    void scan(Document &doc) override { }
};

struct Scanner : IMachine {                   // Not OK
    void print(Document &doc) override { /* Blank */ }
    void fax(Document &doc) override { /* Blank */ }
    void scan(Document &doc) override {  
        // Do scanning ...
    }
};
  • MultiFunctionPrinter는 IMachine 인터페이스에 의해 구현해야할 print(), fax(), scan() 메소드를 구현하여도 아무 문제가 없다.
  • 하지만 Scanner인 경우 IMachine 인터페이스에 의해 구현할 책임이 있는 print(), fax() 메소드를 구현할 필요가 없다.
  • 이는 ISP를 위반한 사례이며, 인터페이스의 책임을 분리하여 Scanner에 제공해야한다는 것을 알 수 있다.
/* -------------------------------- Interfaces ----------------------------- */
struct IPrinter {
    virtual void print(Document &doc) = 0;
};

struct IScanner {
    virtual void scan(Document &doc) = 0;
};
/* ------------------------------------------------------------------------ */

struct Printer : IPrinter {
    void print(Document &doc) override;
};

struct Scanner : IScanner {
    void scan(Document &doc) override;
};

struct IMachine : IPrinter, IScanner { };

struct Machine : IMachine {
    IPrinter&   m_printer;
    IScanner&   m_scanner;

    Machine(IPrinter &p, IScanner &s) : printer{p}, scanner{s} { }

    void print(Document &doc) override { printer.print(doc); }
    void scan(Document &doc) override { scanner.scan(doc); }
};
  • IPrinter와 IScanner 인터페이스로 print와 scan의 책임을 분리하고 IMachine 인터페이스는 2 기능을 모두 사용할 경우 사용하도록 한다.
  • Scanner와 Machine 모두 필요한 책임만을 가진 인터페이스를 구현하고 있다.

DIP(Dependency Inverstion Principle)

  • 어떤 클래스를 참조하여 사용할 때, 그 클래스를 직접 참조하는 것이 아닌대상의 상위 요소(추상 클래스 | 인터페이스)로  참조하라는 원칙
  • 구현 클래스에 의존하지 말고, 인터페이스에 의존하라는 의미
  • 의존 관계를 맺을 때 변화하기 쉬운 것 또는 자주 변화하는 것보다는 변화하기 어려운 것 거의 변화하지 않는 것에 의존하라는 것
  • DIP의 지향점은 각 클래스간 결합도(coupling)를 낮추는 것이다.
enum class Relationship { parent, child, sibling };

struct Person {
    string      m_name;
};

struct Relationships {      // Low-level <<<<<<<<<<<<-------------------------
    vector<tuple<Person, Relationship, Person>>     m_relations;

    void add_parent_and_child(const Person &parent, const Person &child) {
        m_relations.push_back({parent, Relationship::parent, child});
        m_relations.push_back({child, Relationship::child, parent});
    }
};

struct Research {           // High-level  <<<<<<<<<<<<------------------------
    Research(const Relationships &relationships) {
        for (auto &&[first, rel, second] : relationships.m_relations) {// Need C++17 here
            if (first.m_name == "John" && rel == Relationship::parent)
                cout << "John has a child called " << second.m_name << endl;
        }
    }
};

int main() {
    Person parent{"John"};
    Person child1{"Chris"};
    Person child2{"Matt"};

    Relationships relationships;
    relationships.add_parent_and_child(parent, child1);
    relationships.add_parent_and_child(parent, child2);

    Research _(relationships);

    return EXIT_SUCCESS;
}
  • 위 코드는 나중에 Relationship이라는 struct의 멤버인 vector가 set으로 바뀌거나 다른 컨테이너를 사용해야하면 여러 부분이 수정되어야 한다.
    • Relationship의 멤버 변수인 Relationship::m_relations의 이름만 바뀌여도 여러 부분이 바뀌어야한다.
  •  Low-level 모듈인 Relationship은 High-level 모듈인 Research와 직접적으로 의존하게 된다. 이는 DIP를 위반하는 것이다.
struct RelationshipBrowser {
    virtual vector<Person> find_all_children_of(const string &name) = 0;
};

struct Relationships : RelationshipBrowser {     // Low-level <<<<<<<<<<<<<<<------------------------
    vector<tuple<Person, Relationship, Person>>     m_relations;

    void add_parent_and_child(const Person &parent, const Person &child) {
        m_relations.push_back({parent, Relationship::parent, child});
        m_relations.push_back({child, Relationship::child, parent});
    }

    vector<Person> find_all_children_of(const string &name) {
        vector<Person> result;
        for (auto &&[first, rel, second] : m_relations) {
            if (first.name == name && rel == Relationship::parent) {
                result.push_back(second);
            }
        }
        return result;
    }
};

struct Research {                                // High-level <<<<<<<<<<<<<<<----------------------
    Research(RelationshipBrowser &browser) {
        for (auto &child : browser.find_all_children_of("John")) {
            cout << "John has a child called " << child.name << endl;
        }
    }
    //  Research(const Relationships& relationships)
    //  {
    //    auto& relations = relationships.relations;
    //    for (auto&& [first, rel, second] : relations)
    //    {
    //      if (first.name == "John" && rel == Relationship::parent)
    //      {
    //        cout << "John has a child called " << second.name << endl;
    //      }
    //    }
    //  }
};
  • 추상화를 통해 상위, 하위 모듈이 인터페이스에 의존하도록 수정한 코드이다.
  • RelationshipBrowser 인터페이스와 Relationship, Research는 의존 관계를 맺고 있다. 
  •  Relationship::m_relations의 이름이 바뀌어도 Research는 아무 영향을 받지 않는다.
    • RelationshipBrowser의 find_all_children_of() 함수에 의해 자식들을 찾고 있기 때문
  • C++에서 DIP는 STL이나 다른 라이브러리를 통해 만족 시킬 수 있다.
    • 만약, Vector가 아닌 다른 컨테이너를 사용해도 STL에서 제공하는 iterator를 공통으로 사용하기 때문에 관계를 맺은 다른 클래스는 영향을 받지 않는다.
    • 이는 C++에서 제공하는 auto 키워드에 의해 이루어진다.

 

출처

https://inpa.tistory.com/entry/OOP-%F0%9F%92%A0-%EA%B0%9D%EC%B2%B4-%EC%A7%80%ED%96%A5-%EC%84%A4%EA%B3%84%EC%9D%98-5%EA%B0%80%EC%A7%80-%EC%9B%90%EC%B9%99-SOLID

 

💠 객체 지향 설계의 5가지 원칙 - S.O.L.I.D

객체 지향 설계의 5원칙 S.O.L.I.D 모든 코드에서 LSP를 지키기에는 어려움. 리스코프 치환 원칙에 따르면 자식 클래스의 인스턴스가 부모 클래스의 인스턴스를 대신하더라도 의도에 맞게 작동되어

inpa.tistory.com

https://www.nextree.co.kr/p6960/

 

객체지향 개발 5대 원리: SOLID

현재를 살아가는 우리들은 모두 일정한 원리/원칙 아래에서 생활하고 있습니다. 여기서의 원칙 이라 함은 좁은 의미로는 개개인의 사고방식이나 신념, 가치관 정도가 될 수가 있겠고, 넓게는 한

www.nextree.co.kr

https://www.vishalchovatiya.com/category/design-patterns/

 

Design Patterns Archives – Vishal Chovatiya

After hitting a certain level of experience & spending quite enough time in the industry, I have realised the importance of designing/architecting system & software. So I have started looking into system/software design & got to know nothing can better sta

www.vishalchovatiya.com