콘텐츠로 이동

Webhook

외부 시스템(Toss 등)에서 들어오는 웹훅을 수신, 검증, 라우팅하는 Generic 도메인. HMAC 서명 검증과 idempotency 체크로 안전한 단일 진입점을 제공한다.

Bounded context

  • TypeGeneric
  • Sibling contexts — Billing (주 라우팅 대상)
  • Location in codebaseengine/webhook/

Why this domain exists

외부 시스템 웹훅은 다음 보안/신뢰성 과제를 가진다.

  • 위변조 — 누군가 Toss 를 가장하고 가짜 결제 성공 이벤트를 보낼 수 있음
  • 중복 전송 — Toss 가 재전송 정책으로 같은 이벤트 여러 번 보낼 수 있음
  • 순서 뒤바뀜 — 후속 이벤트가 선행 이벤트보다 먼저 도착할 수 있음
  • 부분 실패 — 처리 중 프로세스 크래시 시 재전송이 필요한지 불명

Webhook 도메인은 이 문제들을 한 곳에서 처리 하고, 검증된 이벤트만 실제 도메인(Billing 등)에 전달한다. 결제 도메인이 직접 웹훅 처리를 하면 보안 검증 로직이 흩어지고 실수 위험이 커진다.

Generic 으로 분류한 이유는 웹훅 수신 자체에 비즈니스 로직이 없기 때문. 검증/라우팅만 담당하는 infrastructure 성격.

Domain model

WebhookEvent

수신된 웹훅의 기록.

WebhookEvent
├─ id                    UUID v7      PK
├─ source                TEXT         'toss' | (future: 'discord', ...)
├─ external_event_id     TEXT         Toss eventId 등, source 와 함께 unique
├─ event_type            TEXT         'PAYMENT.DONE', 'BILLING_KEY.DELETED' 등
├─ signature             TEXT         수신한 HMAC 서명
├─ signature_valid       BOOLEAN
├─ payload               JSONB        원본 body
├─ received_at           TIMESTAMPTZ
├─ processed_at          TIMESTAMPTZ
├─ processing_result     TEXT         'success' | 'failed' | 'duplicate'
├─ processing_error      TEXT
└─ created_at            TIMESTAMPTZ

모든 수신 이벤트는 기록 (유효/무효 관계없이). 보안 조사와 재처리 대비.

Invariants

  • (source, external_event_id) UNIQUE — 같은 이벤트 중복 수신 방지
  • Signature_valid=false 인 경우 도메인에 전달 금지 — 공격 시도로 간주
  • 모든 수신 시도 기록 — 성공/실패/중복 구분되어 저장
  • Idempotency Redis — DB UNIQUE 전에 Redis idem:webhook:{source}:{external_event_id} SET NX (TTL 24h) 로 빠른 거부

Request lifecycle

sequenceDiagram
    participant Toss
    participant API as umbra-api
Webhook Handler participant Redis participant DB participant Billing Toss->>API: POST /webhooks/toss API->>API: 1. Verify HMAC signature alt Invalid signature API->>DB: INSERT webhook_events (signature_valid=false) API-->>Toss: 401 Unauthorized end API->>Redis: 2. SET NX idem:webhook:toss:{event_id} TTL 24h alt Already exists (duplicate) API->>DB: INSERT webhook_events (processing_result=duplicate) API-->>Toss: 200 OK (no-op) end API->>DB: 3. INSERT webhook_events (pending) API->>Billing: 4. Route to domain handler alt Processing success Billing-->>API: OK API->>DB: UPDATE processing_result='success', processed_at=NOW() API-->>Toss: 200 OK else Processing failure Billing-->>API: Error API->>DB: UPDATE processing_result='failed', processing_error=... API-->>Toss: 500 Server Error (Toss will retry) end

Domain events

Webhook 자체는 도메인 이벤트를 발행하지 않는다. 단 Audit 목적으로 다음은 기록:

Event Trigger Subscribers
WebhookReceived (internal metric) 웹훅 수신 운영 메트릭
WebhookRejected (internal) 서명 검증 실패 보안 alert

이들은 Outbox 가 아닌 metric pipeline 으로 수집.

Ports

Inbound

