콘텐츠로 이동

EDA (Event-Driven Architecture)

분류: Layer 6 - 운영 심화: 관측성 & 복원력 | 작성일: 2026-03-22 | 선수지식: Queue/Worker Basics, Retry / Backoff / Idempotency

EDA(Event-Driven Architecture)는 서비스가 다른 서비스를 직접 호출해 기다리는 대신, “어떤 일이 이미 발생했다”는 이벤트를 발행하고 각 소비자가 독립적으로 반응하게 만드는 아키텍처 패턴이다.

여기서 이벤트(event) 는 명령이 아니라 사실 기록이다. order.created는 “주문을 만들어라”가 아니라 “주문이 생성되었다”는 불변 사실을 뜻한다. 이벤트를 만드는 쪽은 Producer(생산자) 또는 Publisher라고 부르고, 이벤트를 받아 처리하는 쪽은 Consumer(소비자) 또는 Subscriber라고 부른다. 이 둘 사이에서 이벤트를 저장, 라우팅, 재전달하는 인프라가 Broker(브로커) 다.

EDA의 핵심은 “비동기로 바꾸면 빠르다”가 아니다. 핵심은 동기 호출로 묶여 있던 서비스의 시간, 장애, 배포 의존성을 브로커를 기준으로 느슨하게 나누는 것이다.

마이크로서비스 환경에서 직접 API 호출은 단순해 보이지만, 호출 수가 늘어날수록 한 요청의 성공 여부가 여러 서비스의 상태에 동시에 묶인다. 예를 들어 OrderService가 주문 생성 중 PaymentService, InventoryService, NotificationService를 순서대로 동기 호출한다고 하자. 세 하위 서비스가 각각 99.9% 성공하더라도 전체 성공률은 단순 독립 실패 가정에서 0.999 x 0.999 x 0.999 = 99.7003%가 된다.

지연 시간도 같은 방식으로 합쳐진다. 주문 저장 80ms, 결제 승인 250ms, 재고 차감 120ms, 이메일 발송 400ms라면 사용자는 이메일 발송 지연까지 주문 API 응답 시간으로 경험한다. 이메일이 주문 생성의 본질적 결정에 필요하지 않다면, 이 지연은 불필요한 결합이다.

EDA는 이런 요청을 두 부분으로 나눈다.

구분의미예시
결정 경로사용자에게 지금 확정해 알려야 하는 작업주문 레코드 생성, 결제 승인처럼 즉시 결과가 필요한 일
반응 경로결정 이후 각 서비스가 독립적으로 할 작업알림, 감사 로그, 추천 모델 갱신, 검색 인덱스 업데이트

직접 호출에서는 반응 경로가 결정 경로 안으로 들어온다. EDA에서는 결정 경로가 이벤트를 남기고 끝난 뒤, 반응 경로가 브로커를 통해 분리된다.

2.5 선행 기술의 한계 — 직접 호출에서 이벤트로

섹션 제목: “2.5 선행 기술의 한계 — 직접 호출에서 이벤트로”

직접 호출 구조는 처음에는 이해하기 쉽다. 호출자가 대상 서비스의 주소를 알고, 요청을 보내고, 응답을 받으면 된다. 문제는 서비스가 늘어나면 호출자가 너무 많은 것을 알아야 한다는 점이다. OrderService가 결제, 재고, 알림, 쿠폰, 회계, 데이터 분석 서비스를 모두 직접 호출하면 주문 서비스는 주문 도메인보다 주변 서비스의 장애와 배포 일정에 더 민감해진다.

EDA는 이 한계를 “사실을 기록하고 관심 있는 쪽이 가져간다”는 방식으로 푼다. OrderServiceorder.created 이벤트를 브로커에 발행한다. 결제 서비스는 결제가 필요하면 구독하고, 알림 서비스는 알림이 필요하면 구독한다. 이후 새로운 감사 로그 서비스가 생겨도 OrderService 코드를 수정하지 않고 구독자만 추가할 수 있다.

직접 호출과 EDA의 응답 경로 차이
sequenceDiagram
participant Client
participant OrderService
participant PaymentService
participant InventoryService
participant NotificationService
participant Broker

Client->>OrderService: 주문 생성 요청
OrderService->>PaymentService: 동기 charge()
PaymentService-->>OrderService: 완료 또는 지연
OrderService->>InventoryService: 동기 reserve()
InventoryService-->>OrderService: 완료
OrderService->>NotificationService: 동기 send()
NotificationService-->>OrderService: 완료
OrderService-->>Client: 모든 하위 호출 뒤 응답

Client->>OrderService: 주문 생성 요청
OrderService->>Broker: order.created 발행
Broker-->>OrderService: 접수
OrderService-->>Client: 주문 접수 응답
Broker-->>PaymentService: 결제 처리
Broker-->>InventoryService: 재고 예약
Broker-->>NotificationService: 알림 발송

이 구조가 문서의 lineage_oneliner와 연결된다. 마이크로서비스의 강결합을 브로커 기반 비동기 이벤트 통신으로 바꾸는 것이 EDA의 등장 이유다.

3.1 이벤트는 명령이 아니라 사실이다

섹션 제목: “3.1 이벤트는 명령이 아니라 사실이다”

이벤트 이름은 보통 과거형으로 둔다. CreateOrder는 명령(command)에 가깝고, OrderCreatedorder.created는 이벤트에 가깝다. 명령은 특정 대상에게 “이 일을 하라”고 지시하지만, 이벤트는 “이 일이 발생했다”고 알린다.

이 차이가 중요하다. Producer가 “PaymentService야 결제해”라고 말하면 결제 서비스의 존재와 API 계약을 알아야 한다. Producer가 “주문이 생성되었다”고만 말하면 결제, 재고, 알림, 분석 서비스가 각자의 책임에 맞게 반응할 수 있다.

이벤트에는 최소한 아래 정보가 들어간다.

{
"eventId": "evt_01HZX9K7",
"eventType": "order.created",
"occurredAt": "2026-03-22T10:15:30.000Z",
"schemaVersion": 1,
"data": {
"orderId": "ord_1001",
"userId": "user_42",
"amount": 39000
}
}

Event schema(이벤트 스키마) 는 이벤트 필드와 타입, 버전의 계약이다. Consumer는 스키마를 기준으로 메시지를 해석하므로, 필드명을 바꾸거나 의미를 바꾸면 Consumer가 깨질 수 있다. 그래서 이벤트에는 schemaVersion이나 명시적인 eventType을 넣고, 새 필드는 가능하면 하위 호환되게 추가한다.

