콘텐츠로 이동

Event Flow

Umbra 의 도메인 간 통신은 Outbox 패턴으로 이루어집니다. Publisher 는 트랜잭션 안에 이벤트를 기록하고, Poller 가 Subscriber 에 전달합니다. 이 문서는 Outbox 의 동작 원리, 전체 이벤트 카탈로그, 도메인 간 이벤트 흐름을 정리합니다.

Why Outbox

도메인 간 통신의 세 가지 옵션을 비교하며 Outbox 를 선택한 이유입니다.

직접 함수 호출 은 트랜잭션 일관성이 쉽지만 도메인 간 강결합을 만듭니다. Billing 이 Licensing 을 import 하는 구조는 Hexagonal 원칙과 충돌합니다.

인메모리 이벤트 버스 는 결합도를 낮추지만 프로세스 재시작 시 이벤트 손실이 발생합니다. 결제 SaaS 에서는 허용 불가합니다.

외부 메시지 브로커(Kafka, RabbitMQ) 는 강력하지만 MVP 인프라로는 과합니다.

Outbox 패턴 은 PostgreSQL 트랜잭션을 활용해 일관성과 at-least-once 전달을 동시에 보장합니다. 추가 인프라는 outbox 테이블 하나와 worker 고루틴 하나뿐입니다. 결제 SaaS 의 표준 패턴이기도 합니다.

How it works

Outbox 는 세 단계로 동작합니다.

sequenceDiagram
    participant Domain as Publishing Domain
    participant DB as PostgreSQL
    participant Outbox as events.outbox
    participant Poller as Outbox Poller
(worker goroutine) participant Sub as Subscribing Domain Note over Domain,Outbox: Step 1. Transactional write Domain->>DB: BEGIN Domain->>DB: UPDATE domain state Domain->>Outbox: INSERT event
(published_at = NULL) Domain->>DB: COMMIT Note over Poller: Step 2. Polling (2s interval) Poller->>Outbox: SELECT * WHERE
published_at IS NULL
FOR UPDATE SKIP LOCKED Outbox-->>Poller: Unpublished events Note over Poller,Sub: Step 3. Dispatch loop for each event Poller->>Sub: Invoke handler(event) alt Handler success Sub-->>Poller: OK Poller->>Outbox: UPDATE published_at = NOW() else Handler failure Sub-->>Poller: Error Poller->>Outbox: UPDATE retry_count++
next_retry_at end end

핵심은 Step 1 의 트랜잭션 입니다. 도메인 상태 변경과 이벤트 기록이 원자적으로 이루어지므로 "상태는 바뀌었는데 이벤트가 발행되지 않은" 불일치가 발생할 수 없습니다.

Delivery guarantees

At-least-once

Outbox 는 최소 한 번 전달 을 보장합니다. Poller 가 이벤트를 dispatch 한 후 handler 가 성공했는데 published_at 업데이트 전 crash 가 발생하면 다음 폴링에서 같은 이벤트가 다시 전달됩니다.

이 때문에 모든 subscriber handler 는 idempotent 해야 합니다. 같은 이벤트가 두 번 처리되어도 결과가 동일해야 합니다.

Idempotency patterns

Handler 가 idempotent 하도록 작성하는 패턴입니다.

Pattern 1. Conditional update

-- License 갱신을 idempotent 하게
UPDATE licensing.licenses
SET expires_at = GREATEST(expires_at, $new_expires_at)
WHERE id = $license_id;

같은 이벤트가 두 번 와도 expires_at 은 최댓값으로 유지됩니다.

Pattern 2. Event ID deduplication

-- audit 이 중복 기록을 막음
INSERT INTO audit.events (outbox_event_id, ...)
VALUES ($outbox_id, ...)
ON CONFLICT (outbox_event_id) DO NOTHING;

outbox_event_id 에 UNIQUE 제약을 두어 중복 INSERT 를 거부합니다.

Pattern 3. Natural idempotency

