Published on

Kafka Exactly-Once인데 중복처리? Outbox+Idempotency 디버깅

Authors

서버 운영 중 가장 당황스러운 순간 중 하나는 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 발행)의 원자성 문제를 줄이는 패턴입니다.

일반적인 흐름은 다음과 같습니다.

  1. 비즈니스 트랜잭션에서 orders 업데이트
  2. 같은 트랜잭션에서 outbox 테이블에 이벤트 row insert
  3. 별도 퍼블리셔가 outbox를 읽어 Kafka로 발행
  4. 발행 성공 시 outbox row를 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 류의 패턴이 없거나
  • 상태 전이를 NEW PROCESSING SENT로 나누지 않거나
  • PROCESSING 전환이 원자적이지 않으면

중복 발행은 쉽게 발생합니다.

실패 모드 D: 파티셔닝 키가 바뀌어 순서가 깨짐

중복 자체가 아니라 “두 번 처리된 것처럼 보이는” 케이스도 있습니다.

  • 같은 aggregate(예: orderId)의 이벤트가 서로 다른 파티션에 들어가면
  • 소비 순서가 뒤섞여 “취소 후 결제완료” 같은 비정상 상태가 잠깐 나타날 수 있습니다.

이때 읽기 모델/캐시/서치 인덱스에서 중복처럼 관측되기도 합니다.

Idempotency를 어디에 걸어야 하는가: 소비자(Consumer) 기준으로 설계

중복을 현실적으로 제거하는 가장 강력한 방법은 소비자에서 멱등 처리하는 것입니다. 핵심은 “무엇을 키로 dedup할 것인가”입니다.

멱등 키 설계 3원칙

  1. 비즈니스 의미가 있는 고유 키를 사용한다(가능하면 커맨드/요청 ID)
  2. 이벤트 재발행/재처리에도 변하지 않는 값이어야 한다
  3. 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()
);

소비 로직은 다음처럼 구성합니다.

  1. processed_events에 먼저 insert 시도
  2. 성공하면 비즈니스 업데이트 수행
  3. 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가 두 번 소비?

가능하면 로그에 아래를 구조화해서 남기세요.

  • eventId
  • topic, partition, offset
  • consumerGroupId
  • orderId 같은 비즈니스 키

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=true
  • acks=all
  • retries 충분히
  • 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인데 왜 두 번 됐지 류의 장애는 대부분 사라집니다.