- Published on
Kafka Exactly-Once인데 중복처리? Outbox+Idempotency 디버깅
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 운영 중 가장 당황스러운 순간 중 하나는 exactly-once를 믿고 설계를 단순화했는데, 결제/포인트/재고 같은 핵심 도메인에서 중복 처리가 터지는 경우입니다. Kafka 문서와 설정을 다시 읽어보면 분명 EOS(Exactly-Once Semantics)를 지원한다고 하는데, 현실에서는 “왜 두 번 처리됐지?”가 반복됩니다.
이 글은 다음 질문에 답하는 형태로 구성합니다.
- Kafka EOS가 정확히 무엇을 보장하고, 무엇을 보장하지 않는가
- Outbox 패턴을 썼는데도 중복이 나는 전형적인 실패 모드는 무엇인가
- Idempotency(멱등성)를 어디에, 어떤 키로 걸어야 진짜 중복 방지가 되는가
- 재현과 관측(로그/메트릭/DB 제약)으로 원인을 좁히는 디버깅 절차
참고로 “읽기 모델이 가끔 두 번 반영된다/순서가 꼬인다” 같은 증상은 디버깅 방법론이 유사합니다. 필요하면 CQRS 읽기모델 최종일관성 버그 진단 7단계도 함께 보시면 문제를 구조적으로 쪼개는 데 도움이 됩니다.
Kafka Exactly-Once의 보장 범위를 먼저 자르기
Kafka에서 말하는 EOS는 보통 아래 조합을 의미합니다.
- Producer idempotence: 재시도 시 중복 레코드 생성을 줄임
- Transactional producer: 여러 토픽/파티션에 대한 produce를 트랜잭션으로 묶음
- Consumer read-process-write: consume한 오프셋 커밋을 트랜잭션에 포함(정확히는
sendOffsetsToTransaction)
하지만 중요한 함정이 있습니다.
함정 1: “Kafka 내부”의 once일 뿐, DB/외부 API까지는 아니다
Kafka 트랜잭션은 Kafka 브로커에 대한 원자성을 제공합니다. 반면 여러분의 서비스는 대개 다음과 같이 동작합니다.
- Kafka에서 메시지 consume
- DB 업데이트
- 외부 API 호출(결제, 메일, 푸시)
- 오프셋 커밋
여기서 DB 업데이트와 외부 API 호출은 Kafka 트랜잭션 바깥입니다. 즉, Kafka는 DB side-effect의 중복을 막아주지 않습니다.
함정 2: “정확히 한 번 처리”와 “정확히 한 번 전달”은 다르다
실무에서 필요한 건 대개 “부작용(side-effect)이 정확히 한 번”입니다. 메시지가 중복 전달되더라도, 처리 로직이 멱등하면 결과는 한 번만 반영됩니다.
따라서 결론은 단순합니다.
- Kafka EOS는 도움이 되지만, 중복 방지의 최종 책임은 애플리케이션 멱등성에 있습니다.
Outbox 패턴을 썼는데도 중복이 나는 이유
Outbox는 “DB 트랜잭션으로 상태 변경과 이벤트 발행 의도를 함께 기록”해서, dual-write(DB 업데이트 + Kafka 발행)의 원자성 문제를 줄이는 패턴입니다.
일반적인 흐름은 다음과 같습니다.
- 비즈니스 트랜잭션에서
orders업데이트 - 같은 트랜잭션에서
outbox테이블에 이벤트 row insert - 별도 퍼블리셔가
outbox를 읽어 Kafka로 발행 - 발행 성공 시
outboxrow를SENT로 마킹
그런데도 중복이 나는 대표 케이스는 아래입니다.
실패 모드 A: Outbox 퍼블리셔의 “at-least-once 발행”
퍼블리셔가 Kafka로 보낸 뒤 SENT 업데이트 전에 죽으면, 재시작 후 같은 outbox row를 다시 발행할 수 있습니다. 즉 Outbox는 보통 at-least-once 발행입니다.
이 자체는 나쁜 게 아닙니다. 설계가 “발행은 중복 가능, 소비는 멱등”이면 안정적으로 굴러갑니다.
실패 모드 B: Outbox row 자체가 중복 생성됨
요청 재시도(클라이언트 타임아웃, LB 재전송, 사용자 더블클릭)로 인해 비즈니스 트랜잭션이 두 번 실행되면 outbox도 두 번 쌓입니다.
- 이 경우 Kafka 설정과 무관하게 원천 이벤트가 두 개입니다.
실패 모드 C: Outbox 폴링의 동시성/락 설계가 약함
다중 인스턴스가 폴링하면서 같은 row를 동시에 집어가면 중복 발행이 생깁니다.
select ... for update skip locked류의 패턴이 없거나- 상태 전이를
NEWPROCESSINGSENT로 나누지 않거나 PROCESSING전환이 원자적이지 않으면
중복 발행은 쉽게 발생합니다.
실패 모드 D: 파티셔닝 키가 바뀌어 순서가 깨짐
중복 자체가 아니라 “두 번 처리된 것처럼 보이는” 케이스도 있습니다.
- 같은 aggregate(예: orderId)의 이벤트가 서로 다른 파티션에 들어가면
- 소비 순서가 뒤섞여 “취소 후 결제완료” 같은 비정상 상태가 잠깐 나타날 수 있습니다.
이때 읽기 모델/캐시/서치 인덱스에서 중복처럼 관측되기도 합니다.
Idempotency를 어디에 걸어야 하는가: 소비자(Consumer) 기준으로 설계
중복을 현실적으로 제거하는 가장 강력한 방법은 소비자에서 멱등 처리하는 것입니다. 핵심은 “무엇을 키로 dedup할 것인가”입니다.
멱등 키 설계 3원칙
- 비즈니스 의미가 있는 고유 키를 사용한다(가능하면 커맨드/요청 ID)
- 이벤트 재발행/재처리에도 변하지 않는 값이어야 한다
- DB에서 유니크 제약으로 강제할 수 있어야 한다
예시 키 후보:
commandId(가장 권장: 클라이언트 요청 단위)eventId(Outbox row의 PK를 이벤트에 포함)aggregateId + version(이벤트 소싱/버저닝 기반)
(권장) DB 유니크 제약으로 멱등성을 “증명 가능한” 형태로 만들기
아래는 결제 완료 이벤트를 처리할 때 event_id로 중복을 막는 전형적인 방식입니다.
-- 이미 처리한 이벤트를 기록하는 테이블
create table processed_events (
event_id varchar(64) primary key,
processed_at timestamptz not null default now()
);
-- 예: 결제 반영 테이블
create table payment_ledger (
payment_id varchar(64) primary key,
order_id varchar(64) not null,
amount numeric not null,
created_at timestamptz not null default now()
);
소비 로직은 다음처럼 구성합니다.
processed_events에 먼저 insert 시도- 성공하면 비즈니스 업데이트 수행
- insert가 유니크 충돌이면 “이미 처리됨”으로 종료
@Transactional
public void handlePaymentCompleted(PaymentCompletedEvent e) {
try {
processedEventRepository.insert(e.eventId());
} catch (DuplicateKeyException dup) {
// 이미 처리한 이벤트: 멱등하게 종료
return;
}
// 실제 부작용(ledger 반영 등)
paymentLedgerRepository.insert(
e.paymentId(), e.orderId(), e.amount()
);
}
포인트는 “코드의 if 체크”가 아니라 DB 유니크 제약으로 중복을 막는 것입니다. 그래야 장애/동시성에서도 깨지지 않습니다.
외부 API 호출이 섞이면: Idempotency-Key를 함께 써라
외부 결제/메일/푸시 API는 네트워크 재시도에서 중복 호출이 흔합니다. 가능하다면 API가 제공하는 Idempotency-Key(또는 requestId)를 사용하세요.
- 소비자 멱등(내 DB)
- 외부 API 멱등(외부 시스템)
두 겹을 쌓아야 “진짜 한 번”에 가까워집니다.
Outbox 퍼블리셔 구현 체크리스트(중복 발행을 줄이기)
중복 발행 자체를 0으로 만들 필요는 없지만, 불필요한 중복을 줄이면 운영이 편해집니다.
1) 폴링은 원자적으로 집어가라: skip locked
PostgreSQL 기준으로는 다음 패턴이 많이 쓰입니다.
with picked as (
select id
from outbox
where status = 'NEW'
order by id
limit 100
for update skip locked
)
update outbox o
set status = 'PROCESSING', locked_at = now()
from picked
where o.id = picked.id
returning o.*;
- 여러 인스턴스가 떠도 같은 row를 중복으로 집지 않음
PROCESSING으로 전환이 원자적임
2) Kafka 발행 성공 후 SENT 마킹은 재시도 가능하게
SENT 업데이트가 실패하면 다음 폴링에서 PROCESSING row가 영원히 남을 수 있습니다. 다음을 고려하세요.
PROCESSING타임아웃(예:locked_at오래된 row를NEW로 되돌림)SENT업데이트도 재시도
3) 이벤트 페이로드에 eventId를 반드시 포함
소비자 멱등 키로 쓰려면 이벤트 자체에 eventId가 있어야 합니다.
{
"eventId": "outbox_983742",
"eventType": "PAYMENT_COMPLETED",
"orderId": "o_1001",
"paymentId": "p_9001",
"amount": 12000,
"occurredAt": "2026-02-26T10:00:00Z"
}
“EOS인데 중복”을 실제로 디버깅하는 순서
증상만 보면 모두 “중복 처리”지만, 원인은 크게 세 갈래로 나뉩니다.
- 원천 이벤트가 중복 생성됨
- 발행이 중복 수행됨
- 소비가 중복 반영됨
아래 순서대로 보면 빠르게 좁혀집니다.
1) 중복의 단위를 정의한다: 무엇이 두 번인가
- 같은
orderId가 두 번 처리? - 같은
paymentId가 두 번 반영? - 같은
eventId가 두 번 소비?
가능하면 로그에 아래를 구조화해서 남기세요.
eventIdtopic,partition,offsetconsumerGroupIdorderId같은 비즈니스 키
2) Outbox 테이블에서 원천 중복 여부 확인
가장 먼저 DB에서 확인합니다.
select aggregate_id, event_type, count(*)
from outbox
where created_at >= now() - interval '1 day'
group by 1,2
having count(*) > 1
order by count(*) desc;
- 여기서 이미 2개면 Kafka 이전 단계 문제(요청 재시도/비즈니스 중복 실행)입니다.
- 이 경우
commandId기반 유니크 제약을 비즈니스 트랜잭션에 넣는 게 정공법입니다.
3) Kafka에서 “같은 eventId가 여러 번 발행”됐는지 확인
컨슈머 로그에서 eventId가 다른데 비즈니스 키만 같으면 “원천 중복”입니다.
반대로 eventId가 같은데 오프셋이 다르면 “발행 중복”입니다.
- 퍼블리셔 재시작 타이밍
SENT마킹 실패- 폴링 동시성
을 의심하세요.
4) 소비자에서 중복 반영이 가능한지(멱등성 결여) 확인
가장 흔한 버그는 아래처럼 “존재 여부 조회 후 insert” 패턴입니다.
// 안티패턴: 동시성에서 깨지기 쉬움
if (!processedEventRepository.exists(e.eventId())) {
processedEventRepository.insert(e.eventId());
paymentLedgerRepository.insert(...);
}
동시에 두 스레드/두 인스턴스가 들어오면 둘 다 exists=false를 보고 insert를 시도해 중복 반영이 날 수 있습니다. 반드시 유니크 제약 + insert-first로 바꾸세요.
5) 리밸런스/세션 타임아웃으로 인한 재처리 확인
컨슈머가 처리 중에 리밸런스가 나면 같은 메시지를 다른 인스턴스가 다시 처리할 수 있습니다.
- 처리 시간이 긴데
max.poll.interval.ms가 작음 - 배치 처리로 poll 간격이 길어짐
- 외부 API 호출이 느려짐
이 경우도 “중복 전달” 자체는 정상 범주이며, 멱등성이 해결책입니다.
성능 병목이 동반된다면 DB 커넥션/스레드 고갈이 원인이 되는 경우가 많습니다. 운영 중 커넥션 풀이 말라서 처리 지연 -> 리밸런스 -> 재처리로 이어지는 패턴도 있으니, 비슷한 증상이 있다면 Spring Boot HikariCP 커넥션 고갈 3분 진단도 같이 점검해보는 게 좋습니다. (본문의 -> 는 MDX 빌드 안전을 위해 인라인 코드로 표기했습니다.)
Kafka 설정 관점: “중복을 줄이는” 옵션과 한계
중복을 아예 없애기보다는 “발생 확률을 낮추는” 설정입니다.
Producer
enable.idempotence=trueacks=allretries충분히transactional.id설정(트랜잭션 사용 시)
단, 이것은 “브로커에 중복 레코드를 덜 만든다”에 가깝고, Outbox 퍼블리셔가 같은 row를 두 번 보내는 논리적 중복까지 제거해주진 않습니다.
Consumer
- 오프셋 커밋을 자동에 맡기지 말고, 처리 완료 후 커밋(또는 트랜잭션 연계)
- 처리 시간이 길면
max.poll.interval.ms조정
하지만 다시 강조하면, 리밸런스/재시도는 언제든 생기므로 최종 방어선은 멱등성입니다.
실전 권장 아키텍처: Outbox + Idempotent Consumer + (가능하면) 외부 API 멱등
운영에서 가장 튼튼한 조합은 다음입니다.
- Outbox로 “DB 상태 변경 + 이벤트 발행 의도”를 원자적으로 기록
- 퍼블리셔는 중복 발행 가능성을 स्वीकार(at-least-once)
- 소비자는
eventId또는commandId로 DB 유니크 제약 기반 멱등 처리 - 외부 API는
Idempotency-Key로 중복 호출 방지
이렇게 하면 Kafka EOS 유무와 관계없이, 장애/재시작/리밸런스가 있어도 결과가 안정적으로 수렴합니다.
마무리: “Exactly-Once”를 믿는 대신, 경계를 설계하라
Kafka Exactly-Once는 분명 강력하지만, 많은 팀이 기대하는 “내 DB와 외부 시스템까지 포함한 정확히 한 번”을 자동으로 만들어주진 않습니다. 중복 처리는 버그가 아니라 분산 시스템의 기본 상태에 가깝고, Outbox와 멱등성은 그 기본 상태를 다루는 표준 도구입니다.
정리하면 디버깅은 아래 한 줄로 귀결됩니다.
- 중복이 “생성”인지, “발행”인지, “반영”인지 경계를 나누고
- 소비자에서 DB 유니크 제약으로 멱등성을 강제하라
이 두 가지만 제대로 해도 exactly-once인데 왜 두 번 됐지 류의 장애는 대부분 사라집니다.