AI 에이전트 멱등성: 재시도로 인한 이중 청구 방지

에이전트 재시도는 중복 청구 및 중복 주문을 생성합니다. 멱등성 키가 어떻게 작동하는지, 작업 단계별로 키를 생성하는 방법, 그리고 두 번째 호출이 아무것도 변경하지 않는지 테스트하는 방법을 알아보세요.

Ashley Innocent

Ashley Innocent

26 August 2026

AI 에이전트 멱등성: 재시도로 인한 이중 청구 방지

Apidog 엔터프라이즈

온프레미스 배포

SSO & RBAC

SOC 2 준수

Apidog Enterprise 살펴보기

에이전트가 결제 엔드포인트를 호출했습니다. 요청은 처리되었고, 청구는 완료되었지만, 응답이 돌아오는 길에 타임아웃되었습니다. 에이전트는 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가 변경되고 키도 변경됩니다. 이것이 바로 원하는 동작입니다.

서버가 해야 할 일

헤더를 올바르게 처리하는 것은 단순한 조회 이상을 필요로 합니다. 작동하는 구현은 네 가지를 수행합니다:

  1. 도착 시, 키를 선점하려고 시도합니다. 어떤 작업을 수행하기 전에 고유 제약 조건이 있는 테이블에 삽입합니다. 삽입에 실패하면 다른 시도가 해당 키를 소유한 것입니다.
  2. 키가 존재하고 저장된 요청 지문이 다른 경우, 422로 거부합니다. 다른 본문과 동일한 키는 클라이언트 버그를 의미하며, 이전 결과를 조용히 반환하면 이를 숨기게 됩니다.
  3. 키가 존재하고 첫 번째 시도가 여전히 진행 중인 경우, 호출자가 경쟁하는 대신 물러나도록 409를 반환합니다.
  4. 작업이 완료되면 상태 코드와 본문을 키에 저장한 다음, 이후 모든 요청에 대해 이를 반환합니다.
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을 반환합니다.

따라서 응답이 아닌 상태를 단언합니다:

Apidog에서는 이를 테스트 시나리오로 구성할 수 있습니다. 1단계는 고정된 Idempotency-Key와 함께 POST를 보내고, 2단계는 이를 반복하며, 3단계는 리소스를 나열하고 개수를 단언합니다. 1단계에서 받은 응답 ID를 변수에 저장하고, 2단계가 동일한 값을 반환하는지 단언합니다. 전체 시나리오가 저장되므로, 결제 경로에 대한 모든 변경 시 CI에서 실행되며, 이는 회귀가 실제로 나타나는 지점입니다. 동일한 기술이 API 계약 테스트 가이드의 더 넓은 패턴에도 적용됩니다.

실제 버그를 잡을 수 있기 때문에 다룰 가치가 있는 두 가지 사례가 더 있습니다:

목킹(Mocking)도 여기에서 도움이 됩니다. 에이전트를 아직 구축 중이고 결제 API가 아직 존재하지 않는 경우, 멱등성을 인식하는 응답으로 목킹하여 에이전트의 재시도 로직이 일찍부터 연습될 수 있도록 합니다. 에이전트가 프로덕션 대신 목을 사용해야 하는 이유에 대한 저희 글은 그러한 습관에 대한 더 넓은 사례를 제시합니다.

키를 추가할 수 없을 때

때로는 API가 당신의 것이 아니며 멱등성 지원이 없을 수도 있습니다. 여전히 대략적인 선호도 순서대로 선택지가 있습니다.

어떤 실행이 무엇을 했는지 알기

멱등성은 중복을 막습니다. 어떤 시도가 레코드를 생성했는지는 알려주지 않으며, 이는 사고 후 받게 되는 질문입니다.

작업에 실행 ID를 계속 연결하십시오. 에이전트가 귀하의 서비스인 경우, 이는 위에서 키 파생에 사용된 작업 ID와 단계 ID를 모든 시도와 함께 기록하는 것을 의미합니다. 에이전트가 할당된 작업을 실행하는 코딩 런타임인 경우, 플랫폼이 일반적으로 이를 보유합니다. Sharkly에서는 각 실행이 해당 작업과 연결되며, 실행 상태와 결과가 댓글 스레드와 함께 저장되므로 반복된 쓰기는 익명 재시도가 아닌 특정 실행으로 추적됩니다.

배포 전 체크리스트

이 목록을 따라 작업하면 이중 청구 이야기는 더 이상 불가능해지며, 이는 재시도 정책이 덜 공격적이 아니라 더 공격적이 될 수 있음을 의미합니다. 이것이 진정한 보상입니다. 멱등성은 에이전트를 위험하게 만들지 않으면서도 탄력적으로 만들 수 있게 해줍니다.

자주 묻는 질문

Apidog에서 API 설계-첫 번째 연습

API를 더 쉽게 구축하고 사용하는 방법을 발견하세요