[python cleancode] 4. SOLID 원칙

햄도·2021년 4월 11일
0

Python Cleancode

목록 보기
4/9

출처

파이썬 클린코드를 읽으며 정리한 내용입니다.

  • 파이썬에 적용된 클린 디자인의 원리 - 솔리드(SOLID) 원칙 검토
  • 솔리드 원칙을 파이썬스러운 방식으로 구현하는 방법
  • SOLID?
    • S: 단일 책임 원칙
    • O: 개방/폐쇄의 원칙
    • L: 리스코프 치환 원칙
    • I: 인터페이스 분리 원칙
    • D: 의존성 역전 원칙

단일 책임 원칙

  • Single Responsibility Principle
  • 소프트웨어 컴포넌트(일반적으로는 클래스)가 단 하나의 책임을 져야 한다.
  • 클래스가 하나의 책임을 진다는 것은 하나의 구체적인 일을 담당한다는 것을 의미하며, 따라서 변화해야 할 이유는 하나뿐이다.
  • 어떤 경우에도 여러 책임을 가진 객체를 만들어서는 안되며, 이렇게 필요한 일 이상의 것을 하거나 너무 많은 것을 알고있는 객체를 신(god) 객체라 부른다. 이러한 객체는 유지보수가 어렵다.
  • 객체의 속성이나 메서드의 특성이 다른 그룹이 발견되면 두 개 이상의 다른 추상화가 한 곳에 혼합되어 있다는 신호! 다른 곳으로 옮겨야 함.
  • 반대로 클래스의 메서드는 상호 배타적이어야 하기 때문에 서로 다른 책임을 가지고 있으므로 더 작은 클래스로 분해할 수 있어야 한다.

너무 많은 책임을 가진 클래스

  • 이벤트의 정보를 읽어서 로그별로 필요한 액션을 분류하는 애플리케이션 예시
  • 서로 독립적으로 수행할 수 있는 메서드를 하나의 인터페이스에 정의했다는 것이 문제
  • 각 메서드는 책임을 하나씩 가지며, 각 책임마다 수정 사유가 발생한다.
  • 예시로, load_activity() 메서드는 데이터 구조가 변경되면 수정되어야 한다. 이 경우 데이터 구조의 변경으로 인해 시스템 모니터링하는 객체를 수정하게 되기 때문에 바람직하지 않다. 다른 메서드들도 마찬가지이다. 따라서 이 클래스를 변경해야 하는 이유가 너무 많아진다.
class SystemMonitor:
    def load_activity(self):
        """소스에서 처리할 이벤트를 가져온다."""
    
    def identify_events(self):
        """가져온 데이터를 파싱하여 도메인 객체 이벤트로 변환"""
    
    def stream_events(self):
        """파싱한 이벤트를 외부 에이전트로 전송"""

책임 분산

  • 위의 예시에서 모든 메서드를 다른 클래스로 분리하여 각 클래스마다 단일 책임을 갖게 하자.
  • 이렇게 하면 각 객체들은 특정 기능을 캡슐화하여 나머지 객체에 영향을 미치지 않으며 명확하고 구체적인 의미를 갖는다. 따라서 변경 사항이 한 곳에만 적용되며 다른 곳에는 적은 영향을 주기 때문에 유지보수가 쉬워진다.
  • 뿐만 아니라 재사용하기도 더 좋아진다. 애플리케이션의 다른 곳에서 로그를 다른 용도로 읽기 위해서는 ActivityReader 클래스만 상속받아 사용하면 된다. 수정을 하지 않았다면 로그를 읽는데에 필요없는 다른 메소드도 상속받게 되었을 것이다.
  • 클래스가 딱 하나의 메서드를 가져야 한다는 뜻은 아니다. 처리해야 할 로직이 같은 경우 하나의 클래스에 여러 메서드를 추가할 수 있다.

개방/폐쇄 원칙

  • Open/Close Principle
  • 모듈이 개방되어 있으면서도 폐쇄되어야 한다는 원칙
  • 확장 가능하고, 새로운 요구사항이나 도메인 변화에 잘 적응하는 코드를 작성해야 한다 -> 즉 새로운 것을 추가만 할 뿐 기존 코드는 그대로 유지해야 한다.
  • 새로운 기능을 추가하다가 기존 코드를 수정했다면 그것은 기존 로직이 잘못 디자인되었다는 것을 의미..

