[DAY15] Object-Oriented Design: Cohesion, Coupling, and SOLID

베리투스·2025년 8월 25일

TIL: Today I Learned

목록 보기
23/93

오늘은 좋은 객체지향 코드를 작성하기 위한 설계 원칙에 대해 깊이 있게 학습했다. 코드 품질을 측정하는 지표인 응집도결합도의 개념을 이해하고, "응집도는 높게, 결합도는 낮게" 유지하는 것의 중요성을 배웠다. 🚀 더 나아가, 유지보수와 확장이 용이한 소프트웨어를 만들기 위한 5가지 핵심 원칙인 SOLID 원칙을 구체적인 코드 예제와 함께 익혔다. 이 원칙들을 통해 더 유연하고 견고한 프로그램을 설계하는 방법을 배웠다.


📌 목표

  • 객체지향 설계의 중요성 이해
  • 응집도와 결합도의 개념을 익히고 좋은 코드를 판단하는 기준 학습
  • SOLID 5원칙(SRP, OCP, LSP, ISP, DIP)의 개념 이해
  • SOLID 원칙을 실제 코드에 적용하여 설계 개선하기

📖 이론

1. 좋은 설계의 필요성

  • 단순히 C++ 문법을 아는 것을 넘어, 객체지향적으로 코드를 설계하는 능력은 매우 중요하다.
  • 좋은 설계는 개발 시간을 단축하고, 새로운 기능 추가나 변경에 유연하게 대응할 수 있게 하며, 대부분의 라이브러리나 오픈 소스가 객체지향적으로 설계되어 있어 이를 이해하는 데 큰 도움이 된다.

2. 응집도 (Cohesion)

  • 정의: 클래스 또는 모듈 내부의 구성 요소들이 얼마나 밀접하게 관련되어 있는지를 나타내는 척도.
  • 높은 응집도 (Good): 클래스가 단 하나의 목적이나 책임을 가지며, 내부의 메서드와 데이터가 모두 그 목적을 위해 긴밀하게 연관된 상태. (예: 피자배달 클래스에 경로확인, 고객대응, 시간측정 기능만 있는 경우)
  • 낮은 응집도 (Bad): 클래스가 서로 관련 없는 여러 기능을 포함하는 상태. 하나의 기능을 변경해도 다른 기능에 영향을 줄 수 있어 유지보수가 어렵다. (예: 피자배달 클래스에 웹사이트디자인, 회사마케팅 기능이 섞여 있는 경우)
  • 목표: 응집도는 높게 유지해야 한다.

3. 결합도 (Coupling)

  • 정의: 하나의 클래스(모듈)가 다른 클래스(모듈)에 얼마나 의존하는지를 나타내는 척도.
  • 낮은 결합도 (Good): 클래스들이 서로에게 최소한으로 의존하거나, 인터페이스(추상화)를 통해 의존하는 상태. 한 클래스의 변경이 다른 클래스에 미치는 영향이 적다. (예: 자동차 클래스가 구체적인 디젤엔진이 아닌, 엔진이라는 추상 인터페이스에 의존하는 경우)
  • 높은 결합도 (Bad): 한 클래스가 다른 구체적인 클래스에 직접적으로 의존하는 상태. 의존하는 클래스가 변경되면, 의존하는 쪽도 함께 수정해야 한다. (예: 자동차 클래스가 디젤엔진 클래스를 직접 멤버로 가지는 경우, 전기엔진으로 바꾸려면 자동차 클래스 코드를 수정해야 함)
  • 목표: 결합도는 낮게 유지해야 한다.

4. SOLID 원칙

