튜토리얼에서 생략되는 MCP 서버 구축의 핵심 요소들
요약
MCP(Model Context Protocol) 서버를 실제 운영 환경에서 안정적으로 구축하기 위해 튜토리얼에서 간과하기 쉬운 핵심 요소들을 다룹니다. stdio 통신 시 stdout 오염 방지와 에이전트의 의사결정을 돕는 타입화된 에러 처리의 중요성을 강조합니다.
핵심 포인트
- stdio 전송 시 stdout은 JSON-RPC 채널이므로 로그는 반드시 stderr로 남겨야 함
- 단순한 에러 메시지는 에이전트의 무한 재시도와 API 비용 급증을 유발함
- 에이전트가 상황을 판단할 수 있도록 기계가 읽을 수 있는 타입화된 에러를 제공해야 함
- 실제 운영 가능한 MCP 서버를 위해서는 프로토콜 외적인 안정성 확보가 필수적임
첫 번째 Model Context Protocol 서버를 구축하는 데는 약 20분 정도가 소요됩니다. 공식 퀵스타트(Quickstart)는 정말 훌륭합니다. npm install을 실행하고, 도구(tool)를 등록하고, stdio 전송(transport)을 연결하면 Claude나 Cursor가 당신의 코드를 호출할 수 있습니다. Hello, world.
하지만 다른 사람들이 실제로 사용할 수 있는 무언가를 만들려고 시도하는 순간, 당신은 절벽 아래로 떨어지게 됩니다.
저는 그 절벽이 실재한다는 것을 알고 있습니다. 누군가가 그것을 측정했기 때문입니다. 2026년 4월에 실시된 2,181개의 원격 MCP 엔드포인트(endpoints) 스캔 결과, 52%가 완전히 작동하지 않는 상태였으며, 오직 약 9%만이 완전히 건강한 상태였습니다. 이것들은 버려진 장난감이 아닙니다. 사람들이 배포하고 작동하기를 기대했던 서버들입니다. 이들은 프로토콜 버그 때문에 죽은 것이 아닙니다. 프로토콜은 쉬운 부분입니다. 이들은 프로토콜 '주변'의 모든 것들 때문에 죽었으며, 그것이 바로 튜토리얼들이 생략하는 부분입니다.
여기 실제로 중요한 부분들과 제가 각 부분을 처리하는 방법이 있습니다. 하단에는 이 모든 것을 포함하는 작은 MIT 라이선스 스타터 키트(starter kit)가 있지만, 이 아이디어들은 어떤 스택(stack)에도 적용 가능합니다.
1. stdio에 관하여, stdout은 당신의 것이 아닙니다
첫 번째 문제는 모두를 괴롭힙니다. stdio 전송 방식에서 stdout은 JSON-RPC 채널입니다. 당신의 것이든 의존성(dependency)의 것이든, 단 하나의 잘못된 console.log가 프로토콜 스트림(stream)에 한 줄을 주입하면, 클라이언트는 실제 console.log와는 전혀 상관없는 모호한 JSON 파싱 에러(JSON parse error)를 내며 죽어버립니다.
해결책은 단 한 단어입니다: stderr로 로그를 남기세요.
// stdio에서 stdout은 프로토콜 채널입니다. 오직 stderr로만 로그를 남기세요.
console.error(`${SERVER_INFO.name} v${SERVER_INFO.version} ready on stdio`);
그게 전부입니다. 하지만 당신은 이것을 '알고' 있어야 합니다. 퀵스타트는 아무것도 로그를 남기지 않기 때문에 아무도 알려주지 않습니다.
2. 실패는 읽을 수 있어야 합니다. 그렇지 않으면 당신의 에이전트(agent)는 눈을 감고 비행하는 것과 같습니다
도구가 예외(throw)를 발생시킬 때, 기본적인 경험은 최악입니다. 에이전트(agent)는 internal error라는 메시지만 보게 되며, 이것이 인증 실패인지, 잘못된 인자(argument) 때문인지, 아니면 상위 시스템의 500 에러인지 구분할 수 없습니다. 에이전트는 재시도(retry)를 할지, 입력을 수정할지, 아니면 포기할지를 결정할 수 없습니다. 그래서 종종 실패할 수밖에 없는 호출을 루프(loop) 내에서 반복하며 하룻밤 사이에 당신의 API 예산을 모두 태워버리곤 합니다. 이러한 "조용한 재시도(quiet retry)"는 에이전트가 운영 환경(production)에서 비용을 급증시키는 가장 흔한 원인 중 하나로 언급됩니다.
해결책은 기계가 읽을 수 있는 코드(machine-readable code)를 포함하는 타입화된 에러(typed error)와, 예외 발생 시 연결을 끊어버리는 대신 적절한 MCP 에러 결과로 변환해 주는 래퍼(wrapper)를 사용하는 것입니다:
export class ToolError extends Error {
constructor(public readonly code: ToolErrorCode, message: string) {
super(message);
...
이제 에이전트는 [forbidden_host] Host "x" is not in ALLOWED_FETCH_HOSTS라는 메시지를 보고 실제로 이에 대해 추론할 수 있습니다. 가독성(Legibility)은 단순히 로그를 읽는 인간을 위한 것이 아니라, 모델(model)을 위해 구축해야 하는 기능입니다.
3. URL을 가져오는 모든 도구는 별도로 명시하지 않는 한 공개 프록시(open proxy)입니다
URL을 입력받아 이를 가져오는(fetch) 도구를 작성하는 순간, 당신은 잠재적인 SSRF(Server-Side Request Forgery) 프록시를 구축한 것입니다. 에이전트(또는 프롬프트 인젝션(prompt-injected)된 에이전트)가 http://169.254.169.254/나 당신의 내부 관리자 패널을 가리키고 그 응답을 읽을 수 있기 때문입니다. 호스트 허용 목록(host allowlist)이 없는 fetch 도구는 트리거가 발생하기만을 기다리는 보안 사고와 같습니다.
따라서 예시로 든 fetch 도구는 허용된다고 명시되지 않은 모든 요청을 거부하며, HTTPS와 엄격한 타임아웃(timeout)을 강제합니다:
if (parsed.protocol !== "https:") throw new ToolError("invalid_input", "https only");
if (!allowedHosts().includes(parsed.hostname))
throw new ToolError("forbidden_host", `${parsed.hostname} is not allowlisted (SSRF guard).`);
...
세 가지 방어 기제가 포함되어 있는데, "API를 호출하는 도구는 다음과 같습니다"라고 설명하는 튜토리얼에는 이 중 어느 것도 포함되어 있지 않습니다.
4. 인증(Auth)은 대부분의 원격 서버가 넘지 못하는 벽입니다
2026년의 조사 데이터는 냉혹합니다. OAuth는 운영 환경의 MCP 서버를 가로막는 가장 큰 장애물이며, 원격 서버의 절반 이상이 정적 키(static keys)로 회귀하고 있습니다. 또한 OAuth 실패는 대개 "조용히(silent)\
원격 환경에서 안전하게 운영하기 위해 반드시 완전한 OAuth가 필요한 것은 아닙니다. 당신에게 필요한 것은 **실패 시 차단(fails closed)**하며 그 이유를 알려주는 인증 방식입니다. 제대로 구현된 베어러 토큰(Bearer tokens)은 올바른 첫 단계가 될 수 있습니다:
if (tokens.length === 0) {
// 인증이 꺼진 HTTP MCP 서버는 스크래핑(scraping)의 대상이 되는 기본 설정입니다. 개방된 상태로 실행되는 것을 거부하세요.
return reject("No MCP_BEARER_TOKENS configured; refusing all requests.");
...
여기서 중요한 단어는 _차단(closed)_입니다. 토큰 설정을 잊어버렸을 때, 서버가 인터넷에 도구(tools)를 조용히 제공하는 대신 모든 요청을 거부해야 한다는 뜻입니다. 사용자별 스코프(scopes), 토큰 교환(token exchange), 리프레시(refresh)를 포함하는 완전한 위임형 OAuth 2.1은 퍼블릭 멀티 테넌트(multi-tenant) 서버를 위한 실제적이고 더 큰 작업입니다. 실수하기 쉬운 지점은 직접 만든 흐름(hand-rolled flow)이 그것과 같다고 가정하거나, "일단은" 인증을 끈 채로 배포하는 것입니다.
5. 무상태성(Statelessness)은 콜드 스타트(cold-start)의 해결책입니다
이것은 응답 불가능한 엔드포인트의 52% 뒤에 숨겨진 미묘한 문제입니다. MCP의 스트리밍 가능한 HTTP 전송(transport)은 프로세스 메모리에 세션 상태(session state)를 유지할 수 있습니다. 이를 스케일 투 제로(scale to zero)가 가능하거나 인스턴스 간에 부하를 분산하는 환경—Lambda, Cloud Run, Fly, Workers 등—에 배포하면, 후속 요청은 **해당 세션을 본 적이 없는 콜드 인스턴스(cold instance)**에 도달하게 됩니다. 클라이언트는 응답을 기다리며 멈춰버립니다. 에러도, 로그도 없이 그저 죽어버리는 것입니다.
저렴한 해결책은 세션 상태를 전혀 유지하지 않는 것입니다. 즉, 요청마다 새로운 서버와 전송 계층을 구축하는 것입니다.
app.post("/mcp", bearerAuth, async (req, res) => {
const server = buildServer();
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined }); // 무상태(stateless)
...
나중에 진정으로 요청 간 상태 공유가 필요하다면, 모듈 변수가 아닌 세션 ID를 키로 사용하는 Redis나 Durable Object를 백엔드로 사용하세요. 하지만 시작은 무상태(stateless)로 해야 합니다. 그것이 당신이 실제로 배포하게 될 플랫폼에서 살아남을 수 있는 구조입니다.
6. 실패 경로를 테스트하지 않았다면, 테스트를 하지 않은 것입니다
마지막 습관은 가장 비용이 적게 듭니다. 사람들이 MCP 도구(tools)를 위해 테스트를 작성할 때, 대개 데모를 테스트합니다. 즉, 도구를 호출하고 성공적인 결과(happy result)를 확인하는 것이죠. 하지만 위의 모든 섹션은 하나의 '가드(guard, 보호 장치)'를 설명하고 있으며, 테스트하지 않은 가드는 존재하지 않는 것과 같습니다. 가치를 증명하는 테스트는 서버가 올바르게 **거부(refuses)**하는지를 확인하는 테스트입니다.
it("allowlist에 포함되지 않은 호스트를 거부함 (SSRF 가드)", async () => {
await expect(
httpGetJson({ url: "https://evil.example.com/x" }),
...
무엇을 검증(assert)하고 있는지 주목하세요. 단순히 예외를 던지는 것뿐만 아니라, _올바른 기계 판독 가능 코드(machine-readable code)_와 함께 예외를 던지는지를 확인하는 것입니다. 왜냐하면 그 코드가 바로 섹션 2에서 다룬 계약(contract)이며, 에이전트(agent)가 추론하는 근거이기 때문입니다. 만약 리팩터링 과정에서 forbidden_host가 일반적인 internal로 바뀐다면, 타입 체커(type checker)는 신경 쓰지 않을 것이고, 해피 패스(happy-path) 테스트도 신경 쓰지 않을 것입니다. 그러면 당신의 에이전트는 "정책에 의해 차단됨"과 "서버 버그"를 구분하는 능력을 조용히 상실하게 됩니다. 이 테스트만이 유일하게 그 자리를 지키고 있는 방어선입니다.
인증(auth)도 마찬가지 원리입니다. 작성할 가치가 있는 테스트는 토큰이 구성되지 않았을 때 서버가 모든 요청을 거부하는 테스트입니다. 페일 클로즈(Fail-closed, 실패 시 차단)는 하나의 동작 방식입니다. 이를 확실히 고정(pin)해 두세요.
패턴
이 모든 것들은 이름을 붙이고 나면 더 이상 어렵지 않습니다. 그것이 핵심입니다. 실패 모드(failure mode)의 문제는 난이도가 아니라 _가시성(invisibility)_입니다. 이 모든 요소가 튜토리얼에는 빠져 있기 때문에, 모두가 운영 환경(production)에서 클라이언트가 멈춰버리는 상황을 겪으며 똑같은 방식으로 이를 재발견하게 됩니다.
저는 이 여섯 가지 요소를 MIT 라이선스의 작은 TypeScript 스타터 키트로 패키징했습니다. stdio + 스트리밍 가능한 HTTP, 페일 클로즈 방식의 베어러 인증(bearer auth), 읽기 쉬운 ToolError, SSRF 가드가 적용된 예시 도구, 거부 테스트, 그리고 콜드 스타트(cold-start) 문제를 다루는 DEPLOYMENT.md가 포함되어 있습니다. 이 키트는 빌드 가능하고 테스트가 완료되었으며, 한 번에 처음부터 끝까지 읽을 수 있도록 설계되었습니다.
→ https://github.com/park11innyc-lgtm/mcp-server-starter-kit
클론(clone)한 뒤, 필요 없는 부분은 삭제하고 낭떠러지로 떨어지는 실수를 피하세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기