끄적끄적

Kafka 실무 완전 정복 — Consumer Lag 폭증·메시지 중복·파티션 설계 실수 직접 겪고 해결한 이야기 본문

Back-end/Java

Kafka 실무 완전 정복 — Consumer Lag 폭증·메시지 중복·파티션 설계 실수 직접 겪고 해결한 이야기

mashko 2026. 2. 25. 23:22
반응형
⚡ Apache Kafka 실무 완전 정복

Kafka, 개념
"실무에서 터진 문제들과 해결법"

Consumer Lag 폭증 · 파티션 설계 실수 · 메시지 중복/유실 — 직접 겪고 해결한 이야기

⚡ Kafka 3.x 기준 ☕ Spring Boot 3 + Java 17 ⏱ 읽기 약 20분 🔥 실무 트러블슈팅 포함

📋 목차

  1. 들어가며 — Kafka를 처음 실무에 도입했을 때
  2. Kafka 핵심 개념 — 딱 알아야 할 것만
  3. Topic · Partition · Consumer Group 설계 전략
  4. Spring Boot + Kafka 실전 연동 코드
  5. Producer 설정 — 메시지 유실 없애는 법
  6. Consumer 설정 — 중복 처리와 멱등성
  7. 실무에서 터진 트러블슈팅 BEST 5
  8. Consumer Lag 모니터링 세팅
  9. Kafka vs RabbitMQ — 언제 뭘 쓸까
📖 Section 01

들어가며 — Kafka를 처음 실무에 도입했을 때

사내 서비스 간 이벤트 처리를 REST API로 직접 호출하다가 한계에 부딪혔습니다. 서비스 A가 서비스 B를 직접 호출하는 구조에서 B가 잠깐 다운되면 이벤트가 그냥 사라졌고, 트래픽 폭증 시 API 타임아웃이 연쇄적으로 터졌어요.

그때 Kafka를 도입했는데, 처음엔 개념 이해도 어렵고 설정값 하나 잘못 건드렸다가 메시지 수십만 건을 중복 처리하거나 유실시키는 사고를 겪었습니다. 이 글은 그 과정에서 몸으로 배운 것들을 정리한 내용입니다.

💡
이 글을 읽으면 알 수 있는 것들:
① Kafka를 왜 쓰는지, 언제 쓰면 안 되는지
② 파티션 개수를 잘못 설계하면 왜 망하는지
at-least-once vs exactly-once — 실무에선 뭘 선택해야 하는지
④ Consumer Lag이 갑자기 폭증할 때 어떻게 대응하는지
· · ·
📚 Section 02

Kafka 핵심 개념 — 딱 알아야 할 것만

Producer
메시지 발행
Kafka Broker
Topic: order-events
Consumer Group A
주문 처리 서비스
 
Partition 0
offset: 0~N
Partition 1
offset: 0~N
Partition 2
offset: 0~N
Consumer Group B
알림 서비스
같은 Topic을 여러 Consumer Group이 독립적으로 소비 가능 — 이게 Kafka의 핵심 강점
개념 한 줄 설명 실무 포인트
🟠 Topic 메시지를 분류하는 카테고리 (DB 테이블 비유) 서비스 도메인 단위로 분리 추천
🟠 Partition Topic의 물리적 분할 단위, 병렬 처리의 핵심 한 번 늘리면 줄이기 매우 어려움 ⚠️
🟠 Offset 파티션 내 메시지의 순번 (변경 불가) Consumer가 어디까지 읽었는지 추적
🟠 Consumer Group 같은 Topic을 독립적으로 소비하는 Consumer 묶음 서비스별로 그룹 분리 필수
🟠 Broker Kafka 서버 1대 최소 3대 클러스터 구성 권장
🟠 Consumer Lag Producer가 보낸 것과 Consumer가 읽은 것의 차이 이게 늘어나면 장애 신호 🚨
🟠 Replication Factor 메시지 복제 수 (장애 대비) 운영환경 최소 3 권장
· · ·
🏗️ Section 03

Topic · Partition · Consumer Group 설계 전략

1

파티션 개수 — 처음부터 넉넉하게, 하지만 무작정 늘리면 안 된다

파티션은 늘릴 수 있지만 줄일 수 없습니다. 또 파티션 수 = Consumer 최대 병렬 처리 수입니다. Consumer가 3개인데 파티션이 1개면 나머지 2개는 놀고, 파티션이 10개인데 Consumer가 3개면 한 Consumer가 여러 파티션을 담당합니다.

💡 공식: 파티션 수 ≥ Consumer 수. 처음엔 예상 Consumer 수 × 2~3배로 시작하세요.
2

Partition Key 설계 — 순서 보장이 필요하다면 반드시

Kafka는 같은 파티션 내에서만 순서를 보장합니다. 예를 들어 사용자 A의 주문 이벤트가 순서대로 처리돼야 한다면, userId를 파티션 키로 써야 합니다. 그래야 사용자 A의 메시지가 항상 같은 파티션으로 들어가요.

