파이썬에서 SOLID 원칙 적용하는 방법

파이썬에서 SOLID 원칙 적용하는 방법
TILPosted On Jul 9, 202415 min read

SOLID principles in Python

SOLID이란 무엇인가요?

객체 지향 프로그래밍은 모든 프로그래머의 도구 상자에서 매우 유용한 도구입니다. 그러나 사용할 때 대부분의 사람들이 빠지는 흔한 함정이 있습니다.

SOLID 원칙은 이러한 함정을 피하고 깔끔하고 유지보수 가능한 코드를 작성하는 데 도움이 되는 일련의 지침입니다.

"SOLID"은 다음을 나타내는 머리글자입니다:

  • 단일 책임 원칙 (SRP)
  • 개방/폐쇠 원칙 (OCP)
  • 리스코프 치환 원칙 (LSP)

  • 인터페이스 분리 원칙

  • 의존성 역전 원칙

1. 단일 책임 원칙 (SRP)

로버트 C. 마틴 (a.k.a 아저씨 밥)이 "OOD의 원칙"이라는 기사에서 만들어진 단일 책임 원칙은 다음과 같습니다.

한 클래스는 한 가지 책임만 가져야 합니다. 한 클래스가 여러 가지 일을 한다면, 여러 클래스로 분리해야 합니다.

간단한 예를 통해 이를 설명해보겠습니다. 우리가 Google 드라이브 또는 Dropbox에서 객체를 읽고 쓰는 클래스가 있다고 가정해 봅시다.

class StorageClient:
    _instance = None
    _google_client = None
    _dropbox_client = None

    def __init__(self, google_credentials, dropbox_credentials) -> None:
        self._google_client = "Google 클라이언트"
        self._dropbox_client = "Dropbox 클라이언트"

    @classmethod
    def get_or_create_instance(cls, google_credentials, dropbox_credentials) -> "StorageClient":
        if not cls._instance:
            cls._instance = StorageClient(google_credentials, dropbox_credentials)

        return cls._instance

    def read_from_google(self, key):
        ...

    def upload_to_google(self, key, value):
        ...

    def read_from_dropbox(self, key):
        ...

    def upload_to_dropbox(self, key, value):
        ...

이 클래스의 문제는 두 가지 책임을 가지고 있다는 점입니다. Google 드라이브 및 Dropbox에서 객체를 읽고 쓰는 데에 대한 별도의 로직을 구현해야 합니다. SRP를 준수하기 위해 이 클래스를 GoogleStorageClient와 DropboxStorageClient로 분리할 수 있습니다.

class GoogleStorageClient:
    _instance = None
    _google_client = None

    def __init__(self, google_credentials) -> None:
        self._google_client = "Google client"

    @classmethod
    def get_or_create_instance(cls, google_credentials) -> "GoogleStorageClient":
        if not cls._instance:
            cls._instance = GoogleStorageClient(google_credentials)

        return cls._instance

    def read(self, key):
        ...

    def upload(self, key, value):
        ...


class DropboxStorageClient:
    _instance = None
    _dropbox_client = None

    def __init__(self, dropbox_credentials) -> None:
        self._dropbox_client = "Dropbox client"

    @classmethod
    def get_or_create_instance(cls, dropbox_credentials) -> "DropboxStorageClient":
        if not cls._instance:
            cls._instance = DropboxStorageClient(dropbox_credentials)

        return cls._instance

    def read(self, key):
        ...

    def upload(self, key, value):
        ...

조금 더 상세하게 작성하더라도, 두 클라이언트를 개별적으로 개발하고 코드를 더 유지보수하기 쉽게 만듭니다. 예를 들어 Google 클라이언트를 작업하는 사람은 Dropbox 클라이언트의 작동 방식을 알 필요가 없으며 그 반대도 마찬가지입니다.

2. 개방/폐쇄 원칙 (OCP)

버트랜드 메이어는 1988년 저술한 "객체지향 소프트웨어 구성"에서 개방-폐쇄 원칙을 처음 제안한 것으로 일반적으로 알려져 있습니다. 그러나 1990년대에 이 원칙은 언클 밥이 1996년에 발표한 "개방-폐쇄 원칙"으로 현재의 형태로 재정의되었습니다.

개방/폐쇄 원칙은 다음을 의미합니다:

클래스에 새 기능을 추가할 수 있어야 하며 기존 코드를 변경하지 않아도 됩니다.

예를 들어, 다음 클래스는 개방/폐쇄 원칙을 위반합니다:

