파이썬에서의 클린 디자인의 원칙을 계속 공부를 해본다. 특히 솔리드 원칙이라는 것을 생각해보고 이를 파이썬스럽게 구현할 수 있는지 살펴본다. SOLID 원칙은 다음을 뜻한다.
단일 책임 원칙은 소프트웨어 컴포넌트(클래스)가 단 하나의 책임을 져야한다는 원칙이다. 클래스가 단일 책임이 있다는 것은 하나의 구체적인 일을 담당한다는 것을 의미한다.
그리고 이는 오직 해당 도메인(클래스가 담당하는 일)의 문제가 변경되는 경우에만 클래스를 업데이트해야 한다는 것을 의미한다. 다른 이유로 클래스를 업데이트해야 한다면, 그것은 아마 추상화가 잘못 되었거나 해당 클래스가 너무 많은 책임을 가지고 있기 때문일 것이다.
필요한 일 이상의 것을 하거나 너무 많은 것을 알고 있는 객체를 일컬어 신(god) 객체라 부른다. 이러한 객체는 서로 관련이 없는 행동을 그룹화한 것이므로 유지보수가 어렵다.
단일 책임 원칙에서 추구하는 것은 클래스에 있는 프로퍼티와 속성이 항상 메서드를 통해서 사용되도록 하는 것이다. 이렇게 하면 이들은 서로 관련된 개념이기 때문에 동일한 추상화로 묶는 것이 가능하다.
로그 파일이나 데이터베이스와 같은 소스에서 이벤트의 정보를 읽어서 로그별로 필요한 액션을 분류하는 애플리케이션 구조를 예제로 하여 살펴본다.
예제
class SystemMonitor:
def load_activity(self):
"""소스에서 처리할 이벤트를 가져오기"""
def identify_envents(self):
""" 가져온 데이터를 파싱하여 도메인 객체 이벤트로 변환 """
def stream_events(self):
""" 파싱한 이벤트를 외부 에이전트로 전송 """
이 클래스의 문제점은 독립적인 동작을 하는 메서드를 하나의 인터페이스에 정의했다는 것이다. 각각의 동작은 나머지 부분과 독립적으로 수행할 수 있다.
이 디자인 결합은 유지보수를 어렵게 하여 클래스가 경직되고 융통성이 없으며 오류가 발생하기 쉽게 만든다.
솔루션을 관리하기 쉽도록 모든 메서드를 다른 클래스로 분리하여 각 클래스마다 단일 책임을 갖게 하자.
방법은 각자의 책임을 가진 클래스를 만들고, 이것의 인스턴스들과 교류하는 하나의 객체를 만드는 것이다. 각각의 클래스는 나머지와 독립적인 특정한 메서드를 캡슐화한 상태이므로 수정이 필요한 경우에도 나머지 객체에는 영향을 미치지 않게 된다.

OCP 원칙을 따르지 않는 예제를 통해 유지보수의 어려움과 비유연성을 확인해보려고 한다. 이 예제는 다른 시스템에서 발생하는 이벤트를 분류하는 기능을 가지고 있고, 각 컴포넌트는 수집한 데이터를 기반으로 어떤 타입의 이벤트인지 정확히 분류를 해야한다. 단순함을 위해서 데이터는 딕셔너리 형태이다.
클래스 다이어그램에서 Event 인터페이스를 상속받은 하위 클래스와 해당 Event를 참조하는 SystemMonitor 클래스를 확인해보자

