객체 디자인 원칙 (SOLID)

객체 디자인 원칙 (SOLID)

객체 지향 프로그래밍에서는 몇 가지 중요한 객체 디자인 원칙이 제안 되어 있습니다. 이 원칙들은 코드의 가독성, 재 사용성, 확장성을 향상 시키고, 유지 보수를 쉽게 만드는데 도움이 됩니다. 여러 객체 디자인 원칙 중에서 SOLID 원칙이 가장 널리 알려져 있습니다. SOLID 원칙은 다음 다섯 가지 원칙으로 구성되어 있습니다.

  1. 단일 책임 원칙 (Single Responsibility Principle – SRP)
    클래스는 단 하나의 변경 이유만을 가져야 합니다. 즉, 클래스는 오직 하나의 책임만을 가져야 하며, 이를 통해 클래스의 변경이 다른 부분에 영향을 미치는 것을 방지합니다.

  2. 개방/폐쇄 원칙 (Open/Closed Principle – OCP)
    소프트웨어 개체(클래스, 모듈, 함수 등)는 확장에 대해 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 합니다. 즉, 새로운 기능이 추가될 때는 기존 코드를 수정하지 않고 새로운 코드를 추가할 수 있어야 합니다.

  3. 리스코프 치환 원칙 (Liskov Substitution Principle – LSP)
    기본 클래스의 인스턴스는 하위 클래스의 인스턴스로 대체 가능해야 합니다. 즉, 상속 관계에 있는 클래스들은 그 클래스의 객체를 대체해도 프로그램의 의미가 변하지 않아야 합니다.

  4. 인터페이스 분리 원칙 (Interface Segregation Principle – ISP)
    클라이언트는 자신이 사용하지 않는 메서드에 의존 관계를 맺으면 안 됩니다. 즉, 인터페이스는 클라이언트가 관심 없는 메서드를 제공해서는 안 됩니다.

  5. 의존 역전 원칙 (Dependency Inversion Principle – DIP)
    고수준 모듈은 저수준 모듈에 의존해서는 안 되며, 양쪽 모듈 모두 추상화에 의존해야 합니다. 즉, 추상화(인터페이스나 추상 클래스)는 구체적인 구현보다 안정적이어야 합니다.


단일 책임 원칙(Single Responsibility Principle : SRP)

  • 장점

    • 유지보수성 향상
      하나의 클래스나 모듈이 하나의 책임만을 갖는다면, 해당 책임에 대한 변경이 다른 부분에 미치는 영향이 적어져 유지보수성이 향상됩니다.

    • 코드 가독성 향상
      클래스나 모듈이 특정 기능이나 역할에만 집중하면 코드의 가독성이 증가하며, 개발자가 코드를 이해하고 사용하기 쉬워집니다.

    • 재사용성 촉진
      단일 책임을 가진 클래스는 특정 동작을 수행하는데 필요한 모든 기능을 포함하므로, 다른 곳에서 필요한 경우 해당 클래스를 재사용하기 용이합니다.

  • 단점

    • 클래스 수 증가
      각 책임에 따라 클래스를 따로 만들어야 하므로 클래스의 수가 증가할 수 있습니다.

    • 일부 책임이 분리되기 어려움
      모든 경우에 단일 책임을 정의하고 분리하기 어려울 수 있습니다.

    • 간단한 기능에도 클래스 생성이 필요할 수 있음
      단일 책임을 위해 작은 기능도 클래스로 감싸야 할 수 있습니다.

#include <iostream>
#include <string>
#include <vector>

// 사용자 정보 클래스
class UserInfo {
public:
    UserInfo(const std::string& name, int age) : name(name), age(age) {}

    // 사용자 정보 출력
    void displayUserInfo() const {
        std::cout << "Name: " << name << ", Age: " << age << std::endl;
    }

private:
    std::string name;
    int age;
};

// 사용자 관리 클래스
class UserManager {
public:
    // 사용자 정보를 저장하는 컨테이너
    std::vector<UserInfo> users;

    // 사용자 정보 추가
    void addUser(const std::string& name, int age) {
        users.emplace_back(name, age);
    }

    // 모든 사용자 정보 출력
    void displayAllUsers() const {
        for (const auto& user : users) {
            user.displayUserInfo();
        }
    }
};

int main() {
    // 사용자 관리 객체 생성
    UserManager userManager;

    // 사용자 추가
    userManager.addUser("Alice", 25);
    userManager.addUser("Bob", 30);

    // 모든 사용자 정보 출력
    userManager.displayAllUsers();

    return 0;
}

