1P 직매입에서 3PL 확대로 다양한 규격의 입고 예정 정보를 외부 파트너로부터 받게 되면서, DB 쓰기와 카프카 발행의 원자성 부재로 생기던 데이터 불일치를 Outbox 패턴 + Spring Kafka RetryableTopic으로 해결한 기록이다.
핵심 포인트- 기존엔 인터페이스 DB 저장과 카프카 발행이 분리돼 발행 후 커밋 실패 시 불일치가 났고, 컨슈머는 파트너 미등록 등으로 처리 실패 후 수동 개입이 필요했다.
- Outbox 패턴으로 DB 쓰기와 메시지 발행을 한 트랜잭션에 묶어 원자성을 보장하고, namastack-outbox 0.3.0으로 테이블·폴링·재시도·인스턴스 관리를 위임했다.
- RetryableTopic으로 컨슈머 실패 시 재시도 토픽으로 이동, 입고 정보가 1~2일 전 생성되는 특성을 활용해 10분 간격 144회(총 24시간) 자동 재시도한다.
- 기본값 MULTIPLE_TOPICS는 재시도 토픽 144개를 만들어, SINGLE_TOPIC 전략으로 토픽 1개로 통합해 모니터링을 단순화했다.
- 알림은 메인 실패만 슬랙, 재시도 실패는 무알림, 재시도 성공·DLT 도착만 알림으로 노이즈를 줄였다.
상세 정리- 레거시 실패: 파트너가 API를 호출하거나 컬리가 주기 조회 → 인터페이스 DB 적재 → 카프카 발행 → 리스너 변환의 흐름에서 발행과 커밋의 원자성이 없었다.
- Outbox 라이브러리: namastack-outbox-starter-jpa 0.3.0(2025-10 릴리즈, Spring Boot 3), outbox_record 테이블(id·status·aggregate_id·event_type·payload·retry_count·next_retry_at 등) + 인덱스 6개.
- 설정: poll-interval 2000ms, batch-size 10, exponential 재시도(initial 2000·max 60000·multiplier 2.0, max-retries 3), executor 4~8, heartbeat 5s.
- Producer 변경: 카프카를 직접 발행하는 대신 OutboxRecord(eventType INBOUND_EXPECTATION_SAVE, payload는 메시지 JSON 직렬화)를 저장만 한다.
- Poller: 별도 배치 인스턴스(@EnableBatchProcessing·@EnableScheduling·@EnableOutbox)가 OutboxRecordProcessor로 eventType을 분기해 카프카로 발행한다.
- RetryableTopic 구현: @RetryableTopic(attempts=145 = 메인 1 + 재시도 144, @Backoff delay=600000ms=10분), @DltHandler가 최종 실패 시 슬랙 알림을 보낸다.
- 이중 직렬화 문제: 메인 토픽에서 JsonSerializer로 직렬화된 메시지를 재시도 토픽에서 다시 직렬화하면 손상돼, retryKafkaTemplate은 StringSerializer로 직렬화를 스킵하도록 KafkaTemplate 2개로 분리했다.
- 토픽 판별: TopicNameSet.isMainTopic/isRetryTopic으로 현재 토픽을 구분해 알림 정책을 적용한다.
- 재시도 전략: 총 145회·10분 간격·최대 24시간으로, 입고 정보 생성까지의 1~2일 여유 동안 파트너·상품 동기화 완료를 기다린다.
- 트레이드오프: 아웃박스 테이블 운영과 폴링 간격만큼의 발행 지연을 감수하는 대신, 검증된 라이브러리로 발행부/수신부 책임을 분리하고 자가 치유를 얻는다.
- 결과: 원자성으로 메시지 유실이 불가능해지고, 24시간 자동 재시도로 수동 대응은 DLT만 남으며, 모니터링 토픽은 메인+재시도 2개로 줄었다.
왜 읽나Spring/Kafka로 외부 연동 메시지의 유실·순서 문제를 겪는 백엔드 개발자에게 Outbox + RetryableTopic 조합의 설정값·전략 레퍼런스.