[ python ] 04. SOLID 원칙_(2)

박찬영·2024년 4월 4일

파이썬 클린 코드

목록 보기
12/19
post-thumbnail

파이썬 클린 코드

04. SOLID 원칙

L : Liskov's substitution principle (리스코프 치환 원칙)

리스코프 치환 원칙은 설계의 안정성을 높이기 위해 객체가 가져야 하는 일련의 특성을 말한다.
LSP의 요지는 클라이언트가 특별한 주의를 기울이지 않고도 부모 클래스를 대신하여 하위 클래스를 그대로 사용할 수 있어야 한다는 것이다. 즉, 클라이언트는 부모 타입 대신에 어떠한 하위 타입을 사용해도 정상적으로 동작해야 한다.

리스코프 치환 원칙의 정의
만약 S가 T의 하위 타입이라면 프로그램을 변경하지 않고 T 타입의 객체를 S 타입의 객체로 치환 가능해야 한다.

여러 하위 타입을 가진 객체를 사용하는 클라이언트를 생각해볼 수 있다. 이 타입은 추상 클래스 또는 인터페이스일 수 있다. 이 타입을 확장하는 여러 하위 타입이 있을 수 있다.(Subtype) 이 원칙의 배경은 계층 구조가 올바륵 구현되었다면 클라이언트 클래스가 주의를 기울이지 않고도 모든 하위 클래스의 인스턴스로 작업할 수 있어야한다는 것이다.

이는 지난 포스팅에서 살펴본 인터페이스 디자인과 관련이 있다. 좋은 클래스는 명확하고 간결한 인터페이스를 가지고 있으며, 하위 클래스가 해당 인터페이스를 따르는 한 프로그램은 정상적으로 동작한다.

LSP 문제를 검사하는 도구

LSP 문제를 mypypylint 같은 도구를 사용해 쉽게 검출할 수 있다.

mypy로 잘못된 메서드 서명 검사
Event 클래스의 하위 클래스 중 하나가 호환되지 않는 방식으로 메서드를 재정의하면 mypy는 어노테이션을 검사하여 이를 보고한다.

class Event:
  ...
  def meets_condition(self, event_data:dict)->bool:
    return False

class LoginEvent(Event):
  def meets_condition(self, event_data: list) -> bool: # 다른 타입을 사용
    return bool(event_data)

이 코드는 명확히 LSP를 위반했다. 파생 클래스가 부모 클래스에서 정의한 파라미터와 다른 타입을 사용했기 때문이다. LSP 원칙을 따랐다면 호출자는 아무런 차이를 느끼지 않고 투명하게 Event 또는 LoginEvent를 사용할 수 있어야 한다.

상속 관계에 있지는 않지만, 공통 인터페이스를 사용하는 경우, 예를 들면 딕셔너리와 리스트와 같은 관계에도 주의할 필요가 있다. 이들은 모두 이터러블이라는 공통점이 있어서 클라이언트가 딕셔너리나 리스트를 받기 위해 이터러블 인터페이스를 파라미터의 타입으로 정의할 수 있다. 이렇게 하면 LSP 원칙을 준수하는 논리 구조이지만, 실제 파라미터로 딕셔너리나 리스트가 아닌 다른 이터러블 데이터 타입이 들어온다면 문제가 발생한다. 이런 경우 메서드의 구조를 바꾸거나 디자인을 다시 하거나, 타입 어노테이션을 바꿔야 한다.

#type : ignore와 같은 주석을 통해 에러를 무시하면 안 된다. 이 문제를 실제로 해결하기 위해 코드를 리팩토링하거나 수정해야 한다. 코드 분석 도구는 수정이 필요한 타당한 이유가 있기 때문에 디자인 결함을 보고하고 있는 것이다.

pylint로 호환되지 않는 서명 검사
또 다른 자주 발생하는 LSP 위반 사례는 계층의 파라미터 타입이 다른 것이 아니라 메서드의 서명 자체가 완전히 다른 경우이다.

다음과 같이 계층 구조의 호환성을 깨는 클래스가 있다고 가정해볼 수 있다. 메서드의 서명을 변경하거나 파라미터를 추가하는 등의 차이가 있는 경우이다.

class LogoutEvent(Event):
	def meets_condition(self, event_data: dict, override: bool) -> bool:
    	if override:
        	return True
        ...

pylint는 이를 감지하여 유익한 정보를 출력한다.

애매한 LSP 위반 사례

어떤 경우는 LSP를 위바한 것이 명확하지 않아서 자동화된 도구로 검사하기 애매할 수 있다. 이런 경우는 코드 리뷰를 하면서 자세히 코드를 살펴볼 수밖에 없다.

