본문 바로가기
Research Notes (개인)

Kafka는 결국 At-Least-Once다: 멱등성(idempotency) + processed_event + dedupe로 “운영 가능한 정확성” 만들기

Kafka를 처음 쓸 때 누구나 한 번은 “Exactly-once(EOS)로 끝내면 되지 않나?”라는 기대를 합니다.
하지만 운영에 들어가면 결론은 꽤 빨리 정리됩니다.

현실의 Kafka 파이프라인은 At-Least-Once를 전제로 설계해야 한다.
그러니 “중복이 생긴다”를 받아들이고, 중복이 결과를 망치지 않게 만드는 쪽이 더 강하다.

이 글은 그 결론을 바탕으로:

  • 왜 EOS가 어렵고(특히 Kafka 밖으로 나가는 순간)
  • At-Least-Once에서 중복이 어떻게 생기며
  • 이를 멱등성 / processed_event / dedupe로 어떻게 설계하는지

를 예시와 함께 기술 블로그 형태로 정리합니다.


TL;DR

  • “메시지는 최소 1번은 온다”가 아니라 “같은 메시지가 2번 이상 올 수 있다”가 운영의 기본값입니다.
  • 그래서 컨슈머는 반드시:
    • 멱등 키(dedup_key / event_id) 를 기준으로
    • “이미 처리했는지”를 원자적으로 판단하고
    • 중복이면 아무 일도 하지 않는 방식으로 설계해야 합니다.
  • 가장 실용적인 표준 패턴은:
    • processed_event 테이블(또는 KV/Redis/캐시) + 비즈니스 변경을 같은 트랜잭션으로 묶기
    • 또는 UPSERT/조건부 UPDATE 같은 “DB 레벨 멱등 쓰기”입니다.

1) 왜 Kafka에서 EOS(Exactly-once)가 ‘현실적으로’ 어려운가?

Kafka 자체는 트랜잭션/멱등 프로듀서/read_committed 같은 기능을 제공합니다.
그런데 “진짜” 문제는 대부분 여기서 발생합니다.

1.1 Kafka 내부 vs Kafka 밖(외부 시스템)

  • Kafka → Kafka: 트랜잭션을 써서 “produce + offsets”를 묶으면 EOS에 가까워질 수 있음
  • Kafka → DB / Kafka → 외부 API: 여기서부터는 Kafka 트랜잭션이 “바깥 시스템”을 원자적으로 묶어주지 못합니다

즉, Kafka가 아무리 잘해도 다음을 완전히 없애진 못합니다.

  • DB 쓰기는 성공했는데 offset commit이 실패 → 재시작 후 같은 메시지 재처리
  • offset commit은 됐는데 DB 쓰기가 실패 → 메시지는 “처리된 걸로” 보이는데 DB에는 반영 안 됨
  • 외부 API가 “성공했는지/실패했는지” 애매한 타임아웃 → 재시도하면 중복 호출 가능

결론적으로 운영 설계는 이렇게 가야 합니다.

중복은 생긴다.
그러니 중복이 결과에 영향을 못 주게 만들자.


2) At-Least-Once에서 중복은 어떻게 생기나? (대표 시나리오 3개)

여기서 “중복”은 Kafka가 나쁜 게 아니라, 분산 시스템의 자연스러운 결과입니다.

시나리오 A) DB 반영 성공 → offset commit 실패

  1. 컨슈머가 메시지를 처리하고 DB에 반영(성공)
  2. offset commit 직전에 프로세스가 죽거나 네트워크가 끊김
  3. 재시작하면 같은 메시지를 다시 읽음
  4. DB에 동일 반영이 2번 발생 (중복)

시나리오 B) 처리 도중 리밸런스/세션 만료

  • 폴링 지연, GC stop-the-world, 네트워크 지연 등으로 컨슈머가 그룹에서 떨어지면
  • 같은 파티션을 다른 인스턴스가 가져가고
  • 어떤 레코드는 “처리됐는지 애매한 상태”로 중복 처리될 수 있습니다.

시나리오 C) 프로듀서/릴레이 재시도(재전송)

  • 프로듀서는 네트워크 타임아웃이면 “전송 성공인지 실패인지”를 확신하기 어렵습니다.
  • 그래서 재시도를 하면 같은 이벤트가 다시 발행될 수 있습니다.
  • 특히 Outbox/Relay 같은 구조는 “재전송”을 전제로 하므로 중복 가능성을 반드시 포함합니다.

3) 핵심 전략: “멱등 키(dedup_key)”를 이벤트 계약에 포함하라

멱등성을 설계하려면 “이 메시지가 이전에 처리한 것과 같은 의미인가?”를 판별할 수 있어야 합니다.
그 판단의 기준이 dedup_key (또는 event_id) 입니다.

3.1 좋은 dedup_key의 조건

  • 같은 의미의 이벤트는 항상 같은 키
  • 재시도/재전송/리플레이가 와도 키가 변하지 않음
  • 업무적으로 자연스럽고(트랜잭션 ID, 엔티티 버전 등) 충돌 가능성이 낮음

예시(권장 방향):

  • dedup_key = order:{order_id}:{event_type}:{event_version}
  • dedup_key = {tx_id}:{sequence} (업무 트랜잭션 ID + 이벤트 시퀀스)
  • “시간 버킷”을 넣을 때는 정말 같은 의미인지 주의 (버킷이 달라지면 다른 키가 됨)

4) 표준 패턴 1: processed_event 테이블로 컨슈머를 멱등하게 만들기

가장 보편적이고 강한 패턴은:

(1) dedup_key를 먼저 기록해 ‘처리권’을 확보하고
(2) 비즈니스 반영을 수행하고
(3) 같은 트랜잭션으로 커밋한다

4.1 processed_event 테이블 예시

