Files
llm-wiki/wiki/blog/ca-tmpl-transactional-outbox-pattern-2026-07-02.md

11 KiB

title, source_type, status, confidence, tags, related_projects, last_reviewed, canonical_sources, audience, target_publish, status_label
title source_type status confidence tags related_projects last_reviewed canonical_sources audience target_publish status_label
Transactional Outbox를 Polling 계약으로 구현하기 blog verified high
blog
ca-tmpl
outbox
event-driven
transaction
ca-tmpl
2026-07-02
wiki/projects/ca-tmpl/transactional-outbox-pattern
backend-engineer ready

Transactional Outbox를 Polling 계약으로 구현하기

Parent / 부모 (필수)

타깃 독자 / Target reader

  • 독자 profile: DB transaction과 message publish 사이의 원자성 문제를 skeleton에서 다루려는 백엔드 엔지니어.
  • 이미 안다고 가정하는 것: transaction, message broker, retry.
  • 처음 듣는다고 가정하는 것: SKIP LOCKED polling과 per-aggregate FIFO gate를 계약으로 다루는 방식.

도입 / Hook

  • 문제 / 궁금증: DB commit과 broker publish를 한 번에 성공시키는 것은 생각보다 어렵다.
  • 이 글이 답하는 것: ca-tmpl이 transactional outbox를 어떤 구현과 검증 범위로 잡았는지.
  • 이 글이 답하지 않는 것: production broker throughput과 장애 복구 실측.

본문 outline / Body outline

  1. dual write 문제와 outbox의 목적.
  2. outbox table과 polling worker.
  3. SKIP LOCKED와 per-aggregate FIFO gate.
  4. idempotency/retry/DLQ와의 경계.
  5. local verification과 운영 검증 없음.

본문 / Body

DB 저장과 message publish를 한 use case에서 함께 처리하면 dual-write 문제가 생깁니다. 예를 들어 주문을 DB에 저장한 직후 broker로 이벤트를 보내야 한다고 해보겠습니다. DB commit은 성공했는데 publish 직전에 프로세스가 죽으면, DB에는 상태가 남지만 외부 시스템은 그 사실을 모릅니다. 반대로 publish는 성공했는데 DB transaction이 rollback되면, 외부 시스템은 존재하지 않는 변경을 본 셈이 됩니다.

Transactional outbox는 이 틈을 줄이는 패턴입니다. business table을 수정하는 같은 DB transaction 안에서 outbox table에도 이벤트 row를 저장합니다. 그리고 별도의 relay가 outbox row를 읽어 broker로 publish합니다. 여기서 중요한 점은 “DB와 broker를 한 transaction으로 묶는다”가 아닙니다. broker publish는 여전히 바깥 작업입니다. 대신 DB 안에 “나중에 반드시 publish해야 할 사실”을 남겨서, 프로세스 실패 후에도 다시 이어갈 수 있게 만듭니다.

ca-tmpl은 outbox를 문서상의 패턴으로만 두지 않고, application port와 persistence adapter, PostgreSQL migration, relay use case, scheduler/metrics까지 구현했습니다. OutboxAppendPort는 business operation이 여는 TransactionPort.inWrite(...) 안에서 호출되어야 합니다. 구현체가 자기 transaction을 새로 열지 않는다는 계약도 중요합니다. 같은 write transaction에 aggregate save와 event append가 함께 있어야 dual-write를 줄이는 의미가 생기기 때문입니다.

outbox row는 상태 머신을 가집니다. 처음에는 PENDING이고 relay가 claim하면 IN_FLIGHT가 됩니다. publish가 성공하면 PUBLISHED, 일시 실패하면 FAILED, retry를 모두 소진하면 DEAD가 됩니다. DEAD는 단순한 로그가 아니라 운영자가 봐야 하는 terminal failure입니다. ca-tmpl 문서와 코드 모두 이 상태를 manual intervention이 필요한 상태로 둡니다.