계약이 수정되는 경우는 특히 자동으로 감지하기가 더 어렵다. LSP에서 하위 클래스는 상위 클래스와 호환 가능하다는 점을 감안할 때 계약은 계층 구조 어디에서나 항상 유지되어야만 한다.

부모 클래스는 클라이언트와의 계약을 정의한다. 하위 클래스는 그러한 계약을 따라야 한다. 예를 들면 다음과 같다.

  • 하위 클래스는 부모 클래스에 정의된 것보다 사전조건을 엄격하게 만들면 안 된다.
  • 하위 클래스는 부모 클래스에 정의된 것보다 약한 사후조건을 만들면 안 된다.

이전에 정의한 이벤트 계층구조를 LSP와 DbC간의 관계를 보여주기 위해 변경하며 살펴보자
이번 에제는 사전조건에서 파라미터가 딕셔너리 타입인지, 그리고 beforeafter 키를 가지고 있는지 확인한다. 두 키 값은 또다시 딕셔너리를 내포하고 있다. 이렇게 하면 클라이언트는 KeyError를 받지 않으므로 보다 발전된 캡슐화를 할 수 있다. 그저 사전조건 체크 메서드만 호출하면 되기 때문이다.

from collections.abc import Mapping

class Event:
  def __init__(self, raw_data):
    self.raw_data = raw_data

  @staticmethod
  def meets_condition(event_data: dict) -> bool:
    return False

  @staticmethod
  def validate_precondition(event_data: dict):
    """인터페이스 계약의 사전조건
    ''event_data''의 파라미터가 적합한 형태인지 유효성 검사
    """
    if not isinstance(event_data, Mapping):
      raise ValueError(f"{event_dir} dict 데이터 타입이 아닙니다.")
    
    for moment in ("before","after"):
      if moment not in event_data:
        raise ValueError(f"{event_data}{moment} 정보가 없습니다.")
      if not isinstance(event_data[moment], Mapping):
        raise ValueError(f"event_data[{momentl!r}]")

이제 올바른 유형을 탐지하기 전에 사전조건을 먼저 검사한다.

class SystemMonitor:
  """시스템에서 발생한 이벤트 분류"""
  def __init__(self, event_data):
    self.event_data = event_data

  def identify_event(self):
    Event.validate_precondition(self.event_data)
    event_cls = next(
        (
            event_cls
            for event_cls in Event.__subclasses__()
            if event_cls.meets_condition(self.event_data)
        ),
        UnknownEvent,
    )
    return event_cls(self.event_data)

계약은 오직 최상위 레벨의 키 "before"와 "after"가 필수적이고 그 값 또한 딕셔너리 타입이어야 한다고만 명시되어 있다. 하위 클래스에서 보다 제한적인 파라미터를 요구하는 경우 검사에 통과하지 못한다.

다음은 트랜잭션을 위한 이벤트 클래스를 설계해보도록 하자.

class TransactionEvent(Event):
  """시스템에서 발생한 트랜잭션 이벤트 """

  @staticmethod
  def meets_condition(event_data: dict)-> bool:
    return event_data["after"].get("transaction") is not None

트랜잭션을 위한 이벤트 클래스는 올바르게 설계되었다. "transaction"이라는 키에 제한을 두지 않고 사용하고 있다. 그 값이 있을 경우에만 사용하고 필수로 필요한 것은 아니다.

그러나 이전에 사용하던 LoginEvent와 LogoutEvent 클래스는 before와 after의 session이라는 키를 사용하기 때문에 그대로 사용할 수가 없다. 이렇게 되면 계약이 깨지고 KeyError가 발생하기 때문에 나머지 클래스를 사용하는 것과 같은 방식으로 클래스를 사용할 수 없다.

이 문제는 TransactionEvent와 마찬가지로 대괄호 대신 .get() 메서드를 사용해서 해결할 수 있다.

LSP 최종 정리

LSP는 객체지향 소프트웨어 설계의 핵심이 되는 다형성을 강조하기 때문에 좋은 디자인의 기초가 된다. 인터페이스의 메서드가 올바른 계층구조를 갖도록 하여 상속된 클래스가 부모 클래스와 다형성을 유지하도록 하는 것이다.

LSP에서 제안하는 방식으로 신중하게 클래스를 디자인하면 계층을 올바르게 확장하는데 도움이 된다. 즉 이전에 OCP에 기여한다고도 말할 수 있다.

I : Interface segregation principle (인터페이스 분리 원칙)

