• S : 단일 책임 원칙
  • O : 개방, 폐쇄의 원칙
  • L : 리스코프 치환 원칙
  • I : 인터페이스 분리 원칙
  • D : 의존성 역전 원칙

단일 책임 원칙

단일 책임 원칙(Single Responsibility Principle - SRP)는 소프트웨어 컴포넌트가 단 하나의 책임을 져야한다는 원칙이다.
클래스가 유일한 책임이 있다는 것은 하나의 구체적인 일을 담당한다는 것을 의미하며, 변화해야 할 이유 또한 한 가지임을 의미한다.

도메인의 문제가 변경되면 클래스를 업데이트 해야 한다. 다른 이유로 클래스를 수정해야 한다면 추상화가 잘못되어 클래스에 너무 많은 책임이 있다는 것을 뜻한다.

이 디자인 원칙은 보다 응집력 있는 추상화를 하는 데 도움이 된다. 어떤 경우에도 여러 책임을 가진 객체를 만들어서는 안된다. 서로 다른 행동을 그룹화 한 객체는 유지보수가 어려워진다.

클래스는 작을수록 좋다. 소프트웨어 디자인에서 SRP는 응집력과 밀접한 관련이 있다. 여기서 추구하려는 것은 클래스에 있는 프로퍼티와 속성이 항상 메서드를 통해서 사용되도록 하는 것이다. 이렇게 하면 이들은 관련된 개념이기 때문에 동일한 추상화로 묶는 것이 가능하다.

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

다음은 SRP를 준수하지 않은 디자인이다.

Untitled 84.png

class SystemMonitor:
    def load_activity(self):
        """소스에서 처리할 이벤트 가져오기"""
 
    def identify_events(self):
        """가져온 데이터를 파싱하여 도메인 객체 이벤트로 변환"""
 
    def stream_events(self):
        """파싱한 이벤트를 외부 에이전트로 전송"""

이 클래스의 문제점은 독립적인 동작을 하는 메서드를 하나의 인터페이스에 정의했다는 것이다. 각각의 동작은 나머지 부분과 독립적으로 수행할 수 있다.

이 디자인 결함은 유지보수를 어렵게 하여 클래스가 경직되고 융통성 없으며 어류가 발생하기 쉽게 만든다.
여기에서 각 메서드는 클래스의 책임을 대표한다. 각각의 책임마다 수정 사유가 발생한다. 즉 메서드마다 다양한 변경의 필요성이 생기게된다.

위 코드에서 load_activity의 데이터의 구조를 변경하는 등의 이유로 수정해야 할 경우 클래스 전체를 변경해야 하며, 이는 적절하지 않다.

데이터의 표현 변경으로 전체 클래스를 변경해서는 안된다.

책임 분산

Untitled 1 35.png

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()

솔루션을 보다 쉽게 관리하기 위해 모든 메서드를 다른 클래스로 분리하여 각 클래스마다 단일 책임을 갖게 한 예이다.

메서드들을 각자의 책임을 가진 여러 객체로 만들고, 이들 객체들과 협력하여 동일한 기능을 수행하는 객체를 만들 수 있다. 이 때 각각의 객체들은 특정한 기능을 캡슐화하여 나머지 객체들에 영향을 미치지 않으며 명확하고 구체적인 의미를 갖는다.

이 변경으로 변경사항이 로컬에만 적용되고 영향이 미미하므로 각 클래스의 유지보수가 쉬워진다.

ActivityReader을 다른 용도로 사용하고자 할 때 이 디자인을 적용하면 단순히 ActivityReader타입의 객체를 사용하면 된다.
이전 디자인에서는 전혀 필요하지 않은 identify_events()stream_events()같은 메서드도 같이 상속 받야아 했지만, 이제는 필요한 메서드만 상속 받을 수 있다.

처리해야 할 로직이 같은 경우에만 한 클래스 내에 여러 메서드를 추가할 수 있다.

개방 폐쇄 원칙

개방/폐쇄 원칙(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)

위 코드는 다음과 같이 동작한다.

>>> 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'

이 디자인은 몇가지 문제점을 가진다. 첫 번째 문제는 이벤트 유형을 결정하는 논리가 일체형으로 중앙 집중화 된다는 점이다.
지원하려는 이벤트가 늘어날수록 메서드가 커지므로 결국 매우 큰 메서드가 될 수도 있다.

또한 elif문을 사용함으로써 새로운 유형의 이벤트를 시스템에 추가할 때마다 메서드를 수정해야 한다.

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

