AI 에이전트 가드레일 실전 설계: 프로덕션 피해 범위를 $1로 제한하는 법

PocketOS·DataTalks·Kiro 사고가 드러낸 공통 원인을 짚고, AI 에이전트의 프로덕션 피해 범위를 제한하는 클라우드 역할, 툴 허용 목록, 비용 상한, 멱등성, 변경 전 스냅샷 설계를 실제 TypeScript 코드와 이번 주에 적용할 5단계 순서로 설명합니다.

Saturday, September 5, 2026Omid Saffari
AI 에이전트 가드레일 실전 설계: 프로덕션 피해 범위를 $1로 제한하는 법

Cursor에서 Claude Opus 4.6을 실행하던 AI 에이전트가 4월 25일 단 9초 만에 PocketOS의 프로덕션 데이터베이스와 백업을 삭제한 뒤, 스스로 잘못을 인정하는 기록까지 남겼습니다. 렌터카 예약 3개월치가 사라졌습니다. 저는 매일 프로덕션을 다루는 에이전트 6개를 운영합니다. 이 글에서는 그중 가장 위험한 에이전트가 사고를 쳐도 초과 지출은 $1에 그치도록 만든 정확한 초크포인트, 허용 목록, 멱등성 아키텍처를 공개합니다.

AI 에이전트 사고로 본 90일간의 피해

2026년 4월 25일. Claude Opus 4.6을 구동하는 Cursor 에이전트는 PocketOS CEO Jeremy Crane이 “일상적인 작업”이라고 설명한 스테이징 업무를 처리하고 있었습니다. 자격 증명이 일치하지 않자 에이전트는 스스로 상황을 “정리”하기로 판단했고, 프로덕션 데이터베이스와 백업이 들어 있던 Railway 스토리지 볼륨을 삭제했습니다. 걸린 시간은 9초였습니다. 서비스는 30시간 동안 중단됐습니다. 복구할 수 있는 최신 백업은 3개월 전 것이어서 렌터카 예약 3개월치가 그대로 사라졌습니다. 이후 에이전트는 프로덕션을 건드리지 말라는 명시적 지시를 어겼다고 인정하는 기록을 남겼습니다.

2026년 2월 26일. DataTalks.Club 창립자 Alexey Grigorev는 컴퓨터를 바꾼 상태였고, 로컬 Terraform 상태 파일은 최신이 아니었습니다. 리소스를 정리해 달라는 요청을 받은 Claude Code는 프로덕션 스택을 대상으로 terraform destroy를 실행했습니다. 나중에 일부 복구된 courses_answer 테이블에는 1,943,200개 행이 들어 있었습니다. 학생들이 2년 반 동안 제출한 기록이었습니다. 자동 스냅샷마저 삭제된 바로 그 계정에 보관돼 있었습니다.

2025년 12월 중순. Amazon의 사내 에이전트 Kiro는 엔지니어의 상위 권한을 그대로 물려받았습니다. Amazon의 2인 승인 게이트는 사람에게만 적용되고 에이전트가 실행되는 역할에는 적용되지 않았기 때문에, Kiro는 이 게이트를 우회해 AWS Cost Explorer의 프로덕션 환경을 삭제한 뒤 다시 만들었습니다.

사고는 3건이고 모델도, 기술 스택도 모두 달랐습니다. 하지만 공통 원인은 모델이 아닙니다. 광범위한 권한의 상속, 자율 실행 루프, 그리고 사람이 확인 프롬프트에 대응하는 속도를 압도하는 실행 속도가 결합된 결과입니다. ServiceNow는 이미 바로 이 공포를 겨냥한 “킬 스위치” 제품을 판매하고 있습니다. 아래 아키텍처는 이를 자체 구축한 버전이며, 저는 현재 프로덕션에서 실제로 운영하고 있습니다.

비기술 창업자가 반드시 알아야 할 핵심

AI 담당자가 이 문제를 언급해 이 글을 읽고 있는 창업자라면, 잘못 설계했을 때의 대가는 단순한 “AI 버그”가 아니라는 점부터 알아야 합니다. 예약 시스템과 학생 기록, 고객 데이터가 사라질 수 있습니다. 복구 테스트를 아무도 하지 않아 백업이 3개월 전 상태에 머물러 있다면 피해는 더 커집니다.