class Vehicle:
    def __init__(self, vehicle_type, **kwargs) -> None:
        self.vehicle = vehicle_type
        if self.vehicle_type == "car":
            self.tires = kwargs["tires"]
            self.mode = kwargs["mode"]
        elif self.vehicle_type == "boat":
            self.motors = kwargs["motors"]
            self.mode = kwargs["mode"]

    def get_specifications(self) -> str:
        if self.vehicle_type == "car":
            return f"This {self.vehicle_type} has {self.tires} tires and can drive on {self.mode}."
        elif self.vehicle_type == "boat":
            return f"This {self.vehicle_type} has {self.motors} motors and can float on {self.mode}."

이 클래스의 문제점은 새로운 차량, 예를 들어 비행기를 추가하려면 기존 클래스를 수정해야 한다는 것입니다.

기존 코드를 수정하는 것은 위험할 수 있으며 버그를 도입할 수도 있고 유닛 테스트를 실패할 수도 있습니다.

대신 추상 기본 클래스를 정의하고 상속을 사용하여 클래스가 개방/폐쇄 원칙을 따르도록 할 수 있습니다.

from abc import ABC, abstractmethod

class Vehicle(ABC):
    def __init__(self, mode) -> None:
        self.mode = mode

    @abstractmethod
    def get_specifications(self) -> str:
        ...

class Car(Vehicle):
    def __init__(self, tires) -> None:
        super().__init__("lane")
        self.tires = tires

    def get_specifications(self) -> str:
        return f"This car has {self.tires} tires and can drive on {self.mode}."

class Boat(Vehicle):
    def __init__(self, motors) -> None:
        super().__init__("water")
        self.motors = motors

    def get_specifications(self) -> str:
        return f"This boat has {self.motors} motors and can float on {self.mode}."

class Plane(Vehicle):
    def __init__(self, engines) -> None:
        super().__init__("air")
        self.engines = engines

    def get_specifications(self) -> str:
        return f"This plane has {self.engines} engines and can fly through the {self.mode}."

이제 새 차량을 추가하고 싶다면, 단순히 Vehicle 클래스를 상속하고 get_specifications 메서드를 구현하는 새 클래스를 생성하면 됩니다.

3. 리스코프 치환 원칙 (LSP)

리스코프 치환 원칙은 1987년 OOPSLA 컨퍼런스에서 Barbara Liskov에 의해 소개되었습니다. 이 원칙은 다음과 같습니다:

다시 말해, 만약 ST의 서브 클래스라면, T 타입의 객체를 S 타입의 객체로 대체할 수 있어야 하며, 프로그램의 기능을 변경하지 않아야 합니다.

예를 들어, 다음과 같은 클래스를 고려해보세요:

class Person:
    def __init__(self, name, age) -> None:
        self.name = name
        self.age = age

    def get_name(self) -> str:
        return self.name

    def vote(self, give_vote) -> int:
        if give_vote:
            return 1
        return 0

class Child(Person):
    def __init__(self, name, age) -> None:
        super().__init__(name, age)

    def vote(self) -> None:
        raise NotImplementedError("어린이는 투표할 수 없습니다.")

이 코드의 문제는 Child 클래스가 리스코프 치환 원칙을 위반한다는 것입니다. Person 타입의 객체를 Child 타입의 객체로 대체하려고 하면, 예를 들어 vote 메서드를 사용하려고 할 때 프로그램이 예상대로 동작하지 않을 것입니다.

이 문제를 해결하기 위해서는 Person을 추상 기본 클래스로 변환하고, 그것을 상속하는 Child와 Adult 두 클래스를 만들면 됩니다.

from abc import ABC, abstractmethod

class Person(ABC): def init(self, name, age) -> None: self.name = name self.age = age

def get_name(self) -> str:
    return self.name

class Child(Person): def init(self, name, age) -> None: super().init(name, age)

def go_to_school(self) -> None:
    print(f"{self.name} is going to school.")

class Adult(Person): def init(self, name, age) -> None: super().init(name, age)

def vote(self) -> int:
    return 1

이제 프로그램의 정확성에 영향을 주지 않고 Person 유형의 객체를 Child 또는 Adult 유형의 객체로 대체할 수 있습니다.

4. Interface Segregation Principle (ISP)

인터페이스 분리 원칙(Interface Segregation Principle, ISP)은 Uncle Bob이 만들었습니다. 이 원칙은 다음과 같이 설명합니다:

큰 인터페이스를 피해야 합니다. 이는 모든 클라이언트가 구현하는 인터페이스 메서드를 사용하지 않는 대규모 인터페이스를 의미합니다.

예를 들어, 다음과 같은 인터페이스를 고려해 보세요:

from abc import ABC, abstractmethod

class Printer(ABC):
    def scan(self) -> None: ...

    def fax(self) -> None: ...

    def print(self) -> None: ...


class SimplePrinter(Printer):
    def scan(self) -> None:
        raise NotImplementedError("This printer cannot scan.")

    def fax(self) -> None:
        raise NotImplementedError("This printer cannot fax.")

    def print(self) -> None:
        print("Printing...")


