본문 바로가기

C++ 공부

C++ 공부 8일차(응집도, 결합도, SOLID 원칙 5가지)

C++ 문법에 대해 많은 내용을 아는 것도 중요하지만, 객체 지향적으로 코드를 구현할 수 있는 것도 상당히 중요하다.

1. 대부분 라이브러리 및 오픈소스는 객체지향적으로 설계되어 있다.

이를 이해하면 더 깊이 있는 학습이 가능하다.

2. 좋은 설계로 구현된 코드는 개발 시간을 단축할 수 있다.

3. 좋은 설계로 구현된 코드는 기능 변경에 유연하게 대응할 수 있다.

응집도

클래스 또는 모듈 내부의 구성 요소들이 얼마나 밀접하게 관련되어 있는지를 나타낸다.

따라서 일반적으로 응집도가 높을수록 좋은 설계라고 평가된다.

클래스 내부에 관련 없는 기능들이 포함되어 있으면, 변경이 자주 발생하고, 확장하기도 쉽지 않습니다.

 

아래 기능을 제공하는 코드를 구현해보자.

1. 특정 문자열을 받고 메시지를 출력

2. 두 수의 합을 반환

3. 특정 문자열을 받고 역으로 출력

 

그리고 추가적으로 아래 기능이 추가 구현해보자.

1. 두 수의 곱을 반환하는 기능 추가.

2. 특정 문자열을 받고 메시지를 출력하기 전 대문자로 변환.

응집도가 낮은 코드

객체지향 프로그래밍을 처음 접하는 사람들은 종종 클래스 하나에 모든 기능을 집어넣는 실수를 합니다.

class 를 사용하긴 하는데 class 하나에 모든 걸 다 집어넣는 구조로 구현한다.

이는 아래와 같은 이유로 좋은 설계로 보기 어렵다.

1. 목적이 다른 기능이 섞여 있다.

2. 하나의 class에 모든 기능이 집중되어 유지 보수가 어렵다.

//기능 추가 전 코드

#include <iostream>
#include <string>
using namespace std;

class Utility 
{
public:
    void printMessage(const string& message) 
    {
        cout << "Message: " << message << endl;
    }

    void calculateSum(int a, int b)
    {
        cout << "Sum: " << (a + b) << endl;
    }

    void reverseString(const string& str) 
    {   //string(first, last) : first부터 last 직전까지의 원소들을 복사해서 새로운 string 생성.
        string reversed = string(str.rbegin(), str.rend()); 
        cout << "Reversed: " << reversed << endl;
    }
};

int main()
{
    Utility util;
    util.printMessage("Hello");
    util.calculateSum(5, 10);
    util.reverseString("world");
    return 0;
}
// 기능 추가 후 코드

#include <iostream>
#include <string>
#include <algorithm> // for transform
using namespace std;

class Utility
{
public:
    void printMessage(const string& message) 
    {
        string upperMessage = message;
        transform(upperMessage.begin(), upperMessage.end(), upperMessage.begin(), ::toupper); //대문자
        cout << "Message: " << upperMessage << endl;
    }

    void calculateSum(int a, int b) 
    {
        cout << "Sum: " << (a + b) << endl;
    }

    void calculateProduct(int a, int b) // 두 수의 곱 반환 기능 추가
    {
        cout << "Product: " << (a * b) << endl;
    }

    void reverseString(const string& str) 
    {
        string reversed = string(str.rbegin(), str.rend());
        cout << "Reversed: " << reversed << endl;
    }
};

int main()
{
    Utility util;
    util.printMessage("Hello");
    util.calculateSum(5, 10);
    util.calculateProduct(5, 10);
    util.reverseString("world");
    return 0;
}

위 두 코드는 둘 다 작동은 원하는대로 잘 되지만 하나의 클래스에 많은 기능을 구현하면 기능을 추가하거나 변경할 때 다른곳에서 에러가 발생 할 수도 있다.

응집도가 높은 코드

클래스를 목적에 따라 나누어 구현하여 응집도를 높일 수 있다.

