마리아노 아나야의 파이썬 클린코드를 읽고 정리한 포스트입니다.
주제
- 소프트웨어 디자인에서 SOLID 원칙을 익힌다.
- 단일 책임 원칙을 따르는 컴포넌트를 디자인한다.
- 개발/폐쇄의 원칙을 통해 유지보수성을 뛰어나게 한다.
- 리스코프 치환 원칙을 준수하여 객체지향 디자인에서 적절한 클래스 계층을 설계한다.
- 인터페이스 분리와 의존성 역전을 활용해 설계한다.
SOLID
솔리드(SOLID) 원칙은 객체 지향 프로그래밍과 설계의 다섯 가지 기본 원칙을 말한다. 이 원칙들은 로버트 C. 마틴이 2000년대 초반에 명명했다.
- S: 단일 책임 원칙(Single Responsibility Principle)
- O: 개방-폐쇄 원칙(Open/Closed Principle)
- L: 리스코프 치환 원칙(Liskov Substitution Principle)
- I: 인터페이스 분리 원칙(Interface Segregation Principle)
- D: 의존 역전 원칙(Dependency Inversion Principle)
단일 책임 원칙
단일 책임 원칙(SRP)은 클래스는 단 하나의 책임을 가져야 한다는 원칙이다. 클래스가 유일한 책임이 있다는 것은 하나의 구체적인 일을 담당하는 것을 의미하며 따라서 변경이 필요한 이유도 단 하나만 있어야 한다.
다시 말해 클래스는 작을수록 좋다. 소프트웨어 디자인에서 SRP(Single Responsibility Principle)는 응집력과 밀접한 관련이 있다. 여기서 추구하려는 것은 클래스에 있는 프로퍼티와 속성이 항상 메서드를 통해서 사용되도록 하는 것이다. 이렇게 하면 이들은 관련된 개념이기 때문에 동일한 추상화로 묶는 것이 가능하다.
너무 많은 책임을 가진 클래스
다음 코드는 로그 파일이나 데이터베이스와 같은 소스에서 이벤트의 정보를 읽어서 로그별로 필요한 액션을 분류하는 애플리케이션을 만들어볼 것이다.
다음은 SRP를 준수하지 않은 디자인이다.
class SystemMonitor:
def load_activities(self):
"""소스에서 처리할 이벤트를 가져오기"""
def identify_events(self):
"""가져온 데이터를 파싱하여 도메인 객체 이벤트로 변환하기"""
def stream_events(self):
"""파싱한 이벤트를 외부 에이전트로 전송하기"""
이 클래스의 문제점은 독립적인 동작을 하는 메서드를 하나의 인터페이스에 정의했다는 것이다. 이 디자인 결함은 유지보수를 어렵게 하여 클래스가 경직되고 융통성이 없으며 오류가 발생하기 쉽게 만든다. 각 메서드는 클래스의 책임을 대표한다. 각각의 책임마다 수정 사유가 발생한다. 즉 메서드마다 다양한 변경의 필요성이 생기게 된다.
특정 소스에서 정보를 가져오는 로더(loader) 메서드를 생각해보자. 데이터를 소스에 연결하고, 데이터를 로드하고, 예상된 형식으로 파싱하는 등의 작업이다. 만약 데이터 구조를 바꾸는 등의 이유로 이 중에 어떤 것이라도 수정해야 한다면 SystemMonitor 클래스를 변경해야 한다. 데이터의 표현이 변경되었다고 해서 시스템 모니터링 객체를 변경해서는 안된다.
외부 요소에 의한 영향력을 최소화 하고 싶을 때 해결책은 보다 작고 응집력 있는 추상화를 하는 것이다.
책임 분산
솔루션을 관리하기 쉽도록 모든 메서드를 다른 클래스로 분리하여 각 클래스마다 단일 책임을 갖게 하자.
class EventLoader:
def load(self):
"""소스에서 처리할 이벤트를 가져오기"""
class EventParser:
def parse(self):
"""가져온 데이터를 파싱하여 도메인 객체 이벤트로 변환하기"""
class EventStreamer:
def stream(self):
"""파싱한 이벤트를 외부 에이전트로 전송하기"""
이렇게 하면 각 클래스는 하나의 책임만 갖게 되고, 변경이 필요한 이유도 하나만 생기게 된다. 각각의 클래스는 나머지와 독립적인 특정한 메서드를 캡슐화한 상태이므로 수정이 필요한 경우에도 나머지 객체에는 영향을 미치지 않는다.
어떤 클래스가 있는데 너무 많은 기능을 가지고 있다면, 기능이 계속 확장될 것이라고 예상할 수 있다. 이런 경우에는 클래스를 분리하는 것이 좋다. 문제는 책임을 분리하는 경계선을 어디로 하느냐이다. 이런 경우 내부 협업이 어떻게 이뤄지고 있는지, 책임은 어떻게 분산되고 있는지 이해하기 위해 단일 모놀리식(monolithic) 클래스를 먼저 만들어 보면서 시작할 수 있다. 이렇게 하면 추상화를 어떻게 해야 하는지 보다 명확하게 파악하는 데 도움이 된다.
개방/폐쇄 원칙(OCP)
개방/폐쇄 원칙(Open/Closed Principle)은 모듈이 개방되어 있으면서도 폐쇄되어야 한다는 원칙이다.
클래스를 디자인할 때는 유지보수가 쉽도록 로직을 캡슐화하여 확장에는 개방되고 수정에는 폐쇄되도록 해야 한다.
간단히 말해서 확장 가능하고, 새로운 요구사항이나 도메인 변화에 잘 적응하는 코드를 작성해야 한다는 뜻이다. 즉 새로운 문제가 발생할 경우 새로운 것을 추가만 할 뿐 기존 코드는 그대로 유지해야 한다는 뜻이다.
새로운 기능을 추가하다가 기존 코드를 수정했다면 그것은 기존 로직이 잘못 디자인되었다는 것을 뜻한다. 이상적으로는 요구사항이 변경되면 새로운 기능을 구현하기 위해 기존 모듈을 확장하되 기존 코드는 수정을 하면 안 된다.
개방/폐쇄 원칙을 따르지 않을 경우 유지보수의 어려움
다음 코드는 다른 시스템에서 발생하는 이벤트를 분류하는 기능을 가지고 있다. 각 컴포넌트는 수집한 데이터를 기반으로 어떤 타입의 이벤트인지 정확히 분류를 해야 한다. 단순함을 위해 데이터는 사전 형태로 저장되어 있고 로그나 쿼리 등의 방법으로 이미 데이터를 수집했다고 가정한다.
Event 인터페이스를 상속받은 하위 클래스와 해당 Event를 참조하는 SystemMonitor 클래스를 볼 수 있다.
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, raw_data) -> Event:
"""이벤트 유형을 식별한다."""
event_type = raw_data["type"]
if event_type == "login":
return LoginEvent(raw_data)
elif event_type == "logout":
return LogoutEvent(raw_data)
else:
return UnknownEvent(raw_data)
얼핏 보기에는 확장 가능한 구조로 보인다. 새로운 이벤트가 추가되면 Event의 하위 클래스를 추가하고, SystemMonitor는 새로운 유형의 이벤트를 처리할 수 있는 것처럼 보인다. 그러나 자세히 살펴보면 새로운 유형을 판단하는 로직은 SystemMonitor의 identify 안에서 이뤄지기 때문에 SystemMonitor는 새로운 유형의 이벤트에 완전히 종속되어 있다.
첫 번째로 다음과 같이 문제를 해결할 수 있다.
@dataclass
class Event:
raw_data: dict
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, event_data) -> Event:
"""이벤트 유형을 식별한다."""
if (
self.event_data["before"]["session"] == 0
and self.event_data["after"]["session"] == 1
):
return LoginEvent(event_data)
elif (
self.event_data["before"]["session"] == 1
and self.event_data["after"]["session"] == 0
):
return LogoutEvent(event_data)
else:
return UnknownEvent(event_data)
위 코드는 다음과 같이 동작한다.
>>> 11 = SystemMonitor({"before": {"session": 0}, "after": {"session": 1}})
>>> 11.identify_event().__class__.__name__
'LoginEvent'
>>> 12 = SystemMonitor({"before": {"session": 1}, "after": {"session": 0}})
>>> 12.identify_event().__class__.__name__
'LogoutEvent'
>>> 13 = SystemMonitor({"before": {"session": 1}, "after": {"session": 1}})
>>> 13.identify_event().__class__.__name__
'UnknownEvent'
위 결과를 통해 이벤트 유형이 어떻게 분류되는지 알 수 있다. 예를 들어 이전에는 session 플래그가 0이었다가 이후에는 1로 변경되는 경우 LoginEvent로 분류된다. 반대의 상황이 되면 LogoutEvent로 분류된다. 그 외의 경우에는 UnknownEvent로 분류된다. 마지막에 None 대신에 기본값으로 UnknownEvent를 반환함으로써 다형성을 보장하기 위한 것이다. 이러한 패턴을 null 객체 패턴이라고 하는데, null 객체 패턴은 9장 “일반적인 디자인 패턴”에서 자세히 다룬다.
이 디자인에도 몇 가지 문제점이 있다. 첫 번째 문제는 이벤트 유형을 결정하는 로직이 단일 메서드에 중앙 집중화된다는 점이다. 지원하려는 메서드가 늘어날수록 메서드도 커질 것이므로 결국 매우 큰 메서드가 될 수 있다. 이미 논의한 것처럼 한 가지 일만 하는 것도 아니고 한 가지 일을 제대로 하지도 못한다.
같은 방법으로 이 메서드가 수정을 위해 닫히지 않았다는 것을 알 수 있다. 새로운 유형의 이벤트를 시스템에 추가할 때마다 메서드를 수정해야 한다(줄줄이 이어인 elif 문장이 가독성 측면에서 최악이라는 것은 두말할 필요가 없다).
이 메서드를 변경하지 않고도 새로운 유형의 이벤트를 추가하고 싶다(폐쇄 원칙). 새로운 이벤트가 추가될 때 이미 존재하는 코드를 수정하지 않고 코드를 확장하여 새로운 유형의 이벤트를 지원하고 싶다(개방 원칙).
확장성을 가진 이벤트 시스템으로 리팩토링
이전 예제의 문제점은 SystemMonitor 클래스가 분류하려는 구체 클래스와 직접 상호 작용한다는 점이다. 개방/폐쇄 원칙을 따르는 디자인을 하려면 추상화를 해야 한다.
대안은 SystemMonitor 클래스를 추상적인 이벤트와 협력하도록 변경하고, 이벤트에 대응하는 개별 로직은 각 이벤트 클래스에 위임하는 것이다.
그런 다음 각각의 이벤트에 다형성을 가진 새로운 메서드를 추가해야 한다. 이 메서드는 전달되는 데이터가 해당 클래스의 타입과 일치하는지 판단하는 역할을 한다. 또한 기존 분류 로직을 수정하여 전체 이벤트에 대해서 해당 판별 로직에 매칭이 되는지 확인한다.
class Event:
def __init__(self, raw_data):
self.raw_data = raw_data
@staticmethod
def meets_condition(event_data: dict) -> bool:
"""이벤트 데이터가 이벤트 조건을 충족하는지 확인한다."""
return False
class UnknownEvent(Event):
"""데이터를 처리할 수 없는 이벤트"""
class LoginEvent(Event):
"""로그인 이벤트"""
@staticmethod
def meets_condition(event_data: dict) -> bool:
return (
event_data["before"]["session"] == 0
and event_data["after"]["session"] == 1
)
class LogoutEvent(Event):
"""로그아웃 이벤트"""
@staticmethod
def meets_condition(event_data: dict) -> bool:
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, event_data) -> Event:
"""이벤트 유형을 식별한다."""
for event_cls in Event.__subclasses__():
try:
if event_cls.meets_condition(event_data):
return event_cls(event_data)
except KeyError:
continue
else:
return UnknownEvent(event_data)
이제 상호 작용이 추상화를 통해 이뤄지고 있음에 주목하자(이 경우 Event는 추상 클래스이거나 인터페이스가 될 수 있지만 설명을 간단하게 하기 위해 구체 클래스를 사용한다). identify_event는 이제 특정 이벤트 타입과 비교하는 것이 아니고, 일반적인 인터페이스를 가진 제네릭 이벤트와 비교한다. 이 인터페이스를 따르는 제네릭들은 모두 meets_condition 메서드를 구현하여 다형성을 보장한다.
__subclasses__()
메서드를 사용해 이벤트 유형의 목록을 가져오는 것에 주목하자. 이제 새로운 유형의 이벤트를 지원하려면 단지 Event 클래스를 상속 받고 비즈니스 로직에 따라 meets_condition() 메서드를 구현하기만 하면 된다.
이 예제는 확장 가능한 디자인에 대해서만 설명하면 되기 때문에 __subclasses__()
메서드를 사용했다. 그러나 abc 모듈의 register 메서드를 사용하여 클래스를 등록하거나, 자체적으로 클래스 레지스트리 목록을 관리한 뒤에 조회하는 것도 가능한다. 중요한 것은 객체들 간의 관계가 변하면 안 된다는 것이다.
이 디자인을 사용하면 원래의 identify_event 메서드가 닫히게 된다. 새로운 유형의 이벤트가 도메인에 추가된다더라도 수정할 필요가 없다. 반대로, 이벤트 계층은 확장을 위해 열려 있다. 새로운 유형의 이벤트가 도메인에 추가되면 인터페이스에 맞춰서 새로운 클래스를 추가하기만 하면 된다.
이벤트 시스템 확장
이제 이 디자인이 실제로 원하는 대로 확장 가능하다는 것을 증명해보자. 모니터링하는 시스템에서 새로운 트랜잭션이 실행되었음을 알려주는 이벤트가 추가되었다고 가정하자.
TransactionEvent 클래스를 추가하고, 이벤트를 식별하는 로직을 추가한다.
class TransactionEvent(Event):
"""트랜잭션 이벤트"""
@staticmethod
def meets_condition(event_data: dict) -> bool:
return (
event_data["before"].get("transaction") is not None
or event_data["after"].get("transaction") is not None
)
다음과 같이 코드를 실행하여 이전에 있던 이벤트도 그대로 잘 분류하고, 새로운 이벤트에 대해서도 정확히 잘 분류하는 것을 알 수 있다.
>>> 11 = SystemMonitor({"before": {"session": 0}, "after": {"session": 1}})
>>> 11.identify_event().__class__.__name__
'LoginEvent'
>>> 12 = SystemMonitor({"before": {"session": 1}, "after": {"session": 0}})
>>> 12.identify_event().__class__.__name__
'LogoutEvent'
>>> 13 = SystemMonitor({"before": {"session": 1}, "after": {"session": 1}})
>>> 13.identify_event().__class__.__name__
'UnknownEvent'
>>> 14 = SystemMonitor({"after": {"transaction": "Tx001"}})
>>> 14.identify_event().__class__.__name__
'TransactionEvent'
새 이벤트를 추가했지만 SystemMonitor.identify_event() 메서드는 전혀 수정하지 않은 것에 주목하자. 따라서 이 메서드가 새로운 유형의 이벤트에 대해서 폐쇄되어 있다는 것을 알 수 있다.
반대로 Event 클래스는 필요할 때마다 새로운 유형의 이벤트를 추가할 수 있게 해준다. 따라서 이벤트는 새로운 타입의 확장에 대해 개방되어 있다고 할 수 있다.
OCP 최종 정리
이것이 개방/폐쇄 원칙이다. 도메인에 새로운 문제가 나타다고 기존 코드를 수정하지 않고 새 코드를 추가하기만 하면 된다.
가장 중요한 것은 코드를 변경하지 않고 기능을 확장하기 위해서는 보호하려는 추상화에 대해서 적절한 폐쇄를 해야 한다는 것이다. 일부 추상화의 경우 충돌이 발생할 수 있기 때문에 모든 프로그램에서 이 원칙을 적용할 수 있는 것은 아니다. 특정 요구사항에 대해서는 적절한 추상화가 다른 유형의 요구사항에 대해서는 부적절한 추상화가 될 수도 있다. 이런 경우에는 가장 확장성이 뛰어난 요구사항에 대해서 폐쇄를 하도록 해야 한다.
리스코프 치환 원칙(LSP)
리스코프 치환 원칙(Liskov Substitution Principle)은 설계의 안정성을 높이기 위해 객체가 가져야 하는 일련의 특성을 말한다.
LSP의 요지는 클라이언트가 특별한 주의를 기울이지 않고도 부모 클래스를 대신하여 하위 클래스를 그대로 사용할 수 잇어야 한다는 것이다. 즉, 클라이언트는 부모 타입 대신에 어떠한 하위 타입을 사용해도 정상적으로 동작해야 한다. 클아이언트는 사용하는 클래스의 내부 구조를 알 필요가 없다. 그러한 변경이 발생하더라도 클라이언트는 영향을 받지 않아야 한다.
리스코프 치환 원칙을 위반하는 예제
아래 코드는 리스코프 치환 원칙을 위반하는 예제이다. 이 코드는 파생 클래스가 부모 클래스에서 정의한 파라미터와 다른 타입을 사용했기 때문에 명확히 LSP를 위반했다. LSP 원칙을 따랐다면 호출자는 아무런 차이를 느끼지 않고 투명하게 Event 또는 LoginEvent를 사용할 수 있어야 한다. 이 두가지 타입의 객체를 치환해도 애플리케이션 실행에 실패해선 안 된다.
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)
이벤트 데이터가 dict 타입인지 list 타입인지에 따라서 meets_condition 메서드의 반환값이 달라진다. 이렇게 되면 클라이언트는 이벤트 데이터의 타입에 따라서 메서드의 반환값이 달라진다는 것을 알아야 한다. 이는 리스코프 치환 원칙을 위반하는 것이다.
도구를 사용해 LSP 문제 검사하기
mypy로 잘못된 메서드 서명 검사
mypy는 정적 타입 검사 도구이다. mypy를 사용하면 잘못된 메서드 서명을 검사할 수 있다. 위에서 언급한 예제를 mypy로 실행하면 다음과 같은 오류 메세지가 표시된다.
$ mypy lsp.py
lsp.py: error: Argument 1 to "meets_condition" incompatible with supertype "Event"
LSP 원칙은 객체 지향 설계의 관점에서도 의미가 있다. 서브클래싱(subclassing)을 할 때는 구체화를 해야 하지만 각 서브클래스의 기본 틀은 부모 클래스가 선언하는 것이어야 한다. 이전 예에서 SystemMonitor 클래스는 모든 이벤트 유형과 상호 교환 가능하기를 기대할 것이다. 여기서 각 이벤트 유형은 결국 Event를 상속받은 클래스이다(LoginEvent는 Event의 한 종류이고 나머지 하위 클래스도 마찬가지이다.) 이러한 하위 클래스 중 하나가 기본 클래스의 메시지를 구현하지 않거나, 선언되지 않은 다른 public 메서드를 구현하거나, 메서드의 서명을 변경하여 계층 구조를 깨뜨리면 identify_event 메서드는 더 이상 동작하지 않게 된다.
pylint로 호환되지 않는 서명 검사
또 다른 자주 발생하는 LSP 위반 사례는 계층의 파라미터 타입이 다른 것이 아니라 메서드의 서명 자체가 완전히 다른 경우이다. 이것은 아주 큰 실수처럼 보이지만 탐지하는 것이 쉽지 않다. 파이썬은 인터프리터 언어이므로 초기에 컴파일러를 사용해 이러한 유형의 오류를 감지하지 못했다면 런터임까지 발견되지 않는다. 다행히도 mypy나 pylint와 같은 정적 코드 분석기를 사용해 초기에 이런 오류를 잡을 수 있다.
mypy로 이러한 유형의 오류를 잡고 pylint를 실행해 더 많은 통찰력을 얻는 것도 좋다.
다음과 같이 계증 구조의 호환성을 깨는 클래스가 있다고 가정해보자. 메서드의 서명을 변경하거나 파라미터를 추가하는 등의 차이가 있는 경우이다.
class LoginEvent(Event):
...
def meets_condition(self, event_data: dict, override: bool) -> bool:
if override:
return True
...
pylint 실행 결과는 다음과 같다.
$ pylint lsp.py
Parameters differ from overridden 'meets_condition' method (argumentsdiffer)
애매한 LSP 위반 사례
어떤 경우는 LSP를 위반한 것이 명확하지 않아서 자동화된 도구로 검사하기 애매할 수 있다. 이런 경우는 코드 리뷰를 하면서 자세히 코드를 살펴볼 수밖에 없다.
계약이 수정되는 경우는 특히 자동으로 감지하기가 더 어렵다. LSP에서 하위 클래스는 상위 클래스와 호환 가능하다는 점을 감안할 때 계약은 계층 구조 어디에서나 항상 유지되어야만 한다.
3장 “좋은 코드의 일반적인 특정”을 떠올려 보자. 클라이언트와 컴포넌트 사이의 계약은 몇 가지 규칙을 가지고 있다. 클라이언트는 유효성을 검사할 수 있도록 사전조건을 제공하고 컴포넌트는 클라이언트가 사후조건으로 검사할 값을 반환한다.
부모 클래스는 클라이언트와의 계약을 정의한다. 하위 클래스는 그러한 계약을 따라야 한다. 예를 들면 다음과 같다.
- 하위 클래스는 부모 클래스에 정의된 것보다 사전조건을 엄격하게 만들면 안 된다.
- 하위 클래스는 부모 클래스에 정의된 것보다 약한 사후조건을 만들면 안 된다.
이전 섹션에서 정의한 이벤트 계층구조를 이제는 LSP와 DbC 간의 관계를 보여주기 위해 변경해보자.
이번 예제는 사전조건에서 파라미터가 dict 타입인지, 그리고 “before”와 “after” 키를 가지고 있는지 확인한다. “before”와 “after” 키의 값은 또다시 dict를 내포하고 있다. 이렇게 하면 클라이언트는 KeyError를 받지 않으므로 보다 발전된 캡슐화를 할 수 있다. 그저 사전조건 체크 메서드만 호출하면 되기 때문이다. 사전조건 검증에 실패한 경우 시스템 실패로 처리해도 무방하다고 가정한다. 이제 SystemMonitor는 더 이상 협력하는 클래스에서 어떤 예외를 발생시키는지 몰라도 상관이 없다(예외는 캡슐화를 약화시킨다는 점을 기억하자. 예외 처리를 하려면 호출하는 객체에 대해 부가적인 정보가 필요하기 때문이다).
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 TypeError(f"event_data must be Mapping; got {event_data!r}")
for moment in ("before", "after"):
if moment not in event_data:
raise ValueError(f"event_data must have key {moment!r}")
if not isinstance(event_data[moment], Mapping):
raise ValueError(
f"event_data[{moment!r}] must be Mapping; got {event_data[moment]!r}"
)
이제 올바른 이벤트 유형을 탐지하기 전에 사전조건을 먼저 검사한다.
class SystemMonitor:
"""시스템에서 발생하는 이벤트 분류"""
def __init__(self, event_data):
self.event_data = event_data
def identify_event(self, event_data) -> Event:
"""이벤트 유형을 식별한다."""
Event.validate_precondition(event_data)
for event_cls in Event.__subclasses__():
try:
if event_cls.meets_condition(event_data):
return event_cls(event_data)
except KeyError:
continue
else:
return UnknownEvent(event_data)
계약은 오직 최상위 레벨의 키 “before”와 “after”가 필수이고 그 값 또한 dict 타입이어야 한다고만 명시되어 있다. 하위 클래스에서 보다 제한적인 파라미터를 요구하는 경우 검사에 통과하지 못한다.
트랜잭션을 위한 이벤트 클래스는 올바르게 설계되었다. “transaction” 이라는 키에 제한을 두지 않고 사용하고 있다. 그 값이 있을 경우에만 사용하고 필수로 필요한 것은 아니다.
class TransactionEvent(Event):
"""트랜잭션 이벤트"""
@staticmethod
def meets_condition(event_data: dict) -> bool:
return (
event_data["before"].get("transaction") is not None
or event_data["after"].get("transaction") is not None
)
그러나 이전에 사용하던 LoginEvent와 LogoutEvent 클래스는 before와 after의 “session”이라는 키를 사용하기 때문에 그대로 사용할 수 없다. 이렇게 되면 계약이 걔지고 KeyError가 발생하기 때문에 나머지 클래스를 사용하는 것과 같은 방식으로 클래스를 사용할 수 없다.
이 문제는 TransactionEvent와 마찬가지로 대괄호 대신 .get() 메서드를 사용해서 해결할 수 있다. 이제 LSP를 사용한 계약이 다시 성립하고 다형성을 활용할 수 있다.
>>> 11 = SystemMonitor({"before": {"session": 0}, "after": {"session": 1}})
>>> 11.identify_event().__class__.__name__
'LoginEvent'
>>> 12 = SystemMonitor({"before": {"session": 1}, "after": {"session": 0}})
>>> 12.identify_event().__class__.__name__
'LogoutEvent'
>>> 13 = SystemMonitor({"before": {"session": 1}, "after": {"session": 1}})
>>> 13.identify_event().__class__.__name__
'UnknownEvent'
>>> 14 = SystemMonitor({"after": {"transaction": "Tx001"}})
>>> 14.identify_event().__class__.__name__
'TransactionEvent'
자동화된 도구가 얼마나 유용하고 유익한 지에 관계없이 이런 것까지 검출해주기를 기대하는 것은 무리이다. 클래스 디자인을 할 때는 실수로 메서드의 입력과 출력을 변경해서 원래 기대한 것과 달라지지 않도록 주의해야 한다.
LSP 최종 정리
LSP는 객체지향 소프트웨어 설계의 핵심이 되는 다형성을 강조하기 때문에 좋은 디자인의 기초가 된다. 인터페이스의 메서드가 올바른 계층구조를 갖도록 하여 상속된 클래스가 부모 클래스와 다형성을 유지하도록 하는 것이다.
이 원리가 이전의 원리와 어떻게 관련되어 있는지 주목하는 것도 흥미롭다. 새로운 클래스가 원래의 계약과 호환되지 않는 확장을 하려고 하면 클라이언트와의 계약이 깨져서 결과적으로 그러한 확장이 가능하지 않을 것이다. 또는 확장을 가능하게 하려면 수정에 대해 폐쇄되어야 한다는 원칙을 깨야 한다. 이는 전혀 바람직하지 않을 형태이다.
LSP에서 제안하는 방식으로 신중하게 클래스를 디자인하면 계층을 올바르게 확장하는데 도움이 된다. 즉 LSP가 OCP(Optional Close Principle)를 지원한다는 것이다. 이 두 원칙은 서로 보완적이다. LSP는 계층을 올바르게 확장하는 것을 지원하고, OCP는 계층을 확장하는 것을 가능하게 한다.
인터페이스 분리 원칙(ISP)
인터페이스 분리 원칙(Interface Segregation Principle)은 클라이언트가 자신이 사용하지 않는 메서드에 의존하지 않아야 한다는 원칙이다.
객체 지향적인 용어로 ‘인터페이스’는 객체가 노출하는 메서드의 집합니다. 즉 객체가 수신하거나 해석할 수 있는 모든 메시지가 인터페이스를 구성하며, 클라이언트는 이것들을 호출할 수 있다. 인터페이스는 클래스의 정의와 구현을 분리한다.
파이썬에서 인터페이스는 메서드의 형태를 보고 암시적으로 정의된다. 이것은 파이썬이 소위 말하는 덕 타이핑(duck typing)원리를 따르기 때문이다.
덕 타이핑은 모든 객체가 자신이 가지고 있는 메서드와 자신이 할 수 있는 일에 의해서 표현된다는 점에서 출발한다. 즉 클래스의 타입, 이름, docstring, 클래스 속성 또는 인스턴스 속성에 상관없이 객체의 본질을 정의하는 것은 궁극적으로 메서드의 형태이다. 클래스의 메서드는 실제로 그 객체가 무엇인지 결정한다. 이것은 객체가 어떤 타입인지가 아니라 객체가 무엇을 할 수 있는지에 따라서 객체를 구분한다는 것을 의미한다. “어떤 새가 오리처럼 걷고 오리처럼 꽥꽥 소리를 낸다면 오리여야만 한다”는 데서 덕 타이핑이라고 불린다.
오랫동안 덕 타이핑은 파이썬에서 인터페이스를 정의하는 유일한 방법이었다. 파이썬 3(PEP-3119)에서 인터페이스를 다른 방식으로 정의하는 추상 기본 클래스 개념을 도입했다. 추상 기본 클래스는 파생 클래스가 구현해야 할 일부분을 공통된 동작으로 구현하거나 인터페이스로 정의하는 것이다. 이는 특정 중요 메서드가 실제로 재정의 되었는지 확인이 필요할 때 유용하며 isinstance()와 같은 메서드의 기능을 재정의하거나 확장하는 메커니즘으로도 작동한다.
‘추상 기본 클래스(abstract base class)’는 파생 클래스가 반드시 구현해야 하는 것을 명시적으로 가리키기 위한 유용하고 강력한 도구이다. 예를 들어, 리스코프 치환 원칙(LSP)에서 Event가 제네릭 클래스라면 Event 클래스 자체를 사용하기보다는(Event 자체로는 실질적인 무엇을 의미하지 않기 때문이다), LoginEvent처럼 실제 이벤트 중 하나를 사용하게 될 것이다. 이 경우 Event 클래스는 추상 기본 클래스로 지정할 수 있다. 이제 SystemMonitor는 이 추상 클래스와 작업을 하고, Event 클래스는 인터페이스처럼 동작하게 된다. (인터페이스는 “어떤 행위를 가지고 있는지 표현하는 객체”를 말한다.) 여기서 더 나아가서 기본 클래스에 있는 meets_condition 메서드에서 제공하는 구현 내용이 부족하다고 판단되면 각 파생 클래스가 이를 직접 구현하도록 강제할 수 있다. 이런 경우에 @abstractmethod 데코레이터를 사용한다. (@abstractmethod로 마킹한 메서드는 반드시 파생 클래스에서 모두 구현을 해야만 인스턴스화가 가능하다.)
abc 모듈에는 가상 서브클래스(virtual subclass)를 계층 구조의 일부로 등록하는 방법도 포함되어 있다. (ABC의 register() 메서드를 사용하면 기존 기본 클래스에 파생 클래스를 추가할 수 있다. 이렇게 추가된 파생 클래스를 virtual subclass라고 한다.) 즉, 오리와 같이 걷고, 오리와 같이 소리내고, 자신을 오리라고 말할 수 있는 새로운 유형의 오리를 등록하는 것이다.
파이썬이 인터페이스를 어떻게 해석하는지에 대한 이러한 개념은 이번 원리와 다음 원리를 이해하는데 중요하다.
추상적으로 말하자면 ISP는 다음을 뜻한다. 여러 메서드를 가진 인터페이스가 있다면 매우 정확하고 구체적인 구분에 따라 더 적은 수의 메서드(가급적이면 단 하나)를 가진 여러 개의 인터페이스로 분할하는 것이 좋다는 것이다. 재사용성을 높이기 위해 가능한 작은 단위로 인터페이스를 분리한다면 인터페이스를 구현하려는 각 클래스가 매우 명확한 동작과 책임을 지니기 때문에 응집력이 높아진다.
너무 많은 일을 하는 인터페이스
여러 데이터 소스에서 이벤트를 파싱하는 인터페이스를 가정해보자. 예를 들면 XML과 JSON 포맷의 데이터를 파싱하는 경우이다. 모범사례를 참고하여 구체 클래스(concrete class)는 대신에 다음과 같은 인터페이스를 만들었다고 해보자.
class EventParser:
def from_xml(self, xml: str) -> dict:
...
def from_json(self, json: str) -> dict:
...
이 인터페이스는 이벤트를 파싱하는 데 필요한 모든 메서드를 가지고 있다. 그러나 이 인터페이스는 ISP를 위반한다. 이 인터페이스를 구현하는 클래스는 모든 메서드를 구현해야 한다. 이것은 이 인터페이스를 구현하는 클래스가 XML과 JSON 데이터를 모두 파싱해야 한다. 예를 들면, 어떤 클래스틑 XML 메서드를 필요로 하지 않고 JSON 으로만 구성할 수 있다면 어떨까? 그럼에도 여전히 인터페이스에서는 필요하지 않은 from_xml() 메서드를 제공할 것이다. 이것은 결합력을 높이고 유연성을 떨어뜨리며 클라이언트가 필요하지도 않은 메서드를 구현하도록 한다.
인터페이스는 작을수록 좋다.
앞의 인터페이스는 각각 하나의 메서드만 가지는 두 개의 인터페이스로 분리하는 게 좋다. 이렇게 하더라도 EventParser가 두 개의 인터페이스를 모두 구현하면 여전히 동일한 기능을 달성할 수 있다. 왜냐하면 인터페이스나 추상 기본 클래스는 그저 몇 가지 조건이 더해진 일반 클래스이기 때문이다. 그리고 파이썬은 다중 상속을 지원한다. 이제 재사용 가능한 보다 구체적인 인터페이스를 갖게 되었고, 각각의 메소드가 별도의 인터페이스로 분리되었다.
from abc import ABCMeta, abstractmethod
class XMLParser(metaclass=ABCMeta):
@abstractmethod
def from_xml(self, xml: str) -> dict:
...
class JSONParser(metaclass=ABCMeta):
@abstractmethod
def from_json(self, json: str) -> dict:
...
class EventParser(XMLParser, JSONParser):
def from_xml(self, xml: str) -> dict:
pass
def from_json(self, json: str) -> dict:
pass
이 디자인을 사용하면 XMLEventParser에서 파생된 클래스는 from_xml() 메서드만을 구현하면 되고, 마찬가지로 JSONEventParser에서 파생된 클래스는 from_json() 메서드만 구현하면 된다. 이것만으로 XML과 JSON을 다룰 수 있다. 무엇보다 중요한 것은 이 둘이 독립성을 유지하게 되었고, 새로운 작은 객체를 사용해 모든 기능을 유연하게 조합할 수 있게 되었다는 점이다.
인터페이스에서 정의한 추상 메서드는 구체 클래스에서 반드시 구현해야 한다는 점에 주의하자. 추상메서드를 구현하지 않으면 다음과 같은 런타임 오류가 발생한다.
>>> from src.isp import EventParser
>>> EventParser()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: Can't instantiate abstract class EventParser with abstract methods from_json, from_xml
SRP와 유사하지만 주요 차이점은 ISP는 인터페이스에 대해 이야기하고 있다는 점이다. 따라서 이것은 행동의 추상화이다. 인터페이스가 실제로 구현될 때까지는 아무 것도 정해진 것이 없으므로 변경할 이유가 없다. 그러나 이 원칙을 준수하지 않으면 별개의 기능이 결합된 인터페이스를 만들게 된다. 이렇게 상속된 클래스는 SRP 또한 준수할 수 없게 된다. 클래스를 변경해야 할 이유가 두 가지 이상이 되기 때문이다.
인터페이스는 얼마나 작아야 할까?
추상 클래스이든 아니든 기본 클래스는 다른 클래스들이 확장할 수 있도록 인터페이스를 정의한다. 응집력의 관점에서 가능한 단 한 가지 일을 수행하는 작은 인터페이스여야 한다. 그렇다고 반드시 딱 한 가지 메서드만 있어야 한다는 뜻은 아니다.
그러나 하나 이상의 메서드라 하더라도 적절하게 하나의 클래스에 속해 있을 수 있다. 예를 들어, 컨텍스트 관리자를 추상화한 믹스인 클래스를 제공하고자 한다고 자어해보자. 이 믹스인 클래스를 상속받은 클래스는 공짜로 컨텍스트 관리자의 기능을 얻게 된다. 이미 알고 있듯이 컨텍스트 관리자는 ‘enter‘와 ‘exit’ 두 가지 메서드를 필요로 한다. 이들은 반드시 함께 제공되어야 한다. 그렇지 않으면 전혀 유효한 컨텍스트 관리자가 아니기 때문이다.
이 두 메서드를 하나의 클래스에서 제공하지 않으면 전혀 쓸모없는 것은 물론이고 잘못 이해할 가능성도 높아진다. 이것은 약간 과장된 예제이지만 앞의 섹션에 사용된 예제와의 비교를 통해 인터페이스를 디자인할 때 타산지석으로 삼아야 할 것이다.
의존성 역전(DIP)
의존성 역전 원칙(Dependency Inversion Principle)은 고수준 모듈은 저수준 모듈에 의존해서는 안 된다는 원칙이다. 두 모듈 모두 추상화에 의존해야 한다는 것이다. 추상화는 세부 사항에 의존해서는 안 된다. 세부 사항은 추상화에 의존해야 한다.
A와 B 두 객체가 상호 교류를 한다고 생각해보자. A는 B의 인스턴스를 사용하지만 우리가 B 모듈을 직접 관리하지는 않는다. 외부 라이브러리 또는 다른 팀의 모듈 등을 사용하는 경우이다. 만약 코드가 B에 크게 의존하면 B 코드가 변경되면 원래의 코드는 쉽게 깨지게 된다. 이를 방지하기 위해서 의존성을 거꾸로 뒤집어서 역전시켜야 한다. 즉 B가 A에 적응해야 한다. 이렇게 하려면 인터페이스를 개발하고 코드가 B의 구체적인 구현에 의존하지 않도록 해야 한다. 해당 인터페이스를 준수하는 것은 B의 책임이다.
강한 의존성을 가진 예
이벤트 모니터링 시스템의 마지막 부분은 식별된 이벤트를 데이터 수집기로 전달하여 분석하는 것이었다. 단순하게 구현해보자면 데이터를 목표지에 전송하는 이벤트 전송 클래스 Syslog를 만들면 된다.
class Syslog:
def __init__(self, host, port):
self.host = host
self.port = port
def send(self, event):
"""이벤트를 수집기로 전송한다."""
...
class EventStreamer:
def __init__(self, event):
self.event = event
self.syslog = Syslog("localhost", 514)
def stream(self):
"""이벤트를 수집기로 전송한다."""
self.syslog.send(self.event)
그러나 이것은 저수준(Syslog)의 내용에 따라 고수준(EventStreamer) 클래스가 변경되어야 하므로 별로 좋은 디자인이 아니다. 만약 Syslog로 데이터를 보내는 방식이 변경되면 EventStreamer도 변경해야 한다. 만약 다른 데이터에 대해서는 전송 목적지를 변경하거나 새로운 데이터를 추가하려면 stream()메서드를 이러한 요구사항에 따라 지속적으로 적응(수정)해야 하므로 문제가 생긴다.
의존성을 거꾸로
이러한 문제를 해결하려면 EventStreamer를 구체 클래스가 아닌 인터페이스와 대화하도록 하는 것이 좋다. 이렇게 하면 인터페이스의 구현은 세부 구현사항을 가진 저수준 클래스가 담당하게 된다.
from abc import ABC, abstractmethod
class DataTargetClient(ABC):
@abstractmethod
def send(self, event):
"""이벤트를 수집기로 전송한다."""
...
class Syslog(DataTargetClient):
def __init__(self, host, port):
self.host = host
self.port = port
def send(self, event):
"""이벤트를 수집기로 전송한다."""
...
class EventStreamer:
def __init__(self, event, target_client):
self.event = event
self.target_client = target_client
def stream(self):
"""이벤트를 수집기로 전송한다."""
self.target_client.send(self.event)
이제 데이터를 전송할 대상을 나타내는 인터페이스가 생겼다. 의존성이 어떻게 역전되었는지 살펴보자. EventStreamer는 특정 데이터 대상의 구체적인 구현과 관련이 없어졌다. 구현 내용이 바뀌어도 수정할 필요가 없다. 실제 인터페이스를 정확하게 구현하고 변화를 수용하는 것은 각각의 인터페이스 구현체가 담당한다.
즉 첫 번째 EventStreamer구현은 Syslog 유형의 객체와만 동작했기 때문에 유연성이 떨어진다. 그렇지만 .send() 메서드가 정의된 DataTargetClient 추상 기본 클래스를 확장한다. 이제부터 이메일 같은 새로운 유형의 데이터 대상이 추가되어도 send() 메서드의 구현은 모두 새로운 클래스에서 담당한다.
심지어 런타인 중에도 send() 메서드를 구현한 객체의 프로퍼티를 수정해도 여전히 잘 동작한다. 이렇게 의존성을 동적으로 제공한다고 하여 종종 의존성 주입(dependency injection)
이라고 한다.
실제로 이것이 왜 필요한지 궁금해 할 것이다. 파이썬은 충분히 융통성이 있으며(때로는 너무 유연하다), 동적인 타입의 언어이기 때문에 인터페이스를 사용하지 않고도 EventStreamer에 특정 데이터 대상 객체를 제공할 수 있다. 그렇다면 간단하게 send() 메서드를 가진 객체를 넘기면 되는데 왜 굳이 추상 기본 클래스(인터페이스)를 정의하는 것일까?
엄밀히 말하면 그렇게 할 수도 있다. 실제로 꼭 이렇게 할 필요는 없으며 그렇게 해도 프로그램을 똑같이 동작한다. 결국 다형성에서 상속이 항상 효과가 있다는 것을 의미하지는 않는다. 그러나 추상 기본 클래스를 사용하는 것이 좋은 습관이다. 첫 번째 장점은 덕 타이핑이다. 덕 타이핑이 가능하면 모델의 가독성도 높아진다. 상속은 is a 관계임을 기억하자. (is a는 객체 지향 프로그래밍에서 객체 간의 관계를 표현할 때 사용하는 용어 중 하나인데, “The apple is a fruit.” 처럼 is a 를 사용해 표현이 가능하다면 apple과 fruit은 상속 관계라는 뜻이다. apple이 fruit을 상속했으므로 “사과는 과일이다.” 라고 말할 수 있다. 주의할 것은 그 반대는 성립하지 않는 다는 것이다. “과일은 사과다” 라고 말할 수는 없다. 즉, 상속은 단방향이다.)
따라서 추상 기본 클래스를 선언하고 확장함으로써 Syslog는 DataTargetClient라고 말할 수 있다. 즉 코드 사용자는 코드를 읽고 이해할 수 있다(이것이 바로 덕 타이핑이다).
결국 추상 기본 클래스를 사용하는 것이 필수는 아니다. 그러나 클린 디자인을 위해서 바람직하다. 이것이 이 책이 있는 이유 중 하나로 파이썬이 너무 유연하여 자주 발생하는 실수를 줄이기 위함이다.
의존성 주입(Dependency Injection)
우리의 코드가 구체적인 특정 구현에 종속되게 하지 말고, 계층 사이를 연결하는 강력한 추상화 개체와 이야기하게 하자. 앞의 예에서 시스템 로그를 저장하는 Syslog에 의존하면 시스템이 어떻게 경직화 되는지 살펴보았다. 그래서 DataTrargetClient 인터페이스를 만들고 Syslog는 그것을 구현하도록 디자인을 변경했다. 이렇게 함으로써 클라이언트에게 좀 더 개방된 형태의 디자인을 제공할 수 있게 되었다. 새로운 전송지가 추가된다면 인터페이스를 따르는 새 클래스를 만들기만 하면 된다. 이제 우리의 코드는 확장에 열려 있고, 수정에 닫혀 있다(여러 원칙들이 상호 연결되어 있는 것을 발견할 수 있다).
이제 이러한 객체들 간의 협업은 어떻게 될까? 이번에는 자신을 필요로 하는 객체에 어떻게 의존성을 주입시키는지 살펴보자.
단순한 구현 방법 중 하나는 이벤트 처리기(EventStreamder)에서 필요한 객체 (여기서는 Syslog)를 직접 생성하는 것이다.
class EventStreamer:
def __init__(self, event):
self.event = event
self.target_client = Syslog("localhost", 514)
def stream(self):
"""이벤트를 수집기로 전송한다."""
self.target_client.send(self.event)
그러나 이것은 유연한 디자인이라 할 수 없으며, 우리가 만든 인터페이스를 최대한 활용하지도 않안ㅆ다. 이렇게 구현하면 테스트도 어렵게 된다. 이 클래스에 대한 단위 테스트를 작성하려면 Syslog의 생성 로직을 수정하거나, 생성한 뒤에 정보를 업데이트 해야 한다. Syslog 생성 시 문제가 있다면(일반적으로 생성 중에 많은 작업을 하는 것은 안 좋은 습관이지만 연결을 맺는 등의 작업이 필요할 수 있다) 해당 문제가 초기화 작업에 그대로 전파된다. 이 문제는 지연(lazy) 설정을 활용하여 극복할 수도 있지만, 이 경우에도 객체가 어떻게 동작하는지 계속 관심을 가지고 있어야 한다.
더 나은 디자인은 의존성을 주입하는 것이다. 이벤트 처리기가 필요로 하는 것들을 직접 관리하지 말고, 제공을 받도록 하자.
class EventStreamer:
def __init__(self, event, target_client: DataTargetClient):
self.event = event
self.target_client = target_client
def stream(self):
"""이벤트를 수집기로 전송한다."""
self.target_client.send(self.event)
이렇게 함으로써 인터페이스를 사용하고 다형성을 지원하게 되었다. 이제 초기화 시 인터페이스를 구현하는 어떤 객체도 전달할 수 있으며, 이벤트 처리기가 그런 유형을 모두 처리할 수 있다는 것을 더욱 명시적으로 표현하고 있다.
이전과 달리 이제 테스트도 간단하다. 단위 테스트에서 Syslog를 사용하고 싶지 않으면, 알맞은 테스트 더블(인터페이스와 호환되는 새로운 클래스)을 제공하기만 하면 된다. (Test Double - 여기서 double은 무언가를 꼭 닮은 역할을 하는 것을 말하는 것이다. 대역 배우를 body double이라 하는 것처럼 test double은 원래 코드를 대신하는 대역 테스트 코드이다.)
TIP.
__init__
메서드에서 의존성이 있는 것들을 직접 생성하지 않도록 하자. 대신에 사용자가__init__
메서드에 의존성을 파라미터로 전달하도록 하여 보다 유연하게 대응할 수 있도록 하자.
만약 복잡한 초기화 과정을 가졌거나 초기화 인자가 많은 경우라면, 종속성 그래프를 만들고 관련 라이브러리가 생성을 담당하도록 하는 것이 좋은 방법이다. 즉, 객체를 연결하기 위한 글루코드(glue code - 프로그램의 기본 동작과는 관련이 없지만 프로그램 구성 요소 간의 호환성을 위해 접착제(glue) 역할을 하는 코드)에서 보일러플레이트(boilerplate) 코드를 제거하는 것이다.
이러한 라이브러리 중 하나는 pinject
이다. 이 라이브러리는 의존성 주입을 위한 강력한 프레임워크이다. 이 라이브러리를 사용하면 의존성을 주입하는 것이 매우 쉬워진다. 이 라이브러리는 다음과 같이 사용한다.
import pinject
class EventStreamer:
def __init__(self, event, target_client: DataTargetClient):
self.event = event
self.target_client = target_client
def stream(self):
"""이벤트를 수집기로 전송한다."""
self.target_client.send(self.event)
class _EventStreamerBindingSpec(pinject.BindingSpec):
def provide_target(self):
return Syslog()
obj_graph = pinject.new_object_graph(binding_specs=[_EventStreamerBindingSpec()])
의존성 주입이 필요한 클래스에 의존성이 어떻게 주입될지를 결정하는 바인딩 스펙(binding specification) 객체를 정의할 수 있다. 바인딩 스펙 객체에서 provide_<dependency>
형태의 모든 메서드는 해당 <dependency>
와 같은 이름의 변수에 대해 의존성을 반환한다.
바로 아래의 코드를 함께 보면 이해가 쉽다. _EventStreamerBindingSpec의 provide_target() 메서드는 EventStreamer의 생성자 파라미터 중 target에 대해서 Syslog() 객체를 사용해야 한다고 알려주는 스펙의 역할을 한다.
이제 새롭게 생성한 object_graph의 provide() 메서드를 사용해서 다음과 같이 의존성이 주입된 객체를 얻을 수 있다.
event_streamer = obj_graph.provide(EventStreamer)
위 코드를 실행하면 Syslo를 target으로 하는 EventStreamer의 인스턴스 event_streamer를 얻을 수 있다.
만약 설정해야 할 의존성이 많고, 객체 간의 관계가 복잡하다면 명시적으로 이들 간의 관계를 선언하고, 도구에서 초기화하는 것이 좋다. 다시 말하면, 한 곳에서 생성 방법을 관리하고, 도구가 실제 생성을 담당하도록 하는 것이다(그런 면에서 팩토리 객체와 유사하다).
이렇게 의존성을 주입한다고 해서 원래의 디자인에서 얻은 유연성을 잃는 것은 아니라는 점을 기억하자. 객체 그래프(object graph)는 객체에 필요한 엔티티를 어떻게 만드는지를 정의한 객체일 뿐이다. 여전히 우리는 EventStreamer 클래스를 완전히 제어할 수 있으며, 인터페이스 규격만 맞춘다면 이전과 같은 형태로 어떤 객체든 전달하여 생성하고 사용할 수 있다. 인터페이스의 규격을 준수하여 개발한 여러 객체를 초기화 스펙에 맞춰 생성하고 이전과 동일하게 사용할 수 있다.
요약
SOLID 원칙은 객체 지향 소프트웨어 설계의 핵심 원칙이다. 소프트웨어 빌드는 엄청나게 어려운 작업이다. 코드에 포함된 로직은 복잡하다. 런타임에서의 동작은(때로는 가능할 떄도 있지만) 예측할 수 없을 수 있고, 요구 사항이 지속적으로 변할 뿐만 아니라 환경도 끊임없이 변하므로 여러 문제가 발생할 수 있다.
또한 다양한 기술, 패러다임 등을 사용한 디자인으로 소프트웨어를 구성할 수 있으며 특정 방법으로 문제를 해결하기 위해서 함께 작동할 수 있다. 시간이 지나면서 요구 사항이 변경되면 이러한 접근 방식 중 일부가 정확하지 않을 수 있다. 그러나 이때는 잘못된 디자인을 적절하고 융통성 있게 리팩토링 하기에는 너무 늦은 시기일 수 있다.
즉 디자인을 잘못하면 미래에 많은 비용이 든다는 것을 의미한다. 그리고 기존의 소프트웨어가 앞으로 수년 후에도 융통성 있게 변화에 적을할 수 있는지를 확인할 방법은 없다. 바로 이러한 이유 때문에 원칙에 충실해야 한다.
Previous
‘3. 좋은 코드의 일반적인 특징’ -> Previous
Next
‘5. 데코레이터를 사용한 코드 개선’ -> Next