Open/Closed Principle, OCP (개방/폐쇄 원칙)

  • 장점

    • 확장성 향상
      새로운 기능이나 모듈을 추가할 때, 기존 코드를 변경하지 않고도 쉽게 확장할 수 있습니다.

    • 안정성 유지
      기존의 코드는 변경되지 않으므로 안정성을 유지할 수 있습니다.

    • 코드 결합도 감소
      새로운 기능을 추가할 때 기존 코드를 변경하지 않기 때문에 코드 간의 결합도가 감소하며, 이는 시스템을 더 유연하게 만듭니다.

  • 단점

    • 추상화의 어려움
      올바른 추상화 수준을 찾는 것이 어려울 수 있습니다. 너무 많은 추상화는 복잡성을 증가시키고, 너무 적은 추상화는 유연성을 제한할 수 있습니다.

    • 설계 초기에 더 많은 노력 필요
      초기에는 확장성을 고려하여 설계를 해야 하므로 초기에는 더 많은 노력이 필요할 수 있습니다。

    • 모든 경우에 적용이 어려움
      특정한 경우나 상황에서는 개방/폐쇄 원칙을 완전히 적용하기 어려울 수 있습니다.

#include <iostream>
#include <vector>

// 동물 클래스 (추상화)
class Animal {
public:
    virtual void makeSound() const = 0;
};

// 강아지 클래스
class Dog : public Animal {
public:
    void makeSound() const override {
        std::cout << "Dog barks." << std::endl;
    }
};

// 고양이 클래스
class Cat : public Animal {
public:
    void makeSound() const override {
        std::cout << "Cat meows." << std::endl;
    }
};

// 동물 소리 재생 함수
void playAnimalSound(const std::vector<Animal*>& animals) {
    for (const auto& animal : animals) {
        animal->makeSound();
    }
}

int main() {
    // 강아지와 고양이를 생성하고 벡터에 추가
    std::vector<Animal*> animals;
    animals.push_back(new Dog());
    animals.push_back(new Cat());

    // 동물 소리 재생
    playAnimalSound(animals);

    // 메모리 누수 방지를 위해 동적 할당한 객체들을 삭제
    for (const auto& animal : animals) {
        delete animal;
    }

    return 0;
}

Liskov Substitution Principle – LSP (리스코프 치환 원칙)

  • 장점

    • 다형성 지원
      하위 클래스가 상위 클래스를 대체할 수 있으면, 다형성을 지원하여 코드의 유연성을 향상시킵니다.
      서브타입을 부모 타입으로 사용할 수 있기 때문에, 다형성이 자연스럽게 지원됩니다.

    • 확장성 강화
      새로운 클래스가 기존 클래스를 대체할 수 있으면, 새로운 클래스를 추가하거나 확장하여 시스템을 유연하게 구성할 수 있습니다.

    • 코드 안정성 증가
      LSP는 코드의 예측 가능성을 증가시키고, 클래스 간의 관계를 명확하게 정의하여 코드의 이해성을 향상시키고, 서브클래스가 부모클래스를 대체할 수 있기 때문에, 코드의 안정성을 높입니다.

  • 단점

    • 추상화 어려움
      LSP를 지키기 위해서는 적절한 추상화 수준을 선택하는 것이 어려울 수 있습니다.

    • 일관된 설계 필요
      모든 클래스가 LSP를 준수하려면 일관된 설계가 필요하며, 이를 유지하는 것이 어려울 수 있습니다.

    • 리팩토링 어려움
      기존 코드에 대한 수정이 어려울 수 있으며, 서브클래스의 변경이 전반적인 시스템에 영향을 미칠 수 있습니다.

#include <iostream>

// 도형을 나타내는 기본 클래스
class Shape {
public:
    virtual void draw() const {
        std::cout << "Drawing a shape" << std::endl;
    }
};

// 원 클래스, Shape를 상속
class Circle : public Shape {
public:
    void draw() const override {
        std::cout << "Drawing a circle" << std::endl;
    }
};

// 클라이언트 코드
void drawShape(const Shape& shape) {
    shape.draw();
}

int main() {
    Shape shape;
    Circle circle;

    drawShape(shape);  // 기본 도형을 그림
    drawShape(circle); // 원을 그림

    return 0;
}

이 코드에서 Circle 클래스는 Shape 클래스를 상속하고 있습니다. drawShape 함수는 Shape 클래스를 매개변수로 받아 도형을 그리는 함수입니다. 이 함수에 Circle 객체를 전달하여도 정상적으로 동작하며, 이는 LSP를 따르고 있음을 나타냅니다.


Interface Segregation Principle, ISP (인터페이스 분리 원칙)

인터페이스 분리 원칙(Interface Segregation Principle, ISP)은 SOLID 원칙 중 하나로, 클라이언트가 자신이 사용하지 않는 메서드에 의존하지 않아야 한다는 원칙입니다. 좀 더 쉽게 설명하기 위해 간단한 C++ 예제를 통해 이해해봅시다.

예를 들어, 작업자(Worker) 인터페이스가 있고, 일꾼(Worker) 클래스가 이를 구현하고 있다고 가정합시다. 그리고 이 작업자는 일을 시작하고 끝내는 두 가지 메서드를 가지고 있다고 생각해봅시다

#include <iostream>

// 작업자(Worker) 인터페이스
class Worker {
public:
    virtual void startWork() = 0;
    virtual void stopWork() = 0;
};

// 일꾼(Worker) 클래스
class Laborer : public Worker {
public:
    void startWork() override {
        std::cout << "Laborer starts working." << std::endl;
    }

