Apache Kafka란? Producer·Consumer 동작 원리와 토픽·파티션 개념 정리


초당 수백만 건의 이벤트를 처리하는 넷플릭스, 링크드인, 우버의 데이터 파이프라인 한가운데 Kafka가 있습니다. Apache Kafka의 Producer·Consumer 동작 원리를 이해하면 왜 Kafka가 단순한 메시지 큐를 넘어 “이벤트 스트리밍 플랫폼”으로 불리는지가 보입니다. 메시지가 어떻게 파티션에 쌓이고, 컨슈머 그룹이 어떻게 부하를 나누며, 오프셋이 왜 장애 복구의 핵심인지를 코드와 다이어그램으로 완전히 풀어드립니다. 2025년 3월 출시된 Apache Kafka 4.0은 10년 이상 중추 역할을 해온 ZooKeeper를 완전히 제거하고 자체 개발한 KRaft 모드를 기본으로 채택한 메이저 릴리즈로, 이 글에서 최신 아키텍처까지 함께 다룹니다. Velog


목차

  1. Apache Kafka란 무엇인가 — 탄생 배경과 핵심 개념
  2. Kafka 아키텍처 완전 해부: 브로커·토픽·파티션·오프셋
  3. Producer 동작 원리: 메시지가 브로커에 도달하기까지
  4. Consumer·컨슈머 그룹 동작 원리: 오프셋·리밸런싱·장애 복구
  5. 실전 Spring Kafka 코드와 핵심 설정 가이드
  6. 전문가 관점: KRaft 아키텍처·Kafka vs RabbitMQ·모니터링

