Published on

Kafka Exactly-Once 함정 - MSA 중복처리 근절

Authors

서로 다른 마이크로서비스가 Kafka로 이벤트를 주고받는 구조에서 “Exactly-Once를 켰는데도 중복이 나온다”는 이야기는 흔합니다. 이유는 간단합니다. Kafka의 Exactly-Once는 Kafka 내부의 특정 경로에서만 성립하고, 서비스 코드와 외부 시스템까지 포함한 E2E 관점에서는 여전히 중복 가능성이 남기 때문입니다.

이 글에서는 Exactly-Once의 적용 범위를 정확히 자르고, MSA에서 중복이 생기는 대표 함정을 짚은 뒤, 실전에서 중복을 “운영 가능한 수준으로 근절”하는 설계를 코드와 함께 정리합니다.

1) Exactly-Once는 어디까지 보장하나

Kafka에서 Exactly-Once를 말할 때 보통 다음을 의미합니다.

  • 프로듀서 측: idempotent producer와 트랜잭셔널 프로듀서가 결합되어, 재시도나 리더 변경 같은 상황에서 브로커에 중복 레코드가 기록되는 것을 억제
  • 컨슈머 측: read_committed로 트랜잭션이 커밋된 레코드만 읽고, 오프셋 커밋을 트랜잭션에 묶어 consume-transform-produce 파이프라인에서 중복을 줄임

하지만 이는 대개 아래의 범위에서만 강합니다.

  • Kafka 토픽에서 읽어서 Kafka 토픽에 다시 쓰는 스트림 처리
  • 오프셋 커밋과 결과 토픽 기록을 한 트랜잭션으로 묶는 경우

반대로 다음은 Kafka Exactly-Once의 바깥입니다.

  • 컨슈머가 DB에 쓰는 작업
  • 컨슈머가 외부 API를 호출하는 작업
  • 메시지 처리 후 DB 커밋은 되었는데 오프셋 커밋이 실패한 상황
  • 오프셋은 커밋되었는데 DB 커밋이 롤백된 상황

결론적으로 MSA에서 “중복처리 근절”은 Kafka 설정만으로 끝나지 않고, DB 트랜잭션 경계와 멱등성이 핵심이 됩니다.

2) 중복이 생기는 대표 함정 7가지

함정 1: 오프셋 자동 커밋

enable.auto.commit=true는 중복과 유실을 모두 만들기 쉽습니다. 처리 완료 전에 오프셋이 커밋되면 유실, 처리 후 커밋이 안 되면 재처리로 중복이 발생합니다.

해결은 단순합니다.

  • enable.auto.commit=false
  • 처리 성공 후에만 오프셋 커밋

함정 2: “DB 쓰기”와 “오프셋 커밋”이 원자적이지 않음

가장 흔한 중복 시나리오입니다.

  1. 컨슈머가 메시지를 처리하고 DB에 반영
  2. 오프셋 커밋 전에 프로세스가 죽음
  3. 재시작 후 같은 메시지를 다시 읽음
  4. DB에 동일 반영이 또 일어나 중복

Kafka 트랜잭션은 DB를 같이 묶어주지 않습니다. 따라서 DB 레벨의 멱등 처리 또는 인박스 패턴이 필요합니다.

함정 3: producer retries로 인한 중복을 “클라이언트에서” 재생산

프로듀서에서 재시도를 켜고도, transactional.id나 idempotence 설정이 불완전하면, 장애 시 동일 이벤트가 여러 번 발행될 수 있습니다.

  • enable.idempotence=true
  • 적절한 acks=all
  • 트랜잭셔널 사용 시 안정적인 transactional.id

함정 4: 리밸런싱과 긴 처리 시간

처리가 오래 걸리면 리밸런싱 중에 파티션이 다른 인스턴스로 넘어가며 재처리가 발생할 수 있습니다.

  • max.poll.interval.ms와 실제 처리 시간 불일치
  • session.timeout.ms 설정 미스

해결은 처리 시간을 줄이거나, 병렬도를 조절하거나, 폴링과 처리를 분리해 하트비트를 유지하는 구조가 필요합니다.

함정 5: 외부 API 호출은 원천적으로 Exactly-Once가 아님

외부 결제, 문자 발송, 이메일 발송 등은 “한 번만 호출”이 매우 어렵습니다. 네트워크 타임아웃이 발생하면 실제로는 성공했는데 클라이언트는 실패로 보고 재시도할 수 있습니다.

이 경우 중복 방지는 Kafka가 아니라 외부 API의 멱등 키 또는 우리 쪽의 중복 차단 저장소로 해야 합니다.

함정 6: 이벤트 키 설계가 불안정

