Audit¶
모든 도메인 이벤트를 구독하여 영구 감사 로그로 기록하는 Generic 도메인. 결제 분쟁, 보안 사고, 운영 문제 조사의 source of truth 이며 사용자 대시보드의 활동 내역도 여기서 공급된다.
Bounded context¶
- Type — Generic
- Sibling contexts — 다른 모든 도메인 (모두 구독 대상)
- Location in codebase —
engine/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¶
- Persistence —
engine/audit/repository.go— sqlc 기반 - Summary renderer —
engine/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— Outboxdomain/notification.md— 같은 이벤트를 다른 목적으로 구독