응답을 놓치면 당신의 AI 에이전트가 중복 결제를 발생시킵니다
요약
AI 에이전트가 도구 호출 시 응답 유실로 인해 발생하는 중복 결제 문제를 다룹니다. 단순 재시도 로직의 위험성을 경고하며, 멱등성 장부와 멱등성 키를 활용한 해결책을 제시합니다.
핵심 포인트
- 응답 유실 시 에이전트의 재시도는 중복 결제를 유발함
- 백오프와 지터는 네트워크 예절일 뿐 중복 실행을 막지 못함
- 멱등성 장부를 통해 '최대 한 번(at-most-once)' 실행을 보장해야 함
- 제3자 서비스 이용 시 제공업체의 멱등성 키 활용이 필수적임
만약 당신의 에이전트가 카드로 결제되는 도구 (tool)를 호출했는데, 전송 과정에서 _응답 (response)_이 유실되었다면, 당신의 에이전트는 안전하게 실패(fail safely)한 것이 아닙니다. 에이전트는 고객에게 중복 결제를 발생시켰으며, 정작 본인은 그 사실을 전혀 모릅니다.
이것이 바로 버그의 핵심입니다. 돈은 이미 이동했습니다. 에이전트는 "ok"라는 응답을 듣지 못했으므로, 모든 모범적인 재시도 루프 (retry loop)가 하는 것처럼 다시 시도했습니다. 동일한 프롬프트 (prompt), 동일한 도구 (tool), 동일한 인자 (arguments)로 말이죠. 두 번째 결제가 발생했습니다.
요약 (TL;DR)
- 재시도 (retry)는 네트워크 이벤트가 아닙니다. 그것은 이미 발생했을지도 모르는 부수 효과 (side effect)에 대한 의미론적 (semantic) 결정입니다.
- 백오프 (Backoff), 지터 (jitter), 그리고 재시도 상한선 (retry ceiling)은 재시도를 예의 바르게 만들 뿐입니다. 이들은 중복 결제를 막는 데 아무런 도움이 되지 않습니다.
- 아주 작은 멱등성 장부 (idempotency ledger, 기록된 결과에 매핑된 중복 제거 키)를 사용하면, 당신이 제어하는 효과에 대해 결정론적으로 **최대 한 번 (at-most-once)**의 도구 호출을 보장할 수 있습니다. 당신이 제어하지 않는 제3자 결제 (Stripe)의 경우, 중복 결제를 막는 것은 당신의 프로세스 앞단에 있는 장부가 아니라 제공업체의 멱등성 키 (idempotency key)입니다. 이 글에서는 이 두 가지 사례를 구분하여 다룹니다.
- 아래 데모에서, 단순한 런타임 (runtime)은 100개의 주문에 대해 120번의 부수 효과 호출을 실행하여 고객에게 $399.80를 초과 청구합니다. 반면 장부를 사용하면 동일한 횟수의 재시도 상황에서도 정확히 100번 호출하며 $0.00를 초과 청구합니다.
- 이것은 '정확히 한 번 (exactly-once)'이 아니라 '최대 한 번 (at-most-once)'입니다. 저는 이것이 실패하는 지점에 대해 솔직하게 밝힐 것입니다.
에이전트 프레임워크가 제공하는 재시도는 잘못된 재시도입니다
어떤 에이전트 프레임워크를 열어보더라도 재시도 로직 (retry logic)을 발견할 수 있을 것입니다. 지수 백오프 (Exponential backoff), 지터 (Jitter), 최대 시도 횟수 상한선 (max-attempts ceiling) 등 말이죠. 이 모든 것은 단 한 가지 실패 모드, 즉 _요청이 도착하지 않았을 때_를 위해 구축되었습니다.
그것은 괜찮은 기본값입니다. 하지만 도구가 부수 효과 (side effect)를 갖는 순간, 그것은 잘못된 기본값이 됩니다.
그 로직은 읽기 (reads) 작업에는 올바릅니다. 만약 GET /reviews?page=4가 타임아웃 (timeout)된다면, 재시도는 비용이 들지 않으며 명백히 옳은 행동입니다. 다시 읽어도 해가 되지 않으니까요.
하지만 쓰기 (writes) 작업에 대해서는 조용히 잘못된 방식입니다. 도구 호출이 실패할 수 있는 두 가지 서로 다른 방식이 있으며, 호출자에게는 이 둘이 동일하게 보입니다:
- **요청 (request)**이 유실되었습니다. 부수 효과 (side effect)가 발생하지 않았습니다. 재시도 (retrying)는 안전하며 반드시 필요합니다.
- **응답 (response)**이 유실되었습니다. 부수 효과 (side effect)가 이미 발생했습니다. 재시도를 하면 작업이 중복 실행됩니다.
에이전트의 관점에서는 두 경우 모두 결과가 없는 도구 호출 (tool call)로 보입니다. 타임아웃 (timeout), 소켓 끊김 (dropped socket), 혹은 이미 업스트림 (upstream)으로 POST 요청을 전달한 프록시 (proxy)의 502 에러 등입니다. 에이전트는 실패 상황만 보고는 1번 케이스와 2번 케이스를 구분할 수 없습니다. 에이전트에게 필요한 정보는 회선 (wire)의 _반대편_에 있으며, 바로 그곳이 에이전트가 도달할 수 없었던 지점입니다.
따라서 지수 백오프 (backoff)는 여기서 도움이 되지 않습니다. 백오프는 언제 재시도할지를 결정할 뿐, _부수 효과가 이미 실행되었는지 여부_를 결정하지는 못합니다. charge (결제), send_email (이메일 전송), create_refund (환불 생성), POST /orders (주문 생성)와 같은 작업에서 중요한 것은 바로 그 두 번째 질문입니다. 소신 있게 말하자면, 쓰기 재시도 (write retry)는 네트워크에 관한 문제가 아니라 의미론 (semantics)에 관한 문제입니다. 네트워크 설정 (network knobs)을 더 강하게 조절해 봤자, 더 느리고 정중한 일정으로 중복 결제를 발생시킬 뿐입니다.
"최대 한 번 (at-most-once)"의 실제 의미
분산 시스템 (distributed systems) 전문가들은 세 가지 전달 보장 (delivery guarantees) 방식을 사용하며, 에이전트 관련 문서에서는 이 용어들을 느슨하게 사용하므로 정확한 의미를 파악하는 것이 중요합니다.
- 최소 한 번 (At-least-once): 동작이 한 번 이상 실행됩니다. 동작을 놓치지는 않지만, 반복될 수 있습니다. 단순한 재시도 루프 (retry loop)가 제공하는 방식입니다. 멱등성 (idempotent)이 보장되는 읽기 작업에는 괜찮지만, 결제 작업에는 위험합니다.
- 최대 한 번 (At-most-once): 동작이 0번 또는 1번 실행됩니다. 동작을 놓칠 수는 있지만 (드문 경우), 절대 반복되지는 않습니다. 돈과 관련된 작업에서 원하는 방식입니다.
- 정확히 한 번 (Exactly-once): 정확히 한 번만 실행됩니다. 모두가 원하는 방식입니다. 메시지를 유실할 수 있는 시스템에서 이는 가장 어려운 과제입니다. 공짜로 얻을 수 있는 것이 아니며, '최대 한 번'의 _전달 (delivery)_과 '최소 한 번'의 재시도 (retries), 그리고 중복 제거 (dedup)를 결합하여 근사치를 구현합니다. 아래의 원장 (ledger)은 이 중복 제거에 관한 부분입니다.
멱등성 원장 (idempotency ledger)은 중간 단계인 '최대 한 번 (at-most-once)'을 깔끔하게 보장해 줍니다. 즉, 원장이 위치한 경계에서 '최소 한 번 (at-least-once)'의 시도 (attempts) 위에, '부수 효과 (side effect)'에 대해 '최대 한 번'을 구현합니다. 시도는 네트워크가 강제하는 만큼 얼마든지 발생할 수 있습니다. 하지만 부수 효과는 단 한 번만 발생하는데, 이는 두 번째 시도가 기록된 결과를 발견하고 이를 재실행하는 대신 다시 재생 (replay)하기 때문입니다. 나중에 자세히 설명하겠지만, 주의할 점은 다음과 같습니다. 만약 부수 효과가 당신이 소유하지 않은 회선(wire)의 반대편에 존재한다면, 중요한 경계는 당신의 것이 아니라 제공자 (provider)의 경계라는 점입니다.
읽기 (Reads)는 '최소 한 번 (at-least-once)' 상태를 유지합니다. 실제 부수 효과가 수반되는 쓰기 (Writes)는 '최대 한 번 (at-most-once)'으로 이동합니다. 이것이 설계 결정의 전부입니다.
이것은 제가 발명한 것이 아닙니다. Stripe가 공개 API에서 제공하는 것과 동일한 메커니즘입니다. 그들의 설명은 다음과 같습니다:
"Stripe의 멱등성 (idempotency)은 특정 멱등성 키 (idempotency key)에 대해 수행된 첫 번째 요청이 성공하든 실패하든 상관없이, 해당 요청의 결과 상태 코드와 본문 (body)을 저장함으로써 작동합니다. 동일한 키를 가진 후속 요청은
500에러를 포함하여 동일한 결과를 반환합니다."
이 문장을 두 번 읽어보세요. 그들은 결과, 즉 상태와 본문을 저장하고 이를 재생합니다. 결제를 다시 실행하는 것이 아닙니다. AI 에이전트의 쓰기 도구 (write tool)도 정확히 동일한 계약 (contract)이 필요하지만, 대부분의 도구는 아직 이를 갖추고 있지 않습니다.
경계: 이것이 다른 세 가지처럼 보일 수 있기 때문에
저는 이전에 12,000행에서 중단된 스크래퍼를 행을 다시 쓰지 않고 재개하는 법, 변경되지 않은 페이지의 재다운로드를 건너뛰기 위한 조건부 GET (conditional GET), 그리고 에이전트가 이미 본 모든 페이지를 다시 읽는 문제에 대해 글을 쓴 적이 있습니다. 그것들은 모두 당신의 데이터를 읽거나 다시 쓰는 것에 관한 것입니다. 즉, 재개 (resume)를 깔끔하게 만들고, 읽기 (read)를 저렴하게 만드는 것에 관한 것이었습니다.
이것은 완전히 다른 문제입니다. 이것은 파일의 길이를 자르는(truncating) 방식으로는 되돌릴 수 없고, 당신이 소유하지도 않은 외부 (external) 부수 효과 (side effect)를 동반하는 도구 호출 (tool call)에 관한 것입니다. 예를 들어 결제, 이메일 발송, 환불, 타인의 시스템에 접수된 주문 등이 이에 해당합니다. 이미 작성된 행(row)이 무엇인지 확인하는 방식으로는 결제 건을 "재개 (resume)"할 수 없습니다. 부수 효과가 발생하는 즉시 돈은 사라지기 때문입니다. 해결책은 파일 오프셋 (file offset)이 아닙니다. "내가 이미 정확히 이 동작을 수행했다"를 인식하고 원래의 응답을 다시 돌려주는 키 (key)가 필요합니다.
그리고 여기서 키를 어디에 둘 것인지가 결정됩니다. 만약 부수 효과가 외부적이고 당신이 이를 소유하지 않는다면 (예: Stripe의 카드 결제), 중복 제거 (dedup)는 _호출된 쪽 (callee)_에서 이루어져야 합니다. 서비스 제공자가 당신의 키를 확인하고, 반복된 요청임을 인식하여 다시 결제하는 것을 거부해야 합니다. 당신의 프로세스 앞에 위치한 원장 (ledger)은 응답을 놓친 경우 (lost-response case)에 도움이 되지 않습니다. 원격 결제는 이미 실행되었고, 당신의 원장에는 아무것도 기록되지 않았으며, 재시도 (retry)가 원장을 그대로 지나쳐 두 번째 결제로 이어지기 때문입니다. 이것이 바로 우리가 다루는 버그의 핵심입니다. 반면 당신이 직접 소유한 부수 효과 (당신이 쓰는 행, 당신이 큐에 넣는 작업, 당신이 처음부터 끝까지 제어하는 서비스)의 경우에는 당신이 소유한 원장이 완벽한 해결책이 됩니다. 키가 확인되는 경계 (boundary)를 당신이 제어하기 때문입니다. 이 두 가지 사례를 명확히 구분하십시오. 아래의 데모는 메커니즘을 명확히 보여주기 위해 의도적으로 이 둘을 하나의 프로세스로 합쳐 놓았습니다. 재시도 위생 (retry hygiene)과 같은 계열이지만, 실패의 양상과 해결책은 완전히 다릅니다.
데모: 단순 재시도 vs 원장, 재시도는 동일하지만 청구 금액은 다르다
여기 독립적으로 실행 가능한 시뮬레이션이 있습니다. 네트워크나 의존성 없이 표준 라이브러리의 hashlib와 json만 사용하므로, 5초 안에 실행하여 수치를 확인할 수 있습니다. 장난감 수준의 PaymentAPI는 실제 부수 효과 (잔액 및 호출 카운터)를 가집니다. 우리는 19.99달러짜리 주문 100건을 실행합니다. 전송 계층 (transport)은 매 5번째 호출마다 "응답을 놓치며", 따라서 100건의 호출 중 20건이 재시도됩니다.
단순한 (naive) 런타임은 단순히 charge를 다시 호출함으로써 재시도합니다. 원장 (ledger) 런타임은 각 논리적 동작에 키를 부여하고, 재시도 시 기록된 결과를 재생 (replay)합니다.
AI 에이전트를 위한 최대 1회 (at-most-once) 도구 호출: 단순 재시도 (naive retry) vs 멱등성 원장 (idempotency ledger).
...
실행해 보겠습니다. 이것은 제가 직접 타이핑한 것이 아니라, 제 컴퓨터의 표준 출력 (stdout)에서 그대로 복사한 정확한 결과값입니다:
시나리오: 100개 주문 @ $19.99, 매 5번째마다 응답 손실 (따라서 20회 재시도)
단순 재시도 (NAIVE) orders=100 side_effect_calls= 120 balance=$ 2398.80 expected=$ 1999.00 duplicate_charges=20
...
두 실행 모두 재시도 횟수는 20회로 동일합니다. 원장 (ledger)이 재시도를 적게 한 것이 아닙니다. 똑같이 재시도했음에도 불구하고 정확한 금액인 $1,999.00에 도달했습니다. 반면 단순 재시도 (naive) 런타임은 $2,398.80까지 치솟았으며, 아무런 에러도 발생하지 않았습니다. 왜냐하면 아무것도 에러가 나지 않았기 때문입니다. 모든 결제는 "성공"했습니다. 이 부분이 바로 이 버그를 까다롭게 만드는 지점입니다. 고객이 이메일을 보낼 때까지는 눈에 보이지 않습니다.
원장이 실제로 작동하는 방식, 세 단계로 보기
전체 메커니즘은 call_once에 들어 있습니다. 이는 세 줄의 로직으로 구성되어 있으며, 실수하기 쉬운 하나의 순서 규칙이 있습니다.
1. 논리적 동작을 위한 안정적인 키 (stable key) 도출. idem_key("wf-checkout", "charge", [order_id, amount])는 워크플로 (workflow), 단계 (step), 그리고 인자 (arguments)를 해싱 (hash)합니다. 여기서 중요한 단어는 _안정적 (stable)_입니다. 동일한 논리적 동작은 재시도 시에도 반드시 동일한 키를 생성해야 합니다. 만약 키에 타임스탬프 (timestamp), 시도마다 생성되는 무작위 UUID, 또는 다시 샘플링된 LLM 토큰 (token)이 포함된다면, 재시도 시 다른 키를 얻게 됩니다. 그러면 원장은 이를 놓치게 되고, 결국 중복 결제가 발생합니다. 원장의 성능은 키의 결정론적 (determinism) 특성에 달려 있습니다. 계속 읽어보세요. 이것이 사람들이 가장 많이 실수하는 1순위 요소입니다.
2. 뛰어들기 전에 확인하기. 만약 키가 이미 원장에 존재한다면, 기록된 결과를 반환하고 부수 효과 (side effect)를 건드리지 마십시오. 그것이 재생 (replay)입니다. 이는 Stripe가 저장된 상태와 본문을 반환하는 것과 동일한 계약 (contract)입니다.
3. 응답이 유실되기 전에 기록하십시오. call_once의 순서를 보십시오. 우리는 api.charge(...)를 호출한 직후, 응답이 에이전트에게 돌아온 후가 아니라 즉시 ledger[key] = result를 호출합니다. 그 이유는 응답이 돌아오지 않을 수도 있다는 점이 핵심이기 때문입니다. 만약 성공적인 왕복 (round-trip) 시에만 기록한다면, 당신이 이 시스템을 구축한 바로 그 상황에서는 아무것도 기록되지 않은 상태가 됩니다. 이 예제 코드에 대한 솔직한 주의 사항(caveat)을 하나 말씀드리자면, '결제 후 기록 (charge-then-write)'은 여전히 두 단계로 이루어지므로, 그 간극에서 충돌 (crash)이 발생하면 재시도 시 다시 결제될 수 있습니다. 운영 환경 (production)에서는 기록이 부수 효과 (side effect)와 원자적 (atomically)으로 커밋되어야 합니다 (또는 먼저 대기 중인 행을 예약해야 합니다). 이 부분은 한계 (limits) 섹션에서 다시 다루겠습니다. 여기서의 순서 (ordering)는 재생 (replay)을 가능하게 하며, 원자성 (atomicity)은 보장 (guarantee)을 제공합니다.
그게 전부입니다. 백오프 (backoff) 변경도, 새로운 프레임워크도 필요 없습니다. 데모에서는 딕셔너리 (dict)를 사용했지만, 운영 환경에서는 고유 제약 조건 (unique constraint)이 있는 행 (row)을 사용합니다.
내가 왜 이 고생을 하는가: 응답이 돌아오지 않는 상황 2,190번의 경험
저는 운영 환경에서 스크레이퍼 (scrapers)와 데이터 도구들을 실행합니다. 32개의 공개된 액터 (actors)가 있으며, 2026년 6월 기준으로 **총 2,190회의 실행 (lifetime runs)**을 기록했습니다 (제 Apify 프로필 apify.com/knotless_cadence에 있는 원시 생애 주기 카운터 기준이며, Trustpilot 기록만 해도 962회를 넘어섰습니다). 이 중 어떤 것도 카드를 결제하지는 않습니다. 그런데 왜 저는 결제에 대해 쓰고 있는 걸까요?
그 정도의 규모가 되면 더 이상 '해피 패스 (happy path, 정상 경로)'를 믿지 않게 되기 때문입니다. 수천 번의 실행을 거치다 보면, "요청은 완료되었지만 확인 응답 (acknowledgement)이 유실되었다"는 상황은 더 이상 교과서적인 예외 케이스 (edge case)가 아니라, 그냥 흔히 일어나는 일 (a Tuesday)이 됩니다. 프록시 (proxies)가 전달 후 멈춰버리기도 합니다. 워커 (worker)가 작업을 수행하고 "완료"를 기록하는 사이에 OOM (Out of Memory)으로 종료되기도 합니다. 본문이 읽히지도 않았는데 200 응답이 도착하기도 합니다. 2,190번의 실행이 저에게 각인시킨 운영상의 교훈은 "재시도 (retries)를 추가하라"가 아닙니다. 모든 프레임워크에는 재시도가 있습니다. 진짜 교훈은 **"식별성 (identity)에 대한 개념이 없는 재시도는, 지난 시도에서 되돌릴 수 없는 일이 일어나지 않았다는 것에 거는 도박이다"**라는 점입니다. 그리고 충분히 긴 시간의 관점에서 볼 때, 그 도박은 반드시 패배합니다. 읽기 (reads) 작업에서는 잃을 것이 없습니다. 하지만 에이전트가 그 동일하고 순진한 재시도 로직을 charge (결제)에 적용하는 날, 그 도박은 실제 돈을 잃게 만듭니다.
그것이 바로 에이전트(agents)로 넘어가는 가교입니다. 우리는 이제 LLM (Large Language Model)을 도구(charge (결제), refund (환불), send (전송), book (예약))를 직접 작성하도록 연결하고 있으며, 읽기 작업(reads) 아래에 항상 잠복해 있던 것과 동일한 순진한 재시도 루프(retry loop)를 그들에게 넘겨주고 있습니다. 영향 범위(blast radius)가 단순히 "페이지를 다시 다운로드함"에서 "사람에게 두 번 과금함"으로 바뀌었을 뿐입니다.
이것이 실패하는 지점 (배포하기 전에 읽으세요)
제가 당신에게 정확히 한 번(exactly-once)을 보장한다고 말한다면 거짓말일 것입니다. 이것은 최대 한 번(at-most-once)이며, 날카로운 모서리(sharp edges)를 가지고 있습니다. 솔직한 목록은 다음과 같습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기