Agent Tool은 정확히 한 번 실행될 수 있을까: 승인, 멱등성, 재시도

송금 Tool을 두 번 실행하지 않기 위해 사용자 의도와 실행 결과를 연결하는 법

송금과 결제처럼 부수 효과가 있는 Agent Tool에서 사용자 승인, action fingerprint, idempotency key, Queue 재전달과 불명확한 실행 결과를 안전하게 처리하는 방법을 설명합니다.

앞선 State는 Event가 아니다에서는 현재 snapshot과 event history를 분리했다. Event가 다시 전달될 수 있으므로 UI가 같은 event ID를 두 번 처리해도 결과가 달라지지 않아야 한다고 설명했다.

Agent Tool은 더 어렵다.

진행률 이벤트를 두 번 그리면 화면이 잠시 이상해질 수 있다. 송금 Tool을 두 번 실행하면 돈이 두 번 나간다. 이메일 발송, 주문 생성, 광고 집행, 계정 삭제와 같은 Tool도 외부 세계에 되돌리기 어려운 결과를 만든다.

다음과 같은 코드는 데모에서는 자연스럽다.

if user_approved:
    return await transfer_money(
        recipient=action.recipient,
        amount=action.amount,
        currency=action.currency,
    )

하지만 운영 환경에서는 질문이 더 많다.

  • 사용자가 무엇을 승인했는가?
  • 승인 후 LLM이 금액을 바꾸지는 않았는가?
  • 사용자가 승인 버튼을 두 번 누르면 어떻게 되는가?
  • Queue가 같은 작업을 다시 전달하면 어떻게 되는가?
  • 송금은 성공했지만 Worker가 응답을 받기 전에 죽으면 어떻게 되는가?
  • 재시도해도 되는 실패와 멈춰야 하는 실패를 누가 구분하는가?

이 글에서는 송금을 교육용 예제로 들어 이 경계를 하나씩 설계한다. 코드는 개념 설명용이며 실제 금융기관의 원장, 규제 준수, 인증 및 이상거래 탐지 체계를 대체하지 않는다.

1. 정확히 한 번이라는 말부터 나눈다

“Tool을 정확히 한 번 실행한다”는 말에는 서로 다른 세 가지 의미가 섞여 있다.

질문의미
함수가 한 번 호출됐는가?Worker process 내부 실행 횟수
외부 API 요청이 한 번 전송됐는가?네트워크 요청 횟수
실제 송금이 한 번만 발생했는가?업무상 부수 효과의 횟수

Worker가 timeout을 관측했다고 외부 API가 실행되지 않은 것은 아니다. 요청은 provider에 도착해 송금이 완료됐지만 응답 패킷만 유실됐을 수 있다.

반대로 함수가 두 번 호출되고 HTTP 요청이 두 번 전송됐더라도, 두 요청이 같은 idempotency key를 사용해 provider가 같은 거래 결과를 반환한다면 업무상 송금은 한 번만 발생할 수 있다.

따라서 목표는 “코드 한 줄을 물리적으로 한 번만 실행하는 것”이 아니다.

같은 사용자 의도로 발생한 모든 재시도가 하나의 업무 결과로 수렴하도록 만든다.

AWS Builders’ Library의 멱등 API 설명도 재시도가 부수 효과를 반복하지 않도록 client request ID와 요청 의도를 연결하는 방식을 설명한다. 중요한 것은 같은 요청인지뿐 아니라 같은 의도인지다.

2. 인증, 권한과 승인은 서로 다르다

송금 전에 사용자가 로그인했다고 하자. 그것만으로 특정 송금을 승인한 것은 아니다.

단계확인하는 질문
Authentication이 사용자는 누구인가?
Authorization이 사용자가 송금 Tool을 사용할 권한이 있는가?
Approval이 수취인에게 이 금액을 지금 보내도 되는가?

승인은 “진행할까요?”라는 추상적인 질문이어서는 안 된다. 사용자가 중요한 거래 내용을 직접 확인할 수 있어야 한다.

송금 승인

보내는 계좌: 운영 계좌 •••• 1024
받는 대상: ABC Supplier •••• 7781
금액: KRW 3,000,000
수수료: KRW 500
목적: INV-2026-0918 지급
만료: 10분 후