EDA의 기본 구조는 세 요소로 설명할 수 있다.

  1. Producer: 이벤트를 발행하는 서비스다. 누가 소비하는지는 몰라야 한다.
  2. Broker: 이벤트를 저장하고 라우팅하는 중계자다. AWS SNS, SQS, EventBridge, Kafka, RabbitMQ가 여기에 속한다.
  3. Consumer: 이벤트를 받아 자기 소유 상태를 바꾸거나 외부 부수 효과를 수행하는 서비스다.
브로커가 이벤트를 다루는 기본 흐름
flowchart TD
Producer["Producer: 이벤트 발행"] --> Broker["Broker: 저장/라우팅"]
Broker --> Match{"구독 조건 또는 라우팅 규칙"}
Match -->|"일치"| ConsumerA["Consumer A"]
Match -->|"일치"| ConsumerB["Consumer B"]
Match -->|"불일치"| Drop["전달 대상 없음"]
ConsumerA --> AckA{"처리 성공?"}
AckA -->|"예"| DoneA["ack / offset commit"]
AckA -->|"아니오"| RetryA["재시도 또는 DLQ"]

브로커가 없으면 Producer가 모든 Consumer의 주소, 인증, 타임아웃, 재시도 정책을 직접 알아야 한다. 브로커가 있으면 Producer는 브로커에만 이벤트를 쓰고, Consumer는 자기 속도로 처리한다. 이때 Consumer가 느리거나 잠시 죽어도 브로커가 메시지를 보존할 수 있다.

다이어그램의 ack는 Consumer가 “처리를 끝냈다”고 브로커에 확인해 주는 신호다. offset commit은 Kafka 같은 로그 기반 브로커에서 “여기까지 읽었다”는 위치를 저장하는 작업이다. DLQ(Dead Letter Queue)는 반복 실패한 메시지를 본 흐름에서 격리하는 큐다.

Topic(토픽) 은 같은 종류의 이벤트가 모이는 논리적 채널이다. 예를 들어 order-events 토픽에는 order.created, order.paid, order.cancelled 같은 주문 이벤트가 들어갈 수 있다.

Queue(큐) 는 Consumer가 처리할 일을 쌓아두는 대기열이다. 같은 큐를 여러 Worker가 읽으면 메시지 한 건은 보통 그중 한 Worker가 처리한다. 그래서 Queue는 작업 분산과 처리 속도 조절에 강하다.

Pub/Sub(Publish/Subscribe) 는 Producer가 이벤트를 발행하면 여러 Subscriber가 같은 이벤트를 각각 받는 모델이다. 같은 주문 이벤트를 결제, 재고, 알림 서비스가 모두 받아야 한다면 Pub/Sub이 맞다.

Queue와 Pub/Sub 선택

Queue

한 메시지를 하나의 Consumer 인스턴스가 가져가 처리한다. 처리량 조절과 작업 분산이 핵심이다.

이미지 리사이징, 이메일 발송, 정산 작업처럼 같은 일을 여러 Worker가 나눠 처리할 때

Pub/Sub

같은 이벤트를 여러 Subscriber가 각자 받는다. 한 사실에 여러 반응을 붙이는 구조다.

주문 생성 후 결제, 재고, 알림, 감사 로그가 모두 독립적으로 반응해야 할 때

AWS에서는 SNS Topic이 Pub/Sub 팬아웃을 맡고, 각 Consumer 앞에 SQS Queue를 붙여 느린 Consumer를 격리하는 조합이 흔하다.

SNS + SQS Fan-out 패턴
flowchart TD
Order["OrderService"] -->|"Publish order.created"| SNS["SNS Topic: order-events"]
SNS --> PaymentQ["SQS Queue: payment"]
SNS --> InventoryQ["SQS Queue: inventory"]
SNS --> AuditQ["SQS Queue: audit-log"]
PaymentQ --> PaymentWorker["payment worker"]
InventoryQ --> InventoryWorker["inventory worker"]
AuditQ --> AuditWorker["audit worker"]

많은 메시지 시스템은 at-least-once delivery(최소 1회 전달) 를 기본으로 한다. 메시지가 유실되지 않도록 같은 메시지를 한 번 이상 전달할 수 있다는 뜻이다. 이 보장은 “정확히 한 번만 실행된다”는 뜻이 아니다. Worker가 결제를 성공시킨 뒤 ack 전에 죽으면 브로커는 같은 메시지를 다시 전달할 수 있다.

그래서 Consumer는 idempotency(멱등성) 를 가져야 한다. 멱등성이란 같은 요청이나 이벤트를 여러 번 처리해도 최종 결과가 한 번 처리한 것과 같게 만드는 성질이다. 결제 Consumer라면 eventIdorderId + action을 처리 이력 테이블에 저장하고, 이미 처리한 이벤트는 부수 효과를 다시 실행하지 않아야 한다.

간단한 처리 흐름은 다음과 같다.

1. eventId = evt_01HZX9K7 수신
2. processed_events에서 eventId 존재 여부 확인
3. 없으면 결제 승인 또는 재고 차감 수행
4. 같은 트랜잭션 또는 안전한 순서로 eventId 처리 완료 기록
5. 같은 eventId가 다시 오면 skip

멱등성은 Retry / Backoff / Idempotency 문서의 핵심 개념이기도 하다. EDA에서는 이 개념이 선택 사항이 아니라 Consumer 설계의 기본 조건이 된다.

Ordering(순서 보장) 은 이벤트가 발생한 순서대로 Consumer에게 보이는지를 말한다. 주문 하나의 상태 변화는 order.created -> order.paid -> order.shipped 순서가 중요하다. 하지만 모든 주문을 전역 순서로 처리할 필요는 거의 없다.

순서를 지키려면 보통 같은 엔티티 키를 같은 흐름에 묶는다. Kafka라면 orderId를 partition key로 쓰고, SQS FIFO라면 MessageGroupId = orderId처럼 설정한다. 여기서 partition key나 MessageGroupId는 같은 주문처럼 순서를 지켜야 하는 이벤트를 같은 처리 줄에 묶는 키다. 반대로 모든 메시지를 하나의 전역 그룹으로 묶으면 순서는 쉬워지지만 처리량이 크게 줄어든다.

Eventual consistency(최종 일관성) 는 분산된 서비스의 상태가 잠시 어긋날 수 있지만 시간이 지나면 같은 사실을 반영하도록 수렴하는 성질이다. 주문 서비스는 PENDING_PAYMENT 상태를 저장했고, 결제 서비스가 몇 초 뒤 payment.approved를 발행할 수 있다. 이 사이에 UI가 “결제 확인 중”을 보여주는 것은 정상이다. 반대로 사용자에게 이미 “결제 완료”라고 보여준 뒤 나중에 결제가 실패하면 도메인 약속이 깨진 것이다.

EDA를 설계할 때는 “어디까지는 즉시 일관성이 필요하고, 어디부터는 최종 일관성이 허용되는가”를 먼저 정해야 한다.

