콘텐츠로 이동

Events (Outbox) Schema

events schema 는 Outbox 패턴의 테이블을 포함한다. 도메인 간 이벤트 전달의 전송 매체로서 모든 Core 도메인이 이벤트를 INSERT 하고 Worker 의 Poller 가 처리한다.

Schema purpose

  • PostgreSQL schemaevents
  • Corresponding domain — 단일 도메인이 아닌 공용 인프라 (platform/event/)
  • sqlc packagedb/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;

처리 후:

-- MarkPublished
UPDATE events.outbox
SET published_at = NOW()
WHERE id = $1;

실패 시:

-- 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 — 실무 가이드