콘텐츠로 이동

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 (schema events, table outbox, PK BIGSERIAL)
  • Publisher — 도메인 상태 변경 트랜잭션 안에서 이벤트를 INSERT
  • Pollerapps/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 로 전환 (구조는 유지)

References

  • ADR-0004 — PostgreSQL (outbox 저장소)
  • ADR-0012 — 두 도메인 동기화 수단
  • ADR-0015 — Poller 는 두 도구 외 별도
  • ADR-0017 — Poller 는 Worker 프로세스에 배치