콘텐츠로 이동

EDA (Event-Driven Architecture)

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

EDA(Event-Driven Architecture)는 시스템 구성요소들이 직접 호출하는 대신 “이벤트”를 발행(Publish)하고 구독(Subscribe)하는 방식으로 통신하는 아키텍처 패턴이다.

프론트엔드 브릿지: React의 useEffect는 상태 변화 이벤트에 반응한다. Redux의 미들웨어는 액션이 발생할 때 부수 효과를 처리한다. EDA는 이 패턴을 서비스 간 레벨로 확장한 것이다. “어떤 서비스에서 이벤트가 발생하면 → 다른 서비스들이 각자의 방식으로 반응한다”는 구조가 동일하다. DOM의 element.addEventListener('click', handler)NestJS EventEmitter2@OnEvent('order.created')가 되고, 이것이 다시 SQS Consumer로 확장된다. 규모만 다를 뿐 반응형 패턴은 같다.

마이크로서비스 환경에서 서비스 간 직접 API 호출은 의존성과 장애 전파를 만든다. EDA는 이 문제를 해결하는 핵심 패턴이다. Nest.js에도 내장 EventEmitter와 외부 메시지 브로커 연동 모듈이 있고, 실무에서 서비스 간 통신 구조를 설계할 때 반드시 마주치는 개념이다.

EDA가 동작하는 방식 (전체 흐름)

섹션 제목: “EDA가 동작하는 방식 (전체 흐름)”

비유: 식당 주문 시스템과 같다. 손님(Publisher)이 주문서를 작성해서 주방 창구(Event Broker)에 꽂으면, 조리팀·음료팀·서빙팀(Subscriber들)이 각자 관련 주문을 가져가서 처리한다. 손님은 어떤 팀이 처리하는지 몰라도 되고, 각 팀도 손님이 누구인지 알 필요 없다.

원리: EDA의 3가지 핵심 구성요소

  1. Publisher(생산자): 이벤트를 발행하고 즉시 다음 작업을 진행. 누가 구독하는지 신경 쓰지 않음.
  2. Event Broker(중계자): 이벤트를 받아 저장하고, 구독자에게 전달. AWS SNS, SQS, Kafka, RabbitMQ가 이 역할.
  3. Subscriber(소비자): 관심 있는 이벤트만 구독하고 처리. 발행자가 누구인지 몰라도 됨.

브로커 내부 동작:

Publisher → [Broker] → 구독자 목록 조회 → 각 Subscriber에게 전달
이벤트 저장 (at-least-once 보장)
필터링 (어떤 구독자에게 보낼지 결정)
재시도 (전달 실패 시 재전송)

왜 이렇게 설계되었는가 — 브로커가 필요한 이유

Publisher가 Subscriber를 직접 호출하면 두 가지 문제가 생긴다. 첫째, Publisher가 모든 Subscriber의 주소(URL, 큐 이름)를 알아야 한다 — 결합도 증가. 둘째, Subscriber가 다운됐을 때 이벤트가 유실된다. 브로커는 이 두 문제를 해결한다: Publisher는 브로커에만 발행하면 되고(위치 투명성), 브로커가 Subscriber가 복구될 때까지 이벤트를 보관한다(내구성). 이 두 가지 속성이 EDA가 MSA 환경에서 표준 패턴이 된 근본 이유다.

2025년 EventBridge 신기능 — 더욱 강력해진 지능형 라우팅

2025년 Amazon EventBridge는 향상된 로깅 기능을 추가했다. 이제 이벤트 전체 라이프사이클(성공/실패/재시도 지표 포함)을 상세히 추적할 수 있다. 또한 Cross-Account 직접 전달이 가능해져, 다른 AWS 계정의 SQS Queue로 직접 이벤트를 전송할 수 있다 (기존에는 중간 인프라가 필요했음).

기존 방식 (크로스 계정):
계정 A EventBridge → 계정 A Lambda → 계정 B SQS
2025년 방식 (직접 전달):
계정 A EventBridge → 계정 B SQS (직접!)
→ 멀티 계정 환경(개발/스테이징/프로덕션 계정 분리)에서 아키텍처가 크게 단순해짐

📖 더 보기: Publisher-Subscriber Pattern - Microsoft Azure 아키텍처 가이드 — 위 브로커 내부 흐름(필터링, 재시도, 보장 수준)을 다이어그램으로 확인 가능


