이벤트 기반 시스템에서는 메시지가 한 번만 전달될 것이라고 가정하면 거의 반드시 사고가 납니다. 브로커 재시도, 컨슈머 재시작, 네트워크 타임아웃 때문에 중복 전달은 정상 동작에 가깝기 때문입니다. 이 글은 Node.js 백엔드에서 Kafka(또는 다른 MQ) 소비 로직을 중복 허용 + 결과 멱등 구조로 만드는 방법을 실무 기준으로 정리합니다.

왜 Idempotent Consumer가 필요한가

at-least-once 전달 모델의 현실

Kafka, RabbitMQ, SQS 같은 시스템은 보통 at-least-once 전달을 기본으로 둡니다. 즉, “유실은 줄이되 중복 가능성은 열어둔다”는 설계입니다. 따라서 중복 메시지를 애플리케이션에서 안전하게 흡수해야 합니다.

중복 처리 실패가 만드는 대표 장애

  • 주문/결제 이벤트 중복 반영으로 금액 불일치
  • 포인트/재고가 두 번 차감되는 데이터 손상
  • 외부 API 호출 중복으로 비용 증가 및 CS 이슈

핵심은 “메시지 중복” 자체가 아니라, 중복이 비즈니스 결과를 바꾸는 구조입니다.

Node.js Idempotent Consumer 설계 원칙

H3. 멱등 키(idempotency key)부터 명확히 정한다

메시지마다 비즈니스 관점의 유일 키를 가져야 합니다. 예: orderId + eventType + eventVersion

브로커 offset이나 timestamp를 키로 쓰면 재처리/재발행 시 의미가 깨질 수 있습니다. “이 작업은 이미 처리했는가?”를 판별할 수 있는 키가 필요합니다.

H3. DB에 “처리 이력”을 원자적으로 남긴다

가장 단순하고 강력한 방식은 processed_messages 테이블(또는 Redis set + 영속 백업)에 멱등 키를 unique 제약으로 저장하는 방법입니다.

create table processed_messages (
  id bigint generated always as identity primary key,
  idempotency_key varchar(200) not null,
  consumer_name varchar(100) not null,
  processed_at timestamptz not null default now(),
  unique (idempotency_key, consumer_name)
);

비즈니스 업데이트와 처리 이력 기록을 같은 트랜잭션으로 묶어야 부분 성공(데이터만 반영, 이력 미기록) 같은 꼬임을 줄일 수 있습니다.

구현 예시: PostgreSQL + Node.js

H3. 트랜잭션 안에서 “중복이면 무시” 처리

import { Pool } from 'pg';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

export async function handleOrderPaid(event: {
  eventId: string;
  orderId: string;
  amount: number;
}) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');

    const key = `order-paid:${event.eventId}`;

    const inserted = await client.query(
      `
      insert into processed_messages (idempotency_key, consumer_name)
      values ($1, $2)
      on conflict (idempotency_key, consumer_name) do nothing
      returning id
      `,
      [key, 'billing-consumer']
    );

    // 이미 처리된 메시지라면 안전하게 종료
    if (inserted.rowCount === 0) {
      await client.query('ROLLBACK');
      return { skipped: true };
    }

    await client.query(
      `update orders
       set paid_amount = paid_amount + $1,
           status = 'PAID'
       where id = $2`,
      [event.amount, event.orderId]
    );

    await client.query('COMMIT');
    return { skipped: false };
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
}

이 패턴의 장점은 단순합니다.

  • 첫 처리: 이력 insert 성공 + 비즈니스 반영
  • 중복 처리: 이력 insert 충돌 → 비즈니스 로직 미실행

운영에서 자주 놓치는 포인트

H3. “외부 API 호출”은 별도 멱등 장치가 필요하다

DB는 트랜잭션으로 보호해도, 외부 결제/알림 API는 네트워크 타임아웃 시 결과를 확신하기 어렵습니다. 가능하면 외부 API에도 idempotency key를 전달하고, 없다면 호출 결과를 저장해 재시도 정책을 분리해야 합니다.

H3. DLQ와 재처리 정책을 같이 설계한다

멱등 소비자는 중복에는 강하지만, 스키마 불일치/필수 데이터 누락 같은 “영구 실패”까지 해결해주진 않습니다. 실패 메시지는 DLQ(Dead Letter Queue)로 보내고 운영자가 재처리할 수 있는 runbook을 준비해야 합니다.

H3. 관측 지표를 미리 붙인다

최소한 아래 지표는 기본으로 수집하는 편이 좋습니다.

  • duplicate_detected_count (중복 감지 건수)
  • consumer_lag
  • processing_latency_p95
  • dlq_enqueue_count

중복 감지율이 갑자기 늘면, 프로듀서 재시도 폭증이나 브로커/네트워크 문제의 신호일 수 있습니다.

실무 체크리스트

H3. 배포 전

  • 멱등 키 규칙이 팀 내에서 문서화되어 있는가
  • 처리 이력 저장소에 unique 제약이 있는가
  • 비즈니스 반영과 이력 기록이 같은 트랜잭션인가

H3. 배포 후

  • duplicate_detected_count 알림 임계치를 설정했는가
  • DLQ 재처리 절차(담당자/명령어/검증 항목)가 있는가
  • 장애 회고 때 중복 처리 누락 케이스를 템플릿화했는가

요약

이벤트 기반 아키텍처에서 “중복 메시지”는 예외가 아니라 기본값입니다. Node.js 컨슈머를 안전하게 운영하려면 멱등 키 설계 + 원자적 처리 이력 기록 + DLQ/관측 지표를 세트로 가져가야 합니다. 이 3가지를 표준화하면 재시도는 더 공격적으로 가져가면서도, 데이터 정합성은 안정적으로 지킬 수 있습니다.

내부 링크