도구 호출은 어떻게 "딱 한 번만" 실행되게 만드는가

← 전체 목차 · 이전: 도구 호출 실패, 재시도인가 보상인가 · 다음: 다단계 도구 호출, 중간에 실패하면 전부 어떻게 되돌리나

"이미 실행했는지 검사 → 실행 → 완료 표시"라는 3줄짜리 방어 코드는 작동하는 것처럼 보이지만 검사와 실행 사이에 경쟁 창구가 열려 있다. 프런트엔드 디바운스는 대화 인터페이스를 직접 두 번 호출하면 그대로 뚫리고, Redis 분산락은 실행 시간이 락 만료 시간을 넘기는 순간 두 번째 요청도 같은 락을 잡는다. 분산락은 동시성 제어이지 멱등성 보장이 아니다 — 이 둘을 같은 것으로 착각하는 순간 설계는 무너진다.

멱등의 단위는 HTTP 요청이 아니라 업무 의미론이다

정의를 먼저 바로잡아야 한다: 멱등은 "같은 요청을 한 번만 처리한다"가 아니라 **"같은 업무 의미의 중복 요청이 같은 결과를 반환한다"**는 것이다. 이 차이가 멱등 키를 무엇으로 만들지를 결정한다 — 전송 계층의 요청 지문이 아니라 **세션 + 업무 키(또는 클라이언트가 생성한 전역 고유 request_id)**로 구성해야 한다.

중복은 이상이 아니라 Agent 링크의 상시 속성이다

중복 발생 지점구체적 경로
다회 대화 컨텍스트같은 질문 재입력, 컨텍스트가 같은 도구를 다시 트리거
도구 호출 타임아웃호출자가 재시도하는데 서버는 이미 처리 완료
스트리밍 중단토큰이 일부만 반환된 채 끊김 → 클라이언트 재연결 후 재전송
MQ 재투입컨슈머가 ack 전에 죽음 → 메시지 재전달
다중 인스턴스 동시 스케줄여러 복제본이 같은 작업을 동시에 낚아챔

여기까지가 "정상적인 중복"이다. 그런데 실무에서는 분산락이 멀쩡히 작동하는데도 같은 실행 인스턴스 내부에서 같은 도구 호출이 1초 간격으로 세 번 완주하는 사고가 난다. 락은 독점적으로 잘 걸렸고, 코드에 루프도 없고, 재현도 안 되고, 에러 로그도 없다. 원인은 락 바깥에 있다: Agent 프레임워크 내장 재시도, 도구 타임아웃 재발송, 실행기 스레드 적체, 네트워크 지터로 인한 중복 투입, LLM 출력 파싱 실패 시 자동 재시도. 이 다섯 가지는 하나같이 락이 전혀 개입하지 않는 계층에서 같은 부작용을 두 번 이상 발생시킨다. 분산락이 막는 것은 "동시에 여러 실행자가 뛰는 것"뿐이지, "한 실행자가 같은 일을 여러 번 하는 것"은 막지 못한다.

3층 방어선: 유일 키가 기준선, 상태 머신이 진행, 분산락은 동시성 전용

1층(기준선) 유일 요청 ID + DB 유일 제약
   "삽입이 곧 판정"인 원자적 연산 — select 후 insert의 경쟁 창구를 없앤다
   삽입 성공 = 내가 첫 실행자 / 삽입 실패(유일키 충돌) = 이미 처리됨 → 기존 결과 반환

2층(진행) 상태 머신, 영향 행수로 판정
   update task set status='실행중' where task_id=xxx and status='대기'
   영향 행수 1 → 내가 선점 / 영향 행수 0 → 중복으로 판정, 기존 결과 반환

3층(동시성 전용) 분산락
   락이 타임아웃돼도 무방하다 — 두 번째 요청은 어차피 1층의 유일키에서 막힌다
   락 시간을 늘려서 문제를 풀려 하지 말 것(아래 Tips 참고)

agent_invocation 표 최소 필드: request_id(유일 제약) · business_key · status · result · tool_name. 레거시 대화 표에 유일 인덱스를 더 이상 추가할 수 없을 때는 이 표를 별도의 멱등 보조 표로 독립시켜, 핵심 업무 표를 건드리지 않고 멱등 능력만 얹는다. 실행과 멱등 기록은 같은 데이터베이스 트랜잭션에 넣어야 "실행됐는데 기록 안 됨"과 "기록됐는데 실행 안 됨"이 둘 다 발생하지 않는다.

업무 등급별 차등 방어 — 모든 호출에 3층을 다 씌우지 않는다

등급대상방어 수준
강일관성 호출공정 생성, 결제 차감, 데이터 귀속호출 전 실행 표식 선검사(세션+업무주기 기준, 표식 존재 시 즉시 종료) + 분산락 + 업무 유일키 3중
비핵심 호출지식베이스 검색, 콘텐츠 생성, 상태 조회프레임워크 기본 락만으로 충분, 극저확률 중복 리스크는 명시적으로 수용

강일관성 호출의 핵심은 **"선검사 후종료"**다 — 중복이 이미 발생한 뒤 데이터베이스로 걸러내는 것이 아니라, 실행 자체를 시작하기 전에 표식으로 차단한다. 부작용(문자 발송, 외부 API 호출)은 한번 나가면 회수가 안 되므로, 방어는 호출의 상류로 올려야지 하류(DB)로 미루면 안 된다.