1. Apache Kafka란 무엇인가 — 탄생 배경과 핵심 개념 {#1}

Apache Kafka의 Producer·Consumer 동작 원리를 이해하기 전에, Kafka가 어떤 문제를 해결하기 위해 만들어졌는지부터 살펴봅니다.

Kafka 탄생 배경: 링크드인의 데이터 파이프라인 문제

2011년 링크드인은 심각한 데이터 파이프라인 문제를 겪고 있었습니다. 수십 개의 서비스가 서로 직접 데이터를 주고받으면서 시스템이 복잡한 스파게티 구조가 된 것입니다.

[Kafka 이전: N×M 직접 연결의 지옥]

서비스A ──────► DB1
서비스A ──────► 검색엔진
서비스A ──────► 분석시스템
서비스B ──────► DB1
서비스B ──────► 검색엔진
서비스C ──────► 분석시스템
...
→ N개 서비스 × M개 수신처 = 수백 개 파이프라인 관리
→ 하나가 느려지면 전체가 영향받음
→ 스키마 변경 시 모든 연결 수정 필요

[Kafka 도입 후: 중앙 허브 구조]

서비스A ─┐
서비스B ─┼──► KAFKA ──┬──► DB1
서비스C ─┘    (허브)  ├──► 검색엔진
                      └──► 분석시스템
→ 모든 서비스가 Kafka만 바라봄
→ 생산자와 소비자가 완전히 분리(Decoupling)
→ 수신처 추가 시 기존 Producer 코드 수정 불필요

링크드인의 Jay Kreps, Neha Narkhede, Jun Rao가 개발해 2011년 오픈소스로 공개한 Kafka는 현재 Apache Software Foundation의 최상위 프로젝트로, Fortune 500 기업 80% 이상이 사용하는 사실상의 이벤트 스트리밍 표준이 되었습니다.

Kafka의 세 가지 핵심 역할

[Kafka가 동시에 수행하는 세 가지 역할]

① 메시지 브로커 (Message Broker)
   서비스 간 비동기 통신 허브
   예: 주문 서비스 → Kafka → 재고/결제/알림 서비스 동시 전달

② 이벤트 스토어 (Event Store)
   메시지를 소비 후에도 설정 기간(기본 7일) 동안 보존
   → 장애 복구, 데이터 재처리, 감사 로그에 활용

③ 스트림 처리 플랫폼 (Stream Processing)
   Kafka Streams / ksqlDB로 실시간 데이터 변환·집계
   예: 클릭 로그 실시간 집계 → 추천 엔진에 피드

Kafka vs 전통 메시지 큐 (RabbitMQ, ActiveMQ)

[핵심 차이점 비교]

┌──────────────────┬────────────────────┬──────────────────────┐
│       특성        │    Kafka           │  RabbitMQ / ActiveMQ │
├──────────────────┼────────────────────┼──────────────────────┤
│ 메시지 보존       │ 소비 후도 보존      │ 소비 후 즉시 삭제     │
│                  │ (설정 기간)         │                      │
│ 처리량            │ 초당 수백만 건      │ 초당 수만 건          │
│ 소비 방식         │ Pull (Consumer 주도)│ Push (Broker 주도)   │
│ 재처리            │ 오프셋 되감기로 가능│ 불가 (이미 삭제됨)   │
│ 순서 보장         │ 파티션 내 보장      │ 큐 단위 보장         │
│ 주요 용도         │ 이벤트 스트리밍    │ 작업 큐 (Task Queue) │
│                  │ 대용량 로그 수집    │ RPC 패턴             │
└──────────────────┴────────────────────┴──────────────────────┘

2. Kafka 아키텍처 완전 해부: 브로커·토픽·파티션·오프셋 {#2}

Apache Kafka의 Producer·Consumer 동작 원리를 이해하는 데 있어 아키텍처의 각 구성 요소를 명확히 아는 것이 첫 번째입니다.

전체 아키텍처 한눈에 보기

[Kafka 클러스터 전체 구조]

┌─────────────────────────────────────────────────────────────────┐
│                      Kafka Cluster                              │
│                                                                 │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │                    Broker 1 (Leader)                     │  │
│  │  Topic: orders                                            │  │
│  │  ┌──────────────────┐  ┌──────────────────┐             │  │
│  │  │   Partition 0    │  │   Partition 1    │             │  │
│  │  │ [0][1][2][3][4]  │  │ [0][1][2][3]    │             │  │
│  │  │  ↑ offset        │  │  ↑ offset        │             │  │
│  │  └──────────────────┘  └──────────────────┘             │  │
│  └──────────────────────────────────────────────────────────┘  │
│                                                                 │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │                   Broker 2 (Follower)                    │  │
│  │  Topic: orders (복제본, Replica)                          │  │
│  │  ┌──────────────────┐  ┌──────────────────┐             │  │
│  │  │  Partition 0 복제 │  │  Partition 1 복제│             │  │
│  │  └──────────────────┘  └──────────────────┘             │  │
│  └──────────────────────────────────────────────────────────┘  │
│                                                                 │
│  ┌──────────────────────────────────────────────────────────┐  │
│  │           KRaft Controller (Kafka 4.0 기본)              │  │
│  │   메타데이터 관리 (토픽·파티션·리더 정보)               │  │
│  │   [과거: ZooKeeper → 현재: KRaft 내장]                  │  │
│  └──────────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────────┘
         ↑  Producer                       Consumer Group ↓
    메시지 전송                           메시지 읽기 + 오프셋 커밋

브로커 (Broker)

브로커는 Kafka 서버 프로세스 하나를 의미합니다. 실제 메시지를 디스크에 저장하고 Producer의 쓰기 요청과 Consumer의 읽기 요청을 처리합니다. 보통 3개 이상의 브로커로 클러스터를 구성해 고가용성을 확보합니다.

Kafka가 일반 메시지 큐와 달리 엄청난 처리량을 낼 수 있는 비결이 바로 브로커의 저장 방식에 있습니다. Kafka는 메모리가 아닌 **순차적 디스크 쓰기(Sequential Disk Write)**를 사용합니다. 무작위 접근이 느린 HDD도 순차 쓰기는 메모리에 필적하는 속도를 냅니다. 여기에 Zero-Copy 기술(OS 커널이 직접 네트워크 소켓으로 데이터 전송)을 더해 CPU 오버헤드를 최소화합니다.

토픽 (Topic)과 파티션 (Partition)

토픽은 메시지를 분류하는 논리적 채널입니다. orders, user-events, payment-results 처럼 비즈니스 도메인별로 생성합니다. 하나의 토픽은 반드시 하나 이상의 파티션으로 구성됩니다.

[파티션이 병렬 처리의 핵심인 이유]

토픽: "orders" (파티션 3개 설정)

Partition 0: [주문#0][주문#1][주문#5][주문#8] → Consumer A가 처리
Partition 1: [주문#2][주문#4][주문#7]         → Consumer B가 처리
Partition 2: [주문#3][주문#6][주문#9]         → Consumer C가 처리

→ Consumer A, B, C가 동시에 병렬 처리
→ 처리량 병목 시 파티션 수 증가 → Consumer 수 증가로 수평 확장

파티션의 핵심 특성:
① 파티션 내 메시지는 순서 보장 (Partition 0 안에서)
② 파티션 간 순서는 보장 안 됨 (Partition 0 vs Partition 1)
③ 한 번 늘린 파티션은 줄일 수 없음 (신중히 설계 필요!)
④ 파티션 단위로 복제(Replication) 및 리더 선출

오프셋 (Offset)

오프셋은 파티션 내 각 메시지의 고유한 순번입니다. 0부터 시작해 1씩 증가하며, Consumer는 이 오프셋을 북마크처럼 사용해 “어디까지 읽었는지”를 추적합니다.

[오프셋의 동작 방식]

Partition 0의 메시지 구조:
┌───────┬───────┬───────┬───────┬───────┬───────┐
│ off=0 │ off=1 │ off=2 │ off=3 │ off=4 │ off=5 │
│ msg A │ msg B │ msg C │ msg D │ msg E │ msg F │
└───────┴───────┴───────┴───────┴───────┴───────┘
                           ↑
                    Consumer가 커밋한 오프셋 = 3
                    (msg A~D 처리 완료)
                    다음 poll() 시 offset=4부터 읽기 시작

오프셋의 강력한 기능 — 재처리(Replay):
카프카는 메시지를 소비해도 삭제하지 않음
→ 오프셋을 과거로 되감으면(seek) 동일 메시지 재처리 가능
→ 버그 수정 후 재처리, 새 Consumer 그룹의 과거 데이터 처리에 활용

복제 (Replication)와 리더-팔로워

[복제 팩터 3의 의미]

replication.factor=3 설정 시:
Partition 0 Leader   → Broker 1 (쓰기·읽기 담당)
Partition 0 Follower → Broker 2 (리더 복제본 유지)
Partition 0 Follower → Broker 3 (리더 복제본 유지)

Broker 1 장애 시:
→ KRaft 컨트롤러가 감지
→ Broker 2 또는 3을 새 Leader로 선출 (수 초 이내)
→ Producer/Consumer는 새 Leader에 자동 연결
→ 데이터 손실 없이 서비스 지속

3. Producer 동작 원리: 메시지가 브로커에 도달하기까지 {#3}

이제 Apache Kafka의 Producer 동작 원리를 메시지가 생성되는 순간부터 브로커 디스크에 기록되기까지 단계별로 추적합니다.

Producer 내부 처리 흐름

[Producer 내부 동작 전체 흐름]

애플리케이션 코드
    │ producer.send(record)
    ▼
┌──────────────────────────────────────────────┐
│              Producer 내부                   │
│                                              │
│  1. Serializer (직렬화)                      │
│     Java 객체 → byte[]                       │
│     key: String → bytes                      │
│     value: Order 객체 → JSON bytes           │
│                  │                           │
│  2. Partitioner (파티션 결정)                │
│     ├─ partition 명시 → 그 파티션으로        │
│     ├─ key 있음 → hash(key) % 파티션수       │
│     └─ key 없음 → Sticky Partitioning        │
│                  │                           │
│  3. RecordAccumulator (배치 버퍼)            │
│     파티션별로 메시지 모아두기                │
│     ┌──────────┐ ┌──────────┐               │
│     │ P0 배치  │ │ P1 배치  │               │
│     │[m1][m2]  │ │[m3]      │               │
│     └────┬─────┘ └────┬─────┘               │
│          │             │                     │
│  4. Sender (네트워크 전송 스레드)            │
│     batch.size 초과 OR linger.ms 경과 시     │
│     → 브로커로 배치 전송                     │
└──────────────────┬───────────────────────────┘
                   │
                   ▼
              Kafka Broker
              (Partition Leader)

파티션 결정 전략 상세

java

// 파티션 결정 세 가지 경우

// 케이스 1: 파티션 직접 지정 (특수한 경우에만)
ProducerRecord<String, String> record =
    new ProducerRecord<>("orders", 0, "key", "value"); // partition=0 명시

// 케이스 2: Key 기반 파티션 (같은 key → 항상 같은 파티션)
ProducerRecord<String, String> record =
    new ProducerRecord<>("orders", "user-12345", orderJson);
// "user-12345"의 hash값으로 파티션 결정
// → user-12345의 모든 주문이 같은 파티션에 순서대로 저장
// → 특정 사용자의 이벤트 순서 보장에 활용

// 케이스 3: Key 없음 → Sticky Partitioning (Kafka 2.4+)
ProducerRecord<String, String> record =
    new ProducerRecord<>("orders", null, orderJson);
// 배치가 채워질 때까지 한 파티션에 집중 → 배치 효율 극대화
// 배치 전송 후 다음 파티션으로 이동 (라운드 로빈과 다름)

acks 설정: 신뢰성과 성능의 트레이드오프

acks는 Producer가 브로커로부터 쓰기 성공 확인을 얼마나 기다릴지 결정하는 핵심 설정입니다.

[acks 설정별 동작 비교]

acks=0 (Fire and Forget)
Producer ──► Broker [응답 안 기다림]
✅ 가장 빠름
❌ 메시지 유실 가능 (브로커 장애 시)
용도: 로그 수집 등 일부 손실 허용 가능한 경우

acks=1 (Leader만 확인)
Producer ──► Leader Broker ──► ACK 응답
                │
            팔로워 복제는 백그라운드
✅ 빠름
❌ Leader가 팔로워 복제 전 장애 시 메시지 유실 가능
용도: 일반적인 서비스

acks=all (또는 acks=-1, 모든 ISR 확인) ← 권장
Producer ──► Leader ──► Follower1 복제 완료
                    ──► Follower2 복제 완료
                    ──► ACK 응답
✅ 데이터 유실 없음
❌ 가장 느림 (복제 완료까지 대기)
용도: 결제, 주문 등 데이터 유실이 허용되지 않는 경우

권장 조합 (금융/결제 수준 안정성):
acks=all + min.insync.replicas=2 + enable.idempotence=true

Producer 배치 전송과 성능 튜닝

java

// Producer 핵심 설정값
Properties props = new Properties();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "kafka:9092");
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG,
          StringSerializer.class.getName());
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG,
          StringSerializer.class.getName());

// 신뢰성 설정
props.put(ProducerConfig.ACKS_CONFIG, "all");          // 모든 ISR 확인
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true); // 중복 전송 방지
props.put(ProducerConfig.RETRIES_CONFIG, Integer.MAX_VALUE); // 재시도 무제한

// 배치 성능 설정
props.put(ProducerConfig.BATCH_SIZE_CONFIG, 16384);    // 16KB 배치 크기
props.put(ProducerConfig.LINGER_MS_CONFIG, 10);        // 10ms 대기 후 전송
// → 10ms 동안 메시지를 모아 한번에 전송 → 처리량 향상
props.put(ProducerConfig.BUFFER_MEMORY_CONFIG, 33554432); // 32MB 버퍼

// 압축 (처리량↑, 저장공간↓, CPU↑)
props.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "snappy"); // snappy/gzip/lz4/zstd

KafkaProducer<String, String> producer = new KafkaProducer<>(props);

비동기 전송과 콜백 처리

java

// ❌ 동기 전송 — 처리량 매우 낮음
try {
    RecordMetadata metadata = producer.send(record).get(); // 완료까지 블록
    System.out.println("전송 완료: partition=" + metadata.partition()
                     + " offset=" + metadata.offset());
} catch (Exception e) {
    log.error("전송 실패", e);
}

// ✅ 비동기 전송 + 콜백 — 권장 방식
producer.send(record, (metadata, exception) -> {
    if (exception != null) {
        // 전송 실패 처리 (재시도, DLQ 전송, 알람 등)
        log.error("메시지 전송 실패: topic={}, key={}",
                  record.topic(), record.key(), exception);
        deadLetterQueueService.send(record); // DLQ로 이동
    } else {
        // 전송 성공 로그 (필요 시)
        log.debug("전송 성공: partition={}, offset={}",
                  metadata.partition(), metadata.offset());
    }
});

// 중요: 애플리케이션 종료 시 반드시 flush + close
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
    producer.flush();  // 버퍼에 남은 메시지 모두 전송
    producer.close();  // 연결 종료
}));

