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 IDevent_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, 과거형 동사 형태 (
PaymentSucceededO,PaymentSuccessX) - 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 패턴 선택