개방/폐쇄 원칙을 따르지 않을 경우 유지보수의 어려움

  • 다른 시스템에서 발생하는 이벤트를 분류하는 기능 예시
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)
  • 이벤트 처리 로직
    • 세션에 플래그가 없었지만 지금은 있는 경우: LoginEvent
    • 반대: LogoutEvent
    • 식별할 수 없는 경우: UnknownEvent
  • 위 디자인의 문제점
    • 이벤트 유형을 결정하는 로직이 SystemMonitor에 집중되어있다. 지원하려는 이벤트가 늘어날수록 메서드는 점점 커져 한 가지 일도 제대로 못하게 된다. elif 명령문 체인 또한 가독성 측면에서 최악이다.
    • 메서드를 변경하지 않고도 새로운 유형의 이벤트를 추가하고(폐쇄 원칙), 새로운 이벤트가 추가될 때 기존 코드를 변경하지 않고 확장하려면 어떻게 해야 할까(개방 원칙)?

확장성을 가진 이벤트 시스템으로 리팩토링

  • 이전 예제의 문제점은 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)
Event.__subclasses__()
>>> [__main__.UnknownEvent, __main__.LoginEvent, __main__.LogoutEvent]
  • __subclasses__() 메서드를 사용해 이벤트 유형을 찾기 때문에, 이제 새로운 유형의 이벤트를 지원하려면 Event 클래스를 상속받아 비즈니스 로직에 따라 meets_condition()메서드를 구현하기만 하면 된다.

이벤트 시스템 확장

  • 모니터링 중인 시스템의 사용자 트랜잭션에 대응하는 이벤트를 지원해야 한다고 가정
  • 아래 클래스를 하나 추가하는 것만으로 기존 코드가 잘 동작한다.
class TransactionEvent(Event):
    """시스템에서 발생한 트랜잭션 이벤트"""
    @staticmethod
    def meets_condition(event_data: dict):
        return event_data["after"].get("transaction") is not None
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 클래스는 필요할 때마다 새로운 유형의 이벤트를 추가할 수 있기 때문에, 새로운 타입의 확장에 대해 개방되어 있다.

OCP 최종 정리

  • 이 원칙은 다형성의 효과적인 사용과 밀접하게 관련되어 있다. OCP 원칙을 지킨다면 다형성을 따르는 형태의 계약을 만들고, 모델을 쉽게 확장할 수 있는 구조로 디자인할 수 있다.
  • 다만, 특정 요구 사항에 대해 적절한 추상화가 다른 유형의 요구사항에 대해서는 적절하지 않을 수 있다. 이러한 경우 가장 확장 가능한 요구사항에 적합하게 폐쇄해야한다.

리스코프 치환 원칙(LSP)

  • Liskov substitution principle
  • 어떤 클래스에서든 클라이언트는 특별한 주의를 기울이지 않고 하위 타입을 사용할 수 있어야 한다.
  • 즉, 클라이언트는 완전히 분리되어 있으며 클래스 변경 사항과 독립되어야 한다.
  • 공식 정의: S가 T의 하위 타입이라면 프로그램을 변경하지 않고 T 타입의 객체를 S 타입의 객체로 치환 가능해야 한다.
  • 이것은 인터페이스 디자인과 관련이 있다. 좋은 클래스는 명확하고 간결한 인터페이스를 가지고 있으며, 하위 클래스가 해당 인터페이스를 따르는 한 프로그램은 정상적으로 동작한다.
  • 계약을 통한 설계와도 관련이 있다. 주어진 타입과 클라이언트 사이에는 계약이 필요하며, 하위 클래스는 상위 클래스에서 정의한 계약을 따라야한다.

도구를 사용해 LSP 문제 검사하기

  • LSP 문제를 Mypy나 Pylint 등으로 쉽게 검출할 수 있다.

메서드 서명의 잘못된 데이터타입 검사

  • 코드 전체에 타이핑을 사용했다면 초기에 LSP 준수 여부를 빠르게 확인할 수 있다.
  • 클래스의 하위 클래스 중 하나가 호환되지 않는 방식으로 메서드를 재정의하면 Mypy는 어노테이션을 검사하여 이를 확인한다.
  • 파생 클래스가 부모 클래스에서 정의한 파라미터와 다른 타입을 사용하거나, 다른 타입을 반환한다면 오류가 발생한다.
  • 참고로, 이터러블 등의 인터페이스를 통해 파라미터를 처리한다면 LSP에서 벗어나지는 않지만, 서명의 타입이 리스트도 아니고 사전도 아니기 때문에 문제가 있다. 이 경우 서명의 타입이 명확해지도록 수정이 필요하다.