claim 단계는 PostgreSQL의 FOR UPDATE SKIP LOCKED를 사용합니다. 여러 relay가 동시에 row를 읽을 때, 이미 다른 transaction이 잠근 row를 기다리지 않고 건너뛰게 하는 방식입니다. 이것은 multi-instance relay에서 같은 row를 동시에 claim하는 경합을 줄입니다. 다만 SKIP LOCKED가 순서 보존까지 해결하지는 않습니다. 그래서 ca-tmpl query에는 같은 aggregate의 더 이른 미게시 row가 있으면 뒤 row를 claim하지 않는 NOT EXISTS gate가 같이 들어갑니다.

relay use case의 흐름도 의도적으로 짧은 transaction과 바깥 publish를 나눕니다. 먼저 짧은 write transaction에서 batch를 claim합니다. 그다음 publish는 transaction 밖에서 수행합니다. 성공한 row는 다시 짧은 write transaction으로 PUBLISHED 처리합니다. publish 실패는 잡아서 FAILED 또는 DEAD로 바꾸고 error log를 남깁니다. 반대로 publish 성공 후 markPublished가 실패하면 예외를 삼키지 않습니다. row가 IN_FLIGHT로 남고 timeout 이후 재claim될 수 있기 때문입니다.

이 구조는 exactly-once delivery를 약속하지 않습니다. outbox relay가 publish 성공 후 상태 갱신에 실패하면 같은 event가 다시 publish될 수 있습니다. 따라서 consumer는 eventIdidempotencyKey로 dedupe해야 합니다. ca-tmpl project canonical도 이 지점을 명확히 나눕니다. outbox는 at-least-once delivery를 제공하고, 최종 정합성은 idempotent consumer와 함께 닫힙니다.

Debezium CDC나 Kafka Connect Outbox SMT도 대안입니다. 하지만 ca-tmpl은 skeleton baseline에서 Kafka Connect cluster, connector, WAL slot 운영을 기본 요구로 두지 않았습니다. 수 초 수준 lag를 허용하는 전제에서는 DB table + polling이 더 작은 운영 단위입니다. 대신 lag SLO가 sub-second로 내려가거나 polling query가 DB load를 만들면 CDC 전환을 검토하는 migration trigger를 문서에 남겼습니다.

검증 범위는 local/dev입니다. ./gradlew check가 통과했고, application relay logic, RDBMS adapter, PostgreSQL Testcontainers 기반 row lifecycle과 SKIP LOCKED claim, publish adapter가 테스트되었습니다. 운영 배포, production lag 측정, DLQ 재처리 운영 경험은 없습니다. 따라서 이 글은 “outbox를 운영에서 검증했다”가 아니라 “ca-tmpl skeleton에 outbox polling 계약을 구현하고 로컬 검증했다”까지 말합니다.

코드 예제 / Code samples (있다면)

// 출처: [[wiki/projects/ca-tmpl/transactional-outbox-pattern]]
// 실제 파일: application-core/.../OutboxAppendPort.java, ca-tmpl @f6fbd4e196b4
public interface OutboxAppendPort {
  /**
   * Appends event to the outbox table, participating in the caller's existing
   * write transaction. Calling outside TransactionPort.inWrite(...) is a
   * contract violation.
   */
  void append(NewOutboxEvent event);
}
// 출처: [[wiki/projects/ca-tmpl/transactional-outbox-pattern]]
// 실제 파일: application-core/.../OutboxEventStatus.java, ca-tmpl @f6fbd4e196b4
public enum OutboxEventStatus {
  PENDING,
  IN_FLIGHT,
  PUBLISHED,
  FAILED,
  DEAD
}
// 출처: [[wiki/projects/ca-tmpl/transactional-outbox-pattern]]
// 실제 파일: adapter-persistence-postgresql/.../PostgreSqlOutboxClaimRepository.java
private static final String CLAIM_SQL =
    """
      SELECT * FROM outbox_event o
      WHERE o.next_attempt_at <= :now
        AND o.status IN ('PENDING', 'FAILED', 'IN_FLIGHT')
        AND NOT EXISTS (
          SELECT 1 FROM outbox_event p
          WHERE p.aggregate_id = o.aggregate_id
            AND p.occurred_at  < o.occurred_at
            AND p.status      <> 'PUBLISHED'
        )
      ORDER BY o.occurred_at ASC
      LIMIT :limit
      FOR UPDATE SKIP LOCKED
      """;
