- Published on
Kafka Exactly-Once 깨질 때 원인·해결 7가지
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 팀이 같은 Kafka 클러스터를 쓰다 보면 “Exactly-Once로 만들었다고 생각했는데 중복 처리됐다” 같은 사건이 주기적으로 터집니다. 문제는 Exactly-Once가 단일 스위치가 아니라는 점입니다. 프로듀서 트랜잭션, 컨슈머 오프셋 커밋, 처리 로직의 원자성, 토픽 설정, 리밸런스, 장애 복구, 외부 시스템 연동까지 여러 조건이 동시에 맞아야 합니다.
이 글은 Exactly-Once가 깨질 때 자주 등장하는 원인 7가지를 “증상 - 원인 - 해결”로 정리합니다. 마지막에는 운영 점검 체크리스트도 제공합니다.
참고: 분산 트랜잭션 없이도 중복/보상 문제를 다루는 관점이 필요할 때는 SAGA 패턴 글도 같이 보면 좋습니다. DDD에서 분산 트랜잭션 없이 SAGA 구현하기
Exactly-Once의 범위부터 명확히 하기
Kafka에서 Exactly-Once는 보통 아래 두 가지 층위로 말합니다.
- Kafka 내부(브로커 - 프로듀서 - 컨슈머/스트림즈)에서의 Exactly-Once
- 프로듀서 idempotence 및 트랜잭션
read_committed격리 수준- 오프셋 커밋을 트랜잭션에 포함
- Kafka와 외부 시스템(DB, 캐시, HTTP API 등)까지 포함한 Exactly-Once
- 사실상 “외부 시스템이 idempotent”하거나
- outbox/inbox, dedup 테이블, SAGA/보상 등 별도 설계가 필요
즉, Kafka만으로 끝나는 파이프라인인지, 외부 부작용(side effect)까지 포함하는지에 따라 “깨졌다”의 의미가 달라집니다.
1) 트랜잭션을 쓰지만 컨슈머가 read_uncommitted로 읽는 경우
증상
- 장애나 재시작 이후, 이전에 롤백된 레코드가 소비되어 중복/유령 처리 발생
- 스트림즈/프로듀서가 트랜잭션으로 보냈는데도 다운스트림에서 이상 데이터가 보임
원인
프로듀서 트랜잭션은 커밋된 레코드만 읽도록 컨슈머가 isolation.level=read_committed를 사용해야 의미가 있습니다. 기본값이 read_uncommitted인 클라이언트/커넥터가 섞이면 롤백 레코드도 읽을 수 있습니다.
해결
- 컨슈머(또는 Kafka Connect sink/source)의 격리 수준을 강제
# consumer
isolation.level=read_committed
enable.auto.commit=false
- 운영 팁: 팀/서비스별 템플릿 설정을 만들어 “기본값”을 통일하세요.
2) 오프셋 커밋이 처리 결과와 원자적으로 묶이지 않는 경우
증상
- 처리 성공 후 커밋 전에 죽으면 재처리되어 중복 발생
- 반대로 커밋은 됐는데 실제 처리가 실패해서 데이터 유실처럼 보임
원인
Exactly-Once는 “메시지 처리”와 “오프셋 커밋”이 같은 원자적 단위로 묶여야 합니다.
- 단순 컨슈머에서
commitSync()를 처리 로직과 분리 - 자동 커밋(
enable.auto.commit=true) 사용 - Kafka Streams가 아닌 일반 컨슈머에서 외부 DB 업데이트와 오프셋 커밋을 따로 수행
해결 A: Kafka Streams를 사용한다면
Kafka Streams는 EOS를 비교적 안전하게 제공하지만, 설정이 필요합니다.
processing.guarantee=exactly_once_v2
# 과도한 트랜잭션 오버헤드를 줄이기 위한 튜닝 포인트
commit.interval.ms=100
해결 B: 일반 Consumer + Producer(consume-transform-produce)라면
컨슘한 레코드 처리와 “오프셋 커밋”을 프로듀서 트랜잭션에 포함시키는 패턴을 씁니다.
// Java pseudo-code
producer.initTransactions();
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
producer.beginTransaction();
try {
for (ConsumerRecord<String, String> r : records) {
// 1) 처리 결과를 Kafka로 produce
producer.send(new ProducerRecord<>("out-topic", r.key(), transform(r.value())));
}
// 2) 오프셋을 트랜잭션에 포함
Map<TopicPartition, OffsetAndMetadata> offsets = currentOffsets(records);
producer.sendOffsetsToTransaction(offsets, consumer.groupMetadata());
producer.commitTransaction();
} catch (Exception e) {
producer.abortTransaction();
// 이후 poll에서 재처리되지만, 트랜잭션 덕분에 중복이 줄어듦
}
}
주의: 본문에서 Map<TopicPartition, OffsetAndMetadata> 같은 제네릭 표기는 MDX에서 문제가 될 수 있어 코드블록에서만 사용했습니다.
3) 프로듀서 설정이 idempotent/transactional 요구사항을 만족하지 못하는 경우
증상
- 네트워크 지연/재시도 상황에서 같은 레코드가 중복으로 저장됨
- 리더 페일오버 직후 중복이 늘어남
원인
Idempotent producer는 중복 전송을 줄이지만, 아래 조건이 어긋나면 기대와 달리 동작합니다.
enable.idempotence=true가 꺼져 있음acks=1또는acks=0max.in.flight.requests.per.connection가 너무 큼(순서 역전 가능성)- 트랜잭션을 쓰면서
transactional.id가 안정적으로 유지되지 않음(매번 랜덤 생성)
해결
프로듀서 기본 템플릿을 “EOS 안전값”으로 고정하세요.
enable.idempotence=true
acks=all
retries=2147483647
max.in.flight.requests.per.connection=5
# 트랜잭션 사용 시 필수
transactional.id=my-service-prod-01
transactional.id는 인스턴스 재시작에도 동일하게 유지되어야 합니다.- 쿠버네티스라면 Pod 이름이 바뀌는 특성을 고려해 “안정적인 아이덴티티”를 설계해야 합니다.
4) 리밸런스/세션 타임아웃으로 인한 중복 처리
증상
- 배포/스케일링 시점에 중복이 급증
- 처리량이 튀거나 특정 파티션이 반복해서 다른 인스턴스로 이동
원인
리밸런스가 발생하면 같은 레코드가 다른 컨슈머에게 다시 할당될 수 있습니다. 특히 아래 상황에서 “처리 중이던 레코드”가 재처리되기 쉽습니다.
max.poll.interval.ms를 넘길 정도로 처리 시간이 길다session.timeout.ms/heartbeat.interval.ms가 부적절하다- 정지/GC/OOM 등으로 하트비트가 끊긴다
해결
- 처리 시간이 길면
max.poll.interval.ms를 늘리고,max.poll.records를 줄여 배치 크기를 줄입니다.
max.poll.records=100
max.poll.interval.ms=900000
session.timeout.ms=45000
heartbeat.interval.ms=15000
- 컨슈머 로직에서 “처리 중단 시 안전한 재처리”가 되도록 idempotent 처리(아래 7번)도 같이 적용해야 합니다.
- 리소스 이슈로 컨슈머가 죽는다면 메모리/CPU 제한을 점검하세요. 쿠버네티스 환경에서 OOM이 반복되면 이 글의 진단 흐름이 도움이 됩니다. K8s OOMKilled 반복? cgroup v2 메모리 진단
5) 토픽/브로커 설정으로 인해 트랜잭션 로그/상태가 불안정한 경우
증상
- 간헐적으로
ProducerFencedException,InvalidProducerEpochException등 트랜잭션 관련 오류 - 커밋/어보트가 느려지고 타임아웃 증가
- EOS 파이프라인인데도 특정 시간대에 중복/유실 같은 이상 징후
원인
Exactly-Once는 내부적으로 트랜잭션 코디네이터와 로그 토픽에 의존합니다. 대표적으로 다음이 문제를 일으킵니다.
transaction.state.log.replication.factor가 낮음(브로커 장애 시 위험)transaction.state.log.min.isr가 낮음- 브로커/디스크 성능 문제로 트랜잭션 상태 기록이 지연
- 토픽
min.insync.replicas와 프로듀서acks조합이 불일치
해결
- 운영 환경에서는 트랜잭션 관련 내부 토픽 복제/ISR을 충분히 확보합니다.
# broker
transaction.state.log.replication.factor=3
transaction.state.log.min.isr=2
# topic(중요 토픽)
min.insync.replicas=2
- 디스크/네트워크 병목이 있는지 브로커 지표(요청 지연, ISR 변동, 언더리플리케이티드 파티션)를 함께 보세요.
6) Kafka Connect/Sink가 외부 시스템에 “적어도 한 번”으로 쓰는 경우
증상
- Kafka 내부에서는 EOS인데, DB/Elasticsearch/데이터웨어하우스에 중복 row/document가 생김
- 커넥터 재시작/태스크 재할당 후 중복이 증가
원인
많은 Sink 커넥터는 Kafka에서 레코드를 읽는 것과 외부 시스템에 쓰는 것을 완전히 원자적으로 묶지 못합니다. 즉 “Kafka에서는 정확히 한 번처럼 보이지만, 외부에는 최소 한 번”이 됩니다.
해결
- Sink 대상 시스템에 idempotent upsert 전략을 적용
- 예: 키 기반
UPSERT, 문서 ID를 메시지 키로 고정
- 예: 키 기반
- 가능하면 outbox 패턴 또는 inbox/dedup 테이블로 외부 쓰기를 방어
- 커넥터 설정에서
read_committed지원 여부 확인
Connect를 통한 외부 연동이 “트랜잭션 대체” 역할을 하길 기대했다면, SAGA/보상 관점으로 요구사항을 다시 정리하는 게 안전합니다. MSA 트랜잭션 Saga 보상 실패·중복처리 해결
7) 애플리케이션 레벨에서 idempotency 키/중복 제거가 없는 경우
증상
- 재시작, 리밸런스, 타임아웃 같은 이벤트 후 동일 비즈니스 이벤트가 두 번 처리됨
- 결제/포인트/재고 같은 “부작용”이 중복 반영
원인
Kafka EOS는 “Kafka에 기록된 레코드의 중복”을 줄여줄 뿐, 애플리케이션의 부작용을 자동으로 되돌려주지 않습니다. 특히 외부 API 호출, DB insert, 이메일 발송 등은 재처리 시 중복 부작용이 발생합니다.
해결
가장 실전적인 해법은 idempotency 키 기반 중복 제거입니다.
- 메시지 키 또는 헤더에
event_id를 넣고 - DB에
processed_events같은 테이블을 두어 한 번만 처리
-- 예: PostgreSQL
CREATE TABLE processed_events (
event_id TEXT PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
// pseudo-code
boolean first = insertProcessedEvent(eventId); // PK 충돌이면 false
if (!first) {
// 이미 처리됨: 스킵
return;
}
applySideEffect();
핵심은 “중복이 절대 없다”가 아니라, 중복이 와도 안전하게 무시할 수 있게 만드는 것입니다.
운영 점검 체크리스트
아래 중 하나라도 “모르겠다/기본값”이면 EOS가 깨질 가능성이 큽니다.
- 컨슈머 격리 수준이
read_committed인가 enable.auto.commit=false로 수동 커밋/트랜잭션 커밋을 통제하는가- 프로듀서가
enable.idempotence=true,acks=all인가 - 트랜잭션 사용 시
transactional.id가 재시작 후에도 안정적으로 유지되는가 - 리밸런스/타임아웃(특히
max.poll.interval.ms)이 처리 시간과 맞는가 - 내부 토픽 복제/ISR 및 브로커 디스크 지연이 건강한가
- 외부 시스템 연동은 idempotent upsert 또는 dedup/inbox로 방어되는가
마무리
Exactly-Once는 “Kafka 설정 몇 개 켜면 끝”이 아니라, 처리 모델과 장애 모델을 포함한 합의입니다. Kafka 내부 EOS를 먼저 제대로 맞춘 뒤, 외부 부작용은 idempotency 키와 dedup 설계로 현실적인 안전장치를 두는 것이 가장 비용 대비 효과가 좋습니다.
원하는 형태(Streams 기반, Consumer-Producer 기반, Connect 기반, DB outbox 기반)로 아키텍처를 알려주면, 그 조합에서 EOS가 깨지는 지점을 더 구체적인 설정과 코드로 좁혀서 점검표를 만들어 드릴 수 있습니다.