[거절] [승인]

OWASP의 Transaction Authorization 지침은 송금처럼 민감한 작업에서 사용자가 대상 계좌와 금액 같은 중요한 거래 데이터를 확인해야 한다는 원칙을 설명한다. 승인과 실행은 서버에서 강제하고, 승인한 데이터가 실행 전에 바뀌면 이전 승인을 재사용해서는 안 된다.

3. LLM의 제안을 바로 실행하지 않는다

LLM은 Tool 호출을 제안할 수 있다.

{
  "tool": "transfer_money",
  "arguments": {
    "recipient_account": "supplier-7781",
    "amount": 3000000,
    "currency": "KRW",
    "memo": "INV-2026-0918"
  }
}

이 출력은 아직 실행 명령이 아니다. 먼저 서버가 Tool schema와 업무 규칙으로 검증한 뒤 영속적인 action intent를 만든다.

LLM Tool 제안
→ schema 검증
→ 권한과 한도 확인
→ action intent 저장
→ 사용자 승인
→ 실행

LLM이 생성한 JSON을 브라우저에 보낸 뒤, 브라우저가 다시 보내준 JSON을 그대로 실행해서는 안 된다. 사용자가 승인할 action은 서버에 저장되어 있어야 하고, 승인 요청은 그 action_id만 가리켜야 한다.

POST /tool-actions/action_123/approve

승인 endpoint가 수취인과 금액을 다시 입력받아 action을 덮어쓰면, 사용자가 본 내용과 실제 실행 내용이 달라질 수 있다.

4. Action fingerprint와 idempotency key는 역할이 다르다

두 값을 구분해야 한다.

Action fingerprint

Fingerprint는 사용자가 승인한 내용을 고정한다.

SHA-256(
  tool_name
  + tool_schema_version
  + canonical_arguments
  + tenant_id
  + actor_id
)

정규화된 인자의 예는 다음과 같다.

{
  "amount_minor": 300000000,
  "currency": "KRW",
  "memo": "INV-2026-0918",
  "recipient_account": "supplier-7781"
}

승인 시점의 fingerprint와 실행 시점의 fingerprint가 다르면 실행하지 않는다. 금액, 통화, 수취인 또는 Tool 버전이 바뀌었다면 새로운 action으로 다시 승인받는다.

Idempotency key

Idempotency key는 하나의 사용자 의도를 재시도 동안 동일하게 식별한다.

action_id = action_123
idempotency_key = tool-action:action_123

같은 수취인에게 같은 금액을 다음 달에 다시 송금하는 것은 정상일 수 있다. 따라서 단순히 recipient + amount hash를 전역 idempotency key로 사용하면 정상적인 두 번째 송금까지 차단할 수 있다.

원칙은 다음과 같다.

  • 하나의 action을 재시도할 때는 같은 idempotency key를 사용한다.
  • 사용자가 새 송금을 의도했다면 새 action ID와 새 키를 만든다.
  • 같은 키가 다른 인자와 결합되면 오류로 처리한다.

Stripe의 Idempotent requests 문서는 같은 키로 재시도하면 최초 결과를 재사용하고, 같은 키에 다른 인자를 사용하면 오용을 막기 위해 비교한다. 다만 키 보존 시간과 오류 저장 방식은 provider별로 다르므로 실제 계약을 확인해야 한다.

5. 실행 의도를 PostgreSQL에 먼저 남긴다

멱등성의 기준 기록은 TTL로 사라질 수 있는 Redis key가 아니라 PostgreSQL 같은 영속 저장소에 둔다.

CREATE TABLE tool_actions (
    action_id UUID PRIMARY KEY,
    tenant_id UUID NOT NULL,
    run_id UUID NOT NULL,
    tool_name TEXT NOT NULL,
    tool_version TEXT NOT NULL,

    arguments JSONB NOT NULL,
    action_fingerprint TEXT NOT NULL,
    idempotency_key TEXT NOT NULL,

    status TEXT NOT NULL CHECK (
        status IN (
            'proposed',
            'awaiting_approval',
            'approved',
            'executing',
            'succeeded',
            'failed_retryable',
            'failed_terminal',
            'outcome_unknown',
            'cancelled',
            'expired'
        )
    ),

    approved_fingerprint TEXT,
    approved_by UUID,
    approved_at TIMESTAMPTZ,
    approval_expires_at TIMESTAMPTZ,

    execution_attempt_id UUID,
    provider_reference TEXT,
    result JSONB,
    error JSONB,

    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    UNIQUE (tenant_id, idempotency_key)
);

