Kafka 유의사항 — 리밸런싱·중복·파티션 스큐

Kafka의 로그 구조는 단순하다. 끝에 덧붙이고, 파티션으로 나누고, 컨슈머가 각자 읽는다. 그런데 운영에서 깨지는 건 이 단순함이 아니라 그 위에 얹힌 가정들이다.

“순서가 보장된다”, “한 번만 처리된다”, “컨슈머를 늘리면 빨라진다” — 이 문장들은 전부 조건부 참이다. 조건을 모르면 새벽에 알람을 받는다. 자주 밟는 함정을 왜 생기나 → 증상 → 어떻게 피하나 순서로 정리한다.

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=allmin.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·파티션별 랙 모니터링으로 메우는 게 운영의 실체다. 단순한 건 로그 구조이지, 그 위의 분산 시스템이 아니다.