이벤트에 “고유 식별자”가 없거나, 재발행 시마다 새로운 ID를 만들면 컨슈머는 중복을 식별할 수 없습니다.

  • 이벤트에는 eventId를 반드시 포함
  • 비즈니스 엔티티 기준 aggregateId도 포함

함정 7: “중복은 없을 것”이라는 가정으로 관측이 빈약함

중복은 언젠가 발생합니다. 관측이 없으면 장애가 아니라 “데이터 품질 저하”로 오래 잠복합니다.

  • 중복 차단 카운터
  • 재처리율, 리밸런싱 횟수
  • DLQ로 빠지는 비율

운영 관점에서 사가 기반 보상 트랜잭션이 얽히면 중복과 보상이 결합되어 더 복잡해집니다. 이 주제는 MSA Saga 패턴 - 보상 트랜잭션 실패 디버깅과 함께 보면 원인 분리가 쉬워집니다.

3) 현실적인 목표: “효과적으로 Exactly-Once”를 만드는 3종 세트

MSA에서 실전 해법은 보통 아래 조합입니다.

  1. Kafka 프로듀서 트랜잭션 또는 idempotent producer로 토픽 중복을 최소화
  2. 컨슈머는 DB에 멱등하게 반영하거나, 인박스 테이블로 중복을 차단
  3. 외부 사이드이펙트는 멱등 키 또는 outbox 기반 비동기 처리로 분리

이 중 2번이 핵심입니다.

4) 컨슈머 멱등 처리: Inbox 테이블 패턴

핵심 아이디어는 간단합니다.

  • 메시지의 eventId를 DB에 기록
  • 이미 처리한 eventId면 비즈니스 로직을 실행하지 않음
  • 이 체크와 비즈니스 상태 변경을 하나의 DB 트랜잭션으로 묶음

스키마 예시

CREATE TABLE message_inbox (
  event_id VARCHAR(64) PRIMARY KEY,
  topic VARCHAR(255) NOT NULL,
  partition_id INT NOT NULL,
  offset_value BIGINT NOT NULL,
  received_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE orders (
  order_id VARCHAR(64) PRIMARY KEY,
  status VARCHAR(32) NOT NULL,
  updated_at TIMESTAMP NOT NULL
);

처리 로직 의사코드

아래는 Java 또는 Kotlin 계열에서 흔히 쓰는 형태의 의사코드입니다.

// enable.auto.commit=false 전제

void onMessage(ConsumerRecord<String, String> record) {
  Event evt = parse(record.value());

  db.transaction(() -> {
    boolean inserted = inboxDao.insertIfAbsent(
      evt.eventId,
      record.topic(),
      record.partition(),
      record.offset()
    );

    if (!inserted) {
      // 이미 처리한 이벤트
      return;
    }

    // 비즈니스 반영은 멱등하게 설계
    // 예: 동일 orderId에 동일 상태로 set 하는 형태
    ordersDao.updateStatus(evt.orderId, evt.newStatus);
  });

  // DB 트랜잭션 성공 후 오프셋 커밋
  consumer.commitSync();
}

여기서 중요한 점은 insertIfAbsent가 원자적으로 동작해야 한다는 것입니다. 보통은 PK 충돌을 이용합니다.

INSERT INTO message_inbox(event_id, topic, partition_id, offset_value)
VALUES (?, ?, ?, ?);

PK 중복이면 실패하고, 애플리케이션은 이를 “이미 처리됨”으로 간주합니다.

이 패턴이 막아주는 것

  • 컨슈머 재시작, 리밸런싱, 일시적 장애로 인해 같은 이벤트가 다시 들어와도 DB 반영은 한 번만 수행
  • 오프셋 커밋이 늦어져 재처리되어도 안전

트레이드오프

  • inbox 테이블이 계속 커짐
  • 보관 정책이 필요

보관 정책은 보통 다음 중 하나입니다.

  • 이벤트 보존 기간만큼 TTL 삭제
  • 집계 키 기준으로 최신 이벤트만 남기고 정리
  • 파티션별 오프셋 기반으로 정리

5) DB 업데이트도 “멱등한 형태”로 만들기

inbox로 1차 방어를 하더라도, 비즈니스 업데이트 자체가 멱등하면 안정성이 더 올라갑니다.

예를 들어 결제 완료 이벤트가 여러 번 와도, 상태를 단순히 PAID로 세팅하는 업데이트는 멱등적입니다.

UPDATE orders
SET status = 'PAID', updated_at = NOW()
WHERE order_id = ?
  AND status != 'PAID';

또는 이벤트 버전이 있다면 낙관적 조건을 넣을 수 있습니다.

