에이전트가 결제 엔드포인트를 호출했습니다. 요청은 처리되었고, 청구는 완료되었지만, 응답이 돌아오는 길에 타임아웃되었습니다. 에이전트는 200 응답을 받지 못했으므로, 실패 시 지시받은 대로 재시도했습니다. 이제 고객은 두 번 청구되었고, 로그에는 오류처럼 보이는 것이 아무것도 없습니다.
이것이 에이전트와 일반 API 클라이언트를 구별하는 실패 모드입니다. 사람이 "결제" 버튼을 한 번 클릭하면 로딩 스피너를 보고 기다립니다. 재시도 루프에 있는 에이전트는 아무 응답도 없으면 다시 시도하며, 때로는 사람이 할 수 있는 것보다 빠르게 세 번 또는 네 번 연속으로 시도합니다. 에이전트를 더 안정적으로 만들기 위해 추가하는 모든 재시도 정책은 중복 쓰기 발생 가능성을 높입니다. 해결책은 멱등성(idempotency)입니다. 반복적인 요청이 단일 요청과 동일한 결과를 생성하도록 하는 것입니다.
이 가이드에서는 HTTP 수준에서 멱등성이 무엇을 의미하는지, 에이전트가 실제로 재사용할 수 있는 키를 생성하는 방법, 서버가 이를 준수하기 위해 무엇을 저장해야 하는지, 그리고 실제 고객이 두 번 청구되기 전에 전체를 테스트하는 방법을 다룹니다. AI 에이전트가 프로덕션에서 고장나는 이유에 대한 저희의 주요 글을 읽지 않으셨다면, 중복 쓰기는 대부분의 "에이전트가 두 번 처리했어요" 보고서 밑에 숨어 있는 실패 모드입니다.
Apidog는 이 글의 테스트 부분에서 등장합니다. 멱등성은 API와 에이전트의 도구 레이어에 구축해야 하는 것입니다. 그 후 필요한 것은 동일한 요청을 두 번 실행하여 두 번째 요청이 아무것도 변경하지 않았음을 증명하는 방법이며, 이는 CI에서 저장하고 실행할 수 있는 테스트입니다.
에이전트가 사람보다 멱등성을 더 자주 깨뜨리는 이유
에이전트 트래픽과 관련된 세 가지 요인이 중복을 흔하게 만듭니다.
첫 번째는 재시도 횟수입니다. 에이전트 프레임워크는 일시적인 네트워크 오류가 실행 실패의 가장 흔한 원인이기 때문에 기본적으로 적극적으로 재시도합니다. 에이전트 오류 복구 가이드에서는 백오프와 서킷 브레이커를 다루며, 그 안에 있는 모든 기술은 특정 요청이 서버에 도달하는 횟수를 증가시킵니다.
두 번째는 타임아웃의 모호성입니다. 요청이 타임아웃되면 클라이언트는 서버가 이를 처리했는지 여부에 대해 아무것도 알지 못합니다. 프록시에서 받은 504 응답은 쓰기 작업이 전혀 일어나지 않았거나, 쓰기는 일어났지만 응답이 손실되었음을 의미할 수 있습니다. 사람은 보통 재시도하기 전에 확인합니다. 에이전트는 일반적으로 그렇게 하지 않는데, "먼저 확인"하는 것은 모델이 결정해야 하는 추가 도구 호출이기 때문입니다.
세 번째는 루프입니다. 작업을 실패한 에이전트는 실패한 단계뿐만 아니라 전체 작업을 다시 시작할 수 있습니다. 1단계에서 주문을 생성하고 4단계에서 실패하면, 단순한 재시도는 두 번째 주문을 생성합니다. 이것이 다단계 에이전트가 스크립트와 크게 다른 점입니다. 재시도 경계가 모호하며, 코드 대신 모델이 재시작 지점을 결정합니다.
이러한 점들을 종합하면 문제의 윤곽을 알 수 있습니다. 에이전트가 잘못된 요청을 보내는 것이 아닙니다. 에이전트는 올바른 요청을 여러 번 보냅니다.
멱등성이 실제로 보장하는 것
어떤 작업이 여러 번 수행되어도 한 번 수행한 것과 동일한 효과를 가질 때 멱등(idempotent)하다고 합니다. HTTP 시맨틱스 사양인 RFC 9110에서는 GET, PUT, DELETE를 멱등하다고 정의합니다. POST는 멱등하지 않으며, 이것이 바로 위험한 작업이 POST 호출(주문 생성, 메시지 전송, 송금 시작 등)이 되는 경향이 있는 이유입니다.
두 가지 명확화가 많은 혼란을 줄여줄 것입니다.
멱등성은 안전한(safe) 것과 동일하지 않습니다. 안전한 메서드는 아무것도 변경하지 않습니다. DELETE는 멱등하지만 파괴적입니다. 다섯 번 호출해도 한 번 호출한 것과 동일하게 리소스는 삭제된 상태로 남아있지만, 리소스는 사라진 상태입니다. 에이전트는 두 속성을 별도로 분류해야 하며, 이는 에이전트를 위한 최소 권한 API 키에 대한 저희 글이 자격 증명 측면에서 주장하는 바입니다.
멱등성은 동일한 응답과도 같지 않습니다. 두 번째 호출은 첫 번째 호출의 저장된 결과를 반환할 수 있으며, 다른 상태 코드를 반환할 수도 있습니다. 변경되어서는 안 되는 것은 서버의 상태입니다. 한 번의 청구. 한 번의 주문. 한 번의 이메일.
멱등성 키: POST를 안전하게 만드는 패턴
표준적인 해결책은 요청과 함께 클라이언트가 생성한 키를 보내는 것입니다. 서버는 결과와 함께 키를 기록하고, 동일한 키를 가진 후속 요청은 작업을 다시 수행하는 대신 기록된 결과를 반환합니다.
Stripe는 이 헤더를 대중화했으며, Stripe의 멱등성 문서는 여전히 시맨틱스를 가장 명확하게 설명하고 있습니다. 또한 IETF는 이를 Idempotency-Key 헤더 필드로 표준화하려는 노력을 하고 있으며, 자신만의 헤더 이름을 만들기 전에 읽어볼 가치가 있습니다.
요청은 다음과 같습니다:
POST /v1/payments HTTP/1.1
Host: api.yourservice.com
Authorization: Bearer sk_live_...
Idempotency-Key: 9f2b7c14-6d3a-4b18-9d55-1e2a7c0b4f31
Content-Type: application/json
{
"amount": 4900,
"currency": "usd",
"customer_id": "cus_8812",
"description": "Pro plan, August"
}
키는 UUID입니다. 서버에는 "이것은 동일한 논리적 작업이다"라는 의미 이상은 없습니다. 서버는 요청 본문의 지문(fingerprint) 및 생성된 응답과 함께 이를 저장합니다.
에이전트가 재사용할 수 있는 키 생성
여기서 대부분의 에이전트 구현이 잘못됩니다. 도구 래퍼가 호출할 때마다 새로운 UUID를 생성하면, 재시도할 때마다 키가 변경되어 멱등성은 아무것도 하지 못합니다. 키는 HTTP 시도가 아닌 논리적 작업에 연결되어야 합니다.
규칙: 에이전트가 작업을 수행하기로 결정했을 때 키를 생성하고, 해당 결정에 대한 모든 재시도에 대해 키를 유지해야 합니다.
import uuid
class PaymentTool:
def __init__(self, client):
self.client = client
self._keys = {}
def charge(self, task_id, step_id, amount, customer_id):
# One key per (task, step). Retries of the same step reuse it.
op = f"{task_id}:{step_id}"
if op not in self._keys:
self._keys[op] = str(uuid.uuid4())
return self.client.post(
"/v1/payments",
headers={"Idempotency-Key": self._keys[op]},
json={"amount": amount, "customer_id": customer_id},
)
결정론적 키도 작동하며, 메모리 내 딕셔너리와 달리 프로세스 재시작에도 살아남습니다:
import hashlib
def idempotency_key(task_id: str, step_id: str, payload: dict) -> str:
raw = f"{task_id}|{step_id}|{sorted(payload.items())}"
return hashlib.sha256(raw.encode()).hexdigest()[:32]
키는 시도에서 파생되는 것이 아니라 작업 실행 및 단계에서 파생되어야 하며, 타임스탬프나 시도당 재생성되는 무작위 값에서 파생되어서는 안 됩니다. 에이전트가 전체 작업을 다시 시작하고 실제로 새로운 청구를 의도한다면, 작업 ID가 변경되고 키도 변경됩니다. 이것이 바로 원하는 동작입니다.
서버가 해야 할 일
헤더를 올바르게 처리하는 것은 단순한 조회 이상을 필요로 합니다. 작동하는 구현은 네 가지를 수행합니다:
- 도착 시, 키를 선점하려고 시도합니다. 어떤 작업을 수행하기 전에 고유 제약 조건이 있는 테이블에 삽입합니다. 삽입에 실패하면 다른 시도가 해당 키를 소유한 것입니다.
- 키가 존재하고 저장된 요청 지문이 다른 경우,
422로 거부합니다. 다른 본문과 동일한 키는 클라이언트 버그를 의미하며, 이전 결과를 조용히 반환하면 이를 숨기게 됩니다. - 키가 존재하고 첫 번째 시도가 여전히 진행 중인 경우, 호출자가 경쟁하는 대신 물러나도록
409를 반환합니다. - 작업이 완료되면 상태 코드와 본문을 키에 저장한 다음, 이후 모든 요청에 대해 이를 반환합니다.
CREATE TABLE idempotency_records (
key TEXT PRIMARY KEY,
request_hash TEXT NOT NULL,
state TEXT NOT NULL, -- in_progress | completed
response_status INT,
response_body JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);
만료를 설정합니다. 24시간은 현실적인 모든 재시도 기간을 포괄하며, 키를 영원히 보관하면 테이블이 부채가 됩니다. Stripe는 24시간 후에 키를 만료시키는데, 이는 복사하기에 합리적인 기본값입니다.
두 번째 호출이 아무것도 변경하지 않는지 테스트하기
멱등성을 구축하는 것은 작업의 절반입니다. 그것이 유효함을 증명하는 것이 나머지 절반이며, 이 절반은 기능이 작동하든 안 하든 정상적인 경로가 동일해 보이기 때문에 종종 건너뛰어집니다.
테스트는 설명하기 간단합니다. 요청을 보내고, 결과를 캡처하고, 정확히 동일한 요청을 다시 보내고, 서버가 작업을 두 번 수행하지 않았음을 단언합니다. 어려운 부분은 마지막 단언입니다. 응답만으로는 알 수 없기 때문입니다. 두 번의 성공적인 청구는 모두 200을 반환합니다.
따라서 응답이 아닌 상태를 단언합니다:
- 두 번째 응답 본문이 리소스 ID를 포함하여 첫 번째와 일치합니다. 새로운 ID는 새로운 리소스가 생성되었음을 의미합니다.
- 컬렉션에 대한 후속
GET요청은 두 개가 아닌 하나의 레코드를 반환합니다. - 모든 카운터나 잔액이 한 번만 변경되었습니다.
Apidog에서는 이를 테스트 시나리오로 구성할 수 있습니다. 1단계는 고정된 Idempotency-Key와 함께 POST를 보내고, 2단계는 이를 반복하며, 3단계는 리소스를 나열하고 개수를 단언합니다. 1단계에서 받은 응답 ID를 변수에 저장하고, 2단계가 동일한 값을 반환하는지 단언합니다. 전체 시나리오가 저장되므로, 결제 경로에 대한 모든 변경 시 CI에서 실행되며, 이는 회귀가 실제로 나타나는 지점입니다. 동일한 기술이 API 계약 테스트 가이드의 더 넓은 패턴에도 적용됩니다.

