Apache Kafka를 AI 에이전트 이벤트 브로커로 — MCP·A2A 멀티에이전트 시스템 대규모 확장 패턴
멀티에이전트 시스템을 처음 구성할 때 대부분 HTTP로 시작합니다. 에이전트 A가 B를 직접 호출하고, B가 C를 호출하는 식으로요. 처음 두세 개 에이전트는 꽤 잘 돌아갑니다. 그런데 열 개를 넘어가면 슬슬 이상해집니다. 타임아웃이 연쇄적으로 터지고, 어디서 실패했는지 추적이 안 되고, 새 에이전트를 추가할 때마다 기존 코드를 건드려야 합니다. 저도 그 경험을 직접 했고, 그때서야 깨달았습니다 — 에이전트 간 통신은 마이크로서비스와 완전히 같은 문제를 안고 있다는 걸요. 에이전트 10개 이상, 혹은 초당 수천 건의 이벤트를 처리해야 하는 규모라면 이 글이 도움이 될 겁니다.
2025년 4월 Google이 A2A 프로토콜을 오픈소스로 공개하면서 이 스택이 실제로 수렴하기 시작했습니다. Confluent, LangChain, CrewAI, SAP, Salesforce 등 50개 이상의 파트너가 즉시 지원을 선언했고, Anthropic의 MCP와 함께 Apache Kafka가 에이전트 생태계의 이벤트 브로커로 자리를 잡아가고 있습니다. 이 글에서는 왜 Apache Kafka가 이 역할에 적합한지, 그리고 MCP·A2A와 함께 어떤 아키텍처 패턴을 만들어내는지를 실제 사례 중심으로 살펴봅니다.
핵심 개념
네 계층의 역할 분리
이 스택을 처음 접하면 MCP와 A2A가 비슷해 보여서 헷갈리기 쉽습니다. 저도 처음엔 "둘 다 에이전트 통신 아닌가?" 싶었는데, 역할이 명확하게 다릅니다.
| 계층 | 프로토콜/기술 | 역할 | 정의하는 것 |
|---|---|---|---|
| 도구 접근 | MCP (Anthropic) | 에이전트 ↔ 외부 세계 | 에이전트가 API, DB, 파일에 접근하는 방법 |
| 에이전트 협업 | A2A (Google) | 에이전트 ↔ 에이전트 | 에이전트들이 서로를 발견하고 작업을 위임하는 방법 |
| 메시지 전달 | Apache Kafka | 비동기 메시지 흐름 | 메시지가 신뢰성 있게 전달되는 방법 |
| 스트림 처리 | Apache Flink | 실시간 데이터 변환 | 이벤트 스트림을 집계·변환해 에이전트 입력을 만드는 방법 |
한 문장으로 요약하면: A2A가 에이전트의 언어를 정의하고, MCP가 에이전트의 도구 접근을 표준화하고, Kafka가 메시지 흐름을 보장하고, Flink가 그 흐름을 처리합니다.
MCP (Model Context Protocol): 에이전트가 외부 도구나 데이터 소스와 상호작용하는 방식을 USB-C처럼 표준화한 프로토콜입니다. LangChain, CrewAI 같은 프레임워크가 각자 다른 방식으로 도구를 연결하던 문제를 해결합니다.
A2A (Agent-to-Agent Protocol): 서로 다른 벤더나 프레임워크로 만들어진 에이전트들이 협업할 수 있도록 Google이 공개한 오픈 프로토콜입니다. Agent Card를 통해 에이전트의 능력을 광고하고, Task API로 작업을 위임합니다.
Apache Flink: Kafka 스트림을 실시간으로 처리·변환·집계하는 엔진입니다. LLM 기반 에이전트는 초당 수천 건의 원시 센서·트랜잭션 스트림을 직접 처리하기에 토큰 비용과 레이턴시가 맞지 않습니다. Flink가 이 부담을 대신 지면 에이전트는 정제된 이벤트 하나만 받으면 됩니다.
왜 HTTP가 에이전트 규모에서 깨지는가
"왜 굳이 Kafka인가? HTTP/REST로 충분하지 않나?"라는 질문을 가장 많이 받습니다. 솔직히 소규모에서는 HTTP가 훨씬 단순합니다. 그런데 에이전트 수가 늘어나면 HTTP 기반 아키텍처는 마이크로서비스 스파게티와 똑같은 문제를 재현합니다.
# HTTP 기반 (포인트-투-포인트)
에이전트A → 에이전트B
에이전트A → 에이전트C
에이전트B → 에이전트C
에이전트B → 에이전트D
...
# n개 에이전트 = 최대 n*(n-1)/2개 직접 연결
# 한 에이전트 장애 = 연결된 모든 에이전트에 전파
# Kafka 기반 (발행-구독)
에이전트A → [Kafka 토픽: price-events] → 에이전트B, C, D (구독)
에이전트B → [Kafka 토픽: analysis-results] → 에이전트E, F (구독)
# 에이전트는 토픽만 알면 됨. 서로를 몰라도 됨
# 한 에이전트 장애 = 해당 에이전트만 영향발행-구독(pub/sub): 메시지를 보내는 쪽(발행자)과 받는 쪽(구독자)이 서로를 직접 알지 않아도 되는 통신 패턴입니다. 발행자는 토픽에 메시지를 던지고, 구독자는 관심 있는 토픽만 골라 받습니다.
Kafka는 세 가지를 바꿔줍니다. 첫째, 에이전트가 서로를 직접 참조하지 않아도 됩니다(느슨한 결합). 둘째, 모든 메시지가 Kafka 로그에 영구 기록되므로 감사와 재실행이 가능합니다. 셋째, 에이전트와 파티션을 독립적으로 수평 확장할 수 있습니다.
Kafka 위의 오케스트레이터-워커 패턴
Kafka 위에서 에이전트를 구성하는 가장 대표적인 패턴은 오케스트레이터-워커입니다.
┌──────────────────────────────────────────────────────┐
│ Kafka Topics │
│ │
│ [task-requests] [task-results] [dead-letter] │
└──────────┬──────────────┬─────────────────────────────┘
│ │
┌──────┴──────┐ │
│ Orchestrator│◄──────┘
│ Agent │ 결과 수집 및
│ (A2A 위임) │ 다음 단계 결정
└──────┬──────┘
│ 작업 발행
┌──────┴──────────────────────┐
│ │
┌───┴────┐ ┌────────┐ ┌────────┴┐
│Worker A│ │Worker B│ │Worker C │
│(분석) │ │(가격) │ │(알림) │
└────────┘ └────────┘ └─────────┘오케스트레이터는 task-requests 토픽에 작업을 발행하고, 워커들은 자신이 처리할 수 있는 작업 유형을 구독합니다. 작업 할당은 Kafka 컨슈머 그룹 파티션 배분으로 자연스럽게 이루어지기 때문에 오케스트레이터가 워커를 일일이 지목하지 않아도 됩니다. 처리 결과는 task-results 토픽으로 다시 발행되고, 오케스트레이터가 이를 집계해 다음 단계를 결정합니다.
실무에서 가장 많이 들어오는 질문이 "워커 일부가 실패하면 어떻게 되나요?"인데, Kafka의 오프셋 관리가 이 문제를 해결합니다. 워커가 메시지를 처리하지 못하면 오프셋을 커밋하지 않아 해당 메시지가 재처리 대상이 됩니다. 반복 실패하는 메시지는 dead-letter 토픽으로 격리해 전체 흐름이 막히지 않도록 할 수 있습니다.
A2A Agent Card: 에이전트가 서로를 발견하는 방법
A2A의 핵심 개념 중 하나가 Agent Card입니다. 에이전트가 자신의 능력을 선언하는 JSON 문서로, /.well-known/agent.json 경로에 공개됩니다.
{
"name": "PriceAnalysisAgent",
"description": "경쟁사 가격 데이터를 분석해 가격 조정 권고를 생성합니다",
"url": "http://price-analysis-agent/",
"capabilities": {
"streaming": false,
"pushNotifications": true
},
"skills": [
{
"id": "analyze-price-gap",
"name": "가격 차이 분석",
"description": "제품 ID와 경쟁사 가격을 받아 조정 권고를 반환합니다",
"inputModes": ["text"],
"outputModes": ["text"]
}
]
}오케스트레이터는 런타임에 이 Agent Card를 조회해 "어떤 에이전트가 어떤 작업을 처리할 수 있는가"를 파악하고 A2A Task로 위임합니다. 에이전트가 추가되거나 교체되더라도 오케스트레이터 코드를 바꾸지 않아도 된다는 게 이 방식의 강점입니다.
실전 적용
예시 1: 이커머스 실시간 가격 자동 조정
제가 흥미롭게 봤던 실제 패턴입니다. 경쟁사 가격이 변동했을 때 자동으로 대응하는 시스템인데, 여기서 Kafka + A2A 조합이 얼마나 자연스럽게 맞아떨어지는지 볼 수 있습니다.
# 1. 가격 수집 에이전트 (Kafka Producer)
# MCP를 통해 경쟁사 웹사이트 스크래핑 후 이벤트 발행
from confluent_kafka import Producer
import json
import uuid
class PriceCollectorAgent:
def __init__(self):
self.producer = Producer({'bootstrap.servers': 'kafka:9092'})
self.mcp_client = MCPClient("scraping-server")
async def collect_and_publish(self, competitor_url: str):
# MCP 도구 호출: 경쟁사 가격 스크래핑
price_data = await self.mcp_client.call_tool(
"scrape_price",
{"url": competitor_url}
)
event = {
"event_type": "competitor_price_changed",
"competitor": competitor_url,
"new_price": price_data["price"],
"product_id": price_data["product_id"],
"timestamp": price_data["timestamp"],
"correlation_id": str(uuid.uuid4()) # 분산 추적용, 처음부터 반드시 포함
}
self.producer.produce(
topic="ecommerce.price-changed.v1",
key=price_data["product_id"],
value=json.dumps(event)
)
self.producer.flush()# 2. 가격 분석 에이전트 — A2A Task 위임
# 주의: 아래는 A2A 프로토콜 흐름을 표현한 의사 코드입니다.
# 실제 구현은 google-a2a 레퍼런스 SDK 또는 httpx를 직접 사용해야 합니다.
import asyncio
import httpx
from confluent_kafka import Consumer
class PriceAnalysisAgent:
def __init__(self):
self.consumer = Consumer({
'bootstrap.servers': 'kafka:9092',
'group.id': 'price-analysis-group',
'auto.offset.reset': 'earliest'
})
self.consumer.subscribe(['ecommerce.price-changed.v1'])
# MCP로 내부 재고 DB에도 접근
self.mcp_client = MCPClient("inventory-db-server")
async def process(self):
loop = asyncio.get_event_loop()
while True:
# confluent_kafka poll()은 동기 호출이므로
# asyncio 이벤트 루프 블로킹 방지를 위해 executor로 래핑
msg = await loop.run_in_executor(None, self.consumer.poll, 1.0)
if msg is None or msg.error():
continue
event = json.loads(msg.value())
# MCP로 내부 재고 상태 확인
inventory = await self.mcp_client.call_tool(
"get_inventory", {"product_id": event["product_id"]}
)
should_adjust = self.analyze_price_gap(event, inventory)
if should_adjust:
# A2A: Agent Card 조회 후 주문 업데이트 에이전트에 위임
async with httpx.AsyncClient() as client:
await client.post(
"http://order-update-agent/tasks/send",
json={
"message": {
"parts": [{
"text": f"Update price for {event['product_id']} to {event['new_price'] * 0.99}"
}]
},
"metadata": {"correlation_id": event["correlation_id"]}
}
)
# 정상 처리 후 오프셋 커밋
self.consumer.commit()| 단계 | 에이전트 | 역할 | 통신 방식 |
|---|---|---|---|
| 1 | PriceCollector | 경쟁사 가격 수집 | MCP (스크래핑 도구) → Kafka 발행 |
| 2 | PriceAnalysis | 재고 확인 + 가격 차이 분석 | MCP (DB 조회) + Kafka 구독 → A2A 위임 |
| 3 | OrderUpdate | 주문 가격 업데이트 | A2A 수신 → Kafka 발행 |
| 4 | CustomerNotify | 고객 알림 발송 | Kafka 구독 → MCP (알림 도구) |
예시 2: 제조 IoT 예측 유지보수
Flink가 왜 이 스택에 필요한지 가장 잘 보여주는 예시입니다. 센서 한 개의 값만 보면 이상인지 아닌지 모릅니다. 여러 센서의 값을 시간 윈도우로 집계해야 패턴이 보이거든요.
아래 코드는 Flink Java API입니다. Flink는 Java/Scala API가 더 성숙해 있어 프로덕션에서 많이 쓰이며, Python을 선호한다면 PyFlink로도 동일한 로직을 표현할 수 있습니다.
// Flink Java: IoT 센서 스트림에서 이상 패턴 감지
// 10분 윈도우 내 진동 센서 평균이 임계값 초과 시 이벤트 발행
DataStream<SensorReading> sensorStream = env
.addSource(new FlinkKafkaConsumer<>(
"manufacturing.sensor-reading.v1",
new SensorReadingDeserializer(),
kafkaProps
));
DataStream<MaintenanceAlert> alerts = sensorStream
.keyBy(SensorReading::getMachineId)
// 10분 슬라이딩 윈도우, 1분마다 갱신
.window(SlidingEventTimeWindows.of(
Time.minutes(10), Time.minutes(1)
))
.aggregate(new VibrationAnomalyDetector())
.filter(alert -> alert.getSeverity() > THRESHOLD);
// 이상 감지 이벤트를 Kafka 토픽에 발행
// 진단 에이전트가 구독하고 A2A로 유지보수 에이전트에 위임
alerts.addSink(new FlinkKafkaProducer<>(
"agent.task-requests.maintenance",
new AlertSerializer(),
kafkaProps
));전체 흐름을 정리하면:
IoT 센서
→ Kafka(manufacturing.sensor-reading.v1)
→ Flink(10분 윈도우 이상 패턴 감지)
→ Kafka(agent.task-requests.maintenance)
→ 진단 에이전트
→ A2A
→ 유지보수 스케줄링 에이전트Flink가 없으면 진단 에이전트가 원시 센서 스트림을 직접 처리해야 합니다. LLM 기반 에이전트에게 초당 수천 건의 원시 데이터를 넘기면 토큰 비용과 레이턴시 모두 감당이 안 됩니다. Flink가 먼저 걸러주면 에이전트는 "10분 내 진동 이상 감지됨"이라는 정제된 이벤트 하나만 처리하면 됩니다.
Kafka 토픽 설계 — 에이전트 생태계의 "계약"
실무에서 가장 많이 고민하게 되는 부분이 토픽 구조 설계입니다. 나중에 바꾸기 어렵기 때문에 처음부터 신경 써두는 편이 좋습니다. 저도 첫 프로젝트에서 토픽 이름을 너무 단순하게 짓는 바람에 이벤트 타입이 늘어났을 때 곤란했던 경험이 있습니다.
# 권장 토픽 네이밍 패턴: {도메인}.{이벤트-타입}.{버전}
topics:
# 원시 이벤트 (에이전트가 발행)
- ecommerce.price-changed.v1
- ecommerce.order-updated.v1
- manufacturing.sensor-reading.v1
# 에이전트 간 작업 위임
- agent.task-requests.pricing
- agent.task-results.pricing
- agent.task-requests.maintenance
- agent.dead-letter.v1 # 반복 실패 메시지 격리용
# 알림 및 액션
- notification.customer-alert.v1
- action.price-adjustment.v1
# Schema Registry에 Avro 스키마 등록
# 에이전트 메시지 형식 변경 시 하위 호환성 보장
schemas:
price-changed:
type: record
fields:
- name: product_id
type: string
- name: old_price
type: double
- name: new_price
type: double
- name: source_agent
type: string
- name: correlation_id # 분산 추적용 — 처음부터 반드시 포함
type: stringSchema Registry: 에이전트가 주고받는 메시지의 형식(스키마)을 중앙에서 버전 관리하는 컴포넌트입니다. 에이전트 A가 메시지 구조를 바꿀 때 에이전트 B가 갑자기 실패하는 상황을 방지합니다. Confluent Schema Registry가 가장 널리 쓰입니다.
장단점 분석
가장 많이 받는 질문이 "운영 비용이 너무 크지 않나요?"인데, 솔직히 틀린 말이 아닙니다. 어떤 상황에서 어디까지 감수해야 하는지 정리해봤습니다.
장점
| 항목 | 내용 |
|---|---|
| 비동기 분리 | 에이전트가 서로를 직접 호출하지 않아 한 에이전트 장애가 전체로 전파되지 않음 |
| 수평 확장 | 에이전트, Kafka 파티션, Flink 작업을 각각 독립적으로 스케일아웃 가능 |
| 이벤트 재현성 | Kafka 로그에 모든 에이전트 행동이 기록되어 감사·디버깅·재실행 가능 |
| 실시간 대응 | Flink가 이벤트 스트림을 밀리초 단위로 처리해 에이전트의 즉각 반응 지원 |
| 벤더 중립성 | A2A 표준으로 LangChain, CrewAI, SAP, Salesforce 등 이종 프레임워크 간 협업 가능 |
| 장기 워크플로 | Flink 상태 저장 처리로 수 시간~수 일에 걸친 에이전트 워크플로 지원 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 운영 복잡성 | Kafka 클러스터, Schema Registry, Flink 클러스터를 동시에 운영해야 함 | Confluent Cloud로 클러스터 관리 부담 제거. 소규모 시작은 단일 브로커(replication.factor=1)로도 가능 |
| 프로토콜 성숙도 | A2A가 2025년 4월 출시된 신규 프로토콜, Flink의 MCP/A2A 네이티브 지원 미성숙 | 핵심 경로에만 우선 도입, Agent Card 스펙 변경을 대비해 추상화 레이어 하나 추가 |
| 레이턴시 트레이드오프 | 비동기 구조 특성상 초저지연(1ms 이하)이 필요한 HFT 등에는 부적합 | 동기 응답이 반드시 필요한 외부 API 경계는 gRPC/HTTP 직접 호출 유지 |
| 디버깅 어려움 | 여러 에이전트가 비동기 상호작용 시 원인 추적이 복잡 | OpenTelemetry + correlation_id 필수 도입. Kafka UI 또는 Confluent Control Center로 토픽 흐름 시각화 |
| 오버엔지니어링 위험 | 소규모 에이전트 시스템에 Kafka 도입 시 복잡도만 증가 | 에이전트 10개 이상 또는 초당 수천 건 이벤트 이상일 때 검토. 그 이하라면 HTTP로 충분 |
| 보안 거버넌스 | 에이전트 간 신뢰 검증, 메시지 출처 인증, 권한 범위 제어 필요 | Kafka ACL + mTLS로 발행·구독 권한 제어. A2A Agent Card 검증으로 위임 신뢰성 확보 |
mTLS (Mutual TLS): 클라이언트와 서버 양쪽이 모두 인증서로 신원을 증명하는 방식입니다. 에이전트 A가 Kafka에 메시지를 발행할 때 "이 메시지가 정말 A에서 온 것인가"를 보장하는 데 활용됩니다.
실무에서 가장 흔한 실수
-
모든 에이전트 통신을 Kafka로 바꾸려 하는 것: 저도 첫 프로젝트에서 사용자 요청에 실시간으로 답해야 하는 API 엔드포인트까지 비동기로 만들었다가 UX가 깨진 경험이 있습니다. Kafka는 내부 에이전트 간 통신에 적합하고, 사용자 인터페이스와의 경계는 동기 방식이 자연스러운 경우가 많습니다.
-
correlation_id없이 시작하는 것: 나중에 분산 추적을 도입하려고 하면 기존 모든 메시지 스키마를 수정해야 합니다. 이게 없으면 "어떤 에이전트가 어떤 순서로 이 메시지를 처리했는가"를 추적하는 게 거의 불가능해지므로, 처음부터 모든 이벤트에 포함하는 것을 강하게 권장합니다. -
토픽을 너무 세분화하거나 너무 뭉뚱그리는 것:
agent-communication이라는 토픽 하나에 모든 에이전트 메시지를 넣으면 구독자가 관계없는 데이터를 모두 받아야 합니다. 반대로 메시지 타입별로 토픽을 만들면 관리가 불가능해집니다. 도메인 단위로 구분하되 Schema Registry로 메시지 타입을 분리하는 방식이 균형 잡혀 있습니다.
마치며
Kafka는 AI 에이전트 생태계에서 에이전트들이 서로를 알지 않아도 협업할 수 있게 해주는 인프라 계층이며, A2A·MCP와 함께 비로소 수십~수백 개 에이전트가 대규모로 동작하는 구조가 완성됩니다.
지금 바로 시작해볼 수 있는 3단계가 있습니다.
-
로컬 환경 구성부터 시작해보시면 좋습니다.
docker-compose로 Kafka + Schema Registry + Kafka UI를 띄우는 것이 가장 빠른 진입점입니다. Confluent의 공식 docker-compose 예시를 기반으로docker-compose up -d한 줄로 5분 안에 환경을 만들 수 있습니다. -
기존 에이전트 하나에 MCP 서버를 붙여보시면 좋습니다. Anthropic의 공식 MCP Python SDK(
pip install mcp)로 기존 API 호출을 MCP 도구로 감싸고, LangChain이나 CrewAI 에이전트에서 이를 호출하는 패턴을 먼저 경험해보시면 개념이 구체적으로 잡힙니다. -
두 에이전트 사이에 Kafka 토픽을 하나 끼워 넣어보시면 좋습니다. 직접 호출하던 A→B 연결을 A→Kafka→B로 바꾸고, B를 강제 종료했을 때 메시지가 유실되지 않고 재시작 후 이어받는 걸 직접 확인하는 순간, 왜 이 구조가 필요한지 몸으로 느끼게 됩니다.
참고 자료
- Agentic AI with the Agent2Agent Protocol (A2A) and MCP using Apache Kafka as Event Broker | Kai Waehner
- Why Google's Agent2Agent Protocol Needs Apache Kafka | Confluent Blog
- A2A, MCP, Kafka and Flink: The New Stack for AI Agents | The New Stack
- How Apache Kafka and Flink Power Event-Driven Agentic AI in Real Time | Kai Waehner
- The Future of AI Agents Is Event-Driven | Confluent Blog
- How Kafka improves agentic AI | Red Hat Developer