지난 시간 시스템의 모든 입력을 이벤트로 정의내렸다.
앱은 이벤트 처리기가 됐다.
자연스럽지 못한 부분도 있다.
갑작스런 사고로 인해 BachCreated(해석: "생성된 배치" 또는 "배치 생성"
)라는 이벤트가 발생됐는데,
엄밀히 말하면 배치는 아직 생성되지 않았기 때문이다.
배치 생성이 필요한 상황에 BatchCreatd라....
즉, 시스템의 모든 입력을 이벤트로 정의내리는 것은 적합하지 않다는 말이 된다.
그래서 커맨드(command)의 개념이 나온다.
커맨드란, 시스템의 한 부분에서 다른 시스템으로 전달되는 것이라 말한다.
하지만 뭔가 불충분한 설명이다.
왜냐하면 객체 A가 객체 B에 decrease_quantity
라고 요청을 하는 것도
위의 커맨드의 개념에 부합하긴 한다. 시스템 A에서 시스템 B로 무언가가 전달되고 있기 때문이다.
커맨드의 특징을 보면 비로소 다른 개념이라는게 보인다.
커맨드는 보통 아무 메소드도 들어있지 않은 데이터 구조(dump data structure)로 표현(p.218)한다고 한다.
이벤트 | 커맨드 | |
---|---|---|
이름 | 과거형 | 명령형 |
오류 처리 | 관심 없음 | 오류를 알려줌 |
받는 행위자 | 모든 리스너 | 정해진 수신자 |
구조 | dump data structure | dump data structure |
class Command:
pass
@dataclass
class CancelMatch(Command):
"""매치 취소 커맨드"""
matach_id: int
@dataclass
class CancelMatchApply(Command):
"""매치 신청 취소 커맨드"""
match_apply_id: int
@dataclass
class CancelStadium(Command):
"""구장 대관 취소 커맨드"""
product_stadium_id: int
위의 작업은 그리 어려운 일이 아니다.
이벤트로만 정의했던 기존 개념을 이벤트와 커맨드로 세분화했을 뿐이다.
from typing import Union
def toString(num: Union[int, float]) -> str:
return str(num)
res = toString(1.75)
>> 1.75
res = toString(1)
>> 1
Message = Union[commands.Command, events.Event]
def handle(message: Message, uow: unit_of_work.AbstractUnitOfWork):
results = []
queue = [message]
while queue:
message = queue.pop(0)
if isinstance(message, events.Event):
handle_event(message, queue, uow)
elif isinstance(message, commands.Command):
cmd_result = handle_command(message, queue, uow)
results.apend(cmd_result)
else:
raise Exception(f'{message} was not an Event or Command')
return results
세 가지 언급하고 싶은 부분이 있는 코드다.
Union으로 한 개 이상의 타입의 파라미터가 가능하다고 명시한 부분이다.
의도가 한 눈에 들어와 가독성이 좋았다.
이벤트와 커맨드는 시스템 입력들이다.
이벤트 또는 커맨드
와 handle_event
핸들러 중간에
handle이라는 추상화 헬퍼 함수가 있다.
중간에 위치한 헬처 함수가 handle_command 또는 handle_event를 호출한다.
이벤트의 경우 리턴 값은 빈 리스트다.
커맨드의 경우 리턴 값은 강제로 일으킨 에러다.
이 부분은 아직 임시 방편으로, 없어져야할 부분이라고 한다.
CQRS를 배우면서 개선해나갈 예정.
커맨드 처리 방법을 한 번 보자.
def handle_command(
command: commands.Command,
queue: List[Message],
uow: unit_of_work.AbstractUnitOfWork
):
logger.debug('handling command %s', command)
try:
handler = COMMAND_HANDLERS[type(command)]
result = handler(command, uow=uow)
queue.extend(uow.collect_new_events())
return result
except Exception:
logger.exception('Exception handling command %s', command)
raise
커맨드 디스패처는 커맨드 한 개에 핸들러 한 개만 허용한다.
오류 발생하면 그걸로 끝이라는 말이다.
평소 고민하던 부분이었다.
상품 환불 요청 -> 비즈니스 규칙에 따른 환불 정도 산출 -> (도메인 기준) 상품 환불 접수 처리 완료 -> PG에 환불 요청, 카카오 알림톡 발송, 쿠폰 차감, 포인트 차감
EDM 또는 이벤트 기반으로 아키텍처를 구축하진 않았다.
다만, 새로운 시각으로 보게 됐다.
환불 요청에 대한 주문 상태 변경 혹은 상품 업데이트는 커맨드다.
하지만 PG에 cancel API 호출하기, 카카오톡 서버에 알림톡 발송 요청하기, 쿠폰 반환하기, 포인트 차감하기 등은 이벤트다.
관심사를 최대한 파편화하여 격리시켰다. 격리된 각각의 파편은 독립적으로 실패한다. 따라서 도메인 모델 관점에서 시스템 신뢰성은 높아진 셈이다.
왜냐하면 중요한 건 결국 유저가 환불하기
를 클릭했을 때, 그 요청이 접수됐냐, 안됐냐이기 때문이다. 알림톡이 발송이 안됐다거나, 카카오페이의 서버 에러로 지급이 안됐다거나, 쿠폰 환불이 안됐다거나, 포인트가 덜 혹은 더 많이 지급됐다는 부분은 나중에라도 처리될 수 있는 사건(Event)이기 때문이다.
"이 코드에서 성공해야 하는 부분은 주문을 만드는 커맨드 핸들러 뿐이다. 이 부분은 고객이 신경쓰는 유일한 부분이고 비즈니스 관계자들이 중요하게 여기는 부분이다." - p.225
평소에 CloudWatch에 쌓인 로그를 보진 않는다.
운영팀 혹은 실제 서비스 장애 발생 시 확인하게 된다.
가만히 살펴보면 원인을 찾는 것과 동시에 본능적으로 확인하는 일이 있다.
그것은 에러 발생한 시간을 찾는 것이다.
왜 그 시간 전에는 에러가 안났고, 그 이후에 났을까?
혹시 누가 코드를 배포했나? 아니면 인프라 설정값을 누가 변경했나?
RDS 로그도 그 시간을 기준으로 살펴볼까?
등등 시간 기준으로 메시지들을 바라보게 된다.
from tenacity import (
Retrying,
RetryError,
stop_after_attempt,
wait_exponential
)
def handle_event():
try:
for attempt in Retrying(
stop=stop_after_attempt(3),
wait=wait_exponential()
):
with attempt:
1 + "1"
except RetryError:
print("Attempted 3 times but failed!")
handle_event()
>> Attempted 3 times but failed!
장점 | 단점 |
---|---|
성공해야하는 비즈니스 로직과 나중에 처리해도 되는 부분을 구분할 수 있다. | - |
명시성(CreateBatch)는 암시성(BatchCreatNeeded, BatchCreate)보다 더 낫다 | 비즈니스 로직이 복잡해져서 커맨드와 실패를 작게 만든 이벤트들이 많아져서 관리와 추론이 어려워질 수 있다. 그래도 SRP가 명확한 다양한 코드들이 스파게티 코드보단 낫다고 생각한다. |