발주 한 건을 맡겼는데 구매 시스템에는 세 건이 들어가 있어요. 에이전트 로그에는 이렇게 적혀 있고요. 타임아웃, 타임아웃, 성공.
에이전트는 한 번 성공했다고 믿어요. 도구는 세 번 일했고요. 타임아웃은 "실패했다"가 아니라 **"모른다"**인데, 재시도는 그걸 실패로 읽거든요.
고객사 사고가 아니라 가짜 발주 도구에 세션 2,000개를 흘린 재현이에요. 지난 글이 조용히 실패하는 도구였다면, 이번엔 너무 많이 성공하는 도구예요.
처음엔 재시도를 끄면 된다고 생각했어요
재시도를 전부 끄면 중복은 0건이에요. 대신 81개 세션은 발주가 안 들어갔고, 135개 세션은 들어갔는데 에이전트가 "실패했습니다"라고 보고했어요. 담당자는 다시 요청하겠죠. 재시도를 없앤 게 아니라 사람에게 넘긴 거였어요.
에이전트의 재시도는 한 군데가 아니라 두 군데서 일어나요
하나는 HTTP 클라이언트예요. 같은 요청을 그대로 다시 보내요. 다른 하나는 모델이에요. 오류를 읽고 도구를 새로 불러요. 호출 ID 가 새로 붙고 인자도 조금씩 달라져요.
두 층을 다 넣고 전략 다섯 가지를 비교했어요. 시도의 4%는 "실행은 됐는데 응답이 늦는" 경우로 뒀고, 세션의 5%는 도구가 느린 구간이라 그 비율이 70%라고 가정했어요.
멱등성 키를 붙였는데도 44개 세션이 중복이었어요
멱등성 키는 "아까 그 요청이에요"라고 알려주는 이름표예요. 같은 키를 다시 받으면 도구는 실행하지 않고 처음 결과를 돌려줘요. 문제는 그걸 무엇으로 만드느냐였어요.
| 전략 | 정확히 한 번 | 중복 세션 | 초과 발주 | 누락 | 삼켜진 정상 주문 |
|---|---|---|---|---|---|
| A 재시도 없음 | 1,919 | 0 | 0 | 81 | 0 |
| B 재시도만 | 1,856 | 144 | 313 | 0 | 0 |
| C 키 = 호출 ID | 1,956 | 44 | 57 | 0 | 0 |
| D 키 = 인자 해시 | 1,932 | 16 | 18 | 0 | 52 |
| E 키 = 요청서 번호 | 2,000 | 0 | 0 | 0 | 0 |
C 는 클라이언트 재전송을 전부 막았어요. 그런데 모델이 다시 부르면 호출 ID 가 바뀌니 도구 눈에는 처음 보는 요청이에요.
D 는 양쪽으로 무너졌어요. 모델이 메모를 "긴급 (재시도 1)"로 바꾼 16개 세션은 뚫렸고, 같은 품목을 진짜 한 번 더 주문한 52건은 성공 응답만 받고 사라졌어요. AWS 도 같은 얘기를 해요. 인자가 같다고 의도가 같은 건 아니라고요.
범인은 재시도가 아니라 키의 출신이었어요
C 와 D 의 키는 둘 다 모델이 만든 것에서 나와요. 그래서 키를 모델이 건드릴 수 없는 곳에서 가져오기로 했어요. 세션이 시작되기 전부터 있던 구매 요청서 번호예요. 몇 번을 다시 불러도 바뀌지 않으니까요.
def create_purchase_order(ctx: RunContext, item: str, qty: int, memo: str = ""):
key = f"po:{ctx.request_id}" # 모델 인자가 아니라 실행 컨텍스트에서 온다
saved = store.get(key)
if saved:
if saved.fingerprint != (item, qty):
return ToolResult("error", error_code="IDEMPOTENCY_MISMATCH")
return saved.result # 다시 실행하지 않고 첫 결과를 돌려준다
result = erp.post("/orders", json={"item": item, "qty": qty, "memo": memo})
store.put(key, fingerprint=(item, qty), result=result)
return result
달라진 건 도구 스키마에 키가 없다는 점이에요. 모델은 키를 볼 수도 바꿀 수도 없어요. 같은 키인데 품목이나 수량이 다르면 삼키지 않고 오류로 돌려주고요. Stripe 의 멱등성 키도 같은 방식이에요. 첫 결과를 저장해 두었다가 그대로 돌려주고, 인자가 다르면 오류를 내요.
MCP 에도 idempotentHint 라는 자리가 있어요. 기본값은 false 이고, 이름 그대로 힌트예요. 적어 둔다고 도구가 멱등해지진 않아요.
바로 믿으면 안 되는 세 가지
하나, 숫자 크기는 저희가 넣은 가정에서 나와요. 순서는 구조에서 나오지만 144건, 44건은 실패율을 바꾸면 달라져요.
둘, E 에서도 14개 세션은 발주가 들어갔는데 실패로 보고됐어요. 키는 중복을 막을 뿐 결과를 알려주진 않아요. 상태를 묻는 조회 도구가 따로 있어야 해요.
셋, 요청서 번호 같은 식별자가 없는 작업이 있어요. 대화 중에 나온 "메일 보내줘"가 그래요. 이때는 오케스트레이터가 실행 ID 와 단계 번호로 키를 만들어야 하는데, 이번에 재보지 못했어요.
다시 불러도 되는 도구인가요
타임아웃 두 번과 성공 한 번은 사실 성공 세 번이었어요. 물어야 했던 건 재시도 횟수가 아니라 이 도구를 다시 불러도 되느냐였어요.
프롬프트 인젝션 글에서 인자 범위를 모델 밖에 고정한 것과 같은 줄기예요. 완성된 규칙은 아니에요. 지금은 도구마다 "무엇이 같은 요청인가"를 한 줄씩 적어 가고 있어요.
자주 묻는 질문
Q. 조회 도구에도 멱등성 키가 필요한가요? A. 아니요. 발주 · 결제 · 발송 · 티켓 생성처럼 한 번 더 실행되면 뭔가 하나 더 생기는 도구만 보면 돼요.
Q. 체크포인트나 durable execution 런타임을 쓰면 해결되지 않나요? A. 런타임은 어느 단계까지 끝났는지를 기억해 줘요. 다만 결과를 모르는 외부 호출은 런타임도 다시 보낼 수밖에 없어서, 받는 쪽이 키로 걸러 줘야 해요.
Q. 상대 시스템이 멱등성 키를 받지 않으면요? A. 본문 코드처럼 래퍼가 키와 결과를 저장해요. 저장 전에 죽는 틈은 남으니, 쓰기 전에 요청서 번호로 기존 발주를 먼저 조회해요.
Sources
- Stripe API Reference — Idempotent requests: https://docs.stripe.com/api/idempotent_requests
- Amazon Builders' Library — Making retries safe with idempotent APIs (Malcolm Featonby): https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/
- Model Context Protocol Specification (2025-06-18) — Schema, ToolAnnotations.idempotentHint: https://modelcontextprotocol.io/specification/2025-06-18/schema
본문 수치는 이 회차에서 직접 돌린 재현 실험 결과예요. 실제 LLM 호출과 고객사 데이터는 쓰지 않았고, 가짜 발주 도구 하나 · 세션 2,000개 · seed 20261005 로 실행했습니다. 실패율과 모델 재호출 확률은 가정값이에요.