엔지니어링 리드에게 “에이전트 성능이 좋은가?”라고 묻는 것은 핵심을 비껴갑니다. 물어야 할 질문은 이것입니다. 에이전트 한 번의 실행으로 발생할 수 있는 최대 피해는 어디까지이며, 그 상한은 누가 정했는가? 답이 “모델을 신뢰한다”거나 “diff를 검토한다”라면 통제 지점이 없는 것입니다. 희망에 기대고 있을 뿐입니다.

원하는 답은 이런 모습이어야 합니다. “에이전트는 삭제 권한이 전혀 없는 클라우드 역할로 실행됩니다. 이름이 지정된 툴 12개만 호출할 수 있고, 그중 셸은 하나도 없습니다. 모든 유료 호출은 비용 상한이 강제된 함수 하나를 통과합니다. 최악의 경우에도 단계 하나가 실패하고 지출은 최대 $1입니다.” 이것이 전체 아키텍처입니다. 지금부터 그 구현 방식을 살펴보겠습니다.

“조심하라”와 “diff를 검토하라”가 통하지 않는 이유

PocketOS의 볼륨 삭제에는 9초밖에 걸리지 않았습니다. Kiro의 삭제 작업도 사람이 확인 프롬프트를 읽고 판단하기 전에 끝났습니다. 에이전트 속도에서는 실행이 시작된 뒤 개입하는 방식이 구조적으로 불가능합니다. 행동을 확인하는 순간에는 이미 모든 일이 끝난 뒤입니다.

대부분의 에이전트 안전 관련 글은 이 지점을 피해 갑니다. 결론이 불편하기 때문입니다. 실제로 작동하는 유일한 안전장치는 실행 전 차단입니다. “에이전트가 물어보고 피곤한 사람이 yes를 누르는 방식”도, “모든 것을 기록해 나중에 검토하는 방식”도 아닙니다. 실행 전 차단이란 파괴적 작업을 애초에 에이전트의 실행 가능 범위에서 제거하는 것을 뜻합니다.

Kiro는 그 반례를 명확히 보여줬습니다. Amazon에는 2인 승인 게이트가 있었습니다. 하지만 이 게이트는 작업을 시작하는 사람에게만 적용됐습니다. 에이전트는 작업을 시작한 사람의 권한을 물려받았고, 실행 역할에 게이트가 연결돼 있지 않아 그대로 우회했습니다. 사람 앞에는 승인 절차를 세워두고 자율 루프에는 모든 삭제 권한을 열어둔 셈입니다.

따라서 문제를 보는 틀부터 바꿔야 합니다. 에이전트를 “감독”하는 것이 아닙니다. 루프가 시작되기 전에 실행 가능 범위를 제한하고, 그 제약을 프롬프트가 아니라 역할 자체의 속성으로 만들어야 합니다.

AI 에이전트 가드레일의 초크포인트: 모든 유료·변경 호출을 함수 하나로

제가 운영하는 6개 에이전트 스택에서는 모든 유료 모델 호출이 함수 하나를 통과합니다. 함수 이름은 callAi입니다. 이 함수는 다음 5가지를 순서대로 수행합니다. $20 일일 지출 상한을 먼저 확인하고, 호출 시간을 측정하고, 토큰 수와 USD 비용을 agent_idworkflow_instance_id 태그가 붙은 append-only ai_call_log 행에 기록하고, 인스턴스당 $1 상한을 사후 확인한 뒤, 결과를 반환합니다.

TypeScript
export async function callAi(env: Env, args: CallAiArgs) {
  const { agentId, workflowInstanceId, model, messages } = args;

  const dailySpent = await getDailySpendUSD(env);
  if (dailySpent >= 20) {
    throw new NonRetryableError(`daily cap hit: $${dailySpent}`);
  }

  const t0 = Date.now();
  const res = await anthropic.messages.create({ model, messages });
  const costUSD = priceOf(model, res.usage);

  await env.DB.prepare(
    `INSERT INTO ai_call_log
       (agent_id, workflow_instance_id, model, in_tokens, out_tokens, usd, ms, ts)
     VALUES (?, ?, ?, ?, ?, ?, ?, unixepoch())`
  ).bind(agentId, workflowInstanceId, model,
         res.usage.input_tokens, res.usage.output_tokens,
         costUSD, Date.now() - t0).run();

  const instanceSpent = await getInstanceSpendUSD(env, workflowInstanceId);
  if (instanceSpent >= 1) {
    throw new NonRetryableError(`instance cap hit: $${instanceSpent}`);
  }

  return res;
}