4. Consumer·컨슈머 그룹 동작 원리: 오프셋·리밸런싱·장애 복구 {#4}

Apache Kafka의 Consumer 동작 원리는 Producer보다 복잡합니다. 오프셋 관리와 컨슈머 그룹의 리밸런싱 메커니즘이 Kafka의 신뢰성과 확장성의 핵심이기 때문입니다.

Consumer와 컨슈머 그룹의 관계

[컨슈머 그룹과 파티션 할당 규칙]

토픽: "orders" (파티션 3개)

케이스 1: Consumer 수 < 파티션 수
Consumer Group A (2개 Consumer):
├── Consumer-1: Partition 0, Partition 1 담당 (2개 처리)
└── Consumer-2: Partition 2 담당 (1개 처리)
→ Consumer-1에 부하 집중 (불균형)

케이스 2: Consumer 수 = 파티션 수 ← 최적
Consumer Group A (3개 Consumer):
├── Consumer-1: Partition 0 담당
├── Consumer-2: Partition 1 담당
└── Consumer-3: Partition 2 담당
→ 완벽한 부하 분산

케이스 3: Consumer 수 > 파티션 수
Consumer Group A (4개 Consumer):
├── Consumer-1: Partition 0 담당
├── Consumer-2: Partition 1 담당
├── Consumer-3: Partition 2 담당
└── Consumer-4: 대기 (파티션 없음, 유휴 상태)
→ 파티션 수가 병렬성의 상한선

핵심 규칙: 파티션 하나는 동일 그룹 내 Consumer 하나에만 할당
→ 파티션 내 순서 처리 보장을 위한 설계

복수 컨슈머 그룹: 동일 메시지를 여러 서비스가 독립 소비

[컨슈머 그룹의 독립성]

Topic: "orders" (파티션 3개)
│
├── Consumer Group: order-service
│   (주문 처리 서비스: 재고 차감)
│   Partition 0, 1, 2 각각 독립 오프셋 관리
│
├── Consumer Group: notification-service
│   (알림 서비스: 이메일·SMS 발송)
│   Partition 0, 1, 2 독립 오프셋 관리
│
└── Consumer Group: analytics-service
    (분석 서비스: 통계 집계)
    Partition 0, 1, 2 독립 오프셋 관리

→ 동일한 메시지를 3개 서비스가 각자 독립적으로 소비
→ 한 서비스의 장애가 다른 서비스의 소비에 영향 없음
→ 새 서비스 추가 시 과거 데이터도 처음부터 처리 가능

오프셋 커밋: 자동 vs 수동

기본 옵션은 poll() 메서드가 수행될 때 일정 간격마다 오프셋을 커밋하도록 enable.auto.commit=true로 설정되어 있으며, 이를 비명시 오프셋 커밋이라 합니다. poll() 메서드 호출 뒤 리밸런싱 또는 컨슈머 강제종료 발생 시 컨슈머가 처리하는 데이터가 중복 또는 유실될 수 있는 가능성이 있습니다. Velog

java

// ❌ 자동 커밋 (기본값) — 중복/유실 가능성 있음
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, true);
props.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, 5000); // 5초마다 자동 커밋

