7 분 소요

객체 지향적 설계

객체를 중심으로 프로그램을 구조화하는 설계 기법

재사용성과 유지보수성 을 높이며
직관적이고 확장 가능한 프로그램을 만들 수 있다

  • 단점은 없나?
    설계 단계가 복잡할 수 있고
    ‘다형성’,’동적 바인딩’등이 성능 오버헤드를 일으킬 수 있다
    또한 지나치게 복잡하게 디자인이 되면
    오히려 유지보수성이 떨어지며
    직관적이지 못할 수 있음

핵심이 되는 개념 (객체 지향의 4대 특성)

  • 추상화
    복잡한 시스템에서 ‘핵심 개념’만 뽑아 ‘단순화’한다
    (ex : 자동차 객체 -> 색,연료량(속성) / 주행, 정지 (행동))
  • 캡슐화
    객체의 내부 상태는 숨기고, 공개된 메서드만을 통해 접근
    (데이터를 보호하며, 유지보수를 용이하게 한다)
  • 상속
    기존 클래스의 속성과 기능을 재사용하여 새로운 클래스 정의
    (ex : Animal -> Dog, Cat)
  • 다형성
    같은 행동을 시켜도, 각 객체에 맞게 다른 동작을 수행
    (ex : makeSound() -> Dog : Bark, Cat : Meow)

응집도

클래스 나 모듈 내부의 구성 요소가
얼마나 ‘밀접하게 관련’되어 있는지 표현

‘높을수록’ 좋다
-> 한 클래스는 ‘하나의 책임’만 갖도록 설계
(아래에 나오는 SOLID 원칙 중 하나)

낮은 응집도?

보통 ‘서로 관련 없는’ 기능들이
하나의 클래스에 포함되는 경우

Image

유틸리티 클래스 안에
온갖 기능을 넣어놓은 경우
(보통 잡다한 기능들을 넣어놓기 좋은 클래스명이긴 함)

  • 보통 추가 기능들이 생기기 시작하면
    별도의 클래스를 구현하여 리팩토링 하는 것이 정석
    (함수 하나만 있는 클래스는 확장성이 좋으나
    현실적으로 귀찮은 면이 있다…)

높은 응집도?

서로 관련 있는 기능들만이
하나의 클래스에 존재하는 경우

Image

유틸리티 클래스가 아니라
각각의 기능을 담은 클래스를 구현
(정석적이다)

결합도

각 모듈이나 클래스 간
‘의존성’이 얼마나 강한지를 표현

‘낮을수록’ 좋다
-> 모듈 간 변경 영향이 최소화

높은 결합도?

특정한 클래스 수정시
‘다른 클래스’도 수정이 필요해지는 경우

Image

만약 새로운 Engine 클래스를 추가하는 경우
Car 클래스에 대한 수정이 불가피해진다
-> 유지보수성이 하락

낮은 결합도?

클래스의 수정이
다른 클래스에게 주는 영향이 최소화됨

Image

Engine이라는 인터페이스용 부모 클래스를 통해
자동차와 새로운 엔진 클래스와의 결합도가 낮아졌다

SOLID

객체지향 프로그래밍을 위한
검증한 설계 원칙

‘높은 응집도’와 ‘낮은 결합도’를
고려한 프로그래밍 원칙이다

S : 단일 책임 원칙(SRP)

‘하나의 클래스’는 ‘하나의 책임’만을 가지는 원칙

각각의 클래스는 그 ‘기능’이나 ‘역할’만을 수행하는데
집중해야 하며, 그것만을 위해 변경되어야 한다!

예시)
요구되는 기능은

  • 학생의 이름 받기
  • 학생의 이름 출력
  • 학생의 점수를 통한 성적 계산

잘못 적용한 경우

Image

Student 클래스 내에 모든 메서드가 구현됨
학생의 정보를 담는 것 외에도
‘성적 계산’과 ‘출력’에 대한 내용이 포함되어
여러 책임이 생겼다

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

제대로 적용한 경우

Image

Student 클래스는 ‘학생의 정보’만을 관리
GradeCalculator 클래스가 성적을 계산하며
StudenetPrinter 클래스가 출력을 담당

// 학생 정보 관리 클래스
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;
    }
};

O : 개방 폐쇠 원칙 (OCP)

확장에는 열려 있고, 수정에는 닫혀있다는 개념

-> 가능한 최소한의 변경으로 새로운 기능을 추가할 수 있도록 설계

