- Published on
Kafka Exactly-Once 실패? 멱등키·Outbox 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 Kafka를 붙이면 처음엔 이렇게 믿기 쉽습니다. 프로듀서에 idempotence 켜고, 컨슈머에서 트랜잭션 쓰면 Exactly-Once 아닌가?
하지만 운영에 들어가면 ‘정확히 한 번’이 깨지는 지점은 Kafka 내부보다 서비스 경계(HTTP/DB/외부 API) 에서 더 자주 발생합니다. 특히 다음 상황에서 “Kafka는 한 번 보냈는데, 우리 비즈니스는 두 번 처리”가 됩니다.
- 컨슈머가 DB 커밋 직후 크래시 → 오프셋 커밋 실패 → 재처리
- 프로듀서가 DB 커밋 후 이벤트 발행 실패 → 데이터는 바뀌었는데 이벤트는 없음(유실)
- 재시도 정책(HTTP, gRPC, 메시지)이 겹쳐 중복이 자연 발생
이 글은 Exactly-Once를 ‘Kafka 옵션’이 아니라 ‘시스템 설계’로 달성하는 방법을 다룹니다. 핵심은 두 가지입니다.
- 멱등키(Idempotency Key) 로 “중복 처리”를 무해화
- Transactional Outbox 로 “DB 변경과 이벤트 발행”을 원자적으로 묶기
그리고 마지막으로, 이 둘을 실제로 어떻게 조합해서 운영 장애를 줄이는지 코드와 체크리스트로 정리합니다.
1) Kafka Exactly-Once의 범위: 어디까지 보장하나
Kafka에서 말하는 Exactly-Once는 보통 Kafka 내부(토픽/파티션/오프셋) 관점의 보장입니다.
- Idempotent Producer: 네트워크 재시도 등으로 같은 레코드가 중복 전송되는 것을 브로커 단에서 억제
- Transactions + read_process_write 패턴: 컨슈머가 읽고 처리한 뒤 다른 토픽에 쓰고 오프셋까지 같은 트랜잭션으로 커밋
여기까지는 강력합니다. 하지만 대부분의 서비스는 처리 결과를 DB에 저장하거나 외부 시스템 호출로 확정합니다. 이 순간부터는 Kafka 트랜잭션만으로는 해결이 안 됩니다.
대표적인 실패 시나리오 3가지
(1) DB 커밋 완료 → 오프셋 커밋 전에 크래시
컨슈머가 메시지 처리 후 DB에 반영했습니다. 그런데 오프셋 커밋 전에 프로세스가 죽으면, 재시작 후 같은 메시지를 다시 읽습니다.
- 결과: DB 반영이 2번 일어날 수 있음
(2) DB 커밋 완료 → 이벤트 발행 실패
주문 상태를 PAID로 바꾸고, OrderPaid 이벤트를 Kafka에 발행해야 합니다.
- DB는 커밋됨
- Kafka 발행은 타임아웃/권한/네트워크로 실패
결과: 상태는 바뀌었는데 이벤트가 없음(유실) → 다운스트림 서비스가 영원히 모름
(3) 재시도 정책이 겹치며 중복이 증폭
- API Gateway 재시도
- gRPC client retry
- Kafka consumer retry/backoff
각 계층이 “선의의 재시도”를 하면, 실제로는 중복이 기하급수로 늘 수 있습니다. (gRPC 데드라인/재시도 조합 문제는 gRPC MSA 데드라인 전파 누락으로 타임아웃 폭증 해결도 함께 보면 좋습니다.)
2) 멱등키(Idempotency Key): 중복을 ‘발생해도 안전’하게
멱등키는 간단히 말해 “이 요청/이 이벤트를 이미 처리했는지” 를 판별하는 키입니다.
- 프로듀서 측: 같은 비즈니스 이벤트를 여러 번 발행해도 동일 키
- 컨슈머 측: 같은 키가 다시 오면 처리 스킵(또는 동일 결과 반환)
멱등키 설계 원칙
- 비즈니스 의미를 가진 유일성:
orderId + eventType처럼 “같은 의미의 처리”를 대표해야 함 - 저장소에 유니크 제약으로 강제: 코드의 if-check는 레이스 컨디션에 취약
- 처리 결과를 함께 저장(선택): 중복 요청에 동일 응답을 주고 싶다면 결과 캐시까지
예시: 컨슈머에서 유니크 키로 중복 처리 차단(PostgreSQL)
아래는 event_id(멱등키)를 유니크로 잡고, 중복이면 아무 것도 하지 않는 패턴입니다.
CREATE TABLE processed_events (
event_id TEXT PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 비즈니스 테이블 예시
CREATE TABLE wallet (
user_id BIGINT PRIMARY KEY,
balance BIGINT NOT NULL
);
컨슈머 처리 로직(의사코드):
BEGIN;
-- 1) 멱등키 선점(중복이면 여기서 걸러짐)
INSERT INTO processed_events(event_id)
VALUES (:eventId)
ON CONFLICT DO NOTHING;
if rows_affected == 0:
ROLLBACK;
return; -- 이미 처리됨
-- 2) 비즈니스 반영
UPDATE wallet
SET balance = balance + :amount
WHERE user_id = :userId;
COMMIT;
이 방식의 장점은 명확합니다.
- 컨슈머가 크래시로 재처리해도 두 번째는
processed_events에서 막힘 - 오프셋 커밋이 늦어져도 비즈니스는 1회만 반영
단점/주의점도 있습니다.
processed_events가 무한히 커짐 → TTL/파티셔닝/아카이빙 필요- “정말 같은 이벤트인가?”를 eventId 생성 규칙으로 보장해야 함
3) Outbox 패턴: DB 변경과 이벤트 발행의 원자성 확보
멱등키가 “중복 처리”를 막는다면, Outbox는 “이벤트 유실”을 막습니다.
문제의 본질은 이겁니다.
- DB 트랜잭션과 Kafka 발행은 서로 다른 원자성 도메인
- 둘을 2PC로 묶는 건 대부분의 팀에게 과하고 운영 리스크가 큼
그래서 Transactional Outbox가 실전 해법으로 가장 널리 쓰입니다.
Transactional Outbox 동작 흐름
- 비즈니스 변경(예: 주문 결제 처리)과 함께
- 같은 DB 트랜잭션 안에서
outbox테이블에 이벤트 레코드를 INSERT - 별도 릴레이(Outbox Publisher)가
outbox를 폴링/스트리밍해서 Kafka로 발행 - 발행 성공하면
outbox레코드를SENT처리
즉, “DB에 커밋된 사실”만 신뢰하고, Kafka 발행은 재시도 가능한 비동기 작업으로 격리합니다.
Outbox 테이블 스키마 예시
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
aggregate_type TEXT NOT NULL,
aggregate_id TEXT NOT NULL,
event_type TEXT NOT NULL,
event_id TEXT NOT NULL, -- 멱등키로도 사용 가능
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'PENDING',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
sent_at TIMESTAMPTZ NULL
);
CREATE UNIQUE INDEX ux_outbox_event_id ON outbox(event_id);
CREATE INDEX ix_outbox_pending ON outbox(status, created_at);
비즈니스 트랜잭션에서 Outbox 쓰기
BEGIN;
UPDATE orders
SET status = 'PAID'
WHERE order_id = :orderId;
INSERT INTO outbox(aggregate_type, aggregate_id, event_type, event_id, payload)
VALUES ('Order', :orderId, 'OrderPaid', :eventId, :payload_json);
COMMIT;
이제 Kafka 발행이 실패해도 이벤트는 DB에 남아있고, 퍼블리셔가 재시도해서 결국 발행됩니다.
4) Outbox Publisher: 중복 발행과 순서 문제를 다루는 법
Outbox Publisher는 구현체가 다양합니다.
- 폴링 기반: 일정 주기로
PENDING을 읽어 발행 - CDC 기반: Debezium 등으로 outbox 변경을 캡처해 Kafka로
여기서는 폴링 기반을 예로 들되, 동시성/중복 발행 방지를 함께 넣겠습니다.
폴링 + SKIP LOCKED로 안전하게 가져오기(PostgreSQL)
여러 퍼블리셔 인스턴스가 떠도, 같은 row를 중복 처리하지 않도록 잠금을 활용합니다.
-- PENDING 중 일부를 잠그고 가져오기
WITH cte AS (
SELECT id
FROM outbox
WHERE status = 'PENDING'
ORDER BY created_at
LIMIT 100
FOR UPDATE SKIP LOCKED
)
UPDATE outbox
SET status = 'PROCESSING'
WHERE id IN (SELECT id FROM cte)
RETURNING *;
이후 애플리케이션에서 Kafka로 발행하고 성공 시:
UPDATE outbox
SET status = 'SENT', sent_at = now()
WHERE id = :id;
Kafka 발행 측 멱등성
퍼블리셔도 장애로 재시작하면 같은 outbox 이벤트를 다시 발행할 수 있습니다. 따라서 아래 중 하나는 필수입니다.
- 토픽의 key를
event_id로 두고, 컨슈머에서 멱등 처리 - 또는 프로듀서 idempotence/transaction을 켜되, 비즈니스 멱등을 대체하진 못함
프로듀서 설정 예시(Java/Kafka Producer):
enable.idempotence=true
acks=all
retries=Integer.MAX_VALUE
max.in.flight.requests.per.connection=5
실전에서는 “프로듀서 멱등 + 컨슈머 멱등키”를 같이 둡니다. 전자는 전송 중복을 줄이고, 후자는 비즈니스 중복을 막습니다.
5) 결국 정답은 ‘멱등키 + Outbox’ 조합이다
둘 중 하나만 쓰면 빈틈이 생깁니다.
- 멱등키만: 이벤트 유실(발행 실패) 문제는 남음
- Outbox만: 발행 중복/컨슈머 재처리 시 비즈니스 중복 반영 위험이 남음
추천 조합(가장 흔한 운영형)
- Producer(비즈니스 서비스)
- DB 트랜잭션에서 상태 변경 + outbox insert
- Outbox Publisher
- outbox를 읽어 Kafka 발행(재시도)
- Consumer(다운스트림 서비스)
event_id기반 멱등 처리(유니크 제약)
이렇게 하면:
- 상태 변경은 DB 커밋으로 확정
- 이벤트는 outbox로 유실 방지
- 중복 전달/재처리는 멱등키로 무해화
6) 운영에서 자주 터지는 포인트와 체크리스트
(1) Outbox 테이블이 병목이 되는 경우
- 인덱스 부재로
PENDING조회가 느려짐 processed_events/outbox가 커져 vacuum/IO 비용 증가
대응:
status, created_at복합 인덱스- 파티셔닝(월 단위 등) 또는 TTL 아카이빙
- 퍼블리셔 배치 크기/주기 튜닝
(2) 소비자 DB 커넥션 고갈로 재처리 폭증
컨슈머가 재시도하며 커넥션을 더 잡아먹고, 결국 장애가 눈덩이처럼 커질 수 있습니다. DB 커넥션 고갈은 메시징 시스템에서 특히 치명적입니다. 필요하면 Aurora PostgreSQL remaining connection slots... 체크리스트처럼 프록시/풀링 전략까지 같이 점검하세요.
(3) Saga/보상 트랜잭션과의 충돌
Outbox로 이벤트 발행이 “결국” 보장되면, 다운스트림에서 Saga 보상 로직이 이미 실행된 뒤 늦게 이벤트가 도착하는 상황도 생깁니다. 이때도 멱등키와 상태 머신 설계가 중요합니다. 보상 트랜잭션이 꼬일 때의 디버깅 관점은 MSA Saga 보상 트랜잭션 꼬임 디버깅 실전과 연결됩니다.
7) 실전 예시: ‘결제 완료’ 이벤트를 안전하게 발행/소비하기
목표
orders.status = PAID갱신과OrderPaid이벤트 발행이 분리되어도 유실 없음OrderPaid가 중복 전달되어도 지갑 적립은 1회만
이벤트 스키마(예)
{
"eventId": "01HZX...",
"type": "OrderPaid",
"orderId": "123",
"userId": 7,
"amount": 1000,
"occurredAt": "2026-02-24T12:34:56Z"
}
eventId는 ULID/UUID 등으로 생성- 동일 주문에 대해 동일 이벤트가 다시 만들어질 수 있다면
eventId = hash(orderId + type + paidAt)같은 결정적 키도 고려
발행 측(트랜잭션)
- 결제 확정 → orders 업데이트
- outbox insert(유니크 event_id)
퍼블리셔
PENDING→PROCESSING→ Kafka 발행 →SENT- 실패 시
PROCESSING을 다시PENDING으로 돌리는 리커버리 잡(타임아웃 기준)
소비 측(멱등)
processed_events(event_id)에 먼저 insert로 선점- 성공한 경우에만 wallet 업데이트
이 흐름이면 컨슈머 크래시/재시작, 네트워크 문제, 프로듀서 타임아웃 등 흔한 장애를 겪어도 “정확히 한 번의 비즈니스 효과”를 만들 수 있습니다.
8) 결론: Exactly-Once는 옵션이 아니라 계약이다
Kafka의 Exactly-Once는 강력하지만, 대부분의 장애는 Kafka 바깥에서 생깁니다. 그래서 실전에서는 다음 계약을 시스템에 박아 넣어야 합니다.
- Outbox로 유실을 없애고
- 멱등키로 중복을 무해화한다.
이 두 가지가 갖춰지면, “Exactly-Once”는 더 이상 희망사항이 아니라 운영 가능한 특성이 됩니다.
부록: 최소 체크리스트
- 모든 이벤트에
event_id(멱등키) 존재 - 컨슈머는
event_id유니크 제약으로 중복 차단 - 상태 변경과 outbox insert가 같은 DB 트랜잭션
- Outbox Publisher는
SKIP LOCKED등으로 동시성 안전 - outbox/processed_events에 아카이빙/파티셔닝/TTL 전략
- 재시도 정책(HTTP/gRPC/Kafka)의 데드라인·백오프 정렬