유지보수와 확장이 용이한 설계를 위한 5가지 원칙의 앞 글자를 딴 것이다.

  1. S - 단일 책임 원칙 (SRP, Single Responsibility Principle)

    • 클래스는 단 하나의 책임만 가져야 한다. 클래스를 변경해야 하는 이유는 오직 하나여야 한다.
    • (예) Student 클래스는 학생 정보만 관리하고, 성적 계산은 GradeCalculator, 출력은 StudentPrinter 클래스가 각각 맡도록 분리한다.
  2. O - 개방-폐쇄 원칙 (OCP, Open-Closed Principle)

    • 소프트웨어 요소(클래스, 모듈 등)는 확장에는 열려(Open) 있어야 하고, 수정에는 닫혀(Closed) 있어야 한다.
    • 기존 코드를 수정하지 않고도 새로운 기능을 추가할 수 있어야 한다. 주로 추상화(인터페이스)와 다형성을 통해 구현한다.
    • (예) ShapeManagerif-else로 도형 종류를 판별하는 대신, Shape 인터페이스를 받고 shape.draw()를 호출하도록 설계하면, 새로운 도형(Triangle)이 추가되어도 ShapeManager 코드는 수정할 필요가 없다.
  3. L - 리스코프 치환 원칙 (LSP, Liskov Substitution Principle)

    • 자식 클래스는 언제나 부모 클래스로 대체할 수 있어야 한다. 즉, 부모 클래스를 사용하는 코드가 자식 클래스 객체로 바꿔치기해도 문제없이 동작해야 한다.
    • (예) 정사각형직사각형의 하위 타입처럼 보이지만, setWidth()setHeight()의 동작 방식이 다르다. (정사각형은 너비를 바꾸면 높이도 바뀌어야 함) 이는 부모(직사각형)의 행동 규약을 어기는 것이므로 LSP를 위반한다. 따라서 상속 관계가 아닌 별도의 클래스로 설계하거나, 공통의 Shape 인터페이스를 상속받도록 해야 한다.
  4. I - 인터페이스 분리 원칙 (ISP, Interface Segregation Principle)

    • 클라이언트는 자신이 사용하지 않는 인터페이스에 의존하도록 강요받아서는 안 된다.
    • 하나의 거대한 인터페이스 대신, 역할에 따라 여러 개의 작은 인터페이스로 분리하는 것이 좋다.
    • (예) 복합기 인터페이스에 print(), scan(), fax()가 모두 있다면, 프린터 기능만 필요한 클래스도 scan(), fax()를 억지로 구현해야 한다. IPrinter, IScanner, IFax로 인터페이스를 분리하는 것이 더 낫다.
  5. D - 의존 역전 원칙 (DIP, Dependency Inversion Principle)

    • 고수준 모듈은 저수준 모듈에 의존해서는 안 되며, 둘 모두 추상화에 의존해야 한다.
    • 구체적인 구현 클래스가 아닌, 추상 클래스나 인터페이스에 의존해야 한다.
    • (예) Computer 클래스가 구체적인 Keyboard, Monitor 클래스에 직접 의존하는 대신, IInputDevice, IOutputDevice라는 인터페이스에 의존하도록 설계한다. 이렇게 하면 키보드 대신 마우스를, 모니터 대신 프린터를 쉽게 교체할 수 있다.

💻 코드

SOLID 원칙 적용: 자동차와 엔진 설계

결합도가 높은 코드 (Before)

#include <iostream>
#include <string>

// 구체적인 Engine 클래스
class Engine {
public:
    void start() { std::cout << "Engine started" << std::endl; }
};

// Car가 구체적인 Engine 클래스에 직접 의존 (높은 결합도)
class Car {
private:
    Engine engine; // Engine 객체를 멤버로 직접 소유
public:
    void startCar() {
        engine.start();
        std::cout << "Car started" << std::endl;
    }
};

int main() {
    Car myCar;
    myCar.startCar();
    // 만약 ElectricEngine을 사용하려면 Car 클래스를 수정해야 함
    return 0;
}

결합도를 낮춘 코드 (After - OCP, DIP 적용)

#include <iostream>
#include <memory> // std::unique_ptr

// 추상화 계층: Engine 인터페이스 (순수 가상 함수)
class IEngine {
public:
    virtual void start() = 0; // 엔진은 시동 기능이 있어야 함
    virtual ~IEngine() = default; // 가상 소멸자
};

