하나의 MCP 서버, 두 개의 모델: 비용이 0달러인 상시 가동 Ops 에이전트
요약
MCP(Model Context Protocol) 서버를 활용하여 고비용의 Claude와 저비용의 로컬 모델을 동시에 연결하는 아키텍처를 소개합니다. 하나의 도구 서버에 여러 클라이언트를 연결함으로써, 상시 가동되는 저비용 Ops 에이전트를 구축하는 방법을 다룹니다.
핵심 포인트
- MCP 서버를 통해 모델을 교체 가능한 클라이언트로 활용 가능
- 도구의 보안 경계(울타리)는 모델이 아닌 서버에 존재해야 함
- Claude와 로컬 모델(Ollama)을 동일한 MCP 서버에 연결하여 비용 최적화
- 텔레그램 봇, 워치독 등을 통해 24시간 자율 운영 에이전트 구현
Part 4: your first MCP server와 hardening deep dive의 동반 시리즈입니다. Claude Code, Beyond the Prompt 시리즈의 일부입니다.
Part 4에서는 Claude에게 손을 부여했습니다. 즉, 제 시스템에 제한적이고 감사된(audited) 도구들을 노출하는 MCP (Model Context Protocol) 서버를 구축한 것입니다. 그것은 잘 작동합니다. 하지만 제가 이름을 붙이는 데 시간이 좀 걸렸던 형태(shape)의 문제가 하나 있었습니다.
Claude는 대화형이며 사용량에 따라 비용이 발생합니다. 제가 키보드 앞에 있을 때만 동작하며, 모든 동작은 토큰(tokens) 비용을 발생시킵니다. 반면, 제 시스템은 하루 24시간 내내 돌아갑니다. 새벽 3시에 무언가 고장이 나면 집에 아무도 없는 상황이 발생합니다.
제가 원했던 것은 상시 가동되는 에이전트였습니다. 저널을 감시하고, 반복되는 오류를 분류(triaging)하며, 제 휴대폰을 통해 "왜 저 서비스가 실패하고 있는가?"라는 질문에 무료로 답할 수 있는 에이전트 말입니다.
그 해답은 두 번째 Claude가 아니었습니다. 그것은 **동일한 도구 서버(tool server)에 연결된 두 번째 클라이언트(client)**였습니다.
그리고 이것이 바로 훔칠 만한 가치가 있는 아이디어이므로, 먼저 명확히 밝혀두겠습니다:
강제성(enforcement)이 도구(tools)에 존재하게 되면, 모델은 교체 가능한 클라이언트가 됩니다. 울타리는 모델에 있는 것이 아니라 서버에 있습니다. 따라서 저렴하고 성능이 낮은 로컬 모델을 동일한 울타리에 연결하여, 안전하게 틀릴 수 있도록 허용할 수 있습니다.
이것은 안전(safety) 측면에서는 사실입니다. 하지만 비용(cost) 측면에서는 사실이 아니라는 것을 알게 되었고, 저는 이미 그것을 구축한 후에 측정을 통해서야 그 사실을 깨달았습니다. 아래에 제가 꽤 자랑스러워했던 기능 하나를 무산시킨 수치들과 함께 그에 대한 섹션이 있습니다.
아키텍처: 하나의 서버, 여러 클라이언트
장비(box) 위에서 네 가지 구성 요소가 실행됩니다:
- MCP 도구 서버 (The MCP tool server). 파트 4에서 다룬 견고한 경계: 약 30개의 좁은 범위의 도구들로 구성되며, 샌드박스(sandboxed) 처리, 감사 로그(audit-logged) 기록, 베어러 인증(bearer-auth) 적용, 오직 프라이빗 네트워크에만 연결됨. 인터넷에는 절대 노출되지 않음.
- Claude, MCP 클라이언트(client)로 연결되어 사용자가 루프(loop) 안에 있는 상태에서 상호작용하는 작업 수행.
- 로컬 모델 (A local model), 동일한 장비에서 Ollama를 통해 서빙되며, 또 다른 클라이언트로 연결됨. 인터넷과 절대 통신하지 않음.
- 로컬 모델을 구동하는 세 가지 자율 진입점 (Three autonomous entry points): Telegram 봇, 워치독 (watchdog), 그리고 야간 감사 (nightly audit).
중요한 점은 이것이 무엇이 아닌가 하는 것입니다: 로컬 모델은 Claude의 어시스턴트가 아니며, Claude는 로컬 모델의 감독관이 아닙니다. 그들은 동등한 관계(peers)입니다. 두 개의 클라이언트, 하나의 울타리입니다.
도구 카탈로그 (The tool catalogue)
수행하는 기능별로 그룹화된 실제 인터페이스입니다. 모든 도구는 프롬프트(prompt)가 아닌 도구 자체에 가드레일(guardrail)을 포함하고 있다는 점에 유의하세요.
관찰 (Observation, 읽기 전용)
| 도구 | 기능 | 가드레일 |
|---|---|---|
db_query_ro | SQL 쿼리 실행 | **읽기 전용 DB 역할 (read-only DB role)**을 통한 SELECT만 허용, 500행 제한, 5초 문장 타임아웃 |
| ... |
검색 (Retrieval, 사람들이 흔히 건너뛰는 부분)
| 도구 | 기능 |
|---|---|
code_search | 인덱싱된 리포지토리(repo)에 대한 하이브리드 시맨틱(semantic) + BM25 검색. grep 문자열을 추측하는 대신 의도(intent)( |
| Tool | 기능 | 게이트 조건 |
|---|---|---|
create_branch | 새 브랜치 생성 | 이름은 반드시 auto/<agent>-fix-*와 일치해야 합니다. 다른 것은 허용되지 않습니다. |
| ... |
안전성을 확보하는 설계: 호출자별, 도구 표면(tool surfaces) 분리
이 부분이 제가 가장 강조하고 싶은 부분입니다. 왜냐하면 비용 효율적이고 거의 아무도 시도하지 않기 때문입니다.
서버는 약 30개의 도구를 노출합니다. 하지만 모든 호출자가 이 도구들을 볼 수 있는 것은 아닙니다.
- Telegram 봇 → 읽기 전용(read-only) 도구. 휴대폰으로 질문할 때, 쓰기 경로가 없습니다.
- ≤ 20개의 변경된 라인, ≤ 1개의 파일. 이보다 크면 자동 수정(auto-fix)이 아니라 변경(change)입니다.
- 카테고리는 반드시 화이트리스트에 포함되어야 함: JSONB 직렬화 (JSONB serialization), 시간대 정보가 없는 datetime (timezone-naive datetime), 누락된
None체크, 임포트 오류 (import error), 연결 재시도/백오프 (connection retry/backoff). 그게 전부입니다. 알려진 다섯 가지의 지루하고 기계적인 버그 형태들입니다. - 에러 시그니처 (error signature)가 지난 30일 동안 최소 두 번 발생했어야 함. 제가 가장 좋아하는 규칙입니다: 에이전트는 이미 재발한 것을 확인한 버그만 수정할 수 있습니다. 새로운 수정은 없습니다. 새로움은 인간의 영역입니다.
- 대상 파일은 동결(frozen) 상태가 아니어야 함. 동결 목록은 실제로 해를 끼칠 수 있는 모든 것입니다: 실시간 자금 경로 모듈, 중앙 설정 (central config), CI, systemd 유닛, 배포 스크립트 — 그리고 에이전트 자신의 소스 코드까지 포함됩니다. 그래야 에이전트가 자신의 가드레일 (guardrails)을 다시 쓸 수 없기 때문입니다.
- 브랜치는
auto/*-fix-*와 일치해야 함. - 이름 변경, 바이너리, 새 파일 생성 또는 삭제 금지. 기존의 단 하나의 파일에 대한 수정만 허용되며, 그 외에는 아무것도 안 됩니다.
- 절대로 main 브랜치에 푸시하지 않음. 브랜치 생성, 커밋, PR (Pull Request) 오픈, 그리고 나에게 알림. 머지 (merge)는 인간이 합니다. 언제나 말이죠.
이 중 하나라도 놓치면 호출 결과는 blocked로 반환되며, 재시도하지 말고 telegram_alert를 통해 인간에게 에스컬레이션 (escalate) 하라는 지침이 내려옵니다.
그 밑바탕에 깔린 철학은 다음과 같습니다: 로컬 모델은 틀려도 괜찮다. 모델이 피해를 입힐 수 없으므로, 똑똑할 필요가 없습니다. 모든 제안은 제가 휴대폰으로 읽는 PR 형태로 전달됩니다.
여기서 더 멍청한 모델이 안전한 이유: 심층 방어 (defense in depth)
로컬 모델은 프런티어 모델 (frontier model)보다 도구 호출 (tool-calling) 능력이 떨어집니다. 모델은 도구 이름을 환각 (hallucinate) 할 것입니다. 따라서 집행은 계층적으로 이루어집니다:
- **서버 측 가드레일 (Server-side guardrails)**이 진정한 방벽입니다. 위에 언급된 모든 게이트는 프롬프트가 아닌 서버에서 강제됩니다.
- **클라이언트 측 도구 화이트리스트 (A client-side tool whitelist)**가 그 앞에 위치합니다. 모델로부터 전달된 도구 이름은 유니코드 정규화(NFKC, 따라서 유사 문자가 몰래 들어올 수 없음)를 거치며, 모든 네임스페이스 구분자(
/,:,.)는 거부됩니다. 또한, 해당 호출자가 공표한 목록에 없는 모든 것은 서버에 도달하기 전에 차단됩니다. 이는 읽기 전용 Telegram 봇에서apply_patch를 환각 (hallucinate) 하는 약한 모델의 사례가 결코 가설에 그치지 않기 때문에 존재합니다. - 데이터베이스 역할 (The database role)은 데이터를 위한 방벽입니다. SQL에 대한 문자열 검사가 아니라, 연결 제한이 있는 전용
SELECT전용 역할을 사용합니다. (왜 그런지에 대한 심층 보안 강화 분석 (hardening deep dive)에서 자세히 다룹니다.) - 비밀 정보 (Secrets)는 서버 측에서 차단되므로, 요청을 받더라도 어떤 도구도 이를 읽을 수 없습니다.
- **모든 호출은 감사 로그 (audit-logged)**로 기록되며, 인자의 해시값, 지연 시간(latency), 결과 유형이 포함됩니다. 두 개의 모델, 하나의 원장(ledger).
참고: 이 중 어느 것도 모델의 성능에 의존하지 않습니다. 그것이 바로 핵심입니다.
실제로 일상에서 하는 일
새벽 3시, 침대 위에서. 무언가 잘못된 것 같습니다. Telegram을 열고 _"X 수집기가 왜 실패하고 있지?"_라고 입력합니다. 로컬 모델은 journalctl_tail을 호출하고, 이어서 systemctl_is_active를, 그다음 관련 함수를 찾기 위해 code_search를 호출합니다. 최대 6번의 도구 반복(tool iterations)을 거친 후, 세 문장으로 된 진단 결과와 그 근거가 된 로그 발췌본을 가지고 돌아옵니다. 비용: 0원. 노트북: 닫혀 있음.
와치독 (The watchdog). 주기적인 감시 작업입니다. 한 달 동안 세 번 발생한 예외(exception)를 찾아내고, 그 형태(JSONB 직렬화)를 인식하며, 파일이 얼어붙지 않았음을 확인합니다. 8줄짜리 디프(diff)를 생성하고, auto/…-fix-jsonb-…를 열어, 자동 수정(autofix) 라벨이 붙은 PR을 제출한 뒤, 저에게 경고 (warn)를 보냅니다. 저는 휴대폰으로 디프를 읽고 병합(merge)하거나 닫습니다. 이 시스템이 스스로 병합에 성공한 적은 단 한 번도 없습니다.
야간 감사 (nightly audit). 인간의 주의를 기울일 가치가 있는 새롭고 반복적인 사항은 필수적으로 ## Evidence 섹션(로그 발췌본, 단위, 발생 횟수)과 ## Source 섹션(파일 경로, 단위, 계보 (lineage))을 포함하는 GitHub issue가 됩니다. 이 엄격한 형식이 이들을 소음이 아닌 분류 가능한 (triage-able) 정보로 만들어 줍니다.
낮 시간의 Claude. 동일한 도구를 사용하지만 모드는 다릅니다: 저와 상호작용하며 루프(loop) 안에 있는 대화형 모드입니다. grepping을 하기 전에 시맨틱 검색 (semantic search)을 수행하고, psql 출력을 복사해서 붙여넣는 대신 DB 쿼리를 실행하며, 자체 체크리스트를 강제하는 배포 도구 (deploy tool)를 통해 배포를 진행합니다.
토큰 배당금 (token dividend): 서버는 읽고, 모델은 읽지 않는다
이 시스템을 구축할 때 제가 과소평가했던 부분이 바로 이것입니다. 저는 끊임없이 이 도구들을 사용하는데, 그 이유는 안전 때문이 아닙니다. 경제성 때문입니다.
모든 도구는 하나의 **압축 함수 (compression function)**입니다. 도구는 로컬 환경(the box)에서 비용이 많이 드는 읽기 작업을 수행하고 오직 정답만을 반환합니다. 그렇지 않을 경우 모델이 수행해야 했을 작업과 비교해 보십시오:
| 서버가 없을 때 | 도구가 있을 때 |
|---|---|
SSH 접속 후, 파일을 cat으로 열어 800줄을 채팅창에 붙여넣기 | read_file이 경로 확인 및 바이트 제한(byte-capped)을 거쳐 반환 |
| ... |
마지막 두 항목을 자세히 보십시오. 질문은 _"설정되어 있는가?"_와 _"일치하는가?"_였으므로, 도구는 불리언(boolean) 값과 해시(hash)를 반환합니다. 모델이 전혀 필요하지 않은 콘텐츠에 소모되는 토큰은 제로(0)입니다. 자료가 아니라 정답을 가져오는 것입니다.
이는 토큰 관련 글에서 언급한 "대량의 읽기 작업에는 서브에이전트 (subagent)를 사용하라"와 동일한 레버리지입니다: 스캐닝에 드는 50,000개의 토큰을 메인 컨텍스트 (main context)가 아닌 다른 곳에서 소비하고, 500개 토큰 정도의 결론만 가져오는 것입니다. MCP 도구는 바로 그것을 영구적으로 만든 것입니다. 사용자가 신경 쓸 필요 없이, 모든 호출마다 팬아웃 (fan-out)이 서버 측에서 발생합니다.
그리고 제가 진심으로 만족스러웠던 부분은 바로 이것입니다: 가드레일 (guardrail)과 토큰 예산 (token budget)이 결국 동일한 코드 한 줄로 나타난다는 점입니다. 폭주하는 쿼리를 중단시키는 행 제한 (row cap)은 40,000개 토큰의 결과물을 중단시키는 행 제한과 같습니다. .ssh/를 차단하는 경로 제한 (path confinement)은 모델이 필요하지 않은 디렉토리로 배회하는 것을 막아주는 장치입니다. 로그 테일 (log tail)에서의 절단 (truncation)은 안전 밸브인 동시에 비용 제어 수단이기도 합니다.
이것은 우연이 아닙니다. _"요청받은 것만 반환하라"_는 원칙은 보안 원칙인 동시에 효율성 원칙입니다. 도구를 제대로 강화 (harden)하면 비용 절감 효과를 공짜로 얻게 됩니다. 반대로 말하면, 도구가 텍스트 벽을 반환한다면 그것은 비용이 많이 들면서 동시에 보안에도 취약한 것이며, 한 번의 수정으로 두 문제를 모두 해결할 수 있습니다.
그 다음 한 단계 더 나아가 보았고, 왜 실패했는지 측정했습니다
모든 도구가 압축 함수 (compression function)라면, 당연한 다음 단계는 _조사 전체 (whole investigation)_를 압축하는 것입니다. 프론티어 모델 (frontier model)에게 도구를 하나 더 제공합니다:
ask_local(question) -> { answer, evidence, grounded, ... }
Claude가 대량의 기계적인 질문 — "유닛 X에 대한 6시간 분량의 저널을 스캔하여, 고유한 에러 시그니처와 그 횟수를 알려달라" — 을 넘기면, 로컬 모델이 5번의 도구 호출과 읽기 작업을 자신의 무료 컨텍스트 (free context) 내에서 수행하고, Claude는 2만 개의 로그 토큰 대신 세 문장을 돌려받습니다. 모델 간 위임 (Model-to-model delegation). 토큰 배당금 (token dividend)의 궁극적인 버전입니다.
저는 이것을 만들었습니다. 작동합니다. 하지만 비활성화된 상태로 출시합니다. 그 이유를 설명하겠습니다. 왜냐하면 그 _이유_가 기능 자체보다 더 가치 있기 때문입니다.
어디서든 옹호할 수 있는 설계 결정
ask_local은 단순한 답변이 아니라 증거 (evidence)를 반환합니다.
이것은 타협할 수 없는 사항이며, 앞서 언급한 '죽은 수집기 (dead collector)'와 동일한 교훈을 줍니다. 검증 불가능한 손실 압축기 (lossy compressor)는 **환각 세탁기 (hallucination-laundering machine)**와 같습니다. 호출자 입장에서 틀린 요약과 맞는 요약은 동일하게 보이기 때문에, 어느 쪽이든 — 조용히 — 그에 따라 행동하게 됩니다. 그것이 발생할 수 있는 최악의 실패 모드입니다.
따라서 모든 답변은 그것을 구성한 도구 호출 (tool calls)과 함께 전달되며, 도구가 사용 가능한 데이터를 반환하지 않을 경우 — 즉, 모델이 내 시스템이 아닌 자신의 가중치 (weights)로부터 답변했을 경우 — grounded 값은 큰 경고와 함께 false로 전환됩니다. 도구 오류 (tool errors) 및 차단된 호출 (blocked calls)은 접지 (grounding)로 간주되지 않습니다. 호출자 (caller)는 행동하기 전에 grounded를 확인해야 하며, 그렇지 않으면 아무것도 배운 것이 없습니다.
그것을 무너뜨린 수치들
나는 실제 장비에 있는 실제 로컬 모델을 대상으로 테스트를 실행했습니다. 해당 장비는 12코어 사양이며, 라이브 시스템이 이미 사용 중이라 부하 (load)가 12–17에 달해 있었습니다. llama3.2:3b 모델을 워밍업(warm)한 상태에서, 동일한 호출을 수행하되 내가 광고한 도구의 개수만 다르게 하여 테스트했습니다:
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기