[Clean Architecture] SOLID

SOLID

SRP: The Single Responsibility Principle

이 챕터를 공부하면서 혼란스러웠던 부분이 있었다. 웹 검색을하면서 나오는 The Single Responsibility 의 정의와 책에서 설명하는 정의가 달라서이다.

대부분의 웹에서 나온 결과들은 다음과 같이 정의한다.

  • A module(source file) should be reponsible one, and only one, reason to change.

허나, 책에서는 위 정의는 틀린 것 이라고 말하며 다음과 같이 정의한다.

  • A module(source file) should be responsible one, and only one, actor(user or stakeholder) to change

두 정의 모두 로버트 마틴에 의한 정의이지만, 필자가 이해한 방식으로 설명을 하자면, 엄밀히 얘기하자면 아래가 맞는 말이라고 생각한다. 동일한 이유로 한 클래스의 한 함수, 메소드(기능)를 두 개발팀에서 공통적으로 사용하게 된다면 이는 분리가 필요할 것 이다. 반대로, 두 메소드가 있고, 각 메소드를 사용하는 이유는 다르더라도 같은 팀 혹은 한 유저만, 같은 경우에 사용한다면 굳이 분리할 필요가 없을 것 이다.

허나 실전에서 해당 프로젝트가 시간이 지남에 따라 팀이 나눠지고, 프로젝트가 커짐으로 인해 actor가 추가될 수도 있는 등의 다양한 변화가 있을 것이고 이런 상황에 대해 어느 정도 예상도 해가면서 구조를 설계해가야한다. 또한 혼자서 풀스택으로 다 개발을 한다면, actor는 본인 한 명 밖에 없지만 실제 코드는 마치 여러 팀, 여러 actor가 있는 것 처럼 reason을 고려하여 설계하는 것이 바람직한 방향일 것 이다.

책에서는 설명을 잘해주고 있지만 예제가 코드가 충분치 않아서 구글링으로 나온 예시 코드를 첨부하여보고자 한다.

SRP example 1.

class Person:
    def __init__(self, name):
        self.name = name

    def __repr__(self):
        return f'Person(name={self.name})'

    @classmethod
    def save(cls, person):
        print(f'Save the {person} to the database')


if __name__ == '__main__':
    p = Person('John Doe')
    Person.save(p)

위에서 Person Class 는 두 가지 책임이 있다.

  • Manage the person’s property
  • Store the person in the database.

Person의 속성을 정의해야하고, 이를 저장하는 두 가지가 있는 것 이다.
따라서 이를 다음과 같이 변경해준다.

class Person:
    def __init__(self, name):
        self.name = name

    def __repr__(self):
        return f'Person(name={self.name})'


class PersonDB:
    def save(self, person):
        print(f'Save the {person} to the database')


if __name__ == '__main__':
    p = Person('John Doe')

    db = PersonDB()
    db.save(p)

이렇게 분리해여 Person Class 에서는 Person의 속성을 전담하여 관리하고, PersonDB Class에서는 DBA팀에서 저장과 관련한 부분을 전담하여 관리할 수 있다.

Class가 두 개로 분리되어 있는데 코드와 Class는 분리하면서도 코드를 다음과 같이 연결할 수 있다.(facade pattern)

class PersonDB:
    def save(self, person):
        print(f'Save the {person} to the database')


class Person:
    def __init__(self, name):
        self.name = name
        self.db = PersonDB() # PersonDB Class 를 가져올 것임

    def __repr__(self):
        return f'Person(name={self.name})'

    def save(self):
        self.db.save(person=self)


if __name__ == '__main__':
    p = Person('John Doe')
    p.save()

SRP example 2.

class Car:
    prices={'BMW': 100000, 'Audi': 200000, 'Mercedes': 300000}
    def __init__(self, name):

        if name not in self.prices:
            print("Sorry, we don't have this car")
            return

        self.name = name
        self.price = self.prices[name]        

    def testDrive(self):
        print("Driving {}".format(self.name))

    def buy(self, cash):
        if cash < self.price:
            print("Sorry, you don't have enough money")
        else:
            print("Buying {}".format(self.name))

if __name__ == '__main__':
    car = Car('BMW')
    car.buy(100000)

위 코드는 세 부분으로 나눠질 수 있다.

  • Manage the Car’s property
  • Test drive a Car
  • Buy a Car

아래는 참고한 글의 필자가 위의 코드를 분리시킨 것인데 뭔가 깔끔하다는 느낌이 들지 않는다.

class Car:
    prices={'BMW':200000, 'Audi': 200000, 'Mercedes': 300000}
    def __init__(self, name):
        if name not in self.prices:
            print("Sorry, we don't have this car")
            return

        self.name = name
        self.price = self.prices[name]        

    def testDrive(self):
        
        print("Driving {}".format(self.name))

