결제 webhook 모니터링·replay 가드레일 체크리스트 2026: signature·event ordering·idempotency·dead-letter queue
결제 webhook 모니터링·replay 가드레일 체크리스트 2026: signature·event ordering·idempotency·dead-letter queue
결제 webhook은 공급사가 결제·환불·구독·분쟁 상태의 변화를 전달하는 중요한 입력입니다. 하지만 endpoint가 HTTP 성공을 반환했다는 사실만으로 내부 주문, entitlement, invoice가 안전하게 갱신됐다고 볼 수는 없습니다. 요청은 도착했지만 서명이 검증되지 않았을 수 있고, 같은 event가 다시 올 수 있으며, 서로 다른 event가 순서를 바꿔 도착할 수도 있습니다. 처리 중 downstream 장애가 생긴 이벤트는 일반 retry로 해결되지 않고 별도 격리가 필요합니다.
이 글은 webhook ingress 관측성 → 원문·서명 검증 → event ID 중복 제거 → event ordering과 aggregate version 검사 → idempotent consumer → dead-letter queue(DLQ) → 검증된 replay → evidence ledger의 운영 통제를 다룹니다. provider 전체 장애를 지휘하고 복구하는 절차는 B2B SaaS 결제 공급사 장애 대응 체크리스트의 범위입니다. 단건 checkout의 authorization failure와 retry 판단은 결제 승인 실패·재시도 가드레일 체크리스트에서 다루며, 평상시 gateway 성능 비교는 결제 라우팅 A/B 테스트 체크리스트로 분리합니다. multi-gateway 전환 준비는 결제 공급사 failover·multi-gateway 준비 체크리스트를 참고합니다.
기능과 공식 문서는 2026년 8월 2일 확인 기준입니다. 실제 event 형식, 재전송 정책, 서명 방식, 보존 기간과 API 동작은 공급사·계정·계약에 따라 달라질 수 있습니다. 이 글은 일반적인 결제 운영 체크리스트이며 법률·세무·회계·PCI 준수 판단이나 특정 장애·중복 결제 방지를 보장하지 않습니다. 광고·파트너스 코드는 포함하지 않으며, 발행 시 필요한 삽입은 발행 스크립트의 책임으로 분리합니다.
먼저 범위를 분리합니다
webhook을 다룬다는 이유로 모든 결제 운영 문제를 한 endpoint와 한 consumer에서 해결하려 하면 owner와 상태 기준이 섞입니다.
| 기존 글 | 중심 질문 | 이 글에서 다루지 않는 범위 |
|---|---|---|
| 결제 공급사 장애 대응 체크리스트 | 공급사 장애 중 backlog와 복구를 어떻게 지휘할 것인가 | 사고 전체의 지휘·복구 계획 |
| 결제 승인 실패·재시도 가드레일 | 한 checkout 시도의 상태와 다음 안전한 행동은 무엇인가 | webhook inbox·DLQ 운영 |
| 결제 라우팅 A/B 테스트 | 두 경로의 승인·비용·지연 결과를 어떻게 비교할 것인가 | webhook replay 성능 비교 |
| 결제 공급사 failover·multi-gateway 준비 | 대체 gateway로 전환할 조건을 갖췄는가 | webhook event 처리 파이프라인 자체 |
| 이 글 | 결제 event를 받고, 검증하고, 한 번만 적용하고, 근거를 남기며 replay할 수 있는가 | 임의 공격 재현·우회, 임의 retry 수치, compliance 보장 |
구독 invoice와 금액 대사 후속은 구독 청구 월말 대사 체크리스트와 연결할 수 있습니다. 대사는 webhook 처리 완료와 동일한 상태가 아니므로, processed와 reconciled를 분리해 기록해야 합니다.
먼저 결론: HTTP 2xx보다 처리 증거를 봅니다
안전한 webhook 흐름은 다음과 같이 분리합니다.
[HTTPS ingress 수신]
↓
[원문 body·headers·수신 시각 보존]
↓
[provider·endpoint·timestamp·signature 검증]
├── 실패 → [거부/격리 + evidence ledger]
↓ 성공
[event ID inbox 중복 검사 + durable queue 저장]
├── 중복 → [재실행 없이 관찰 결과 기록]
↓ 신규
[aggregate ID·event type·version 검사]
├── 늦거나 순서 불명확 → [보류/현재 객체 조회]
↓ 적용 가능
[idempotent consumer가 내부 action 실행]
├── 일시 실패 → [제한된 내부 재처리]
├── 영구 실패 → [DLQ + owner 지정]
↓
[현재 상태·action 결과·replay 근거를 ledger에 기록]
핵심은 수신, 저장, 업무 적용을 한 단계로 뭉치지 않는 것입니다. 수신 endpoint는 검증 가능한 원문과 event 식별자를 남기고 durable queue에 안전하게 넣은 뒤 빠르게 응답할 수 있습니다. 단, queue 저장이 확인되지 않았는데 성공 응답을 보내면 공급사의 재전송과 내부 유실을 동시에 만들 수 있으므로 저장 결과를 기준으로 응답해야 합니다.
1. ingress 모니터링은 “요청 수”보다 구간별 손실을 보여줘야 합니다
Stripe Webhooks 공식 문서는 endpoint가 event를 수신하고 처리하는 기본 구조와 운영 시 확인할 항목을 설명합니다. 공급사 대시보드의 전송 기록과 내부 로그는 서로 다른 관측 지점이므로 하나의 숫자로 합치지 않습니다. Paddle은 Webhooks overview, Chargebee는 Webhook 설정 문서, Recurly는 Webhooks 문서를 현재 계정 설정과 함께 확인합니다.
최소한 아래 구간을 별도의 metric과 trace로 구분합니다.
| 구간 | 기록할 사실 | 운영 질문 |
|---|---|---|
| ingress received | provider 추정, endpoint, 수신 시각, request correlation | 공급사 전송 대비 우리 endpoint에 도착했는가 |
| raw persisted | body hash, header 요약, 보존 위치, 저장 결과 | 원문을 검증·재조사할 수 있는가 |
| signature verified | 검증 결과, 사용한 endpoint secret 버전, 검증 시각 | 인증되지 않은 입력이 다음 단계로 갔는가 |
| inbox accepted | event ID, provider, queue record ID | durable queue에 유실 없이 들어갔는가 |
| consumer started | consumer version, attempt, worker ID | 어느 코드가 처리했는가 |
| action applied | 내부 action ID, aggregate version, 결과 | 권한·주문·invoice가 실제로 바뀌었는가 |
| reconciled | 대사 근거와 담당 owner | 처리 완료와 금액 확인이 일치하는가 |
| DLQ or replay | 사유, 승인자, replay run ID | 실패를 누가 어떤 근거로 다시 실행했는가 |
대시보드에는 received_count, persist_failed_count, signature_failed_count, inbox_duplicate_count, queue_lag, consumer_error_count, ordering_hold_count, dlq_open_count, replay_open_count, evidence_missing_count를 분리해 표시합니다. 평균 처리 시간 하나로 끝내지 말고, event type·provider·endpoint·consumer version별로 지연과 오류를 나눠야 특정 종류의 event만 막히는 현상을 볼 수 있습니다.
로그에는 payload 전체를 무조건 남기지 않습니다. 카드번호, 인증 데이터, secret과 같은 민감정보가 들어갈 수 있으므로 운영 로그에는 안전한 식별자·body hash·필드 존재 여부·민감정보 제거 결과를 남기고, 원문 저장소의 접근 권한과 보존 정책은 별도로 통제합니다.
2. signature 검증은 JSON 재직렬화 전에 수행합니다
서명 검증은 파싱된 JSON을 다시 문자열로 만든 뒤 비교하는 방식으로 단순화하지 않습니다. 공백, 줄바꿈, 키 순서와 인코딩이 달라지면 원문 바이트가 달라질 수 있기 때문입니다. provider의 공식 SDK와 문서가 요구하는 raw request body, header, timestamp 처리 순서를 그대로 적용합니다.
Stripe webhook signature 공식 문서는 endpoint secret과 원문 request body를 사용한 검증과 흔한 실패 원인을 설명합니다. 이 문서를 다른 provider에 그대로 복사하지 말고, provider별 검증 어댑터를 두어 알고리즘·헤더·timestamp·secret rotation 동작을 명시합니다.
| 검증 단계 | 통과 기준 | 실패 시 동작 |
|---|---|---|
| endpoint 식별 | 예상 provider와 endpoint에 매핑됨 | event를 consumer로 보내지 않고 격리 |
| raw body 확보 | 파싱·정규화 전 원문을 확보함 | 재검증 불가 상태로 보류 |
| secret 선택 | endpoint와 active secret version이 일치함 | secret을 추측하거나 우회하지 않음 |
| signature 계산 | 공식 SDK·알고리즘으로 검증됨 | signature_invalid 기록 |
| timestamp 확인 | provider가 요구하는 시간 조건과 clock 상태 확인 | 서버 시계·timestamp 오류를 별도 분류 |
| rotation 확인 | 이전·현재 secret의 허용 기간과 owner가 문서화됨 | 무기한 이전 secret 허용 금지 |
서명 실패를 일반 consumer 오류로 재시도하면 공격성 입력이나 설정 오류가 queue를 오염시킬 수 있습니다. signature_invalid는 일반 업무 retry 대상이 아니라 endpoint 설정, secret rotation, clock, body 변형 여부를 확인하는 보안·운영 사건으로 분류합니다. 검증에 사용한 secret 값 자체를 ledger나 로그에 기록하지 않고, 식별 가능한 secret version과 결과만 남깁니다.
3. event ID 중복 제거는 inbox의 원자적 제약으로 만듭니다
공급사가 같은 event를 다시 보내거나, 내부 queue worker가 timeout 뒤 같은 record를 재처리할 수 있습니다. 따라서 “이미 처리했는지”를 애플리케이션 메모리나 로그 검색으로 판단하지 않습니다. provider + endpoint + event_id 같은 명시적인 inbox 키에 원자적 uniqueness를 부여하고, 신규 삽입과 중복 관찰을 구분합니다.
CREATE TABLE webhook_inbox (
provider TEXT NOT NULL,
endpoint_key TEXT NOT NULL,
event_id TEXT NOT NULL,
body_hash TEXT NOT NULL,
received_at TIMESTAMP NOT NULL,
signature_status TEXT NOT NULL,
processing_status TEXT NOT NULL,
queue_record_id TEXT,
PRIMARY KEY (provider, endpoint_key, event_id)
);
중복 event가 오면 “무시”만 하지 말고 duplicate_observed_at, source request correlation, body hash 일치 여부를 기록합니다. 같은 event ID인데 body hash가 다르면 정상 중복이 아니라 provider 설정·저장 오류·위변조 가능성을 조사할 예외입니다. 이미 성공한 업무 action을 다시 실행하지 않는 것이 우선이며, 새 body를 덮어써서 최초 근거를 잃지 않습니다.
또한 event ID만으로 모든 중복을 막을 수 있다고 가정하지 않습니다. 서로 다른 event가 같은 환불·권한 변경을 의미할 수 있으므로, 내부 action에도 action_id 또는 business_operation_key를 두어 fulfillment, entitlement, refund ledger 같은 downstream 작업을 멱등하게 만듭니다.
4. event ordering과 aggregate version을 별도 판단합니다
webhook 도착 순서는 공급사에서 상태가 발생한 순서와 같지 않을 수 있습니다. payment_succeeded보다 payment_created가 늦게 도착하거나, subscription_updated가 여러 번 전송되는 상황을 전제로 설계합니다. 단순히 수신 시각이 최신이면 현재 상태라고 덮어쓰지 않습니다.
aggregate별로 다음 값을 보존합니다.
| 필드 | 목적 |
|---|---|
aggregate_type |
payment, invoice, subscription 등 객체 종류 구분 |
aggregate_id |
provider 객체와 내부 객체 연결 |
provider_event_created_at |
공급사가 event를 만든 시각의 참고값 |
provider_object_version |
공급사가 제공하는 version·revision·sequence |
last_applied_version |
내부가 적용한 마지막 version |
last_event_id |
현재 상태를 바꾼 근거 event |
state_transition |
이전 상태와 새 상태 |
ordering_decision |
apply, hold, stale, fetch_current |
provider가 신뢰할 수 있는 sequence나 object version을 제공하지 않는다면 event 시각만으로 강한 순서를 확정하지 않습니다. 상태 전이가 허용되는지 현재 객체를 조회하고, 필요한 정보가 없으면 ordering_hold로 보냅니다. 예를 들어 환불 완료 event가 먼저 도착했는데 결제 성공 event가 늦게 도착한 경우, 금액·객체 현재 상태·내부 action 기록을 함께 확인한 뒤 허용된 전이만 적용합니다.
구독 접근 권한처럼 순서에 민감한 업무는 다음 원칙을 적용합니다.
- 이전 version보다 낮거나 같은 event는 새 업무 action을 만들지 않습니다.
- version 정보가 없거나 충돌하면 현재 provider 객체 조회를 먼저 수행합니다.
- 조회 결과와 event의 aggregate ID가 다르면 적용하지 않고 예외로 보냅니다.
- “마지막으로 도착한 event”를 “최종 상태”의 대체물로 사용하지 않습니다.
- 상태 변경과
last_applied_version갱신은 하나의 transaction 또는 동등한 원자성으로 묶습니다.
5. idempotent consumer는 event 수신과 업무 효과를 분리합니다
consumer가 같은 event를 두 번 읽어도 같은 최종 상태와 한 번의 업무 효과만 만들어야 합니다. 이를 위해 inbox의 processed=true 하나만 두지 말고, event 처리 상태와 내부 action 상태를 분리합니다.
def consume(record):
event = verify_and_load(record)
if event is None:
return "quarantined"
with transaction() as tx:
inbox = tx.lock_inbox(event.provider, event.endpoint, event.id)
if inbox is None:
return "missing_inbox"
if inbox.signature_status != "verified":
return "blocked_signature"
decision = tx.check_version_and_transition(
aggregate_id=event.aggregate_id,
event_type=event.type,
provider_version=event.object_version,
)
if decision in {"stale", "duplicate_transition"}:
tx.record_observation(event.id, decision)
return decision
if decision == "hold_for_current_state":
tx.mark_ordering_hold(event.id)
return decision
action = tx.get_or_create_action(
key=(event.provider, event.aggregate_id, event.business_operation_key)
)
if action.status == "applied":
tx.mark_processed_observation(event.id, action.id)
return "already_applied"
apply_business_change(tx, event, action)
tx.mark_action_applied(action.id, event.id)
tx.mark_inbox_processed(event.id)
return "applied"
코드의 핵심은 성공 표시를 먼저 쓰는 것이 아니라, version 검사·action 생성·업무 변경·처리 근거를 같은 원자적 경계에서 관리하는 데 있습니다. 외부 API 호출이 transaction 안에서 원자적으로 묶이지 않는다면 outbox, action status, 보상 조회와 재처리 정책을 별도로 설계해야 합니다. 외부 시스템이 이미 성공했는지 알 수 없는 상태에서 무조건 새 호출을 하지 말고, provider 객체 조회와 내부 action key로 결과를 확인합니다.
6. dead-letter queue는 실패한 event의 무덤이 아닙니다
일반 queue retry로 해결되지 않는 event는 DLQ로 이동시키되, DLQ를 “나중에 전부 다시 실행”하는 보관함으로 만들지 않습니다. 다음 실패 유형을 구분해야 owner가 올바른 조치를 선택할 수 있습니다.
| 실패 분류 | 예시 | DLQ 후 기본 조치 |
|---|---|---|
signature_invalid |
secret·raw body·timestamp 검증 실패 | 보안·설정 확인, 자동 replay 금지 |
schema_unknown |
새 event type 또는 필수 필드 누락 | parser 버전 확인, 안전한 quarantine |
ordering_hold |
version 부재·순서 충돌 | 현재 aggregate 조회 후 판단 |
dependency_unavailable |
내부 DB·권한 서비스 일시 불가 | 의존성 복구 확인 후 제한적 재처리 |
business_conflict |
이미 취소·환불된 상태와 충돌 | owner 승인과 원장 대조 |
permanent_mapping_error |
provider 객체와 내부 ID 연결 불가 | 수동 mapping·대사, 자동 실행 금지 |
DLQ 레코드에는 원래 event ID, body hash, 실패 code, first failed at, last attempt at, consumer version, dependency snapshot, owner, next review at, replay eligibility를 둡니다. 오류 메시지만 남기면 동일 원인이 고쳐졌는지 판단할 수 없습니다. 또한 DLQ 재처리는 원래 queue에 event를 넣는 행위와 실제 업무 action이 적용된 행위를 구분합니다.
DLQ에서 바로 원문을 수정하거나, 서명 검증을 건너뛰거나, event type을 임의로 바꿔 실행하지 않습니다. parser·mapping을 고친 뒤 동일 원문으로 dry-run 결과를 만들고, expected transition과 action key를 검토한 다음 검증된 replay를 승인합니다.
7. 검증된 replay는 범위·현재 상태·중복 효과를 확인한 뒤 실행합니다
Stripe 미전달 event 처리 공식 문서는 미전달 event를 확인하고 처리할 때 event 목록과 처리 여부를 기준으로 삼는 접근을 설명합니다. 공급사에서 resend를 눌렀거나 내부 queue에 다시 넣었다는 사실만으로 replay가 완료된 것은 아닙니다. event가 inbox에 도착했는지, consumer가 적용했는지, 최종 객체와 내부 action이 맞는지 각각 확인해야 합니다.
replay run은 다음 순서로 만듭니다.
- 범위 고정: provider, endpoint, event ID 목록, aggregate ID, 원래 발생 구간을 명시합니다.
- 원문 확인: body hash와 signature verification 결과가 보존돼 있는지 확인합니다.
- 현재 상태 조회: payment·refund·invoice·subscription 등 필요한 provider 객체를 재조회합니다.
- 예상 전이 계산: 현재 내부 version, event version, 허용 전이, action key를 dry-run으로 계산합니다.
- 중복 효과 검사: 이미 적용된 action, 대사 완료, 고객 권한 상태를 확인합니다.
- 승인 기록: 범위·사유·검토자·실행자·consumer version·stop 조건을 ledger에 기록합니다.
- 제한 실행: 승인된 event만 replay하고, 각 event 결과를 개별 기록합니다.
- 사후 검증: 처리 결과와 현재 객체, 내부 상태, 대사 예외를 비교합니다.
- 종료 선언: 남은 DLQ와 미확정 상태를 별도로 남기고 전체 성공으로 포장하지 않습니다.
replay 대상이 넓을수록 안전한 것은 아닙니다. event type별로 업무 효과가 다른지, 현재 상태 조회가 필요한지, 이미 종료된 action을 건너뛸 수 있는지 확인합니다. replay_all 버튼 하나로 모든 event를 실행하는 운영 도구보다, allowlist와 필수 필드 검증을 통과한 run만 실행하는 도구가 재현성과 통제에 유리합니다.
8. evidence ledger는 “누가 무엇을 근거로 적용했는가”를 남깁니다
webhook 운영에서 로그와 ledger는 역할이 다릅니다. 로그는 디버깅과 시계열 관찰에 유용하지만, 특정 event가 어떤 상태 전이와 내부 action으로 이어졌는지 재구성할 수 있는 업무 근거는 별도 ledger가 필요합니다.
| ledger 필드 | 기록 내용 |
|---|---|
evidence_id |
근거 레코드의 고유 ID |
provider_event_id |
외부 event와의 연결 |
body_hash |
원문 동일성 확인용 hash |
signature_result |
verified, invalid, unavailable 등 결과 |
aggregate_id / version |
적용 대상과 version |
previous_state / next_state |
내부 상태 전이 |
action_id |
실제 업무 효과의 멱등 key |
consumer_version |
실행 코드와 mapping 버전 |
replay_run_id |
수동·자동 replay와 연결 |
actor / approver |
실행자와 승인자 |
decision_reason |
apply, stale, hold, DLQ 사유 |
observed_at |
관찰·적용 시각 |
reconciliation_ref |
이후 대사 자료와 연결 |
ledger는 처리 완료를 임의로 덮어쓰는 메모장이 아닙니다. 원래 event, 최초 처리 결과, 후속 조회, replay 결과를 append-only 성격으로 남기고 정정이 필요하면 새 evidence를 추가합니다. 이렇게 해야 “처음에는 signature 검증 실패였지만 secret rotation 후 다시 검증했고, 이미 적용된 action은 건너뛰었다” 같은 운영 판단을 재현할 수 있습니다.
상태 흐름으로 보는 운영 결정
[RECEIVED]
↓ raw persist 실패 ─────────→ [INGRESS_EXCEPTION]
↓
[SIGNATURE_CHECK]
├─ invalid ─────────────────→ [QUARANTINED]
↓ verified
[INBOX_DEDUPE]
├─ same event/action ────────→ [DUPLICATE_OBSERVED]
↓ new
[ORDER_CHECK]
├─ stale ────────────────────→ [NOOP_RECORDED]
├─ unknown/conflict ─────────→ [ORDERING_HOLD]
↓ applyable
[IDEMPOTENT_CONSUME]
├─ dependency retry exhausted → [DLQ_OPEN]
├─ business conflict ─────────→ [MANUAL_REVIEW]
↓ applied
[ACTION_APPLIED]
↓ current state and evidence check
[RECONCILIATION_PENDING] ───────→ [CLOSED_WITH_EVIDENCE]
DUPLICATE_OBSERVED와 NOOP_RECORDED는 실패가 아닐 수 있지만, 관찰 사실과 action이 없었다는 근거를 남겨야 합니다. CLOSED_WITH_EVIDENCE도 금액 대사가 아직 끝나지 않았다면 reconciled와 혼동하지 않습니다.
운영 전 실무 체크리스트
- [ ] provider·endpoint별 raw body 보존 방식과 접근 권한을 정의했습니다.
- [ ] raw body가 파싱·정규화되기 전에 provider 공식 방식으로 signature를 검증합니다.
- [ ] secret rotation, endpoint mapping, clock 이상을 별도 metric과 owner로 관리합니다.
- [ ]
provider + endpoint + event_id에 원자적 uniqueness를 적용했습니다. - [ ] 동일 event ID의 body hash가 다른 경우를 별도 보안·데이터 예외로 올립니다.
- [ ] 수신, queue 저장, consumer 시작, action 적용, 대사를 서로 다른 상태로 기록합니다.
- [ ] provider event version 또는 sequence가 없을 때 event 시각만으로 순서를 확정하지 않습니다.
- [ ] aggregate별
last_applied_version과 허용 상태 전이를 원자적으로 갱신합니다. - [ ] 업무 action에 별도 멱등 key를 두어 fulfillment·entitlement·refund의 중복 효과를 막습니다.
- [ ] signature 실패, schema 미지원, ordering hold, 의존성 오류, 영구 mapping 오류를 분리합니다.
- [ ] DLQ에는 실패 reason, consumer version, owner, next review, replay eligibility를 남깁니다.
- [ ] replay 전에 event 범위, 원문 hash, 현재 객체 상태, 예상 전이, 기존 action을 확인합니다.
- [ ] replay는 allowlist·dry-run·승인·stop 조건·사후 검증을 거칩니다.
- [ ] 수동 replay와 provider resend가 겹쳐도 내부 action은 한 번만 적용됩니다.
- [ ] evidence ledger에 event, version, action, actor, approver, reason, replay run을 연결합니다.
- [ ] webhook 처리 완료와 invoice·payment·refund·payout 대사 완료를 같은 상태로 표시하지 않습니다.
- [ ] 임의 공격 재현·서명 우회·임의 retry 수치·법률·세무·PCI 준수 보장을 운영 절차에서 제외합니다.
마무리
결제 webhook의 안정성은 endpoint가 요청을 받는지보다 받은 event를 검증 가능한 근거와 함께 한 번의 업무 효과로 연결하는지에 달려 있습니다. 관측성을 ingress부터 대사까지 나누고, 원문 body와 signature 결과를 보존하며, event ID와 action key를 분리해 중복을 막아야 합니다. 순서가 불확실하면 현재 aggregate를 조회하고, 해결되지 않는 실패는 DLQ에서 owner가 판단하게 해야 합니다.
replay도 “다시 넣기”가 아니라 범위를 고정하고, 현재 상태와 예상 전이를 dry-run으로 확인하고, 승인과 사후 검증을 남기는 운영 작업입니다. 마지막으로 로그보다 오래 재현할 수 있는 evidence ledger를 구축하면 공급사 재전송, 내부 queue 재처리, 수동 조정이 겹치는 상황에서도 어떤 event가 실제 상태를 바꿨는지 설명할 수 있습니다. 이것이 webhook 모니터링과 replay 가드레일의 완료 기준입니다.