이를 통해 아래 장점을 얻을 수 있다.

1. 기능 변경이 필요할 때 특정 class만 수정하면 된다.

2. 관련된 class 끼리 정보를 공유하여 코드의 구조가 명확해 진다.

// 기능 추가 전 코드

#include <iostream>
#include <string>
#include <algorithm> // for transform
using namespace std;

// 클래스를 3개로 나눔
class MessageHandler
{
public:
    void printMessage(const string& message)
    {
        cout << "Message: " << message << endl;
    }
};

class Calculator
{
public:
    void calculateSum(int a, int b)
    {
        cout << "Sum: " << (a + b) << endl;
    }
};

class StringManipulator
{
public:
    void reverseString(const string& str)
    {
        string reversed = string(str.rbegin(), str.rend());
        cout << "Reversed: " << reversed << endl;
    }
};

int main() 
{
    MessageHandler messageHandler;
    messageHandler.printMessage("Hello");

    Calculator calculator;
    calculator.calculateSum(5, 10);

    StringManipulator stringManipulator;
    stringManipulator.reverseString("world");

    return 0;
}
// 기능 추가 후

#include <iostream>
#include <string>
#include <algorithm> // for transform
using namespace std;

class MessageHandler 
{
public:
    void printMessage(const string& message) // 기능 수정 다른 클래스에 영향 x
    {
        string upperMessage = message;
        transform(upperMessage.begin(), upperMessage.end(), upperMessage.begin(), ::toupper);
        cout << "Message: " << upperMessage << endl;
    }
};

class Calculator 
{
public:
    void calculateSum(int a, int b) 
    {
        cout << "Sum: " << (a + b) << endl;
    }

    void calculateProduct(int a, int b) // 곱셈 기능 추가 다른 클래스에 영향x
    {
        cout << "Product: " << (a * b) << endl;
    }
};

class StringManipulator 
{
public:
    void reverseString(const string& str) 
    {
        string reversed = string(str.rbegin(), str.rend());
        cout << "Reversed: " << reversed << endl;
    }
};

int main()
{
    MessageHandler messageHandler;
    messageHandler.printMessage("Hello");

    Calculator calculator;
    calculator.calculateSum(5, 10);
    calculator.calculateProduct(5, 10);

    StringManipulator stringManipulator;
    stringManipulator.reverseString("world");

    return 0;
}

위 두 코드는 잘 작동함 뿐 아니라 어느 클래스에 어떤 기능을 구현해놨는지 찾을 때, 에러가 발생한다면 어느 클래스에서 에러가 발생했는지를 파악하여 비교적 쉽게 수정이 가능하다.

결합도

모듈 또는 클래스 간의 의존성을 나타낸다. 일반적으로 결합도는 낮을수록 좋은 코드이다.

결합도가 높으면 각 모듈 간의 의존성이 강해져 하나의 모듈이 변경될 때 다른 모듈도 영향을 받게 된다.

결합도가 높은 코드

// 기능 추가 전 코드

#include <iostream>
#include <string>

using namespace std;

// 기존 Engine 클래스
class Engine 
{
public:
    string state;

    Engine() : state("off") // 생성자 호출시 state에 off 대입
    {
    }

    void start() // start 호출시 state에 on 대입
    {
        state = "on";
        cout << "Engine started" << endl;
    }
};

class Car 
{
public:
    Engine engine; // Car 클래스에 Engine 클래스를 직접 포함함

    void startCar() 
    {
        if (engine.state == "off") 
        {
            engine.start();
            cout << "Car started" << endl;
        }
    }
};
// 기능 추가 후 코드

#include <iostream>
#include <string>

using namespace std;

// 기존 Engine 클래스
class Engine
{
public:
    string state;

    Engine() : state("off")
    {
    }

    void start()
    {
        state = "on";
        cout << "Engine started" << endl;
    }
};

// 새로운 ElectricEngine 클래스 (기존 Engine과는 별도)
class ElectricEngine
{
public:
    string state;