class Finances:
    def buy(car, cash):
        if cash == car.price:
            print("Buying {}".format(car.name))
        elif cash > car.price/3:
            print("Buying {} on installments".format(car.name))
        else:
            print("Sorry, you don't have enough money")

if __name__ == '__main__':
    car = Car('BMW')
    Finances.buy(car, 100000)

필자가 위의 코드도 마음에 들지 않는 이유는 다음과 같다.

  • Manage the Car’s property
  • Test drive a Car
  • Buy a Car

Manage, TestDrive 와 Buy를 분리해 놓았다. 차를 관리하는 유닛에서 관리와 테스트를 진행하고, 별도의 딜러가 Buy를 하는 개념인 거 같다. 헌데 TestDrive는 따로 고객? 소비하는 측에서 하는게 아닌지, Buy도 딜러입장에 아니라 소비하는 측에서 Buy를 하는건데 또 Buy내 코드를 보면 딜러가 handling하는 부분이 맞아서 함수 네이밍을 잘못한거라 봐야할지 흠 썩 그리 마음에 드는 예시는 아니었다.


OCP: The Open-Closed Principle

  • A software artifact should be open for extension but closed for modification.

소스가 기능 확장에는 열려있지만, 기능 수정에는 닫혀있어야 한다는 개방폐쇄성원칙이다.

OCP example 1.

"""
Open-Closed Principle
Software entities(Classes, modules, functions) should be open for extension, not
modification.
"""
class Animal:
    def __init__(self, name: str):
        self.name = name
    
    def get_name(self) -> str:
        pass

animals = [
    Animal('lion'),
    Animal('mouse')
]

def animal_sound(animals: list):
    for animal in animals:
        if animal.name == 'lion':
            print('roar')

        elif animal.name == 'mouse':
            print('squeak')

animal_sound(animals)

"""
The function animal_sound does not conform to the open-closed principle because
it cannot be closed against new kinds of animals.  If we add a new animal,
Snake, We have to modify the animal_sound function.  You see, for every new
animal, a new logic is added to the animal_sound function.  This is quite a
simple example. When your application grows and becomes complex, you will see
that the if statement would be repeated over and over again in the animal_sound
function each time a new animal is added, all over the application.
"""

animals = [
    Animal('lion'),
    Animal('mouse'),
    Animal('snake')
]

def animal_sound(animals: list):
    for animal in animals:
        if animal.name == 'lion':
            print('roar')
        elif animal.name == 'mouse':
            print('squeak')
        elif animal.name == 'snake':
            print('hiss')

animal_sound(animals)


"""
어떻게 OCP를 적용해 볼 수 있을까??
"""
class Animal:
    def __init__(self, name: str):
        self.name = name
    
    def get_name(self) -> str:
        pass

    def make_sound(self):
        pass

    def __repr__(self):
        return f'Animal(name={self.name})'


class Lion(Animal):
    def make_sound(self):
        return 'roar'


class Mouse(Animal):
    def make_sound(self):
        return 'squeak'


class Snake(Animal):
    def make_sound(self):
        return 'hiss'


def animal_sound(animals: list):
    for animal in animals:
        print(animal.make_sound())

animals = [
    Lion('Lion'),
    Mouse('Mouse'),
    Snake('Snake')
]

animal_sound(animals)

"""
추가될 수 있는 요소들을 Animal에서 상속되게 하여 각 Class로 만들어주었다.

Animal now has a virtual method make_sound. We have each animal extend the
Animal class and implement the virtual make_sound method.
Every animal adds its own implementation on how it makes a sound in the
make_sound.  The animal_sound iterates through the array of animal and just
calls its make_sound method.
Now, if we add a new animal, animal_sound doesn’t need to change.  All we need
to do is add the new animal to the animal array.
animal_sound now conforms to the OCP principle.
"""

OCP example 2.


"""
또 다른 예시.
Let’s imagine you have a store, and you give a discount of 20% to your favorite
customers using this class: When you decide to offer double the 20% discount to
VIP customers. You may modify the class like this:
"""

class Discount:
    def __init__(self, customer, price):
        self.customer = customer
        self.price = price

    def give_discount(self):
            if self.customer == 'fav':
                return self.price * 0.2
            if self.customer == 'vip':
                return self.price * 0.4


"""
새로운 타입의 고객이 추가될 경우 위의 코드가 수정되어야한다.
타입별로 별도의 클래스를 만들고 Discount 의 부모로부터 상속받도록 하자.
아래의 예시는 각 타입별로 묶는 것이 아니라 상속에 상속으로 구현했다.
No, this fails the OCP principle. OCP forbids it. If we want to give a new
percent discount maybe, to a diff.  type of customers, you will see that a new
logic will be added.
To make it follow the OCP principle, we will add a new class that will extend
the Discount.  In this new class, we would implement its new behavior:
"""