ISP는 "작은 인터페이스"에 대한 가이드라인을 제공한다. 객체 지향적인 용어로 인터페이스는 객체가 노출되는 메서드의 집합이다. 즉 객체가 수신하거나 해석할 수 있는 모든 메시지가 인터페이스를 구성하며, 클라이언트는 이것들을 호출할 수 있다. 인터페이스는 클래스의 정의와 구현을 분리한다.

파이썬에서 인터페이스는 메서드의 형태를 보고 암시적으로 정의된다. 이것은 파이썬이 소위 말하는 덕 타이핑(duck typing) 원리를 따르기 때문이다.

Duck typing ??
모든 객체가 자신이 가지고 있는 메서드와 자신이 할 수 있는 일에 의해서 표현된다는 점에서 출발한다. 즉 클래스의 타입, 이름, docstring, 클래스 속성 또는 인스턴스 속성에 관계없이 객체의 본질을 정의하는 것은 궁극적으로 메서드의 형태이다. 클래스의 메서드는 실제로 그 객체가 무엇인지 결정한다.
"어떤 새가 오리처럼 걷고 오리처럼 꽥꽥 소리를 낸다면 오리여야만 한다."

오랫동안 덕 타이핑은 파이썬에서 인터페이스를 정의하는 유일한 방법이었지만, 인터페이스를 다른 방식으로 정의하는 추상 기본 클래스 개념이 도입됐다. 추상 기본 클래스는 파생 클래스가 구현해야 할 일부분을 공통된 기본 동작으로 구현하거나 인터페이스로 정의하는 것이다. 이는 특정 주요 메서드가 실제로 재정의 되었는지 확인이 필요할 때 유용하며 isinstance()와 같은 메서드의 기능을 재정의하거나 확장하는 메커니즘으로도 작동한다.

추상 기본 클래스 (abstract base class)

추상 기본 클래스는 파생 클래스가 반드시 구현해야 하는 것을 명시적으로 가리키기 위한 유용한 도구이다.
앞선 예제에서 Event가 제네릭 클래스라면 Event 클래스 자체를 사용하기보다는 LoginEvent처럼 실제 이벤트 중 하나를 사용하게 될 것이다. 이런 경우 명시적으로 Event를 추상 기본 클래스로 지정할 수 있다.

이러면 SystemMonitor는 이 추상 클래스와 작업을 하고, Event 클래스는 인터페이스처럼 동작하게 된다. 여기서 더 나아가서 기본 클래스에 있는 meets_condition 메서드에서 제공하는 구현 내용이 부족하다고 판단되면 각 파생 클래스가 이를 직접 구현하도록 강제할 수 있다. 이런 경우에 @abstractmethod 데코레이터를 사용한다.

기본 클래스에서 @abstractmethod로 마킹한 메서드는 반드시 파생 클래스에서 모두 구현을 해야만 인스턴스화가 가능하다

abc 모듈에는 가상 서브클래스를 계층 구조의 일부로 등록하는 방법도 포함되어 있다. ABC의 register() 메서드를 사용하면 기존 기본 클래스에 파생 클래스를 추가할 수 있다. 이렇게 추가된 파생 클래스를 virtual subclass라고 한다.

다시 ISP 원리로 돌아오면, ISP는 결국 여러 메서드를 가진 인터페이스가 있다면 매우 정확하고 구체적인 구분에 따라 더 적은 수의 메서드를 가진 여러 개의 인터페이스로 분할하는 것이 좋다는 원칙이다. 재사용성을 높이기 위해 가능한 작은 단위로 인터페이스를 분리하면 인터페이스를 구현하려는 각 클래스가 매우 명확한 동작과 책임을 지니기 때문에 응집력이 높아진다.

너무 많은 일을 하는 인터페이스

예를 들어 여러 데이터 소스에서 이벤트를 파싱하는 인터페이스를 가정해보자... 예를 들면 XML과 JSON 포맷의 데이터를 파싱하는 경우이다. 이에 대해서 다음과 같은 인터페이스를 만들었다고 하면, 이는 불필요한 모든 기능을 정의한 인터페이스라고 볼 수 있다. 왜냐하면 어떤 클래스는 XML 메서드를 필요로 하지 않고 JSON으로만 구성할 수도 있다. 이러면 필요없는 xml 메서드를 제공하는 꼴이 된다. 이것은 결합력을 높이고 유연성을 떨어뜨리며 클라이언트가 필요하지도 않은 메서드를 구현하도록 한다.

인터페이스는 작을수록 좋다

