Apache Kafka 완벽 심화 가이드: 아키텍처 내부 동작부터 실전 운영까지
Apache Kafka 완벽 심화 가이드: 아키텍처 내부 동작부터 실전 운영까지
이 글은 Apache Kafka의 내부 동작 원리부터 실전 운영 노하우까지를 다루는 심화 가이드이다. 단순히 “Kafka는 메시지 큐입니다”를 넘어서, 왜 Kafka가 빠른지, 내부에서 어떤 일이 일어나는지, 실제 운영에서 어떤 문제가 발생하고 어떻게 해결하는지를 깊이 있게 정리한다.
1. Kafka란 무엇인가 — 탄생 배경과 핵심 철학
1.1 LinkedIn은 왜 Kafka를 만들었는가
2010년대 초, LinkedIn은 급격한 성장과 함께 심각한 데이터 파이프라인 문제에 직면했다. 사용자 활동 추적, 메트릭 수집, 로그 집계, 검색 인덱싱 등 수십 개의 시스템이 서로 포인트-투-포인트(Point-to-Point) 방식으로 연결되어 있었다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
★ LinkedIn의 데이터 파이프라인 문제 (Before Kafka)
[User Activity] ──→ [Hadoop]
[User Activity] ──→ [Real-time Monitoring]
[User Activity] ──→ [Search Index]
[Metrics] ──→ [Hadoop]
[Metrics] ──→ [Dashboard]
[Logs] ──→ [Hadoop]
[Logs] ──→ [Monitoring]
[Logs] ──→ [Alerting]
[DB Changes] ──→ [Cache]
[DB Changes] ──→ [Search Index]
[DB Changes] ──→ [Hadoop]
→ 시스템이 N개일 때 최대 N×(N-1)개의 연결 필요!
→ 새 시스템 추가 시 기존 모든 소스에 연결 필요
시스템 수가 늘어날수록 연결 수는 기하급수적으로 증가했고, 각 연결마다 데이터 포맷, 프로토콜, 에러 처리 로직이 달랐다.
기존에 사용 가능했던 메시지 큐 시스템(RabbitMQ, ActiveMQ 등)은 LinkedIn의 요구사항을 충족하지 못했다.
| 한계 영역 | 상세 설명 |
|---|---|
| 처리량 | 초당 수백만 건의 이벤트를 처리해야 했으나 기존 MQ는 초당 수만 건 수준 |
| 내구성 | 메시지를 소비 후에도 장기간 보관해야 했으나 기존 MQ는 소비 즉시 삭제 |
| 확장성 | 수평 확장이 어렵고, 브로커 추가 시 복잡한 재설정 필요 |
| 다중 소비자 | 하나의 메시지를 여러 소비자가 독립적으로 읽어야 했으나 1:1 전달이 기본 |
| 실시간 + 배치 | 실시간 처리와 배치 처리를 동시에 지원해야 함 |
이러한 문제를 해결하기 위해 Jay Kreps, Neha Narkhede, Jun Rao 세 엔지니어가 2010년부터 새로운 메시징 시스템을 설계하기 시작했다. 그것이 바로 Apache Kafka이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
★ Kafka 도입 후 LinkedIn의 아키텍처 (After Kafka)
[User Activity] ──┐
[Metrics] ──┤
[Logs] ──┼──→ [ Apache Kafka ] ──┬──→ [Hadoop]
[DB Changes] ──┤ ├──→ [Real-time Monitoring]
[Search Events] ──┘ ├──→ [Search Index]
├──→ [Dashboard]
├──→ [Cache]
└──→ [Alerting]
→ 모든 소스는 Kafka에만 보내면 됨
→ 모든 소비자는 Kafka에서만 읽으면 됨
→ N+M개의 연결로 충분 (N: 소스 수, M: 소비자 수)
1.2 메시지 큐 vs 이벤트 스트리밍 플랫폼
Kafka를 단순히 “메시지 큐”라고 부르는 것은 정확하지 않다. Kafka는 이벤트 스트리밍 플랫폼(Event Streaming Platform) 이다.
1
2
3
4
5
6
7
8
9
10
11
★ 전통적 메시지 큐 (RabbitMQ / ActiveMQ)
Producer ──→ [ Queue: msg1, msg2, msg3 ] ──→ Consumer
│
소비 후 삭제
(msg1 사라짐)
특징:
- 메시지는 한 번 소비되면 큐에서 삭제됨
- 하나의 메시지는 하나의 소비자만 받음 (Point-to-Point)
- 큐가 비면 끝 → 과거 데이터 재처리 불가
1
2
3
4
5
6
7
8
9
10
11
12
13
★ Kafka (이벤트 스트리밍 플랫폼)
Producer ──→ [ Log: msg1, msg2, msg3, msg4, msg5 ... ]
↑ ↑ ↑
│ │ │
Consumer A Consumer B Consumer C
(offset=0) (offset=2) (offset=4)
특징:
- 메시지는 소비 후에도 보존됨 (보존 정책에 따라)
- 여러 소비자가 독립적으로 같은 메시지를 읽을 수 있음 (N:N)
- 각 소비자는 자신의 오프셋(위치)을 독립적으로 관리
- 과거 데이터 재처리(replay) 가능
| 비교 항목 | 전통적 메시지 큐 | Kafka |
|---|---|---|
| 데이터 모델 | Queue (FIFO) | Distributed Commit Log |
| 소비 후 데이터 | 삭제됨 | 보존됨 (설정 기간/크기까지) |
| 소비자 모델 | Push 기반 | Pull 기반 |
| 소비자 수 | 보통 1:1 | N:N (독립적 소비) |
| 데이터 재처리 | 불가능 | 오프셋 리셋으로 가능 |
| 처리량 | 수만 msg/sec | 수백만 msg/sec |
| 순서 보장 | 큐 전체에서 보장 | 파티션 내에서 보장 |
| 확장 방식 | 수직 확장 위주 | 수평 확장 (파티션 추가) |
실무 팁: RabbitMQ는 복잡한 라우팅(헤더, 토픽 패턴 등), 메시지별 TTL, 우선순위 큐 등에서 여전히 강점을 가진다. Kafka는 대용량 이벤트 스트리밍에, RabbitMQ는 복잡한 메시지 라우팅에 더 적합하다.
1.3 Kafka의 핵심 설계 철학
1) Append-only, Immutable Log
Kafka의 파티션은 본질적으로 끝에만 추가(append-only) 가 가능한 불변(immutable) 로그 파일이다.
1
2
3
4
5
6
7
8
9
10
11
12
Partition 0 (Append-only Log)
┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
│ 0 │ 1 │ 2 │ 3 │ 4 │ 5 │ 6 │ 7 │ ← offset
│ msg │ msg │ msg │ msg │ msg │ msg │ msg │ msg │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
↑
새 메시지는
항상 끝에 추가
- 중간 삽입 불가, 수정 불가
- 삭제는 보존 정책에 의해서만 (앞쪽부터 일괄 삭제)
이 설계가 주는 이점:
- ★ 순차적 디스크 I/O: 항상 끝에 쓰기 때문에 랜덤 I/O 대신 순차적 I/O 사용 → HDD에서도 빠름
- ★ 동시성 안전: 불변 데이터이므로 락(Lock) 없이 여러 소비자가 동시 읽기 가능
- ★ 캐시 친화적: OS 페이지 캐시를 극대화 활용 가능
2) 오프셋(Offset) 기반 소비자 위치 추적
전통적 메시지 큐에서는 브로커가 “누가 어떤 메시지를 받았는지”를 추적한다. Kafka에서는 소비자가 자신의 오프셋을 스스로 관리한다.
3) 시간/크기 기반 보존 정책
| 설정 | 기본값 | 설명 |
|---|---|---|
log.retention.hours |
168 (7일) | 메시지 보존 시간 |
log.retention.bytes |
-1 (무제한) | 파티션당 최대 보존 크기 |
log.segment.bytes |
1GB | 세그먼트 파일 크기 |
log.cleanup.policy |
delete | delete 또는 compact |
⚠️ log.retention.hours, log.retention.minutes, log.retention.ms가 모두 설정된 경우, 가장 작은 단위(ms)가 우선 적용된다.
2. 핵심 아키텍처 Deep Dive
2.1 Kafka 클러스터 구성 요소
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
★ ZooKeeper 기반 구조 (Kafka 3.3 이전)
┌─────────────────────────────────────────────────┐
│ Kafka Cluster │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Broker 0 │ │ Broker 1 │ │ Broker 2 │ │
│ │(Controller)│ │ │ │ │ │
│ └─────┬────┘ └─────┬────┘ └─────┬────┘ │
│ │ │ │ │
└────────┼──────────────┼──────────────┼─────────────┘
│ │ │
┌────┴──────────────┴──────────────┴────┐
│ ZooKeeper Ensemble │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ ZK1 │ │ ZK2 │ │ ZK3 │ │
│ └─────┘ └─────┘ └─────┘ │
│ - Controller 선출 │
│ - 브로커 등록/감지 │
│ - 토픽/파티션 메타데이터 │
│ - ACL 관리 │
└───────────────────────────────────────┘
★ KRaft 모드 (Kafka 3.3+, ZooKeeper 제거)
┌─────────────────────────────────────────────────┐
│ Kafka Cluster │
│ │
│ ┌──────────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Controller 0 │ │ Broker 1 │ │ Broker 2 │ │
│ │ (Raft Leader) │ │ │ │ │ │
│ │ + Broker │ │ │ │ │ │
│ └──────────────┘ └──────────┘ └──────────┘ │
│ │
│ - ZooKeeper 의존성 완전 제거 │
│ - 메타데이터를 Raft 프로토콜로 자체 관리 │
│ - 수십만 파티션까지 확장 가능 │
│ - Controller 장애 복구 속도 대폭 향상 │
└─────────────────────────────────────────────────┘
2.2 Topic → Partition → Segment → Index 계층 구조
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
★ Kafka 저장소 계층 구조
Topic: "order-events"
├── Partition 0 (/var/kafka-logs/order-events-0/)
│ ├── Segment: 00000000000000000000.log (0~999 오프셋)
│ │ 00000000000000000000.index
│ │ 00000000000000000000.timeindex
│ ├── Segment: 00000000000000001000.log (1000~1999 오프셋)
│ │ 00000000000000001000.index
│ │ 00000000000000001000.timeindex
│ └── Segment: 00000000000000002000.log (2000~ , Active)
│ 00000000000000002000.index
│ 00000000000000002000.timeindex
├── Partition 1 (/var/kafka-logs/order-events-1/)
│ └── ...
└── Partition 2 (/var/kafka-logs/order-events-2/)
└── ...
- Topic: 논리적인 메시지 채널 (예: order-events)
- Partition: 물리적인 병렬 처리 단위. 디스크에 디렉토리로 존재
- Segment: 파티션 내 실제 파일. 일정 크기(기본 1GB)나 시간이 지나면 새 세그먼트 생성
- Index: 세그먼트 내 메시지를 빠르게 찾기 위한 인덱스 파일
2.3 Partition 내부 파일 구조
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
★ Segment 파일 구조
.log 파일 (실제 메시지):
┌─────────────────────────────────────────────────┐
│ RecordBatch (offset 0-4) │
│ ┌──────┬──────────┬──────┬──────┬──────────────┐│
│ │offset│timestamp │key │value │headers ││
│ │ 0 │167...001 │ord-1 │{...} │ ││
│ │ 1 │167...002 │ord-2 │{...} │ ││
│ │ ... ││
│ └──────────────────────────────────────────────┘│
│ RecordBatch (offset 5-9) │
│ └──────────────────────────────────────────────┘│
└─────────────────────────────────────────────────┘
.index 파일 (오프셋 → 물리적 위치, Sparse Index):
┌──────────────────────────────────────┐
│ Relative Offset │ Physical Position │
├─────────────────┼────────────────────┤
│ 0 │ 0 │
│ 4 │ 1024 │
│ 8 │ 2048 │
│ ... │ ... │
└──────────────────────────────────────┘
.timeindex 파일 (타임스탬프 → 오프셋):
┌──────────────────────────────────────┐
│ Timestamp │ Offset │
├─────────────────┼────────────────────┤
│ 1680000001000 │ 0 │
│ 1680000005000 │ 4 │
│ 1680000009000 │ 8 │
└──────────────────────────────────────┘
★ .index는 Sparse Index이다. 모든 오프셋이 아니라 일정 간격(기본 4KB)마다 인덱스 엔트리를 생성한다. 특정 오프셋을 찾을 때는 이진 검색으로 가장 가까운 엔트리를 찾고, 그 위치부터 순차 탐색한다.
2.4 오프셋(Offset)의 정체
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Partition 내 오프셋 관련 개념:
Log Start Offset High Watermark Log End Offset
│ │ │
▼ ▼ ▼
┌────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┐
│ 50 │ 51 │ 52 │ 53 │ 54 │ 55 │ 56 │ 57 │ 58 │ 59 │ 60 │
└────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┘
│←── 삭제된 메시지 ──→│←── 소비 가능 ──→│←── 미복제 ──→│
- Log Start Offset: 현재 보존 중인 가장 오래된 메시지의 오프셋
- High Watermark (HW): 모든 ISR에 복제 완료된 마지막 오프셋
→ Consumer는 여기까지만 읽을 수 있음
- Log End Offset (LEO): Leader의 마지막 메시지 다음 오프셋
3. Producer 내부 동작 원리
3.1 Producer 아키텍처
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
★ Producer 내부 구조
Application Thread Sender Thread
┌──────────────────┐ ┌──────────────────┐
│ KafkaProducer │ │ Sender │
│ .send(record) │ │ │
│ │ │ │ │
│ ▼ │ │ │
│ Serializer │ │ │
│ (Key + Value) │ │ │
│ │ │ │ │
│ ▼ │ │ │
│ Partitioner │ │ │
│ (파티션 결정) │ │ │
│ │ │ │ │
│ ▼ │ drain │ │
│ RecordAccumulator├───────────────────▶ NetworkClient │
│ ┌─────────────┐ │ (batch 단위) │ │ │
│ │TP0: [batch] │ │ │ ▼ │
│ │TP1: [batch] │ │ │ Kafka Broker │
│ │TP2: [batch] │ │ │ │
│ └─────────────┘ │ │ ◀── Response │
│ │ │ (acks, errors) │
└──────────────────┘ └──────────────────┘
3.2 Partitioner 전략
1) Sticky Partitioner (기본, Kafka 2.4+)
Key가 null일 때 하나의 파티션에 배치가 꽉 찰 때까지 메시지를 모은 뒤, 다른 파티션으로 이동한다.
2) Key-based Partitioning
Key가 있으면 murmur2(key) % numPartitions로 항상 같은 파티션에 전달한다. 같은 주문 ID의 이벤트가 순서대로 처리되어야 할 때 필수이다.
Custom Partitioner 구현 예제:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class RegionPartitioner implements Partitioner {
@Override
public int partition(String topic, Object key, byte[] keyBytes,
Object value, byte[] valueBytes, Cluster cluster) {
List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
int numPartitions = partitions.size();
if (keyBytes == null) {
return ThreadLocalRandom.current().nextInt(numPartitions);
}
String keyStr = (String) key;
if (keyStr.startsWith("KR-")) {
return 0; // 한국 리전 → Partition 0
} else if (keyStr.startsWith("US-")) {
return 1; // 미국 리전 → Partition 1
}
return Math.abs(Utils.murmur2(keyBytes)) % numPartitions;
}
@Override
public void close() {}
@Override
public void configure(Map<String, ?> configs) {}
}
3.3 Batching & Linger 메커니즘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
★ Batching 동작 흐름
batch.size = 16KB, linger.ms = 5ms
시간 ──────────────────────────────────────────→
msg1 ─┐
msg2 ─┤ RecordAccumulator에 쌓임
msg3 ─┤
│
├─ 조건 1: 배치 크기(16KB) 도달 → 즉시 전송!
│
└─ 조건 2: linger.ms(5ms) 경과 → 모인 만큼 전송
→ 둘 중 하나라도 충족되면 Sender가 배치를 전송
| 설정 | 기본값 | 설명 |
|---|---|---|
batch.size |
16384 (16KB) | 배치 최대 크기 |
linger.ms |
0 | 전송 대기 시간 (처리량 높이려면 5~20) |
buffer.memory |
33554432 (32MB) | 전체 전송 버퍼 크기 |
max.block.ms |
60000 (1분) | 버퍼 가득 시 블로킹 최대 시간 |
3.4 acks 설정과 트레이드오프
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
★ acks=0 (Fire and Forget)
Producer ──msg──▶ Broker Producer는 응답을 기다리지 않음
메시지가 유실되어도 모름
★ 가장 빠름, 유실 위험
★ acks=1 (Leader만 확인)
Producer ──msg──▶ Leader Broker
Producer ◀──ACK── Leader Leader에 기록된 순간 ACK
Follower 복제 전 Leader 장애 시 유실
★ acks=all(-1) (모든 ISR 확인)
Producer ──msg──▶ Leader ──replicate──▶ Follower 1
──replicate──▶ Follower 2
Producer ◀──ACK── Leader (모든 ISR 복제 완료 후)
★ 가장 안전, 가장 느림
Spring Kafka 설정 예제:
1
2
3
4
5
6
7
8
9
10
11
12
spring:
kafka:
producer:
bootstrap-servers: kafka1:9092,kafka2:9092,kafka3:9092
acks: all
retries: 2147483647
properties:
enable.idempotence: true
max.in.flight.requests.per.connection: 5
linger.ms: 10
batch.size: 32768
compression.type: lz4
3.5 Idempotent Producer
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
★ Idempotent Producer 동작 원리
enable.idempotence=true 설정 시:
1. Producer 초기화 시 Broker로부터 PID(Producer ID) 할당
2. 각 <PID, Partition>마다 Sequence Number 0부터 시작
Producer (PID=1000)
├── Partition 0: seq=0, 1, 2, 3 ...
├── Partition 1: seq=0, 1, 2, 3 ...
└── Partition 2: seq=0, 1, 2, 3 ...
전송 흐름:
Producer ──(PID=1000, seq=5, msg)──▶ Broker
Broker: "PID=1000의 마지막 seq=4, 이번에 5 → 정상, 저장"
재시도 발생:
Producer ──(PID=1000, seq=5, msg)──▶ Broker (같은 메시지 재전송)
Broker: "PID=1000의 마지막 seq=5, 이번도 5 → 중복! 무시하고 ACK 반환"
★ Kafka 3.0부터 enable.idempotence의 기본값이 true로 변경되었다.
3.6 재시도와 순서 보장
max.in.flight.requests.per.connection은 브로커의 ACK를 받지 않고 동시에 보낼 수 있는 최대 요청 수이다.
1
2
3
4
5
6
7
8
9
⚠️ 비멱등 모드에서 max.in.flight > 1일 때 순서 역전
Producer ──batch1(seq 0,1)──▶ Broker (전송 중)
Producer ──batch2(seq 2,3)──▶ Broker (전송 중)
batch1 실패! batch2 성공!
→ Broker에 seq 2,3이 먼저 기록됨
→ batch1 재시도 성공 → seq 0,1이 나중에 기록됨
→ 순서: 2,3,0,1 (역전!)
★ Idempotent Producer에서는 max.in.flight를 최대 5까지 설정해도 순서가 보장된다. 브로커가 시퀀스 번호로 순서를 검증하고, 순서가 맞지 않으면 OutOfOrderSequenceException을 반환하여 Producer가 올바른 순서로 재전송하게 한다.
4. Consumer 내부 동작 원리
4.1 Consumer Group 메커니즘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
★ Consumer Group 동작
Topic: order-events (Partition 0, 1, 2)
Consumer Group A (group.id = "order-service")
├── Consumer A-1: Partition 0
├── Consumer A-2: Partition 1
└── Consumer A-3: Partition 2
Consumer Group B (group.id = "analytics")
├── Consumer B-1: Partition 0, 1
└── Consumer B-2: Partition 2
→ 같은 Group 내에서는 파티션을 나눠서 소비
→ 다른 Group은 독립적으로 전체 소비
→ Consumer 수 > Partition 수이면 남는 Consumer는 유휴 상태
4.2 Partition Assignment 전략
| 전략 | 균등성 | 리밸런싱 영향 | 프로토콜 | 권장 시나리오 |
|---|---|---|---|---|
| RangeAssignor | 낮음 | 전체 중단 | Eager | 레거시 호환 |
| RoundRobinAssignor | 높음 | 전체 중단, 대규모 이동 | Eager | 단일 토픽 |
| StickyAssignor | 높음 | 전체 중단, 최소 이동 | Eager | 다중 토픽 |
| CooperativeStickyAssignor | 높음 | 점진적 (중단 최소) | Cooperative | ★ 신규 프로젝트 권장 |
1
2
3
// Spring Kafka에서 CooperativeStickyAssignor 설정
config.put(ConsumerConfig.PARTITION_ASSIGNMENT_STRATEGY_CONFIG,
Collections.singletonList(CooperativeStickyAssignor.class));
4.3 Rebalancing 과정
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
★ Eager Rebalancing (기존 방식 - Stop-the-World)
시간 ──────────────────────────────────────────────→
C0: ████████████│STOP│ │████████████
C1: ████████████│STOP│ │████████████
C2: ████████████│STOP│ │████████████
│ │ │
│←── Rebalance 기간 ──────→│
│ (전체 소비 중단!) │
★ Cooperative Rebalancing (점진적 방식)
C0: ████████████████████████████████████████████████████
C1: ████████████████████│STOP│██████████████████████████
C2: ████████████████████████████████████████████████████
C3 (신규): ·····│START│████████████████████
│ │
← 변경된 파티션만 이동 →
실무 팁: Eager에서 Cooperative로 전환 시 롤링 업그레이드를 해야 한다. 1단계:
partition.assignment.strategy를[CooperativeStickyAssignor, RangeAssignor]둘 다 지정 후 롤링 배포 2단계: 모든 Consumer 업데이트 후[CooperativeStickyAssignor]만 남기고 롤링 배포
4.4 Offset 관리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
★ __consumer_offsets 토픽 구조
토픽명: __consumer_offsets (파티션 50개, 기본)
정리 정책: compact (최신 값만 유지)
Key: (group.id, topic, partition)
Value: (offset, metadata, timestamp)
예시:
┌──────────────────────────────────────────────────────────┐
│ Key: ("order-service", "order-events", 0) │
│ Value: { offset: 15234, metadata: "", timestamp: ... } │
├──────────────────────────────────────────────────────────┤
│ Key: ("order-service", "order-events", 1) │
│ Value: { offset: 14982, metadata: "", timestamp: ... } │
└──────────────────────────────────────────────────────────┘
→ 어떤 파티션에 저장? hash(group.id) % 50
→ 해당 파티션의 Leader 브로커가 이 그룹의 Coordinator
4.5 Commit 전략
Auto Commit의 문제점:
1
2
3
4
5
시나리오 (메시지 유실):
1. poll() → offset 100~200 수신
2. 5초 경과 → offset 200 자동 커밋
3. offset 150까지만 처리한 상태에서 크래시!
4. 재시작 → offset 200부터 읽기 (151~199 유실)
Spring Kafka의 AckMode:
| AckMode | 설명 |
|---|---|
RECORD |
매 레코드 처리 후 커밋 (가장 안전, 가장 느림) |
BATCH (기본) |
poll()로 받은 모든 레코드 처리 후 커밋 |
MANUAL |
Acknowledgment.acknowledge() 호출 시 커밋 |
MANUAL_IMMEDIATE |
acknowledge() 호출 즉시 커밋 |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Component
public class OrderEventListener {
@KafkaListener(topics = "order-events", groupId = "order-service")
public void handleOrderEvent(ConsumerRecord<String, String> record,
Acknowledgment acknowledgment) {
try {
Order order = objectMapper.readValue(record.value(), Order.class);
orderService.process(order);
acknowledgment.acknowledge(); // 처리 성공 시에만 커밋
} catch (Exception e) {
log.error("Failed to process order event: {}", record.value(), e);
// acknowledge()를 호출하지 않으면 다음 poll에서 재처리됨
}
}
}
4.6 poll() 루프와 핵심 설정
| 설정 | 기본값 | 설명 |
|---|---|---|
max.poll.interval.ms |
300000 (5분) | 연속 poll() 호출 간 최대 허용 시간. 초과 시 리밸런싱 |
max.poll.records |
500 | 한 번의 poll()에서 반환할 최대 레코드 수 |
session.timeout.ms |
45000 | Consumer가 죽은 것으로 판단하는 시간 |
heartbeat.interval.ms |
3000 | Heartbeat 전송 간격 |
fetch.min.bytes |
1 | Fetch 응답 최소 데이터 크기 |
fetch.max.wait.ms |
500 | Fetch 응답 최대 대기 시간 |
★ “느린 소비자(Slow Consumer)” 문제 해결 순서:
- 먼저 처리 로직을 프로파일링하여 병목 지점 파악
- 병목을 해결할 수 없다면 max.poll.records를 줄임
- 그래도 부족하면 pause/resume 패턴 적용
- max.poll.interval.ms 증가는 최후의 수단
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# application.yml - Consumer 완전한 설정 예제
spring:
kafka:
consumer:
bootstrap-servers: kafka1:9092,kafka2:9092,kafka3:9092
group-id: order-service
auto-offset-reset: earliest
enable-auto-commit: false
max-poll-records: 100
properties:
max.poll.interval.ms: 300000
session.timeout.ms: 30000
heartbeat.interval.ms: 10000
partition.assignment.strategy: org.apache.kafka.clients.consumer.CooperativeStickyAssignor
listener:
concurrency: 3
ack-mode: manual
5. 복제(Replication)와 고가용성
5.1 Leader-Follower 복제 모델
1
2
3
4
5
6
7
8
9
10
11
12
13
★ 복제 구조 (replication.factor = 3)
Topic: order-events, Partition 0
Broker 1 Broker 2 Broker 3
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ P0 (Leader) │──────▶│ P0 (Follower)│ │ P0 (Follower)│
│ │──────────────────────────────▶│ │
│ [0,1,2,3,4] │ fetch│ [0,1,2,3,4] │ fetch│ [0,1,2,3] │
└──────────────┘ └──────────────┘ └──────────────┘
↑ ↑
모든 읽기/쓰기 아직 offset 4를
요청 처리 복제하지 못함
- 모든 읽기/쓰기는 Leader를 통해 수행 (Kafka 2.4+ Follower Fetching 옵션 있음)
- Follower는 Leader를 주기적으로 fetch하여 데이터를 복제
replication.factor는 보통 3으로 설정 (프로덕션 최소 권장)
5.2 ISR (In-Sync Replicas) 동작 원리
ISR은 Leader와 “충분히” 동기화된 Follower의 집합이다.
1
2
3
4
5
6
7
8
9
10
11
12
ISR 멤버십 관리:
replica.lag.time.max.ms = 30000 (30초)
시간 0: ISR = {Broker1(L), Broker2(F), Broker3(F)}
모두 동기화 상태
시간 T: Broker3이 네트워크 문제로 30초 이상 fetch 못함
ISR = {Broker1(L), Broker2(F)} ← Broker3 제외 (ISR Shrink)
시간 T+60: Broker3 복구, Leader 따라잡음
ISR = {Broker1(L), Broker2(F), Broker3(F)} ← 복귀 (ISR Expand)
5.3 High Watermark와 Log End Offset
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
★ LEO와 HW의 관계
Leader (Broker 1): [0][1][2][3][4][5][6][7]
↑ LEO = 8
Follower (Broker 2): [0][1][2][3][4][5][6]
↑ LEO = 7
Follower (Broker 3): [0][1][2][3][4][5]
↑ LEO = 6
High Watermark = min(ISR의 모든 LEO) = 6
Consumer는 offset 0~5까지만 읽을 수 있음 (HW 미만)
offset 6, 7은 아직 모든 ISR에 복제되지 않았으므로 읽기 불가
5.4 Unclean Leader Election
unclean.leader.election.enable (기본 false, Kafka 1.0+)
ISR이 모두 다운되었을 때 비동기 Follower가 Leader가 되는 것을 허용할지 결정한다.
⚠️ 절대 true로 설정하면 안 되는 이유: 동기화되지 않은 Follower가 Leader가 되면 그 사이에 쌓인 메시지가 모두 유실된다. 가용성보다 데이터 무결성이 훨씬 중요한 대부분의 프로덕션 환경에서는 false를 유지해야 한다.
5.5 min.insync.replicas와 acks=all 조합
★ 황금률: replication.factor=3 + min.insync.replicas=2 + acks=all
1
2
3
4
5
6
7
8
9
10
11
12
13
14
이 조합의 의미:
- 3개의 복제본 유지
- 쓰기 시 최소 2개의 ISR에 복제되어야 ACK
- 1개 브로커 장애 시에도 읽기/쓰기 모두 정상
- 2개 브로커 동시 장애 시 쓰기 불가 (NotEnoughReplicasException)
┌────────────────┬──────────┬──────────┬───────────────────┐
│ RF │ min.ISR │ acks │ 허용 장애 수 │ 안전성 │
├────────────────┼──────────┼──────────┼───────────────────┤
│ 3 │ 1 │ all │ 2 (쓰기) │ 낮음 (1대만 확인) │
│ 3 │ 2 │ all │ 1 (쓰기) │ ★ 권장 │
│ 3 │ 3 │ all │ 0 (쓰기) │ 높지만 가용성 낮음 │
│ 5 │ 3 │ all │ 2 (쓰기) │ 매우 높음 (금융) │
└────────────────┴──────────┴──────────┴───────────────────┘
6. 메시지 전달 보장 (Delivery Semantics)
6.1 세 가지 전달 보장 수준
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
★ At-most-once (최대 한 번)
Commit FIRST → Process
오프셋 커밋 후 처리 중 장애 → 메시지 유실
유실 가능, 중복 없음
★ At-least-once (최소 한 번, 기본)
Process FIRST → Commit
처리 후 커밋 전 장애 → 재처리 시 중복
유실 없음, 중복 가능
★ Exactly-once (정확히 한 번)
Idempotent Producer + Transactional API + read_committed
유실 없음, 중복 없음 (가장 까다로움)
6.2 Exactly-once의 두 축
- Idempotent Producer: 단일 파티션 내 중복 방지 (PID + Sequence Number)
- Transactional API: 여러 파티션/토픽에 걸친 원자적 쓰기
6.3 Transactional API 상세
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Transactional Producer 사용 예제
Properties props = new Properties();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, "order-processor-tx-1");
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
KafkaProducer<String, String> producer = new KafkaProducer<>(props);
producer.initTransactions();
try {
producer.beginTransaction();
// 여러 토픽/파티션에 원자적으로 쓰기
producer.send(new ProducerRecord<>("order-events", key, orderEvent));
producer.send(new ProducerRecord<>("inventory-events", key, inventoryEvent));
producer.send(new ProducerRecord<>("notification-events", key, notificationEvent));
// Consumer 오프셋도 트랜잭션에 포함 가능
producer.sendOffsetsToTransaction(offsets, consumerGroupMetadata);
producer.commitTransaction(); // 모두 성공 시 커밋
} catch (Exception e) {
producer.abortTransaction(); // 하나라도 실패 시 전체 롤백
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
★ 트랜잭션 내부 흐름 (Two-Phase Commit)
Producer Transaction Coordinator Broker(s)
│ │ │
│──initTransactions──────────▶│ │
│◀────(PID 할당)──────────────│ │
│ │ │
│──beginTransaction──────────▶│ │
│ │──AddPartitionsToTxn──────▶│
│ │ │
│──send(topic-A, msg1)──────────────────────────────────▶│
│──send(topic-B, msg2)──────────────────────────────────▶│
│ │ │
│──commitTransaction─────────▶│ │
│ │──WriteTxnMarker(COMMIT)──▶│
│ │ │
│◀────(commit complete)───────│ │
6.4 Consumer 측 Exactly-once 처리 패턴
1
2
# Consumer 설정
isolation.level=read_committed
이 설정으로 Consumer는 커밋된 트랜잭션의 메시지만 읽는다.
★ 핵심 포인트: “End-to-End Exactly-once”는 Kafka 내부(Consume → Transform → Produce)에서만 완전히 보장된다. 외부 시스템(DB, HTTP API 등)과 연동할 때는 반드시 Idempotent Consumer 패턴을 구현해야 한다.
Outbox 패턴:
1
2
3
4
5
6
7
8
9
10
┌─────────────────────────────────────────────────────┐
│ Service │
│ ┌─ DB Transaction ─────────────────────────┐ │
│ │ 1. UPDATE orders SET status='PAID' │ │
│ │ 2. INSERT INTO outbox (topic, key, val) │ │
│ └──────────────────────────────────────────┘ │
│ │
│ CDC (Debezium) 또는 Polling │
│ outbox 테이블 변경 감지 ──── Kafka Produce ──▶ Topic │
└─────────────────────────────────────────────────────┘
7. 성능의 비밀 — 왜 Kafka는 빠른가
7.1 Sequential I/O vs Random I/O
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
전통적 메시지 큐 (B-Tree 기반):
Random Write/Read → 디스크 헤드가 여기저기 이동
→ 매우 느림 (HDD: ~10ms/seek)
Kafka (Append-Only Log):
Sequential Write → 디스크 헤드가 한 방향으로만 이동
→ 매우 빠름 (HDD에서도 수백 MB/s)
┌──────────────┬───────────────────┬──────────────────┐
│ Storage │ Random I/O │ Sequential I/O │
├──────────────┼───────────────────┼──────────────────┤
│ HDD (7200) │ ~100 IOPS │ ~150 MB/s │
│ │ (~0.4 MB/s @4KB) │ │
├──────────────┼───────────────────┼──────────────────┤
│ SSD (SATA) │ ~10,000 IOPS │ ~500 MB/s │
│ │ (~40 MB/s @4KB) │ │
├──────────────┼───────────────────┼──────────────────┤
│ NVMe SSD │ ~500,000 IOPS │ ~3,000 MB/s │
└──────────────┴───────────────────┴──────────────────┘
★ HDD Sequential I/O (150 MB/s) > SSD Random I/O (40 MB/s)!
7.2 OS Page Cache 활용
1
2
3
4
5
6
7
8
9
10
11
12
13
┌──────────────────────────────────────────────────────────┐
│ Kafka 브로커 │
│ │
│ Producer ──▶ [OS Page Cache] ──비동기──▶ Disk │
│ │ │
│ ▼ │
│ Consumer (cache hit 시 디스크 접근 없이 바로 읽기) │
│ │
│ 예: 64GB RAM 서버 │
│ ├── JVM Heap: 6GB │
│ ├── OS + 기타: 2GB │
│ └── Page Cache: 56GB ← 여기에 최근 데이터 캐싱! │
└──────────────────────────────────────────────────────────┘
★ Kafka 브로커의 JVM 힙은 4~6GB이면 충분하다. 64GB 서버에서 힙을 32GB로 설정하면 Page Cache에 사용할 메모리가 줄어들어 오히려 성능이 떨어진다.
7.3 Zero-Copy (sendfile 시스템콜)
1
2
3
4
5
6
7
8
★ 일반적인 데이터 전송 (4번 복사):
Disk → Kernel Buffer → User Buffer → Socket Buffer → NIC
★ Zero-Copy (sendfile, 2번 복사):
Disk → Kernel Buffer ─────────────────────────→ NIC
(User Space는 전혀 관여하지 않음!)
성능 개선: ~2.5배 빠름, CPU 사용률 ~65% 감소
Kafka는 Java NIO의 FileChannel.transferTo()를 사용하며, 내부적으로 Linux의 sendfile() 시스템콜을 호출한다.
⚠️ SSL/TLS 암호화가 활성화되면 Zero-Copy를 사용할 수 없다. 암호화는 User Space에서 수행되어야 하므로 SSL 활성화 시 처리량이 20~30% 감소할 수 있다.
7.4 Batching + Compression
압축 알고리즘 비교:
| 알고리즘 | 압축률 | 압축 속도 | 해제 속도 | 권장 사례 |
|---|---|---|---|---|
| none | 1x | N/A | N/A | 이미 압축된 데이터 |
| gzip | ~6-8x (최고) | ~50 MB/s (느림) | ~250 MB/s | 저장 공간 중요 |
| snappy | ~3-4x | ~500 MB/s (빠름) | ~500 MB/s | CPU 절약 중요 |
| lz4 | ~4-5x | ~700 MB/s | ~1,000 MB/s | ★ 기본 추천 |
| zstd | ~5-7x | ~300 MB/s | ~800 MB/s | 높은 압축률 + 합리적 속도 |
7.5 파티셔닝과 수평 확장
1
2
3
4
5
처리량 확장 예시:
파티션 1개, Consumer 1개: ~50,000 msg/s
파티션 3개, Consumer 3개: ~150,000 msg/s (3배)
파티션 6개, Consumer 6개: ~300,000 msg/s (6배)
파티션 12개, Consumer 12개: ~550,000 msg/s (11배)
7.6 벤치마크와 비교
1
2
3
4
5
6
처리량 비교 (단일 노드 기준, 1KB 메시지):
Apache Kafka ████████████████████████████████████ ~800K+ msg/s
Apache Pulsar ██████████████████████████████ ~600K msg/s
RabbitMQ █████████ ~30-50K msg/s
ActiveMQ ████ ~10-20K msg/s
대규모 운영 사례:
- LinkedIn: 클러스터 100+, 브로커 4,000+, 일일 7+ 조 메시지, 7+ PB 처리량
- Netflix: 일일 수천억 건, 모든 마이크로서비스 간 통신의 중추
- Uber: 일일 수조 건, 여러 데이터센터에 걸친 멀티 클러스터
★ Kafka가 빠른 이유 한 줄 요약: Sequential I/O + OS Page Cache + Zero-Copy + Batching + Compression + Partitioning
8. 실전 운영 가이드
8.1 파티션 수 결정 전략
1
2
3
4
5
6
7
파티션 수 = max(목표 처리량 / Producer 단일 파티션 처리량,
목표 처리량 / Consumer 단일 파티션 처리량)
예: 목표 처리량 = 1,000,000 msg/s
Producer 단일 파티션: ~200,000 msg/s
Consumer 단일 파티션: ~100,000 msg/s
파티션 수 = max(5, 10) = 10개 → 여유분 포함 12~16개 권장
⚠️ 파티션 수는 늘릴 수는 있지만 줄일 수 없다 (토픽 재생성 필요)
실무 가이드라인:
- 소규모: 6개
- 중규모: 12~30개
- 대규모: 100개 이상
8.2 Consumer Lag 모니터링과 대응
1
2
3
4
5
Consumer Lag = Log End Offset - Consumer Committed Offset
예:
Partition 0: LEO=15000, Committed=14800 → Lag = 200
Partition 1: LEO=15200, Committed=12000 → Lag = 3200 ← 문제!
1
2
3
4
5
6
7
8
# Consumer Group 상태 확인
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
--describe --group order-service
# 출력:
# GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG
# order-service order-events 0 14800 15000 200
# order-service order-events 1 12000 15200 3200
Lag 증가 시 대응 전략:
- 파티션 수와 Consumer 수 동시 증가
- Consumer 처리 로직 최적화 (배치 처리, 비동기 I/O)
max.poll.records조정
8.3 Retention 정책
| 정책 | 설정 | 설명 |
|---|---|---|
| Time-based | log.retention.hours=168 |
기본 7일, 시간 초과 시 삭제 |
| Size-based | log.retention.bytes=107374182400 |
파티션당 최대 100GB |
| Compact | log.cleanup.policy=compact |
같은 Key의 최신 값만 유지 |
| 혼합 | log.cleanup.policy=delete,compact |
삭제 + 압축 병행 |
8.4 Log Compaction 동작 원리
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
★ Log Compaction 전/후
Before Compaction:
┌──────────────────────────────────────────────┐
│ Key=A,v1 │ Key=B,v1 │ Key=A,v2 │ Key=C,v1 │
│ Key=B,v2 │ Key=A,v3 │ Key=C,null │ Key=D,v1│
└──────────────────────────────────────────────┘
After Compaction:
┌──────────────────────────────────┐
│ Key=A,v3 │ Key=B,v2 │ Key=D,v1 │
└──────────────────────────────────┘
- Key=A: 최신 값 v3만 유지
- Key=B: 최신 값 v2만 유지
- Key=C: null(tombstone) → 삭제됨
- Key=D: v1 유지
사용 사례: CDC(Change Data Capture), KTable, 설정 토픽, __consumer_offsets
8.5 브로커 핵심 설정 튜닝
| 카테고리 | 설정 | 기본값 | 권장값 | 설명 |
|---|---|---|---|---|
| 네트워크 | num.network.threads |
3 | CPU 코어/2 | 네트워크 요청 처리 스레드 |
num.io.threads |
8 | 디스크수 * 2 | I/O 작업 처리 스레드 | |
socket.send.buffer.bytes |
102400 | 1048576 | 소켓 전송 버퍼 | |
socket.receive.buffer.bytes |
102400 | 1048576 | 소켓 수신 버퍼 | |
| 로그 | log.segment.bytes |
1073741824 | 1GB | 세그먼트 크기 |
log.retention.hours |
168 | 워크로드별 | 보존 시간 | |
log.cleanup.policy |
delete | 워크로드별 | 정리 정책 | |
| 복제 | min.insync.replicas |
1 | 2 | 최소 ISR 수 |
default.replication.factor |
1 | 3 | 기본 복제 팩터 | |
num.replica.fetchers |
1 | 4 | Follower fetch 스레드 수 |
OS 레벨 튜닝:
1
2
3
4
5
6
7
8
# 스왑 최소화
sysctl vm.swappiness=1
# 파일 디스크립터 한도
ulimit -n 100000
# XFS 파일시스템 + noatime
mount -o noatime /dev/sdb /var/kafka-logs
8.6 Kafka 모니터링
주요 JMX 메트릭:
| 메트릭 | 설명 | 알림 기준 |
|---|---|---|
UnderReplicatedPartitions |
ISR이 부족한 파티션 수 | > 0이면 경고 |
ActiveControllerCount |
활성 Controller 수 | != 1이면 위험 |
ISRShrinkRate |
ISR 축소 빈도 | 지속 증가 시 경고 |
MessagesInPerSec |
초당 수신 메시지 수 | 추세 모니터링 |
BytesInPerSec / BytesOutPerSec |
초당 입출력 바이트 | 대역폭 한계 모니터링 |
RequestHandlerAvgIdlePercent |
요청 핸들러 유휴 비율 | < 0.3이면 경고 |
Prometheus + Grafana 구성:
1
2
3
4
5
6
7
8
9
# JMX Exporter 설정 (jmx_exporter.yml)
lowercaseOutputName: true
rules:
- pattern: kafka.server<type=BrokerTopicMetrics, name=(MessagesInPerSec|BytesInPerSec|BytesOutPerSec)><>OneMinuteRate
name: kafka_server_brokertopicmetrics_$1
type: GAUGE
- pattern: kafka.server<type=ReplicaManager, name=(UnderReplicatedPartitions|IsrShrinksPerSec|IsrExpandsPerSec)><>Value
name: kafka_server_replicamanager_$1
type: GAUGE
9. Kafka 에코시스템
9.1 Kafka Connect
1
2
3
4
5
6
7
8
9
10
11
★ Kafka Connect 아키텍처
Source Systems Kafka Connect Sink Systems
┌──────────┐ ┌──────────────┐ ┌──────────┐
│ MySQL │──Source──────▶│ │──Sink─────▶│ ES │
│ (CDC) │ Connector │ Kafka │ Connector │ │
└──────────┘ │ Cluster │ └──────────┘
┌──────────┐ │ │ ┌──────────┐
│ MongoDB │──Source──────▶│ │──Sink─────▶│ S3 │
└──────────┘ Connector │ │ Connector └──────────┘
└──────────────┘
주요 Connector:
- Debezium (Source): MySQL, PostgreSQL, MongoDB CDC
- JDBC Connector (Source/Sink): 범용 RDBMS 연결
- Elasticsearch Sink: 검색 인덱싱
- S3 Sink: 데이터 레이크 적재
9.2 Kafka Streams vs Apache Flink
| 비교 항목 | Kafka Streams | Apache Flink |
|---|---|---|
| 배포 방식 | 라이브러리 (별도 클러스터 불필요) | 독립 클러스터 |
| 입력 소스 | Kafka만 가능 | Kafka, 파일, DB, 소켓 등 |
| 상태 관리 | RocksDB (로컬) | RocksDB + 체크포인트 |
| Exactly-once | 네이티브 지원 | 체크포인트 기반 |
| Windowing | Tumbling, Hopping, Sliding, Session | 전부 + Custom |
| 적합한 경우 | 단순~중간 복잡도 스트림 처리 | 복잡한 CEP, 대규모 스트림 |
| 운영 부담 | 낮음 (Spring Boot에 포함) | 높음 (전용 클러스터 운영) |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Kafka Streams 예제 - Spring Boot
@Configuration
@EnableKafkaStreams
public class KafkaStreamsConfig {
@Bean
public KStream<String, String> orderStream(StreamsBuilder builder) {
KStream<String, String> orders = builder.stream("orders");
orders
.groupBy((key, value) -> extractCategory(value))
.windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofMinutes(1)))
.count(Materialized.as("category-count"))
.toStream()
.to("category-counts-per-minute");
return orders;
}
}
9.3 Schema Registry와 직렬화
1
2
3
4
5
Producer ─(스키마 등록)───▶ Schema Registry ◀─(스키마 조회)── Consumer
│ (REST API) │
│ 메시지 = [MagicByte][SchemaID][Data] │
▼ ▼
Kafka Broker 역직렬화
Avro vs Protobuf vs JSON Schema:
| 항목 | Avro | Protobuf | JSON Schema |
|---|---|---|---|
| 직렬화 형식 | 바이너리 | 바이너리 | JSON (텍스트) |
| 크기 효율 | 매우 좋음 | 매우 좋음 | 나쁨 |
| 스키마 진화 | 우수 | 우수 | 보통 |
| 코드 생성 | 선택적 | 필수 | 불필요 |
| Kafka 생태계 지원 | ★ 가장 성숙 | 성숙 | 기본적 |
호환성 타입:
| 호환성 | 허용 변경 | 배포 순서 |
|---|---|---|
| BACKWARD (기본) | 기본값 있는 필드 추가, 필드 삭제 | Consumer 먼저 |
| FORWARD | 필드 삭제, 기본값 있는 필드 추가 | Producer 먼저 |
| FULL | 기본값 있는 필드 추가/삭제 | 순서 무관 |
9.4 ksqlDB 개요
SQL 문법만으로 Kafka 스트림 처리를 할 수 있는 도구이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-- 실시간 필터링
CREATE STREAM high_value_orders AS
SELECT * FROM orders_stream WHERE amount > 100000
EMIT CHANGES;
-- 윈도우 집계 (1분 단위 상품별 매출)
CREATE TABLE sales_per_minute AS
SELECT product, COUNT(*) AS order_count, SUM(amount) AS total_amount
FROM orders_stream
WINDOW TUMBLING (SIZE 1 MINUTE)
GROUP BY product
EMIT CHANGES;
-- Stream-Table 조인
CREATE STREAM enriched_orders AS
SELECT o.order_id, o.amount, u.name AS user_name, u.tier AS user_tier
FROM orders_stream o
LEFT JOIN user_profiles u ON o.user_id = u.user_id
EMIT CHANGES;
10. 장애 시나리오 & 트러블슈팅
10.1 브로커 장애 시 리더 선출 과정
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
[정상 상태]
Broker 1 (Controller) Broker 2 Broker 3
┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐
│ P0: Leader │ │ P0: Follower │ │ P0: Follower │
│ P1: Follower │ │ P1: Leader │ │ P1: Follower │
│ P2: Follower │ │ P2: Follower │ │ P2: Leader │
└────────────────────┘ └────────────────────┘ └────────────────────┘
Broker 2 다운!
[1] Controller가 장애 감지 (ZK session timeout / KRaft heartbeat)
[2] Broker 2가 Leader인 P1의 ISR에서 새 Leader 선출 (Broker 1)
[3] LeaderAndIsr 요청으로 모든 브로커에 전파
[4] 클라이언트 메타데이터 갱신 → 새 Leader로 요청 전환
[복구 후]
Broker 1 (Controller) Broker 2 (DOWN) Broker 3
┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐
│ P0: Leader │ │ OFFLINE │ │ P0: Follower │
│ P1: Leader ← 새 │ │ │ │ P1: Follower │
│ P2: Follower │ │ │ │ P2: Leader │
└────────────────────┘ └────────────────────┘ └────────────────────┘
10.2 Consumer Rebalancing Storm 해결
원인: 느린 Consumer, 잦은 배포, session.timeout.ms 너무 짧음
해결 방법:
1) Static Group Membership
1
2
3
props.put(ConsumerConfig.GROUP_INSTANCE_ID_CONFIG,
"order-consumer-" + hostname);
props.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, 300000); // 5분
Consumer가 잠시 끊겨도 session.timeout.ms 이내에 돌아오면 리밸런싱 없이 기존 파티션 할당을 유지한다.
2) CooperativeStickyAssignor 사용
변경이 필요한 파티션만 재할당하여 중단을 최소화한다.
3) 타임아웃 적절히 조정
1
2
3
4
heartbeat.interval.ms=3000
session.timeout.ms=45000
max.poll.interval.ms=600000
max.poll.records=100
실무 팁: Consumer 배포 시
group.instance.id를 설정하고session.timeout.ms를 배포 시간보다 길게 잡으면 리밸런싱 없이 무중단 배포가 가능하다.
10.3 메시지 유실 시나리오와 방지
시나리오 1: acks=1 + Leader 장애
Leader에만 기록 후 ACK → Follower 복제 전 Leader 다운 → 유실
시나리오 2: Auto Commit + Consumer 장애
자동 커밋 후 처리 완료 전 크래시 → 미처리 메시지 건너뜀
시나리오 3: unclean.leader.election.enable=true
ISR 모두 다운 시 비동기 Follower가 Leader → 미복제 메시지 유실
★ 메시지 유실 방지 체크리스트:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
Producer:
☑ acks=all
☑ enable.idempotence=true
☑ max.in.flight.requests.per.connection=5 이하
Broker:
☑ min.insync.replicas=2 (replication.factor=3)
☑ unclean.leader.election.enable=false
☑ default.replication.factor=3
Consumer:
☑ enable.auto.commit=false
☑ 처리 완료 후 수동 커밋
☑ 적절한 에러 핸들링 + DLQ 구성
10.4 메시지 중복 처리 패턴
Idempotent Consumer 구현:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Service
public class IdempotentOrderProcessor {
@Transactional
@KafkaListener(topics = "orders", groupId = "order-processor")
public void processOrder(ConsumerRecord<String, String> record,
Acknowledgment ack) {
String messageId = String.format("%s-%d-%d",
record.topic(), record.partition(), record.offset());
if (processedMessageRepository.existsById(messageId)) {
log.info("이미 처리된 메시지, 건너뜀: {}", messageId);
ack.acknowledge();
return;
}
Order order = parseOrder(record.value());
orderRepository.save(order);
processedMessageRepository.save(new ProcessedMessage(
messageId, record.topic(), record.partition(),
record.offset(), LocalDateTime.now()
));
ack.acknowledge();
}
}
실무 팁: 대부분의 실무에서는 “At-Least-Once + Idempotent Consumer” 조합이 가장 현실적인 선택이다.
10.5 파티션 핫스팟 해결
원인: 특정 Key에 메시지 집중
1
2
3
4
Partition 0: ████ (1,000 msg/s)
Partition 1: ███ (800 msg/s)
Partition 2: ████████████████████ (50,000 msg/s) ← 핫스팟!
Partition 3: ████ (1,200 msg/s)
해결 방법:
| 방법 | 순서 보장 | 설명 |
|---|---|---|
| Key 재설계 | 유지 가능 | 더 세분화된 Key (주문ID vs 고객사ID) |
| Custom Partitioner | 선택적 | 핫 Key만 분산 처리 |
| 솔트(Salt) 키 | 불가 | Key에 랜덤 접미사 추가 (가장 간단) |
| 파티션 수 증가 | 깨질 수 있음 | 기존 Key 매핑 변경됨 |
1
2
3
// 솔트 키 기법
String saltedKey = key + "-" + random.nextInt(10);
producer.send(new ProducerRecord<>(topic, saltedKey, value));
핫스팟 해결 전략 선택 가이드:
1
2
3
4
5
6
순서 보장 필요?
│
├─ YES → Key 재설계 또는 Custom Partitioner
│
└─ NO → 솔트 키 기법 (가장 간단하고 효과적)
또는 Key를 null로 설정 (라운드로빈)
실무 팁: 파티션별 메시지 분포를 주기적으로 모니터링하고, Grafana 대시보드에 파티션별 BytesInPerSec, MessagesInPerSec 메트릭을 포함시켜두면 핫스팟을 조기에 발견할 수 있다.
마무리
Kafka를 제대로 활용하기 위해서는 단순히 API 사용법을 아는 것을 넘어, 내부 아키텍처와 동작 원리를 이해해야 한다. 이 글에서 다룬 내용을 요약하면 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
★ Kafka 핵심 정리
[아키텍처]
- 분산 커밋 로그 기반의 이벤트 스트리밍 플랫폼
- Topic → Partition → Segment → Index 계층 구조
- KRaft 모드로 ZooKeeper 의존성 제거 (3.3+)
[Producer]
- RecordAccumulator + Sender Thread 비동기 아키텍처
- acks=all + idempotent producer로 안전한 전송
- Batching + Compression으로 처리량 극대화
[Consumer]
- Consumer Group 기반 병렬 처리
- CooperativeStickyAssignor로 리밸런싱 최소화
- Manual Commit으로 정확한 오프셋 관리
[고가용성]
- ISR 기반 Leader-Follower 복제
- replication.factor=3 + min.insync.replicas=2 + acks=all
[성능]
- Sequential I/O + Page Cache + Zero-Copy + Batching
[운영]
- Consumer Lag 모니터링이 핵심
- Prometheus + Grafana로 JMX 메트릭 수집
- 장애 시나리오별 대응 방안 숙지 필수
Kafka는 단순한 메시지 큐가 아니라, 현대 분산 시스템의 데이터 중추(Data Backbone) 역할을 하는 플랫폼이다. 이 가이드가 Kafka를 실무에서 안정적으로 운영하는 데 도움이 되기를 바란다.