실제 버그를 잡을 수 있기 때문에 다룰 가치가 있는 두 가지 사례가 더 있습니다:
- 동일한 키, 다른 본문. 조용한 성공이 아닌
422를 예상해야 합니다. - 동시 중복. 두 요청을 동시에 보내고 정확히 하나만 성공하는지 확인합니다. 이는 순차적인 테스트에서는 절대 드러나지 않는 누락된 고유 제약 조건을 잡아냅니다.
목킹(Mocking)도 여기에서 도움이 됩니다. 에이전트를 아직 구축 중이고 결제 API가 아직 존재하지 않는 경우, 멱등성을 인식하는 응답으로 목킹하여 에이전트의 재시도 로직이 일찍부터 연습될 수 있도록 합니다. 에이전트가 프로덕션 대신 목을 사용해야 하는 이유에 대한 저희 글은 그러한 습관에 대한 더 넓은 사례를 제시합니다.
키를 추가할 수 없을 때
때로는 API가 당신의 것이 아니며 멱등성 지원이 없을 수도 있습니다. 여전히 대략적인 선호도 순서대로 선택지가 있습니다.
- 작업을 자연스럽게 멱등하게 만듭니다. 클라이언트가 선택하는 리소스 경로에 대한
PUT은 본질적으로 멱등합니다:PUT /orders/{client_order_id}. API 디자인을 제어한다면,POST와 헤더를 사용하는 것보다 이것을 선호하십시오. 추가 테이블이 필요하지 않습니다. - 쓰기 전에 확인합니다. 에이전트가 레코드를 생성하기 전에 동일한 자연 키를 가진 기존 레코드를 조회하도록 합니다. 이는 확인과 쓰기 사이에 경쟁 상태가 발생하여 여전히 두 개의 레코드를 생성할 수 있으므로 더 약하지만, 흔한 타임아웃 사례는 제거합니다.
- 다운스트림에서 중복을 제거합니다. 쓰기 작업이 메시지 또는 이벤트인 경우, 컨슈머에서 중복 제거를 수행합니다. 안정적인 메시지 ID를 첨부하고 컨슈머가 중복을 삭제하도록 합니다. 이는 이벤트 기반 시스템의 표준 관행이며, 안정적인 웹훅 가이드의 지침과 함께 사용됩니다.
- 작업을 게이트합니다. 진정으로 되돌릴 수 없거나 멱등하게 만들 수 없는 작업의 경우, 사람을 앞에 두십시오. 이는 AI 에이전트 가드레일에 대한 저희 글의 승인 게이트 패턴이며, 중복의 비용이 충분히 높을 때 올바른 해답입니다.
어떤 실행이 무엇을 했는지 알기
멱등성은 중복을 막습니다. 어떤 시도가 레코드를 생성했는지는 알려주지 않으며, 이는 사고 후 받게 되는 질문입니다.
작업에 실행 ID를 계속 연결하십시오. 에이전트가 귀하의 서비스인 경우, 이는 위에서 키 파생에 사용된 작업 ID와 단계 ID를 모든 시도와 함께 기록하는 것을 의미합니다. 에이전트가 할당된 작업을 실행하는 코딩 런타임인 경우, 플랫폼이 일반적으로 이를 보유합니다. Sharkly에서는 각 실행이 해당 작업과 연결되며, 실행 상태와 결과가 댓글 스레드와 함께 저장되므로 반복된 쓰기는 익명 재시도가 아닌 특정 실행으로 추적됩니다.

