Published on

Kafka Exactly-Once 깨질 때 진단 7단계

Authors

Exactly-Once가 깨졌다는 말은 보통 다음 중 하나로 관측됩니다.

  • 동일 이벤트가 두 번 처리됨(duplicate)
  • 이벤트가 처리되지 않고 빠짐(loss)
  • 처리 순서가 뒤집힘(reorder)
  • 재시작 이후 특정 구간이 반복 처리됨(replay)

Kafka의 Exactly-Once는 단일 옵션 하나로 끝나지 않습니다. producer 트랜잭션, 브로커의 transaction coordinator, 컨슈머의 오프셋 커밋, 그리고 최종 싱크(예: DB, 검색 인덱스, 외부 API)의 멱등성까지 한 체인으로 맞아야 “실질적인” Exactly-Once가 됩니다.

아래 7단계는 장애/중복 처리 이슈가 발생했을 때, 아키텍처 관점에서 어디부터 무엇을 확인해야 하는지 순서대로 정리한 진단 루틴입니다.

1단계: “Exactly-Once”의 범위를 먼저 정의하기

가장 흔한 실패는 기술적 구현보다 “정의의 불일치”입니다. 팀마다 Exactly-Once를 다르게 말합니다.

  • Kafka 내부에서만 Exactly-Once인가
  • consume -> process -> produce 파이프라인까지 포함하는가
  • DB 반영까지 포함하는가

예를 들어 Kafka Streams는 트랜잭션과 오프셋 커밋을 묶어 EOS를 제공하지만, 외부 DB에 쓰는 순간 DB가 멱등하지 않으면 “전체 시스템” 관점에서는 Exactly-Once가 아닙니다.

확인 질문

  • 중복이 발생한 지점이 Kafka 토픽인가, DB인가, 다운스트림 API인가
  • 중복의 기준 키는 무엇인가(예: eventId, orderId, (partition, offset))

최소 권장 로그 필드

  • topic, partition, offset
  • producerId 또는 transactionalId
  • 비즈니스 키(예: eventId)

2단계: 증상을 “중복 vs 유실 vs 재처리”로 분류하기

같은 “EOS 깨짐”이라도 원인이 완전히 다릅니다.

  • 중복(duplicate): 재시도, 타임아웃, 리밸런스, 커밋 시점 오류
  • 유실(loss): acks/min.insync.replicas 설정 문제, 잘못된 오프셋 커밋, 로그 보존/컴팩션 오해
  • 재처리(replay): 커밋 누락, 트랜잭션 abort, consumer group 재조인

빠른 분류 방법

  • 동일 eventId가 여러 번 처리됐고, 처리 로그에 서로 다른 (partition, offset)이 찍히면 “생산 중복” 또는 “업스트림 중복 이벤트” 가능성이 큽니다.
  • 동일 (partition, offset)이 두 번 처리되면 “컨슈머 측 재처리” 가능성이 큽니다(리밸런스, 커밋 시점).

3단계: Producer 트랜잭션 설정과 타임아웃 체인 점검

Kafka에서 EOS의 핵심은 idempotent producertransactional producer입니다. 여기서 조금만 어긋나도 중복 또는 유실처럼 보이는 현상이 생깁니다.

Producer 필수 점검 항목

  • enable.idempotence=true
  • acks=all
  • retries 충분히 큼(사실상 무한에 가깝게)
  • max.in.flight.requests.per.connection 값(순서/중복에 영향)
  • transactional.id가 인스턴스 재시작/스케일링에서 안정적인지

transactional.id가 매 배포마다 랜덤하게 바뀌면, 브로커는 새 트랜잭션 프로듀서를 “다른 주체”로 보고 이전 세션과의 연속성이 끊깁니다. 반대로 동일 transactional.id를 서로 다른 인스턴스가 동시에 쓰면 ProducerFencedException이 발생하고, 그 후 재처리/중복이 연쇄적으로 나타날 수 있습니다.

Java Producer 예시(트랜잭션)

Properties props = new Properties();
props.put("bootstrap.servers", "kafka:9092");
props.put("enable.idempotence", "true");
props.put("acks", "all");
props.put("retries", Integer.toString(Integer.MAX_VALUE));
props.put("max.in.flight.requests.per.connection", "5");
props.put("transactional.id", "payments-tx-01");

