Events (Outbox) Schema¶
eventsschema 는 Outbox 패턴의 테이블을 포함한다. 도메인 간 이벤트 전달의 전송 매체로서 모든 Core 도메인이 이벤트를 INSERT 하고 Worker 의 Poller 가 처리한다.
Schema purpose¶
- PostgreSQL schema —
events - Corresponding domain — 단일 도메인이 아닌 공용 인프라 (
platform/event/) - sqlc package —
db/queries/events/
단일 테이블 events.outbox. 모든 도메인이 INSERT 하고 poller 가 UPDATE.
Tables¶
events.outbox¶
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,
CONSTRAINT outbox_retry_nonneg CHECK (retry_count >= 0)
);
CREATE INDEX outbox_unpublished_idx
ON events.outbox (id)
WHERE published_at IS NULL;
CREATE INDEX outbox_retry_due_idx
ON events.outbox (next_retry_at)
WHERE published_at IS NULL AND next_retry_at IS NOT NULL;
CREATE INDEX outbox_aggregate_idx
ON events.outbox (aggregate_type, aggregate_id, id);
CREATE INDEX outbox_event_type_occurred_idx
ON events.outbox (event_type, occurred_at);
Columns
| Column | Type | Constraints | Description |
|---|---|---|---|
id | BIGSERIAL | PK | BIGSERIAL — 순서 기반 처리 (ADR-0021 예외) |
aggregate_type | TEXT | NOT NULL | subscription / license / snapshot 등 |
aggregate_id | UUID | NOT NULL | 이벤트 발생 entity ID |
event_type | TEXT | NOT NULL | PaymentSucceeded 등 CamelCase |
payload | JSONB | NOT NULL | 이벤트 데이터 |
occurred_at | TIMESTAMPTZ | NOT NULL | 이벤트 발생 시각 |
published_at | TIMESTAMPTZ | — | Publisher 성공 시각, NULL = 미발행 |
retry_count | INTEGER | CHECK ≥ 0 | 처리 실패 재시도 횟수 |
last_error | TEXT | — | 마지막 실패 사유 |
next_retry_at | TIMESTAMPTZ | — | 재시도 예정 시각 |
Indexes
| Index | Columns | Purpose |
|---|---|---|
outbox_pkey | (id) | PK, 순서 보장 |
outbox_unpublished_idx | (id) WHERE published_at IS NULL | Poller scan |
outbox_retry_due_idx | (next_retry_at) WHERE 미발행 + 재시도 있음 | 재시도 cron |
outbox_aggregate_idx | (aggregate_type, aggregate_id, id) | 특정 entity 의 이벤트 이력 |
outbox_event_type_occurred_idx | (event_type, occurred_at) | 타입별 이벤트 통계 |
Invariants
- BIGSERIAL id 는 엄격 증가 → 처리 순서 보장 의 기반
published_at은 단방향 — 한 번 설정되면 NULL 로 되돌리지 않음retry_count는 단조 증가- Payload 는 JSON serializable (Go interface, func 금지)
Why BIGSERIAL (not UUID v7)¶
다른 테이블은 모두 UUID v7 PK (ADR-0021) 지만 outbox.id 는 BIGSERIAL 예외.
- 순서 기반 처리 — Poller 가
ORDER BY id ASC로 조회하여 이벤트 순서 보장 - 페이지네이션 효율 —
WHERE id > ?가 단순 - UUID 정렬 은 v7 라도 microseconds 내 동시 생성 시 순서가 불명확
- 외부 참조가 없으므로 순차 ID 노출 위험 없음
Event envelope¶
payload JSONB 의 표준 구조:
{
"version": 1,
"data": {
/* 이벤트별 필드 */
},
"metadata": {
"causation_id": "uuid-of-triggering-event",
"correlation_id": "workflow-or-request-id"
}
}
version— payload schema 버전, 진화 대응data— 이벤트 실제 데이터metadata— 선택적, 추적 용
Publisher / Poller flow¶
Publisher (도메인 서비스)¶
-- 도메인 상태 변경과 같은 트랜잭션
BEGIN;
UPDATE billing.subscriptions SET ... WHERE id = $1;
INSERT INTO events.outbox (aggregate_type, aggregate_id, event_type, payload)
VALUES ('subscription', $1, 'PaymentSucceeded', $2);
COMMIT;
Poller (Worker 고루틴)¶
-- FetchUnpublishedBatch
SELECT *
FROM events.outbox
WHERE published_at IS NULL
AND (next_retry_at IS NULL OR next_retry_at <= NOW())
ORDER BY id ASC
LIMIT 50;
처리 후:
실패 시:
-- MarkRetry (exponential backoff)
UPDATE events.outbox
SET retry_count = retry_count + 1,
last_error = $1,
next_retry_at = NOW() + $2 -- exponential: 1min, 5min, 15min, 1h ...
WHERE id = $3;
Retention & archive¶
Outbox 는 영구 보관 대상이 아니다. 처리된 이벤트는 Audit 에 이관된 후 삭제.
- 처리 완료 (published_at NOT NULL) + 30일 경과 → DELETE
- 매일 새벽 asynq cron 실행
- Audit 은 이 시점 전에 이벤트를 본인 테이블에 복제 완료했으므로 outbox 삭제는 안전
- Dead letter (retry_count >= 10) → 삭제하지 않음, 운영자 수동 처리
Archive cron¶
-- ArchiveOldOutboxEvents
DELETE FROM events.outbox
WHERE published_at IS NOT NULL
AND published_at < NOW() - INTERVAL '30 days';
Poller optimization¶
FOR UPDATE SKIP LOCKED¶
단일 poller 로 시작하지만 향후 수평 확장 시 lock contention 을 피하기 위한 패턴:
SELECT * FROM events.outbox
WHERE published_at IS NULL
ORDER BY id ASC
LIMIT 50
FOR UPDATE SKIP LOCKED;
MVP 는 단일 poller 로 운영하므로 이 구문 불필요. 수평 확장 시 추가.
Index-only scan¶
outbox_unpublished_idx 의 PARTIAL INDEX 덕분에 발행 완료된 이벤트 row 는 인덱스에 없음 → 스캔 대상 작음.
Cross-schema references¶
없음. Outbox 는 하위 레이어 인프라라 상위 도메인에 FK 걸지 않음. aggregate_id 는 soft reference (도메인 테이블의 entity ID).
Data retention¶
- Published events — 30일 후 삭제 (Audit 가 영구 보관)
- Unpublished events — 보관 지속, 운영자 개입 대기
- Dead letter (retry_count >= 10) — 삭제 금지, 운영자 검토 후 수동 삭제
Size estimation¶
- 이벤트 평균 1~2KB
- 일일 수천 이벤트 가정 (MVP)
- 30일 롤링 보관 → 수백 MB
- Neon 성능 영향 없음
Migration history¶
| Date | Change | Rationale |
|---|---|---|
| 2026-04-xx | 초기 스키마 | MVP |
See also¶
architecture/event-flow.md— 이벤트 카탈로그, publisher/subscriber 매핑data/audit-schema.md— 이벤트 영구 보관adr/0016-outbox-pattern.md— Outbox 채택adr/0021-pk-uuid-v7.md— BIGSERIAL 예외 근거guides/outbox-pattern.md— 실무 가이드