이전 예제의 문제점은 SystemMonitor 클래스가 분류하려는 구체 클래스와 직접 상호작용 한다는 점이다.

개방/폐쇄 원칙을 따르는 디자인을 달성하려면 추상화를 해야 한다.

대안은 SystemMonitor 클래스를 추상적인 이벤트와 협력하도록 변경하고, 이벤트에 대응하는 개별 로직을 각 이벤트 클래스에 위임하는 것이다. 그 후 각각의 이벤트에 다형성을 가진 새로운 메서드를 추가해야 한다.
이 메서드는 전달되는 데이터가 해당 킁래스의 타입과 일치하는지 판단하는 역할을 한다.

Untitled 2 30.png

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 메서드를 구현하기만 하면 된다.

이벤트 시스템 확장

Untitled 3 28.png

새로운 타입의 이벤트를 추가하게 되는 경우 새로운 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)

새로운 이벤트를 추가했지만 SystemMonitor.identify_event() 메서드는 전혀 수정하지 않았다. 따라서 이 메서드가 새로운 유형의 이벤트에 대하여 폐쇄되어 있다고 할 수 있다.

반대로 Event 클래스는 필요할 때마다 새로운 유형의 이벤트를 추가할 수 있게 해준다. 따라서 이벤트는 새로운 타입의 확장에 대해 개방되어 있다고 할 수 있다.

도메인에 새로운 문제가 나타나도 기존 코드를 수정하지 않고 새 코드를 추가하기만 하면 되는것이 이 원칙의 진정한 본질이다.

OCP 최종 정리

이 원칙은 다형성의 효과적인 사용과 밀접하게 관련되어 있다. 다형성을 따르는 형태의 계약을 만들고 모델을 쉽게 확장할 수 있는 일반적인 구조로 디자인하는 것이다.

이 원칙은 소프트웨어 엔지니어링의 중요한 문제인 유지보수성에 대한 문제를 해결한다. OCP를 따르지 않으면 파급 효과가 생겨나거나 작은 변경이 코드 전체에 영향을 미치거나 다른 부분을 손상시키게 된다.

코드를 변경하지 않고 기능을 확장하기 위해서는 보호하려는 추상화에 대해서 적절한 폐쇄를 해야 한다.

리스코프 치환 원칙(LSP)

리스코프 치환 원칙(Liskov substitution principle, LSP)은 설계시 안정성을 유지하기 위해 객체 타입이 유지해야 하는 일련의 특성을 말한다.

LSP의 주된 생각은 어떤 클래스에서든 클라이언트는 특별한 주의를 기울이지 않고 하위 타입을 사용할 수 있어야 한다는 것이다.

Untitled 4 25.png

위 그림에서 클라이언트는 별다른 주의를 기울이지 않고도 타입을 확장하는 여러 하위클래스(subtype1 subtype2 subtypeN)의 인스턴스로 작업을 할 수 있어야 한다.

주어진 타입과 클래스 사이에는 계약이 필요하다. LSP의 규칙에 따르면 하위 클래스는 상위 클래스에서 정의한 계약을 따르도록 디자인해야 한다.

만약 파생 클래스가 부모 클래스에서 정의한 파라미터와 다른 타입이나 반환 값을 사용할 경우 LSP가 위반되었다고 할 수 있다.

애매한 LSP 위반 사례

클라이언트와 제공자 사이의 계약은 몇 가지 규칙을 가지고 있다. 클라이언트는 제공자가 유효성을 검사할 수 있도록 사전조건을 제공하고 제공자는 클라이언트가 사후 조건으로 검사할 값을 반환한다.

부모 클래스는 클라이언트와의 계약을 정의한다. 하위 클래스는 그 계약을 따라야 한다.

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

LSP 최종 정리

LSP는 객체지향 소프트웨어 설계의 핵심이 되는 다형성을 강조하기 때문에 좋은 디자인의 기초가 된다.

새로운 클래스가 원래의 계약과 호환되지 않는 확장을 하려고 하면 클라이언트와의 계약이 깨져서 결과적으로는 확장이 불가할것이다. 또는 확장이 가능하려면 수정에 대해 폐쇄되어야 한다는 원칙을 깨야 한다. 이는 바람직하지 못한 형태이다.

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

인터페이스 분리 원칙

인터페이스 분리 원칙(interface segregation principle, ISP)은 “작은 인터페이스”에 대한 가이드라인을 제공한다.

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

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