관측과 아키텍처: 유령 중복을 눈으로 보이게 만들기

  • 조정 횟수 vs 실행 횟수: 두 지표의 차이가 곧 유령 중복의 실측치다. 같으면 링크가 깨끗하고, 실행 횟수가 더 크면 지금 중복이 발생 중이라는 뜻이며 사용자 항의를 기다릴 필요가 없다.
  • 동일 세션 반복 트리거 자동 경보: 에러율 기반 경보는 이 유형의 사고에서 전혀 울리지 않는다(에러 로그가 없으므로) — 업무 차원의 반복 횟수를 신호로 써야 한다.
  • 단일점 조정 + 비동기 회신 확인: 핵심 Agent의 도구 호출 지시는 단일 노드에서만 발급해 다중 조정 노드의 중복 파견을 원천 차단하고, 실행 결과는 비동기 회신으로 확인해 성공 회신을 받은 뒤에만 "완료"로 표시한다(회신 미수신은 지연 재시도로 처리).
  • 고부하 사전 점검: 대목·월초처럼 부하가 몰리는 시점 전에 실행기 스레드풀 적체를 사전 점검한다 — 유령 중복은 대개 부하의 함수이기 때문이다.

핵심 통찰

  1. 분산락은 "동시에 몇 명이 뛰는가"를 통제할 뿐, "한 명이 몇 번 뛰는가"는 통제하지 못한다 — 이 두 질문을 같은 문제로 착각하는 것이 이 주제 전체에서 가장 흔한 오답이다. 유령 중복 사고에서 락은 정상 작동했지만 중복은 락이 관여하지 않는 계층(재시도·재발송·적체·지터·파싱재시도)에서 발생했다.
  2. "선 조회 후 삽입"에는 항상 경쟁 창구가 있고, 데이터베이스 유일 제약만이 판정을 저장 엔진으로 내려보내 원자적으로 만든다 — 삽입 성공/실패라는 이진 결과 자체가 판정이므로 외부 컴포넌트에 의존하지 않는다.
  3. 상태 머신의 "영향 행수"는 락 없이 동시성을 판정하는 가장 가벼운 수단이다update ... where status=이전상태가 1행이면 내가 이겼고 0행이면 누가 먼저 갔다는 뜻이며, 판정과 상태 전이가 한 번의 쓰기로 합쳐진다.
  4. 모든 호출에 같은 강도의 방어를 씌우는 것은 낭비이자 오답이다 — 조회성 호출에 3층 방어를 씌우면 지연만 늘고, 결제·공정 생성에 프레임워크 기본 락만 씌우면 사고가 난다. 등급을 먼저 나누는 것 자체가 설계 능력이다.
  5. 레거시 표를 건드릴 수 없다는 제약은 흔히 나오는 실전 질문이며, 답은 "구조 변경"이 아니라 "능력 분리"다 — 별도의 멱등 보조 표를 만들어 핵심 업무 표와 결합도를 끊으면 스키마 마이그레이션 없이 멱등을 얹을 수 있다.
  6. 다운그레이드 경로는 "사후 정정 가능"을 반드시 남겨야 한다 — 멱등 표 쓰기가 실패해 Redis 임시 기록 + 짧은 만료로 물러설 때는 최종 대사와 결과 재기입이 짝을 이뤄야 하며, 그렇지 않으면 다운그레이드는 곧 멱등 포기와 같다.
  7. 멱등 기록은 무한히 증가하는 자산이므로 생명주기를 설계해야 하고, 보존 기간은 상류의 최장 재시도 주기보다 반드시 길어야 한다 — 삭제 창구 안에서 재시도가 들어오면 정리 동작 자체가 새로운 중복 구멍을 만든다.

엔지니어링 실전 Tips

  • "락 시간을 늘리면 해결된다"는 전형적인 오답 방향이다: 실행에 40초 걸리는데 락을 30초에서 60초로 늘리면 경쟁 창구는 뒤로 미뤄질 뿐 사라지지 않고, 데드락 위험만 올라가고 처리량은 떨어진다.
  • select for update 비관적 락도 멱등이 아니다: 여전히 동시성 문제만 풀 뿐, "두 번째 요청이 뭘 반환해야 하는가"에는 답하지 못한다.
  • 중복 판정 시 오류가 아니라 첫 실행의 결과를 그대로 반환할 것: 멱등의 정의 자체가 "같은 결과 반환"이므로 agent_invocation 표에 result 필드를 두고 유일키 충돌 분기에서 이를 재생한다.
  • 선삽입 후실행, 순서를 바꾸지 말 것: 먼저 실행하고 나중에 기록하면 그 사이 창구에서 중복 실행이 발생한다.
  • 멱등 키는 클라이언트가 생성하거나 세션+업무 키로 서버가 조합하며, 전송 계층 요청 지문으로 만들지 않는다.
  • 재시도 계열 중 가장 놓치기 쉬운 것은 LLM 출력 파싱 실패 재시도다: 실패한 것은 파싱이지 도구 실행이 아니므로, 이미 받은 도구 결과를 캐시해두고 파싱만 재시도해야지 전체를 처음부터 다시 재생하면 안 된다.
  • 모니터링은 에러율이 아니라 "동일 세션 반복 트리거"를 신호로 삼을 것: 유령 중복은 에러 로그를 남기지 않으므로 에러율 기반 경보는 이 사고 유형에서 무력하다.

용어

멱등(Idempotent) — 같은 요청을 한 번 실행하든 연속해서 여러 번 실행하든 결과가 동일하다는 성질. 도구 호출 실패, 재시도인가 보상인가에서는 멱등 키를 "재시도 능력의 전제 조건"으로만 짧게 다뤘다면, 이 문서는 그 멱등을 실제로 어떻게 구현하는지(유일 요청 ID + DB 유일 제약 + 상태 머신)와 락만으로는 못 잡는 "유령 중복"의 다섯 가지 근본 원인을 다룬다.