Python/파이썬클린코드

[파이썬 클린코드] Chapter4 : SOLID원칙-2

공부하는 sum 2023. 3. 17. 06:30
728x90

http://www.yes24.com/Product/Goods/114667254

파이썬 클린코드 2nd edition 을 읽고 정리한 내용입니다.


3. 리스코프 치환 원칙

Liskov substitution priciple

설계의 안정성을 높이기 위해서 객체가 가져야 하는 일련의 특성. 클라이언트가 특별히 신경쓰지 않더라도, 부모 클래스 대신 자식 클래스를 사용해도 정상적으로 동작해야 한다는 것을 의미한다. 

3-1. 도구를 사용해 LSP검사하기

mypy나 pylint등을 사용하여 LSP문제를 검출해 낼 수 있다. 만약 코드 전체에 타입 어노테이션을 사용하고, mypy를 설정한 경우에는 초기에 기본 오류 여부와 LSP준수 여부를 빠르게 확인할 수 있게 된다. 

3-2. 예시 

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

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

Rectangle 클래스를 상속하고있는 Square라는 클래스가 있다. 

  • Square 클래스는 width와 height가 동일한 정사각형
  • Rectangle 클래스는 width와 height가 다른 직사각형

이기 때문에 Square클래스는 Rectangle 클래스의 하위 타입이 아니며, 이는 리스코프 치환 원칙을 위반한 경우라고 할 수 있다. 

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

class Square(Rectangle):
    def __init__(self, size):
        super().__init__(size, size)

 코드를 리스코프 치환 원칙을 준수하도록 수정하려면, Square 클래스의 생성자에서 Rectangle 클래스의 생성자를 호출하여 width height 초기화하도록 변경해야 한다. 

애매하게 리스코프 치환 원칙을 지키지 않는 예시

class Rectangle:
    def __init__(self, width: int, height: int) -> None:
        self.width = width
        self.height = height

    def get_area(self) -> int:
        return self.width * self.height

class Square(Rectangle):
    def __init__(self, size: int) -> None:
        super().__init__(size, size)

Square 클래스에서는 Rectangle 클래스에서 정의된 width와 height를 모두 동일한 값으로 초기화기 때문에, 정사각형의 면적을 구할 때도 width * height 대신 width * width를 사용한다. 

이 경우에는 Square 클래스가 Rectangle 클래스의 하위 클래스이긴 하지만 Square 클래스의 인스턴스는 Rectangle 클래스의 인스턴스와 달리 width와 height가 항상 같은 값을 가지므로, Rectangle 클래스에서 정의된 get_area 메서드를 호출했을 때 예상한 결과가 나오지 않게 된다. 

Square과 Rectangle클래스는 같은 인터페이스는 가지고 있지만, Square클래스가 하위 클래스로서의 상위 클래스의 규약을 지키지 않고 있기 때문에 애매한 경우라고 한다. 

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def get_area(self) -> int:
        pass

class Rectangle(Shape):
    def __init__(self, width: int, height: int) -> None:
        self.width = width
        self.height = height

    def get_area(self) -> int:
        return self.width * self.height

class Square(Shape):
    def __init__(self, size: int) -> None:
        self.size = size

    def get_area(self) -> int:
        return self.size ** 2

그렇기에 추상 클래스인 Shape를 정의하고, Rectangle과 Square모두 Shape를 상속받도록 이렇게 고칠 수 있다.

이렇게 Shape 클래스를 도입하면, Rectangle 클래스와 Square 클래스가 모두 Shape 클래스의 규약을 따르도록 강제할  있으므로, 리스코프 치환 원칙을 지킬  있게 된다. 

사실 Square클래스에서 Rectangle클래스를 상속받는 것 자체가 잘못된 것인데, 생각해보면

  • Rectangle : 서로 다른 두 쌍의 평행한 변을 가진 사각형
  • Square : 변의 길이가 모두 같은 정사각형

Rectangle이 Square의 상위 개념인 것이 아니기 때문에 애초에 잘못된 것이라고 할 수 있다. 