class AdvancedPrinter(Printer):
    def scan(self) -> None:
        print("Scanning...")

    def fax(self) -> None:
        print("Faxing...")

    def print(self) -> None:
        print("Printing...")

이 경우, SimplePrinter 클래스는 scan 및 fax 메서드가 필요하지 않지만, Printer 인터페이스를 구현하므로 이들을 구현해야 합니다. 이는 인터페이스 격리 원칙을 위반하는 것입니다.

그 대신, Printer 인터페이스를 Scanner, Fax 및 Printer 세 개의 별도의 인터페이스로 분리할 수 있습니다.

from abc import ABC, abstractmethod


class Scanner(ABC):
    @abstractmethod
    def scan(self) -> None:
        ...


class Fax(ABC):
    @abstractmethod
    def fax(self) -> None:
        ...


class Printer(ABC):
    @abstractmethod
    def print(self) -> None:
        ...


class SimplePrinter(Printer):
    def print(self) -> None:
        print("Printing...")


class AdvancedPrinter(Scanner, Fax, Printer):
    def scan(self) -> None:
        print("Scanning...")

    def fax(self) -> None:
        print("Faxing...")

    def print(self) -> None:
        print("Printing...")

이제 SimplePrinter 클래스는 Printer 인터페이스만 구현하면 되고, AdvancedPrinter 클래스는 세 인터페이스를 모두 구현할 수 있습니다.

이 방식을 통해 코드를 이해하기 쉽게 만들고 SimplePrinter 클래스에 불필요한 메서드가 필요 없어졌습니다.

5. 의존성 역전 원칙

언클 밥이 만든 의존성 역전 원칙은 다음과 같습니다:

이 원칙은 고수준 모듈과 저수준 모듈을 결합을 느슨하게 하기 위해 그들 사이에 추상화 계층을 도입하는 것에 관한 것입니다. 이를 통해 결합이 적고 유연한 시스템을 만들 수 있습니다.

다음은 의존성 역전 원칙을 위반하는 예시입니다. 고수준 모듈인 PaymentService가 저수준 모듈인 PaypalProcessor에 직접 의존하는 것입니다:

class PaypalProcessor:
    def process_payment(self, amount):
        print(f"Processing payment of ${amount} via PayPal")


class PaymentService:
    def __init__(self) -> None:
        self.payment_processor = PaypalProcessor()

    def perform_payment(self, amount):
        self.payment_processor.process_payment(amount)


payment_service = PaymentService()
payment_service.perform_payment(100)

만약 다른 결제 게이트웨이로 전환하고 싶다면, PaymentService 클래스를 수정해야 하는데 이는 개방-폐쇄 원칙을 위배합니다.

대신, 우리가 결제를 처리하는 PaymentService 고수준 모듈과 PayPal, Stripe와 같은 다른 결제 게이트웨이와 상호 작용할 수 있는 추상 인터페이스인 PaymentProcessor가 있는 것으로 가정해 봅시다.

from abc import ABC, abstractmethod


class PaymentProcessor(ABC):
    @abstractmethod
    def process_payment(self, amount):
        pass


class PayPalPaymentProcessor(PaymentProcessor):
    def process_payment(self, amount):
        print(f"Processing payment of ${amount} via PayPal")


class StripePaymentProcessor(PaymentProcessor):
    def process_payment(self, amount):
        print(f"Processing payment of ${amount} via Stripe")


class PaymentService:
    def __init__(self, payment_processor):
        self.payment_processor = payment_processor

    def perform_payment(self, amount):
        self.payment_processor.process_payment(amount)


paypal_processor = PayPalPaymentProcessor()
payment_service = PaymentService(paypal_processor)
payment_service.perform_payment(100)

이렇게하면 PaymentService 클래스는 특정 결제 프로세서 구현에 의존하지 않습니다. 대신 PaymentProcessor 인터페이스에 의존하고 있어서 PaymentService 클래스를 수정하지 않고도 다양한 결제 프로세서 간에 전환할 수 있습니다.

결론

SOLID 원칙은 깨끗하고 유지보수 가능하며 유연한 코드를 작성하는 데 도움이 되는 일련의 지침입니다. 이러한 원칙을 따르면 이해하기 쉬우며 테스트하고 유지하기 쉬운 코드를 만들 수 있습니다. 이러한 원칙에 적응하는 데는 시간이 걸릴 수 있지만, 확실히 더 나은 프로그래머가 되고 더 나은 소프트웨어를 만들 수 있도록 도와줄 것입니다. 이들은 가이드라인이며 절대적인 규칙이 아니므로 현명하게 사용하고 특정 요구사항에 맞게 적용하십시오.