Published on

Kafka 정확히-한번? MSA 중복이벤트 5분 진단

Authors

서버 로그를 보면 동일한 주문 이벤트가 두 번 처리되고, 결제 승인도 두 번 호출되고, 재고가 마이너스로 내려갑니다. 그런데 팀은 말합니다. "Kafka는 정확히-한번(Exactly-Once) 아닌가요?"

결론부터 말하면, Kafka의 정확히-한번은 "Kafka 내부에서" 또는 "Kafka 트랜잭션으로 묶은 경로에서" 성립하는 성질이지, MSA 전체(HTTP 호출, 외부 API, DB 업데이트, 캐시 갱신 등)에서 자동으로 보장되는 마법이 아닙니다. 중복 이벤트는 보통 다음 중 하나입니다.

  • 프로듀서가 같은 이벤트를 두 번 보냄(재시도, 타임아웃, 네트워크 단절)
  • 브로커에는 한 번만 들어갔는데 컨슈머가 두 번 처리함(리밸런스, 커밋 타이밍, 장애 복구)
  • 컨슈머는 한 번만 처리했는데 다운스트림(DB/외부 API)에서 중복 효과가 발생함(비멱등)

이 글은 "5분 진단"이 목표입니다. 원인 분류를 빠르게 하고, 어떤 관측 포인트를 봐야 하는지, 그리고 현실적인 해결책(멱등성, 아웃박스, 트랜잭션, 키 설계)을 코드와 함께 정리합니다.

1) 5분 진단: 먼저 "중복"이 어디서 생겼는지 분리하기

중복을 해결하려면, 가장 먼저 중복의 위치를 분리해야 합니다.

A. 브로커에 동일 레코드가 2개 쌓였나?

  • 동일한 eventId 또는 동일한 비즈니스 키(예: orderId)가 같은 토픽/파티션에 여러 번 존재
  • 원인 후보: 프로듀서 재시도, 프로듀서 멱등성 미사용, 키 설계 불량, 업스트림에서 이벤트 자체를 중복 발행

B. 브로커에는 1개인데 컨슈머가 2번 처리했나?

  • 컨슈머 로그에 동일 eventId가 두 번 처리되지만, 토픽에는 한 번만 있음
  • 원인 후보: 오프셋 커밋 타이밍 문제, 리밸런스 중 재처리, 장애 후 재시작, max.poll.interval.ms 초과

C. 컨슈머도 1번 처리했는데 DB/외부 시스템에서 중복 효과가 생겼나?

  • 로그상 "처리"는 한 번인데, DB에 중복 row, 외부 결제 API가 중복 승인
  • 원인 후보: 비멱등 API 호출, DB 유니크 제약 부재, 트랜잭션 경계 불일치, 재시도 정책 설계 미흡

이 분리가 되면 해결의 80%는 끝입니다.

2) Kafka "정확히-한번"이 의미하는 범위

Kafka에서 말하는 Exactly-Once Semantics(EOS)는 보통 다음을 포함합니다.

  • Idempotent Producer: 프로듀서 재시도에도 브로커에 중복 레코드가 쌓이지 않도록 함
  • Transactions: consume-process-produce를 하나의 트랜잭션으로 묶어, 스트림 처리에서 중복/유실을 줄임

하지만 다음은 자동으로 해결되지 않습니다.

  • 컨슈머가 DB에 쓰는 작업(특히 별도 DB 트랜잭션)
  • 외부 HTTP API 호출(결제, 알림, 배송 등)
  • 분산 트랜잭션이 없는 마이크로서비스 간 사이드이펙트

즉, Kafka EOS는 "Kafka를 통과하는 레코드"의 중복을 줄여줄 뿐, 비즈니스 사이드이펙트의 정확히-한번은 애플리케이션 설계(멱등성, 아웃박스, 유니크 제약, 사가)로 달성해야 합니다.

사가에서 중복 실행 자체를 막는 접근은 아래 글의 사고방식과도 연결됩니다.