// 위험 시나리오:
// poll() → 메시지 수신 → [자동 커밋] → 처리 중 크래시
// → 재시작 시 커밋된 오프셋부터 읽기 → 처리 못한 메시지 유실!

// ✅ 수동 커밋 — 처리 완료 후 명시적 커밋 (at-least-once 보장)
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);

KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(List.of("orders"));

while (true) {
    ConsumerRecords<String, String> records =
        consumer.poll(Duration.ofMillis(1000));

    for (ConsumerRecord<String, String> record : records) {
        try {
            // 비즈니스 로직 처리
            orderService.process(record.value());
        } catch (Exception e) {
            log.error("처리 실패: offset={}", record.offset(), e);
            // 실패 처리: 재시도, DLQ 전송 등
        }
    }

    // 모든 레코드 처리 완료 후 커밋
    try {
        consumer.commitSync(); // 동기 커밋 (느리지만 확실)
        // 또는 consumer.commitAsync(); // 비동기 커밋 (빠르지만 실패 시 재시도 없음)
    } catch (CommitFailedException e) {
        log.error("오프셋 커밋 실패", e);
    }
}

메시지 전달 보장 수준 (Delivery Semantics)

[세 가지 전달 보장 수준]

At-Most-Once (최대 한 번):
- 오프셋을 먼저 커밋 → 처리
- 처리 전 크래시 시 해당 메시지 유실
- 빠르지만 데이터 유실 가능
- 용도: 클릭 로그 등 일부 손실 허용 가능