    ElectricEngine() : state("off")
    {
    }

    void start()
    {
        state = "on";
        cout << "Electric Engine running silently" << endl;
    }
};

// 기존 Car 클래스 수정
class Car
{
public:
    Engine engine; // Car 클래스는 여전히 Engine 클래스에 강하게 의존

    void startCar()
    {
        if (engine.state == "off")
        {
            engine.start();
            cout << "Car started" << endl;
        }
    }
};

결합도가 낮은 코드

결합도를 낮추기 위해 Car 클래스가 특정 엔진을 직접 포함하지 않고 인터페이스를 활용하는 방법을 사용할 수 있습니다.

1. Car class는 엔진 인터페이스에만 의존하므로, 새로운 엔진을 추가해도 자동 코드를 수정할 필요가 없습니다.

2. 확장성이 높아지며, 다양한 엔진을 유연하게 지원할 수 있습니다.

// 기능 추가 전

#include <iostream>
#include <memory>
#include <string>

using namespace std;

class Engine
{
public:
    virtual void start() = 0; // 순수 가상 함수 = 추상클래스 = 인터페이스
    virtual ~Engine() = default;
};

class DieselEngine : public Engine
{
public:
    void start() // 순수 가상 함수 내용 구현
    {
        cout << "Diesel Engine started" << endl;
    }
};

class Car
{
private:
    unique_ptr<Engine> engine; // 인터페이스에 의존하여 결합도 감소

public:
    Car(unique_ptr<Engine> eng) : engine(move(eng))
    {
    }

    void startCar()
    {
        engine->start();
        cout << "Car started" << endl;
    }
};

int main()
{
    auto engine = make_unique<DieselEngine>(); // 디젤엔진 타입 객체를 생성하고 유니크 포인터 engine이 소유권을 가짐.
    Car myCar(move(engine)); // engine(디젤엔젤 객체를 소유한 유니크포인터)의 소유권을 Car클래스의 eng(매개변수)로 받아서 Car의 멤버변수 engine에 넣음
    myCar.startCar(); // Car 클래스의 startCar()함수 호출
    return 0;
}
// 기능 추가 후

#include <iostream>
#include <memory>

using namespace std;

// 공통 인터페이스 정의
class Engine
{
public:
    virtual void start() = 0;
    virtual ~Engine() = default;
};

// DieselEngine 구현
class DieselEngine : public Engine
{
public:
    void start()
    {
        cout << "Diesel Engine started" << endl;
    }
};

// 새로운 ElectricEngine 구현
class ElectricEngine : public Engine
{
public:
    void start()
    {
        cout << "Electric Engine started silently" << endl;
    }
};

// Car 클래스는 Engine 인터페이스에만 의존
class Car
{
private:
    unique_ptr<Engine> engine;

public:
    Car(unique_ptr<Engine> eng) : engine(move(eng))
    {
    }

    void startCar()
    {
        engine->start();
        cout << "Car started" << endl;
    }
};

int main()
{
    // DieselEngine을 사용하는 경우
    auto dieselEngine = make_unique<DieselEngine>();
    Car dieselCar(move(dieselEngine));
    dieselCar.startCar();

    // ElectricEngine을 사용하는 경우
    auto electricEngine = make_unique<ElectricEngine>();
    Car electricCar(move(electricEngine));
    electricCar.startCar();

    return 0;
}

SOLID 원칙

이미 많은 개발자들이 검증한 설계 원칙이 SOLID 원칙이다.

SOLID 원칙의 주요 목적은 다음과 같다.

1. 유지보수성 및 확장성 향상

2. 변경에 유여한 설계 제공

SOLID 원칙이란 객체지향 설계에서 유지 보수성과 확장성을 높이기 위한 5가지 원칙을 의미합니다.

단일 책임 원칙(SRP)

각 클래스는 하나의 책임만 가져야 한다는 원칙입니다.

즉, 클래스는 하나의 변경 이유만을 가져야 하며, 특정 기능이나 역할을 수행하는 데 집중해야 합니다.

 