3) 빠른 체크리스트: 중복 이벤트의 대표 원인 10가지

(1) 프로듀서 멱등성 미활성화

프로듀서가 재시도할 때 동일 메시지가 브로커에 여러 번 적재될 수 있습니다.

  • 진단: 토픽에서 동일 eventId가 여러 번 보임
  • 해결: enable.idempotence=true, 적절한 acks=all, retries 조정

(2) message key가 비어있거나 랜덤

키가 없으면 라운드로빈 파티셔닝으로 같은 엔티티 이벤트가 파티션을 오가며 순서가 깨지고, 재처리 시 중복처럼 보일 수 있습니다.

  • 진단: 동일 orderId 이벤트가 여러 파티션에 분산
  • 해결: 엔티티 기준 키 고정(예: orderId)

(3) 컨슈머가 "처리 후 커밋"을 보장하지 않음

오프셋을 너무 늦게 커밋하거나, 처리 중 장애로 커밋 전에 죽으면 재시작 후 같은 레코드를 다시 읽습니다.

  • 진단: 장애/리밸런스 직후 중복 처리 증가
  • 해결: 커밋 전략 재검토(수동 커밋, 트랜잭션, 처리-커밋 원자성)

(4) 리밸런스/세션 타임아웃으로 재처리

처리가 오래 걸리면 max.poll.interval.ms를 넘겨 리밸런스가 발생하고, 파티션이 다른 인스턴스로 이동하면서 중복 처리로 이어집니다.

  • 진단: 컨슈머 그룹 리밸런스 로그와 중복 시점이 일치
  • 해결: 처리 시간 단축, max.poll.interval.ms/session.timeout.ms/heartbeat.interval.ms 조정

(5) 외부 API가 비멱등

컨슈머는 한 번만 읽었는데, 네트워크 타임아웃으로 HTTP 재시도를 하면서 결제가 두 번 승인되는 케이스가 흔합니다.

  • 진단: 외부 API 호출 로그에 동일 요청이 여러 번
  • 해결: Idempotency-Key 도입, 서버 측 중복 방지

재시도/백오프 설계는 아래 글의 패턴을 참고할 수 있습니다.

(6) DB에 유니크 제약이 없다

"중복을 허용하지 않는" 비즈니스 키에 유니크 인덱스가 없으면, 컨슈머 재처리 한 번으로 데이터가 바로 중복됩니다.

  • 진단: 동일 eventId 또는 orderId로 row가 2개 이상
  • 해결: 유니크 인덱스 + INSERT ... ON CONFLICT DO NOTHING 같은 멱등 upsert

(7) Outbox 없이 "DB 업데이트 후 이벤트 발행"을 분리

DB 커밋은 성공했는데 이벤트 발행이 실패하면 재시도 로직이 DB 업데이트까지 다시 수행하면서 중복이 생깁니다.

  • 진단: DB 변경과 이벤트 발행 사이에 실패 흔적
  • 해결: 트랜잭션 아웃박스(Outbox) 패턴

(8) Exactly-once를 "컨슈머-DB"까지 확장했다고 착각

Kafka 트랜잭션을 써도 DB는 별도 트랜잭션입니다. 원자적으로 묶이지 않으면 중복/유실 가능성이 남습니다.

  • 해결: DB 멱등성(유니크 제약, 처리 테이블), 또는 CDC/Outbox로 경계 정리

(9) 관측(Observability)이 eventId 중심이 아님

중복을 잡으려면 이벤트에 고유 식별자가 있어야 합니다.

  • 해결: 모든 이벤트에 eventId, correlationId, causationId, occurredAt를 포함

(10) 인프라 이슈로 재시도/타임아웃이 급증

DNS, 네트워크, 로드밸런서 이슈는 "재시도 폭발"을 만들고 중복을 키웁니다.

4) 실전 설계: 중복을 "없애는" 대신 "무해하게" 만들기

