- #1 Clean Architecture
- #2 Hexagonal & MessageQueue
- #3 Event-Driven Architecture — 지금 이 글
- #4 Microservices(MSA)
2편에서 Publisher/Consumer를 한 서비스 안에서 정리했다. 그 이벤트가 서비스 경계를 넘어 흐르기 시작하면 Event-Driven Architecture(EDA)가 된다. 핵심 아이디어는 “무언가 일어났다는 사실(event)을 브로커에 던지고, 누가 그걸 소비하는지는 신경 쓰지 않는다”는 것이다.
1. 한 장으로 보는 그림

Order Service는 order.created 이벤트를 브로커에 발행할 뿐, Inventory·Notification·Analytics가 그걸 듣는지 모른다. 소비자를 새로 붙여도 생산자 코드는 한 줄도 바뀌지 않는다. 이 디커플링이 EDA의 전부라 해도 된다.
2. 명령(Command) vs 이벤트(Event)
| Command | Event | |
|---|---|---|
| 의미 | “이것을 해라”(지시) | “이런 일이 일어났다”(사실) |
| 방향 | 특정 수신자를 지목 | 누가 들을지 모름(브로드캐스트) |
| 시제 | 명령형 (CreateOrder) | 과거형 (OrderCreated) |
| 결합도 | 상대적으로 강함 | 느슨함 |
EDA에서 주고받는 건 대체로 과거형 사실이다. 이름을 OrderCreated·PaymentSettled처럼 과거형으로 짓는 습관이 설계를 바르게 이끈다.
3. 두 가지 조율 방식
- Choreography(코레오그래피) — 중앙 조율자 없이 각 서비스가 이벤트를 듣고 자기 일을 한 뒤 다음 이벤트를 낸다. 느슨하지만 전체 흐름이 코드에 흩어진다.
- Orchestration(오케스트레이션) — 오케스트레이터가 흐름을 지휘한다. 흐름이 한곳에 모이지만 그 지점이 결합점이 된다.
4. 대가(代價) — 최종적 일관성과 Outbox
EDA는 공짜가 아니다. 비동기라 최종적 일관성(eventual consistency)을 받아들여야 하고, “DB 커밋은 됐는데 이벤트 발행은 실패”하는 이중 쓰기 문제가 생긴다. 정석 해법이 Transactional Outbox다 — 이벤트를 같은 트랜잭션 안에서 outbox 테이블에 저장하고, 별도 릴레이가 그걸 읽어 브로커로 보낸다.
// 같은 DB 트랜잭션 안에서 상태 변경 + outbox 적재 (원자성 보장)
func (u *orderUC) Place(ctx context.Context, id string) error {
return u.tx.Do(ctx, func(ctx context.Context) error {
if err := u.repo.Save(ctx, order); err != nil { return err }
return u.outbox.Add(ctx, "order.created", OrderCreated{ID: id}) // 아직 발행 아님
})
// 릴레이(별도 워커)가 outbox를 폴링해 브로커로 발행 → 이중 쓰기 문제 회피
}
발행 어댑터의 위치는 2편 그대로다 — 인터페이스는 UseCase가 소유(outbound port), 구현은 adapter/messaging. EDA는 그 관계를 서비스 사이로 확장한 것뿐이다.
마지막 편에서는 이 서비스들이 독립 배포 단위가 되는 MSA(Microservices)를 본다.
답글 남기기