class Discount:
    def __init__(self, customer, price):
        self.customer = customer
        self.price = price

    def get_discount(self):
            return self.price * 0.2


class VIPDiscount(Discount):
    def get_discount(self):
        return super().get_discount() * 2

"""
If you decide 80% discount to super VIP customers, it should be like this:
You see, extension without modification.
"""

class SuperVIPDiscount(VIPDiscount):
    def get_discount(self):
        return super().get_discount() * 2

OCP example 3.

class Person:
    def __init__(self, name):
        self.name = name

    def __repr__(self):
        return f'Person(name={self.name})'


class PersonStorage:
    def save_to_database(self, person):
        print(f'Save the {person} to database')

    def save_to_json(self, person):
        print(f'Save the {person} to a JSON file')


if __name__ == '__main__':
    person = Person('John Doe')
    storage = PersonStorage()
    storage.save_to_database(person)

위의 예시는 다음과 같은 형태를 하고 있다.

PersonStoragesave_to_database, save_to_json 두 메소드를 갖고 있다.
허나 XML 파일로 저장하려는 것을 추가하려면 기존의 클래스를 수정하여 추가하여주어야 한다. 즉 이는 OCP 원칙에 위배된다.

수정해보면 이렇게 PersonStorage 의 하나의 클래스에 하위 저장하는 클래스들을 묶어줄 수 있다.

from abc import ABC, abstractmethod


class Person:
    def __init__(self, name):
        self.name = name

    def __repr__(self):
        return f'Person(name={self.name})'


class PersonStorage(ABC):
    @abstractmethod
    def save(self, person):
        pass


class PersonDB(PersonStorage):
    def save(self, person):
        print(f'Save the {person} to database')


class PersonJSON(PersonStorage):
    def save(self, person):
        print(f'Save the {person} to a JSON file')


class PersonXML(PersonStorage):
    def save(self, person):
        print(f'Save the {person} to a XML file')


if __name__ == '__main__':
    person = Person('John Doe')
    storage = PersonXML()
    storage.save(person)

LSP(Liskov Substitution Principle)

  • The Liskov substitution principle states that a child class must be substitutable for its parent class. Liskov substitution principle aims to ensure that the child class can assume the place of its parent class without causing any errors.

해당 법칙은 상위 타입의 객체를 하위 타입의 객체로 치환해도 동작에 문제가 없어야 함을 의미합니다. 즉, B가 A의 자식일 때, A 타입을 사용하는 부분에서 B로 치환해도 문제없이 동작이 되어야 합니다.

LSP example 1.

from abc import ABC, abstractmethod

class Notification(ABC):
    @abstractmethod
    def notify(self, message, email):
        pass

class Email(Notification):
    def notify(self, message, email):
        print(f'Send {message} to {email}')

class SMS(Notification):
    def notify(self, message, phone):
        print(f'Send {message} to {phone}')


if __name__ == '__main__':
    notification = SMS()
    notification.notify('Hello', '[email protected]')

# 실행결과
# Send Hello to [email protected]

구조를 떠나서 코드 자체도 에러가 있다. 번호로 SMS 전송을 의도 했는데 입력을 이메일로 input을 넣은 것이 작동하고 있다.

LSP example 2.

정사각형, 직사각형 예시 추가 예정.


ISP: Interface Segregation Principle

인터페이스 분리의 원칙(The interface Segregation Principle)이란 클라이언트는 불필요한 인터페이스에 의존하지 않아야 한다는 원칙이다.

ISP example 1.

from abc import ABC, abstractmethod


class Vehicle(ABC):
    @abstractmethod
    def go(self):
        pass

    @abstractmethod
    def fly(self):
        pass
        
class Aircraft(Vehicle):
    def go(self):
        print("Taxiing")

    def fly(self):
        print("Flying")

class Car(Vehicle):
    def go(self):
        print("Going")

    def fly(self):
        raise Exception('The car cannot fly')
      

차가 fly가 필요가 없음에도 불필요한 메소드가 포함되 었다. 이는 다음과 같은 구조로 변경해볼 수 있다.

class Movable(ABC):
    @abstractmethod
    def go(self):
        pass


class Flyable(Movable):
    @abstractmethod
    def fly(self):
        pass

class Aircraft(Flyable):
    def go(self):
        print("Taxiing")

    def fly(self):
        print("Flying")
        
class Car(Movable):
    def go(self):
        print("Going")

