Event-Driven Architecture: 동기 지옥에서 벗어나 시스템을 변화시킨 여정
동기 방식의 한계를 넘어 Event-Driven Architecture를 도입한 실전 경험. Kafka, RabbitMQ 활용법과 설계 패턴을 스토리텔링으로 풀어냅니다.
Event-Driven Architecture: 동기 지옥에서 벗어나 시스템을 변화시킨 여정
모든 좋은 기술 도입에는 이야기가 있습니다. 이 글은 댄 하먼의 8단 스토리 서클(Story Circle) 구조를 따라, 동기 방식의 한계에서 출발해 Event-Driven Architecture(EDA)를 도입하고 돌아오기까지의 여정을 기술적으로 풀어냅니다.
TL;DR
- 동기(Synchronous) 아키텍처는 초기에는 단순하지만, 트래픽과 서비스가 증가하면 병목과 장애 전파의 원인이 됩니다
- Event-Driven Architecture는 서비스 간 결합도를 낮추고 확장성을 극대화하는 비동기 설계 패턴입니다
- Kafka와 RabbitMQ는 각각 다른 시나리오에 적합한 메시지 브로커입니다
- EDA 도입은 기술적 복잡성이라는 대가를 수반하지만, 올바르게 적용하면 시스템 전체의 안정성과 확장성이 근본적으로 달라집니다
1단계: 안정 (You) — 익숙한 동기 세계
모든 이야기는 주인공의 일상에서 시작됩니다.
우리의 시스템도 마찬가지였습니다. 전형적인 모놀리식 아키텍처에서 모든 요청은 동기적으로 처리되었습니다. 사용자가 주문을 넣으면, 하나의 HTTP 요청 안에서 모든 일이 순차적으로 일어났습니다.
사용자 요청 → 재고 확인 → 결제 처리 → 알림 발송 → 응답 반환
코드도 직관적이었습니다:
def create_order(request):
# 모든 것이 하나의 흐름 안에서 동기적으로 처리
inventory = check_inventory(request.item_id)
payment = process_payment(request.user_id, request.amount)
notification = send_notification(request.user_id, "주문 완료")
log_analytics(request)
return OrderResponse(status="completed")
이 방식은 심플하고 예측 가능했습니다. 디버깅도 쉬웠고, 콜 스택 하나로 모든 흐름을 추적할 수 있었습니다. 모든 것이 편안한 컴포트 존(Comfort Zone) 이었습니다.
하지만 안정은 오래가지 않습니다.
2단계: 욕구 (Need) — 무언가 달라져야 한다
이야기의 주인공에게는 해결해야 할 문제가 생깁니다.
트래픽이 10배로 늘었을 때, 동기 시스템의 약점이 한꺼번에 드러났습니다.
문제 1: 장애 전파 (Failure Cascading)
알림 서비스 하나가 다운되자, 전체 주문 프로세스가 멈췄습니다.
[주문 요청] → [재고 확인 ✅] → [결제 처리 ✅] → [알림 발송 ❌ TIMEOUT]
↓
전체 요청 실패
결제는 됐는데 주문은 실패?
문제 2: 응답 시간 누적
각 서비스의 응답 시간이 합산되어 사용자 경험을 직격했습니다.
재고 확인: 50ms + 결제 처리: 200ms + 알림: 100ms + 분석 로깅: 80ms
= 총 430ms (최소)
트래픽이 몰리면 각 서비스의 응답 시간이 늘어나면서 전체 레이턴시는 기하급수적으로 증가했습니다.
문제 3: 강한 결합 (Tight Coupling)
새로운 기능을 추가할 때마다 주문 서비스의 코드를 수정해야 했습니다. 포인트 적립? 주문 서비스 수정. 추천 엔진 연동? 주문 서비스 수정. 모든 변경이 하나의 서비스에 집중되었습니다.
def create_order(request):
inventory = check_inventory(request.item_id)
payment = process_payment(request.user_id, request.amount)
notification = send_notification(request.user_id, "주문 완료")
log_analytics(request)
update_recommendation_engine(request) # 또 추가
award_loyalty_points(request) # 또 추가
sync_crm(request) # 또 추가...
# 끝이 보이지 않는 의존성 체인
return OrderResponse(status="completed")
시스템은 우리에게 분명히 말하고 있었습니다. “이대로는 안 된다.”
3단계: 진입 (Go) — 낯선 세계로의 첫 발걸음
주인공은 익숙한 세계를 떠나 새로운 영역에 발을 들입니다.
우리는 Event-Driven Architecture라는 낯선 세계에 진입하기로 결정했습니다.
Event-Driven Architecture란?
서비스 간 직접 호출 대신, 이벤트(Event) 를 발행하고 관심 있는 서비스가 이를 구독하는 방식입니다.
[동기 방식]
주문 서비스 → 직접 호출 → 재고 서비스
주문 서비스 → 직접 호출 → 결제 서비스
주문 서비스 → 직접 호출 → 알림 서비스
[이벤트 방식]
주문 서비스 → "OrderCreated" 이벤트 발행 → 메시지 브로커
↓
재고 서비스 (구독)
결제 서비스 (구독)
알림 서비스 (구독)
핵심 개념은 세 가지입니다:
| 개념 | 설명 | 비유 |
|---|---|---|
| Producer | 이벤트를 발행하는 서비스 | 라디오 방송국 |
| Broker | 이벤트를 전달하는 중간자 | 전파 송신탑 |
| Consumer | 이벤트를 구독하는 서비스 | 라디오 수신기 |
첫 번째 이벤트 설계
이벤트의 구조부터 정의해야 했습니다:
{
"eventId": "evt-a1b2c3d4",
"eventType": "order.created",
"timestamp": "2026-04-13T09:30:00Z",
"version": "1.0",
"source": "order-service",
"data": {
"orderId": "ord-12345",
"userId": "usr-67890",
"items": [
{"itemId": "item-001", "quantity": 2, "price": 29900}
],
"totalAmount": 59800
},
"metadata": {
"correlationId": "corr-xyz789",
"traceId": "trace-abc123"
}
}
모든 것이 낯설었습니다. 동기 방식에서는 함수 호출의 반환값으로 성공/실패를 바로 알 수 있었는데, 이벤트를 발행한 후에는 결과를 바로 알 수 없다는 사실이 불안했습니다.
4단계: 탐색 (Search) — 적응과 시행착오
낯선 세계에서 주인공은 시행착오를 겪으며 적응합니다.
메시지 브로커 선택: Kafka vs RabbitMQ
가장 먼저 부딪힌 선택지는 메시지 브로커였습니다.
┌─────────────────────────────────────────────────────────┐
│ Apache Kafka │
├─────────────────────────────────────────────────────────┤
│ • 분산 로그 기반 스트리밍 플랫폼 │
│ • 메시지를 디스크에 영속적으로 저장 │
│ • 파티션 기반 병렬 처리 │
│ • 높은 처리량 (초당 수백만 메시지) │
│ • 이벤트 리플레이 가능 │
│ • 적합: 로그 수집, 스트림 처리, 이벤트 소싱 │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ RabbitMQ │
├─────────────────────────────────────────────────────────┤
│ • AMQP 프로토콜 기반 메시지 브로커 │
│ • 유연한 라우팅 (Exchange → Queue) │
│ • 메시지 단위 ACK/NACK │
│ • 낮은 지연 시간 │
│ • 우선순위 큐 지원 │
│ • 적합: 작업 큐, RPC 패턴, 복잡한 라우팅 │
└─────────────────────────────────────────────────────────┘
우리의 선택 기준:
def choose_broker(requirements):
if requirements.need_event_replay:
return "Kafka" # 이벤트 리플레이가 필요하면 Kafka
if requirements.throughput > 100_000_per_sec:
return "Kafka" # 대용량 처리가 필요하면 Kafka
if requirements.complex_routing:
return "RabbitMQ" # 복잡한 라우팅이 필요하면 RabbitMQ
if requirements.message_priority:
return "RabbitMQ" # 우선순위 처리가 필요하면 RabbitMQ
if requirements.team_experience == "limited":
return "RabbitMQ" # 팀 경험이 적으면 진입장벽이 낮은 RabbitMQ
return "Kafka" # 기본값은 Kafka (확장성 우선)
주문 시스템에는 Kafka를, 내부 태스크 큐에는 RabbitMQ를 함께 사용하기로 했습니다.
실전 구현: 주문 서비스 리팩토링
동기 코드를 이벤트 기반으로 변환했습니다:
# Before: 동기 방식
def create_order(request):
inventory = check_inventory(request.item_id)
payment = process_payment(request.user_id, request.amount)
send_notification(request.user_id, "주문 완료")
return OrderResponse(status="completed")
# After: 이벤트 기반
class OrderService:
def __init__(self, event_bus: EventBus):
self.event_bus = event_bus
def create_order(self, request):
# 주문 생성 (핵심 로직만 동기 처리)
order = Order.create(
user_id=request.user_id,
items=request.items
)
order.save()
# 이벤트 발행 (나머지는 비동기로 처리)
self.event_bus.publish(
event_type="order.created",
data={
"order_id": order.id,
"user_id": request.user_id,
"items": request.items,
"total_amount": order.total_amount
}
)
return OrderResponse(
order_id=order.id,
status="accepted" # "completed"가 아닌 "accepted"
)
# 각 서비스는 독립적으로 이벤트를 구독
class InventoryConsumer:
@subscribe("order.created")
def handle_order_created(self, event):
reserve_inventory(event.data["items"])
self.event_bus.publish("inventory.reserved", {...})
class PaymentConsumer:
@subscribe("inventory.reserved")
def handle_inventory_reserved(self, event):
process_payment(event.data["user_id"], event.data["total_amount"])
self.event_bus.publish("payment.completed", {...})
class NotificationConsumer:
@subscribe("payment.completed")
def handle_payment_completed(self, event):
send_notification(event.data["user_id"], "주문이 완료되었습니다")
시행착오들
탐색 과정은 순탄하지 않았습니다:
실수 1: 이벤트 스키마 버전 관리 미흡
이벤트 구조를 변경했더니 기존 컨슈머가 일제히 실패했습니다.
# 해결: 스키마 레지스트리와 버전 관리 도입
class EventSchema:
@staticmethod
def validate(event, version="1.0"):
schema = SchemaRegistry.get("order.created", version)
return schema.validate(event.data)
@staticmethod
def evolve(event, from_version, to_version):
"""하위 호환성을 위한 이벤트 변환"""
transformer = SchemaRegistry.get_transformer(
event.type, from_version, to_version
)
return transformer.transform(event)
실수 2: 멱등성(Idempotency) 미처리
네트워크 재시도로 같은 이벤트가 두 번 처리되어 결제가 이중으로 발생했습니다.
# 해결: 멱등성 키 기반 중복 처리 방지
class IdempotentConsumer:
def __init__(self, cache: Redis):
self.cache = cache
def process(self, event):
idempotency_key = f"processed:{event.event_id}"
if self.cache.exists(idempotency_key):
logger.info(f"Duplicate event skipped: {event.event_id}")
return # 이미 처리된 이벤트
self._handle(event)
self.cache.setex(idempotency_key, ttl=86400, value="1")
실수 3: 이벤트 순서 보장 실패
결제 완료 이벤트가 재고 확인 이벤트보다 먼저 도착하는 경우가 발생했습니다.
# 해결: Kafka 파티션 키를 사용한 순서 보장
producer.send(
topic="order-events",
key=order_id.encode(), # 같은 주문의 이벤트는 같은 파티션으로
value=event.serialize()
)
5단계: 발견 (Find) — 원하던 것을 얻다
시행착오 끝에 주인공은 목표에 도달합니다.
EDA가 자리를 잡자 시스템의 변화는 극적이었습니다.
성과 1: 장애 격리 성공
[Before] 알림 서비스 장애 → 전체 주문 프로세스 중단
[After] 알림 서비스 장애 → 주문은 정상 처리, 알림만 지연 후 재처리
성과 2: 응답 시간 개선
[Before] 430ms (동기 합산)
[After] 85ms (주문 생성 + 이벤트 발행만)
개선율: 약 80% 감소
성과 3: 신규 기능 추가의 자유
# 새로운 서비스 추가 시 기존 코드 수정 불필요
# 그냥 새로운 컨슈머를 추가하면 끝
class LoyaltyPointsConsumer:
@subscribe("payment.completed")
def award_points(self, event):
points = calculate_points(event.data["total_amount"])
add_points(event.data["user_id"], points)
class RecommendationConsumer:
@subscribe("order.created")
def update_model(self, event):
update_recommendation(event.data["user_id"], event.data["items"])
주문 서비스는 이 컨슈머들의 존재조차 알 필요가 없었습니다. Open-Closed Principle이 아키텍처 수준에서 실현된 순간이었습니다.
Saga 패턴으로 분산 트랜잭션 해결
여러 서비스에 걸친 트랜잭션은 Saga 패턴으로 해결했습니다:
class OrderSaga:
"""
Choreography 기반 Saga: 각 서비스가 이벤트를 발행하며 체인 형성
정상 흐름:
OrderCreated → InventoryReserved → PaymentCompleted → OrderConfirmed
보상 흐름 (결제 실패 시):
PaymentFailed → InventoryReleased → OrderCancelled
"""
COMPENSATION_MAP = {
"payment.failed": [
("inventory-service", "release_inventory"),
("order-service", "cancel_order"),
],
"inventory.insufficient": [
("order-service", "reject_order"),
],
}
def handle_failure(self, failure_event):
compensations = self.COMPENSATION_MAP.get(failure_event.type, [])
for service, action in compensations:
self.event_bus.publish(
event_type=f"compensation.{action}",
data=failure_event.data
)
6단계: 대가 (Take) — 치러야 할 비용
원하는 것을 얻었지만, 주인공은 무거운 대가를 치릅니다.
EDA는 공짜가 아니었습니다.
대가 1: 디버깅 복잡성 폭발
동기 방식에서는 콜 스택 하나로 전체 흐름을 파악할 수 있었지만, 이벤트 기반에서는 흐름이 여러 서비스에 분산되어 추적이 어려워졌습니다.
# 해결: 분산 추적(Distributed Tracing) 도입
class TracedEventBus:
def publish(self, event_type, data, trace_context=None):
trace_id = trace_context or generate_trace_id()
event = Event(
event_type=event_type,
data=data,
metadata={
"trace_id": trace_id,
"span_id": generate_span_id(),
"parent_span_id": get_current_span_id(),
"timestamp": datetime.utcnow().isoformat()
}
)
# OpenTelemetry로 추적 데이터 수집
with tracer.start_span(f"publish:{event_type}") as span:
span.set_attribute("event.id", event.event_id)
self.broker.send(event)
대가 2: Eventual Consistency와의 싸움
데이터가 즉시 일관되지 않는 세계에 적응해야 했습니다:
사용자: "방금 주문했는데 주문 내역에 안 보여요!"
실제: 이벤트가 아직 처리 중... (수백 ms 지연)
# 해결: CQRS 패턴으로 읽기/쓰기 모델 분리
class OrderQueryService:
"""읽기 전용 뷰 - 이벤트를 구독하여 비정규화된 뷰 유지"""
@subscribe("order.created")
def on_order_created(self, event):
self.read_db.upsert("orders_view", {
"order_id": event.data["order_id"],
"status": "processing",
"created_at": event.timestamp
})
@subscribe("payment.completed")
def on_payment_completed(self, event):
self.read_db.update("orders_view",
filter={"order_id": event.data["order_id"]},
update={"status": "confirmed"}
)
대가 3: 운영 복잡성
모니터링해야 할 대상이 급격히 늘어났습니다:
# 필수 모니터링 지표
monitoring:
broker:
- consumer_lag # 컨슈머가 얼마나 뒤처져 있는가
- partition_count # 파티션 수 적절한가
- replication_factor # 복제 상태
consumers:
- processing_rate # 초당 처리량
- error_rate # 처리 실패율
- retry_count # 재시도 횟수
dead_letter_queue:
- queue_depth # 처리 불가 메시지 수
- oldest_message_age # 가장 오래된 미처리 메시지
7단계: 귀환 (Return) — 익숙한 세계로 돌아오다
대가를 치른 주인공은 원래 세계로 돌아옵니다.
EDA를 도입하고, 대가를 치르고, 해결책을 찾은 후에 우리는 다시 일상적인 개발 업무로 돌아왔습니다. 하지만 시스템의 모습은 완전히 달라져 있었습니다.
최종 아키텍처
┌─────────────────┐
│ API Gateway │
└────────┬────────┘
│
┌────────▼────────┐
│ Order Service │
│ (Producer) │
└────────┬────────┘
│ publish
┌────────▼────────┐
│ Apache Kafka │
│ (Event Broker) │
└────────┬────────┘
│ subscribe
┌──────────────────┼──────────────────┐
│ │ │
┌─────────▼──────┐ ┌────────▼───────┐ ┌────────▼───────┐
│ Inventory │ │ Payment │ │ Notification │
│ Service │ │ Service │ │ Service │
└────────────────┘ └────────────────┘ └────────────────┘
│ │ │
└──────────────────┼──────────────────┘
│
┌────────▼────────┐
│ Dead Letter │
│ Queue (DLQ) │
└─────────────────┘
운영 안정화 체크리스트
class EDAHealthCheck:
"""EDA 도입 후 안정화를 위한 체크리스트"""
checks = [
"모든 이벤트에 correlationId/traceId 포함",
"모든 컨슈머에 멱등성 처리 구현",
"Dead Letter Queue 설정 및 알림 연동",
"이벤트 스키마 레지스트리 운영",
"컨슈머 랙 모니터링 및 알림 설정",
"Saga 보상 트랜잭션 테스트 완료",
"이벤트 리플레이 절차 문서화",
"장애 시 수동 이벤트 재발행 도구 준비",
]
8단계: 변화 (Change) — 달라진 우리
여정을 마친 주인공은 근본적으로 변화합니다.
EDA를 경험한 후, 시스템 설계를 바라보는 관점 자체가 바뀌었습니다.
Before vs After: 사고방식의 변화
[Before] "이 서비스가 저 서비스를 호출해야 해"
[After] "이 서비스는 이런 이벤트를 발행하고, 관심 있는 서비스가 알아서 반응해"
[Before] "모든 것이 즉시 일관되어야 해"
[After] "결과적 일관성으로 충분한 경우가 대부분이야"
[Before] "에러가 나면 즉시 롤백해야 해"
[After] "보상 트랜잭션으로 비즈니스 정합성을 맞추면 돼"
EDA를 적용해야 할 때 vs 하지 말아야 할 때
| 적용해야 할 때 | 하지 말아야 할 때 |
|---|---|
| 서비스 간 결합도를 낮춰야 할 때 | 단순한 CRUD 애플리케이션 |
| 높은 처리량이 필요할 때 | 강한 즉시 일관성이 필수일 때 |
| 독립적인 확장이 필요할 때 | 팀 규모가 작고 서비스가 적을 때 |
| 이벤트 리플레이/감사가 필요할 때 | 운영 인프라 투자가 어려울 때 |
핵심 설계 원칙 정리
PRINCIPLES = {
"이벤트는 사실(fact)이다":
"과거에 일어난 일을 기록하는 것이지, 명령이 아니다. "
"'OrderCreated'이지 'CreateOrder'가 아니다.",
"멱등성은 선택이 아니다":
"모든 컨슈머는 같은 이벤트를 두 번 받아도 "
"같은 결과를 보장해야 한다.",
"스키마는 계약이다":
"이벤트 스키마를 변경할 때는 하위 호환성을 "
"반드시 유지해야 한다.",
"관찰 가능성이 생명줄이다":
"분산 추적, 로깅, 메트릭 없이 EDA를 운영하는 것은 "
"눈을 감고 운전하는 것과 같다.",
"단순함을 먼저 고려하라":
"EDA가 필요 없는 곳에 도입하면 "
"복잡성만 늘어난다. 동기 호출이 맞는 곳도 있다.",
}
FAQ
Q: EDA를 도입하면 기존 동기 API를 전부 바꿔야 하나요?
아닙니다. 점진적으로 도입할 수 있습니다. 핵심 비즈니스 흐름은 동기로 유지하면서, 부가적인 처리(알림, 로깅, 분석)부터 이벤트 기반으로 전환하는 것이 일반적인 접근법입니다.
Q: Kafka와 RabbitMQ 중 하나만 선택해야 하나요?
꼭 그렇지 않습니다. 용도에 따라 함께 사용할 수 있습니다. 대용량 이벤트 스트리밍에는 Kafka를, 태스크 큐나 복잡한 라우팅이 필요한 곳에는 RabbitMQ를 사용하는 하이브리드 구성도 흔합니다.
Q: 이벤트 순서를 보장하려면 어떻게 해야 하나요?
Kafka의 경우 파티션 키를 사용하면 같은 키를 가진 이벤트의 순서가 보장됩니다. 전역적인 순서 보장은 단일 파티션을 사용해야 하지만, 처리량이 제한되므로 대부분의 경우 파티션 키 기반의 부분 순서 보장으로 충분합니다.
Q: Dead Letter Queue(DLQ)는 반드시 필요한가요?
실수로라도 빠뜨리면 안 됩니다. 처리에 실패한 이벤트가 영원히 유실되면 데이터 정합성 문제로 이어집니다. DLQ에 모아두고, 모니터링하며, 원인을 파악한 후 재처리하는 프로세스가 필수입니다.
마치며
댄 하먼의 스토리 서클은 “변화는 대가를 수반한다” 는 것을 가르쳐줍니다.
Event-Driven Architecture도 마찬가지입니다. 동기 방식의 단순함이라는 컴포트 존을 떠나, 복잡성이라는 대가를 치르고, 확장성과 유연성이라는 보상을 얻어 돌아옵니다. 그리고 그 여정을 통해 시스템과 개발자 모두가 성장합니다.
중요한 것은, 모든 시스템이 이 여정을 떠날 필요는 없다는 점입니다. 동기 방식이 충분한 곳에서는 동기 방식이 최선입니다. 하지만 시스템이 성장의 한계에 부딪혔을 때, EDA는 그 벽을 넘기 위한 강력한 선택지가 됩니다.
당신의 시스템은 지금 스토리 서클의 어느 단계에 있나요?