콘텐츠로 이동

Audit

모든 도메인 이벤트를 구독하여 영구 감사 로그로 기록하는 Generic 도메인. 결제 분쟁, 보안 사고, 운영 문제 조사의 source of truth 이며 사용자 대시보드의 활동 내역도 여기서 공급된다.

Bounded context

  • TypeGeneric
  • Sibling contexts — 다른 모든 도메인 (모두 구독 대상)
  • Location in codebaseengine/audit/

Why this domain exists

결제 SaaS 는 감사 추적이 필수다. 다음 세 가지 요구에 대응해야 한다.

첫째, 규제 및 분쟁 — "언제 누가 이 결제를 시작했는가", "언제 이 License 가 downgrade 되었는가" 같은 질문에 정확히 답해야 한다.

둘째, 보안 사고 조사 — 길드 owner 가 "누가 내 서버를 수정했나" 물으면 답할 수 있어야 한다.

셋째, 사용자 투명성 — 대시보드 "활동 내역" 탭은 사용자에게 신뢰를 준다. 내 구독이 언제 무슨 일을 겪었는지 볼 수 있어야 한다.

Audit 이 모든 도메인 이벤트를 구독 하는 유일한 Generic 도메인인 이유는 이벤트가 이미 도메인 간 통신 매개체 (ADR-0016) 이기 때문이다. Outbox 에 기록된 이벤트를 그대로 받아 보관하면 전 시스템의 타임라인이 완성된다.

Domain model

AuditEvent

모든 이벤트의 영구 기록.

AuditEvent
├─ id                    UUID v7      PK
├─ outbox_event_id       BIGINT       UNIQUE → events.outbox.id (중복 방지)
├─ aggregate_type        TEXT         'subscription' | 'license' | 'snapshot' 등
├─ aggregate_id          UUID         entity ID
├─ event_type            TEXT         CamelCase (e.g. `PaymentSucceeded`)
├─ actor_user_id         UUID         → identity.users.id (행위자, nullable)
├─ actor_type            TEXT         'user' | 'system' | 'cron' | 'webhook'
├─ guild_id              UUID         → guild.guilds.id (nullable, context 용)
├─ payload               JSONB        원본 이벤트 payload
├─ summary               TEXT         사람이 읽을 수 있는 요약 문장
├─ occurred_at           TIMESTAMPTZ
└─ recorded_at           TIMESTAMPTZ

Payload 는 원본 이벤트 전체를 보존. summary 는 대시보드 표시용으로 가공 (예: "Pablo 가 Pro 구독을 시작했습니다").

Aggregates

  • AuditEvent — immutable aggregate, 단일 row 저장 후 수정 없음

Invariants

  • Immutable — 한 번 기록된 AuditEvent 는 수정/삭제 금지
  • Outbox event 와 1:1 중복 방지UNIQUE (outbox_event_id) 로 강제
  • 모든 도메인 이벤트는 Audit 구독 — 새 이벤트 타입 추가 시 Audit 에 자동 기록되도록
  • Payload 는 원본 보존 — 가공 금지 (요약은 별도 summary 컬럼)

Domain events

Audit 은 구독만 한다. 자기 이벤트 발행 없음 (AuditEvent 기록 자체가 이벤트 아님).

Consumed

모든 도메인의 모든 이벤트. Outbox poller 가 Audit handler 를 필수 구독자로 등록.

예시:

  • UserRegistered, UserDeleted (Identity)
  • BotInstalled, BotKicked, GuildDeleted, GuildOwnerChanged (Guild)
  • MemberJoinedViaWebJoin, MemberRestored (Member)
  • SubscriptionStarted, PaymentSucceeded, PaymentFailed, PaymentFailedFinal (Billing)
  • LicenseGranted, LicenseExtended, LicenseDowngraded 등 (Licensing)
  • SnapshotCreated, RestoreStarted, RestoreCompleted, AntiNukeTriggered (Recovery)
  • NotificationSent, NotificationFailed (Notification)

Ports

Inbound

// engine/audit/service.go
type Service interface {
    RecordEvent(ctx, event OutboxEvent) error  // Outbox handler

    // 조회 (대시보드)
    ListByGuild(ctx, guildID, opts) ([]*AuditEvent, error)
    ListByUser(ctx, userID, opts) ([]*AuditEvent, error)
    ListByAggregate(ctx, aggregateType, aggregateID, opts) ([]*AuditEvent, error)
    GetByID(ctx, id) (*AuditEvent, error)
}