MSA에서 현실적인 목표는 보통 이겁니다.

  • Kafka 레코드는 at-least-once로 올 수 있다
  • 따라서 컨슈머 처리는 멱등(idempotent) 해야 한다
  • 사이드이펙트(DB/외부 API)는 중복 방지 장치를 둔다

4.1 이벤트 스키마에 eventId를 강제하라

중복 방지의 시작은 식별자입니다.

{
  "eventId": "9f3b2f9a-7b6a-4a4f-9b5a-2d4c1c8c0b4a",
  "eventType": "OrderPaid",
  "aggregateId": "order-123",
  "occurredAt": "2026-02-24T10:15:30Z",
  "payload": {
    "orderId": "123",
    "amount": 12000,
    "currency": "KRW"
  }
}
  • eventId: 이벤트 인스턴스 식별자(중복 여부 판단)
  • aggregateId: 엔티티 단위 순서/파티션 키로 활용

4.2 컨슈머 멱등 처리: "처리 테이블" + 유니크 키

가장 단순하고 강력한 방식은 DB에 처리 이력을 남기고 유니크로 막는 것입니다.

예: PostgreSQL

CREATE TABLE consumed_event (
  event_id uuid PRIMARY KEY,
  consumed_at timestamptz NOT NULL DEFAULT now()
);

컨슈머 로직은 다음 순서로 갑니다.

  1. consumed_eventeventId를 먼저 기록(유니크)
  2. 이미 있으면 중복이므로 스킵
  3. 없으면 비즈니스 로직 수행
-- 1) 중복이면 아무것도 하지 않음
INSERT INTO consumed_event(event_id)
VALUES ($1)
ON CONFLICT DO NOTHING;

애플리케이션에서는 영향 받은 row 수로 중복 여부를 판단합니다.

// Node.js + pg 예시 (의사 코드)
await client.query('BEGIN');

const r = await client.query(
  'INSERT INTO consumed_event(event_id) VALUES ($1) ON CONFLICT DO NOTHING',
  [eventId]
);

if (r.rowCount === 0) {
  await client.query('ROLLBACK');
  return; // duplicate
}

// 비즈니스 업데이트(유니크 제약을 추가로 걸면 더 안전)
await client.query(
  'UPDATE orders SET status = $1 WHERE order_id = $2',
  ['PAID', orderId]
);

await client.query('COMMIT');

핵심은 "중복이면 스킵"을 DB 트랜잭션 내부에서 결정하는 것입니다. 그래야 컨슈머 프로세스가 동시에 같은 이벤트를 처리하려 할 때도 안전합니다.

4.3 외부 API 호출은 Idempotency-Key로 잠가라

결제/알림처럼 외부 부작용이 큰 호출은 반드시 멱등 키를 씁니다.

POST /payments/charge HTTP/1.1
Idempotency-Key: 9f3b2f9a-7b6a-4a4f-9b5a-2d4c1c8c0b4a
Content-Type: application/json

{"orderId":"123","amount":12000}

외부 시스템이 멱등 키를 지원하지 않으면, 호출 전에 내부 DB에 "요청 예약"을 유니크로 기록하고, 이미 있으면 호출을 막는 방식으로 감쌀 수 있습니다.

4.4 Outbox 패턴: DB와 이벤트 발행의 경계를 정리

"DB 업데이트"와 "Kafka 발행"을 한 트랜잭션으로 묶기 어렵다면, Outbox가 가장 흔한 해법입니다.

  • 서비스 DB 트랜잭션 안에서 outbox 테이블에 이벤트를 기록
  • 별도 릴레이(폴링 또는 CDC)가 outbox를 읽어 Kafka로 발행
CREATE TABLE outbox (
  id uuid PRIMARY KEY,
  aggregate_id text NOT NULL,
  event_type text NOT NULL,
  payload jsonb NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  published_at timestamptz
);

CREATE INDEX outbox_unpublished_idx ON outbox(published_at) WHERE published_at IS NULL;
-- 주문 상태 변경 + outbox 기록을 한 트랜잭션으로
BEGIN;

