서로 다른 두 시스템에 쓰는데 한쪽만 성공해 상태가 어긋나는 문제. 실무 사고의 단골이다.
발행 쪽 — 결제는 됐는데 알림이 안 갔다
@Transactional
void order() {
repo.save(order); // DB
kafkaTemplate.send("orders", event); // 브로커 — 롤백되지 않는다
}
-
브로커 발행 실패 — → 주문은 있는데 후속 처리가 안 간다
-
DB 롤백 — → 없는 주문의 이벤트가 나갔다
-
두 시스템을 한 트랜잭션으로 묶을 수 없다는 것이 근본 원인이다
해법은 Outbox 패턴 — 같은 DB의 outbox 테이블에 같은 트랜잭션으로 쓰고, 릴레이가 발행한다.
소비 쪽 — 같은 주문이 두 번 처리됐다
컨슈머가 처리를 마치고 오프셋을 커밋하기 직전에 죽는다
- 재시작 후 같은 메시지를 다시 처리한다
Kafka 기본 보장이 at-least-once 이므로 중복은 '정상' 이다
멱등 소비 세 가지 방법
-- ① 처리 이력 테이블 + 유니크 제약 (가장 확실하다)
CREATE TABLE processed_message (message_id VARCHAR(100) PRIMARY KEY);
-- 처리 시작 시 삽입을 시도한다. 중복이면 예외 → 건너뛴다
INSERT INTO processed_message (message_id) VALUES (:id);
-- ② UPSERT — 여러 번 실행돼도 결과가 같다
INSERT INTO point (user_id, amount) VALUES (:u, :a)
ON CONFLICT (user_id) DO UPDATE SET amount = point.amount + :a;
-- ⚠️ 누적 연산은 UPSERT 로도 멱등이 아니다 → ① 과 함께 써야 한다
-- ③ 조건부 갱신 — 상태 전이가 한 번만 일어나게
UPDATE orders SET status = 'PAID' WHERE id = :id AND status = 'PENDING';
-- 영향 행이 0이면 이미 처리된 것 → 조용히 넘어간다
②의 주의점이 중요하다 — 포인트 적립처럼 더하는 연산은 두 번 실행되면 두 배가 된다. 유니크 제약과 반드시 함께 쓴다.
처리 이력은 언제 지우나
processed_message 가 무한히 쌓인다
- 메시지 리텐션 기간(보통 7일) + 여유만큼만 보관하고 배치로 정리한다
- 그보다 오래된 중복은 애초에 재전송될 수 없다
정리
-
발행 쪽 유실 — → Outbox 패턴
-
소비 쪽 중복 — → 멱등 컨슈머
-
둘을 합친 "Outbox + 멱등 컨슈머" 가 분산 정합의 골드 스탠더드다
면접 함정
- ❌ "트랜잭션 안에서 발행하면 안전하다" → 브로커는 롤백되지 않는다. 오히려 최악이다.
- ❌ "UPSERT면 멱등하다" → 누적 연산은 아니다.
어떤 id로 중복을 판정하나
Kafka 의 (topic, partition, offset) 재처리 시 offset 이 같아 안전하다
-
단 재발행되면 offset 이 달라진다
-
비즈니스 이벤트 id (UUID) — 발행 측이 생성해 페이로드에 담는다
- 재발행돼도 같은 id → 가장 안전하다
발행 시점에 이벤트 id를 만들어 넣는 것이 정석이다. 브로커 메타데이터에 의존하면 재발행·브로커 교체에서 깨진다.
소비자 쪽 전체 형태
@KafkaListener(topics = "orders")
@Transactional
public void onOrder(OrderPlaced e) {
try {
processedRepo.save(new Processed(e.eventId())); // 유니크 제약
} catch (DataIntegrityViolationException dup) {
log.info("이미 처리됨: {}", e.eventId());
return; // 조용히 넘어간다
}
orderService.handle(e); // 같은 트랜잭션 — 실패하면 이력도 함께 롤백된다
}
이력 저장과 실제 처리를 같은 트랜잭션에 두는 것이 핵심이다. 나누면 이력만 남고 처리가 안 된 상태가 생긴다.