이미 idempotent 한 작업(예: "이 subscription 을 cancel" — 이미 cancel 이면 no-op)은 추가 처리 없이도 안전합니다.

Ordering

이벤트는 events.outbox.id (BIGSERIAL) 순서로 처리됩니다. 같은 aggregate 에 대한 이벤트는 삽입 순서대로 전달됩니다.

단, 다른 aggregate 의 이벤트는 poller 병렬 실행 시 순서가 바뀔 수 있습니다. MVP 는 poller 를 단일 worker 프로세스 안 단일 고루틴으로 운영하여 엄격한 순서를 유지합니다.

Event schema

모든 이벤트는 공통 envelope 구조를 가집니다.

{
  "id": 12345,
  "aggregate_type": "subscription",
  "aggregate_id": "01J0XX...",
  "event_type": "PaymentSucceeded",
  "payload": { ... },
  "occurred_at": "2026-04-13T00:15:00Z",
  "published_at": null,
  "retry_count": 0
}
  • id — BIGSERIAL PK, 순서 기준
  • aggregate_type — 이벤트 발생 주체 타입 (subscription, license, snapshot 등)
  • aggregate_id — 해당 entity ID
  • event_type — 이벤트 이름 (CamelCase)
  • payload — 이벤트별 데이터 (JSONB)
  • occurred_at — 이벤트 발생 시각
  • published_at — NULL 이면 미발행
  • retry_count — 실패 재시도 횟수

Event catalog

Umbra 에서 정의된 모든 도메인 이벤트입니다.

Guild events

길드 관리 컨텍스트에서 발생하는 이벤트.

Event Trigger Payload Subscribers
BotInstalled 봇이 길드에 설치됨 guild_id, installer_user_id Licensing (Free License 자동 생성), Audit
BotKicked 봇이 길드에서 강퇴됨 guild_id Licensing (suspend), Billing (suspend), Audit
GuildOwnerChanged 길드 owner 변경 guild_id, old_owner_id, new_owner_id Audit
GuildDeleted 길드 자체 삭제 guild_id Licensing (cancel), Billing (cancel), Audit

Billing events

결제 컨텍스트에서 발생하는 이벤트.

Event Trigger Payload Subscribers
BillingKeyIssued 빌링키 발급 성공 user_id, billing_key_id Audit
BillingKeyDeleted 빌링키 삭제 user_id, billing_key_id Licensing (영향 구독 suspend), Billing (Subscription suspend), Audit
SubscriptionStarted 구독 생성 및 첫 결제 성공 subscription_id, guild_id, plan_code Licensing (License 생성/active), Notification, Audit
PaymentSucceeded 정기 결제 성공 subscription_id, attempt_id, new_period_end Licensing (expires_at 연장), Audit
PaymentFailed 결제 실패 (재시도 예정) subscription_id, attempt_id, retry_count Notification (사용자 알림), Audit
PaymentFailedFinal 4차 실패 → 자동 해지 subscription_id Licensing (Free downgrade), Notification (알림), Audit
SubscriptionCanceled 사용자 해지 요청 subscription_id, cancel_at_period_end Notification (확인 알림), Audit
SubscriptionSuspended 봇 강퇴 등으로 suspend subscription_id, reason Licensing (License suspend), Audit
PlanUpgraded Plan 업그레이드 subscription_id, old_plan, new_plan Licensing (즉시 혜택 적용), Notification, Audit
PlanDowngraded Plan 다운그레이드 subscription_id, old_plan, new_plan, effective_at Notification, Audit

Licensing events

권한 컨텍스트에서 발생하는 이벤트.

Event Trigger Payload Subscribers
LicenseGranted License 신규 생성 license_id, guild_id, plan_code Audit
LicenseExtended License 기간 연장 license_id, new_expires_at Audit
LicenseDowngraded License Plan 하향 license_id, old_plan, new_plan Notification, Audit
LicenseSuspended License 일시 중지 license_id, reason Notification, Audit
LicenseCanceled License 완전 해지 license_id Audit