Choreography(코레오그래피) 는 중앙 조정자 없이 각 서비스가 이벤트에 반응하며 전체 흐름을 만드는 방식이다. order.created를 보고 결제 서비스가 결제하고, payment.approved를 보고 재고 서비스가 예약하는 식이다. 느슨한 결합은 좋지만, 전체 흐름을 한눈에 파악하기 어렵다.

Orchestration(오케스트레이션) 은 Saga orchestrator처럼 중앙 조정자가 각 단계를 호출하거나 이벤트를 지시하는 방식이다. 전체 상태를 추적하기 쉽지만, 조정자가 새 결합 지점이 된다.

구분장점위험 신호어울리는 상황
ChoreographyProducer와 Consumer 결합이 낮다이벤트만 보고 전체 업무 흐름을 추적하기 어렵다알림, 감사 로그, 분석처럼 반응이 독립적일 때
Orchestration진행 상태와 보상 처리를 중앙에서 볼 수 있다조정자가 너무 많은 도메인 규칙을 안다결제, 재고, 배송처럼 단계 순서와 보상이 중요할 때

3.7 프로세스 내 이벤트와 서비스 간 이벤트

섹션 제목: “3.7 프로세스 내 이벤트와 서비스 간 이벤트”

NestJS의 @OnEvent나 Node.js의 EventEmitter도 이벤트라는 이름을 쓴다. 하지만 이것은 보통 프로세스 내 이벤트다. 같은 애플리케이션 프로세스 안에서 모듈 간 콜백을 느슨하게 연결하는 방식이므로, 프로세스가 죽으면 이벤트도 함께 사라진다.

서비스 간 EDA는 다르다. 이벤트가 외부 브로커에 기록되고, Consumer가 다른 프로세스나 다른 서비스로 분리된다. 이 차이를 놓치면 “로컬 이벤트를 emit했으니 EDA를 했다”고 오해하기 쉽다.

구분프로세스 내 이벤트서비스 간 EDA
범위같은 애플리케이션 프로세스 안여러 서비스, 여러 프로세스, 때로는 여러 계정
내구성프로세스가 죽으면 이벤트도 사라질 수 있다Broker가 보존 기간 동안 메시지를 보관한다
실패 처리예외 처리와 로그 중심retry, DLQ, visibility timeout, offset 관리
결합 완화모듈 간 직접 호출을 줄인다서비스 간 시간/장애/배포 결합을 줄인다
학습 포인트이벤트 이름과 handler 흐름 이해스키마, 멱등성, 순서, 최종 일관성까지 함께 설계

따라서 @OnEvent("order.created") 예제는 이벤트 사고방식을 익히는 작은 출발점으로는 좋다. 그러나 결제, 재고, 알림이 서로 다른 서비스라면 외부 브로커와 메시지 내구성, 중복 처리까지 고려해야 한다.

간단한 기준은 다음과 같다.

  • 같은 프로세스 안에서 부수 효과를 분리하려는 목적이면 EventEmitter로 충분할 수 있다.
  • 다른 서비스가 독립적으로 배포되고 장애도 독립적으로 격리되어야 한다면 외부 브로커가 필요하다.
  • 이벤트가 유실되면 비즈니스 상태가 틀어지는 흐름이라면 Outbox와 Consumer 멱등성을 함께 봐야 한다.

온라인 주문 API를 생각해 보자. 처음 설계는 다음처럼 보일 수 있다.

POST /orders
1. Order DB에 주문 저장
2. PaymentService.charge(orderId)
3. InventoryService.reserve(orderId)
4. NotificationService.send(orderId)
5. 사용자에게 응답

이 설계는 읽기 쉽지만 세 문제가 있다.

문제실제로 생기는 일
시간 결합이메일이나 푸시가 느리면 주문 API도 느려진다
장애 결합알림 서비스 장애가 주문 생성 실패로 번질 수 있다
배포 결합새 Consumer가 생길 때마다 주문 서비스 코드가 바뀐다

EDA로 바꾸면 주문 생성의 첫 응답은 “주문 접수”까지로 줄이고, 뒤 작업은 이벤트로 분리한다.

POST /orders
1. Order DB에 주문 저장: status = PENDING_PAYMENT
2. order.created 이벤트 발행
3. 사용자에게 "주문 접수, 결제 확인 중" 응답
Payment Consumer
4. order.created 수신
5. 결제 승인 후 payment.approved 발행
Inventory Consumer
6. payment.approved 수신
7. 재고 예약 후 inventory.reserved 발행
Notification Consumer
8. 각 상태 이벤트에 맞춰 알림 발송

이 흐름에서 중요한 점은 응답 문구다. 아직 결제와 재고가 끝나지 않았다면 사용자에게 “완료”라고 말하면 안 된다. EDA는 최종 일관성을 허용하므로, 화면과 API 계약도 그 사실을 반영해야 한다.

시점일어난 일사용자에게 말할 수 있는 상태
T+0ms주문 요청 수신처리 중
T+80msOrder DB 저장, order.created 발행주문 접수
T+180ms결제 Consumer가 승인 완료결제 완료
T+260ms재고 Consumer가 예약 완료상품 준비 중
T+400ms+알림 Consumer가 이메일 또는 푸시 발송알림 발송 여부는 주문 성공과 분리

동기 설계에서는 이메일 발송 400ms가 API 응답 시간에 들어갈 수 있다. 이벤트 설계에서는 Broker 접수까지만 응답 경로에 남기고, 알림 지연은 알림 Consumer의 문제로 격리한다.

4.4 실패를 넣어 보면 경계가 보인다

섹션 제목: “4.4 실패를 넣어 보면 경계가 보인다”

결제 Consumer가 같은 order.created 이벤트를 두 번 받는다고 하자. 멱등성이 없으면 결제가 두 번 청구된다. 멱등성이 있으면 첫 번째 처리에서 orderId = ord_1001 결제 완료를 기록하고, 두 번째 처리에서는 이미 완료된 주문으로 판단해 skip한다.

재고 Consumer가 5분 동안 죽어 있으면 주문 서비스는 계속 주문 접수를 받을 수 있다. 대신 inventory 큐의 backlog와 oldest message age가 증가한다. 이때 정상적인 시스템은 사용자가 “재고 확인 중” 상태를 볼 수 있고, 운영자는 backlog를 보고 Consumer 장애를 찾을 수 있어야 한다.

결제는 성공했지만 재고가 없다는 사실이 뒤늦게 발견될 수도 있다. 이런 도메인에서는 inventory.reservation_failed 이벤트와 결제 취소 같은 보상 흐름이 필요하다. 이 지점부터는 Saga Pattern의 영역에 가까워진다.

이벤트 설계는 흐름도만으로 끝내면 위험하다. 각 이벤트가 어떤 상태 전이를 만들 수 있는지 테이블로 적어야 한다.

