Published on

Kafka Exactly-Once 실패? 멱등키·Outbox 실전

Authors

서버에서 Kafka를 붙이면 처음엔 이렇게 믿기 쉽습니다. 프로듀서에 idempotence 켜고, 컨슈머에서 트랜잭션 쓰면 Exactly-Once 아닌가?

하지만 운영에 들어가면 ‘정확히 한 번’이 깨지는 지점은 Kafka 내부보다 서비스 경계(HTTP/DB/외부 API) 에서 더 자주 발생합니다. 특히 다음 상황에서 “Kafka는 한 번 보냈는데, 우리 비즈니스는 두 번 처리”가 됩니다.

  • 컨슈머가 DB 커밋 직후 크래시 → 오프셋 커밋 실패 → 재처리
  • 프로듀서가 DB 커밋 후 이벤트 발행 실패 → 데이터는 바뀌었는데 이벤트는 없음(유실)
  • 재시도 정책(HTTP, gRPC, 메시지)이 겹쳐 중복이 자연 발생

이 글은 Exactly-Once를 ‘Kafka 옵션’이 아니라 ‘시스템 설계’로 달성하는 방법을 다룹니다. 핵심은 두 가지입니다.

  1. 멱등키(Idempotency Key) 로 “중복 처리”를 무해화
  2. 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): 중복을 ‘발생해도 안전’하게

멱등키는 간단히 말해 “이 요청/이 이벤트를 이미 처리했는지” 를 판별하는 키입니다.

  • 프로듀서 측: 같은 비즈니스 이벤트를 여러 번 발행해도 동일 키
  • 컨슈머 측: 같은 키가 다시 오면 처리 스킵(또는 동일 결과 반환)

멱등키 설계 원칙

  1. 비즈니스 의미를 가진 유일성: orderId + eventType 처럼 “같은 의미의 처리”를 대표해야 함
  2. 저장소에 유니크 제약으로 강제: 코드의 if-check는 레이스 컨디션에 취약
  3. 처리 결과를 함께 저장(선택): 중복 요청에 동일 응답을 주고 싶다면 결과 캐시까지

예시: 컨슈머에서 유니크 키로 중복 처리 차단(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 동작 흐름

  1. 비즈니스 변경(예: 주문 결제 처리)과 함께
  2. 같은 DB 트랜잭션 안에서 outbox 테이블에 이벤트 레코드를 INSERT
  3. 별도 릴레이(Outbox Publisher)가 outbox를 폴링/스트리밍해서 Kafka로 발행
  4. 발행 성공하면 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)

퍼블리셔

  • PENDINGPROCESSING → 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)의 데드라인·백오프 정렬