[Kafka 운영 ⑥] Rebalance Storm — 컨슈머 그룹 리밸런스 폭풍 진정시키기
컨슈머 그룹 리밸런스가 끝없이 반복되는 'Rebalance Storm'의 원인을 heartbeat·session·poll 타임아웃 모델로 분석하고, Cooperative Rebalancing과 Static Membership으로 진정시키는 실전 방법을 정리합니다.
새벽 2시, 컨슈머 랙(lag) 알림이 울립니다. 대시보드를 열어보니 같은 컨슈머 그룹이 1분에 수십 번씩 리밸런스를 반복하고 있습니다. 컨슈머는 멀쩡히 떠 있는데도 메시지는 거의 처리되지 않습니다. 파티션을 받자마자 다시 빼앗기고, 또 받고, 또 빼앗기는 일이 끝없이 이어집니다. 이것이 운영자가 가장 두려워하는 **Rebalance Storm(리밸런스 폭풍)**입니다. 이번 글에서는 폭풍의 발생 메커니즘을 타임아웃 모델로 해부하고, 그것을 진정시키는 구체적인 설정과 전략을 다룹니다.
이 글에서 배우는 것
- 리밸런스가 무엇이고 어떤 이벤트가 그것을 촉발하는지
- heartbeat·session·poll-interval 세 가지 타임아웃이 만드는 생존 판정 모델
- "처리 지연 → 강퇴 → 재분배 → 더 느려짐"으로 수렴하지 않는 폭풍 루프의 정체
- 타임아웃 튜닝, Cooperative Rebalancing, Static Membership으로 폭풍을 멈추는 방법
1. 리밸런스란 무엇이고 무엇이 촉발하는가
리밸런스의 정의
컨슈머 그룹은 토픽의 파티션들을 그룹 멤버(컨슈머)들에게 나눠 줍니다. **리밸런스(rebalance)**는 이 "파티션 → 멤버" 할당을 다시 계산하고 재분배하는 과정입니다. 그룹을 조율하는 브로커 측 Group Coordinator가 리밸런스를 주도하고, 멤버들은 JoinGroup → SyncGroup 프로토콜을 통해 새 할당을 받습니다.
리밸런스 자체는 정상적인 메커니즘입니다. 문제는 그것이 너무 자주, 멈추지 않고 일어날 때입니다.
리밸런스를 촉발하는 이벤트
| 트리거 | 설명 | 흔한 정도 |
|---|---|---|
| 멤버 합류(join) | 새 컨슈머 인스턴스가 그룹에 들어옴 (스케일 아웃, 재기동) | 정상 |
| 멤버 이탈(leave) | 컨슈머가 graceful하게 그룹을 떠남 (정상 종료) | 정상 |
| 구독 변경 | subscribe() 토픽 목록이 바뀜, 정규식 구독에 새 토픽 매칭 | 가끔 |
| 파티션 수 변경 | 토픽 파티션이 늘어남 | 드묾 |
| heartbeat 누락 | 멤버가 session.timeout.ms 안에 하트비트를 못 보냄 | 흔한 주범 |
| poll 지연 | 멤버가 max.poll.interval.ms 안에 poll()을 못 부름 (처리 지연) | 흔한 주범 |
앞쪽 네 가지는 의도된 변경이라 어쩔 수 없습니다. 운영에서 우리를 괴롭히는 것은 거의 항상 마지막 두 가지 — "멤버가 죽은 것처럼 보여서" 강퇴당하는 경우입니다. 컨슈머 프로세스는 멀쩡한데 코디네이터가 죽었다고 판단해 파티션을 회수하는 것이죠.
2. 타임아웃 모델 — 두 개의 독립적인 생존 판정
Rebalance Storm을 이해하려면 컨슈머의 "살아있음"을 판정하는 두 개의 서로 다른 메커니즘을 구분해야 합니다. 많은 운영자가 이 둘을 헷갈려서 엉뚱한 설정을 만집니다.
메커니즘 ① — 하트비트 스레드 (heartbeat / session)
컨슈머는 백그라운드에 하트비트 스레드를 따로 돌립니다. 이 스레드는 애플리케이션이 메시지를 처리하든 말든 독립적으로 코디네이터에게 "나 살아있어"라는 신호를 보냅니다.
heartbeat.interval.ms(기본 3000): 하트비트를 보내는 주기. 짧을수록 죽음을 빨리 감지하지만 트래픽이 늘어납니다.session.timeout.ms(기본 45000): 코디네이터가 이 시간 동안 하트비트를 한 번도 못 받으면 해당 멤버를 죽었다고 선언하고 리밸런스를 시작합니다.
session.timeout.ms는 브로커의 group.min.session.timeout.ms(기본 6000)와 group.max.session.timeout.ms(기본 1800000) 사이에 있어야 합니다. 이 범위를 벗어난 값을 주면 컨슈머 join이 거부됩니다.
권장 비율: heartbeat.interval.ms ≈ session.timeout.ms / 3. 세션 타임아웃이 만료되기 전에 최소 3번의 하트비트 기회를 갖도록 하기 위함입니다. 네트워크 순단 한 번에 멤버가 강퇴되지 않게 하는 안전 마진이죠.
메커니즘 ② — 폴 루프 (max.poll.interval)
하트비트 스레드가 살아있다고 해도, 애플리케이션 스레드가 실제로 일을 하고 있는지는 별개입니다. 컨슈머가 poll()로 받은 레코드를 처리하느라 너무 오래 걸려서 다음 poll()을 부르지 못하면, 그 컨슈머는 "살아는 있지만 진행이 막혔다(stuck)"고 봐야 합니다.
max.poll.interval.ms(기본 300000, 5분): 두 번의poll()호출 사이 최대 허용 간격. 이 시간을 넘기면 컨슈머는 스스로 그룹을 떠나고(leave), 코디네이터는 리밸런스를 시작합니다.max.poll.records(기본 500): 한 번의poll()이 반환하는 최대 레코드 수. 이 값이 클수록 한 배치 처리에 시간이 더 걸립니다.
핵심은 이 두 메커니즘이 독립적이라는 점입니다.
| 측정 대상 | 누가 보냄/판정 | 위반 시 | |
|---|---|---|---|
| session.timeout | 하트비트 스레드의 생존 | 백그라운드 스레드 ↔ 코디네이터 | 코디네이터가 죽었다고 선언 |
| max.poll.interval | poll 루프의 진행 | 애플리케이션 스레드 자신 | 컨슈머가 스스로 그룹 이탈 |
즉, 하트비트는 잘 가는데 처리가 느려서 강퇴되는 상황이 실제로 가장 흔합니다. 하트비트 스레드는 부지런히 "살아있어"를 외치지만, 메인 스레드는 무거운 레코드 하나를 5분 넘게 붙잡고 있는 것이죠. 이때 session.timeout.ms만 늘려봐야 소용없습니다. 범인은 max.poll.interval.ms이기 때문입니다.
3. 폭풍은 어떻게 발생하는가
Rebalance Storm은 단일 원인이 아니라 자기 강화 루프입니다. 보통 처리 지연에서 시작합니다.
- 어떤 컨슈머가 무거운 메시지 배치를 만나 처리가
max.poll.interval.ms를 넘깁니다. - 그 컨슈머가 그룹에서 강퇴되고, 리밸런스가 발생합니다.
- 강퇴된 컨슈머의 파티션이 남은 컨슈머들에게 재분배됩니다. 이제 살아남은 컨슈머들은 더 많은 파티션을 떠안습니다.
- 부하가 늘어난 컨슈머들도 처리가 느려져
max.poll.interval.ms를 넘기기 시작합니다. - 또 강퇴 → 또 재분배 → 남은 멤버에게 더 큰 부하 → 또 느려짐...
수렴하지 않습니다. 게다가 리밸런스 자체가 비용입니다. eager 방식에서는 리밸런스 동안 모든 멤버가 모든 파티션을 반납하고(stop-the-world) 멈췄다가 새로 받습니다. 처리가 멈춘 시간만큼 랙은 더 쌓이고, 쌓인 랙은 다음 배치를 더 무겁게 만들어 악순환을 가속합니다.
이 증상은 **Part 2 — '컨슈머 랙이 줄지 않는다'**와도 직결됩니다. 랙이 줄지 않는 원인을 추적하다 보면, 실제로는 컨슈머가 처리를 못 하는 게 아니라 리밸런스로 계속 멈춰 서기 때문인 경우가 많습니다. 랙 그래프와 함께 리밸런스 빈도(RebalanceRatePerHour 같은 메트릭, 혹은 코디네이터 로그의 Preparing to rebalance 빈도)를 반드시 같이 보세요.
폭풍을 알아보는 신호
- 컨슈머 로그에
Member ... sending LeaveGroup request/Attempt to heartbeat failed since group is rebalancing가 반복. - 브로커(코디네이터) 로그에
Preparing to rebalance group ... (reason: removing member ... on ...)가 분 단위로 반복. - 컨슈머는 OOM/크래시 없이 떠 있는데 처리량(throughput)이 0에 가깝게 진동.
4. 폭풍을 진정시키는 설정 튜닝
폭풍의 진앙은 거의 항상 처리가 타임아웃을 넘기는 것입니다. 따라서 처방은 "타임아웃 안에 처리가 끝나게 만들거나, 타임아웃을 처리 시간에 맞게 늘리는" 두 방향입니다.
타임아웃 정렬
# 하트비트/세션 — 일시적 네트워크 순단에 강퇴되지 않게
session.timeout.ms=45000
heartbeat.interval.ms=15000 # session / 3
# 폴 루프 — 한 배치 처리 시간에 맞춰 충분히
max.poll.interval.ms=600000 # 5분 → 10분 (실제 P99 처리시간 기준)
max.poll.records=200 # 500 → 200, 배치를 가볍게판단 기준은 명확합니다.
- 하트비트가 끊겨서(네트워크/GC) 강퇴된다면 →
session.timeout.ms를 키우고heartbeat.interval.ms를 그 1/3로 맞춥니다. - 처리가 느려서(무거운 배치) 강퇴된다면 →
max.poll.interval.ms를 P99 배치 처리시간보다 넉넉히 늘리거나,max.poll.records를 줄여 한 배치를 가볍게 만듭니다. 둘 다 병행하는 것이 보통 가장 효과적입니다.
무거운 처리는 poll 스레드에서 떼어내기
가장 근본적인 해법은 무거운 작업을 poll 루프 바깥으로 옮기는 것입니다. poll()은 빠르게 레코드를 가져오기만 하고, 실제 처리는 별도 워커 스레드 풀에 위임합니다. 단, 이렇게 하면 오프셋 커밋과 in-flight 처리 사이의 순서·중복 보장을 직접 관리해야 합니다. 처리가 끝난 레코드까지만 커밋하도록 pause()/resume()으로 백프레셔를 거는 패턴을 함께 써야 합니다.
| 처방 | 효과 | 주의점 |
|---|---|---|
session.timeout.ms ↑ | 순단/GC에 의한 강퇴 감소 | 진짜 죽은 멤버 감지가 느려짐 |
max.poll.interval.ms ↑ | 느린 처리에 의한 강퇴 감소 | 진짜 멈춘(stuck) 멤버 감지가 느려짐 |
max.poll.records ↓ | 한 배치를 가볍게 → poll 간격 단축 | 전체 throughput은 유지(오버헤드 약간 증가) |
| 처리 오프로딩 | poll은 항상 빠르게 복귀 | 오프셋 커밋/순서 관리 직접 구현 |
5. Cooperative(증분) 리밸런싱
설정을 아무리 잘 잡아도, eager 리밸런싱은 매 리밸런스마다 stop-the-world라는 본질적 비용을 갖습니다. 이를 줄이는 것이 Cooperative(Incremental) Rebalancing입니다 (KIP-429).
eager vs cooperative
| 구분 | Eager (RangeAssignor, RoundRobinAssignor) | Cooperative (CooperativeStickyAssignor) |
|---|---|---|
| 회수 방식 | 모든 멤버가 모든 파티션을 한꺼번에 반납 | 재할당이 필요한 파티션만 점진적으로 회수 |
| 처리 중단 | 리밸런스 동안 그룹 전체 정지(stop-the-world) | 영향 없는 파티션은 계속 처리 |
| 리밸런스 횟수 | 1회 | 보통 2회(revoke 후 재join)지만 영향 범위가 작음 |
| 폭풍 영향 | 멈춤이 커서 랙 누적 가속 | 멈춤이 작아 폭풍 완화에 유리 |
eager 방식에서는 멤버 하나가 들어오거나 나가도 그룹 전체가 일단 모든 파티션을 놓습니다. 본래 자기 것이던 파티션까지 회수했다가 다시 받는 낭비가 생기죠. cooperative는 실제로 주인이 바뀌어야 하는 파티션만 회수합니다. 나머지는 처리를 멈추지 않으므로 폭풍 상황에서 "멈춤이 멈춤을 부르는" 악순환을 크게 줄여줍니다.
partition.assignment.strategy=org.apache.kafka.clients.consumer.CooperativeStickyAssignor마이그레이션 주의
eager → cooperative 전환은 그룹 내 모든 컨슈머가 동시에 같은 전략을 쓰지 않으면 안전하지 않습니다. 무중단 롤링 전환을 위해 보통 두 단계를 거칩니다.
- 1차 배포:
partition.assignment.strategy에 기존 전략과 CooperativeSticky를 함께 나열 (예:RangeAssignor, CooperativeStickyAssignor). 모든 인스턴스가 이 설정을 갖게 될 때까지 둘 다 지원. - 2차 배포: 리스트에서 기존 eager 전략을 제거하고
CooperativeStickyAssignor만 남김.
한 번에 바꾸면 전환 도중 멤버 간 전략이 섞여 할당 협상이 깨질 수 있습니다.
6. Static Membership — 롤링 재시작 폭풍 막기
배포할 때마다 컨슈머를 롤링 재시작하면, 멤버가 나갔다 들어올 때마다 리밸런스가 두 번씩 일어납니다(leave 시 1번, rejoin 시 1번). 인스턴스가 많으면 배포 한 번에 수십 번의 리밸런스 폭풍이 발생합니다. Static Membership(KIP-345)은 이를 없애줍니다.
각 컨슈머 인스턴스에 **고정된 group.instance.id**를 부여하면, 코디네이터는 그 멤버를 "정적 멤버"로 기억합니다. 정적 멤버가 잠깐 사라졌다가 session.timeout.ms 안에 같은 ID로 돌아오면, 코디네이터는 리밸런스를 일으키지 않고 이전 할당을 그대로 돌려줍니다.
# 인스턴스마다 고유하고 안정적인 ID (예: StatefulSet ordinal, 호스트명)
group.instance.id=consumer-prod-0
# 재시작이 이 시간 안에 끝나야 리밸런스가 생략됨 → 넉넉히
session.timeout.ms=120000핵심 운영 포인트:
group.instance.id는 인스턴스마다 고유하고 재시작 후에도 동일해야 합니다. Kubernetes라면 StatefulSet의 Pod ordinal(pod-0,pod-1...)이 안성맞춤입니다.- 재시작이
session.timeout.ms안에 끝나야 합니다. 그래서 정적 멤버십을 쓸 때는 세션 타임아웃을 배포에 걸리는 시간보다 넉넉히(예: 2~5분) 잡는 경우가 많습니다. - 트레이드오프: 진짜로 죽은 정적 멤버도
session.timeout.ms가 다 지나야 감지됩니다. 가용성(빠른 장애 감지)과 배포 안정성(리밸런스 회피) 사이의 균형입니다.
Static Membership + CooperativeStickyAssignor를 함께 쓰는 것이 오늘날 대규모 컨슈머 그룹의 사실상 표준 조합입니다.
7. 진단 체크리스트 — 타임라인으로 보기
폭풍을 만나면 다음 타임라인 관점으로 원인을 좁혀가세요. 하트비트 주기, 세션 만료, poll 간격이 시간 축에서 어떻게 맞물리는지를 보면 어느 메커니즘이 터졌는지 한눈에 드러납니다.
| 증상 / 로그 | 의심 원인 | 우선 조치 |
|---|---|---|
heartbeat failed ... group is rebalancing 반복 | 다른 멤버가 강퇴 트리거 | 강퇴된 멤버 쪽 로그 추적 |
LeaveGroup + 처리 직후 발생 | poll 지연(처리 과다) | max.poll.interval.ms↑ / max.poll.records↓ |
| 긴 GC 후 강퇴 | session 만료(STW GC) | session.timeout.ms↑, 힙/GC 튜닝 |
| 롤링 배포마다 폭풍 | 멤버 재시작 | group.instance.id(static membership) |
| 리밸런스마다 throughput 0 | eager STW | CooperativeStickyAssignor로 전환 |
마치며
- 리밸런스는 정상 메커니즘이지만, 끝없이 반복되면 폭풍이 됩니다. 폭풍은 거의 항상 "처리 지연 → 강퇴 → 재분배 → 더 느려짐"의 자기 강화 루프입니다.
- 컨슈머의 생존은 두 개의 독립적인 메커니즘으로 판정됩니다. 하트비트 스레드(
session.timeout.ms)와 poll 루프(max.poll.interval.ms). 둘을 구분하지 못하면 엉뚱한 설정을 만지게 됩니다. 운영에서 가장 흔한 범인은 poll 지연입니다. - 처방:
heartbeat.interval.ms≈session.timeout.ms/3로 정렬하고, 처리 시간에 맞춰max.poll.interval.ms를 늘리거나max.poll.records를 줄이며, 무거운 처리는 poll 스레드 밖으로 오프로딩하세요. - CooperativeStickyAssignor로 stop-the-world 회수를 없애고, **Static Membership(
group.instance.id)**으로 롤링 재시작 리밸런스를 회피하세요. 이 둘의 조합이 대규모 컨슈머 그룹의 표준 처방입니다. - 랙이 줄지 않는다면(Part 2 참고) 컨슈머가 느린 게 아니라 리밸런스로 멈춰 서 있는 것일 수 있습니다. 랙 그래프와 리밸런스 빈도를 항상 함께 보세요.
참고 자료
- Apache Kafka. "Consumer Configurations" — https://kafka.apache.org/documentation/#consumerconfigs
- KIP-429. "Kafka Consumer Incremental Rebalance Protocol" — https://cwiki.apache.org/confluence/display/KAFKA/KIP-429%3A+Kafka+Consumer+Incremental+Rebalance+Protocol
- KIP-345. "Introduce static membership protocol to reduce consumer rebalances" — https://cwiki.apache.org/confluence/display/KAFKA/KIP-345%3A+Introduce+static+membership+protocol+to+reduce+consumer+rebalances
— Data Dynamics 엔지니어링 팀