예시)

  • 도형에 해당하는 번호를 받고, 그 도형을 그려주는 클래스 제작

잘못 적용한 경우

Image

ShapeManager가 모든 도형을 관리하는 경우
특정 도형이 추가되면 drawShape 함수를 수정해야 한다

class ShapeManager {
public:
    void drawShape(int shapeType) {
        if (shapeType == 1) {
            // 원 그리기
        } else if (shapeType == 2) {
            // 사각형 그리기
        }
    }
};

제대로 적용한 경우

Image

ShapeManager는 Shape 인터페이스를 인자로 받기에
더는 영향을 받지 않는다
(결합도를 낮춤)

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

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

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

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

L : 리스코프 치환 원칙 (LSP)

자식 클래스는 부모 클래스의 역할을 대체 가능해야 함
(부모 클래스를 사용하는 코드에서
자식 클래스를 사용하는 코드로 대체되어도 정상 동작)

부모 클래스의 동작을 일관되게 유지해야 함을 의미

예시)

  • 모든 도형은 넓이를 계산 가능
  • Rectangle 클래스를 기반으로 Square 클래스를 작성 중

잘못 적용한 경우

Image

Rectangle 인터페이스를 상속받는 Square 클래스
그러나 정작 Square는 정사각형 이기에
높이와 넓이를 따로 설정할 필요가 없음

또한 Rectangle 의 속성을 이용하는 함수에서
Square 사용시, 예기치 못한 결과가 나온다

#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); // 정사각형은 너비와 높이가 같아야 함
    }
    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;
}

제대로 적용한 경우

Image

Square는 Rectangle과 비슷하지만 확실히
다른 부분이 존재하기에
‘상속’을 받지 않고 공통의 인터페이스를 생성

이후 넓이를 구할때 각각의 함수를 호출하여
해결한다

-> 비슷해보이는 기능 좀 있다고 상속 받는 것이 아니다

#include <iostream>

class Shape {
public:
    virtual int getArea() const = 0; // 넓이를 계산하는 순수 가상 함수
};

class Rectangle : public 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 {
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;
}

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

클라이언트는 ‘자신이 사용하지 않는’ 메서드에
의존하지 않아야 한다는 원칙
(자식 클래스가 부모 클래스의 불필요한 기능을 상속받는 경우
보통 ISP를 위반했다 한다)

‘하나의 거대한 인터페이스’ 보단
‘역할 별 세분화된 인터페이스’를 상속받아
‘필요한 기능’만 구현하도록 설계

-> 불필요한 의존성을 없애기 위해 작은 인터페이스로 분리한다

  • 종종 SRP와 비슷한 결과가 되기도 하지만
    SPR는 ‘클래스’에 집중하며, ‘높은 응집도’를 목표로 한다
    ISP는 ‘인터페이스’에 집중하며, ‘낮은 결합도’를 목표로 함

예시)

  • 프린트, 스캔 기능이 있는 클래스 구현

잘못 적용한 경우

Image

프린트, 스캔을 하나의 클래스에서 구현하게 되면
이 클래스를 상속받는 클래스는
딱히 필요하지 않은 기능이여도 이를 구현해야 함

class Machnine {
private:

public:
    Machnine() {}

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

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

제대로 적용한 경우

Image

프린트와 스캔의 인터페이스를 구현하고
실제 사용하는 클래스는 이들의 인터페이스를 통해 사용
-> 결합도가 낮아짐

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

D : 의존 역전 원칙 (DIP)

고수준 모듈은 저수준 모듈에 의존하는 것이 아닌
두 모듈 모두 ‘추상화’에 의존해야 한다는 원칙

‘구체적인 구현 결과’에 의존하지 말고
인터페이스나 추상 클래스를 중간에 두어
‘결합도’를 낮추는 것이 좋은 설계라는 것

예시)

  • 컴퓨터는 키보드와 모니터를 가짐
  • 키보드는 입력을, 모니터는 출력을 담당

잘못 적용한 경우

Image

Computer 클래스가
Keyboard 클래스와 Monitor 클래스에 강하게 결합됨
차후, Keyboard, Monitor 클래스가 늘어나는 경우나
입력 장치나 출력 장치를 바꾸는 등의 경우
변경의 소지가 있음

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

제대로 적용한 경우

Image

각각의 역할을 인터페이스로 구현하여
인터페이스의 함수를 호출하는 방식
다른 입/출력 장치 클래스로 교체가 쉬움

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

댓글남기기