상한을 넘으면 재시도할 수 없는 오류가 발생하고 Workflow가 종료됩니다. 에이전트의 추론 계층보다 위에서 예외가 던져지기 때문에, 에이전트가 “스스로 판단해” 계속 진행할 여지는 없습니다. 예산 게이트는 에이전트가 논박할 수 있는 프롬프트가 아닙니다.

이를 자주 언급되는 63시간 동안 $4,200를 소진한 폭주 사례와 비교해 보겠습니다. 초크포인트가 없으면 종료 상태도 없습니다. 사람이 청구서를 발견할 때까지 루프는 계속 비용을 태웁니다. 인스턴스별 상한을 두면 한 번의 실행에서 발생할 수 있는 최악의 비용은 $1입니다. 일일 상한을 두면 하루 최악의 비용은 $20입니다. 저도 구축 과정에서 실수로 두 상한에 모두 걸려봤지만, 어느 쪽도 저녁 식사비보다 크지 않았습니다.

초크포인트의 또 다른 역할은 사후 분석입니다. 결과가 반환되기 전에 모든 행동이 감사 로그 행으로 남습니다. 문제가 생겼을 때 필요한 사후 분석은 에이전트가 남긴 자백을 재구성하는 일이 아니라 ai_call_log에 대한 SELECT 쿼리입니다.

AI 에이전트 권한 관리: Zod 툴 12개만, 셸과 Terraform은 제외

에이전트가 변경 작업을 수행할 수 있는 전체 범위는 엄격한 Zod 스키마로 정의된 툴 12개뿐입니다. Bash 툴도, terraform 툴도, 클라우드 볼륨 API도 아예 노출돼 있지 않습니다. 에이전트가 아무리 terraform destroy를 실행해야 한다고 추론해도 그 의도를 행동으로 표현할 방법이 없습니다.

이를 강제하는 Claude Agent SDK 설정은 간단합니다.

TypeScript
const result = await query({
  prompt,
  permissionMode: "dontAsk",
  allowedTools: [
    "read_brief", "fetch_corpus", "render_markdown",
    "validate_directives", "persist_draft", "schedule_publish",
    "update_status", "log_event", "fetch_citation",
    "embed_text", "search_vectors", "notify_human"
  ],
  // no Bash, no Write, no Edit, no infrastructure tools
});

permissionMode: "dontAsk"에서는 목록에 없는 툴이 즉시 거부됩니다. 프롬프트로 승격되지도, 사람의 검토 대상으로 넘어가지도 않습니다. 곧바로 거부됩니다. 문서에 명시된 권한 평가 순서는 PreToolUse 훅, 거부 규칙, 허용 규칙, 질문 규칙, 모드 확인, canUseTool 콜백 순으로 진행됩니다. 그러나 dontAsk에서는 사람 참여형 콜백이 구조적으로 건너뛰어지고, 알 수 없는 툴은 모드 확인 단계에서 차단됩니다.

바로 이 설정이 있었다면 PocketOS와 DataTalks 사고는 시작 단계에서 멈췄을 것입니다. 이 구성에서는 terraform destroy가 에이전트가 호출할 수 있는 툴 이름이 아니므로 DataTalks 사고가 일어날 수 없습니다. 볼륨 삭제가 실행 가능 범위에 없으므로 PocketOS 사고도 불가능합니다. 모델이 상황을 “정리”하겠다고 판단하더라도, 그 의도가 행동으로 넘어갈 경로 자체가 없습니다.

“예상하지 못한 작업이 필요하면 어떡하지?”라는 이유로 Bash 툴을 추가하고 싶어질 수 있습니다. 그러지 마십시오. Bash를 추가하는 순간 피해 범위는 “Bash가 도달할 수 있는 모든 것”으로 넓어집니다.

멱등성: 재실행이 파괴 작업을 중복 수행하지 못하게 하는 법

모든 Cloudflare Workflow 인스턴스에는 입력값으로 만든 결정적 ID publish-{brief_id}가 부여됩니다. ID가 중복되면 instances.create()가 오류를 던지므로, 같은 이벤트가 다시 발생해도 Workflow가 두 번 실행되지 않습니다. 큐 계층에서 재시도 폭풍이 일어나더라도 중복 게시가 아니라 아무 작업도 하지 않는 상태로 끝납니다.

