에이전트의 재시도는 왜 결제를 두 번 실행하는가: 도구 호출 멱등성 설계
에이전트가 외부 도구를 호출하다 타임아웃을 만나면 가장 자연스러운 복구는 재시도다. 하지만 charge_card, send_email, create_ticket처럼 상태를 바꾸는 도구는 같은 호출을 두 번 실행해도 같은 결과가 되지 않는다. 모델이 같은 인자를 다시 만들었다고 해서 안전한 것도 아니다. 첫 요청이 실제로 성공했지만 응답만 유실된 상태일 수 있기 때문이다.
이 문제는 프롬프트로 “중복 실행하지 마”라고 써서 해결되지 않는다. 실행 계층이 요청의 정체성을 저장하고, 부작용이 발생했는지 조회하며, 재시도와 보상을 구분해야 한다. 아래에서는 에이전트 도구 계약에 멱등성 키를 넣는 방법부터 승인 UI, outbox, 감사 로그까지 실제 구현 단위로 정리한다.
실패는 성공과 실패 두 상태로 나뉘지 않는다
외부 API 호출 결과는 성공, 명시적 실패, 결과 불명이라는 세 상태로 봐야 한다. HTTP 400처럼 서버가 거부했다고 확실한 경우는 실패다. HTTP 200과 거래 ID를 받았다면 성공이다. 문제는 클라이언트 타임아웃, 연결 리셋, 워커 종료다. 서버가 처리 전 끊었는지, 처리 후 응답만 잃었는지 호출자 입장에서는 모른다.
에이전트 런타임이 결과 불명을 단순 실패로 기록하면 모델은 다음 step에서 다시 호출한다. 카드 승인은 두 번 잡히고, Slack 알림은 중복 전송되며, CRM에는 같은 리드가 두 개 생긴다. 따라서 tool result 스키마에 status: succeeded|failed|unknown을 두고, unknown일 때는 재호출보다 조회 도구 get_operation_status를 우선 실행하도록 상태 머신을 설계해야 한다.
멱등성 키는 인자 해시와 다르다
멱등성 키는 “이 사업상 작업은 한 번만 실행되어야 한다”는 식별자다. customer_id + amount를 해시하면 같은 고객에게 같은 금액을 두 번 청구해야 하는 정상 거래까지 하나로 합쳐질 수 있다. 반대로 모델이 메모 필드의 문장부호를 바꾸면 인자 해시가 달라져 같은 작업이 중복 실행된다. 사업 이벤트에서 발급한 order_id나 invoice_id를 기준으로 키를 만든다.
예를 들어 charge_card(order_481, 39000)의 키는 charge:order_481:v1처럼 도구명, 도메인 식별자, 작업 버전을 포함할 수 있다. DB에는 키, canonical arguments, 상태, 외부 provider operation ID, 최초 시각, 최종 응답을 저장한다. 같은 키가 다시 오면 인자가 동일한지 검증하고 저장된 결과를 반환한다. 인자가 다르면 새 실행이 아니라 409 idempotency_conflict로 막아야 한다.
도구 스키마에 실행 의미를 드러내는 필드
모델에게 노출하는 JSON Schema에는 단순히 amount와 email만 두지 않는다. operation_id, reason, expected_version처럼 실행 안전성에 필요한 필드를 런타임이 채우거나 모델이 선택하도록 구분한다. 멱등성 키 자체는 모델이 자유 생성하게 두지 않고 오케스트레이터가 workflow run과 business object에서 결정하는 편이 안전하다.
도구 메타데이터에는 side_effect: none|reversible|irreversible, retry_policy, requires_confirmation, status_tool을 둔다. 날씨 조회는 자동 재시도가 가능하지만 송금은 unknown 상태에서 조회만 허용한다. 이메일 발송은 되돌릴 수 없지만 초안 생성은 부작용이 없다. 같은 “메일 도구” 안에서도 create_draft와 send_draft를 분리하면 승인 경계와 재시도 정책이 선명해진다.
시나리오 1: 결제 API의 응답이 10초 뒤 끊겼을 때
고객 주문 워크플로가 결제를 호출하고 10초 타임아웃을 만났다고 하자. 나쁜 구현은 세 번 지수 백오프로 POST를 반복한다. 좋은 구현은 최초 호출 전에 로컬 operation row를 pending으로 커밋하고, 동일한 idempotency key를 결제 사업자에도 전달한다. 타임아웃이면 로컬 상태를 unknown으로 바꾸고 provider의 거래 조회 API를 호출한다.
조회에서 승인 거래 ID가 나오면 succeeded로 확정하고 다음 단계로 간다. 미존재가 확실할 때만 같은 키로 재시도한다. 조회 API조차 불안정하면 사람 검토 큐로 보내며 새 키를 발급하지 않는다. 사용자 화면에는 “실패” 대신 “결제 결과 확인 중”을 보여준다. 상태를 정확히 표현하는 UX가 중복 버튼 클릭을 막는 안전장치이기도 하다.
시나리오 2: 에이전트가 고객에게 메일을 두 번 보낼 때
메일 전송 API가 멱등성 키를 지원하지 않는 경우가 많다. 이때 애플리케이션 DB에서 send_intent_id를 유일 키로 잡고 outbox 패턴을 쓴다. 에이전트는 발송 intent를 생성하고, 별도 워커가 outbox 레코드를 가져가 실제 발송한다. 워커가 죽어도 레코드는 남지만, SMTP 단계 직후 죽으면 여전히 결과 불명이 생긴다.
완벽한 exactly-once 전송을 약속하기보다 중복 확률과 피해를 줄인다. Message-ID를 고정하고 공급자 delivery event를 수집하며, recipient + campaign + business_event에 유일 제약을 둔다. 중요한 계약 해지 메일은 자동 발송이 아니라 초안과 승인으로 분리한다. 운영 화면에는 “모델이 send_email을 호출했다”가 아니라 intent 생성, provider 수락, delivered/bounced 이벤트를 단계별로 보여줘야 한다.
승인 화면은 호출 직전의 인자를 고정해야 한다
사람 승인 버튼만 추가하고 승인 후 모델이 인자를 다시 생성하게 하면 승인한 내용과 실행한 내용이 달라질 수 있다. 승인 대상은 자연어 요약뿐 아니라 canonical tool name, canonical arguments, operation ID, 예상 부작용을 포함한 불변 스냅샷이어야 한다. 승인 후에는 그 스냅샷을 실행하고 모델이 수정하려면 새 승인을 받아야 한다.
예를 들어 “김대리에게 3만9천 원 환불” 카드에는 고객 ID, 주문 ID, 결제 수단 끝 네 자리, 금액, 사유를 표시한다. 승인 토큰은 스냅샷 해시와 만료 시각에 묶는다. 오래 열린 브라우저 탭에서 승인했는데 주문 상태가 이미 바뀌었을 수 있으므로 실행 시 expected_version이나 현재 상태를 재검증한다. 이것이 optimistic concurrency control이 에이전트 UX와 만나는 지점이다.
보상 트랜잭션을 롤백으로 착각하지 않기
여러 도구를 연속 호출하는 워크플로는 데이터베이스 트랜잭션처럼 원자적으로 되돌릴 수 없다. 항공권을 취소해도 수수료가 남고, 보낸 메일은 회수되지 않는다. 따라서 각 단계에 compensating action이 있는지, 비용과 승인 조건은 무엇인지 미리 정의한다. reserve_inventory에는 release_inventory가 있지만 publish_public_post에는 진짜 역연산이 없다.
워크플로 상태에는 완료 단계 목록과 보상 상태를 저장한다. 세 번째 단계가 실패하면 모델에게 막연히 “복구해”라고 하지 않고 정책 엔진이 허용된 보상 순서를 실행한다. 보상도 자체 operation ID와 멱등성 키를 가져야 한다. 취소 호출의 재시도가 원래 예약보다 더 위험할 수 있기 때문이다. irreversible 단계는 가능한 한 마지막에 배치한다.
운영자가 남겨야 할 로그
각 tool attempt에 workflow_run_id, step_id, operation_id, idempotency_key_hash, tool_name, canonical_args_hash, attempt_no, side_effect_class, started_at, finished_at, result_status, provider_operation_id를 남긴다. 키 원문이나 민감 인자를 로그에 그대로 쓰지 않고 해시와 마스킹된 요약을 저장한다. unknown에서 succeeded로 바뀐 근거가 조회인지 webhook인지도 기록한다.
알람은 단순 tool error rate보다 unknown_age_seconds, 동일 operation의 attempt 수, idempotency conflict 수, 수동 검토 큐 체류시간에 건다. 중복 피해가 발견되면 모델 답변을 분석하기 전에 같은 operation ID가 어디서 재발급됐는지 추적한다. 에이전트의 신뢰성은 모델이 도구명을 잘 고르는 비율만이 아니라, 네트워크가 애매하게 실패해도 한 사업 이벤트를 한 번만 처리하는 실행 계층에서 결정된다.
재시도 정책을 코드 리뷰할 때 보는 순서
도구 어댑터를 리뷰할 때 첫 질문은 “몇 번 재시도하는가”가 아니라 “어떤 실패가 재시도 가능한가”다. DNS 실패처럼 서버에 도달하지 않았다고 비교적 확실한 경우, HTTP 429처럼 처리되지 않았다고 계약된 경우, 응답 본문 파싱 실패처럼 서버 처리 여부가 불명인 경우를 분리한다. 라이브러리 기본 retry가 POST에도 적용되는지 반드시 확인한다. 애플리케이션 재시도와 HTTP 클라이언트 재시도가 겹치면 설정상 3회가 실제로는 9회가 된다.
다음으로 operation row가 외부 호출보다 먼저 커밋되는지 본다. 호출 후 기록하면 프로세스가 그 사이 죽을 때 실행 흔적이 사라진다. DB 트랜잭션 안에서 네트워크 호출을 오래 잡는 방식도 피한다. intent를 커밋하고 워커가 claim한 뒤 호출하며, lease 만료와 재할당 규칙을 둔다. 워커 두 개가 동시에 claim하지 못하도록 unique constraint나 SELECT ... FOR UPDATE SKIP LOCKED 같은 동시성 제어가 필요하다.
마지막으로 운영자가 손으로 해결할 수 있는지를 본다. unknown operation 화면에는 원래 인자, 외부 조회 결과, 관련 webhook, 가능한 조치가 보여야 한다. ‘재실행’ 버튼은 새 operation을 조용히 만들지 않고 기존 키를 사용해야 한다. 불가피하게 새 작업을 만들면 원래 operation과 supersedes 관계를 남기고 2인 승인을 요구한다. 수동 복구 경로까지 멱등하지 않으면 가장 긴급한 순간에 중복이 발생한다.
팀 회의에서 숫자로 답해야 할 질문
이 주제를 운영 회의에 올릴 때는 ‘좋아졌다’는 표현을 금지하고 unknown operation 체류시간, idempotency conflict, provider operation ID의 이전 값, 변경 후 값, 표본 수를 한 화면에 놓는다. 특히 응답 유실 뒤 동일 결제 재시도를 재현한 부하 또는 회귀 샘플을 고정해야 다음 배포와 비교할 수 있다. 정상 요청만 모은 평균은 사고 직전의 위험을 설명하지 못한다. 실패·강등·재시도가 포함된 분모와 제외 규칙도 함께 기록한다.
담당자는 지표 변화에 대응하는 단 하나의 조치를 미리 연결한다. unknown operation 체류시간가 경계를 넘으면 어떤 배포를 되돌릴지, idempotency conflict가 늘면 어떤 큐나 샘플을 열어볼지, provider operation ID의 이상을 누가 확인할지를 runbook에 적는다. 알람이 다섯 개 동시에 울리는데 모두 ‘조사 필요’로 끝나면 제어면이 없는 것과 같다. 변경자는 release ID와 예상 효과를 남기고, 관찰 창이 끝난 뒤 실제 효과와 부작용을 기록한다.
마지막 검토에서는 비용과 품질을 같은 요청 ID로 연결한다. 비용이 줄었지만 실패 재시도가 늘었거나, 정확도가 올랐지만 p95가 제품 예산을 넘으면 성공이 아니다. 이 글에서 제시한 메트릭은 보고용 숫자가 아니라 다음 행동을 고르는 분기 조건이다. 임계치, 관찰 시간, rollback 조건까지 적혀 있을 때 비로소 구현 지식이 운영 정책이 된다.