Send a Fact, Not a Command
한국어 원문으로 표시합니다.
한 줄 요약
동기 호출은 "이걸 해 줘"이고 이벤트는 "이런 일이 있었다"이다. 후자는 상대가 지금 살아 있지 않아도 된다.
왜 이게 필요했나
주문이 만들어지면 재고를 줄이고, 포인트를 적립하고, 알림을 보내고, 분석 이벤트를 쌓아야 한다고 합시다. 이것을 동기 호출로 엮으면 주문 API 의 응답 시간은 네 서비스의 응답 시간의 합이 되고, 가용성은 네 서비스 가용성의 곱이 됩니다. 각각 99.9% 라면 합쳐서 99.6% 입니다. 그리고 알림 서비스가 죽으면 주문을 받지 못합니다. 알림 때문에 매출이 멈추는 설계입니다.
이벤트로 바꾸면 주문 서비스는 "주문이 생성됨"이라는 사실 하나만 발행하고 끝납니다. 나머지 넷은 각자의 속도로 그것을 소비합니다. 알림 서비스가 10분 죽어 있어도 주문은 계속 받히고, 살아나면 밀린 이벤트를 처리합니다.
어떻게 동작하나
여기서 중요한 것은 이름입니다. SendNotification 은 명령이고 OrderCreated 는 사실입니다. 명령을 큐로 보내면 그것은 그냥 비동기 RPC 입니다. 발신자가 수신자를 알고 있고, 수신자가 늘어나면 발신자 코드가 바뀝니다. 사실을 발행하면 발신자는 누가 듣는지 모릅니다. 새 소비자가 붙어도 발행자는 그대로입니다. 이것이 결합을 실제로 줄이는 지점입니다.
이벤트 페이로드에는 소비자가 자기 일을 마치는 데 필요한 정보를 충분히 담습니다. 소비자가 발행자의 DB 를 되짚어 조회하면 결합이 되살아납니다. 다만 너무 많이 담으면 이벤트가 스키마가 되고, 스키마는 곧 계약이라 바꾸기 어려워집니다. 실무의 타협은 식별자와 함께 변하지 않을 핵심 필드만 담는 것입니다.
대가도 분명합니다. 최종 일관성입니다. 주문은 생성됐는데 재고는 아직 안 줄었을 수 있는 시간 창이 생깁니다. 그 창이 200ms 인지 30초인지, 그 사이에 사용자가 무엇을 볼지를 제품 차원에서 정해야 합니다. "곧 반영됩니다"라는 화면 문구가 사실은 아키텍처 결정입니다.
이벤트 이름이 설계를 정한다
같은 내용을 담아도 이름에 따라 시스템이 달라집니다. 표로 보면 분명합니다.
| 이름 | 종류 | 발신자가 아는 것 | 소비자가 늘면 |
|---|---|---|---|
SendNotification |
명령 | 누가 처리할지 안다 | 발신자 코드가 바뀐다 |
ReserveStock |
명령 | 누가 처리할지 안다 | 발신자 코드가 바뀐다 |
OrderCreated |
사실 | 아무도 모른다 | 발신자는 그대로 |
PaymentApproved |
사실 | 아무도 모른다 | 발신자는 그대로 |
과거형 동사를 쓰면 저절로 사실이 됩니다. 이름 규칙 하나가 아키텍처를 지킵니다.
명령이 필요한 자리도 있습니다. "지금 이것을 해라" 가 정확히 한 소비자에게 가야 할 때입니다. 그때는 큐(포인트 투 포인트)를 쓰고, 사실은 토픽(발행-구독)으로 보냅니다. 둘을 같은 채널에 섞으면 곧 구분이 사라집니다.
발행이 실패하는 자리
가장 위험한 순간은 DB 는 커밋됐는데 이벤트 발행이 실패하는 것 입니다. 주문은 만들어졌는데 아무도 모릅니다. 반대로 이벤트를 먼저 보내고 DB 가 실패하면 존재하지 않는 주문의 이벤트가 돌아다닙니다.
❌ 위험한 순서
BEGIN; INSERT order; COMMIT;
publish(OrderCreated) ← 여기서 죽으면 이벤트가 영영 안 나간다
✅ 아웃박스 패턴
BEGIN;
INSERT order;
INSERT outbox(topic, payload); ← 같은 트랜잭션 안
COMMIT;
(별도 프로세스가 outbox 를 읽어 발행하고 표시한다)
한 트랜잭션 안에서 둘 다 커밋되므로 "주문은 있는데 이벤트가 없는" 상태가 원천적 으로 불가능해집니다. 대신 발행이 조금 늦어지고, 같은 이벤트가 두 번 나갈 수 있습니다(발행 후 표시 전에 죽으면). 그래서 소비자는 언제나 멱등해야 합니다.
스키마는 계약이다
이벤트를 발행하는 순간 그것은 공개 API 가 됩니다. 누가 듣는지 모르므로 함부로 바꿀 수 없습니다. 두 규칙이면 대부분 견딥니다.
- 필드는 더하기만 한다. 지우거나 이름을 바꾸면 옛 소비자가 깨집니다.
- 버전을 이름에 넣는다. 정말 호환이 깨지는 변경이면
OrderCreated.v2를 새로 내보내고, 한동안 둘 다 발행합니다.
소비자 쪽도 규칙이 있습니다. 모르는 필드는 무시합니다. 엄격한 역직렬화
(additionalProperties: false)를 쓰면 발행자가 필드를 하나 더할 때마다 소비자가
전부 깨집니다.
현장에서 만나는 모습
비동기로 바꾸면 디버깅이 어려워집니다. 동기 호출은 스택트레이스 하나로 끝나지만, 이벤트는 발행 시점과 소비 시점이 떨어져 있습니다. 그래서 이벤트에 상관관계 ID 를 반드시 실어야 합니다. 이것 없이 이벤트 아키텍처를 운영하는 것은 눈을 감고 운전하는 것과 같습니다.
또 하나. 이벤트는 순서가 보장되지 않을 수 있습니다. 같은 주문에 대한 OrderCreated 와 OrderPaid 가 뒤집혀 도착하면 소비자가 깨집니다. 파티션 키를 집합체 ID(예: 주문 ID)로 잡으면 같은 집합체 안에서는 순서가 보장됩니다. 전역 순서는 포기하는 게 보통입니다.
다음 퀴즈에서 확인할 것
이 모듈은 개념만 다룹니다. 이벤트를 실제로 안전하게 발행하는 문제는 바로 다음 모듈의 아웃박스 실습에서 손으로 다룹니다.