Published on

Kafka Exactly-Once 깨질 때 Outbox 패턴 점검

Authors

서버에서 enable.idempotence 와 트랜잭션 프로듀서를 켰는데도, 운영에서는 여전히 중복 이벤트가 생기거나(dup), 반대로 유실(loss)이 발생하는 경우가 있습니다. 이때 많은 팀이 Kafka의 Exactly-Once Semantics(EOS) 자체를 의심하지만, 실제로는 DB 커밋과 Kafka 발행 사이의 경계에서 일관성이 깨지는 경우가 더 흔합니다.

이 글은 Outbox 패턴을 기준으로 “Kafka Exactly-Once가 깨졌다고 느껴질 때” 어디부터 점검해야 하는지, 그리고 재발 방지를 위해 어떤 설계를 고정해야 하는지 정리합니다.

또한 분산 트랜잭션 관점의 실패 복구는 MSA Saga 패턴 실패 복구 - 보상·재시도 설계 와도 맞닿아 있고, 관측 가능성(Trace)까지 포함하면 OpenTelemetry로 MSA 분산 트랜잭션 추적 실전 도 함께 참고하면 좋습니다.

1) 먼저 정리: Kafka EOS가 보장하는 범위

Kafka의 EOS는 크게 두 축입니다.

  • 프로듀서 측: idempotent producer + transactional producer를 통해, 같은 레코드가 재전송되더라도 브로커에 중복으로 커밋되지 않게(또는 트랜잭션 단위로 원자적 커밋) 처리
  • 컨슈머 측: read_committed 로 트랜잭션 커밋된 레코드만 읽고, 컨슈머 오프셋 커밋을 트랜잭션에 묶어(consume-transform-produce) 정확히 한 번 처리에 가깝게 구성

하지만 중요한 제한이 있습니다.

  • Kafka EOS는 Kafka 내부(토픽/오프셋)에서의 원자성에 강합니다.
  • 반면 DB 커밋과 Kafka 발행을 하나의 원자적 트랜잭션으로 묶어주지 않습니다.

즉, 애플리케이션이 DB commit 이후 produce 를 하다가 죽으면 “DB에는 반영됐는데 이벤트는 안 나감”이 생길 수 있고, 반대로 produce 이후 DB commit 실패가 나면 “이벤트는 나갔는데 DB는 롤백”이 발생할 수 있습니다.

이 경계를 안정적으로 만드는 대표 해법이 Outbox 패턴입니다.

2) 증상별로 보는 “Exactly-Once가 깨졌다” 시나리오

2.1 중복 이벤트가 발생한다

대표 원인:

  • Outbox relay(퍼블리셔)가 재시도하면서 동일 레코드를 다시 publish
  • 컨슈머가 at-least-once로 동작하며 중복 처리
  • 토픽 키 설계가 잘못되어 동일 엔티티 이벤트가 파티션을 넘나들며 순서가 섞이고, 이를 중복으로 오인

중복 자체는 분산 시스템에서 흔합니다. 핵심은 중복이 발생해도 부작용이 없도록(idempotent consumer) 만들거나, 중복을 탐지/차단해야 합니다.

2.2 이벤트 유실이 발생한다

대표 원인:

  • DB 변경과 Kafka 발행이 분리되어 있고, 중간에 프로세스가 죽음
  • Outbox 테이블에 기록은 됐는데 relay가 읽지 못함(폴링/CDC 장애)
  • Outbox 레코드가 청소/보관 정책 문제로 조기 삭제

유실은 중복보다 위험합니다. Outbox는 “DB에 남아 있는 한 결국 발행된다”는 성질로 유실을 막는 데 초점을 둡니다.

2.3 순서 보장이 깨진다

대표 원인:

  • 동일 aggregate에 대한 이벤트가 서로 다른 파티션으로 발행됨(키 불일치)
  • Outbox relay가 병렬로 publish하면서 같은 키를 여러 워커가 처리
  • DB에서 읽는 순서와 토픽에 커밋되는 순서가 달라짐(배치/재시도)

순서 문제는 “Exactly-Once”가 아니라 “ordering” 이슈입니다. 하지만 운영에서는 둘이 섞여 보입니다.

3) Outbox 패턴의 핵심 불변식(invariant)

Outbox 패턴이 제대로 동작하려면 아래 3가지를 불변식으로 잡는 게 좋습니다.

  1. 도메인 변경과 Outbox insert는 동일 DB 트랜잭션에서 커밋
  2. Outbox relay는 최소 1회 이상 발행(at-least-once)해도 괜찮게 설계
  3. 컨슈머는 중복을 견딜 수 있어야 하며(또는 dedup 저장소로 차단), 순서/정합성 규칙을 명시

Kafka의 EOS를 “완전무결한 exactly once”로 기대하기보다, Outbox로 유실을 막고, 나머지는 중복 허용 + 멱등 처리로 고정하는 접근이 현실적입니다.

