Java 아키텍처·Spring 용어 사전
실무 사례중복 소비 · 멱등 처리

이중 쓰기

DB와 브로커처럼 서로 다른 두 시스템에 쓰면서 한쪽만 성공하는 문제. Outbox와 멱등 소비로 양쪽을 막는다.

서로 다른 두 시스템에 쓰는데 한쪽만 성공해 상태가 어긋나는 문제. 실무 사고의 단골이다.

발행 쪽 — 결제는 됐는데 알림이 안 갔다

@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);       // 같은 트랜잭션 — 실패하면 이력도 함께 롤백된다
}

이력 저장과 실제 처리를 같은 트랜잭션에 두는 것이 핵심이다. 나누면 이력만 남고 처리가 안 된 상태가 생긴다.

함께 보면 좋은 용어

노트에서 맥락과 함께 보기 — 실무 사고 사례 — Spring·JPA·MSA