PostgreSQL의 unique constraint는 같은 tenant에서 동일한 idempotency key의 action이 두 개 생성되는 것을 데이터베이스 수준에서 막는다.

Redis는 다음 용도로 보조할 수 있다.

  • 짧은 실행 lock
  • 승인 대기 알림
  • UI 진행 상태
  • 최근 이벤트 전달

하지만 Redis key가 만료되거나 eviction됐다는 이유로 같은 송금을 다시 허용해서는 안 된다.

6. 사용자가 승인한 정확한 행동을 묶는다

Tool 실행 승인 흐름 (개념)
  1. LLM Tool 제안
  2. 서버가 action intent 저장
  3. 사용자에게 정확한 인자 표시
  4. 승인 (action_id만 전송)
  5. fingerprint 재검증
  6. 실행권 확보 (조건부 UPDATE)
  7. 외부 API (동일 idempotency key)
  8. 결과를 PostgreSQL에 저장

서버는 승인 시점에 저장된 action을 읽는다.

async def approve_action(
    action_id: str,
    user: User,
) -> ApprovedAction:
    action = await action_store.get_for_update(action_id)

    authorize_tool(user, action.tool_name)
    authorize_transaction(user, action.arguments)

    if action.status != "awaiting_approval":
        raise InvalidActionState(action.status)

    if action.approval_expires_at <= now():
        await action_store.mark_expired(action_id)
        raise ApprovalExpired()

    fingerprint = fingerprint_action(
        tool_name=action.tool_name,
        tool_version=action.tool_version,
        arguments=action.arguments,
        tenant_id=str(action.tenant_id),
        actor_id=str(user.id),
    )

    if fingerprint != action.action_fingerprint:
        raise ActionChangedAfterReview()

    return await action_store.mark_approved(
        action_id=action_id,
        approved_by=user.id,
        approved_fingerprint=fingerprint,
    )

브라우저가 보낸 approved=true만 믿지 않는다. 승인 대상, 사용자 권한, 만료 시간과 fingerprint를 서버에서 다시 확인한다.

승인 후 사용자가 금액을 수정하고 싶다면 기존 action을 변경하지 않는다.

기존 action → cancelled 또는 superseded
새 action → awaiting_approval
새 fingerprint → 새 승인

이렇게 해야 감사 로그에서 “사용자가 본 것”과 “실제로 실행한 것”을 연결할 수 있다.

7. 한 Worker만 실행권을 가져간다

Queue는 같은 메시지를 다시 전달할 수 있고, 일시적으로 두 Worker가 같은 run을 처리하려 할 수도 있다. 실행 전에 PostgreSQL의 조건부 갱신으로 한 Worker만 action을 확보한다.

UPDATE tool_actions
SET
    status = 'executing',
    execution_attempt_id = $2,
    updated_at = NOW()
WHERE action_id = $1
  AND status = 'approved'
  AND approved_fingerprint = action_fingerprint
  AND approval_expires_at > NOW()
RETURNING *;

한 Worker만 row를 반환받는다. 다른 Worker는 이미 executing 또는 succeeded인 action을 보고 새 외부 호출을 만들지 않는다.

하지만 이 조건부 갱신만으로 모든 중복을 막을 수 있는 것은 아니다. Worker가 executing으로 바꾼 뒤 외부 API 호출에 성공하고, 로컬 결과를 저장하기 전에 죽을 수 있기 때문이다.

이 장애 구간을 닫으려면 외부 서비스도 같은 idempotency key를 이해해야 한다.

8. 외부 API에도 같은 키를 보낸다

async def execute_transfer(
    action: ToolAction,
    client: TransferClient,
) -> TransferResult:
    assert action.status == "executing"
    assert action.approved_fingerprint == action.action_fingerprint

    return await client.transfer(
        recipient_account=action.arguments["recipient_account"],
        amount_minor=action.arguments["amount_minor"],
        currency=action.arguments["currency"],
        memo=action.arguments["memo"],
        idempotency_key=action.idempotency_key,
    )

