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,
    execution_lease_until TIMESTAMPTZ,
    execution_fencing_token BIGINT NOT NULL DEFAULT 0,
    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 진행 state
  • 최근 이벤트 전달

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

승인과 queue 발행 사이도 복구해야 한다

승인 기록을 approved로 바꾼 뒤 process가 queue 발행 전에 종료되면, 데이터베이스에는 실행 가능한 action이 남지만 Worker는 그것을 알지 못한다. 반대로 queue를 먼저 발행하면 Worker가 아직 읽을 수 없는 action을 받을 수 있다.

이 구간은 transactional outbox로 닫는다. 승인 transaction 안에서 action 전이와 outbox 행을 함께 commit하고, 별도 relay가 아직 발행되지 않은 행을 queue로 전달한다. relay가 중복 전송해도 Worker의 조건부 claim과 동일 idempotency key가 결과를 한 번으로 수렴시킨다.

BEGIN;

UPDATE tool_actions
SET status = 'approved', approved_by = $actor_id, approved_at = NOW()
WHERE action_id = $action_id
  AND status = 'awaiting_approval';

INSERT INTO outbox_events (event_id, topic, payload, published_at)
VALUES ($event_id, 'tool-action.approved', jsonb_build_object('action_id', $action_id), NULL);

COMMIT;

queue 발행 자체를 database transaction에 묶을 수는 없으므로 relay는 published_at IS NULL 행을 재시도한다. 소비자는 전달 횟수가 아니라 action_id와 action의 영속 state를 기준으로 판단한다.

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)

    # 같은 사용자가 같은 action을 다시 승인해도 새 action을 만들지 않는다.
    if (
        action.status == "approved"
        and action.approved_by == user.id
        and action.approved_fingerprint == action.action_fingerprint
    ):
        return ApprovedAction.from_existing(action)

    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를 서버에서 다시 확인한다. get_for_update()와 mark_approved()는 하나의 database transaction 안에서 실행하며, mark_approved()도 WHERE status = 'awaiting_approval' 조건을 유지한다. 그래서 두 번 클릭된 승인 요청 중 첫 요청만 전이를 만들고, 같은 승인자의 후속 요청은 이미 확정된 결과를 돌려줄 수 있다.

승인 후 사용자가 금액을 수정하고 싶다면 기존 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,
    execution_lease_until = NOW() + INTERVAL '2 minutes',
    execution_fencing_token = execution_fencing_token + 1,
    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 호출에 성공하고, 로컬 결과를 저장하기 전에 죽을 수 있기 때문이다.

Worker가 외부 호출 전에 죽으면 executing action이 영구히 멈추지 않도록 lease가 만료된 뒤 recovery worker가 action을 회수한다. 다만 lease 만료만으로 이전 Worker가 외부 API를 호출하지 않았다고 단정할 수는 없다. 회수 뒤에도 같은 action_id와 idempotency key를 사용하고, 이전 시도의 결과가 불명하면 곧바로 재호출하지 않고 reconciliation으로 보낸다. fencing token은 내부 저장소의 오래된 갱신을 막는 데 유용하지만, provider가 token을 검증하지 않는 한 외부 부수 효과 자체를 막지는 못한다.

이 장애 구간을 닫으려면 외부 서비스도 같은 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가 이 계약을 지원한다면 두 번째 요청은 새 송금을 만들지 않고 최초 실행의 결과를 반환하거나 기존 실행 state를 조회할 수 있다.

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

  • 키를 얼마나 오래 보관하는가?
  • 같은 키에 다른 인자를 보내면 거부하는가?
  • 요청이 실행되기 전 validation 실패도 저장하는가?
  • 서버 오류 결과도 같은 키에 묶이는가?
  • 처리 중인 요청과 동시에 같은 키가 들어오면 어떻게 되는가?
  • 거래 state를 별도 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

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

결과 불명일 때의 복구 경로
  1. 동일 idempotency key로 Provider 호출
  2. 응답 유실 시 outcome_unknown으로 기록
  3. 같은 key·provider reference로 조회
  4. 미실행이 권위 있게 확인될 때만 같은 action 재시도

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

대신 outcome_unknown state로 전환한다.

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로 state를 확인한다.

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"
        and remote.is_authoritative_not_executed
        and action.provider_visibility_deadline <= now()
    ):
        await retry_same_action(action)  # 반드시 같은 action과 같은 key
    else:
        await action_store.keep_outcome_unknown(action.action_id)

not_found가 정말 “실행되지 않음”을 뜻하는지는 provider 계약으로 확인해야 한다. 비동기 처리나 조회 지연이 있는 provider라면 즉시 not_found를 재시도 신호로 쓰지 않는다. provider가 권위 있게 미실행을 확인하고, 그 결과가 보일 때까지의 visibility window도 지났을 때만 같은 action을 재시도한다. 조회 기능도 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 또는 최종 state 저장
→ queue ack

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

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

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 만료 뒤의 recovery, fencing token, retry budget과 Worker crash recovery 정책을 함께 설계해야 한다. 특히 이미 executing이던 action을 재전달 메시지에서 곧바로 ack하지 말고, lease와 provider 조회 결과에 따라 정상 진행·reconciliation·회수 중 어느 경로인지 영속 기록으로 판정해야 한다.

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. approved transaction은 commit됐지만 outbox relay가 queue 발행 전에 종료된다.
  4. executing Worker가 lease 만료 뒤 살아나 외부 호출을 시도한다.
  5. 승인 후 수취인이나 금액 변경을 시도한다.
  6. Worker가 외부 호출 직전에 종료된다.
  7. Provider 성공 직후 로컬 결과 저장 전에 종료된다.
  8. Provider 응답이 timeout되지만 실제 거래는 성공한다.
  9. 승인 만료 뒤 늦은 메시지가 도착한다.
  10. 같은 idempotency key에 다른 인자를 보낸다.
  11. Redis의 lock과 진행 state가 모두 사라진다.
  12. Runtime이 이전 checkpoint에서 Tool node를 다시 실행한다.

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

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

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

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

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

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

핵심 원칙은 네 가지다.

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

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

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

초안의 승인 원칙은 사용자가 수취인·금액 같은 중요 데이터를 직접 확인하고 서버에서 거래 state 전이를 강제하라는 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

토론

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

// Lightweight delegated tracking: capture the FIRST deliberate interaction // per Lab/article, including preset clicks. No passive views or slider spam.