앞의 인터페이스는 각각 하나의 메서드를 가진 두 개의 다른 인터페이스로 분리하는 것이 좋다. 이렇게 하더라도 EventParser가 두 개의 인터페이스를 모두 구현하면 여전히 동일한 기능을 달성할 수 있다. 왜냐하면 인터페이스나 추상 기본 클래스는 그저 몇 가지 조건이 더해진 일반 클래스이기 때문이다. 그리고 파이썬은 다중 상속을 지원한다.

이제 재사용 가능한 보다 구체적인 인터페이스를 갖게 되었고, 각각의 메소드가 별도의 인터페이스로 분리되었다.

from abc import ABCMeta, abstractmethod

#인터페이스 정의
class XMLEventParser(metaclass=ABCMeta):
  @abstractmethod
  def from_xml(xml_data: str):
    """XML 형태의 데이터를 파싱 """
#인터페이스 정의
class JSONEventParser(metaclass=ABCMeta):
  @abstractmethod
  def from_json(json_data: str):
    """JSON 형태의 데이터를 파싱 """
    
#구체 클래스
class EventParser(XMLEventParser, JSONEventParser):
  """ XML과 JSON 형태의 데이터를 파싱"""
  def from_xml(xml_data: str):
    pass
  
  def from_json(json_data: str):
    pass

인터페이스에서 정의한 추상 메서드는 구체 클래스에서 반드시 구현해야 한다는 점에 주의하자

인터페이스는 얼마나 작아야 할까?

추상 클래스이든 아니든 기본 클래스는 다른 클래스들이 확장할 수 있도록 인터페이스를 정의한다.
응집력의 관점에서 가능한 단 한 가지 일을 수행하는 작은 인터페이스여야 한다. 그렇다고 반드시 딱 한 가지 메서드만 있어야 한다는 뜻은 아니다. 앞의 예제에서는 두 메서드가 완전히 관련이 없었기 때문에 다른 클래스로 분리하는 것이 합리적이었지만, 하나 이상의 메서드라 하더라도 적절하게 하나의 클래스에 속해 있을 수 있다.

D : Dependency inversion principle (의존성 역전 원칙)

의존성 역전 원칙, DIP는 코드가 깨지거나 손상되는 취약점으로부터 보호해주는 흥미로운 디자인 원칙을 제시한다. 의존성을 역전시킨다는 것은 코드가 세부 사항이나 구체적인 구현에 적응하도록 하지 않고, 대신에 API같은 것에 적응하도록 하는 것이다.

추상화를 통해 세부 사항에 의존하지 않도록 해야 하지만, 반대로 세부 사항은 추상화에 의존해야 한다.
A와 B 두 객체가 상호 교류를 한다고 생각해보자, A는 B의 인스턴스를 사용하지만 우리가 B 모듈을 직접 관리하지 않는다. 외부 라이브러리 또는 다른 팀의 모듈 등을 사용하는 경우인 것을 생각해보자.

만약 코드가 B에 크게 의존하면 B코드가 변경되면 원래의 코드는 쉽게 깨지게 된다. 이를 방지하기 위해서 의존성을 거꾸로 뒤집어서 역전시켜야 한다. 즉 B가 A에 적응해야 한다. 이렇게 하려면 인터페이스를 개발하고 코드가 B의 구체적인 구현에 의존하지 않도록 해야 한다. 대신에 정의한 인터페이스에 의존적이도록 해야 한다. 해당 인터페이스를 준수하는 것은 B의 책임이다.

추상화는 인터페이스 형태로 제공된다. 파이썬에서는 추상 기본 클래스의 형태로 제공된다. 일반적으로 구체적인 구현이 추상 컴포넌트보다 훨씬 자주 바뀔 것인데, 이런 이유로 시스템이 변경, 수정 또는 확장될 것으로 예상되는 지점에 유연성을 확보하기 위해 추상화(인터페이스)를 사용하는 것이다.

강한 의존성을 가진 예

사용하던 예제 케이스인 이벤트 모니터링 시스템의 마지막 부분은 식별된 이벤트를 데이터 수집기로 전달하여 분석하는 것이다. 단순하게 구현해보면, 데이터를 목표지에 전송하는 이벤트 전송 클래스 Syslog를 만들면 된다.

그러나 이것은 저수준의 내용에 따라 고수준의 클래스가 변경되어야 하므로 별로 좋은 디자인이 아니다. 만야 Syslog로 데이터를 보내는 방식이 변경되면 EventStreamer를 수정해야 한다. 만약 다른 데이터에 대해서는 전송 목적지를 변경하거나 새로운 데이터를 추가하려면 stream() 메서드를 이러한 요구사항에 따라 지속적으로 수정해야 하므로 문제가 생긴다.

의존성을 거꾸로


