Published on

Kafka EOI 중복처리 해결 - Outbox+사가 패턴

Authors

서로 다른 서비스가 Kafka로 이벤트를 주고받는 구조에서 “정확히-한번(Exactly Once)”을 목표로 해도, 운영 환경에서는 중복 처리(duplicate processing)가 쉽게 발생합니다. 원인은 단순합니다. Kafka의 EOI는 Kafka 내부에서의 생산-소비-트랜잭션 경계를 강하게 만들어주지만, 대부분의 비즈니스는 DB 업데이트, 외부 API 호출, 다른 토픽 발행처럼 Kafka 밖의 부작용(side effect)을 포함합니다. 이 순간부터 “정확히-한번”은 시스템 전체 관점에서 깨지기 쉽습니다.

이 글에서는 Kafka EOI의 현실적인 한계를 짚고, Outbox 패턴으로 원자적 발행을 확보한 뒤, 사가(Saga)로 분산 트랜잭션을 안전하게 완결하는 조합으로 중복을 흡수하는 설계를 제시합니다. 구현 관점에서 필요한 테이블 스키마, 멱등키(idempotency key), 소비자 중복 방지, 보상 트랜잭션까지 함께 다룹니다.

관련해서 실서비스 운영에서는 “가끔만” 발생하는 인증/시간 문제도 중복처럼 보이는 장애를 만들곤 합니다. 예를 들어 게이트웨이에서 JWT가 간헐적으로 실패하면 재시도 폭주로 같은 요청이 여러 번 들어올 수 있습니다. 이런 류의 간헐 장애는 Nginx에서 JWT 401 간헐 발생 - 시계오차 해결 같은 원인까지 함께 점검하는 것이 좋습니다.

Kafka EOI를 믿었는데 왜 중복이 생길까

Kafka의 EOI는 보통 다음 기능 조합을 의미합니다.

  • Producer 트랜잭션(transactional.id, enable.idempotence)
  • Consumer에서 read_committed
  • 소비-처리-오프셋 커밋을 트랜잭션으로 묶는 패턴(특히 Kafka Streams)

하지만 다음 케이스에서 중복은 다시 나타납니다.

1) DB 커밋과 Kafka 발행이 원자적이지 않다

가장 흔한 흐름은 이렇습니다.

  1. DB에 주문 생성
  2. Kafka에 OrderCreated 발행

둘을 하나의 원자적 트랜잭션으로 묶지 않으면,

  • DB는 커밋됐는데 Kafka 발행이 실패하거나
  • Kafka 발행은 됐는데 DB가 롤백되거나
  • 재시도 로직 때문에 Kafka 발행이 중복되거나

같은 일이 발생합니다.

2) 소비자 리밸런스, 타임아웃, 재시도

소비자가 메시지를 처리했지만 오프셋 커밋 전에 죽으면, 같은 메시지가 다시 전달됩니다. Kafka 관점에서는 정상이며, 애플리케이션이 멱등하게 처리해야 합니다.

3) 외부 API 호출은 트랜잭션에 포함되지 않는다

결제 승인 같은 외부 API는 “한 번만 호출”이 보장되지 않습니다. 네트워크 타임아웃이 나면 성공 여부를 모른 채 재시도하게 되고, 이때 중복 승인/중복 취소 같은 사고가 납니다.

정리하면, Kafka EOI는 Kafka 내부 상태에 강하지만, 우리가 진짜로 원하는 것은 비즈니스 부작용까지 포함한 EOI입니다. 그 간극을 메우는 대표적인 조합이 Outbox와 사가입니다.

Outbox 패턴: DB 상태와 이벤트 발행을 하나로 묶기

Outbox 패턴의 핵심은 “이벤트를 Kafka에 바로 쏘지 말고, DB 트랜잭션 안에서 Outbox 테이블에 먼저 적재”하는 것입니다.

  • 주문 테이블 업데이트
  • Outbox 테이블에 이벤트 레코드 추가