4) Outbox 테이블 설계 체크리스트

아래는 실무에서 자주 문제가 나는 포인트들입니다.

4.1 이벤트 식별자(event_id)와 멱등 키

  • event_id 는 전역 유니크(UUID 권장)
  • 컨슈머 멱등 처리 키로도 사용 가능
  • 같은 비즈니스 커맨드에서 여러 이벤트가 나가면, command_id 또는 correlation_id 를 별도로 둬서 추적성을 확보

4.2 상태 컬럼과 락 전략

일반적으로 다음 중 하나를 택합니다.

  • published_atNULL 인 행을 폴링해서 발행 후 업데이트
  • statusNEW PROCESSING PUBLISHED FAILED 로 두고 워커가 가져갈 때 PROCESSING 으로 전이

멀티 워커 환경에서는 행 잠금이 중요합니다. PostgreSQL이라면 FOR UPDATE SKIP LOCKED 가 실전에서 가장 많이 쓰입니다.

4.3 인덱스와 보관 정책

  • 폴링 쿼리 조건(published_at IS NULL 또는 status=NEW)에 맞춘 인덱스 필수
  • 성공 이벤트를 언제 삭제/아카이브할지 결정(감사/리플레이 요구사항)
  • 테이블이 커지면 vacuum/인덱스 bloat로 성능이 급락할 수 있음

PostgreSQL 운영에서 outbox 테이블이 폭증하면 autovacuum 튜닝이 필요할 수 있습니다. 관련해서는 PostgreSQL autovacuum 멈춤으로 테이블 폭증 해결 도 참고할 만합니다.

5) 구현 예제: 트랜잭션 안에서 Outbox 기록하기

아래는 PostgreSQL 기준의 간단한 예시입니다. 핵심은 도메인 변경과 Outbox insert가 같은 트랜잭션이라는 점입니다.

-- 도메인 테이블
CREATE TABLE orders (
  id            BIGSERIAL PRIMARY KEY,
  status        TEXT NOT NULL,
  updated_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Outbox 테이블
CREATE TABLE outbox_events (
  id             BIGSERIAL PRIMARY KEY,
  event_id       UUID NOT NULL UNIQUE,
  aggregate_type TEXT NOT NULL,
  aggregate_id   TEXT NOT NULL,
  event_type     TEXT NOT NULL,
  payload        JSONB NOT NULL,
  created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
  published_at   TIMESTAMPTZ NULL
);

CREATE INDEX outbox_unpublished_idx
  ON outbox_events (created_at)
  WHERE published_at IS NULL;
-- 하나의 트랜잭션에서 주문 상태 변경 + 이벤트 적재
BEGIN;

UPDATE orders
SET status = 'PAID', updated_at = now()
WHERE id = 42;

INSERT INTO outbox_events(event_id, aggregate_type, aggregate_id, event_type, payload)
VALUES (
  gen_random_uuid(),
  'Order',
  '42',
  'OrderPaid',
  jsonb_build_object('orderId', 42, 'paidAt', now())
);

COMMIT;

이렇게 하면 애플리케이션이 커밋 직후 죽더라도 Outbox 레코드는 남습니다. 즉, “DB는 반영됐는데 이벤트 유실”을 구조적으로 막을 수 있습니다.

6) 구현 예제: Relay 워커(폴링) + 안전한 가져오기

PostgreSQL에서 멀티 워커로 안전하게 가져오는 전형적인 패턴입니다.

-- 미발행 이벤트를 배치로 가져오되, 다른 워커와 충돌하지 않게 잠금
WITH cte AS (
  SELECT id
  FROM outbox_events
  WHERE published_at IS NULL
  ORDER BY created_at
  FOR UPDATE SKIP LOCKED
  LIMIT 100
)
SELECT e.*
FROM outbox_events e
JOIN cte ON e.id = cte.id;

애플리케이션 로직(의사 코드)은 보통 아래 흐름입니다.

1) 트랜잭션 시작
2) SKIP LOCKED로 미발행 이벤트 N개 조회
3) 트랜잭션 커밋 (혹은 PROCESSING 마킹 후 커밋)
4) Kafka로 발행
5) 발행 성공 시 published_at 업데이트
6) 실패 시 재시도 (지수 백오프, DLQ 고려)

여기서 자주 깨지는 포인트는 4)와 5) 사이입니다.

  • Kafka 발행 성공
  • 그런데 published_at 업데이트 전에 프로세스 다운

그러면 동일 이벤트가 다시 발행됩니다. 즉, Outbox는 기본적으로 중복을 만들 수 있습니다.

따라서 Outbox 패턴을 “Exactly-Once를 만들어주는 장치”로 이해하면 운영에서 실망합니다. Outbox는 유실 방지 장치이고, 중복은 컨슈머 멱등 처리로 흡수하는 게 정석입니다.

7) 중복을 줄이려면: 프로듀서 멱등성과 메시지 키

