AI 에이전트의 중복 결제를 막는 멱등성 구현 가이드
에이전트가 결제 엔드포인트를 호출했습니다. 요청은 처리되어 청구까지 완료됐지만, 응답이 돌아오는 중 타임아웃이 발생했습니다. 에이전트는 200 응답을 받지 못했기 때문에 실패 시 지시에 따라 재시도했고, 고객은 두 번 청구됐습니다. 로그에는 명확한 오류가 남지 않았습니다.
이것이 에이전트와 일반 API 클라이언트의 중요한 차이입니다. 사람은 결제 버튼을 한 번 누른 뒤 로딩 상태를 확인하지만, 재시도 루프에 있는 에이전트는 응답이 없으면 즉시 다시 시도합니다. 때로는 사람이 할 수 있는 것보다 빠르게 세네 번 연속 요청을 보냅니다.
재시도 정책을 추가할수록 중복 쓰기 가능성도 커집니다. 해결책은 멱등성(idempotency) 입니다. 같은 요청을 여러 번 보내도 한 번 보낸 것과 동일한 서버 상태를 만들도록 설계하는 것입니다.
이 글에서는 다음을 다룹니다.
- HTTP 수준에서 멱등성이 의미하는 것
- 에이전트가 재시도에 재사용할 수 있는 키 생성법
- 서버가 멱등성 키를 처리하며 저장해야 할 데이터
- 실제 고객이 중복 청구되기 전에 검증하는 테스트 방법
AI 에이전트가 프로덕션에서 고장나는 이유에서도 설명했듯이, 중복 쓰기는 “에이전트가 두 번 처리했다”는 장애 보고의 대표적인 원인입니다.
Apidog는 테스트 예제에 사용합니다. 멱등성은 API와 에이전트 도구 레이어에 먼저 구현해야 합니다. 이후에는 동일한 요청을 두 번 실행해 두 번째 요청이 상태를 변경하지 않았음을 증명하고, 그 테스트를 CI에서 반복 실행하면 됩니다.
에이전트가 사람보다 멱등성을 자주 깨뜨리는 이유
에이전트 트래픽에서 중복 요청을 만드는 원인은 크게 세 가지입니다.
1. 적극적인 재시도
에이전트 프레임워크는 일시적인 네트워크 오류를 흔한 실패 원인으로 보고 기본적으로 재시도합니다. 에이전트 오류 복구에서 다루는 백오프와 서킷 브레이커도 서버에 도달하는 요청 횟수를 늘릴 수 있습니다.
2. 모호한 타임아웃
클라이언트가 타임아웃을 받으면 서버가 작업을 처리했는지 알 수 없습니다. 프록시의 504는 다음 두 상황 모두를 의미할 수 있습니다.
- 쓰기 작업이 시작되지 않음
- 쓰기는 완료됐지만 응답이 유실됨
사람은 보통 상태를 확인한 후 재시도하지만, 에이전트에게는 “먼저 확인”을 위한 추가 도구 호출과 판단이 필요합니다.
3. 전체 작업 재시작
다단계 작업에서 1단계로 주문을 생성하고 4단계에서 실패했다고 가정해 보겠습니다. 전체 작업을 단순 재시작하면 두 번째 주문이 생성됩니다.
스크립트와 달리 에이전트의 재시작 경계는 모호하며, 재시작 지점을 코드가 아니라 모델이 결정할 수 있습니다.
결국 에이전트가 잘못된 요청을 보내는 것이 아니라, 올바른 요청을 여러 번 보내는 것이 문제입니다.
멱등성이 보장하는 것
작업을 여러 번 실행해도 한 번 실행한 것과 동일한 효과를 만들면 해당 작업은 멱등적입니다. HTTP 시맨틱스 사양인 RFC 9110은 GET, PUT, DELETE를 멱등 메서드로 정의합니다. POST는 멱등적이지 않으므로 주문 생성, 메시지 전송, 송금 시작처럼 위험한 작업에 주의해야 합니다.
두 가지 개념을 구분해야 합니다.
멱등성은 안전성과 다르다
안전한(safe) 메서드는 서버 상태를 변경하지 않습니다. DELETE는 멱등적이지만 파괴적입니다. 다섯 번 호출해도 리소스가 삭제된 상태라는 최종 결과는 같지만, 리소스 자체는 사라집니다.
에이전트의 도구는 멱등성과 안전성을 별도로 분류해야 합니다. 자격 증명 측면에서는 에이전트를 위한 최소 권한 API 키도 함께 적용하세요.
멱등성은 동일한 응답과 다르다
두 번째 호출은 첫 번째 호출에서 저장한 결과를 반환할 수 있고, 다른 상태 코드를 반환할 수도 있습니다. 중요한 것은 서버 상태가 중복으로 변경되지 않는 것입니다.
- 청구는 한 번만 발생해야 합니다.
- 주문은 하나만 생성되어야 합니다.
- 이메일은 한 번만 전송되어야 합니다.
멱등성 키로 POST 안전하게 만들기
일반적인 해결책은 클라이언트가 요청마다 멱등성 키를 생성해 보내는 것입니다. 서버는 키와 요청 결과를 저장하고, 같은 키를 가진 후속 요청에는 작업을 다시 수행하지 않고 저장된 결과를 반환합니다.
Stripe는 이 패턴을 널리 알렸으며, Stripe 멱등성 문서가 시맨틱스를 명확하게 설명합니다. IETF의 Idempotency-Key 헤더 필드 초안도 자체 헤더를 정의하기 전에 참고할 만합니다.
POST /v1/payments HTTP/1.1
Host: api.yourservice.com
[REDACTED CREDENTIAL] [REDACTED]...
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와 단계 ID에서 파생해야 합니다.
에이전트가 전체 작업을 새로 시작하고 실제로 새로운 청구를 의도한다면 작업 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를 포함해 첫 번째 응답과 일치하는가
- 컬렉션 조회 결과가 두 개가 아니라 하나인가
- 카운터나 잔액이 한 번만 변경됐는가
Apidog에서는 다음과 같은 시나리오로 구성할 수 있습니다.
- 고정된
Idempotency-Key로POST요청을 보냅니다. - 같은 요청을 반복합니다.
- 리소스를 조회하고 레코드 수를 단언합니다.
- 첫 번째 응답의 리소스 ID를 변수에 저장하고 두 번째 응답과 비교합니다.
시나리오를 저장하면 결제 경로가 변경될 때마다 CI에서 실행할 수 있습니다. 이는 API 계약 테스트 가이드의 패턴에도 적용됩니다.
다음 두 사례도 반드시 테스트하세요.
-
동일한 키, 다른 본문: 조용한 성공이 아니라
422를 반환해야 합니다. - 동시 중복 요청: 두 요청을 동시에 보내 정확히 하나만 작업을 수행하는지 확인합니다. 순차 테스트로는 데이터베이스의 누락된 고유 제약 조건을 발견할 수 없습니다.
결제 API가 아직 없다면 멱등성 동작을 구현한 목으로 에이전트를 연결하세요. 페이로드가 다를 때 422를 반환하도록 하면 에이전트의 재시도 로직을 초기 단계부터 검증할 수 있습니다. 자세한 이유는 에이전트가 프로덕션 대신 목 API를 사용해야 하는 이유를 참고하세요.
키를 추가할 수 없을 때
외부 API를 사용하거나 멱등성 지원이 없는 경우에도 다음 대안을 고려할 수 있습니다.
-
작업을 자연스럽게 멱등하게 설계합니다.
클라이언트가 선택한 리소스 경로에
PUT을 사용합니다.
PUT /orders/{client_order_id}
API 디자인을 제어한다면 POST와 별도 헤더를 사용하는 것보다 이 방식을 우선할 수 있습니다. 추가 멱등성 테이블도 필요하지 않습니다.
쓰기 전에 확인합니다.
생성 전에 동일한 자연 키를 가진 레코드를 조회합니다. 확인과 쓰기 사이에 경쟁 상태가 발생할 수 있으므로 완전한 해결책은 아니지만, 일반적인 타임아웃 중복은 줄일 수 있습니다.다운스트림에서 중복을 제거합니다.
메시지나 이벤트라면 안정적인 메시지 ID를 붙이고 컨슈머에서 중복을 제거합니다. 이는 이벤트 기반 시스템의 표준 패턴이며, 안정적인 웹훅 설계 가이드와 함께 사용할 수 있습니다.작업을 게이트합니다.
되돌릴 수 없고 멱등하게 만들 수 없는 작업은 사람의 승인을 거치게 합니다. AI 에이전트 가드레일의 승인 게이트 패턴이 이에 해당합니다.
어떤 실행이 작업을 수행했는지 추적하기
멱등성은 중복을 방지하지만, 어떤 시도가 실제로 레코드를 생성했는지는 알려주지 않습니다. 장애가 발생하면 이 정보가 필요합니다.
모든 작업에 실행 ID를 연결하세요. 자체 서비스의 에이전트라면 키 생성에 사용한 작업 ID와 단계 ID를 모든 시도와 함께 기록합니다. 코딩 런타임처럼 플랫폼이 실행을 관리한다면 플랫폼의 실행 ID를 사용합니다.
Sharkly에서는 각 실행이 작업과 연결되고, 실행 상태와 결과가 댓글 스레드와 함께 저장됩니다. 따라서 반복된 쓰기를 익명의 재시도가 아니라 특정 실행으로 추적할 수 있습니다.
배포 전 체크리스트
- 에이전트가 호출하는 모든 비멱등 도구가 멱등성 키를 요구하는가
- 도구 래퍼가 키 없는 요청을 거부하는가
- 키가 시도가 아니라 작업과 단계에서 파생되는가
- 서버가 작업 실행 전에 키를 선점하는가
- 동일한 키와 다른 페이로드에 오류를 반환하는가
- 동시 중복이 애플리케이션 타이밍이 아니라 데이터베이스 제약 조건으로 처리되는가
- 두 번째 호출이 상태를 변경하지 않음을 증명하는 테스트가 CI에서 실행되는가
- 키가 일정에 따라 만료되고 테이블이 정리되는가
이렇게 설계하면 재시도 정책을 덜 공격적으로 만들지 않고도 에이전트를 더 탄력적으로 운영할 수 있습니다. 멱등성은 에이전트를 위험하게 만들지 않으면서 재시도를 허용하는 기반입니다.
자주 묻는 질문
읽기 전용 도구에도 멱등성 키가 필요한가요?
아니요. GET은 이미 멱등적이고 안전하므로 재시도해도 일반적으로 지연 시간 외의 부작용이 없습니다. 생성, 청구, 전송처럼 상태를 변경하는 호출에 키를 사용하세요.
키는 에이전트와 도구 래퍼 중 어디에서 생성해야 하나요?
도구 래퍼에서 에이전트의 작업 ID와 단계 ID를 기반으로 생성해야 합니다. 모델이 키를 생성하게 하면 재시도 때 값을 다시 만들거나 작업 간 충돌을 일으킬 수 있습니다.
반복 요청은 어떤 상태 코드를 반환해야 하나요?
원래 호출에서 저장된 상태 코드와 본문을 반환하는 것이 일반적입니다. 처음 201을 반환했다면 반복된 POST도 같은 본문과 함께 201을 반환할 수 있습니다.
일부 API는 반복 응답을 표시하기 위해 Idempotent-Replay: true 같은 헤더를 추가합니다. 이는 디버깅에 유용하며, 해당 헤더를 무시하는 클라이언트에도 안전합니다.
키는 얼마나 오래 보관해야 하나요?
24시간이면 대부분의 재시도 기간을 처리할 수 있습니다. 더 오래 보관해도 이점이 적고 테이블만 계속 커집니다. 만료 후 재시도는 새로운 작업으로 처리하세요.
멱등성이 트랜잭션을 대체하나요?
아니요.
- 멱등성 키는 중복 요청이 중복 효과를 만드는 것을 막습니다.
- 트랜잭션은 단일 요청의 작업을 원자적으로 처리합니다.
둘 다 필요합니다. 데이터베이스가 허용한다면 키 선점과 실제 작업을 같은 트랜잭션으로 처리하세요.
실제 결제 공급자 없이 어떻게 테스트하나요?
페이로드가 불일치할 때 422를 반환하는 멱등성 지원 목으로 에이전트를 연결하세요. 목 API로 AI 에이전트 테스트하기에서 설정 방법을 확인할 수 있습니다. 목과 재시도 테스트를 같은 프로젝트에서 실행하려면 Apidog 다운로드를 사용하세요.


Top comments (0)