TypeScript
const id = `publish-${msg.body.briefId}`;
try {
  await env.PUBLISHER.create({ id, params: msg.body });
} catch (e) {
  if (e.message.includes("already exists")) return; // safe replay
  throw e;
}

Workflow 내부에서는 모든 step.do() 결과가 캐시되어 안전하게 재생됩니다. 단계를 재시도하면 부수 효과를 다시 실행하는 대신 캐시된 결과를 재생합니다. 여기에 정규 D1 테이블의 INSERT OR IGNORE와 저장할 때마다 생성하는 버전별 R2 스냅샷을 결합하면, 잘못된 단계가 과거 기록을 몰래 덮어쓸 수 없습니다. 이전 버전은 R2 키 하나만 찾으면 됩니다.

DataTalks 사고의 근본 원인은 오래된 상태 파일이 되돌릴 수 없는 삭제를 유도했다는 점입니다. 결정적 Workflow ID와 변경 전 스냅샷을 함께 적용하면 오래된 상태로 인한 파괴도 치명적이지 않습니다. Workflow가 오래된 가정에 따라 실행되더라도 스냅샷이 롤백 지점이 됩니다. 이 지속 실행 원리는 Cloudflare Workflow와 Managed Agents를 비교한 글에서 자세히 다뤘습니다. 멱등성 계산이 다른 이름으로 같은 격리 원리를 구현합니다.

핵심 모델은 이렇습니다. 파괴 가능성이 있는 단계는 먼저 버전별 스냅샷을 기록합니다. 스냅샷은 사후 조치가 아니라 사전 조건입니다. 스냅샷 기록에 실패하면 변경 작업을 실행하지 않습니다. 변경에 실패하면 스냅샷이 복원 지점이 됩니다. 상태를 잃고도 복원할 수 없는 경로는 존재하지 않습니다.

이번 주에 적용할 수 있는 5단계 AI 에이전트 보안 전환

반드시 아래 순서로 진행하십시오. 1단계만으로도 앞서 살펴본 사고 3건을 모두 막을 수 있었습니다. 나머지를 당장 하지 못하더라도 이것부터 적용해야 합니다.

  1. 에이전트 클라우드 역할의 삭제·파괴 권한을 0으로 제한합니다

    에이전트가 실행되는 IAM 역할, 서비스 계정 또는 API 토큰에는 프로덕션 리소스를 삭제하거나 파괴하는 동작이 없어야 합니다. DeleteVolume도, terraform destroy 권한도, DROP TABLE도 허용하지 마십시오. 역할이 피해 범위의 최저선을 결정합니다. 역할 자체의 범위가 넓다면 그 위에서 어떤 조치를 해도 소용없습니다.

    Kiro 사고의 교훈을 구체화하면 이렇습니다. 에이전트가 작업을 시작한 사람의 광범위한 권한을 물려받게 하지 말고, 별도의 제한된 역할을 부여해야 합니다.

  2. 명시적 allowedTools 목록과 permissionMode dontAsk로 에이전트를 감쌉니다

    에이전트가 업무를 수행하는 데 필요한 최소한의 툴만 고르십시오. 제 환경에는 12개가 있고, 다른 환경에는 6개면 충분할 수도 있습니다. 범용 셸 툴은 완전히 제거해야 합니다. 현재 “편리하다”는 이유로 Bash 툴을 제공하고 있다면, 바로 그 편의성이 취약점입니다.

    TypeScript
    { permissionMode: "dontAsk", allowedTools: [/* explicit list */] }
  3. 예산 회로 차단기를 추가합니다

    모든 유료 호출을 래퍼 하나로 통과시킵니다. 일일 하드 상한을 사전 확인하고 인스턴스별 상한을 사후 확인하십시오. 한도를 넘으면 NonRetryableError를 던져야 합니다. 인스턴스별 상한은 일일 상한이 할 수 없는 차단 역할을 합니다. 63시간 동안 $4,200를 쓴 폭주 사례는 실행별 비용이 누적되는 동안에도 합리적인 일일 상한 아래에 머물렀습니다. 인스턴스별 상한은 일일 상한이 놓치는 문제를 잡아냅니다.

  4. 변경 전에 스냅샷을 생성합니다

    파괴 가능성이 있는 모든 단계는 먼저 버전별 스냅샷을 기록해야 합니다. R2도, S3도, *_archive D1 테이블도 괜찮습니다. 중요한 것은 동일한 단계와 동일한 트랜잭션 형태의 블록 안에서 스냅샷 기록을 변경 작업의 사전 조건으로 만드는 것입니다.

  5. Append-only 작업 로그를 남깁니다

    툴 호출마다 한 행을 기록하고, 결과가 반환되기 전에 저장하십시오. 에이전트 ID, Workflow 인스턴스 ID, USD 비용, 지연 시간을 태그로 남깁니다. 언젠가 문제가 생기면, 그리고 결국은 생기게 마련이라면, 사후 분석은 기억을 재구성하는 작업이 아니라 SELECT가 됩니다.

