리밸런싱이 왜 지옥이 되는가

카프카 컨슈머 그룹은 파티션을 멤버에게 나눠 갖는다. 멤버가 들어오거나 나가면, 혹은 구독 토픽이 바뀌면 파티션을 다시 배분하는 리밸런싱이 일어난다. 문제는 기본 프로토콜(Eager)에서 리밸런싱이 시작되면 모든 컨슈머가 자기 파티션을 일단 반납하고 멈춘다는 점이다. 이 Stop-the-World 구간 동안 그룹 전체의 처리가 정지한다.

배포 한 번에 파드가 순차적으로 재시작되면 리밸런싱이 파드 수만큼 연쇄로 터진다. 여기에 처리 지연이 겹치면 컨슈머가 죽었다고 오판당해 또 리밸런싱이 유발되는 악순환에 빠진다. 이게 흔히 말하는 리밸런싱 지옥이다.

가짜 죽음: 두 개의 타임아웃 오해

리밸런싱 폭풍의 주범은 대부분 타임아웃 설정 오해다. 두 값을 구분해야 한다.

설정의미초과 시
session.timeout.ms하트비트가 이 시간 안 오면 죽음 판정멤버 추방 → 리밸런싱
max.poll.interval.mspoll() 호출 간 최대 간격(처리 시간)그룹 이탈 → 리밸런싱

하트비트는 백그라운드 스레드가 보내므로 session.timeout.ms는 잘 안 걸린다. 진짜 원인은 max.poll.interval.ms인 경우가 많다. 한 번에 가져온 레코드를 처리하는 데 이 시간을 넘기면, 하트비트가 정상이어도 컨슈머가 그룹에서 튕겨 나간다.

배치 크기와 처리 시간부터 맞춘다

무거운 처리를 하는 컨슈머라면 max.poll.records를 줄여 한 번에 처리할 양을 통제하는 게 우선이다. 외부 API 호출이나 DB 쓰기가 들어가면 500건 기본값은 쉽게 시간을 초과한다.

max.poll.records=100
max.poll.interval.ms=300000   # 실제 최악 처리시간 + 여유
session.timeout.ms=45000
heartbeat.interval.ms=15000    # session의 1/3 권장

규칙은 단순하다. heartbeat.interval.ms는 session.timeout.ms의 1/3 이하, max.poll.interval.ms는 한 배치의 최악 처리시간을 실측해 그보다 넉넉히 잡는다. 값을 무작정 키우면 진짜 장애 파드를 감지하는 데 오래 걸리니 균형이 필요하다.

협력적 리밸런싱으로 Stop-the-World 없애기

카프카 2.4+는 Cooperative Sticky 할당자를 제공한다. 전체 파티션을 반납하지 않고, 재배치가 필요한 파티션만 넘긴다. 영향받지 않는 파티션은 처리를 계속하므로 정지 구간이 사실상 사라진다. 대부분의 서비스에서 기본값보다 이쪽을 권장한다.

partition.assignment.strategy=org.apache.kafka.clients.consumer.CooperativeStickyAssignor

Eager에서 Cooperative로 넘어갈 때는 롤링 업그레이드 절차가 있다. 한 번에 전환하지 말고, 두 전략을 함께 등록한 버전을 먼저 배포한 뒤 순차 재시작으로 넘긴다.

정적 멤버십으로 배포 폭풍 잠재우기

배포 때마다 파드가 새 멤버로 취급돼 리밸런싱이 나는 문제는 static membership으로 막는다. group.instance.id를 고정하면, 파드가 잠깐 재시작해도 session.timeout.ms 안에 같은 ID로 복귀 시 리밸런싱이 아예 일어나지 않는다.

from kafka import KafkaConsumer
import os

consumer = KafkaConsumer(
    "orders",
    bootstrap_servers="broker:9092",
    group_id="order-worker",
    group_instance_id=os.environ["POD_NAME"],  # StatefulSet 등 안정적 식별자
    session_timeout_ms=45000,
    max_poll_records=100,
    partition_assignment_strategy=["cooperative-sticky"],
)

쿠버네티스라면 StatefulSet의 안정적 파드명을 group.instance.id로 넣으면 잘 맞는다. 단, 이 값이 파드 간 중복되면 그룹이 깨지니 유일성을 반드시 보장해야 한다.

운영 시 주의점

  • graceful shutdown: 종료 시 close()를 호출해 명시적으로 그룹을 떠나야 한다. SIGKILL로 끊기면 session 타임아웃까지 파티션이 붕 뜬다.
  • static membership의 이면: 진짜 죽은 파드도 session 타임아웃까지 감지가 늦어진다. 타임아웃을 무한정 키우지 말 것.
  • 모니터링: 컨슈머 랙과 함께 리밸런싱 발생 횟수·소요시간 지표를 반드시 대시보드에 올린다. 잦은 리밸런싱은 처리시간 초과의 신호다.
  • 단일 스레드 처리량 한계: poll 루프 안에서 무거운 작업을 다 처리하려다 시간 초과가 나면, 파티션 수를 늘려 병렬도를 확보하거나 처리를 비동기 워커로 분리하는 편이 낫다.

정리하면, 타임아웃을 실측 기반으로 맞추고 → Cooperative Sticky로 정지 구간을 없애고 → static membership으로 배포 폭풍을 막는 순서다. 세 가지를 함께 적용하면 리밸런싱은 지옥이 아니라 가끔 일어나는 정상 동작으로 돌아온다.