- Published on
Kafka Exactly-Once 깨질 때 중복처리 방지 전략
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서론
Kafka를 도입할 때 많은 팀이 기대하는 것 중 하나가 Exactly-Once(정확히 한 번) 처리입니다. 하지만 운영을 하다 보면 “분명 한 번만 처리될 줄 알았는데 결제가 두 번 반영됐다”, “알림이 중복 발송됐다”, “집계가 튀었다” 같은 사건이 반복됩니다. 이때 중요한 관점은 하나입니다.
- Kafka의 Exactly-Once는 **모든 시스템 경계(end-to-end)**에 대해 자동으로 보장되는 마법이 아니라,
- 특정 조건(트랜잭션/아이덴포턴트 프로듀서/정확한 오프셋 커밋 방식/싱크 대상의 트랜잭션 참여 등)에서만 성립하며,
- 경계가 DB/외부 API/서드파티로 넘어가는 순간, 중복은 “언젠가” 반드시 발생합니다.
따라서 실무에서의 정답은 “EOS를 맹신”하는 것이 아니라, **EOS가 깨질 때도 중복을 흡수하는 설계(중복처리 방지, deduplication + idempotency)**를 갖추는 것입니다.
> 장애 원인 추적 관점은 DB 쪽도 함께 봐야 합니다. 예를 들어 중복 이벤트가 들어왔을 때 DB에서 데드락/재시도가 겹치면 중복 반영이 더 커질 수 있습니다. 관련해서는 MySQL 8.0 InnoDB 데드락 원인추적·해결 실전도 같이 참고하면 좋습니다.
Kafka Exactly-Once는 어디까지 보장하나
Kafka에서 흔히 말하는 Exactly-Once는 크게 두 층으로 나뉩니다.
- Producer 측: idempotent producer + transactional producer
- 동일한 레코드를 재전송하더라도 브로커가 중복을 제거(조건부)하거나,
- 트랜잭션 단위로 publish를 원자적으로 처리
- Streams/Consumer 측: consume-process-produce + offset commit을 트랜잭션으로 묶기
- Kafka Streams는 내부적으로
processing.guarantee=exactly_once_v2를 통해- 입력 토픽 소비
- 상태 저장소 업데이트
- 출력 토픽 생산
- 오프셋 커밋 을 하나의 트랜잭션으로 엮어 “Kafka 내부”에서의 정확히 한 번을 달성합니다.
하지만 DB에 쓰거나 외부 API를 호출하면 이야기가 달라집니다. Kafka 트랜잭션에 DB가 자동으로 참여하지 않기 때문입니다(2PC를 직접 붙이지 않는 이상). 결국 end-to-end EOS는 애플리케이션 설계로 만들어야 합니다.
Exactly-Once가 깨지는 대표 시나리오
중복 처리의 원인을 “Kafka가 이상함”으로 뭉뚱그리면 재발을 막기 어렵습니다. 아래는 현장에서 자주 만나는 케이스들입니다.
1) 컨슈머 처리 후 오프셋 커밋 전 크래시
- 메시지 처리(예: DB 업데이트)는 완료
- 오프셋 커밋 전에 프로세스가 죽음
- 재시작 후 같은 레코드를 다시 읽음 → 중복 처리
2) 외부 시스템 타임아웃/재시도
- 결제 API 호출이 성공했지만 응답이 타임아웃
- 애플리케이션은 실패로 판단하고 재시도
- 외부 시스템이 멱등하지 않으면 이중 결제 같은 사고로 이어짐
3) 리밸런스/세션 타임아웃 중 처리 중단
- 처리 시간이 길어
max.poll.interval.ms를 넘기거나 - 네트워크 이슈로 세션이 끊겨 파티션이 다른 컨슈머로 이동
- 동일 레코드가 다시 처리될 수 있음
4) 프로듀서 재시도 + 브로커 장애
- 프로듀서가 ack를 받기 전에 네트워크 단절
- 실제로는 브로커에 기록됐지만 프로듀서는 실패로 보고 재전송
- idempotent 설정이 없거나, 트랜잭션 경계가 어긋나면 중복 가능
5) 토픽 리플레이/백필(backfill)
- 버그 수정 후 과거 데이터를 재처리
- “정상적으로” 중복이 발생하는 상황
- 이때 dedup 설계가 없으면 데이터가 망가짐
중복처리 방지의 핵심: 멱등성 + 중복 감지 저장소
실전에서 가장 강력한 패턴은 다음 두 가지를 결합하는 것입니다.
- Idempotency(멱등 처리): 같은 요청을 여러 번 받아도 결과가 한 번 처리된 것과 같게
- Dedup store(중복 감지 저장소): “이 이벤트를 이미 처리했는지”를 빠르게 판별
여기서 이벤트를 식별하는 키가 중요합니다.
- 가장 이상적: 업무적으로 유일한 키 (예:
paymentId,orderId,shipmentId) - 차선: producer가 부여한
eventId(UUID) - 주의: Kafka의
offset은 재처리/리플레이 시 의미가 바뀌므로 dedup 키로 부적절
패턴 1) DB Unique Key로 중복을 ‘자연스럽게’ 막기
가장 단순하고 강력한 방법은 DB에 유니크 제약을 걸어 중복 삽입을 실패시키고, 애플리케이션이 그 실패를 “이미 처리됨”으로 해석하는 것입니다.
예: MySQL에서 이벤트 처리 테이블 + 유니크 키
CREATE TABLE processed_events (
event_id VARCHAR(36) NOT NULL,
processed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (event_id)
);
CREATE TABLE orders (
order_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (order_id)
);
컨슈머 로직은 “먼저 event_id를 기록하고, 성공하면 비즈니스 업데이트”처럼 보이지만, 이 순서에는 함정이 있습니다.
- 이벤트 기록 성공
- 비즈니스 업데이트 실패
- 재처리 시 event_id가 이미 존재 → 비즈니스 업데이트가 영원히 안 됨
따라서 같은 트랜잭션으로 묶어야 합니다.
예: 트랜잭션으로 dedup + 비즈니스 업데이트 원자화
START TRANSACTION;
-- 1) 중복이면 여기서 걸림
INSERT INTO processed_events(event_id) VALUES (?);
-- 2) 비즈니스 반영
UPDATE orders
SET status = 'PAID'
WHERE order_id = ?;
COMMIT;
애플리케이션은 INSERT에서 PK 충돌이 나면 “이미 처리된 이벤트”로 보고 ACK(오프셋 커밋)만 하면 됩니다.
> 이 패턴은 트래픽이 높을 때 데드락/락 경합이 생길 수 있습니다. 특히 동일 order_id에 대해 여러 이벤트가 몰리면 데드락이 증가할 수 있으니, 인덱스/트랜잭션 범위/재시도 정책을 함께 설계해야 합니다. (상세는 위 MySQL 데드락 글 참고)
패턴 2) Outbox/Inbox 패턴으로 ‘경계’를 통제하기
Kafka ↔ DB 경계에서 EOS가 깨지는 이유는 한쪽은 성공, 다른 쪽은 실패가 가능하기 때문입니다.
- DB는 커밋됐는데 Kafka publish 실패
- Kafka consume는 했는데 DB 반영 실패
이를 통제하는 대표 패턴이:
- Outbox: DB 트랜잭션에 “발행할 이벤트”를 같이 기록하고, 별도 릴레이 프로세스가 Kafka로 전송
- Inbox: 컨슈머가 받은 이벤트를 DB에 먼저 기록(유니크)하고 처리
이 글의 주제(중복처리 방지)에서는 Inbox가 특히 중요합니다.
Inbox 테이블 예시
CREATE TABLE inbox (
event_id VARCHAR(36) NOT NULL,
topic VARCHAR(200) NOT NULL,
partition_id INT NOT NULL,
offset_id BIGINT NOT NULL,
payload JSON NOT NULL,
processed TINYINT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
processed_at TIMESTAMP NULL,
PRIMARY KEY (event_id)
);
- 동일
event_id는 한 번만 들어오게 막고 processed=0인 것만 처리- 장애 시에도 “어디까지 처리했는지”를 DB가 기억
단점은 쓰기 비용이 늘고, 테이블 관리(파티셔닝/TTL)가 필요하다는 점입니다.
패턴 3) Redis/캐시 기반 Dedup(단, 최종 방어선은 DB)
“알림 발송”, “메일 전송”처럼 DB 트랜잭션으로 깔끔히 묶기 어려운 작업은 Redis 같은 캐시로 단기 중복을 막는 방법이 자주 쓰입니다.
SETNX dedup:{eventId} 1 EX 86400(24시간)- 성공하면 처리, 실패하면 skip
다만 Redis는 영속성이 약하거나(설정에 따라), 장애/플러시 시 키가 날아갈 수 있습니다. 따라서 금전/정산/재고 같은 정합성이 중요한 도메인에서는 Redis dedup을 “보조 장치”로만 두고, DB 유니크/원장 방식이 최종 방어선이 되어야 합니다.
패턴 4) 외부 API 호출은 ‘요청 멱등 키’로 잠그기
Kafka에서 중복이 생겼을 때 가장 위험한 구간은 외부 결제/배송/문자 발송 API입니다. 이때는 외부 시스템이 제공하는 Idempotency-Key(또는 요청 식별자)를 반드시 사용해야 합니다.
- Stripe:
Idempotency-Key - 많은 결제/배송 API:
requestId,clientTxId등의 필드
만약 외부가 멱등을 지원하지 않는다면, 호출 전에 우리 쪽에서 “이미 호출했는지”를 저장하고, 호출 결과를 재사용해야 합니다.
Kafka 설정으로 중복 가능성을 ‘줄이기’
중복을 0으로 만들 수는 없지만, Kafka 설정으로 빈도를 낮출 수는 있습니다.
Producer
enable.idempotence=trueacks=allretries충분히 크게max.in.flight.requests.per.connection는 idempotence 조건에 맞게(일반적으로 5 이하 권장)
Consumer
enable.auto.commit=false(수동 커밋)- 처리 성공 이후에만 커밋
- 처리 시간이 길면
max.poll.interval.ms조정 또는 워커 스레드/비동기 처리 구조 재검토
Streams(사용 시)
processing.guarantee=exactly_once_v2- 상태 저장소/체인지로그 토픽 설정 점검
하지만 다시 강조하면, 이것들은 “중복을 줄이는 장치”이지 “외부까지 포함한 EOS”가 아닙니다.
Spring Kafka 예시: DB 멱등 + 수동 커밋
아래는 가장 흔한 구조(컨슈머 → DB 반영 → 커밋)에서 중복을 DB 유니크로 흡수하는 예시입니다.
@KafkaListener(topics = "payments", groupId = "payment-service")
public void onMessage(ConsumerRecord<String, String> record, Acknowledgment ack) {
PaymentEvent event = parse(record.value());
try {
paymentService.applyIdempotently(event); // 내부에서 DB 트랜잭션 + unique key
ack.acknowledge();
} catch (DuplicateKeyException e) {
// 이미 처리된 이벤트: 재처리 방지
ack.acknowledge();
} catch (Exception e) {
// 실패: ack 하지 않음 -> 재시도(재처리) 유도
throw e;
}
}
@Transactional
public void applyIdempotently(PaymentEvent event) {
processedEventRepository.insert(event.eventId()); // PK unique
orderRepository.markPaid(event.orderId());
}
포인트는 다음입니다.
- 중복은 예외가 아니라 정상 흐름으로 취급한다(= DuplicateKey는 성공으로 간주)
- ACK는 “비즈니스 반영이 끝난 뒤”에만
- DB 트랜잭션으로 dedup 기록과 비즈니스 업데이트를 원자적으로
운영에서 반드시 챙길 체크리스트
1) 이벤트 ID 표준화
- 모든 이벤트에
eventId(UUID) +occurredAt+producer를 포함 - 업무 키(
orderId)와 eventId를 분리
2) 재처리(Replay) 전략
- “토픽을 되감아도 안전한가?”를 릴리즈 체크리스트에 포함
- dedup TTL을 짧게 두면 replay 때 다시 중복 반영될 수 있음(도메인에 맞게 설계)
3) 관측성: 중복을 숫자로 본다
duplicate_detected_total같은 메트릭을 만든다- 특정 파티션/키에서만 중복이 급증하면 락 경합/리밸런스/지연을 의심
서버가 OOM으로 죽으면서 커밋이 밀리고 재처리가 폭증하는 패턴도 흔합니다. 이때는 애플리케이션 로직만 보지 말고 노드/컨테이너 메모리까지 같이 확인해야 합니다. 필요하면 리눅스 OOM Kill 원인 추적 - dmesg·cgroup·journalctl처럼 시스템 레벨에서 원인을 잡아야 합니다.
결론: EOS를 ‘믿는’ 대신 중복을 ‘흡수’하라
Kafka의 Exactly-Once는 분명 강력하지만, 그것만으로는 결제/정산/알림 같은 현실 시스템의 중복을 막기 어렵습니다. 실무적으로 안전한 접근은 다음 한 줄로 정리됩니다.
- Kafka는 at-least-once로 보고 설계하되, 애플리케이션/DB에서 멱등성과 dedup을 구현해 결과를 exactly-once처럼 만든다.
가장 추천하는 조합은:
- 이벤트에 유일한
eventId - DB 트랜잭션 +
processed_events유니크 키로 dedup - 외부 API는 idempotency key 사용
- 리밸런스/크래시/리플레이에도 안전한 운영 체크리스트
이렇게 해두면 “EOS가 깨지는 순간”이 와도, 시스템은 중복을 조용히 삼키고 정합성을 지킬 수 있습니다.