Pylint로 호환되지 않는 서명 검사

  • 메서드의 서명 자체가 완전히 다른 경우 또한 Mypy와 Pylint를 통해 잡을 수 있다.
  • Mypy로 먼저 이러한 유형의 오류를 잡고, Pylint를 실행해 더 많은 통찰을 얻는 것도 좋다?
  • Pylint가 더 엄격한 잔소리꾼인것같다.

애매한 LSP 위반 사례

  • LSP를 위반한 것이 명확하지 않아서 자동화된 도구로 검사하기 애매한 경우에는, 코드 리뷰를 하며 자세히 살펴보는 수밖에 없다.
  • 계약이 수정되는 경우는 특히 자동으로 감지하기가 더 어렵다.
# 사전조건을 확인하도록 수정
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)
# 올바른 이벤트 유형 탐지 전 사전조건 검사
class SystemMonitor:
    """시스템에서 발생한 이벤트 분류"""
    
    def __init__(self, event_data):
        self.event_data = event_data
    
    def identify_event(self):
        Event.meets_condition_pre(self.event_data)
        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)
  • 계약에는 최상위 레벨의 키 before와 after가 필수이고, 그 값 또한 사전 타입이어야 한다고 명시되어 있다.
  • 트랜잭션 이벤트 클래스는 transaction이라는 키를 필수로 요구하지 않기 때문에 수정하지 않아도 되지만, 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
        )
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__
>>> 'UnknownEvent'

LSP 최종 정리

  • LSP는 객체지향 소프트웨어 설계의 핵심이 되는 다형성을 강조하기 때문에 좋은 디자인의 기초가 된다.
  • 계약에 의한 디자인같은 다른 원리들와도 관계를 가진다. 새로운 클래스가 원래의 계약과 호환되지 않는 확장을 하려고 하면 클라이언트와의 계약은 깨지게 된다.
  • LSP에서 제안하는 방식으로 신중하게 클래스를 디자인하면 계층을 올바르게 확장하는데에 도움이 된다.
  • 즉 LSP는 OCP에 기여한다.

인터페이스 분리 원칙

  • Interface Segregation Principle
  • 그동안 제시되었던 작은 인터페이스에 대한 가이드라인
  • 인터페이스란 객체가 노출하는 메서드의 집합이며, 다른 클라이언트에서 호출할 수 있는 요청들
  • 파이썬에서 인터페이스는 클래스 메서드의 형태를 보고 암시적으로 정의된다. -> 덕타이핑을 통해 객체지향의 인터페이스와 같은 기능 수행
    • 예를 들어 len()이라는 내장함수에서 사용할 수 있는 객체를 만들고 싶다면, __len__()이라는 매직메서드를 정의
    • 이렇게 느슨한 규칙처럼 정의된 사항을 프로토콜이라 한다.
    • 참고
  • 덕 타이핑: 어떤 새가 오리처럼 걷고 꽥꽥 소리를 낸다면 오리여야만 한다. 즉 모든 객체는 자신이 가지고 있는 메서드와 자신이 할 수 있는 일에 의해서 표현된다.
  • 덕 타이핑은 파이썬에서 인터페이스를 정의하는 유일한 방법이었지만, 파이썬 3에서 추상 기본 클래스 개념이 도입되어 파생 클래스가 구현해야 할 부분을 정의할 수 있게 되었다.
  • 또한 이 모듈에는 가상 하위 클래스라는 타입을 계층구조에 등록하는 기법이 포함되어 있다.
  • 인터페이스 분리 원칙을 추상적으로 말하자면, 다중 메서드를 가진 인터페이스가 있다면 매우 구체적인 구분에 따라 더 적은 수의 메서드(가급적이면 단 하나)를 가진 여러 개의 메서드로 분할하는 것이 좋다는 것
  • 인터페이스를 최대한 작게 쪼개야 인터페이스를 구현하는 각 클래스가 명확한 책임을 지니게 된다.

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

  • 여러 데이터 소스에서 이벤트를 파싱하는 인터페이스 가정
  • EventParser
    • from_xml()
    • from_json()
  • 이것을 구현한 클래스는 from_xml() 메서드와 from_json() 메서드를 모두 구현해야 한다. 하지만 둘 중 하나만 필요하다면 나머지 하나는 필요없는 메서드가 된다.

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

  • 앞의 인터페이스는 각각 하나의 메서드를 가진 두 개의 인터페이스로 분리하는 것이 좋다.
  • XMLEventParser
    • from_xml()
  • JSONEventParser
    • from_json()
  • 이렇게 하면, 작은 객체를 이용해 모든 기능을 유연하게 조합할 수 있다.
  • ISP는 SRP와 비슷하지만, ISP는 인터페이스에 대한 내용이다. ISP를 잘 지킨 인터페이스를 상속한 클래스가 SRP를 준수할 수 있다.

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

  • 인터페이스에 메서드가 딱 한가지만 있어야 하는 것은 아니다.
  • 컨텍스트 관리자를 추상화한 믹스인 클래스를 제공하는 경우, 해당 클래스에는 __enter____exit__ 두 메서드가 있어야 컨텍스트 관리자의 역할을 할 수 있다.

