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             │
└──────────────────────────────────────┘

.indexSparse 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)” 문제 해결 순서:

  1. 먼저 처리 로직을 프로파일링하여 병목 지점 파악
  2. 병목을 해결할 수 없다면 max.poll.records를 줄임
  3. 그래도 부족하면 pause/resume 패턴 적용
  4. 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의 두 축

  1. Idempotent Producer: 단일 파티션 내 중복 방지 (PID + Sequence Number)
  2. 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 증가 시 대응 전략:

  1. 파티션 수와 Consumer 수 동시 증가
  2. Consumer 처리 로직 최적화 (배치 처리, 비동기 I/O)
  3. 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: 데이터 레이크 적재
비교 항목 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를 실무에서 안정적으로 운영하는 데 도움이 되기를 바란다.