⚠️ 파티션 키 없이 쓰면 Round-Robin으로 분산 → 순서 뒤섞임 주의!
3

Consumer Group 분리 전략 — 서비스별로 독립 그룹

같은 Topic을 여러 Consumer Group이 독립적으로 읽을 수 있는 것이 Kafka 최대 강점입니다. 예를 들어 order-events Topic을 주문처리 서비스, 알림 서비스, 통계 서비스가 각각 독립적인 Consumer Group으로 소비하면, 한 서비스가 느려져도 다른 서비스에 영향이 없습니다.

💡 Consumer Group 이름은 서비스명-환경-목적 형태로 명확하게 관리하세요. ex) order-service-prod-payment
· · ·
☕ Section 04

Spring Boot + Kafka 실전 연동 코드

 
 
 
application.yml — Kafka 기본 설정
YAML
spring:
  kafka:
    bootstrap-servers: kafka1:9092,kafka2:9092,kafka3:9092

    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
      # 메시지 유실 방지 핵심 설정
      acks: all           # 모든 replica 확인 후 ack (유실 방지)
      retries: 3
      properties:
        enable.idempotence: true  # 중복 방지 (exactly-once producer)
        max.in.flight.requests.per.connection: 5

    consumer:
      group-id: order-service-prod
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
      # 오프셋 자동 커밋 OFF — 수동 커밋으로 메시지 처리 보장
      enable-auto-commit: false
      auto-offset-reset: earliest
      properties:
        spring.json.trusted.packages: "com.myapp.event"
 
 
 
OrderEventProducer.java
Java
@Service
@RequiredArgsConstructor
public class OrderEventProducer {

    private static final String TOPIC = "order-events";
    private final KafkaTemplate<String, OrderEvent> kafkaTemplate;
    private static final Logger log = LoggerFactory.getLogger(OrderEventProducer.class);

    public void sendOrderEvent(OrderEvent event) {
        // userId를 파티션 키로 → 같은 사용자 메시지는 같은 파티션
        var record = new ProducerRecord<>(
            TOPIC,
            event.getUserId(),  // partition key
            event
        );

        kafkaTemplate.send(record)
            .whenComplete((result, ex) -> {
                if (ex != null) {
                    log.error("Kafka 전송 실패 | orderId={} | error={}",
                        event.getOrderId(), ex.getMessage());
                    // TODO: Dead Letter Queue 또는 DB Outbox 패턴으로 보상
                } else {
                    log.info("전송 완료 | partition={} | offset={}",
                        result.getRecordMetadata().partition(),
                        result.getRecordMetadata().offset());
                }
            });
    }
}
 
 
 
OrderEventConsumer.java — 수동 커밋 + 멱등성 처리
Java
@Service
@RequiredArgsConstructor
public class OrderEventConsumer {

    private final OrderService orderService;
    private final ProcessedEventRepository processedEventRepo;

    @KafkaListener(
        topics = "order-events",
        groupId = "order-service-prod",
        containerFactory = "kafkaListenerContainerFactory"
    )
    public void consume(
        ConsumerRecord<String, OrderEvent> record,
        Acknowledgment ack  // 수동 커밋용
    ) {
        OrderEvent event = record.value();
        String eventId = event.getEventId();

        try {
            // 멱등성 체크 — 이미 처리한 이벤트면 스킵
            if (processedEventRepo.existsByEventId(eventId)) {
                log.warn("중복 이벤트 스킵 | eventId={}", eventId);
                ack.acknowledge();  // 중복이어도 커밋은 해야 함
                return;
            }

            // 비즈니스 로직 처리
            orderService.processOrder(event);

            // 처리 완료 기록 (중복 방지용)
            processedEventRepo.save(new ProcessedEvent(eventId));

            // 처리 성공 시에만 offset 커밋
            ack.acknowledge();

        } catch (Exception e) {
            log.error("이벤트 처리 실패 | eventId={} | partition={} | offset={}",
                eventId, record.partition(), record.offset(), e);
            // ack 안 함 → 재처리 (at-least-once 보장)
            // 무한 재시도 방지는 DLT(Dead Letter Topic)로 해결
        }
    }
}
🔑
핵심 포인트 — enable-auto-commit: false 설정 이유:
자동 커밋을 켜두면 메시지를 받는 순간 offset이 커밋됩니다. 그러다 처리 도중 서버가 죽으면? 메시지는 유실됩니다. 수동 커밋으로 "처리 완료 후 커밋" 패턴을 쓰면 최소 한 번(at-least-once) 처리를 보장할 수 있습니다.
· · ·
🚨 Section 05

실무에서 터진 트러블슈팅 BEST 5

🔴

Case 1 — Consumer Lag이 갑자기 수십만으로 폭증

🚨 현상: 평소에 잘 처리되던 Consumer가 갑자기 Lag이 수십만 건으로 치솟음. 서비스 지연 발생.
✅ 원인 & 해결: Consumer의 처리 로직에 외부 API 호출이 있었고, 그 API가 느려지면서 Consumer 처리량이 급감했습니다.
해결책 → Consumer 인스턴스 스케일 아웃 (파티션 수만큼 늘릴 수 있음) + 외부 API에 타임아웃 설정 + 비동기 처리로 전환.
🔴