현재 상태이벤트다음 상태주의할 점
없음order.createdPENDING_PAYMENT주문 ID가 이미 있으면 중복 생성하지 않는다
PENDING_PAYMENTpayment.approvedPAID같은 결제 승인 이벤트가 두 번 와도 한 번만 반영한다
PENDING_PAYMENTpayment.failedPAYMENT_FAILED재시도 가능한 실패와 최종 실패를 구분한다
PAIDinventory.reservedREADY_TO_SHIP재고 예약 실패 시 보상 이벤트가 필요하다
PAIDinventory.reservation_failedREFUND_PENDING결제 취소 또는 고객 안내가 필요하다
READY_TO_SHIPshipment.createdSHIPPING배송 서비스의 지연이 주문 생성 실패가 되어서는 안 된다

이 테이블을 쓰면 EDA의 경계가 더 분명해진다. payment.approved가 오기 전에는 PAID가 아니다. inventory.reserved가 오기 전에는 출고 준비가 아니다. 이벤트가 늦게 오는 동안 사용자가 어떤 상태를 보아야 하는지도 이 표에서 결정된다.

상태 전이는 Consumer 멱등성의 기준이기도 하다. 이미 PAID인 주문에 같은 payment.approved가 다시 오면 무시할 수 있다. 하지만 이미 REFUND_PENDING인 주문에 뒤늦은 inventory.reserved가 오면 단순 무시가 아니라 순서 문제나 보상 흐름 충돌로 기록해야 한다.

4.6 이벤트 계약을 작은 JSON으로 점검하기

섹션 제목: “4.6 이벤트 계약을 작은 JSON으로 점검하기”

좋은 이벤트는 Consumer가 필요한 사실을 담되, Producer 내부 모델을 그대로 노출하지 않는다. OrderCreated 이벤트에 주문 테이블의 모든 컬럼을 넣으면 Consumer가 주문 서비스의 내부 스키마에 묶인다. 반대로 orderId만 넣으면 모든 Consumer가 다시 주문 API를 조회해야 해서 결합이 되살아난다.

필드넣는 이유
eventId중복 수신을 감지하는 멱등성 키
eventTypeConsumer가 처리할 이벤트 종류를 구분
occurredAt실제 사건 발생 시각과 처리 지연을 구분
schemaVersion하위 호환 변경과 breaking change를 관리
data.orderId도메인 엔티티 식별자
data.amount결제, 감사 로그 등 다수 Consumer가 쓰는 핵심 값
traceId여러 서비스 로그를 한 요청 흐름으로 묶기

스키마 품질은 장애 때 드러난다. Consumer가 amount를 원 단위로 해석하는데 Producer가 갑자기 센트 단위로 바꾸면 타입은 맞아도 의미가 깨진다. 그래서 이벤트 계약은 “필드가 있다”가 아니라 “필드의 단위, 의미, 변경 규칙이 유지된다”까지 포함한다.

주문 API의 동기 응답 시간이 다음과 같다고 하자.

작업p95 지연
주문 저장80ms
결제 승인250ms
재고 예약120ms
이메일400ms

모두 동기로 묶으면 단순 합산만 해도 p95가 850ms에 가까워질 수 있다. 결제 승인은 주문 완료 판단에 필요하므로 동기 경로에 남기고, 재고 예약과 이메일을 이벤트로 분리하면 응답 경로는 대략 330ms 수준으로 줄어든다. 반대로 결제까지 이벤트로 빼면 응답은 더 빨라질 수 있지만, 사용자는 결제가 실패할 수 있는 주문을 “완료”로 보게 된다.

이 예시는 EDA의 목표가 무조건 응답 시간을 줄이는 것이 아님을 보여준다. 목표는 제품 계약상 즉시 확정해야 하는 일과 나중에 처리해도 되는 일을 구분하는 것이다.

5. EDA를 쓰면 좋아지는 조건과 나빠지는 조건

섹션 제목: “5. EDA를 쓰면 좋아지는 조건과 나빠지는 조건”

EDA는 강력하지만 기본값으로 깔아두는 패턴은 아니다. 먼저 아래 질문으로 판단한다.

질문”예”라면 EDA에 유리한 이유
이 작업이 사용자 응답에 즉시 필요 없는가응답 경로에서 분리해도 사용자 계약이 깨지지 않는다
실패해도 재시도나 보상으로 복구 가능한가DLQ, 재처리, 멱등성으로 운영할 수 있다
같은 사실에 여러 서비스가 관심을 가지는가Pub/Sub으로 새 반응을 독립적으로 추가할 수 있다
Consumer별 처리 속도가 달라도 되는가큐와 브로커가 느린 Consumer를 격리한다
이벤트 이력이 감사나 분석에 가치가 있는가사실 기록이 나중에 추적 자산이 된다

반대로 아래 조건에서는 EDA가 문제를 키울 수 있다.

상황왜 나빠지는가더 단순한 대안
강한 ACID 트랜잭션 필수여러 서비스 상태가 잠시 어긋나는 것을 허용하기 어렵다단일 DB 트랜잭션 또는 동기 API
즉시 최종 결과가 필요함사용자는 “완료”를 기대하지만 시스템은 아직 처리 중이다동기 호출 후 결과 확정
작은 모놀리스브로커, 스키마, 재처리 운영 비용이 가치보다 크다같은 프로세스 함수 호출
전역 순서가 필수순서 보장 때문에 처리량과 확장성이 급격히 줄어든다단일 writer 또는 명시적 상태 머신
관측성이 부족한 팀실패가 HTTP 500처럼 바로 보이지 않고 큐 뒤에 숨는다먼저 로그, 메트릭, DLQ 운영 체계 구축

반례: EDA가 주문 완료 UX를 망치는 경우

섹션 제목: “반례: EDA가 주문 완료 UX를 망치는 경우”

주문 API가 order.created만 발행하고 바로 “주문 완료”를 반환한다고 하자. 결제 Consumer는 2초 뒤 카드 한도 초과로 실패했고, 재고 Consumer도 예약하지 못했다. 사용자는 이미 완료 화면을 봤는데 몇 초 뒤 취소 알림을 받는다. 이런 흐름은 기술적으로 EDA일 수 있지만, 제품 계약으로는 실패다.

이 경우 결제 승인 여부가 주문 완료의 필수 조건이라면 결제는 동기 결정 경로에 남기는 편이 낫다. 결제 성공 후 order.paid 이벤트를 발행해 알림, 감사 로그, 추천 모델 갱신을 분리하면 EDA의 이점과 사용자 계약을 둘 다 지킬 수 있다.

반례: 단순 설정 변경에 브로커를 넣는 경우

섹션 제목: “반례: 단순 설정 변경에 브로커를 넣는 경우”