그렇기 때문에 클래스를 설계 시에 이런 부분들을 주의깊게 생각하여 상속 관계를 구성하는 것이 좋다고 한다. 


4. 인터페이스 분리 원칙

Interface Segregation Principle

여러 메서드를 가진 인터페이스가 있다면, 더 적은 수의 메서드를 가진 여러개의 인터페이스로 분할하는 것이 좋다는 것을 의미한다.

인터페이스란, 객체가 노출하는 메서드의 집합을 의미한다. 파이썬에서는 덕타이핑의 원리를 따르고 있어 메서드의 형태를 보고 암시적으로 정의된다. 덕타이핑은 모든 객체가 자신이 가지고 있는 메서드와 할 수 있는 일에 의해서 표현된다는 것으로, 클래스의 메서드는 실제로 그 객체가 무엇인지를 정한다는 것을 의미한다. 

이렇게 더 작은 단위로 분할하게 되면, 구현하려는 각 클래스가 더 명확한 동작과 책임을 지니게 되어 응집력이 높아지는 효과가 있다. 

ISP는 SRP와 비슷하지만 인터페이스에 대해서 이야기 하고 있으므로 이는 행동의 추상화인 것이다. 

4-1. 예시

from abc import ABC, abstractmethod

class Car(ABC):
    @abstractmethod
    def start_engine(self):
        pass

class Radio:
    def __init__(self, channel):
        self.channel = channel

    def change_channel(self, channel):
        self.channel = channel

class SportsCar(Car):
    def start_engine(self):
        print("Starting the engine of the sports car")

class FamilyCar(Car):
    def start_engine(self):
        print("Starting the engine of the family car")

class CarWithRadio(Car):
    def __init__(self, radio):
        self.radio = radio

    def start_engine(self):
        print("Starting the engine of the car with radio")

    def change_channel(self, channel):
        self.radio.change_channel(channel)

Car 인터페이스는 start_engine() 메서드 하나만을 포함하고 있다.

CarWithRadio 클래스에서 Radio 객체를 받아서 change_channel() 메서드를 사용할  있지만, SportsCar나 FamilyCar 클래스는 change_channel() 메서드가 필요하지 않기 때문에 사용하지 않을 수 있다. 
이렇게 인터페이스가  필요한 메서드만을 포함하도록 하면, 불필요한 코드가 생기지 않고 의도는 더 명확해지는 효과가 있다. 

하지만 인터페이스 분리 원칙을 적용하면 클래스 수가 많아질 수 있다. 이렇게 클래스 수가 늘어나는 것은 클래스 간의 의존성을 줄이고, 느슨한 결합을 유지하는 데 도움이 된다. 만약 클래스 수가 많아진다 해도, 각각의 클래스는 한 가지 기능에만 집중하기 때문에 클래스의 역할을 파악하고 수정하기 쉽게 되기 때문에 이 원칙을 따르는 것이 좋다. 

인터페이스를 어떻게 분리하는게 좋을까?

인터페이스는 관련성이 높은 작은 단위로 분리하는 것이 좋다.

인터페이스는 관련된 일부 동작만 포함해야하며, 이를 통해 클라이언트는 필요한 동작만 사용할 수 있게 된다. 따라서 작은 인터페이스가 여러 개 있는 것이 더 좋은 설계라고 할 수 있다. 인터페이스가 너무 작아서 구현하는 데 너무 많은 시간이 소요된다면, 인터페이스가 너무 세분화되어 있을 수도 있으므로 다시 확인해 볼 필요가 있다. 


5. 의존성 역전 원칙

Dependency Inversion Principle

추상화를 통해 세부 사항에 의존하지 않도록 해야하지만, 반대로 구체적인 구현은 추상화에 의존해야 한다는 것이다. 

즉, 고수준 모듈은 저수준 모듈에 의존해서는 안 되며, 양쪽 모두 추상화된 인터페이스에 의존해야 한다는 원칙이다. 객체 간의 결합도를 낮추어서 유연하고 확장가능한 코드를 작성하는데 도움이 된다. 