EDA의 핵심: 느슨한 결합

# 직접 호출 (강한 결합)
OrderService.createOrder()
→ PaymentService.charge() # PaymentService가 느리면 전체 대기
→ NotificationService.send() # 하나가 죽으면 전체 실패
# EDA (느슨한 결합)
OrderService → [order.created 이벤트 발행] → 즉시 응답 반환
↓ (비동기, 순서 무관)
PaymentService 구독 → 결제 처리 (독립 실행)
NotificationService 구독 → 알림 발송 (독립 실행)
InventoryService 구독 → 재고 차감 (독립 실행)

Publisher / Subscriber / Event Broker

  • Publisher: 이벤트를 발행하는 쪽. 누가 구독하는지 모름.
  • Subscriber: 이벤트를 받아 처리하는 쪽. 누가 발행했는지 모름.
  • Event Broker: 이벤트를 중계하는 인프라 (Kafka, RabbitMQ, AWS SNS/SQS)

Queue vs Pub/Sub 차이

Queue (1:1)Pub/Sub (1:N)
소비자하나의 Consumer가 처리여러 Subscriber가 각자 처리
예시SQS → Worker 하나가 처리SNS → 여러 Lambda/SQS가 동시 처리
사용작업 분산, 비동기 처리이벤트 브로드캐스트
중복처리동일 메시지를 한 번만 처리각 구독자가 같은 메시지를 모두 처리

AWS에서의 SNS + SQS Fan-out 패턴 (실무에서 가장 많이 쓰이는 구조):

OrderService
▼ Publish
[SNS Topic: order-events]
┌──┴──┬──────────────┐
▼ ▼ ▼
[SQS] [SQS] [Lambda]
payment inventory notification
worker worker function
# SNS가 이벤트를 받아 여러 SQS/Lambda에 팬아웃
# 각 SQS에는 Worker가 붙어서 독립적으로 처리

Nest.js에서의 EDA

1) 내장 EventEmitter (프로세스 내 이벤트)

app.module.ts
// 설치: npm install @nestjs/event-emitter eventemitter2
import { EventEmitterModule } from "@nestjs/event-emitter";
@Module({
imports: [EventEmitterModule.forRoot()],
})
export class AppModule {}
// order.service.ts — 이벤트 발행
import { EventEmitter2 } from "@nestjs/event-emitter";
@Injectable()
export class OrderService {
constructor(private eventEmitter: EventEmitter2) {}
async createOrder(dto: CreateOrderDto) {
const order = await this.orderRepository.save(dto);
// 이벤트 발행 — 누가 처리하는지 신경 쓰지 않음
this.eventEmitter.emit("order.created", {
orderId: order.id,
userId: order.userId,
});
return order; // 즉시 응답 반환 (비동기 처리)
}
}
// notification.service.ts — 이벤트 구독
import { OnEvent } from "@nestjs/event-emitter";
@Injectable()
export class NotificationService {
@OnEvent("order.created")
async handleOrderCreated(payload: { orderId: string; userId: string }) {
console.log(`주문 생성 이벤트 수신: ${payload.orderId}`);
await this.sendEmail(payload.userId, "주문이 완료되었습니다.");
}
}

예상 실행 흐름:

POST /orders 요청
→ OrderService.createOrder() 실행
→ order.created 이벤트 emit
→ 즉시 { id: 'order-123', ... } 응답 반환
→ (비동기) NotificationService.handleOrderCreated() 실행
→ 이메일 발송

2) 외부 메시지 브로커 연동 (서비스 간 이벤트)

// AWS SQS와 연동하는 경우 (@ssut/nestjs-sqs 패키지 사용)
// 설치: npm install @ssut/nestjs-sqs @aws-sdk/client-sqs
@SqsMessageHandler('order-created-queue', false)
async handleOrderCreated(message: AWS.SQS.Message) {
const payload = JSON.parse(message.Body);
console.log('SQS 메시지 수신:', payload);
// { orderId: 'order-123', userId: 'user-456', timestamp: '...' }
await this.processOrder(payload);
}

📖 더 보기: Event-Driven Architecture with NestJS: Using the EventEmitter Module — 위 @OnEvent 패턴의 전체 예제와 에러 처리 방법 확인 가능


