쇼핑 예제를 통해 주문하는 과정을 이벤트 중재자를 사용해 풀어내려한다.
워크 플로우

각 단계별 세부 과정은 비동기적으로 수행된다.
OrderCreated 이벤트 -> 결제 모듈/재고 모듈/알림 모듈이 각각 구독InventoryReserved 이벤트 -> 포장 모듈이 구독OrderCreated → PayCommand, ReserveStockCommand, NotifyAcceptedCommandInventoryReserved → PackCommandOrderPacked → ShipCommand, NotifyShippingReadyOrderShipped → NotifyShippedIventoryReserved 이벤트가 두 번 처리되면 재고가 두 번 차감될 수 있다.주문 발송
OrderCreated 이벤트 발행 -> Event Store에 기록주문 처리 (비동기)
주문 이행
주문 배송
고객 알림
Thread 기반 MSA 가정
각 모듈(결제, 재고, 포장, 배송, 알림)은 독립적인 스레드로 동작 -> 실제 MSA처럼 독립 서비스 느낌을 낸다.
Broker (토픽별 큐)
BlockingQueue를 사용하여 토픽별 이벤트 큐를 만듦.
broker.publish(event) → 구독자가 broker.take(topic)으로 수신한다.
Event Store (저장/재처리)
이벤트를 모두 저장하고 상태(READY, PROCESSED, FAILED)를 기록.
실패한 이벤트는 replayFailed()를 통해 재처리한다.
Mediator (중재자/Orchestrator)
완료 이벤트를 구독하여 다음 커맨드를 비동기로 발행.
-> OrderCreated -> Pay, ReserveStock, NotifyAccepted
-> InventoryReserved -> Pack
-> OrderPacked -> Ship, NotifyShippingReady
-> OrderShipped->NotifyShipped
Command 패턴 (비동기 실행 로그)
Mediatorsms AsyncCommand를 통해 커맨드를 발행하고,
로그에 [COMMAND][ASYNC], [COMMAND][RUN], [COMMAND][DONE]이 찍혀서 비동기성이 드러남.
static final class EventStore {
private final Map<Long, Event> byId = new ConcurrentHashMap<>();
private final Map<Status, Set<Long>> byStatus = new ConcurrentHashMap<>();
public void append(Event e) { ... } // 이벤트 기록
public void markProcessed(long id) { ... }
public void markFailed(long id, String error) { ... }
public void replayFailed(java.util.function.Consumer<Event> sink) {
System.out.println("[REPROCESS] scanning FAILED events…");
for (Event e : findByStatus(Status.FAILED)) {
System.out.printf("[REPROCESS] replay %s (reason=%s)%n", e, e.error);
updateStatus(e.id, Status.READY, null);
sink.accept(e); // 브로커에 다시 발행
}
}
}
실패한 이벤트를 다시 READY로 바꾸고 브로커에 발행 -> 재처리 가능.
public void onOrderCreated(Event e) {
issue(new AsyncCommand("PayCommand",
() -> emitCmd(Topic.CMD_PAY, e), commandsPool)).executeAsync();
issue(new AsyncCommand("ReserveStockCommand",
() -> emitCmd(Topic.CMD_RESERVE_STOCK, e), commandsPool)).executeAsync();
issue(new AsyncCommand("NotifyAcceptedCommand",
() -> emitCmdWith(Topic.CMD_NOTIFY, e, Map.of("notifyType", "ORDER_ACCEPTED")),
commandsPool)).executeAsync();
}
주문 생성 이벤트가 들어오면 동시에 결제/재고 차감/알림 커맨드를 발행.
static final class InventoryModule extends Module {
private final Random rnd = new Random();
protected void handle(Event e) throws Exception {
sleep(300);
// 30% 확률로 실패 (재처리 데모용)
if (rnd.nextInt(10) < 3) throw new RuntimeException("재고 차감 실패(일시적)");
System.out.printf("[%s] 재고 차감 성공 (orderId=%s)%n", id, e.payload.get("orderId"));
}
}
재고 모듈은 일부러 실패 확률을 넣어두어, 재처리 기능이 동작하는 걸 확인할 수 있다.
[BROKER][PUB] Event{id=1, topic=EVT_ORDER_CREATED, status=PROCESSED, payload={orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34, userId=u-123}}
[COMMAND][ASYNC] PayCommand - submitted
[COMMAND][ASYNC] ReserveStockCommand - submitted
[COMMAND][RUN ] PayCommand - running
[COMMAND][RUN ] ReserveStockCommand - running
[BROKER][PUB] Event{id=2, topic=CMD_PAY, status=READY, payload={orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34, userId=u-123}}
[COMMAND][DONE ] PayCommand - completed
[payment#2] start: Event{id=2, topic=CMD_PAY, status=READY, payload={orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34, userId=u-123}}
[COMMAND][ASYNC] NotifyAcceptedCommand - submitted
[BROKER][PUB] Event{id=3, topic=CMD_RESERVE_STOCK, status=READY, payload={orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34, userId=u-123}}
[COMMAND][DONE ] ReserveStockCommand - completed
[inventory#2] start: Event{id=3, topic=CMD_RESERVE_STOCK, status=READY, payload={orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34, userId=u-123}}
[COMMAND][RUN ] NotifyAcceptedCommand - running
[BROKER][PUB] Event{id=4, topic=CMD_NOTIFY, status=READY, payload={orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34, userId=u-123, notifyType=ORDER_ACCEPTED}}
[COMMAND][DONE ] NotifyAcceptedCommand - completed
[notify#1] start: Event{id=4, topic=CMD_NOTIFY, status=READY, payload={orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34, userId=u-123, notifyType=ORDER_ACCEPTED}}
[notify#1] 고객 알림 발송 (type=ORDER_ACCEPTED, orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34)
[BROKER][PUB] Event{id=5, topic=EVT_CUSTOMER_NOTIFIED, status=READY, payload={orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34, userId=u-123, notifyType=ORDER_ACCEPTED}}
[notify#1] done -> EVT_CUSTOMER_NOTIFIED
[MEDIATOR] 고객 알림 완료 이벤트 수신: Event{id=5, topic=EVT_CUSTOMER_NOTIFIED, status=PROCESSED, payload={orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34, userId=u-123, notifyType=ORDER_ACCEPTED}}
[payment#2] 결제 완료 (orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34)
[BROKER][PUB] Event{id=6, topic=EVT_PAYMENT_COMPLETED, status=READY, payload={orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34, userId=u-123}}
[payment#2] done -> EVT_PAYMENT_COMPLETED
[inventory#2] FAIL: Event{id=3, topic=CMD_RESERVE_STOCK, status=FAILED, payload={orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34, userId=u-123}} (재고 차감 실패(일시적))
[REPROCESS] scanning FAILED events…
[REPROCESS] replay Event{id=3, topic=CMD_RESERVE_STOCK, status=FAILED, payload={orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34, userId=u-123}} (reason=재고 차감 실패(일시적))
[BROKER][PUB] Event{id=3, topic=CMD_RESERVE_STOCK, status=READY, payload={orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34, userId=u-123}}
[inventory#1] start: Event{id=3, topic=CMD_RESERVE_STOCK, status=READY, payload={orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34, userId=u-123}}
[inventory#1] 재고 차감 성공 (orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34)
[BROKER][PUB] Event{id=7, topic=EVT_INVENTORY_RESERVED, status=PROCESSED, payload={orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34, userId=u-123}}
[inventory#1] done -> EVT_INVENTORY_RESERVED
[COMMAND][ASYNC] PackCommand - submitted
[COMMAND][RUN ] PackCommand - running
[BROKER][PUB] Event{id=8, topic=CMD_PACK, status=READY, payload={orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34, userId=u-123}}
[COMMAND][DONE ] PackCommand - completed
[packing#1] start: Event{id=8, topic=CMD_PACK, status=READY, payload={orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34, userId=u-123}}
[packing#1] 포장 완료 (orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34)
[BROKER][PUB] Event{id=9, topic=EVT_ORDER_PACKED, status=READY, payload={orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34, userId=u-123}}
[packing#1] done -> EVT_ORDER_PACKED
[COMMAND][ASYNC] ShipCommand - submitted
[COMMAND][RUN ] ShipCommand - running
[BROKER][PUB] Event{id=10, topic=CMD_SHIP, status=READY, payload={orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34, userId=u-123}}
[COMMAND][DONE ] ShipCommand - completed
[COMMAND][ASYNC] NotifyShippingReady - submitted
[shipping#2] start: Event{id=10, topic=CMD_SHIP, status=READY, payload={orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34, userId=u-123}}
[COMMAND][RUN ] NotifyShippingReady - running
[BROKER][PUB] Event{id=11, topic=CMD_NOTIFY, status=READY, payload={orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34, userId=u-123, notifyType=SHIPPING_READY}}
[COMMAND][DONE ] NotifyShippingReady - completed
[notify#2] start: Event{id=11, topic=CMD_NOTIFY, status=READY, payload={orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34, userId=u-123, notifyType=SHIPPING_READY}}
[notify#2] 고객 알림 발송 (type=SHIPPING_READY, orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34)
[BROKER][PUB] Event{id=12, topic=EVT_CUSTOMER_NOTIFIED, status=READY, payload={orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34, userId=u-123, notifyType=SHIPPING_READY}}
[notify#2] done -> EVT_CUSTOMER_NOTIFIED
[MEDIATOR] 고객 알림 완료 이벤트 수신: Event{id=12, topic=EVT_CUSTOMER_NOTIFIED, status=PROCESSED, payload={orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34, userId=u-123, notifyType=SHIPPING_READY}}
[shipping#2] 배송 처리 (orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34)
[BROKER][PUB] Event{id=13, topic=EVT_ORDER_SHIPPED, status=READY, payload={orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34, userId=u-123}}
[shipping#2] done -> EVT_ORDER_SHIPPED
[COMMAND][ASYNC] NotifyShipped - submitted
[COMMAND][RUN ] NotifyShipped - running
[BROKER][PUB] Event{id=14, topic=CMD_NOTIFY, status=READY, payload={orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34, userId=u-123, notifyType=SHIPPED}}
[COMMAND][DONE ] NotifyShipped - completed
[notify#1] start: Event{id=14, topic=CMD_NOTIFY, status=READY, payload={orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34, userId=u-123, notifyType=SHIPPED}}
[notify#1] 고객 알림 발송 (type=SHIPPED, orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34)
[BROKER][PUB] Event{id=15, topic=EVT_CUSTOMER_NOTIFIED, status=PROCESSED, payload={orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34, userId=u-123, notifyType=SHIPPED}}
[notify#1] done -> EVT_CUSTOMER_NOTIFIED
[MEDIATOR] 고객 알림 완료 이벤트 수신: Event{id=15, topic=EVT_CUSTOMER_NOTIFIED, status=PROCESSED, payload={orderId=782b4c7b-0f2f-42a1-a583-3d5e0160bb34, userId=u-123, notifyType=SHIPPED}}
[BROKER][PUB] Event{id=1, topic=EVT_ORDER_CREATED, status=PROCESSED, ...}[COMMAND][ASYNC] PayCommand - submitted / RUN / DONE[BROKER][PUB] Event{id=2, topic=CMD_PAY, ...}[payment#2] start: ... → 결제 완료 → [BROKER][PUB]EVT_PAYMENT_COMPLETED(id=6) → done[COMMAND][ASYNC] ReserveStockCommand - submitted / RUN / DONE[BROKER][PUB] Event{id=3, topic=CMD_RESERVE_STOCK, ...}[inventory#2] start: ... → FAIL ... (재고 차감 실패(일시적))FAILED 상태 기록.[COMMAND][ASYNC] NotifyAcceptedCommand - submitted / RUN / DONE[BROKER][PUB] Event{id=4, topic=CMD_NOTIFY, ... notifyType=ORDER_ACCEPTED}[notify#1] start: ... → 고객 알림 발송(ORDER_ACCEPTED)[BROKER][PUB] EVT_CUSTOMER_NOTIFIED(id=5)[MEDIATOR] 고객 알림 완료 이벤트 수신: id=5요약: 주문 생성 후, 결제/재고.접수알림이 동시에 진행된다. 결제 성공, 알림 성공, 재고만 1차 실패.
[REPROCESS] scanning FAILED events…[REPROCESS] replay Event{id=3, ...} (reason=재고 차감 실패(일시적))[BROKER][PUB] Event{id=3, topic=CMD_RESERVE_STOCK, status=READY, ...}[inventory#1] start: id=3 ... → 재고 차감 성공[BROKER][PUB] EVT_INVENTORY_RESERVED(id=7) → done[COMMAND][ASYNC] PackCommand - submitted / RUN / DONE[BROKER][PUB] Event{id=8, topic=CMD_PACK, ...}[packing#1] start: id=8 ... → 포장 완료[BROKER][PUB] EVT_ORDER_PACKED(id=9) → done[COMMAND][ASYNC] ShipCommand - submitted / RUN / DONE[BROKER][PUB] Event{id=10, topic=CMD_SHIP, ...}[shipping#2] start: id=10 ... → 배송 처리[BROKER][PUB] EVT_ORDER_SHIPPED(id=13) → done[COMMAND][ASYNC] NotifyShippingReady - submitted / RUN / DONE[BROKER][PUB] Event{id=11, topic=CMD_NOTIFY, ... notifyType=SHIPPING_READY}[notify#2] start: id=11 ... → 고객 알림 발송(SHIPPING_READY)[BROKER][PUB] EVT_CUSTOMER_NOTIFIED(id=12) → done[MEDIATOR] 고객 알림 완료 이벤트 수신: id=12요약: 포장 이후 Mediator가 배송과 배송준비 알림을 동시에 흘려보낸다.
[COMMAND][ASYNC] NotifyShipped - submitted / RUN / DONE[BROKER][PUB] Event{id=14, topic=CMD_NOTIFY, ... notifyType=SHIPPED}[notify#1] start: id=14 ... → 고객 알림 발송(SHIPPED)[BROKER][PUB] EVT_CUSTOMER_NOTIFIED(id=15) → done[MEDIATOR] 고객 알림 완료 이벤트 수신: id=15EVT_INVENTORY_RESERVED, EVT_ORDER_PACKED, EVT_ORDER_SHIPPED)를 수신한 뒤 다음 커맨드를 발행한다. 그래서 “재고 확보 전에는 포장 X, 포장 전에는 배송 X”가 지켜진다.
의도
회원시스템 이벤트기반 아키텍처 구축하기
[소프트웨어 아키텍처 101] Ch. 14 이벤트 기반 아키텍처 스타일