Published on

Kafka 중복발행·중복처리 막는 Outbox+Idempotency

Authors

서버가 한 번만 이벤트를 발행하고, 소비자도 한 번만 처리하면 좋겠지만 Kafka 환경에서 이는 “희망사항”에 가깝습니다. 네트워크 재시도, 프로듀서 재전송, 컨슈머 리밸런스, 커밋 타이밍, 장애 복구 등 현실적인 변수가 겹치면 중복 발행중복 처리는 자연스럽게 발생합니다.

이 글은 “중복을 완전히 없애는” 대신, Outbox 패턴으로 발행 경로를 안정화하고, Idempotency(멱등 처리)로 소비 경로를 안전하게 만들어 전체 시스템을 실무적으로 견고하게 만드는 방법을 다룹니다.

왜 Kafka에서 중복이 생기는가

1) 프로듀서 관점: at-least-once의 대가

Kafka는 기본적으로 at-least-once 전달이 쉬운 구조입니다. 프로듀서는 전송 성공을 확신하지 못하면 재시도하고, 이때 브로커에는 이미 기록되었는데 응답만 유실되면 동일 메시지가 다시 들어갈 수 있습니다.

Kafka에는 프로듀서 idempotence 기능이 있지만, 이것만으로 “업무 이벤트가 절대 중복 발행되지 않음”을 보장하긴 어렵습니다.

  • 프로듀서 idempotence는 “동일 프로듀서 세션에서 재전송 중복”을 줄이는 데 강점
  • 애플리케이션 레벨에서 동일 비즈니스 이벤트를 두 번 만들면(예: DB 트랜잭션 재시도, 타임아웃 후 재호출) Kafka는 그것을 구분하지 못함

2) 컨슈머 관점: 커밋과 처리의 원자성 부재

컨슈머가 메시지를 처리하고 오프셋을 커밋하는 과정은 다양한 방식으로 엇갈릴 수 있습니다.

  • 처리 완료 전에 커밋하면 장애 시 유실 위험
  • 처리 완료 후 커밋하면 장애 시 재처리(중복 처리) 위험

실무에서는 대체로 유실보다 중복이 낫기 때문에 “처리 후 커밋”을 택하고, 그 결과 중복 처리를 받아들이되 멱등 처리로 방어합니다.

목표를 명확히 하자: Exactly-once는 어디까지 필요한가

많은 팀이 “Exactly-once를 달성하자”라고 시작하지만, 실제로는 다음 두 가지를 분리하는 게 좋습니다.

  • 발행 경로: DB 상태 변경과 이벤트 발행의 정합성
  • 소비 경로: 동일 이벤트가 여러 번 와도 결과가 한 번만 반영되도록

결론적으로 실무에서 가장 비용 대비 효과가 큰 조합은 다음입니다.

  • 발행은 Outbox 패턴으로 “DB 커밋과 이벤트 생성”을 묶기
  • 소비는 Idempotency 키로 “중복 처리 무해화”하기

Outbox 패턴으로 중복 발행과 유실을 줄이기

Outbox 패턴 핵심 아이디어

업무 트랜잭션에서 DB 상태를 바꾸는 것과 “이벤트를 발행했다”를 같은 트랜잭션으로 묶기 어렵습니다. 그래서 아예 발행을 Kafka로 직접 하지 않고, DB에 이벤트를 먼저 기록합니다.

  • 업무 테이블 업데이트
  • outbox 테이블에 이벤트 레코드 insert
  • 둘을 같은 DB 트랜잭션으로 commit
  • 별도 퍼블리셔가 outbox를 읽어 Kafka로 발행

이렇게 하면 “DB는 바뀌었는데 이벤트는 못 보냄” 또는 “이벤트는 보냈는데 DB는 롤백됨” 같은 불일치가 크게 줄어듭니다.

Outbox 테이블 설계 예시

아래처럼 최소한의 필드를 갖춘 outbox 테이블을 둡니다.

CREATE TABLE outbox_event (
  id            BIGSERIAL PRIMARY KEY,
  event_id      UUID NOT NULL,
  aggregate_type VARCHAR(50) NOT NULL,
  aggregate_id  VARCHAR(100) NOT NULL,
  event_type    VARCHAR(100) NOT NULL,
  payload       JSONB NOT NULL,
  status        VARCHAR(20) NOT NULL DEFAULT 'NEW',
  created_at    TIMESTAMP NOT NULL DEFAULT now(),
  published_at  TIMESTAMP NULL,
  UNIQUE (event_id)
);

CREATE INDEX idx_outbox_status_created_at
ON outbox_event(status, created_at);

포인트는 다음입니다.

  • event_id는 전역 유니크 키로 두고 유니크 제약을 겁니다
  • status 기반으로 퍼블리셔가 폴링하거나, CDC로 스트리밍합니다

애플리케이션 트랜잭션에서 Outbox 기록