EDA 심화 패턴: Outbox Pattern (이벤트 유실 방지)

EDA에서 자주 발생하는 문제: DB에 주문을 저장했지만 이벤트 발행이 실패하면 데이터 불일치 발생.

문제 상황:
1. DB에 ORDER 저장 (성공)
2. SNS/SQS에 order.created 이벤트 발행 (실패!)
→ DB에는 주문이 있는데, 결제/알림 서비스에는 이벤트가 전달 안 됨
해결: Outbox Pattern
1. DB 트랜잭션 안에서 ORDER 저장 + outbox 테이블에 이벤트 레코드도 함께 저장
2. 별도 프로세스(Outbox Processor)가 outbox 테이블을 폴링해서 이벤트 발행
3. 발행 성공 시 outbox 레코드를 처리 완료로 표시
→ DB 저장과 이벤트 발행이 원자적으로 묶임 (유실 불가)

📌 Outbox Pattern 상세 구현(outbox-relay.service.ts, cron relay, idempotency key)은 L8 cqrs-event-sourcing.md에서 다룹니다. L6에서는 “DB 저장과 이벤트 발행을 원자적으로 묶어야 유실이 없다”는 개념 이해로 충분합니다.

📖 더 보기: Outbox & Saga Pattern on AWS EDA — Outbox 패턴과 Saga 패턴이 실제 AWS 환경에서 어떻게 연결되는지 다이어그램 포함 설명 (중급)


이벤트 소싱 (Event Sourcing) — EDA의 다음 단계

EDA에서 이벤트를 발행·소비하는 것에 익숙해지면, “이벤트 자체를 DB에 저장해 상태를 이벤트 재생으로 복원”하는 Event Sourcing으로 확장할 수 있다. 현재 상태(status = ‘delivered’) 대신 order.created → order.paid → order.shipped → order.delivered 이벤트 시퀀스를 저장하는 방식이다. 이벤트 자체가 감사 로그(Audit Log)가 되고, 특정 시점 상태로 되돌리는 시간여행 디버깅도 가능해진다.

단, Event Sourcing은 복잡성 비용이 상당하다. 이벤트 스키마 버전 관리(수년 전 이벤트도 올바르게 재생), Projection 지연에 따른 최종 일관성 처리, CQRS와의 결합 설계까지 추가 고려사항이 많다. 금융·의료·법률처럼 감사 이력이 필수인 도메인이 아니라면 단순 CRUD에 도입하는 것은 과도한 엔지니어링이다.

📖 Event Sourcing의 Event Store, Projection, Snapshot, Upcaster 패턴 등 상세 구현은 L9 아키텍처 패턴(cqrs-event-sourcing.md) 에서 다룬다. L6 수준에서는 “EDA → Event Sourcing으로 이어지는 개념적 연결”을 이해하는 것으로 충분하다.

패턴 1: AWS 브로커 선택 기준 (SNS vs SQS vs EventBridge)

실무에서 “어떤 브로커를 쓸지”는 자주 나오는 결정이다. 2025년 현재 AWS는 세 가지 옵션을 제공하며, 계층적으로 조합해 쓰는 것이 표준 패턴이다.

서비스전달 방식주요 사용 사례선택 기준
SQSPull(폴링)비동기 작업 큐, Worker 패턴순서/속도 조절이 필요한 1:1 처리
SNSPush(즉시)실시간 팬아웃, 모바일 푸시, 여러 서비스 동시 알림즉시 1:N 브로드캐스트
EventBridgePush(라우팅)복잡한 이벤트 라우팅, AWS 서비스 간 연동, 스케줄러패턴 매칭, 크로스 계정, 서드파티 연동
권장 조합 아키텍처:
EventBridge (지능형 라우터, 패턴 매칭)
SNS Topic (팬아웃, 실시간 전달)
┌──┴──┬──────────────┐
↓ ↓ ↓
[SQS] [SQS] [Lambda]
(버퍼링, 재처리) (즉시 처리)

패턴 1-B: EventBridge Content Filtering — 조건별 라우팅 (NestJS 예시)

EventBridge의 핵심 장점은 이벤트 내용(Content)을 기반으로 다른 타깃에 라우팅하는 패턴 매칭이다. SNS/SQS는 “모든 메시지를 전달”하지만 EventBridge는 “조건에 맞는 메시지만 특정 타깃으로 전달”한다.