Outbox relay에서 Kafka 프로듀서를 구성할 때 최소한 아래는 확인합니다.

  • enable.idempotence=true
  • acks=all
  • retries 는 충분히 크게
  • max.in.flight.requests.per.connection 을 멱등 설정과 호환되게(클라이언트 기본값 확인)

또한 메시지 키는 순서/파티션 결정에 직결됩니다.

  • 같은 aggregate_id 는 같은 키를 써서 같은 파티션으로 가게 해야 순서가 유지됩니다.
  • 키가 랜덤이면 순서는 깨지고, 다운스트림에서 “중복/역전”처럼 보이는 현상이 늘어납니다.

8) 컨슈머 멱등 처리: 결국 마지막 방어선

Outbox로 유실을 막아도, 중복은 남습니다. 컨슈머에서 다음 중 하나를 반드시 구현하세요.

8.1 processed_events 테이블로 dedup

CREATE TABLE processed_events (
  event_id UUID PRIMARY KEY,
  processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

컨슈머 처리 흐름(의사 코드):

BEGIN
1) event_id를 processed_events에 INSERT 시도
2) 이미 존재하면(중복) 비즈니스 로직 스킵하고 COMMIT
3) 없으면 비즈니스 로직 수행
4) COMMIT

이 방식은 DB 쓰기 비용이 들지만 가장 명확합니다.

8.2 Upsert 기반 멱등

도메인 테이블에 자연키가 있고, 이벤트가 동일 결과를 여러 번 적용해도 동일 상태가 된다면 INSERT ... ON CONFLICT DO NOTHING 또는 상태 전이의 조건부 업데이트로 멱등성을 만들 수 있습니다.

9) “EOS인데 왜 중복이 나오지?” 자주 하는 오해

  • Kafka EOS는 “컨슈머가 정확히 한 번 처리”를 자동으로 보장하지 않습니다.
  • 애플리케이션이 외부 DB를 갱신하는 순간, Kafka 트랜잭션 경계 밖으로 나갑니다.
  • Outbox를 붙여도 relay 업데이트 타이밍 때문에 중복은 가능하며, 이는 결함이 아니라 설계 선택입니다.

따라서 운영 목표를 다음처럼 다시 쓰는 게 좋습니다.

  • 목표 1: 이벤트 유실은 0에 가깝게(Outbox)
  • 목표 2: 중복은 허용하되 부작용은 0에 가깝게(멱등 컨슈머)
  • 목표 3: 순서 규칙을 명시하고 키/파티션/워커 모델로 강제

10) 운영 점검 체크리스트(장애 시 바로 보는 항목)

10.1 Outbox 적재는 정말 같은 트랜잭션인가

  • 도메인 변경과 outbox insert가 같은 커밋에 묶였는지
  • ORM 사용 시 flush 타이밍 때문에 outbox만 먼저 커밋되는 코드가 없는지

10.2 Relay의 읽기/잠금/병렬성

  • SKIP LOCKED 또는 동등한 락 전략이 있는지
  • 동일 파티션 키에 대해 여러 워커가 경쟁하며 순서를 깨지 않는지
  • 배치 발행 후 부분 성공 처리(일부만 published 처리)에서 누락이 생기지 않는지

10.3 Kafka 프로듀서 설정

  • acks=all 인지
  • 브로커 장애 시 재시도/타임아웃이 충분한지
  • 메시지 키가 aggregate 기준으로 안정적인지

10.4 컨슈머 멱등 처리 유무

  • dedup 테이블이 있는지
  • 없으면 upsert/조건부 업데이트로 멱등이 성립하는지
  • 중복 이벤트가 들어왔을 때 부작용(중복 과금, 중복 알림)이 없는지

10.5 관측 가능성

  • event_id correlation_id 로 로그를 연결할 수 있는지
  • outbox에서 발행까지 걸린 지연(백로그)을 메트릭으로 보는지

분산 추적을 붙이면 “DB 커밋은 됐는데 publish가 밀린다” 같은 병목을 훨씬 빨리 찾습니다. 이 부분은 OpenTelemetry로 MSA 분산 트랜잭션 추적 실전 과 함께 구성하면 효과가 큽니다.

11) 결론: Exactly-Once를 ‘주장’하지 말고 ‘증명 가능한 불변식’으로 바꾸기

Kafka EOS는 강력하지만, DB와 결합되는 순간 정확히 한 번은 쉽게 깨집니다. Outbox 패턴을 도입했다면 다음을 명확히 하세요.

  • Outbox는 유실을 막는다(핵심 가치)
  • 중복은 컨슈머 멱등으로 흡수한다(현실적 방어)
  • 순서는 키/파티션/워커 모델로 강제한다(규칙화)

운영에서 “Exactly-Once가 깨졌다”는 사건을 줄이려면, Kafka 설정을 더 만지는 것보다 Outbox 불변식과 멱등 처리의 빈틈을 점검하는 것이 더 빠르고 재발 방지에도 효과적입니다.