Kafka에서 중복 이벤트를 전제로 설계해야 하는 이유와 처리 전략

zion·2025년 12월 18일

1. 왜 Kafka에서는 중복 이벤트를 고려해야 하는가

Kafka를 도입한다는 것은 비즈니스 로직과 후속 처리 로직을 분리하겠다는 설계 선택이다.

이 선택은 다음과 같은 변화를 만든다.

  • 하나의 트랜잭션 경계가 여러 컴포넌트로 분리된다
  • 처리 시점이 비동기적으로 분리된다
  • 실행 순서와 재시도 타이밍을 애플리케이션이 직접 통제할 수 없게 된다

Kafka는 기본적으로 at-least-once 전달 방식을 사용한다.
이 말은 곧,

메시지 유실 가능성을 줄이는 대신
중복 처리 책임을 Consumer에게 위임한다

는 의미다.

따라서 Kafka 기반 시스템에서는 다음과 같은 상황이 정상 동작 범위로 발생할 수 있다.

  • producer retry로 인한 중복 전송
  • consumer 장애 후 재시작
  • offset commit 이전 성공 처리
  • rebalancing으로 인한 재처리

즉, Kafka를 사용하는 순간부터 시스템은
중복 이벤트를 예외가 아닌 전제 조건으로 두고 설계해야 한다.


2. Kafka에서 발생하는 두 가지 중복

Kafka 중복 이벤트는 성격이 다른 두 종류의 중복이 존재한다.


2-1. 이벤트 자체의 중복 (eventId 기준)

같은 eventId를 가진 이벤트가 여러 번 소비됨

발생 원인

  • producer retry
  • consumer rebalancing
  • offset rollback
  • 장애 후 재처리

이 경우는 기술적인 중복이다.
같은 이벤트가 여러 번 전달되었을 뿐,
비즈니스 의미는 동일하다.


2-2. 비즈니스 의미의 중복 (aggregate 기준)

같은 주문 / 같은 좋아요 / 같은 재고 차감이 두 번 반영됨

특징

  • eventId는 서로 다를 수 있다
  • 하지만 같은 비즈니스 행위가 두 번 처리된 결과를 만든다

이 중복은 단순 전달 문제가 아니라,
도메인 규칙이 깨지는 상황이다.


3. 핵심 원칙: Consumer는 “결과적으로” 멱등해야 한다

흔히 다음과 같은 원칙을 이야기한다.

Consumer는 항상 멱등해야 한다

이 문장은 어떤 의미를 가지고 있을까

❓ 좋아요 +1, 재고 -1 같은 로직은 멱등하지 않은데?

이 로직 자체는 본질적으로 비멱등이다.

따라서 이 원칙의 정확한 의미는 다음에 가깝다.

같은 이벤트가 여러 번 전달되더라도
최종 비즈니스 상태는 한 번만 반영되어야 한다

즉,

  • 로직 자체가 멱등일 필요는 없고
  • 중복 이벤트가 결과를 깨뜨리지 않도록 보호 장치가 있어야 한다

전략 1: eventId 기반 중복 제거 (Inbox / Dedup Table)

eventId는 어디서 생성해야 할까?

이벤트 발행 시점에 producer가 eventId를 생성한다.

초기에는 Auto Increment ID를 고려했지만,
Outbox 패턴을 사용하면서 문제가 발생했다.

  • Outbox insert 시점에는 eventId가 없음
  • 이후 update 로직이 필요해짐
  • 트랜잭션 흐름이 불필요하게 복잡해짐

그래서 producer에서 ID를 먼저 생성하는 방식으로 전환했다.


UUID vs ULID

항목UUID v4ULID
유니크 보장매우 높음매우 높음
시간 정보없음포함
정렬 가능
DB 인덱스 친화성
이벤트 흐름 추적어려움쉬움
표준화매우 높음상대적으로 낮음

Kafka + Outbox 환경에서는 다음 이유로 ULID가 특히 유리했다.

  • Outbox 테이블에서 최신 이벤트 스캔 비용 감소
  • DB 인덱스 locality 개선
  • 이벤트 발생 흐름을 시간 기준으로 추적 가능