// NestJS에서 EventBridge로 이벤트 발행 (AWS SDK v3)
import {
EventBridgeClient,
PutEventsCommand,
} from "@aws-sdk/client-eventbridge";
@Injectable()
export class OrderEventPublisher {
private readonly client = new EventBridgeClient({ region: "ap-northeast-2" });
async publishOrderCreated(order: Order): Promise<void> {
await this.client.send(
new PutEventsCommand({
Entries: [
{
Source: "com.myapp.orders",
DetailType: "OrderCreated",
Detail: JSON.stringify({
orderId: order.id,
userId: order.userId,
amount: order.amount,
tier: order.userTier, // "standard" | "premium" | "vip"
}),
EventBusName: process.env.EVENT_BUS_NAME,
},
],
}),
);
}
}

EventBridge 콘솔(또는 IaC)에서 규칙(Rule)을 만들어 Content Filtering 적용:

// Rule 1: VIP 주문만 프리미엄 처리 큐로
{
"source": ["com.myapp.orders"],
"detail-type": ["OrderCreated"],
"detail": {
"tier": ["vip"],
"amount": [{ "numeric": [">=", 100000] }]
}
}
// → 타깃: SQS priority-order-queue (전담 워커)
// Rule 2: 전체 주문은 일반 처리 큐로
{
"source": ["com.myapp.orders"],
"detail-type": ["OrderCreated"]
}
// → 타깃: SQS standard-order-queue

이 패턴의 장점: Publisher는 이벤트 하나만 발행, 라우팅 로직은 EventBridge 규칙이 담당 → 새 처리 조건이 생겨도 Publisher 코드를 변경하지 않아도 된다.

패턴 2: adjoe 실제 운영 사례 (하루 5억 요청 처리)

독일 애드테크 기업 adjoe는 SNS+SQS로 하루 5억 건 이상의 요청을 처리한다:

규모: 130개 SNS Topic, 300개 이상 SQS Queue
가장 바쁜 Queue: 하루 50만 건 메시지 처리
선택 이유: AWS 관리형 → 높은 가용성, 실질적으로 무제한 스케일링
핵심 교훈:
- 서비스가 130개 이벤트 타입을 발행 → 각 이벤트마다 독립적인 SNS Topic
- 소비자(SQS Queue)가 300개 → 같은 이벤트도 팀/목적별로 독립 처리
- 직접 연결 대신 브로커를 두어 장애 격리 달성

패턴 3: NestJS Scalable Event-Driven Notification System

// 실제 프로덕션에서 AWS SQS + NestJS로 알림 시스템 구축 패턴
// 특징: 각 알림 채널(이메일/SMS/푸시)을 독립 Worker로 분리
// 1. SNS에 이벤트 발행 (OrderService)
await snsClient.publish({
TopicArn: process.env.ORDER_EVENTS_TOPIC_ARN,
Message: JSON.stringify({ orderId, userId, type: "order.created" }),
MessageAttributes: {
eventType: { DataType: "String", StringValue: "order.created" },
},
});
// 2. 각 채널별 SQS Worker (독립 ECS 서비스로 배포)
// - email-notification-worker: SES로 이메일 발송
// - push-notification-worker: FCM으로 앱 푸시
// - slack-notification-worker: Slack 웹훅 전송
// 장점: 이메일 발송이 느려도 푸시/Slack에 영향 없음
// 장점: 각 채널 Worker를 부하에 따라 독립 스케일링 가능
  • 서비스 간 통신 (주문 생성 → 결제, 알림, 재고 차감 동시 처리)
  • 시스템 간 데이터 동기화
  • 감사 로그(Audit Log) 기록
  • Nest.js 내부 모듈 간 이벤트 통신
  • 팀 서비스에서 @OnEvent 또는 메시지 브로커 사용 여부 파악
  • 서비스 간 직접 API 호출이 많다면 EDA로 개선 가능한 부분 식별
  • 장애 전파 문제가 있는 구조를 EDA로 개선하는 제안
