카테고리 없음

[Design Doc] Transactional Outbox Pattern — Kafka 직접 발행의 한계와 설계 (7주차 · 6팀 · 조수현)

Coding-Su 2026. 7. 3. 17:06
728x90

TL;DR

Kafka에 직접 발행하면 트랜잭션 커밋과 Kafka 발행 사이의 원자성을 보장할 수 없다. Outbox 테이블을 경유하는 방식으로 At Least Once 발행을 보장했고, partition-offset 기반 멱등성 키의 한계를 발견해 outboxId 기반으로 진화시켰다.


Introduction & Goals

Context / Background

좋아요, 조회, 주문 이벤트를 다른 시스템(commerce-streamer)으로 전파해야 했다. 처음엔 서비스 로직 안에서 직접 kafkaTemplate.send()를 호출하는 방식을 고려했는데, 트랜잭션 커밋 직후 Kafka 발행 직전에 애플리케이션이 죽으면 이벤트가 유실된다는 문제가 있었다.

// 문제: 커밋 후 send 전에 죽으면 이벤트 유실
@Transactional
public void like(Long memberId, Long productId) {
    likeRepository.save(new LikeModel(memberId, productId));
    // ← 여기서 죽으면?
    kafkaTemplate.send("catalog-events", event); // 발행 안 됨
}

Goals

  • 이벤트 유실 없이 At Least Once 발행 보장
  • Kafka 장애가 핵심 비즈니스 로직(좋아요 등록)에 영향을 주지 않아야 함
  • 중복 발행이 발생해도 Consumer가 멱등하게 처리

Detailed Design

System Architecture

[commerce-api]
핵심 로직 실행 (좋아요 저장)
  └─ 같은 트랜잭션에서 outbox 테이블에 PENDING으로 저장

OutboxRelayScheduler (1초마다)
  └─ PENDING 최대 100개 조회
  └─ payload에 outboxId 추가 후 Kafka 발행
  └─ 성공 → PUBLISHED, 실패 → PENDING 유지 (자동 재시도)

[commerce-streamer]
CatalogEventsConsumer / OrderEventsConsumer
  └─ outboxId로 event_handled 체크 (중복 skip)
  └─ eventType switch → metrics upsert
  └─ manual Ack

Data Models

outbox 테이블

컬럼 설명
id PK (outboxId로 활용)
topic 발행 대상 토픽
partition_key Kafka 파티션 키
event_type 이벤트 종류 식별자
payload JSON 직렬화된 이벤트
status PENDING / PUBLISHED
created_at / published_at 발행 시각 추적

 

event_handled 테이블

컬럼 설명
event_id (PK) outboxId — 중복 처리 방지
handled_at 처리 시각

Alternatives Considered

멱등성 키 전략

옵션 Pros Cons
partition-offset Kafka 메타데이터 기반, payload 파싱 불필요 Outbox 재발행 시 다른 offset으로 도착 → event_handled 미스 → 중복 처리
outboxId 같은 비즈니스 이벤트는 항상 동일한 ID → 재발행 횟수에 무관하게 차단 Producer/Consumer 간 payload 구조 의존성

선택 근거: Outbox 크래시 시나리오를 분석했더니 partition-offset 방식의 허점이 있었다.

 

처음엔 partition-offset을 멱등성 키로 썼다. Kafka 메시지는 파티션 번호와 offset이라는 고유 위치를 가지는데, 이걸 조합해 event_handled에 저장하면 같은 메시지가 두 번 와도 차단할 수 있다고 생각했다.

 

문제는 "같은 메시지가 다른 offset으로 올 수 있다"는 점이었다. 아래 시나리오에서 발생한다:

1. OutboxRelay → Kafka 발행 성공 → ack 수신
2. outbox 레코드를 PUBLISHED로 업데이트 시도
3. DB 커밋 전 장애 발생 💥
4. 재시작 → outbox 레코드가 여전히 PENDING
5. "아직 안 보낸 것"으로 인식 → 재발행
6. 새 offset으로 Consumer에 도착
7. event_handled에 이전 offset은 있지만 새 offset은 없음 → 중복 처리

 

Kafka가 이미 메시지를 받았는데, Outbox DB는 모르는 상황이 만들어진 것이다. enable.idempotence=true도 이 경우엔 막을 수 없다 — 재시작하면 새 Producer 인스턴스가 생기고, 새 시퀀스 번호를 써서 Kafka가 새 메시지로 인식한다.

 

outboxId를 사용하면 재발행 횟수나 offset에 관계없이 같은 비즈니스 이벤트는 항상 동일한 ID를 가지므로 event_handled에서 차단된다. outboxId는 닭-달걀 문제(payload 생성 시 ID가 없음)가 있어, RelayScheduler가 Kafka 발행 직전에 이미 저장된 outbox의 ID를 payload에 주입하는 방식으로 해결했다.


Cross-cutting Concerns

Observability

  • PUBLISHED 전환 실패 시 [OutboxRelay] 발행 실패: outboxId={} 로그로 추적 가능
  • PENDING 레코드가 누적되면 Kafka 장애 또는 직렬화 오류 신호

Known Limitations

  • DLQ 미구현: 기술적 실패(DB 오류 등)가 반복되면 같은 메시지가 무한 재시도되어 파티션 블로킹 가능
  • 발행 재시도 횟수 제한 없음: retry_count + Exponential Backoff 미적용 (Nice-to-Have)
728x90