At-Least-Once (최소 한 번): ← 기본적으로 지향
- 처리 완료 후 오프셋 커밋
- 크래시 후 재시작 시 재처리 → 중복 처리 가능
- 멱등성(Idempotent) 처리 로직 필요
- 용도: 대부분의 서비스 (중복 처리 허용 가능한 경우)

Exactly-Once (정확히 한 번): ← 가장 강력, 가장 복잡
- Kafka Transactions + enable.idempotence=true 조합
- 프로듀서 중복 전송 방지 + 소비-처리-커밋 원자적 수행
- 성능 오버헤드 발생
- 용도: 결제, 재고, 금융 거래

리밸런싱: Consumer 추가·제거 시 파티션 재할당

리밸런싱이란 특정 토픽을 구독하던 컨슈머 그룹에 변동이 있는 경우에 해당 그룹 안의 파티션을 재분배하는 행위로, 여러 상황에서 발생할 수 있습니다. Sk

[리밸런싱 발생 시나리오와 영향]

리밸런싱 발생 원인:
① Consumer 추가 (Scale Out)
② Consumer 제거 또는 장애 (heartbeat 타임아웃)
③ 파티션 수 변경
④ 구독 토픽 변경

EAGER 리밸런싱 (기존 방식):
1. 전체 Consumer가 파티션 구독 중단 (Stop-the-World!)
2. 파티션 재분배 완료까지 소비 중단
3. 새 파티션 할당 후 재시작
→ 리밸런싱 동안 Consumer Lag 급증 가능

COOPERATIVE (Incremental) 리밸런싱 (Kafka 3.1+, 권장):
1. 재할당 필요한 파티션만 잠깐 중단
2. 나머지 파티션은 계속 소비 진행
3. 영향 최소화 (점진적 재분배)
→ Kafka 4.0에서 기본값으로 채택

# 리밸런싱 방지를 위한 핵심 설정
props.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, 45000);    // 45초
props.put(ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG, 15000); // 15초
props.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, 300000); // 5분
# MAX_POLL_INTERVAL_MS: 처리 시간이 이보다 길면 Consumer가 그룹에서 제외
# → 처리가 오래 걸리는 경우 이 값을 충분히 크게 설정

