환불 핸들러가 원장을 확인하고 결제했지만, 여전히 두 번 결제되었다.
요약
환불 핸들러가 응답 손실이나 에이전트의 재시도로 인해 동일한 이벤트를 여러 번 처리할 때 발생하는 중복 결제 문제를 다룹니다. 원장(ledger) 확인, 고유 키 주장(claim), 트랜잭션 무결성 제약 등 다양한 방식으로 중복 처리를 방지하는 방법을 비교 분석합니다.
핵심 포인트
- 환불 핸들러는 응답 손실 등으로 인해 동일 이벤트가 재전달될 수 있습니다.
- 단순히 원장을 읽어보는 것만으로는 모든 재시도 시나리오를 막을 수 없습니다.
- 중복 처리를 방지하려면 고유 키 주장(claim)이나 트랜잭션 무결성 제약이 필요합니다.
환불 핸들러(refund handler)가 환불을 커밋한 후 응답을 잃어버리는 경우가 있습니다. 어쩌면 HTTP 응답이 시간 초과되었을 수 있고, 어쩌면 워커(worker)가 메시지를 인지하기 전에 죽었을 수도 있습니다. 혹은 AI 에이전트의 도구 호출(tool call)이었는데, 에이전트가 시간 초과를 보고 동일한 인수로 도구를 다시 호출했을 수도 있습니다. 재시도하는 것은 송신자 측에서 잘못한 것이 아닙니다. 문제는 핸들러가 두 번째 전달된 메시지를 어떻게 처리하느냐입니다.
일반적인 해결책은 먼저 확인하는 것입니다. 즉, 원장(ledger)에 이미 해당 주문에 대한 환불이 있는지 확인하고, 있다면 조기에 반환하는 것입니다. 저는 이 수정 사항이 얼마나 효과적인지 보고 싶어서, 네 가지 다른 방식으로 동일한 이벤트가 다시 전달되는 것을 시뮬레이션하고 그 영향이 몇 번 발생했는지 계산하는 작은 pytest 플러그인을 작성했습니다. 이 플러그인은 공개적이며 오프라인으로 실행할 수 있습니다: [github.com/jigonyoo/replay-twice].
동일한 이벤트를 두 번 전달하는 네 가지 방법
| 시나리오 | 발생하는 일 |
|---|---|
duplicate | 동일한 이벤트가 순차적으로 두 번 도착함 |
| ... | |
이 결과는 핸들러의 반환 값에서 나오는 것이 아닙니다. 대신, 실제로 커밋된 내용(예: 해당 주문의 환불 원장 행 수)을 계산하는 effects(key) 함수를 드릴에 제공하며, 카운트가 정확히 하나일 때만 테스트가 통과합니다. |
네 가지 핸들러, 하나의 원장
이 저장소는 환불 도구와 payment.succeeded 웹훅의 SQLite 버전을 포함하고 있으며, 각각 네 가지 방식으로 작성되었습니다:
- naive: 매번 환불을 기록합니다.
- check_then_act: 원장을 읽어보고, 아무것도 없으면 기록합니다.
- guarded_raises: 트랜잭션 내에서 고유 키를 가진 테이블에 주문 ID를 주장(claim)합니다. 두 번째 주장은 무결성 오류(integrity error)를 발생시킵니다.
- guarded:
INSERT OR IGNORE을 사용하여 동일한 주장을 수행하며, 이미 주장이 이루어졌다면
환불 흐름(Refund flow)을 세 번 실행했습니다. 순차적 시나리오(sequential scenarios)는 실행당 한 번의 테스트를 수행하며, 동시성 시나리오(concurrent scenario)는 실행당 200번의 테스트를 수행하고 각 테스트마다 여덟 개의 중첩된 배송 건을 포함합니다. 세 가지 실행 모두 모든 셀에서 동일한 판결을 내렸습니다. 즉, 환불이 레이스 조건(race) 내에서 몇 번 발생했는지는 실행마다 달랐습니다.
| 핸들러 | duplicate | timeout_retry | crash_before_ack | concurrent (200 trials) |
|---|---|---|---|---|
| naive | 두 번 결제됨 | 두 번 결제됨 | 두 번 결제됨 | 200번 이상 두 번 결제됨 |
| ... |
웹훅 흐름(webhook flow)과 환불 도구의 async 버전은 셀별로 동일한 판결을 내렸습니다. Linux 환경에서 replay-twice 0.2.0, Python 3.11, 스레드 8개, 시드 0으로 측정했습니다.
먼저 읽는 것만으로는 충분하지 않은 이유
쓰기 전에 읽는 방식(Reading before writing)은 첫 번째 배송이 완료된 후 도착하는 모든 재시도(retry)를 처리합니다. 이는 네 가지 시나리오 중 세 가지를 커버하며, 이 때문에 해당 패턴은 코드 검토(code review)에서 살아남습니다: 핸들러를 두 번 호출하여 테스트하면 통과합니다.
하지만 두 배송 건이 겹칠 때는 실패합니다. 둘 다 빈 원장(empty ledger)을 읽고, 아무것도 결제되지 않았다고 판단하며, 둘 다 쓰기 작업을 수행합니다. 재시도하는 웹훅 송신자(webhook sender), 동일한 메시지를 가져가는 두 워커(worker), 또는 이전 도구 호출이 아직 실행 중일 때 도구 호출을 발생시키는 에이전트 루프(agent loop) 모두 이러한 중첩 현상을 초래할 수 있습니다.
여기서 읽기 작업은 락(lock)을 사용하지 않으며, 락을 추가하는 것은 생각보다 어렵습니다. 대부분의 데이터베이스 기본 격리 수준(default isolation level)에서 읽기와 쓰기를 하나의 트랜잭션으로 감싸더라도, 두 배송 건 모두 빈 원장을 볼 수 있게 하며, SELECT … FOR UPDATE는 아직 존재하지 않는 행(row)을 잠글 수 없습니다. 키에 대한 고유 제약 조건(unique constraint)이 데이터베이스가 실제로 강제하는 방어책입니다.
200/200에 대한 솔직한 단점(caveat)이 있습니다. 예제 핸들러는 읽기(read)와 쓰기(write) 사이에 1밀리초 동안 대기하고, 드릴은 여덟 건의 전송을 같은 순간에 해제합니다. 둘 다 의도적으로 간극(gap)을 넓히므로, 이는 프로덕션 환경에서 예상해야 할 실패율이 아닙니다. 이 코드는 간극이 존재한다는 것을 보여주며, 핸들러를 순차적으로 두 번만 호출하는 테스트로는 결코 발견할 수 없다는 것을 의미합니다.
한 번이라는 것 자체가 정답은 아니다
guarded_raises는 절대 두 번 지불하지 않았습니다. 여전히 버그가 있습니다. 모든 재전송(redelivery)이 고유 키에 도달하여 예외를 발생시키고, 이는 웹 핸들러에서는 보통 500 에러로 처리되며, 전송기(sender)는 오류를 보고 다시 시도합니다. 돈은 한 번만 움직였지만, 재시도는 멈추지 않습니다.
따라서 드릴은 핸들러 오류와 효과 카운트(effect count)를 분리하여 보고합니다. 기본 설정으로는 경고(warning)와 함께 통과하지만, `redelivery_errors=
위의 모든 내용은 SQLite를 대상으로 단일 프로세스에서 실행됩니다. 이 테스트는 여러 서버가 서로 경쟁하는 상황, 실제 데이터베이스의 격리 수준(isolation level), 분산 잠금(distributed locks), 브로커 전달 정책(broker delivery policies), 핸들러 반환 후 커밋되는 효과(effects), 결제 제공업체 측의 중복(duplicates), 실제 프로세스 충돌 및 전원 손실, 네트워크 파티션, 옵저버가 볼 수 없는 효과, 또는 절대 반환되지 않는 핸들러(테스트 러너의 타임아웃 사용) 등은 다루지 않습니다. 또한 키 자체가 잘못된 경우에도 도움이 될 수 없습니다. 하나의 주문에 대한 두 개의 부분 환불에는 두 개의 고유 식별자(identities)가 필요합니다. 테스트 통과(A pass means...)는 실행된 스케줄이 안전했다는 의미일 뿐, 모든 스케줄이 안전하다는 것을 의미하지 않습니다.
핸들러에서 시도해 보기
pip install "replay-twice @ git+https://github.com/jigonyoo/replay-twice"
from uuid import uuid4
from replay_twice import ReplayCase, SCENARIOS
...
프로덕션이 아닌 테스트 데이터베이스를 대상으로 지정하세요: 핸들러가 실제로 호출됩니다. 기본적으로 동시성 테스트는 한 번 실행되므로, 위 표와 같이 여러 번 실행하려면 --replay-repeats=200을 추가하세요. async def 핸들러도 작동합니다. 위의 표는 replay-twice report로 약 4분 만에 재현되며, 원본 관찰 기록(raw observations)은 리포지토리에 커밋됩니다.
AI의 도움을 받아 작성되었습니다. 모든 숫자는 리포지토리의 커밋된 보고서에서 가져온 것입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기