마이크로서비스 결합도를 끊어내는 Apache Kafka와 이벤트 드리븐 아키텍처 실전 가이드
처음 마이크로서비스를 도입했을 때, 저도 비슷한 실수를 했습니다. 서비스 A가 B를 직접 HTTP로 호출하고, B가 C를 호출하고, C가 D를 호출하는... 결국 "분산된 모놀리스"라는 최악의 결과물을 만들어냈죠. 그 대가는 꽤 혹독했습니다. 주문 서비스 배포 순서를 잘못 잡아 재고 서비스가 4시간 넘게 503을 뱉었고, 알림 서비스 API 스펙 하나가 바뀌었는데 결제 팀 코드까지 터지는 상황이 반복됐습니다. "이건 뭔가 근본적으로 잘못됐다"는 걸 그때서야 실감했습니다.
이벤트 드리븐 아키텍처(EDA)는 이 문제를 다르게 접근합니다. "주문이 완료됐어, 너희가 알아서 반응해"라고 이벤트를 던지고 끝내는 방식이죠. 재고 서비스가 살아있든 죽어있든, 결제 서비스가 느리든 빠르든 — 주문 서비스는 신경 쓸 필요가 없습니다. 그리고 그 중심에 Apache Kafka가 있습니다. 여기서 잠깐, "나도 서비스들이 서로 너무 많이 아는 구조로 고통받고 있다"는 느낌이 든다면, 이 글이 딱 맞는 글입니다.
이 글에서는 EDA의 핵심 원리부터 Kafka 4.0의 변화, 실무에서 바로 쓸 수 있는 코드 패턴, 그리고 솔직히 겪어봐야 아는 함정들까지 한 번에 정리해 드립니다. 이 글을 읽고 나면 팀에 EDA를 도입할 때 무엇부터 피해야 하는지, 그리고 왜 Netflix와 Uber가 Kafka를 전사 인프라의 중심에 놓는지 이해하게 될 것입니다.
핵심 개념
EDA의 세 주인공: Producer, Broker, Consumer
EDA를 이해하는 가장 빠른 방법은 신문사 비유입니다. 기자(Producer)가 기사를 씁니다. 신문사(Broker, 여기서는 Kafka)가 기사를 보관하고 배포합니다. 구독자(Consumer)는 자신이 관심 있는 섹션만 골라 읽습니다. 구독자가 여행 중이어도 신문은 쌓여있고, 돌아와서 읽으면 됩니다.
[Order Service] → "order.placed" 이벤트 발행
↓
[ Kafka Broker ]
↓ ↓ ↓
[재고 서비스] [결제 서비스] [알림 서비스]
(재고 차감) (결제 처리) (이메일/SMS)여기서 핵심은 Order Service가 나머지 세 서비스를 전혀 모른다는 점입니다. 나중에 포인트 서비스를 추가하고 싶다면? Order Service 코드는 한 줄도 건드리지 않아도 됩니다. 그냥 새 서비스가 order.placed 토픽을 구독하면 끝입니다.
Kafka의 핵심 구조 이해하기
Kafka를 처음 접하면 용어가 조금 낯설게 느껴질 수 있는데, 하나씩 뜯어보면 직관적입니다.
| 개념 | 한 줄 설명 | 실무 감각 |
|---|---|---|
| Topic | 이벤트가 쌓이는 논리적 채널 | order.placed, payment.completed처럼 이벤트 타입별로 분리 |
| Partition | 토픽을 물리적으로 분할한 단위 | 파티션 수 = 병렬 처리 가능한 최대 Consumer 수 |
| Consumer Group | 같은 그룹 내 Consumer들이 파티션을 나눠 처리 | 수평 확장의 핵심. 그룹 내엔 중복 처리 없음 |
| Offset | 파티션 내 메시지의 위치값 | 이 번호 덕분에 장애 후 정확한 지점부터 재처리 가능 |
| Log Compaction | 동일 키의 최신 값만 보존하는 정리 전략 | 이벤트 소싱에서 "현재 상태" 스냅샷 용도로 활용 |
Consumer Group이란? 같은 그룹 ID를 가진 Consumer들은 하나의 논리적 소비자로 동작합니다. 토픽에 파티션이 4개이고 Consumer가 4개라면 각각 1개씩 담당 — 완벽한 병렬 처리가 됩니다. 반면 서로 다른 그룹(재고 서비스 그룹, 알림 서비스 그룹)은 같은 메시지를 독립적으로 각자 소비합니다.
Consumer Group 리밸런싱(Rebalancing) 은 실무에서 자주 맞닥뜨리는 까다로운 이슈입니다. Consumer가 추가되거나 제거될 때, 또는 max.poll.interval.ms 안에 메시지를 처리하지 못할 때 리밸런싱이 발생합니다. 이 동안 해당 그룹의 메시지 처리가 일시 중단됩니다. 저도 처음엔 왜 갑자기 처리 지연이 생기는지 한참 헤맸는데, 알고 보니 무거운 배치 처리 로직 때문에 max.poll.interval.ms(기본 5분)를 초과하고 있었습니다. 이 값을 실제 처리 시간에 맞게 튜닝하거나, Kafka 3.1+에서 지원하는 Cooperative Sticky Assignor를 사용하면 리밸런싱 중에도 다른 파티션은 계속 처리할 수 있어 훨씬 안정적입니다.
Kafka 4.0: ZooKeeper와의 완전한 이별
2025년 3월, Kafka 4.0이 출시되면서 오랜 숙제였던 ZooKeeper 의존성이 완전히 제거됐습니다. KRaft(Kafka Raft) 모드가 기본값이 된 것인데, 솔직히 이게 얼마나 큰 변화인지는 ZooKeeper 때문에 새벽 온콜을 받아본 분들만 체감할 수 있을 겁니다.
이전에는 Kafka 클러스터를 운영하려면 Kafka 브로커 + ZooKeeper 앙상블을 별도로 구성해야 했습니다. 메타데이터 관리 시스템이 둘이니 장애 포인트도 두 배였죠. KRaft 이후로는 Kafka 자체가 Raft 합의 알고리즘(투표로 리더를 결정하는 내부 합의 프로토콜)으로 메타데이터를 직접 관리합니다. 단일 시스템, 수백만 파티션까지 확장 가능, 클러스터 시작 시간 대폭 단축.
추가로 Queues for Kafka(KIP-932) 기능도 들어왔습니다. 기존 Kafka의 Consumer Group은 "브로드캐스트" 방식입니다 — 같은 그룹의 Consumer들이 파티션을 나눠 갖고, 서로 다른 그룹은 같은 메시지를 독립적으로 전부 받습니다. KIP-932는 여기에 "경쟁 소비(competing consumer)" 의미론을 더합니다. 여러 Consumer가 하나의 큐에서 메시지를 경쟁적으로 가져가는 방식으로, RabbitMQ에서 익숙한 그 패턴입니다. RabbitMQ를 쓰던 팀이 Kafka로 마이그레이션할 이유가 하나 더 생긴 셈입니다.
EDA의 핵심 패턴 3가지
실무에서 EDA를 설계할 때 자주 등장하는 세 가지 패턴입니다. 각각 독립된 글 하나가 필요한 주제들이라, 여기서는 "언제 이 패턴을 쓰는지"에 집중해서 설명하겠습니다.
CQRS (Command Query Responsibility Segregation) 언제 쓰는가: 읽기와 쓰기 부하가 극단적으로 다를 때 쓰기(Command)와 읽기(Query)의 책임을 분리하는 패턴입니다. 이벤트를 발행해 쓰기 모델을 갱신하고, 별도로 최적화된 읽기 모델(예: Elasticsearch)을 유지합니다. 주문 생성은 드물지만 주문 목록 조회는 수천 번씩 발생하는 쇼핑몰이 대표적인 적용 사례입니다.
Event Sourcing
언제 쓰는가: 감사 추적(audit trail)이 필수이거나, 과거 시점 상태를 재현해야 할 때
"현재 상태"를 DB에 저장하는 대신, 상태 변화를 일으킨 이벤트를 저장합니다. 재고 서비스라면 재고입고(100), 주문소비(30), 반품(5) 이벤트들을 쌓아두고 집계해서 현재 재고(75)를 계산하는 방식입니다. 덕분에 "어제 오후 3시 재고는 몇 개였지?"처럼 과거 시점으로 되감기(time-travel)가 가능해집니다.
Saga 패턴 언제 쓰는가: 여러 서비스에 걸친 트랜잭션이 필요한데 2PC(2단계 커밋)는 쓰기 싫을 때 분산 환경에서 트랜잭션을 처리하는 패턴입니다. "주문 → 결제 → 재고 차감"처럼 여러 서비스에 걸친 작업을 이벤트 체인으로 연결하고, 중간에 실패하면 보상 이벤트(compensation event)로 롤백합니다.
실전 적용
예시 1: 이커머스 주문 처리 파이프라인 (NestJS + kafkajs)
실무에서 가장 자주 마주치는 시나리오입니다. 주문이 들어오면 재고, 결제, 알림이 각각 독립적으로 처리되어야 할 때를 코드로 살펴보겠습니다. 아래 코드는 NestJS 마이크로서비스 모듈 기준입니다.
먼저 핵심 타입 정의부터 잡고 가겠습니다. TypeScript에서 타입 없이 이벤트를 다루다 보면 필드명 오타 하나로 디버깅을 몇 시간씩 하게 됩니다.
// types/events.ts
export interface CreateOrderDto {
userId: string;
items: Array<{ productId: string; quantity: number; price: number }>;
}
export interface OrderPlacedEvent {
orderId: string;
userId: string;
items: Array<{ productId: string; quantity: number; price: number }>;
totalAmount: number;
correlationId: string;
timestamp: string;
}
export interface DebeziumEvent<T = Record<string, unknown>> {
op: 'c' | 'u' | 'd' | 'r'; // create, update, delete, snapshot read
before: T | null;
after: T | null;
source: { db: string; table: string };
}폴더 구조는 다음과 같습니다:
src/
order/
order.module.ts
order.service.ts ← Producer 역할
inventory/
inventory.module.ts
inventory.consumer.ts ← Consumer 역할
types/
events.ts// order/order.service.ts — Producer 역할
import { Injectable } from '@nestjs/common';
import { ClientKafka, Inject } from '@nestjs/microservices';
// @Inject('KAFKA_SERVICE'): NestJS DI 컨테이너에서 'KAFKA_SERVICE' 토큰으로 등록된 ClientKafka를 주입합니다
import { CreateOrderDto, OrderPlacedEvent } from '../types/events';
@Injectable()
export class OrderService {
constructor(
@Inject('KAFKA_SERVICE') private readonly kafkaClient: ClientKafka,
private readonly orderRepository: OrderRepository,
) {}
async placeOrder(dto: CreateOrderDto): Promise<Order> {
const order = await this.orderRepository.save(dto);
const event: OrderPlacedEvent = {
orderId: order.id,
userId: order.userId,
items: order.items,
totalAmount: order.totalAmount,
correlationId: crypto.randomUUID(), // 분산 추적용 고유 ID
timestamp: new Date().toISOString(),
};
await this.kafkaClient.emit('order.placed', event);
return order;
}
}// inventory/inventory.consumer.ts — Consumer 역할
import { Controller } from '@nestjs/common';
import {
MessagePattern,
Payload,
Ctx,
KafkaContext,
ClientKafka,
Inject,
} from '@nestjs/microservices';
// @Ctx() context: KafkaContext: NestJS가 Kafka 메타데이터(offset, partition 등)를 주입해줍니다
import { OrderPlacedEvent } from '../types/events';
const MAX_RETRY = 3;
@Controller()
export class InventoryConsumer {
constructor(
private readonly inventoryService: InventoryService,
@Inject('KAFKA_SERVICE') private readonly kafkaClient: ClientKafka,
) {}
@MessagePattern('order.placed')
async handleOrderPlaced(
@Payload() event: OrderPlacedEvent,
@Ctx() context: KafkaContext,
) {
const { correlationId, orderId, items } = event;
const retryCount = Number(
context.getMessage().headers?.['x-retry-count'] ?? 0,
);
try {
await this.inventoryService.reserveItems(items);
await this.kafkaClient.emit('inventory.reserved', {
orderId,
correlationId,
status: 'SUCCESS',
});
} catch (error) {
if (retryCount < MAX_RETRY) {
// 재시도: 헤더에 횟수를 담아 같은 토픽으로 재발행
await this.kafkaClient.emit('order.placed', {
...event,
headers: { 'x-retry-count': String(retryCount + 1) },
});
} else {
// 재시도 초과 → DLQ로 격리
await this.kafkaClient.emit('inventory.reserve.dlq', {
originalEvent: event,
reason: error.message,
failedAt: new Date().toISOString(),
});
}
}
}
}| 코드 포인트 | 설명 |
|---|---|
correlationId |
이벤트 체인 전체를 추적하는 고유 ID. 분산 추적 필수 |
emit() vs send() |
emit은 fire-and-forget(응답 불필요), send는 응답 대기 |
x-retry-count 헤더 |
재시도 횟수 추적. MAX_RETRY 초과 시 DLQ 토픽으로 격리 |
| 보상 이벤트 | 실패 시 Saga 패턴의 롤백 신호. 상위 서비스가 구독해 처리 |
예시 2: Debezium으로 DB 변경을 Kafka 이벤트로 변환 (CDC 패턴)
레거시 시스템에서 EDA로 전환할 때 정말 유용한 패턴입니다. 기존 DB에 직접 이벤트 발행 코드를 심기 어려울 때, Debezium이 DB의 변경 로그(binlog/WAL)를 읽어서 자동으로 Kafka 이벤트로 만들어줍니다.
{
"name": "orders-connector",
"config": {
"connector.class": "io.debezium.connector.mysql.MySqlConnector",
"database.hostname": "mysql",
"database.port": "3306",
"database.user": "debezium",
"database.password": "dbz",
"database.server.name": "shop",
"table.include.list": "shop.orders",
"topic.prefix": "cdc",
"transforms": "unwrap",
"transforms.unwrap.type": "io.debezium.transforms.ExtractNewRecordState"
}
}주의:
database.password는 예시용 값입니다. 프로덕션에서는 반드시 AWS Secrets Manager, Vault 등 시크릿 관리 도구에서 주입하는 것을 권장합니다.
// CDC 이벤트 소비 예시 — 토픽명은 "cdc.shop.orders" 형태로 자동 생성됩니다
import { DebeziumEvent } from '../types/events';
interface OrderRecord {
id: string;
status: string;
userId: string;
}
@MessagePattern('cdc.shop.orders')
async handleOrderCDC(
@Payload() cdcEvent: DebeziumEvent<OrderRecord>,
) {
const { op, after, before } = cdcEvent;
// op: 'c'(create), 'u'(update), 'd'(delete), 'r'(snapshot read)
if (op === 'u' && after?.status === 'SHIPPED') {
await this.notificationService.sendShippingAlert(after);
}
}CDC(Change Data Capture)란? 데이터베이스의 변경 로그를 실시간으로 감지해 이벤트로 변환하는 기법입니다. 애플리케이션 코드 수정 없이 기존 DB를 이벤트 소스로 만들 수 있어, 레거시 시스템의 EDA 마이그레이션 첫 단계로 많이 활용됩니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 느슨한 결합 | Producer와 Consumer가 서로를 전혀 몰라도 됩니다. 새 서비스를 추가해도 기존 코드를 건드리지 않아서 배포 리스크가 줄고, 팀 간 의존성도 끊어냅니다 |
| 높은 처리량 | 초당 수백만 메시지 처리. Netflix는 하루 수조 건의 이벤트를 Kafka로 처리하고 있습니다 |
| 내구성 & 재처리 | 디스크에 로그가 보존됩니다. 장애 복구 후 Offset부터 정확히 재처리할 수 있고, 버그 픽스 후 과거 이벤트를 다시 돌릴 수도 있습니다 |
| 탄력적 확장 | 파티션 추가만으로 수평 확장이 가능합니다. Consumer Group이 자동으로 부하를 분산하기 때문에, 트래픽이 몰리면 Consumer 인스턴스만 늘리면 됩니다 |
| 장애 격리 | 알림 서비스가 죽어도 주문 서비스는 이벤트를 계속 발행합니다. 알림 서비스가 복구되면 쌓인 이벤트를 처리합니다. 장애가 연쇄적으로 전파되지 않습니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 운영 복잡성 | Kafka 클러스터 운영·모니터링에 전문 지식이 필요합니다 | 초기엔 Confluent Cloud, Amazon MSK 같은 관리형 서비스를 활용하면 운영 부담이 크게 줄어듭니다 |
| 최종 일관성 | 동기 처리와 달리 즉각적인 일관성을 보장하지 않습니다 | UI에서 "처리 중" 상태를 명시하고, 사용자 경험을 설계 단계부터 고려하면 좋습니다 |
| 메시지 순서 | 파티션 수준에서만 순서를 보장합니다. 토픽 전체 순서는 없습니다 | 순서가 중요한 이벤트는 동일한 파티션 키(예: userId, orderId)를 사용하면 됩니다 |
| Exactly-once 처리 | 기본은 at-least-once입니다. 중복 처리가 발생할 수 있습니다 | Producer 측에서 enable.idempotence=true + transactional.id 설정으로 트랜잭션 발행을 활성화하고, Consumer 측에서 isolation.level=read_committed로 커밋된 메시지만 읽도록 맞춰야 합니다. 그리고 Consumer 자체도 Idempotent하게 설계하는 것이 이상적입니다 |
| 디버깅 어려움 | 이벤트 체인을 따라가는 디버깅은 로그 분석만으론 한계가 있습니다 | 모든 이벤트에 correlationId를 심고, Jaeger나 Zipkin 같은 분산 추적 도구를 도입하면 훨씬 편해집니다 |
| 스키마 관리 | order.placed 이벤트에 필드를 추가하면 모든 Consumer에 영향이 생깁니다 |
Confluent Schema Registry로 스키마 버전을 관리하고, 하위 호환성 검증을 CI에 포함시키면 좋습니다 |
Idempotent Consumer란? 같은 메시지를 여러 번 처리해도 결과가 동일하도록 설계된 Consumer입니다. 예를 들어 "재고 5개 차감" 이벤트가 중복 도착했을 때, 이벤트 ID를 DB에 기록해두고 이미 처리한 이벤트는 무시하는 방식으로 구현할 수 있습니다.
실무에서 가장 흔한 실수
-
토픽을 너무 굵게 나누는 것 —
order하나로 모든 주문 이벤트를 몰아넣으면, Consumer가 자신에게 필요없는 이벤트까지 걸러내야 합니다.order.placed,order.shipped,order.cancelled처럼 이벤트 타입별로 세분화해두면 나중에 훨씬 관리하기 편합니다. -
파티션 키를 설정하지 않는 것 — 기본 라운드로빈 분배를 쓰면 같은 사용자의 이벤트가 여러 파티션에 흩어집니다. 저도 처음엔 파티션 키를 빠뜨려서 "분명 순서대로 처리했는데 왜 상태가 꼬이지?"를 한참 디버깅했습니다. 순서가 중요한 도메인이라면
userId나orderId를 파티션 키로 지정하면 됩니다. -
Dead Letter Queue(DLQ)를 빠뜨리는 것 — Consumer가 특정 이벤트를 처리하다 계속 실패하면 해당 파티션 전체가 막힐 수 있습니다. 위 코드 예시처럼 처리 실패 이벤트는 DLQ 토픽으로 격리하고 별도로 모니터링하는 구조를 처음 설계에 넣어두는 게 이상적입니다. 나중에 추가하려면 의외로 손이 많이 갑니다.
마치며
이벤트 드리븐 아키텍처는 단순히 기술 스택을 바꾸는 것이 아닙니다. 팀이 서로 의존하고 소통하는 방식 자체를 바꾸는 일입니다. "이 기능을 추가하려면 A팀 API 스펙 변경을 기다려야 해"가 아니라, "우리가 관심 있는 이벤트를 구독하면 되니까 A팀과 별개로 배포할 수 있어"가 되는 순간, 팀의 자율성이 달라집니다.
물론 처음에는 ZooKeeper 설정부터 Consumer Group 리밸런싱 이슈까지 배워야 할 것들이 만만치 않게 느껴질 수 있습니다. 하지만 Kafka 4.0 이후로 진입 장벽이 많이 낮아졌고, 관리형 서비스를 활용하면 운영 부담도 크게 줄었습니다. 지금이 시작하기 좋은 시점입니다.
지금 바로 시작해볼 수 있는 3단계:
-
로컬에서 Kafka 4.0 띄워보기 — Docker Compose 하나면 충분합니다.
docker run -p 9092:9092 apache/kafka:4.0.0으로 KRaft 모드 Kafka를 바로 실행해볼 수 있습니다. Redpanda Console이나 Conduktor를 함께 띄우면 토픽 목록, 파티션별 Offset, Consumer Group의 Lag(처리 지연)을 한눈에 볼 수 있습니다. Consumer Group Lag가 0으로 수렴하는 모습을 눈으로 확인하는 것만으로도 개념이 훨씬 빠르게 잡힙니다. -
간단한 Producer/Consumer 작성해보기 — 현재 사용하는 언어의 클라이언트(Node.js라면
kafkajs, Java라면spring-kafka)로 "hello world" 이벤트를 발행하고 소비하는 코드를 직접 작성해보시면 좋습니다. Kafka UI에서 "Consumer Groups" 탭을 열어두고, Consumer를 추가하거나 제거할 때 파티션 재할당이 어떻게 일어나는지 직접 보면 이게 가장 빠른 이해 방법입니다. -
기존 서비스의 동기 호출 하나를 이벤트로 교체해보기 — 전체 시스템을 한 번에 바꾸는 것보다, 현재 프로젝트에서 "알림 발송"처럼 실패해도 재시도하면 그만인 비중요 호출 하나를 이벤트로 전환해보면 어떨까요. 작은 성공 경험이 팀의 신뢰를 만들어줍니다.
다음 글: Saga 패턴 실전 구현 — 분산 트랜잭션에서 롤백이 필요할 때 보상 이벤트를 어떻게 설계하는지, Choreography vs Orchestration 방식의 차이와 함께 코드로 다뤄볼 예정입니다.
참고 자료
- Apache Kafka 공식 문서 | apache.org
- Confluent Developer — Kafka 개념 및 튜토리얼 | confluent.io
- KRaft — Apache Kafka Without ZooKeeper | Confluent Developer
- Kafka 4.0 KRaft 아키텍처 | InfoQ
- Confluent Schema Registry 공식 문서 | confluent.io
- Top Trends for Data Streaming with Apache Kafka and Flink in 2026 | Kai Waehner
- The Rise of Durable Execution Engine with Apache Kafka | Kai Waehner
- Uber uForwarder: Scalable Kafka Consumer Proxy | InfoQ
- Event-Driven Architecture and Kafka Explained: Pros and Cons | PRODYNA
- Event Driven Architecture and Kafka — F-Lab