같은 action의 재시도에는 반드시 같은 키를 사용한다.

첫 호출:
Idempotency-Key: tool-action:action_123

재시도:
Idempotency-Key: tool-action:action_123

Provider가 이 계약을 지원한다면 두 번째 요청은 새 송금을 만들지 않고 최초 실행의 결과를 반환하거나 기존 실행 상태를 조회할 수 있다.

중요한 것은 “idempotency”라는 필드 이름의 존재가 아니다. 다음 계약을 확인해야 한다.

  • 키를 얼마나 오래 보관하는가?
  • 같은 키에 다른 인자를 보내면 거부하는가?
  • 요청이 실행되기 전 validation 실패도 저장하는가?
  • 서버 오류 결과도 같은 키에 묶이는가?
  • 처리 중인 요청과 동시에 같은 키가 들어오면 어떻게 되는가?
  • 거래 상태를 별도 API로 조회할 수 있는가?

Provider가 idempotency key나 거래 조회 API를 지원하지 않으면, 호출 성공과 로컬 저장 사이의 장애 구간을 일반적인 애플리케이션 코드만으로 완전히 닫을 수 없다는 한계를 명시한다.

호출이 성공하면 provider reference와 결과 payload를 action row에 남긴다.

async def finalize_success(
    action: ToolAction,
    result: TransferResult,
) -> None:
    await action_store.mark_succeeded(
        action_id=action.action_id,
        provider_reference=result.reference,
        result=result.payload,
    )

UNIQUE (tenant_id, idempotency_key)에 걸리면 기존 row를 반환하되, 저장된 인자 fingerprint가 다르면 재사용을 거부한다.

async def insert_tool_action(row: NewToolAction) -> ToolAction:
    try:
        return await action_store.insert(row)
    except UniqueIdempotencyKey:
        existing = await action_store.get_by_idempotency_key(
            row.tenant_id,
            row.idempotency_key,
        )
        if existing.action_fingerprint != row.action_fingerprint:
            raise IdempotencyKeyArgumentMismatch()
        return existing

9. Timeout은 실패가 아니라 결과 불명일 수 있다

응답 유실 구간 (timeout ≠ 실패)
  1. 외부 송금 성공
  2. 응답 패킷 유실
  3. Worker는 timeout만 관측
  4. 재시도 전 같은 key로 조회·reconciliation

가장 위험한 시점은 외부 부수 효과와 로컬 결과 저장 사이이다.

Worker ── 송금 요청 ──> Provider
                         │
                         ├─ 송금 성공
                         └─ 응답 전송
                              X 네트워크 단절

Worker가 관측한 것: timeout
실제로 일어난 것: 송금 성공

이때 action을 바로 failed로 표시하고 새 idempotency key로 재시도하면 중복 송금이 발생할 수 있다.

대신 outcome_unknown 상태로 전환한다.

try:
    result = await execute_transfer(action, client)
except NetworkTimeout as error:
    await action_store.mark_outcome_unknown(
        action_id=action.action_id,
        error=serialize_error(error),
    )
    await reconciliation_queue.publish({
        "action_id": str(action.action_id),
    })
    return

Reconciliation worker는 같은 idempotency key나 provider reference로 상태를 확인한다.

async def reconcile_transfer(action: ToolAction) -> None:
    remote = await transfer_client.find_by_idempotency_key(
        action.idempotency_key
    )

    if remote.status == "succeeded":
        await action_store.mark_succeeded(
            action.action_id,
            provider_reference=remote.reference,
            result=remote.payload,
        )
    elif remote.status == "not_found":
        await retry_same_action(action)
    else:
        await action_store.keep_outcome_unknown(action.action_id)

not_found가 정말 “실행되지 않음”을 뜻하는지는 provider 계약으로 확인해야 한다. 조회 기능도 idempotency contract도 없다면 자동으로 안전한 재시도를 보장하기 어렵다. 그 경우 수동 reconciliation이나 더 보수적인 중단 정책이 필요하다.

10. 재시도 여부는 오류 종류가 아니라 결과 확실성으로 정한다

