결제 webhook replay 검증 로그·대사 리포트 템플릿 2026: event ledger·provider object·invoice·entitlement 차이를 한 화면에서 확인하는 법
결제 webhook replay 검증 로그·대사 리포트 템플릿 2026: event ledger·provider object·invoice·entitlement 차이를 한 화면에서 확인하는 법
결제 webhook을 다시 처리한 뒤 replay complete라는 문구만 남기면 운영 검증은 끝나지 않습니다. event가 queue에 들어갔는지, consumer가 실제 업무 action을 적용했는지, 결제 공급사 객체가 어떤 상태인지, invoice 금액과 상태가 맞는지, 고객의 entitlement가 그 결과와 일치하는지까지 확인해야 합니다.
이 글은 webhook 수신 endpoint를 처음 설계하는 방법보다 replay 한 건의 결과를 재현 가능한 로그와 대사 리포트로 증명하는 방법에 초점을 둡니다. 수신·서명·중복 제거·순서·DLQ의 기본 가드레일은 결제 webhook 모니터링·replay 가드레일 체크리스트에서 다루고, 공급사 전체 장애 중 backlog와 복구 순서를 지휘하는 방법은 B2B SaaS 결제 공급사 장애 대응 체크리스트로 분리합니다.
기능과 공식 문서는 2026년 8월 2일 확인 기준입니다. 실제 event schema, 객체 상태, invoice 처리, replay API와 보존 기간은 공급사·제품·계정 설정에 따라 달라질 수 있습니다. 이 글은 일반적인 SaaS 결제 운영 체크리스트이며 법률·세무·회계·PCI DSS 준수 판단이나 특정 결제 결과를 보장하지 않습니다.
먼저 범위를 분리합니다
replay 검증과 월말 대사는 연결되지만 같은 질문은 아닙니다.
| 문서 | 중심 질문 | 이 글과의 경계 |
|---|---|---|
| 결제 webhook 모니터링·replay 가드레일 | event를 검증하고 중복 없이 처리·격리·replay할 수 있는가 | 수신 파이프라인과 가드레일의 기본 설계 |
| 구독 청구 월말 대사 체크리스트 | billing platform·processor·ERP 사이 금액과 기간을 맞추는가 | 월말·기간 단위의 금액 대사 |
| 결제 승인 실패·재시도 가드레일 | 단건 checkout의 최종 상태와 다음 안전한 행동은 무엇인가 | 승인 시도·unknown 상태·재시도 판단 |
| 이 글 | replay run의 event·객체·invoice·entitlement가 서로 맞는가 | replay 결과와 업무 효과의 증명 |
따라서 이 글에서 말하는 reconciled는 “webhook handler가 200을 반환했다”는 뜻이 아닙니다. 같은 결제 단위를 여러 원천에서 읽고, 일치·불일치·확인 필요를 명시적으로 분류했다는 뜻입니다.
먼저 결론: replay run 하나에 네 개의 상태를 함께 붙입니다
검증 리포트는 다음 네 영역을 한 행 또는 한 화면에서 연결해야 합니다.
[replay run]
↓
[event ledger] ── event_id / body_hash / processing_result
↓
[provider object] ── payment / refund / subscription / customer
↓
[invoice] ── amount / currency / status / period
↓
[entitlement] ── access / plan / effective_at / source_action
↓
[reconciliation result] ── match / mismatch / hold / owner / next action
네 영역 중 하나라도 빠지면 “처리됐다”와 “고객에게 올바른 결과가 반영됐다”를 구분할 수 없습니다. 특히 entitlement는 결제 공급사의 객체가 아니므로, provider API에서 성공을 확인했다고 내부 접근 권한까지 정상이라고 추정하지 않습니다.
1. replay run을 먼저 고정합니다
한 번의 재처리를 여러 event의 모음으로 뭉뚱그리지 말고, 추적 가능한 replay_run_id를 발급합니다. Stripe의 미전달 event 처리 공식 문서도 event 목록과 처리 여부를 기준으로 미전달 event를 구분하는 흐름을 설명합니다. 공급사마다 재전송 API와 event 상태가 다르므로, 외부 문서의 절차를 그대로 복사하지 말고 내부 run 기록을 기준으로 통합합니다.
replay run 기본 필드
| 필드 | 기록할 값 | 확인 질문 |
|---|---|---|
replay_run_id |
내부에서 유일한 실행 ID | 이번 검증 범위를 다른 실행과 구분할 수 있는가 |
reason_code |
provider outage, parser fix, DLQ review 등 | 왜 지금 다시 처리했는가 |
requested_by |
요청자·팀·ticket ID | 승인과 요청의 근거가 있는가 |
approved_by |
승인 owner와 시각 | 자동 재처리와 승인 replay가 구분되는가 |
scope_type |
event ID, aggregate, 시간 구간 등 | 범위가 과도하게 넓지 않은가 |
scope_hash |
대상 목록의 해시 | 실행 후 대상이 바뀌지 않았는가 |
consumer_version |
parser·consumer 버전 | 어떤 코드로 결과를 만들었는가 |
mode |
dry-run, apply, verify-only | 계산과 실제 업무 변경을 구분했는가 |
started_at, finished_at |
시작·종료 시각 | 지연과 중단을 재구성할 수 있는가 |
run_status |
planned, running, partial, complete, held | complete를 성급히 표시하지 않았는가 |
scope_hash는 event ID 목록이나 대상 key 목록을 정렬한 뒤 만든 식별값으로 사용할 수 있습니다. 중요한 것은 특정 구현을 강제하는 해시 알고리즘이 아니라, 실행 전후에 “같은 대상이었는가”를 확인할 수 있는 고정된 범위입니다.
replay는 먼저 dry-run으로 예상 전이와 중복 action 여부를 계산합니다. dry-run 결과가 없거나 원문 signature·body hash를 확인할 수 없으면 apply로 넘어가지 않고 held로 남깁니다.
2. event ledger는 수신 로그와 업무 효과를 분리합니다
Stripe Webhooks 공식 문서는 event 수신과 endpoint 처리의 기본 흐름을 설명하고, Stripe idempotent requests 문서는 같은 논리 요청의 중복 결과를 다룰 때 확인할 기준을 제공합니다. 그러나 idempotency key나 event ID가 있다고 해서 invoice와 entitlement의 업무 효과까지 자동으로 검증되는 것은 아닙니다.
event ledger에는 최소한 다음 필드를 둡니다.
| 그룹 | 필드 예시 | 목적 |
|---|---|---|
| 식별 | provider, endpoint_key, event_id, aggregate_id |
원천 event와 업무 대상을 연결 |
| 원문 증거 | body_hash, received_at, signature_status, source_request_id |
같은 원문을 다시 확인 |
| 처리 | inbox_status, queue_record_id, consumer_version, attempt_count |
queue와 consumer의 상태 분리 |
| 전이 | previous_state, new_state, object_version, ordering_decision |
왜 이 상태로 바뀌었는지 기록 |
| 업무 효과 | action_id, action_key, action_status, affected_entity |
중복 fulfillment·권한 변경 방지 |
| replay | replay_run_id, dry_run_result, replay_eligibility, applied_at |
이번 재처리의 범위와 결과 연결 |
| 대사 | reconciliation_status, mismatch_code, owner, next_review_at |
일치하지 않는 행을 후속 조치로 보냄 |
processed=true 하나만으로는 충분하지 않습니다. event가 queue에서 읽혔지만 업무 action이 실패했을 수 있고, action은 성공했지만 entitlement 반영이 지연됐을 수 있습니다. 따라서 event_received, event_persisted, consumer_applied, provider_verified, invoice_matched, entitlement_matched를 별도 상태로 관리합니다.
3. provider object를 현재 상태의 근거로 조회합니다
event payload는 발생 당시의 스냅샷일 수 있습니다. replay 시점의 현재 객체와 다를 수 있으므로, 리포트에는 event payload와 현재 provider object를 나란히 둡니다. 공급사별 객체 이름은 다르지만 다음 공통 필드를 매핑할 수 있습니다.
| 공통 필드 | event payload | 현재 provider object | 판정 |
|---|---|---|---|
provider_object_id |
event에 담긴 ID | API 조회 ID | 동일해야 함 |
object_type |
payment·invoice·refund 등 | 현재 객체 종류 | 유형 변환 오류 확인 |
status |
당시 상태 | replay 검증 시점 상태 | 시간 차이를 설명 |
amount |
event 금액 | 현재 객체 금액 | 통화와 함께 대조 |
currency |
event 통화 | 현재 객체 통화 | 숫자만 비교하지 않음 |
customer_id |
고객 ID | 현재 객체 고객 | 잘못된 고객 연결 방지 |
created_at |
event/object 시각 | 현재 조회 시각 | 발생·조회 시각 구분 |
latest_event_id |
처리 근거 | 공급사 또는 내부 최신 근거 | 마지막 도착 event와 혼동 금지 |
event_created_at이 최신이라고 해서 현재 상태를 확정하지 않습니다. 늦게 도착한 event, 동일 객체의 여러 변경, 취소·환불·재청구가 섞일 수 있기 때문입니다. provider가 제공하는 object status·revision·sequence가 있으면 활용하고, 없다면 현재 객체 조회와 내부 허용 전이를 함께 기록합니다. Stripe를 사용하는 경우에는 Event object 공식 문서의 id, type, created, data.object와 Invoice object 공식 문서의 상태·금액 필드를 실제 계정 API 응답과 대조합니다. Stripe의 Entitlements 공식 문서도 결제·invoice 상태와 기능 접근 권한을 같은 객체로 취급하지 않도록 구분해 읽습니다.
Paddle은 Webhooks overview 공식 문서에서 현재 webhook 구조와 이벤트 수신을 설명하고, Chargebee는 Webhook 설정 문서에서 webhook 이벤트 설정을 다룹니다. Recurly Webhooks 문서도 공급사별 전달·event 모델이 다를 수 있음을 확인할 때 참고할 수 있습니다.
4. invoice와 payment object를 같은 것으로 취급하지 않습니다
결제 성공 object가 있다고 invoice가 곧바로 paid 상태라는 뜻은 아닙니다. 반대로 invoice가 paid로 보이는데 내부 entitlement가 아직 열리지 않았을 수도 있습니다. 리포트는 금액뿐 아니라 객체 간 관계와 시각을 기록해야 합니다.
invoice 대사 필드
| 필드 | 확인 내용 |
|---|---|
invoice_id |
provider·내부 invoice ID 연결 |
customer_id |
payment와 invoice 고객이 같은지 |
subscription_id |
구독 청구라면 동일 계약과 연결되는지 |
billing_period_start, billing_period_end |
어느 기간의 청구인지 |
subtotal, discount, tax, total |
금액 구성요소를 분리 |
currency |
금액과 함께 대조 |
invoice_status |
draft, open, paid, void, uncollectible 등 공급사 원문 |
payment_reference |
결제 object·charge·transaction 연결 |
paid_at |
invoice 완료 시각과 event 시각 비교 |
credit, refund, adjustment |
총액과 실제 수금액 차이 설명 |
source_event_id |
상태를 바꾼 event 근거 |
리포트에서 amount_match를 계산할 때는 단순히 payment.amount == invoice.total만 보지 않습니다. discount·tax·credit·refund·부분 결제·통화·반올림 규칙을 별도 열에 남기고, 시스템마다 금액을 minor unit으로 저장하는지도 확인합니다. 불명확한 금액 차이를 “반올림 오차”로 임의 분류하지 말고 amount_mismatch 또는 needs_review로 보냅니다.
월말·기간 단위의 대사 흐름은 B2B SaaS 구독 청구 월말 대사 체크리스트와 연결할 수 있습니다. 이 글의 replay report는 특정 run의 원인과 처리 근거를 보존하는 것이 목적이고, 월말 대사는 기간 전체의 청구·환불·payout 집계를 확인하는 것이 목적입니다.
5. entitlement를 별도 원장으로 대조합니다
고객이 실제로 사용할 수 있는 기능·좌석·용량을 entitlement라고 부르겠습니다. 이는 provider의 결제 성공과 별개의 내부 상태입니다. 결제 event를 성공적으로 처리했어도 plan mapping 오류, consumer 지연, cache 지연, 취소 event 순서 역전으로 entitlement가 잘못 열리거나 닫힐 수 있습니다.
entitlement 대사 필드
| 필드 | 기록할 값 | 운영 질문 |
|---|---|---|
account_id |
내부 고객·조직 ID | provider customer와 정확히 연결되는가 |
entitlement_key |
기능·좌석·용량 key | 무엇을 열고 닫는가 |
plan_code |
내부 plan과 provider price/plan mapping | 상품 mapping이 현재 버전인가 |
expected_state |
invoice·subscription 결과에서 계산한 상태 | 계산 근거가 무엇인가 |
actual_state |
entitlement 서비스의 현재 상태 | 실제 사용 가능 상태는 무엇인가 |
effective_at |
적용 시작 시각 | 결제 시각과 권한 시각 차이를 설명 |
expires_at |
만료 예정 시각 | 기간 경계와 grace를 구분 |
source_action_id |
entitlement를 바꾼 내부 action | event와 업무 효과 연결 |
mapping_version |
plan/feature 변환표 버전 | 과거 replay 결과 재현 |
cache_observed_at |
cache 확인 시각 | stale read 가능성 분리 |
expected_state=active, actual_state=inactive이면 replay가 실패했다고 단정하지 않습니다. action은 성공했지만 read model이 늦었을 수 있고, 정상적으로 grace period를 적용한 것일 수도 있습니다. entitlement_mismatch의 원인을 mapping_error, action_missing, projection_lag, period_rule, manual_hold처럼 나눠 owner와 다음 확인을 붙입니다.
6. 한 화면 리포트 템플릿
아래 표는 운영 대시보드나 스프레드시트에 그대로 옮길 수 있는 최소 템플릿입니다. 실제 시스템의 개인정보·결제 민감정보는 원문을 그대로 복사하지 말고 안전한 ID·hash·마스킹 값만 표시합니다.
| 영역 | 필드 | 예시 값 | 판정 |
|---|---|---|---|
| run | replay_run_id |
rr_20260802_0017 |
고정 |
| run | mode / scope |
verify-only / 12 events |
범위 확인 |
| event | event_id / body_hash |
evt_... / sha256:... |
원문 연결 |
| event | processing_result |
already_applied |
중복 효과 없음 |
| provider | object_id / status |
pi_... / succeeded |
현재 상태 확인 |
| provider | amount / currency |
10000 / KRW |
금액·통화 일치 |
| invoice | invoice_id / status |
inv_... / paid |
청구 상태 확인 |
| invoice | total / paid_at |
10000 / timestamp |
결제 근거 연결 |
| entitlement | account_id / plan_code |
acct_... / pro |
고객·상품 mapping |
| entitlement | expected / actual |
active / active |
권한 일치 |
| evidence | action_id / consumer_version |
act_... / consumer-14 |
업무 효과 재현 |
| result | reconciliation_status |
match |
최종 분류 |
| follow-up | mismatch_code / owner |
none / - |
보류 시 후속 지정 |
최종 분류는 최소 match, partial_match, mismatch, held, not_applicable로 제한합니다. complete라는 실행 상태와 match라는 대사 상태를 같은 열에 넣지 않습니다. replay가 끝났지만 entitlement 조회가 실패했다면 run_status=complete, reconciliation_status=held처럼 서로 다른 상태를 가질 수 있습니다.
샘플 행 4개
아래 값은 실제 고객·결제 정보가 아닌 가상 값입니다. 샘플의 목적은 네 객체의 관계와 예외 상태를 구분하는 데 있습니다.
모바일에서 가로로 밀어야 하는 7열 표 대신, 한 샘플을 한 블록으로 표시합니다. 실제 대시보드에서는 이 필드를 열로 저장하되, 모바일 화면에서는 아래처럼 세로로 보여주는 편이 확인하기 쉽습니다.
샘플 1 — 정상 일치
replay_run_id:rr_demo_001- event 결과:
applied, actionact_demo_01 - provider object:
succeeded, 10000 KRW - invoice:
paid, 10000 KRW - entitlement:
active - 최종 판정:
match· 다음 조치: 종료
샘플 2 — replay 중복 skip
replay_run_id:rr_demo_002- event 결과:
already_applied, duplicate observed - provider object:
succeeded, 10000 KRW - invoice:
paid, 10000 KRW - entitlement:
active - 최종 판정:
match· 다음 조치: 중복 관찰만 기록
샘플 3 — invoice paid·entitlement pending
replay_run_id:rr_demo_003- event 결과:
applied, action 존재 - provider object:
succeeded, 20000 KRW - invoice:
paid, 20000 KRW - entitlement:
pending - 최종 판정:
held/entitlement_mismatch· 다음 조치: projection·mapping owner 확인
샘플 4 — refund 후 권한 잔류
replay_run_id:rr_demo_004- event 결과:
applied, event는 refund - provider object:
refunded, 10000 KRW - invoice:
paid+ refund 10000 KRW - entitlement:
active - 최종 판정:
mismatch· 다음 조치: entitlement 회수 action과 시각 대조
세 번째 행처럼 provider와 invoice가 일치해도 entitlement가 지연될 수 있습니다. 네 번째 행처럼 refund가 확인됐는데 접근 권한이 남아 있으면 “webhook replay 성공”으로 닫지 않고 내부 회수 action과 grace·기간 규칙을 확인합니다. already_applied는 실패가 아니라 새 업무 효과를 만들지 않았다는 처리 결과이며, reconciled와는 별도 판정입니다.
7. 불일치 코드는 원인별로 분리합니다
리포트의 목적은 오류를 많이 표시하는 것이 아니라 다음 worker가 같은 조사를 반복하지 않게 하는 것입니다.
| 코드 | 의미 | 기본 후속 조치 |
|---|---|---|
event_missing |
대상 event 원문·ledger가 없음 | source와 보존 저장소 확인, replay 중지 |
duplicate_body_mismatch |
같은 event ID인데 body hash가 다름 | provider·저장 오류 조사, 자동 적용 금지 |
provider_object_missing |
현재 객체 조회 불가 | 삭제·권한·ID mapping 확인 |
provider_state_drift |
event 당시 상태와 현재 객체 상태가 다름 | 시각·후속 event·현재 상태 조회 |
amount_mismatch |
invoice와 payment의 금액·통화가 다름 | discount·tax·credit·refund 대조 |
invoice_link_missing |
결제와 invoice 연결 근거가 없음 | object relation과 mapping 확인 |
entitlement_action_missing |
청구는 맞지만 권한 action이 없음 | action ledger와 projection 확인 |
entitlement_state_mismatch |
expected와 actual 권한이 다름 | mapping·기간·cache·manual hold 확인 |
evidence_incomplete |
처리 결과는 있으나 근거 필드가 없음 | raw 증거와 consumer log 보강 |
manual_override |
사람이 상태를 변경함 | 승인자·사유·되돌림 경로 기록 |
불일치 코드를 새로 만들 때는 “누가 무엇을 확인하면 닫히는가”까지 정의합니다. 단순한 error 또는 failed는 원인과 owner를 숨기므로 운영 리포트의 종료 기준으로 사용하지 않습니다.
8. replay 후 검증 순서
검증 순서를 고정하면 결과가 좋아 보이는 행만 먼저 닫는 일을 줄일 수 있습니다.
- 범위 확인:
replay_run_id, 대상 event 목록, mode, consumer version을 고정합니다. - 원문 확인: body hash, signature 결과, provider·endpoint 매핑을 확인합니다.
- 중복 확인: event ID와 action key를 조회해 이미 적용된 업무 효과를 재실행하지 않습니다.
- 현재 객체 확인: provider object ID, status, amount, currency, customer를 조회합니다.
- invoice 연결: invoice·subscription·refund·credit 관계와 청구 기간을 대조합니다.
- 업무 효과 확인: action ledger와 entitlement expected/actual을 비교합니다.
- 불일치 분류: mismatch code, owner, next review date를 채웁니다.
- 종료 판정: 네 영역의 근거가 모두 있고 후속 조치가 없을 때만
match로 닫습니다.
이 순서는 공급사 API 호출 횟수나 retry 수를 정해 주는 규칙이 아닙니다. 실제 조회 제한, 보존 기간, 권한과 계정 설정에 맞춰 조정하되, 어떤 영역을 확인했는지의 증거는 남겨야 합니다.
9. 공개 리포트와 내부 원문을 분리합니다
운영 리포트에는 고객·결제 민감정보가 들어갈 수 있습니다. 따라서 업무 대시보드에는 customer_id와 provider_object_id의 안전한 축약값, body hash, 상태, 금액 집계와 mismatch code만 보여주고, 원문 payload·secret·인증 데이터·카드 정보는 별도 접근 통제 영역에 둡니다.
로그를 만들 때 다음 값은 원문으로 복사하지 않습니다.
- secret, signature 원문, API key
- 카드번호, CVC, 인증 데이터
- 고객 이메일·전화번호 등 불필요한 개인 식별정보
- provider가 반환한 민감한 오류 전문
대신 secret_version, signature_verified, body_hash, redaction_version, 접근한 사람과 시각을 기록합니다. 리포트에 source URL을 남기는 것과 운영 payload를 공개하는 것은 다른 문제입니다.
10. 공개 전 점검표
이 주제는 결제 상태를 다루므로 “템플릿이 있어 보인다”는 이유만으로 공개하지 않습니다.
- 제목과 본문이 결제 webhook replay 검증·대사 리포트 검색 의도에 맞는가
- 오전의 webhook 모니터링 글과 수신·signature·DLQ 설명이 중복되지 않는가
- event ledger, provider object, invoice, entitlement 네 영역이 모두 분리돼 있는가
- Stripe·Paddle·Chargebee·Recurly 공식 문서 링크가 실제 문장과 연결되는가
- 금액·통화·세금·환불을 임의로 회계 처리하라고 지시하지 않는가
- 의료·투자·법률·세무 직접 조언과 내부 운영 비밀이 없는가
- raw Markdown, frontmatter, draft marker가 WordPress HTML에 남지 않는가
- 기존 결제 운영 글로 가는 내부링크가 4개 이상 존재하는가
- 발행 후 public
200, canonical self, robots index, noindex 없음인가 - public HTML에 AdSense와 현재 파트너스 블록이 존재하는가
마무리
결제 webhook replay의 완료 조건은 “event를 다시 넣었다”가 아닙니다. 원문 event가 무엇이었는지, 현재 provider object가 어떤 상태인지, invoice가 어떤 금액·기간을 가리키는지, 고객 entitlement가 실제로 그 결과와 일치하는지를 같은 replay_run_id로 연결해 설명할 수 있어야 합니다.
가장 작은 실무 시작점은 네 개의 상태를 한 행에 두는 것입니다.
event ledger → provider object → invoice → entitlement
여기에 match / mismatch / held, mismatch code, owner, next review date를 붙이면 replay 결과가 재현 가능한 운영 기록이 됩니다. 반대로 HTTP 2xx, queue 처리 완료, invoice paid 중 하나만 보고 고객 권한까지 자동으로 정상이라고 닫으면 다음 장애에서 같은 조사를 반복하게 됩니다.
공식 출처
- Stripe Webhooks
- Stripe Event object
- Stripe Invoice object
- Stripe Entitlements
- Stripe: Process undelivered events
- Stripe: Idempotent requests
- Paddle: Webhooks overview
- Paddle: Signature verification
- Paddle: Transactions
- Paddle: Subscriptions
- Chargebee: Webhook settings
- Chargebee: Events API
- Chargebee: Invoices API
- Recurly: Webhooks
- Recurly: Invoices
- Recurly: Subscriptions