Webhook¶
외부 시스템(Toss 등)에서 들어오는 웹훅을 수신, 검증, 라우팅하는 Generic 도메인. HMAC 서명 검증과 idempotency 체크로 안전한 단일 진입점을 제공한다.
Bounded context¶
- Type — Generic
- Sibling contexts — Billing (주 라우팅 대상)
- Location in codebase —
engine/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¶
- Persistence —
engine/webhook/repository.go - Idempotency —
engine/webhook/adapter/redis/— Redis SET NX - Toss signature —
engine/webhook/adapter/toss/— HMAC-SHA256 - Handler —
apps/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 서명 전달:
secret는 Toss 대시보드에서 발급 받은 webhook secret, Fly.io secret 에 저장raw_body는 파싱 전 원본 바이트 (JSON 재직렬화 시 서명 깨짐)- 서명 불일치 → 401 +
signature_valid=false기록
Idempotency strategy¶
Two-layer idempotency:
- Redis SET NX (fast path)
- Key:
idem:webhook:{source}:{external_event_id} - TTL: 24h
-
이미 존재하면 즉시 200 반환
-
DB UNIQUE (safety net)
(source, external_event_id)UNIQUE 제약- 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— 웹훅 활용 시나리오