개념 A개념 B차이점
EDAMSAMSA는 서비스 분리 아키텍처, EDA는 서비스 간 통신 방식 (함께 쓰임)
QueuePub/SubQueue는 1:1 작업 처리, Pub/Sub은 1:N 이벤트 브로드캐스트
동기 API 호출이벤트 발행동기는 응답을 기다리고, 이벤트는 발행 후 즉시 진행
EDACQRSCQRS는 읽기/쓰기 분리 패턴, EDA와 함께 쓰이는 경우 많음
Outbox Pattern직접 이벤트 발행Outbox는 DB 저장과 이벤트 발행을 원자적으로 묶어 유실 방지
SNSEventBridgeSNS는 단순 팬아웃, EventBridge는 복잡한 패턴 매칭과 라우팅 지원

🔧 @OnEvent 리스너가 실행되지 않는다

섹션 제목: “🔧 @OnEvent 리스너가 실행되지 않는다”

증상: eventEmitter.emit('order.created', payload)를 호출했는데 @OnEvent('order.created') 핸들러가 호출되지 않음 원인: EventEmitterModule.forRoot()AppModuleimports에 등록하지 않았거나, 핸들러가 있는 Service를 providers에 등록하지 않음 해결:

  1. app.module.tsEventEmitterModule.forRoot() import 여부 확인
  2. 핸들러 Service가 해당 모듈의 providers에 등록되어 있는지 확인
  3. 이벤트 이름 대소문자/오타 확인 (order.created vs order.Created)

🔧 동일한 이벤트가 중복 처리된다 (SQS)

섹션 제목: “🔧 동일한 이벤트가 중복 처리된다 (SQS)”

증상: 주문 생성 이벤트 1건에 결제가 2번 처리됨 원인: SQS는 at-least-once 전달을 보장하므로 네트워크 오류 시 같은 메시지를 2번 전달할 수 있음. Worker가 메시지 처리 후 ack(삭제) 전에 타임아웃이 나면 다시 Queue에 돌아옴 해결:

  1. 처리 로직을 멱등성(idempotent) 있게 구현 — 동일 orderId로 이미 결제가 됐으면 스킵
  2. 처리 완료된 이벤트 ID를 DB/Redis에 기록하고 중복 체크
  3. SQS Visibility Timeout을 처리 시간보다 넉넉하게 설정 (기본 30초)

🔧 이벤트 핸들러 에러가 전체 앱을 죽인다

섹션 제목: “🔧 이벤트 핸들러 에러가 전체 앱을 죽인다”

증상: @OnEvent 핸들러에서 예외가 발생했더니 서버 프로세스가 다운됨 원인: Nest.js 내장 EventEmitter는 기본적으로 핸들러 에러를 상위로 전파함. try-catch 없이 예외를 던지면 uncaught exception이 됨 해결:

@OnEvent('order.created')
async handleOrderCreated(payload: OrderCreatedEvent) {
try {
await this.processOrder(payload);
} catch (error) {
// 에러를 삼키고 로깅 — 앱이 죽지 않도록
this.logger.error(`order.created 처리 실패: ${error.message}`, error.stack);
// 필요하면 DLQ(Dead Letter Queue)로 이동하거나 재시도 로직 추가
}
}

🔧 이벤트 발행은 성공했는데 DB 저장이 실패해서 데이터가 꼬인다

섹션 제목: “🔧 이벤트 발행은 성공했는데 DB 저장이 실패해서 데이터가 꼬인다”

증상: SNS 이벤트는 발행됐는데 DB 트랜잭션이 롤백되어 DB에는 데이터가 없음. 구독자 서비스는 이미 처리 완료. 원인: 이벤트 발행과 DB 저장이 별개 트랜잭션으로 분리되어 있어 원자성이 없음 해결:

  1. Outbox Pattern 도입 — DB 트랜잭션 안에서 이벤트 레코드를 outbox 테이블에 함께 저장
  2. 별도 프로세스가 outbox 테이블을 읽어서 이벤트 발행 (분리된 신뢰 경계 없음)
  3. 단기 해결책: 이벤트 발행 전에 DB 저장이 완전히 커밋됐는지 확인 후 발행

🔧 SNS → SQS 팬아웃 시 일부 구독자만 메시지를 받는다

섹션 제목: “🔧 SNS → SQS 팬아웃 시 일부 구독자만 메시지를 받는다”

