Kafka 컨슈머 group id를 제대로 설정하지 않으면 벌어지는 일
generation이 16,453이었습니다#
컨슈머 group id로 쓸 문자열, 대충 아무거나 정해도 되지 않을까요? 저도 오랫동안 별생각이 없었습니다. 어차피 그룹을 구분하는 이름표일 뿐이니, 겹치지만 않으면 된다고 여겼습니다.
그런데 Datadog에서 한 컨슈머 그룹의 리밸런싱이 정상 범주인지 확인하다가 좀 이상한 숫자를 봤습니다. 처음엔 최근 1시간만 조회했는데 리밸런싱 로그가 0건이라 멀쩡해 보였습니다. 혹시나 해서 관측 창을 24시간으로 넓혔더니, 그 그룹의 generation이 16,453을 찍고 있었습니다. 건강한 컨슈머 그룹이라면 한 자릿수에서 많아야 수백 대에 머무는 값입니다. 최근 1시간만 우연히 조용했을 뿐, 이 그룹은 사실상 쉬지 않고 리밸런싱을 반복하고 있었던 겁니다.
이 글은 그 16,453이라는 숫자가 어떻게 나왔는지, 그러니까 컨슈머 group id를 제대로 정하지 않으면 무슨 일이 벌어지는지를 원리부터 풀어보려고 합니다. 컨슈머 그룹이 하는 일을 먼저 짚고, group id를 잘못 정했을 때 따라오는 일들을 붙여본 뒤, 고치는 얘기는 곁가지로 덧붙이겠습니다.
컨슈머 그룹, group id, 그리고 generation#
원리부터 짚고 가겠습니다. Kafka에서 컨슈머 그룹은 토픽의 파티션들을 멤버끼리 나눠 갖는 단위입니다. 파티션이 4개인 토픽을 멤버 2개가 구독하면 각자 2개씩 맡는 식으로, 브로커 쪽의 그룹 코디네이터가 파티션을 멤버들에게 분배합니다. 이때 어떤 멤버가 어떤 파티션을 맡을지 정하는 규칙이 파티션 할당 전략인데, 기본값인 range assignor는 구독한 토픽을 기준으로 파티션을 쭉 늘어놓고 멤버들에게 잘라 나눠줍니다.
그러면 Kafka는 두 컨슈머가 같은 그룹인지 아닌지를 무엇으로 판단할까요? 바로 group id입니다. group id는 그룹의 정체성 그 자체라서, 서로 다른 프로세스라도 같은 group id를 들고 코디네이터에 접속하면 Kafka는 둘을 같은 그룹의 멤버로 취급합니다. 이 문장이 이 글의 전부라고 해도 됩니다. group id가 같으면 같은 그룹이고, 같은 그룹이면 파티션을 나눠 갖고, 파티션을 나눠 가지면 운명을 공유합니다.
멤버 구성이 바뀌면 코디네이터는 파티션 할당을 처음부터 다시 계산합니다. 이 재계산 과정이 리밸런싱입니다. 리밸런싱을 유발하는 사건은 세 가지입니다.
- 멤버가 새로 들어올 때 (새 pod 뜨기, 배포)
- 멤버가 나갈 때 (pod 종료, 롤링 재시작)
- 멤버가 죽었다고 판정될 때 (
max.poll.interval.ms안에 다음 poll을 못 하거나, heartbeat를 놓칠 때)
그리고 리밸런싱에는 방식이 몇 가지 있는데, 오래된 기본값인 eager 방식은 리밸런싱이 시작되면 그룹의 모든 멤버가 일단 자기 파티션을 전부 반납하고 소비를 멈춥니다. 재분배가 끝날 때까지 그룹 전체가 손을 놓는, 말 그대로 stop-the-world입니다.
여기서 마지막 개념 하나가 generation입니다. generation은 리밸런싱이 한 번 일어날 때마다 1씩 올라가는 카운터입니다. generation 4라는 건 그 그룹이 지금까지 4번 리밸런싱했다는 뜻이고, 건강한 그룹은 배포할 때나 잠깐 오르내릴 뿐 좀처럼 커지지 않습니다. 그래서 generation은 그룹이 얼마나 자주 흔들렸는지를 보여주는 지문 같은 값입니다. 도입부의 16,453이 왜 비정상인지가 여기서 나옵니다.
여섯 서비스가 같은 group id를 쓰고 있었습니다#
그럼 generation이 16,453까지 올라간 그 그룹은 대체 무엇이 문제였을까요. 코드를 열어보니 group id 값이 이렇게 박혀 있었습니다.
// 서비스마다 자기 토픽을 구독하면서, groupId 값만은
// 이렇게 리터럴로 박아 두고 있었습니다
@KafkaListener(topics = "…(서비스마다 다름)", groupId = "consumerGroup")
public void consume(ConsumerRecord<?, ?> record) {
// …
}group id가 consumerGroup였습니다. 마치 변수 이름으로 쓸 법한 단어를 값 칸에 그대로 옮겨 적은 듯한 리터럴입니다. 코드에서 이 리터럴을 하드코딩한 서비스를 우선 셋(프로비저닝·알림·CMS) 확인했는데, 그것만으로도 한 서비스의 단발 실수는 아니라는 게 분명했습니다. 그래서 Datadog으로 이 group id의 실제 멤버를 열어 봤더니, 서로 다른 팀이 만들고 따로 배포하는 여섯 개 서비스가 같은 그룹에 묶여 있었습니다. 프로비저닝, 알림, 평가, CMS, 미팅, 인재 관리까지, group id만은 우연히 한 글자도 안 틀리고 같았던 겁니다.
앞 절의 원리를 그대로 대입하면 결과는 뻔합니다. group id가 같으니 Kafka 입장에선 전부 한 그룹입니다. 여섯 서비스의 컨슈머 인스턴스가 죄다 하나로 묶여서, 프로덕션 기준 약 90개짜리 단일 메가 그룹이 만들어져 있었습니다.
프로비저닝 서비스 ─┐
알림 서비스 ─┤
평가 서비스 ─┼── groupId = "consumerGroup" ──▶ 하나의 컨슈머 그룹
CMS 서비스 ─┤ (멤버 약 90개)
미팅 서비스 ─┤
인재 서비스 ─┘그런데 신기하게도 동작은 잘 됐습니다. 각 서비스는 자기 토픽만 구독하고, Kafka는 각 멤버의 구독 정보를 보고 파티션을 배정하기 때문에, 알림 토픽은 알림 서비스 컨슈머로 가고 CMS 토픽은 CMS 서비스 컨슈머로 갔습니다. 엉뚱한 서비스가 남의 토픽을 받아 처리하는 오배치는 없었습니다. 겉보기엔 각자 자기 일을 멀쩡히 하고 있으니, 이 여섯이 한 그룹으로 얽혀 있다는 사실이 아무 데서도 드러나지 않았습니다. 문제는 바로 이 "동작은 한다"에 있었습니다.
그래서 벌어진 일#
한 그룹으로 묶였다는 게 원리적으로 어떤 결과를 낳는지 순서대로 붙여보겠습니다.
폭발 반경 — 남이 흔들면 나도 리밸런싱된다#
멤버가 하나라도 들락거리면 그룹 전체가 리밸런싱한다고 했습니다. 그런데 이 그룹의 멤버는 여섯 서비스에 흩어져 있습니다. 그러니까 여섯 중 아무 서비스나 하나 배포하거나 pod 하나만 재시작해도, 그 join/leave가 그룹 전체의 리밸런싱을 유발합니다. eager 방식이면 원인 제공과 무관한 나머지 멤버들까지 전부 소비를 멈추고 재분배를 기다립니다. 내 서비스는 아무것도 안 건드렸는데 옆 팀 배포 한 번에 내 컨슈머가 멈추는 겁니다. 이게 이 글에서 말하려는 **폭발 반경(blast radius)**입니다. 리밸런싱의 충격이 원인을 제공한 서비스에 그치지 않고, group id로 묶인 전원에게 퍼집니다.
여섯 서비스가 각자 자기 리듬으로 배포하고 스케일링하니, 이 그룹은 사실상 쉴 틈이 없었습니다. 스테이징 한 곳에서는 지난 1시간 동안만 리밸런싱이 약 1,600건, 멈추는 구간 없이 분당 수백 건씩 파티션을 회수하고 다시 나눠주기를 반복하고 있었습니다. 프로덕션은 그보다 잔잔해서 30분에 14번, 대략 분당 한 번꼴이었습니다.
그렇게 리밸런싱이 쌓이고 쌓여서 그 카운터가 16,453이 된 겁니다. 프로덕션이 16,453, 스테이징 두 곳도 각각 4,668과 3,056으로, 셋 다 정상 범주를 한참 벗어나 있었습니다.
파티션이 옮겨 다니며 중복이 생긴다#
리밸런싱이 잦은 게 왜 실제 피해로 이어지는지가 다음 문제입니다. 매 리밸런싱마다 파티션 할당이 새로 계산되는데, 이때 같은 서비스 안에서도 파티션 소유권이 다른 인스턴스로 옮겨 다닙니다. generation이 16,463에서 16,467로 올라가는 짧은 사이에도 특정 서비스의 파티션이 이 pod에서 저 pod로 넘어가는 게 보였습니다.
문제는 offset 커밋 시점입니다. 컨슈머가 메시지를 처리하고 나서 처리한 지점(offset)을 커밋해야 "여기까지 읽었다"고 기록되는데, 처리 도중에 아직 offset을 커밋하기 전에 리밸런싱이 끼어들면 그 파티션은 회수돼서 다른 컨슈머에게 넘어갑니다. 넘겨받은 컨슈머는 마지막으로 커밋된 offset부터 다시 읽으니, 앞 컨슈머가 처리하던 그 메시지를 처음부터 또 처리합니다. 이게 중복 소비입니다. at-least-once 전달에서는 원래 일어날 수 있는 일이지만, 리밸런싱이 분당 수백 번 일어나는 그룹에서는 이 중복의 창이 상시 열려 있는 셈입니다.
프로덕션에서 실제로 터진 중복#
이게 이론에 그치지 않는다는 걸 프로덕션 로그가 보여줬습니다. 하루치를 놓고 보면 저녁 18~19시 구간에 리밸런싱이 1,485건으로 피크를 찍었는데, 바로 그 시간대에 평가 서비스의 중복 INSERT 에러가 12건 몰려 있었습니다. 18시 13분에 그 그룹의 generation이 16,453으로 sync된 시각과도 정확히 겹칩니다.
에러의 실체는 이랬습니다. 같은 요약 점수 이벤트가 두 번 INSERT되면서, 그 테이블에 걸린 복합 유니크 키 제약에 걸려 Duplicate entry ... for key '<복합 유니크 키>'가 반복해서 찍혔습니다. 앞 절의 경로 그대로입니다. 컨슈머 A가 이벤트를 처리하던 중 offset 커밋 전에 리밸런싱이 끼어들었고, 파티션이 B로 재할당됐고, B가 같은 이벤트를 다시 INSERT하다 유니크 키에 부딪힌 겁니다.
여기서 정직하게 선을 그어두고 싶은 게 두 가지 있습니다.
하나는 이 12건이 피해의 전부가 아니라는 점입니다. 이 에러는 DB에 유니크 키가 걸려 있어서 "두 번째 INSERT"가 튕겨 나온 덕에 로그로 잡힌 경우입니다. 반대로 유니크 키가 없는 부수효과, 그러니까 알림을 한 번 더 발송한다거나 외부 API를 다시 호출하는 처리는 두 번 실행돼도 에러 없이 그냥 조용히 두 번 실행되고 끝납니다. 로그로는 증명할 방법이 없고, "알림이 두 번 왔다"는 CS로나 뒤늦게 드러납니다. 그래서 로그로 관측된 중복은 빙산의 일각일 뿐, 실제 규모가 얼마인지는 저도 말할 수 없습니다.
다른 하나는 처리량 저하는 이번엔 증명하지 못했다는 점입니다. stop-the-world 리밸런싱이 잦으면 처리량이 떨어지는 게 원리상 당연한데, 하필 이번 피크가 트래픽이 적은 야간에 몰려서 원래 처리량 자체가 낮았습니다. 그러다 보니 리밸런싱으로 인한 처리 지연이 그래프에 잘 안 드러났습니다. 이번엔 운이 좋았을 뿐, 업무 시간에 배포까지 겹쳤다면 눈에 띄었을 겁니다. 구조상 위험은 그대로 남아 있습니다.
절반이 놀아도 리밸런싱 비용은 똑같다#
한 가지 더 있습니다. 앞서 range assignor는 자기 토픽을 구독하지 않은 멤버에겐 파티션을 안 준다고 했습니다. 이 메가 그룹에선 각 서비스가 자기 토픽만 구독하니, 프로덕션 약 90개 멤버 중 절반 가까이가 partitions=[], 즉 파티션을 하나도 못 받은 채 그룹에 들어와 있었습니다. 자기 차례가 아닌 토픽에 대해서는 빈손인 멤버들입니다.
그런데 이 빈손 멤버들도 리밸런싱이 돌 때는 전원 참여하고, heartbeat도 꼬박꼬박 보냅니다. 일은 절반만 하는데 리밸런싱 비용은 전원이 함께 냅니다. 게다가 멤버가 많을수록 한 번의 배포가 흔드는 반경도 커지니, 이 idle 멤버들이 폭발 반경을 키우는 데도 한몫하고 있었습니다.
왜 아무도 안 잡고 있었나#
여기까지 보면 의아합니다. generation이 16,453이고 중복까지 찍히는데, 왜 오랫동안 아무도 이걸 잡지 않았을까요.
가장 큰 이유는 겉으로 멀쩡히 동작했기 때문입니다. 라우팅은 정확했고 각 서비스는 자기 일을 하고 있었으며, 눈에 띄는 피크마저 트래픽 적은 야간에 몰려 있었으니, 굳이 이 시스템을 열어볼 이유가 없었습니다.
여기에 비용이 안 든다는 사정이 겹칩니다. 좀 반직관적인데, idle 멤버 40여 개는 별도로 띄운 pod가 아니라 각 서비스 프로세스 안의 컨슈머 스레드라, 멤버가 아무리 많아도 인프라 비용은 사실상 늘지 않습니다. 관리형 Kafka도 그룹 멤버 수가 아니라 토픽·파티션·스토리지를 기준으로 과금하니 한 그룹에 멤버가 90개든 9개든 청구서는 똑같고, 리밸런싱 RPC 오버헤드도 비용으로 치면 무시할 수준이죠. 이렇게 AWS 청구서에 아예 안 잡히는 문제이다 보니 비용 알람에도 안 걸리고 아무의 눈에도 안 띄었습니다. 리밸런싱은 비싸다는 통념이 있지만 적어도 돈으로는 안 비쌌고, 진짜 비싼 건 안정성과 데이터 정합성 쪽인데 그건 청구서에 안 나옵니다.
관측 창의 함정도 한몫했습니다. 도입부에서 최근 1시간만 봤을 땐 리밸런싱 0건이라 정상처럼 보였다가, 창을 24시간으로 넓히고서야 16,453이 드러났다고 했죠. 짧은 창이 조용한 걸 건강의 증거로 삼으면 안 되는데, 하필 그 1시간이 리밸런싱이 뜸한 구간이었을 뿐입니다.
한 가지 더, 헷갈리기 쉬운 지점이 있습니다. generation이 높다는 건 "이 그룹이 아프다"까지만 말해줍니다. 무슨 병인지는 별도로 봐야 합니다. 이번 그룹은 여러 서비스가 group id를 공유해서 서로를 흔드는 게 원인이었습니다. 남이 흔들어서 내가 리밸런싱되는, 폭발 반경 문제입니다. 그런데 조사하다 마주친 또 다른 그룹은 generation이 수백만까지 올라가 있었는데, 그건 성격이 전혀 달랐습니다. 단일 서비스가 혼자 쓰는 그룹인데, 컨슈머 처리가 너무 느려서 max.poll.interval.ms 안에 다음 poll을 못 하고 자기 자신이 죽었다고 판정돼 추방됐다가 다시 붙기를 반복하는, 이를테면 자기가 느려서 자기를 추방하는 문제였습니다. 겉으로 드러나는 증상(높은 generation)은 비슷해도 원인 축이 다르고, 그러니 처방도 다릅니다. generation만 보고 같은 병으로 뭉뚱그리면 엉뚱한 데를 고치게 됩니다.
group id를 서비스별로 나누면#
고치는 얘기는 이 글의 곁가지이지만, 원리를 확인했으니 처방도 원리에서 곧장 나옵니다.
근본 해법은 단순합니다. group id를 서비스별로 유일하게 주는 것입니다. 설정 한 줄, consumerGroup 대신 각 서비스가 자기만의 이름을 쓰면 됩니다. 그러면 여섯 서비스가 여섯 그룹으로 갈라지고, 어느 서비스가 배포하든 리밸런싱의 반경이 그 서비스 안으로 국한됩니다. 옆 팀 배포에 내 컨슈머가 멈추는 일이 사라지는 거죠.
이게 원리적으로 맞다는 방증은 같은 클러스터 안에 이미 있었습니다. 서비스별로 전용 group id를 제대로 쓰고 있던 다른 그룹들은 generation이 애초부터 낮은 대역에서 살고 있었습니다.
| group id 상태 | 관측된 generation |
|---|---|
| 서비스별 전용 group id | 대체로 수십 ~ 수백 이하 |
여섯 서비스가 공유한 consumerGroup | 16,453 |
오해를 막기 위해 분명히 해두면, 이 표는 우리가 고쳐서 내려간 before/after가 아닙니다. 전용 group id를 쓴 그룹들이 원래부터 이 낮은 대역에서 살고 있었다는 뜻이고, 그게 "분리하면 폭발 반경이 국한된다"는 원리의 방증일 뿐입니다.
분리 말고 다른 카드도 검토는 해봤는데, 대부분 근본 처방은 아니었습니다.
- 리밸런싱 전략을 cooperative(incremental) 방식으로 바꾸기. 지금은 eager로 보이는데, 그 stop-the-world 방식 대신 파티션을 조금씩만 회수해서 소비 중단을 줄이는 겁니다. 충격은 완화되지만, 여전히 여섯 서비스가 한 그룹이라 배포 하나에 전원이 흔들리는 구조 자체는 그대로입니다. 폭발 반경을 없애는 게 아니라 덜 아프게 만드는 완화책입니다.
- static membership(
group.instance.id) 도입. 멤버에 고정 ID를 줘서 롤링 재시작 때 불필요한 리밸런싱을 억제하는 방법입니다. 배포로 인한 흔들림은 줄지만 이것도 완화이고, 한 그룹이라는 사실은 바뀌지 않습니다. max.poll.records를 낮춰 처리 예산을 조절하기. 이건 앞에서 얘기한 "느린 컨슈머가 자기를 추방하는" 다른 축의 처방입니다. 우리 그룹은 처리가 느려서가 아니라 group id를 공유해서 흔들리는 것이라, 이 설정을 만져봐야 이번 문제와는 상관이 없습니다.
그래서 완화책들은 완화책으로 두고, 근본은 group id 분리로 가는 게 맞다고 봤습니다.
다만 정직하게 적어두면, 이 분리는 아직 다 못 했습니다. 원인 규명까지는 끝냈고 플랫폼 담당 쪽과 공유했지만, 실제로 group id를 서비스별로 갈라 배포하는 건 계획 단계입니다. 그러니 이 글에는 "고쳤더니 generation이 얼마로 떨어졌다" 같은 after 수치가 없습니다. 재미있는 건, 문제의 서비스 중 하나는 전용 그룹과 공유 그룹을 동시에 갖고 있었다는 점입니다. 할 줄 몰라서가 아니라, 알면서도 일관되게 적용하지 않아서 생긴 문제였던 셈입니다.
group id는 문자열이 아니라 격리 경계다#
이번에 다시 배운 건, group id는 그냥 겹치지만 않으면 되는 이름표가 아니라 격리 경계라는 점입니다. group id를 아무렇게나 정하는 순간 내 서비스는 같은 문자열을 쓴 남의 서비스와 리밸런싱 운명을 공유하게 되고, 옆 팀의 배포가 내 컨슈머를 멈추는 통로가 열립니다.
이번 일로 남은 건 몇 가지입니다. 라우팅이 맞고 겉으로 동작한다고 해서 괜찮은 게 아닙니다. 폭발 반경 같은 문제는 정상 동작 뒤에 숨어 있습니다. generation은 지문처럼 읽되, 높으면 일단 아프다고만 받아들이고 무슨 병인지는 group id 공유 여부로 한 번 더 확인해야 합니다. 그리고 최근 1시간이 조용한 걸 건강의 증거로 삼지 말고 관측 창을 넓혀서 봐야 합니다. 무엇보다, group id 유일성 같은 건 사람이 매번 신경 쓰기 어려우니 코드 리뷰 체크리스트나 린트 규칙으로 강제하는 편이 낫겠다고 생각했습니다. 문자열 하나 대충 정한 대가치고는 꽤 컸으니까요.