예시는 Spring 기반으로 작성하지만 개념은 동일합니다.

@Transactional
public void placeOrder(PlaceOrderCommand cmd) {
    Order order = orderRepository.save(new Order(cmd));

    OutboxEvent event = OutboxEvent.of(
        UUID.randomUUID(),
        "Order",
        order.getId().toString(),
        "OrderPlaced",
        objectMapper.valueToTree(Map.of(
            "orderId", order.getId(),
            "amount", order.getAmount()
        ))
    );

    outboxRepository.save(event);
}

이 트랜잭션이 커밋되면 DB 상태와 outbox 이벤트는 함께 확정됩니다.

Outbox 퍼블리셔(폴링) 구현 개요

퍼블리셔는 NEW 상태를 가져와 Kafka로 발행한 뒤 PUBLISHED로 바꿉니다.

@Scheduled(fixedDelay = 500)
public void publish() {
    List<OutboxEvent> batch = outboxRepository.findNextBatchForPublish(100);

    for (OutboxEvent e : batch) {
        try {
            kafkaTemplate.send(
                "order-events",
                e.getAggregateId(),
                e.getPayload().toString()
            ).get();

            outboxRepository.markPublished(e.getId());
        } catch (Exception ex) {
            // 재시도는 다음 스케줄에서
        }
    }
}

여기서도 중복은 완전히 사라지지 않습니다.

  • Kafka send 성공 후 DB 업데이트 실패하면 다음 폴링에서 다시 발행할 수 있음
  • 따라서 발행 측에서도 event_id를 헤더에 넣고, 소비 측에서 멱등 처리하는 설계가 함께 가야 합니다

CDC 기반 Outbox(확장)

폴링 대신 Debezium 같은 CDC를 붙여 outbox 테이블 변경을 Kafka로 흘리는 방식도 있습니다. 운영 복잡도는 늘지만, 폴링 부하와 레이턴시를 줄일 수 있습니다.

소비자 Idempotency로 “중복 처리”를 무해화하기

멱등 처리의 기본: 중복키 저장소

컨슈머가 메시지를 처리하기 전에 “이 이벤트를 이미 처리했는가”를 확인하고, 처리 후에는 “처리 완료”를 기록합니다.

가장 흔한 구현은 다음 둘 중 하나입니다.

  • DB에 processed_event 테이블을 두고 event_id를 유니크로 저장
  • Redis에 SETNX로 이벤트 id를 기록하고 TTL을 둠

정합성이 중요한 경우 DB 방식이 더 단단합니다.

processed_event 테이블 예시

CREATE TABLE processed_event (
  consumer_group VARCHAR(200) NOT NULL,
  event_id UUID NOT NULL,
  processed_at TIMESTAMP NOT NULL DEFAULT now(),
  PRIMARY KEY (consumer_group, event_id)
);
  • consumer group 단위로 멱등을 분리해야 할 때가 많습니다
  • 동일 이벤트라도 서로 다른 컨슈머 그룹(서로 다른 서비스)이 처리하는 것은 정상입니다

컨슈머 처리 흐름(트랜잭션 결합)

가장 중요한 포인트는 “업무 반영”과 “processed_event 기록”을 가능한 한 같은 트랜잭션으로 묶는 것입니다.

@Transactional
public void onMessage(KafkaRecord record) {
    UUID eventId = record.getHeaderAsUuid("event_id");

    boolean first = processedEventRepository.tryInsert(
        "order-service", eventId
    );

    if (!first) {
        return; // 이미 처리함
    }

    // 여기부터 업무 로직
    OrderPlaced payload = parse(record.value());
    orderReadModelRepository.upsert(payload);
}

tryInsert는 유니크 제약을 활용해 “넣을 수 있으면 처음, 아니면 중복”을 판별합니다.

PostgreSQL이라면 다음처럼 구현할 수 있습니다.

INSERT INTO processed_event(consumer_group, event_id)
VALUES (:group, :eventId)
ON CONFLICT DO NOTHING;

영향 범위를 줄이려면, 업무 로직 자체도 가능하면 멱등하게 만듭니다.

  • upsert 사용
  • 상태 전이를 조건부로 적용(현재 상태가 기대 상태일 때만 업데이트)

Outbox와 Idempotency를 같이 써야 하는 이유

Outbox만 쓰면 발행 정합성은 좋아지지만, 여전히 다음이 남습니다.

  • 퍼블리셔 재시도, 장애 복구 시 중복 발행 가능
  • Kafka 전달 특성상 컨슈머 재처리 가능

Idempotency만 쓰면 소비는 안전해지지만, 발행이 유실되면 복구가 어렵습니다.

둘을 조합하면 다음과 같은 현실적인 강점을 얻습니다.

  • DB 커밋과 이벤트 생성의 정합성 확보(Outbox)
  • 중복 전달을 허용하면서도 결과는 한 번만 반영(Idempotency)
  • 장애 시 재시도 전략을 공격적으로 가져갈 수 있어 운영이 편해짐