관리자 한 명이 하루 몇 번 바꾸는 서비스 설정을 모든 변경마다 이벤트로 흘려보내고, 각 서버가 비동기 Consumer로 반영하게 만들 수도 있다. 하지만 실제 요구가 “변경 즉시 모든 요청에 같은 설정 적용”이라면 이벤트 지연과 Consumer 실패가 오히려 위험하다. 이런 경우에는 중앙 설정 저장소와 동기 읽기, 또는 캐시 무효화 정도가 더 단순하다.

AWS 환경에서는 세 서비스를 조합해서 쓰는 일이 많다. 차이는 역할에서 나온다.

AWS 브로커 선택 기준

SQS

Consumer가 pull로 메시지를 가져가며, 메시지 보존과 재시도, DLQ 구성이 쉽다.

처리 속도 조절이 필요한 비동기 작업 큐, Worker 기반 처리

SNS

Topic에 발행된 메시지를 여러 구독 대상에 push한다. 단순 팬아웃에 강하다.

하나의 이벤트를 여러 SQS Queue, Lambda, HTTP endpoint로 동시에 보내야 할 때

EventBridge

이벤트 패턴 매칭, 콘텐츠 기반 라우팅, AWS 서비스 이벤트 연동을 지원한다.

이벤트 내용으로 조건 라우팅하거나 계정/서비스 경계를 넘어 이벤트를 연결할 때

예를 들어 주문 이벤트를 모든 후속 서비스에 전달하려면 SNS Topic으로 팬아웃하고, 각 서비스 앞에 SQS Queue를 둔다. VIP 주문만 별도 처리 큐로 보내야 한다면 EventBridge의 이벤트 패턴 매칭이 더 적합할 수 있다.

오픈소스 브로커와 관리형 서비스를 고를 때는 처리량 숫자만 보지 말고, 재처리 모델과 운영 부담을 함께 봐야 한다.

항목KafkaRabbitMQAWS SQS/SNS
중심 모델append-only 로그와 consumer offsetexchange/queue 기반 메시지 라우팅관리형 큐와 topic 팬아웃
강점고처리량 스트리밍, 과거 이벤트 재생유연한 라우팅, 낮은 지연운영 부담이 낮고 AWS 권한/모니터링과 통합
재처리보존 기간 내 offset reset으로 가능소비 후 삭제가 기본보존 기간 내 재수신 또는 DLQ redrive 중심
운영 부담파티션, replication, broker 운영 이해 필요클러스터와 exchange 정책 이해 필요인프라는 관리형, 대신 AWS 제약과 비용 이해 필요
잘 맞는 경우데이터 파이프라인, 이벤트 로그, 분석복잡한 라우팅, 프로토콜 다양성AWS 기반 백엔드의 작업 큐와 팬아웃

AWS SQS FIFO는 기본 batching 기준 초당 3,000 messages per API method 수준의 한계가 있다. High throughput FIFO 같은 옵션으로 높일 수 있지만, Kafka처럼 긴 이벤트 로그를 재생하는 스트리밍 플랫폼으로 보면 안 된다. 반대로 Kafka는 강력하지만 파티션 키, consumer lag, retention, 재처리 정책을 이해하지 못하면 운영 복잡도가 빠르게 커진다.

브로커를 쓰면 부하가 사라지는 것이 아니라 줄을 세워 보이게 된다. Producer가 초당 1,200건을 발행하고 Consumer 10개가 각각 초당 100건을 처리하면 총 처리량은 초당 1,000건이다. 차이인 초당 200건이 backlog로 쌓인다. 5분이면 200 x 60 x 5 = 60,000건이 밀린다.

이 숫자는 운영 판단에 직접 연결된다.

관측값해석
backlog count만 증가Producer 속도가 Consumer 처리량을 초과한다
oldest message age 증가사용자가 보는 상태가 실제보다 오래 밀린다
DLQ 증가재시도로 해결되지 않는 메시지가 있다
Consumer CPU 낮고 backlog 증가Consumer가 폴링하지 않거나 권한/라우팅 문제가 있다
Consumer CPU 높고 backlog 증가처리량 부족 또는 downstream 병목 가능성이 크다

adjoe의 공개 운영 사례처럼 하루 수억 건 요청, 약 130개 이벤트/SNS Topic, 300개 이상 SQS Queue를 조합하는 구조가 나오는 이유도 이 때문이다. 같은 이벤트라도 팀과 목적별 큐를 분리하면 느린 Consumer가 다른 Consumer를 막지 않는다. 가장 바쁜 Queue가 하루 약 50만 건을 처리하더라도, 나머지 Consumer는 자기 큐의 backlog와 실패율을 독립적으로 관리할 수 있다.

순서 보장은 필요한 범위만 좁게 잡는 것이 좋다.

필요한 순서설계 힌트
같은 주문의 상태 변화 순서orderId를 partition key 또는 message group으로 사용
같은 사용자의 포인트 적립 순서userId 기준으로 묶기
전체 서비스의 모든 이벤트 순서가능하면 피한다. 처리량과 가용성을 크게 잃는다

전역 순서를 요구하면 Consumer를 마음대로 늘리기 어렵다. 반대로 주문별 순서만 필요하면 주문 ID 단위로 병렬성을 얻을 수 있다. “어떤 순서가 실제 도메인 불변식인가”를 먼저 물어야 한다.

EDA에서 Producer와 Consumer는 동시에 배포되지 않을 수 있다. 그래서 스키마 변경은 API 변경보다 더 보수적으로 다루는 편이 안전하다.

변경 유형보통 안전한가이유
optional 필드 추가대체로 안전기존 Consumer는 모르는 필드를 무시할 수 있다
필수 필드 추가위험기존 Producer 또는 Consumer가 값을 모를 수 있다
필드 삭제위험어떤 Consumer가 아직 쓰고 있을 수 있다
필드 이름 변경breakingConsumer 파서가 바로 실패할 수 있다
단위 변경breaking타입은 같아도 의미가 달라진다
enum 값 추가조건부 안전Consumer가 unknown 값을 처리할 수 있어야 한다

실무에서는 새 필드를 추가하고 충분한 기간 동안 두 버전을 같이 흘린 뒤, Consumer 사용 현황을 보고 오래된 필드를 제거한다. 중요한 이벤트라면 schema registry(이벤트 스키마를 중앙에서 관리하는 저장소)나 contract test(Producer 이벤트가 Consumer 기대 형식을 깨지 않는지 확인하는 테스트)를 두어 Producer 변경이 Consumer를 깨는지 배포 전에 확인한다.

첫 학습 단계에서는 세 가지만 기억하면 된다.

  1. 이벤트는 한번 발행되면 과거 메시지도 Consumer가 읽을 수 있다.
  2. Consumer는 자신이 모르는 필드를 무시하고, 모르는 enum은 안전하게 실패해야 한다.
  3. 의미가 바뀌는 변경은 새 이벤트 타입이나 새 버전으로 내는 편이 낫다.