// 출처: [[wiki/projects/ca-tmpl/transactional-outbox-pattern]]
// 실제 파일: application-core/.../PublishPendingOutboxEventsUseCase.java
List<OutboxEvent> claimed =
    tx.inWrite(() -> store.claimBatch(batchSize, now, inFlightTimeout));

for (OutboxEvent event : sorted) {
  publishPort.publish(event);              // outside transaction
  tx.inWrite(() -> store.markPublished(event.eventId()));
}
-- 출처: [[wiki/projects/ca-tmpl/transactional-outbox-pattern]]
-- 실제 파일: adapter-persistence-postgresql/.../V3__outbox_event.sql
CREATE TABLE outbox_event (
    event_id         varchar(64)  NOT NULL,
    aggregate_id     varchar(256) NOT NULL,
    event_type       varchar(256) NOT NULL,
    payload          text         NOT NULL,
    occurred_at      timestamptz  NOT NULL,
    status           varchar(16)  NOT NULL,
    attempt_count    integer      NOT NULL DEFAULT 0,
    next_attempt_at  timestamptz  NOT NULL,
    correlation_id   varchar(64)  NOT NULL,
    idempotency_key  varchar(256) NOT NULL,
    CONSTRAINT pk_outbox_event PRIMARY KEY (event_id)
);

Sources / 근거 (canonical 인용 필수, derived layer 의무)

사실 vs 의견 / Fact vs opinion 구분

  • 사실: ca-tmpl에는 OutboxAppendPort, OutboxStorePort, OutboxEventStatus, PublishPendingOutboxEventsUseCase, PostgreSQL FOR UPDATE SKIP LOCKED claim repository, outbox migration, publish adapter가 존재한다. 근거: wiki/projects/ca-tmpl/transactional-outbox-pattern
  • 사실: ./gradlew check, application unit test, RDBMS adapter test, PostgreSQL Testcontainers 기반 row lifecycle/claim 검증이 로컬 범위에 포함된다. 근거: wiki/projects/ca-tmpl/transactional-outbox-pattern
  • 사실: 운영 배포, production lag 측정, DLQ 재처리 운영 경험은 없다. 근거: wiki/projects/ca-tmpl/transactional-outbox-pattern
  • 의견: ca-tmpl 같은 skeleton에서는 Debezium CDC보다 polling outbox가 더 작은 baseline일 수 있다.
  • 알지 못하는 것: production lag, throughput, broker 장애 상황의 DLQ 운영 결과.

답할 수 있는 범위 / Answer boundary

  • 자신 있게 답할 수 있는 후속 질문:
    • transactional outbox가 dual-write 문제를 어떻게 줄이는가?
    • FOR UPDATE SKIP LOCKED는 claim 경합에서 무엇을 해결하는가?
    • per-aggregate FIFO gate가 왜 별도로 필요한가?
    • 왜 outbox가 exactly-once가 아니라 at-least-once + consumer dedupe인가?
  • 다음 글로 넘길 부분:
    • broker-specific scaling.
    • Debezium CDC/Kafka Connect Outbox SMT 전환.
    • production lag/DLQ 운영 측정.

게시 체크리스트 / Publish checklist

  • 모든 사실 주장에 canonical 링크 있음
  • 사실 vs 의견 분리 명시됨
  • 금지 마케팅 표현 없음
  • 코드 예제 출처 명시
  • 타깃 독자 가정과 톤 일치
  • /lint 통과
  • 게시 URL 기록 (게시 후):