[아키텍처 #3] Event-Driven Architecture(EDA) — 브로커로 서비스를 느슨하게 잇기

Go 아키텍처 시리즈

  1. #1 Clean Architecture
  2. #2 Hexagonal & MessageQueue
  3. #3 Event-Driven Architecture — 지금 이 글
  4. #4 Microservices(MSA)

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

1. 한 장으로 보는 그림

EDA — 생산자와 소비자가 브로커를 사이에 두고 서로를 모른다 (원본 제작)
EDA — 생산자와 소비자가 브로커를 사이에 두고 서로를 모른다 (원본 제작)

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)를 본다.

참고

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다