아래 상황을 구현해보자.

1. 학생의 이름을 받을 수 있어야 합니다.

2. 학생의 이름을 출력할 수 있어야 합니다.

3. 학생의 점수를 받고 성적을 계산할 수 있어야 합니다.

Student 클래스에 모든 메서드가 구현된 잘못된 경우.

// 단일 책임 원칙이 적용되지 않은 코드

#include <iostream>
#include <string>

class Student
{
public:
    void setName(const std::string& name)
    {
        this->name = name;
    }

    void displayDetails()
    {
        std::cout << "Student Name: " << name << std::endl;
    }

    void calculateGrade(int score)
    {
        if (score >= 90) {
            std::cout << "Grade: A" << std::endl;
        } else if (score >= 80) {
            std::cout << "Grade: B" << std::endl;
        } else {
            std::cout << "Grade: C" << std::endl;
        }
    }

private:
    std::string name;
};

각 기능을 나눠서 클래스를 구현한 단일 책임 원칙이 제대로 적용된 경우다.

// 단일 책임 원칙이 잘 적용된 코드

#include <iostream>
#include <string>

// 학생 정보 관리 클래스
class Student
{
public:
    void setName(const std::string& name)
    {
        this->name = name;
    }

    std::string getName() const
    {
        return name;
    }

private:
    std::string name;
};

// 성적 계산 클래스
class GradeCalculator
{
public:
    void calculateGrade(int score)
    {
        if (score >= 90)
        {
            std::cout << "Grade: A" << std::endl;
        } else if (score >= 80)
        {
            std::cout << "Grade: B" << std::endl;
        }
        else
        {
            std::cout << "Grade: C" << std::endl;
        }
    }
};

// 출력 클래스
class StudentPrinter
{
public:
    void displayDetails(const Student& student)
     {
        std::cout << "Student Name: " << student.getName() << std::endl;
    }
};

개방 폐쇄 원칙(OCP)

확장에는 열려 있어야 하고, 수정에는 닫혀있어야 한다는 개념이다.

즉, 기존 코드를 최소한으로 변경하면서 새로운 기능을 추가할 수 있도록 설계해야 합니다.

 

도형에 해당되는 번호를 받고, 해당 도형을 그려주는 클래스를 제작해보자.

 

아래와 같이 ShapeManager 클래스 하나가 모든 도형을 다 관리하고 있는 경우는 잘못된 것이다.

다른 도형이 추가된다면 drawShape의 코드가 수정된다.

즉 ShapeManager는 계속해서 영향을 받는다.

// 개방 폐쇄 원칙이 적용되지 않은 코드

