← all posts
DEV 2026.05.02 · 14 min read Intermediate

Kafka Consumer는 왜 멈추는가

Consumer Group 상태 머신과 리밸런싱 발생 조건부터 Cooperative 전략, 중복 처리 방지, Lag 진단까지 — Consumer가 멈추는 모든 이유를 추적한다.


Kafka Consumer를 운영하다 보면 처리가 갑자기 멈추는 순간을 만난다. Consumer를 추가했을 뿐인데 메시지 처리가 수십 초간 중단되고, JVM GC가 터지면 리밸런싱이 연쇄적으로 일어나고, Lag이 줄지 않아 Consumer를 늘려도 파티션 수가 바닥이면 소용없다. 이 모든 현상의 뿌리는 하나다 — Consumer Group이 어떻게 상태를 관리하고, 무엇이 그 상태를 흔드는가?

Consumer Group의 상태 머신

Consumer Group은 브로커 위의 Group Coordinator(GC) 하나가 관리한다. GC는 hash(groupId) % 50으로 결정되는 __consumer_offsets 파티션의 Leader 브로커다. GC가 멤버십·리밸런싱·offset 커밋을 전담하므로, 이 브로커에 장애가 나면 해당 그룹 전체가 일시 중단된다.

그룹의 상태는 네 단계를 순환한다.

[Empty] ──JoinGroup 요청──▶ [PreparingRebalance]

                              모든 멤버 JoinGroup 완료

                          [CompletingRebalance]

                           GC → 모든 멤버 SyncGroup 응답

                               [Stable] ──Consumer 이탈/추가──▶ [PreparingRebalance]

PreparingRebalanceCompletingRebalance 구간이 핵심이다. 이 두 상태 동안 Consumer는 Fetch를 멈춘다. JoinGroup 단계에서는 모든 멤버가 응답할 때까지 기다리고, SyncGroup 단계에서는 Group Leader(첫 JoinGroup을 보낸 Consumer 인스턴스)가 파티션 할당 계획을 계산해 GC에 전달한다. GC는 결과를 각 멤버에게 배포할 뿐, 할당 로직은 클라이언트 쪽에 있다.

리밸런싱이 일어나는 진짜 원인

Consumer 추가·제거는 누구나 아는 원인이다. 놓치기 쉬운 원인이 더 위험하다.

session.timeout.ms 초과 — HeartbeatThread가 GC에게 주기적으로 heartbeat를 보내는데, GC가 session.timeout.ms(기본 10초) 안에 heartbeat를 받지 못하면 해당 Consumer를 장애로 간주하고 그룹에서 제거한다. JVM Full GC가 15초 STW를 일으키면 HeartbeatThread도 멈추고, 세션 타임아웃이 먼저 터진다.

max.poll.interval.ms 초과 — 이건 다른 메커니즘이다. HeartbeatThread는 살아 있어도, poll() 호출 간격이 max.poll.interval.ms(기본 5분)를 넘으면 Consumer가 스스로 GC에게 LeaveGroup을 보낸다. 레코드 500개를 받아서 외부 API를 레코드당 1초씩 호출하면 500초 — 5분을 훌쩍 넘는다.

heartbeat는 정상인데 리밸런싱이?

두 타이머는 독립적이다. HeartbeatThread가 정상적으로 heartbeat를 보내고 있어도 max.poll.interval.ms를 초과하면 LeaveGroup이 발생한다. “heartbeat는 괜찮은데 왜 리밸런싱이 나지?”의 원인이 여기 있다.

설정 균형점은 명확하다: heartbeat.interval.ms ≤ session.timeout.ms / 3, 그리고 max.poll.records × 레코드당 최대 처리 시간 ≪ max.poll.interval.ms. G1GC를 쓰면 STW를 200ms 이하로 묶을 수 있어 session.timeout.ms 기본값으로도 충분하다.

Eager vs Cooperative — 얼마나 멈추는가

기본 RangeAssignorEager 방식이다. 리밸런싱이 시작되면 모든 Consumer가 보유 파티션을 전부 반납하고, JoinGroup → SyncGroup을 거쳐 다시 받는다. Consumer 10개, 파티션 30개인 그룹에 Consumer 1개를 추가하면 파티션 3개만 이동하면 됨에도 30개 전부가 처리를 멈춘다.

Kafka 2.4에서 도입된 Cooperative(Incremental) Rebalancing은 이동이 필요한 파티션만 revoke한다. 두 라운드로 나뉜다.

Round 1: 이동 대상 파티션 확인 + revoke
  C3이 P5를 반납하기로 결정
  → C1, C2는 중단 없이 계속 처리

Round 2: revoke된 P5를 C4에게 배정
  → C1, C2 여전히 처리 중

같은 조건에서 Eager는 30개 파티션이 중단, Cooperative는 3개만 중단된다. Auto Scaling 환경에서 Eager의 “리밸런싱 → Lag 증가 → 또 리밸런싱” 피드백 루프를 막으려면 CooperativeStickyAssignor가 필수다.

