SOLID 원칙, 객체지향 설계 5가지 원칙

객체 지향 프로그래밍의 5가지 원칙, SOLID(5가지의 원칙의 앞 글자를 따서 만든 이름)에 대해서 알아보려고 합니다.

프로그래밍 개발에 있어서, 항상 정형화된 방법으로 개발할 필요는 없지만, SOLID 같은 설계 원칙을 기반으로 개발을 하면 유지 보수하기 쉽고, 유연하고 확장이 용이한 소프트웨어를 개발할 수 있습니다.

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

SRP 원칙은 클래스나 모듈은 단 하나의 책임을 가져야 한다는 것을 의미합니다. 클래스는 변경되는 이유가 오직 하나여야 하며, 변경 사유는 단 하나의 책임과 관련되어야 합니다. 이렇게 함으로써 클래스는 응집도를 높이고, 유지보수성을 개선할 수 있습니다.

예를 들면, 아래 Rectangle 클래스는 2가지 기능을 갖고 있습니다.

  • 사각형의 면적 계산
  • 사각형을 렌더링

만약 면적 계산 알고리즘에 문제가 있으면 Rectangle을 수정해야하며, 렌더링에 문제가 있을 때도 Rectangle을 수정해야 합니다. SRP 원칙을 따르지 않기 때문에, 모듈화가 잘 안되어있고 관리하기도 쉽지 않습니다.

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def calculate_area(self):
        return self.width * self.height

    def render_on_canvas(self):
        # 캔버스에 사각형을 그리는 코드
        pass

아래와 같이 Rectangle에서 렌더링 기능을 분리한다면, Rectangle은 면적 계산 역할만 담당하며, RectangleRenderer는 렌더링만 담당하게 됩니다. 그리고 어떤 문제가 발생했을 때 관련 있는 클래스만 수정하면 됩니다.

class Rectangle:
    def __init__(self, width, height):
        self.width = width
        self.height = height

    def calculate_area(self):
        return self.width * self.height

class RectangleRenderer:
    def render_on_canvas(self, rectangle):
        # 캔버스에 사각형을 그리는 코드
        pass

2. 개방 폐쇄 원칙 (OCP : Open-Closed Principle)

OCP 원칙은 확장에 열려있고, 변경에는 닫혀있어야 한다는 의미입니다.

즉, 새로운 기능이 추가되거나 변경이 필요한 경우, 기존 코드를 수정하지 않고 확장할 수 있는 구조가 되어야 합니다.

예를 들면, 아래 예제에서 Shape는 부모 클래스이며, Rectangle은 Shape 클래스를 상속받아 구현한 사각형 면적을 계산하는 클래스입니다.

class Shape:
    def __init__(self, color):
        self.color = color

    def calculate_area(self):
        pass

class Rectangle(Shape):
    def __init__(self, color, width, height):
        super().__init__(color)
        self.width = width
        self.height = height

    def calculate_area(self):
        return self.width * self.height

이 때, 원의 면적을 구하는 클래스를 추가해야하는 요구사항이 생겼습니다.

아래와 같이 기존 코드를 변경하지 않으면서 Shape를 상속받는 Circle 클래스를 구현하여 기능을 확장할 수 있습니다.

class Circle(Shape):
    def __init__(self, color, radius):
        super().__init__(color)
        self.radius = radius

    def calculate_area(self):
        return 3.14 * (self.radius ** 2)

다른 요구사항으로, 삼각형의 면적을 구하는 클래스에 대한 요구사항이 추가되었습니다.

이번에도 아래와 같이 기존 코드 변경 없이 기능을 추가할 수 있습니다.

class Triangle(Shape):
    def __init__(self, color, base, height):
        super().__init__(color)
        self.base = base
        self.height = height

    def calculate_area(self):
        return 0.5 * self.base * self.height

위의 예처럼, 기능을 추가할 때 기존 코드를 변경하지 않고 구현할 수 있다면, 확장에는 열려있고 변경에는 닫혀있다고 할 수 있습니다.

새로운 기능을 추가할 때, 기존 클래스의 여러 곳을 수정해야 한다면 OCP를 위반하는 것이며, 모듈화/구조에 문제가 있다고 봐야할 것 같습니다.

3. 리스코프 치환 원칙 (LSP : Liskov Substitution Principle)

LSP 원칙은 하위 타입은 상위 타입을 대체할 수 있어야 한다는 의미입니다. 즉, 하위 타입은 상위 타입과 호환이 되어야 합니다.

예를 들면, 아래 예제에서 Calculator가 상위 타입의 클래스이며, ImprovedCalculator가 하위 타입의 클래스입니다. LSP에 따르면, ImprovedCalculator는 Calculator의 역할을 대신할 수 있어야 합니다. 하지만 이 클래스들은 LSP를 위반합니다.

두 클래스는 동일하게 동작하는 것처럼 보이지만, 특정 케이스에서 다르게 동작합니다.

  • add(x, y)의 인자가 모두 양수일 때, 두 함수는 동일한 값을 리턴 (하위 타입은 상위 타입을 대체)
  • add(x, y)의 인자가 음수일 때, Calculator는 음수도 합하지만, ImprovedCalculator는 ValueError 발생 (대체하지 못함)
class Calculator:
    def add(self, x, y):
        return x + y

    def subtract(self, x, y):
        return x - y

class ImprovedCalculator(Calculator):
    def add(self, x, y):
        if x > 0 and y > 0:
            return x + y
        else:
            raise ValueError("Both operands must be positive numbers.")

    def subtract(self, x, y):
        if x > y:
            return x - y
        else:
            raise ValueError("The first operand must be greater than the second operand.")

