콘텐츠로 이동

Notification

Umbra 시스템에서 사용자에게 알림을 전달하는 도메인. 결제 이벤트, 복구 결과, AntiNuke 감지, 플랜 변경 등 여러 도메인이 발행한 이벤트를 구독하여 Discord DM 또는 감사 채널로 전달한다.

Bounded context

  • Type — Supporting
  • Sibling contexts — Billing, Licensing, Recovery 의 이벤트 구독자
  • Location in codebaseengine/notification/

Why this domain exists

여러 도메인이 각자 "알림 발송" 을 구현하면 다음 문제가 발생한다.

  • 동일한 알림 로직(rate limit, 실패 재시도, 템플릿)의 중복 구현
  • 사용자 알림 선호도 관리의 분산
  • Discord rate limit 을 도메인마다 개별 대응

Notification 도메인은 모든 사용자 알림의 단일 진입점 이 된다. 발신 채널(Discord DM, 길드 감사 채널, 대시보드 toast)을 추상화하고, 이벤트 → 메시지 렌더링을 한 곳에서 관리한다.

Domain model

NotificationRequest

발송 예정 또는 완료된 알림.

NotificationRequest
├─ id               UUID v7      PK
├─ recipient_type   TEXT         'user_dm' | 'guild_audit_channel' | 'dashboard_toast'
├─ recipient_id     TEXT         user_dm 은 Discord user ID, guild_audit 는 channel ID
├─ guild_id         UUID         → guild.guilds.id (관련 길드, nullable)
├─ template_key     TEXT         'payment.succeeded', 'restore.completed' 등
├─ payload          JSONB        템플릿 렌더링용 변수
├─ state            TEXT         'pending' | 'sent' | 'failed' | 'suppressed'
├─ attempt_count    INTEGER
├─ last_error       TEXT
├─ scheduled_at     TIMESTAMPTZ
├─ sent_at          TIMESTAMPTZ
└─ created_at       TIMESTAMPTZ

NotificationPreference

사용자별 알림 선호도 (User 에 귀속).

NotificationPreference
├─ user_id              UUID     PK, → identity.users.id
├─ dm_enabled           BOOLEAN  DM 전체 on/off
├─ payment_notifications BOOLEAN
├─ recovery_notifications BOOLEAN
├─ antinuke_notifications BOOLEAN
├─ marketing_notifications BOOLEAN
└─ updated_at           TIMESTAMPTZ

기본값은 모두 true. 사용자가 대시보드에서 카테고리별 off 가능. AntiNuke 긴급 알림은 사용자 선호도를 무시할 수 있음 (중요도 override).

Aggregates

  • NotificationRequest — 독립 aggregate
  • NotificationPreference — User 가 root aggregate, 여기서 수정

Invariants

  • recipient_type 과 recipient_id 일치user_dm 은 Discord user ID, guild_audit_channel 은 channel ID
  • template_key 존재 — 코드베이스에 정의된 템플릿 키만 허용 (CI 로 검증 가능)
  • max attempt_count — 3회 실패 시 failed 로 전환
  • suppressed 상태 — 사용자 preference 로 off 된 경우, 이력은 남기되 발송 안 함

State machine

stateDiagram-v2
    [*] --> Pending : Event received
    Pending --> Sent : Discord API success
    Pending --> Failed : Max retries exceeded
    Pending --> Suppressed : User preference off
    Sent --> [*]
    Failed --> [*]
    Suppressed --> [*]

Domain events

Published

Notification 은 기본적으로 이벤트를 구독하는 쪽 이라 새로 발행할 일은 적다. 단 Audit 을 위해 다음은 기록:

Event Trigger Payload Subscribers
NotificationSent DM 또는 채널 메시지 발송 성공 request_id, template_key, recipient_type Audit
NotificationFailed 3회 재시도 실패 request_id, last_error Audit

Consumed (주요 이벤트 → 알림 매핑)