상황알고 있는 사실기본 대응
Schema 검증 실패외부 호출 전 실패수정 후 새 시도 가능
권한 또는 승인 실패실행하면 안 됨재시도하지 않음
Rate limit실행 전 거부인지 계약 확인 필요같은 키로 backoff
연결 생성 실패요청이 전송되지 않았다는 확신이 있음같은 키로 재시도
응답 대기 timeout실행 여부를 모름outcome_unknown, 조회 우선
Provider 5xx실행 여부가 제품별로 다름같은 키와 provider 계약 사용
성공 후 로컬 DB 저장 실패외부 효과는 발생했을 수 있음같은 키로 결과 복구
업무 규칙 위반재시도해도 성공하지 않음terminal failure
승인 만료더 이상 실행 권한 없음새 승인 필요

재시도 횟수, backoff와 jitter도 필요하지만 그 전에 “같은 요청을 반복해도 안전한가?”를 확인해야 한다. 멱등하지 않은 부수 효과를 일반적인 네트워크 재시도 정책에 그대로 넣으면 안 된다.

11. Queue Ack는 결과 저장 뒤에 한다

정상 순서는 다음과 같다.

action 실행권 확보
→ 외부 API 호출
→ PostgreSQL에 succeeded 또는 최종 상태 저장
→ Queue ack

먼저 ack한 뒤 결과 저장에 실패하면 Queue에는 완료로 보이지만 로컬 시스템에는 결과가 없다.

반대로 결과를 저장한 뒤 ack 전에 Worker가 죽으면 메시지가 다시 전달될 수 있다. 재전달된 Worker는 action 상태를 확인한다.

async def handle_tool_message(message: ToolMessage) -> None:
    action = await action_store.get(message.action_id)

    if action.status == "succeeded":
        await message.ack()
        return

    if action.status == "outcome_unknown":
        await reconciliation_queue.publish({"action_id": str(action.action_id)})
        await message.ack()
        return

    claimed = await action_store.claim_if_approved(
        action_id=message.action_id,
        attempt_id=new_attempt_id(),
    )

    if claimed is None:
        await message.ack()
        return

    await execute_claimed_action(claimed)

실제 구현에서는 lease 만료, fencing token, retry budget과 Worker crash recovery 정책을 추가해야 한다.

12. Checkpoint와 Tool 실행 경계를 맞춘다

Agent runtime이 Tool node 직전에서 checkpoint를 저장했다고 하자.

checkpoint A
→ 송금 Tool 실행
→ checkpoint B

송금은 성공했지만 checkpoint B 저장 전에 Worker가 죽으면, runtime은 checkpoint A에서 재개해 Tool node를 다시 실행할 수 있다.

따라서 checkpoint만으로 부수 효과 중복을 막을 수 없다. Tool node가 재실행되더라도 같은 action_id를 사용해 기존 결과를 조회해야 한다.

async def transfer_tool(state: AgentState) -> ToolResult:
    action = await action_store.get(state.transfer_action_id)

    if action.status == "succeeded":
        return ToolResult.from_stored(action.result)

    if action.status == "outcome_unknown":
        raise ToolExecutionPendingReconciliation(action.action_id)

    if action.status != "approved":
        raise ToolNotApproved(action.action_id)

    return await execute_existing_action(action)

Runtime의 thread ID나 checkpoint ID를 idempotency key로 그대로 사용하지 않는 편이 좋다. 같은 checkpoint 안에서 서로 다른 Tool action이 있을 수 있고, 사용자가 같은 업무를 새 의도로 다시 실행할 수도 있기 때문이다.

13. 보상 작업은 Rollback이 아니다

이미 외부 계좌로 송금이 완료됐다면 데이터베이스 transaction처럼 ROLLBACK할 수 없다.

반대 방향의 송금을 만들 수 있더라도 그것은 새로운 금융 거래다.

원래 송금
→ 취소 요청
→ 새로운 승인
→ 반환 또는 반대 거래

보상 작업에는 자체 action ID, 승인, idempotency key와 감사 기록이 필요하다. “실패하면 자동으로 반대 Tool을 호출한다”는 방식은 더 큰 부수 효과를 만들 수 있다.

