Kafka를 운영하다 보면 결국 마주하는 지표가 컨슈머 랙(consumer lag)입니다. 프로듀서가 쌓아 놓은 마지막 오프셋(LOG-END-OFFSET)과 컨슈머가 커밋한 오프셋(CURRENT-OFFSET)의 차이, 즉 “아직 처리하지 못한 메시지 수”입니다. 랙이 일정하면 건강하지만, 단조 증가하기 시작하면 컨슈머가 프로듀서를 따라잡지 못한다는 신호이고 방치하면 이벤트 지연이 눈덩이처럼 커집니다.
랙 문제는 대개 “컨슈머를 몇 개 더 띄우면 되겠지”로 접근했다가 실패합니다. 파티션 수가 병렬성의 상한을 정하고, 리밸런싱이 처리를 멈추게 하고, 오프셋 커밋 방식이 중복·유실을 만들기 때문입니다. 이 글에서는 랙의 원인을 파티션·리밸런싱·오프셋·컨슈머 처리의 네 축으로 나눠 진단하고 각 축의 튜닝을 정리합니다.
랙을 정확히 읽는 법
가장 먼저 할 일은 랙이 전체적으로 늘어나는지, 특정 파티션에만 쏠리는지 구분하는 것입니다. 원인과 처방이 다릅니다. 전체 증가는 처리량 부족(스케일·처리 로직)이고, 특정 파티션 쏠림은 키 분포 불균형(hot partition)입니다.
# 컨슈머 그룹의 파티션별 랙을 확인 — LAG 컬럼이 파티션마다 균등한지 본다
kafka-consumer-groups.sh --bootstrap-server localhost:9092
--group billing --describe
# 출력 예시
# TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG CONSUMER-ID
# orders 0 1043201 1043500 299 consumer-1-...
# orders 1 982110 1290004 307894 consumer-2-... ← 한 파티션에만 랙 집중
# orders 2 1120400 1120650 250 consumer-3-...
위 예시처럼 파티션 1에만 30만 건이 몰렸다면 컨슈머를 늘려도 소용없습니다. 그 파티션은 컨슈머 하나가 전담하므로 원인은 키 편향이거나 그 인스턴스만 느린 것입니다. 반대로 모든 파티션 랙이 고르게 우상향하면 총 처리량 부족이므로 스케일과 처리 로직을 봐야 합니다. 랙은 절대값보다 기울기가 중요하며, 랙 1만이라도 기울기가 양수면 결국 터지므로 변화율을 반드시 함께 봐야 합니다.
파티션 수가 병렬성의 상한이다
Kafka에서 한 파티션은 같은 컨슈머 그룹 안에서 오직 한 컨슈머에게만 할당됩니다. 파티션이 12개인 토픽에 컨슈머를 20개 띄우면 12개만 일하고 8개는 idle로 놀게 됩니다. 즉 병렬성의 상한이 파티션 수이므로, 처리량이 부족할 때 첫 번째로 확인할 것은 “파티션 수 >= 컨슈머 수”입니다. 파티션이 부족하면 컨슈머를 늘려도 처리량이 오르지 않습니다.
# 파티션은 늘릴 수만 있고 줄일 수는 없다 — 신중하게 산정
kafka-topics.sh --bootstrap-server localhost:9092
--alter --topic orders --partitions 24
# 주의: 파티션을 늘리면 key→partition 매핑(murmur2 해시 % 파티션수)이
# 바뀌므로, 같은 키가 기존과 다른 파티션으로 갈 수 있다.
# → 파티션 단위 순서 보장이 "증설 시점 경계"에서 깨진다.
파티션 산정 기준은 목표 처리량 / 파티션당 처리량입니다. 파티션당 초당 5,000건, 목표 초당 60,000건이면 최소 12개가 필요하며 피크 여유로 1.5~2배 잡습니다. 다만 파티션이 수천 개면 파일 핸들과 리밸런싱·복제 오버헤드가 커지므로 무한정 늘릴 수는 없습니다.
핫 파티션: 키 편향을 푸는 법
특정 파티션에만 랙이 쏠린다면 원인은 대개 키 카디널리티 부족이나 편향입니다. 주문 키를 country로 잡으면 트래픽이 한 국가에 90% 몰린 서비스에서는 그 파티션 하나가 전체 부하를 짊어집니다.
# 나쁜 예: country 를 키로 → 카디널리티 낮고 편향 심함
producer.send("orders", key=b"KR", value=event)
# 좋은 예: 순서가 order_id 단위로만 필요하다면 order_id 를 키로
producer.send("orders", key=order_id.encode(), value=event)
# 순서가 country 단위인데 편향을 완화하려면 복합 키 + 버킷으로 분산
bucket = hash(order_id) % 4 # 국가당 4개 버킷
producer.send("orders", key=f"{country}-{bucket}".encode(), value=event)
복합 키는 순서 보장 단위를 “국가 전체”에서 “국가+버킷”으로 낮추는 트레이드오프를 동반합니다. 주문 단위 순서만 지키면 되는 경우가 대부분이므로 이 방식으로 핫 파티션을 해소할 수 있습니다. 키 없이(key=null) 보내면 편향은 사라지지만 순서도 사라지므로 로그·메트릭에만 적합합니다.
리밸런싱: 처리를 멈추게 하는 주범
컨슈머 그룹의 멤버가 추가·제거되거나 세션이 만료되면 리밸런싱이 일어나 파티션을 재분배합니다. 문제는 기본 전략(eager)에서는 리밸런싱 동안 모든 컨슈머가 처리를 멈춘다(stop-the-world)는 점이며, 이 시간 동안 랙이 순수하게 증가합니다.
리밸런싱이 자주 튀는 흔한 원인은 max.poll.interval.ms 초과입니다. 한 배치 처리가 이 시간보다 오래 걸리면 브로커는 컨슈머가 죽었다고 판단해 쫓아내고, 남은 컨슈머에 부하가 몰려 다시 쫓겨나는 악순환(rebalance storm)이 생깁니다.
// 리밸런싱 안정화를 위한 핵심 설정
Properties props = new Properties();
// 한 번 poll 로 가져올 최대 레코드 수를 줄여 처리 시간을 예측 가능하게
props.put("max.poll.records", 200);
// 한 배치 처리에 허용하는 최대 시간 — 처리 로직 최악 시간보다 넉넉히
props.put("max.poll.interval.ms", 300000); // 5분
// 하트비트/세션: 세션 타임아웃은 하트비트의 3배가 관례
props.put("heartbeat.interval.ms", 3000);
props.put("session.timeout.ms", 45000);
// 협력적 리밸런싱: stop-the-world 대신 영향받는 파티션만 이동
props.put("partition.assignment.strategy",
"org.apache.kafka.clients.consumer.CooperativeStickyAssignor");
CooperativeStickyAssignor는 리밸런싱의 성격을 바꿉니다. eager 방식은 모든 파티션을 반납했다 다시 받지만, 협력적(incremental) 방식은 실제로 주인이 바뀌는 파티션만 잠깐 멈추므로 멤버가 자주 바뀌는 환경에서 랙 스파이크를 크게 줄여 줍니다.
group.instance.id를 부여하는 정적 멤버십도 유용합니다. 배포 재시작처럼 잠깐 나갔다 돌아오는 멤버가 세션 타임아웃 이내에 복귀하면 리밸런싱을 건너뛰므로, 롤링 배포가 잦은 서비스에서 불필요한 재분배를 크게 줄입니다.
오프셋 커밋: 중복과 유실 사이
오프셋을 언제 커밋하느냐가 장애 시 중복/유실을 결정합니다. 자동 커밋(enable.auto.commit=true)은 백그라운드에서 주기적으로 커밋하므로 “처리 완료”와 “커밋” 시점이 어긋납니다. 처리 전에 커밋이 나가면 장애 시 유실이, 처리 후 커밋 전에 죽으면 중복이 발생합니다.
# 수동 커밋: 처리 성공 후에만 커밋 → at-least-once 보장
consumer = KafkaConsumer(
"orders",
group_id="billing",
enable_auto_commit=False, # 자동 커밋 끔
max_poll_records=200,
)
for batch in iter_batches(consumer):
try:
process(batch) # 실제 비즈니스 처리
consumer.commit() # 처리가 끝난 뒤에만 오프셋 커밋
except Exception:
# 커밋하지 않음 → 다음 poll 에서 같은 오프셋부터 재처리
# 컨슈머는 반드시 멱등(idempotent)해야 한다
log.exception("batch failed, will reprocess")
raise
at-least-once가 기본이므로 컨슈머는 멱등하게 설계해야 합니다. 이벤트에 고유 ID를 부여하고 저장 시 “이미 처리한 ID면 무시”하는 방식으로 중복을 흡수합니다.
-- 멱등 처리의 전형: 이벤트 ID를 유니크 키로 두고 충돌은 무시
INSERT INTO processed_orders (event_id, order_id, amount)
VALUES ($1, $2, $3)
ON CONFLICT (event_id) DO NOTHING;
매 레코드마다 동기 커밋하면 정확하지만 브로커 왕복이 많아 처리량이 떨어집니다. 배치 단위로 커밋하되 멱등성으로 재처리를 흡수하는 것이 균형점입니다.
컨슈머 내부 처리 병목 풀기
파티션과 리밸런싱을 정리했는데도 랙이 남는다면 병목은 컨슈머 내부 처리 로직입니다. 흔한 패턴은 레코드마다 외부 API나 DB 쓰기를 동기·건별로 수행하는 것으로 네트워크 왕복 지연이 처리량을 지배합니다. 파티션을 늘리지 않고 처리량을 올리는 첫 방법은 배치 처리로, poll 로 받은 레코드를 모아 벌크 INSERT·벌크 API로 보냅니다.
# 건별 처리(느림) → 배치 벌크 처리(빠름)
records = consumer.poll(timeout_ms=1000, max_records=500)
rows = [to_row(r) for r in records]
db.bulk_insert("orders", rows) # 왕복 500회 → 1회
consumer.commit()
둘째는 파티션 내 처리 병렬화입니다. 순서 제약이 없다면 poll 한 배치를 워커 스레드 풀에 분산할 수 있으며, “배치 내 모든 작업이 끝난 뒤 커밋”을 지켜야 합니다.
- 순서가 필요한 작업: 병렬화 불가. 파티션 수 증설이 정석.
- 순서가 무관한 작업(멱등 upsert, 알림 발송 등): 배치를 워커 풀로 병렬 처리하면 파티션 증설 없이 처리량 향상.
운영 모니터링과 경보
랙은 반드시 추세 기반으로 경보해야 합니다. 절대값 임계치만 걸면 정상 급증 때도 울리고 서서히 무너지는 상황은 놓칩니다. Kafka Exporter로 파티션별 랙을 Prometheus에 수집하고 PromQL로 증가 추세를 잡는 것이 실무 표준입니다.
# Prometheus 경보: 총 랙이 15분간 지속 증가 + 임계치 초과 시 알람
groups:
- name: kafka-consumer-lag
rules:
- alert: ConsumerLagIncreasing
expr: |
sum by (consumergroup) (delta(kafka_consumergroup_lag[15m])) > 0
and
sum by (consumergroup) (kafka_consumergroup_lag) > 10000
for: 15m
labels: { severity: warning }
annotations:
summary: "컨슈머 랙 지속 증가: {{ $labels.consumergroup }}"
대시보드에는 최소한 파티션별 랙(핫 파티션 식별), 랙 변화율(기울기 부호), 리밸런싱 빈도(잦으면 세션 설정 점검), 배치 처리 p99(max.poll.interval.ms 근접 시 위험)를 둡니다.
마무리: 진단 순서
컨슈머 랙은 여러 층의 문제가 겹쳐 나타나므로 처방보다 진단 순서가 중요합니다. 랙이 전체적인지 특정 파티션인지 구분하고(핫 파티션이면 키 전략), 리밸런싱이 잦은지 확인하고(협력적 리밸런싱·정적 멤버십), 오프셋 커밋이 정확한지 점검하고(수동 커밋·멱등성), 마지막으로 컨슈머 처리를 배치·병렬로 최적화합니다.
“컨슈머를 늘리면 된다”는 파티션 수가 상한이라는 사실 앞에서 무너집니다. 네 축을 순서대로 짚으면 대부분의 랙은 무작정 스케일아웃하지 않고도 안정화됩니다. 랙은 절대값이 아니라 기울기로 보고, 기울기를 0 이하로 유지하는 것이 목표입니다.
자주 묻는 질문
Q. 컨슈머를 파티션 수보다 많이 띄웠는데 처리량이 안 오릅니다. 왜인가요?
A. 한 파티션은 그룹 내 한 컨슈머에게만 할당되므로 파티션 수를 초과한 컨슈머는 idle로 놀게 됩니다. 처리량을 늘리려면 파티션을 증설하거나, 순서가 무관한 작업이라면 컨슈머 내부에서 배치를 워커 풀로 병렬 처리하세요.
Q. 배포할 때마다 랙이 튀는데 정상인가요?
A. 롤링 배포 시 리밸런싱이 발생해 순간 랙이 오르는 것은 흔합니다. 스파이크가 크다면 CooperativeStickyAssignor로 stop-the-world를 피하고 group.instance.id로 정적 멤버십을 부여해 세션 타임아웃 이내 복귀 시 리밸런싱을 건너뛰게 하세요.
Q. 자동 커밋이 편한데 꼭 수동 커밋을 써야 하나요?
A. 유실을 감수할 수 있는 메트릭 수집이라면 자동 커밋도 괜찮습니다. 하지만 결제·주문처럼 유실·중복이 문제되는 이벤트는 처리 성공 후 수동 커밋이 안전합니다. at-least-once 특성상 재처리가 발생하므로 이벤트 ID 기반 멱등 처리를 반드시 함께 구현하세요.