AI 에이전트가 같은 도구를 두 번 호출한다면
분산 시스템과 AI 워크플로에서 재시도는 정상입니다. DB 제약, 멱등 키, 제한된 재시도로 도구 호출의 중복 부작용을 막는 방법을 정리합니다.
출처 및 AI 안내: 이 글은 제미니의 개발실무 유튜브를 기반으로 작성되었습니다.
gpt-5.6-sol모델을 사용해 생성·편집했습니다.
백엔드에서 같은 요청이 두 번 처리되는 것을 어떻게 막아야 하느냐는 질문을 받았습니다. 눈에 보이는 원인은 버튼을 연달아 누르는 일이었습니다. 어려운 문제는 버튼 바깥에 있었습니다.
클라이언트가 요청을 보냅니다. 서버는 처리를 끝냈지만 응답이 도착하기 전에 연결이 끊깁니다. 클라이언트에는 타임아웃만 보입니다. 작업이 실패했는지, 성공하고 응답만 사라졌는지 알 수 없으니 같은 요청을 다시 보냅니다.
AI 에이전트도 오래된 이 문제를 그대로 물려받았습니다. 모델이 같은 도구를 다시 선택할 수 있고, 오케스트레이션 계층이 시간이 초과된 단계를 재시도할 수 있습니다. 마지막 결과를 잃어버린 워크플로를 사람이 재개할 수도 있습니다. 주문 생성, 환불, 메일 전송, 배포 시작 같은 도구라면 두 번째 호출은 실제 비용이나 피해로 이어집니다.
호출자는 재시도합니다. 반복된 요청이 무엇을 의미하는지는 API가 결정해야 합니다.
Redis보다 비즈니스 제약부터 찾기
중복 요청은 하나의 기술 문제로 묶을 수 없습니다. 기능마다 허용할 수 있는 결과가 다릅니다.
댓글이 우연히 두 번 등록되는 것은 불편하지만 감당할 수 있을지 모릅니다. 같은 쿠폰을 두 번 발급하면 제품 정책을 어길 수 있습니다. 결제가 두 번 발생하는 일은 허용할 수 없습니다. 락이나 캐시, 큐를 고르기 전에 지켜야 할 제약을 평범한 문장으로 적어야 합니다.
한 사용자는 같은 쿠폰을 한 번만 받을 수 있다.
이 규칙에는 이미 (user_id, coupon_id)라는 자연스러운 식별자가 있습니다. 모든 쓰기가 하나의 권위 데이터베이스를 통과한다면 유니크 제약으로 모든 애플리케이션 프로세스에서 규칙을 지킬 수 있습니다.
CREATE TABLE issued_coupon (
user_id BIGINT NOT NULL,
coupon_id BIGINT NOT NULL,
issued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (user_id, coupon_id)
);
두 요청이 동시에 애플리케이션의 exists 검사를 통과할 수는 있습니다. 같은 전역 유니크 인덱스가 두 쓰기에 적용된다면 둘 다 커밋할 수는 없습니다. 데이터베이스가 제약을 지키게 하고, 제약 위반은 제품 요구사항에 맞는 API 응답으로 바꾸면 됩니다.
분산 락 서비스를 추가하기 전에 이 방식부터 검토하는 편이 낫습니다. 이후 Redis를 유입 제어나 부하 완화 용도로 추가하더라도 데이터베이스 제약은 최종 불변식으로 유지해야 합니다. 임대 기반 락은 이 제약을 대신하지 않습니다. 락에 정확성을 의존하려면 소유권 검증, 만료 처리와 보호 대상의 fencing까지 필요합니다.
프론트엔드에서 버튼을 비활성화하는 작업도 사용자 경험에는 도움이 됩니다. 그러나 시스템의 정확성을 보장하지는 못합니다. 요청은 스크립트, 모바일 재시도, 메시지 큐, 에이전트에서도 들어옵니다. 데이터를 소유한 경계에서 정합성을 보장해야 합니다.
자연스러운 식별자가 없다면 멱등 키 사용하기
실행 전에는 적당한 비즈니스 키가 없는 작업도 있습니다. 주문을 만들거나, 메시지를 보내거나, 작업을 시작해 달라는 요청이 그렇습니다. 이때 하나의 논리적 시도에 멱등 키를 부여하고 재시도할 때 같은 키를 사용합니다.
POST /orders
Idempotency-Key: 7dc3e9c2-0f27-4f8e-9aa1-a4b92d2f7834
키의 존재 여부만 저장해서는 부족합니다. 정상적인 재시도와 우연하거나 악의적인 키 재사용을 구분할 수 있어야 합니다.
CREATE TABLE idempotency_request (
scope VARCHAR(100) NOT NULL,
idempotency_key VARCHAR(255) NOT NULL,
request_hash CHAR(64) NOT NULL,
state VARCHAR(20) NOT NULL
CHECK (state IN ('processing', 'succeeded', 'failed')),
owner_token VARCHAR(100),
lease_expires_at TIMESTAMPTZ,
status_code INTEGER,
resource_id VARCHAR(100),
response_body JSONB,
result_expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (scope, idempotency_key)
);
처리 흐름은 다음과 같이 만들 수 있습니다.
- 요청을 인증·인가하고 입력을 검증합니다.
- 작업, 인증된 범위, 경로, 쿼리, 기본값 적용 후의 본문, 관련 헤더와 API 버전처럼 동작에 영향을 주는 입력 전체를 버전 있는 fingerprint로 만들고
request_hash에 저장합니다. (scope, idempotency_key)를processing상태로 원자적으로 선점합니다.- 키가 이미 있다면 기존 요청 해시와 비교합니다.
- 같은 키에 다른 입력이 들어오면 요청을 거절합니다.
- 첫 요청이 종료 상태라면 저장한 결과를 그대로 돌려줍니다.
- 아직 처리 중이라면 문서화한 진행 중 응답을 주거나 짧은 시간만 기다립니다.
- 비즈니스 작업을 끝내고 결과를 저장합니다.
비즈니스 결과가 같은 데이터베이스에 저장된다면 멱등 키 선점, 비즈니스 쓰기와 succeeded 전환을 하나의 트랜잭션에서 수행해야 합니다. 롤백되면 선점과 비즈니스 쓰기가 함께 사라져야 합니다. 원격 호출을 포함한 채 이 트랜잭션을 오래 유지해서는 안 됩니다.
오래 걸리는 작업에는 별도의 소유권 규칙이 필요합니다. 워커가 죽으면 processing 레코드만 남을 수 있고, 느린 워커는 lease가 만료된 뒤에도 살아 있을 수 있습니다. 로컬 쓰기가 fencing되거나 외부 시스템에서 안전하게 중복 제거되는 경우에만 재선점을 허용하고 결과 커밋 때 소유자 또는 fencing token을 검증해야 합니다. 결과 보관 기간과 처리 lease를 분리하고, 보관 정책에 따른 삭제는 종료 상태 레코드에만 적용합니다.
scope에는 재사용의 안전 범위를 결정하는 정보가 들어가야 합니다. 테넌트, 사용자, 도구와 작업 종류가 후보입니다. create_order의 키가 refund_order의 같은 문자열과 충돌해서는 안 됩니다.
응답 전체를 저장하면 같은 결과를 돌려주기 쉽지만 민감한 데이터를 오래 보관하거나 저장 공간을 많이 쓸 수 있습니다. 리소스 ID만 저장하고 응답을 다시 구성하는 방식이 더 안전할 때도 있습니다. 선택은 API 계약에 달려 있습니다. 어느 쪽을 택하든 결과 보관 기간은 호출자의 최대 재시도 기간보다 길어야 합니다. 종료 레코드를 삭제한 뒤 도착한 늦은 재시도는 새로운 작업이 될 수 있습니다.
어떤 실패를 종료 상태로 저장하고 재현할지도 계약으로 정해야 합니다. 입력 검증 실패는 보통 키를 선점하지 않아야 하고, 로컬 응답이 500이었다는 이유만으로 결과를 모르는 외부 부작용을 다시 실행해서는 안 됩니다.
클라이언트가 만든 키와 신뢰는 다른 문제다
클라이언트가 생성한 멱등 키는 서버가 중요한 작업의 보호를 클라이언트 입력에 맡기는 것처럼 보여 위험하게 느껴질 수 있습니다. 하지만 이는 서로 다른 책임을 섞은 판단입니다.
Stripe API는 클라이언트가 충분히 무작위인 키를 만들도록 합니다. 엔드포인트 실행이 시작된 뒤에는 500 응답까지 포함해 상태 코드와 응답 본문을 저장하고, 같은 키가 다시 오면 그 결과를 돌려줍니다. 입력 검증 실패나 다른 동시 실행과의 충돌처럼 실행이 시작되지 않은 결과는 저장하지 않으므로 다시 시도할 수 있습니다. 키는 최소 24시간이 지난 뒤 제거될 수 있으며, 제거된 키를 다시 사용하면 새로운 요청으로 처리됩니다. 호출자가 작업할 권한이 있는지는 여전히 인증과 인가가 판단합니다. 멱등 키는 하나의 논리적 시도에서 발생한 재시도만 식별합니다.
서버가 키를 발급하는 방식도 유효합니다. 체크아웃 세션이나 준비 단계가 있는 워크플로에 잘 맞을 수 있습니다. 하지만 자동으로 더 안전한 것은 아니며, 제출되지 않은 세션이 많으면 쓰이지 않는 레코드가 쌓입니다. 제품 흐름에 따라 선택하면 됩니다.
클라이언트가 키를 만든다면 충분한 엔트로피를 사용하고 개인정보를 넣지 않아야 합니다. 서버는 키의 최대 길이와 허용 형식을 검증하고, 인증된 주체별 보관량과 할당량을 제한하며, 과도한 키 생성을 거절해야 합니다. 키를 작업 범위에 묶고, 재사용되면 요청 fingerprint 전체를 비교하며, 보관 기간과 결과 재현 방식을 API 계약에 명시합니다. 관측용 로그에는 원문 키가 안전하다고 가정하지 말고 해시값이나 길이가 제한되고 이스케이프된 표현을 남기는 편이 안전합니다.
멱등 키는 인증, 인가, 호출 빈도 제한, 어뷰징 방지를 대신하지 않습니다.
외부 부작용 사이의 틈 닫기
로컬 멱등 테이블만으로 모든 중복을 막을 수는 없습니다.
서비스가 결제 시스템을 호출한다고 가정해 보겠습니다. 결제는 성공했는데 로컬 멱등 레코드를 succeeded로 바꾸기 전에 프로세스가 죽습니다. 재시도된 로컬 작업이 결제를 다시 요청하면 고객에게 두 번 청구될 수 있습니다.
멱등 식별자를 외부 부작용 경계까지 전달해야 합니다. 들어온 원본 키를 그대로 넘기지는 않습니다. 전체 로컬 작업 식별자로부터 안정적인 외부 멱등 키를 파생하거나 영속화된 고유 하위 작업 ID를 사용합니다. 이 키의 유일성 범위는 외부 공급자의 멱등 네임스페이스와 일치해야 하며, 서로 다른 외부 부작용마다 별도의 키가 필요합니다. 정합성 확인을 위해 공급자 요청 ID와 리소스 ID도 저장합니다.
비동기 작업에서는 비즈니스 변경과 아웃박스 레코드를 한 데이터베이스 트랜잭션에 기록합니다. 소비자도 고유한 이벤트 또는 명령 ID를 기준으로 쓰기 작업을 멱등하게 만들어야 합니다.
원격 상태 조회는 고유 상관 ID로 조회할 수 있고 공급자가 충분히 강한 read-after-write 계약을 제공할 때만 재시도 판단에 사용할 수 있습니다. 최종 일관적이거나 요청을 고유하게 연결할 수 없는 시스템의 not found는 실행되지 않았다는 증거가 아닙니다. 이런 경우 부작용을 자동으로 다시 실행하지 말고 unknown으로 기록한 뒤 정합성 확인 또는 보상 절차를 수행합니다. 타임아웃은 호출자가 기다리기를 포기했다는 뜻입니다. 원격 시스템이 아무 작업도 하지 않았다는 증거는 아닙니다.
그래서 분산 워크플로 전체에 “정확히 한 번”이라는 표현을 쉽게 쓰지 않습니다. 특정 경계에서 비즈니스 결과를 하나로 만들 수는 있습니다. 그 경계 사이의 전달도 각각 설계해야 합니다.
AI 도구는 부작용을 계약에 드러내기
도구 이름만으로 안전 정책을 정하기는 어렵습니다. update_record는 대상에 따라 무해할 수도, 비용이 클 수도, 파괴적일 수도 있습니다. 저는 도구 계약에서 이런 효과를 직접 드러내는 쪽을 선호합니다.
- 읽기만 하는가?
- 외부 상태를 수정하거나 삭제하는가?
- 같은 입력을 반복하면 부작용이 한 번 더 발생하는가?
- 통제된 범위 밖의 외부 대상과 상호작용하는가?
- 사람의 확인이 필요한가?
Model Context Protocol은 도구 annotation으로 readOnlyHint, destructiveHint, idempotentHint, openWorldHint를 제공합니다. idempotentHint의 정의는 오케스트레이터가 알고 싶어 하는 내용과 가깝습니다. 같은 인자로 도구를 반복 호출해도 환경에 추가 효과가 없어야 합니다.
MCP 스키마에서 idempotentHint의 기본값은 false이며 readOnlyHint == false일 때만 의미가 있습니다. 명세는 이 값들이 보장 사항이 아니라 힌트라고도 명시합니다. 신뢰할 수 없는 서버가 보낸 annotation만으로 도구 사용을 결정해서는 안 됩니다. idempotentHint: true라고 적는다고 도구가 멱등해지지는 않습니다. 서버의 저장 제약과 부작용 처리 방식이 멱등성을 만듭니다.
멱등성은 사용자가 최초 호출을 원했다는 사실도 증명하지 않습니다. 환불, 배포, 데이터 삭제처럼 영향이 큰 작업에는 여전히 승인 단계가 필요할 수 있습니다. 중복 안전성과 사람의 의도 확인은 서로 다른 문제입니다.
재시도에 예산 두기
재시도는 짧은 장애에서 가용성을 높입니다. 동시에 이미 힘든 시스템에 부하를 추가합니다.
AWS Builders’ Library는 이런 이유로 재시도를 “이기적”이라고 표현합니다. 타임아웃, 제한된 지수 백오프와 지터를 함께 사용하라고 권합니다. 타임아웃만으로 원격 부작용의 발생 여부를 알 수 없으므로 부작용이 있는 API를 멱등하게 만든 뒤 재시도해야 한다고도 설명합니다.
기본적으로 재시도를 소유하는 계층을 하나만 선택합니다. 호출 스택에서 시도 횟수가 곱해지지 않도록 하위 클라이언트의 재시도를 끄거나 상위 계층에 위임합니다. 그 계층의 정책을 정할 때 저는 다음을 확인합니다.
- 어떤 실패를 일시적이라고 판단해 재시도하는가?
- 워크플로 전체에서 몇 번까지 시도할 수 있는가?
- 중첩된 클라이언트가 재시도를 곱하지 않도록 어느 계층이 책임지는가?
- 시도 사이에 어떤 백오프와 지터를 적용하는가?
- 운영자가 하나의 trace에서 최초 호출과 모든 재시도를 확인할 수 있는가?
- 언제 자동 처리를 멈추고 사람에게 정합성 확인을 요청하는가?
재시도 횟수에 상한이 없으면 짧은 장애가 끝나지 않는 사고로 커질 수 있습니다.
정상 흐름보다 경합을 테스트하기
순차 테스트만으로는 이 문제를 충분히 검증할 수 없습니다. 서로 다른 애플리케이션 인스턴스에 같은 요청을 동시에 보내야 합니다. 비즈니스 쓰기가 끝난 뒤 응답이 오기 전에 연결을 끊어보고, 외부 부작용과 로컬 상태 변경 사이에서 워커를 종료해 봐야 합니다. 처리 lease를 만료시켜 이전 워커가 더는 커밋하지 못하는지 확인하고, 종료 상태 레코드를 삭제한 뒤에도 재시도해 봅니다.
HTTP 응답만 맞는지 보지 말고 비즈니스 결과를 검증합니다.
- 쿠폰은 하나만 있는가?
- 주문은 하나만 생성됐는가?
- 환불은 외부 시스템에 한 번만 도착했는가?
- 배포는 한 번만 시작됐는가?
- 반복된 요청은 문서화된 일관된 결과를 받았는가?
운영 중에 추적할 정보도 남겨야 합니다. 멱등 키의 해시값, 요청 또는 워크플로 ID, 시도 횟수, 도구 이름, 결과, 외부 correlation ID를 함께 기록합니다. 재사용 검증에 해시가 필요하다는 이유로 비밀값이나 민감한 요청 본문까지 로그에 남겨서는 안 됩니다.
문제의 크기에 맞게 설계하기
AI 에이전트가 중복 실행 문제를 새로 만들지는 않았습니다. 하나의 의도가 하나의 호출만 만든다는 오래된 가정을 더 자주 깨뜨릴 뿐입니다.
비즈니스 제약부터 찾고, 데이터에 자연스러운 고유 키가 있다면 데이터베이스 제약을 사용합니다. 하나의 논리적 시도를 따로 식별해야 할 때 멱등 레코드를 추가합니다. 그 식별자를 외부 부작용까지 전달하고, 재시도 횟수를 제한하고, 동시에 들어오는 요청을 테스트합니다.
같은 도구를 두 번 불렀을 때 결제나 메일 전송, 삭제, 배포가 그대로 두 번 실행된다면 그 도구는 아직 에이전트에게 맡길 준비가 되지 않았습니다.