AI 에이전트가 이메일을 두 번 보낸 경우: Idempotency 해결 방법
요약
AI 에이전트의 재시도 메커니즘으로 인해 발생하는 이메일 중복 전송 문제를 다룹니다. 근본 원인은 동작은 발생했으나 시스템 기록에 남지 않아, 상위 계층에서 안전하다고 판단하고 작업을 반복 실행하기 때문입니다. 해결책은 멱등성(idempotency)을 확보하는 것입니다.
핵심 포인트
- AI 에이전트 재시도는 여러 계층(HTTP 클라이언트 등)에서 발생할 수 있습니다.
- 작업의 부작용(side effect)이 기록되지 않아 중복 실행 문제가 발생합니다.
- 멱등성(idempotency)은 작업을 두 번 수행해도 한 번과 동일한 효과를 내도록 보장하는 속성입니다.
- 결제 시스템이나 메시지 큐 등에서 이미 활용되어 온 개념입니다.
에이전트가 send_email을 호출합니다. 메일 API는 느리고, HTTP 클라이언트는 30초 후에 포기하며, 모델은 'timeout'을 '전송되지 않음'으로 해석합니다. 그래서 제목 줄을 다시 작성하고 재전송합니다. 고객은 이메일을 두 통 받게 됩니다.
가장 명확한 해결책은 도구(tool)의 인자(arguments)를 기반으로 중복 제거(dedupe)하는 것입니다. 하지만 여기서는 모델이 인자를 변경했기 때문에 실패합니다.
작동하는 해결책은 다음과 같습니다: 모든 도구 호출이 두 번 실행될 것이라고 가정하고, 두 번째 실행이 무엇을 할지 미리 결정하는 것입니다. 이 글에서는 에이전트 재시도(agent retries)가 어디서 오는지, 두 번째 실행을 무해하게 만드는 핵심 요소는 무엇인지, 그리고 이를 증명하는 세 가지 테스트를 다룹니다. 메시지 큐에서 정확히 한 번 전달되는 것(exactly-once delivery)은 다루지 않습니다.
저는 이 문제를 GroundedDocs라는 RAG 시스템의 인덱서(indexer)에서 처음 접했습니다. 에이전트가 아니라요. 작업이 중간에 실패하여 다시 실행되면서 모든 청크(chunk)를 두 번 저장했고, 동일한 구절(passage)이 검색 결과에 두 번 나타나 다른 증거들을 밀어냈습니다.
중복된 도구 호출은 어떻게 생겼을까요?
이것은 패턴을 보여주기 위해 제가 작성한 예시 추적(illustrative trace)입니다. 실제 시스템의 출력물은 아닙니다.
step 4 send_email(subject="Your refund is approved") -> timeout after 30s
step 5 send_email(subject="Refund approved, order 8812") -> ok, msg_1042
...
Step 4는 시간 초과되었지만 이메일은 발송되었습니다. 모델은 실패를 감지하고 제목을 수정하여 다시 시도했습니다. 에이전트 자체의 추적 기록 어디에도 고객이 이메일을 두 통 받았다는 내용은 없습니다.
근본적인 원인은 항상 같습니다. 동작(action)은 발생했지만, 시스템이 그것이 발생했다는 것을 기록하지 않은 것입니다. 그래서 어떤 계층(layer)이 안전해 보이는 방식으로 다시 시도합니다. Temporal의 문서는 가장 깔끔한 버전을 설명합니다: 워커가 작업을 완료한 후, 완료를 보고하기 직전에 충돌하는 경우입니다. 작업은 재시도되고, 보호 장치 없이는 '결제 처리 시나리오에서 중복 청구'와 같은 문제가 발생할 수 있습니다 (Temporal).
이 문제를 방지하는 속성이 있습니다. 이 속성은 **멱등성(idempotent)**이라고 불리는데, 이는 어떤 작업을 두 번 수행해도 한 번만 수행했을 때와 동일한 효과를 낸다는 의미입니다. 백엔드 엔지니어들은 결제 시스템이나 메시지 큐(queue)에서 수년간 이를 활용해 왔습니다. 하지만 에이전트가 등장하면서 이 문제가 더 복잡한 형태로 돌아왔습니다.
에이전트 재시도(retries)는 어디서 오는가?
잘못된 모델은 '내 추적 기록에 도구 호출(tool call)이 하나 있다는 것은 하나의 부작용(side effect)을 의미한다'고 생각하는 것입니다. 에이전트 스택에서는 네 개의 계층에서 동일한 작업을 재시도할 수 있으며, 이들 대부분은 사용자에게 알리지 않고 그렇게 합니다.
1. HTTP 클라이언트. 라이브러리는 타임아웃이나 서버 오류를 조용히 재시도합니다. 일반적인 기본값 중 하나는 Anthropic Python SDK가 연결 오류, 408, 409, 429, 그리고 5xx 상태 코드를 2회 재시도하며, 타임아웃 또한 재시도합니다. 사용자의 도구(tool) HTTP 클라이언트, 게이트웨이 또는 서비스 메시(service mesh)가 동일하게 작동할 수 있습니다.
사용자가 보는 것: 추적 기록에 도구 호출이 하나만 있지만, 다운스트림 로그에는 요청이 두 개 나타납니다.
2. 오케스트레이터(orchestrator). 에이전트 프레임워크가 실패한 노드 또는 도구 호출을 재시도합니다.
사용자가 보는 것: 동일한 인자(arguments)로 같은 단계가 두 번 기록됩니다.
3. 모델(model). 도구가 오류를 반환하면, 모델이 이를 다시 호출합니다. 이는 설계상 의도된 동작입니다. Model Context Protocol은 도구 오류를 모델에 반환하여 모델이 '스스로 수정하고 조정된 매개변수로 재시도'할 수 있게 합니다.
사용자가 보는 것: 인자가 약간 다른 두 개의 도구 호출입니다.
4. 재개된 실행(resumed run). 프로세스가 충돌하거나 승인을 위해 일시 중지되면, 해당 실행은 마지막 체크포인트(checkpoint)부터 다시 시작됩니다. LangGraph는 이미 완료된 노드를 재실행하지 않지만, 체크포인트 이후의 노드는 LLM 호출, API 요청 또는 인터럽트를 포함하여 '재실행'합니다.
- 일반 클라이언트는 동일한 요청을 재시도합니다. 모델은 내용을 바꿔서(reworded arguments) 재시도하므로, 두 번째 호출이 중복처럼 보이지 않습니다.
- 일반 클라이언트는 오류 발생 시 멈춥니다. 하지만 모델은 오류를 읽고 다른 경로를 시도합니다.
- 에이전트 실행은 길어져서 일시 중지되었다가 다시 시작(resume)되며, 재개는 단계를 다시 실행합니다.
그리고 이 계층들이 곱해집니다. 3번 시도를 하는 클라이언트가 있고, 그 안에 3번 시도를 하는 오케스트레이터가 있으며, 모델이 두 번 시도하는 경우, 단일 사용자 요청에 대해 하나의 액션이 최대 18번까지 실행될 수 있습니다. 재개(resume)는 이 모든 것을 반복할 수 있습니다.
두 번째 실행을 안전하게 만드는 것은 무엇일까요?
일곱 가지 규칙이 있습니다. 제가 적용하는 순서대로입니다.
1. 모든 도구를 세 그룹으로 분류하세요. 읽기 전용(Read-only) 도구는 반복해도 안전합니다. 일부 쓰기 작업은 본질적으로 반복 가능합니다.
4. 키와 결과를 기록합니다. 키, 상태, 그리고 결과를 고유 제약 조건이 있는 테이블이나 set-if-absent 기능을 가진 Redis에 저장해야 합니다. 가능하다면 해당 레코드를 액션과 같은 트랜잭션 내에서 작성하세요. Stripe가 이 방식을 사용합니다: 특정 키에 대한 첫 번째 요청의 상태 코드와 본문을 저장하며, 그 키로 반복 요청이 오면 동일한 응답을 받게 됩니다.
5. 반복 시에는 최초 결과를 성공으로 반환합니다. AWS는 이를 "의미적으로 동등한 응답(semantically equivalent response)"이라고 부릅니다 (AWS Builders' Library). 모델에게 '이미 존재함(already exists)'을 오류로 절대 보내지 마세요. 모델은 이를 실패로 간주하고 다른 경로를 시도할 것입니다. 만약 동일한 키가 다른 매개변수와 함께 도착한다면, 거부해야 합니다. Stripe와 AWS 모두 이 방식을 따릅니다.
6. 타임아웃 후에는 재시도하기 전에 확인합니다. 다운스트림 서비스에 해당 키가 완료되었는지 문의하세요. 타임아웃은 액션이 실행되었는지 여부에 대해 아무것도 알려주지 않습니다.
7. 되돌릴 수 없는 것에 게이트를 설치합니다. 결제, 전송(sends), 삭제와 같은 작업에는 사람의 승인이나 실행당 하드 리미트를 적용해야 합니다. 키는 가장 길게 예상되는 재개 시간보다 더 오래 보관하세요. Stripe는 최소 24시간 동안 키를 보관합니다.
코드로 어떻게 구현되나요?
실제 운영 코드는 아니지만, Python으로 아이디어를 스케치한 코드입니다:
def run_write_tool(run_id, tool, args):
key = f"{run_id}:{tool.name}:{tool.business_id(args)}"
row = store.get(key)
...
중요한 줄은 첫 번째 줄입니다. 키는 모델이 작성한 제목줄이 아니라 business_id(args)에서 가져온 주문 번호여야 합니다. 이것이 위 추적 과정에서 수정된 재시도가 확인 단계에서 멈추게 되는 이유입니다.
GroundedDocs에서는 이것이 가장 저렴한 것부터 가장 많은 작업에 이르기까지 세 가지 결정으로 이어졌습니다.
Ingestion은 반복 가능합니다. 각 문서는 내용의 해시 값으로 식별되며, 각 청크는 안정적인 ID를 가집니다. 인덱서(indexer)를 이미 본 문서에 대해 다시 실행해도 새로운 청크는 작성되지 않습니다.
에이전트의 도구는 의도적으로 읽기 전용입니다. 에이전트 경로는 하나의 도구, 즉 문서 조회 기능만을 가지고 있습니다. 이 도구는 아무것도 변경하지 않으므로, 네 가지 종류의 재시도(retry) 모두에게 무해합니다. 가장 저렴한 멱등성(idempotency) 수정 방법은 위험한 쓰기 작업을 아예 하지 않는 것입니다.
에이전트를 둘러싼 쓰기 작업에는 키가 부여됩니다. 모든 요청은 여전히 두 가지 것을 작성합니다: 감사 이벤트(audit event)와 속도 제한 대비 사용 횟수입니다. 재시도된 단계는 두 번 로깅되거나 두 번 계산되어서는 안 되므로, 각각은 요청 ID와 단계를 조합하여 만들어진 키를 가집니다.
멱등성 키가 도움이 되지 않는 경우는 언제인가요?
충돌이 "시작됨(started)"과 "완료됨(done)" 사이에 발생할 때입니다. 래퍼는 "시작됨"을 기록하고, 이메일은 발송되며, 프로세스는 "완료됨"이 작성되기 전에 죽습니다. 재개 시, 스토어는 이메일이 전송되었는지 여부를 알려줄 수 없습니다. 이 간극을 메울 수 있는 것은 단 두 가지뿐입니다: 다운스트림 서비스(downstream service)가 전달받은 키를 존중하거나, 사용자가 직접 요청하는 것입니다 (규칙 6). 만약 둘 다 지원하지 않는다면, 해당 도구를 되돌릴 수 없는 것으로 취급하고 게이트(제한)해야 합니다 (규칙 7).
재개 전에 키가 만료될 때입니다. 한 작업이 승인을 기다리며 사흘 동안 대기하지만, 키는 24시간 동안만 유효합니다. 재개된 호출은 완전히 새로운 것처럼 보입니다. Stripe는 이를 명확히 설명합니다: 정리(pruned)된 후 재사용되는 키는 새로운 요청으로 간주됩니다.
키가 너무 광범위할 때입니다. run_id:send_email:order_8812는 해당 주문에 대한 두 번째 이메일을 차단하는데, 여기에는 "환불 승인" 다음에 와야 하는 합법적인 "환불 지급됨" 이메일도 포함됩니다. 예를 들어 이메일 템플릿 이름과 같이 의도(intent)를 키에 담아, 두 가지 실제 동작이 두 개의 키를 갖도록 하세요.
중복을 위해 에이전트를 어떻게 테스트하나요?
다음 20분 안에 위험한 쓰기 작업 하나로 시도해 보세요:
- 모든 도구를 나열하고 읽기 전용(read-only), 반복 가능한 쓰기(repeatable write), 또는 위험한 쓰기(dangerous write)로 표시합니다.
- 위험한 쓰기 중 하나에 대해 재시도할 수 있는 모든 계층과 그 횟수를 적습니다. 곱해봅니다.
- 성공 후 충돌: 도구가 반환된 직후 프로세스를 종료하고, 실행을 재개합니다.
- 답장 손실 및 가짜 오류: 도구는 성공하지만 타임아웃을 반환하게 하고, 이후에는 성공하지만 오류를 모델에 반환하게 합니다.
- 각 테스트 후 부작용 횟수를 계산합니다. 목표는 정확히 하나입니다.
제가 현재 사용하는 규칙은 다음과 같습니다: 모든 도구 호출이 두 번 실행될 것이라고 가정하고, 두 번째 실행이 무엇을 할지 미리 결정합니다.
당신의 스택에서 당신이 재시도할 수 있다는 것을 몰랐던 쓰기를 어떤 계층이 재시도했으며, 그것이 무엇을 중복했습니까?
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기