도구 이름부터 고르면 논의가 흐려진다. 먼저 요구를 좁힌다.

질문예라면 더 봐야 할 것
과거 이벤트를 다시 읽어야 하는가Kafka 같은 로그 기반 스트림
Consumer가 자기 속도로 pull해야 하는가SQS, Kafka consumer group
같은 이벤트를 여러 대상에 뿌려야 하는가SNS, Kafka topic, RabbitMQ exchange
이벤트 내용으로 조건 라우팅해야 하는가EventBridge, RabbitMQ topic exchange
자체 운영 인력이 충분한가Kafka/RabbitMQ 자체 운영 가능성
AWS 네이티브 권한과 서버리스가 중요한가SNS/SQS/EventBridge

예를 들어 “주문 생성 후 이메일, 푸시, 감사 로그를 각각 처리하고 싶다”면 SNS + 각 Consumer별 SQS가 단순하다. “모든 주문 이벤트를 7일 동안 보관하고, 분석 Consumer가 나중에 offset을 되돌려 재처리해야 한다”면 Kafka가 더 자연스럽다. “VIP 주문만 별도 큐로 보내고 금액 조건도 봐야 한다”면 EventBridge의 content filtering을 검토한다.

EventBridge류 라우터는 이벤트 내용을 보고 타깃을 고른다. 이때 Producer는 이벤트 하나를 발행하고, 라우팅 규칙이 Consumer 선택을 맡는다.

{
"source": ["com.myapp.orders"],
"detail-type": ["OrderCreated"],
"detail": {
"tier": ["vip"],
"amount": [{ "numeric": [">=", 100000] }]
}
}

위 규칙에 매칭되는 실제 이벤트 payload는 아래처럼 생긴다.

{
"source": "com.myapp.orders",
"detail-type": "OrderCreated",
"detail": {
"orderId": "ord_123",
"tier": "vip",
"amount": 120000
}
}

source, detail-type, detail.tier, detail.amount가 규칙과 맞기 때문에 이 이벤트는 VIP 주문 처리 큐로 라우팅된다. tier"standard"이거나 amount가 100,000 미만이면 Producer는 정상 발행했더라도 이 규칙에는 매칭되지 않는다.

장점은 새 조건을 추가할 때 Producer 코드를 덜 건드린다는 점이다. 위험은 라우팅 규칙이 코드 밖에 있어, 테스트와 변경 이력이 약하면 “이벤트는 발행됐는데 아무 Consumer도 못 받는” 조용한 실패가 생긴다는 점이다.

그래서 조건 라우팅을 쓰는 경우에는 샘플 이벤트와 규칙을 함께 테스트해야 한다. 운영에서 No rules matched에 가까운 상태가 반복되면 권한 문제가 아니라 이벤트 패턴과 실제 payload가 어긋난 것일 수 있다.

EDA에서 가장 위험한 실패 중 하나는 DB 저장과 이벤트 발행이 분리되는 것이다.

1. Order DB에 주문 저장 성공
2. SNS/SQS에 order.created 발행 시도
3. 네트워크 오류로 발행 실패
4. DB에는 주문이 있지만 아무 Consumer도 모름

반대도 가능하다.

1. order.created 이벤트 발행 성공
2. Order DB 트랜잭션 롤백
3. Consumer는 존재하지 않는 주문을 처리하려고 함

Outbox Pattern 은 같은 DB 트랜잭션 안에서 도메인 변경과 이벤트 레코드를 함께 저장하고, 별도 relay가 outbox 테이블을 읽어 브로커에 발행하는 패턴이다.

주문 생성 트랜잭션
- orders 테이블에 주문 저장
- outbox_events 테이블에 order.created 저장
- commit
Outbox relay
- PENDING 이벤트 조회
- Broker에 발행
- 성공하면 PROCESSED 표시

이 패턴은 “DB 커밋은 성공했는데 이벤트만 사라지는” dual write 문제를 줄인다. 하지만 relay도 중복 발행할 수 있으므로 Consumer 멱등성은 여전히 필요하다. Outbox는 중복을 없애는 패턴이 아니라, 유실 가능성을 줄이고 재발행 가능한 지점을 만드는 패턴으로 이해해야 한다.

상세 구현은 L8의 CDC & Outbox 패턴에서 이어서 다룬다. L8에서는 DB 트랜잭션과 복제 로그를 선수지식으로 삼아 WAL을 읽는 CDC, relay의 발행·재시도, 적체 관리까지 심화할 수 있기 때문이다. L6에서는 “상태 변경과 outbox 이벤트 레코드 저장을 같은 DB 트랜잭션에 묶고, 브로커 발행은 relay가 나중에 재시도 가능하게 만든다”는 목적을 잡으면 충분하다.

Outbox를 도입하면 이벤트 유실 가능성은 줄지만, 중복 발행 가능성은 남는다. relay가 브로커에 이벤트를 발행한 직후 죽고 PROCESSED 표시를 하지 못하면, 재시작 후 같은 outbox row를 다시 발행할 수 있다.

따라서 Outbox의 안전성은 두 층으로 봐야 한다.

책임
Producer DB 트랜잭션도메인 상태 변경과 outbox row 저장을 함께 commit
Relaypending 이벤트를 찾아 브로커에 발행하고 상태를 갱신
Consumer같은 eventId가 두 번 와도 부수 효과를 한 번만 수행

Relay를 여러 인스턴스로 늘릴 때는 같은 row를 두 relay가 동시에 집지 않게 해야 한다. SQL에서는 FOR UPDATE SKIP LOCKED 같은 패턴을 쓰기도 한다. 다만 이 문서에서 중요한 것은 SQL 문법이 아니라 “relay 병렬화도 동시성 문제를 만든다”는 감각이다.

Event Sourcing 은 이벤트를 통신 수단이 아니라 상태의 원장으로 삼는 방식이다. 일반 EDA에서는 주문 테이블이 현재 상태의 원본이고 이벤트는 다른 서비스에 사실을 알리는 메시지일 수 있다. Event Sourcing에서는 order.created, payment.approved, shipment.created 같은 이벤트 로그를 재생해 현재 상태를 만든다.

구분일반 EDAEvent Sourcing
원본 상태보통 서비스 DB의 현재 row이벤트 로그 자체
이벤트 용도다른 서비스에 사실 전달상태 재구성의 원천
장점결합 완화, 비동기 처리, 후속 반응 분리감사 이력, 시간 여행 디버깅, 재생 기반 Projection
비용스키마와 Consumer 운영이벤트 버전, snapshot, projection lag, upcaster

