- Published on
Kafka 중복·역순 메시지, DDD로 멱등 처리하기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버에서 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)은 동일한 주문 라인에 대해 중복 적용되면 안 된다.
이 불변식을 지키려면 멱등 처리를 “컨슈머가 중복을 걸러준다” 수준이 아니라,
- 도메인 모델이 이벤트 적용 가능 여부를 판단하고
- 영속 계층이 원자적으로 중복을 차단하며
- 오프셋 커밋은 도메인 커밋 이후에 수행
되도록 설계해야 합니다.
핵심 전략 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가 정확히 한 번을 보장해주길” 바라지 않아도, 시스템은 현실적인 장애와 재시도 속에서 일관된 비즈니스 결과를 만들어낼 수 있습니다.