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

178 lines
11 KiB
Markdown

---
title: Transactional Outbox를 Polling 계약으로 구현하기
source_type: blog
status: verified
confidence: high
tags: [blog, ca-tmpl, outbox, event-driven, transaction]
related_projects: [ca-tmpl]
last_reviewed: 2026-07-02
canonical_sources:
- wiki/projects/ca-tmpl/transactional-outbox-pattern
audience: backend-engineer
target_publish:
status_label: ready
---
# Transactional Outbox를 Polling 계약으로 구현하기
## Parent / 부모 (필수)
- 핵심 canonical: [[wiki/projects/ca-tmpl/transactional-outbox-pattern]]
- 관련 개념 문서: [[wiki/concepts/transactional-outbox-pattern]] — 일반 outbox 개념. 현재 이 글의 구현 사실 근거는 verified project canonical에 둔다.
## 타깃 독자 / 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는 `eventId``idempotencyKey`로 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 (있다면)
```java
// 출처: [[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);
}
```
```java
// 출처: [[wiki/projects/ca-tmpl/transactional-outbox-pattern]]
// 실제 파일: application-core/.../OutboxEventStatus.java, ca-tmpl @f6fbd4e196b4
public enum OutboxEventStatus {
PENDING,
IN_FLIGHT,
PUBLISHED,
FAILED,
DEAD
}
```
```java
// 출처: [[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
""";
```
```java
// 출처: [[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()));
}
```
```sql
-- 출처: [[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 의무)
- [[wiki/projects/ca-tmpl/transactional-outbox-pattern]] — 이 글의 1차 canonical. outbox 구현, SKIP LOCKED polling, Testcontainers/local verification, prod 미검증 경계를 따른다.
- [[wiki/concepts/transactional-outbox-pattern]] — 관련 개념 문서. 구현 사실 출처로 쓰지 않는다.
## 사실 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
- [x] 모든 사실 주장에 canonical 링크 있음
- [x] 사실 vs 의견 분리 명시됨
- [x] 금지 마케팅 표현 없음
- [x] 코드 예제 출처 명시
- [x] 타깃 독자 가정과 톤 일치
- [x] `/lint` 통과
- [ ] 게시 URL 기록 (게시 후):
## Related / 관련
- 후속 글 후보: [[wiki/blog/ca-tmpl-idempotency-key-design-2026-07-02]]