Case 2 — 메시지 중복 처리로 결제가 2번 됨

🚨 현상: Consumer가 재시작되면서 이미 처리된 메시지를 다시 소비, 결제 중복 발생. (at-least-once의 부작용)
✅ 해결: 위 코드처럼 이벤트 ID 기반 멱등성 체크 도입. DB에 처리된 eventId를 저장하고, 소비 전 체크. Redis를 사용하면 성능 + TTL 관리까지 가능합니다.
🔴

Case 3 — Rebalancing 폭풍으로 Consumer 전체 멈춤

🚨 현상: Consumer 하나가 처리 시간이 길어지자 Kafka가 해당 Consumer를 "죽었다"고 판단 → 리밸런싱 반복 → 전체 Consumer가 계속 멈추는 현상.
✅ 해결: max.poll.interval.ms를 처리 시간에 맞게 충분히 늘려줌 (기본 5분). 또는 max.poll.records를 낮춰 한 번에 처리하는 메시지 수를 줄임. Static Group Membership (group.instance.id) 설정으로 불필요한 리밸런싱 방지.
🔴

Case 4 — 파티션 수 부족으로 병렬 처리 불가

🚨 현상: Consumer 인스턴스를 10대로 늘렸는데 처리량이 전혀 안 오름. 파티션이 3개밖에 없어서 7대는 놀고 있었음.
✅ 해결: 파티션 수를 늘렸지만 이미 메시지가 있는 Topic의 파티션을 늘리면 Key 기반 라우팅이 깨집니다. 새 Topic으로 마이그레이션이 가장 안전합니다. 처음 설계가 중요한 이유입니다.
🔴

Case 5 — 역직렬화 오류로 Consumer 완전 멈춤

🚨 현상: Producer에서 이벤트 스키마를 변경했는데 Consumer는 구버전. 역직렬화 실패가 반복되며 해당 파티션 소비가 완전히 멈춤.
✅ 해결: ErrorHandlingDeserializer 적용으로 역직렬화 실패 메시지는 Dead Letter Topic(DLT)으로 보내도록 설정. 장기적으로 Avro + Schema Registry 도입으로 스키마 버전 관리.
· · ·
📊 Section 06

Consumer Lag 모니터링 세팅

📊
Kafka 모니터링 필수 스택:
Kafka Exporter → Prometheus → Grafana 조합이 현재 실무 표준입니다.
핵심 지표: kafka_consumergroup_lag (Consumer Lag), kafka_topic_partitions, kafka_brokers
Lag이 임계값(예: 10,000건) 초과 시 Slack 알림 연동 필수입니다.
 
 
 
CLI — Consumer Lag 즉시 확인
Shell
# Consumer Group 목록 확인
kafka-consumer-groups.sh \
  --bootstrap-server kafka1:9092 \
  --list

# 특정 Consumer Group의 Lag 상세 확인
kafka-consumer-groups.sh \
  --bootstrap-server kafka1:9092 \
  --describe \
  --group order-service-prod

# 출력 예시:
# TOPIC         PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG
# order-events  0          1500            1500            0     ← 정상
# order-events  1          800             5000            4200  ← 위험!
· · ·
⚖️ Section 07

Kafka vs RabbitMQ — 언제 뭘 쓸까?

항목 Kafka RabbitMQ
처리량 초당 수백만 건 초당 수만 건
메시지 보관 디스크 저장, 재처리 가능 소비 후 삭제 (기본)
Consumer 그룹 여러 그룹이 독립 소비 하나의 큐, 하나의 소비자 그룹
순서 보장 파티션 내 보장 단일 큐 내 보장
학습 난이도 높음 낮음
운영 난이도 높음 (클러스터) 낮음
적합한 상황 대용량 이벤트 스트리밍, 로그 수집 소규모 작업 큐, RPC 패턴
실무 선택 기준:
📌 Kafka를 쓸 때: 대용량 이벤트, 여러 서비스가 같은 이벤트를 소비, 메시지 재처리가 필요할 때, 데이터 파이프라인
📌 RabbitMQ를 쓸 때: 단순 작업 큐, 소규모 트래픽, 빠른 구축이 필요할 때, 복잡한 라우팅 규칙이 필요할 때

⚡ "Kafka는 도입보다 운영이 어렵습니다"

Kafka를 도입하고 나서 가장 많은 시간을 쓴 건 개발이 아니라 운영이었습니다.
Consumer Lag 모니터링, 파티션 재설계, 멱등성 처리...
이 글에서 다룬 내용들을 처음부터 알았더라면 훨씬 수월했을 텐데요 😅

비슷한 경험이 있으시면 댓글로 공유해 주세요!

#Kafka #아파치카프카 #카프카실무 #SpringBootKafka #ConsumerLag #메시지큐 #이벤트드리븐 #백엔드개발 #카프카트러블슈팅 #개발블로그 #KafkaVsRabbitMQ #MSA

 

반응형
Comments