Recovery events

Recovery 컨텍스트에서 발생하는 이벤트.

Event Trigger Payload Subscribers
SnapshotCreated 스냅샷 생성 완료 snapshot_id, guild_id, trigger Audit
SnapshotDeleted 스냅샷 삭제 (rolling 정리) snapshot_id, guild_id Audit
RestoreStarted 복구 workflow 시작 restore_job_id, snapshot_id, guild_id Notification (시작 알림), Audit
RestoreCompleted 복구 완료 restore_job_id, result_summary Notification (완료 알림), Audit
RestoreFailed 복구 실패 restore_job_id, error_summary Notification (실패 알림), Audit
AntiNukeTriggered 이상 감지 발동 guild_id, detected_pattern, suspects Notification (긴급 알림), Audit
AntiNukeActioned 자동 대응 실행 guild_id, actions_taken Notification, Audit

Member events

멤버 컨텍스트에서 발생하는 이벤트.

Event Trigger Payload Subscribers
MemberJoinedViaWebJoin 웹 조인으로 멤버 가입 member_id, guild_id, slug Audit
MemberRestored Member 수동 복원 실행 member_id, guild_id, restored_roles Audit

Identity events

Identity 컨텍스트의 이벤트는 MVP 에서 Audit 만 구독합니다.

Event Trigger Payload Subscribers
UserRegistered 신규 User 생성 user_id, discord_user_id Audit
UserSessionCreated 새 세션 생성 user_id, session_id Audit (민감 정보 마스킹)

Flow examples

주요 시나리오의 이벤트 흐름 예시입니다.

Example 1. 첫 구독 시작

사용자가 Pro 구독을 시작하는 전체 흐름입니다.

sequenceDiagram
    participant User
    participant API
    participant Billing
    participant DB
    participant Outbox
    participant Poller
    participant Licensing
    participant Notification

    User->>API: Subscribe to Pro
    API->>Billing: StartSubscription()
    Billing->>DB: INSERT billing_keys
    Billing->>DB: INSERT subscriptions (pending)
    Billing->>DB: First Toss charge → success
    Billing->>DB: UPDATE subscription (active)
    Billing->>Outbox: INSERT SubscriptionStarted
    API-->>User: 200 OK

    Note over Poller: 2s later
    Poller->>Licensing: Handle SubscriptionStarted
    Licensing->>DB: INSERT license (active)
    Licensing->>Outbox: INSERT LicenseGranted

    Poller->>Notification: Handle SubscriptionStarted
    Notification->>User: Discord DM "Pro activated"

    Poller->>Licensing: Handle LicenseGranted
    Note over Licensing: Audit 에서만 처리

하나의 사용자 액션이 두 이벤트 체인 을 만듭니다. SubscriptionStarted 로 Licensing 이 반응해 LicenseGranted 를 발행하고, 이를 Audit 이 기록합니다.

Example 2. 결제 실패 → 자동 해지

3차 재시도까지 실패하여 4차 실패 시 자동 해지되는 흐름입니다.

sequenceDiagram
    participant Cron as asynq cron
    participant Billing
    participant DB
    participant Outbox
    participant Poller
    participant Licensing
    participant Notification

    Note over Cron,Billing: 1st failure (24h later)
    Cron->>Billing: Retry (retry_count=1)
    Billing->>Billing: Toss charge → failure
    Billing->>DB: UPDATE subscription (past_due)
    Billing->>Outbox: PaymentFailed
    Poller->>Notification: "Payment failed, retry in 24h"

    Note over Cron,Billing: 2nd, 3rd failures similar
    Note over Cron,Billing: 4th failure → final

    Cron->>Billing: Retry (retry_count=4)
    Billing->>Billing: Toss charge → failure
    Billing->>DB: UPDATE subscription (canceled)
    Billing->>Outbox: PaymentFailedFinal

    Poller->>Licensing: Handle PaymentFailedFinal
    Licensing->>DB: UPDATE license (downgrade to Free)
    Licensing->>Outbox: LicenseDowngraded

    Poller->>Notification: Handle PaymentFailedFinal
    Notification->>User: "Subscription canceled"

    Poller->>Notification: Handle LicenseDowngraded
    Notification->>User: "Downgraded to Free"