Source Event Template Recipient Notes
SubscriptionStarted subscription.started User DM + Guild audit channel
PaymentSucceeded payment.succeeded User DM (payment_notifications 존중)
PaymentFailed payment.failed User DM (override 가능)
PaymentFailedFinal subscription.auto_canceled User DM + Guild audit
SubscriptionCanceled subscription.canceled User DM
PlanUpgraded plan.upgraded User DM + Guild audit
PlanDowngraded plan.downgraded User DM + Guild audit
LicenseSuspended license.suspended Guild owner DM
RestoreStarted restore.started Guild audit
RestoreCompleted restore.completed Guild owner DM + Guild audit
RestoreFailed restore.failed Guild owner DM (override)
AntiNukeTriggered antinuke.triggered Guild owner DM (override) Preference 무시, 긴급 알림
AntiNukeActioned antinuke.actioned Guild owner DM + Guild audit

Ports

Inbound

type Service interface {
    Enqueue(ctx, request NotificationRequest) error
    GetPreference(ctx, userID) (*NotificationPreference, error)
    UpdatePreference(ctx, userID, pref) error
}

Outbound

  • DiscordDMClient — 사용자 DM 발송
  • DiscordChannelClient — 감사 채널 메시지 발송
  • NotificationRepository — sqlc 기반
  • PreferenceRepository — sqlc 기반

Consumer

Notification 의 핵심 부분은 event consumer 등록 이다. Outbox poller 가 이벤트를 dispatch 하면 Notification 이 수신.

// engine/notification/consumer.go
func (c *Consumer) OnPaymentSucceeded(ctx, event) error {
    return c.service.Enqueue(ctx, NotificationRequest{
        RecipientType: "user_dm",
        RecipientID:   event.PayerDiscordID,
        TemplateKey:   "payment.succeeded",
        Payload:       event.ToPayload(),
    })
}

Adapters

  • Persistenceengine/notification/adapter/persistence/
  • Discord DMengine/notification/adapter/discord_dm/
  • Discord Channelengine/notification/adapter/discord_channel/

Templates

템플릿은 코드베이스에 내장 (engine/notification/templates/).

  • template_key 마다 ko / en 버전 (i18n)
  • Discord Components V2 포맷 활용 (Container, Section)
  • 렌더링은 Go text/template + payload JSONB

예시 template_key:

payment.succeeded
payment.failed
subscription.started
subscription.canceled
subscription.auto_canceled
plan.upgraded
plan.downgraded
license.suspended
restore.started
restore.completed
restore.failed
antinuke.triggered
antinuke.actioned

Permission model

  • Preference 조회/수정 — User 본인만
  • Notification 내역 조회 — User 본인 (대시보드)
  • Admin 조회 — 내부 운영 도구만 (별도 인증)

Retry and rate limit

Discord rate limit 고려:

  • User DM 은 사용자당 rate limit 존재 → 연속 알림은 큐 처리
  • Global rate limit 대응을 위해 asynq priority queue 사용
  • 3회 실패 시 failed 로 확정, 운영자 알림

Failure modes

  • Discord user 가 봇 DM 거부 — 실패 응답, 해당 사용자에 대해 "DM 불가" 캐시 (24h) → 이후 알림은 guild_audit_channel 로 fallback
  • 감사 채널 삭제됨 — 알림 실패, GuildConfig 재설정 요구 DM
  • Template 렌더링 오류 — 즉시 failed, 운영 알림
  • 대량 이벤트 폭주 — asynq queue 가 완충, 지연은 발생하나 손실 없음

See also

  • data/notification-schema.md — DB 스키마
  • architecture/event-flow.md — 이벤트 카탈로그 (Subscribers 컬럼)
  • domain/billing.md, domain/licensing.md, domain/recovery/overview.md — 구독 대상 이벤트 발행자
  • adr/0016-outbox-pattern.md — 이벤트 구독 메커니즘