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)