Example 3. 봇 강퇴 → 연쇄 suspend

봇이 길드에서 강퇴되면 Licensing 과 Billing 이 동시에 반응합니다.

sequenceDiagram
    participant Discord
    participant Bot
    participant Guild as Guild Service
    participant Outbox
    participant Poller
    participant Licensing
    participant Billing

    Discord->>Bot: GUILD_DELETE event
    Bot->>Guild: HandleBotKicked(guild_id)
    Guild->>Outbox: BotKicked

    Poller->>Licensing: Handle BotKicked
    Licensing->>Licensing: Suspend licenses for guild
    Licensing->>Outbox: LicenseSuspended

    Poller->>Billing: Handle BotKicked
    Billing->>Billing: Suspend active subscriptions
    Billing->>Outbox: SubscriptionSuspended

    Poller->>Licensing: Handle SubscriptionSuspended
    Note over Licensing: Already suspended, no-op (idempotent)

BotKicked 이벤트 하나에 Licensing 과 Billing 이 각각 반응합니다. 순환 같아 보이지만 각 handler 가 idempotent 하므로 안전합니다.

Outbox table schema

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;

CREATE INDEX outbox_retry
ON events.outbox(next_retry_at)
WHERE published_at IS NULL AND next_retry_at IS NOT NULL;

상세 스키마 정의는 data/events-schema.md 를 참조하세요.

Poller configuration

Poller 는 apps/worker/ 안 고루틴으로 동작합니다.

Setting Value Rationale
Polling interval 2 seconds 지연과 DB 부하의 균형
Batch size 50 events 한 번에 처리할 이벤트 수
Max concurrent handlers 1 순서 보장 (MVP)
Failed event retry Exponential backoff (1min → 5min → 15min → 1h) 일시 장애 복구
Dead letter threshold 10 retries 10회 실패 시 수동 개입 필요

Event retention

이벤트는 영원히 보관되지 않습니다.

  • Published events — 30일 후 audit.events 로 archive, events.outbox 에서 삭제
  • Failed events (retry_count >= 10) — Dead letter 상태, 수동 확인 후 삭제 또는 재처리
  • Archive schedule — 매일 새벽 asynq cron 으로 실행

archive 된 이벤트는 audit 용도로만 보관되며 재발행 불가합니다.

Monitoring

Poller 와 outbox 건강 상태는 다음 지표로 모니터링됩니다.

Metric Alert threshold
Unpublished events count > 1000 (정체 감지)
Max event age in outbox > 1 minute (lag)
Failed event count > 10 (handler 오류 의심)
Retry_count distribution 평균 > 1 (불안정 의심)

Grafana Cloud 에서 이 지표들을 대시보드로 시각화합니다.

Constraints

  • 모든 Subscriber handler 는 idempotent 해야 함
  • Event payload 는 불변 — 한 번 기록된 이벤트는 수정 금지
  • Event type 이름은 CamelCase PascalCase, 과거형 동사 형태 (PaymentSucceeded O, PaymentSuccess X)
  • Outbox INSERT 는 반드시 도메인 상태 변경과 같은 트랜잭션 안에서
  • Subscriber 가 새 이벤트를 발행하려면 반드시 Outbox 를 다시 거침 (직접 호출 금지)

See also

  • architecture/context-map.md — 도메인 컨텍스트 관계
  • architecture/process-communication.md — 프로세스 간 통신
  • data/events-schema.md — outbox 테이블 스키마 상세
  • guides/outbox-pattern.md — Publisher/Subscriber 작성 가이드
  • adr/0016-outbox-pattern.md — Outbox 패턴 선택