KafkaProducer<String, String> producer = new KafkaProducer<>(props);
producer.initTransactions();

producer.beginTransaction();
try {
  producer.send(new ProducerRecord<>("payments", "k1", "v1")).get();
  producer.commitTransaction();
} catch (Exception e) {
  producer.abortTransaction();
}

타임아웃 체인에서 자주 생기는 함정

  • transaction.timeout.ms가 너무 짧으면 처리 중 트랜잭션이 만료되어 abort되고, 재처리로 이어집니다.
  • 네트워크 지연이나 DNS 이슈로 coordinator 통신이 흔들리면 트랜잭션 상태가 불안정해집니다.

쿠버네티스 환경에서 네트워크/DNS 문제가 트랜잭션 코디네이터 통신에 영향을 주는 경우가 있습니다. 클러스터 레벨의 네트워크 장애 가능성도 함께 점검하세요. 필요하면 EKS CoreDNS CrashLoopBackOff - upstream 타임아웃 해결 같은 체크리스트로 DNS 레이어부터 안정화하는 것이 우선일 때도 있습니다.

4단계: Broker 측 트랜잭션/ISR/복제 설정 점검

Producer가 아무리 올바르게 설정돼도, 브로커의 내구성 설정이 약하면 “커밋됐다고 믿었는데 사라짐” 같은 유실 패턴이 나옵니다.

핵심 브로커 설정/상태

  • min.insync.replicas
  • 토픽별 replication.factor
  • 토픽별 unclean.leader.election.enable (가능하면 비활성)
  • 컨트롤러/브로커 장애 시 ISR 축소가 빈번한지

acks=all인데도 유실이 의심되면, ISR이 1로 떨어지는 순간이 있었는지(브로커 장애, 네트워크 분리, 디스크 문제)를 먼저 봐야 합니다.

운영 점검 명령 예시

# 토픽 파티션/리플리카 상태
kafka-topics --bootstrap-server kafka:9092 --describe --topic payments

# 컨슈머 그룹 지연/파티션 할당
kafka-consumer-groups --bootstrap-server kafka:9092 --describe --group payments-cg

5단계: Consumer 오프셋 커밋 모델이 EOS와 일치하는지 확인

중복의 가장 큰 원인은 “처리는 했는데 커밋이 안 됨” 또는 “커밋은 했는데 처리가 완결되지 않음”의 순서 문제입니다.

대표적인 안티패턴

  • enable.auto.commit=true 상태에서 비즈니스 처리 수행
  • DB write 성공 전에 오프셋을 먼저 커밋
  • 배치 처리 중 일부만 성공했는데 전체 오프셋을 커밋

수동 커밋의 기본 원칙

  • 처리(부작용 포함)가 성공한 뒤 커밋
  • 실패 시 커밋하지 않고 재처리하되, 멱등으로 흡수

Java Consumer 수동 커밋 예시

props.put("enable.auto.commit", "false");

while (true) {
  ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
  for (ConsumerRecord<String, String> r : records) {
    process(r); // DB write or downstream call
  }
  consumer.commitSync();
}

여기서 process(r)가 멱등하지 않으면 “정확히 한 번”은 불가능합니다. Kafka는 오프셋을 ‘정확히 한 번’ 전달해주는 것이 아니라, 재처리 가능성을 줄여주는 수준이기 때문입니다.

6단계: consume -> process -> produce 트랜잭션 경계 재점검

가장 이상적인 EOS 파이프라인은 “입력 오프셋 커밋”과 “출력 토픽 write”를 하나의 트랜잭션으로 묶는 것입니다.

패턴 A: Kafka Streams 또는 트랜잭션 프로듀서로 출력과 커밋을 결합

  • 입력 레코드 처리
  • 출력 토픽에 produce
  • sendOffsetsToTransaction으로 오프셋을 트랜잭션에 포함
  • 커밋

트랜잭션에 오프셋 포함 예시