UPDATE orders SET status = 'PAID' WHERE order_id = $1;

INSERT INTO outbox(id, aggregate_id, event_type, payload)
VALUES ($2, $1, 'OrderPaid', $3::jsonb);

COMMIT;

릴레이는 published_at IS NULL인 건을 가져가 발행 후 마킹합니다. 발행이 중복될 수 있으므로, 여기에도 eventId 기반의 멱등(프로듀서 멱등성, 컨슈머 멱등성)이 결합되면 안정성이 올라갑니다.

5) Kafka 설정 관점: "중복을 줄이는" 최소 설정

중복을 0으로 만들 수는 없지만, "브로커에 중복 적재" 가능성은 크게 줄일 수 있습니다.

5.1 프로듀서: 멱등성과 안전한 전송

Java/Spring이든 다른 클라이언트든 개념은 같습니다.

# 핵심
enable.idempotence=true
acks=all
retries=2147483647

# 순서 보존과 처리량의 균형
max.in.flight.requests.per.connection=5
linger.ms=5
batch.size=32768
  • enable.idempotence=true는 사실상 기본에 가깝게 가져가야 합니다.
  • 트랜잭션까지 쓰면 더 강하지만, 운영 복잡도가 올라갑니다.

5.2 컨슈머: 커밋 전략을 명확히

"자동 커밋"은 중복/유실의 원인을 숨기기 쉽습니다. 수동 커밋 또는 프레임워크의 트랜잭션 경계를 이해해야 합니다.

enable.auto.commit=false
isolation.level=read_committed
max.poll.records=100
max.poll.interval.ms=300000
  • isolation.level=read_committed는 트랜잭션 프로듀서와 함께 쓸 때만 의미가 큽니다.
  • max.poll.interval.ms는 처리 시간이 긴 작업이 있으면 반드시 점검합니다.

6) 장애 상황별 "중복" 패턴과 즉시 처방

패턴 1: 배포 직후 중복 급증

  • 원인 후보: 컨슈머 그룹 리밸런스, 처리 시간 증가로 max.poll.interval.ms 초과
  • 처방: 배포 시 스로틀링, 처리 시간 상한 설정, 리밸런스 로그와 함께 분석

패턴 2: 외부 API 타임아웃이 늘면서 중복 결제

  • 원인 후보: 타임아웃 후 재시도, 서버는 처리했는데 클라이언트는 실패로 간주
  • 처방: Idempotency-Key, 타임아웃/재시도 정책 정리, 외부 시스템의 중복 방지 확인

패턴 3: 특정 엔티티만 순서가 뒤집혀 "중복"처럼 보임

  • 원인 후보: 키 미설정으로 파티션이 섞임
  • 처방: message keyaggregateId로 고정, 파티션 전략 재검토

7) 결론: Exactly-once를 "기능"이 아니라 "설계"로 보기

Kafka의 정확히-한번은 분명 강력하지만, MSA에서 중복 이벤트 문제를 끝내려면 다음 3가지를 동시에 만족해야 합니다.

  1. 브로커 중복을 줄이기: 프로듀서 멱등성, 필요 시 트랜잭션
  2. 컨슈머 재처리를 허용하기: at-least-once를 전제로 설계
  3. 사이드이펙트를 멱등하게 만들기: DB 유니크 제약 + 처리 테이블 + 외부 API Idempotency-Key + Outbox

"중복이 발생할 수 있다"는 전제를 받아들이고, 중복이 발생해도 비즈니스적으로 한 번만 반영되게 만들면, 운영에서 가장 골치 아픈 재현 불가/간헐 장애가 급격히 줄어듭니다.

다음에 중복 이슈가 오면, 우선 토픽에 중복이 쌓였는지부터 확인하고(A/B/C 분리), eventId와 DB 유니크 제약부터 잠그세요. 5분 안에 방향이 잡힐 겁니다.