별거 아닌 것 같지만, 클래스를 사용하는 클라이언트가 있다면, 클라이언트는 하위 타입이 상위 타입의 기능을 할 수 있다고 생각하여 코드를 구현할 수 있으며, 대부분의 케이스는 동일하게 동작하여 문제가 없다고 생각할 수 있습니다. 하지만 위와 같이 특정 케이스에서 호환이 안되는 경우가 발생하여 문제가 발생할 수 있습니다.

4. 인터페이스 분리 원칙 (ISP : Interface segregation principle)

ISP 원칙은 자신이 사용하지 않는 인터페이스에 의존하지 않아야 한다는 것을 의미합니다. 즉, 인터페이스는 클라이언트에 필요한 최소한의 기능만 제공해야 합니다. 이렇게 함으로써 결합도를 낮출 수 있고, 인터페이스의 변경이 클라이언트에 미치는 영향을 최소화할 수 있습니다.

아래 예제로 ISP 원칙에 대해서 설명해보겠습니다.

Animal 클래스는 eat, sleep, fly, swim 함수를 인터페이스로 갖고 있습니다. BirdFish 클래스는 Animal 클래스를 상속하였고, 필요한 메소드만 구현하였습니다. (Bird의 경우, 수영을 할수 없기 때문에 swim 함수는 구현하지 않음)

class Animal:
    def eat(self):
        pass

    def sleep(self):
        pass

    def fly(self):
        pass

    def swim(self):
        pass

class Bird(Animal):
    def eat(self):
        print("Bird is eating")

    def sleep(self):
        print("Bird is sleeping")

    def fly(self):
        print("Bird is flying")

    def swim(self):
        raise NotImplementedError

class Fish(Animal):
    def eat(self):
        print("Fish is eating")

    def sleep(self):
        print("Fish is sleeping")

    def fly(self):
        raise NotImplementedError

    def swim(self):
        print("Fish is swimming")

사실 위의 코드는 ISP 원칙을 위반합니다. 인터페이스에 모든 함수들이 포함되어 있어서 인터페이스를 구현할 때 불필요한 메소드가 포함되었습니다. 즉, 인터페이스가 너무 커서 불필요한 정보도 함께 노출되고 있으며, 인터페이스를 작게 나눠서 필요한 부분만 사용하도록 만들어야 합니다.

아래 코드를 보시면, 인터페이스가 작은 단위로 분리되었습니다. Bird의 경우 Animal과 Flyable 인터페이스만 구현하고 있으며, Fish의 경우 Animal과 Swimmable 인터페이스만 구현하고 있습니다. 인터페이스가 작은 단위로 분리되면, 다른 인터페이스에 대한 의존성 줄일 수 있습니다. 의존성이 줄어드면 유지보수도 편하고 특정 인터페이스에 문제가 생겼을 때, 관련있는 일부 클래스만 신경쓰면 됩니다.

class Animal:
    def eat(self):
        pass

    def sleep(self):
        pass

class Flyable:
    def fly(self):
        pass

class Swimmable:
    def swim(self):
        pass

class Bird(Animal, Flyable):
    def eat(self):
        print("Bird is eating")

    def sleep(self):
        print("Bird is sleeping")

    def fly(self):
        print("Bird is flying")

class Fish(Animal, Swimmable):
    def eat(self):
        print("Fish is eating")

    def sleep(self):
        print("Fish is sleeping")

    def swim(self):
        print("Fish is swimming")

5. 의존 역전 원칙 (DIP : Dependency Inversion Principle)

DIP 원칙은 상위 모듈은 하위 모듈에 의존해서는 안 되며, 양쪽 모두 추상화에 의존해야 한다는 것을 의미합니다.

예를 들어, 아래 코드에서 OrderProcessor가 상위 모듈이며, MailingService는 하위 모듈입니다.

  • OrderProcessor는 MailingService를 의존하고 있음
  • DIP 원칙에 위배되고 있음
class MailingService:
    def send_email(self, recipient, subject, message):
        # 전자우편을 보내는 구체적인 구현 로직
        pass

class OrderProcessor:
    def __init__(self, mailing_service):
        self.mailing_service = mailing_service

    def process_order(self, order):
        # 주문 처리 로직
        # ...
        # 주문 처리 후 전자우편 발송
        self.mailing_service.send_email(
            recipient=order.customer.email,
            subject="주문 완료",
            message="주문이 성공적으로 처리되었습니다."
        )

위 예제는 DIP 원칙을 위배하고 있는데, 아래와 같이 추상클래스를 생성하여 OrderProcessor가 추상클래스를 의존하게 하면, 더 이상 하위 모듈인 MailingService의 의존성이 사라지게 되어 DIP 원칙을 따르게 됩니다.

class MailingService(ABC):
    @abstractmethod
    def send_email(self, recipient, subject, message):
        pass

class EmailMailingService(MailingService):
    def send_email(self, recipient, subject, message):
        # 전자우편을 보내는 구체적인 구현 로직
        pass

class OrderProcessor:
    def __init__(self, mailing_service):
        self.mailing_service = mailing_service

    def process_order(self, order):
        # 주문 처리 로직
        # ...
        # 주문 처리 후 전자우편 발송
        self.mailing_service.send_email(
            recipient=order.customer.email,
            subject="주문 완료",
            message="주문이 성공적으로 처리되었습니다."
        )
Loading script...

Related Posts

codechachaCopyright ©2019 codechacha