// engine/webhook/service.go
type Service interface {
    HandleTossWebhook(ctx, rawBody []byte, headers http.Header) error

    // 조회 (관리자 도구)
    ListRecent(ctx, source string, opts) ([]*WebhookEvent, error)
    GetByID(ctx, id) (*WebhookEvent, error)
    Reprocess(ctx, id) error  // 수동 재처리
}

Outbound

type WebhookRepository interface {
    Insert(ctx, event WebhookEvent) error
    Update(ctx, event WebhookEvent) error
    GetByExternalID(ctx, source, externalEventID) (*WebhookEvent, error)
    ListRecent(ctx, source, opts) ([]*WebhookEvent, error)
}

type IdempotencyStore interface {
    SetNX(ctx, key string, ttl time.Duration) (bool, error)  // true = new
    Exists(ctx, key) (bool, error)
}

type SignatureVerifier interface {
    VerifyToss(payload []byte, signature string) (bool, error)
}

type BillingRouter interface {
    HandleTossEvent(ctx, event TossWebhookEvent) error
}

Adapters

  • Persistenceengine/webhook/repository.go
  • Idempotencyengine/webhook/adapter/redis/ — Redis SET NX
  • Toss signatureengine/webhook/adapter/toss/ — HMAC-SHA256
  • Handlerapps/api/internal/handler/webhook_toss.go 가 Service 호출

Toss webhook events

Umbra 가 처리하는 Toss 이벤트 타입:

Event Type Description Handler
PAYMENT.DONE 결제 성공 Billing (보조 검증)
PAYMENT.CANCELED 결제 취소 (관리자 측) Billing
PAYMENT.FAILED 결제 실패 Billing (보조 알림)
BILLING_KEY.DELETED 빌링키 삭제 Billing

주의: 결제 성공/실패 판단의 primary source 는 Toss Charge 응답 (Billing 이 직접 호출). 웹훅은 누락 대비 보조 확인.

Signature verification

Toss 는 Toss-Signature 헤더로 HMAC-SHA256 서명 전달:

signature = HMAC-SHA256(secret, raw_body)
  • secret 는 Toss 대시보드에서 발급 받은 webhook secret, Fly.io secret 에 저장
  • raw_body 는 파싱 전 원본 바이트 (JSON 재직렬화 시 서명 깨짐)
  • 서명 불일치 → 401 + signature_valid=false 기록

Idempotency strategy

Two-layer idempotency:

  1. Redis SET NX (fast path)
  2. Key: idem:webhook:{source}:{external_event_id}
  3. TTL: 24h
  4. 이미 존재하면 즉시 200 반환

  5. DB UNIQUE (safety net)

  6. (source, external_event_id) UNIQUE 제약
  7. Redis 장애 시에도 중복 INSERT 거부

두 층 모두 통과한 이벤트만 도메인 라우팅.

Retry semantics

  • Toss 가 2xx 받을 때까지 재전송 (지수 백오프)
  • 처리 실패 시 500 반환 → Toss 재시도 유도
  • 3xx/4xx 반환은 Toss 가 재시도 안 함 → 오작동 risk. 서명 불일치만 401.

Permission model

  • 웹훅 엔드포인트 — 공개 (인증 없음, HMAC 서명으로 대체)
  • Reprocess — 내부 운영 도구 (별도 관리자 인증)
  • 조회 — 내부 운영 도구

Failure modes

  • Toss 서명 secret 교체 — 교체 타이밍에 receiving 이벤트 서명 불일치 → 운영자 수동 확인 필수. Phase 2 에서 secret rotation 지원 계획.
  • Redis 장애 — 1차 idempotency 우회, DB UNIQUE 가 보호. 중복 처리 위험 약간 상승하나 안전.
  • Billing 처리 중 crash — WebhookEvent 는 pending 상태로 유지. 500 응답으로 Toss 재전송 유도.
  • Toss payload schema 변경 — JSONB 는 원본 보관, 파싱 오류 시 failed + alert
  • Phase 2: Discord outgoing webhook — 현재 Discord 는 incoming 웹훅 사용 안 함 (Gateway 로 수신). 향후 추가 시 동일 패턴 재사용.

See also

  • data/webhook-schema.md — DB 스키마
  • domain/billing.md — 주 라우팅 대상
  • adr/0013-payment-toss-billing.md — Toss 통합
  • flows/payment-failure.md — 웹훅 활용 시나리오