증상: SNS에 이벤트를 발행했는데 3개 SQS 중 1개만 메시지를 받음 원인 1: SQS Queue에 SNS Topic을 구독하는 설정이 빠졌거나, SNS Subscription이 Pending confirmation 상태 원인 2: SQS Queue의 Access Policy에 SNS가 메시지를 보낼 권한이 없음 해결:

  1. SNS → Topics → 해당 Topic → Subscriptions 탭에서 모든 구독 상태 Confirmed 여부 확인
  2. SQS Queue → Access Policy 확인 — SNS Topic ARN에서 sqs:SendMessage 허용 여부 점검:
    {
    "Effect": "Allow",
    "Principal": { "Service": "sns.amazonaws.com" },
    "Action": "sqs:SendMessage",
    "Resource": "arn:aws:sqs:ap-northeast-2:123456:my-queue",
    "Condition": {
    "ArnEquals": {
    "aws:SourceArn": "arn:aws:sns:ap-northeast-2:123456:order-events"
    }
    }
    }
  3. 콘솔에서 SNS → Publish message로 테스트 메시지 수동 발행해서 어떤 Queue에 도달하는지 확인

🔧 EventBridge 규칙에 이벤트가 도달했는데 타깃이 실행되지 않는다

섹션 제목: “🔧 EventBridge 규칙에 이벤트가 도달했는데 타깃이 실행되지 않는다”

증상: EventBridge 규칙의 Invocations 지표는 증가하는데, SQS/Lambda 타깃에서 처리가 안 됨 원인 1: EventBridge가 타깃(SQS, Lambda 등)을 호출할 권한이 없음 (리소스 기반 정책 누락) 원인 2: 이벤트 패턴(Event Pattern)이 실제 이벤트 구조와 맞지 않아 필터링됨 해결:

  1. EventBridge → Rules → 해당 Rule → Monitoring 탭에서 FailedInvocations 지표 확인
  2. SQS Queue Access Policy에 EventBridge가 메시지를 보낼 수 있는 권한 추가:
    {
    "Effect": "Allow",
    "Principal": { "Service": "events.amazonaws.com" },
    "Action": "sqs:SendMessage",
    "Resource": "arn:aws:sqs:ap-northeast-2:123456:my-queue"
    }
  3. EventBridge 콘솔 → “Test event pattern” 기능으로 실제 이벤트 JSON을 붙여넣어 패턴 일치 여부 검증
  4. 2025년 추가된 EventBridge 상세 로깅 활성화: Rule → “Logging” → CloudWatch Logs 대상으로 설정하면 이벤트 처리 성공/실패 상세 내역 확인 가능

🔧 Outbox Pattern 구현 후 이벤트가 중복 발행된다

섹션 제목: “🔧 Outbox Pattern 구현 후 이벤트가 중복 발행된다”

증상: Outbox 테이블에서 이벤트를 읽어 발행하는 Processor가 동일한 이벤트를 두 번 발행함 원인: Outbox Processor를 여러 인스턴스로 스케일아웃한 경우, 여러 인스턴스가 같은 outbox 레코드를 동시에 읽고 발행할 수 있다. 또는 Processor가 실패 후 재시작하면서 이미 발행했던 레코드를 다시 처리함 해결:

  1. SELECT ... FOR UPDATE SKIP LOCKED 패턴으로 분산 잠금 구현:

    -- Outbox Processor에서 처리할 레코드 선점 (다른 인스턴스가 못 가져가게)
    BEGIN;
    SELECT id, event_type, payload
    FROM outbox_events
    WHERE status = 'PENDING'
    ORDER BY created_at ASC
    LIMIT 10
    FOR UPDATE SKIP LOCKED; -- 다른 트랜잭션이 잠근 행은 건너뜀
    -- 처리 후 상태 변경
    UPDATE outbox_events SET status = 'PROCESSED', processed_at = NOW()
    WHERE id IN (...);
    COMMIT;
  2. TypeORM에서 SKIP LOCKED 사용:

    const events = await this.dataSource
    .createQueryBuilder(OutboxEvent, "e")
    .where("e.status = :status", { status: "PENDING" })
    .orderBy("e.createdAt", "ASC")
    .limit(10)
    .setLock("pessimistic_write_or_fail") // FOR UPDATE SKIP LOCKED
    .getMany();
  3. 이벤트 발행 자체의 멱등성도 보장: SNS MessageDeduplicationId(FIFO Topic) 또는 EventBridge의 이벤트 ID를 Consumer 쪽에서 중복 체크

  • EDA가 직접 호출 방식과 어떻게 다른지 설명할 수 있다
  • Queue(1:1)와 Pub/Sub(1:N)의 차이를 설명할 수 있다
  • Nest.js에서 @OnEvent 데코레이터의 동작 방식을 설명할 수 있다
  • 팀 서비스에서 EDA 패턴이 쓰이는 곳을 찾을 수 있다
  • Outbox Pattern이 왜 필요한지 설명할 수 있다
  • SNS / SQS / EventBridge 중 어떤 상황에 무엇을 쓸지 설명할 수 있다