Consumer 장애 복구 시나리오

Consumer Group 내부의 Consumer에서 장애가 발생할 경우, Consumer Group 내부에서 리밸런싱 동작을 통해 동일 Group의 다른 Consumer가 그 역할을 대신 수행하므로, 굳이 장애 대비를 위한 추가 Consumer 리소스를 할당하지 않아도 됩니다. Velog

[Consumer 장애 시 자동 복구 흐름]

정상 상태:
Consumer Group (3 Consumer, Partition 3개)
├── Consumer-1: Partition 0 (오프셋 커밋: 150)
├── Consumer-2: Partition 1 (오프셋 커밋: 200)
└── Consumer-3: Partition 2 (오프셋 커밋: 180)

Consumer-1 장애 발생:
1. Heartbeat 타임아웃 (session.timeout.ms 경과)
2. Group Coordinator가 리밸런싱 트리거
3. 파티션 재분배:
   ├── Consumer-2: Partition 0 + Partition 1 (추가 담당)
   └── Consumer-3: Partition 2 (기존 유지)
4. Consumer-2가 Partition 0의 마지막 커밋 오프셋(150) 이후부터 재처리
   → 메시지 유실 없음 (At-Least-Once 보장)

5. 실전 Spring Kafka 코드와 핵심 설정 가이드 {#5}

지금까지의 원리를 실제 Spring Boot 프로젝트에 적용하는 완성된 코드를 살펴봅니다.

의존성 및 기본 설정

yaml

# build.gradle
dependencies {
    implementation 'org.springframework.kafka:spring-kafka'
    implementation 'org.springframework.boot:spring-boot-starter'
}

# application.yml
spring:
  kafka:
    bootstrap-servers: localhost:9092

    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
      acks: all
      properties:
        enable.idempotence: true
        max.in.flight.requests.per.connection: 5
        retries: 2147483647
        linger.ms: 10
        batch.size: 16384
        compression.type: snappy

    consumer:
      group-id: order-service
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
      auto-offset-reset: earliest  # 처음 구독 시 가장 오래된 메시지부터 읽기
      enable-auto-commit: false     # 수동 커밋 (at-least-once)
      properties:
        spring.json.trusted.packages: "com.example.dto"
        max.poll.records: 500       # 한 번에 최대 500개 레코드 처리
        max.poll.interval.ms: 300000

Producer 구현

java

@Service
@Slf4j
@RequiredArgsConstructor
public class OrderEventProducer {

    private final KafkaTemplate<String, OrderEvent> kafkaTemplate;

    private static final String TOPIC = "orders";

    public void sendOrderCreated(Order order) {
        OrderEvent event = OrderEvent.builder()
            .orderId(order.getId().toString())
            .userId(order.getUserId().toString())
            .amount(order.getAmount())
            .status("CREATED")
            .occurredAt(Instant.now())
            .build();

        // 키: userId → 같은 사용자의 이벤트가 같은 파티션에 순서대로 저장
        CompletableFuture<SendResult<String, OrderEvent>> future =
            kafkaTemplate.send(TOPIC, event.getUserId(), event);

        future.whenComplete((result, ex) -> {
            if (ex != null) {
                log.error("주문 이벤트 전송 실패: orderId={}",
                          event.getOrderId(), ex);
                // 알림, DLQ 처리, 재시도 로직 등
            } else {
                RecordMetadata metadata = result.getRecordMetadata();
                log.info("주문 이벤트 전송 성공: orderId={}, partition={}, offset={}",
                         event.getOrderId(),
                         metadata.partition(),
                         metadata.offset());
            }
        });
    }
}

Consumer 구현 (수동 커밋 + 에러 핸들링)

java

@Component
@Slf4j
@RequiredArgsConstructor
public class OrderEventConsumer {

    private final OrderService orderService;
    private final KafkaTemplate<String, OrderEvent> kafkaTemplate;

    @KafkaListener(
        topics = "orders",
        groupId = "order-service",
        concurrency = "3",  // 스레드 3개 = 파티션 3개에 병렬 처리
        containerFactory = "kafkaListenerContainerFactory"
    )
    public void consume(
            @Payload OrderEvent event,
            @Header(KafkaHeaders.RECEIVED_PARTITION) int partition,
            @Header(KafkaHeaders.OFFSET) long offset,
            Acknowledgment acknowledgment) {  // 수동 ACK

        log.info("메시지 수신: partition={}, offset={}, orderId={}",
                 partition, offset, event.getOrderId());

        try {
            // 비즈니스 로직 처리
            orderService.processOrderCreated(event);

            // 처리 성공 시 오프셋 커밋
            acknowledgment.acknowledge();

        } catch (RetryableException e) {
            // 일시적 오류 (DB 연결 문제 등) → 재시도
            log.warn("일시적 오류, 재시도 예정: orderId={}", event.getOrderId(), e);
            throw e; // Spring Kafka의 RetryTemplate이 재시도

        } catch (Exception e) {
            // 비즈니스 오류 (데이터 문제 등) → DLQ 전송
            log.error("처리 실패, DLQ 전송: orderId={}", event.getOrderId(), e);
            kafkaTemplate.send("orders.DLT", event.getUserId(), event);
            acknowledgment.acknowledge(); // DLQ 전송 후 오프셋 커밋 (무한 루프 방지)
        }
    }
}

// Consumer 설정 (에러 핸들링 + 재시도)
@Configuration
public class KafkaConsumerConfig {

    @Bean
    public ConcurrentKafkaListenerContainerFactory<String, OrderEvent>
            kafkaListenerContainerFactory(ConsumerFactory<String, OrderEvent> cf) {

        ConcurrentKafkaListenerContainerFactory<String, OrderEvent> factory =
            new ConcurrentKafkaListenerContainerFactory<>();
        factory.setConsumerFactory(cf);

        // 수동 ACK 모드
        factory.getContainerProperties()
               .setAckMode(ContainerProperties.AckMode.MANUAL_IMMEDIATE);

        // 재시도 설정: 3번 재시도, 2초 간격으로 지수 백오프
        factory.setCommonErrorHandler(new DefaultErrorHandler(
            new DeadLetterPublishingRecoverer(kafkaTemplate), // 최종 실패 시 DLT로
            new FixedBackOff(2000L, 3L) // 2초 간격, 최대 3번
        ));

        return factory;
    }
}

Consumer Lag 모니터링

bash

# Kafka Consumer Lag 확인 (중요 운영 지표)
kafka-consumer-groups.sh \
  --bootstrap-server localhost:9092 \
  --describe \
  --group order-service

# 출력 예시:
# GROUP          TOPIC   PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG
# order-service  orders  0          1500            1502            2     ← 거의 실시간
# order-service  orders  1          1200            1350            150   ← 처리 지연!
# order-service  orders  2          980             981             1

# LAG = LOG-END-OFFSET - CURRENT-OFFSET
# LAG이 지속적으로 증가하면 Consumer 처리 속도가 Producer보다 느림
# → Consumer 수 증가 또는 처리 로직 최적화 필요

6. 전문가 관점: KRaft 아키텍처·Kafka vs RabbitMQ·모니터링 {#6}

Apache Kafka의 Producer·Consumer 동작 원리를 완전히 이해했다면, 이제 최신 트렌드와 운영 관점을 살펴봅니다.

Kafka 4.0의 KRaft: ZooKeeper 없는 세상

Apache Kafka 4.0은 2025년 3월에 출시된 메이저 릴리즈로, 10년 이상 Kafka의 중추 역할을 해온 Apache ZooKeeper를 완전히 제거하고 자체 개발한 KRaft 모드를 기본으로 채택했습니다. Velog

[ZooKeeper 모드 vs KRaft 모드 비교]

ZooKeeper 모드 (구방식):
┌──────────────────┐    ┌───────────────────────┐
│  Kafka Cluster   │    │  ZooKeeper Ensemble   │
│ ┌────┐ ┌────┐   │◄──►│  ┌────┐ ┌────┐ ┌────┐│
│ │Bkr1│ │Bkr2│   │    │  │ZK1 │ │ZK2 │ │ZK3 ││
│ └────┘ └────┘   │    │  └────┘ └────┘ └────┘│
└──────────────────┘    └───────────────────────┘
문제점: 별도 ZooKeeper 앙상블 운영 필요
        운영 복잡성, 파티션 수 제한 (~200K), 느린 컨트롤러 장애 복구

KRaft 모드 (Kafka 4.0 기본):
┌──────────────────────────────────────────────┐
│               Kafka Cluster                  │
│  ┌─────────────────────────────────────────┐ │
│  │      KRaft Controller (3~5대)           │ │
│  │  메타데이터를 Kafka 내부 토픽으로 관리  │ │
│  │  Raft 합의 알고리즘으로 리더 선출       │ │
│  └─────────────────────────────────────────┘ │
│  ┌────┐ ┌────┐ ┌────┐                        │
│  │Bkr1│ │Bkr2│ │Bkr3│  (Broker 역할만 수행) │
│  └────┘ └────┘ └────┘                        │
└──────────────────────────────────────────────┘
장점:
✅ ZooKeeper 별도 운영 불필요 → 운영 단순화
✅ 파티션 수 제한 완화 (수백만 파티션 지원)
✅ 컨트롤러 장애 복구 시간 단축
   (ZooKeeper 30초+ → KRaft 수 초 이내)

KRaft 모드에서의 컨트롤러는 메모리 내에 메타데이터 캐시를 유지하고 있으며, ZooKeeper와의 의존성도 제거해 내부적으로 메타데이터의 동기화와 관리 과정을 효율적으로 개선했습니다. 또한 액티브 컨트롤러 장애 시 최신 메타데이터가 메모리에 유지되고 있으므로 메타데이터 복제하는 시간도 줄어들어 보다 효율적인 컨트롤러 리더 선출 작업이 이루어집니다. Sk

Kafka 4.0 신기능: Queue 모드

Kafka 4.0은 새로운 기능, 개선 사항, 지원 종료 및 중요한 업데이트를 포함하고 있으며, KRaft 기반 아키텍처, 진일보한 컨슈머 리밸런스 프로토콜, 큐 개념 도입이 대표적인 세 가지 변화입니다. RosettaLens

[Kafka 4.0 Queue 모드: 새로운 소비 방식]

기존 방식 (Partition-based):
- Consumer Group의 Consumer들이 파티션을 나눠 가짐
- Consumer A: Partition 0 전담 → 파티션 수만큼만 병렬 처리

신규 Queue 모드 (4.0+):
- 메시지를 특정 파티션 아닌 Consumer 풀로 자유롭게 분배
- Consumer A가 바빠도 Consumer B가 즉시 처리 가능
- RabbitMQ의 Work Queue 패턴과 유사
- 기존 파티션 기반 방식과 공존 (선택 적용 가능)

활용 케이스:
→ 처리 시간이 불균일한 작업 (일부는 1ms, 일부는 5초)
→ 특정 Consumer 과부하 없이 고르게 분산하고 싶을 때

Kafka 운영 핵심 지표 모니터링

yaml

# Prometheus + Grafana Kafka 모니터링 핵심 지표

# 1. Consumer Lag (가장 중요)
kafka_consumer_group_lag
→ 0에 가까울수록 실시간 처리 중
→ 지속적 증가 = 처리 속도 < 생산 속도 → Consumer 확장 필요

# 2. Producer 전송 실패율
kafka_producer_record_error_rate
→ 0이어야 정상
→ 증가 시 acks 설정, 네트워크 상태, 브로커 상태 점검

# 3. 브로커 디스크 사용량
kafka_log_size
→ retention.bytes, retention.ms 설정으로 관리
→ 디스크 풀 → 브로커 다운 위험

# 4. Under-Replicated Partitions (복제 지연)
kafka_server_replica_manager_under_replicated_partitions
→ 0이어야 정상
→ 증가 시 브로커 장애 또는 네트워크 문제 징후

# 5. Request 처리 레이턴시
kafka_network_request_total_time_ms (p99)
→ 정상: 수 ms ~ 수십 ms
→ 급증 시 브로커 과부하 또는 GC 압박

Kafka 사용 적합한 경우 vs 아닌 경우

[Kafka 도입 판단 기준]

✅ Kafka가 적합한 경우:
- 초당 수만~수백만 건의 이벤트 처리
- 이벤트 소싱(Event Sourcing) 아키텍처
- 여러 서비스가 동일 이벤트를 독립적으로 소비
- 실시간 데이터 파이프라인 (로그 수집, 클릭 스트림)
- 장애 후 특정 시점부터 재처리가 필요한 경우
- MSA 환경에서 서비스 간 비동기 통신

❌ Kafka가 부적합한 경우:
- 요청-응답 패턴 (단순 RPC) → REST API, gRPC 사용
- 소량의 메시지 (초당 수십 건 미만) → Redis Pub/Sub, RabbitMQ 가볍게 사용
- 메시지 처리 즉시 삭제 필요 → RabbitMQ Work Queue 패턴
- 팀이 Kafka 운영 경험 없고 서비스 초기 단계 → 단순함 우선
- 복잡한 라우팅 규칙 필요 → RabbitMQ의 Exchange/Binding 활용

결론

Apache Kafka의 Producer·Consumer 동작 원리는 세 가지 핵심으로 요약됩니다. 첫째, Producer는 메시지를 Serializer→Partitioner→배치 버퍼를 거쳐 브로커에 전달하며, acks 설정으로 신뢰성과 성능을 조율합니다. 둘째, Consumer는 파티션에서 Pull 방식으로 메시지를 읽고 오프셋을 커밋해 “어디까지 처리했는지”를 추적하며, 컨슈머 그룹이 파티션을 나눠가져 수평 확장을 실현합니다. 셋째, 파티션 수가 병렬 처리의 상한선이며 Consumer 수는 파티션 수를 초과해도 의미가 없습니다. 오늘 배운 내용을 바탕으로 로컬에 Kafka Docker를 띄우고, Producer로 메시지를 전송한 뒤 kafka-consumer-groups.sh로 Lag를 직접 관찰하는 것부터 시작해보세요.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다