이 두 작업을 같은 DB 트랜잭션으로 처리하면, DB 관점에서 상태와 이벤트가 정합성을 갖습니다. 이후 별도의 릴레이(Outbox Relay)가 Outbox를 읽어 Kafka에 발행합니다.

Outbox 테이블 스키마 예시

아래 예시는 Postgres 기준입니다.

create table outbox_event (
  id uuid primary key,
  aggregate_type varchar(50) not null,
  aggregate_id varchar(100) not null,
  event_type varchar(100) not null,
  payload jsonb not null,
  headers jsonb,
  status varchar(20) not null default 'PENDING',
  created_at timestamptz not null default now(),
  published_at timestamptz,
  publish_attempts int not null default 0,
  last_error text
);

create index idx_outbox_pending on outbox_event(status, created_at);
create unique index uq_outbox_dedup
  on outbox_event(aggregate_type, aggregate_id, event_type, (payload->>'eventId'));

포인트는 다음입니다.

  • status로 발행 상태 관리
  • publish_attempts로 재시도/백오프 제어
  • 유니크 인덱스로 “같은 이벤트” 중복 적재 방지(서비스 특성에 맞게 키 설계)

애플리케이션 트랜잭션 예시(주문 생성)

Spring/JPA 느낌의 의사코드입니다.

@Transactional
fun createOrder(cmd: CreateOrderCommand): UUID {
  val order = orderRepository.save(Order.create(cmd))

  val event = OutboxEvent(
    id = UUID.randomUUID(),
    aggregateType = "Order",
    aggregateId = order.id.toString(),
    eventType = "OrderCreated",
    payload = mapOf(
      "eventId" to UUID.randomUUID().toString(),
      "orderId" to order.id.toString(),
      "amount" to order.amount
    )
  )

  outboxRepository.save(event)
  return order.id
}

여기서 Kafka producer는 등장하지 않습니다. “DB에 커밋되면 이벤트도 반드시 존재한다”가 보장됩니다.

Outbox Relay(발행자) 설계 포인트

Outbox Relay는 보통 다음 중 하나로 구현합니다.

  • 폴링 방식: PENDING을 일정 주기로 조회해 발행
  • CDC 방식: Debezium 같은 CDC로 Outbox 변경을 Kafka로 전달

폴링 방식의 핵심은 동시성 제어입니다. 여러 인스턴스가 같은 Outbox를 읽어도 중복 발행이 최소화되도록 SELECT ... FOR UPDATE SKIP LOCKED 같은 기법이 자주 쓰입니다.

with cte as (
  select id
  from outbox_event
  where status = 'PENDING'
  order by created_at
  limit 100
  for update skip locked
)
update outbox_event
set status = 'PROCESSING'
where id in (select id from cte)
returning *;

발행 성공 시 PUBLISHED로 변경하고, 실패 시 PENDING으로 되돌리거나 publish_attempts를 증가시키며 백오프합니다.

Kafka 발행 자체는 idempotent producer를 켜는 것이 좋습니다.

enable.idempotence=true
acks=all
retries=2147483647
max.in.flight.requests.per.connection=5

단, 이것만으로 “구독자가 한 번만 처리”는 보장되지 않으므로 소비자 측 멱등도 함께 필요합니다.

소비자 중복 처리 방지: 멱등 소비자와 처리 로그

Outbox로 발행 중복을 줄였더라도, 소비자는 여전히 “적어도 한 번(at-least-once)”로 메시지를 받을 수 있습니다. 따라서 소비자는 멱등해야 합니다.

가장 실용적인 방법은 처리 로그(Processed Message Log) 테이블을 두고, 이벤트의 고유 키를 저장하는 것입니다.

처리 로그 테이블 예시

create table processed_event (
  consumer_name varchar(100) not null,
  event_id varchar(100) not null,
  processed_at timestamptz not null default now(),
  primary key (consumer_name, event_id)
);

소비자 처리 흐름(트랜잭션 내부에서 중복 체크)

