프롬프트는 소망이고, 도구는 법이다
요약
비엔지니어가 AI 에이전트를 통해 코드를 작성하고 프로덕션에 배포할 수 있도록 돕는 안전한 인프라 구축 방법을 다룹니다. MCP(Model Context Protocol)와 Cloudflare Workers를 활용하여 보안 가드레일을 확보하고 컨텍스트 비대화를 방지하는 아키텍처를 설명합니다.
핵심 포인트
- AI가 코드를 작성하는 것보다 실행 권한을 안전하게 관리하는 가드레일 구축이 핵심임
- MCP를 활용해 단일 엔드포인트로 다수의 도구를 관리하여 컨텍스트 비대화 방지
- Gateway, Skill-runner, Agent-runner로 구성된 3계층 워커 아키텍처 제안
- 비밀 정보(Secrets)를 사용자 로직과 분리하여 보안성 확보
비엔지니어들이 AI 도구를 프로덕션에 배포할 수 있게 만든 방법 — 그리고 이를 안전하게 만든 지루한 인프라.
한 프로덕트 매니저(PM)가 평범한 영어로 워크플로우를 설명했습니다 — "매일 아침, 어제 실패한 결제 내역을 가져와서 에러 코드별로 그룹화한 뒤, 우리 채널에 요약본을 게시해 줘." 20분 후, 그것은 프로덕션(production)에서 실행되고 있었습니다. 그녀는 에디터를 한 번도 열지 않았습니다. TypeScript 코드는 단 한 줄도 보지 못했습니다. 그녀는 에이전트(agent)와 대화했고, 에이전트가 코드를 작성했으며 — 사람이 풀 리퀘스트(pull request)를 검토한 후 — 배포되었습니다.
이 문장은 당신을 불안하게 만들어야 합니다. 그것을 만든 저조차도 불안했습니다.
데모(demo)는 "보세요, 코드를 작성했습니다"라고 말합니다. 하지만 운영(operation) 측면에서는 "마케터의 도구가 이제 결제 데이터베이스로 가는 경로를 가졌고, 아무도 그것을 검토하지 않았다"는 뜻입니다. 흥미로운 엔지니어링은 LLM이 코드를 작성하는 부분이 아닙니다. 그 부분은 쉽고 데모하기 좋은 부분일 뿐입니다. 진짜는 그 코드가 존재해도 되는지를 결정하는 가드레일(guardrails)에 있습니다.
여기에 플랫폼이 있으며, 실행되는 코드를 읽을 수 없는 사람들에게 이를 안전하게 넘겨주기 위해 제가 해결해야 했던 다섯 가지 문제입니다.
시스템의 형태
이 플랫폼은 엔지니어, PM, 디자이너, QA 등 누구라도 재사용 가능한 AI 도구를 게시할 수 있고, 다른 모든 사람이 이를 사용할 수 있는 공간입니다. 한 번 작성하면 모두가 사용할 수 있습니다.
설계 전체가 다음 용어들에 의존하므로 미리 몇 가지 용어를 정의하겠습니다:
- MCP (Model Context Protocol)는 AI 클라이언트가 당신의 함수를 발견하고 호출하는 표준 방식입니다. 핵심적인 세부 사항은 클라이언트가 서버에 _"어떤 도구들을 가지고 있나요?"_라고 묻고, 서버가 목록으로 응답하는 단계가 있다는 것입니다. 이 점을 기억하세요. 설계의 절반이 바로 그 목록 하나에 달려 있습니다.
- Cloudflare Workers는 자신의 서버 대신 네트워크 에지(edge)에 있는 Cloudflare의 서버에서 실행되는 코드입니다. Durable Objects는 모델의 컨텍스트(context) — 모델이 현재 볼 수 있는 유한하고 토큰 비용이 발생하는 창(window) — 의 _외부_에 존재하는 세션별 서버 측 저장소입니다. 이 중 어느 것도 생소한 것은 아닙니다. 중요한 것은 각 상태(state) 조각이 어디에 사느냐 하는 것입니다.
내부적으로는 MCP(Model Context Protocol)를 통해 통신하는 세 개의 작은 워커(Workers)로 구성됩니다: 게이트웨이 (gateway) (인증, 라우팅, 비밀 정보), 스킬 러너 (skill-runner), 그리고 **에이전트 러너 (agent-runner)**입니다. 비밀 정보(Secrets)는 게이트웨이가 비밀 관리자(secrets manager)로부터 가져오며, 인라인(inlined)으로 삽입되거나 해당 코드가 명시적으로 _액션 (action)_인 경우를 제외하고는 사용자 로직을 실행하는 코드에 전달되지 않습니다 (이 차이점에 대해서는 아래에서 더 자세히 다룹니다).
대부분의 "AI 플랫폼" 게시물들이 생략하는 부분이 바로 여기입니다: 어떻게 소비되는가. 당신은 Claude 클라이언트에 50개의 별도 에이전트를 설치하지 않습니다. 단 하나의 MCP 서버에 연결할 뿐입니다. 게시된 모든 도구(tool)는 그 단일 엔드포인트를 통해 나타납니다. 이러한 선택이 플랫폼과 컨텍스트 비대화(context-bloat) 기계 사이의 차이를 결정하며, 그 이유는 나중에 다시 설명하겠습니다.
도구 그 자체는 회사가 운영하는 시스템들—이슈 트래커(issue trackers), 채팅, 문서, CMS, 분석 데이터 웨어하우스(analytics warehouse), 결제 데이터베이스—에 도달합니다. 그 데이터 중 일부는 무해합니다. 하지만 일부는 부주의한 fetch 한 번이면 바로 컴플라이언스 사고(compliance incident)로 이어질 수 있는 데이터입니다. 전체 설계는 이러한 비대칭성을 중심으로 조직되어 있습니다.
문제 1: 프롬프트는 소망이고, 도구는 법이다.
작성 흐름(authoring flow)은 고정된 파이프라인입니다: 계획을 세우고, 계획을 승인받고, 파일을 생성하고, 자신의 작업을 검토하고, PR(Pull Request)을 생성합니다. 아주 깔끔하고 질서 정연한 흐름이죠.
하지만 에이전트는 이를 준수하기를 거부했습니다. 계획이 승인되기도 전에 파일을 생성했습니다. 코드를 "검토"한다며 _좋아 보임(looks good)_이라고 말하고는 즉시 PR을 생성했습니다. 번거로운 단계들을 건너뛰고 결승점을 향해 돌진했습니다. 왜냐하면 _도움이 될 것, 작업을 완료할 것_을 최적화하는 모델은 그렇게 행동하기 때문입니다. 나의 파이프라인은 내 머릿속과 모델이 그저 정중한 제안 정도로 취급하는 긴 지침 파일(instruction file) 속에만 존재했습니다.
나는 절박함이 커지는 순서대로, 우선 당연한 것들부터 시도해 보았습니다:
- 지침 (Instructions). **"STOP. 계획이 승인될 때까지 코드를 작성하지 마십시오."**와 같이 굵은 글씨로 강조된 시스템 프롬프트 (System Prompt)를 사용하는 방식입니다. 모델은 이를 읽고 동의하지만, 작업이 필요해 보이면 어쨌든 코드를 작성합니다. 프롬프트 텍스트는 모델이 가중치를 두어 고려하는 입력값일 뿐, 모델이 반드시 준수해야 하는 규칙이 아닙니다.
- 인메모리 상태 머신 (An in-memory state machine). 대화의 단계를 추적하고 다음 단계로 넘어가는 것을 거부하는 방식입니다. 하지만 이는 컨텍스트 (Context)가 압축되는 순간 무용지물이 됩니다. 에이전트 (Agents)는 공간을 절약하기 위해 이전 기록을 요약하며, 이 과정에서 모델이 20개의 메시지 전에는 "알고 있었던" 사실이 조용히 사라지고, 자신이 어떤 단계에 있는지 잊어버리게 됩니다.
- 훅 (Hooks). 동작을 가로채서 허용되지 않은 동작을 차단하는 방식입니다. 모델은 차단된 경로를 우회하거나, 말을 바꾸거나, 동일한 목적지에 도달할 수 있는 다른 도구 (Tool)를 찾아내는 데 놀라울 정도로 능숙합니다.
이 세 가지 방식 모두에서 나타나는 패턴은 다음과 같습니다: 각각은 _모델의 추론 (Reasoning) 내부_에 존재하며, 모델의 추론 내부에 있는 모든 것은 협상의 대상이 될 수 있습니다. 작업 압박을 받는 모델은 텍스트를 신뢰할 수 없을 정도로 능숙하게 합리화하며 규칙을 넘어섭니다. 따라서 프롬프트는 여전히 모델을 조종할 수는 있지만, _보장 (Guarantee)_할 수는 없습니다. 그리고 프로덕션 규칙 (Production rule)에는 보장이 필요합니다.
따라서 비결은 모델에게 규칙을 더 잘 말해주는 것이 아닙니다. 규칙을 도구의 속성으로 만드는 것입니다. 각 단계가 그 자체로 하나의 도구가 되고, 이 도구들이 그래프 (Graph)를 형성하게 합니다. 즉, 단계 도구가 이전 단계가 수행되었는지 검증하고, 성공했을 때만 **다음 단계에 대한 지침을 반환 (Return)**하는 방식입니다. 모델은 앞서 나갈 수 없습니다. 현재의 게이트 (Gate)가 지침을 넘겨주기 전까지는 물리적으로 다음 지침을 가지고 있지 않기 때문이며, 이 게이트가 다음 상태로 넘어가는 유일한 엣지 (Edge)가 됩니다.
start_building → confirm_plan → submit_for_review → submit_final → create_pull_request
이 부분은 저를 포함해 사람들이 처음에 잘못 이해하는 지점입니다. 게이트(gate)를 벽으로 만드는 것은 도구 호출(tool call) 실패를 무시하기 어렵다는 점이 아닙니다. 모델은 오류를 무시할 수 있습니다. 훅(hooks)을 우회했던 것과 마찬가지로, 오류를 재시도하거나 우회할 수 있습니다. 모델이 할 수 없는 것은 다음 단계의 지침을 조작(fabricate)하는 것입니다. 왜냐하면 그 지침들은 오직 검증된 성공 응답(validated success response) 내에만 존재하기 때문입니다. 결정론(determinism)은 오류에 있는 것이 아니라, 서버 측의 상태 게이트(state gate)에 있습니다. 모든 도구는 동작하기 전에 영속화된 단계(persisted phase)를 확인합니다. 오류는 단지 게이트가 _아직 아님(not yet)_이라고 말하는 방식일 뿐입니다.
구체적으로 설명하자면: 에이전트가 단계가 여전히 planning인 상태에서 create_pull_request를 호출합니다. 게이트는 잘못된 단계를 확인하고 오류를 반환하며, — 가장 중요한 부분인데 — 다음 단계의 지침을 절대 넘겨주지 않습니다. 에이전트는 완료가 금지된 것이 아니라, 완료할 수 없는 것입니다. 완료하려면 부여받지 못한 단어들이 필요하기 때문입니다.
상태(State)는 세션(session)을 키로 하여 Durable Object 스토리지의 서버 측에 존재합니다. 이는 모델의 컨텍스트(context) 외부에서 완전히 영속화되므로, 인메모리(in-memory) 버전을 무너뜨렸던 압축(compaction) 작업도 여기에 영향을 미칠 수 없습니다.
const fail = (text: string) => ({ isError: true, content: [{ type: "text", text }] });
const ok = (text: string) => ({ content: [{ type: "text", text }] });
...
한 줄로 요약하자면 이 원칙입니다: 도구가 마지막 단계를 확인하기 전까지 모델은 다음 단계에 대한 권한을 얻지 못합니다. 프롬프트(prompt)가 아니라, 프로그램(program)입니다.
submit_final은 "신뢰하되 검증하라(trust but verify)"가 그냥 "검증하라(verify)"가 되는 지점입니다. 이 단계는 최종 파일들과 모델 자체의 코드 리뷰에서 나온 결과물(findings)을 모두 가져오며, 빈 리뷰는 거부합니다:
if (!reviewFindings || reviewFindings.length === 0) {
return fail(
"review_findings is empty. Re-review the diff and report concrete findings " +
...
이 검사가 무엇을 얻을 수 있는지에 대해 솔직해져야 합니다. 이는 최소한의 기준(floor)을 높여줄 뿐, 실제적인 검토를 보장하지는 않습니다. 모델은 "괜찮아 보인다"는 조건에 만족했을 때와 마찬가지로, 단 하나의 무의미한 결과(throwaway finding)만으로도 length > 0 조건을 충족할 수 있습니다. 하지만 '결과가 전혀 없음(zero findings)'을 에러로 만드는 것은, "괜찮아 보인다"는 상태를 단순히 종료 조건이 아닌 '다시 확인해 보라는 프롬프트(prompt)'로 전환합니다. 그리고 실제로 이러한 유도(nudge)는 매우 큰 가치가 있습니다. 이것은 천장(ceiling)이 아니라 바닥(floor)입니다.
문제 2: "코드를 작성해줘"는 너무 강력한 권한입니다. 이를 세 가지로 나누십시오.
엔지니어가 아닌 사람이 도구(tool)를 작성할 수 있고, 그 도구가 "임의의 코드(arbitrary code)"라면, 엔지니어가 아닌 사람이 운영 환경(production)을 대상으로 임의의 코드를 작성할 수 있다는 뜻입니다. 그것은 플랫폼이 아닙니다. 그것은 채팅 인터페이스를 가진 장애 발생기(incident generator)일 뿐입니다.
따라서 "도구"는 단일 개념이 아닙니다. 도구는 정확히 세 가지 프리미티브(primitives) 중 하나여야 하며, 이들 사이의 차이가 전체 안전 모델(safety model)을 결정합니다.
- **기술 (A skill)**은 순수 로직입니다.
fetch가 없습니다. 비밀 정보(secrets)도 없습니다. 부수 효과(side effects)도 없습니다. "이 결제 건들을 에러 코드별로 그룹화해줘"는 기술입니다. - **액션 (An action)**은 외부 세계에 접촉할 수 있는 유일한 요소입니다. 모든
fetch, 모든 API 키, 모든 비밀 정보는 오직 여기서만 존재하며 다른 곳에는 존재하지 않습니다. "데이터베이스에서 어제 실패한 결제 내역을 읽어와줘"는 액션입니다. - **에이전트 (An agent)**는 기술과 액션을 워크플로우(workflow)로 오케스트레이션(orchestrate)합니다. 에이전트는 구성(compose)할 뿐, 직접 외부로 손을 뻗지 않습니다.
// skill — 순수함. fetch()를 포함하고 있다면 검토 단계에서 거부됨.
export const groupByErrorCode = defineSkill({
name: "group_payments_by_error_code",
...
이것은 형식적인 절차(ceremony)가 아닙니다. 이는 _"이 도구가 결제 데이터를 유출할 수 있는가?"_라는 질문에 기계적인 답변이 가능하다는 것을 의미합니다. 즉, 결제 데이터에 접근할 수 있는 액션을 사용하는 경우에만 유출이 가능하다는 것입니다. 기술(skills)은 할 수 없습니다. 에이전트(agents)도 할 수 없습니다. 당신이 액션(actions)을 감사(audit)한다면, 폭발 반경(blast radius)을 감사한 것이 됩니다.
이 모든 것은 새로운 아이디어가 아닙니다. 이는 작업복을 입은 역량 기반 보안 (capability-based security)일 뿐입니다. 기술 (skill)은 주변 권한 (ambient authority)을 가지지 않습니다. 네트워크가 기술에 전달된 적이 없기 때문에 네트워크에 접근할 수 없습니다. 이 기여의 핵심은 원칙 그 자체가 아니라, 그것이 겨냥하고 있는 위협 모델 (threat model)에 있습니다. 즉, 코드의 작성자는 유용성 (helpfulness)을 최적화하는 언어 모델 (language model)이며, 명세 (spec)는 출력값을 읽을 수 없는 누군가의 문장이라는 점입니다.
주의 깊은 독자라면 요구할 만한 두 가지 솔직한 참고 사항이 있습니다:
- "
fetch가 포함되어 있으면 거부됨"이 많은 일을 수행하고 있는데, 어떻게 가능한가? "분석 (analysis)"라는 단어가 암시하는 것보다는 적게 수행하며, 정확하게 짚고 넘어갈 가치가 있습니다. 제출 시점의 체크는 정규 표현식 (regex) — 파일 텍스트에 대해 실행되는/fetch\s*\(/— 이지, AST 파싱 (AST parse)이 아닙니다. 이는 단순한 실수는 잡아내지만, 결심을 굳힌 작성자(예:globalThis["fet" + "ch"], 동적import(), 또는 모든 간접 참조)를 막지는 못합니다 (그대로 통과됩니다). 따라서 정적 체크 (static check)는 벽이 아니라 냄새를 맡는 테스트 (smell test)로 취급하십시오. 진짜 경계는 작성자가 우회하여 수정할 수 없는 두 가지 구조적 사실입니다. 첫째, 기술은 **빈 환경 (empty environment)**에서 실행됩니다. 실행기 (runner)는 메모리에 비밀 정보 (secrets)를 보유하지만 기술에는{}를 전달하므로, 길을 잃은fetch는 중요한 무엇인가에 인증할 자격 증명 (credentials)이 없습니다. 즉, 공개 URL에 접속할 수는 있어도 아무것도 알아낼 수 없습니다. 둘째, 비밀 정보를 보유하거나 네트워크에 접촉하는 모든 프리미티브 (primitive) — 즉 모든 액션 (action) — 은 기술과는 **별도의 워커 (separate Worker)**에서 실행되며, 비밀 관리자 (secrets manager)가 연결된 유일한 워커가 바로 그곳입니다. 기술은fetch로부터 샌드박스 (sandboxed) 처리되어 격리된 것이 아니라, 자격 증명으로부터 격리 (quarantined)된 것입니다. 이것이 정규 표현식을 통과하여 몰래 들여온fetch(조차도 극복할 수 없는 부분입니다. - 주니어 작성자에게 있어 승리 또한 뒤집힌 형태의 동일한 경계입니다. 당신은 데이터베이스 토큰을 절대 보유하지 않으므로, 이를 잘못된 곳에 붙여넣을 수 없습니다. 토큰은 당신의 파일에 절대 들어오지 않습니다. 당신이 배포한 후, 런타임 (runtime)에 비밀 관리자로부터 액션의 워커 (action's Worker)로 주입됩니다. 회사를 보호하는 경계가 바로 당신 자신으로부터 당신을 보호하는 경계입니다.
액션 경계(action boundary)가 제공하는 한 가지 더 중요한 이점은, 당신이 특정 모델 벤더(model vendor)에 종속되지 않는다는 것입니다. LLM (Large Language Model)이 필요한 액션은 OpenAI, Gemini, 또는 Claude를 호출할 수 있습니다. 제공자(provider)는 액션별로 선택 사항이며, 모든 키는 동일한 시크릿 매니저(secrets manager)에서 가져옵니다. 모델 목록은 코드가 아닌 설정(config)에 존재합니다. 즉, 모델을 추가하는 것은 배포(deploy)가 아닌 편집(edit)의 문제입니다. 플랫폼은 당신의 도구가 어떤 모델과 통신하는지 상관하지 않습니다. 모델과 통신하는 것 또한 그저 또 다른 액션이기 때문입니다.
문제 3: 모든 사람이 모든 도구를 봐서는 안 됩니다 — 그리고 이것이 컨텍스트가 깨끗하게 유지되는 이유이기도 합니다.
열린 이슈(open issues)를 요약하는 도구는 모두에게 유용합니다. 하지만 결제 데이터베이스를 읽는 도구는 그렇지 않습니다. AI 도구의 위험한 부분은 그것이 무엇을 쓰느냐가 아니라, 무엇을 '볼 수 있느냐'에 있습니다. 따라서 당신에게 어떤 도구가 나타날지는 도구를 만든 사람이 누구냐가 아니라, 그 도구가 접근할 수 있는 데이터의 민감도에 의해 제한됩니다.
모든 프리미티브(primitive)는 선택 사항인 allowedGroups를 가집니다. 비어 있으면 공개(public)를 의미합니다. 그렇지 않으면 플랫폼은 아이덴티티 제공자(identity provider, 당신이 어떤 팀에 속해 있는지 이미 알고 있는 기업용 싱글 사인온(SSO))로부터 사용자의 그룹을 가져와 — 어떤 대시보드를 열 수 있는지를 결정하는 것과 동일한 그룹입니다 — 그리고 "사용 가능한 도구가 무엇인가요?"라는 질문에 답하는 시점에 해당 그룹과 도구의 허용된 그룹(allowed groups)을 교차(intersect)시킵니다.
function registerTools(server: McpServer, tools: ToolDef[], user: UserProps) {
for (const tool of tools) {
if (!hasAccess(tool.allowedGroups, user.groups)) continue; // 이 사용자에게는 목록에 없음
...
이제 두 번째 결실이자, 저를 놀라게 했던 부분입니다. 누가 무엇을 볼지 결정하는 동일한 그룹 체크가 컨텍스트 위생(context hygiene)까지 담당합니다.
몇 달이 지나자 약 10개의 팀에 걸쳐 150개 이상의 도구가 게시되었습니다. 모든 MCP 설정은 규모가 커짐에 따라 동일한 벽에 부딪힙니다. 만약 클라이언트가 모든 도구 스키마(schema)를 사전에 로드한다면, 단 하나의 질문을 던지기도 전에 토큰 예산(token budget)이 바닥나 버립니다. 저희는 이 문제에 부딪히지 않았습니다. 그리고 플랫폼이 하는 일과 클라이언트가 하는 일의 차이에 대해 솔직하게 말할 가치가 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기