이메일 발송과 티켓 생성도 비슷하다. 이미 보낸 이메일을 데이터베이스에서 삭제할 수 없고, 이미 외부 고객이 본 메시지를 되돌릴 수 없다. 보상 가능성과 되돌릴 수 있음을 같은 의미로 사용해서는 안 된다.

14. 반드시 테스트해야 할 실패 지점

Happy path만 테스트하면 멱등성 문제를 발견하기 어렵다.

최소한 다음 상황을 인위적으로 만들어야 한다.

  1. 사용자가 승인 버튼을 연속으로 두 번 누른다.
  2. 같은 Queue 메시지를 두 Worker가 거의 동시에 받는다.
  3. 승인 후 수취인이나 금액 변경을 시도한다.
  4. Worker가 외부 호출 직전에 종료된다.
  5. Provider 성공 직후 로컬 결과 저장 전에 종료된다.
  6. Provider 응답이 timeout되지만 실제 거래는 성공한다.
  7. 승인 만료 뒤 늦은 메시지가 도착한다.
  8. 같은 idempotency key에 다른 인자를 보낸다.
  9. Redis의 lock과 진행 상태가 모두 사라진다.
  10. Runtime이 이전 checkpoint에서 Tool node를 다시 실행한다.

테스트의 성공 조건은 “Tool 함수가 한 번 호출됐다”가 아니다.

외부 업무 효과가 하나인가?
승인한 내용과 실행한 내용이 같은가?
결과를 모르면 자동 중복 실행 대신 안전하게 멈추는가?
재시도 후에도 같은 action 결과로 수렴하는가?

15. 정확히 한 번보다 중요한 것

분산 시스템에서 모든 계층이 동시에 정확히 한 번만 움직인다고 가정하는 것은 위험하다.

  • 사용자는 버튼을 다시 누른다.
  • 브라우저는 응답을 받지 못한다.
  • Queue는 메시지를 다시 전달한다.
  • Worker는 checkpoint에서 다시 시작한다.
  • 네트워크는 성공 응답을 잃는다.
  • 외부 서비스의 idempotency key는 언젠가 만료된다.

좋은 Tool 실행 경계는 이 중복을 숨기지 않는다. 하나의 사용자 의도를 영속적인 action으로 만들고, 정확한 인자에 승인을 묶고, 모든 재시도에 같은 idempotency key를 사용하며, 결과를 모르는 상태를 별도로 관리한다.

핵심 원칙은 네 가지다.

  1. 승인은 정확한 Tool 인자에 묶는다.
  2. 하나의 사용자 의도에는 하나의 영속적인 action ID를 사용한다.
  3. 재시도에는 항상 같은 idempotency key를 사용한다.
  4. 실행 결과를 모르면 새 부수 효과를 만들지 않는다.

이 네 가지가 지켜질 때 Agent가 다시 생각하고, Worker가 다시 실행되고, Queue가 메시지를 다시 전달하더라도 사용자는 같은 송금을 두 번 만들지 않는 시스템에 가까워질 수 있다.

다음 글 Agent가 일하면서 말하게 하기에서는 승인 대기, 실행 진행과 reconciliation 상태를 SSE로 사용자에게 전달하는 방법을 살펴본다. 전체 흐름은 Agent Engineering 시리즈에서 확인할 수 있다.

초안의 승인 원칙은 사용자가 수취인·금액 같은 중요 데이터를 직접 확인하고 서버에서 거래 상태 전이를 강제하라는 OWASP 지침, 재시도에서 같은 의도를 구별하라는 AWS Builders’ Library, 동일 키 재사용 시 최초 결과와 인자 일치를 다루는 Stripe 공식 문서, 그리고 중복을 데이터베이스 수준에서 막는 PostgreSQL 제약조건을 기준으로 잡았습니다.

출처 / 데이터 / 코드

참고문헌

  1. Making retries safe with idempotent APIs — AWS Builders’ Library
  2. Idempotent requests — Stripe Documentation
  3. PostgreSQL — Constraints — PostgreSQL Documentation
  4. Transaction Authorization Cheat Sheet — OWASP Cheat Sheet Series
  5. LangGraph Persistence — LangChain Documentation

Discussion

토론

질문, 정정, 반례, 다른 해석이 있다면 남겨 주세요.