@Transactional
fun onMessage(msg: OrderCreated) {
  val inserted = processedEventRepository.insertIgnore(
    consumerName = "PaymentService",
    eventId = msg.eventId
  )

  if (!inserted) {
    // 이미 처리한 이벤트: 멱등하게 무시
    return
  }

  // 실제 부작용 처리
  paymentRepository.createPayment(
    orderId = msg.orderId,
    amount = msg.amount
  )
}

DB가 insert ignore를 지원하지 않으면, 유니크 키 충돌을 잡아 “이미 처리됨”으로 간주하면 됩니다.

이렇게 하면 리밸런스, 재시도, 중복 발행이 있어도 소비자 부작용은 한 번만 발생합니다.

사가(Saga): 분산 트랜잭션을 “완결”시키는 방법

Outbox는 “상태와 이벤트 발행”을 묶어주지만, 서비스 간 업무 흐름(예: 주문 생성 -> 결제 승인 -> 재고 차감 -> 배송 준비)은 여전히 분산 트랜잭션입니다.

사가의 목표는 다음입니다.

  • 각 서비스는 로컬 트랜잭션만 수행
  • 다음 단계는 이벤트로 트리거
  • 실패 시 보상 트랜잭션으로 되돌림

코레오그래피 vs 오케스트레이션

  • 코레오그래피: 각 서비스가 이벤트를 구독하고 다음 이벤트를 발행
  • 오케스트레이션: 중앙 오케스트레이터가 상태 머신처럼 단계를 지휘

중복 처리 관점에서는 둘 다 멱등이 필요하지만, 운영 난이도와 가시성은 오케스트레이션이 유리한 경우가 많습니다(특히 복잡한 보상 규칙이 있을 때).

Outbox+사가를 결합한 실전 플로우

예시: 주문-결제-재고 흐름

  1. OrderService: 주문 생성, OrderCreated를 Outbox에 기록
  2. Relay: OrderCreated Kafka 발행
  3. PaymentService: OrderCreated 소비, 결제 승인 시도
    • 성공: PaymentApproved를 Outbox에 기록
    • 실패: PaymentFailed를 Outbox에 기록
  4. InventoryService: PaymentApproved 소비, 재고 차감
    • 성공: StockReserved
    • 실패: StockReservationFailed
  5. OrderService(또는 Orchestrator): 실패 이벤트를 받으면 주문 취소, 보상 이벤트 발행

여기서 중요한 것은 “각 단계가 다음을 보장”하는 것입니다.

  • 로컬 DB 변경과 이벤트 기록은 같은 트랜잭션
  • 이벤트 발행은 Outbox Relay가 담당
  • 소비자는 처리 로그로 중복을 흡수
  • 보상도 동일한 규칙(Outbox, 멱등 소비자)으로 실행

보상 트랜잭션도 멱등해야 한다

사가에서 실패 시 보상은 필수인데, 보상 또한 중복으로 실행될 수 있습니다.

예를 들어 결제 취소 API를 두 번 호출하면 문제가 생길 수 있습니다. 따라서 외부 결제사 호출에는 다음을 권장합니다.

  • 결제사에 idempotency-key 헤더를 지원하는지 확인
  • 지원하지 않으면 내부적으로 결제 요청/응답을 저장하고, 동일 키 재시도 시 같은 결과를 반환

간단한 내부 멱등키 테이블 예시는 다음과 같습니다.

create table idempotency_key (
  key varchar(200) primary key,
  request_hash varchar(64) not null,
  response jsonb,
  created_at timestamptz not null default now()
);

요청이 들어오면 key로 선점하고(유니크), 이미 있으면 저장된 응답을 그대로 반환하는 방식입니다.

메시지 키, 이벤트 ID, 스키마: “중복을 정의”해야 막을 수 있다

중복 처리를 막으려면 “무엇이 같은 이벤트인가”를 명확히 해야 합니다.

권장 규칙:

  • 이벤트는 eventId를 반드시 포함(전역 유니크)
  • 비즈니스 상 같은 의미의 이벤트는 동일 eventId를 유지(재시도 시 새로 만들지 않기)
  • Kafka message key는 aggregateId(예: orderId)로 두어 순서 보장
  • 이벤트 스키마 버전 필드 추가

예시(JSON):