CQRS, Event Sourcing, Saga Pattern, Outbox Pattern, AWS SNS+SQS Fan-out, Kafka Consumer Group, EventBridge

  • 팀 코드에서 @OnEvent, EventEmitter, 메시지 브로커 사용 여부 확인
    Terminal window
    # 팀 프로젝트에서 검색
    grep -r "@OnEvent\|EventEmitter\|ClientProxy" src/
    예상 출력: 사용 중이면 src/orders/order.listener.ts:5:@OnEvent('order.created') 형태
  • 서비스 간 직접 HTTP 호출이 있는 부분 → EDA로 바꾸면 어떨지 생각
  • 팀 코드에서 EventEmitter2 또는 ClientProxy import를 검색해서 이벤트 흐름 다이어그램 그려보기
  • AWS 콘솔에서 SNS Topics 목록 확인 경로: SNS → Topics → 현재 사용 중인 Topic과 각 Topic의 Subscription 수 확인
  1. EDA는 이벤트 발행/구독으로 서비스 간 느슨한 결합을 만드는 아키텍처다
  2. Publisher는 누가 구독하는지, Subscriber는 누가 발행했는지 몰라도 된다
  3. Queue(1:1)는 작업 처리, Pub/Sub(1:N)은 이벤트 브로드캐스트에 사용한다
  4. Nest.js는 내장 EventEmitter와 외부 메시지 브로커 모듈을 모두 지원한다
  5. 마이크로서비스에서 장애 전파를 막는 핵심 패턴이다

11. 실전 장애 대응 시나리오 (On-Call Runbook)

섹션 제목: “11. 실전 장애 대응 시나리오 (On-Call Runbook)”

EDA 구조에서 자주 발생하는 장애 패턴과 on-call 대응 절차

시나리오 A: “특정 이벤트가 처리되지 않는다”는 제보를 받았을 때

섹션 제목: “시나리오 A: “특정 이벤트가 처리되지 않는다”는 제보를 받았을 때”
Step 1. 이벤트가 브로커에 도달했는지 확인 (2분)
AWS SNS의 경우:
SNS → Topics → 해당 Topic → Monitoring
확인 지표: NumberOfMessagesPublished (발행 수)
→ 발행 수가 0이면 Publisher 쪽 문제
AWS SQS의 경우:
SQS → 해당 Queue → Monitoring
확인 지표: NumberOfMessagesSent, NumberOfMessagesReceived
→ Sent는 있는데 Received가 0이면 Consumer(Worker)가 폴링을 안 하는 것
Step 2. DLQ 확인 (2분)
SQS → DLQ Queue → ApproximateNumberOfMessagesVisible
→ DLQ에 메시지가 있으면 Consumer가 처리 실패한 것
→ DLQ 메시지 샘플 한 건 확인 → Body 내용으로 어떤 이벤트인지 파악
Step 3. Consumer(Worker) 상태 확인 (3분)
ECS → Clusters → Worker 서비스 → Tasks
→ STOPPED 태스크가 있으면 로그 확인 (왜 죽었는지)
→ CloudWatch Log Insights에서 Worker 로그 에러 검색
Step 4. 임시 복구
Worker 재시작 → DLQ Redrive(원본 큐로 재전송) → 처리 재개 확인

시나리오 B: “Poison Pill” 메시지로 Consumer가 무한 실패할 때

