필사 모드: Kafka와 Event-Driven Architecture 완전 해부 — Partition/ISR, Exactly-Once, Outbox, Schema Registry, Flink까지
한국어들어가며 — "우리는 Kafka를 쓴다"의 공허함
2015년 이후 모든 컨퍼런스에서 들리는 문장:
> "저희는 이벤트 드리븐으로 Kafka를 씁니다."
그런데 파고 물어보면:
- "파티션 수는 왜 그렇게?" → "기본값으로..."
- "Exactly-Once 보장되나요?" → "아마도..."
- "Consumer가 메시지 잃으면?" → "...?"
Kafka는 **쓰기 쉽고 운영하기 어려운** 대표적 시스템이다. 이 글에서는:
- **Kafka 내부 구조** — 파티션, Leader, ISR, Log Segment의 물리적 진실
- **Producer/Consumer**의 내부 동작
- **Exactly-Once Semantics (EOS)** — 전설의 진실
- **Transactional Outbox** — DB와 이벤트의 일관성
- **Event Sourcing vs CDC vs Event Notification** 구분
- **Schema Registry**와 Avro/Protobuf/JSON Schema
- **Kafka Streams vs Flink vs ksqlDB** 비교
- **Dead Letter Queue**와 재처리
- **Pulsar, Redpanda** 같은 현대 대안
- 100ms SLA 달성 실전 기법
> 이전 글 [Zero-Downtime DB Migration](/blog/culture/2026-04-15-zero-downtime-database-migration-expand-contract-gh-ost-pt-osc-concurrently-logical-replication-deep-dive-guide-2025)에서 CDC를 언급했다. Kafka는 그 CDC의 버스이자 이벤트 아키텍처의 척추다.
1. Kafka의 탄생 — 2010년 LinkedIn
왜 만들었나
2010년 LinkedIn에서 **500+개의 ETL 파이프라인**이 점대점으로 얽혀 있었다.
- 프로필 서비스 → 검색 인덱서
- 프로필 서비스 → 데이터 웨어하우스
- 활동 로그 → 추천 시스템
- 활동 로그 → Hadoop
엔지니어 수만큼 파이프라인. 복잡도 폭발.
Jay Kreps의 제안
"모든 이벤트를 **한 개의 통합 로그**에 넣자. 소비자는 각자 읽자." 이 단순한 아이디어가 Kafka다.
왜 "Kafka"인가
Jay Kreps가 Franz Kafka(작가) 팬. "쓰기에 특화된 시스템" → "Kafka는 글을 많이 썼으니". 의외로 진지한 기술 제품의 이름 유래가 허무하다.
2011년 오픈소스, 2014년 Confluent 설립
Jay Kreps, Neha Narkhede, Jun Rao가 Confluent 창업. Kafka는 Apache로 기증. 지금은 **Fortune 100의 80%** 사용.
2. Kafka의 구성 요소 — 3분 요약
Topic
"이벤트의 종류". 예: `user-clicks`, `order-created`.
Partition
**Topic을 여러 조각으로 나눈 것**. 각 파티션은 **순서 있는 로그**. 파티션이 Kafka의 병렬성과 순서 보장의 단위.
Topic: orders
├── Partition 0: [evt1] [evt2] [evt3] ...
├── Partition 1: [evt4] [evt5] ...
├── Partition 2: [evt6] [evt7] [evt8] ...
Broker
Kafka 서버. 여러 개가 모여 **Cluster**.
Producer / Consumer
이벤트를 쓰고 읽는 클라이언트.
Consumer Group
여러 Consumer가 같은 Group ID로 묶이면, **각 파티션이 Group 내 한 Consumer에게만 할당**. 병렬 처리.
Topic: orders (3 partitions)
Group: order-processor
├── Consumer A → Partition 0
├── Consumer B → Partition 1
└── Consumer C → Partition 2
Consumer가 Partition 수보다 많으면 나머지는 놀고, 적으면 한 Consumer가 여러 Partition 담당.
3. Partition 내부 — Log Segment의 물리적 진실
Append-only Log
각 Partition은 디스크의 파일. **오로지 끝에만 추가**.
/var/lib/kafka/orders-0/
├── 00000000000000000000.log
├── 00000000000000000000.index
├── 00000000000000000000.timeindex
├── 00000000000054321099.log
├── 00000000000054321099.index
├── 00000000000054321099.timeindex
└── leader-epoch-checkpoint
Segment 분리
1GB(기본) 또는 1주(기본)마다 새 Segment. 이유:
- 삭제/압축이 파일 단위로 용이
- 특정 시점의 데이터만 로드 가능
Index 파일
- `.index` — offset → 파일 위치 매핑 (sparse)
- `.timeindex` — timestamp → offset 매핑
Consumer가 "offset 1,234,567부터"를 요청하면 index로 **O(log N)** 탐색.
Zero-Copy로 빠른 이유
Kafka의 속도 비결: Linux `sendfile()` 시스템 콜. 디스크 → NIC로 **커널 공간에서 직접**. 유저스페이스 복사 없음.
전통 방식:
디스크 → 커널 페이지 캐시 → 유저 버퍼 → 커널 소켓 버퍼 → NIC
(4번 복사)
Zero-copy:
디스크 → 커널 페이지 캐시 → NIC
(2번 복사, CPU 개입 최소)
덕분에 단일 브로커가 **수 GB/s** 처리 가능.
Page Cache — Kafka의 "메모리"
Kafka는 **자체 캐시를 최소**로. 리눅스 **page cache**에 의존. 이론상:
- Producer가 쓴 데이터는 page cache에 남음
- Consumer가 바로 읽으면 디스크 I/O 없이 메모리에서
RAM이 많은 서버일수록 유리. "Kafka는 디스크 기반이지만 실제로는 메모리에서 읽는다."
4. Replication — Leader와 ISR의 정치학
Replication 기본
각 Partition은 **N개 복제본**. 하나는 **Leader**, 나머지는 **Follower**.
- Producer/Consumer는 Leader에만 접근
- Follower는 Leader에서 데이터를 pull
ISR (In-Sync Replicas)
Leader와 **"거의 동기화된"** Follower 목록. 정의:
- 최근 `replica.lag.time.max.ms` (기본 30초) 이내에 Leader를 따라잡음
Producer의 **acks** 설정이 ISR과 연관:
- `acks=0` — Producer는 응답 기다리지 않음. 최고 성능, 유실 가능
- `acks=1` — Leader만 쓰면 OK. Leader 죽으면 유실 가능
- `acks=all` (=-1) — **모든 ISR이 쓸 때까지 대기**. 최고 안전성
`acks=all` + `min.insync.replicas=2` (기본 1) → **최소 2개 ISR 쓸 때까지 대기**. Leader 죽어도 유실 없음.
Leader Election
Leader가 죽으면 **Controller Broker**가 ISR 중 하나를 새 Leader로 승격.
**Unclean Leader Election** — ISR이 비어있다면?
- `unclean.leader.election.enable=false` (기본) → 가용성 손실 대신 데이터 보호
- `true` → 비ISR에서 Leader 선출, 데이터 유실 가능성
금융권은 반드시 `false`.
KRaft — ZooKeeper 제거
2022년부터 Kafka는 **KRaft 모드**에서 ZooKeeper 불필요. Raft 기반 자체 메타데이터 관리. 2025년 4.0부터 ZooKeeper 완전 제거.
5. Producer 내부 — 배칭과 압축의 예술
Producer 아키텍처
[앱 코드]
│ send(record)
▼
[Serializer] → [Partitioner] → [Accumulator (배칭)]
│ batch.size 도달 or linger.ms 만료
▼
[Sender Thread]
│ 각 Broker에 batch 전송
▼
[Broker Leader]
핵심 튜닝 포인트
- **batch.size** — 배치 최대 크기 (기본 16KB). 크면 throughput 증가, 지연 증가
- **linger.ms** — 배치를 채울 때까지 대기 시간 (기본 0). 5~20ms 추천
- **compression.type** — `none/gzip/snappy/lz4/zstd`. **lz4** 또는 **zstd** 추천
- **acks** — 앞서 설명
- **max.in.flight.requests.per.connection** — 동시 요청 수. Idempotence 쓸 땐 5 이하
Partitioner 로직
Producer가 메시지를 어느 파티션에 보내나?
1. 메시지에 `partition` 명시 → 그걸로
2. `key` 있음 → **hash(key) % num_partitions** (같은 키는 같은 파티션)
3. 둘 다 없음 → 스티키 파티셔너 (배치 효율)
Idempotent Producer
`enable.idempotence=true` — Producer 재시도로 **같은 메시지가 두 번 쓰이는 것**을 방지. Producer 쪽 PID + sequence number로 중복 검출.
6. Consumer 내부 — Offset의 정치학
Offset이란
각 파티션 내에서 메시지의 순번. Consumer가 "어디까지 읽었는지" 기록.
Offset 저장 위치 변천사
- 2010~2014: ZooKeeper에 저장 (부하 폭증)
- 2014~: **`__consumer_offsets`** 내부 토픽에 저장 (Kafka 자체 관리)
Commit 전략
**Auto-commit** (기본)
- `enable.auto.commit=true`, `auto.commit.interval.ms=5000`
- 자동으로 offset 저장
- 위험: 메시지 처리 중 Consumer 죽으면 재처리 (at-least-once)
**Manual commit**
for message in consumer:
process(message)
consumer.commit() # 명시적으로
- 처리 후 커밋 → 더 안전
- 커밋 전 죽으면 다음 Consumer가 재처리
At-least-once vs At-most-once vs Exactly-once
- **At-least-once** — 메시지가 **최소 1번** 전달. 중복 가능. (기본)
- **At-most-once** — 최대 1번. 유실 가능.
- **Exactly-once** — 정확히 1번. 복잡.
대부분 시스템은 At-least-once + **소비자 멱등성**으로 사실상 exactly-once.
Rebalance — Consumer Group의 춤
Consumer가 추가/제거되면 파티션 재할당. **모든 Consumer가 멈춤** (stop-the-world).
- Rebalance 중 수 초간 처리 중단
- **Static Membership** (2.3+)로 완화
- **Cooperative Rebalancing** (2.4+)로 점진적 재할당
7. Exactly-Once Semantics — 전설의 진실
2017년 발표의 충격
Confluent가 Kafka 0.11에서 EOS 발표. 커뮤니티 반응: **"진짜?"** 수십 년간 분산 시스템이 풀지 못한 난제.
두 가지 요소
**1. Idempotent Producer** (앞서 설명)
- 한 Producer가 retry로 중복 쓰지 않음
- PID + sequence로 브로커가 중복 검출
**2. Transactions**
- 여러 파티션에 걸친 **원자적 쓰기**
- Consumer의 offset commit도 트랜잭션에 포함
Producer 트랜잭션 예
producer.initTransactions();
try {
producer.beginTransaction();
producer.send(new ProducerRecord<>("topic-a", key, value));
producer.send(new ProducerRecord<>("topic-b", key, value));
producer.sendOffsetsToTransaction(offsets, groupId);
producer.commitTransaction();
} catch (Exception e) {
producer.abortTransaction();
}
"두 토픽에 쓰기 + consumer offset 커밋"이 **원자적**. 하나라도 실패하면 전부 롤백.
Read Committed
Consumer가 `isolation.level=read_committed`면 **커밋된 트랜잭션만** 읽음. Abort된 메시지는 스킵.
한계
EOS는 **Kafka 내부**에서만 보장. DB까지 연결되면?
- Kafka → DB: Kafka는 커밋했는데 DB는 실패 → 불일치
- 해법: **Transactional Outbox** 패턴 (다음 섹션)
성능 비용
EOS는 **10~30% throughput 감소**. 모든 워크로드에 적용은 낭비. 금융, 중복 치명적 사례에만.
8. Transactional Outbox — DB와 이벤트의 일관성
문제
def create_order(order):
db.insert(order) # 1
kafka.send("orders", order) # 2
1 성공, 2 실패 → DB엔 주문, Kafka엔 이벤트 없음.
순진한 해법: 순서 바꾸기
kafka.send("orders", order) # 1
db.insert(order) # 2
1 성공, 2 실패 → Kafka 이벤트 있는데 DB엔 없음. 더 나쁨.
Outbox 패턴
**같은 DB 트랜잭션**에 메인 write + 이벤트 write를 같이.
BEGIN;
INSERT INTO orders (...) VALUES (...);
INSERT INTO outbox (event_type, payload, created_at)
VALUES ('order.created', ..., NOW());
COMMIT;
그 후 별도 **Relay 프로세스**가 outbox 테이블을 읽어 Kafka에 발행:
[App] ──► [DB: orders + outbox] ──► [Relay/Debezium] ──► [Kafka]
Debezium — 자동화된 Outbox
Debezium이 **outbox 테이블의 binlog/WAL**을 읽어 Kafka에 publish. 설치 후 테이블만 정의하면 끝.
장점
- DB 트랜잭션으로 일관성
- Kafka 장애 시에도 데이터는 DB에 남음
- 재처리 가능
함정
- Outbox 테이블이 커짐 → 정기 cleanup 필요
- Relay의 **exactly-once** 도 고려 (Debezium은 at-least-once)
- 소비자는 항상 멱등하게
9. Event Sourcing vs CDC vs Event Notification
셋의 차이 구분
혼용하면 설계가 엉망.
Event Notification
"무슨 일이 일어났다"는 단순 통지. 페이로드 최소.
{ "type": "order.created", "id": 42 }
소비자는 상세가 필요하면 API로 질의.
**장점**: 커플링 낮음, 페이로드 작음
**단점**: 소비자가 follow-up 호출 필요
Event-Carried State Transfer
통지에 **전체 상태**를 실음.
{
"type": "order.created",
"id": 42,
"items": [...],
"total": 99.99,
"customer": {...}
}
**장점**: 소비자가 추가 호출 불필요
**단점**: 페이로드 큼, 스키마 변경 파급
Event Sourcing
"상태"가 아닌 **"상태를 만든 모든 이벤트"**를 저장. 현재 상태는 이벤트 replay로 재구성.
OrderCreated → ItemAdded → ItemAdded → DiscountApplied → OrderPaid
**장점**: 완전한 감사 로그, 과거 어느 시점도 재구성
**단점**: 복잡, 쿼리 어려움 (CQRS 필수), 스키마 진화 어려움
CDC (Change Data Capture)
**DB 변경 자체**를 이벤트화. 소스는 DB.
{
"op": "u",
"before": { "status": "pending" },
"after": { "status": "paid" }
}
**장점**: 앱 코드 수정 불필요, Single Source of Truth
**단점**: DB 스키마가 이벤트 계약됨
현실 — 혼합
대규모 시스템은 셋을 **목적별로 섞어** 쓴다.
- 핵심 비즈니스: Outbox로 Event-Carried State
- 검색 인덱싱/분석: CDC
- 단순 알림: Event Notification
10. Schema Registry — 계약의 수호자
문제
Producer가 스키마 바꿨는데 Consumer가 따라오지 못함 → 파싱 실패. 수 주 뒤 발견.
Schema Registry
스키마의 **중앙 저장소 + 호환성 검증**. Confluent가 처음 제시.
흐름
1. Producer가 메시지 쓰기 전, 스키마 등록
2. Registry가 **기존 스키마와 호환성 체크**
3. Breaking change면 거부
4. 메시지에는 스키마 ID만 포함, Consumer가 ID로 스키마 조회
호환성 모드
- **BACKWARD** — 새 스키마로 옛 데이터 읽기 가능 (컬럼 추가 OK, 삭제 X)
- **FORWARD** — 옛 스키마로 새 데이터 읽기 가능
- **FULL** — 양방향
- **NONE** — 검증 없음 (위험)
Producer 먼저 배포, Consumer는 천천히 업그레이드할 거면 **BACKWARD**.
Avro vs Protobuf vs JSON Schema
| 항목 | Avro | Protobuf | JSON Schema |
|---|---|---|---|
| 스키마 진화 | 뛰어남 | 좋음 | 제한적 |
| 페이로드 크기 | 작음 | 가장 작음 | 큼 |
| 가독성 | 낮음 | 낮음 | 높음 |
| 생태계 | Hadoop/Kafka | gRPC | 웹 |
| 기본 Kafka | Avro 선호 | 인기 급상승 | 레거시 |
2025년 추세: **Protobuf**. gRPC 생태계와 공유, 크기/속도.
11. Stream Processing — Kafka Streams vs Flink vs ksqlDB
Kafka Streams (라이브러리)
Java/Scala 앱에 embedded. 별도 클러스터 불필요.
stream.filter(...)
.map(...)
.groupByKey()
.count()
.to("output-topic");
**장점**: 간단, 인프라 없음, Kafka에 강한 결합
**단점**: JVM only, 대규모 상태 처리 약함
Apache Flink (별도 클러스터)
Stream 처리의 **패왕**. SQL, Java, Python, Scala.
**장점**:
- True streaming (batch도 지원)
- Exactly-once 체크포인팅
- 대규모 상태 처리 (수 TB)
- Event time 처리 뛰어남
**단점**:
- 별도 클러스터 관리
- 학습 곡선 가파름
ksqlDB
Kafka 위의 **SQL 엔진**. Stream/Table을 SQL로 조작.
CREATE STREAM orders_large AS
SELECT * FROM orders WHERE amount > 1000;
**장점**: SQL 친숙, 빠른 프로토타이핑
**단점**: 복잡한 로직 한계, 성능이 Flink만 못함
선택 가이드
- JVM + 간단 로직 + Kafka 중심 → Kafka Streams
- 복잡한 상태, 대규모, 다언어 → **Flink**
- SQL로 빠르게 → ksqlDB
Flink의 채택이 최근 2~3년 급증. AWS는 Managed Flink, Confluent는 Flink 통합 제공.
12. Dead Letter Queue와 재처리
실패하는 메시지의 운명
처리 중 예외 발생:
def process(message):
order = json.loads(message) # 여기서 파싱 실패!
db.save(order)
선택:
1. **Retry 영원히** — 독이 든 메시지(poison pill) 하나가 전체 중단
2. **Skip하고 로그** — 데이터 유실
3. **DLQ로 보내기** — 별도 토픽에 저장, 별도 처리
DLQ 패턴
[topic: orders] ──► [Consumer] ──► 실패 시 ──► [topic: orders-dlq]
│
▼
[DLQ Processor] — 수동/자동 재처리
재처리 전략
1. **Alert만** — 엔지니어가 수동 확인
2. **자동 재처리** — TTL 붙여서 재시도 (n번까지)
3. **수정 후 replay** — 소비자 코드 수정 후 DLQ 전체 재처리
4. **Kill Switch** — 특정 조건 메시지만 skip
Spring Cloud Stream / Kafka Streams DSL
표준 라이브러리들이 DLQ 내장. `DeadLetterPublishingRecoverer` 등.
13. Pulsar, Redpanda — Kafka의 대안들
Apache Pulsar (Yahoo, 2016)
**특징**:
- **분리 아키텍처** — 브로커(무상태)와 스토리지(Apache BookKeeper) 분리
- Multi-tenancy 네이티브
- Geo-replication 기본
- Kafka 프로토콜 호환
**장점**: 확장 독립적, 컴퓨트/스토리지 분리
**단점**: 운영 복잡도 (BookKeeper + ZooKeeper + Broker 3계층)
Redpanda (2020)
**특징**:
- C++ 재구현, JVM 없음
- **Kafka 프로토콜 호환** (드롭인 교체)
- ZooKeeper 없이 Raft
- 단일 바이너리
**장점**:
- 같은 하드웨어에서 **10배 성능**
- 낮은 지연 (p99 밀리초)
- 운영 단순
**단점**: 생태계는 여전히 Kafka가 큼, 모든 툴이 호환되진 않음
WarpStream (2023)
**S3 기반**. 로컬 디스크 없음. 모든 데이터 S3에.
- 저장 비용 극적 절감
- 지연은 200ms 정도 (S3 특성)
- 스트리밍 분석 워크로드에 적합
선택
- 표준/생태계 → Kafka
- 단순 운영 + 고성능 → Redpanda
- Multi-tenant 대규모 → Pulsar
- 저장 비용 최소 → WarpStream
14. 100ms SLA 달성 실전 기법
초저지연 목표:
- **Producer to Broker**: 5ms 미만
- **Broker**: 디스크 반영 10ms 미만
- **Broker to Consumer**: 5ms 미만
- **처리**: 50ms 미만
- **총**: 100ms 미만
달성 기법
1. **Producer linger.ms=0** — 배칭 대신 즉시 전송 (throughput 감소 트레이드오프)
2. **SSD + 풍부한 page cache** — 디스크 I/O 최소
3. **min.insync.replicas=2** — 과도한 내구성 X (Leader + 1 follower)
4. **압축 = lz4** — 가장 빠른 코덱
5. **Consumer fetch.min.bytes=1** — 즉시 가져옴
6. **핫 파티션 모니터링** — 한 파티션 포화 방지
7. **Direct Memory I/O** — JVM 튜닝 (Kafka Streams 앱)
8. **Consumer Poll loop 최적화** — 처리 중 poll 차단 방지
관측성
필수 메트릭:
- Producer `record-send-rate`, `batch-size-avg`
- Broker `RequestHandlerAvgIdlePercent` (낮으면 포화)
- Consumer `records-lag-max` (가장 중요)
- 네트워크 I/O
15. 실전 함정과 안티패턴
함정 1: 파티션 수를 나중에 늘림
Partition 수는 **한번 정하면 잘 못 바꿈**. 늘리면 키의 해시 매핑이 바뀌어 **순서 보장 깨짐**. 처음부터 넉넉히 (예상치 3배).
함정 2: Key 없이 순서 기대
Key 없으면 라운드 로빈 → **순서 보장 없음**. 순서가 중요하면 반드시 key.
함정 3: Large Messages (>1MB)
Kafka는 기본 1MB 메시지 제한. 대용량은 **S3에 올리고 참조만 Kafka로**.
함정 4: Consumer가 느림 → Rebalance 폭증
`max.poll.interval.ms`(기본 5분) 초과 시 Consumer kick. 처리 배치가 길면 늘리거나 프로세스를 잘게.
함정 5: Transactional이 모든 걸 해결
트랜잭션은 10~30% 성능 대가. Event Notification 정도는 **필요 없음**.
함정 6: 토픽 수 폭증
1만 토픽 이상은 Kafka 메타데이터 압박. 적정 수로 통합 + 헤더/타입 필드로 구분.
함정 7: retention 미설정
기본 7일. 분석 목적이면 짧고, 이벤트 소스면 길게. **compacted topic**으로 최신만 유지 가능.
함정 8: Schema Registry 없이 운영
Breaking change 탐지 못함. 운영 6개월만 해도 후회.
16. 실전 체크리스트 12가지
1. **Partition 수는 넉넉히** — 후회 없이
2. **Key 전략을 명확히** — 순서/분산 트레이드오프
3. **acks=all + min.insync.replicas=2** — 내구성 표준
4. **Idempotent Producer ON**
5. **Schema Registry 도입** — 계약 수호
6. **Transactional Outbox** — DB 일관성
7. **DLQ 토픽 준비** — poison pill 대비
8. **Consumer lag 모니터링** — 지연의 핵심 지표
9. **압축 lz4 또는 zstd**
10. **KRaft 모드로** — ZooKeeper 제거
11. **Rebalance 최소화** — Static Membership, Cooperative
12. **Stream 처리 도구 선택** — 요구사항에 맞게
다음 글 예고 — Redis 내부와 분산 캐시 전략
Kafka가 이벤트의 버스라면, **Redis**는 실시간 데이터의 임시 저장소이자 연산 엔진이다. 다음 글에서는:
- **Redis의 싱글 스레드 아이디어** — 왜 빠른가
- **다양한 자료구조** — String, List, Set, Hash, Sorted Set, Stream, HyperLogLog, Bitmap
- **Redis Cluster** — Hash Slot과 Redirection
- **Redis Sentinel** — 고가용성
- **Persistence** — RDB vs AOF
- **Cache 패턴** — Cache-Aside, Write-Through, Write-Behind
- **Thundering Herd** 문제와 해법
- **분산 락** — Redlock 논쟁
- **Valkey** — Redis 포크 탄생 배경 (2024)
- **Dragonfly / KeyDB** — 멀티스레드 대안
- 2024년 Redis 라이선스 변경의 충격
Redis를 **캐시**로만 쓰는 팀이 많은데, 실제로는 훨씬 풍부한 도구다. 다음 글에서 그 전모를 보자.
> **"Redis는 자료구조 서버(Data Structure Server)다. 캐시는 사용처의 하나일 뿐이다." — Salvatore Sanfilippo (antirez)**
현재 단락 (1/340)
2015년 이후 모든 컨퍼런스에서 들리는 문장: