[PonsWarp 개념 교실 25] 멱등성 — 같은 결제 이벤트를 두 번 처리하지 않는 법
쉽게 말하면, 멱등성은 같은 요청을 한 번 처리하든 열 번 처리하든 최종 결과가 한 번 처리한 것과 같게 만드는 성질이다.

결제 Webhook은 네트워크 오류 때문에 같은 이벤트가 재전송될 수 있다. 결제 권한을 두 번 올리거나 사용량을 두 번 기록하면 안 된다.
이벤트 ID를 열쇠로 쓴다
Webhook event_id 수신
↓
이미 DB에 기록됐나?
├─ 예 → 성공 응답, 재처리 안 함
└─ 아니 → event_id 기록 → 실제 상태 변경
PonsWarp는 결제 이벤트 ID를 DB에 기록하는 try_record_* 경계를 둔다. DB의 primary key/unique 제약이 중복 이벤트를 막는 마지막 방어선이다.
왜 먼저 기록하나
상태 변경 후 ID를 기록하면, 상태 변경 직후 프로세스가 죽었을 때 재시도 이벤트가 다시 처리될 수 있다. 반대로 기록과 상태 변경을 하나의 트랜잭션으로 묶으면 두 작업이 함께 성공하거나 함께 실패한다.
Webhook 검증(HMAC/API)과 멱등성은 서로 다른 책임이다. 서명이 맞아도 이미 처리한 이벤트라면 다시 적용하지 않는다.
PonsWarp 적용 지점
| 항목 | 구현 | 증거 |
|---|---|---|
| 이벤트 기록 | try_record_* | database.rs:757-803 |
| LemonSqueezy 검증 | raw body HMAC-SHA256 | billing.rs:495-513 |
| PayPal 검증 | verify-webhook-signature API | billing.rs:466-489 |
| 중복 이벤트 | 조기 반환 | billing.rs:575-616,663-693 |
| 권한 반영 | entitlement upsert | billing.rs:751-803 |
멱등성과 트랜잭션
Webhook 재전송에서 가장 위험한 순서는 “비즈니스 상태를 먼저 바꾸고 event_id를 나중에 기록”하는 것이다. 첫 요청이 상태를 바꾼 직후 DB 연결이 끊기면, 재전송된 두 번째 요청은 새 이벤트로 오인한다. PonsWarp의 try_record_* 계열 경로는 이벤트 ID를 유일 키로 기록하는 시점을 중복 방지의 경계로 삼는다.
textWebhook 수신 │ 서명·기본 형식 검증 ▼ 이벤트 ID insert (unique) ├─ 이미 존재 → 같은 성공 응답, 부수효과 없음 └─ 새 이벤트 → 상태 변경과 기록을 같은 트랜잭션으로
멱등 키의 범위도 중요하다. provider event id가 전역적으로 유일한지, 계정별로만 유일한지에 따라 DB unique constraint가 달라진다. payload hash를 함께 저장하면 같은 event_id로 내용이 바뀐 재전송을 탐지할 수 있다. 이는 정상 duplicate와 변조·provider 오류를 구분하는 데 도움이 된다.
서명 검증과 멱등성은 다르다
서명 검증은 “이 요청을 provider가 보냈는가”를 확인하고, 멱등성은 “이미 이 이벤트를 적용했는가”를 확인한다. 서명이 유효해도 같은 이벤트는 여러 번 올 수 있다. 반대로 event_id가 새로워도 서명이 틀리면 처리하면 안 된다. 두 검사를 하나의 boolean으로 합치면 운영 로그와 재시도 정책이 흐려진다.
| 단계 | 실패하면 |
|---|---|
| raw body 서명 검증 | 401/400, 상태 변경 없음 |
| event type·schema 검증 | dead-letter 또는 400 |
| unique event insert | duplicate면 안전한 성공 응답 |
| business transaction | rollback 후 provider 재시도 유도 |
| 응답 | 처리 결과를 재전송 계약에 맞춤 |
재구축 시에는 “이미 처리됨”을 오류로 반환하지 않는 것이 중요하다. provider는 2xx가 아니면 계속 재시도할 수 있기 때문이다. 다만 동일 ID의 payload가 달라졌다면 조용히 성공시키지 말고 보안 이벤트로 기록해야 한다. 외부 API 호출을 트랜잭션 안에서 직접 수행한다면 outbox나 보상 처리가 필요하다.
외부 부수효과는 한 번 더 분리한다
DB 트랜잭션으로 event_id와 내부 상태를 원자적으로 묶어도 이메일·결제 provider·메시지 발행 같은 외부 부수효과는 같은 트랜잭션에 들어가지 않는다. outbox에 “발행할 작업”을 기록하고 worker가 재시도하게 만들면 내부 멱등성과 외부 전달 재시도를 각각 관리할 수 있다. outbox consumer도 message id를 기준으로 멱등해야 한다.
재구축 시에는 duplicate webhook을 정상적인 운영 흐름으로 취급한다. 중복 횟수를 무조건 오류로 올리기보다 provider별 재시도 패턴을 관찰하고, payload mismatch만 강한 경보로 분리한다.
응답과 재시도의 계약
Webhook provider가 2xx를 받으면 같은 이벤트를 더 이상 재시도하지 않을 수 있고, 4xx·5xx는 정책에 따라 다시 보낼 수 있다. 따라서 서명 오류·스키마 오류·일시적 DB 장애·이미 처리된 이벤트를 같은 상태 코드로 묶지 않는다. 이미 처리된 동일 이벤트는 부수효과 없이 2xx를 반환하는 것이 일반적인 멱등 계약이다.
DB unique constraint는 애플리케이션의 SELECT then INSERT보다 강한 경쟁 방지선이다. 두 요청이 동시에 들어와도 하나만 insert에 성공하고, 다른 쪽은 duplicate 경로로 전환한다. 이 invariant를 테스트할 때는 순차 호출만으로 충분하지 않고 동시 요청을 만들어야 한다.
테스트해야 할 경계
같은 event_id를 두 번 보내고 부수효과가 한 번만 발생하는지, 두 요청을 동시에 보내도 unique constraint가 지켜지는지 확인한다. 서명은 같지만 payload가 달라진 이벤트도 별도 경보가 되는지 확인한다. 트랜잭션 중간에 예외를 넣어 rollback 뒤 provider 재시도가 안전한지도 검증해야 한다.
이 경계 테스트가 있어야 멱등성은 문서의 좋은 말이 아니라 DB와 외부 이벤트 사이에서 실제로 보존되는 invariant가 된다.
이벤트 보관 기간도 멱등성의 일부다. provider가 재시도할 수 있는 기간보다 짧게 event_id를 삭제하면 오래된 이벤트가 다시 부수효과를 만들 수 있다. 삭제 대신 archive하거나, payload hash와 처리 결과를 보존하는 정책을 선택해야 한다.
재처리 결과를 저장하면 운영자가 같은 이벤트의 상태와 원인을 추적할 수 있다. 단순히 “중복”만 남기지 말고 최초 처리 시각, provider, 결과 코드를 함께 기록한다.
이 기록은 재시도 폭주와 실제 provider 장애를 분리하는 운영 근거가 된다.
이 수치가 필요하다.
한 문장 정리
멱등성은 재전송되는 결제 이벤트의 ID를 DB에 기록해 이미 처리한 이벤트를 건너뛰고, 권한·사용량·구독 상태가 중복 반영되지 않게 한다.