배포 전 체크리스트
- 에이전트가 호출할 수 있는 모든 비멱등성 도구는 멱등성 키를 필요로 하며, 도구 래퍼는 키 없이는 전송을 거부합니다.
- 키는 시도에서 파생되는 것이 아니라 작업과 단계에서 파생됩니다.
- 서버는 작업을 수행하기 전에 키를 선점하며, 작업 후에 선점하지 않습니다.
- 다른 페이로드와 동일한 키는 캐시된 응답 대신 오류를 반환합니다.
- 동시 중복은 애플리케이션 타이밍이 아닌 데이터베이스 제약 조건에 의해 처리됩니다.
- 저장된 테스트는 두 번째 호출이 아무것도 변경하지 않는다는 것을 증명하며, CI에서 실행됩니다.
- 키는 일정에 따라 만료되고 테이블은 정리됩니다.
이 목록을 따라 작업하면 이중 청구 이야기는 더 이상 불가능해지며, 이는 재시도 정책이 덜 공격적이 아니라 더 공격적이 될 수 있음을 의미합니다. 이것이 진정한 보상입니다. 멱등성은 에이전트를 위험하게 만들지 않으면서도 탄력적으로 만들 수 있게 해줍니다.
자주 묻는 질문
- 읽기 전용 도구에도 멱등성 키가 필요한가요? 아니요.
GET요청은 이미 멱등하고 안전하므로, 이를 재시도하는 것은 약간의 지연 시간만 발생시킬 뿐 다른 비용은 없습니다. 생성, 청구, 전송 또는 기타 상태를 변경하는 호출에 키를 사용하십시오. - 키는 에이전트에서 생성해야 하나요, 아니면 도구 래퍼에서 생성해야 하나요? 도구 래퍼에서 에이전트의 작업 및 단계 식별자를 기반으로 생성해야 합니다. 모델이 키를 생성하도록 하는 것은 실수입니다. 모델은 재시도 시 값을 재생성하며, 작업 간에 충돌을 일으킬 수 있습니다.
- 반복된 요청은 어떤 상태 코드를 반환해야 하나요? 원래 호출에서 저장된 상태를 반환해야 합니다. 따라서 처음
201을 반환한 두 번째POST는 동일한 본문과 함께 다시201을 반환합니다. 일부 API는 반복을 표시하기 위해Idempotent-Replay: true와 같은 헤더를 추가하는데, 이는 디버깅에 유용하고 이를 무시하는 클라이언트에게는 무해합니다. - 키는 얼마나 오래 보관해야 하나요? 24시간이면 거의 모든 재시도 기간을 포괄합니다. 더 긴 보관은 거의 도움이 되지 않으며 테이블을 무한정 증가시킵니다. 클라이언트가 해당 기간 이후에 재시도하면 새로운 작업으로 취급하십시오.
- 이것이 트랜잭션을 대체하나요? 아니요. 멱등성 키는 중복 요청이 중복 효과를 생성하는 것을 막습니다. 트랜잭션은 단일 요청을 원자적으로 유지합니다. 둘 다 필요하며, 데이터베이스가 허용하는 경우 키 선점은 작업과 동일한 트랜잭션으로 작성되어야 합니다.
- 실제 결제 공급자 없이 이것을 어떻게 테스트하나요? 에이전트를 페이로드 불일치 시
422를 포함하여 키 시맨틱스를 구현하는 목(mock)으로 가리키십시오. 목 API를 사용하여 AI 에이전트 테스트에 대한 저희 가이드는 설정에 대해 다루며, 목과 재시도 테스트를 동일한 프로젝트에서 사용하려면 Apidog를 다운로드하십시오.