섹션 제목: “시나리오 B: “Poison Pill” 메시지로 Consumer가 무한 실패할 때”
증상: 특정 메시지 때문에 Consumer가 계속 실패하고, 정상 메시지도 처리 못하게 됨
Poison Pill이란?
→ 파싱 불가 데이터, 잘못된 형식, 비즈니스 로직에서 처리 불가한 특수 케이스
→ Visibility Timeout 후 계속 재노출 → Consumer가 계속 실패 → DLQ 도달
즉각 조치:
1. DLQ에서 해당 메시지 내용 확인
SQS → DLQ → Send and receive messages → Receive message
→ Body 내용 복사 후 파싱 시도
2. 정상 처리가 불가한 케이스라면:
→ DLQ에서 해당 메시지 수동 삭제
→ 또는 별도 처리 스크립트로 예외 처리 후 삭제
3. 근본 해결:
→ Consumer 코드에 예외 처리 강화 (파싱 실패 시 로그 남기고 스킵)
→ Schema 검증 로직 추가 (Consumer 진입 시 메시지 유효성 먼저 체크)
예방 패턴:
@SqsMessageHandler('order-queue', false)
async handleOrder(message: Message) {
try {
const payload = JSON.parse(message.Body!);
// payload 검증 (Zod, class-validator 등)
await this.processOrder(payload);
} catch (parseError) {
// 파싱/검증 실패는 재시도해도 동일 → DLQ로 즉시 보냄
this.logger.error('파싱 실패 - 재시도 불필요', { body: message.Body });
throw parseError; // throw해서 DLQ로 이동시킴
}
}

시나리오 C: “SNS → SQS 팬아웃이 일부만 동작할 때”

섹션 제목: “시나리오 C: “SNS → SQS 팬아웃이 일부만 동작할 때””
증상: SNS에 이벤트를 발행했는데 3개 SQS 구독 중 1개만 메시지를 받음
확인 순서:
1. SNS → Topics → Subscriptions 탭
→ 모든 구독의 Status가 "Confirmed"인지 확인
→ "PendingConfirmation" 있으면 다시 구독 설정 필요
2. 누락된 SQS Queue의 Access Policy 확인
SQS → 해당 Queue → Access policy
→ SNS Topic ARN에서 sqs:SendMessage를 허용하는 Policy 있는지 확인
→ 없으면 아래 Policy 추가 필요:
Principal: sns.amazonaws.com
Action: sqs:SendMessage
Condition: ArnEquals aws:SourceArn = [SNS Topic ARN]
3. 메시지 필터링 확인
SNS Subscription → Filter policy가 설정된 경우
→ 발행 메시지의 MessageAttributes와 Filter policy가 매칭되는지 확인

EventBridge Pipes (2024~2025 주목 기능)

EventBridge Pipes는 이벤트 소스(SQS, DynamoDB Streams 등)와 이벤트 타깃(Lambda, SQS 등)을 코드 없이 직접 연결하는 서비스다. 기존에는 Lambda를 통해 변환해야 했던 것을 Pipe의 필터링/변환 단계로 처리한다.

기존 방식:
SQS → Lambda (필터링/변환 코드) → 다른 SQS/SNS
EventBridge Pipes 방식:
SQS ──[Pipe]──→ EventBridge Target
└─ 필터링: messageType = "order.created"만
└─ 변환: input template으로 필드 재구성
└─ 타깃: 다른 SQS / EventBridge Bus / Lambda
이점: Lambda 없이 이벤트 라우팅 가능 → 인프라 단순화

Schema Registry (이벤트 계약 표준화)

대규모 EDA에서 이벤트 스키마 관리가 핵심 과제가 됐다. EventBridge Schema Registry를 사용하면 이벤트 형식을 중앙에서 관리하고, TypeScript 타입을 자동 생성할 수 있다. 팀 간 이벤트 계약(Contract)을 코드로 명시하는 방향이 2025년 업계 표준으로 자리잡는 중이다.

Dead-Letter Queue 14일 보존 원칙

2025년 프로덕션 베스트 프랙티스로 “DLQ Retention은 항상 최대 14일로 설정”이 권장된다. 원본 큐와 같은 보존 기간으로 설정하면 DLQ에 도달하기 전에 메시지가 만료될 수 있다.

Terminal window
# DLQ Retention 14일로 설정
aws sqs set-queue-attributes \
--queue-url https://sqs.ap-northeast-2.amazonaws.com/123456/email-dlq \
--attributes MessageRetentionPeriod=1209600 # 14일 = 1209600초

📖 더 보기: Event-Driven Architecture: Production Pitfalls & Fixes — EDA를 실제 프로덕션에서 운영할 때 마주치는 함정들과 해결법 (중급)