Event Sourcing은 감사 이력이 핵심인 금융, 정산, 법적 추적 도메인에서는 강력하다. 하지만 단순 CRUD 서비스에 도입하면 읽기 모델, projection 지연, 과거 이벤트 마이그레이션까지 감당해야 한다. L6에서는 EDA와 Event Sourcing을 같은 것으로 보지 않는 것이 중요하다.

이벤트 발행 방식을 선택할 때는 유실 위험과 운영 복잡도를 같이 본다.

방식장점위험
직접 발행구현이 가장 단순하다DB commit과 broker publish 사이 dual write 실패
Outbox앱 DB 트랜잭션 안에 이벤트 기록을 남긴다relay 운영, 중복 발행, outbox backlog 관리 필요
CDCDB 변경 로그를 읽어 이벤트화한다WAL/binlog, connector, schema evolution 운영 필요

초기에는 직접 발행으로 시작할 수 있다. 하지만 “DB에는 있는데 이벤트가 없다”가 비즈니스 장애가 되는 순간 Outbox나 CDC를 검토해야 한다. 결제, 재고, 정산처럼 누락 비용이 큰 이벤트는 처음부터 Outbox를 설계하는 편이 안전하다.

EDA의 장애는 HTTP 요청 실패처럼 한 곳에서 바로 보이지 않을 때가 많다. 그래서 명령어보다 먼저 실패 신호를 읽을 수 있어야 한다.

신호의미먼저 의심할 것
이벤트 발행 수는 있는데 소비가 없다Broker까지는 왔지만 Consumer가 가져가지 못한다subscription, queue policy, worker 상태
backlog와 oldest age가 함께 증가처리가 생산 속도를 따라가지 못한다Consumer 수, downstream 지연, rate limit
DLQ가 증가한다재시도로 해결되지 않는 poison pill이 있다schema mismatch, 파싱 실패, 비즈니스 예외
같은 결제나 알림이 반복된다at-least-once를 고려하지 않았다idempotency key, 처리 완료 기록
상태가 과거로 되돌아간다순서 보장 범위가 잘못됐다partition key, FIFO message group
”완료” UI 뒤에 취소 알림이 온다최종 일관성 경계를 제품 계약에 반영하지 못했다결정 경로와 반응 경로 재분리
Consumer parse error가 급증한다이벤트 스키마가 깨졌다schema version, producer 배포 변경
DB에는 있는데 이벤트가 없다dual write 실패 가능성이 있다outbox 도입 또는 relay 상태

Poison pill 은 재시도해도 계속 실패하는 메시지다. 예를 들어 필수 필드가 빠졌거나 스키마가 바뀌어 Consumer가 영원히 파싱하지 못하는 이벤트가 여기에 속한다. Poison pill은 정상 메시지 처리까지 막을 수 있으므로 DLQ로 격리하고, 왜 재시도로 해결되지 않는지 원인을 남겨야 한다.

DLQ(Dead Letter Queue) 는 반복 실패한 메시지를 격리하는 큐다. DLQ는 실패를 해결하지 않는다. 실패한 메시지가 사라지지 않게 붙잡아 두고, 원인을 분석할 수 있게 만드는 장치다.

8.1 실패 worked case: 일부 Consumer만 메시지를 못 받는다

섹션 제목: “8.1 실패 worked case: 일부 Consumer만 메시지를 못 받는다”

상황은 이렇다. order.created를 SNS Topic에 발행했고, payment 큐와 audit 큐는 메시지를 받지만 notification 큐만 비어 있다.

이때 Producer 코드를 먼저 고치면 방향이 틀릴 수 있다. 같은 Topic에서 다른 큐는 메시지를 받았으므로 발행 자체는 성공했을 가능성이 크다. 먼저 notification 쪽 subscription이 confirmed인지, filter policy가 payload와 맞는지, SQS access policy가 해당 SNS Topic의 SendMessage를 허용하는지 본다.

이 사례의 학습 포인트는 “이벤트가 발행됐다”와 “모든 Consumer가 받았다”가 다른 사실이라는 점이다. Pub/Sub은 구독자별 전달 경로가 독립적이므로, 한 Consumer만 실패할 수 있다.

8.2 실패 worked case: backlog가 쌓이지만 에러 로그는 없다

섹션 제목: “8.2 실패 worked case: backlog가 쌓이지만 에러 로그는 없다”

Producer는 초당 500건을 발행하고 Consumer는 초당 300건만 처리한다고 하자. 에러 로그는 없지만 backlog는 초당 200건씩 쌓인다. 10분이면 120,000건이 밀린다.

이 장애는 코드 예외가 아니라 용량 문제다. Consumer CPU가 높다면 Worker 수나 downstream 처리량이 부족할 가능성이 크다. Consumer CPU가 낮고 Received가 늘지 않는다면 polling 설정, 권한, 네트워크, queue name 오타를 의심해야 한다.

이때 “재시도 횟수 증가”는 해결책이 아니다. 재시도는 실패한 작업을 다시 넣는 것이므로 처리량 부족 상황에서는 backlog를 더 키울 수 있다.

8.3 실패 worked case: 이벤트 순서가 뒤집힌다

섹션 제목: “8.3 실패 worked case: 이벤트 순서가 뒤집힌다”

payment.approved보다 order.created가 늦게 처리되면 Consumer는 존재하지 않는 주문의 결제를 보게 된다. 원인은 여러 가지다. 다른 partition으로 나뉘었거나, Consumer 병렬 처리 중 한 메시지가 오래 걸렸거나, Producer가 상태별 이벤트를 다른 경로로 발행했을 수 있다.

해결 방향은 “모든 이벤트 전역 순서 보장”이 아니다. 같은 주문에 관한 이벤트만 같은 key로 묶고, Consumer는 예상하지 못한 상태 전이를 만나면 재시도하거나 보류 상태에 넣어야 한다. 주문 A와 주문 B 사이의 순서는 대개 중요하지 않다.

8.4 실패 worked case: 스키마 변경이 조용히 깨진다

섹션 제목: “8.4 실패 worked case: 스키마 변경이 조용히 깨진다”

Producer가 amount를 숫자에서 문자열로 바꿨다고 하자. JavaScript Consumer는 암묵 변환 때문에 당장 죽지 않을 수도 있다. 하지만 정산 Consumer가 문자열 비교를 하면 "9000" > "100000" 같은 잘못된 판단이 생길 수 있다.

스키마 실패는 항상 parse error로 드러나지 않는다. 단위, enum, nullable 의미가 바뀌면 데이터는 통과하지만 비즈니스 결과가 틀어진다. 그래서 이벤트 계약에는 타입뿐 아니라 단위와 의미를 적어야 하고, 중요한 Consumer는 contract test나 샘플 이벤트 테스트를 가져야 한다.

본문 이해에는 아래 절차가 필요하지 않다. 실제 AWS 환경에서 문제가 났을 때 확인할 최소 순서만 남긴다.