위 예시를 보면 인터페이스는 작게 유지하도록 해야하며 함부로 결합시켜서는 곤란해질 수 있다. 구체적이고 작은 인터페이스를 구현해야하며 사용되지 않을 인터페이스를 구성해서는 안된다.


DIP: The Dependency Inversion Principle

  • 의존성 관계 원칙

  • High-level modules should not depend on the low-level modules. Both should depend on abstractions.

  • Abstractions should not depend on details. Details should depend on abstractions.

상위 모듈은 하위 모듈의 구현 내용에 의존해선 안되며 상위 모듈과 하위 모듈 모두가 추상화된 내용에 의존해야 한다는 것 이다.

DIP example 1

class FXConverter:
    def convert(self, from_currency, to_currency, amount):
        print(f'{amount} {from_currency} = {amount * 1.2} {to_currency}')
        return amount * 1.2


class App:
    def start(self):
        converter = FXConverter()
        converter.convert('EUR', 'USD', 100)


if __name__ == '__main__':
    app = App()
    app.start()

# 실행결과
# 100 EUR = 120.0 USD

위 코드는 어떤 문제가 있을까?

지금 상황에서는 FXConverter 에서 변경사항이 생기거나 FXConverter가 아닌 다른 API를 사용하고자 한다면 App Class 또한 수정해주어야 한다.

이를 방지하기 위해 의존성 변경(invert the dependency)이 필요하다.

위의 구조로 변경하여 AppFXConverter가 아닌 추상화 클레스(인터페이스)에 의존하도록 한다.

from abc import ABC

class CurrencyConverter(ABC): # 추상화 클래스 (Interface)
    def convert(self, from_currency, to_currency, amount) -> float:
        pass


class FXConverter(CurrencyConverter):
    def convert(self, from_currency, to_currency, amount) -> float:
        print('Converting currency using FX API')
        print(f'{amount} {from_currency} = {amount * 1.2} {to_currency}')
        return amount * 1.15


class AlphaConverter(CurrencyConverter):
    def convert(self, from_currency, to_currency, amount) -> float:
        print('Converting currency using Alpha API')
        print(f'{amount} {from_currency} = {amount * 1.2} {to_currency}')
        return amount * 1.2


class App:
    def __init__(self, converter: CurrencyConverter): # App이 추상화 클래스를 참고함
        self.converter = converter

    def start(self):
        self.converter.convert('EUR', 'USD', 100) # 추상화 클래스에 의존하여 메소드 호출


if __name__ == '__main__':
    converter = AlphaConverter() # 추상화 된 클래스를 통해 어떤 API를 이용할지
    app = App(converter)
    app.start()
    
# 실행결과
# Converting currency using Alpha API
# 100 EUR = 120.0 USD

DIP example 2

class XMLHttpService(XMLHttpRequestService):
    pass

class Http:
    def __init__(self, xml_http_service: XMLHttpService):
        self.xml_http_service = xml_http_service
    
    def get(self, url: str, options: dict):
        self.xml_http_service.request(url, 'GET')

    def post(self, url: str, options: dict):
        self.xml_http_service.request(url, 'POST')

Http 상위 레벨의 Component에서 XMLHttpService 하위레벨 Component에 의존하고 있다.

따라서 이는 다음과 같이 바꿔볼 수 있을 것 같다.

from abc import ABC

class Connection(ABC):
    def request(self, url: str, options: dict):
        raise NotImplementedError

class XMLHttpService(Connection):
    xhr = XMLHttpRequest()

    def request(self, url: str, options:dict):
        self.xhr.open()
        self.xhr.send()

class NodeHttpService(Connection):
    def request(self, url: str, options:dict):
        pass

class MockHttpService(Connection):
    def request(self, url: str, options:dict):
        pass


class Http:
    def __init__(self, http_connection: Connection):
        self.http_connection = http_connection
    
    def get(self, url: str, options: dict):
        self.http_connection.request(url, 'GET')

    def post(self, url: str, options: dict):
        self.http_connection.request(url, 'POST')

if __name__ == '__main__':
    httpRequest = NodeHttpService() # 추상화 된 클래스를 통해 어떤 http를 이용할지
    http = Http(httpRequest)
    http.get()


다른 예제 링크

참고
https://www.pythontutorial.net/python-oop/
=> 여긴 SOLID 원칙 외에 다른 것도 봐야겠다.

https://github.com/doorBW/python_clean_code
=> 여기도 안본 예제가 조금 더 있음

https://docs.python.org/3/library/abc.html
=> 알아야하는데 대충 보고 넘김

https://github.com/heykarimoff/solid.python

https://www.geeksforgeeks.org/facade-method-python-design-patterns/

좋은 웹페이지 즐겨찾기