producer.beginTransaction();
try {
  // produce output
  producer.send(new ProducerRecord<>("payments-out", key, value));

  // commit consumed offsets as part of the transaction
  Map<TopicPartition, OffsetAndMetadata> offsets = currentOffsets();
  producer.sendOffsetsToTransaction(offsets, consumer.groupMetadata());

  producer.commitTransaction();
} catch (Exception e) {
  producer.abortTransaction();
}

이 경계가 깨지면 다음이 발생합니다.

  • 출력은 됐는데 오프셋 커밋이 안 돼서 재처리(출력 중복)
  • 오프셋은 커밋됐는데 출력이 안 돼서 유실

패턴 B: 외부 DB를 쓰는 경우(Outbox 또는 멱등 키)

Kafka 트랜잭션은 DB 트랜잭션과 원자적으로 묶을 수 없습니다. 이때는 다음 중 하나가 필요합니다.

  • Outbox 패턴: DB에 이벤트를 함께 저장하고, 별도 릴레이가 Kafka로 발행
  • 멱등 테이블: eventId에 유니크 제약을 걸고 중복 삽입을 무시

DB 락/지연이 길어지면 커밋 타이밍이 흔들려 재처리 빈도가 증가합니다. 특히 Postgres에서 autovacuum 정체나 테이블 bloat가 있으면 처리 시간이 늘어나 트랜잭션 타임아웃과 결합해 문제를 키웁니다. 관련해서 Postgres VACUUM이 안 도는 이유 - wraparound·락도 함께 점검하면 원인 규명이 빨라집니다.

7단계: 리밸런스, 배포, 장애 복구 시나리오로 “재현 가능한” 검증하기

EOS가 깨지는 순간은 정상 시보다 “변화 이벤트”에서 자주 발생합니다.

  • 컨슈머 스케일 인/아웃
  • 롤링 배포
  • 브로커 재시작
  • 네트워크 파티션

체크리스트

  • cooperative rebalancing을 쓰는지(불필요한 파티션 회수를 줄임)
  • max.poll.interval.ms가 처리 시간보다 충분히 큰지
  • session.timeout.ms와 하트비트 설정이 환경에 맞는지
  • 배포 시 transactional.id 충돌이 없는지(동일 ID 동시 실행 방지)

장애 재현 테스트 예시(개념)

  • 컨슈머에 인위적으로 sleep을 넣어 max.poll.interval.ms 초과 유도
  • 네트워크 차단으로 coordinator 접근 실패 유도
  • 파드 강제 재시작으로 fencing/abort 발생 여부 확인

쿠버네티스에서 이런 이벤트는 GitOps 배포 드리프트나 롤백 과정에서도 자주 발생합니다. 배포/동기화 실패가 반복되며 파드가 흔들리는 상황이라면, 애플리케이션 레벨만 보지 말고 배포 오케스트레이션도 함께 안정화해야 합니다. 필요하면 Argo CD Sync 실패? 드리프트·헬스체크 7단계처럼 운영 레이어 점검을 병행하세요.


실전 결론: “EOS가 깨졌다”를 해결하는 가장 빠른 루트

정리하면, 현장에서 가장 빠른 해결 루트는 다음 순서가 효율적입니다.

  1. Exactly-Once의 범위를 명확히 정의하고(토픽 vs DB) 관측 기준 키를 정한다
  2. 중복/유실/재처리를 로그로 분류한다(특히 (partition, offset) 재처리 여부)
  3. Producer 트랜잭션과 transactional.id 운영 모델을 고정한다
  4. 브로커의 ISR/복제 내구성 설정으로 “커밋의 의미”를 보장한다
  5. Consumer 커밋 순서를 부작용 완료 이후로 정렬한다
  6. consume -> produce는 트랜잭션으로 묶고, DB는 Outbox 또는 멱등 키로 흡수한다
  7. 리밸런스/배포/장애 복구 시나리오에서 반복 재현 테스트로 검증한다

Exactly-Once는 기능이 아니라 “아키텍처 계약”입니다. Kafka 옵션을 맞추는 것에서 끝내지 말고, 트랜잭션 경계와 멱등성, 그리고 운영 이벤트(배포/리밸런스/장애)를 포함한 전체 체인으로 진단해야 재발을 막을 수 있습니다.