분산 시스템에서 “한 번 실행”은 생각보다 어려운 요구다. 서버가 후속 작업을 생성한 직후 연결이 끊기면 클라이언트는 성공 여부를 모른다. 같은 요청을 다시 보내면 작업이 두 개 생길 수 있고, 재시도하지 않으면 실제로 실패했을 때 업무가 누락된다.

에이전트의 승인 흐름은 이 문제를 더 복잡하게 만든다. 사용자가 승인 버튼을 두 번 누를 수 있고, API가 checkpoint를 재개하는 동안 네트워크 오류가 날 수 있으며, 도구 middleware가 일시적 실패를 재시도할 수도 있다. 그래서 “모델이 같은 도구를 두 번 부르지 않을 것”이라는 기대가 아니라 저장 계층에서 중복을 막아야 한다.

승인 ID를 업무 요청의 정체성으로 사용한다

후속 작업을 승인하면 API는 approval UUID를 MCP 요청의 Idempotency-Key로 전달한다. 같은 승인을 다시 처리하면 같은 키가 사용된다. 모델이 새 문자열을 만들거나 클라이언트가 임의 키를 정하지 않는다.

Records 서비스는 이 키가 없으면 쓰기를 거절한다. 그리고 tenant_ididempotency_key 조합에 unique constraint를 둔다.

python
UniqueConstraint(
    "tenant_id",
    "idempotency_key",
    name="uq_task_tenant_idempotency",
)

tenant를 키에 포함하는 이유는 서로 다른 tenant의 요청 공간을 분리하기 위해서다. 같은 UUID가 우연히 겹치더라도 tenant마다 독립적으로 해석할 수 있다.

재호출은 기존 결과를 반환한다

서비스가 같은 키를 발견하면 새 작업을 만들지 않고 기존 FollowupTask를 반환한다. 호출자는 첫 번째 응답을 잃었더라도 같은 결과를 얻는다. 이것이 단순 중복 검사와 다른 점이다. “이미 처리됨” 오류만 돌려주면 클라이언트는 원래 생성된 리소스를 다시 찾아야 한다.

테스트는 같은 인자로 쓰기 함수를 두 번 호출한다. 반환된 task ID가 같고 실제 task row는 하나인지 확인한다. 이 테스트는 모델 없이 실행되며 중복 방지 책임이 서비스와 데이터베이스에 있음을 보여준다.

감사 로그는 성공한 row와 다른 질문에 답한다

후속 작업 테이블은 현재 업무 상태를 보여준다. 감사 이벤트는 누가, 어느 tenant와 thread에서, 어떤 요청으로 도구를 호출했고 결과가 무엇이었는지 설명한다. 두 번째 멱등 호출도 별도의 감사 이벤트로 남는다. 업무 row는 하나지만 실행 시도는 두 번이었다는 사실을 보존하는 것이다.

감사 이벤트에는 전체 민감 데이터 대신 제한된 요약을 저장한다.

  • actor, tenant, thread와 request ID
  • 호출한 도구 이름
  • redacted argument summary
  • 성공 또는 실패 범주
  • 시작·완료 시간과 처리 시간

프롬프트 전문, 모델 reasoning, 고객 레코드 전체를 로그에 넣지 않는다. 관측 가능성을 높이려다 새로운 개인정보 저장소를 만들지 않기 위해서다.

production에서 보강할 부분

현재 감사 테이블은 애플리케이션 규칙상 append-only다. 운영 환경에서는 runtime DB role에서 UPDATE와 DELETE 권한을 제거하고, tamper-evident storage로 이벤트를 내보내는 편이 낫다. 감사 로그 자체의 접근 권한과 보존 기간도 업무 데이터와 다르게 관리해야 한다.

멱등성과 감사는 서로 대체하지 않는다. 멱등성은 중복 부작용을 막고, 감사는 어떤 시도가 있었는지 남긴다. 사람의 승인이 있는 에이전트라면 두 장치가 승인 ID를 중심으로 연결될 때 가장 설명하기 쉽다.