// 저수준 모듈: 구체적인 엔진 구현
class DieselEngine : public IEngine {
public:
    void start() override { std::cout << "Diesel Engine started" << std::endl; }
};

class ElectricEngine : public IEngine {
public:
    void start() override { std::cout << "Electric Engine started silently" << std::endl; }
};

// 고수준 모듈: Car는 추상 인터페이스인 IEngine에만 의존 (낮은 결합도)
class Car {
private:
    std::unique_ptr<IEngine> engine; // 구체적인 엔진이 아닌 인터페이스에 의존
public:
    // 외부에서 생성된 엔진 객체를 주입받음 (의존성 주입)
    Car(std::unique_ptr<IEngine> eng) : engine(std::move(eng)) {}

    void startCar() {
        engine->start(); // 다형성을 통해 실제 엔진의 start()가 호출됨
        std::cout << "Car started" << std::endl;
    }
};

int main() {
    // DieselEngine을 사용하는 자동차
    auto diesel = std::make_unique<DieselEngine>();
    Car dieselCar(std::move(diesel));
    dieselCar.startCar();

    std::cout << "---" << std::endl;

    // ElectricEngine을 사용하는 자동차
    // Car 클래스 코드 수정 없이 새로운 엔진을 유연하게 교체 가능
    auto electric = std::make_unique<ElectricEngine>();
    Car electricCar(std::move(electric));
    electricCar.startCar();

    return 0;
}

⚠️ 실수

  • 처음에는 단일 책임 원칙(SRP)을 생각하지 않고, 한 클래스에 온갖 기능을 다 넣으려고 했다. 😅 "일단 돌아가게 만들자"는 생각이었는데, 기능 하나를 수정하니 다른 기능까지 영향을 받는 것을 보고 클래스를 분리해야 하는 이유를 깨달았다.
  • 리스코프 치환 원칙(LSP)이 가장 헷갈렸다. 논리적으로 '정사각형은 직사각형이다'가 맞다고 생각해서 상속 구조를 만들었는데, 코드상에서는 부모의 행동 규칙을 위반하여 예기치 않은 버그를 발생시킬 수 있다는 점이 충격이었다. 🤯 현실 세계의 'is-a' 관계가 코드의 'is-a' 관계와 항상 일치하지 않는다는 것을 배웠다.
  • 의존 역전 원칙(DIP)을 적용할 때, 처음에는 인터페이스를 만들고도 main 함수에서 구체 클래스를 직접 생성해서 넘겨주는 게 무슨 의미가 있나 싶었다. 하지만 핵심은 Car 같은 고수준 모듈이 구체 클래스의 존재 자체를 모르게 만드는 것이었고, 이를 통해 Car의 재사용성과 확장성이 극대화된다는 것을 이해했다.

✅ 핵심 요약

개념설명비고
응집도 (Cohesion)클래스 내부 요소들이 얼마나 밀접하게 관련 있는가?높을수록(High) 좋다. (하나의 책임)
결합도 (Coupling)클래스 간의 의존성이 얼마나 강한가?낮을수록(Low) 좋다. (느슨한 연결)
SRP (단일 책임)클래스는 단 하나의 책임만 가져야 한다.클래스를 변경할 이유는 하나뿐.
OCP (개방-폐쇄)확장에 열려있고, 수정에 닫혀있어야 한다.기존 코드 수정 없이 기능 추가.
LSP (리스코프 치환)자식 클래스는 부모 클래스와 대체 가능해야 한다.부모의 행동 규약을 지켜야 함.
ISP (인터페이스 분리)자신이 쓰지 않는 메서드에 의존하지 않아야 한다.거대한 인터페이스를 잘게 분리.
DIP (의존 역전)구체적인 구현이 아닌, 추상화(인터페이스)에 의존해야 한다.결합도를 낮추는 핵심 원칙.
profile
Shin Ji Yong // Unreal Engine 5 공부중입니다~

0개의 댓글