이벤트가 처리되지 않을 때
  1. Producer가 이벤트를 발행했는지 본다. SNS라면 NumberOfMessagesPublished, EventBridge라면 matched rule 또는 invocation 지표를 본다.
  2. 대상 Queue에 메시지가 들어왔는지 본다. SQS라면 NumberOfMessagesSent, ApproximateNumberOfMessagesVisible, ApproximateAgeOfOldestMessage를 확인한다.
  3. Worker가 polling하고 있는지 본다. Received가 0이면 Consumer 중단, 권한, 네트워크를 의심한다.
  4. DLQ에 메시지가 있는지 본다. DLQ에 있다면 Consumer 처리 실패이며, 메시지 body와 schema version을 먼저 확인한다.
  5. EventBridge 규칙이 의심되면 샘플 이벤트와 pattern을 aws events test-event-pattern으로 비교한다.
  6. Rule은 match됐는데 target이 실행되지 않으면 SQS/Lambda resource policy와 target permission을 본다.
중복 처리 신고가 들어왔을 때
  1. 같은 eventId, orderId, idempotency key가 여러 번 처리됐는지 로그에서 찾는다.
  2. Consumer가 부수 효과 실행 전에 처리 이력을 확인하는지 본다.
  3. 처리 이력 저장과 부수 효과 실행 사이에 장애가 나면 어떤 상태가 되는지 확인한다.
  4. SQS라면 visibility timeout이 실제 처리 시간보다 짧지 않은지 확인한다.
  5. 재처리해야 한다면 먼저 멱등성 보강 또는 수동 중복 방지 기준을 만든 뒤 DLQ redrive를 한다.
DLQ 보존 기간

SQS 메시지 보존 기간은 기본 4일이고 최대 14일이다. 원본 큐에서 며칠 재시도한 뒤 DLQ로 이동하면 분석 가능 시간이 짧아질 수 있으므로, 장애 분석용 DLQ는 원본 큐보다 길게 잡는 편이 안전하다. 14일은 주말이나 휴일 이후 조사 시간을 확보하기 위한 보수적 설정이다.

개념 A개념 B차이점
EDAMSAMSA는 서비스 분리 방식이고, EDA는 분리된 서비스가 통신하는 방식이다. 함께 쓰일 수 있지만 같은 말은 아니다.
EventCommandEvent는 이미 발생한 사실이고, Command는 특정 대상에게 일을 시키는 요청이다.
QueuePub/SubQueue는 작업을 나눠 처리하고, Pub/Sub은 같은 사실을 여러 Consumer에게 전달한다.
TopicQueueTopic은 발행 채널이고, Queue는 처리 대기열이다. SNS Topic에서 여러 SQS Queue로 팬아웃할 수 있다.
EDACQRSCQRS는 읽기/쓰기 모델 분리이고, EDA는 이벤트 기반 통신이다. CQRS Projection을 이벤트로 갱신할 수 있다.
Outbox Pattern직접 이벤트 발행Outbox는 DB 변경과 이벤트 레코드를 같은 트랜잭션에 묶어 유실을 줄인다.
ChoreographyOrchestrationChoreography는 각 서비스가 이벤트에 반응하고, Orchestration은 중앙 조정자가 흐름을 관리한다.

팀 코드나 설계 문서를 볼 때는 “어디에 브로커가 있는가”보다 “어떤 결합을 줄이려 했는가”를 먼저 본다.

  • 주문, 결제, 알림, 정산처럼 한 요청 뒤에 여러 후속 작업이 붙어 있는가?
  • 후속 작업 중 사용자 응답에 꼭 필요하지 않은 일이 있는가?
  • 직접 HTTP 호출 실패가 상위 요청 실패로 전파되고 있는가?
  • 이벤트 Consumer가 멱등하게 작성되어 있는가?
  • 이벤트 스키마와 버전 변경 규칙이 문서화되어 있는가?
  • DLQ, backlog age, Consumer error rate를 볼 수 있는 대시보드가 있는가?

코드 검색은 보조 수단이다.

Terminal window
rg "@OnEvent|EventEmitter|SqsMessageHandler|PutEventsCommand|snsClient.publish" src

검색 결과가 나오면 “이벤트를 발행하는 곳”, “이벤트를 소비하는 곳”, “브로커 또는 큐 이름”, “실패 시 재시도와 DLQ가 있는지”를 이어서 그려본다.

EDA 학습 확인

  • EDA를 동기 호출 결합 문제에서 출발해 설명할 수 있다.
  • event, producer, consumer, broker, topic, queue, pub/sub을 구분할 수 있다.
  • 이벤트가 명령이 아니라 사실 기록이라는 점을 예시로 설명할 수 있다.
  • 주문/결제/재고 예시에서 결정 경로와 반응 경로를 나눌 수 있다.
  • Eventual consistency가 사용자 계약에 어떤 영향을 주는지 설명할 수 있다.
  • Consumer idempotency가 왜 필요한지 at-least-once 전달과 연결해 설명할 수 있다.
  • Queue, Pub/Sub, EventBridge 라우팅을 어떤 기준으로 고를지 말할 수 있다.
  • EDA를 쓰면 안 되는 반례를 하나 이상 들 수 있다.
  • DLQ, backlog age, duplicate side effect 같은 실패 신호를 해석할 수 있다.
  • Outbox Pattern이 dual write 실패를 어떻게 줄이는지 설명할 수 있다.
  • Saga Pattern: 여러 서비스에 걸친 단계와 보상 트랜잭션을 다루는 패턴
  • CQRS: Command 모델과 Query 모델을 분리하는 설계
  • Event Sourcing: 현재 상태가 아니라 이벤트 로그를 원장으로 삼는 방식
  • Schema Registry: 이벤트 스키마를 중앙에서 관리하고 코드 타입을 생성하는 도구
  • Consumer Group: 여러 Consumer가 같은 스트림을 나누어 읽는 모델
  • Backpressure: 생산 속도가 소비 속도를 넘을 때 시스템이 압력을 제어하는 방법
  1. EDA는 직접 호출의 시간, 장애, 배포 결합을 이벤트와 브로커로 줄이는 패턴이다.
  2. 이벤트는 명령이 아니라 이미 발생한 사실이며, Producer와 Consumer는 서로를 몰라야 한다.
  3. Queue는 작업 분산, Pub/Sub은 여러 Consumer 반응, EventBridge류 라우터는 조건별 연결에 강하다.
  4. EDA는 최종 일관성, 중복 전달, 순서 제약을 드러내므로 멱등성, 스키마, 관측성이 필수다.
  5. 사용자에게 즉시 확정해야 하는 결정은 동기 경로에 남기고, 재시도 가능한 후속 반응만 이벤트로 분리한다.