- Published on
Kafka 중복 소비로 DDD 사가 깨질 때 - Idempotency 키 설계
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서로 다른 마이크로서비스가 이벤트로 엮인 DDD 사가에서 가장 흔하게 터지는 사고는 중복 소비(duplicate consumption)입니다. Kafka는 기본적으로 at-least-once 전달을 제공하므로, 컨슈머 재시작, 리밸런싱, 네트워크 지연, 커밋 타이밍 문제 등으로 같은 메시지가 한 번 더 처리될 수 있습니다. 문제는 사가가 본질적으로 분산 상태 머신인데, 중복 이벤트가 들어오면 상태 전이가 두 번 일어나면서 도메인 불변식이 깨지기 쉽다는 점입니다.
예를 들어 결제 승인 이벤트를 두 번 처리하면 재고가 두 번 차감되거나, 보상 트랜잭션이 두 번 실행되어 환불이 중복으로 나가기도 합니다. 이 글에서는 “중복 소비로 사가가 깨질 때”를 전제로, 실전에서 통하는 Idempotency 키 설계를 중심으로 방어 전략을 정리합니다.
관련해서 보상 트랜잭션 중복 실행을 더 넓게 다룬 글도 함께 참고하면 좋습니다: MSA Saga 보상 트랜잭션 중복 실행 방지법
왜 Kafka 중복 소비가 사가를 깨뜨리는가
사가 오케스트레이션 또는 코레오그래피 모두 결국은 다음 패턴을 가집니다.
- 어떤 서비스가 이벤트를 소비한다
- 로컬 트랜잭션으로 상태를 바꾼다
- 다음 단계 이벤트를 발행한다
Kafka 중복 소비가 발생하면 아래가 연쇄로 무너집니다.
- 같은 이벤트를 두 번 처리한다
- 로컬 상태 변경이 두 번 적용된다
- 다음 단계 이벤트도 두 번 발행된다
- 다운스트림이 또 두 번 처리한다
즉, 중복은 “한 서비스에서만” 끝나지 않고 사가 전체를 증폭시키는 형태로 퍼집니다.
여기서 중요한 포인트는 Kafka offset이 “업무 처리 완료”의 증거가 아니라는 점입니다. offset 커밋이 성공했더라도 DB 트랜잭션이 롤백될 수 있고, 반대로 DB는 커밋됐는데 offset 커밋이 실패해 재처리될 수도 있습니다.
Idempotency 키: 무엇을 멱등하게 만들 것인가
멱등성은 “같은 요청을 여러 번 수행해도 결과가 같음”을 의미하지만, 사가에서는 더 구체적으로 정의해야 합니다.
이벤트 처리를 멱등하게 할 것인가커맨드 실행을 멱등하게 할 것인가외부 사이드이펙트(결제, 이메일, 포인트)를 멱등하게 할 것인가
실무적으로는 보통 이벤트 처리 단위에서 멱등을 보장하고, 특히 돈/재고 같은 강한 불변식이 있는 곳은 사이드이펙트 단위에서도 멱등을 한 번 더 겹쳐서 둡니다.
좋은 Idempotency 키의 조건
사가에서 쓸 키는 단순히 eventId 하나로 끝나지 않는 경우가 많습니다. 좋은 키의 조건은 다음과 같습니다.
- 전역 유일성: 서비스 인스턴스, 파티션, 재시도와 무관하게 유일해야 함
- 업무 의미가 명확: “무엇을 한 번만 해야 하는가”를 표현해야 함
- 범위(scope)가 명확: 주문 단위인지, 단계 단위인지, 외부 호출 단위인지
- 재발행/리플레이에 안전: 이벤트 재처리, 백필(backfill)에도 충돌 없이 동작
권장하는 실전 조합은 아래 중 하나입니다.
sagaId+stepName+eventType+versionaggregateId+commandName+commandVersion+causationId
여기서 causationId는 “이 커맨드를 발생시킨 원인 이벤트 ID”입니다. 같은 원인으로부터 파생된 처리는 중복으로 보지 않게 만들 수 있습니다.
이벤트 메타데이터 설계: correlationId, causationId
Kafka 메시지에 다음 메타데이터를 넣는 것을 권합니다.
messageId: 메시지 자체의 유일 IDcorrelationId: 사가/요청 흐름을 관통하는 IDcausationId: 바로 직전 원인 메시지 IDproducer: 생산자 서비스명occurredAt: 이벤트 발생 시각
이를 JSON으로 표현하면 다음과 같습니다.
{
"messageId": "01HZY7Q9J2Y0K4Y6G9S9R5C8KQ",
"correlationId": "order-20250224-000123",
"causationId": "01HZY7Q7Z9R4FQG8X7J0M1P2AB",
"type": "PaymentApproved",
"occurredAt": "2026-02-24T10:15:30.123Z",
"payload": {
"orderId": "O-000123",
"amount": 12000,
"currency": "KRW"
}
}
messageId는 재발행 시 바뀔 수 있지만, causationId와 correlationId를 잘 유지하면 “업무적으로 같은 처리”를 판별하기 쉬워집니다.
가장 안전한 패턴: Inbox 테이블로 소비 멱등 보장
Kafka 중복 소비를 막는 가장 정석적인 방법은 Inbox(consumer dedup) 테이블을 두는 것입니다.
핵심은 “이 메시지를 처리했는지”를 DB의 유니크 제약으로 판별하고, 처리 로직과 함께 같은 트랜잭션으로 묶는 것입니다.
테이블 예시(PostgreSQL)
create table consumer_inbox (
consumer_name text not null,
idempotency_key text not null,
message_id text not null,
received_at timestamptz not null default now(),
processed_at timestamptz,
status text not null default 'RECEIVED',
primary key (consumer_name, idempotency_key)
);
create index on consumer_inbox (message_id);
primary key (consumer_name, idempotency_key)가 핵심입니다. 같은 컨슈머가 같은 키를 두 번 처리하려고 하면 두 번째는 삽입에서 막힙니다.
처리 흐름
- 트랜잭션 시작
- inbox에
insert시도 - 성공하면 비즈니스 로직 수행
- 로컬 상태 업데이트
- 필요하면 outbox에 이벤트 기록
- inbox 상태 업데이트
- 트랜잭션 커밋
중복이면 2번에서 실패하므로 비즈니스 로직이 실행되지 않습니다.
Spring Boot 의사코드
아래 코드에서 부등호가 포함된 제네릭은 MDX 빌드 에러를 피하기 위해 모두 인라인 코드로 표기합니다.
@Service
public class PaymentApprovedConsumer {
private final InboxRepository inboxRepository;
private final OrderRepository orderRepository;
private final OutboxRepository outboxRepository;
@Transactional
public void onMessage(PaymentApprovedEvent event) {
String consumer = "order-service.payment-approved";
String idempotencyKey = event.correlationId() + ":PaymentApproved:v1";
boolean inserted = inboxRepository.tryInsert(consumer, idempotencyKey, event.messageId());
if (!inserted) {
// 이미 처리한 메시지
return;
}
Order order = orderRepository.getById(event.payload().orderId());
order.markPaid(event.payload().amount());
outboxRepository.append(
"OrderPaid",
event.correlationId(),
event.messageId(),
new OrderPaidPayload(order.getId())
);
inboxRepository.markProcessed(consumer, idempotencyKey);
}
}
@Transactional이 기대대로 동작하지 않으면 이 패턴 자체가 무너질 수 있습니다. 트랜잭션 경계가 의도대로 잡히는지 점검이 필요하다면 다음 글이 도움됩니다: Spring Boot 3에서 @Transactional 무시되는 7가지
Idempotency 키를 무엇으로 잡을까: 실전 설계 가이드
키를 잡는 방식은 크게 3가지가 있습니다.
1) messageId 기반(가장 단순, 하지만 함정 있음)
- 장점: 구현이 쉽다
- 단점: 재발행 시
messageId가 바뀌면 중복으로 인식하지 못함
“정확히 같은 Kafka 레코드”의 재처리만 막는 수준입니다. 운영에서 리플레이/재발행을 자주 한다면 부족합니다.
2) correlationId 기반(사가 단위)
- 장점: 사가 전체 흐름을 묶어 관리 가능
- 단점: 사가 내 여러 단계 이벤트를 구분하기 어렵다
그래서 보통 correlationId + stepName 형태로 확장합니다.
3) causationId 기반(원인 이벤트 단위)
- 장점: 같은 원인으로부터 파생된 중복 처리를 잘 막는다
- 단점: 이벤트 체인이 길어지면 추적이 필요
권장 조합은 다음입니다.
idempotencyKey = correlationId + ":" + consumerStep + ":" + version- 또는
idempotencyKey = causationId + ":" + consumerStep + ":" + version
여기서 version은 스키마/로직 변경으로 “같은 단계지만 처리 의미가 달라진 경우”를 구분하기 위한 안전장치입니다.
사가가 깨지는 대표 시나리오와 키 설계 대응
시나리오 A: 결제 승인 이벤트 중복으로 재고 2회 차감
- 문제:
InventoryReserved가 두 번 생성되거나, 재고 수량이 음수가 됨 - 대응:
- 재고 서비스 컨슈머의 idempotencyKey를
orderId:reserve:v1로 고정 - 또는
correlationId:InventoryReserve:v1
- 재고 서비스 컨슈머의 idempotencyKey를
이때 “재고 차감” 자체도 orderId 단위로 유니크하게 만들면 더 강합니다.
시나리오 B: 보상 트랜잭션 중복으로 환불 2회 실행
- 문제: 결제 게이트웨이에 환불 API가 두 번 호출됨
- 대응:
- 외부 결제 API 호출에
idempotencyKey헤더를 전달(게이트웨이가 지원한다면) - 내부적으로도
refundId또는orderId:refund:v1로 중복 호출 방지
- 외부 결제 API 호출에
보상 중복 방지는 사가에서 특히 치명적이므로 별도로 정리한 글을 함께 보는 것을 권합니다: MSA Saga 보상 트랜잭션 중복 실행 방지법
Outbox와 조합: 중복 소비뿐 아니라 중복 발행도 다루기
소비 멱등만으로는 부족할 때가 있습니다. 예를 들어 DB는 커밋됐는데 Kafka 발행이 실패하면, 재시도로 같은 이벤트를 또 만들면서 중복 발행이 생깁니다.
이때는 Outbox 패턴이 사실상 필수입니다.
- Inbox: “이 메시지 처리했나”
- Outbox: “이 이벤트 발행했나”
Outbox 테이블에도 idempotencyKey 또는 eventKey를 두고 유니크 제약을 걸면, 동일 이벤트가 중복으로 쌓이는 것을 막을 수 있습니다.
create table outbox (
id bigserial primary key,
event_key text not null,
event_type text not null,
correlation_id text not null,
causation_id text,
payload jsonb not null,
created_at timestamptz not null default now(),
published_at timestamptz
);
create unique index outbox_event_key_uq on outbox (event_key);
event_key 예시는 orderId:OrderPaid:v1처럼 “업무적으로 한 번만 나가야 하는 이벤트”를 기준으로 잡습니다.
Kafka 컨슈머 설정만으로 해결하려는 시도의 한계
다음 같은 접근은 도움이 되지만, 단독으로는 사가 안정성을 보장하지 못합니다.
enable.auto.commit=false로 수동 커밋- 처리 후 커밋
max.poll.interval.ms튜닝- 동시성 조절
이것들은 “중복 확률을 낮추는 것”이지 “중복을 0으로 만드는 것”이 아닙니다. 사가의 안전은 결국 애플리케이션 계층에서 멱등으로 담보해야 합니다.
운영에서 자주 놓치는 디테일
1) idempotencyKey 보관 기간(TTL)
Inbox를 영구 보관하면 테이블이 커집니다. 하지만 너무 짧게 지우면 늦게 도착한 중복을 못 막습니다.
- 주문/결제 도메인: 최소 며칠에서 몇 주
- 정산/회계: 더 길게(감사 추적)
삭제는 배치로 하되, processed_at 기준으로 정리하는 것을 권합니다.
2) 파티션 키와 멱등 키의 관계
파티션 키를 orderId로 잡으면 같은 주문 이벤트가 같은 파티션에 모여 순서가 안정적입니다. 그러나 중복은 여전히 발생할 수 있으니 Inbox는 별개로 필요합니다.
3) “이미 처리됨”을 정상 흐름으로 다루기
중복 감지는 예외가 아니라 정상 제어 흐름이어야 합니다.
- 로그 레벨을
debug또는 샘플링 - 메트릭으로
dedup_hit카운트 - 알람은 “dedup 급증” 같은 이상 징후에만
체크리스트: 사가가 중복에 깨지지 않게 만드는 최소 요건
- 이벤트에
messageId,correlationId,causationId를 넣는다 - 컨슈머는 Inbox 테이블로 멱등을 보장한다
- idempotencyKey는
correlationId또는causationId에stepName과version을 붙여 설계한다 - 로컬 상태 변경과 Inbox 기록은 같은 DB 트랜잭션으로 묶는다
- 발행 측은 Outbox로 중복 발행과 유실을 제어한다
- 돈/재고 같은 외부 사이드이펙트는 가능하면 외부 API의 멱등 키도 함께 사용한다
마무리
Kafka 기반 사가에서 “중복 소비는 언젠가 반드시 일어난다”는 가정이 현실적입니다. 따라서 사가를 안전하게 만들려면 컨슈머 레벨에서 멱등을 강제하는 Inbox + Idempotency 키가 중심이 되어야 합니다. 핵심은 키를 단순히 랜덤 ID로 두는 게 아니라, 업무적으로 한 번만 일어나야 하는 행위를 정확히 표현하도록 설계하는 것입니다.
중복을 완전히 없애려 하기보다, 중복이 발생해도 시스템 상태가 흔들리지 않도록 만드는 것이 사가 설계의 성패를 가릅니다.