class ShapeManager
{
public:
    void drawShape(int shapeType)
    {
        if (shapeType == 1
        {
            // 원 그리기
        } else if (shapeType == 2)
        {
            // 사각형 그리기
        }
        // 수정시에 계속 내용을 추가해야됨. => 수정에 닫혀있지 않다.
    }
};

ShapeManager는 Shape의 인터페이스(순수 가상 함수)를 인자로 받는다.

도형이 추가된다 해도 ShapeManager는 전혀 영향을 받지 않는다.

해당 도형 관련 클래스가 수정되고 Shape 인터페이스만 구현해 주면 된다.

// 개방 폐쇄 원칙이 잘 적용된 코드

class Shape
{
public:
    virtual void draw() = 0; // 순수 가상 함수
};

class Circle : public Shape
{
public:
    void draw()
    {
        // 원 그리기
    }
};

class Square : public Shape
{
public:
    void draw()
    {
        // 사각형 그리기
    }
};

// 만약 삼각형을 추가한다면 새로운 Triangle 클래스를 생성하면 된다. => 확장에 열려있음

class ShapeManager
{
public:
    void drawShape(Shape& shape)
    {
        shape.draw(); // 다형성 활용
    }
};

리스 코프 치환 원칙(LSP)

자식 클래스는 부모 클래스에서 기대되는 행동을 보장해야 합니다.

객체지향 프로그래밍에서 다형성을 활용할 때, 부모 클래스를 사용하는 코드가 자식 클래스로 대체되더라도 정상적으로 동작해야 합니다.(위의 개방폐쇄원칙 예시의 Shape& shape)

이를 위해 자식 클래스는 부모 클래스의 동작을 일관되게 유지해야 합니다.

 

아래 상황을 구현해 봅시다.

1. 모든 도형은 넓이를 계산할 수 있어야 합니다.

2. Rectangle(직사각형) 클래스와 이를 상속 받는 Square(정사각형) 클래스를 설계해 봅시다.

 

아래와 같이 Rectangle 인터페이스가 존재하고 Square는 이를 상속받는다.

하지만 정사각형은 너비와 높이를 따로 설정할 필요가 없다. 따라서 Rectangle에서 기대하는 행동을 보장하지 못한다.

// 리스코프 치환이 적용되지 않은 코드

#include <iostream>

class Rectangle
{
public:
    virtual void setWidth(int w) // 직사각형은 너비 값도 필요하고
    {
    	width = w;
    }
    virtual void setHeight(int h) // 높이 값도 필요함.
    {
    	height = h;
    }
    int getWidth() const
    {
   		return width;
    }
    int getHeight() const
    {
    	return height;
    }
    int getArea() const
    {
    	return width * height;
    }

private:
    int width = 0;
    int height = 0;
};

class Square : public Rectangle
{
public:
    void setWidth(int w) override // 정사각형은 너비와 높이가 같아야 함
    {
        Rectangle::setWidth(w);
        Rectangle::setHeight(w); // 정사각형에서는 setHeight가 필요 없음
    }
    void setHeight(int h) override
    {
        Rectangle::setHeight(h);
        Rectangle::setWidth(h);
    }
};

void testRectangle(Rectangle& rect)
{
    rect.setWidth(5);
    rect.setHeight(10);
    std::cout << "Expected area: 50, Actual area: " << rect.getArea() << std::endl;
}

int main()
{
    Rectangle rect;
    testRectangle(rect); // Expected area: 50

    Square square;
    testRectangle(square); // Expected area: 50, Actual area: 100 (문제 발생)
    return 0;
}
// 부모 클래스에 있는 속성이 자식 클래스에는 필요 없는 상황이 발생함.

공통된 기능을 인터페이스로 설정하고 자식 클래스에서 인터페이스를 받고 필요한 속성은 자식클래스에서 생성한다.

//리스코프 치환 원칙이 제대로 적용된 코드

#include <iostream>

class Shape // 공통 기능을 인터페이스로
{
public:
    virtual int getArea() const = 0; // 넓이를 계산하는 순수 가상 함수
};

class Rectangle : public Shape // Shape를 상속받는 직사각형 클래스
{
public:
    void setWidth(int w) { width = w; }
    void setHeight(int h) { height = h; }
    int getWidth() const { return width; }
    int getHeight() const { return height; }
    int getArea() const override { return width * height; } // 부모클래스 인터페이스 오버라이드

private:
    int width = 0;
    int height = 0;
};

class Square : public Shape // Shape를 상속받는 정사각형 클래스
{
public:
    void setSide(int s) { side = s; }
    int getSide() const { return side; }
    int getArea() const override { return side * side; } // 부모클래스 인터페이스 오버라이드

private:
    int side = 0;
};

void testShape(Shape& shape)
{
    std::cout << "Area: " << shape.getArea() << std::endl;
}

int main()
{
    Rectangle rect;
    rect.setWidth(5);
    rect.setHeight(10);
    testShape(rect); // Area: 50

    Square square;
    square.setSide(7);
    testShape(square); // Area: 49
    return 0;
}

인터페이스 분리 원칙(ISP)

클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 한다는 원칙입니다.

즉, 하나의 거대한 인터페이스보다는 역할별로 세분화된 인터페이스를 만들어, 필요한 기능만 구현하도록 설계해야 합니다.

 

아래 요구사항에 맞는 설계를 해봅시다.
프린트, 스캔이 가능해야 한다.

 

프린터, 스캔을 하나의 클래스에서 모두 구현하게 되면, 이를 상속받는 클래스에서는 필요가 없는 경우에도 이를 모두 구현해야 합니다.

// 인터페이스 분리 원칙이 적용되지 않은 코드

class Machnine
{
private:

public:
    Machnine() {}