얼핏 보기에는 새로운 이벤트가 추가되면 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):
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)
하지만 이런 디자인도 몇 가지 문제점이 있다. 먼저 이벤트 유형을 결정하는 로직이 단일 메서드에 중앙 집중화된다는 점이다. 지원하려는 이벤트가 늘어날수록 메서드가 매우 커질 수 있다.
같은 방법으로 이 메서드가 후에 수정을 위해서 닫힌 메서드가 아닌 걸 확인할 수 있다. 새로운 유형의 이벤트를 시스템에 추가할 때마다 메서드를 수정해야 한다.(줄줄이 elif 문장을 추가)
이 메서드를 변경하지 않고 새로운 유형의 이벤트를 추가하고 싶다(폐쇄 원칙). 새로운 이벤트가 추가될 때 이미 존재하는 코드를 변경하지 않고 코드를 확장하여 새로운 유형의 이벤트를 지원하고 싶다(개방 원칙)
이전 예제의 문제점은 SystemMonitor 클래스가 분류하려는 구체 클래스와 직접 상호 작용한다는 점이다. OCP 원칙을 따르는 디자인을 하려면 추상화를 해야 한다.
대안은 SystemMonitor 클래스를 추상적인 이벤트와 협력하도록 변경하고, 입ㄴ트에 대응하는 개별 로직은 각 이벤트 클래스에 위임하는 것이다. 그런 다음 각각의 이벤트에 다형성을 가진 새로운 메서드를 추가해야 한다. 이 메서드는 전달되는 데이터가 해당 클래스의 타입과 일치하는지 판단하는 역할을 한다. 또한 기존 분류 로직을 수정하여 전체 이벤트에 대해서 해당 판별 로직에 매칭이 되는지 확인한다.
OCP 원칙을 따르는 클래스 다이어그램을 살펴보자

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):
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)
이제 상호 작용이 추상화를 통해 이뤄지고 있음에 주목하자(이 경우 Event는 추상 클래스이거나 인터페이스가 될 수 있지만 설명을 간단하게 하기 위해 구체 클래스를 사용한다)
identify_event는 이제 특정 이벤트 타입과 비교하는 것이 아니고, 일반적인 인터페이스를 가진 제네릭 이벤트와 비교한다. 이 인터페이스를 따르는 제네릭들은 모두 meets_condition 메서드를 구현하여 다형성을 보장한다.
__subclasses__() 메서드를 사용해 이벤트 유형의 목록을 가져오는 것에 주목하자. 이제 새로운 유형의 이벤트를 지원하려면 단지 Event 클래스를 상속 받고 비지니스 로직에 따라 meets_condition() 메서드를 구현하기만 하면 된다.
이 디자인을 사용하면 원래의 identify_event 메서드가 닫히게 된다. 새로운 유형의 이벤트가 도메인에 추가되더라도 수정할 필요가 없다. 반대로, 이벤트 계층은 확장을 위해 열려 있다. 새로운 이벤트가 도메인에 추가되면 인터페이스에 맞춰서 새로운 클래스를 추가하기만 하면 된다.
다음 예제를 통해서 실제로 원하는 대로 확장이 가능한 것을 확인해보려고 한다. 모니터링하는 시스템에서 새로운 트랜잭션이 실행되었음을 알려주는 이벤트가 추가되었다고 가정해보자.
먼저 새로운 이벤트를 포함하는 클래스 다이어그램은 다음과 같다.

TransactionEvent라는 새로운 클래스를 추가하고 meets_condition이라는 메서드로 구현한다.
이전 코드의 나머지 부분이 모두 변경되지 않는다고 가정하고 새 클래스에 대한 코드는 다음과 같이 작성할 수 있다.
class Event:
def __init__(self, raw_data):
self.raw_data = raw_data
@staticmethod
def meets_condition(event_data: dict) -> bool:
return False
# 새로운 클래스
class TransactionEvent(Event):
""" 시스템에서 발생한 트랜잭션 이벤트 """
@staticmethod
def meets_condition(event_data: dict):
return event_data["after"].get("transaction") is not None
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)
새 이벤트를 추가했지만 SystemMonitor.identify_event() 메서드는 전혀 수정하지 않은 것에 주목하자. 따라서 이 메서드가 새로운 유형의 이벤트에 대해서 폐쇄되어 있다고 말할 수 있다.
반대로 Event 클래스는 필요할 때마다 새로운 유형의 이벤트를 추가할 수 있게 해준다. 따라서 이벤트는 새로운 타입의 확장에 대해 개방되어 있다고 말할 수 있다.
결과를 살펴보면 기존 event와 새로운 event모두 정확하게 분류하는 것을 볼 수 있다.


이 원칙은 다형성의 효과적인 사용과 밀접한 관련이 있다. 다형을 따르는 형태의 계약을 만들고 모델을 쉽게 확장할 수 있는 일반적인 구조로 디자인하는 것이다.
이 원칙은 소프트웨어 엔지니어링의 중요한 문제인 유지보수성에 대한 문제를 해결한다. OCP를 따르지 않으면 ㅏ파급 효과가 생기거나 작은 변경이 코드 전체에 영향을 미치거나 다른 부분을 손상시키게 된다.
마지막 중요한 요점은 코드를 변경하지 않고 기능을 확장하기 위해서는 보호하려는 추상화에 대해서 적절한 폐쇄를 해야 한다는 것이다. 일부 추상화의 경우 충돌이 발생할 수 있기 때문에 모든 프로그램에서 이 원칙을 적용할 수 있는 것은 아니다. 이런 경우에는 가장 확장성이 뛰어난 요구사항에 대해서 폐쇄를 하도록 해야 한다.
이번 포스팅에서는 SOLID 원칙의 S와 O에 해당하는 원칙들을 예제와 함께 살펴보았다. 이는 코드 상에서 수행한다기 보다는 클래스 디자인 상에서 수행하는 것이 더 큰 것 같다.