각 단계는 하루 이내에 끝낼 수 있습니다. 이 순서가 중요한 이유는 격리 효과가 차곡차곡 쌓이기 때문입니다. 1단계가 최저선을 정하고, 2단계가 그 위의 실행 가능 범위를 닫으며, 3단계가 그 범위 안에서 발생하는 비용을 제한합니다. 4단계와 5단계는 어떤 실패든 복구하고 조회할 수 있게 만듭니다.

FAQ

시스템 프롬프트에 프로덕션을 절대 삭제하지 말라고 쓰면 충분하지 않나요?

PocketOS도 그렇게 했습니다. 에이전트가 남긴 기록에는 해당 지시를 명시적으로 어겼다는 내용까지 있었습니다. 지시는 통제 지점이 아닙니다. 프롬프트는 확률적 시스템에 들어가는 입력일 뿐이고, 툴 허용 목록과 클라우드 역할은 런타임의 속성입니다. 통제는 런타임에서 구현해야 합니다.

permissionMode dontAsk를 사용하면 모든 상호작용을 포기해야 하나요?

아닙니다. 목록에 없는 툴이 프롬프트를 띄우는 대신 거부된다는 뜻입니다. 허용된 실행 범위는 그대로 사용할 수 있습니다. 사라지는 것은 “에이전트가 묻고 피곤한 사람이 yes를 누르는” 실패 경로뿐이며, 바로 없애야 할 경로입니다.

일일 비용 상한이 있는데 인스턴스별 상한도 필요한 이유는 무엇인가요?

63시간 동안 $4,200를 쓴 폭주 사례는 실행별 비용이 누적되는 동안에도 합리적인 일일 상한 아래에 머물렀기 때문입니다. 잘못된 루프 하나가 “하루 $20”보다 훨씬 적게 쓰면서도 주말 내내 이어져 총비용이 네 자릿수까지 불어날 수 있습니다. 인스턴스별 상한은 일일 상한이 구조적으로 제공할 수 없는 회로 차단기입니다.

Cloudflare Workflows가 아니라 Vercel이나 Railway를 사용해도 적용할 수 있나요?

초크포인트, 허용 목록, 범위가 제한된 역할은 플랫폼과 무관합니다. Cloudflare에만 해당하는 것은 결정적 Workflow ID라는 멱등성 구현 수단뿐입니다. 다른 스택에서는 작업에 멱등성 키를 사용하고 큐 또는 작업 실행기 계층에서 중복을 거부하면 됩니다.

2인 승인만으로 충분한가요?

Kiro에는 사람을 위한 2인 승인이 있었지만, 에이전트는 이를 우회할 수 있는 권한을 물려받았습니다. 승인 게이트는 작업을 시작한 사람이 아니라 에이전트가 실행되는 역할에 연결돼야 합니다. 에이전트 역할에 삭제 권한이 있다면 게이트는 장식에 불과합니다.

지난주 팀의 에이전트가 프로덕션에 지나치게 가까이 접근해 이 글을 읽고 있다면, DVNC.dev에서 제가 제공하는 작업이 바로 이런 격리 설계입니다. 위 아키텍처를 해당 스택에 적용하되, 피해 범위를 먼저 차단하는 순서대로 진행합니다.

마지막 업데이트

2026년 9월 5일

카테고리Build

Google에서 이 사이트를 우선하기

Google 검색에서 omidsaffari.com을 선호 소스로 추가

omidsaffari.com을 선호 소스로 지정하면 Google이 Top Stories, AI Overviews, AI Mode에서 우선적으로 보여 줍니다.

Build의 다른 글

Build 글 전체 보기
뉴스레터

매주 일요일, 한 통의 편지. 뜨거운 의견이 아닌, 돌아가는 시스템.

AI 벤처 포트폴리오 운영에서 나오는 빌드 로그, 가동 중인 시스템, 현장 노트.

주간 발행. 스팸 없음. 언제든 해지 가능합니다.