- Published on
Kafka Exactly-Once 깨질 때 진단 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
Kafka를 쓰다 보면 “Exactly-Once로 구성했는데 왜 중복 처리되죠?” 같은 질문을 결국 만나게 됩니다. 결론부터 말하면 Kafka의 Exactly-Once는 아키텍처/클라이언트 설정/오프셋 커밋 방식/싱크(외부 DB) 쓰기 방식이 함께 맞물릴 때만 성립합니다. 그리고 깨질 때의 증상은 대개 중복 생산, 중복 소비, 유실, 재처리 폭주로 나타납니다.
이 글은 Exactly-Once가 깨지는 대표 시나리오를 트랜잭션(Producer), 오프셋(Consumer), 재처리(Replay/Backfill) 관점에서 나눠 진단하고, 로그/메트릭/설정/코드에서 무엇을 확인해야 하는지 체크리스트 형태로 정리합니다.
관련해서 분산 트랜잭션을 애플리케이션 레벨에서 다루는 관점은 사가 패턴 글도 함께 참고하면 좋습니다: Kotlin MSA에서 Saga 보상 트랜잭션 설계 실전
Exactly-Once가 의미하는 범위부터 재정의
Kafka에서 Exactly-Once는 보통 아래 두 가지를 섞어서 말합니다.
- Kafka 내부(토픽 간) Exactly-Once
- 트랜잭션 프로듀서 +
sendOffsetsToTransaction을 사용한 consume-transform-produce 파이프라인 - 동일 파티션에 대해 중복 생산이 논리적으로 제거되고, 오프셋 커밋과 결과 레코드 생산이 원자적으로 묶임
- Kafka 외부(예: DB)까지 Exactly-Once
- Kafka 트랜잭션만으로는 부족합니다.
- DB upsert, 유니크 키, idempotency key, outbox/inbox 패턴 등 외부 싱크의 멱등성이 필요합니다.
따라서 “Exactly-Once가 깨졌다”는 말은 실제로는 다음 중 하나인 경우가 많습니다.
- Kafka 내부는 EOS인데, DB가 멱등이 아니라서 중복 반영됨
- 소비자 리밸런스/크래시 상황에서 오프셋 커밋이 트랜잭션과 분리되어 중복 처리
- 재처리 작업이 운영 트래픽과 섞여 같은 키를 두 번 적용
1) 트랜잭션(Producer)에서 EOS가 깨지는 흔한 원인
체크 1: transactional.id가 안정적으로 유지되는가
트랜잭션 프로듀서는 반드시 transactional.id가 인스턴스 재시작 후에도 동일하게 유지되어야 합니다.
- 잘못된 예: Pod가 뜰 때마다 랜덤 UUID를
transactional.id로 설정 - 결과: 브로커는 매번 “새 트랜잭션 프로듀서”로 인식하고, 이전 인스턴스의 미완료 트랜잭션/시퀀스와의 관계가 끊기며 운영 중복/유실을 촉발할 수 있습니다.
권장:
- Stateful한 워커라면
transactional.id를service-name + partition-id처럼 결정적(deterministic)으로 부여 - 컨슈머 그룹과 1:1로 매핑되는 스트림 작업이라면 태스크 단위로 고정
체크 2: enable.idempotence=true와 acks=all이 실제로 적용되는가
Kafka EOS의 전제는 멱등 프로듀서입니다.
enable.idempotence=trueacks=allretries는 충분히 크게
주의할 점:
- 일부 클라이언트/프레임워크는 설정이 덮어써질 수 있습니다.
- 운영에서 “분명 설정했는데”가 아니라, 실제 프로듀서 config dump 혹은 startup log로 검증해야 합니다.
체크 3: max.in.flight.requests.per.connection로 인한 순서 역전
멱등 프로듀서라도 순서가 꼬이면 재시도 과정에서 예기치 않은 중복처럼 보일 수 있습니다.
- 일반적으로
max.in.flight.requests.per.connection=5는 멱등성과 함께 안전하지만, - 프레임워크 조합, 커스텀 파티셔너, 비동기 콜백에서 “처리 완료”를 잘못 판단하면 중복 반영을 만들 수 있습니다.
체크 4: 트랜잭션 타임아웃과 배치 지연
transaction.timeout.ms 내에 트랜잭션이 커밋되지 않으면 abort되고, 소비자는 재처리하면서 중복이 발생할 수 있습니다.
- 큰 배치 처리
- 외부 API 호출 지연
- GC pause
이 경우 증상:
- 프로듀서 로그에
Transaction timed out또는 abort 관련 로그 - 컨슈머는 동일 오프셋 범위를 다시 처리
2) 오프셋(Consumer) 커밋이 EOS를 무너뜨리는 패턴
EOS 파이프라인에서 핵심은 “결과 레코드 생산”과 “오프셋 커밋”을 같은 트랜잭션으로 묶는 것입니다.
패턴 A: enable.auto.commit=true
자동 커밋은 거의 항상 EOS를 깨는 지름길입니다.
- 레코드 처리 중인데 오토 커밋이 먼저 발생하면
- 장애 시 “처리는 안 됐는데 오프셋은 넘어간” 유실이 생깁니다.
따라서 EOS를 목표로 한다면:
enable.auto.commit=false- 수동 커밋을 하되, 가능하면
sendOffsetsToTransaction을 사용
패턴 B: 결과는 트랜잭션으로 생산하지만 오프셋은 별도 커밋
아래는 흔히 나오는 안티패턴입니다.
- 트랜잭션으로 produce
commitSync로 오프셋 커밋
둘이 원자적으로 묶이지 않으므로, 크래시 타이밍에 따라 중복/유실이 발생합니다.
올바른 패턴: consume-transform-produce + sendOffsetsToTransaction
아래 예시는 Java Kafka Client 기준의 전형적인 EOS 루프입니다.
Properties p = new Properties();
p.put("bootstrap.servers", "kafka:9092");
// Consumer
p.put("group.id", "orders-eos");
p.put("enable.auto.commit", "false");
p.put("isolation.level", "read_committed");
// Producer (same app)
p.put("enable.idempotence", "true");
p.put("acks", "all");
p.put("transactional.id", "orders-eos-tx-0");
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(p);
KafkaProducer<String, String> producer = new KafkaProducer<>(p);
producer.initTransactions();
consumer.subscribe(List.of("orders"));
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(500));
if (records.isEmpty()) continue;
producer.beginTransaction();
try {
for (ConsumerRecord<String, String> r : records) {
String out = transform(r.value());
producer.send(new ProducerRecord<>("orders_enriched", r.key(), out));
}
// 오프셋을 트랜잭션에 포함
Map<TopicPartition, OffsetAndMetadata> offsets = new HashMap<>();
for (TopicPartition tp : records.partitions()) {
List<ConsumerRecord<String, String>> prs = records.records(tp);
long lastOffset = prs.get(prs.size() - 1).offset();
offsets.put(tp, new OffsetAndMetadata(lastOffset + 1));
}
producer.sendOffsetsToTransaction(offsets, consumer.groupMetadata());
producer.commitTransaction();
} catch (Exception e) {
producer.abortTransaction();
// abort 후에는 같은 레코드를 다시 poll해서 재처리하게 됨
}
}
진단 포인트:
- 컨슈머에
isolation.level=read_committed가 없으면 abort된 트랜잭션의 레코드도 읽어버려 “유령 중복”처럼 보일 수 있습니다. read_committed는 성능/지연에 영향을 줄 수 있으니, 토픽/파이프라인 단위로 적용을 검토합니다.
3) 재처리(Replay)에서 Exactly-Once가 깨지는 순간
재처리는 운영에서 가장 많이 사고가 나는 구간입니다. 이유는 간단합니다.
- 동일 이벤트를 다시 흘리는 순간, “한 번만 적용”이라는 가정이 깨짐
- 운영 트래픽과 재처리 트래픽이 섞이면 레이스 컨디션이 발생
시나리오 1: 컨슈머 그룹을 같은 걸로 재처리
같은 group.id로 재처리를 돌리면, 기존 컨슈머와 리밸런스가 발생하고 처리 순서가 뒤집히거나 지연이 커집니다.
권장:
- 재처리 전용
group.id를 사용 - 재처리 결과를 별도 토픽으로 분리한 뒤 검증 후 머지
시나리오 2: 재처리 입력이 원본 토픽이 아니라 “가공된 토픽”
가공 토픽은 이미 한 번의 변환/집계가 들어가 있어, 재처리 시 원본과 동일한 결과를 보장하기 어렵습니다.
권장:
- 가능하면 원본 이벤트 토픽을 보관(보존 기간, 압축 정책 포함)
- 이벤트 스키마 버전과 변환 로직 버전을 함께 기록
시나리오 3: 외부 DB 싱크가 멱등이 아님
Kafka 내부 EOS를 맞춰도 DB에 INSERT만 하면 재처리에서 중복 행이 생깁니다.
해결책(현실적인 우선순위):
- 이벤트에
event_id를 넣고 DB에 유니크 인덱스 UPSERT사용- 처리 테이블에
(event_id, consumer_name)형태의 inbox 테이블을 두고 선처리 체크
PostgreSQL 예시:
CREATE TABLE order_projection (
order_id text PRIMARY KEY,
status text NOT NULL,
updated_at timestamptz NOT NULL
);
CREATE TABLE inbox (
event_id text PRIMARY KEY,
processed_at timestamptz NOT NULL DEFAULT now()
);
-- 트랜잭션으로 묶어서 적용
-- 1) inbox에 event_id 기록 (중복이면 충돌)
-- 2) projection 갱신
애플리케이션 로직(의사코드):
BEGIN;
INSERT INTO inbox(event_id) VALUES (:eventId) ON CONFLICT DO NOTHING;
if rowInserted:
UPSERT order_projection ...;
COMMIT;
이 패턴은 Kafka EOS와 별개로 “외부 세계 exactly-once”를 현실적으로 달성하는 핵심 장치입니다.
4) 증상별로 역추적하는 실전 진단 플로우
증상 A: 같은 메시지가 두 번 처리됨(중복)
- 프로듀서가 중복 생산했는지 확인
- 동일 key, 동일 payload가 같은 파티션에 연속으로 존재?
- 프로듀서 재시도/타임아웃 로그 확인
- 컨슈머가 같은 오프셋을 두 번 읽었는지 확인
- 리밸런스 직후 중복이 늘어나는가
enable.auto.commit여부- 수동 커밋이 처리 완료보다 먼저 발생하는지
- DB/외부 싱크가 멱등인지 확인
- 유니크 키 부재
- upsert 미사용
- 재처리 시점에만 중복이 증가한다면 거의 여기입니다.
증상 B: 메시지가 유실됨(처리되지 않음)
- 오토 커밋으로 오프셋이 먼저 커밋된 케이스
- 예외 발생 시 오프셋을 커밋해버리는 코드
- 프로듀서에서
acks=1또는 브로커 장애 시 손실
증상 C: 재처리 시 처리량 폭주, 지연 급증
- 재처리 컨슈머가 같은 토픽/파티션을 과도하게 잡아 리밸런스 유발
- 다운스트림(DB, API)의 rate limit으로 재시도 폭발
이 경우 재시도/백오프 설계가 중요합니다. API rate limit 대응 관점은 다음 글의 패턴이 도움이 됩니다: OpenAI 429/Rate Limit 대응 - 재시도·백오프·큐잉
5) 운영에서 꼭 확인할 설정 체크리스트
Consumer
enable.auto.commit=falseisolation.level=read_committed(트랜잭션 읽는 파이프라인이면 권장)max.poll.interval.ms가 처리 시간보다 충분히 큰지max.poll.records가 배치 처리/트랜잭션 크기와 맞는지
Producer
enable.idempotence=trueacks=alltransactional.id가 안정적으로 고정되는지transaction.timeout.ms가 처리 최악 지연을 커버하는지
Topic / Broker 관점에서 자주 놓치는 것
min.insync.replicas와acks=all조합이 실제로 내구성을 보장하는지- 로그 압축(compaction) 토픽에서 key 설계가 멱등에 맞는지
6) “EOS인데도 중복”을 만드는 프레임워크/코드 레벨 함정
함정 1: 처리 완료 시점 정의가 모호함
예:
- DB write는 비동기인데, future 완료 전에 오프셋을 트랜잭션에 포함
- 외부 API 호출 결과를 기다리지 않고 커밋
해결:
- “사이드이펙트가 끝난 뒤” 오프셋을 커밋
- 비동기라면 join/await 지점을 명확히
함정 2: 멱등 키가 비즈니스 키와 다름
order_id로 upsert하면 “같은 주문의 상태 변경 이벤트”는 덮어써지지만,- “서로 다른 이벤트가 같은 주문에 적용되는 순서”가 보장되지 않으면 논리 오류가 납니다.
권장:
- 이벤트 자체의 고유 ID(
event_id)와 버전/시퀀스(event_version)를 함께 설계
7) 재처리 설계: 안전한 백필 파이프라인 패턴
운영에서 추천하는 접근은 다음 중 하나입니다.
패턴 1: 재처리 결과를 별도 토픽으로 분리
- 원본 토픽
orders - 재처리 전용 컨슈머
orders-replayer - 출력 토픽
orders_enriched_backfill
검증 후에만 다운스트림에 적용합니다.
패턴 2: outbox/inbox로 외부 싱크 멱등 확보
- Kafka는 “전달”을 담당
- DB는 inbox로 “중복 방지”를 담당
이렇게 역할을 분리하면, Kafka EOS가 일부 구간에서 깨져도 최종 상태는 보호됩니다.
분산 환경에서 보상/재시도/중복을 다루는 설계 감각은 사가 관점에서도 연결됩니다: Kotlin MSA에서 Saga 보상 트랜잭션 설계 실전
결론: Exactly-Once는 “설정”이 아니라 “경로”를 보장한다
Kafka Exactly-Once가 깨질 때는 감으로 추측하기보다, 아래 3가지를 분리해서 추적하면 빠르게 원인을 좁힐 수 있습니다.
- 트랜잭션 프로듀서가 정말로 안정적으로 동작하는가
transactional.id고정- 타임아웃/abort 로그
- 오프셋 커밋이 트랜잭션과 원자적으로 묶였는가
- 오토 커밋 제거
sendOffsetsToTransaction사용read_committed적용 여부
- 재처리와 외부 싱크가 멱등성을 갖는가
- 재처리 전용 group
- 결과 분리 토픽
- DB inbox/upsert
이 3축을 체크리스트로 고정해두면, “EOS인데 중복” 같은 애매한 장애도 재현 로그와 함께 구조적으로 설명하고, 재발 방지까지 연결할 수 있습니다.