도구 호출은 어떻게 "딱 한 번만" 실행되게 만드는가
← 전체 목차 · 이전: 도구 호출 실패, 재시도인가 보상인가 · 다음: 다단계 도구 호출, 중간에 실패하면 전부 어떻게 되돌리나
"이미 실행했는지 검사 → 실행 → 완료 표시"라는 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의 도구 호출 지시는 단일 노드에서만 발급해 다중 조정 노드의 중복 파견을 원천 차단하고, 실행 결과는 비동기 회신으로 확인해 성공 회신을 받은 뒤에만 "완료"로 표시한다(회신 미수신은 지연 재시도로 처리).
- 고부하 사전 점검: 대목·월초처럼 부하가 몰리는 시점 전에 실행기 스레드풀 적체를 사전 점검한다 — 유령 중복은 대개 부하의 함수이기 때문이다.
핵심 통찰
- 분산락은 "동시에 몇 명이 뛰는가"를 통제할 뿐, "한 명이 몇 번 뛰는가"는 통제하지 못한다 — 이 두 질문을 같은 문제로 착각하는 것이 이 주제 전체에서 가장 흔한 오답이다. 유령 중복 사고에서 락은 정상 작동했지만 중복은 락이 관여하지 않는 계층(재시도·재발송·적체·지터·파싱재시도)에서 발생했다.
- "선 조회 후 삽입"에는 항상 경쟁 창구가 있고, 데이터베이스 유일 제약만이 판정을 저장 엔진으로 내려보내 원자적으로 만든다 — 삽입 성공/실패라는 이진 결과 자체가 판정이므로 외부 컴포넌트에 의존하지 않는다.
- 상태 머신의 "영향 행수"는 락 없이 동시성을 판정하는 가장 가벼운 수단이다 —
update ... where status=이전상태가 1행이면 내가 이겼고 0행이면 누가 먼저 갔다는 뜻이며, 판정과 상태 전이가 한 번의 쓰기로 합쳐진다.
- 모든 호출에 같은 강도의 방어를 씌우는 것은 낭비이자 오답이다 — 조회성 호출에 3층 방어를 씌우면 지연만 늘고, 결제·공정 생성에 프레임워크 기본 락만 씌우면 사고가 난다. 등급을 먼저 나누는 것 자체가 설계 능력이다.
- 레거시 표를 건드릴 수 없다는 제약은 흔히 나오는 실전 질문이며, 답은 "구조 변경"이 아니라 "능력 분리"다 — 별도의 멱등 보조 표를 만들어 핵심 업무 표와 결합도를 끊으면 스키마 마이그레이션 없이 멱등을 얹을 수 있다.
- 다운그레이드 경로는 "사후 정정 가능"을 반드시 남겨야 한다 — 멱등 표 쓰기가 실패해 Redis 임시 기록 + 짧은 만료로 물러설 때는 최종 대사와 결과 재기입이 짝을 이뤄야 하며, 그렇지 않으면 다운그레이드는 곧 멱등 포기와 같다.
- 멱등 기록은 무한히 증가하는 자산이므로 생명주기를 설계해야 하고, 보존 기간은 상류의 최장 재시도 주기보다 반드시 길어야 한다 — 삭제 창구 안에서 재시도가 들어오면 정리 동작 자체가 새로운 중복 구멍을 만든다.
엔지니어링 실전 Tips
- "락 시간을 늘리면 해결된다"는 전형적인 오답 방향이다: 실행에 40초 걸리는데 락을 30초에서 60초로 늘리면 경쟁 창구는 뒤로 미뤄질 뿐 사라지지 않고, 데드락 위험만 올라가고 처리량은 떨어진다.
select for update 비관적 락도 멱등이 아니다: 여전히 동시성 문제만 풀 뿐, "두 번째 요청이 뭘 반환해야 하는가"에는 답하지 못한다.
- 중복 판정 시 오류가 아니라 첫 실행의 결과를 그대로 반환할 것: 멱등의 정의 자체가 "같은 결과 반환"이므로
agent_invocation 표에 result 필드를 두고 유일키 충돌 분기에서 이를 재생한다.
- 선삽입 후실행, 순서를 바꾸지 말 것: 먼저 실행하고 나중에 기록하면 그 사이 창구에서 중복 실행이 발생한다.
- 멱등 키는 클라이언트가 생성하거나 세션+업무 키로 서버가 조합하며, 전송 계층 요청 지문으로 만들지 않는다.
- 재시도 계열 중 가장 놓치기 쉬운 것은 LLM 출력 파싱 실패 재시도다: 실패한 것은 파싱이지 도구 실행이 아니므로, 이미 받은 도구 결과를 캐시해두고 파싱만 재시도해야지 전체를 처음부터 다시 재생하면 안 된다.
- 모니터링은 에러율이 아니라 "동일 세션 반복 트리거"를 신호로 삼을 것: 유령 중복은 에러 로그를 남기지 않으므로 에러율 기반 경보는 이 사고 유형에서 무력하다.
용어
멱등(Idempotent) — 같은 요청을 한 번 실행하든 연속해서 여러 번 실행하든 결과가 동일하다는 성질. 도구 호출 실패, 재시도인가 보상인가에서는 멱등 키를 "재시도 능력의 전제 조건"으로만 짧게 다뤘다면, 이 문서는 그 멱등을 실제로 어떻게 구현하는지(유일 요청 ID + DB 유일 제약 + 상태 머신)와 락만으로는 못 잡는 "유령 중복"의 다섯 가지 근본 원인을 다룬다.