UPDATE orders
SET status = ?, version = version + 1
WHERE order_id = ?
  AND version = ?;

이 방식은 중복뿐 아니라 역순 도착에도 강해집니다.

6) 프로듀서 측 함정: transactional.id와 fencing

Kafka 트랜잭션을 쓰면 transactional.id가 동일한 프로듀서 인스턴스가 중복 실행될 때 fencing이 일어납니다. 이는 중복 발행 방지에 유리하지만, 운영 중 다음 문제가 생깁니다.

  • 배포 중 동일 transactional.id를 가진 인스턴스가 잠깐 겹치면 한쪽이 fencing으로 실패
  • 스테이트풀한 프로듀서 재시작 정책이 불안정하면 연쇄 실패

따라서 transactional.id는 보통 다음 원칙으로 설계합니다.

  • 인스턴스마다 유일하지 않고, “논리적 프로듀서” 단위로 고정
  • 동시에 두 개가 뜨지 않도록 오케스트레이션 레벨에서 보장

프로세스가 반복 재시작하는 환경에서는 원인 파악이 먼저입니다. 시스템 레벨 재시작 루프는 systemd 서비스 재시작 반복 원인 추적 체크리스트 같은 접근으로 빠르게 좁힐 수 있습니다.

7) 외부 API 사이드이펙트: 멱등 키 없으면 “우리 DB로” 만든다

외부 API가 멱등 키를 지원하면 가장 좋습니다.

  • 요청에 Idempotency-Key를 넣고
  • 동일 키 요청은 서버가 동일 결과를 반환

지원하지 않는다면 다음 중 하나로 우회합니다.

  • 외부 호출 전에 side_effect_log 같은 테이블에 eventId를 기록하고, 이미 존재하면 호출하지 않음
  • 외부 호출을 outbox로 분리해 재시도 가능하게 만들고, 호출 결과를 저장해 중복 호출을 차단

네트워크 타임아웃은 중복을 양산합니다. 특히 게이트웨이 계층에서 504가 자주 보이면 재시도 정책이 중복 호출을 만들 수 있습니다. 인프라 타임아웃과 재시도 상호작용은 Cloud Run 504 Timeout 원인·해결 9가지도 참고할 만합니다.

8) Kafka Streams를 쓸 때의 착시: EOS는 토폴로지 내부만

Kafka Streams의 exactly_once_v2는 강력하지만, 다음을 기억해야 합니다.

  • 상태 저장소와 출력 토픽 사이의 일관성을 보장하는 쪽에 강함
  • 토폴로지 밖의 DB, 외부 API에는 적용되지 않음

따라서 Streams로 “결정”을 내리고, 실제 사이드이펙트는 outbox 이벤트로 분리한 다음, 멱등 컨슈머가 실행하는 형태가 운영적으로 안전합니다.

9) 운영 체크리스트: 중복을 ‘없애는’ 게 아니라 ‘통제’한다

마지막으로 중복을 통제하기 위한 체크리스트입니다.

컨슈머

  • enable.auto.commit=false
  • 처리 성공 후 커밋
  • inbox 테이블 또는 유니크 키 기반 dedup
  • 리밸런싱 대비 max.poll.interval.ms와 처리 시간 정합
  • 실패 시 재시도와 DLQ 정책 분리

프로듀서

  • enable.idempotence=true
  • acks=all
  • 트랜잭션 사용 시 안정적인 transactional.id
  • 재시도 정책이 중복 발행을 재생산하지 않는지 점검

이벤트 스키마

  • eventId 필수
  • aggregateIdeventType 명확화
  • 버전 또는 발생 시각으로 역순 도착 방어

관측

  • 중복 차단 카운터: inbox PK 충돌 횟수
  • 파티션 리밸런싱 횟수, 컨슈머 랙
  • 재시도율과 DLQ 유입률

10) 결론: Exactly-Once를 믿지 말고, 멱등성을 설계하라

Kafka Exactly-Once는 “Kafka 내부 파이프라인”에서는 유효하지만, MSA에서 우리가 진짜로 원하는 것은 DB와 외부 사이드이펙트까지 포함한 E2E 일관성입니다. 이를 위해서는

  • 컨슈머의 inbox 기반 dedup
  • 비즈니스 업데이트의 멱등성
  • 외부 호출의 멱등 키 또는 outbox 분리

이 3가지를 기본값으로 깔고, Kafka 트랜잭션은 그 위에 얹는 보조 수단으로 보는 것이 안전합니다.

중복은 언젠가 발생합니다. 중요한 것은 “발생해도 데이터가 망가지지 않는 설계”와 “발생했는지 바로 아는 관측”입니다.