ADR-0016: Outbox Pattern for Cross-Domain Events¶
Umbra 의 도메인 간 이벤트 전달은 Outbox 패턴으로 구현한다. Publisher 는 도메인 상태 변경과 같은 트랜잭션 안에서
events.outbox에 이벤트를 기록하고, Worker 의 poller 고루틴이 구독자에게 전달한다.
Status¶
Accepted
- Decided at — 2026-04-13
- Decided by — Pablo
Context¶
Umbra 의 9개 Bounded Context (Recovery, Licensing, Billing, Identity, Guild, Member, Notification, Audit, Webhook)는 서로 상태 변경을 알려야 한다. 예:
- Billing 이 결제 성공 → Licensing 이 License 기간 연장
- Guild 에서 봇 강퇴 감지 → Licensing 이 suspend, Billing 이 suspend
- 여러 도메인의 상태 변경 → Audit 이 전부 기록
직접 호출(Billing → Licensing)은 강결합 을 만들고, 트랜잭션 경계가 모호해진다. 인메모리 이벤트 버스는 프로세스 재시작 시 손실 위험. Kafka/RabbitMQ 같은 외부 브로커는 MVP 인프라에 과함.
Decision¶
Outbox 패턴 을 채택한다.
- Outbox 테이블 —
events.outbox(schemaevents, tableoutbox, PK BIGSERIAL) - Publisher — 도메인 상태 변경 트랜잭션 안에서 이벤트를 INSERT
- Poller —
apps/worker/안 고루틴이 2초 주기로 미발행 이벤트 조회 → Subscriber 호출 - Delivery — At-least-once (중복 가능, handler 는 idempotent 해야 함)
- Ordering — MVP 는 단일 poller 로 BIGSERIAL 순서 유지
선택 근거:
- 트랜잭션 일관성 — 도메인 상태 변경과 이벤트 발행이 원자적 → "상태는 바뀌었는데 이벤트가 없다" 불가능
- At-least-once 전달 — 프로세스 장애 후에도 이벤트 손실 없음
- 단일 인프라 재사용 — PostgreSQL 만으로 구현, Redis 나 외부 브로커 추가 불필요
- 결제 SaaS 의 표준 패턴 — Stripe, Shopify 등 결제 시스템이 같은 패턴 사용
- 추적 가능 — 모든 이벤트가 DB 에 남아 audit / debugging 용이
Consequences¶
Positive¶
- 도메인 간 강결합 제거 (Publisher 는 Subscriber 를 모름)
- 트랜잭션 일관성 확보 (상태-이벤트 원자성)
- 이벤트 히스토리가 DB 에 영구 기록 (audit / replay 용)
- 외부 브로커 없이 구현
Negative¶
- Polling 지연 — 이벤트 발행에서 구독자 호출까지 최대 2초
- Poller 중단 시 이벤트 정체 — Worker 프로세스가 살아있어야 함
- 이벤트 테이블 비대화 — 주기적 archive 필요 (30일 후
audit.events로 이관) - Handler idempotent 요구 — 중복 실행 대비 필수
Neutral¶
- Poller 는 Worker 프로세스에 고정 (Bot/API 는 이벤트 발행만)
- Kafka 수준의 처리량이 필요해지면 재검토
Implementation sketch¶
Outbox 스키마¶
CREATE SCHEMA events;
CREATE TABLE events.outbox (
id BIGSERIAL PRIMARY KEY,
aggregate_type TEXT NOT NULL,
aggregate_id UUID NOT NULL,
event_type TEXT NOT NULL,
payload JSONB NOT NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
published_at TIMESTAMPTZ,
retry_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
next_retry_at TIMESTAMPTZ
);
CREATE INDEX outbox_unpublished ON events.outbox(id) WHERE published_at IS NULL;
Publisher¶
// In Billing domain
tx.ExecContext(ctx, `UPDATE billing.subscriptions ...`)
tx.ExecContext(ctx, `
INSERT INTO events.outbox (aggregate_type, aggregate_id, event_type, payload)
VALUES ('subscription', $1, 'PaymentSucceeded', $2)
`, subscriptionID, payloadJSON)
tx.Commit()
Poller¶
// In worker process
for {
events := fetchUnpublished(ctx, batchSize=50)
for _, event := range events {
err := dispatchToSubscribers(event)
if err != nil {
incrementRetry(event)
} else {
markPublished(event)
}
}
time.Sleep(2 * time.Second)
}
Subscriber handler¶
func (h *LicensingHandler) OnPaymentSucceeded(ctx context.Context, event Event) error {
// Idempotent: GREATEST 사용
_, err := tx.ExecContext(ctx, `
UPDATE licensing.licenses
SET expires_at = GREATEST(expires_at, $1)
WHERE id = $2
`, event.NewExpiresAt, event.LicenseID)
return err
}
Idempotency requirement¶
모든 handler 는 idempotent 해야 한다. 같은 이벤트가 두 번 처리되어도 결과가 동일해야 함.
패턴:
GREATEST(current, new)조건부 업데이트- Event ID 기반 dedup (
INSERT ... ON CONFLICT DO NOTHING) - Natural idempotency (이미 canceled 인 것을 다시 cancel → no-op)
상세는 architecture/event-flow.md 참조.
Alternatives considered¶
Alternative 1: 직접 함수 호출 (도메인 간 import)¶
Pros
- 즉시 실행, 지연 0
- 구현 단순
Cons
- 도메인 간 강결합 (Billing → Licensing import)
- 트랜잭션 경계 불명확 (Licensing 작업 실패가 Billing 롤백?)
- Hexagonal 원칙 위반
Why rejected — 결합도 문제가 크고 트랜잭션 안전성 보장 어려움.
Alternative 2: 인메모리 이벤트 버스 (Go channel)¶
Pros
- 매우 빠름
- 외부 의존성 0
Cons
- 프로세스 재시작 시 이벤트 손실 → 결제 SaaS 에 허용 불가
- 다른 프로세스로 이벤트 전파 불가
Why rejected — 손실 위험이 결제 SaaS 에 치명적.
Alternative 3: Kafka / RabbitMQ¶
Pros
- 업계 표준 메시지 브로커
- 높은 처리량, 복잡한 라우팅
Cons
- 추가 인프라 운영 부담
- MVP 규모에 과도한 복잡도
- Fly.io 에서 self-hosting 부담
Why rejected — MVP 에 과함. 규모 확대 시 Outbox 를 Kafka producer 로 교체 가능한 구조.
Alternative 4: PostgreSQL LISTEN/NOTIFY¶
Pros
- PostgreSQL 네이티브, 추가 인프라 0
- 즉시 알림
Cons
- 연결 끊김 시 알림 손실 (at-most-once)
- 메시지 크기 제한 (8KB)
- 구독자 재시작 시 놓친 이벤트 복구 불가
Why rejected — 손실 가능성. Outbox 와 함께 사용 가능(poller 깨우기 용도)하지만 주 메커니즘으로는 부적합.
Alternative 5: Temporal workflow 로 이벤트 라우팅¶
Pros
- Temporal 이 retry 와 순서 보장
Cons
- 모든 이벤트가 Temporal 을 경유 → Temporal 부하 집중
- 결정론 규칙 부담
- 단순 도메인 통신에 과한 복잡도
Why rejected — Temporal 은 장기 워크플로우에 국한(ADR-0015). 이벤트 전달에는 Outbox 가 적합.
Compliance¶
- 도메인 상태 변경 시 이벤트 발행은 같은 트랜잭션 에서 수행 (코드 리뷰 필수 확인)
- Subscriber handler 는 idempotent 하게 작성 (GREATEST, ON CONFLICT, natural idempotency)
- Poller 는 Worker 프로세스에 단 하나만 실행 (중복 실행 금지)
- 새 이벤트 타입은
architecture/event-flow.md카탈로그에 등록 - Payload schema 변경은 버전 필드 또는 새 이벤트 타입으로 처리 (기존 payload 는 불변)
Revisit triggers¶
- Polling 지연(2초)이 비즈니스 요구에 부족하면 LISTEN/NOTIFY 로 poller wake-up 보강
- 이벤트 처리량이 poller 단일 고루틴 한계 초과 시 샤딩(aggregate_type 별 poller) 검토
- 규모 확대로 Kafka 가 필요해지면 Outbox → Kafka connector 로 전환 (구조는 유지)