Published on

Kafka 중복·역순 메시지, DDD로 멱등 처리하기

Authors

서버에서 Kafka를 쓰다 보면 “한 번만 처리되겠지”라는 기대는 빠르게 깨집니다. 컨슈머 리밸런싱, 네트워크 지연, 프로듀서 재시도, 컨슈머의 수동 커밋 전략, 파티션 재할당 등 현실적인 사건들이 겹치면 중복(duplicate)역순(out-of-order) 은 흔한 일상입니다. 문제는 이것이 단순히 메시지 처리 로직의 if 문 몇 줄로 해결되지 않는다는 점입니다. 특히 결제, 재고, 쿠폰, 포인트 같은 상태 변경 도메인에서는 “같은 이벤트를 두 번 적용”하거나 “나중 이벤트가 먼저 적용”되는 순간 데이터 정합성이 깨집니다.

이 글에서는 Kafka의 전달 특성을 전제로, DDD(도메인 주도 설계) 관점에서 멱등성을 애플리케이션 외곽(인프라/컨슈머)에서 땜질하는 것이 아니라 도메인 모델에 녹여서 중복·역순을 견디는 설계를 만드는 방법을 설명합니다.

> 운영 관점에서 재시도·백오프·큐 설계가 함께 필요하다면, 외부 API 호출이 섞인 컨슈머에서 특히 유용한 패턴은 OpenAI 429/RateLimitError 재시도·백오프·큐 설계 글의 아이디어를 그대로 가져올 수 있습니다.

Kafka에서 중복·역순이 발생하는 전형적인 이유

1) 최소 1회(at-least-once) + 수동 커밋

대부분의 시스템은 처리 성공 후 offset을 커밋합니다. 처리 성공 직후 커밋 전에 장애가 나면, 같은 메시지를 다시 받습니다. 즉 중복은 정상 시나리오입니다.

2) 리밸런싱과 파티션 재할당

컨슈머 그룹에서 멤버가 빠지거나 추가되면 리밸런싱이 일어나고, 커밋/처리 경계가 꼬이면서 재처리가 발생합니다.

3) 역순(out-of-order)

Kafka는 파티션 내 순서만 보장합니다. 다음 경우 역순이 생깁니다.

  • 같은 Aggregate(예: orderId)가 서로 다른 파티션으로 라우팅됨(키 설정 실수)
  • 멀티 토픽/멀티 스트림을 조합하면서 합류(join) 지점에서 순서가 흐트러짐
  • 동일 키라도 “이벤트 시간(event time)”과 “도착 시간(arrival time)” 차이로 비즈니스 관점에서 역순처럼 보임

결론은 간단합니다.

  • 중복은 반드시 온다.
  • 역순도 충분히 온다.
  • 그러니 “정확히 한 번(exactly once)”을 맹신하기보다, 도메인에서 안전하게 처리해야 합니다.

DDD 관점: 멱등성은 ‘도메인 불변식’을 지키는 기술

DDD에서 중요한 것은 “이 이벤트를 한 번만 적용한다”가 아니라, 도메인의 불변식(invariant) 을 어떤 입력에도 깨지지 않게 유지하는 것입니다.

예를 들어 결제 도메인에서 불변식은 다음처럼 표현됩니다.

  • 결제 승인(PAYMENT_APPROVED)은 주문당 1회만 유효하다.
  • 결제가 취소(PAYMENT_CANCELED)되면, 이후 승인 이벤트가 다시 와도 상태가 되돌아가면 안 된다.
  • 재고 차감(STOCK_DECREASED)은 동일한 주문 라인에 대해 중복 적용되면 안 된다.

이 불변식을 지키려면 멱등 처리를 “컨슈머가 중복을 걸러준다” 수준이 아니라,

  1. 도메인 모델이 이벤트 적용 가능 여부를 판단하고
  2. 영속 계층이 원자적으로 중복을 차단하며
  3. 오프셋 커밋은 도메인 커밋 이후에 수행

되도록 설계해야 합니다.

핵심 전략 3가지: (1) 명령/이벤트에 Idempotency Key, (2) Aggregate에 처리 이력, (3) 저장소에서 원자적 보장