덕타이핑은 모든 객체가 자신이 가지고 있는 메서드와 자신이 할 수 있는 일에 의해서 표현된다는 점에서 출발한다.
즉 클래스의 유형, 이름, docstring, 클래스 속성 또는 인스턴스 속성에 관계없이 객체의 본질을 정의하는 것은 궁극적으로 메서드의 형태이다.

추상적으로 ISP는 다음을 뜻한다.
다중 메서드를 가진 인터페이스가 있다면 매우 정확하고 구체적인 구분에 따라 더 적은 수의 메서드(가급적 하나)를 가진 여러 개의 메서드로 분할하는 것이 좋다는 것이다.

재사용성을 높이기 위해 가능한 작은 단위로 인터페이스를 분리한다면 인터페이스 중 하나를 구현하려는 각 클래스가 매우 명확한 동작과 책임을 지니기 때문에 응집력이 높아진다.

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

Untitled 5 22.png

class EventParser: 
    def from_xml(self): 
    
    def from_json(self):

위 클래스는 구체적인 클래스 대신 인터페이스에 종속성이 있는 경우이다.

이것을 인터페이스로 만들려면 파이썬에서는 추상 기본 클래스를 만들고 from_xml()from_json()이라는 메서드를 정의한다.
이 클래스를 상속한 이벤트는 이 메서드들을 구현해야 한다.

만약 클래스가 XML, JSON 둘 중 하나만 필요로 할 경우에도 위 인터페이스에서는 필요하지 않은 나머지 메서드를 제공한다.
이것은 결합력을 높여 유연성을 떨어트리며, 클라이언트가 필요하지 않은 메서드를 구현하도록 한다.

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

앞의 인터페이스는 각각 하나의 메서드를 가진 두 개의 다른 인터페이스로 분리하는 것이 좋다.

Untitled 6 19.png

class XMLEventParser: 
    def from_xml(self): 
 
 
class JSONEventParser: 
    def from_json(self):

이 디자인을 사용하면 XMLEventParser에서 파생된 클래스는 from_xml() 메서드만을 구현하면 되고, 마찬가지로 JSONEventParser에서 파생된 클래스는 from_json()만을 구현하면 된다.

이것으로 두 클래스가 독립성을 유지하게 되었고 새로운 작은 객체를 사용해 모든 기능을 유연하게 조합할 수 있게 되었다. SRP와 유사하지만 주요 차이점은 ISP는 인터페이스에 대해 이야기하고 있다는 점이다.

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

클래스는 다른 클래스들이 확장할 수 있도록 인터페이스를 정의해야 한다.
이것은 응집력의 관점에서 가능한 단 한 가지 일을 수행하는 작은 인터페이스여야 한다.

의존성 역전

의존성 역전(DIP)은 코드가 깨지거나 손상되는 취약점으로부터 보호해주는 디자인 원칙을 제시한다.

의존성을 역전시킨다는 것은 코드가 세부사항이나 구체적인 구현에 적응하도록 하지 않고 API등에 적응하도록 하는 것이다.

추상화를 통해 세부 사항에 의존하지 않도록 해야 하지만, 반대로 세부 사항(구체적 구현)은 추상화에 의존해야 한다.

추상화는 인터페이스 형태로 제공된다.

일반적으로 구체적인 구현이 추상 컴포넌트보다 훨씬 더 자주 바뀐다.
이런 이유로 시스템이 변경, 수정, 확장 될 것으로 예상되는 지점에 유연성을 확보하기 위해 추상화(인터페이스 사용)를 하는 것이다.

엄격한 의존의 예

식별된 이벤트를 전달하는 코드의 예이다.

Untitled 7 17.png

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

위 디자인은 저수준의 내용에 따라 고수준의 클래스가 변경되어야 하므로 좋은 디자인이라고 보기 어렵다.

의존성을 거꾸로

Untitled 8 15.png

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을 구체 클래스가 아닌 인터페이스와 대화하도록 하는 것이 좋다. 이렇게 하면 인터페이스의 구현은 세부 구현사항을 가진 저수준 클래스가 담당하게 된다.

첫 번째 EventStream구현은 Syslog유형의 객체와만 동작했기 때문에 유연성이 떨어진다. 두 번째에서는 .send()메서드를 인터페이스의 메서드로 사용한다. Syslogsend()메서드가 정의된 DataTargetClient 추상 기본 클래스를 확장한다.
이것으로 EventStreamer는 send()가 구현된 객체라면 어떤 것과도 통신할 수 있게 되었다.

이렇게 의존성을 동적으로 제공한다고 의존성 주입(dependency injection) 이라고도 한다.