- Published on
Kafka Exactly-Once 함정 - MSA 중복처리 근절
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 마이크로서비스가 Kafka로 이벤트를 주고받는 구조에서 “Exactly-Once를 켰는데도 중복이 나온다”는 이야기는 흔합니다. 이유는 간단합니다. Kafka의 Exactly-Once는 Kafka 내부의 특정 경로에서만 성립하고, 서비스 코드와 외부 시스템까지 포함한 E2E 관점에서는 여전히 중복 가능성이 남기 때문입니다.
이 글에서는 Exactly-Once의 적용 범위를 정확히 자르고, MSA에서 중복이 생기는 대표 함정을 짚은 뒤, 실전에서 중복을 “운영 가능한 수준으로 근절”하는 설계를 코드와 함께 정리합니다.
1) Exactly-Once는 어디까지 보장하나
Kafka에서 Exactly-Once를 말할 때 보통 다음을 의미합니다.
- 프로듀서 측: idempotent producer와 트랜잭셔널 프로듀서가 결합되어, 재시도나 리더 변경 같은 상황에서 브로커에 중복 레코드가 기록되는 것을 억제
- 컨슈머 측:
read_committed로 트랜잭션이 커밋된 레코드만 읽고, 오프셋 커밋을 트랜잭션에 묶어 consume-transform-produce 파이프라인에서 중복을 줄임
하지만 이는 대개 아래의 범위에서만 강합니다.
- Kafka 토픽에서 읽어서 Kafka 토픽에 다시 쓰는 스트림 처리
- 오프셋 커밋과 결과 토픽 기록을 한 트랜잭션으로 묶는 경우
반대로 다음은 Kafka Exactly-Once의 바깥입니다.
- 컨슈머가 DB에 쓰는 작업
- 컨슈머가 외부 API를 호출하는 작업
- 메시지 처리 후 DB 커밋은 되었는데 오프셋 커밋이 실패한 상황
- 오프셋은 커밋되었는데 DB 커밋이 롤백된 상황
결론적으로 MSA에서 “중복처리 근절”은 Kafka 설정만으로 끝나지 않고, DB 트랜잭션 경계와 멱등성이 핵심이 됩니다.
2) 중복이 생기는 대표 함정 7가지
함정 1: 오프셋 자동 커밋
enable.auto.commit=true는 중복과 유실을 모두 만들기 쉽습니다. 처리 완료 전에 오프셋이 커밋되면 유실, 처리 후 커밋이 안 되면 재처리로 중복이 발생합니다.
해결은 단순합니다.
enable.auto.commit=false- 처리 성공 후에만 오프셋 커밋
함정 2: “DB 쓰기”와 “오프셋 커밋”이 원자적이지 않음
가장 흔한 중복 시나리오입니다.
- 컨슈머가 메시지를 처리하고 DB에 반영
- 오프셋 커밋 전에 프로세스가 죽음
- 재시작 후 같은 메시지를 다시 읽음
- DB에 동일 반영이 또 일어나 중복
Kafka 트랜잭션은 DB를 같이 묶어주지 않습니다. 따라서 DB 레벨의 멱등 처리 또는 인박스 패턴이 필요합니다.
함정 3: producer retries로 인한 중복을 “클라이언트에서” 재생산
프로듀서에서 재시도를 켜고도, transactional.id나 idempotence 설정이 불완전하면, 장애 시 동일 이벤트가 여러 번 발행될 수 있습니다.
enable.idempotence=true- 적절한
acks=all - 트랜잭셔널 사용 시 안정적인
transactional.id
함정 4: 리밸런싱과 긴 처리 시간
처리가 오래 걸리면 리밸런싱 중에 파티션이 다른 인스턴스로 넘어가며 재처리가 발생할 수 있습니다.
max.poll.interval.ms와 실제 처리 시간 불일치session.timeout.ms설정 미스
해결은 처리 시간을 줄이거나, 병렬도를 조절하거나, 폴링과 처리를 분리해 하트비트를 유지하는 구조가 필요합니다.
함정 5: 외부 API 호출은 원천적으로 Exactly-Once가 아님
외부 결제, 문자 발송, 이메일 발송 등은 “한 번만 호출”이 매우 어렵습니다. 네트워크 타임아웃이 발생하면 실제로는 성공했는데 클라이언트는 실패로 보고 재시도할 수 있습니다.
이 경우 중복 방지는 Kafka가 아니라 외부 API의 멱등 키 또는 우리 쪽의 중복 차단 저장소로 해야 합니다.
함정 6: 이벤트 키 설계가 불안정
이벤트에 “고유 식별자”가 없거나, 재발행 시마다 새로운 ID를 만들면 컨슈머는 중복을 식별할 수 없습니다.
- 이벤트에는
eventId를 반드시 포함 - 비즈니스 엔티티 기준
aggregateId도 포함
함정 7: “중복은 없을 것”이라는 가정으로 관측이 빈약함
중복은 언젠가 발생합니다. 관측이 없으면 장애가 아니라 “데이터 품질 저하”로 오래 잠복합니다.
- 중복 차단 카운터
- 재처리율, 리밸런싱 횟수
- DLQ로 빠지는 비율
운영 관점에서 사가 기반 보상 트랜잭션이 얽히면 중복과 보상이 결합되어 더 복잡해집니다. 이 주제는 MSA Saga 패턴 - 보상 트랜잭션 실패 디버깅과 함께 보면 원인 분리가 쉬워집니다.
3) 현실적인 목표: “효과적으로 Exactly-Once”를 만드는 3종 세트
MSA에서 실전 해법은 보통 아래 조합입니다.
- Kafka 프로듀서 트랜잭션 또는 idempotent producer로 토픽 중복을 최소화
- 컨슈머는 DB에 멱등하게 반영하거나, 인박스 테이블로 중복을 차단
- 외부 사이드이펙트는 멱등 키 또는 outbox 기반 비동기 처리로 분리
이 중 2번이 핵심입니다.
4) 컨슈머 멱등 처리: Inbox 테이블 패턴
핵심 아이디어는 간단합니다.
- 메시지의
eventId를 DB에 기록 - 이미 처리한
eventId면 비즈니스 로직을 실행하지 않음 - 이 체크와 비즈니스 상태 변경을 하나의 DB 트랜잭션으로 묶음
스키마 예시
CREATE TABLE message_inbox (
event_id VARCHAR(64) PRIMARY KEY,
topic VARCHAR(255) NOT NULL,
partition_id INT NOT NULL,
offset_value BIGINT NOT NULL,
received_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE orders (
order_id VARCHAR(64) PRIMARY KEY,
status VARCHAR(32) NOT NULL,
updated_at TIMESTAMP NOT NULL
);
처리 로직 의사코드
아래는 Java 또는 Kotlin 계열에서 흔히 쓰는 형태의 의사코드입니다.
// enable.auto.commit=false 전제
void onMessage(ConsumerRecord<String, String> record) {
Event evt = parse(record.value());
db.transaction(() -> {
boolean inserted = inboxDao.insertIfAbsent(
evt.eventId,
record.topic(),
record.partition(),
record.offset()
);
if (!inserted) {
// 이미 처리한 이벤트
return;
}
// 비즈니스 반영은 멱등하게 설계
// 예: 동일 orderId에 동일 상태로 set 하는 형태
ordersDao.updateStatus(evt.orderId, evt.newStatus);
});
// DB 트랜잭션 성공 후 오프셋 커밋
consumer.commitSync();
}
여기서 중요한 점은 insertIfAbsent가 원자적으로 동작해야 한다는 것입니다. 보통은 PK 충돌을 이용합니다.
INSERT INTO message_inbox(event_id, topic, partition_id, offset_value)
VALUES (?, ?, ?, ?);
PK 중복이면 실패하고, 애플리케이션은 이를 “이미 처리됨”으로 간주합니다.
이 패턴이 막아주는 것
- 컨슈머 재시작, 리밸런싱, 일시적 장애로 인해 같은 이벤트가 다시 들어와도 DB 반영은 한 번만 수행
- 오프셋 커밋이 늦어져 재처리되어도 안전
트레이드오프
- inbox 테이블이 계속 커짐
- 보관 정책이 필요
보관 정책은 보통 다음 중 하나입니다.
- 이벤트 보존 기간만큼 TTL 삭제
- 집계 키 기준으로 최신 이벤트만 남기고 정리
- 파티션별 오프셋 기반으로 정리
5) DB 업데이트도 “멱등한 형태”로 만들기
inbox로 1차 방어를 하더라도, 비즈니스 업데이트 자체가 멱등하면 안정성이 더 올라갑니다.
예를 들어 결제 완료 이벤트가 여러 번 와도, 상태를 단순히 PAID로 세팅하는 업데이트는 멱등적입니다.
UPDATE orders
SET status = 'PAID', updated_at = NOW()
WHERE order_id = ?
AND status != 'PAID';
또는 이벤트 버전이 있다면 낙관적 조건을 넣을 수 있습니다.
UPDATE orders
SET status = ?, version = version + 1
WHERE order_id = ?
AND version = ?;
이 방식은 중복뿐 아니라 역순 도착에도 강해집니다.
6) 프로듀서 측 함정: transactional.id와 fencing
Kafka 트랜잭션을 쓰면 transactional.id가 동일한 프로듀서 인스턴스가 중복 실행될 때 fencing이 일어납니다. 이는 중복 발행 방지에 유리하지만, 운영 중 다음 문제가 생깁니다.
- 배포 중 동일
transactional.id를 가진 인스턴스가 잠깐 겹치면 한쪽이 fencing으로 실패 - 스테이트풀한 프로듀서 재시작 정책이 불안정하면 연쇄 실패
따라서 transactional.id는 보통 다음 원칙으로 설계합니다.
- 인스턴스마다 유일하지 않고, “논리적 프로듀서” 단위로 고정
- 동시에 두 개가 뜨지 않도록 오케스트레이션 레벨에서 보장
프로세스가 반복 재시작하는 환경에서는 원인 파악이 먼저입니다. 시스템 레벨 재시작 루프는 systemd 서비스 재시작 반복 원인 추적 체크리스트 같은 접근으로 빠르게 좁힐 수 있습니다.
7) 외부 API 사이드이펙트: 멱등 키 없으면 “우리 DB로” 만든다
외부 API가 멱등 키를 지원하면 가장 좋습니다.
- 요청에
Idempotency-Key를 넣고 - 동일 키 요청은 서버가 동일 결과를 반환
지원하지 않는다면 다음 중 하나로 우회합니다.
- 외부 호출 전에
side_effect_log같은 테이블에eventId를 기록하고, 이미 존재하면 호출하지 않음 - 외부 호출을 outbox로 분리해 재시도 가능하게 만들고, 호출 결과를 저장해 중복 호출을 차단
네트워크 타임아웃은 중복을 양산합니다. 특히 게이트웨이 계층에서 504가 자주 보이면 재시도 정책이 중복 호출을 만들 수 있습니다. 인프라 타임아웃과 재시도 상호작용은 Cloud Run 504 Timeout 원인·해결 9가지도 참고할 만합니다.
8) Kafka Streams를 쓸 때의 착시: EOS는 토폴로지 내부만
Kafka Streams의 exactly_once_v2는 강력하지만, 다음을 기억해야 합니다.
- 상태 저장소와 출력 토픽 사이의 일관성을 보장하는 쪽에 강함
- 토폴로지 밖의 DB, 외부 API에는 적용되지 않음
따라서 Streams로 “결정”을 내리고, 실제 사이드이펙트는 outbox 이벤트로 분리한 다음, 멱등 컨슈머가 실행하는 형태가 운영적으로 안전합니다.
9) 운영 체크리스트: 중복을 ‘없애는’ 게 아니라 ‘통제’한다
마지막으로 중복을 통제하기 위한 체크리스트입니다.
컨슈머
enable.auto.commit=false- 처리 성공 후 커밋
- inbox 테이블 또는 유니크 키 기반 dedup
- 리밸런싱 대비
max.poll.interval.ms와 처리 시간 정합 - 실패 시 재시도와 DLQ 정책 분리
프로듀서
enable.idempotence=trueacks=all- 트랜잭션 사용 시 안정적인
transactional.id - 재시도 정책이 중복 발행을 재생산하지 않는지 점검
이벤트 스키마
eventId필수aggregateId와eventType명확화- 버전 또는 발생 시각으로 역순 도착 방어
관측
- 중복 차단 카운터: inbox PK 충돌 횟수
- 파티션 리밸런싱 횟수, 컨슈머 랙
- 재시도율과 DLQ 유입률
10) 결론: Exactly-Once를 믿지 말고, 멱등성을 설계하라
Kafka Exactly-Once는 “Kafka 내부 파이프라인”에서는 유효하지만, MSA에서 우리가 진짜로 원하는 것은 DB와 외부 사이드이펙트까지 포함한 E2E 일관성입니다. 이를 위해서는
- 컨슈머의 inbox 기반 dedup
- 비즈니스 업데이트의 멱등성
- 외부 호출의 멱등 키 또는 outbox 분리
이 3가지를 기본값으로 깔고, Kafka 트랜잭션은 그 위에 얹는 보조 수단으로 보는 것이 안전합니다.
중복은 언젠가 발생합니다. 중요한 것은 “발생해도 데이터가 망가지지 않는 설계”와 “발생했는지 바로 아는 관측”입니다.