1) 이벤트에 Idempotency Key(=eventId)를 강제한다

가장 먼저 필요한 것은 중복을 식별할 수 있는 키입니다.

  • eventId(UUID)
  • 또는 aggregateId + version
  • 또는 businessKey(주문번호+이벤트종류+시퀀스)

권장: 이벤트 스키마에 eventId, aggregateId, occurredAt, version을 포함하세요.

{
  "eventId": "b6c2f8c1-6d7d-4c1b-b1e4-3c1d7b0b2a9c",
  "aggregateId": "order-20240223-0001",
  "type": "PaymentApproved",
  "version": 7,
  "occurredAt": "2026-02-23T10:15:30Z",
  "payload": {
    "paymentId": "pay_123",
    "amount": 12000
  }
}

2) Aggregate가 “이 이벤트를 이미 처리했는지/처리해도 되는지” 판단한다

도메인에 멱등성을 넣는 가장 전형적인 방법은 Aggregate가 처리한 이벤트(또는 커맨드)의 흔적을 보관하고, 재처리 시 무시하는 것입니다.

다만 “모든 eventId를 무한히 저장”하면 테이블이 커질 수 있으니, 보통은 다음 중 하나를 택합니다.

  • 버전 기반: version이 현재보다 작거나 같으면 무시(역순/중복 모두 방어)
  • 최근 N개 eventId만 저장: 이벤트 재전송 윈도우가 짧을 때
  • 별도 Inbox 테이블: 처리 이력은 인프라 테이블에 두되, 도메인은 “이미 처리됨”을 조회해 판단

버전 기반이 가장 강력하고 단순합니다. 이벤트 생산 측에서 Aggregate 버전을 올바르게 증가시키는 전제가 필요합니다.

3) 저장소에서 원자적으로 막는다: Inbox(또는 ProcessedEvent) + Unique 제약

컨슈머가 멀티스레드/멀티인스턴스로 돌면 “체크 후 처리”는 경쟁 조건(race condition)에 취약합니다.

따라서 DB에 유니크 제약으로 eventId를 한 번만 insert되게 만들고, insert 성공한 경우에만 도메인 로직을 진행하는 패턴이 안전합니다.

  • inbox(event_id PK, received_at, topic, partition, offset, status, ...)
  • insert 실패(duplicate key)면 이미 처리된 이벤트로 간주하고 ack/commit

이 패턴은 흔히 Idempotent Consumer / Inbox 패턴이라고 부릅니다.

구현 예시: Spring Boot + JPA로 Idempotent Consumer 만들기

아래 예시는 “PaymentApproved” 이벤트를 받아 Order Aggregate의 상태를 변경하는 시나리오입니다.

1) Inbox 엔티티 (eventId 유니크)

@Entity
@Table(name = "inbox_event")
public class InboxEvent {

    @Id
    @Column(name = "event_id", nullable = false, updatable = false, length = 36)
    private String eventId;

    @Column(name = "topic", nullable = false)
    private String topic;

    @Column(name = "partition_id", nullable = false)
    private int partition;

    @Column(name = "offset_value", nullable = false)
    private long offset;

    @Column(name = "received_at", nullable = false)
    private Instant receivedAt;

    protected InboxEvent() {}

    public InboxEvent(String eventId, String topic, int partition, long offset) {
        this.eventId = eventId;
        this.topic = topic;
        this.partition = partition;
        this.offset = offset;
        this.receivedAt = Instant.now();
    }

    public String getEventId() { return eventId; }
}
CREATE TABLE inbox_event (
  event_id VARCHAR(36) PRIMARY KEY,
  topic VARCHAR(200) NOT NULL,
  partition_id INT NOT NULL,
  offset_value BIGINT NOT NULL,
  received_at TIMESTAMP NOT NULL
);

2) Order Aggregate: 버전 기반으로 역순/중복 방어

public class Order {
    private String id;
    private long version;
    private OrderStatus status;

    public void applyPaymentApproved(long eventVersion) {
        // 역순 또는 중복 이벤트 방어
        if (eventVersion <= this.version) {
            return;
        }

        // 도메인 불변식
        if (this.status == OrderStatus.CANCELED) {
            // 취소된 주문은 결제 승인으로 되돌릴 수 없음
            return;
        }

        this.status = OrderStatus.PAID;
        this.version = eventVersion;
    }
}