# Spring Kafka
spring:
  kafka:
    consumer:
      properties:
        partition.assignment.strategy: >
          org.apache.kafka.clients.consumer.CooperativeStickyAssignor

기존 Eager 전략에서 마이그레이션할 때는 모든 Consumer에 두 전략을 동시에 선언(CooperativeStickyAssignor,EagerAssignor)하고 순차 재시작한 뒤 EagerAssignor를 제거한다.

리밸런싱 후 중복은 피할 수 없는가

At-Least-Once를 선택한 이상 중복 자체를 막을 수는 없다. 하지만 범위를 줄이고, 부작용을 없앨 수 있다.

중복 발생 경로: C1이 offset 200~249를 처리 완료했지만 커밋 전 리밸런싱이 발생하면, C2는 마지막 커밋 offset인 200부터 다시 읽는다. ConsumerRebalanceListener.onPartitionsRevoked()에서 commitSync(currentOffsets)를 호출하면 반납 직전까지 처리한 offset을 커밋해 중복 범위를 0으로 줄일 수 있다.

중복이 와도 부작용이 없으려면 처리 로직이 멱등해야 한다.

-- Level 1: DB UPSERT (단순 저장)
INSERT INTO orders (order_id, amount, status)
VALUES (?, ?, ?)
ON CONFLICT (order_id) DO UPDATE SET
  status = EXCLUDED.status,
  updated_at = NOW()
WHERE orders.status = 'PENDING';

-- Level 2: 처리 이력 테이블 (이메일 발송 등 외부 부작용)
-- BEGIN;
--   INSERT INTO orders ...;
--   INSERT INTO processed_messages (message_id, processed_at) VALUES (?, NOW());
-- COMMIT;

Kafka 트랜잭션 EOS(Exactly-Once Semantics)는 Kafka 레벨에서 완전한 보장을 제공하지만 처리량이 약 30% 감소한다. 외부 시스템 호출이 포함된 경우 어차피 Level 1/2 멱등 설계가 별도로 필요하다.

Lag — 멈추지 않아도 쌓이는 이유

Consumer Group의 건강 지표는 Lag이다.

Lag = LOG-END-OFFSET - CURRENT-OFFSET

Lag이 증가할 때 Consumer를 무조건 늘리는 것은 틀린 접근일 수 있다. Consumer 수가 파티션 수를 넘으면 초과분은 IDLE이 되어 처리에 기여하지 않는다. 진단 순서는 다음과 같다.

  1. kafka-consumer-groups --describe파티션별 Lag 확인 — 특정 파티션에 Lag이 집중되면 해당 Consumer 인스턴스 장애 또는 처리 병목이다.
  2. 모든 파티션 Lag이 동시에 증가하면 Producer 처리량이 Consumer 처리량을 넘은 것 — 파티션·Consumer 수를 함께 늘리거나 처리 효율을 높인다.
  3. 처리 병목의 가장 빠른 해결책은 배치 처리다. 레코드마다 DB 왕복 1회 → 배치 전체 1회로 바꾸면 처리량이 10~100배 향상된다.
단일 처리: 100개 × DB 왕복 1회 = 100회
배치 처리: 100개 batchInsert = 1회

fetch.min.bytes를 늘리면 Fetch 요청 횟수가 줄어 브로커 부하가 감소하지만, 메시지가 적을 때 fetch.max.wait.ms까지 대기해 지연이 증가한다 — 처리량 중심 시스템에 적합하고 실시간 알림 시스템에는 부적합하다.

트레이드오프

배치 처리는 처리량을 높이지만 max.poll.records 증가 시 max.poll.interval.ms 초과 위험이 따라붙는다. max.poll.records × 레코드당 최대 처리 시간 ≪ max.poll.interval.ms 부등식을 항상 확인하라. 비동기 스레드 풀 처리는 처리량을 더 높일 수 있지만 offset 커밋 타이밍 관리와 OOM 제어가 복잡해진다.

정리

  • Consumer Group 처리 중단의 두 가지 근원: 리밸런싱(PreparingRebalance + CompletingRebalance 구간 Fetch 중단)과 Lag 누적.
  • 리밸런싱 원인은 Consumer 추가·제거만이 아니다 — GC STW로 인한 session timeout, 느린 처리 로직으로 인한 max.poll.interval 초과가 더 자주 발생한다.
  • CooperativeStickyAssignor로 전환하면 이동 파티션만 중단되어 Eager 대비 중단 범위를 최대 90% 줄일 수 있다.
  • 중복은 onPartitionsRevoked 커밋으로 범위를 줄이고, 처리 로직 멱등 설계(UPSERT 또는 처리 이력 테이블)로 부작용을 제거한다.
  • Lag 진단은 전체 합계가 아닌 파티션별로 봐야 하며, Consumer를 늘리기 전에 파티션 수를 먼저 확인한다.

다음 글에서는 Producer 처리량 최적화 — linger.ms, batch.size, 압축 알고리즘의 트레이드오프를 추적한다.