의존성 역전

  • 코드가 세부 사항이나 구체적인 구현에 적응하도록 하지 않고, 대신 API같은 것에 적응하도록 하는 것
  • 추상화를 통해 세부 사항에 의존하지 않도록 해야 하지만, 반대로 세부 사항은 추상화에 의존해야 한다.
  • A와 B 두 객체가 상호 교류를 하고, A가 B의 인스턴스를 사용하며 코드가 B에 크게 의존하는 경우 B가 수정되면 원래의 코드는 쉽게 깨진다.
  • 이 경우에는 인터페이스를 개발하고, 코드가 B의 구체적인 구현이 아닌 인터페이스에 의존하도록 해야 한다. 이 인터페이스를 준수하는 것은 B의 책임이다.
  • 추상화는 인터페이스 형태로 제공되며, 파이썬에서는 추상 기본 클래스의 형태로 제공된다.
  • 시스템이 변경, 수정 또는 확장될 것으로 예상되는 지점에 유연성을 확보하기 위해 인터페이스를 사용한다.

엄격한 의존의 예

  • 이벤트 모니터링 시스템으로 다시 돌아가보자. 이 시스템의 마지막 부분은 식별된 이벤트를 데이터 수집기로 전달해 분석하는 것이었다. 데이터를 목표지에 전송하는 아래와 같은 클래스를 만들 수 있다.
  • Syslog
    • send()
  • 하지만 이 경우 Syslog로 데이터를 보내는 방식이 변경되면 EventStreamer를 수정해야 하므로 좋은 디자인이 아니다.

의존성을 거꾸로

  • 이러한 문제를 해결하려면 EventStreamer가 구체 클래스가 아닌 인터페이스와 대화하도록 하는 것이 좋다.
  • DataTargetClient
    • send()
  • 인터페이스로 인해 EventStreamer는 특정 데이터 대상의 구체적인 구현과 관련이 없어졌고, 실제 인터페이스를 정확하게 구현하고 변화를 수용하는 것은 특정 데이터 대상 각각에 달려있다.
  • 심지어 런타임 중에도 send() 메서드를 구현한 객체의 프로퍼티를 수정해도 여전히 잘 동작한다.
  • 파이썬은 동적 타입의 언어이기 때문에, 인터페이스를 사용하지 않고도 EventStreamer에 send() 메서드를 가진 객체를 넘길 수 있다.
  • 하지만 추상 기본 클래스를 사용하는 것은 좋은 습관이다. 추상 기본 클래스로 인해 덕 타이핑이 가능해지면 모델의 가독성이 높아진다. 또한 파이썬이 너무 유연하여 발생하는 실수도 줄일 수 있다.

요약

  • 디자인을 잘못하면 미래에 많은 비용이 든다. 설계가 정확할 것인지, 그리고 기존 소프트웨어가 앞으로 수년 후에도 융통성있게 변화에 적응할 수 있는지 확인할 방법은 없다. 바로 이러한 이유 때문에 원칙에 충실해야 한다.
  • SOLID 원칙은 만능 해결책이 아니지만, 과거 프로젝트에서 검증된 좋은 가이드라인을 따름으로서 성공할 가능성이 높은 소프트웨어를 만들도록 도와준다.
profile
developer hamdoe

0개의 댓글