포인트는 “멱등성 판단이 도메인 규칙과 함께 있다”는 것입니다. 단순히 eventId를 체크하는 것이 아니라, 상태 전이의 합법성을 도메인이 결정합니다.

3) Kafka Listener: 트랜잭션 안에서 Inbox insert → 도메인 적용 → 커밋

@Component
public class PaymentEventConsumer {

    private final InboxEventRepository inboxRepo;
    private final OrderRepository orderRepo;

    public PaymentEventConsumer(InboxEventRepository inboxRepo, OrderRepository orderRepo) {
        this.inboxRepo = inboxRepo;
        this.orderRepo = orderRepo;
    }

    @KafkaListener(topics = "payment-events", groupId = "order-service")
    @Transactional
    public void onMessage(ConsumerRecord<String, String> record) {
        PaymentApprovedEvent event = PaymentApprovedEvent.fromJson(record.value());

        // 1) Inbox에 eventId를 먼저 기록(유니크로 중복 차단)
        try {
            inboxRepo.save(new InboxEvent(
                event.eventId(),
                record.topic(),
                record.partition(),
                record.offset()
            ));
        } catch (DataIntegrityViolationException duplicate) {
            // 이미 처리한 이벤트: 멱등하게 종료
            return;
        }

        // 2) 도메인 로딩 및 적용
        Order order = orderRepo.findById(event.aggregateId())
            .orElseThrow(() -> new IllegalStateException("Order not found"));

        order.applyPaymentApproved(event.version());

        // 3) 저장(같은 트랜잭션)
        orderRepo.save(order);
        // 트랜잭션 커밋 후 Kafka offset 커밋(설정에 따라)
    }
}

여기서 중요한 운영 포인트는 DB 트랜잭션과 Kafka 오프셋 커밋의 경계입니다.

  • DB 커밋 전에 오프셋이 커밋되면: 처리 유실 위험
  • DB 커밋 후 오프셋 커밋이 실패하면: 중복 처리되지만 Inbox가 막아줌

즉, “유실보다 중복이 낫다”는 원칙에서 중복은 멱등으로 흡수하는 것이 안전합니다.

> DB 커넥션 풀 고갈이나 타임아웃으로 컨슈머가 불안정해지면 리밸런싱/중복이 폭증합니다. 이런 상황을 빨리 진단하려면 Spring Boot HikariCP 풀 고갈·DB 타임아웃 10분 진단 체크리스트를 함께 참고하세요.

역순 메시지까지 다루려면: “버전” 또는 “상태 머신”이 필요하다

중복은 eventId로 잡을 수 있지만, 역순은 더 까다롭습니다. 역순은 “이벤트가 처음 보는 eventId”일 수도 있기 때문입니다.

선택지 A) Aggregate Version으로 단조 증가 보장

  • 이벤트에 version을 포함
  • Aggregate는 eventVersion <= currentVersion이면 무시

장점: 단순, 빠름 단점: 생산 측에서 version 관리가 필요(동시성 제어 포함)

선택지 B) 상태 머신으로 합법 전이만 허용

버전이 없어도, 상태 전이를 엄격히 하면 상당 부분을 막을 수 있습니다.

  • CREATED → PAID 가능
  • CANCELED → PAID 불가
  • PAID → PAID(중복)는 no-op

하지만 “PAID 이후 REFUNDED, 그 이후 PAID가 다시 오는” 같은 복잡한 케이스에서는 버전이 훨씬 명확합니다.

선택지 C) 재정렬(reorder) 버퍼를 둔다 (권장도 낮음)

일정 시간 동안 이벤트를 모아 정렬 후 처리하는 방식입니다.

  • 지연(latency) 증가
  • 버퍼 메모리/스토리지 필요
  • 지연 윈도우 밖 역순은 여전히 문제

대부분의 업무 시스템에서는 버전 + 멱등이 더 실용적입니다.

Outbox/Inbox와 DDD의 접점: “도메인 이벤트”를 신뢰할 수 있게

