Kafka의 로그 구조는 단순하다. 끝에 덧붙이고, 파티션으로 나누고, 컨슈머가 각자 읽는다. 그런데 운영에서 깨지는 건 이 단순함이 아니라 그 위에 얹힌 가정들이다.
“순서가 보장된다”, “한 번만 처리된다”, “컨슈머를 늘리면 빨라진다” — 이 문장들은 전부 조건부 참이다. 조건을 모르면 새벽에 알람을 받는다. 자주 밟는 함정을 왜 생기나 → 증상 → 어떻게 피하나 순서로 정리한다.
컨슈머가 계속 그룹에서 빠지고 리밸런싱이 반복된다
왜 생기나
컨슈머 그룹은 파티션을 컨슈머들에게 나눠준다. 누가 죽거나, 새로 들어오거나, 응답이 없으면 파티션을 다시 분배한다 — 이게 리밸런싱이다.
문제는 “응답이 없다”의 판정이다. 컨슈머는 poll()을 주기적으로 호출해야 살아있다고 인정받는다. max.poll.interval.ms(기본 5분) 안에 다음 poll()을 호출하지 못하면 브로커는 그 컨슈머를 죽은 것으로 보고 추방한다.
max.poll.records=500으로 한 번에 500개를 가져왔는데, 한 건 처리에 외부 API 호출이 끼어 1건당 1초씩 걸린다면? 500초 = 8분이 넘어 5분을 초과한다. 컨슈머는 멀쩡히 일하고 있는데 추방당한다. 추방되면 파티션이 재분배되고, 처리 중이던 레코드는 다른 컨슈머가 다시 가져가고, 그 컨슈머도 같은 이유로 또 추방된다 — 리밸런싱 스톰이다.
처리 지연 → max.poll.interval.ms 초과 → 추방 → 리밸런싱
→ 파티션 재분배 → 다른 컨슈머가 같은 부하 떠안음 → 또 초과 → 또 추방
증상
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
--describe --group my-group
# CONSUMER-ID가 describe할 때마다 바뀐다
# 같은 파티션의 owner가 계속 교체됨
# 처리량은 떨어지는데 CPU는 한가함 (리밸런싱 중에는 소비가 멈춘다)
리밸런싱 중에는 해당 파티션의 소비가 멈춘다. 스톰이 계속되면 사실상 컨슈머 그룹 전체가 일을 못 한다.
어떻게 피하나
근본 원인은 “한 번 가져온 양을 처리 시간 안에 못 끝내는 것”이다. 세 방향이 있다.
spring:
kafka:
consumer:
max-poll-records: 100 # 500 → 100, 한 번에 가져오는 양을 줄인다
properties:
max.poll.interval.ms: 600000 # 처리가 정말 오래 걸리면 한도를 늘린다
max.poll.records줄이기: 한 번에 가져오는 양을 줄이면 처리 주기가 짧아진다. 가장 먼저 시도할 것.- 처리 시간 단축: 외부 호출을 배치/비동기로 묶거나, 무거운 작업은 별도 스레드풀로 빼고
poll()루프는 빠르게 돈다. max.poll.interval.ms늘리기: 처리가 본질적으로 오래 걸린다면 한도 자체를 올린다. 단, 진짜로 죽은 컨슈머를 감지하는 시간도 같이 늘어난다.
그리고 리밸런싱 방식 자체를 바꾼다. 기본 eager 방식은 리밸런싱 때 모든 컨슈머가 파티션을 전부 반납하고 처음부터 다시 나눈다(stop-the-world). cooperative(incremental) 리밸런싱은 영향받는 파티션만 옮기고 나머지는 계속 소비한다.
spring:
kafka:
consumer:
properties:
partition.assignment.strategy: org.apache.kafka.clients.consumer.CooperativeStickyAssignor
CooperativeStickyAssignor는 가능한 한 기존 할당을 유지하면서 필요한 만큼만 옮긴다. 스톰의 충격을 크게 줄인다. (Kafka Streams는 3.x부터 이미 cooperative가 기본이다.)
같은 메시지가 두 번 처리된다
왜 생기나
Kafka 컨슈머의 기본 보장은 at-least-once(최소 한 번)다. “최소 한 번”은 “한 번 이상”이라는 뜻이고, 두 번 이상도 포함한다.
컨슈머는 메시지를 처리한 뒤 offset을 커밋한다. 처리는 끝났는데 offset 커밋 직전에 컨슈머가 죽으면, 재시작한 컨슈머는 커밋된 offset이 없으니 그 메시지를 다시 가져온다. 이미 처리한 걸 또 처리한다.
메시지 수신 → 비즈니스 처리 완료 → (offset 커밋 직전 죽음)
→ 재시작 → 커밋 안 된 offset부터 다시 읽음 → 같은 메시지 재처리
리밸런싱도 같은 창을 만든다. 마지막 커밋 이후 처리한 메시지들은 파티션을 넘겨받은 컨슈머가 다시 읽는다.
증상
- 같은 주문이 두 번 적재되거나, 알림이 두 번 발송되거나, 잔액이 두 번 차감된다.
- Sink DB에서 PK 중복 에러가 나거나, 제약이 없으면 조용히 중복 행이 쌓인다.
어떻게 피하나
여기서 흔히 헷갈리는 게 프로듀서 쪽 중복과 컨슈머 쪽 중복을 한데 묶는 것이다. 둘은 별개다.
프로듀서 쪽은 enable.idempotence가 해결한다. 이건 Kafka 3.0+에서 기본값이 true다. 멱등 프로듀서는 재시도(retries)로 같은 배치가 두 번 전송돼도 브로커가 시퀀스 번호로 걸러내, 프로듀서가 브로커에 적재하는 단계의 중복과 순서를 보장한다. 즉 “send 재시도 때문에 토픽에 같은 레코드가 두 벌 쌓이는” 문제는 기본적으로 막혀 있다.
하지만 이건 컨슈머가 두 번 읽는 것까지 막아주지 않는다. 컨슈머 쪽 중복은 컨슈머가 멱등하게 처리하는 수밖에 없다. 같은 메시지를 두 번 처리해도 결과가 같도록 만든다.
// 멱등 처리 — 메시지 ID로 중복을 걸러낸다
@KafkaListener(topics = "orders")
public void handle(OrderEvent event) {
// 이미 처리한 이벤트면 무시 (UNIQUE 제약 또는 사전 조회)
if (processedRepository.existsByEventId(event.getEventId())) {
return;
}
orderService.apply(event);
processedRepository.save(event.getEventId()); // 같은 트랜잭션 안에서
}
- 자연 키로 upsert:
INSERT ... ON DUPLICATE KEY UPDATE처럼 PK가 같으면 덮어쓰게 한다. 두 번 와도 상태가 같다. (Kafka Connect JDBC Sink의insert.mode: upsert가 바로 이 방식이다 → Kafka Connect 글) - 처리 이력 테이블: 이벤트 ID를 기록하고, 이미 있으면 건너뛴다.
진짜 exactly-once가 필요하면 Kafka 트랜잭션 기반 EOS(Exactly-Once Semantics)가 있다. read_process_write 패턴(Kafka에서 읽어 Kafka로 쓰는 경우)에서 컨슈머 offset 커밋과 프로듀서 전송을 하나의 트랜잭션으로 묶는다. 하지만 처리 결과가 외부 DB·외부 API로 나간다면 그쪽까지 트랜잭션에 넣을 수 없으므로, 결국 멱등 처리가 가장 현실적인 해법이다.
순서가 보장된다더니 뒤죽박죽이다
왜 생기나
Kafka의 순서 보장은 파티션 내부에서만 성립한다. 토픽 전체 순서가 아니다.
토픽에 파티션이 3개면 메시지는 3개 파티션에 나뉘어 들어간다. 각 파티션 안에서는 들어온 순서대로 읽히지만, 파티션끼리는 순서 관계가 없다. 컨슈머 3개가 3개 파티션을 동시에 읽으면 전역 순서는 의미가 없어진다.
같은 주문 1001의 이벤트가 파티션에 흩어지면:
파티션 0: [created(1001)]
파티션 1: [paid(1001)] ← 다른 컨슈머가 먼저 읽을 수 있음
파티션 2: [shipped(1001)]
→ paid가 created보다 먼저 처리될 수 있다
같은 엔티티의 이벤트가 서로 다른 파티션에 들어가면 처리 순서가 뒤집힌다.
증상
- “결제 완료”가 “주문 생성”보다 먼저 처리돼 외래키 에러가 난다.
- 상태 머신이 꼬인다.
shipped다음에pending이 와서 상태가 거꾸로 간다.
어떻게 피하나
같은 엔티티는 같은 키로 파티셔닝한다. 프로듀서는 메시지 키의 해시로 파티션을 정한다. 키가 같으면 항상 같은 파티션으로 간다.
// 주문 ID를 키로 → 같은 주문의 이벤트는 항상 같은 파티션
producer.send(new ProducerRecord<>("orders", order.getId().toString(), event));
주문 1001의 모든 이벤트가 같은 파티션에 순서대로 쌓이고, 한 컨슈머가 그 파티션을 순서대로 읽는다. 주문 간 순서는 어차피 상관없으니 전역 순서는 필요 없다.
전역 순서가 정말 필요하면 파티션을 1개로 둘 수밖에 없는데, 그러면 병렬성이 사라져 처리량이 죽는다. 대부분의 경우 필요한 건 전역 순서가 아니라 “엔티티 단위 순서”다. 키 설계로 푼다.
파티션 수를 늘렸더니 순서가 깨졌다
왜 생기나
이건 위의 순서 보장과 직접 엮인 함정이다. 프로듀서의 기본 파티셔너는 hash(key) % partitionCount로 파티션을 정한다. 여기에 partitionCount가 들어있다.
파티션 수를 바꾸면 같은 키의 목적지가 바뀐다.
파티션 6개일 때: hash("1001") % 6 = 3 → 파티션 3
파티션 8개로 증설: hash("1001") % 8 = 1 → 파티션 1
→ 증설 시점 이전 주문 1001 이벤트는 파티션 3에,
이후 이벤트는 파티션 1에 쌓인다
→ 같은 엔티티의 이벤트가 두 파티션에 흩어져 순서 보장이 깨진다
1만 TPS 글에서 다뤘듯 파티션은 늘릴 수는 있어도 줄일 수는 없다. 그런데 늘리는 것조차 키 기반 순서를 쓰는 토픽에서는 위험하다.
증상
- 파티션 증설 직후부터 특정 엔티티의 이벤트 순서가 어긋난다.
- 증설 전후 경계에 걸친 엔티티에서만 산발적으로 순서 문제가 나타나 재현이 어렵다.
어떻게 피하나
- 키 기반 순서가 중요한 토픽은 파티션 수를 처음에 넉넉히 잡는다. 나중에 바꾸지 않는 게 최선이다.
- 증설이 불가피하면, 진행 중인 엔티티가 양쪽 파티션에 동시에 존재하지 않도록 드레인(기존 이벤트 소비 완료) 후 전환하는 절차가 필요하다. 무중단으로 안전하게 하기는 까다롭다.
- 정 필요하면 커스텀 파티셔너로 파티션 수와 무관한 매핑(예: 일관성 해싱)을 쓸 수도 있지만 복잡도가 올라간다.
처음 파티션 수를 정할 때 “현재 처리량”이 아니라 “예상 최대 컨슈머 수”를 기준으로 잡는 게 이 함정을 피하는 길이다.
특정 파티션만 부하가 몰린다 — 파티션 스큐
왜 생기나
키로 파티셔닝하면 같은 키가 같은 파티션으로 간다. 그런데 특정 키의 트래픽이 압도적으로 많으면(핫 키), 그 파티션 하나만 폭주한다.
키 = 가맹점 ID로 파티셔닝
대형 가맹점 A: 전체 트래픽의 70%
→ A가 매핑된 파티션 하나가 70% 부하
→ 나머지 파티션은 놀고, 그 파티션 담당 컨슈머만 죽어난다
널 키도 문제다. 키 없이 보내면(null), 프로듀서는 sticky partitioning으로 한 파티션에 배치 단위로 몰아 보내는 경향이 있어 분포가 고르지 않을 수 있다. 키 설계를 잘못해 카디널리티가 낮아도(예: 키가 “지역”이라 값이 5개뿐) 파티션이 골고루 안 쓰인다.
증상
- 파티션별 메시지 수/바이트가 크게 불균형하다.
- 특정 파티션의 컨슈머 랙만 계속 증가한다. 전체 평균은 멀쩡해 보여서 놓치기 쉽다.
# 파티션별 오프셋(메시지 수) 확인 — 편중 여부
kafka-run-class.sh kafka.tools.GetOffsetShell \
--broker-list localhost:9092 --topic orders --time -1
# 파티션별 값 차이가 크면 스큐
어떻게 피하나
- 키 카디널리티를 높인다. 핫 키가 있으면
가맹점ID단독 대신가맹점ID + 일자같은 복합 키로 분산하거나, 핫 키에 한해가맹점ID#0~N샐러드 키를 붙여 여러 파티션으로 흩는다. 단, 같은 엔티티 순서 보장이 필요하면 분산과 순서가 충돌하므로 트레이드오프를 따져야 한다. - 널 키를 피한다. 순서가 필요 없더라도 분산이 중요하면 분산용 키를 명시한다.
- 모니터링은 전체 랙이 아니라 파티션별 랙을 본다. 평균만 보면 한 파티션의 폭주를 놓친다.
컨슈머 랙이 계속 쌓인다
왜 생기나
생산 속도가 소비 속도보다 빠르면 차이가 누적된다. 이게 컨슈머 랙(consumer lag) — “프로듀서가 쓴 최신 offset”과 “컨슈머가 커밋한 offset”의 차이다.
생산 12,000/s, 소비 9,000/s
→ 초당 3,000개씩 랙 증가
→ 방치하면 랙이 수백만까지 쌓이고, 컨슈머는 점점 과거 데이터를 처리
랙 자체는 일시적 스파이크면 정상이다. 문제는 줄지 않고 추세적으로 증가할 때다. 소비가 생산을 구조적으로 못 따라가고 있다는 신호다.
증상
records-lag-max가 우상향으로 계속 증가한다.- 실시간이어야 할 처리가 점점 지연된다(이벤트 발생과 처리 사이 시차 확대).
- retention에 걸려 미처리 데이터가 삭제되기 시작한다(아래 함정과 연결된다).
어떻게 피하나
핵심 지표는 **records-lag-max**다. JMX의 consumer-fetch-manager-metrics 그룹에 있고, Micrometer는 kafka_consumer_fetch_manager_records_lag_max로 노출한다. 이 값의 추세에 알람을 건다(절대값보다 증가 추세가 중요).
소비 처리량을 올리는 방향:
- 컨슈머 인스턴스를 늘린다. 단, 파티션 수가 상한이다. 파티션 10개에 컨슈머 20개를 띄우면 10개는 논다. 늘리려면 파티션부터 봐야 한다.
- 처리 로직을 최적화한다. 건당 외부 호출을 배치로 묶거나, I/O를 병렬화한다.
- 프로듀서 폭주가 일시적이면 컨슈머가 따라잡을 때까지 버틸 수 있는지(retention 여유) 확인한다.
랙 알람은 Kafka 운영에서 가장 중요한 알람이다. 랙이 터지면 그 뒤에 retention 소실, 처리 지연 누적 같은 2차 피해가 따라온다.
처리하기도 전에 데이터가 사라졌다 — retention
왜 생기나
Kafka는 컨슈머가 읽었는지와 무관하게 보존 정책(retention)에 따라 데이터를 지운다. 시간 기준(log.retention.hours, 기본 168시간=7일)과 크기 기준(log.retention.bytes)이 있고, 둘 중 하나라도 초과하면 오래된 세그먼트가 삭제된다.
retention 7일 설정
+ 컨슈머가 장애로 8일간 멈춤
→ 멈춰있는 동안 가장 오래된 데이터가 retention에 걸려 삭제됨
→ 컨슈머가 살아나도 그 데이터는 이미 없다. 영구 유실.
랙이 폭증해 컨슈머가 한참 뒤처진 상태에서도 같은 일이 난다. 컨슈머가 도달하기 전에 retention이 앞에서 데이터를 지운다.
증상
에러: Offset out of range / 컨슈머가 읽으려는 offset이 이미 삭제됨
→ auto.offset.reset 정책에 따라 latest(최신부터, 중간 데이터 건너뜀)
또는 earliest(남은 가장 오래된 것부터)로 점프
조용히 데이터가 비는 게 더 위험하다. auto.offset.reset=latest면 사라진 구간을 건너뛰고 최신부터 읽어, 에러 없이 데이터만 빠진다.
어떻게 피하나
- retention을 소비 지연 최악 시나리오보다 길게 잡는다. 컨슈머가 며칠 죽어도 버틸 만큼 여유를 둔다.
- 랙과 retention을 함께 본다. “랙으로 쌓인 미처리량이 retention 안에 들어오는가”를 감시한다.
- 절대 지우면 안 되는 토픽(예: Connect의
schema.history,connect-offsets)은retention.ms=-1(무한) 또는cleanup.policy=compact로 둔다 → Kafka Connect 글 - 디스크 용량 알람을 걸어, retention을 크게 잡았을 때 디스크가 먼저 차서 브로커가 죽는 일을 막는다.
acks=all인데도 데이터가 날아갔다 — min.insync.replicas와 unclean election
왜 생기나
내구성은 acks 하나로 결정되지 않는다. acks=all은 “ISR(In-Sync Replicas) 전체가 받으면 ack”인데, ISR이 몇 개인지가 빠져 있다.
복제본 3개 토픽에서 2개가 뒤처져 ISR이 1개(리더만)로 줄었다고 하자. 이때 acks=all은 “ISR 전체” = 리더 하나만 받으면 ack를 준다. 리더가 죽으면 그 데이터는 사라진다. acks=all을 믿었는데 사실상 acks=1이었던 셈이다.
acks=all + min.insync.replicas 설정 없음(기본 1)
ISR이 리더 1개로 축소 → 리더만 받고 ack → 리더 사망 → 유실
여기에 unclean leader election이 겹치면 더 나쁘다. ISR에 없는(즉 뒤처진) 복제본을 리더로 승격시키는 옵션이다. 켜져 있으면 데이터가 덜 복제된 팔로워가 리더가 되면서, 그 사이 차이만큼 데이터가 날아간다. 대신 가용성은 올라간다(리더 후보가 없어 멈추는 일을 막는다).
증상
- ack를 받은 메시지인데 장애 후 사라진다.
- ISR이 자주 1로 줄어든다(팔로워 복제 지연).
어떻게 피하나
acks=all과 min.insync.replicas를 함께 설정한다.
# 브로커/토픽 설정
min.insync.replicas=2 # ISR이 2개 미만이면 쓰기 거부
unclean.leader.election.enable=false # 뒤처진 복제본을 리더로 승격하지 않음
# 프로듀서
acks=all
min.insync.replicas=2 + 복제본 3개 + acks=all이면, 최소 2개가 받아야 ack가 난다. 1개가 죽어도 데이터는 다른 1개에 남는다. ISR이 2개 미만이면 프로듀서는 NotEnoughReplicas 에러를 받고 쓰기가 막힌다 — 유실 대신 가용성을 포기하는 선택이다.
이건 트레이드오프다.
| 설정 | 내구성 | 가용성 |
|---|---|---|
acks=1 | 낮음 (리더만 받고 ack) | 높음 |
acks=all + min.insync.replicas=2 | 높음 | 낮음 (ISR 부족 시 쓰기 거부) |
unclean.leader.election=true | 낮음 (뒤처진 복제본 승격 시 유실) | 높음 |
금융 데이터처럼 유실이 치명적이면 내구성 쪽, 로그·메트릭처럼 약간의 유실이 허용되면 가용성 쪽으로 기운다. 단일 브로커에서는 복제본이 1개뿐이라 이 설정들이 의미가 없다 — 내구성은 브로커를 늘려야 생긴다 → 1만 TPS 글
큰 메시지를 보냈더니 한쪽에서 막힌다
왜 생기나
메시지 크기 한도는 프로듀서·브로커·컨슈머에 각각 있다. 이 값들이 정합하지 않으면 한 단계에서 막힌다.
- 브로커
message.max.bytes(기본 ~1MB): 브로커가 받는 단일 레코드(배치) 최대 크기. 넘으면 거부. - 토픽
max.message.bytes: 토픽 단위 한도. 브로커 값을 토픽별로 덮어쓴다. - 프로듀서
max.request.bytes(기본 ~1MB): 프로듀서가 한 요청으로 보내는 최대 크기. - 컨슈머
max.partition.fetch.bytes(기본 ~1MB): 컨슈머가 파티션당 한 번에 가져오는 최대 크기.
프로듀서나 브로커는 큰 메시지를 받았는데 컨슈머의 max.partition.fetch.bytes가 작으면, 컨슈머가 그 메시지를 가져오지 못해 멈춘다.
2MB 메시지 전송
프로듀서 max.request.bytes=3MB → OK
브로커 message.max.bytes=3MB → OK, 저장됨
컨슈머 max.partition.fetch.bytes=1MB → 2MB를 못 가져옴 → 그 파티션에서 정체
증상
- 프로듀서:
RecordTooLargeException. - 컨슈머: 특정 파티션에서 진행이 멈춘다(큰 메시지를 못 넘김).
어떻게 피하나
세 단계 한도를 일관되게 맞춘다.
# 브로커
message.max.bytes=3145728 # 3MB
# 토픽 (브로커 값을 덮어씀)
max.message.bytes=3145728
# 프로듀서
max.request.size=3145728
# 컨슈머
max.partition.fetch.bytes=3145728
다만 큰 메시지를 Kafka로 흘리는 것 자체가 안티패턴에 가깝다. 메시지가 크면 배치 효율이 떨어지고, page cache를 빠르게 소모하며, 복제 부하가 커진다. 본문은 S3 같은 외부 스토리지에 두고 Kafka에는 참조(URL/키)만 보내는 claim-check 패턴이 보통 낫다. 한도를 올리기 전에 정말 큰 메시지를 보내야 하는지부터 따진다.
Kafka의 함정은 대부분 “기본 가정이 조건부였다”는 데서 온다.
순서는 파티션 안에서만, 처리는 최소 한 번(중복 가능), 내구성은 acks만으로 부족, 컨슈머는 파티션 수까지만 늘어난다. 이 조건들을 키 설계·멱등 처리·min.insync.replicas·파티션별 랙 모니터링으로 메우는 게 운영의 실체다. 단순한 건 로그 구조이지, 그 위의 분산 시스템이 아니다.