MCP(Model Context Protocol) 서버가 하나둘 늘어나면 자연스럽게 앞단에 게이트웨이를 두는 구성을 검토하게 됩니다. 저는 에이전틱 AI 재단(AAIF, Agentic AI Foundation)의 호스티드 프로젝트인 agentgateway를 대상으로 골랐는데, 기능 표를 읽는 것만으로는 “정책을 켜면 정말로 막히는가"라는 질문에 답할 수 없어서 직접 재 보기로 했습니다. 이전에 K8s Gateway API 구현체 7종을 비교하면서 썼던 “선언한 것과 실제로 강제되는 것은 다르다"는 기준을 MCP 게이트웨이에 그대로 적용한 것입니다. 이 글에서 별도 표시 없이 게이트웨이라고 쓰면 agentgateway를 뜻하며 쿠버네티스의 Gateway API 리소스와는 다른 것이니 구분해서 읽어 주시기 바랍니다.
agentgateway가 어떤 프로젝트인지 먼저 정리하고 넘어가는 게 좋겠습니다. 에이전트가 MCP로 도구를 호출하고 A2A(Agent2Agent)로 다른 에이전트를 호출하며 애플리케이션이 LLM 제공자를 호출하는 트래픽을 한곳에서 받는 오픈소스 프록시로, Solo.io가 2025년 3월에 만들어 같은 해 8월 리눅스 재단에 기여했고 2026년 6월에 AAIF의 호스티드 프로젝트가 되었습니다. 웹 트래픽용 인프라에는 에이전트 트래픽에 필요한 통제와 관측이 없으니 게이트웨이가 서버를 고치지 않고 그 통제와 관측을 붙여 준다는 것이 이 프로젝트가 내세우는 존재 이유입니다. 쿠버네티스 모드에서는 내장 컨트롤 플레인이 Gateway API 리소스와 자체 CRD(Custom Resource Definition, 사용자 정의 리소스)를 읽어 Rust로 만든 데이터 플레인을 설정하고 MCP에 대해서는 JSON-RPC를 파싱해 도구 단위로 CEL 인가 규칙을 평가합니다. 선언된 기능 목록이 이만큼 길다 보니 그 목록과 실제 호출에서 일어나는 일이 얼마나 벌어져 있는지 궁금해졌습니다. 게이트웨이가 경로 안에서 무엇을 더하고 무엇을 받는지를 그림으로 정리하면 다음과 같습니다.
- 정책: 게이트웨이에 걸어 두는 통제 규칙입니다. agentgateway에서는
AgentgatewayPolicy라는 쿠버네티스 리소스에 적고 규칙은 CEL(Common Expression Language, 조건식을 적는 구글의 표현식 언어)로 씁니다. - 도구 이름 화이트리스트(whitelist, 허용 목록): 허용할 도구 이름을 목록으로 적어 두고 그 밖의 도구 호출은 전부 막는 정책입니다. 가장 기본이 되는 통제 방식입니다.
- 인자 조건 정책: 도구 이름만이 아니라 도구에 넘기는 인자 값까지 보고 허용 여부를 정하는 정책입니다. “get-sum 도구는 a가 1일 때만 허용"처럼 씁니다.
- tools/list와 tools/call: MCP의 두 기본 요청입니다. tools/list는 서버가 가진 도구 목록을 받아 오는 요청이고 tools/call은 그중 하나를 실제로 호출하는 요청입니다. 게이트웨이의 정책은 두 요청에 모두 적용됩니다.
결과부터 말씀드리면 게이트웨이를 거치는 비용은 도구 호출(tools/call) 기준 p50(중앙값) 1ms 아래로 작았고(도구 목록 조회는 1~3ms) 꼬리 지연(p99)이 좋아지는 일은 없었으며 그 대가로 서버가 클라이언트별로 해 줄 수 없는 통제와 관측을 얻을 수 있었습니다. 다만 인자 조건을 쓴 정책 1개는 수용된 상태로 보고되면서도 의도대로 강제되지 않았기 때문에, 그래서 정책은 켜 놓는 것으로 끝내지 말고 실제 호출을 보내서 확인해야 한다고 판단했습니다.
측정 환경은 VirtualBox 3노드 쿠버네티스 v1.37.0에 agentgateway v1.5.0이고 백엔드는 MCP 스테이트리스 마이그레이션에서 만든 신 스펙(2026-07-28) 서버를 그대로 썼습니다. 사실 이 측정은 두 번 했습니다. 처음에 v1.4.1로 재서 8월에 공개했고 v1.5.0이 나오면서 새 클러스터에서 전부 다시 측정했는데 정책과 관측처럼 결정론적인 결과는 모두 같았고 지연 수치만 새 값으로 바꿨습니다. 그 과정에서 v1.4.1 때 내렸던 해석 1개를 거두게 되었는데 그 이야기는 비용 절에서 하겠습니다. 가상 환경 수치라 절대값보다는 조건 간 상대 비교로 읽어 주시기 바라며 아래 내용은 모두 GitHub 저장소의 하네스로 재현할 수 있습니다.
도구 이름 화이트리스트가 막는 것과 감추는 것
우선 가장 기본이 되는 통제인 도구 이름 화이트리스트, 곧 허용할 도구 이름을 목록으로 적어 두는 정책부터 확인해 보겠습니다. echo 도구만 허용하는 정책을 걸면 다른 도구 호출은 400으로 거절되고 tools/list 결과에서도 차단된 도구가 사라져 목록에 echo만 남게 됩니다. 이렇게 호출을 거절하는 것과 도구 목록에서 빼는 것을 한 정책이 함께 처리해 줍니다.
사실 거절 응답이 어떤 모양으로 돌아오는지가 더 흥미롭습니다. 인가 오류가 아니라 “Unknown tool”(-32602)로 돌아오는데, 이 코드는 MCP가 따르는 JSON-RPC 표준에서 “잘못된 인자"를 뜻합니다. 권한이 없다는 사실을 알려 주지 않고 차단된 도구가 처음부터 없는 것처럼 보이게 하려고 의도한 설계입니다(도구 열거 방지, 업스트림 #758에서 논의된 내용입니다). 대신 클라이언트는 권한 문제와 도구 부재를 구분할 수 없게 됩니다. 이 선택의 의미는 뒤에서 에이전트 관점으로 다시 살펴보겠습니다.
그렇다면 목록을 걸러 주는 비용은 얼마일까요? 서버에 도구를 8개, 100개, 500개 등록해 두고 도구 1개만 남기는 정책을 거친 tools/list와 정책 없는 tools/list를 비교해 보니 p50 차이가 0.7ms 안이라 게이트웨이에서 거르는 비용은 없다고 봐도 됩니다. 그런데 걸리는 시간을 줄여 주지도 않았습니다. 클라이언트가 받는 응답은 도구 500개 조건에서 181KB가 467바이트로 줄지만 걸리는 시간은 거르지 않은 목록과 같았는데, 게이트웨이가 서버의 전체 목록을 먼저 받은 뒤에 거르기 때문입니다. 따라서 도구가 많은 서버의 느린 tools/list는 게이트웨이 뒤에 두어도 그대로 느리고 그 시간은 서버 쪽에서 줄여야 합니다.
인자 조건 정책이 수용되고도 모든 호출을 막는 이유
이번 측정에서 매우 중요하다고 본 것은 도구 이름이 아니라 도구에 넘기는 인자 값에 조건을 거는 정책, 곧 인자 조건 정책이 예상과 다르게 동작했다는 점입니다. “get-sum 도구를 a가 1일 때만 허용” 같은 조건을 CEL로 쓸 수 있을 것처럼 보이는데, 실제로 걸어 보면 전혀 다른 결과가 나옵니다.
mcpAuthorization:
rules:
- 'mcp.tool.name == "get-sum" && mcp.tool.arguments.a == 1'
이 정책은 컨트롤 플레인 검증을 통과하고(Accepted) 상태도 정상으로 보고됩니다. 하지만 조건에 맞는 호출(a=1)과 조건에 어긋나는 호출(a=2), 그리고 규칙과 무관한 도구(echo)까지 전부 차단되고 tools/list도 빈 목록을 반환합니다. 인가를 평가하는 시점의 CEL 컨텍스트에 도구 인자가 없어서 조건이 성립할 수 없는데, 규칙 목록이 화이트리스트 의미라 결과적으로 아무것도 허용되지 않기 때문입니다.
그렇다면 인자가 없을 때는 통과시키도록 !has(mcp.tool.arguments) || 조건
형태의 가드를 붙이면 어떻게 될까요? 차단은 풀립니다. 그런데 인가를 판단하는
때에는 도구 인자가 아예 없기 때문에 has(...)가 모든 호출에서 거짓이 되고 그
부정인 앞 절은 항상 참이 되어, OR로 이어진 뒤쪽의 인자 조건은 평가할 기회조차
없이 규칙 전체가 참이 됩니다. 결국 규칙이 도구 이름만으로 평가되는 셈이라
막으려던 a=2 호출까지 통과합니다(실측으로 확인한 내용입니다). 그래서 이 인가
정책 방식으로는 인자 통제를 성립시키는 방법이 없고 운영자는 인자 통제가
걸렸다고 믿게 되는데 실제로는 모든 호출이 거절되거나 조건이 사라진 상태가
됩니다. 정책 상태와 클라이언트 응답은 물론이고 프록시 로그와 컨트롤 플레인
로그까지 확인해 봤지만 이 상황을 알려 주는 기록은 어디에서도 볼 수 없었습니다.
운영할 때 특히 주의해야 합니다.
이 동작의 배경을 찾아보면 agentgateway의 인가 컨텍스트가 신원 정보만 담는 것 자체는 의도한 설계입니다. 정책이 tools/list와 tools/call에 공통으로 적용되는데 list 시점에는 인자가 존재할 수 없기 때문이고 메인테이너가 업스트림 #2069에 개선안과 함께 논의를 등록해 두었습니다. 아키텍처 문서에는 이 한정이 적혀 있지만 사용자가 실제로 보는 스키마 문서에는 단계 구분이 없어서 저는 이 간극을 #3092로 제보했고 그 문서에 경고를 추가하는 커뮤니티 PR(Pull Request) #3127이 등록되어 2026년 9월 9일 기준으로 리뷰 단계에 있습니다. 다만 메인테이너는 이슈 하나에 맞춘 경고보다 문서에 제한을 적는 쪽이 맞다는 의견이고 #3092에도 가능한 결과는 문서 수정뿐이라고 답했으니 경고 형태로 해결되지는 않을 것으로 보입니다. v1.4.1에서 처음 측정한 동작을 v1.5.0 릴리스에서도 그대로 재현했고 그 결과를 같은 이슈에 코멘트로 남겨 두었습니다.
이 글을 작성하는 시점에 관련 변경이 하나 더 있었습니다. 2026-09-03에 main에 병합된 PR #3301은 라우트 정책이 실행되기 전에 MCP 본문을 먼저 해석하도록 바꾼 것인데 이 글을 작성하는 2026년 9월 9일까지는 릴리스에 포함되지 않았습니다. 그래서 dev 이미지로 프록시만 바꿔 같은 조건을 라우트 인가 정책(traffic.authorization)으로 걸어 봤더니 a=2는 403으로 거부되고 a=1과 echo는 통과했습니다. 다만 이 글에서 다룬 mcpAuthorization 형태는 dev 이미지에서도 모든 호출을 그대로 차단했고 v1.5.0에서는 라우트 형태가 수용되기만 하고 효과가 없었습니다. 따라서 인자 조건을 라우트 정책으로 쓰려면 #3301이 반영된 릴리스인지 먼저 확인하는 게 좋겠습니다.
인자 단위 통제가 실제로 되는 방식, 가드레일
그러면 인자 통제는 포기해야 할까요? 그렇지는 않습니다. 가드레일(guardrail)
이라는 외부 gRPC 프로세서 방식(설정 필드명은 mcpGuardrails)이 있어서 “a가
1일 때만 허용"을 강제하는 정책 서버를 약 70줄 파이썬으로 만들어 걸어 보니
a=1은 통과하고 a=2는 거부되고 무관한 도구는 영향을 받지 않았습니다.
agentgateway가 도구 인자를 gRPC 요청에 그대로 실어 보내 주기 때문에 검사
서버가 원문을 그대로 검사할 수 있습니다.
거부의 형태도 인가와 다릅니다. 인가 거부가 400 + “Unknown tool"이라 도구 부재와 구분되지 않게 나오는 것과 달리 가드레일 거부는 HTTP 200 + JSON-RPC 오류(-32001)에 서버가 정한 사유 문자열(“get-sum is allowed only with a == 1”)이 그대로 실려 나갑니다. 검사 서버가 없어지면 기본값인 FailClosed가 tools/call을 전부 막고 FailOpen으로 바꾸면 통과시키는 것까지 문서대로 동작했습니다.
이 방식을 쓸 때 추가로 드는 지연 비용도 함께 재 보았습니다. 게이트웨이와 가드레일 파드를 양쪽 조건에 모두 설치해 두고 정책 유무만 바꿔 가며 각 회차에서 두 조건을 인접 교대로 측정했는데, 인자 검사의 지연 비용은 호출당 p50 +0.1~0.7ms로 측정한 부하와 연결 방식 어디에서도 1ms를 넘지 않았고 30분 동안 연속으로 부하를 걸었을 때도 +0.5ms 그대로였습니다. v1.4.1 때는 이 증분이 부하와 무관하게 일정하다고 읽었는데 새 환경에서는 초당 요청 수(rps, requests per second) 200에서 0.1ms, 부하 생성기가 포화한 400rps 구간에서 0.7ms로 편차가 더 커서 상수라는 말은 빼고 “호출당 1ms 아래"라는 상한만 남기게 되었습니다.
덧붙여 v1.5.0 릴리스 노트에 “guardrails for tool calls"가 새 기능으로 올라와 있는데 이것은 지금까지 이야기한 가드레일과 다른 기능이니 헷갈리지 않으시기 바랍니다. LLM 트래픽 안의 tool_calls를 검사하는 프롬프트 가드라서 MCP 백엔드에 붙여 보면 정책은 수용되지만 tools/call에는 아무 효과가 없었습니다(2026년 9월 9일 확인). 인자 조건 정책과 같은 형태로 수용된 상태와 실제 강제가 나누어지는 셈입니다.
게이트웨이 홉의 지연 비용과 p99 변화
물론 게이트웨이 자체의 비용도 궁금하실 텐데, 이 측정에서 조건 통제가 특히 중요했습니다. 게이트웨이 컨트롤 플레인과 프록시를 설치한 채로 직접 경로와 게이트웨이 경로를 모두 재서 자원 조건을 동일하게 맞추고 두 경로의 차이를 부하 생성기의 대상 주소 하나로 좁힌 뒤 회차 안에서 교대로 실행했습니다.
호출마다 새 연결을 여는 조건에서는 p50이 +0.20.3ms 늘었고 연결을 재사용하는
조건에서는 +0.70.8ms 더 걸렸습니다. 도구 호출의 실제 작업이 수십 ms에서 수 초
단위인 것을 감안하면 어느 조건에서든 사용자가 대화 중에 느끼지 못할 크기입니다.
그런데 p99 쪽은 v1.4.1 때와 다르게 나왔습니다. v1.4.1에서는 새 연결 조건의
p99가 게이트웨이 쪽에서 오히려 낮게 나와서 “게이트웨이가 연결 폭주를 자기
층에서 처리해 p99가 낮아진다"고 해석했었는데, v1.5.0과 새 클러스터에서는
새 연결 조건의 p99가 직접 호출과 같거나 소폭 높았고 재사용 조건에서는 3~7ms
높았습니다. 버전과 클러스터가 함께 바뀌어 어느 쪽 때문인지 가릴 수는 없지만
그 해석이 새 환경에서 성립하지 않는 것은 분명해서 거두었습니다.
그렇다면 게이트웨이의 p99 손해는 어떤 조건에서 나타나는 걸까요? 이 질문에 답하려고 서버 쪽에 인위적인 처리 시간(0, 10, 50, 200ms)을 넣고 같은 비교를 반복했습니다. 그림으로 보면 다음과 같습니다.
매번 새 연결을 여는 조건에서는 두 선이 전 구간 겹쳐서 게이트웨이가 p99를 바꾸지 않는다는 것을 알 수 있습니다. 연결을 재사용하는 조건에서는 백엔드에 처리 시간을 넣지 않았을 때 8.9ms 대 16.3ms로 벌어졌다가 백엔드가 50ms를 쓰면 61.5ms 대 62.0ms로 붙고 200ms에서는 차이가 없어집니다. 말하자면 게이트웨이 홉이 p99에 더하는 양은 몇 ms짜리 고정값이라 백엔드가 실제로 시간을 쓰는 순간 보이지 않게 됩니다. 이 형태는 30분 동안 연속으로 부하를 건 측정에서도 그대로였고 같은 게이트웨이 뒤에 느린 백엔드(200ms)를 두고 거기에 부하를 걸어도 빠른 백엔드의 p99는 나빠지지 않았습니다. 다만 30분 동안 초당 새 연결 200개를 계속 여는 조건에서는 부하 생성기가 요청을 버리기 시작했는데, 게이트웨이를 빼고 직접 호출로 같은 실험을 해도 똑같이 버려서 그 한계는 게이트웨이가 아니라 측정 환경 쪽의 연결 수립 상한이었습니다.
정리하면 게이트웨이는 성능 때문에 넣는 물건이 아닙니다. 게이트웨이 홉 1개가 더하는 비용(도구 호출은 p50 1ms 아래, 목록 조회는 1~3ms, 특정 조건에서 p99 몇 ms)을 내고 그 대가로 통제와 관측을 얻는 구성입니다. 무엇을 더하면 얼마가 드는지를 통제 쌍별로 정리하면 다음과 같습니다.
층마다 다른 거부 응답과 에이전트 루프
앞에서 거부의 형태가 층마다 다르다는 이야기를 했는데, 이를 한 장으로 정리하면 다음과 같습니다.
같은 “거부"라도 인가 정책에서는 400 + “Unknown tool"이라 도구 부재와 구분되지 않게 돌아오고 가드레일은 200 + 서버가 정한 사유를 그대로 내보내고 검사 서버 장애(FailClosed)는 200 + 내부 오류 문구로 나타납니다. HTTP 상태 코드만 보는 모니터링에서는 뒤의 두 형태가 성공으로 집계되니 JSON-RPC 오류까지 봐야 하고 반대로 운영자 입장에서는 응답 모양이 어느 층이 막았는지를 알려 주는 진단 단서가 됩니다.
덧붙여 한 가지 더 생각해 볼 것이 있습니다. 이 응답을 읽는 쪽은 대부분 사람이 아니라 에이전트 루프라는 점입니다. LLM이 거부 텍스트를 도구 호출 결과로 읽고 다음 행동을 정하므로, 도구가 없다는 응답을 받은 에이전트는 다른 우회를 시도할 수 있고 조건이 명시된 사유를 받은 에이전트는 인자를 고쳐 다시 시도할 근거를 얻습니다. 그래서 가드레일의 거부 사유는 사실상 LLM이 읽는 입력, 즉 프롬프트로 작동한다고 볼 수 있습니다. 에이전트 행동 자체를 측정하지는 않았으므로 이 문단은 해석입니다만, 거부 메시지를 쓸 때 에이전트가 읽는다는 것을 염두에 두면 손해 볼 일은 없을 것입니다.
문서에 적히지 않은 트레이스 컨텍스트 전달 동작
관측 쪽에서는 기대보다 잘되는 것을 확인했습니다. 우선 클라이언트가
traceparent 헤더를 붙여 보내면 다운스트림 서버는 trace-id는 그대로이고
span-id만 게이트웨이 것으로 바뀐 traceparent를 헤더로 받았고 같은 값이 MCP
스펙이 _meta에 예약한 자리에도 주입되어 있었습니다(5회 전부). 그런데
agentgateway 문서에서는 이 동작을 찾지 못했으니 이번에는 문서에 적힌 것보다 실제 동작이 더
많았던 셈입니다. 서버 코드를 건드리지 않고도 에이전트에서 도구까지 추적
맥락이 이어진다는 뜻이라 운영 관점에서는 반가운 결과였습니다. 다만 이 측정은
게이트웨이의 트레이싱 설정을 켜지 않은 조건에서 다운스트림이 받는 헤더와
_meta를 확인한 것입니다. 트레이싱을 켠 조건에서는 스팬의 부모 관계가 잘못
잡히는 문제가 업스트림 #2904로 보고되어 v1.4.1 이후 수정되었고 그 조건은
이번에 재지 않았습니다.
A2A 쪽은 주소 변경과 관측만 있고 강제는 없음
같은 게이트웨이가 A2A 트래픽도 받기 때문에 A2A 쪽에서 게이트웨이가 하는 일도
같은 방식으로 확인해 보았습니다. 결과를 짧게 말씀드리면 A2A에 대해
agentgateway가 하는 일은 에이전트 카드의 주소를 자기 주소로 바꿔 주는 것과
JSON-RPC 오류 코드를 액세스 로그에 남기는 것 두 가지이고 누가 어떤 에이전트를
호출할 수 있는지 막는 인가 정책은 v1.5.0에 없습니다. 카드의 주소 변경은
서비스에 appProtocol: agentgateway.dev/a2a를 붙여야 켜지는 옵트인이고 v0.3
형식의 url과 v1.0 형식의 supportedInterfaces를 같이 담은 카드에서는
supportedInterfaces 쪽만 게이트웨이 주소로 바뀌어서 옛 형식을 읽는
클라이언트는 백엔드의 직접 주소를 받아 게이트웨이를 그대로 우회하게 됩니다.
이 병기 카드는 공식 파이썬 SDK가 기본으로 내는 형태라는 것까지 실제 SDK
서버로 확인했으니, A2A 에이전트를 게이트웨이 뒤에 두시는 분들은 게이트웨이
경유로 카드를 조회해서 카드에 실제로 실려 나가는 주소를 한 번 읽어 보시기
바랍니다. 보다 상세한 내용은 저장소의
A2A 절에
기록되어 있으니 참고하시기 바랍니다.
정책을 켠 뒤 확인할 점검 목록
지금까지의 내용을 운영 절차로 정리해 보겠습니다.
- 정책을 켠 뒤 허용되어야 할 호출과 막혀야 할 호출을 실제로 보내서 확인합니다. 특히 인자 조건이 담긴 정책은 조건에 맞는 호출이 통과하는지 빠짐없이 봐야 합니다. 수용됐다는 상태 표시는 강제된다는 뜻이 아니었습니다.
- tools/list 결과도 함께 봅니다. 목록 필터링이 정책과 같이 움직이므로 목록이 비어 있다면 정책이 아무것도 매치하지 못하고 있다는 뜻입니다.
- prefixMode로 도구 이름에 접두사가 붙는 환경이라면 정책은 접두사가 붙기 전의 원래 이름으로 씁니다. 클라이언트가 tools/list에서 보는 접두사가 붙은 이름으로 쓰면 인자 조건 정책과 같은 전량 차단이 됩니다.
- 인자 단위 통제가 필요하면 가드레일로 검사 서버를 만듭니다. 지연 비용은 호출당 1ms 아래라 부담이 크지 않고 거부 사유는 에이전트가 읽고 스스로 고칠 수 있는 문장으로 씁니다. PR #3301이 반영된 릴리스부터는 같은 조건을 라우트 인가 정책으로도 걸 수 있으니 릴리스 노트를 확인합니다.
- 게이트웨이에 p99 개선을 기대하지는 않습니다. 홉 비용은 도구 호출 기준 p50 1ms 아래이고 p99는 좋아지지 않으며 연결을 재사용하는 클라이언트가 아주 빠른 도구를 호출할 때만 몇 ms의 p99 손해가 보입니다.
- 도구가 많은 서버라면 tools/list의 시간은 게이트웨이가 줄여 주지 않으니 서버 쪽에서 목록 크기를 관리합니다.
- FailClosed가 기본값이라 검사 서버가 멈추면 tools/call도 함께 막히므로 검사 서버의 이중화도 함께 계획합니다.
측정에 쓴 하네스와 가드레일 정책 서버, 그림을 만드는 스크립트까지 저장소에 공개해 두었으니 참고하시기 바랍니다. 다른 게이트웨이나 다른 환경에서 재현해 보신 결과가 있다면 공유해 주시면 좋겠습니다.