    void print()
    {
        //세부 기능 구현
    }

    void scan()
    {
        //세부 기능 구현
    }
};

프린터, 스캔, 팩스 클래스를 따로 구현하게 되면 각각 필요한 클래스만 가져다가 사용하면 됩니다.

// 인터페이스 분리 원칙이 제대로 적용된 코드

class Printer
{
public:
    virtual void print() = 0;
};

class Scanner
{
public:
    virtual void scan() = 0;
};

class BasicPrinter : public Printer
{
public:
    void print() override
    {
        // 문서 출력
    }
};

class MultiFunctionDevice
{//
private:
    Printer* printer;
    Scanner* scanner;

public:
    MultiFunctionDevice(Printer* p, Scanner* s) : printer(p), scanner(s)
    {}

    void print()
    {
        if (printer) printer->print();
    }

    void scan()
    {
        if (scanner) scanner->scan();
    }
};

의존 역전 원칙(DIP)

고수준 모듈은 저 수준 모듈에 직접 의존하는 것이 아니라, 두 모듈 모두 추상화에 의존해야 합니다.

쉽게 말하면, 구체적인 구현(저 수준 모듈)에 의존하는 것이 아니라, 인터페이스나 추상 클래스 같은 추상화 계층을 두어 결합도를 낮추는 것이 좋은 설계입니다.

 

아래 요구사항에 맞는 설계를 해봅시다.

1. 컴퓨터는 키보드와 모니터가 있습니다.

2. 키보드는 입력을 받을 수 있고 모니터는 출력할 수 있습니다.

 

현재 컴퓨터 클래스는 키보드 클래스와 모니터 클래스에 강하게 결합되어 있습니다.

즉 키보드 종류가 여러 개가 되거나 모니터 종류가 여러 개가 되면 변경량이 많아집니다.

// 의존 역전 원칙이 제대로 적용되지 않은 코드

#include<string>

class Keyboard
{
public:
    std::string getInput()
    {
        return "입력 데이터";
    }
};

class Monitor
{
public:
    void display(const std::string& data)
    {
        // 출력
    }
};

class Computer
{
    Keyboard keyboard; // 타입이 강하다 = 클래스 자체를 선언한다
    Monitor monitor; // 다형성을 활용 못함

public:
    void operate()
    {
        std::string input = keyboard.getInput();
        monitor.display(input);
    }
};

키보드와 모니터를 인터페이스화해서 강한 결합이 아닌 약한 결합으로 했습니다.

이런 설계는 다른 입 / 출력장치로 쉽게 교체 가능합니다.

// 의존 역전 원칙이 제대로 적용된 코드

#include<string>

class InputDevice // 인터페이스
{
public:
    virtual std::string getInput() = 0;
};

class OutputDevice // 인터페이스
{
public:
    virtual void display(const std::string& data) = 0;
};

class Keyboard : public InputDevice // 입력장치 인터페이스를 구현
{
public:
    std::string getInput() override
    {
        return "키보드 입력 데이터";
    }
};

class Monitor : public OutputDevice // 출력장치 인터페이스를 구현
{
public:
    void display(const std::string& data) override
    {
        // 화면에 출력
    }
};

class Computer
{
private:
    InputDevice* inputDevice;
    OutputDevice* outputDevice;

public:
    Computer(InputDevice* input, OutputDevice* output) 
        : inputDevice(input), outputDevice(output)
        {}

    void operate()
    {
        std::string data = inputDevice->getInput();
        outputDevice->display(data);
    }
};
// 다른 입력장치, 출력장치가 생기면 인터페이스로 구현하여 다형성을 활용할 수 있음.

 

숙제 : SOLID원칙을 적용한 Student 클래스 구현