이벤트 키 설계: event_id, aggregate_id, message key

event_id는 “업무 이벤트의 정체성”

event_id는 멱등 처리의 기준입니다.

  • 동일 주문 생성 이벤트면 언제 재발행되어도 같은 event_id여야 한다는 주장도 있지만,
  • 실무에서는 “outbox 레코드 단위 이벤트”로 보고 UUID를 새로 생성하는 경우가 많습니다.

대신 아래를 지키면 됩니다.

  • 중복 발행이 일어나더라도 동일 outbox 레코드의 event_id는 변하지 않게
  • 퍼블리셔가 Kafka 헤더에 event_id를 항상 포함

Kafka message key는 순서 보장을 위한 aggregate_id

주문 단위 순서가 중요하면 message key로 aggregate_id(예: orderId)를 사용합니다.

  • 같은 key는 같은 파티션으로 들어가 상대적 순서가 유지됨
  • 단, 파티션 내에서도 “중복이 없어진다”는 의미는 아닙니다

운영에서 자주 터지는 포인트와 체크리스트

1) Outbox 폴링 경쟁 조건

여러 퍼블리셔 인스턴스가 동시에 같은 NEW 이벤트를 집어가면 중복 발행이 증가합니다.

대응:

  • SELECT ... FOR UPDATE SKIP LOCKED로 배치를 가져오기
  • 상태를 NEW에서 PROCESSING으로 원자적으로 바꾸고 소유자 컬럼을 둠

2) 재시도 폭주와 DB 부하

퍼블리셔가 계속 실패하는데 fixed delay로 촘촘히 재시도하면 DB와 Kafka에 부하가 쌓입니다.

대응:

  • 지수 백오프
  • DLQ(Dead Letter) 또는 FAILED 상태로 전환 후 알람
  • 오래된 outbox 적재량 모니터링

로그가 너무 커져 이슈 파악이 어려워지는 경우가 많습니다. 운영 서버에서 로그 로테이션이 안 돌아 디스크가 차는 문제도 자주 엮이니, 필요하면 리눅스 logrotate가 안 돎? 권한·SELinux 점검 같은 체크리스트로 기본 체력을 점검해두는 게 좋습니다.

3) 컨슈머 리밸런스와 처리 시간

처리가 오래 걸리면 리밸런스가 잦아지고, 그 과정에서 재처리 가능성이 커집니다.

대응:

  • 처리 시간 단축 또는 병렬화
  • 오프셋 커밋 전략 점검
  • DB 커넥션 풀이 병목이 되면 스레드 모델도 재검토

DB 커넥션 풀이 먼저 고갈되는 환경이라면 가상 스레드 도입이 도움이 될 수 있습니다. 관련해서는 Spring Boot 3 가상 스레드로 DB 풀 고갈 막기도 함께 참고할 만합니다.

최소 구현 레시피(실무용)

여기까지를 바탕으로 “오늘 당장” 적용 가능한 최소 레시피를 정리하면 다음 순서가 가장 안전합니다.

  1. Outbox 테이블 추가, 업무 트랜잭션에 outbox insert 포함
  2. 퍼블리셔는 폴링으로 시작하되, 중복을 전제로 설계
  3. Kafka 메시지에 event_id를 헤더로 포함(필수)
  4. 컨슈머는 processed_event 테이블로 멱등 처리
  5. 업무 반영 자체도 upsert, 조건부 업데이트 등으로 2중 안전장치

예시: Kafka 헤더에 event_id 넣기

프로듀서에서 헤더를 넣는 예시입니다.

ProducerRecord<String, String> record = new ProducerRecord<>(
    "order-events",
    aggregateId,
    payloadJson
);
record.headers().add("event_id", eventId.toString().getBytes(StandardCharsets.UTF_8));

kafkaTemplate.send(record);

컨슈머에서 헤더를 읽는 예시입니다.

public UUID readEventId(ConsumerRecord<String, String> record) {
    Header h = record.headers().lastHeader("event_id");
    if (h == null) throw new IllegalStateException("missing event_id");
    return UUID.fromString(new String(h.value(), StandardCharsets.UTF_8));
}

마무리

Kafka에서 중복 발행·중복 처리는 “없애는 문제”라기보다 “감당 가능한 형태로 통제하는 문제”에 가깝습니다. Outbox 패턴은 발행 경로의 정합성을 끌어올리고, Idempotency는 소비 경로에서 중복을 무해화합니다. 둘을 함께 적용하면 장애와 재시도를 두려워하지 않는 이벤트 기반 아키텍처에 한 걸음 더 가까워집니다.

다음 단계로는 outbox 폴링을 CDC로 전환할지, 멱등 키 저장소를 Redis로 옮길지, DLQ와 알람 체계를 어떻게 둘지 같은 운영 설계를 팀 상황에 맞게 확장해보면 좋습니다.