    void stopWork() override {
        std::cout << "Laborer stops working." << std::endl;
    }
};

int main() {
    Laborer laborer;
    laborer.startWork();
    laborer.stopWork();

    return 0;
}

여기서는 Worker 인터페이스가 startWork()stopWork() 두 가지 메서드를 가지고 있습니다. 그리고 Laborer 클래스는 이 인터페이스를 구현하고 있습니다.

이제 인터페이스 분리 원칙을 위반하는 상황을 살펴봅시다. 만약 작업자(Worker)가 휴가를 가는 경우를 다루는 새로운 기능이 추가되어야 한다고 가정해봅시다. 그러나 휴가 관련 메서드는 모든 작업자에게 필요한 것이 아니라 특정 작업자에게만 필요한 경우입니다. 이런 상황에서는 인터페이스를 분리하여 불필요한 의존성을 없애야 합니다

// 휴가 가능한 작업자(LeaveWorker) 인터페이스
class LeaveWorker {
public:
    virtual void takeLeave() = 0;
};

// 일꾼(Laborer) 클래스가 새로운 LeaveWorker 인터페이스를 구현
class Laborer : public Worker, public LeaveWorker {
public:
    void startWork() override {
        std::cout << "Laborer starts working." << std::endl;
    }

    void stopWork() override {
        std::cout << "Laborer stops working." << std::endl;
    }

    void takeLeave() override {
        std::cout << "Laborer takes leave." << std::endl;
    }
};

int main() {
    Laborer laborer;
    laborer.startWork();
    laborer.stopWork();
    laborer.takeLeave();

    return 0;
}

이렇게 하면 LeaveWorker 인터페이스를 필요로 하는 클라이언트는 Laborer 객체에 대해 더 이상 startWork()stopWork() 메서드에 의존하지 않아도 됩니다. 이렇게 하면 클라이언트가 필요로 하는 메서드만 있는 인터페이스를 사용하도록 할 수 있습니다.


Dependency Inversion Principle, DIP (의존 역전 원칙)

의존 역전 원칙(Dependency Inversion Principle, DIP)은 객체 지향 프로그래밍의 SOLID 원칙 중 하나로, 고수준 모듈은 저수준 모듈에 의존해서는 안 되며, 양쪽 모듈 모두 추상화에 의존해야 한다는 원칙입니다. 쉽게 설명하기 위해 간단한 C++ 예제를 사용해봅시다.

예를 들어, 전구를 켜고 끄는 Switch 클래스가 있고, 이 Switch 클래스는 구체적인 전구를 켜고 끄는 일을 담당하는 LightBulb 클래스에 의존한다고 가정해봅시다.

#include <iostream>

// 전구를 켜고 끄는 인터페이스
class LightBulb {
public:
    void turnOn() {
        std::cout << "LightBulb is ON." << std::endl;
    }

    void turnOff() {
        std::cout << "LightBulb is OFF." << std::endl;
    }
};

// 전구를 제어하는 Switch 클래스
class Switch {
public:
    void operate(LightBulb bulb) {
        // Switch 클래스가 LightBulb에 직접 의존하고 있음
        bulb.turnOn();
        bulb.turnOff();
    }
};

int main() {
    LightBulb bulb;
    Switch mySwitch;
    mySwitch.operate(bulb);

    return 0;
}

의존 역전 원칙을 위반하는 예제

위의 코드에서 Switch 클래스가 LightBulb 클래스에 직접 의존하고 있습니다. 이는 의존 역전 원칙을 위반한 것입니다.

이 문제를 해결하려면 Switch 클래스가 LightBulb에 직접 의존하는 것이 아니라, 추상화된 인터페이스에 의존하도록 해야 합니다. 다시 말해, Switch 클래스가 LightBulb 클래스보다 더 추상화된 인터페이스에 의존해야 합니다.

// 전구를 켜고 끄는 인터페이스
class Switchable {
public:
    virtual void turnOn() = 0;
    virtual void turnOff() = 0;
};

// 전구를 제어하는 Switch 클래스
class Switch {
public:
    void operate(Switchable& device) {
        // Switch 클래스가 더 추상화된 인터페이스에 의존하도록 변경
        device.turnOn();
        device.turnOff();
    }
};

// LightBulb 클래스가 Switchable 인터페이스를 구현
class LightBulb : public Switchable {
public:
    void turnOn() override {
        std::cout << "LightBulb is ON." << std::endl;
    }

    void turnOff() override {
        std::cout << "LightBulb is OFF." << std::endl;
    }
};

int main() {
    LightBulb bulb;
    Switch mySwitch;
    mySwitch.operate(bulb);

    return 0;
}

이렇게 하면 Switch 클래스가 LightBulb 클래스 대신 더 추상화된 Switchable 인터페이스에 의존하게 되어, 의존 역전 원칙을 지키게 됩니다. 이렇게 하면 Switch 클래스가 Switchable을 구현하는 다른 클래스에 대해서도 동작할 수 있게 됩니다.