SOLID 원칙을 파이썬스러운 방식으로 구현하는 방법을 설명한다.
- S : 단일 책임 원칙
- O : 개방/폐쇄 원칙
- L : 리스코프 치환 원칙
- I : 인터페이스 분리 원칙
- D : 의존성 역전 원칙
1. 단일 책임 원칙
단일 책임 원칙(Single Responsibility Principle - SRP) 은 소프트웨어 컴포넌트(일반적으로 클래스)가 단 하나의 책임을 져야 한다는 원칙이다. 클래스가 유일한 책임이 있다는 것은 하나의 구체적인 일을 담당한다는 것을 의미하며, 따라서 변화해야 할 이유는 단 하나뿐이다.
도메인의 문제가 변경되면 클래스를 업데이트 해야 한다. 다른 이유로 클래스를 수정한다면 추상화가 잘못되어 클래스에 너무 많은 책임이 있다는 것을 뜻한다.
필요 이상의 책임을 지거나 너무 많은 것을 알고 있는 객체를 신(god) 객체라고 하는데, 유지보수가 어렵다.
다시 말해서 클래스는 작을수록 좋다.
1-1. 너무 많은 책임을 가진 클래스
로그 파일이나 데이터베이스와 같은 소스에서 이벤트 정보를 읽어서 로그 별로 필요한 액션을 분류하는 애플리케이션을 예로 들어본다.
class SystemMonitor:
def load_activity(self):
"""소스에서 처리할 이벤트를 가져오기"""
def identify_events(self):
"""가져온 데이터를 파싱하여 도메인 객체 이벤트로 변환"""
def stream_events(self):
"""파싱한 이벤트를 외부 에이전트로 전송"""
위 클래스의 문제점은 독립적인 동작을 하는 메서드를 하나의 인터페이스에 정의했다는 것이다. 각각의 동작은 나머지 부분과 독립적으로 수행할 수 있다. 이는 유지보수를 어렵게 하여 클래스가 경직되고 오류가 발생하기 쉽게 만든다.
1-2. 책임 분산
클래스를 보다 쉽게 관리하기 위해 모든 메서드를 다른 클래스로 분리하여 각 클래스마다 단일 책임을 갖게 해야 한다.
class ActivityReader:
def load(self):
class SystemMonitor:
def identify_events(self):
class Ouput:
def stream(self):
class AlertSystem:
def run(self):
ActivityReader().load()
SystemMonitor().identify_events()
Output().stream()
2. 개방/폐쇄 원칙
개방/폐쇄 원칙(Open/Close Principle)은 모듈이 개방되어 있으면서도 폐쇄되어야 한다는 원칙이다.
클래스를 디자인할 때는 유지보수가 쉽도록 로직을 캡슐화하여 확장에는 개방되고 수정에는 폐쇄되도록 해야 한다.
간단히 말해서 확장 가능하고, 새로운 요구사항이나 도메인 변화에 잘 적응하는 코드를 작성해야 한다는 뜻이다. 새로운 문제가 발생할 경우 추가만 할 뿐 기존 코드는 그대로 유지해야 한다.
새로운 기능을 추가하다가 기존 코드를 수정했다면 기존 로직이 잘못 디자인되었다는 것을 뜻한다.
2-1. 개방/폐쇄 원칙을 따르지 않을 경우 유지보수의 어려움
다른 시스템에서 발생하는 이벤트를 분류하는 기능을 가진 클래스가 있다. 각 컴포넌트는 수집한 데이터를 기반으로 어떤 타입의 이벤트인지 정확히 분류해야 한다.
class Event:
def __init__(self, raw_data):
self.raw_data = raw_data
class UnknownEvent(Event):
"""데이터만으로 식별할 수 없는 이벤트"""
class LoginEvent(Event):
"""로그인 사용자에 의한 이벤트"""
class LogoutEvent(Event):
"""로그아웃 사용자에 대한 이벤트"""
class SystemMonitor:
"""시스템에서 발생한 이벤트 분류"""
def __init__(self, event_data):
self.event_data = event_data
def identify_event(self):
if (
self.event_data["before"]["session"] == 0
and self.event_data["after"]["session"] == 1
):
return LoginEvent(self.event_data)
elif (
self.event_data["before"]["session"] == 1
and self.event_data["after"]["session"] == 0
):
return LogoutEvent(self.event_data)
return UnknownEvent(self.event_data)
>>> l1 = SystemMonitor({"before": {"session": 0}, "after": {"session": 1}})
>>> l1.identify_event().__class__.__name__
'LoginEvent'
>>> l2 = SystemMonitor({"before": {"session": 1}, "after": {"session": 0}})
>>> l2.identify_event().__class__.__name__
'LogoutEvent'
>>> l3 = SystemMonitor({"before": {"session": 1}, "after": {"session": 1}})
>>> l3.identify_event().__class__.__name__
'UnknownEvent'
이 디자인에는 몇 가지 문제점이 있다. 첫 번째는 이벤트 유형을 결정하는 논리가 중앙 집중화된다는 점이다. 지원하려는 이벤트가 늘어날수록 메서드도 커질 것이므로 결국 매우 큰 메서드가 될 수 있다. 한 가지 일만 하는 것도 아니고 한 가지 일을 제대로 하지도 못한다. 또한 수정을 위해 닫히지 않았다는 것을 알 수 있다. 새로운 유형의 이벤트를 시스템에 추가할 때마다 메서드를 수정해야 한다.
2-2. 확장성을 가진 이벤트 시스템으로 리팩토링
이전 예제의 문제점은 SystemMonitor 클래스가 분류하려는 구체 클래스와 직접 상호 작용한다는 점이다. 개방/폐쇄 원칙을 따르는 디자인을 달성하려면 추상화를 해야 한다.
SystemMonitor 클래스를 추상적인 이벤트와 협력하도록 변경하고, 이벤트에 대응하는 개별 로직은 각 이벤트 클래스에 위임하는 것이다.
그런 다음 각각의 이벤트에 다형성을 가진 새로운 메서드를 추가해야 한다. 이 메서드는 전달되는 데이터가 해당 클래스의 타입과 일치하는지 판단하는 역할을 한다. 또한 기존 분류 로직을 수정하여 이 메서드를 사용해 전체 이벤트를 돌면서 검사해야 한다.
새 코드는 다음과 같다.
class Event:
def __init__(self, raw_data):
self.raw_data = raw_data
@staticmethod
def meets_condition(event_data: dict):
return False
class UnknownEvent(Event):
"""데이터만으로 식별할 수 없는 이벤트"""
class LoginEvent(Event):
@staticmethod
def meets_condition(event_data: dict):
return (
event_data["before"]["session"] == 0
and event_data["after"]["session"] == 1
)
class LogoutEvent(Event):
@staticmethod
def meets_condition(event_data: dict):
return (
event_data["before"]["session"] == 1
and event_data["after"]["session"] == 0
)
class SystemMonitor:
"""시스템에서 발생한 이벤트 분류"""
def __init__(self, event_data):
self.event_data = event_data
def identify_event(self):
for event_cls in Event.__subclasses__():
try:
if event_cls.meets_condition(self.event_data):
return event_cls(self.event_data)
except KeyError:
continue
return UnknownEvent(self.event_data)
이제 상호 작용이 추상화를 통해 이뤄지고 있음에 주목하자. 분류 메서드는 이제 특정 이벤트 타입 대신에 일반적인 인터페이스를 따르는 제네릭 이벤트와 동작한다. 이 인터페이스를 따르는 제네릭들은 모두 meets_condition 메서드를 구현하여 다형성을 보장한다.
__subclasses__() 메서드를 사용해 이벤트 유형을 찾는 것에 주목하자. 이제 새로운 유형의 이벤트를 지원하려면 단지 Event 클래스를 상속받아 비즈니스 로직에 따라 meets_condition() 메서드를 구현하기만 하면 된다.
2-3. 이벤트 시스템 확장
위의 개선된 디자인이 실제로 원하는 대로 확장 가능한지 증명해본다. 새로운 요구사항이 생겨서 모니터링 중인 시스템의 사용자 트랜잭션에 대응하는 이벤트를 지원해야 한다고 가정한다.
TransactionEvent 라는 새로운 클래스를 추가하는 것만으로 기존 코드가 예상한 대로 잘 작동한다.
class Event:
def __init__(self, raw_data):
self.raw_data = raw_data
@staticmethod
def meets_condition(event_data: dict):
return False
class UnknownEvent(Event):
"""데이터만으로 식별할 수 없는 이벤트"""
class LoginEvent(Event):
@staticmethod
def meets_condition(event_data: dict):
return (
event_data["before"]["session"] == 0
and event_data["after"]["session"] == 1
)
class LogoutEvent(Event):
@staticmethod
def meets_condition(event_data: dict):
return (
event_data["before"]["session"] == 1
and event_data["after"]["session"] == 0
)
class TransactionEvent(Event):
"""시스템에서 발생한 트랜잭션 이벤트"""
@staticmethod
def meets_condition(event_data: dict):
return event_data["after"].get("transaction") is not None
class SystemMonitor:
"""시스템에서 발생한 이벤트 분류"""
def __init__(self, event_data):
self.event_data = event_data
def identify_event(self):
for event_cls in Event.__subclasses__():
try:
if event_cls.meets_condition(self.event_data):
return event_cls(self.event_data)
except KeyError:
continue
return UnknownEvent(self.event_data)
>>> l1 = SystemMonitor({"before": {"session": 0}, "after": {"session": 1}})
>>> l1.identify_event().__class__.__name__
'LoginEvent'
>>> l2 = SystemMonitor({"before": {"session": 1}, "after": {"session": 0}})
>>> l2.identify_event().__class__.__name__
'LogoutEvent'
>>> l3 = SystemMonitor({"before": {"session": 1}, "after": {"session": 1}})
>>> l3.identify_event().__class__.__name__
'UnknownEvent'
>>> l4 = SystemMonitor({"after": {"transaction": "Tx001"}})
>>> l4.identify_event().__class__.__name__
'TransactionEvent'
새 이벤트를 추가했지만 SystemMonitor.identify_event() 메서드는 전혀 수정하지 않았다. 따라서 이 메서드가 새로운 유형의 이벤트에 대해서 폐쇄되어 있다고 말할 수 있다.
반대로 Event 클래스는 필요할 때마다 추가할 수 있다. 이에 따라 이벤트는 새로운 타입의 확장에 개방되어 있다고 말할 수 있다.
2-4. OCP 최종 정리
OCP 는 다형성의 효과적인 사용과 밀접하게 관련되어 있다. 다형성을 따르는 형태의 계약을 만들고 모델을 쉽게 확장할 수 있는 일반적인 구조로 디자인하는 것이다.
이 원칙은 유지보수성에 대한 문제를 해결한다. OCP 를 따르지 않으면 파급 효과가 생기거나 작은 변경이 코드 전체에 영향을 미치거나 다른 부분을 손상시키게 된다.
마지막으로 코드를 변경하지 않고 기능을 확장하기 위해서는 보호하려는 추상화(예제에서는 새로운 이벤트 유형)에 대해서 적절하게 폐쇄를 해야 한다는 것이다. 그러나 항상 적용 가능한 것은 아니다. 특정 요구 사항에 대해 적절한 추상화가 다른 유형의 요구사항에 대해서는 그렇지 않을 수 있다. 이러한 경우는 가장 확장 가능한 요구사항에 적합한 폐쇄를 선택해야 한다.
3. 리스코프 치환 원칙(LSP)
리스코프 치환 원칙(Liskov Subsititution Principle, LSP) 은 설계 시 안정성을 유지하기 위해 객체 타입이 유지해야 하는 일련의 특성을 말한다.
LSP 는 어떤 클래스에서든 클라이언트는 특별한 주의를 기울이지 않고도 하위 타입을 사용할 수 있어야 함을 뜻한다. 즉, 클라이언트는 완전히 분리되어 있으며 클래스 변경 사항과 독립되어야 한다.
공식적인 정의는 다음과 같다.
만약 S가 T의 하위 타입이라면 프로그램을 변경하지 않고 T 타입의 객체를 S 타입의 객체로 치환 가능해야 한다.
3-1. 도구를 통해 LSP 문제 검사하기
LSP 문제를 Mypy나 Pylint 같은 도구를 사용해 쉽게 검출할 수 있다.
# 메서드 서명의 잘못된 데이터 타입 검사
코드 전체에 타입 어노테이션을 사용하고, Mypy를 설정했다면 기본 오류 여부와 LSP 준수 여부를 빠르게 확인할 수 있다.
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)
class LogoutEvent(Event):
def meets_condition(self, event_data: dict, override: bool) -> bool:
if override:
return True
...
이 파일에 대해 Mypy를 실행하면 다음과 같은 오류 메시지가 표시된다.
error: Argument 1 of "meets_condition" incompatible with supertype "Event"
파생 클래스가 부모 클래스에서 정의한 파라미터와 다른 타입을 사용했기 때문에 다르게 동작한다. 이 원칙에 따르면 호출자는 아무런 차이를 느끼지 않고 Event 또는 LogoutEvent 를 사용할 수 있어야 한다.
공통 인터페이스를 공유하는 경우 참고 사항이 있다. 예를 들어 사전과 리스트는 모두 이터러블 타입이다. 즉 어떤 경우에는 이터러블 인터페이스를 통해 파라미터를 처리한다면 사전이나 리스트 중 아무것이나 받아도 상관이 없을 수 있다. 논리 자체에는 문제가 없지만, 실제 구현 내용에서 문제가 발생할 수도 있다. 이런 경우 메서드의 코드를 바꾸거나 전체 디자인을 바꾸거나 타입 어노테이션이라도 바꿔야 한다.
# type : ignore 같은 주석을 통해 에러를 무시하면 안 된다. 포매팅 도구를 통해 보고된 내용은 모두 수정이 필요한 타당한 이유를 갖고 있는 내용이기 때문이다.
# Pylint 를 활용한 서명 검사
LSP 위반 사례 중에 계층의 파라미터 타입이 다른 것이 아니라 메서드의 서명 자체가 완전히 다른 경우가 있다. 파이썬은 인터프리터 언어이므로 초기에 컴파일러를 사용해 이러한 유형의 오류를 감지하지 못했다면 런타임까지 발견되지 않는다. Mypy 나 Pylint 같은 정적 코드 분석기를 통해 오류를 잡을 수 있다.
class LogoutEvent(Event):
def meets_condition(self, event_data: dict, override: bool) -> bool:
if override:
return True
...
Parameters differ from overridden 'meets_condition' method (arguments-differ)
앞서 봤던 예제를 Pylint 를 통해 검사한 결과 위와 같이 계층 구조의 호환성을 깨는 부분을 감지하여 정보를 출력한다.
3-2. 애매한 LSP 위반 사례
어떤 경우에는 LSP 를 위반한 것이 명확하지 않아서 자동화된 도구로 검사하기 애매할 수 있다.
DbC 에서 계약이 수정되는 경우는 특히 자동으로 감지하기가 더 어렵다. 부모 클래스는 클라이언트와의 계약을 정의한다. 하위 클래스는 그 계약을 따라야 한다. 예를 들면 아래와 같은 조건을 따라야 한다고 해보자.
- 하위 클래스는 부모 클래스에 정의된 것보다 사전조건을 엄격하게 만들면 안된다.
- 하위 클래스는 부모 클래스에 정의된 것보다 약한 사후조건을 만들면 안된다.
class Event:
def __init__(self, raw_data):
self.raw_data = raw_data
@staticmethod
def meets_condition(event_data: dict):
return False
@staticmethod
def meets_condition_pre(event_data: dict):
"""인터페이스 계약의 사전조건
``event_data`` 파라미터가 적절한 형태인지 유효성 검사
"""
assert isinstance(event_data, dict), f"{event_data!r} is not a dict"
for moment in ("before", "after"):
assert moment in event_data, f"{moment} not in {event_data}"
assert isinstance(event_data[moment], dict)
이번 예제는 사전조건에서 파라미터가 사전 타입인지, 그리고 "before", "after" 키를 가지고 있는지 확인한다. "before", "after" 키의 값은 또다시 객체를 내포해야 한다. 이렇게 하면 클라이언트는 KeyError를 받지 않으므로 보다 발전된 캡슐화를 할 수 있다. 사전조건 체크 메서드만 호출하면 되기 때문이다.
사전조건 검증에 실패한 경우 시스템 실패로 처리해도 무방하다고 가정한다. 이제 SystemMonitor 는 더 이상 협력하는 클래스에서 어떤 예외를 발생시키는지 몰라도 상관없다.
예외는 캡슐화를 약화시킨다. 예외 처리를 하려면 호출하는 객체에 대한 부가적인 정보가 필요하기 때문이다.
이제 올바른 이벤트 유형을 확인하기 전에 사전조건을 먼저 검사한다.
class SystemMonitor:
"""시스템에서 발생한 이벤트 분류"""
def __init__(self, event_data):
self.event_data = event_data
def identify_event(self):
Event.meets_condition_pre(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" 가 필수이고, 그 값 또한 사전 타입이어야 한다고만 명시되어 있다. 하위 클래스에서 보다 제한적인 파라미터를 요구하는 경우 검사에 통과하지 못한다.
앞서 만든 트랜잭션 이벤트 클래스는 올바르게 설계되었다. "transaction" 이라는 키에 제한을 두지 않고 사용하고 있다. 그 값이 있을 경우에만 사용하고 필수로 꼭 필요한 것은 아니다.
class TransactionEvent(Event):
"""시스템에서 발생한 트랜잭션 이벤트"""
@staticmethod
def meets_condition(event_data: dict):
return event_data["after"].get("transaction") is not None
그러나 LoginEvent 와 LogoutEvent 클래스는 before, after 의 "session" 이라는 키를 사용하기 때문에 그대로 사용할 수 없다. 이렇게 되면 KeyError 가 발생하기 때문에 나머지 클래스를 사용하는 것과 같은 방식으로 클래스를 사용할 수 없다.
이 문제는 TransactionEvent 와 마찬가지로 대괄호 대신 .get() 메서드를 사용하여 해결할 수 있다.
class LoginEvent(Event):
@staticmethod
def meets_condition(event_data: dict):
return (
event_data["before"].get("session") == 0
and event_data["after"].get("session") == 1
)
class LogoutEvent(Event):
@staticmethod
def meets_condition(event_data: dict):
return (
event_data["before"].get("session") == 1
and event_data["after"].get("session") == 0
)
이제 LSP를 사용한 계약이 다시 성립하고 다형성을 활용할 수 있다.
>>> l1 = SystemMonitor({"before": {"session": 0}, "after": {"session": 1}})
>>> l1.identify_event().__class__.__name__
'LoginEvent'
>>> l2 = SystemMonitor({"before": {"session": 1}, "after": {"session": 0}})
>>> l2.identify_event().__class__.__name__
'LogoutEvent'
>>> l3 = SystemMonitor({"before": {"session": 1}, "after": {"session": 1}})
>>> l3.identify_event().__class__.__name__
'UnknownEvent'
>>> l4 = SystemMonitor({"before": {}, "after": {"transaction": "Tx001"}})
>>> l4.identify_event().__class__.__name__
'TransactionEvent'
자동화된 도구가 이런 부분까지 검출해주기를 기대하는 것은 다시 무리가 있다. 클래스 디자인을 할 때는 실수로 메서드의 입력과 출력을 변경해서 원래 기대한 것과 달라지지 않도록 주의해야 한다.
3-3. LSP 최종 정리
LSP 는 객체지향 소프트웨어 설계의 핵심이 되는 다형성을 강조하기 때문에 좋은 디자인의 기초가 된다. 인터페이스의 메서드가 올바른 계층구조를 갖도록 하여 상속된 클래스가 부모 클래스와 다형성을 유지하도록 하는 것이다.
이는 앞선 OCP 와도 관련되어 있다. 새로운 클래스가 원래의 계약과 호환되지 않는 확장을 하려고 하면 클라이언트와의 계약이 깨져서 결과적으로 그러한 확장이 불가능하다. 또는 확장을 가능하게 하려면 수정에 대해 폐쇄되어야 한다는 원칙을 깨야 한다.
LSP 에서 제안하는 방식으로 신중하게 클래스를 디자인하면 계층을 올바르게 확장하는데 도움이 된다. 즉 LSP 가 OCP에 기여한다고 볼 수 있다.
4. 인터페이스 분리 원칙
인터페이스 분리 원칙(Interface Segregation Principle, ISP) 은 작은 인터페이스" 에 대한 가이드라인을 제공한다.
객체 지향적인 용어로 인터페이스는 객체가 노출하는 메서드의 집합이다. 이는 다른 클라이언트에서 호출할 수 있느 요청들이다. 인터페이스는 클래스에 노출된 동작의 정의와 구현을 분리한다.
파이썬에서 인터페이스는 클래스 메서드의 형태를 보고 암시적으로 정의된다. 파이썬이 덕 타이핑(duck typing)을 따르기 때문이다.
덕 타이핑(duck typing) 은 모든 객체가 자신이 가지고 있는 메서드와 자신이 할 수 있는 일에 의해서 표현된다는 점에서 출발한다. 즉 클래스의 유형, 이름, docstring, 클래스 속성 또는 인스턴스 속성에 관계없이 객체의 본질을 정의하는 것은 궁극적으로 메서드의 형태이다.
어떤 새가 오리처럼 걷고 오리처럼 꽥꽥 소리를 낸다면 오리여야만 한다.
파이썬 3(PEP-3119) 에서 인터페이스를 덕 타이핑이 아닌 다른 방식으로 정의하는 추상 기본 클래스 개념을 도입했다.
추상 기본 클래스는 파생 클래스가 구현해야 할 일부분을 기본 동작 또는 인터페이스로 정의하는 것이다. 이는 특정 중요 메서드가 실제로 재정의 되었는지 확인이 필요할 때 유용하며 isinstance() 와 같은 메서드의 기능을 재정의 하거나 확장하는 메커니즘으로도 작동한다.
또한 이 모듈에는 가상 하위 클래스(virtual subclass) 라는 타입을 계층구조에 등록하는 기법이 포함되어 있다. 이것은 오리의 새로운 기준을 추가함으로써 덕 타이핑의 개념을 조금 더 확장하는 것이다.
파이썬이 인터페이스를 어떻게 해석하는지에 대한 이러한 개념은 ISP 를 이해하는데 중요하다. 다중 메서드를 가진 인터페이스가 있다면 매우 정확하고 구체적인 구분에 따라 더 적은 수의 메서드(가급적이면 단 하나)를 가진 여러 개의 메서드로 분할하는 것이 좋다는 것이다.
재사용성을 높이기 위해 가능한 작은 단위로 인터페이스를 분리한다면 인터페이스 중 하나를 구현하려는 각 클래스가 매우 명확한 동작과 책임을 지니기 때문에 응집력이 높아진다.
4-1. 너무 많은 일을 하는 인터페이스
class EventParser:
def from_xml(self):
def from_json(self):
어떤 클래스는 XML 메서드를 필요로 하지 않고 JSON 으로만 구성할 수 있음에도 여전히 인터페이스에서는 필요하지 않은 from_xml() 메서드를 제공한다. 이는 결합력을 매우 높여 유연성을 떨어뜨리며 클라이언트가 필요하지도 않은 메서드를 구현하도록 한다.
4-2. 인터페이스는 작을수록 좋다.
위의 인터페이스는 각각 하나의 메서드를 가진 두 개의 다른 인터페이스로 분리하는 것이 좋다.
class XMLEventParser:
def from_xml(self):
class JSONEventParser:
def from_json(self):
각 인터페이스 간의 독립성을 유지할 수 있고, 새로운 작은 객체를 사용해 모든 기능을 유연하게 조합할 수 있게 되었다.
SRP 와 유사하지만 ISP 는 인터페이스에 대해 이야기 한다. 이것은 행동의 추상화라고 볼 수 있다. 이 원칙을 준수하지 않으면 별개의 기능이 결합된 인터페이스를 만들게 되고, 상속된 클래스는 SRP 또한 준수할 수 없게 된다.
4-3. 인터페이스는 얼마나 작아야 하는가
추상 클래스이든 아니든 기본 클래스는 다른 클래스들이 확장할 수 있도록 인터페이스를 정의한다. 응집력의 관점에서 가능한 단 한 가지 일을 수행하는 작은 인터페이스여야 한다. 그러나 하나 이상의 메서드라 하더라도 적절하게 하나의 클래스에 속해 있을 수 있다. 예를 들어 컨텍스트 관리자는 __enter__ 와 __exit__ 두 가지 메서드를 필요로 한다. 이들이 반드시 함께 제공되어야 한다. 그렇지 않으면 유효한 컨텍스트 관리자가 아니기 때문이다.
5. 의존성 역전
의존성 역전 원칙(DIP)은 코드가 깨지거나 손상되는 취약점으로부터 보호해주는 원칙을 제시한다. 의존성을 역전시킨다는 것은 코드가 세부 사항이나 구체적인 구현에 적응하도록 하지 않고, 대신에 API 같은 것에 적응하도록 하는 것이다.
추상화를 통해 세부 사항에 의존하지 않도록 해야 하지만, 반대로 세부 사항은 추상화에 의존해야 한다.
A, B 두 객체가 상호 교류 한다고 가정해본다. A는 B의 인스턴스를 사용하지만 우리가 B 모듈을 직접 관리하지는 않는다. 외부 라이브러리 또는 다른 팀의 모듈 등을 사용하는 경우이다. 만약 코드가 B에 크게 의존하면 B코드가 변경되면 원래의 코드는 쉽게 깨지게 된다. 이를 방지하기 위해서 의존성을 뒤집어서 역전시켜야 한다.
즉 B가 A에 적응해야 한다.
이렇게 하려면 인터페이스를 개발하고 코드가 B 의 구체적인 구현에 의존하지 않도록 해야 한다. 대신에 정의한 인터페이스에 의존적이도록 해야 한다. 해당 인터페이스를 준수하는 것은 B 의 책임이다.
일반적으로 구체적인 구현이 추상 컴포넌트보다 더 자주 바뀐다. 이런 이유로 시스템이 변경, 수정 또는 확장될 것으로 예상되는 지점에 유연성을 확보하기 위해 추상화를 하는 것이다.
5-1. 엄격한 의존의 예
이벤트의 모니터링 시스템의 마지막 부분은 식별된 이벤트를 데이터 수집기로 전달하여 분석하는 것이었다. 단순하게 구현해보면 데이터를 목표지에 전송하는 이벤트 전송 클래스 Syslog 를 만들면 된다.
class EventStreamer:
def __init__(self, parsed_data: str, client: Syslog):
self.parsed_data = parsed_data
self.client = client
def stream(self):
self.client.send(self.parsed_data)
class Syslog:
def send(data: str):
pass
그러나 이는 저수준의 내용(Syslog)에 따라 고수준의 클래스(EventStreamer)가 변경되어야 하므로 별로 좋은 디자인이 아니다. 만약 Syslog 로 데이터를 보내는 방식이 변경되면 EventStreamer를 수정해야 한다.
5-2. 의존성을 거꾸로
이러한 문제를 해결하려면 EventStreamer를 구체 클래스가 아닌 인터페이스와 대화하도록 하는 것이 좋다. 이렇게 하면 인터페이스의 구현은 세부 구현사항을 가진 저수준 클래스가 담당하게 된다.
from abc import ABCMeta, abstractmethod
class EventStreamer:
def __init__(self, parsed_data: str, client: DataTargetClient):
self.parsed_data = parsed_data
self.client = client
def stream(self):
self.client.send(self.parsed_data)
class DataTargetClient(metaclass=ABCMeta):
@abstractmethod
def send(self, data: str):
pass
class Syslog(DataTargetClient):
def send(data: str):
pass
위와 같이 데이터를 전송할 대상을 나타내는 인터페이스를 만들었다. EventStreamer는 특정 데이터 대상의 구체적인 구현과 관련이 없어졌다. 구현 내용이 바뀌어도 수정할 필요가 없다.
첫 번째 EventStreamer 구현은 Syslog 객체와만 동작했기 때문에 유연성이 떨어진다. 그렇지만 .send() 메서드를 구현하는 객체라면 어떤 것과도 통신할 수 있기 때문에 이것을 인터페이스의 메서드로 사용하였다. Syslog는 send() 메서드가 정의된 DataTargetClient 추상 기본 클래스를 확장한다.
따라서 DataTargetClient 를 확장하는 새로운 유형의 데이터 대상이 추가되어도 send() 메서드의 구현은 모두 새로운 클래스에 달려있게 된다.
심지어 런타임 중에도 send() 메서드를 구현한 객체의 프로퍼티를 수정해도 여전히 잘 동작한다. 이렇게 의존성을 동적으로 제공한다고 하여 의존성 주입(dependency injection) 이라고 한다.
파이썬은 동적인 타입의 언어이므로 인터페이스를 사용하지 않고도 EventStreamer 에 특정 데이터 대상 객체를 제공할 수 있다. 그러나 추상 기본 클래스를 사용하는 것은 좋은 습관이다.
덕 타이핑이 가능하면 모델의 가독성이 높아진다. 상속은 is a 관계이다. 따라서 추상 기본 클래스를 선언하고 확장함으로써 Syslog는 DataTargetClient 라고 말할 수 있다. 즉 코드 사용자는 코드를 읽고 이해할 수 있고 이것이 바로 덕 타이핑이다.
is a 는 객체 지향 프로그래밍에서 객체 간의 관계를 표현할 때 사용하는 용어이다. "The apple is a fruit" 처럼 is a 를 사용해 표현이 가능하다면 apple 과 fruit 은 상속 관계라는 뜻이다. apple 이 fruit 을 상속했으므로 "사과는 과일이다" 라고 말할 수 있다. 다만 그 반대는 성립하지 않는다.
결국 추상 기본 클래스를 사용하는 것이 필수는 아니지만 파이썬이 너무 유연하여 자주 발생하는 실수를 줄이는 데 매우 유용하다고 볼 수 있다.
'python' 카테고리의 다른 글
파이썬 클린 코드 - 6장 (디스크립터로 더 멋진 객체 만들기) (0) | 2022.03.03 |
---|---|
파이썬 클린 코드 - 5장 (데코레이터를 사용한 코드 개선) (0) | 2022.02.18 |
파이썬 클린 코드 - 3장 (좋은 코드의 일반적인 특징) (0) | 2022.02.03 |
파이썬 클린 코드 - 2장 (Pythonic code) (0) | 2022.01.27 |
파이썬 클린 코드 - 1장 (코드 포매팅과 도구) (0) | 2022.01.25 |