모든 클래스는 하나의 책임만 가지며, 클래스는 그 책임을 완전히 캡슐화해야 한다.
이 용어는 로버트 마틴이 그의 저서 기민한 소프트웨어 개발과 원칙, 패턴 실례
로 유명해진 객체 지향 설계 원칙
이란 문서의 같은 이름을 가진 단락에서 소개되었다.
로버트 마틴은 책임을 변경하려는 이유로 정의하고,
어떤 클래스나 모듈은 변경하려는 단 하나 이유만을 가져야 한다고 결론 짓는다.
예를 들어서 보고서를 편집하고 출력하는 모듈을 생각해 보자.
이 모듈은 두 가지 이유로 변경될 수 있다.
이 두 가지 변경은 하나는 실질적이고 다른 하나는 꾸미기 위한 매우 다른 원인에 기인한다.
단일 책임 원칙에 의하면 이 문제의 두 측면이 실제로 분리된 두 책임 때문이며,
따라서 분리된 클래스나 모듈로 나누어야 한다.
다른 시기에 다른 이유로 변경되어야 하는 두 가지를 묶는 것은 나쁜 설계일 수 있다.
ArjanCodes
# 여러 책임을 갖는 Order
class Order:
items = []
quantities = []
prices = []
status = "open"
def add_item(self, name, quantity, price):
self.items.append(name)
self.quantities.append(quantity)
self.prices.append(price)
def total_price(self):
total = 0
for i in range(len(self.prices)):
total += self.quantities[i] * self.prices[i]
return total
def pay(self, payment_type, security_code):
if payment_type == "debit":
print("Processing debit payment")
print(f"Verifing security code: {security_code}")
self.status = "paid"
elif payment_type == "credit":
print("Processing credit payment")
print(f"Verifing security code: {security_code}")
self.status = "paid"
else:
raise Exception(f"Unknown payment type: {payment_type}")
order = Order()
order.add_item("Keyboard", 1, 50)
order.add_item("SSD", 1, 150)
order.add_item("USB cable", 2, 5)
print(order.total_price())
order.pay("debit", "0372846")
Order 객체는 상품을 담는 역할 뿐만 아니라, 주문을 결제하는 책임 까지 갖고 있다.
문제는 이렇다.
debit
, credit
외의 결제 수단이 추가되거나, 기존 결제수단 로직 수정이 필요할 때, Order 클래스를 손봐야 한다.어떤 클래스나 모듈은 변경하려는 단 하나 이유만을 가져야 한다고 결론 짓는다.
위 원칙에 벗어난다.
이제 아래와 같이 리팩터링 하자.
Payment클래스를 생성하여 기존 신(God) 객체(=너무 많은 것을 알고 있는 객체)가 하나의 책임만 갖도록 했다.
class Order:
items = []
quantities = []
prices = []
status = "open"
def add_item(self, name, quantity, price):
self.items.append(name)
self.quantities.append(quantity)
self.prices.append(price)
def total_price(self):
total = 0
for i in range(len(self.prices)):
total += self.quantities[i] * self.prices[i]
return total
class Payment:
def pay_debit(self, order, security_code):
print("Processing debit payment")
print(f"Verifing security code: {security_code}")
self.set_status(order)
def pay_credit(self, order, security_code):
print("Processing credit payment")
print(f"Verifing security code: {security_code}")
self.set_status(order)
def set_status(self, order):
order.status = "paid"
order = Order()
order.add_item("Keyboard", 1, 50)
order.add_item("SSD", 1, 150)
order.add_item("USB cable", 2, 5)
print(order.total_price())
payment = Payment()
payment.pay_credit(order, "0372846")
새로운 기능 추가에는 개방 되고, 기존 로직 수정에 대해서는 폐쇄 돼야 하는 원칙.
from dataclasses import dataclass
@dataclass
class Event:
raw_dict: dict
class UnknownEvent(Event):
"""식별 불가 이벤트"""
class LoginEvent(Event):
pass
class LogoutEvent(Event):
pass
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)
first_event = SystemMonitor(
{
"before": {"session": 0},
"after" : {"session": 1},
}
)
print(first_event.identify_event().__class__.__name__) // LoginEvent
second_event = SystemMonitor(
{
"before": {"session": 1},
"after" : {"session": 1},
}
)
print(second_event.identify_event().__class__.__name__) // UnknownEvent
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):
@staticmethod
def meets_condition(event_data):
return False
class LoginEvent(Event):
@staticmethod
def meets_condition(event_data):
return (
event_data["before"]["session"] == 0
and event_data["after"]["session"] == 1
)
class LogoutEvent(Event):
@staticmethod
def meets_condition(event_data):
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)
res = SystemMonitor(
{
"before" : {"session" : 0},
"after" : {"session": 1},
}
).identify_event()
class TransactionEvent(Event):
@staticmethod
def meets_condition(event_data):
return event_data["after"].get("transaction") is not None
OCP는 다형성의 효과적인 사용과 큰 관계가 있다.
=> 다형성을 따르는 형태의 계약
을 만들고 모델을 쉽게 확장할 수 있는 구조로 디자인하는 것이다.
보호하려는 추상화(LoginEvent, LogoutEvent 등)에 대해 적절한 폐쇄를 해야 한다.
S가 T의 하위 타입이라면, 프로그램을 변경하지 않고 T타입을 S타입으로 치환 가능하다.
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)
lsp.py:7: error: Argument 1 of "meets_condition" is incompatible with supertype "Event"; supertype defines the argument type as "dict[Any, Any]" [override]
lsp.py:7: note: This violates the Liskov substitution principle
lsp.py:7: note: See https://mypy.readthedocs.io/en/stable/common_issues.html#incompatible-overrides
Found 1 error in 1 file (checked 1 source file)
상위 클래스(Event)와 하위 클래스(LoginEvent)와의 인터페이스 계약이 위배되어, 리스코프 치환 원칙이 무너졌다.
계약 관계를 위반하는 추가 사례는 다음과 같다.
class LogoutEvnet(Event):
def meets_condition(self, event_data: dict, override: bool) -> bool:
if override:
return True
Parameters differ from overriden 'meets_condition' method (argumentsdiffer)
부모 클래스는 클라이언트와의 계약을 정의한다.
하위 클래스는 그 계약을 따라야 한다.
상위 클래스에서 사전 조건
검증을 엄격하게 하지 않도록 해보자.
from collections.abc import Mapping
from dataclasses import dataclass
@dataclass
class Event:
raw_data: dict
@staticmethod
def meets_condition(event_data: dict) -> bool:
return False
@staticmethod
def validate_precondition(event_data: dict):
if not isinstance(event_data, Mapping):
raise ValueError(f"{event_data} dict 데이터 타입이 아님!")
for moment in ("before", "after"):
if moment not in event_data:
raise ValueError(f"{event_data}에 {moment} 정보가 없음")
if not isinstance(event_data[moment], Mapping):
raise ValueError(f"event_data[{moment}] dict 데이터 타입이 아님!")
class UnknownEvent(Event):
@staticmethod
def meets_condition(event_data):
return False
@dataclass
class SystemMonitor:
event_data: dict
def identify_event(self):
Event.validate_precondition(self.event_data)
event_cls = next(
(
event_cls
for event_cls in Event.__subclasses__()
if event_cls.meets_condition(self.event_data)
),
UnknownEvent,
)
SystemMonitor(
{
"before" : {"session" : 0},
"after" : {"session": 1},
}
).identify_event()
DbC
는 이렇다.
"before" : {"sessionaaaa" : 0},
이라고 수정 후 실행 시, 에러를 반환하지 않게 하자.KeyError가 발생하지 않도록 LoginEvent, LogoutEvent를 수정하자.
in meets_condition
and event_data["after"]["session"] == 1
KeyError: 'session'
# 기존
class LoginEvent(Event):
@staticmethod
def meets_condition(event_data):
return (
event_data["before"]["session"] == 0
and event_data["after"]["session"] == 1
)
# 개선
class LoginEvent(Event):
@staticmethod
def meets_condition(event_data):
return (
event_data["before"].get("session") == 0
and event_data["after"].get("session") == 1
)
# 기존
class LogoutEvent(Event):
@staticmethod
def meets_condition(event_data):
return (
event_data["before"]["session"] == 1
and event_data["after"]["session"] == 0
)
# 개선
class LogoutEvent(Event):
@staticmethod
def meets_condition(event_data):
return (
event_data["before"].get("session") == 1
and event_data["after"].get("session") == 0
)
다형성
으로 시작해서 다형성
으로 끝났다.
LSP는 상위 타입과 하위 타입의 연결성을 유지하는 디자인 원칙이다.
이러한 원칙을 지키면서 하위 타입을 늘리면 자연스레 OCP를 준수하게 된다.
클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야 한다는 원칙이다.
큰 덩어리의 인터페이스들을 구체적이고 작은 단위들로 분리시킴으로써 클라이언트들이 꼭 필요한 메서드들만 이용할 수 있게 한다.
컴퓨터 프로그래밍 분야에서 덕 타이핑(duck typing)은 동적 타이핑의 한 종류로, 객체의 변수 및 메소드의 집합이 객체의 타입을 결정하는 것을 말한다. 클래스 상속이나 인터페이스 구현으로 타입을 구분하는 대신, 덕 타이핑은 객체가 어떤 타입에 걸맞은 변수와 메소드를 지니면 객체를 해당 타입에 속하는 것으로 간주한다. “덕 타이핑”이라는 용어는 다음과 같이 표현될 수 있는 덕 테스트에서 유래했다. (덕은 영어로 오리를 의미한다.)
만약 어떤 새가 오리처럼 걷고, 헤엄치고, 꽥꽥거리는 소리를 낸다면 나는 그 새를 오리라고 부를 것이다.
덕 타이핑에서는, 객체의 타입보다 객체가 사용되는 양상이 더 중요하다. 예를 들면, 덕 타이핑이 없는 프로그래밍 언어로는 오리 타입의 객체를 인자로 받아 객체의 걷기 메소드와 꽥꽥거리기 메소드를 차례로 호출하는 함수를 만들 수 있다. 반면에, 같은 함수를 덕 타이핑이 지원되는 언어에서는 인자로 받는 객체의 타입을 검사하지 않도록 만들 수 있다. 걷기 메소드나 꽥꽥거리기 메소드를 호출 할 시점에서 객체에 두 메소드가 없다면 런타임 에러가 발생하고, 두 메소드가 제대로 구현되어 있다면 함수는 정상적으로 작동한다. 여기에는 인자로 받은 객체가 걷기 메소드와 꽥꽥거리기 메소드를 갖고 있다면 객체를 오리 타입으로 간주하겠다는 암시가 깔려있다. 바로 이 점이 앞에서 인용한 덕 테스트의 사상과 일치하기 때문에 덕 타이핑이라는 이름이 붙었다.
class Duck:
def quack(self):
print("꽥꽥!")
def feathers(self):
print("오리에게 흰색, 회색 깃털이 있습니다.")
class Person:
def quack(self):
print("이 사람이 오리를 흉내내네요.")
def feathers(self):
print("사람은 바닥에서 깃털을 주워서 보여 줍니다.")
def in_the_forest(duck):
duck.quack()
duck.feathers()
def game():
donald = Duck()
john = Person()
in_the_forest(donald)
in_the_forest(john)
game()
결과
꽥꽥!
오리에게 흰색, 회색 깃털이 있습니다.
이 사람이 오리를 흉내내네요.
사람은 바닥에서 깃털을 주워서 보여 줍니다.
덕 타이핑 관점에서 Person 객체도 오리 객체다.
파이썬에서 덕 타이핑은 매우 많이 쓰이고 있다.
파이썬 튜터리얼의 용어 항목은 덕 타이핑을 다음과 같이 설명하고 있다:
객체의 타입을 다른 타입 객체와의 명시적인 관계를 비교하는 것이 아니라, 그 객체의 메서드나 속성들을 비교함으로써 판별하는 파이썬적인 프로그래밍 스타일이다. (“오리처럼 보이고, 오리처럼 운다면 오리임에 틀림없다.”) 특정 타입 대신 인터페이스를 강조함으로써, 잘 디자인된 코드는 다형적 대체를 허용함으로써 유연성을 향상시킬 수 있다. 덕 타이핑을 이용하면 type()이나 isinstance()를 이용한 테스트를 하지 않는다. 대신, 대개 hasattr() 테스트를 이용하거나, 혹은 EAFP (Easier to ask forgiveness than permission; 하고 나서 용서를 비는 것이 하기 전에 허락을 구하는 것보다 쉽다) 프로그래밍 기법을 이용한다.
추상 기본 클래스는 파생(구체) 클래스가 구현해야 할 일부분을 공통된 기본 동작으로 구현하거나 인터페이스로 정의하는 것이다.
SystemMonitor
<-> Event
<-> LoginEvent
, LogoutEvent
, ...
Event
를 추상 기본 클래스로 지정하면, SystemMonitor와 구체 Event들은 모두 추상화(Event)에 의존할 수 있게 된다.
class EventParser:
def from_xml(self):
pass
def from_json(self):
pass
이 인터페이스 클래스는 각각 하나의 메서드를 가진 두 개의 다른 인터페이스로 분리하는 것이 좋다.
이 예제의 코드가 과연 맞는 코드인가?
왜 메서드 서명에 self 가 없는걸까?
그리고 EventParser() 호출에 대한 에러는 왜 없는것일까
from abc import ABCMeta, abstractmethod
class XMLEventParser(metaclass=ABCMeta):
@abstractmethod
def from_xml(xml_data: str):
"""XML 형태의 데이터를 파싱"""
class JSONEventParser(metaclass=ABCMeta):
@abstractmethod
def from_json(json_data: str):
"""JSON 형태의 데이터를 파싱"""
class EventParser(XMLEventParser, JSONEventParser):
def from_xml(xml_data):
pass
def from_json(json_data):
pass
EventParser()
응집력 관점에서 가능한 단 한 가지 일을 수행하는 작은 인터페이스여야 한다.
그러나 하나 이상의 메서드라도 하나의 클래스에 속행있을 수 있다.
저자가 말하는 컨텍스트 관리자의 __exit__
, __enter__
를 인터페이스로 보는게 맞을까? 구현부(매직 매서드) 아닌가?
p.55쪽의 컨텍스트 관리자 구현 이란 챕터에서 위 두 매직 메서드를 설명하고 있지 않은가.
DIP는 소프트웨어 모듈들을 분리하는 특정 형식을 지칭한다. 이 원칙을 따르면, 상위 계층(정책 결정)이 하위 계층(세부 사항)에 의존하는 전통적인 의존관계를 반전(역전)시킴으로써 상위 계층이 하위 계층의 구현으로부터 독립되게 할 수 있다. 이 원칙은 다음과 같은 내용을 담고 있다.
A는 B의 인스턴스를 사용한다. <=> A는 B를 알고있다. <=> A는 B에 의존적이다.
A가 B를 사용하고 있음에도 불구하고, B가 A에 의존적이도록 의존성을 어떻게 역전시킬까?
이것은 사기인가, 마법인가?
로쿤은 삿포로로 일본 워케이션을 가려고 한다.
소지품 검사를 하는 대기줄에 서 있는 로쿤.
예전엔 보안관 앞에서 로쿤이 스스로 소지품을 하나하나 꺼내며 증명해야 했다.
이것은 OO품목으로써, 안전한 물품이고, 100ml이하의 로션은 기내에 실을 수 있다 등등...
그래서 로쿤은 매번 출국하기 전에 학습을 해야만 했다.
설상가상으로 일본출입국심사대에서 소지품 검사 기준을 자주 바꾸는 바람에 로쿤은 매번 기준을 학습해야 했다.
보안관이 로쿤에 적응하게 하려면 어떻게 개혁을 해야 할까?
전문 검사 스캐너를 도입하기로 했다.
로쿤은 전문 검사 스캐너의 "검사 시작" 버튼을 누른다.
전문 검사 스캐너는 보안관이 작성한 최신 소지품 검사 기준을 기반으로 검사를 하도록 개발됐다.
매번 검사 기준이 바뀌어 전문 검사 스캐너 기기의 구체적인 구현 코드가 변경되더라도 로쿤은 "검사 시작"버튼만 누르기만 하면 된다.
또한, 마찬가지로 전문 검사 스캐너의 의존성도 역전됐다.
까다롭고 복잡한 검사 기준도 스캐너 기기의 "검사 시작" 버튼에 맞추어 세부 구현을 하면 되기 때문이다.
import datetime
from dataclasses import dataclass
@dataclass
class SysLog:
retry: int
log_level: str
to_slack: str
ignore_duplicate: bool
created_at: datetime
def send(self):
pass
class EventStreamer:
def stream(self):
SysLog(
retry=5,
log_level="INFO",
to_slcak="info-channel",
ignore_duplicate=True,
created_at=datetime.datetime.now()
).send()
Syslog
의 서명이 변경될 때마다 EventStreamer
도 변경해야 한다.
또한, 시스템 로그 이외의 유저 로그, 보안 로그 등이 새롭게 추가되고, 기존 stream 방식과는 다르게 구현되길 원한다면, stream에는 if-elif-else문 지옥 파티가 시작된다.
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass
class EventStreamer:
_target: DataTargetClient
def stream(self) -> None:
self._target.send()
# 또는
# for event in events:
# self._target.send(event.serialise())
class DataTargetClient(ABC):
@abstractmethod
def send(self):
pass
class SysLog(DataTargetClient):
def send(self):
pass
class SecurityLog(DataTargetClient):
def send(self):
pass
class UserActivityLog(DataTargetClient):
def send(self):
pass
인터페이스를 도입하여 결합을 낮췄고 OCP, LSP때와 마찬가지로 다형성이 큰 힘을 발휘했다.
특히, 테스트 코드를 작성할 때 좋다.
SysLog는 슬랙 채널에 로그를 발송하고 있다.
테스트 mocking은 좋은 수단이지만, 사용하지 않을 수 있다면 더 좋은 코드다.
아래와 같이 테스트 코드(test.py)에만 정의된 가짜 로그를 생성하고 주입한다.
class MockSysLog(DataTargetClient):
def send(self):
pass
테스트 더블
: 대역 테스트 코드/모듈을 의미. body double을 대역 배우라고 하는 것에서 유래.
디자인을 잘못하면 미래에 많은 비용이 든다.
미래는 불확실하다.
원칙에 충실한 설계를 해나갈 수밖에 없다.(SOLID)