..

Transactional Outbox in Practice: Operating in Production

TOC


  1. Overview
  2. 멀티 인스턴스와 중복 발행
  3. 운영 설정 예시
  4. 관측 지점
  5. DLT 정책
  6. Conclusion

Overview


앞선 글들에서 producer, poller, consumer의 기본 책임을 나눴다. 운영 환경에서는 여기에 멀티 인스턴스, 오래 쌓인 row, 컨슈머 실패 같은 현실 문제가 더해진다.

이번 글은 Outbox를 운영할 때 반드시 정해야 하는 몇 가지 기준을 짧게 정리한다.

멀티 인스턴스와 중복 발행


애플리케이션 인스턴스가 다중화되면 각 인스턴스의 폴러가 같은 PENDING row를 동시에 읽을 수 있다.

우리는 폴 쿼리에 SELECT ... FOR UPDATE SKIP LOCKED를 기본으로 둬 경합을 방지한다. 그래도 PROCESSING 도중 인스턴스가 죽는 시나리오는 남기 때문에, 발행 중복 자체를 0으로 만들기보다 멱등 컨슈머가 흡수하는 모델을 유지한다.

-- Outbox 폴러가 PENDING row를 클레임하는 쿼리 패턴
SELECT * FROM TB_TRB_OX_001
 WHERE STATUS = 'PENDING'
 ORDER BY CREATED_AT
 LIMIT 50
   FOR UPDATE SKIP LOCKED;
-- SKIP LOCKED: 다른 인스턴스가 이미 잠근 row는 자동으로 건너뜀

운영 설정 예시


message-lib이 제공하는 주요 설정값을 정리한다. 폴러 동작 튜닝과 SENT row 보관 정책이 핵심이다.

SENT row를 무한히 보관하면 폴링 쿼리와 테이블 관리 비용이 커진다. PENDING/PROCESSING/DEAD row는 정리 대상에서 제외하는 것이 원칙이다. 발행되지 않은 row는 아직 시스템이 처리해야 할 의도이고, DEAD는 운영자가 들여다봐야 할 신호이기 때문이다.

outbox:
  # 폴러 동작
  poll-interval-ms: 500         # 발행 latency 상한 (ms)
  batch-size: 50                # 한 폴 사이클에 처리할 최대 PENDING row 수
  max-retries: 3                # 재시도 초과 시 DEAD 전환

  # Row 보관
  cleanup-retention-days: 30    # SENT row 보관 기간 (이후 삭제)
  cleanup-cron: "0 0 3 * * *"   # 매일 새벽 3시 실행
  # PENDING / PROCESSING / DEAD 는 정리 대상에서 제외

관측 지점


Outbox가 정상 동작하는지는 코드만으로는 알 수 없다. 발행 파이프라인이 살아 있는지는 메트릭으로 확인해야 한다.

이 시리즈에서 Outbox 폴러·발행을 담당하는 공용 SDK인 message-lib은 폴링·재시도·DEAD 전환을 내부에서 처리하며, 4개 메트릭을 기본으로 노출한다.

메트릭 타입 의미
outbox.events.published Counter Kafka 발행 성공 횟수
outbox.events.failed Counter Kafka 발행 실패 횟수
outbox.events.dead Counter DEAD 상태 전환 횟수
outbox.queue.pending Gauge 현재 미발행 PENDING row 수

운영에서 먼저 봐야 하는 두 값은 outbox.queue.pendingoutbox.events.dead다. pending이 늘어나면 폴러가 처리 속도를 따라가지 못하거나 멈췄다는 신호다. dead가 발생하면 Consumer가 끝내 처리하지 못한 메시지가 있다는 뜻이며, 이는 다음 섹션의 DLT 정책과 직접 연결된다.

DLT 정책


재시도를 모두 소진한 메시지가 DLT에 도착했을 때의 정책도 미리 정해야 한다.

대부분의 경우 상세 상태를 FAIL로 마감하고 운영자 알림을 보내는 방식이 적합하다. 로깅만 하고 상태를 남겨두면 사용자 입장에서는 작업이 영원히 진행 중인 것처럼 보일 수 있다.

@DltHandler
public void onDlt(ConsumerRecord<String, UserRegistrationEventAvro> record) {
    UserRegistrationEventAvro event = record.value();
    String dtlId = event != null ? event.getUserRegDmndDtlId().toString() : "<unknown>";
    log.error("[UserRegistration] DLT received: dtlId={}", dtlId);

    // ① 상세 상태를 FAIL로 마감 — 사용자에게 "처리 실패"로 노출
    consumeUseCase.markAsDeadLetter(dtlId, "max retries exhausted");

    // ② 운영자 알림 (Slack / Email 등) — 알림 채널은 팀 인프라에 맞게 연결
}

Conclusion


Transactional Outbox는 exactly-once를 공짜로 주는 패턴이 아니다. 분산 트랜잭션의 부담을 발행 재시도, 멱등 소비, 운영 관측이라는 더 다루기 쉬운 책임으로 나누는 패턴이다.

이 시리즈의 결론은 단순하다. 저장과 발행 의도를 원자적으로 묶고, 발행은 at-least-once로 받아들이며, 컨슈머와 운영 도구가 중복과 누락을 흡수하게 만든다.