위 문제를 해결하기 위해서 EventStreamer를 구체 클래스가 아닌 인터페이스와 대화하도록 하는 것이 좋다. 이렇게 하면 인터페이스의 구현은 세부 구현사항을 가진 저수준 클래스가 담당하게 된다.

인터페이스가 생김으로 인해서 EventStreamer는 특정 데이터 대상의 구체적인 구현과 관련이 없어졌다. 구현 내용이 바뀌어도 수정할 필요가 없다. 실제로 변화를 수용하는 것은 각 인터페이스가 담당한다.

의존성 주입

앞 예제에서 우리는 시스템 로그를 저장하는 Syslog에 의존하면 시스템이 어떻게 경직화 되는지 살펴보았고, 그래서 DataTargetClient 인터페이스를 만들고 Syslog는 그것을 구현하도록 디자인을 변경했다. 이렇게 함으로 클라이언트에게 좀 더 개방된 형태의 디자인을 제공할 수 있게 되었다. 새로운 전송지가 추가된다면 인터페이스를 따르는 새 클래스를 만들기만 하면 된다. 이번에는 자신을 필요로 하는 객체에 어떻게 의존성을 주입시키는지 살펴보려고 한다.

단순한 구현 방법 중 하나는 이벤트 처리기(EventStreamer)에서 필요한 객체(Syslog)를 직접 생성하는 것이다.

class EventStreamer:
	def __init__(self):
    	self._target = Syslog() #의존성이 있는 것을 직접 생성한 경우
    
    def stream(self, events: list[Event]) -> None:
    	for event in events:
        	self._target.send(event.serialise())

그러나 이것은 유연한 디자인이라고 할 수 없다. 만든 인터페이스를 활용하지도 않았다. 더 나은 디자인은 의존성을 주입하는 것이다. EventStreamer가 필요한 것들을 직접 관리하지 말고 제공을 받도록 만든다.

class EventStreamer:
	def __init__(self, target: DataTargetClient): # 의존성을 파라미터로 전달한 경우
    	self._target = target
    
    def stream(self, events: list[Event]) -> None:
    	for event in events:
        	self._target.send(event.serialise())

이렇게 함으로 인터페이스를 사용하고 다형성을 지원하게 되었다. 이제 초기화 시 인터페이스를 구현하는 어떤 객체도 전달할 수 있으며, 이벤트 처리기가 그런 유형을 모두 처리할 수 있다는 것을 더욱 명시적으로 표현하고 있다.

__init__메서드에서 의존성이 있는 것들을 직접 생성하지 않도록 하자. 대신에 사용자가 __init__메서드에 의존성을 파라미터로 전달하도록 하여 보다 유연하게 대응할 수 있도록 하자

만약 복잡한 초기화 과정을 가졌거나 초기화 인자가 많은 경우라면, 종속성 그래프를 만들고 관련 라이브러리가 생성을 담당하도록 하는 것이 좋은 방법이다. 즉, 객체를 연결하기 위한 글루코드에서 보일러플레이트 코드를 제거할 수 있다. 이러한 라이브러리의 예로 pinject 라이브러리가 있다.

class EventStreamer:
	def __init__(self, target: DataTargetClient): # 의존성을 파라미터로 전달한 경우
    	self._target = target
    
    def stream(self, events: list[Event]) -> None:
    	for event in events:
        	self._target.send(event.serialise())

class _EventStreamerBindingSpec(pinject.BindingSpec):
	def provide_target(self):
    	return Syslog()

object_graph = pinject.new_object_graph(
	binding_specs=[_EventStreamerBindingSpec()])

글루코드(glue code) 는 프로그램의 기본 동작과는 관련이 없지만 프로그램 구성 요소 간의 호환성을 위해 접착제 역할을 하는 코드이다.

의존성 주입이 필요한 클래스에 의존성을 어떻게 주입할지 결정하는 바인딩 스펙 객체를 정의할 수 있다. 바인딩 스펙 객체에서 provide_<dependency> 형태의 모든 메서드는 해당 <dependency>와 같은 이름의 변수에 대해 의존성을 반환한다.

마무리

SOLID 원칙은 객체 지향 소프트웨어 설계의 핵심이다. 하지만 SOLID원칙이 마법의 주문이 아니다. 결국 성공할 가능성이 높은 소프트웨어를 만들도록 도와줄 뿐 답안지는 아니다.

좋은 디자인을 위해 중요한 것은 처음 디자인 설계를 어떻게 하는지가 갈림길이라 생각한다. 초기 디자인을 위해 SOLID 원칙과 같은 기본적인 원칙들을 염두에 두고 구현과 디자인 설계를 해야겠다.

profile
안녕하세요 박찬영입니다.

0개의 댓글