결합도가 높은 코드를 만들게 되면 유지보수 및 확장이 어렵게 되는데, 이런 문제를 해결하기 위해서 의존성 역전 원칙에서는 추상화된 인터페이스를 사용한다. 이를 이용하여 고수준 모듈과 저수준 모듈 사이의 의존성을 끊어내게 되는데, 이렇게 하면 고수준 모듈은 추상화된 인터페이스에만 의존하기 때문에 저수준 모듈을 변경해도 고수준 모듈에는 영향을 주지 않게 된다. 

5-1. 의존성 주입

그렇기 때문에 코드가 구체적인 특정 구현에 종속되게 하는 것이 아니라, 계층 사이를 연결하는 추상화 개체에 종속되도록 해야 한다. 이렇게 구현하는 방법으로는 여러가지가 있는데

  1. 필요한 객체를 직접 생성하여 단순하게 구현
  2. 의존성을 주입

1번의 경우에는 단위 테스트도 어렵게 하고, 문제가 생기면 그대로 전달되는 등의 문제가 추가로 생기게 된다. 

그렇기 때문에 2번이 더 나은 방안이다. 이렇게 하면 만들어 놓은 인터페이스를 활용하면서 다형성도 지원할 수 있게 되며, 테스트 또한 간편해진다. 

만약 설정해야 할 의존성이 많고 객체 간 관계가 복잡하다면, 명시적으로 관계를 선언하고 도구에서 초기화 하는 방식을 사용하는 것이 좋다. (생성 방법과 실제 생성의 분리)

5-2. 예시

class AnimalSound:
    def make_sound(self):
        dog = Dog()
        dog.bark()

class Dog:
    def bark(self):
        print("Woof!")

AnimalSound 클래스에서는 Dog 클래스의 인스턴스를 생성하여 사용하는 것을 볼 수 있는데, AnimalSound 클래스는 Dog 클래스에 직접 의존한다는 것을 의미한다. 

class AnimalSound:
    def __init__(self, animal):
        self.animal = animal

    def make_sound(self):
        self.animal.make_sound()

class Dog:
    def make_sound(self):
        print("Woof!")

의존성 주입을 사용하여 수정하면 위와 같다.

AnimalSound 클래스가 Dog 클래스에 직접 의존하지 않고, Dog 클래스의 인스턴스를 주입받아 사용하게 된다. 

여기서 AnimalSound 클래스는 생성자에서 animal 인자를 받고, make_sound 메서드에서 animal 인스턴스의 make_sound 메서드를 호출한다. 이 때 animal 인스턴스는 Dog 클래스의 인스턴스가 될 수도 있고, 다른 클래스의 인스턴스가 될 수도 있다. 이렇게 하면 AnimalSound 클래스가 Dog 클래스에 의존하지 않고, Dog 클래스가 변경되어도 AnimalSound 클래스를 수정하지 않아도 된다. 

상위 수준 모듈과 하위 수준 모듈은 어떻게 구분해야 할까?

상위수준 모듈과 하위수준 모듈은 모듈 간의 의존성을 기준으로 구분된다. 보통 상위수준 모듈은 다른 모듈에 의존하는 경우가 적고, 다른 모듈이 자신에 의존하는 경우가 많은 모듈을 의미하며, 하위수준 모듈은 자신이 다른 모듈에 의존하는 경우가 많고, 다른 모듈이 자신에게 의존하는 경우가 적은 모듈을 의미한다.

하지만 모듈의 상위수준, 하위수준을 정확하게 판단하기 위해서는 어떤 모듈이 자주 변경되는지, 어떤 모듈이 안정적인지, 어떤 모듈이 다른 모듈에 영향을 끼치는지  다양한 요소를 고려해야한다. 일반적으로 변경이 잦은 모듈이 하위수준 모듈이며, 안정적인 모듈이 상위수준 모듈에 해당한다고 한다. 


의존성 역전 원칙은 진짜 무슨 소리인지 모르겠어서 챗피티한테 많이 물어봤다...
그냥 거의 챗피티가 쓴 글이랄까...

728x90