Queue
한 메시지를 하나의 Consumer 인스턴스가 가져가 처리한다. 처리량 조절과 작업 분산이 핵심이다.
이미지 리사이징, 이메일 발송, 정산 작업처럼 같은 일을 여러 Worker가 나눠 처리할 때분류: 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에서는 결정 경로가 이벤트를 남기고 끝난 뒤, 반응 경로가 브로커를 통해 분리된다.
직접 호출 구조는 처음에는 이해하기 쉽다. 호출자가 대상 서비스의 주소를 알고, 요청을 보내고, 응답을 받으면 된다. 문제는 서비스가 늘어나면 호출자가 너무 많은 것을 알아야 한다는 점이다. OrderService가 결제, 재고, 알림, 쿠폰, 회계, 데이터 분석 서비스를 모두 직접 호출하면 주문 서비스는 주문 도메인보다 주변 서비스의 장애와 배포 일정에 더 민감해진다.
EDA는 이 한계를 “사실을 기록하고 관심 있는 쪽이 가져간다”는 방식으로 푼다. OrderService는 order.created 이벤트를 브로커에 발행한다. 결제 서비스는 결제가 필요하면 구독하고, 알림 서비스는 알림이 필요하면 구독한다. 이후 새로운 감사 로그 서비스가 생겨도 OrderService 코드를 수정하지 않고 구독자만 추가할 수 있다.
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의 등장 이유다.
이벤트 이름은 보통 과거형으로 둔다. CreateOrder는 명령(command)에 가깝고, OrderCreated나 order.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의 기본 구조는 세 요소로 설명할 수 있다.
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이 맞다.
한 메시지를 하나의 Consumer 인스턴스가 가져가 처리한다. 처리량 조절과 작업 분산이 핵심이다.
이미지 리사이징, 이메일 발송, 정산 작업처럼 같은 일을 여러 Worker가 나눠 처리할 때같은 이벤트를 여러 Subscriber가 각자 받는다. 한 사실에 여러 반응을 붙이는 구조다.
주문 생성 후 결제, 재고, 알림, 감사 로그가 모두 독립적으로 반응해야 할 때AWS에서는 SNS Topic이 Pub/Sub 팬아웃을 맡고, 각 Consumer 앞에 SQS Queue를 붙여 느린 Consumer를 격리하는 조합이 흔하다.
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라면 eventId나 orderId + 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처럼 중앙 조정자가 각 단계를 호출하거나 이벤트를 지시하는 방식이다. 전체 상태를 추적하기 쉽지만, 조정자가 새 결합 지점이 된다.
| 구분 | 장점 | 위험 신호 | 어울리는 상황 |
|---|---|---|---|
| Choreography | Producer와 Consumer 결합이 낮다 | 이벤트만 보고 전체 업무 흐름을 추적하기 어렵다 | 알림, 감사 로그, 분석처럼 반응이 독립적일 때 |
| Orchestration | 진행 상태와 보상 처리를 중앙에서 볼 수 있다 | 조정자가 너무 많은 도메인 규칙을 안다 | 결제, 재고, 배송처럼 단계 순서와 보상이 중요할 때 |
NestJS의 @OnEvent나 Node.js의 EventEmitter도 이벤트라는 이름을 쓴다. 하지만 이것은 보통 프로세스 내 이벤트다. 같은 애플리케이션 프로세스 안에서 모듈 간 콜백을 느슨하게 연결하는 방식이므로, 프로세스가 죽으면 이벤트도 함께 사라진다.
서비스 간 EDA는 다르다. 이벤트가 외부 브로커에 기록되고, Consumer가 다른 프로세스나 다른 서비스로 분리된다. 이 차이를 놓치면 “로컬 이벤트를 emit했으니 EDA를 했다”고 오해하기 쉽다.
| 구분 | 프로세스 내 이벤트 | 서비스 간 EDA |
|---|---|---|
| 범위 | 같은 애플리케이션 프로세스 안 | 여러 서비스, 여러 프로세스, 때로는 여러 계정 |
| 내구성 | 프로세스가 죽으면 이벤트도 사라질 수 있다 | Broker가 보존 기간 동안 메시지를 보관한다 |
| 실패 처리 | 예외 처리와 로그 중심 | retry, DLQ, visibility timeout, offset 관리 |
| 결합 완화 | 모듈 간 직접 호출을 줄인다 | 서비스 간 시간/장애/배포 결합을 줄인다 |
| 학습 포인트 | 이벤트 이름과 handler 흐름 이해 | 스키마, 멱등성, 순서, 최종 일관성까지 함께 설계 |
따라서 @OnEvent("order.created") 예제는 이벤트 사고방식을 익히는 작은 출발점으로는 좋다. 그러나 결제, 재고, 알림이 서로 다른 서비스라면 외부 브로커와 메시지 내구성, 중복 처리까지 고려해야 한다.
간단한 기준은 다음과 같다.
온라인 주문 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+80ms | Order DB 저장, order.created 발행 | 주문 접수 |
| T+180ms | 결제 Consumer가 승인 완료 | 결제 완료 |
| T+260ms | 재고 Consumer가 예약 완료 | 상품 준비 중 |
| T+400ms+ | 알림 Consumer가 이메일 또는 푸시 발송 | 알림 발송 여부는 주문 성공과 분리 |
동기 설계에서는 이메일 발송 400ms가 API 응답 시간에 들어갈 수 있다. 이벤트 설계에서는 Broker 접수까지만 응답 경로에 남기고, 알림 지연은 알림 Consumer의 문제로 격리한다.
결제 Consumer가 같은 order.created 이벤트를 두 번 받는다고 하자. 멱등성이 없으면 결제가 두 번 청구된다. 멱등성이 있으면 첫 번째 처리에서 orderId = ord_1001 결제 완료를 기록하고, 두 번째 처리에서는 이미 완료된 주문으로 판단해 skip한다.
재고 Consumer가 5분 동안 죽어 있으면 주문 서비스는 계속 주문 접수를 받을 수 있다. 대신 inventory 큐의 backlog와 oldest message age가 증가한다. 이때 정상적인 시스템은 사용자가 “재고 확인 중” 상태를 볼 수 있고, 운영자는 backlog를 보고 Consumer 장애를 찾을 수 있어야 한다.
결제는 성공했지만 재고가 없다는 사실이 뒤늦게 발견될 수도 있다. 이런 도메인에서는 inventory.reservation_failed 이벤트와 결제 취소 같은 보상 흐름이 필요하다. 이 지점부터는 Saga Pattern의 영역에 가까워진다.
이벤트 설계는 흐름도만으로 끝내면 위험하다. 각 이벤트가 어떤 상태 전이를 만들 수 있는지 테이블로 적어야 한다.
| 현재 상태 | 이벤트 | 다음 상태 | 주의할 점 |
|---|---|---|---|
| 없음 | order.created | PENDING_PAYMENT | 주문 ID가 이미 있으면 중복 생성하지 않는다 |
PENDING_PAYMENT | payment.approved | PAID | 같은 결제 승인 이벤트가 두 번 와도 한 번만 반영한다 |
PENDING_PAYMENT | payment.failed | PAYMENT_FAILED | 재시도 가능한 실패와 최종 실패를 구분한다 |
PAID | inventory.reserved | READY_TO_SHIP | 재고 예약 실패 시 보상 이벤트가 필요하다 |
PAID | inventory.reservation_failed | REFUND_PENDING | 결제 취소 또는 고객 안내가 필요하다 |
READY_TO_SHIP | shipment.created | SHIPPING | 배송 서비스의 지연이 주문 생성 실패가 되어서는 안 된다 |
이 테이블을 쓰면 EDA의 경계가 더 분명해진다. payment.approved가 오기 전에는 PAID가 아니다. inventory.reserved가 오기 전에는 출고 준비가 아니다. 이벤트가 늦게 오는 동안 사용자가 어떤 상태를 보아야 하는지도 이 표에서 결정된다.
상태 전이는 Consumer 멱등성의 기준이기도 하다. 이미 PAID인 주문에 같은 payment.approved가 다시 오면 무시할 수 있다. 하지만 이미 REFUND_PENDING인 주문에 뒤늦은 inventory.reserved가 오면 단순 무시가 아니라 순서 문제나 보상 흐름 충돌로 기록해야 한다.
좋은 이벤트는 Consumer가 필요한 사실을 담되, Producer 내부 모델을 그대로 노출하지 않는다. OrderCreated 이벤트에 주문 테이블의 모든 컬럼을 넣으면 Consumer가 주문 서비스의 내부 스키마에 묶인다. 반대로 orderId만 넣으면 모든 Consumer가 다시 주문 API를 조회해야 해서 결합이 되살아난다.
| 필드 | 넣는 이유 |
|---|---|
eventId | 중복 수신을 감지하는 멱등성 키 |
eventType | Consumer가 처리할 이벤트 종류를 구분 |
occurredAt | 실제 사건 발생 시각과 처리 지연을 구분 |
schemaVersion | 하위 호환 변경과 breaking change를 관리 |
data.orderId | 도메인 엔티티 식별자 |
data.amount | 결제, 감사 로그 등 다수 Consumer가 쓰는 핵심 값 |
traceId | 여러 서비스 로그를 한 요청 흐름으로 묶기 |
스키마 품질은 장애 때 드러난다. Consumer가 amount를 원 단위로 해석하는데 Producer가 갑자기 센트 단위로 바꾸면 타입은 맞아도 의미가 깨진다. 그래서 이벤트 계약은 “필드가 있다”가 아니라 “필드의 단위, 의미, 변경 규칙이 유지된다”까지 포함한다.
주문 API의 동기 응답 시간이 다음과 같다고 하자.
| 작업 | p95 지연 |
|---|---|
| 주문 저장 | 80ms |
| 결제 승인 | 250ms |
| 재고 예약 | 120ms |
| 이메일 | 400ms |
모두 동기로 묶으면 단순 합산만 해도 p95가 850ms에 가까워질 수 있다. 결제 승인은 주문 완료 판단에 필요하므로 동기 경로에 남기고, 재고 예약과 이메일을 이벤트로 분리하면 응답 경로는 대략 330ms 수준으로 줄어든다. 반대로 결제까지 이벤트로 빼면 응답은 더 빨라질 수 있지만, 사용자는 결제가 실패할 수 있는 주문을 “완료”로 보게 된다.
이 예시는 EDA의 목표가 무조건 응답 시간을 줄이는 것이 아님을 보여준다. 목표는 제품 계약상 즉시 확정해야 하는 일과 나중에 처리해도 되는 일을 구분하는 것이다.
EDA는 강력하지만 기본값으로 깔아두는 패턴은 아니다. 먼저 아래 질문으로 판단한다.
| 질문 | ”예”라면 EDA에 유리한 이유 |
|---|---|
| 이 작업이 사용자 응답에 즉시 필요 없는가 | 응답 경로에서 분리해도 사용자 계약이 깨지지 않는다 |
| 실패해도 재시도나 보상으로 복구 가능한가 | DLQ, 재처리, 멱등성으로 운영할 수 있다 |
| 같은 사실에 여러 서비스가 관심을 가지는가 | Pub/Sub으로 새 반응을 독립적으로 추가할 수 있다 |
| Consumer별 처리 속도가 달라도 되는가 | 큐와 브로커가 느린 Consumer를 격리한다 |
| 이벤트 이력이 감사나 분석에 가치가 있는가 | 사실 기록이 나중에 추적 자산이 된다 |
반대로 아래 조건에서는 EDA가 문제를 키울 수 있다.
| 상황 | 왜 나빠지는가 | 더 단순한 대안 |
|---|---|---|
| 강한 ACID 트랜잭션 필수 | 여러 서비스 상태가 잠시 어긋나는 것을 허용하기 어렵다 | 단일 DB 트랜잭션 또는 동기 API |
| 즉시 최종 결과가 필요함 | 사용자는 “완료”를 기대하지만 시스템은 아직 처리 중이다 | 동기 호출 후 결과 확정 |
| 작은 모놀리스 | 브로커, 스키마, 재처리 운영 비용이 가치보다 크다 | 같은 프로세스 함수 호출 |
| 전역 순서가 필수 | 순서 보장 때문에 처리량과 확장성이 급격히 줄어든다 | 단일 writer 또는 명시적 상태 머신 |
| 관측성이 부족한 팀 | 실패가 HTTP 500처럼 바로 보이지 않고 큐 뒤에 숨는다 | 먼저 로그, 메트릭, DLQ 운영 체계 구축 |
주문 API가 order.created만 발행하고 바로 “주문 완료”를 반환한다고 하자. 결제 Consumer는 2초 뒤 카드 한도 초과로 실패했고, 재고 Consumer도 예약하지 못했다. 사용자는 이미 완료 화면을 봤는데 몇 초 뒤 취소 알림을 받는다. 이런 흐름은 기술적으로 EDA일 수 있지만, 제품 계약으로는 실패다.
이 경우 결제 승인 여부가 주문 완료의 필수 조건이라면 결제는 동기 결정 경로에 남기는 편이 낫다. 결제 성공 후 order.paid 이벤트를 발행해 알림, 감사 로그, 추천 모델 갱신을 분리하면 EDA의 이점과 사용자 계약을 둘 다 지킬 수 있다.
관리자 한 명이 하루 몇 번 바꾸는 서비스 설정을 모든 변경마다 이벤트로 흘려보내고, 각 서버가 비동기 Consumer로 반영하게 만들 수도 있다. 하지만 실제 요구가 “변경 즉시 모든 요청에 같은 설정 적용”이라면 이벤트 지연과 Consumer 실패가 오히려 위험하다. 이런 경우에는 중앙 설정 저장소와 동기 읽기, 또는 캐시 무효화 정도가 더 단순하다.
AWS 환경에서는 세 서비스를 조합해서 쓰는 일이 많다. 차이는 역할에서 나온다.
Consumer가 pull로 메시지를 가져가며, 메시지 보존과 재시도, DLQ 구성이 쉽다.
처리 속도 조절이 필요한 비동기 작업 큐, Worker 기반 처리Topic에 발행된 메시지를 여러 구독 대상에 push한다. 단순 팬아웃에 강하다.
하나의 이벤트를 여러 SQS Queue, Lambda, HTTP endpoint로 동시에 보내야 할 때이벤트 패턴 매칭, 콘텐츠 기반 라우팅, AWS 서비스 이벤트 연동을 지원한다.
이벤트 내용으로 조건 라우팅하거나 계정/서비스 경계를 넘어 이벤트를 연결할 때예를 들어 주문 이벤트를 모든 후속 서비스에 전달하려면 SNS Topic으로 팬아웃하고, 각 서비스 앞에 SQS Queue를 둔다. VIP 주문만 별도 처리 큐로 보내야 한다면 EventBridge의 이벤트 패턴 매칭이 더 적합할 수 있다.
오픈소스 브로커와 관리형 서비스를 고를 때는 처리량 숫자만 보지 말고, 재처리 모델과 운영 부담을 함께 봐야 한다.
| 항목 | Kafka | RabbitMQ | AWS SQS/SNS |
|---|---|---|---|
| 중심 모델 | append-only 로그와 consumer offset | exchange/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가 아직 쓰고 있을 수 있다 |
| 필드 이름 변경 | breaking | Consumer 파서가 바로 실패할 수 있다 |
| 단위 변경 | breaking | 타입은 같아도 의미가 달라진다 |
| enum 값 추가 | 조건부 안전 | Consumer가 unknown 값을 처리할 수 있어야 한다 |
실무에서는 새 필드를 추가하고 충분한 기간 동안 두 버전을 같이 흘린 뒤, Consumer 사용 현황을 보고 오래된 필드를 제거한다. 중요한 이벤트라면 schema registry(이벤트 스키마를 중앙에서 관리하는 저장소)나 contract test(Producer 이벤트가 Consumer 기대 형식을 깨지 않는지 확인하는 테스트)를 두어 Producer 변경이 Consumer를 깨는지 배포 전에 확인한다.
첫 학습 단계에서는 세 가지만 기억하면 된다.
도구 이름부터 고르면 논의가 흐려진다. 먼저 요구를 좁힌다.
| 질문 | 예라면 더 봐야 할 것 |
|---|---|
| 과거 이벤트를 다시 읽어야 하는가 | 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 |
| Relay | pending 이벤트를 찾아 브로커에 발행하고 상태를 갱신 |
| Consumer | 같은 eventId가 두 번 와도 부수 효과를 한 번만 수행 |
Relay를 여러 인스턴스로 늘릴 때는 같은 row를 두 relay가 동시에 집지 않게 해야 한다. SQL에서는 FOR UPDATE SKIP LOCKED 같은 패턴을 쓰기도 한다. 다만 이 문서에서 중요한 것은 SQL 문법이 아니라 “relay 병렬화도 동시성 문제를 만든다”는 감각이다.
Event Sourcing 은 이벤트를 통신 수단이 아니라 상태의 원장으로 삼는 방식이다. 일반 EDA에서는 주문 테이블이 현재 상태의 원본이고 이벤트는 다른 서비스에 사실을 알리는 메시지일 수 있다. Event Sourcing에서는 order.created, payment.approved, shipment.created 같은 이벤트 로그를 재생해 현재 상태를 만든다.
| 구분 | 일반 EDA | Event 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 관리 필요 |
| CDC | DB 변경 로그를 읽어 이벤트화한다 | 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는 실패를 해결하지 않는다. 실패한 메시지가 사라지지 않게 붙잡아 두고, 원인을 분석할 수 있게 만드는 장치다.
상황은 이렇다. 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만 실패할 수 있다.
Producer는 초당 500건을 발행하고 Consumer는 초당 300건만 처리한다고 하자. 에러 로그는 없지만 backlog는 초당 200건씩 쌓인다. 10분이면 120,000건이 밀린다.
이 장애는 코드 예외가 아니라 용량 문제다. Consumer CPU가 높다면 Worker 수나 downstream 처리량이 부족할 가능성이 크다. Consumer CPU가 낮고 Received가 늘지 않는다면 polling 설정, 권한, 네트워크, queue name 오타를 의심해야 한다.
이때 “재시도 횟수 증가”는 해결책이 아니다. 재시도는 실패한 작업을 다시 넣는 것이므로 처리량 부족 상황에서는 backlog를 더 키울 수 있다.
payment.approved보다 order.created가 늦게 처리되면 Consumer는 존재하지 않는 주문의 결제를 보게 된다. 원인은 여러 가지다. 다른 partition으로 나뉘었거나, Consumer 병렬 처리 중 한 메시지가 오래 걸렸거나, Producer가 상태별 이벤트를 다른 경로로 발행했을 수 있다.
해결 방향은 “모든 이벤트 전역 순서 보장”이 아니다. 같은 주문에 관한 이벤트만 같은 key로 묶고, Consumer는 예상하지 못한 상태 전이를 만나면 재시도하거나 보류 상태에 넣어야 한다. 주문 A와 주문 B 사이의 순서는 대개 중요하지 않다.
Producer가 amount를 숫자에서 문자열로 바꿨다고 하자. JavaScript Consumer는 암묵 변환 때문에 당장 죽지 않을 수도 있다. 하지만 정산 Consumer가 문자열 비교를 하면 "9000" > "100000" 같은 잘못된 판단이 생길 수 있다.
스키마 실패는 항상 parse error로 드러나지 않는다. 단위, enum, nullable 의미가 바뀌면 데이터는 통과하지만 비즈니스 결과가 틀어진다. 그래서 이벤트 계약에는 타입뿐 아니라 단위와 의미를 적어야 하고, 중요한 Consumer는 contract test나 샘플 이벤트 테스트를 가져야 한다.
본문 이해에는 아래 절차가 필요하지 않다. 실제 AWS 환경에서 문제가 났을 때 확인할 최소 순서만 남긴다.
NumberOfMessagesPublished, EventBridge라면 matched rule 또는 invocation 지표를 본다.NumberOfMessagesSent, ApproximateNumberOfMessagesVisible, ApproximateAgeOfOldestMessage를 확인한다.Received가 0이면 Consumer 중단, 권한, 네트워크를 의심한다.aws events test-event-pattern으로 비교한다.eventId, orderId, idempotency key가 여러 번 처리됐는지 로그에서 찾는다.SQS 메시지 보존 기간은 기본 4일이고 최대 14일이다. 원본 큐에서 며칠 재시도한 뒤 DLQ로 이동하면 분석 가능 시간이 짧아질 수 있으므로, 장애 분석용 DLQ는 원본 큐보다 길게 잡는 편이 안전하다. 14일은 주말이나 휴일 이후 조사 시간을 확보하기 위한 보수적 설정이다.
| 개념 A | 개념 B | 차이점 |
|---|---|---|
| EDA | MSA | MSA는 서비스 분리 방식이고, EDA는 분리된 서비스가 통신하는 방식이다. 함께 쓰일 수 있지만 같은 말은 아니다. |
| Event | Command | Event는 이미 발생한 사실이고, Command는 특정 대상에게 일을 시키는 요청이다. |
| Queue | Pub/Sub | Queue는 작업을 나눠 처리하고, Pub/Sub은 같은 사실을 여러 Consumer에게 전달한다. |
| Topic | Queue | Topic은 발행 채널이고, Queue는 처리 대기열이다. SNS Topic에서 여러 SQS Queue로 팬아웃할 수 있다. |
| EDA | CQRS | CQRS는 읽기/쓰기 모델 분리이고, EDA는 이벤트 기반 통신이다. CQRS Projection을 이벤트로 갱신할 수 있다. |
| Outbox Pattern | 직접 이벤트 발행 | Outbox는 DB 변경과 이벤트 레코드를 같은 트랜잭션에 묶어 유실을 줄인다. |
| Choreography | Orchestration | Choreography는 각 서비스가 이벤트에 반응하고, Orchestration은 중앙 조정자가 흐름을 관리한다. |
팀 코드나 설계 문서를 볼 때는 “어디에 브로커가 있는가”보다 “어떤 결합을 줄이려 했는가”를 먼저 본다.
코드 검색은 보조 수단이다.
rg "@OnEvent|EventEmitter|SqsMessageHandler|PutEventsCommand|snsClient.publish" src검색 결과가 나오면 “이벤트를 발행하는 곳”, “이벤트를 소비하는 곳”, “브로커 또는 큐 이름”, “실패 시 재시도와 DLQ가 있는지”를 이어서 그려본다.