여기까지는 컨슈머 관점(Inbox)이었습니다. 반대편, 즉 생산자(프로듀서)에서도 같은 원칙이 필요합니다.

  • 도메인 상태 변경과 이벤트 발행 사이에 틈이 생기면(트랜잭션 경계 불일치)
    • DB는 바뀌었는데 이벤트가 안 나감(유실)
    • 이벤트는 나갔는데 DB는 롤백됨(거짓 이벤트)

이를 막는 대표 패턴이 Outbox 패턴입니다.

  • 같은 DB 트랜잭션에서
    • Aggregate 저장
    • outbox 테이블에 이벤트 저장
  • 별도 퍼블리셔가 outbox를 읽어 Kafka로 발행

Inbox(소비) + Outbox(발행)를 같이 쓰면 “중복/역순/유실”에 대한 내성이 크게 올라갑니다.

운영에서 자주 놓치는 디테일 6가지

1) 파티셔닝 키는 Aggregate ID로 고정

같은 주문의 이벤트가 다른 파티션으로 가면, Kafka가 제공하는 순서 보장은 무의미해집니다.

  • 키: orderId
  • 토픽: 도메인 경계별로 분리하되, 순서가 필요한 스트림은 한 토픽/한 키 정책을 유지

2) DLQ(Dead Letter Queue)로 “영구 실패”를 격리

멱등 처리가 있어도 데이터 오류/스키마 오류는 해결되지 않습니다.

  • 재시도 가능한 오류(일시적) vs 재시도해도 안 되는 오류(영구적)
  • 영구 실패는 DLQ로 보내고, 본 스트림의 진행을 막지 않기

3) Inbox 테이블 청소 정책

eventId를 영구 보관할 필요가 있는지 결정해야 합니다.

  • 회계/정산 등 감사가 필요: 장기 보관 + 파티셔닝
  • 일반 업무 이벤트: 7~30일 TTL로 청소(배치/파티션 드롭)

4) 컨슈머 동시성(concurrency)과 트랜잭션 격리

동일 aggregateId에 대해 동시에 처리하면 DB 락 경합이 생깁니다.

  • 가능하면 파티션 수/컨슈머 수를 조정해 “같은 키는 같은 컨슈머”로 처리
  • 그래도 발생하는 경합은 낙관적 락(Optimistic Lock) + 재시도로 흡수

5) 관측 가능성: eventId로 end-to-end 트레이싱

로그/메트릭에 최소한 아래를 남기세요.

  • eventId, aggregateId, version
  • topic/partition/offset
  • 처리 결과(no-op/적용/오류)

6) 장애 시나리오 리허설

  • 리밸런싱 유도(인스턴스 강제 종료)
  • DB 타임아웃 유도
  • 중복/역순 이벤트 인위적 재주입

이 리허설을 해보면 “멱등이 컨슈머 if 문으로는 안 된다”는 걸 금방 체감합니다.

> 컨슈머 장애로 Pod가 반복 재시작되면 리밸런싱/중복이 폭증합니다. 쿠버네티스 환경이라면 Kubernetes CrashLoopBackOff 원인별 10분 진단 흐름으로 먼저 안정화하는 것이 멱등 설계만큼 중요합니다.

정리: “중복 제거”가 아니라 “도메인 안전성”을 설계하라

Kafka에서 중복·역순은 예외가 아니라 전제입니다. DDD로 멱등 처리를 한다는 것은 다음을 의미합니다.

  • 이벤트는 식별 가능해야 한다(eventId/version)
  • Aggregate는 상태 전이의 합법성을 스스로 지켜야 한다(불변식)
  • 저장소는 원자적으로 중복을 차단해야 한다(Inbox + Unique)
  • 오프셋 커밋은 도메인 커밋 이후에 이루어져야 한다(유실 방지)
  • 운영적으로는 재시도/백오프/DLQ/관측성을 함께 갖춰야 한다

이렇게 설계하면 “Kafka가 정확히 한 번을 보장해주길” 바라지 않아도, 시스템은 현실적인 장애와 재시도 속에서 일관된 비즈니스 결과를 만들어낼 수 있습니다.