CREATE TABLE processed_event (
  dedup_key VARCHAR(256) PRIMARY KEY,
  processed_at DATETIME(3) NOT NULL
);

4.2 처리 흐름(핵심)

  • dedup_key를 PK로 두고 INSERT를 먼저 시도합니다.
  • 이미 처리했다면 PK 충돌이 나므로 “중복 이벤트”로 판단하고 스킵합니다.
  • INSERT가 성공한 경우에만 비즈니스 반영을 하고 같은 트랜잭션으로 커밋합니다.

의사 코드 (Python 스타일)

def handle_event(event):
    dedup_key = event["dedup_key"]

    with db.transaction():
        inserted = db.execute(
            "INSERT INTO processed_event(dedup_key, processed_at) VALUES (?, now(3))",
            [dedup_key],
            on_conflict="DO_NOTHING",
        )
        if not inserted:
            return  # 이미 처리된 이벤트 → 멱등 스킵

        # 여기부터는 최초 1회만 실행되어야 하는 비즈니스 반영
        db.execute(
            "UPDATE account SET balance = balance + ? WHERE id = ?",
            [event["amount"], event["account_id"]],
        )

포인트: processed_event insert + 비즈니스 변경이 같은 DB 트랜잭션이어야 합니다.
둘이 분리되면 “처리 마킹만 되고 실제 반영은 안 됨” 같은 불일치가 다시 생깁니다.


5) 표준 패턴 2: “DB 자체를 멱등하게” 쓰기 (UPSERT / 조건부 UPDATE)

processed_event 테이블은 범용적이지만, 상황에 따라선 “목표 테이블의 설계”만으로도 멱등을 만들 수 있습니다.

5.1 예시: 주문 상태 전이(중복 이벤트에 안전)

  • 이벤트: ORDER_PAID(order_id, paid_at, dedup_key)
  • 반영 규칙: 이미 PAID면 다시 PAID로 만들어도 결과는 동일해야 함
UPDATE orders
SET status = 'PAID', paid_at = COALESCE(paid_at, :paid_at)
WHERE id = :order_id;

이 방식은:

  • 같은 이벤트가 2번 들어와도 결과가 변하지 않게(멱등) 만들 수 있습니다.

5.2 “증가형 집계”는 더 주의(가장 자주 터짐)

예: 클릭 수/포인트 적립처럼 count = count + 1 형태는 중복에 매우 취약합니다.
이때는 processed_event 같은 “1회성 보장 장치”가 특히 중요합니다.


6) 예시로 보는 설계: “적립 이벤트”가 2번 들어오는 사고를 막기

6.1 요구사항

  • 이벤트: POINT_EARNED(user_id, amount, dedup_key)
  • 중복 처리되면 돈이 2번 적립되는 치명적인 사고

6.2 멱등 컨슈머 설계(정석)

  1. processed_eventdedup_key insert 시도
  2. 성공했으면 user_point 테이블 갱신
  3. 트랜잭션 커밋
  4. 중복이면 아무것도 하지 않음

이렇게 하면:

  • 재시작/리밸런스/리플레이/재전송이 와도
  • dedup_key가 동일한 한 결과는 정확히 1번만 반영됩니다.

7) Dedupe의 저장소는 꼭 RDB여야 하나? (선택지와 트레이드오프)

7.1 RDB(Processed table) — 가장 강함

  • 장점: 원자성/영속성/리플레이에 강함
  • 단점: 쓰기 부하(모든 이벤트마다 insert)

7.2 Redis / 캐시 기반 dedupe — 고속이지만 “정확성” 한계

  • 장점: 빠름
  • 단점: TTL 만료/eviction/장애 시 중복 방어가 깨질 수 있음
    • 돈/정산/주문 같은 도메인에는 위험

7.3 로그 컴팩션(topic compaction)로 key 기반 dedupe?

  • 특정 상황(최종 상태만 중요, key 기반 상태 저장)에는 유효하지만
  • “한 번만 반영” 같은 트랜잭션성 요구에는 단독 해법으로 부족한 경우가 많습니다.

실무 결론은 보통 이렇습니다.

정확성이 중요한 쓰기(side effect) 는 RDB 기반 멱등이 기본값이고,
“대충 중복이면 괜찮은” 이벤트는 캐시/확률적 dedupe도 고려할 수 있습니다.


8) 운영 체크리스트: 멱등성은 코드만이 아니라 “운영”까지 포함한다

  • 이벤트에 dedup_key(또는 event_id)가 항상 포함되는가?
  • 컨슈머가 dedupe를 원자적으로 처리하는가? (같은 트랜잭션)
  • 리플레이/재처리를 수행해도 데이터가 깨지지 않는가? (테스트/리허설)
  • dedup 저장소(processed_event)가 커질 때 보관/파티션/TTL 정책이 있는가?
  • 중복률/스킵률/DB 충돌률 같은 운영 지표를 관측하는가?

마무리: “중복이 없다”는 꿈보다, “중복이 무해한 시스템”이 더 현실적이다

Kafka 기반 시스템을 운영하다 보면, 결국 목표는 EOS라는 문구가 아니라 이겁니다.

  • 메시지가 몇 번 오든
  • 순서가 가끔 흔들리든
  • 재시작/리밸런스/리플레이가 언제든 발생하든

결과가 틀리지 않는 시스템

그 출발점이 dedup_key이고, 가장 검증된 무기가 processed_event 기반 멱등 컨슈머입니다.

원하시면 다음 편으로:

  • dedup_key 설계 가이드(도메인별 템플릿)
  • processed_event 테이블 운영(보관기간/파티셔닝/정리 잡)
  • “정확성이 중요한 이벤트 vs 덜 중요한 이벤트” 분류 기준 까지 이어서 정리해드릴게요.