에이전트가 나의 프로덕션 CMS에 직접 글을 쓰게 하기
요약
콘텐츠 플랫폼의 CMS에 에이전트가 직접 글을 작성하도록 MCP(Model Context Protocol) 서버를 구축하여 워크플로우를 자동화하는 방법을 다룹니다. 단순 스크립트나 브라우저 자동화 대신, 서비스 신원 인증과 감사 로그를 지원하는 도구 서버 방식을 통해 안정성과 추적성을 확보합니다.
핵심 포인트
- MCP 서버를 활용해 워크플로우를 인코딩하지 않고 원시 도구(verbs) 중심으로 에이전트 제어
- 브라우저 자동화의 취약점과 REST 스크립트의 관리 어려움을 해결하는 구조 설계
- 사용자 토큰 대신 서비스 신원(Service Identity)을 사용하여 에이전트 작업의 감사 로그 확보
- 로컬 stdio와 프로덕션 리버스 프록시 환경에서 동일한 도구 코드 경로 유지
내가 운영하는 콘텐츠 플랫폼에서 가장 느린 작업은 리뷰를 처음부터 끝까지 작성하는 것입니다. 레코드를 생성하고, 70여 개의 구조화된 필드를 채우고, 서사 블록(narrative blocks)을 작성하고, 스크린샷을 업로드하고 연결한 뒤, 발행합니다. 모든 리뷰는 동일한 형태를 따르며, 바로 이 점이 작업을 고통스럽게 만드는 동시에 에이전트에게 위임하기에 아주 적합한 후보로 만드는 이유입니다.
사람들이 가장 먼저 걱정하는 것은 품질입니다. 에이전트가 창피한 내용을 작성하면 어쩌죠? 그것은 쉬운 문제입니다. 초안은 검토가 가능하며, 나쁜 초안은 읽는 데 드는 시간 외에는 아무런 비용도 발생시키지 않습니다.
하지만 작성이란 프로덕션(production)에 직접 쓰는 것을 의미하며, 이는 차원이 다른 문제입니다. 라이브 CMS에 쓰기 권한(write access)을 가진 에이전트는 단순한 초안 작성 도구가 아닙니다. 그것은 잠도 자지 않고, 지루한 필드 작업에 싫증을 내지 않으며, 첫 번째 레코드가 제대로 나왔는지 의심하지 않고 백로그(backlog)를 끝까지 처리하는 두 번째 관리자입니다.
따라서 질문은 에이전트가 사이트를 편집할 수 있느냐가 아니었습니다. 질문은 이것이었습니다: 에이전트가 무엇으로 인증되는가, 에이전트가 쓸 때 어떤 규칙이 강제되는가, 그리고 사후에 에이전트가 무엇을 했는지 재구성할 수 있는가.
스크립트가 아닌 툴 서버(tool server)를 사용하는 이유
나는 쓰기 경로(write path)를 위해 세 가지 형태를 검토했습니다.
일회성 REST 스크립트는 시작하기에는 가장 빠르지만, 관리하기에는 최악입니다. 각 스크립트는 필요한 검증 규칙(validation rules)의 일부를 매번 새로 구현해야 하며, 서로 조합될 수 없고, 나중에 어떤 스크립트가 무엇을 체크했는지 알려주는 기능도 없습니다.
브라우저 자동화(Browser automation)는 보기보다 유혹적입니다. 실제 관리자 UI를 구동함으로써 UI가 강제하는 모든 규칙을 공짜로 상속받기 때문입니다. 하지만 이는 또한 느리고, 마크업(markup) 변경에 취약하며, 결과값을 원할 때 스크린샷을 던져줄 뿐입니다.
대신 MCP 서버를 구축했습니다. 이 도구들은 기본 원시(primitives)들로 구성되어 있습니다 (목록 가져오기, 목록 업데이트, 에셋 업로드, 페이지 블록 교체 등). 그리고 에이전트가 이들을 어떻게 순서화할지 결정합니다. 이것이 비용을 지불할 가치가 있는 부분이었습니다. 저는 워크플로우를 예측할 필요 없이 동사(verbs)만 예측하면 되었습니다. 대략 40개의 도구가 CMS가 관리하는 엔티티 유형들을 다루며, 이들 중 어느 것도 워크플로우를 인코딩하지 않습니다.
하나의 서버, 두 개의 전송 방식입니다. 로컬에서는 에이전트 호스트가 stdio를 통해 자식 프로세스로 실행합니다. 프로덕션 환경에서는 리버스 프록시 뒤에서 지속적인 서비스로 실행되어 동료의 에이전트가 자체 키로 접근할 수 있습니다. 도구는 같고, 코드 경로도 같으며, 드리프트(drift) 할 부분이 없습니다.
에이전트는 사용자가 아닙니다
MCP 서버는 결코 사람의 세션 토큰을 보유하지 않습니다. 이는 각 운영자별로 발급되는 자체 API 키를 가진 서비스 신원(service identity)으로 백엔드에 인증합니다.
이는 감사 로그(audit log)를 생각하기 전까지는 의미 없는 의식처럼 보였습니다. 만약 에이전트가 제 토큰을 빌려 간다면, 작성되는 모든 행은 제가 했다고 기록되며, 나중에 '제가 관리자 UI에서 변경했는지'와 '에이전트가 새벽 3시에 변경했는지'를 분리할 방법이 없습니다. 운영자별 키는 아무도 자격 증명을 공유하지 않으면서 누가 에이전트를 통해 쓰기 작업을 수행했는지 추적할 수 있게 해줍니다. 한 운영자의 자동화를 취소하는 것은 모든 것이 의존하는 비밀(secret)을 교체하는 대신 단지 한 행을 삭제하는 것만으로 충분합니다.
행 자체는 지루합니다. 액터(Actor), 액션(action), 엔티티 유형(entity type), 엔티티 ID(entity id), 필드와 그 이전/이후 값을 담은 메타데이터 블롭, IP 주소, 타임스탬프가 있으며, 시간과 액터, 엔티티별로 인덱싱됩니다. 지루한 것은 괜찮습니다. 스키마 자체가 어려운 부분은 아니었습니다. 편집자가 작성하는 내용이 실제로 도달하게 만드는 것이 어려웠습니다.
하나의 이음매(Seam), 또는 이음매 없음
여기서 프로젝트가 진정한 문제를 가지고 있었는데, 이는 에이전트와는 완전히 별개의 문제였습니다.
감사 이벤트(audit events)를 생성하는 쓰기 작업은 18개의 모듈에 흩어져 있었습니다. 그중 4개는 제대로 된 쓰기 서비스(write services)였는데, 각각 동일한 8단계 레시피를 직접 구현하고 있었습니다: 들어오는 필드 화이트리스트(allowlist) 처리, 서버 관리 필드 제거, 필수 배열(arrays) 기본값 설정, 규제 주제 페이지에 필요한 저자 귀속(author attribution) 검증, 충돌 확인, 편집이 콘텐츠 업데이트로 간주되는지 결정하기 위한 차이 분석(diff), 최초 게시 타임스탬프 기록, 쓰기, 다시 읽기, 로그 기록 순이었습니다.
나머지 14개는 HTTP 핸들러 본문에서 감사 함수(audit function)를 직접 호출하는 라우트 핸들러(route handlers)였으며, 이 과정의 대부분을 건너뛰었습니다.
이 레시피는 스스로를 단일 정책 소유자라고 부르는 독스트링(docstring)에 명시되어 있었고, 복사-붙여넣기를 통해 강제되었습니다. 사람이 직접 편집하는 속도로 소수의 인원이 작업할 때는 이러한 괴리(drift)가 감당할 수 있는 수준으로 유지되었습니다. 편집의 신선도(editorial freshness)를 조용히 떨어뜨리는 필드들, 엔티티 유형 간에 일관되지 않은 감사 메타데이터, 그리고 이름이 지정된 저자가 반드시 필요함에도 그렇지 않았던 간헐적인 편집 작업 같은 문제들이 말입니다.
에이전트(agent)는 이러한 문제를 만들어내지 않습니다. 오히려 가속화합니다. 다른 17곳에서도 똑같은 일을 해야 한다는 기억에 의존해 유지되는 규칙은, 기계와 프로덕션 데이터베이스(production database) 사이에 두고 싶은 것이 아닙니다.
따라서 MCP 서버가 단 하나의 쓰기 도구(write tool)를 갖기도 전에, 18개의 모듈은 단일 쓰기 동사 실행기(write-verb runner)로 통합되었습니다. 명명된 동사(Named verbs)들은 공개 인터페이스(public interface)로 유지되지만, 각 동사의 본문은 이제 전체 시퀀스를 소유하는 하나의 실행기로 연결되는 짧은 전달자(forwarder)가 되었으며, 각 단계는 정책에 따라 선택적으로 적용됩니다.
통합만 했다면 독스트링과 마찬가지로 똑같이 부패했을 것입니다. 이를 지탱하는 것은 테스트 파일입니다. 이 파일은 등록된 모든 필드 정책에 대한 구조적 불변성(structural invariants)을 확인합니다(콘텐츠 업데이트 필드는 편집 가능한 프로필 필드의 부분 집합이며, 필수 배열도 마찬가지이고, 서버 관리 필드는 편집 가능한 집합과 절대 겹치지 않아야 함). 또한 소스 코드를 grep으로 검색하여 감사 모듈과 실행기(runner) 이외의 그 어디에서도 감사 함수를 임포트(import)하지 않음을 확인합니다.
그 grep 작업이 바로 불변성(invariant)입니다. 이를 수동으로 확인하는 데는 단 하나의 명령어가 필요합니다:
$ grep -rn "audit/audit.service" backend/src | grep -v test
modules/audit/index.ts:4 export { auditLog, auditLogAsync, ... } from './audit.service.js'
modules/write-verb/runner.ts:31 import { auditLogAsync } from '../audit/audit.service.js'
...
감사(audit) 모듈이 스스로를 재내보내기(re-exporting) 하고 있으며, 러너(runner)에서도 이를 사용하고 있습니다. CI(지속적 통합) 환경에서는 누군가 기억해야 할 관례가 아니라 실패하는 테스트로 나타납니다.
이 방식이 무엇을 보장하는지 정확히 짚고 넘어갈 가치가 있습니다. 왜냐하면 저 또한 한동안 머릿속으로 이 부분을 대충 넘겼기 때문입니다. 이 방식은 감사 이벤트(audit events)가 정확히 단 하나의 방출 지점(emission point)을 가진다는 것을 증명합니다. 모든 쓰기 작업이 감사된다는 것을 증명하는 것은 아닙니다. 백엔드의 약 30개 파일은 여전히 ORM을 직접 호출하고 있으며, 그중 대부분은
액션(actions)을 수행하며 편집 감사(editorial audit) 행을 작성할 권한이 없습니다.
에이전트(agent)를 제한하는 범위는 보편적인 적용 범위보다 더 좁고 유용합니다. MCP 서버의 모든 쓰기 도구(write tool)는 /api/admin/* 엔드포인트를 대상으로 하며, 해당 핸들러(handlers)들은 쓰기 서비스(write services) 위에 놓인 인증 상태 파싱(parse-auth-status) 셸(shell)입니다. 에이전트의 전체 쓰기 표면(write surface)은 러너를 통해 실행됩니다. 이는 러너가 범용적이기 때문이 아니라, 에이전트가 러너를 우회하는 그 어떤 것에도 접근할 수 없기 때문입니다.
이 중 어느 것도 에이전트의 작업이 아니었습니다. 이것이 바로 에이전트 작업이 다룰 만한 수준(tractable)이었던 이유입니다.
에이전트가 접근할 수 있는 범위
모든 쓰기 도구는 명시적인 필드 허용 목록(allowlist)을 가지며, 이는 각 필드에 사람이 읽을 수 있는 설명(description)이 포함된 타입화된 입력 스키마(typed input schema)로 표현됩니다. 타입(types)은 쉬운 부분이며 그것만으로는 충분하지 않습니다. string | null | undefined로 타입이 지정된 필드는 이 세 가지가 어떻게 다른지에 대해 아무것도 말해주지 않습니다. CMS에서 그 차이가 핵심입니다. 필드를 생략(omitting)하는 것은 그대로 두라는 의미이고, null은 지우라는 의미인데, 타입만으로는 에이전트에게 당신이 어떤 것을 원하는지 알려줄 수 없습니다.
따라서 설명(descriptions)이 의미론(semantics)을 전달합니다:
position: z.number().int().optional()
.describe('전역 정렬 위치. 끝에 추가하려면 생략하세요.'),
...
그것들은 프롬프트 조각(prompt fragments)들이며, 이것들을 작성하는 것이 제가 이 시스템에서 수행한 프롬프트 엔지니어링 (prompt engineering)의 대부분이었습니다. 생략 (Omission)은 특정 동작을 가집니다: 끝에 추가하거나, 다른 필드로부터 상속받습니다. Null은 생략과는 별개의 지시 사항입니다. 어떤 필드들은 특정 상태에서만 효과를 발휘합니다. 어떤 필드들은 유효한 값을 찾기 위해 다른 도구 (tool)를 먼저 호출하지 않고서는 아예 채울 수 없습니다.
이 중 하나라도 잘못되면 아무런 오류도 발생하지 않습니다. 에이전트 (agent)는 그럴듯한 무언가를 작성하고 다음으로 넘어갑니다. 가장 큰 엔티티 (entity)에는 이러한 항목이 72개나 포함되어 있습니다.
솔직한 이야기를 하자면, 그 허용 목록 (allowlist)은 백엔드 (backend)에 한 번, MCP 서버 (MCP server)에 한 번, 의도적으로 두 번 존재합니다. 이는 서버가 독립적으로 배포 가능한 패키지로 유지되도록 하기 위함입니다. 필드를 추가한다는 것은 두 파일 모두를 편집해야 함을 의미합니다. 하나라도 잊어버리면 업데이트 도구 (update tool)는 해당 필드를 버려버립니다. 쓰기 (write)는 성공했다고 보고하지만, 값은 그저 존재하지 않게 됩니다.
저는 이 사실을 알고 수락했을 때 이를 알려진 위험 요소 (known hazard)로 결정 기록 (decision record)에 작성했지만, 그렇다고 해서 그것이 저를 물어뜯는 것을 막지는 못했습니다. 다시 시작한다면, 저는 하나의 소스로부터 두 가지를 모두 생성하여 결합 (coupling)을 받아들일 것입니다.
두 번의 대가를 치른 경계
기억해 둘 만한 하나의 사고가 있습니다. 플랫폼의 계산기들은 전략 테이블 (strategy tables)과 점수 공식 (scoring formulas)을 서버 측 (server-side)에 유지하며 클라이언트 번들 (client bundle)에는 절대 포함하지 않습니다. 이는 에이전트가 존재하기 몇 달 전, 경쟁 및 라이선스 문제로 인해 내려진 결정이었습니다. 번들에 포함되어 있다면 누구나 이를 읽고 재배포할 수 있기 때문입니다.
동일한 경계로 인해 에이전트 또한 로직 (logic)을 볼 수 없습니다. 에이전트는 엔드포인트 (endpoints)를 호출하고 결과를 읽을 수는 있지만, 테이블에 접근할 경로는 없습니다. 저는 이것을 에이전트 안전성 속성 (agent-safety property)으로 설계한 것은 아니었습니다. 하지만
- 에이전트가 무엇을 할 수 있는지 결정하기 전에, 에이전트가 무엇으로 인증되는지를 먼저 결정하십시오. 인간의 토큰 (human token)을 빌려 쓰는 방식은, 나중에 반드시 확인해야 할 질문에 대해 감사 로그 (audit log)를 무용지물로 만듭니다.
- 쓰기 경로 (write path)를 먼저 통합하고, 에이전트는 그 다음에 추가하십시오. 관례에 따라 규칙이 18곳에 흩어져 있다면, 에이전트는 여러분의 팀보다 훨씬 더 빠르게 그 드리프트 (drift)를 찾아낼 것입니다.
- 불변성 (invariant)을 문서화(docstring)하지 말고 테스트로 만드십시오. 단 하나의 파일만이 감사 이벤트 (audit events)를 생성할 수 있음을 단언하는 grep 명령은, 그 어떤 아키텍처 다이어그램보다 투박하지만 훨씬 더 강력한 지지대 역할을 합니다.
- 단순히 필드뿐만 아니라, 부재 (absence)의 의미론 (semantics)을 기술하십시오. 어떤 타입 시스템도 "이것은 건드리지 마시오"와 "이것을 비우시오"를 구분하지 못합니다. 잘못 추측한 에이전트는 보존하라고 요청받은 데이터를 덮어쓰게 되며, 응답의 그 어떤 내용도 이를 알려주지 않을 것입니다.
- 에이전트의 범위를 여러분이 감사하는 항목이 아니라, 에이전트가 도달할 수 있는 범위로 제한하십시오. 모든 영역을 포괄하는 것은 어렵습니다. 에이전트에게 여러분이 강제하는 경계선 위에 있는 정확히 하나의 엔드포인트 패밀리 (family of endpoints)만을 부여하는 것은 쉽고 증명 가능합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기