{
  "eventId": "3f2a2c2e-7c1f-4c7a-8d3b-4e6f1d3b9b2a",
  "eventType": "PaymentApproved",
  "aggregateType": "Order",
  "aggregateId": "order-123",
  "occurredAt": "2026-02-24T10:15:30Z",
  "schemaVersion": 1,
  "data": {
    "orderId": "order-123",
    "paymentId": "pay-999",
    "amount": 42000
  }
}

운영에서 자주 터지는 함정 5가지

1) Outbox Relay가 중복 발행하면 어떡하나

Relay가 재시작되거나 네트워크 오류가 나면 같은 이벤트를 다시 발행할 수 있습니다. 그래서 소비자 멱등이 최종 안전장치입니다.

추가로 Relay가 PUBLISHED 마킹을 Kafka ACK 이후에만 하도록 하고, PROCESSING 상태가 오래 유지되면 워치독으로 되돌리는 식의 복구 로직도 필요합니다.

2) 처리 로그 테이블이 무한히 커진다

TTL 정책이 필요합니다.

  • 이벤트 재처리 가능 기간만큼만 보관(예: 30일)
  • 파티셔닝 또는 주기적 아카이빙

3) 재처리(replay) 시나리오

버그 수정 후 과거 이벤트를 재처리하고 싶을 수 있습니다. 이때 처리 로그가 있으면 재처리가 막힙니다.

해결책:

  • consumer_name에 버전을 포함(예: PaymentService-v2)
  • 또는 재처리 모드에서는 별도 토픽/별도 소비자 그룹 사용

4) 관측성 부족으로 “어디서 중복됐는지” 모른다

분산 추적을 위해 다음을 이벤트 헤더에 넣는 것을 권장합니다.

  • correlationId
  • causationId
  • traceparent

그리고 Outbox 레코드에도 함께 저장해 “DB 이벤트와 Kafka 이벤트를 조인”할 수 있게 만듭니다.

5) 인프라 이슈가 중복을 증폭시킨다

Pod 재시작, OOMKilled, 노드 네트워크 문제는 소비자 재처리를 늘립니다. 중복이 갑자기 증가하면 애플리케이션 로직만 보지 말고 인프라 이벤트도 같이 봐야 합니다. 메모리 압박으로 프로세스가 죽는 케이스는 리눅스 OOMKilled 원인 추적 - cgroup·dmesg·ulimit 같은 접근으로 원인을 좁히는 것이 도움이 됩니다.

최소 구현 체크리스트

실무에서 “Outbox+사가로 중복을 제어”하려면 아래가 갖춰져야 합니다.

  • Outbox 테이블과 상태 머신(PENDING, PROCESSING, PUBLISHED)
  • Relay의 동시성 제어(SKIP LOCKED 등)
  • 이벤트에 eventId 포함, 재시도 시 동일 eventId 유지
  • 소비자 처리 로그(유니크 키로 중복 흡수)
  • 외부 API 호출의 멱등키 전략
  • 사가 보상 트랜잭션도 동일한 멱등 규칙 적용
  • 재처리/리플레이 전략(버전 분리 또는 토픽 분리)

결론: Kafka EOI는 “기반”이고, 중복 해결은 “설계”다

Kafka의 EOI는 분명 강력하지만, 시스템 전체의 정확히-한번은 Outbox로 원자적 이벤트 생성을 확보하고, 소비자 멱등 처리로 재전달을 흡수하며, 사가로 분산 트랜잭션을 보상까지 포함해 완결할 때 현실적으로 달성됩니다.

한 문장으로 요약하면 다음과 같습니다.

  • Outbox는 “DB와 이벤트의 정합성”을 지키고
  • 멱등 소비자는 “Kafka의 재전달”을 안전하게 만들며
  • 사가는 “실패가 포함된 비즈니스 흐름”을 끝까지 책임집니다.

이 조합을 기본 골격으로 두면, 재시도/리밸런스/장애가 일상이 된 운영 환경에서도 중복으로 인한 금전 사고나 데이터 오염을 크게 줄일 수 있습니다.