type ListOptions struct {
    Before      *time.Time
    After       *time.Time
    EventTypes  []string
    Limit       int
    Cursor      string
}

Outbound

type AuditRepository interface {
    Insert(ctx, event AuditEvent) error
    GetByOutboxID(ctx, outboxID int64) (*AuditEvent, error)
    ListByGuild(ctx, guildID, opts) ([]*AuditEvent, error)
    ListByUser(ctx, userID, opts) ([]*AuditEvent, error)
    ListByAggregate(ctx, aggregateType, aggregateID, opts) ([]*AuditEvent, error)
}

type SummaryRenderer interface {
    Render(event AuditEvent) (string, error)  // template 기반 사람 친화 요약
}

Adapters

  • Persistenceengine/audit/repository.go — sqlc 기반
  • Summary rendererengine/audit/summary.go — event_type 별 템플릿

Event consumption

Audit 은 Outbox poller 가 dispatch 하는 모든 이벤트를 수신:

// engine/audit/consumer.go
func (c *Consumer) OnAnyEvent(ctx, event OutboxEvent) error {
    summary, _ := c.renderer.Render(event)

    audit := AuditEvent{
        ID:             uuid.NewV7(),
        OutboxEventID:  event.ID,
        AggregateType:  event.AggregateType,
        AggregateID:    event.AggregateID,
        EventType:      event.EventType,
        ActorUserID:    extractActor(event),
        ActorType:      inferActorType(event),
        GuildID:        extractGuildID(event),
        Payload:        event.Payload,
        Summary:        summary,
        OccurredAt:     event.OccurredAt,
        RecordedAt:     time.Now(),
    }

    // Idempotent: UNIQUE (outbox_event_id) 로 중복 INSERT 거부
    return c.repo.Insert(ctx, audit)
}

Idempotency 는 UNIQUE (outbox_event_id) 로 자동 처리. 같은 이벤트가 재전송되어도 두 번째는 ON CONFLICT DO NOTHING.

Summary rendering

summary 는 대시보드 표시용 사람 친화 문장. Event type 별 템플릿.

예시:

PaymentSucceeded:
  "Pro 구독 결제 성공 (9,900원)"

LicenseDowngraded:
  "라이선스가 Enterprise → Pro 로 변경되었습니다"

RestoreStarted:
  "Pablo 가 2026-04-10 의 스냅샷으로 복구를 시작했습니다"

AntiNukeTriggered:
  "이상 감지: 5분 내 역할 7개 삭제 (용의자: @admin2)"

ko/en 다국어는 Phase 2 에서 추가.

Permission model

  • 조회 권한 — Guild 의 MANAGE_GUILD 권한자 (대시보드)
  • User 본인의 User-scoped 이벤트 — 본인만
  • 관리자 전체 조회 — 내부 운영 도구 (별도 인증)
  • 삭제 불가 — 감사 로그는 삭제 불가. 운영자도 삭제 권한 없음.

Retention

  • 영구 보관 — 결제 분쟁은 수년 후 발생 가능
  • Phase 2 에서 cold storage 검토 — 1년 이상 된 이벤트는 archive 테이블 또는 S3 로 이관
  • User 탈퇴 시 — User 참조는 actor_user_id = NULL 로 마스킹, 이벤트는 유지 (GDPR 삭제 요청 대응)

Failure modes

  • Outbox event 중복 수신UNIQUE (outbox_event_id) 로 자연 거부
  • Summary rendering 실패summary = NULL 로 기록, 이벤트 자체는 보관 (Phase 2 에서 재렌더링)
  • 저장 실패 — Outbox 재시도로 자동 복구 (at-least-once 전달)
  • 대용량 payload — 단일 이벤트는 수 KB 수준, 문제 없음. 이상치 감지되면 alert.
  • Guild deleted 후 guild_id 참조 — guild_id 는 그대로 유지 (id 는 유효, SET NULL 안 함)

See also

  • data/audit-schema.md — DB 스키마
  • architecture/event-flow.md — 이벤트 카탈로그
  • adr/0016-outbox-pattern.md — Outbox
  • domain/notification.md — 같은 이벤트를 다른 목적으로 구독