이벤트는 단순 식별자가 아니라,
흐름과 순서를 해석하기 위한 단서이기 때문이다.


처리 흐름

  1. producer에서 eventId 생성
  2. 비즈니스 트랜잭션과 함께 Outbox에 이벤트 저장
  3. Kafka로 이벤트 publish
  4. Consumer Inbox에 eventId 저장
  5. eventId 기준 중복 여부 판단
  6. 최초 이벤트만 비즈니스 로직 실행

이 방식은 다음과 같은 정합성 중심 도메인에 적합하다.

  • 주문
  • 결제
  • 쿠폰
  • 재고

전략 2: aggregateId 기반 멱등성

모든 중복 처리를 eventId 기준으로 할 필요는 없다.

경우에 따라 비즈니스 식별자 기준이 더 효율적일 수 있다.

언제 적합한가?

  • 좋아요
  • 팔로우
  • 찜하기

예를 들어:

  • 같은 userId + productId 조합의 좋아요 이벤트
  • 이미 좋아요 상태라면 추가 집계를 하지 않는다

전략 선택 기준

기준eventId dedupaggregateId 멱등
정합성 중요도매우 높음중간
처리 비용높음낮음
상태 복원 필요성높음낮음
대표 예시주문, 결제, 재고좋아요, 팔로우

4. Delta 이벤트 설계

추가로 좋아요, 조회수처럼 발생 빈도가 매우 높은 이벤트는 누적량만 업데이트 하는 이벤트를 설계할수 있다.

{ "delta": +1 }

Consumer는 단순 누적만 수행한다.

UPDATE product
SET like_count = like_count + :delta;

장점

  • Kafka 메시지 수 감소
  • DB 부하 감소

한계

  • 중복 시 정확도 보장 어려움
  • 보정(batch, snapshot) 전략 필요

현재 시스템에서는:

  • 정확성이 더 중요하고
  • 트래픽이 임계점에 도달하지 않았다고 판단하여

👉 단건 이벤트 방식을 유지했다.


5. Outbox 패턴과 중복 처리의 관계

Outbox 패턴은 종종 중복 처리까지 해결해준다고 오해된다. 실제로 초기 Producer 에서 중복을 제거하거나 변화량을 계산해 보내준다면, Customer 에서 더 빠른 처리가 가능하지 않을까 라는 생각아래, Outbox 에서도 중복을 제거를 시도하기도 했었다.

하지만 Producer에서 Outbox의 역할은 명확하다.

  • 이벤트 유실 방지
  • 트랜잭션 경계 보장

중복 방지는 아니다.

때문에 Outbox는 원자성 보장이 중요하다. 하나의 트랜잭션을 사용하여 주문 내역은 저장되었는데 이벤트가 누락되거나, 반대로 주문은 실패했는데 이벤트만 발행되는 모순이 발생하지 않도록 해야한다.

@Transactional
  public OrderInfo createOrder(CreateOrderCommand command) {
    // 비즈니스 로직
    Order order = Order.create(command.userId(), createOrderItems(command.orderItemRequests(), products), command.couponIssueId());
    Order savedOrder = orderService.save(order);

    // 한 트랜잭션으로 Outbox 저장
    OrderCreatedEvent orderCreatedEvent = new OrderCreatedEvent(
        savedOrder.getId(),
        command.userId(),
        command.couponIssueId(),
        command.cardType(),
        command.cardNo()
    );

    OutboxEvent savedOutboxEvent = outboxService.saveEvent(
        "Order",
        savedOrder.getId().toString(),
        "OrderCreated",
        orderCreatedEvent
    );

    return OrderInfo.from(savedOrder);
  }

Outbox + Consumer 멱등성


정리

  • Kafka에서 중복은 장애가 아니라 설계 전제
  • 중복을 “막으려는 설계”보다
  • 중복이 와도 안전한 설계가 중요하다

좋은 Kafka 설계란
중복 이벤트가 와도 시스템이 흔들리지 않는 설계다

Kafka 중복 처리는 기술 문제가 아니라,
도메인 성격에 맞는 멱등성 전략을 선택하는 문제다.

profile
be_zion

0개의 댓글