- Published on
Kafka Exactly-Once 깨질 때 진단 7단계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
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,offsetproducerId또는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 producer와 transactional producer입니다. 여기서 조금만 어긋나도 중복 또는 유실처럼 보이는 현상이 생깁니다.
Producer 필수 점검 항목
enable.idempotence=trueacks=allretries충분히 큼(사실상 무한에 가깝게)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가 깨졌다”를 해결하는 가장 빠른 루트
정리하면, 현장에서 가장 빠른 해결 루트는 다음 순서가 효율적입니다.
- Exactly-Once의 범위를 명확히 정의하고(토픽 vs DB) 관측 기준 키를 정한다
- 중복/유실/재처리를 로그로 분류한다(특히
(partition, offset)재처리 여부) - Producer 트랜잭션과
transactional.id운영 모델을 고정한다 - 브로커의 ISR/복제 내구성 설정으로 “커밋의 의미”를 보장한다
- Consumer 커밋 순서를 부작용 완료 이후로 정렬한다
consume -> produce는 트랜잭션으로 묶고, DB는 Outbox 또는 멱등 키로 흡수한다- 리밸런스/배포/장애 복구 시나리오에서 반복 재현 테스트로 검증한다
Exactly-Once는 기능이 아니라 “아키텍처 계약”입니다. Kafka 옵션을 맞추는 것에서 끝내지 말고, 트랜잭션 경계와 멱등성, 그리고 운영 이벤트(배포/리밸런스/장애)를 포함한 전체 체인으로 진단해야 재발을 막을 수 있습니다.