<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>카이스트 CS 박사, 연구를 서비스로</title>
    <link>https://smhanlab.tistory.com/</link>
    <description>smhanlab 님의 블로그 입니다.</description>
    <language>ko</language>
    <pubDate>Tue, 14 Jul 2026 10:02:15 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>임마누엘한</managingEditor>
    <image>
      <title>카이스트 CS 박사, 연구를 서비스로</title>
      <url>https://tistory1.daumcdn.net/tistory/8657831/attach/1c193d53ef43413fb241c7b37cd8b7ea</url>
      <link>https://smhanlab.tistory.com</link>
    </image>
    <item>
      <title>LLM 플랫폼 한 번 만들어보면 알게 되는 기술 스택 50선 &amp;mdash; 한 줄 정리</title>
      <link>https://smhanlab.tistory.com/13</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에이전트, 게이트웨이, 메시, 벡터 DB&amp;hellip; LLM 플랫폼을 처음부터 끝까지 굴려보면 생각보다 훨씬 많은 도구들이 엮여 돌아갑니다. 최근 사내에서 운영 중인 LLM 플랫폼을 회고하며 정리한 기술 스택 키워드를 카테고리별로 한 줄씩 정리해봤습니다. 비슷한 환경을 구축하시는 분들께 &quot;이게 뭐 하는 친구지?&quot; 싶을 때 빠르게 훑어볼 수 있는 용어집이 되었으면 합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LLM 기반 플랫폼은 단순히 &quot;OpenAI API 호출하는 앱&quot; 이상의 복잡한 시스템입니다. 모델 호출을 추상화하는 &lt;b&gt;게이트웨이&lt;/b&gt;, 에이전트 워크플로를 시각적으로 짜는 &lt;b&gt;노코드 빌더&lt;/b&gt;, 호출을 추적하는 &lt;b&gt;관측 도구&lt;/b&gt;, 메모리를 담당하는 &lt;b&gt;벡터/그래프 DB&lt;/b&gt;, 거기에 이 모든 것을 굴리는 &lt;b&gt;Kubernetes 인프라&lt;/b&gt;와 &lt;b&gt;인증/보안 레이어&lt;/b&gt;까지.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서는 LLM 플랫폼 운영에 실제로 등장하는 약 50여 개의 기술 키워드를 카테고리별로 정리하고, 각각에 대해 한 줄 설명을 붙여 빠르게 훑어볼 수 있도록 구성했습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; ️ 플랫폼 / 아키텍처&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LLM 플랫폼의 뼈대를 구성하는 프레임워크와 게이트웨이, 그리고 벤치마크로 자주 참조되는 매니지드 서비스들입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;마이크로프론트엔드 (Module Federation)&lt;/b&gt; &amp;mdash; 여러 프론트엔드 앱을 독립적으로 개발&amp;middot;배포하고 런타임에 통합하는 아키텍처 패턴&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Vite&lt;/b&gt; &amp;mdash; ES 모듈 기반의 빠른 프론트엔드 빌드 도구이자 개발 서버&lt;/li&gt;
&lt;li&gt;&lt;b&gt;AgentGateway&lt;/b&gt; &amp;mdash; AI 에이전트와 백엔드 도구를 연결&amp;middot;라우팅하는 게이트웨이&lt;/li&gt;
&lt;li&gt;&lt;b&gt;LangFlow&lt;/b&gt; &amp;mdash; 드래그 앤 드롭으로 LLM 워크플로를 시각적으로 구성하는 노코드 빌더&lt;/li&gt;
&lt;li&gt;&lt;b&gt;LiteLLM&lt;/b&gt; &amp;mdash; 100+ LLM을 OpenAI API 포맷으로 통일해 호출할 수 있는 LLM 게이트웨이/프록시&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Langfuse&lt;/b&gt; &amp;mdash; LLM 애플리케이션의 트레이싱&amp;middot;평가&amp;middot;관측을 제공하는 오픈소스 LLMOps 플랫폼&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Dify&lt;/b&gt; &amp;mdash; LLM 앱 개발 플랫폼으로, 에이전트&amp;middot;RAG&amp;middot;워크플로를 GUI로 구성&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Azure AI Foundry&lt;/b&gt; &amp;mdash; Microsoft Azure 기반 엔터프라이즈 AI 모델 개발&amp;middot;배포 플랫폼&lt;/li&gt;
&lt;li&gt;&lt;b&gt;AWS Bedrock AgentCore&lt;/b&gt; &amp;mdash; AWS의 매니지드 에이전트 런타임 환경&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  AI / LLM&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모델을 호출하고, 워크플로를 구성하고, 외부 도구와 연결하고, 안전하게 만드는 데 필요한 핵심 라이브러리&amp;middot;프로토콜&amp;middot;기법들입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Claude / Claude Code / Claude Code Skill&lt;/b&gt; &amp;mdash; Anthropic의 LLM과 코딩 전용 CLI 도구, 작업 패턴을 재사용하는 Skill 시스템&lt;/li&gt;
&lt;li&gt;&lt;b&gt;LangChain&lt;/b&gt; &amp;mdash; LLM 기반 애플리케이션을 컴포저블하게 구축하는 프레임워크&lt;/li&gt;
&lt;li&gt;&lt;b&gt;MCP (Model Context Protocol)&lt;/b&gt; &amp;mdash; LLM과 외부 도구&amp;middot;데이터 소스를 표준화된 방식으로 연결하는 프로토콜&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Semantic Kernel&lt;/b&gt; &amp;mdash; Microsoft의 AI 오케스트레이션 SDK로 에이전트&amp;middot;플러그인 구성에 사용&lt;/li&gt;
&lt;li&gt;&lt;b&gt;프롬프트 엔지니어링&lt;/b&gt; &amp;mdash; JSON 포맷 강제, 페르소나 지시 등 LLM 출력을 제어하는 기법&lt;/li&gt;
&lt;li&gt;&lt;b&gt;RAG (Retrieval-Augmented Generation)&lt;/b&gt; &amp;mdash; 외부 지식을 검색해 LLM 응답에 결합하는 파이프라인&lt;/li&gt;
&lt;li&gt;&lt;b&gt;LLM Guard&lt;/b&gt; &amp;mdash; 금칙어 필터&amp;middot;PII 마스킹 등 LLM 입출력 가드레일 라이브러리&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; ️ 데이터베이스 / 스토어&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LLM 플랫폼은 관계형&amp;middot;그래프&amp;middot;벡터&amp;middot;키-값&amp;middot;풀텍스트 검색 등 거의 모든 종류의 저장소를 다 씁니다. 메모리 계층화의 정석이라 할 만하죠.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Neo4j&lt;/b&gt; &amp;mdash; Cypher 쿼리와 RBAC을 지원하는 그래프 데이터베이스&lt;/li&gt;
&lt;li&gt;&lt;b&gt;PostgreSQL&lt;/b&gt; &amp;mdash; 강력한 기능과 확장성을 갖춘 오픈소스 관계형 데이터베이스&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Redis&lt;/b&gt; &amp;mdash; 인메모리 키-값 스토어로 단기 메모리&amp;middot;세션 컨텍스트에 사용&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Zep&lt;/b&gt; &amp;mdash; 사용자 프로파일과 선호도를 저장하는 LLM 장기 메모리 스토어&lt;/li&gt;
&lt;li&gt;&lt;b&gt;ChromaDB&lt;/b&gt; &amp;mdash; 임베딩 벡터를 저장&amp;middot;검색하는 오픈소스 벡터 데이터베이스&lt;/li&gt;
&lt;li&gt;&lt;b&gt;FTS5&lt;/b&gt; &amp;mdash; SQLite의 풀텍스트 검색 확장 모듈&lt;/li&gt;
&lt;li&gt;&lt;b&gt;ClickHouse&lt;/b&gt; &amp;mdash; 대용량 로그&amp;middot;이벤트 분석에 최적화된 컬럼형 OLAP 데이터베이스&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Cloud SQL Proxy&lt;/b&gt; &amp;mdash; GCP Cloud SQL에 안전하게 접속하기 위한 프록시 클라이언트&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;☸️ 인프라 / 오케스트레이션&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Kubernetes 위에서 마이크로서비스를 굴리고, 빌드하고, 배포하기 위한 표준 도구 모음입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Kubernetes (GKE)&lt;/b&gt; &amp;mdash; 컨테이너 오케스트레이션 플랫폼, GKE는 GCP의 매니지드 K8s&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Istio&lt;/b&gt; &amp;mdash; 사이드카 프록시 기반 서비스 메시로 트래픽 관리&amp;middot;보안&amp;middot;관측 제공&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Kiali&lt;/b&gt; &amp;mdash; Istio 메시의 토폴로지와 트래픽 흐름을 시각화하는 대시보드&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Kustomize&lt;/b&gt; &amp;mdash; overlays와 patch로 Kubernetes 매니페스트를 템플릿 없이 관리&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Bazel / Bazelisk&lt;/b&gt; &amp;mdash; Google에서 만든 다중 언어 빌드 시스템과 버전 관리 래퍼&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Docker (Colima)&lt;/b&gt; &amp;mdash; 컨테이너 런타임과 macOS용 경량 Docker 환경&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Nginx&lt;/b&gt; &amp;mdash; 정적 파일 서빙&amp;middot;리버스 프록시로 널리 쓰이는 웹 서버&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Helm / kube-prometheus-stack&lt;/b&gt; &amp;mdash; Kubernetes 패키지 매니저와 Prometheus 모니터링 번들 차트&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  인증 / 보안&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;엔터프라이즈 환경에서 LLM 플랫폼을 운영하려면 SSO&amp;middot;시크릿 관리&amp;middot;인가 규칙이 필수입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Keycloak&lt;/b&gt; &amp;mdash; OIDC&amp;middot;SAML을 지원하는 오픈소스 SSO&amp;middot;IAM 솔루션&lt;/li&gt;
&lt;li&gt;&lt;b&gt;OAuth2-Proxy&lt;/b&gt; &amp;mdash; 사이드카로 동작하며 OAuth2 인증을 강제하는 리버스 프록시&lt;/li&gt;
&lt;li&gt;&lt;b&gt;SealedSecret (kubeseal)&lt;/b&gt; &amp;mdash; Kubernetes Secret을 암호화해 Git에 안전히 커밋하는 도구&lt;/li&gt;
&lt;li&gt;&lt;b&gt;JWT&lt;/b&gt; &amp;mdash; 토큰 기반 인증&amp;middot;인가에 사용되는 JSON 웹 토큰 표준&lt;/li&gt;
&lt;li&gt;&lt;b&gt;CEL (Common Expression Language)&lt;/b&gt; &amp;mdash; Google에서 만든 가볍고 안전한 표현식 언어, 인가 규칙 등에 활용&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  모니터링 / 관측&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LLM 호출은 비싸고 느리고 비결정적이라, 관측 가능성(observability)이 일반 백엔드보다 훨씬 더 중요합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Prometheus&lt;/b&gt; &amp;mdash; 시계열 메트릭 수집&amp;middot;쿼리에 특화된 오픈소스 모니터링 시스템&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Grafana&lt;/b&gt; &amp;mdash; Prometheus 등 다양한 데이터 소스를 시각화하는 대시보드 도구&lt;/li&gt;
&lt;li&gt;&lt;b&gt;AlertManager&lt;/b&gt; &amp;mdash; Prometheus 알람을 라우팅&amp;middot;그룹핑&amp;middot;억제하는 알림 매니저&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Istio Access Log (Envoy)&lt;/b&gt; &amp;mdash; Envoy 사이드카가 남기는 액세스 로그로 트래픽 분석 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  개발 도구 / 워크플로&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드 리뷰부터 런타임, 패키지 매니저까지 &amp;mdash; 매일 손에 닿는 도구들입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Gerrit&lt;/b&gt; &amp;mdash; Change List(CL) 단위의 코드 리뷰에 특화된 Git 코드 리뷰 시스템&lt;/li&gt;
&lt;li&gt;&lt;b&gt;k9s&lt;/b&gt; &amp;mdash; 터미널에서 Kubernetes 리소스를 빠르게 탐색&amp;middot;조작하는 TUI 도구&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Git&lt;/b&gt; &amp;mdash; 분산 버전 관리 시스템&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Python 3.10&lt;/b&gt; &amp;mdash; 스크립트와 백엔드 개발에 두루 쓰이는 인터프리터 언어&lt;/li&gt;
&lt;li&gt;&lt;b&gt;FastAPI&lt;/b&gt; &amp;mdash; 타입 힌트 기반의 빠른 Python 비동기 웹 프레임워크&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Node.js 20 / nvm&lt;/b&gt; &amp;mdash; JavaScript 런타임과 버전 관리 도구&lt;/li&gt;
&lt;li&gt;&lt;b&gt;TypeScript&lt;/b&gt; &amp;mdash; 정적 타입을 지원하는 JavaScript 상위 집합 언어&lt;/li&gt;
&lt;li&gt;&lt;b&gt;pnpm&lt;/b&gt; &amp;mdash; 디스크 효율과 속도가 뛰어난 Node.js 패키지 매니저&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;☁️ 클라우드&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;플랫폼이 실제로 굴러가는 GCP 환경과 사용자 관리 SaaS입니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;GCP (Google Cloud Platform)&lt;/b&gt; &amp;mdash; Google의 퍼블릭 클라우드 플랫폼&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Workload Identity&lt;/b&gt; &amp;mdash; GCP에서 K8s 서비스 계정을 GCP IAM과 안전하게 매핑하는 메커니즘&lt;/li&gt;
&lt;li&gt;&lt;b&gt;JumpCloud&lt;/b&gt; &amp;mdash; 디렉터리 서비스&amp;middot;SSO&amp;middot;디바이스 관리를 통합한 IDaaS 플랫폼&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 정리하고 보니, LLM 플랫폼이라는 게 결국 &quot;LLM API + 잘 알려진 클라우드 네이티브 스택&quot;의 조합이라는 게 새삼 느껴집니다. 새로운 건 모델과 프로토콜(MCP) 정도이고, 나머지 90%는 Kubernetes&amp;middot;Istio&amp;middot;Prometheus처럼 이미 익숙한 도구들로 돌아가고 있죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 차이가 있다면, &lt;b&gt;메모리(Redis/Zep) &amp;middot; 검색(Chroma/FTS5) &amp;middot; 그래프(Neo4j)&lt;/b&gt; 같은 다양한 저장소 계층을 동시에 다뤄야 하고, &lt;b&gt;호출당 비용이 비싸기 때문에 관측&amp;middot;가드레일이 일반 백엔드보다 훨씬 더 중요해진다&lt;/b&gt;는 점입니다.&lt;/p&gt;</description>
      <author>임마누엘한</author>
      <guid isPermaLink="true">https://smhanlab.tistory.com/13</guid>
      <comments>https://smhanlab.tistory.com/13#entry13comment</comments>
      <pubDate>Fri, 15 May 2026 16:17:47 +0900</pubDate>
    </item>
    <item>
      <title>Gerrit 코드 리뷰에서 반복되는 지적을 Claude Code Skill 로 자동화한 회고 (반복 코멘트 &amp;rarr; TSV &amp;rarr; Skill)</title>
      <link>https://smhanlab.tistory.com/12</link>
      <description>&lt;h2 id=&quot;들어가며&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;6&quot; data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;3개월 분량의 사내 Gerrit 코드 리뷰 코멘트를 모아 보니,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;같은 지적이 자꾸 반복&lt;/b&gt;되고 있었다. 신규 합류자가 같은 사이클을 한 번 더 돌고, 리뷰어 시간이 매번 깎인다. 그래서 다음 파이프라인을 만들어 봤다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #202020; text-align: start;&quot; data-line=&quot;10&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-line=&quot;10&quot;&gt;리뷰 코멘트 전체를 한 곳에 모으기 (Gerrit REST API &amp;rarr; TSV &amp;rarr; Excel).&lt;/li&gt;
&lt;li data-line=&quot;11&quot;&gt;카테고리로 클러스터링.&lt;/li&gt;
&lt;li data-line=&quot;12&quot;&gt;상위 N개 패턴을&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Claude Code Skill&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;한 개로 코드화.&lt;/li&gt;
&lt;li data-line=&quot;13&quot;&gt;신규 코드 작성 시 Claude 가 자동으로 사전 차단.&lt;/li&gt;
&lt;/ol&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;결과적으로, 작성 단계에서 자동 차단되는 항목이 생겨서 같은 PR 의 같은 리뷰 코멘트가 줄어드는 게 확인됐다. 회고 + 만든 사람용 가이드 + 같은 파이프라인을 다른 저장소에 복제하는 방법까지 정리.&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;핵심 키워드: Claude Code, Skill, code review automation, Gerrit, repository skill, SKILL.md, 코드 리뷰 자동화.&lt;/p&gt;
&lt;h2 id=&quot;시스템-컨텍스트&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;19&quot; data-ke-size=&quot;size26&quot;&gt;시스템 컨텍스트&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;21&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;21&quot;&gt;사내 코드 호스팅: Gerrit (CL 단위 리뷰, +1/+2 system).&lt;/li&gt;
&lt;li data-line=&quot;22&quot;&gt;리뷰 코멘트가 Gerrit 곳곳에 흩어져 있어서 패턴화&amp;middot;통계 어려움.&lt;/li&gt;
&lt;li data-line=&quot;23&quot;&gt;신규 합류자 / 다른 팀이 모르고 같은 실수 반복.&lt;/li&gt;
&lt;li data-line=&quot;24&quot;&gt;리뷰어가 매번 같은 지적을 손으로 다는 게 비효율.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;26&quot; data-ke-size=&quot;size16&quot;&gt;목표: 반복 코멘트 &amp;rarr; 일관된 규칙 문서 &amp;rarr;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;에디터 단계에서 사전 차단&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;(Claude Code 가 파일 편집 시 자동 적용).&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;[Gerrit REST API]          [데이터]            [지식]                [실행]
   285 CLs    ──extract──&amp;gt; raw TSV  ──curate──&amp;gt; categorized ──skill──&amp;gt; SKILL.md
   4400+ 리뷰 라인                              top-5 패턴            &amp;darr;
                                                                  Claude Code 가
                                                                  편집 시 자동 발동
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;시도한-것들&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;36&quot; data-ke-size=&quot;size26&quot;&gt;시도한 것들&lt;/h2&gt;
&lt;h3 id=&quot;시도-1--리뷰-전체를-tsv-로-끌어모으기&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;38&quot; data-ke-size=&quot;size23&quot;&gt;시도 1 &amp;mdash; 리뷰 전체를 TSV 로 끌어모으기&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;40&quot; data-ke-size=&quot;size16&quot;&gt;Gerrit REST API 로 최근 N개월 모든 CL 의 리뷰 thread 를 긁어왔다. 핵심 endpoint:&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;# CL 목록
GET /a/changes/?q=project:myrepo+after:2026-02-14&amp;amp;o=ALL_REVISIONS&amp;amp;o=ALL_COMMITS

# 각 CL 의 리뷰 코멘트
GET /a/changes/{change-id}/revisions/current/comments
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;50&quot; data-ke-size=&quot;size16&quot;&gt;스크립트 의사 코드:&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;import requests, csv, base64
from urllib.parse import quote

AUTH = (&quot;user&quot;, &quot;http-password&quot;)  # Gerrit HTTP password 발급 필요
BASE = &quot;https://gerrit.example.com/a&quot;

def fetch_changes(query):
    r = requests.get(f&quot;{BASE}/changes/?q={quote(query)}&amp;amp;o=DETAILED_ACCOUNTS&quot;,
                     auth=AUTH)
    # Gerrit 응답은 XSS 방지용 prefix &quot;)]}'\n&quot; 가 붙어 있다.
    return json.loads(r.text[5:])

def fetch_comments(change_id):
    r = requests.get(f&quot;{BASE}/changes/{change_id}/revisions/current/comments&quot;,
                     auth=AUTH)
    return json.loads(r.text[5:])

with open(&quot;raw-reviews.tsv&quot;, &quot;w&quot;) as fh:
    w = csv.writer(fh, delimiter=&quot;\t&quot;)
    w.writerow([&quot;datetime&quot;, &quot;author&quot;, &quot;repo&quot;, &quot;cl_id&quot;, &quot;file&quot;, &quot;line&quot;, &quot;comment&quot;])
    for cl in fetch_changes(&quot;project:myrepo after:2026-02-14&quot;):
        comments = fetch_comments(cl[&quot;id&quot;])
        for path, items in comments.items():
            for c in items:
                w.writerow([
                    c[&quot;updated&quot;], c[&quot;author&quot;][&quot;name&quot;], cl[&quot;project&quot;],
                    cl[&quot;_number&quot;], path, c.get(&quot;line&quot;, &quot;&quot;), c[&quot;message&quot;]
                ])
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;83&quot; data-ke-size=&quot;size16&quot;&gt;함정 두 가지:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;85&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;85&quot;&gt;Gerrit 응답 JSON 앞에&lt;span&gt;&amp;nbsp;&lt;/span&gt;)]}'\n&lt;span&gt;&amp;nbsp;&lt;/span&gt;5바이트 prefix 가 붙는다 (XSS 방지용). 파싱 전에 제거.&lt;/li&gt;
&lt;li data-line=&quot;86&quot;&gt;HTTP basic auth 는 일반 password 가 아니라&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Gerrit HTTP password&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;(Settings 페이지에서 발급) 를 써야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;88&quot; data-ke-size=&quot;size16&quot;&gt;처음에 컬럼 순서를 막 잡았다가, 분석하면서 두 번 재정리. 최종 순서 (분석 용도로 읽기 좋게):&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #202020; text-align: start;&quot; data-line=&quot;90&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-line=&quot;90&quot;&gt;datetime&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;mdash; 정렬&amp;middot;시계열 분석용&lt;/li&gt;
&lt;li data-line=&quot;91&quot;&gt;author&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;mdash; 누가 코멘트 했는지 (리뷰어 패턴 분석)&lt;/li&gt;
&lt;li data-line=&quot;92&quot;&gt;repo&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;mdash; 어느 저장소&lt;/li&gt;
&lt;li data-line=&quot;93&quot;&gt;cl_id&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;mdash; 추적용&lt;/li&gt;
&lt;li data-line=&quot;94&quot;&gt;file&lt;span&gt;&amp;nbsp;&lt;/span&gt;/&lt;span&gt;&amp;nbsp;&lt;/span&gt;line&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;mdash; 위치&lt;/li&gt;
&lt;li data-line=&quot;95&quot;&gt;comment&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;mdash; 본문&lt;/li&gt;
&lt;/ol&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;97&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;raw / curated 분리.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;raw TSV 는 API 결과 그대로. 사람이 본문을 읽으면서&lt;span&gt;&amp;nbsp;&lt;/span&gt;category&lt;span&gt;&amp;nbsp;&lt;/span&gt;컬럼을 손으로 채운&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;curated TSV&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;를 별도 유지. 분석 도중 카테고리가 바뀌어도 raw 가 남아 있어 재분류 가능.&lt;/p&gt;
&lt;h3 id=&quot;시도-2--카테고리-클러스터링&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;99&quot; data-ke-size=&quot;size23&quot;&gt;시도 2 &amp;mdash; 카테고리 클러스터링&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;101&quot; data-ke-size=&quot;size16&quot;&gt;전체 코멘트 4,400+ 라인을 한 번 훑으면서 의미 단위로 묶었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;103&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;103&quot;&gt;처음엔 50개 정도로 잘게 쪼개고&lt;/li&gt;
&lt;li data-line=&quot;104&quot;&gt;비슷한 것들끼리 통합해서 30개&lt;/li&gt;
&lt;li data-line=&quot;105&quot;&gt;빈도순으로 정렬해서 상위 5개가 전체의 약 28% 차지하는 걸 확인&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;107&quot; data-ke-size=&quot;size16&quot;&gt;상위 5개 (실 예시):&lt;/p&gt;
&lt;p&gt;#패턴빈도영향&lt;/p&gt;
&lt;table style=&quot;color: #202020; text-align: start; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-line=&quot;109&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody data-line=&quot;111&quot;&gt;
&lt;tr data-line=&quot;111&quot;&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;plaintext secret 가 manifest 에 들어감&lt;/td&gt;
&lt;td&gt;18건&lt;/td&gt;
&lt;td&gt;보안 risk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;112&quot;&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;환경별 config 가 base 에 들어가 모든 env 영향&lt;/td&gt;
&lt;td&gt;21건&lt;/td&gt;
&lt;td&gt;운영 사고&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;113&quot;&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;commit message convention 위반 (대소문자 / prefix / ticket)&lt;/td&gt;
&lt;td&gt;11건&lt;/td&gt;
&lt;td&gt;release note 자동화 깨짐&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;114&quot;&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Python import 가 함수 안에 / 파일 끝에&lt;/td&gt;
&lt;td&gt;11건&lt;/td&gt;
&lt;td&gt;가독성&amp;middot;테스트 어려움&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;115&quot;&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;한 CL 에 source + build + deploy + third-party 가 섞임&lt;/td&gt;
&lt;td&gt;13건&lt;/td&gt;
&lt;td&gt;리뷰 부담&amp;middot;롤백 곤란&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;117&quot; data-ke-size=&quot;size16&quot;&gt;이 5개를 Skill 1개로 코드화하기로 결정.&lt;/p&gt;
&lt;h3 id=&quot;시도-3--출력-포맷-선택-confluence-vs-lint-vs-skill&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;119&quot; data-ke-size=&quot;size23&quot;&gt;시도 3 &amp;mdash; 출력 포맷 선택 (Confluence vs Lint vs Skill)&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;121&quot; data-ke-size=&quot;size16&quot;&gt;세 가지 옵션을 비교했다.&lt;/p&gt;
&lt;p&gt;옵션강제력작성 비용신규 합류자 효과유지 비용&lt;/p&gt;
&lt;table style=&quot;color: #202020; text-align: start; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-line=&quot;123&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody data-line=&quot;125&quot;&gt;
&lt;tr data-line=&quot;125&quot;&gt;
&lt;td&gt;Confluence/README&lt;/td&gt;
&lt;td&gt;약함 (사람이 안 봄)&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;td&gt;낮음 (읽어야 효과)&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;126&quot;&gt;
&lt;td&gt;Lint/CI rule&lt;/td&gt;
&lt;td&gt;강함 (PR 차단)&lt;/td&gt;
&lt;td&gt;높음 (각 규칙 구현)&lt;/td&gt;
&lt;td&gt;자동 적용&lt;/td&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;127&quot;&gt;
&lt;td&gt;Claude Code Skill&lt;/td&gt;
&lt;td&gt;중간 (편집 단계)&lt;/td&gt;
&lt;td&gt;낮음 (SKILL.md 1개)&lt;/td&gt;
&lt;td&gt;높음 (Claude 가 사전 차단)&lt;/td&gt;
&lt;td&gt;낮음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;129&quot; data-ke-size=&quot;size16&quot;&gt;CI lint 까지 만들면 가장 강력하지만 초기 비용이 크다. Claude Code Skill 은 SKILL.md 1개로 끝나고, 사람이 잊어버려도 Claude 가 옆에서 잡아주는 형태. ROI 가 가장 좋아 보였다.&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;131&quot; data-ke-size=&quot;size16&quot;&gt;장기적으로는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Skill + Lint 병용&lt;/b&gt;이 정답. Skill 로 작성 단계에서 잡고, Lint 로 PR merge 단계에서 잡는 다층 방어. 일단은 Skill 부터.&lt;/p&gt;
&lt;h3 id=&quot;시도-4--skillmd-작성-가이드라인&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;133&quot; data-ke-size=&quot;size23&quot;&gt;시도 4 &amp;mdash; SKILL.md 작성 가이드라인&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;135&quot; data-ke-size=&quot;size16&quot;&gt;만들면서 굳어진 작성 패턴.&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;137&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. frontmatter 의&lt;span&gt;&amp;nbsp;&lt;/span&gt;description&lt;span&gt;&amp;nbsp;&lt;/span&gt;이 트리거 매칭 핵심.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;Claude 가 이 description 을 보고 발동 여부를 결정. &quot;어떤 파일&amp;middot;언어&amp;middot;상황에서 켜져야 하는지&quot; 를 구체적으로 명시.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;---
name: code-review-&amp;lt;repo&amp;gt;
description: &quot;Apply learned code-review patterns from &amp;lt;repo&amp;gt;. Use when (1) editing kube/ manifests, (2) authoring BUILD/Dockerfile, (3) writing Python in service modules, (4) drafting commit message, (5) splitting a CL. Enforces 5 recurring rule-clusters distilled from &amp;lt;N&amp;gt;-day review feedback. Do NOT trigger for unrelated work or generic Python style questions.&quot;
version: &quot;v1.0&quot;
---
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;147&quot; data-ke-size=&quot;size16&quot;&gt;핵심:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;148&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;148&quot;&gt;발동 조건 (When this skill applies) 을&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;번호 매겨서 5개 이내&lt;/b&gt;로.&lt;/li&gt;
&lt;li data-line=&quot;149&quot;&gt;비발동 조건 (Do NOT trigger) 을 명시. 노이즈가 크게 줄어든다.&lt;/li&gt;
&lt;li data-line=&quot;150&quot;&gt;출처 (N개 CL / M개 코멘트 분석) 명시. Claude 가 정당성을 판단할 때 도움.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;152&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 상위 N개 규칙만, 도메인 분리.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;모든 규칙을 한 파일에 넣으면 description 모호해지고 트리거 정확도 떨어진다. 다른 도메인 (예: 일반 Python style) 은 다른 Skill 로.&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;154&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. Quick Reference 표&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;를 본문 맨 앞에. 규칙 번호 / 발동 조건 / 출처 카테고리. Claude 가 빠르게 정책 매핑을 잡는 데 결정적.&lt;/p&gt;
&lt;p&gt;#RuleTriggerSource Cluster&lt;/p&gt;
&lt;table style=&quot;color: #202020; text-align: start; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-line=&quot;156&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody data-line=&quot;158&quot;&gt;
&lt;tr data-line=&quot;158&quot;&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;plaintext secret 금지&lt;/td&gt;
&lt;td&gt;*credentials*.yaml,&lt;span&gt;&amp;nbsp;&lt;/span&gt;*.env&lt;span&gt;&amp;nbsp;&lt;/span&gt;편집&lt;/td&gt;
&lt;td&gt;C1 (18 comments)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;159&quot;&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;env config 는 overlay 에&lt;/td&gt;
&lt;td&gt;kube/{just,overlays}/&lt;span&gt;&amp;nbsp;&lt;/span&gt;새 파일&lt;/td&gt;
&lt;td&gt;C2 (21 comments)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;160&quot;&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;commit message convention&lt;/td&gt;
&lt;td&gt;/COMMIT_MSG&lt;span&gt;&amp;nbsp;&lt;/span&gt;작성&lt;/td&gt;
&lt;td&gt;C7 (11 comments)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;161&quot;&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Python imports top-of-file&lt;/td&gt;
&lt;td&gt;*.py&lt;span&gt;&amp;nbsp;&lt;/span&gt;새 파일&lt;/td&gt;
&lt;td&gt;C5 (11 comments)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;162&quot;&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;single-purpose CL&lt;/td&gt;
&lt;td&gt;source+build+kube 동시 변경&lt;/td&gt;
&lt;td&gt;C11 (13 comments)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;164&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4. 각 규칙은 5 섹션으로.&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;165&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;165&quot;&gt;When this applies (구체 trigger)&lt;/li&gt;
&lt;li data-line=&quot;166&quot;&gt;The rule (한 줄)&lt;/li&gt;
&lt;li data-line=&quot;167&quot;&gt;Before / After 코드 예시&lt;/li&gt;
&lt;li data-line=&quot;168&quot;&gt;Why (왜 이게 문제인지, 사례 인용)&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;170&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;5. NOT trigger 섹션.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;잘못된 컨텍스트에서 발동하면 노이즈가 심하므로 반드시 명시.&lt;/p&gt;
&lt;h3 id=&quot;시도-5--트리거-정확도-검증&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;172&quot; data-ke-size=&quot;size23&quot;&gt;시도 5 &amp;mdash; 트리거 정확도 검증&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;174&quot; data-ke-size=&quot;size16&quot;&gt;Skill 을 추가하고 일부러 다음 상황들로 테스트.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;176&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;176&quot;&gt;(a) plaintext secret 가 있는 yaml 편집 &amp;rarr; ✅ Claude 가 SealedSecret 으로 감쌀 것 제안.&lt;/li&gt;
&lt;li data-line=&quot;177&quot;&gt;(b) 일반 Python 코드 리팩터링 (스킬 외 영역) &amp;rarr; ✅ 발동 안 됨 (트리거 scope 잘 좁혀짐).&lt;/li&gt;
&lt;li data-line=&quot;178&quot;&gt;(c) 다른 저장소의 비슷한 yaml 편집 &amp;rarr; ✅ 발동 안 됨 (description 에 저장소 명시 효과).&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;180&quot; data-ke-size=&quot;size16&quot;&gt;description 의 trigger / not-trigger 명시 비중이 정확도의 거의 전부였다.&lt;/p&gt;
&lt;h3 id=&quot;시도-6--출력-자산-정리&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;182&quot; data-ke-size=&quot;size23&quot;&gt;시도 6 &amp;mdash; 출력 자산 정리&lt;/h3&gt;
&lt;pre class=&quot;armasm&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;code-review-learning/
├── raw-reviews.tsv          # API 로 긁은 원본
├── curated-reviews.tsv      # 사람이 카테고리 단 버전
├── curated-reviews.xlsx     # 동료 공유용 (필터&amp;middot;정렬 편함)
├── confluence-report.md     # 상위 패턴 + 통계 리포트 (Confluence 업로드용)
└── skills/
    └── code-review-&amp;lt;repo&amp;gt;/
        └── SKILL.md         # Claude Code Skill 본체
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;195&quot; data-ke-size=&quot;size16&quot;&gt;신규 합류자에게는 이 디렉토리 하나만 알려주면 됨. confluence-report 로 큰 그림 &amp;rarr; SKILL 로 자동 차단.&lt;/p&gt;
&lt;h2 id=&quot;gerrit-사용-흐름-정리-다른-분-참고용&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;197&quot; data-ke-size=&quot;size26&quot;&gt;Gerrit 사용 흐름 정리 (다른 분 참고용)&lt;/h2&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;199&quot; data-ke-size=&quot;size16&quot;&gt;Gerrit 처음 쓰는 분들에게 정리: Gerrit 의 CL (Change List) 은 GitHub 의 PR 과 유사하지만 단위가 약간 다르다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;201&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;201&quot;&gt;&lt;b&gt;CL = 1 commit&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;이 원칙. push 시&lt;span&gt;&amp;nbsp;&lt;/span&gt;Change-Id&lt;span&gt;&amp;nbsp;&lt;/span&gt;footer 로 식별.&lt;/li&gt;
&lt;li data-line=&quot;202&quot;&gt;같은 CL 을 여러 patchset 으로 업데이트하면 동일 Change-Id 가 유지되어 한 CL 안에서 revision 이 쌓인다.&lt;/li&gt;
&lt;li data-line=&quot;203&quot;&gt;리뷰 코멘트는 file / line 단위로 달리고, &quot;전체 코드 리뷰 vs 파일별 마킹&quot; 두 layer 가 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;205&quot; data-ke-size=&quot;size16&quot;&gt;신규 합류자가 자주 헷갈리는 부분:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;207&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;207&quot;&gt;&lt;b&gt;CL 하나에 여러 commit 묶으면 안 된다.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;묶으면 single-purpose CL 원칙 위반.&lt;/li&gt;
&lt;li data-line=&quot;208&quot;&gt;&lt;b&gt;여러 파일 / 여러 모듈을 한 CL 에 같이 수정하지 말 것.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;리뷰 부담이 폭증. (이게 위 패턴 #5)&lt;/li&gt;
&lt;li data-line=&quot;209&quot;&gt;patch set 업데이트는&lt;span&gt;&amp;nbsp;&lt;/span&gt;git push -o submit&lt;span&gt;&amp;nbsp;&lt;/span&gt;또는&lt;span&gt;&amp;nbsp;&lt;/span&gt;git push HEAD:refs/for/&amp;lt;branch&amp;gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;같은 형태.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;해결--최종-자산과-운영&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;211&quot; data-ke-size=&quot;size26&quot;&gt;해결 &amp;mdash; 최종 자산과 운영&lt;/h2&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;213&quot; data-ke-size=&quot;size16&quot;&gt;code-review-learning/&lt;span&gt;&amp;nbsp;&lt;/span&gt;디렉토리 하나로 묶고, SKILL 은&lt;span&gt;&amp;nbsp;&lt;/span&gt;~/.claude/skills/code-review-&amp;lt;repo&amp;gt;/SKILL.md&lt;span&gt;&amp;nbsp;&lt;/span&gt;위치에 둠. 신규 합류자에게 알려줄 것은 두 가지.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #202020; text-align: start;&quot; data-line=&quot;215&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-line=&quot;215&quot;&gt;&quot;리뷰 받기 전에 SKILL 이 자동으로 잡는지 확인.&quot;&lt;/li&gt;
&lt;li data-line=&quot;216&quot;&gt;&quot;그래도 모르겠으면 confluence-report 의 Quick Reference 표 한 번 훑기.&quot;&lt;/li&gt;
&lt;/ol&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;218&quot; data-ke-size=&quot;size16&quot;&gt;운영 절차:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;220&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;220&quot;&gt;매월 raw-reviews.tsv 를 갱신 (지난달 추가된 코멘트 fetch).&lt;/li&gt;
&lt;li data-line=&quot;221&quot;&gt;curated 재분류 (한 시간 정도). 새 패턴이 상위로 올라오면 SKILL 에 규칙 추가.&lt;/li&gt;
&lt;li data-line=&quot;222&quot;&gt;SKILL version 을 bump 하고 changelog 한 줄 (v1.0 &amp;rarr; v1.1 (added Rule 6)).&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;검증-방법&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;224&quot; data-ke-size=&quot;size26&quot;&gt;검증 방법&lt;/h2&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;226&quot; data-ke-size=&quot;size16&quot;&gt;Skill 발동이 의도대로인지는 다음으로 확인.&lt;/p&gt;
&lt;pre class=&quot;vala&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;# Claude Code 가 현재 컨텍스트에서 어떤 skill 을 인지하는지
# (Claude Code CLI 안에서)
/help

# 또는 SKILL.md 의 description 을 일부러 모호하게 만들어보고
# 의도하지 않은 컨텍스트에서 발동되는지 확인
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;237&quot; data-ke-size=&quot;size16&quot;&gt;또한 신규 합류자 1명 onboarding 시 같은 패턴의 리뷰 코멘트가 몇 번 나오는지를 metric 으로 추적. 도입 전/후 비교가 가능.&lt;/p&gt;
&lt;h2 id=&quot;배운-점&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;239&quot; data-ke-size=&quot;size26&quot;&gt;배운 점&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;241&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;241&quot;&gt;&lt;b&gt;반복 리뷰 코멘트는 자동화 가능한 신호다.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;같은 지적이 N번 반복되면 그건 Skill / lint / 템플릿으로 옮길 시점. 데이터 수집부터 시작하자.&lt;/li&gt;
&lt;li data-line=&quot;242&quot;&gt;&lt;b&gt;raw / curated TSV 를 분리해두자.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;분석 도중 카테고리가 바뀌어도 raw 가 남아 있어 재분류 가능. 카테고리 변경은 흔하다.&lt;/li&gt;
&lt;li data-line=&quot;243&quot;&gt;&lt;b&gt;SKILL.md 의&lt;span&gt;&amp;nbsp;&lt;/span&gt;description&lt;span&gt;&amp;nbsp;&lt;/span&gt;이 트리거 정확도의 거의 전부.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;발동 조건 / 비발동 조건을 둘 다 적자. 발동만 적으면 노이즈가 커진다.&lt;/li&gt;
&lt;li data-line=&quot;244&quot;&gt;&lt;b&gt;Skill 하나에 너무 많은 규칙을 넣지 말자.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;도메인 분리 &amp;rarr; 트리거 정확도 &amp;uarr;. 사내에 저장소 A / B / C 가 있으면 Skill 도 A / B / C 셋으로.&lt;/li&gt;
&lt;li data-line=&quot;245&quot;&gt;&lt;b&gt;장기 best practice 는 Skill + Lint 병용.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;Skill 은 작성 단계, Lint 는 merge 단계. 다층 방어.&lt;/li&gt;
&lt;li data-line=&quot;246&quot;&gt;&lt;b&gt;Gerrit 응답의&lt;span&gt;&amp;nbsp;&lt;/span&gt;)]}'&lt;span&gt;&amp;nbsp;&lt;/span&gt;prefix 같은 작은 함정&lt;/b&gt;도 한 번 알면 끝. 모르고 처음 만나면 30분 잡아먹는다.&lt;/li&gt;
&lt;li data-line=&quot;247&quot;&gt;한 번 만들어 두면 사내 다른 저장소에도 같은 파이프라인(REST API &amp;rarr; curated TSV &amp;rarr; Skill)으로 복제 가능. 회수 시점이 빠르다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;다음-단계&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;249&quot; data-ke-size=&quot;size26&quot;&gt;다음 단계&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;251&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;251&quot;&gt;&lt;b&gt;CI Lint 추가.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;Skill 로 잡힌 후에도 통과한 케이스를 PR 단계에서 한 번 더 차단.&lt;/li&gt;
&lt;li data-line=&quot;252&quot;&gt;&lt;b&gt;리뷰어별 패턴 분석.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;특정 리뷰어가 자주 잡는 항목이 있다면 그 자체가 다음 Skill 후보.&lt;/li&gt;
&lt;li data-line=&quot;253&quot;&gt;&lt;b&gt;시계열 추적.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&quot;도입 후 같은 패턴 코멘트가 몇 % 줄었는지&quot; 를 dashboard 로.&lt;/li&gt;
&lt;li data-line=&quot;254&quot;&gt;다른 저장소로 파이프라인 복제 (raw fetch script 만 약간 손보면 됨).&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;참고&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;256&quot; data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;258&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;258&quot;&gt;Claude Code 공식 문서 - Skills:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://docs.claude.com/en/docs/claude-code&quot;&gt;https://docs.claude.com/en/docs/claude-code&lt;/a&gt;&lt;/li&gt;
&lt;li data-line=&quot;259&quot;&gt;Gerrit REST API - Changes:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html&quot;&gt;https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html&lt;/a&gt;&lt;/li&gt;
&lt;li data-line=&quot;260&quot;&gt;Gerrit Comments API:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-comments&quot;&gt;https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-comments&lt;/a&gt;&lt;/li&gt;
&lt;li data-line=&quot;261&quot;&gt;Cedar 정책 언어 (전날 포스트 참고):&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://www.cedarpolicy.com/&quot;&gt;https://www.cedarpolicy.com/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <author>임마누엘한</author>
      <guid isPermaLink="true">https://smhanlab.tistory.com/12</guid>
      <comments>https://smhanlab.tistory.com/12#entry12comment</comments>
      <pubDate>Thu, 14 May 2026 15:44:32 +0900</pubDate>
    </item>
    <item>
      <title>agentgateway 에서 여러 MCP 백엔드를 path prefix 로 멀티플렉싱하기 (테넌트별 라우팅 + 운영자 UI 설계)</title>
      <link>https://smhanlab.tistory.com/11</link>
      <description>&lt;h2 id=&quot;들어가며&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;6&quot; data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;agentgateway 에 MCP backend 를&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;여러 개&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;붙여서,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;하나의 listener 포트&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;아래에서 path 로만 갈라서 노출하고 싶었다.&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;예를 들면:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;12&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;12&quot;&gt;/agentgateway/mcp1&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr; 팀 A 의 LangFlow MCP (도구 21개)&lt;/li&gt;
&lt;li data-line=&quot;13&quot;&gt;/agentgateway/mcp2&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr; 외부 mcpbin (테스트용 도구)&lt;/li&gt;
&lt;li data-line=&quot;14&quot;&gt;/agentgateway/mcp3&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr; 내부 검색 도구 MCP&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;각 path 가 사실상&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;테넌트&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;역할이고, 테넌트별로 인증&amp;middot;도구셋&amp;middot;정책이 다르다. agentgateway 가 이걸 지원하는지 확인하고, UI 를 약간 손봐서 운영자 친화적으로 만든 회고. 그리고 미등록 path 의 default 동작이 함정이라 그것도 정리.&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;핵심 키워드: agentgateway, multi-MCP routing, pathPrefix multiplexing, MCP tenant, AI Gateway, fallback route.&lt;/p&gt;
&lt;h2 id=&quot;시스템-컨텍스트&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;20&quot; data-ke-size=&quot;size26&quot;&gt;시스템 컨텍스트&lt;/h2&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;22&quot; data-ke-size=&quot;size16&quot;&gt;요구사항:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #202020; text-align: start;&quot; data-line=&quot;24&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-line=&quot;24&quot;&gt;한 포트(예:&lt;span&gt;&amp;nbsp;&lt;/span&gt;:3000) 아래 path 로 여러 MCP 서버 노출.&lt;/li&gt;
&lt;li data-line=&quot;25&quot;&gt;각 path 가 독립적 MCP 백엔드 + 독립적 Authorization 정책.&lt;/li&gt;
&lt;li data-line=&quot;26&quot;&gt;운영자가 UI 에서 route 를 동적으로 추가/삭제&amp;middot;MCP 셋 바꿀 수 있어야 함.&lt;/li&gt;
&lt;li data-line=&quot;27&quot;&gt;route 별로 외부에 안내할 full URL 을&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;한 번에 복사&lt;/b&gt;할 수 있어야 함 (테넌트 온보딩 편의성).&lt;/li&gt;
&lt;li data-line=&quot;28&quot;&gt;미등록 path 호출은 명시적 404 (디폴트 응답 노출 금지).&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;LLM Client
   │
   │  /agentgateway/mcp1  ─────────────┐
   │  /agentgateway/mcp2  ──────┐      │
   │  /agentgateway/mcp3  ─┐    │      │
   ▼                       │    │      │
agentgateway listener :3000│    │      │
   ├──────────────────────────┐ │      │
   │ route mcp1 ─────────────────────&amp;gt; LangFlow MCP A
   │ route mcp2 ──────────────────&amp;gt; mcpbin (external)
   │ route mcp3 ─────────&amp;gt; 사내 검색 MCP
   └─ fallback ──&amp;gt; 404
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;문제-상황&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;45&quot; data-ke-size=&quot;size26&quot;&gt;문제 상황&lt;/h2&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;47&quot; data-ke-size=&quot;size16&quot;&gt;처음 잡혀 있던 상태:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;49&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;49&quot;&gt;agentgateway 자체는 떠 있고, 하나의 MCP backend (LangFlow) 만 붙어 있음.&lt;/li&gt;
&lt;li data-line=&quot;50&quot;&gt;다른 팀이 자기 MCP 도 같이 노출하고 싶다고 요청.&lt;/li&gt;
&lt;li data-line=&quot;51&quot;&gt;각 팀이 정책을 따로 가지고 있어서, &quot;모두 한 path 에 합쳐서&quot; 는 안 됨.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;53&quot; data-ke-size=&quot;size16&quot;&gt;또한 UI 사용성 이슈:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;55&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;55&quot;&gt;자체 UI 에서 &quot;MCP Backend&quot; 와 &quot;MCP Authorization&quot; 카드가 평면 구조로 분리 노출 &amp;rarr; &quot;어떤 route 에 어떤 backend&amp;middot;정책이 묶였는지 한 눈에 안 보임&quot;.&lt;/li&gt;
&lt;li data-line=&quot;56&quot;&gt;route 의 path prefix 만 표시되고 외부 full URL 은 별도 조합 필요 &amp;rarr; 운영자가 매번 손으로 합쳐서 안내.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;시도한-것들&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;58&quot; data-ke-size=&quot;size26&quot;&gt;시도한 것들&lt;/h2&gt;
&lt;h3 id=&quot;시도-1--지원-가능성-확인&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;60&quot; data-ke-size=&quot;size23&quot;&gt;시도 1 &amp;mdash; 지원 가능성 확인&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;62&quot; data-ke-size=&quot;size16&quot;&gt;agentgateway 의 라우팅 구조를 뜯어봤다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;bind:
  port: 3000
  listeners:
    - name: mcp-main
      routes:
        - name: route-a
          pathPrefix: /agentgateway/mcp1
          backend: backend-a
        - name: route-b
          pathPrefix: /agentgateway/mcp2
          backend: backend-b
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;78&quot; data-ke-size=&quot;size16&quot;&gt;핵심 메커니즘:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;80&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;80&quot;&gt;&lt;b&gt;bind&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr; 포트 listener.&lt;/li&gt;
&lt;li data-line=&quot;81&quot;&gt;&lt;b&gt;listener&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr; 그 포트 아래에서 protocol&amp;middot;hostname 매칭.&lt;/li&gt;
&lt;li data-line=&quot;82&quot;&gt;&lt;b&gt;route&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr; path prefix 단위로 backend 를 매칭.&lt;/li&gt;
&lt;li data-line=&quot;83&quot;&gt;&lt;b&gt;backend&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr; 실제 MCP server (streamable HTTP) 또는 다른 protocol.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;85&quot; data-ke-size=&quot;size16&quot;&gt;route.pathPrefix&lt;span&gt;&amp;nbsp;&lt;/span&gt;가 곧 multiplexing 키. 같은 bind 포트 아래에서 prefix 가 다르면 서로 다른 backend 로 라우팅.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;완전히 가능&lt;/b&gt;.&lt;/p&gt;
&lt;h3 id=&quot;시도-2--자체-ui-의-한계-파악&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;87&quot; data-ke-size=&quot;size23&quot;&gt;시도 2 &amp;mdash; 자체 UI 의 한계 파악&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;89&quot; data-ke-size=&quot;size16&quot;&gt;자체 UI 로 route 추가는 되지만 운영자 관점에서 불편한 점들.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;91&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;91&quot;&gt;&lt;b&gt;MCP Backend&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;카드와&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;MCP Authorization&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;카드가 route 와 분리된 평면 구조 &amp;rarr; 어떤 route 에 어떤 backend&amp;middot;정책이 묶여 있는지 한 눈에 안 들어옴.&lt;/li&gt;
&lt;li data-line=&quot;92&quot;&gt;route 의 path prefix 만 표시되고, 외부 full URL 은 별도 조합 필요. 운영자가 매번&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://gateway.example.com&quot;&gt;https://gateway.example.com&lt;/a&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;+ path 를 합쳐서 안내해야 함.&lt;/li&gt;
&lt;li data-line=&quot;93&quot;&gt;Route name / Path prefix 수정 UX 가 모달 깊숙이.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;시도-3--운영자용-thin-ui-한-겹-얹기&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;95&quot; data-ke-size=&quot;size23&quot;&gt;시도 3 &amp;mdash; 운영자용 thin UI 한 겹 얹기&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;97&quot; data-ke-size=&quot;size16&quot;&gt;agentgateway 위에 운영자용 작은 관리 UI 를 한 겹 얹는 방향으로 갔다. 표시 모델만 바꾸면 되므로 코드량이 크지 않다. 핵심 변경:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;99&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;99&quot;&gt;&lt;b&gt;route 카드 안에 MCP Backend / MCP Authorization 를 종속 표시&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;(sub-card 로). 이렇게만 해도 인지 부하가 크게 줄어든다.&lt;/li&gt;
&lt;li data-line=&quot;100&quot;&gt;route 헤더에&lt;span&gt;&amp;nbsp;&lt;/span&gt;Route Name (Tenant)&lt;span&gt;&amp;nbsp;&lt;/span&gt;형태로 라벨링. 한 줄로 어떤 테넌트의 route 인지 식별.&lt;/li&gt;
&lt;li data-line=&quot;101&quot;&gt;각 route 의 path prefix 옆에&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;full URL&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;과&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;copy 버튼&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;노출.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://gateway.example.com/agentgateway/mcp1&quot;&gt;https://gateway.example.com/agentgateway/mcp1&lt;/a&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;한 번 클릭으로 복사.&lt;/li&gt;
&lt;li data-line=&quot;102&quot;&gt;Route Name / Path Prefix 인라인 편집 (별도 모달 진입 없이).&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;시도-4--route-별-authorization-분리&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;104&quot; data-ke-size=&quot;size23&quot;&gt;시도 4 &amp;mdash; Route 별 Authorization 분리&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;106&quot; data-ke-size=&quot;size16&quot;&gt;MCP Authorization&lt;span&gt;&amp;nbsp;&lt;/span&gt;도 route 단위로 묶이도록 매핑. Cedar 정책은 route id 를 attribute 로 받아서 평가:&lt;/p&gt;
&lt;pre class=&quot;sqf&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;// tenant-a.cedar
forbid (
  principal,
  action,
  resource
) unless {
  context.route.id == &quot;tenant-a&quot; &amp;amp;&amp;amp;
  mcp.tool.name in [
    &quot;search&quot;,
    &quot;rag-query&quot;
  ]
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;123&quot; data-ke-size=&quot;size16&quot;&gt;테넌트 A 의 화이트리스트와 테넌트 B 의 화이트리스트가 다른 정책 파일로 갈라진다. CI 에서 각 정책을 개별 검증 가능 (전날 다룬 Cedar 검증 절차 그대로 적용).&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;125&quot; data-ke-size=&quot;size16&quot;&gt;이 구조의 장점:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;127&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;127&quot;&gt;&lt;b&gt;격리성&lt;/b&gt;. 한 테넌트의 정책 변경이 다른 테넌트에 영향 안 줌.&lt;/li&gt;
&lt;li data-line=&quot;128&quot;&gt;&lt;b&gt;검증성&lt;/b&gt;. 정책 파일 단위로 syntax + 시뮬레이션 검증.&lt;/li&gt;
&lt;li data-line=&quot;129&quot;&gt;&lt;b&gt;추적성&lt;/b&gt;. 정책 변경 PR 에 어떤 테넌트인지가 명확.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;시도-5--미등록-path-의-default-동작-함정&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;131&quot; data-ke-size=&quot;size23&quot;&gt;시도 5 &amp;mdash; 미등록 path 의 default 동작 (함정)&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;133&quot; data-ke-size=&quot;size16&quot;&gt;agentgateway 의 미등록 path (예:&lt;span&gt;&amp;nbsp;&lt;/span&gt;http://agentgateway.&amp;lt;ns&amp;gt;.svc.cluster.local:3000/agentgateway/mcp-unknown) 로 요청이 들어오면, 기본적으로 게이트웨이 root 또는 정의되지 않은 응답으로 매칭된다. 그래서:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;135&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;135&quot;&gt;운영 관점에서 어떤 path 가 valid 한지 모호함.&lt;/li&gt;
&lt;li data-line=&quot;136&quot;&gt;디버깅 시 &quot;이 path 는 안 잡혔다&quot; 라는 게 명확하지 않음.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;138&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결: listener level 에서 fallback route 를 명시적으로 추가.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;우선순위가 가장 낮은&lt;span&gt;&amp;nbsp;&lt;/span&gt;/&lt;span&gt;&amp;nbsp;&lt;/span&gt;prefix route 가 404 응답을 반환하도록.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;- name: fallback-404
  pathPrefix: /
  response:
    status: 404
    body: &quot;route not found&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;148&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 두면 미등록 path 호출은 404 로 명확히 응답. 디버깅 / 보안 모두 개선.&lt;/p&gt;
&lt;h3 id=&quot;시도-6--path-prefix-표시-개선&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;150&quot; data-ke-size=&quot;size23&quot;&gt;시도 6 &amp;mdash; Path Prefix 표시 개선&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;152&quot; data-ke-size=&quot;size16&quot;&gt;자체 UI 가 path prefix 만 보여주는 게 불편하다고 사용자가 명시적으로 요청했다.&lt;/p&gt;
&lt;blockquote style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot; data-line=&quot;154&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-line=&quot;154&quot; data-ke-size=&quot;size16&quot;&gt;&quot;Path Prefix 가&lt;span&gt;&amp;nbsp;&lt;/span&gt;/agentgateway/mcp1&lt;span&gt;&amp;nbsp;&lt;/span&gt;이게 아니라&lt;span&gt;&amp;nbsp;&lt;/span&gt;http://agentgateway.&amp;lt;ns&amp;gt;.svc.cluster.local:3000/agentgateway/mcp1&lt;span&gt;&amp;nbsp;&lt;/span&gt;이거로 뜨도록 해줘. 그리고 옆에 copy 버튼 하나 눌러서, 이 주소를 카피하기 쉽도록 만들어줘.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;156&quot; data-ke-size=&quot;size16&quot;&gt;내부 통신용 full URL 과 외부 노출용 full URL 을 둘 다 표시하는 게 베스트.&lt;/p&gt;
&lt;pre class=&quot;groovy&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;Route: tenant-a-langflow  (Tenant)
  Path Prefix: /agentgateway/mcp1
  Internal URL:  http://agentgateway..svc.cluster.local:3000/agentgateway/mcp1   [copy]
  External URL:  https://gateway.example.com/agentgateway/mcp1                       [copy]
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;165&quot; data-ke-size=&quot;size16&quot;&gt;운영자가 어떤 환경에 어떤 URL 을 안내할지 한 화면에서 결정.&lt;/p&gt;
&lt;h2 id=&quot;해결--최종-구성&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;167&quot; data-ke-size=&quot;size26&quot;&gt;해결 &amp;mdash; 최종 구성&lt;/h2&gt;
&lt;pre class=&quot;dts&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;# agentgateway 라우팅 의사 코드
bind:
  port: 3000
  listeners:
    - name: mcp-main
      routes:

        - name: tenant-a-langflow         # Route Name (Tenant)
          pathPrefix: /agentgateway/mcp1
          backend:
            type: mcp
            url: http://langflow-a:7860/api/v1/mcp/project/&amp;lt;UUID_A&amp;gt;/streamable
          authorization: tenant-a.cedar

        - name: tenant-b-mcpbin
          pathPrefix: /agentgateway/mcp2
          backend:
            type: mcp
            url: https://mcpbin.example.com/remote/mcp
          authorization: tenant-b.cedar

        - name: tenant-c-search
          pathPrefix: /agentgateway/mcp3
          backend:
            type: mcp
            url: http://search-mcp:8080/mcp
          authorization: tenant-c.cedar

        - name: fallback-404
          pathPrefix: /
          response:
            status: 404
            body: &quot;route not found&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;205&quot; data-ke-size=&quot;size16&quot;&gt;운영자 UI 의 표시 모델:&lt;/p&gt;
&lt;pre class=&quot;groovy&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;┌─ Route: tenant-a-langflow (Tenant) ─────────────────┐
│ External: https://gateway.example.com/.../mcp1  [c] │
│                                                     │
│  ├─ MCP Backend                                     │
│  │    type: mcp                                     │
│  │    upstream: http://langflow-a:7860/...          │
│  │                                                  │
│  └─ MCP Authorization (tenant-a.cedar)              │
│       allow tools: search, rag-query, ...           │
│                                                     │
│ [edit name]  [edit prefix]  [delete route]          │
└─────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;222&quot; data-ke-size=&quot;size16&quot;&gt;테넌트 추가는 카드 복제 &amp;rarr; 이름&amp;middot;path&amp;middot;backend URL&amp;middot;정책 파일만 갈아끼우면 끝.&lt;/p&gt;
&lt;h2 id=&quot;검증-절차&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;224&quot; data-ke-size=&quot;size26&quot;&gt;검증 절차&lt;/h2&gt;
&lt;pre class=&quot;awk&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;# 1. 각 route 에서 tools/list 가 정상 응답하는지
for prefix in mcp1 mcp2 mcp3; do
  echo &quot;=== $prefix ===&quot;
  curl -sS -X POST https://gateway.example.com/agentgateway/$prefix \
    -H 'Content-Type: application/json' \
    -H 'Accept: application/json, text/event-stream' \
    -d '{&quot;jsonrpc&quot;:&quot;2.0&quot;,&quot;id&quot;:1,&quot;method&quot;:&quot;tools/list&quot;}' \
    | jq '.result.tools | length'
done

# 2. 미등록 path 는 404
curl -sS -o /dev/null -w &quot;%{http_code}\n&quot; \
  https://gateway.example.com/agentgateway/mcp-unknown
# 404 가 나와야 정상

# 3. 테넌트 A 의 정책이 테넌트 B 에 영향 없는지 (정책 분리 검증)
#    A 에서 deny 된 도구가 B 에서는 영향 없이 호출되어야 함
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;트레이드오프--멀티-listener-와의-비교&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;246&quot; data-ke-size=&quot;size26&quot;&gt;트레이드오프 &amp;mdash; 멀티 listener 와의 비교&lt;/h2&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;248&quot; data-ke-size=&quot;size16&quot;&gt;여러 MCP backend 를 노출하는 방법은 path-prefix multiplexing 외에도 두 가지가 더 있다.&lt;/p&gt;
&lt;p&gt;방법장점단점&lt;/p&gt;
&lt;table style=&quot;color: #202020; text-align: start; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-line=&quot;250&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody data-line=&quot;252&quot;&gt;
&lt;tr data-line=&quot;252&quot;&gt;
&lt;td&gt;&lt;b&gt;path prefix multiplexing (이번 선택)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;1개 포트로 통합. URL 만 다르면 됨. 외부 ingress 단순.&lt;/td&gt;
&lt;td&gt;path naming 규약 필요. wildcard tool name 충돌 가능.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;253&quot;&gt;
&lt;td&gt;&lt;b&gt;host-based virtual host&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;hostname 분리 &amp;rarr; 인지 명확. ingress / certificate 분리 가능.&lt;/td&gt;
&lt;td&gt;DNS / cert 관리 부담. dev 환경에서 hosts 조작 필요.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;254&quot;&gt;
&lt;td&gt;&lt;b&gt;포트별 listener 분리&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;완전 격리. 정책&amp;middot;재시작 영향 분리.&lt;/td&gt;
&lt;td&gt;외부 포트 multiple 노출. 방화벽 정책 늘어남.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;256&quot; data-ke-size=&quot;size16&quot;&gt;PoC ~ 초기 운영 단계는 path prefix 가 가장 가성비. tenant 가 늘어나면서 격리 요구가 강해지면 host-based 로 점진 전환 가능.&lt;/p&gt;
&lt;h2 id=&quot;배운-점&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;258&quot; data-ke-size=&quot;size26&quot;&gt;배운 점&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;260&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;260&quot;&gt;&lt;b&gt;pathPrefix multiplexing 은 MCP 게이트웨이의 핵심 패턴.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;tenant 격리&amp;middot;정책 분리&amp;middot;URL 안내 모두 path 한 줄로 표현된다.&lt;/li&gt;
&lt;li data-line=&quot;261&quot;&gt;&lt;b&gt;자체 UI 의 평면 구조는 운영 친화성이 떨어진다.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;route 를 1차 객체로, backend / authz 를 종속 객체로 시각화하면 인지 부하가 크게 줄어든다. 1차 객체 / 2차 객체 라는 객체 모델 자체가 UI 결정의 출발점.&lt;/li&gt;
&lt;li data-line=&quot;262&quot;&gt;&lt;b&gt;미등록 path 에 대한 fallback 을 명시적으로 정의해두자.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;디폴트로 root 응답이 노출되면 디버깅&amp;middot;보안 양쪽 모두 골치.&lt;/li&gt;
&lt;li data-line=&quot;263&quot;&gt;&lt;b&gt;Cedar 정책에 route id 를 attribute 로 흘려 받게 설계.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;테넌트 추가 시 정책 파일만 한 개 더 늘리면 된다. 정책 격리&amp;middot;검증&amp;middot;추적성 모두 깔끔.&lt;/li&gt;
&lt;li data-line=&quot;264&quot;&gt;&lt;b&gt;운영자 UI 에 internal / external URL 둘 다 표시 + copy 버튼.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;작은 UX 개선이지만 안내 메시지 작성 시간을 크게 줄여준다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;다음-단계&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;266&quot; data-ke-size=&quot;size26&quot;&gt;다음 단계&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;268&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;268&quot;&gt;&lt;b&gt;tenant onboarding 자동화.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;새 tenant 가 들어오면 route + 정책 파일 + (필요 시) DNS / cert 까지 한 번에 provision 하는 IaC.&lt;/li&gt;
&lt;li data-line=&quot;269&quot;&gt;&lt;b&gt;traffic 통계.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;route 별 호출량 / latency / error rate 대시보드.&lt;/li&gt;
&lt;li data-line=&quot;270&quot;&gt;&lt;b&gt;점진적 host-based 전환 PoC.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;격리 요구가 강해지는 시점 대비.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;참고&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;272&quot; data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;274&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;274&quot;&gt;agentgateway docs - routing 구조:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://agentgateway.dev/docs/concepts/routing/&quot;&gt;https://agentgateway.dev/docs/concepts/routing/&lt;/a&gt;&lt;/li&gt;
&lt;li data-line=&quot;275&quot;&gt;MCP spec:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://spec.modelcontextprotocol.io/&quot;&gt;https://spec.modelcontextprotocol.io/&lt;/a&gt;&lt;/li&gt;
&lt;li data-line=&quot;276&quot;&gt;Cedar policy attributes:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://docs.cedarpolicy.com/policies/syntax-policy.html&quot;&gt;https://docs.cedarpolicy.com/policies/syntax-policy.html&lt;/a&gt;&lt;/li&gt;
&lt;li data-line=&quot;277&quot;&gt;Mark Zhu, &quot;agentgateway pathPrefix multiplexing&quot; 블로그 (path 멀티플렉싱 설명).&lt;/li&gt;
&lt;/ul&gt;</description>
      <author>임마누엘한</author>
      <guid isPermaLink="true">https://smhanlab.tistory.com/11</guid>
      <comments>https://smhanlab.tistory.com/11#entry11comment</comments>
      <pubDate>Thu, 14 May 2026 15:44:15 +0900</pubDate>
    </item>
    <item>
      <title>agentgateway MCP Authorization 으로 특정 도구만 차단하기 (Deny Rule + Cedar + mcp.tool.name 매칭)</title>
      <link>https://smhanlab.tistory.com/10</link>
      <description>&lt;h2 id=&quot;들어가며&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;6&quot; data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;전날 agentgateway 에 LangFlow MCP 등록을 마치고 나서 바로 다음 요구가 들어왔다.&lt;/p&gt;
&lt;blockquote style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot; data-line=&quot;10&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-line=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;&quot;게이트웨이에 backend 로 붙은 MCP 들 중에서,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;특정 도구 하나만 호출 못 하게 막고 싶다.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;예를 들어&lt;span&gt;&amp;nbsp;&lt;/span&gt;count-characters&lt;span&gt;&amp;nbsp;&lt;/span&gt;같은 데모용 도구는 운영에서 차단.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;agentgateway 는 이걸 위해&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;MCP Authorization&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;이라는 정책 레이어를 제공한다. 정책 언어는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Cedar&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;(AWS 가 오픈소스화한 정책 언어). MCP 요청의 메타데이터 (mcp.tool.name,&lt;span&gt;&amp;nbsp;&lt;/span&gt;mcp.server.name,&lt;span&gt;&amp;nbsp;&lt;/span&gt;principal&lt;span&gt;&amp;nbsp;&lt;/span&gt;등) 를 보고 allow / deny / forbid 한다.&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;처음 쓰면 deny rule 이 안 먹히는 함정이 있어서, 그 디버깅 과정과 운영용 allow-list 패턴까지 정리한다.&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;핵심 키워드: agentgateway, MCP Authorization, Cedar policy, Deny Rule, MCP 도구 차단, AI Agent 보안.&lt;/p&gt;
&lt;h2 id=&quot;시스템-컨텍스트&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;18&quot; data-ke-size=&quot;size26&quot;&gt;시스템 컨텍스트&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;20&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;20&quot;&gt;agentgateway 의 한 route 에 LangFlow MCP 가 backend 로 연결되어 있고, 도구 21개가 노출된 상태.&lt;/li&gt;
&lt;li data-line=&quot;21&quot;&gt;그 중&lt;span&gt;&amp;nbsp;&lt;/span&gt;count-characters&lt;span&gt;&amp;nbsp;&lt;/span&gt;라는 데모성 도구 1개를 차단하고 싶다.&lt;/li&gt;
&lt;li data-line=&quot;22&quot;&gt;운영 정책: &quot;기본 deny + 명시적 허용&quot; 원칙. PoC 는 deny rule 로 빠르게 막고, 운영은 allow-list 로 전환 예정.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;LLM Client ─ tools/call name=&quot;count-characters&quot; ─&amp;gt; agentgateway
                                                       │
                                                       ▼
                                                Cedar Policy 평가
                                                  │
                                       ┌──────────┴──────────┐
                                       │ allow                │ forbid
                                       ▼                      ▼
                              backend 으로 forward      403 / forbidden 응답
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;문제-상황&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;36&quot; data-ke-size=&quot;size26&quot;&gt;문제 상황&lt;/h2&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;38&quot; data-ke-size=&quot;size16&quot;&gt;agentgateway UI 의 MCP Authorization &amp;rarr;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Add Deny Rule&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;을 클릭해서 조건을 다음과 같이 줬다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;forbid (
  principal,
  action,
  resource
) when {
  mcp.tool.name == &quot;count-characters&quot;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;50&quot; data-ke-size=&quot;size16&quot;&gt;Save. 그런데 LangFlow agent 에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;count-characters&lt;span&gt;&amp;nbsp;&lt;/span&gt;가&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;여전히 호출 가능&lt;/b&gt;. agentgateway UI 의 Authorization 로그에도 deny 흔적이 없다.&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;52&quot; data-ke-size=&quot;size16&quot;&gt;증상 정리:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;54&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;54&quot;&gt;정책 저장은 됨 (재시작 후에도 정책 목록에 보임)&lt;/li&gt;
&lt;li data-line=&quot;55&quot;&gt;트래픽은 정상 통과 (deny 발동 안 함)&lt;/li&gt;
&lt;li data-line=&quot;56&quot;&gt;로그에 deny 도, allow 도 명시적으로 안 찍힘 &amp;rarr;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;정책 자체가 평가 대상에서 빠진 느낌&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;cedar-정책-평가-모델-짧게-정리&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;58&quot; data-ke-size=&quot;size26&quot;&gt;Cedar 정책 평가 모델 짧게 정리&lt;/h2&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;60&quot; data-ke-size=&quot;size16&quot;&gt;문제를 이해하려면 Cedar 의 평가 흐름을 알아야 한다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;요청 = (principal, action, resource, context)
정책들 = [policy_1, policy_2, ...]

평가:
  1. 매칭되는 forbid 가 하나라도 있으면  &amp;rarr; deny  (forbid 가 최우선)
  2. 매칭되는 permit 가 하나라도 있으면  &amp;rarr; allow
  3. 그 외                              &amp;rarr; 기본 deny
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;72&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;중요&lt;/b&gt;:&lt;span&gt;&amp;nbsp;&lt;/span&gt;forbid&lt;span&gt;&amp;nbsp;&lt;/span&gt;가 매칭되려면&lt;span&gt;&amp;nbsp;&lt;/span&gt;when { ... }&lt;span&gt;&amp;nbsp;&lt;/span&gt;안의 모든 attribute 가 요청 context 에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;참조 가능&lt;/b&gt;해야 한다. 참조 불가능한 attribute (서버가 안 채워준 항목) 가 하나라도 들어가 있으면 그 정책은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;evaluation 자체에서 skip&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;된다 (Cedar 의 strict mode 동작).&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;74&quot; data-ke-size=&quot;size16&quot;&gt;이게 첫 번째 정책이 안 먹힌 진짜 이유였다.&lt;/p&gt;
&lt;h2 id=&quot;시도한-것들&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;76&quot; data-ke-size=&quot;size26&quot;&gt;시도한 것들&lt;/h2&gt;
&lt;h3 id=&quot;시도-1--tool-name-만-매칭-실패&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;78&quot; data-ke-size=&quot;size23&quot;&gt;시도 1 &amp;mdash; tool name 만 매칭 (실패)&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;80&quot; data-ke-size=&quot;size16&quot;&gt;첫 정책:&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;forbid (
  principal,
  action,
  resource
) when {
  mcp.tool.name == &quot;count-characters&quot;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;92&quot; data-ke-size=&quot;size16&quot;&gt;이게 안 먹힌 이유를 좁히려면 agentgateway 가 정책 평가 시 어떤 attribute 를 채워서 넘기는지 알아야 한다. 문서를 보니 attribute 는 server scope 와 tool scope 가 분리되어 있고, scope 매칭이 안 된 attribute 는 미정의로 평가된다. 즉:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;94&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;94&quot;&gt;request 가 어떤 server 에 가는 요청인지가 명확하지 않으면 (multiple backend 에 같은 tool name 이 있을 때 etc),&lt;span&gt;&amp;nbsp;&lt;/span&gt;mcp.tool.name&lt;span&gt;&amp;nbsp;&lt;/span&gt;만으로는 어떤 도구를 지칭하는지 모호.&lt;/li&gt;
&lt;li data-line=&quot;95&quot;&gt;모호한 attribute 가 있는 정책은 conservative 하게 skip.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;97&quot; data-ke-size=&quot;size16&quot;&gt;agentgateway 의 평가 정책 (보수적 default): &quot;정책이 정확히 매칭되는 경우만 deny&quot;. 그래서 deny rule 이 silently 무시된 것.&lt;/p&gt;
&lt;h3 id=&quot;시도-2--server-이름까지-같이-매칭-성공&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;99&quot; data-ke-size=&quot;size23&quot;&gt;시도 2 &amp;mdash; server 이름까지 같이 매칭 (성공)&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;101&quot; data-ke-size=&quot;size16&quot;&gt;server scope 를 명시한 정책:&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;forbid (
  principal,
  action,
  resource
) when {
  mcp.server.name == &quot;my-langflow-mcp&quot; &amp;amp;&amp;amp;
  mcp.tool.name == &quot;count-characters&quot;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;114&quot; data-ke-size=&quot;size16&quot;&gt;이번에는 deny 발동. agent 호출은 다음 에러로 떨어진다:&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;MCP error: forbidden (policy denied: tool 'count-characters' on server 'my-langflow-mcp')
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;120&quot; data-ke-size=&quot;size16&quot;&gt;agentgateway UI 의 Authorization 로그에도&lt;span&gt;&amp;nbsp;&lt;/span&gt;denied: count-characters&lt;span&gt;&amp;nbsp;&lt;/span&gt;가 명시적으로 찍힌다. 정책이 평가됐다는 신호.&lt;/p&gt;
&lt;h3 id=&quot;시도-3--여러-도구-한-번에-차단&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;122&quot; data-ke-size=&quot;size23&quot;&gt;시도 3 &amp;mdash; 여러 도구 한 번에 차단&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;124&quot; data-ke-size=&quot;size16&quot;&gt;in&lt;span&gt;&amp;nbsp;&lt;/span&gt;연산자로 그룹 매칭.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;forbid (
  principal,
  action,
  resource
) when {
  mcp.server.name == &quot;my-langflow-mcp&quot; &amp;amp;&amp;amp;
  mcp.tool.name in [
    &quot;count-characters&quot;,
    &quot;echo-debug&quot;,
    &quot;internal-test&quot;
  ]
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;141&quot; data-ke-size=&quot;size16&quot;&gt;in&lt;span&gt;&amp;nbsp;&lt;/span&gt;은 list membership 검사. 깔끔하다.&lt;/p&gt;
&lt;h3 id=&quot;시도-4--와일드카드성-매칭-like&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;143&quot; data-ke-size=&quot;size23&quot;&gt;시도 4 &amp;mdash; 와일드카드성 매칭 (like)&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;145&quot; data-ke-size=&quot;size16&quot;&gt;내부 도구 prefix 가 일관되어 있다면&lt;span&gt;&amp;nbsp;&lt;/span&gt;like&lt;span&gt;&amp;nbsp;&lt;/span&gt;로:&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;forbid (
  principal,
  action,
  resource
) when {
  mcp.server.name == &quot;my-langflow-mcp&quot; &amp;amp;&amp;amp;
  mcp.tool.name like &quot;internal-*&quot;
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;158&quot; data-ke-size=&quot;size16&quot;&gt;like&lt;span&gt;&amp;nbsp;&lt;/span&gt;의&lt;span&gt;&amp;nbsp;&lt;/span&gt;*&lt;span&gt;&amp;nbsp;&lt;/span&gt;는 wildcard. SQL&lt;span&gt;&amp;nbsp;&lt;/span&gt;LIKE&lt;span&gt;&amp;nbsp;&lt;/span&gt;의&lt;span&gt;&amp;nbsp;&lt;/span&gt;%&lt;span&gt;&amp;nbsp;&lt;/span&gt;와 동작이 비슷하다. 내부 prefix 규약을 잘 잡아두면 정책이 한 줄로 끝난다.&lt;/p&gt;
&lt;h3 id=&quot;시도-5--allow-list-패턴-운영-권장&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;160&quot; data-ke-size=&quot;size23&quot;&gt;시도 5 &amp;mdash; Allow-list 패턴 (운영 권장)&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;162&quot; data-ke-size=&quot;size16&quot;&gt;deny rule 보다&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;기본 deny + 화이트리스트&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;가 운영에 더 안전. 신규 도구가 추가될 때 자동으로 막혀서, 의도치 않은 expose 가 안 생긴다.&lt;/p&gt;
&lt;pre class=&quot;sqf&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;// 기본 정책: 명시 화이트리스트에 없으면 forbid
forbid (
  principal,
  action,
  resource
) unless {
  mcp.server.name == &quot;my-langflow-mcp&quot; &amp;amp;&amp;amp;
  mcp.tool.name in [
    &quot;search&quot;,
    &quot;fetch-url&quot;,
    &quot;rag-query&quot;
  ]
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;180&quot; data-ke-size=&quot;size16&quot;&gt;forbid ... unless&lt;span&gt;&amp;nbsp;&lt;/span&gt;는 &quot;조건이 참이 아니면 forbid&quot;. 위 정책은 &quot;server 가 my-langflow-mcp 이면서 tool 이 화이트리스트에 있는 경우가 아니면 모두 forbid&quot;.&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;182&quot; data-ke-size=&quot;size16&quot;&gt;신규 도구를 expose 하려면 화이트리스트에 추가하는 PR 이 필요. 자동 차단의 핵심 이점.&lt;/p&gt;
&lt;h3 id=&quot;시도-6--principal-별-차등-정책&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;184&quot; data-ke-size=&quot;size23&quot;&gt;시도 6 &amp;mdash; Principal 별 차등 정책&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;186&quot; data-ke-size=&quot;size16&quot;&gt;운영자 / 일반 사용자별로 다른 정책이 필요해지면 principal attribute 활용.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;// 운영자만 admin tool 호출 가능
forbid (
  principal,
  action,
  resource
) when {
  mcp.tool.name like &quot;admin-*&quot; &amp;amp;&amp;amp;
  !(principal in Group::&quot;operators&quot;)
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;200&quot; data-ke-size=&quot;size16&quot;&gt;principal 의 group 정보는 agentgateway 가 인증 단계에서 채워준다 (OIDC claim 기반). 어떤 attribute 가 채워져 있는지 정책 디버깅 화면에서 확인 가능.&lt;/p&gt;
&lt;h3 id=&quot;시도-7--정책-syntax-검증-배포-전&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;202&quot; data-ke-size=&quot;size23&quot;&gt;시도 7 &amp;mdash; 정책 syntax 검증 (배포 전)&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;204&quot; data-ke-size=&quot;size16&quot;&gt;Cedar 정책은 런타임 평가 전에 syntax 검증을 통과해야 한다. 운영 파이프라인에서는 CI 에 dry-run 단계를 추가.&lt;/p&gt;
&lt;pre class=&quot;jboss-cli&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;# Cedar CLI 로 syntax 검증
cedar check-policy --policy ./policies/tenant-a.cedar

# 실제 평가 simulation
cedar authorize \
  --policies ./policies/tenant-a.cedar \
  --entities ./entities.json \
  --principal 'User::&quot;alice&quot;' \
  --action 'Action::&quot;call&quot;' \
  --resource 'Tool::&quot;count-characters&quot;'
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;219&quot; data-ke-size=&quot;size16&quot;&gt;CI 가 syntax 에러 / 평가 simulation 결과를 PR comment 로 남기면, 정책 변경에 따른 의도 차이를 미리 잡을 수 있다.&lt;/p&gt;
&lt;h2 id=&quot;원인-정리&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;221&quot; data-ke-size=&quot;size26&quot;&gt;원인 정리&lt;/h2&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;223&quot; data-ke-size=&quot;size16&quot;&gt;처음 안 먹힌 이유:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;scope 매칭 부족&lt;/b&gt;. agentgateway 는 정책 평가 시 자기가 routing 한 server/route 컨텍스트와 정책의 attribute 가 일관되게 매칭되어야 적용한다. tool name 만으로는 모호한 경우 보수적으로 skip 한다.&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;225&quot; data-ke-size=&quot;size16&quot;&gt;해결: server scope 까지 명시.&lt;/p&gt;
&lt;h2 id=&quot;해결--최종-권장-정책&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;227&quot; data-ke-size=&quot;size26&quot;&gt;해결 &amp;mdash; 최종 권장 정책&lt;/h2&gt;
&lt;pre class=&quot;sqf&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;// === my-langflow-mcp 의 화이트리스트 ===
forbid (
  principal,
  action,
  resource
) unless {
  mcp.server.name == &quot;my-langflow-mcp&quot; &amp;amp;&amp;amp;
  mcp.tool.name in [
    &quot;search&quot;,
    &quot;fetch-url&quot;,
    &quot;rag-query&quot;
  ]
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;245&quot; data-ke-size=&quot;size16&quot;&gt;추가 운영 절차:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #202020; text-align: start;&quot; data-line=&quot;247&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-line=&quot;247&quot;&gt;신규 도구 expose &amp;rarr; 화이트리스트에 추가하는 PR.&lt;/li&gt;
&lt;li data-line=&quot;248&quot;&gt;CI 에서 Cedar syntax 검증 + 시뮬레이션 테스트.&lt;/li&gt;
&lt;li data-line=&quot;249&quot;&gt;배포 후 agentgateway 의 Authorization 로그에서 expected / unexpected deny 확인.&lt;/li&gt;
&lt;li data-line=&quot;250&quot;&gt;정기적으로 화이트리스트와 실제 expose 된 도구 목록을 diff (drift detection).&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;검증-절차&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;252&quot; data-ke-size=&quot;size26&quot;&gt;검증 절차&lt;/h2&gt;
&lt;pre class=&quot;ada&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;# 1. 차단된 도구 호출 &amp;rarr; forbidden 응답
curl -sS -X POST https://gateway.example.com/agentgateway/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{
    &quot;jsonrpc&quot;:&quot;2.0&quot;,&quot;id&quot;:1,&quot;method&quot;:&quot;tools/call&quot;,
    &quot;params&quot;:{&quot;name&quot;:&quot;count-characters&quot;,&quot;arguments&quot;:{&quot;text&quot;:&quot;abc&quot;}}
  }' | jq .

# 기대 응답:
# {&quot;jsonrpc&quot;:&quot;2.0&quot;,&quot;id&quot;:1,&quot;error&quot;:{&quot;code&quot;:-32603,&quot;message&quot;:&quot;forbidden&quot;}}

# 2. 허용된 도구 호출 &amp;rarr; 정상 응답
curl -sS -X POST https://gateway.example.com/agentgateway/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{
    &quot;jsonrpc&quot;:&quot;2.0&quot;,&quot;id&quot;:2,&quot;method&quot;:&quot;tools/call&quot;,
    &quot;params&quot;:{&quot;name&quot;:&quot;search&quot;,&quot;arguments&quot;:{&quot;query&quot;:&quot;hello&quot;}}
  }' | jq .

# 3. agentgateway 로그에서 deny / allow 흔적 확인
docker logs agentgateway 2&amp;gt;&amp;amp;1 | grep -i &quot;authz&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;디버깅-체크리스트-정책이-안-먹힐-때&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;280&quot; data-ke-size=&quot;size26&quot;&gt;디버깅 체크리스트 (정책이 안 먹힐 때)&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #202020; text-align: start;&quot; data-line=&quot;282&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-line=&quot;282&quot;&gt;&lt;b&gt;agentgateway 로그에 deny / allow 가 찍히는지&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;먼저 확인. 안 찍히면 정책 자체가 evaluation 에서 skip 된 상태.&lt;/li&gt;
&lt;li data-line=&quot;283&quot;&gt;skip 됐다면 정책의 attribute 가 요청 컨텍스트에 모두 채워져 있는지. server scope 부족이 가장 흔한 원인.&lt;/li&gt;
&lt;li data-line=&quot;284&quot;&gt;attribute 이름 오타 (mcp.tool.name&lt;span&gt;&amp;nbsp;&lt;/span&gt;을&lt;span&gt;&amp;nbsp;&lt;/span&gt;mcp.toolname&lt;span&gt;&amp;nbsp;&lt;/span&gt;으로) &amp;rarr; 실제로 자주 발생.&lt;/li&gt;
&lt;li data-line=&quot;285&quot;&gt;Cedar syntax 자체는 OK 인데 매칭 안 되는 경우 &amp;rarr; cedar CLI 로 시뮬레이션.&lt;/li&gt;
&lt;li data-line=&quot;286&quot;&gt;forbid ... unless&lt;span&gt;&amp;nbsp;&lt;/span&gt;와&lt;span&gt;&amp;nbsp;&lt;/span&gt;forbid ... when&lt;span&gt;&amp;nbsp;&lt;/span&gt;의 의미 정반대. 한 번씩 꼭 헷갈린다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;배운-점&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;288&quot; data-ke-size=&quot;size26&quot;&gt;배운 점&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;290&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;290&quot;&gt;&lt;b&gt;MCP 도구 차단은 server scope 까지 명시해야 안전하게 동작한다.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;tool name 만으로는 모호해서 정책이 silently skip 될 수 있다.&lt;/li&gt;
&lt;li data-line=&quot;291&quot;&gt;&lt;b&gt;Deny rule 보다 allow-list 가 운영에 안전.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;신규 도구 자동 차단으로 사고 감소.&lt;span&gt;&amp;nbsp;&lt;/span&gt;forbid ... unless&lt;span&gt;&amp;nbsp;&lt;/span&gt;패턴이 핵심.&lt;/li&gt;
&lt;li data-line=&quot;292&quot;&gt;&lt;b&gt;agentgateway 의 Authorization 로그는 정책이 평가됐는지 자체를 확인하는 1번 도구.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;deny 흔적이 없으면 룰 자체가 매칭 안 된 것이지, 룰이 약해서 통과한 게 아니다.&lt;/li&gt;
&lt;li data-line=&quot;293&quot;&gt;&lt;b&gt;Cedar 의&lt;span&gt;&amp;nbsp;&lt;/span&gt;in,&lt;span&gt;&amp;nbsp;&lt;/span&gt;like&lt;span&gt;&amp;nbsp;&lt;/span&gt;연산자로 그룹 매칭이 가능.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;운영 정책은 거의 항상 group 으로 쓰게 된다. prefix 규약을 잘 잡아두자 (internal-*,&lt;span&gt;&amp;nbsp;&lt;/span&gt;admin-*&lt;span&gt;&amp;nbsp;&lt;/span&gt;등).&lt;/li&gt;
&lt;li data-line=&quot;294&quot;&gt;&lt;b&gt;정책은 CI 에서 syntax + 시뮬레이션 검증.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;런타임에 발견하면 늦다.&lt;/li&gt;
&lt;li data-line=&quot;295&quot;&gt;&lt;b&gt;forbid ... unless&lt;span&gt;&amp;nbsp;&lt;/span&gt;와&lt;span&gt;&amp;nbsp;&lt;/span&gt;forbid ... when&lt;span&gt;&amp;nbsp;&lt;/span&gt;의 의미가 정반대.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;한 번 헷갈리면 보안 사고. 작성 후 시뮬레이션으로 확인 필수.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;다음-단계&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;297&quot; data-ke-size=&quot;size26&quot;&gt;다음 단계&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;299&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;299&quot;&gt;&lt;b&gt;route 별로 정책을 분리.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;다음 날 다룰 멀티 MCP 라우팅에서는 tenant 별 정책 파일이 갈라진다.&lt;/li&gt;
&lt;li data-line=&quot;300&quot;&gt;&lt;b&gt;principal 인증 강화.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;OIDC claim 기반 group 매핑.&lt;/li&gt;
&lt;li data-line=&quot;301&quot;&gt;&lt;b&gt;Audit 로그 별도 sink.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;Authorization 로그를 SIEM 으로 보내서 deny 패턴 모니터링.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;참고&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;303&quot; data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;305&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;305&quot;&gt;Cedar policy language:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://www.cedarpolicy.com/&quot;&gt;https://www.cedarpolicy.com/&lt;/a&gt;&lt;/li&gt;
&lt;li data-line=&quot;306&quot;&gt;Cedar 문법 정리:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://docs.cedarpolicy.com/policies/syntax-policy.html&quot;&gt;https://docs.cedarpolicy.com/policies/syntax-policy.html&lt;/a&gt;&lt;/li&gt;
&lt;li data-line=&quot;307&quot;&gt;agentgateway 의 MCP Authorization 문서:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://agentgateway.dev/docs/mcp/authz/&quot;&gt;https://agentgateway.dev/docs/mcp/authz/&lt;/a&gt;&lt;/li&gt;
&lt;li data-line=&quot;308&quot;&gt;MCP spec - tool metadata:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://spec.modelcontextprotocol.io/specification/&quot;&gt;https://spec.modelcontextprotocol.io/specification/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <author>임마누엘한</author>
      <guid isPermaLink="true">https://smhanlab.tistory.com/10</guid>
      <comments>https://smhanlab.tistory.com/10#entry10comment</comments>
      <pubDate>Thu, 14 May 2026 15:43:58 +0900</pubDate>
    </item>
    <item>
      <title>Keycloak CSP frame-ancestors 로 iframe 임베드 차단된 문제 &amp;ndash; 저장 버튼 grayed out 함정까지</title>
      <link>https://smhanlab.tistory.com/9</link>
      <description>&lt;h2 id=&quot;들어가며&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;6&quot; data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;사내 포털을&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;마이크로프론트엔드 (MFE)&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;구조로 운영 중이다. shell 앱이 여러 sub 앱을 iframe 으로 띄워서 보여주는 구조인데, 그 안에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Keycloak 으로 보호되는 다른 앱&lt;/b&gt;을 iframe 으로 임베드하려고 했다. 결과는 브라우저 콘솔에 정겨운 한 줄.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;Refused to display 'https://app.example.com/' in a frame because 
an ancestor violates the following Content Security Policy directive: 
&quot;frame-ancestors 'self'&quot;.
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;원인은 한 번에 보이는데 해결까지 30분 이상 잡아먹었다. Keycloak Admin UI 의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;silent save fail&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;함정 때문이었다. 검색해서 오신 분들 위해, 함정과 해결을 모두 정리한다.&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;핵심 키워드: Keycloak, Content-Security-Policy, frame-ancestors, X-Frame-Options, iframe 임베드, Refused to display in a frame, browserSecurityHeaders.&lt;/p&gt;
&lt;h2 id=&quot;시스템-컨텍스트&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;20&quot; data-ke-size=&quot;size26&quot;&gt;시스템 컨텍스트&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;22&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;22&quot;&gt;부모 페이지:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://portal.example.com/&quot;&gt;https://portal.example.com/&lt;/a&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;(마이크로프론트엔드 shell)&lt;/li&gt;
&lt;li data-line=&quot;23&quot;&gt;iframe 으로 임베드할 대상:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://app.example.com/&quot;&gt;https://app.example.com/&lt;/a&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;(Keycloak 인증으로 보호)&lt;/li&gt;
&lt;li data-line=&quot;24&quot;&gt;Keycloak 버전: 24.x&lt;/li&gt;
&lt;li data-line=&quot;25&quot;&gt;운영: Helm chart 로 배포, Realm 설정은 GitOps 로 관리&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;css&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;브라우저
  └─ portal.example.com (shell)
        └─ 
                └─ app.example.com (Keycloak 인증)
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;문제-상황&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;34&quot; data-ke-size=&quot;size26&quot;&gt;문제 상황&lt;/h2&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;36&quot; data-ke-size=&quot;size16&quot;&gt;iframe 이 빈 화면. 콘솔에 다음 메시지.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;Refused to display 'https://app.example.com/' in a frame because 
an ancestor violates the following Content Security Policy directive: 
&quot;frame-ancestors 'self'&quot;.
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;44&quot; data-ke-size=&quot;size16&quot;&gt;이 메시지의 의미.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;46&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;46&quot;&gt;&lt;b&gt;frame-ancestors&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;= &quot;이 페이지를 iframe 으로 띄우는 부모 페이지의 origin 화이트리스트&quot;.&lt;/li&gt;
&lt;li data-line=&quot;47&quot;&gt;'self'&lt;span&gt;&amp;nbsp;&lt;/span&gt;= &quot;자기 자신과 같은 origin 만 허용&quot;.&lt;/li&gt;
&lt;li data-line=&quot;48&quot;&gt;즉&lt;span&gt;&amp;nbsp;&lt;/span&gt;app.example.com&lt;span&gt;&amp;nbsp;&lt;/span&gt;자체에서 자기를 iframe 으로 띄우는 건 OK, 다른 origin (portal.example.com) 은 금지.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;50&quot; data-ke-size=&quot;size16&quot;&gt;Keycloak default 가&lt;span&gt;&amp;nbsp;&lt;/span&gt;frame-ancestors 'self'&lt;span&gt;&amp;nbsp;&lt;/span&gt;라서, 다른 origin 에서 임베드하려면 Realm 의 CSP 헤더와 X-Frame-Options 를 풀어줘야 한다.&lt;/p&gt;
&lt;h2 id=&quot;frame-ancestors-vs-x-frame-options-vs-frame-src-정리&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;52&quot; data-ke-size=&quot;size26&quot;&gt;frame-ancestors vs X-Frame-Options vs frame-src 정리&lt;/h2&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;54&quot; data-ke-size=&quot;size16&quot;&gt;헷갈리니까 한 번 정리.&lt;/p&gt;
&lt;p&gt;헤더누가 누구를 통제하는가표준 상태&lt;/p&gt;
&lt;table style=&quot;color: #202020; text-align: start; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-line=&quot;56&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody data-line=&quot;58&quot;&gt;
&lt;tr data-line=&quot;58&quot;&gt;
&lt;td&gt;&lt;b&gt;frame-ancestors&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;(CSP)&lt;/td&gt;
&lt;td&gt;&lt;b&gt;임베드 당하는 쪽&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;이 &quot;누가 나를 iframe 으로 띄울 수 있는지&quot; 선언&lt;/td&gt;
&lt;td&gt;신표준 (우선순위 &amp;uarr;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;59&quot;&gt;
&lt;td&gt;&lt;b&gt;X-Frame-Options&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;동일 목적, but 단순 값 (DENY / SAMEORIGIN / ALLOW-FROM)&lt;/td&gt;
&lt;td&gt;레거시, but 일부 브라우저는 여전히 참고&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;60&quot;&gt;
&lt;td&gt;&lt;b&gt;frame-src&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;(CSP)&lt;/td&gt;
&lt;td&gt;&lt;b&gt;iframe 을 띄우는 쪽&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;이 &quot;어떤 origin 을 iframe 으로 띄울 수 있는지&quot; 선언&lt;/td&gt;
&lt;td&gt;신표준&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;62&quot; data-ke-size=&quot;size16&quot;&gt;오늘 다루는 건 첫 두 개. iframe 의 부모(shell) 에서 띄우는 쪽이 아니라&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;임베드 당하는 Keycloak 보호 페이지&lt;/b&gt;의 헤더가 막고 있는 케이스.&lt;/p&gt;
&lt;h2 id=&quot;시도한-것들&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;64&quot; data-ke-size=&quot;size26&quot;&gt;시도한 것들&lt;/h2&gt;
&lt;h3 id=&quot;시도-1--realm-security-defenses-에서-csp-직접-수정&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;66&quot; data-ke-size=&quot;size23&quot;&gt;시도 1 &amp;mdash; Realm Security Defenses 에서 CSP 직접 수정&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;68&quot; data-ke-size=&quot;size16&quot;&gt;Keycloak Admin UI &amp;rarr;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Realm settings &amp;rarr; Security Defenses &amp;rarr; Headers 탭&lt;/b&gt;. 거기에&lt;span&gt;&amp;nbsp;&lt;/span&gt;Content-Security-Policy&lt;span&gt;&amp;nbsp;&lt;/span&gt;필드가 있다. 기본값:&lt;/p&gt;
&lt;pre class=&quot;csp&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;frame-src 'self'; frame-ancestors 'self'; object-src 'none';
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;74&quot; data-ke-size=&quot;size16&quot;&gt;frame-ancestors 'self'&lt;span&gt;&amp;nbsp;&lt;/span&gt;를 허용 origin 으로 바꿨다.&lt;/p&gt;
&lt;pre class=&quot;csp&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;frame-src 'self'; frame-ancestors 'self' https://portal.example.com; object-src 'none';
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;80&quot; data-ke-size=&quot;size16&quot;&gt;Save 버튼 클릭.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;그런데 변경이 저장되지 않는 현상.&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;82&quot; data-ke-size=&quot;size16&quot;&gt;브라우저에서 새로고침하면 default 로 돌아와 있다. 직접 Admin REST API 로 GET 해봐도 그대로.&lt;/p&gt;
&lt;pre class=&quot;livescript&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;TOKEN=$(curl -s -X POST \
  &quot;https://kc.example.com/realms/master/protocol/openid-connect/token&quot; \
  -d &quot;client_id=admin-cli&quot; -d &quot;username=admin&quot; -d &quot;password=admin&quot; \
  -d &quot;grant_type=password&quot; | jq -r .access_token)

curl -s -H &quot;Authorization: Bearer $TOKEN&quot; \
  &quot;https://kc.example.com/admin/realms/myrealm&quot; | jq '.browserSecurityHeaders'
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;94&quot; data-ke-size=&quot;size16&quot;&gt;응답에&lt;span&gt;&amp;nbsp;&lt;/span&gt;contentSecurityPolicy&lt;span&gt;&amp;nbsp;&lt;/span&gt;가 default 값 그대로. UI 에는 분명 변경값이 보이는데 백엔드에 persist 가 안 됨.&lt;/p&gt;
&lt;h3 id=&quot;시도-2--ui-함정-paste-시-change-detection-미발동&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;96&quot; data-ke-size=&quot;size23&quot;&gt;시도 2 &amp;mdash; UI 함정: paste 시 change detection 미발동&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;98&quot; data-ke-size=&quot;size16&quot;&gt;원인을 찾았다. 붙여넣기로만 값을 수정하면 Keycloak Admin UI 가 form change 를 감지 못 하는 케이스가 있다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Save 버튼이 grayed out (disabled)&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;인 채로 머무는 게 단서.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;100&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;100&quot;&gt;&lt;b&gt;눈으로 보면 grayed out 인데 클릭은 된다.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;시각적 disabled 상태인데 클릭이 막혀 있지 않아서 &quot;눌렀다&quot; 라고 느끼지만 onClick 이 안 도는 상태.&lt;/li&gt;
&lt;li data-line=&quot;101&quot;&gt;콘솔의 Network 탭을 보면 PUT 요청 자체가&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;나가지 않음&lt;/b&gt;. 즉 silent fail.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;103&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결 트릭&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;(수많은 SPA 폼에서 통한다):&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #202020; text-align: start;&quot; data-line=&quot;105&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-line=&quot;105&quot;&gt;CSP 필드를 클릭해서 커서를 안에 둠.&lt;/li&gt;
&lt;li data-line=&quot;106&quot;&gt;맨 뒤에&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;스페이스 한 칸 추가&lt;/b&gt;.&lt;/li&gt;
&lt;li data-line=&quot;107&quot;&gt;다시&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;백스페이스로 그 스페이스를 지움&lt;/b&gt;.&lt;/li&gt;
&lt;li data-line=&quot;108&quot;&gt;그 순간 React/Angular 의 change event 가 발동되어 Save 버튼이 활성화 (옅은 색 &amp;rarr; 진한 색으로 바뀜).&lt;/li&gt;
&lt;li data-line=&quot;109&quot;&gt;Save 클릭.&lt;/li&gt;
&lt;/ol&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;111&quot; data-ke-size=&quot;size16&quot;&gt;이 트릭은 paste-only 변경에서 dirty 감지를 놓치는 모든 폼에서 통용된다. Keycloak 외에도 일부 운영 콘솔에서 자주 만난다.&lt;/p&gt;
&lt;h3 id=&quot;시도-3--x-frame-options-도-같이-풀기&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;113&quot; data-ke-size=&quot;size23&quot;&gt;시도 3 &amp;mdash; X-Frame-Options 도 같이 풀기&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;115&quot; data-ke-size=&quot;size16&quot;&gt;CSP 만 풀어도 일부 브라우저/구성에서는 X-Frame-Options 가 별도로 차단한다. Keycloak 의 X-Frame-Options 기본값은&lt;span&gt;&amp;nbsp;&lt;/span&gt;SAMEORIGIN.&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;117&quot; data-ke-size=&quot;size16&quot;&gt;같은 Security Defenses 페이지에서 X-Frame-Options 를 둘 중 하나로:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;119&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;119&quot;&gt;비워서 헤더 자체를 안 보내게 (CSP 의 frame-ancestors 가 충분히 통제).&lt;/li&gt;
&lt;li data-line=&quot;120&quot;&gt;ALLOW-FROM &lt;a href=&quot;https://portal.example.com&quot;&gt;https://portal.example.com&lt;/a&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;(레거시 브라우저 호환). 단, 모던 브라우저는 ALLOW-FROM 을 무시하니 신뢰하지 말 것.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;122&quot; data-ke-size=&quot;size16&quot;&gt;가장 안전한 선택:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;헤더 비워두고 CSP frame-ancestors 에 의존&lt;/b&gt;. 모던 브라우저 표준이고, 우선순위도 CSP 가 위.&lt;/p&gt;
&lt;h3 id=&quot;시도-4--영구화-helm--realm-export&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;124&quot; data-ke-size=&quot;size23&quot;&gt;시도 4 &amp;mdash; 영구화 (Helm / Realm export)&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;126&quot; data-ke-size=&quot;size16&quot;&gt;여기까지 와서 Admin UI 변경이 다음 deploy 에서 사라졌다. 우리 환경은 Realm 설정이 GitOps 로 관리되는데, realm.json 의&lt;span&gt;&amp;nbsp;&lt;/span&gt;browserSecurityHeaders&lt;span&gt;&amp;nbsp;&lt;/span&gt;가 default 인 채여서 Helm sync 가 그걸 덮어쓴 것.&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;128&quot; data-ke-size=&quot;size16&quot;&gt;해결: realm.json 의&lt;span&gt;&amp;nbsp;&lt;/span&gt;browserSecurityHeaders&lt;span&gt;&amp;nbsp;&lt;/span&gt;를 함께 수정.&lt;/p&gt;
&lt;pre class=&quot;json&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;{
  &quot;realm&quot;: &quot;myrealm&quot;,
  &quot;...&quot;: &quot;...&quot;,
  &quot;browserSecurityHeaders&quot;: {
    &quot;contentSecurityPolicy&quot;: &quot;frame-src 'self'; frame-ancestors 'self' https://portal.example.com; object-src 'none';&quot;,
    &quot;contentSecurityPolicyReportOnly&quot;: &quot;&quot;,
    &quot;xContentTypeOptions&quot;: &quot;nosniff&quot;,
    &quot;xFrameOptions&quot;: &quot;&quot;,
    &quot;strictTransportSecurity&quot;: &quot;max-age=31536000; includeSubDomains&quot;,
    &quot;xRobotsTag&quot;: &quot;none&quot;,
    &quot;referrerPolicy&quot;: &quot;no-referrer&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;146&quot; data-ke-size=&quot;size16&quot;&gt;Helm values 에 이걸 inject 하거나 realm import 시 적용. 이로써 다음 deploy 에서도 살아남는다.&lt;/p&gt;
&lt;h3 id=&quot;시도-5--검증&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;148&quot; data-ke-size=&quot;size23&quot;&gt;시도 5 &amp;mdash; 검증&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;150&quot; data-ke-size=&quot;size16&quot;&gt;다시 portal 에서 iframe 으로 app 을 열면 빈 화면 대신 Keycloak 로그인 페이지(또는 이미 로그인된 경우 앱) 가 정상 표시.&lt;/p&gt;
&lt;pre class=&quot;nginx&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;# 헤더가 실제로 어떻게 나가는지 확인
curl -sI https://app.example.com/ | grep -iE 'content-security-policy|x-frame-options'
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;157&quot; data-ke-size=&quot;size16&quot;&gt;기대 출력:&lt;/p&gt;
&lt;pre class=&quot;csp&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;content-security-policy: frame-src 'self'; frame-ancestors 'self' https://portal.example.com; object-src 'none';
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;163&quot; data-ke-size=&quot;size16&quot;&gt;X-Frame-Options 헤더가 없거나 비어 있어야 한다 (있으면 모던 브라우저가 CSP 보다 약한 정책으로 차단 가능성).&lt;/p&gt;
&lt;h2 id=&quot;추가-함정--여러-origin-허용&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;165&quot; data-ke-size=&quot;size26&quot;&gt;추가 함정 &amp;mdash; 여러 origin 허용&lt;/h2&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;167&quot; data-ke-size=&quot;size16&quot;&gt;frame-ancestors&lt;span&gt;&amp;nbsp;&lt;/span&gt;는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;공백으로 구분된 source list&lt;/b&gt;. 여러 origin 을 허용하려면:&lt;/p&gt;
&lt;pre class=&quot;awk&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;frame-ancestors 'self' https://portal.example.com https://admin.example.com;
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;173&quot; data-ke-size=&quot;size16&quot;&gt;wildcard 도 가능하지만 보안상 권장 안 함:&lt;/p&gt;
&lt;pre class=&quot;groovy&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;frame-ancestors 'self' https://*.example.com;
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;179&quot; data-ke-size=&quot;size16&quot;&gt;production 에서는 정확한 origin 만 화이트리스트.&lt;/p&gt;
&lt;h2 id=&quot;원인-정리&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;181&quot; data-ke-size=&quot;size26&quot;&gt;원인 정리&lt;/h2&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;183&quot; data-ke-size=&quot;size16&quot;&gt;세 가지가 겹쳤다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #202020; text-align: start;&quot; data-line=&quot;185&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-line=&quot;185&quot;&gt;Keycloak default CSP 가 자기 자신만 iframe 임베드 허용 (frame-ancestors 'self').&lt;/li&gt;
&lt;li data-line=&quot;186&quot;&gt;Admin UI 의 paste 변경이 dirty 감지를 놓쳐서 Save 가 silently fail (Save 버튼 grayed out 이 단서).&lt;/li&gt;
&lt;li data-line=&quot;187&quot;&gt;우리 환경의 GitOps 가 realm.json 을 덮어써서, UI 변경이 다음 deploy 에 사라짐.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;해결--최종-절차&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;189&quot; data-ke-size=&quot;size26&quot;&gt;해결 &amp;mdash; 최종 절차&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #202020; text-align: start;&quot; data-line=&quot;191&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-line=&quot;191&quot;&gt;Realm settings &amp;rarr; Security Defenses &amp;rarr; Headers 에서 CSP / X-Frame-Options 변경.&lt;/li&gt;
&lt;li data-line=&quot;192&quot;&gt;&lt;b&gt;paste 후 스페이스+백스페이스 트릭&lt;/b&gt;으로 Save 버튼 강제 활성화.&lt;/li&gt;
&lt;li data-line=&quot;193&quot;&gt;Save 후 Network 탭에서 PUT 요청이 실제로 나갔는지 확인.&lt;/li&gt;
&lt;li data-line=&quot;194&quot;&gt;realm.json 의&lt;span&gt;&amp;nbsp;&lt;/span&gt;browserSecurityHeaders&lt;span&gt;&amp;nbsp;&lt;/span&gt;도 함께 수정하여 GitOps deploy 에서 살아남게 처리.&lt;/li&gt;
&lt;li data-line=&quot;195&quot;&gt;curl -I&lt;span&gt;&amp;nbsp;&lt;/span&gt;로 헤더가 실제로 어떻게 나가는지 검증.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;디버깅-체크리스트-같은-문제-다시-만났을-때&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;197&quot; data-ke-size=&quot;size26&quot;&gt;디버깅 체크리스트 (같은 문제 다시 만났을 때)&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #202020; text-align: start;&quot; data-line=&quot;199&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-line=&quot;199&quot;&gt;브라우저 콘솔에&lt;span&gt;&amp;nbsp;&lt;/span&gt;Refused to display ... frame-ancestors&lt;span&gt;&amp;nbsp;&lt;/span&gt;가 보이면 100% CSP 이슈. (X-Frame-Options 였다면 메시지 문구가 다르다)&lt;/li&gt;
&lt;li data-line=&quot;200&quot;&gt;Admin UI 에서 변경 후 Save 가 안 먹히는 느낌이면&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;DevTools Network 탭에서 PUT 이 나갔는지 확인&lt;/b&gt;. 1초 안에 판단 가능.&lt;/li&gt;
&lt;li data-line=&quot;201&quot;&gt;curl -I&lt;span&gt;&amp;nbsp;&lt;/span&gt;로 실제 응답 헤더 확인. 브라우저는 캐시&amp;middot;extension 때문에 noise 가 있다.&lt;/li&gt;
&lt;li data-line=&quot;202&quot;&gt;모던 브라우저는 X-Frame-Options 의 ALLOW-FROM 무시. CSP 의 frame-ancestors 에 의존하자.&lt;/li&gt;
&lt;li data-line=&quot;203&quot;&gt;GitOps 환경이면 UI 변경 이전에 source-of-truth 파일부터 수정.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;배운-점&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;205&quot; data-ke-size=&quot;size26&quot;&gt;배운 점&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;207&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;207&quot;&gt;&lt;b&gt;CSP 수정이 안 먹히면 진짜 안 먹힌 건지 Admin UI 가 안 보낸 건지부터 분리하자.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;DevTools Network 탭이 1초만에 판단해준다.&lt;/li&gt;
&lt;li data-line=&quot;208&quot;&gt;&lt;b&gt;paste-only 변경은 SPA 의 change detection 을 놓치는 경우가 많다.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;Keycloak 뿐 아니라 다른 운영 콘솔에서도 빈번. 스페이스+백스페이스 트릭은 반사적으로 쓸 수 있게 외워두는 게 좋다.&lt;/li&gt;
&lt;li data-line=&quot;209&quot;&gt;&lt;b&gt;iframe 임베드 환경은 frame-ancestors / X-Frame-Options 둘 다 정리.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;frame-ancestors 가 신표준이고 우선순위. X-Frame-Options 는 비워두거나 일치시킨다. ALLOW-FROM 은 신뢰 안 된다.&lt;/li&gt;
&lt;li data-line=&quot;210&quot;&gt;&lt;b&gt;운영에서는 Admin UI 보다 realm.json / Helm values 를 single source of truth 로.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;UI 변경은 다음 deploy 에서 날아갈 가능성 상존. GitOps 환경이면 더더욱.&lt;/li&gt;
&lt;li data-line=&quot;211&quot;&gt;&lt;b&gt;frame-ancestors 와 frame-src 의 방향성을 헷갈리지 말 것.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;임베드 당하는 쪽이 통제하는 게 frame-ancestors. 임베드 하는 쪽이 통제하는 게 frame-src. 둘 다 CSP 이지만 역할이 정반대.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;다음-단계&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;213&quot; data-ke-size=&quot;size26&quot;&gt;다음 단계&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;215&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;215&quot;&gt;iframe 안에서 Keycloak 로그인 후 토큰 전파가 정상인지 (cross-origin cookie / SameSite 정책 확인).&lt;/li&gt;
&lt;li data-line=&quot;216&quot;&gt;CSP 의&lt;span&gt;&amp;nbsp;&lt;/span&gt;frame-src&lt;span&gt;&amp;nbsp;&lt;/span&gt;가 shell 쪽에도 정확히 설정되어 있는지 점검.&lt;/li&gt;
&lt;li data-line=&quot;217&quot;&gt;Content-Security-Policy-Report-Only&lt;span&gt;&amp;nbsp;&lt;/span&gt;로 staging 에서 미리 위반 사례를 수집해두면 production 사고가 줄어든다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;참고&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;219&quot; data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;221&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;221&quot;&gt;MDN frame-ancestors:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/frame-ancestors&quot;&gt;https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/frame-ancestors&lt;/a&gt;&lt;/li&gt;
&lt;li data-line=&quot;222&quot;&gt;MDN X-Frame-Options:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options&quot;&gt;https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options&lt;/a&gt;&lt;/li&gt;
&lt;li data-line=&quot;223&quot;&gt;Keycloak Server Admin Guide &amp;ndash; Threat Model Mitigation:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://www.keycloak.org/docs/latest/server_admin/&quot;&gt;https://www.keycloak.org/docs/latest/server_admin/&lt;/a&gt;&lt;/li&gt;
&lt;li data-line=&quot;224&quot;&gt;Keycloak browserSecurityHeaders 옵션 목록:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://www.keycloak.org/server/all-config&quot;&gt;https://www.keycloak.org/server/all-config&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <author>임마누엘한</author>
      <guid isPermaLink="true">https://smhanlab.tistory.com/9</guid>
      <comments>https://smhanlab.tistory.com/9#entry9comment</comments>
      <pubDate>Thu, 14 May 2026 15:43:37 +0900</pubDate>
    </item>
    <item>
      <title>agentgateway 에 Langflow MCP 등록하기 &amp;ndash; route not found, accept header, 도구 21개 헤맨 회고</title>
      <link>https://smhanlab.tistory.com/8</link>
      <description>&lt;h2 id=&quot;들어가며&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;6&quot; data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;LangFlow 에서 만든 MCP (Model Context Protocol) 서버를&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;agentgateway&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;라는 MCP 게이트웨이를 통해 노출하고, 그 게이트웨이를 다른 LLM 클라이언트(다른 LangFlow flow, 사내 챗 포털 등)에서 backend 로 등록해서 쓰려고 했다. 그림은 단순하다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;LLM Client ── MCP ──&amp;gt; agentgateway ── MCP ──&amp;gt; LangFlow MCP server (도구 21개)
                                   └────────&amp;gt; 다른 MCP 서버 (mcpbin 등)
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;15&quot; data-ke-size=&quot;size16&quot;&gt;실제 작업은 단순하지 않았다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;route not found,&lt;span&gt;&amp;nbsp;&lt;/span&gt;client must accept both application/json and text/event-stream,&lt;span&gt;&amp;nbsp;&lt;/span&gt;Tool requires task augmentation, 그리고 도구 카운트는 21이지만 개별 도구가 안 보이는 현상까지, 한 세션 안에서 줄줄이 만났다. 다른 분들이 같은 함정에 빠지지 않도록 디테일하게 정리한다.&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;핵심 키워드: agentgateway, MCP server, mcp-proxy, streamableHTTP, MCP Inspector, route not found.&lt;/p&gt;
&lt;h2 id=&quot;시스템-컨텍스트&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;19&quot; data-ke-size=&quot;size26&quot;&gt;시스템 컨텍스트&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;21&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;21&quot;&gt;&lt;b&gt;MCP&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;= Anthropic 이 주도하는 LLM &amp;harr; tool 간 표준 프로토콜. JSON-RPC 위에서 도구 목록&amp;middot;도구 호출&amp;middot;리소스 가져오기 등을 정의.&lt;/li&gt;
&lt;li data-line=&quot;22&quot;&gt;&lt;b&gt;agentgateway&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;= MCP 트래픽 전용 게이트웨이/프록시. 여러 MCP backend 를 하나의 endpoint 아래로 묶고, path-prefix 라우팅&amp;middot;인증&amp;middot;rate limit 등을 적용.&lt;/li&gt;
&lt;li data-line=&quot;23&quot;&gt;&lt;b&gt;LangFlow&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;= flow 단위로 묶인 도구를&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;자체적으로 MCP server 처럼 노출&lt;/b&gt;할 수 있다. project UUID 단위로 endpoint 가 갈린다.&lt;/li&gt;
&lt;li data-line=&quot;24&quot;&gt;목표: agentgateway 의 한 path 에 LangFlow MCP 를 backend 로 등록 &amp;rarr; 외부에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://gateway.example.com/agentgateway/mcp&quot;&gt;https://gateway.example.com/agentgateway/mcp&lt;/a&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;로 접근 &amp;rarr; LangFlow 의 도구들이 노출되어 호출 가능.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;문제-상황&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;26&quot; data-ke-size=&quot;size26&quot;&gt;문제 상황&lt;/h2&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;28&quot; data-ke-size=&quot;size16&quot;&gt;작업 중 마주친 주요 증상 다섯 가지.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #202020; text-align: start;&quot; data-line=&quot;30&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-line=&quot;30&quot;&gt;&lt;a href=&quot;https://gateway.example.com/agentgateway/mcp&quot;&gt;https://gateway.example.com/agentgateway/mcp&lt;/a&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;호출 시&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;route not found&lt;/b&gt;.&lt;/li&gt;
&lt;li data-line=&quot;31&quot;&gt;도메인은 도는데&lt;span&gt;&amp;nbsp;&lt;/span&gt;mcp: client must accept both application/json and text/event-stream.&lt;/li&gt;
&lt;li data-line=&quot;32&quot;&gt;backend 추가 후 게이트웨이 UI 에 &quot;도구 21개&quot; 라고 뜨는데&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;개별 도구가 LangFlow 의 MCP Tools 리스트에 안 보임&lt;/b&gt;.&lt;/li&gt;
&lt;li data-line=&quot;33&quot;&gt;agent 에서 도구 호출 시&lt;span&gt;&amp;nbsp;&lt;/span&gt;MCP error -32601: Tool simulate-research-query requires task augmentation (taskSupport: 'required').&lt;/li&gt;
&lt;li data-line=&quot;34&quot;&gt;같은 도구를 한 번 호출했는데 동일 응답이&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;두 번씩 출력&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;되는 현상.&lt;/li&gt;
&lt;/ol&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;36&quot; data-ke-size=&quot;size16&quot;&gt;각각의 원인이 다 다르다. 하나씩 좁혀가야 한다.&lt;/p&gt;
&lt;h2 id=&quot;시도한-것들&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;38&quot; data-ke-size=&quot;size26&quot;&gt;시도한 것들&lt;/h2&gt;
&lt;h3 id=&quot;시도-1--path-잘못-잡음-route-not-found&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;40&quot; data-ke-size=&quot;size23&quot;&gt;시도 1 &amp;mdash; Path 잘못 잡음 (route not found)&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;42&quot; data-ke-size=&quot;size16&quot;&gt;처음에 외부 URL 을&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://gateway.example.com/agentgateway/mcp&quot;&gt;https://gateway.example.com/agentgateway/mcp&lt;/a&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;로 쳤는데&lt;span&gt;&amp;nbsp;&lt;/span&gt;route not found&lt;span&gt;&amp;nbsp;&lt;/span&gt;가 떴다. 처음엔 게이트웨이가 안 떠 있는 줄 알았지만&lt;span&gt;&amp;nbsp;&lt;/span&gt;/agentgateway/ui/&lt;span&gt;&amp;nbsp;&lt;/span&gt;는 멀쩡히 응답. listener 는 떠 있는데 그 path 에 route 가 안 잡혀 있는 상황.&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;44&quot; data-ke-size=&quot;size16&quot;&gt;agentgateway 의 URL 모델을 잠시 정리:&lt;/p&gt;
&lt;pre class=&quot;groovy&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;[ ingress ] ── /agentgateway/* ──&amp;gt; [ gateway listener :3000 ]
                                          │
                                          ├── route prefix /agentgateway/mcp ──&amp;gt; backend A
                                          ├── route prefix /agentgateway/ui  ──&amp;gt; built-in UI
                                          └── (그 외 path)                    ──&amp;gt; 404 또는 root
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;54&quot; data-ke-size=&quot;size16&quot;&gt;즉 path 가 3중 매핑이다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #202020; text-align: start;&quot; data-line=&quot;56&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-line=&quot;56&quot;&gt;&lt;b&gt;외부 ingress&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;가&lt;span&gt;&amp;nbsp;&lt;/span&gt;/agentgateway/*&lt;span&gt;&amp;nbsp;&lt;/span&gt;를 게이트웨이 서비스로 흘림 (ingress controller 설정).&lt;/li&gt;
&lt;li data-line=&quot;57&quot;&gt;&lt;b&gt;gateway listener&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;가 :3000 에서 받는다.&lt;/li&gt;
&lt;li data-line=&quot;58&quot;&gt;&lt;b&gt;route&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;가 path prefix 로 backend 를 매칭한다 (/agentgateway/mcp&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr; backend A).&lt;/li&gt;
&lt;/ol&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;60&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 route 가 아예 등록 안 되어 있었다. UI 에서 backend 만 추가하고 route 를 안 연결한 상태. 해결: UI 의 Routes 탭에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;pathPrefix: /agentgateway/mcp&lt;span&gt;&amp;nbsp;&lt;/span&gt;의 route 를 명시적으로 추가하고 backend 와 wire.&lt;/p&gt;
&lt;h3 id=&quot;시도-2--accept-헤더-누락-브라우저-get-의-함정&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;62&quot; data-ke-size=&quot;size23&quot;&gt;시도 2 &amp;mdash; Accept 헤더 누락 (브라우저 GET 의 함정)&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;64&quot; data-ke-size=&quot;size16&quot;&gt;route 를 잡고 나니 다른 에러로 갈아탔다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;mcp: client must accept both application/json and text/event-stream
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;70&quot; data-ke-size=&quot;size16&quot;&gt;이 메시지가 뜨는 건 사실&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;정상 동작&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;이다. MCP streamable HTTP transport 는 두 콘텐츠 타입을&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;동시에&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;요구한다. 브라우저로 그냥 GET 하면&lt;span&gt;&amp;nbsp;&lt;/span&gt;Accept: text/html,...&lt;span&gt;&amp;nbsp;&lt;/span&gt;가 가서 거절된다.&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;72&quot; data-ke-size=&quot;size16&quot;&gt;검증은&lt;span&gt;&amp;nbsp;&lt;/span&gt;curl&lt;span&gt;&amp;nbsp;&lt;/span&gt;로:&lt;/p&gt;
&lt;pre class=&quot;scilab&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;curl -X POST https://gateway.example.com/agentgateway/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{&quot;jsonrpc&quot;:&quot;2.0&quot;,&quot;id&quot;:1,&quot;method&quot;:&quot;tools/list&quot;}'
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;81&quot; data-ke-size=&quot;size16&quot;&gt;이 요청에서 도구 리스트가 떨어지면 게이트웨이는 정상.&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;83&quot; data-ke-size=&quot;size16&quot;&gt;이 단계에서 &quot;왜 안되지&quot; 라고 브라우저로 새로고침만 반복하다가 30분을 날린 적이 있다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;MCP endpoint 디버깅의 1번 도구는 항상 curl + Accept 헤더.&lt;/b&gt;&lt;/p&gt;
&lt;h3 id=&quot;시도-3--langflow-mcp-endpoint-의-정확한-모양&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;85&quot; data-ke-size=&quot;size23&quot;&gt;시도 3 &amp;mdash; LangFlow MCP endpoint 의 정확한 모양&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;87&quot; data-ke-size=&quot;size16&quot;&gt;LangFlow 는 project 단위로 MCP endpoint 를 자동 생성한다.&lt;/p&gt;
&lt;pre class=&quot;awk&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;http://&amp;lt;langflow-host&amp;gt;:7860/api/v1/mcp/project/&amp;lt;PROJECT_UUID&amp;gt;/streamable
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;93&quot; data-ke-size=&quot;size16&quot;&gt;agentgateway 의 backend target 에 이걸 그대로 넣어야 도구가 노출된다. 작업 중 두 개의 프로젝트 UUID 가 섞여서 한참 디버깅 했다.&lt;/p&gt;
&lt;pre class=&quot;llvm&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;project-A 의 UUID: 0e976add-b22c-...    &amp;larr; 이 프로젝트에 도구가 가득
project-B 의 UUID: c8428d40-a404-...    &amp;larr; 빈 프로젝트
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;100&quot; data-ke-size=&quot;size16&quot;&gt;agentgateway 에 B 의 UUID 를 넣고는 &quot;도구가 0개네?&quot; 라고 한참 헤맸다. project 가 여러 개일 때는 LangFlow UI 의 MCP Servers 탭에서 어떤 UUID 가 어떤 project 에 매핑되는지 먼저 확인할 것.&lt;/p&gt;
&lt;h3 id=&quot;시도-4--도구-카운트는-21인데-개별-도구가-안-보임&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;102&quot; data-ke-size=&quot;size23&quot;&gt;시도 4 &amp;mdash; 도구 카운트는 21인데 개별 도구가 안 보임&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;104&quot; data-ke-size=&quot;size16&quot;&gt;LangFlow 의 MCP Tools 등록 화면에서 게이트웨이 URL 을 backend 로 추가했을 때, &quot;도구 21개&quot; 라고 뜨지만 개별 도구가 리스트로 안 펼쳐지는 경우. 새로고침하면 사라졌다가 다른 MCP server (예:&lt;span&gt;&amp;nbsp;&lt;/span&gt;langflow-starter_basic_prompting) 를 클릭하면 다시 등장.&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;106&quot; data-ke-size=&quot;size16&quot;&gt;이건 LangFlow MCP Tools 화면의 캐싱 이슈에 가까운 UI 버그. 회피책:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #202020; text-align: start;&quot; data-line=&quot;108&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-line=&quot;108&quot;&gt;새 backend 등록 후 다른 MCP server 를 한 번 클릭했다가 돌아온다.&lt;/li&gt;
&lt;li data-line=&quot;109&quot;&gt;또는 새로고침 두 번 (한 번은 미스, 두 번째에 정상 로딩되는 케이스가 있다).&lt;/li&gt;
&lt;li data-line=&quot;110&quot;&gt;그래도 안 되면 LangFlow 의 backend 관리 페이지에서 해당 entry 를 지웠다 다시 추가.&lt;/li&gt;
&lt;/ol&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;112&quot; data-ke-size=&quot;size16&quot;&gt;근본 해결책은 LangFlow 가 MCP Inspector 처럼 lazy load &amp;rarr; eager load 로 바꿔야 하지만, 일단 운영 회피책으로 충분.&lt;/p&gt;
&lt;h3 id=&quot;시도-5--tasksupport-required-에러&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;114&quot; data-ke-size=&quot;size23&quot;&gt;시도 5 &amp;mdash; taskSupport: required 에러&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;MCP error -32601: Tool simulate-research-query 
  requires task augmentation (taskSupport: 'required')
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;121&quot; data-ke-size=&quot;size16&quot;&gt;이건 MCP server (여기서는 LangFlow) 가 해당 도구를 &quot;task&quot; 컨텍스트 안에서만 호출 가능하다고 선언했는데, 클라이언트가 그 컨텍스트를 안 만들어준 경우. MCP 스펙의 advanced 기능 중 하나로, 보통은 long-running task 를 진행률과 함께 호출할 때 쓴다.&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;123&quot; data-ke-size=&quot;size16&quot;&gt;세 가지 대응:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;125&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;125&quot;&gt;(a) 클라이언트 측에서 task 컨텍스트를 만들어서 호출 (구현 복잡).&lt;/li&gt;
&lt;li data-line=&quot;126&quot;&gt;(b) 게이트웨이의 Authorization 으로 해당 도구만 차단 (다음 날 포스트 주제).&lt;/li&gt;
&lt;li data-line=&quot;127&quot;&gt;(c) MCP server 측에서 해당 도구의&lt;span&gt;&amp;nbsp;&lt;/span&gt;taskSupport&lt;span&gt;&amp;nbsp;&lt;/span&gt;를&lt;span&gt;&amp;nbsp;&lt;/span&gt;optional&lt;span&gt;&amp;nbsp;&lt;/span&gt;로 바꿔 expose.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;129&quot; data-ke-size=&quot;size16&quot;&gt;PoC 에서는 (b) 로 빠르게 차단하고, 운영용은 (c) 로 picking.&lt;/p&gt;
&lt;h3 id=&quot;시도-6--중복-응답-문제&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;131&quot; data-ke-size=&quot;size23&quot;&gt;시도 6 &amp;mdash; 중복 응답 문제&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;133&quot; data-ke-size=&quot;size16&quot;&gt;같은 도구 호출이 두 번씩 응답하는 현상. 디버깅 단계는:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #202020; text-align: start;&quot; data-line=&quot;135&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-line=&quot;135&quot;&gt;게이트웨이 로그를 켜고&lt;span&gt;&amp;nbsp;&lt;/span&gt;tools/call&lt;span&gt;&amp;nbsp;&lt;/span&gt;요청이 몇 번 들어오는지 본다 &amp;rarr; 1번.&lt;/li&gt;
&lt;li data-line=&quot;136&quot;&gt;backend MCP server 로 forward 되는 횟수 &amp;rarr; 1번.&lt;/li&gt;
&lt;li data-line=&quot;137&quot;&gt;그런데 응답이 2개로 클라이언트에 도착.&lt;/li&gt;
&lt;/ol&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;139&quot; data-ke-size=&quot;size16&quot;&gt;원인: LangFlow 의 flow 가&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;자체적으로 tool 호출 결과를 다시 LLM 으로 합성&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;하면서 한 번, MCP envelope 응답으로 한 번 보내는 케이스. flow 안에 &quot;tool 결과 &amp;rarr; LLM agent &amp;rarr; 사용자&quot; 가 있고, 그 LLM agent 가 또 사용자에게 응답하는 구조면 중복으로 보인다.&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;141&quot; data-ke-size=&quot;size16&quot;&gt;해결: flow 에서 합성 노드를 빼거나, agentgateway 에서 backend 응답을 그대로 passthrough 하도록 정리. 게이트웨이는 passthrough 가 default 여야 깔끔하다.&lt;/p&gt;
&lt;h2 id=&quot;해결--최종-동작-구성&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;143&quot; data-ke-size=&quot;size26&quot;&gt;해결 &amp;mdash; 최종 동작 구성&lt;/h2&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;# agentgateway 라우팅 의사 코드
listener:
  port: 3000
  routes:
    - name: langflow-mcp
      pathPrefix: /agentgateway/mcp
      backend:
        type: mcp
        upstream: http://langflow:7860/api/v1/mcp/project/0e976add-b22c-.../streamable
        transport: streamable-http
    - name: ui
      pathPrefix: /agentgateway/ui
      backend: builtin-ui
    - name: fallback
      pathPrefix: /
      response: 404
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;164&quot; data-ke-size=&quot;size16&quot;&gt;외부 ingress 가&lt;span&gt;&amp;nbsp;&lt;/span&gt;/agentgateway/*&lt;span&gt;&amp;nbsp;&lt;/span&gt;를 게이트웨이로 흘리도록 설정 후, 클라이언트는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://gateway.example.com/agentgateway/mcp&quot;&gt;https://gateway.example.com/agentgateway/mcp&lt;/a&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;를 MCP server URL 로 등록.&lt;/p&gt;
&lt;h2 id=&quot;검증-절차&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;166&quot; data-ke-size=&quot;size26&quot;&gt;검증 절차&lt;/h2&gt;
&lt;pre class=&quot;elixir&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;# 1. tools/list 가 떨어지는지
curl -sS -X POST https://gateway.example.com/agentgateway/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{&quot;jsonrpc&quot;:&quot;2.0&quot;,&quot;id&quot;:1,&quot;method&quot;:&quot;tools/list&quot;}' | jq '.result.tools | length'
# 21 이 나오면 OK

# 2. 특정 도구 호출
curl -sS -X POST https://gateway.example.com/agentgateway/mcp \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{
    &quot;jsonrpc&quot;:&quot;2.0&quot;,&quot;id&quot;:2,&quot;method&quot;:&quot;tools/call&quot;,
    &quot;params&quot;:{&quot;name&quot;:&quot;search&quot;,&quot;arguments&quot;:{&quot;query&quot;:&quot;hello&quot;}}
  }' | jq '.result'

# 3. LangFlow 측 MCP Tools 화면에서 도구 21개가 펼쳐서 보이는지
# 4. agent flow 에서 실제 도구 호출 &amp;rarr; 응답 단일 (중복 없음) 확인
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;디버깅-체크리스트-같은-문제-다시-만났을-때&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;189&quot; data-ke-size=&quot;size26&quot;&gt;디버깅 체크리스트 (같은 문제 다시 만났을 때)&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #202020; text-align: start;&quot; data-line=&quot;191&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-line=&quot;191&quot;&gt;curl + Accept&lt;span&gt;&amp;nbsp;&lt;/span&gt;로 게이트웨이 자체를 먼저 검증. 브라우저 GET 으로 판단 금지.&lt;/li&gt;
&lt;li data-line=&quot;192&quot;&gt;route not found&lt;span&gt;&amp;nbsp;&lt;/span&gt;가 떠도 ingress 가 도는지 / listener 가 떠 있는지 / route 가 등록 됐는지를 분리해서 확인.&lt;/li&gt;
&lt;li data-line=&quot;193&quot;&gt;LangFlow MCP URL 은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;project UUID 단위&lt;/b&gt;. project 가 여러 개면 어떤 UUID 인지부터 확인.&lt;/li&gt;
&lt;li data-line=&quot;194&quot;&gt;&quot;도구 N개&quot; 라고만 뜨고 개별이 안 보이면 UI 캐싱 의심. 다른 server 클릭했다 돌아오거나 새로고침 2회.&lt;/li&gt;
&lt;li data-line=&quot;195&quot;&gt;중복 응답이 보이면 flow 안에 합성 노드가 있는지 확인. 게이트웨이는 passthrough 가 default.&lt;/li&gt;
&lt;li data-line=&quot;196&quot;&gt;taskSupport: required&lt;span&gt;&amp;nbsp;&lt;/span&gt;는 클라이언트가 task 컨텍스트 안 만든 경우. 운영용은 server 측&lt;span&gt;&amp;nbsp;&lt;/span&gt;optional&lt;span&gt;&amp;nbsp;&lt;/span&gt;또는 게이트웨이 deny.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;배운-점&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;198&quot; data-ke-size=&quot;size26&quot;&gt;배운 점&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;200&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;200&quot;&gt;&lt;b&gt;MCP streamable HTTP 디버깅의 1번 도구는&lt;span&gt;&amp;nbsp;&lt;/span&gt;curl + Accept 헤더&lt;/b&gt;. 브라우저 GET 은 항상 client-error 처럼 보여서 헷갈린다.&lt;/li&gt;
&lt;li data-line=&quot;201&quot;&gt;&lt;b&gt;agentgateway 의 내부/외부 URL 을 분리해서 표로 그려두자.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;ingress path / listener port / route prefix / backend URL 네 개를 한 문서에 정리해두면 디버깅 시간이 절반으로 줄어든다.&lt;/li&gt;
&lt;li data-line=&quot;202&quot;&gt;&lt;b&gt;LangFlow MCP endpoint 는 project UUID 단위.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;도구가 0개면 UUID 부터 의심.&lt;/li&gt;
&lt;li data-line=&quot;203&quot;&gt;&lt;b&gt;게이트웨이는 passthrough 가 default 여야 깔끔하다.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;중복 응답이 보이면 backend 측이 자체 합성을 하는지부터 의심.&lt;/li&gt;
&lt;li data-line=&quot;204&quot;&gt;&lt;b&gt;taskSupport: required&lt;span&gt;&amp;nbsp;&lt;/span&gt;같은 advanced 기능은 클라이언트 구현이 따라줘야 한다.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;PoC 단계에서 막히면 일단 deny 로 차단하고, 운영에서 spec 매칭.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;다음-단계&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;206&quot; data-ke-size=&quot;size26&quot;&gt;다음 단계&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;208&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;208&quot;&gt;&lt;b&gt;MCP Authorization 으로 특정 도구만 차단&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;(다음 날 포스트 주제).&lt;span&gt;&amp;nbsp;&lt;/span&gt;taskSupport: required&lt;span&gt;&amp;nbsp;&lt;/span&gt;같은 운영 부적합 도구 일괄 deny.&lt;/li&gt;
&lt;li data-line=&quot;209&quot;&gt;&lt;b&gt;여러 MCP backend 를 path-prefix 로 멀티플렉싱&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;해서 tenant 별로 갈라 노출.&lt;/li&gt;
&lt;li data-line=&quot;210&quot;&gt;게이트웨이의 traffic 모니터링 (어떤 도구가 가장 많이 호출되는지) 셋업.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;참고&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;212&quot; data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;214&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;214&quot;&gt;MCP spec - streamable HTTP transport:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://spec.modelcontextprotocol.io/specification/&quot;&gt;https://spec.modelcontextprotocol.io/specification/&lt;/a&gt;&lt;/li&gt;
&lt;li data-line=&quot;215&quot;&gt;agentgateway 프로젝트:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://github.com/agentgateway/agentgateway&quot;&gt;https://github.com/agentgateway/agentgateway&lt;/a&gt;&lt;/li&gt;
&lt;li data-line=&quot;216&quot;&gt;LangFlow MCP integration 문서:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://docs.langflow.org/mcp-server&quot;&gt;https://docs.langflow.org/mcp-server&lt;/a&gt;&lt;/li&gt;
&lt;li data-line=&quot;217&quot;&gt;MCP Inspector (디버깅 도구):&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://github.com/modelcontextprotocol/inspector&quot;&gt;https://github.com/modelcontextprotocol/inspector&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <author>임마누엘한</author>
      <guid isPermaLink="true">https://smhanlab.tistory.com/8</guid>
      <comments>https://smhanlab.tistory.com/8#entry8comment</comments>
      <pubDate>Thu, 14 May 2026 15:43:18 +0900</pubDate>
    </item>
    <item>
      <title>Langflow + Neo4j Community Edition 으로 폐쇄망 GraphRAG 만들기 (Aura에서 Docker 자체호스팅 전환 후기)</title>
      <link>https://smhanlab.tistory.com/7</link>
      <description>&lt;h2 id=&quot;들어가며&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;6&quot; data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;전날 만든 Langflow + 한국어 임베딩 위에, 이번에는 Neo4j 그래프 DB 를 붙여서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;GraphRAG (Graph Retrieval-Augmented Generation)&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;데모를 만들기로 했다. GraphRAG 의 핵심은 &quot;벡터 유사도만으로는 못 잡는 관계 정보 (entity-relationship) 를 그래프 쿼리로 보강&quot; 하는 것. 예를 들어 &quot;이 고객이 산 상품의 다른 구매자는 누구?&quot; 같은 질문은 벡터 검색만으로는 막막하지만 Cypher 한 줄이면 된다.&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 빠르게 시작하기 위해&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Neo4j Aura (managed cloud)&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;로 PoC 를 만들었는데, 운영 요구사항이 &quot;폐쇄망 환경&quot; 으로 바뀌면서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Community Edition 을 Docker 로 자체호스팅&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;하는 쪽으로 갈아탔다. 그 전환 과정에서 겪은 함정과, 자연어&amp;rarr;Cypher 매핑의 한계, Langflow 사이드바에 커스텀 컴포넌트 영구 등록하는 방법까지 한 번에 정리한다.&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;핵심 키워드: Neo4j Community Edition, Docker, GraphRAG, Langflow custom tool, Cypher, APOC, LANGFLOW_COMPONENTS_PATH.&lt;/p&gt;
&lt;h2 id=&quot;시스템-컨텍스트&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;14&quot; data-ke-size=&quot;size26&quot;&gt;시스템 컨텍스트&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;16&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;16&quot;&gt;데이터 모델: 고객(Customer) &amp;rarr; 주문(Order) &amp;rarr; 상품(Product). 관계는&lt;span&gt;&amp;nbsp;&lt;/span&gt;PURCHASED,&lt;span&gt;&amp;nbsp;&lt;/span&gt;CONTAINS.&lt;/li&gt;
&lt;li data-line=&quot;17&quot;&gt;데모 시나리오: 비즈니스 사용자가 자연어로 질문 &amp;rarr; Langflow agent &amp;rarr; tool 선택 &amp;rarr; Neo4j 조회 &amp;rarr; 답변 합성.&lt;/li&gt;
&lt;li data-line=&quot;18&quot;&gt;운영 환경: 처음엔 macOS 로컬, 최종 목표는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;on-prem 폐쇄망&lt;/b&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;armasm&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;사용자 ─NL─&amp;gt; Langflow agent ─tool 선택─&amp;gt; Neo4j Cypher 도구 ─bolt─&amp;gt; Neo4j
                              └─────tool 선택─&amp;gt; 벡터 검색 도구 ─────&amp;gt; Chroma
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;문제-상황&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;25&quot; data-ke-size=&quot;size26&quot;&gt;문제 상황&lt;/h2&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;첫 두 질문으로 데모 가능성을 가늠.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;29&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;29&quot;&gt;Q1: &quot;주문횟수가 5회 이하인 고객을 찾아줘&quot; &amp;rarr; ✅ 잘 동작 (단순 필터)&lt;/li&gt;
&lt;li data-line=&quot;30&quot;&gt;Q2: &quot;가장 주문수가 많은 고객은?&quot; &amp;rarr; ❌ Langflow agent 가 거절:&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot; data-line=&quot;32&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-line=&quot;32&quot; data-ke-size=&quot;size16&quot;&gt;&quot;전체 데이터 대상 랭킹/정렬 쿼리는 현재 도구로 지원하지 않습니다.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;34&quot; data-ke-size=&quot;size16&quot;&gt;또한 운영 요구사항 변경:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;36&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;36&quot;&gt;&quot;이 PoC 는 곧 사내 폐쇄망에 올라가야 한다.&quot;&lt;/li&gt;
&lt;li data-line=&quot;37&quot;&gt;&amp;rarr; Aura (외부 클라우드) 사용 불가능. 자체 호스팅 가능한 옵션이 필요.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;39&quot; data-ke-size=&quot;size16&quot;&gt;세 번째 문제는 의외의 함정:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;41&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;41&quot;&gt;캔버스에 한 번 만든 커스텀 Neo4j tool 이&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;다른 flow 에서는 사이드바에 안 보임.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;시도한-것들&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;43&quot; data-ke-size=&quot;size26&quot;&gt;시도한 것들&lt;/h2&gt;
&lt;h3 id=&quot;시도-1--aura-free-로-빠르게-poc&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;45&quot; data-ke-size=&quot;size23&quot;&gt;시도 1 &amp;mdash; Aura Free 로 빠르게 PoC&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;47&quot; data-ke-size=&quot;size16&quot;&gt;처음엔 Aura Free 에 데이터를 올려서 테스트했다. 가입 &amp;rarr; 인스턴스 생성 &amp;rarr; 데이터 로드까지 10분 안에 가능. 빠른 PoC 에는 좋다. 그러나 두 가지 한계가 빠르게 드러났다.&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;49&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Aura 의 패스워드 정책.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;1c&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;&quot;Aura 비밀번호가 뭐 말하는거?&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;55&quot; data-ke-size=&quot;size16&quot;&gt;라는 의문이 한 번 들었던 게, Aura 는 인스턴스 생성 시 한 번 보여주는 generated password 를 사용자가 캡쳐해두지 않으면 reset 외에는 방법이 없다. 운영 환경에서는 시크릿 관리 정책과 안 맞을 가능성. PoC 단계에서 비밀번호 분실로 인스턴스 reset 한 번 했다.&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;57&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;폐쇄망 요건과 충돌.&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;59&quot; data-ke-size=&quot;size16&quot;&gt;운영 요구사항이 바뀌면서 Aura 위에 만든 모든 PoC 자산을&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;마이그레이션&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;해야 했다. 데이터 dump 후 self-host 로 복원하는 건 가능하지만, 처음부터 self-host 로 갔으면 추가 작업이 없었을 영역. 결국 PoC 단계부터 deployment target 을 명확히 하는 게 시간 절약이라는 교훈.&lt;/p&gt;
&lt;h3 id=&quot;시도-2--community-edition-docker-셋업&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;61&quot; data-ke-size=&quot;size23&quot;&gt;시도 2 &amp;mdash; Community Edition Docker 셋업&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;63&quot; data-ke-size=&quot;size16&quot;&gt;가장 단순한 단일 노드 셋업.&lt;/p&gt;
&lt;pre class=&quot;haml&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;docker run -d \
  --name neo4j-community \
  -p 7474:7474 \
  -p 7687:7687 \
  -e NEO4J_AUTH=neo4j/changeme \
  -e NEO4J_PLUGINS='[&quot;apoc&quot;]' \
  -v $HOME/neo4j/data:/data \
  -v $HOME/neo4j/logs:/logs \
  --restart unless-stopped \
  neo4j:5-community
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;78&quot; data-ke-size=&quot;size16&quot;&gt;각 옵션의 의미:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;80&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;80&quot;&gt;-p 7474:7474&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr; HTTP 콘솔 (브라우저 UI).&lt;/li&gt;
&lt;li data-line=&quot;81&quot;&gt;-p 7687:7687&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr; Bolt 드라이버 포트 (Python&lt;span&gt;&amp;nbsp;&lt;/span&gt;neo4j&lt;span&gt;&amp;nbsp;&lt;/span&gt;driver 가 붙는 곳).&lt;/li&gt;
&lt;li data-line=&quot;82&quot;&gt;NEO4J_AUTH=neo4j/changeme&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr; 초기 비밀번호.&lt;span&gt;&amp;nbsp;&lt;/span&gt;user/password&lt;span&gt;&amp;nbsp;&lt;/span&gt;한 줄 포맷. 실서비스에서는 반드시 강한 패스워드로 변경.&lt;/li&gt;
&lt;li data-line=&quot;83&quot;&gt;NEO4J_PLUGINS='[&quot;apoc&quot;]'&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr; APOC 플러그인 자동 설치. 거의 모든 advanced 쿼리에서 필요.&lt;/li&gt;
&lt;li data-line=&quot;84&quot;&gt;볼륨 두 개 &amp;rarr; 컨테이너 재기동 시 데이터&amp;middot;로그 유지.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;86&quot; data-ke-size=&quot;size16&quot;&gt;검증:&lt;/p&gt;
&lt;pre class=&quot;1c&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;docker logs neo4j-community | tail
# &quot;Started.&quot; 가 보이면 OK
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;93&quot; data-ke-size=&quot;size16&quot;&gt;브라우저에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;http://localhost:7474&lt;span&gt;&amp;nbsp;&lt;/span&gt;접속 &amp;rarr;&lt;span&gt;&amp;nbsp;&lt;/span&gt;neo4j&lt;span&gt;&amp;nbsp;&lt;/span&gt;/&lt;span&gt;&amp;nbsp;&lt;/span&gt;changeme&lt;span&gt;&amp;nbsp;&lt;/span&gt;로그인 &amp;rarr; 초기 비밀번호 변경 강제됨.&lt;/p&gt;
&lt;h3 id=&quot;시도-3--community-edition-의-한계-인지&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;95&quot; data-ke-size=&quot;size23&quot;&gt;시도 3 &amp;mdash; Community Edition 의 한계 인지&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;97&quot; data-ke-size=&quot;size16&quot;&gt;Community 로 갈아탔더니 사용 가능한 기능이 줄어든다. 알고 가야 할 차이:&lt;/p&gt;
&lt;p&gt;기능CommunityEnterprise / Aura Pro&lt;/p&gt;
&lt;table style=&quot;color: #202020; text-align: start; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-line=&quot;99&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody data-line=&quot;101&quot;&gt;
&lt;tr data-line=&quot;101&quot;&gt;
&lt;td&gt;다중 사용자 (RBAC)&lt;/td&gt;
&lt;td&gt;❌ 단일 사용자&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;102&quot;&gt;
&lt;td&gt;Multi-database&lt;/td&gt;
&lt;td&gt;❌ 1개 DB&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;103&quot;&gt;
&lt;td&gt;Role-based access&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;104&quot;&gt;
&lt;td&gt;Causal Clustering (HA)&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;105&quot;&gt;
&lt;td&gt;Hot backup&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;106&quot;&gt;
&lt;td&gt;Property-level security&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;108&quot; data-ke-size=&quot;size16&quot;&gt;폐쇄망 운영에서 multi-tenant RBAC 가 필요해진 시점에 우리는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;애플리케이션 레벨 RBAC 컴포넌트&lt;/b&gt;를 별도로 만들어서 우회했다. Cypher 쿼리에 자동으로&lt;span&gt;&amp;nbsp;&lt;/span&gt;WHERE tenant_id = $tenant&lt;span&gt;&amp;nbsp;&lt;/span&gt;같은 절을 강제 주입하는 작은 wrapper. Enterprise 가 깔끔하지만 라이선스 비용 대비 PoC 단계에서는 과한 선택이었다.&lt;/p&gt;
&lt;h3 id=&quot;시도-4--자연어--cypher-의-한계&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;110&quot; data-ke-size=&quot;size23&quot;&gt;시도 4 &amp;mdash; 자연어 &amp;rarr; Cypher 의 한계&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;112&quot; data-ke-size=&quot;size16&quot;&gt;Langflow 의 default tool 만으로는 ranking, aggregation 류 쿼리가 약했다. 예를 들어 &quot;가장 주문 많은 고객&quot; 같은 질문은 다음 Cypher 로 끝나는데:&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;MATCH (c:Customer)-[:PURCHASED]-&amp;gt;(o:Order)
RETURN c.name AS name, count(o) AS orders
ORDER BY orders DESC
LIMIT 5
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;121&quot; data-ke-size=&quot;size16&quot;&gt;agent 가 &quot;ranking 쿼리는 지원 안 됨&quot; 으로 거절한다. 이유: agent 가 가진 tool 의 description 이 &quot;특정 키워드 기반 검색과 관계 탐색만 지원&quot; 이라고 적혀 있어서, ranking 의도가 매칭이 안 됐다.&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;123&quot; data-ke-size=&quot;size16&quot;&gt;해결:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;시나리오별 custom tool 을 명시적으로 추가.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;자연어 의도 &amp;rarr; tool 매핑이 정확해진다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;from langflow.custom import Component
from langflow.io import StrInput, IntInput, Output
from neo4j import GraphDatabase

class Neo4jTopCustomers(Component):
    display_name = &quot;Top Customers by Orders&quot;
    description = &quot;주문 수가 많은 상위 N명의 고객을 반환한다. &quot; \
                  &quot;'가장 주문 많은 고객', 'top buyers', '주문 랭킹' 류 질문에 적합.&quot;

    inputs = [
        StrInput(name=&quot;uri&quot;, display_name=&quot;Bolt URI&quot;,
                 value=&quot;bolt://localhost:7687&quot;),
        StrInput(name=&quot;user&quot;, display_name=&quot;User&quot;, value=&quot;neo4j&quot;),
        StrInput(name=&quot;password&quot;, display_name=&quot;Password&quot;, value=&quot;&quot;),
        IntInput(name=&quot;top_n&quot;, display_name=&quot;Top N&quot;, value=5),
    ]

    outputs = [
        Output(display_name=&quot;Result&quot;, name=&quot;result&quot;,
               method=&quot;run&quot;, types=[&quot;Data&quot;])
    ]

    def run(self):
        with GraphDatabase.driver(
            self.uri, auth=(self.user, self.password)
        ) as driver, driver.session() as sess:
            rows = sess.run(
                &quot;&quot;&quot;
                MATCH (c:Customer)-[:PURCHASED]-&amp;gt;(o:Order)
                RETURN c.name AS name, count(o) AS orders
                ORDER BY orders DESC
                LIMIT $n
                &quot;&quot;&quot;,
                n=self.top_n,
            )
            return [r.data() for r in rows]
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;164&quot; data-ke-size=&quot;size16&quot;&gt;핵심은&lt;span&gt;&amp;nbsp;&lt;/span&gt;description&lt;span&gt;&amp;nbsp;&lt;/span&gt;에 자연어 패턴을 충분히 적는 것. agent 가 이 description 을 보고 tool 호출 여부를 결정하므로, &quot;랭킹&quot;, &quot;top&quot;, &quot;가장 많은&quot;, &quot;정렬&quot; 같은 후보 키워드를 description 에 흩뿌려 둔다.&lt;/p&gt;
&lt;h3 id=&quot;시도-5--사이드바에-영구-등록&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;166&quot; data-ke-size=&quot;size23&quot;&gt;시도 5 &amp;mdash; 사이드바에 영구 등록&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;168&quot; data-ke-size=&quot;size16&quot;&gt;캔버스에서 만든 컴포넌트는 그 flow 안에만 존재한다. 다른 flow 에서도 같은 tool 을 쓰려면&lt;span&gt;&amp;nbsp;&lt;/span&gt;LANGFLOW_COMPONENTS_PATH&lt;span&gt;&amp;nbsp;&lt;/span&gt;환경변수로 등록.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;export LANGFLOW_COMPONENTS_PATH=$HOME/langflow-components
mkdir -p $HOME/langflow-components/neo4j_tools
# 그 안에 .py 파일들 저장
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;176&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;디렉토리 구조 규칙:&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;stylus&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;$LANGFLOW_COMPONENTS_PATH/
├── neo4j_tools/                    &amp;larr; 카테고리 폴더
│   ├── neo4j_top_customers.py      &amp;larr; 컴포넌트 파일
│   ├── neo4j_customer_search.py
│   └── neo4j_relationship.py
└── embeddings_korean/
    └── ko_embeddings.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;188&quot; data-ke-size=&quot;size16&quot;&gt;폴더 이름이 사이드바의 카테고리.&lt;span&gt;&amp;nbsp;&lt;/span&gt;__init__.py&lt;span&gt;&amp;nbsp;&lt;/span&gt;는 굳이 필요 없다. 등록 후 Langflow 재시작.&lt;/p&gt;
&lt;h3 id=&quot;시도-6--bolt-연결-확인-langflow-안-거치고&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;190&quot; data-ke-size=&quot;size23&quot;&gt;시도 6 &amp;mdash; Bolt 연결 확인 (Langflow 안 거치고)&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;192&quot; data-ke-size=&quot;size16&quot;&gt;Langflow tool 이 안 도는 게 코드 문제인지 Neo4j 자체 문제인지 분리하기 위해, 항상&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;터미널에서 driver 로 직접 한 번 쳐본다&lt;/b&gt;.&lt;/p&gt;
&lt;pre class=&quot;applescript&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;# verify_neo4j.py
from neo4j import GraphDatabase

with GraphDatabase.driver(&quot;bolt://localhost:7687&quot;,
                           auth=(&quot;neo4j&quot;, &quot;your-password&quot;)) as driver:
    with driver.session() as sess:
        result = sess.run(&quot;MATCH (n) RETURN count(n) AS total&quot;)
        print(result.single()[&quot;total&quot;])
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;205&quot; data-ke-size=&quot;size16&quot;&gt;python verify_neo4j.py&lt;span&gt;&amp;nbsp;&lt;/span&gt;가 도는 순간 DB 자체는 문제 없음 확정. 그러면 그 다음 분리는 Langflow tool code &amp;rarr; agent prompt 순서로 좁힌다.&lt;/p&gt;
&lt;h3 id=&quot;시도-7--aura--self-host-데이터-마이그레이션&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;207&quot; data-ke-size=&quot;size23&quot;&gt;시도 7 &amp;mdash; Aura &amp;rarr; Self-host 데이터 마이그레이션&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;209&quot; data-ke-size=&quot;size16&quot;&gt;Aura 의 admin 페이지에서 dump 파일을 받고, self-host Neo4j 의&lt;span&gt;&amp;nbsp;&lt;/span&gt;import/&lt;span&gt;&amp;nbsp;&lt;/span&gt;디렉토리에 넣고 복원.&lt;/p&gt;
&lt;pre class=&quot;livescript&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;docker exec -it neo4j-community \
  neo4j-admin database load neo4j --from-path=/import --overwrite-destination=true
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;216&quot; data-ke-size=&quot;size16&quot;&gt;복원 후 데이터 개수 검증을 잊지 말 것 (MATCH (n) RETURN count(n)&lt;span&gt;&amp;nbsp;&lt;/span&gt;을 Aura 측&amp;middot;self-host 측 둘 다에서 돌려 비교).&lt;/p&gt;
&lt;h2 id=&quot;해결--최종-구성&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;218&quot; data-ke-size=&quot;size26&quot;&gt;해결 &amp;mdash; 최종 구성&lt;/h2&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;사용자 NL
   │
   ▼
Langflow agent ───┬──&amp;gt; Ko-SRoberta 임베딩 (전날 만든 컴포넌트)
                  │           │
                  │           ▼
                  │       Chroma (벡터 검색)
                  │
                  ├──&amp;gt; Neo4j Customer Search   ─┐
                  ├──&amp;gt; Neo4j Relationship Explore ──&amp;gt; Neo4j 5-community (Docker)
                  └──&amp;gt; Neo4j Top Customers     ─┘
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;234&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;234&quot;&gt;&lt;b&gt;Neo4j Community Edition&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;(Docker) on-prem.&lt;/li&gt;
&lt;li data-line=&quot;235&quot;&gt;&lt;b&gt;Langflow custom tools&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;4종 (검색 / 관계 탐색 / ranking / 한국어 임베딩) 을&lt;span&gt;&amp;nbsp;&lt;/span&gt;LANGFLOW_COMPONENTS_PATH&lt;span&gt;&amp;nbsp;&lt;/span&gt;로 영구 등록.&lt;/li&gt;
&lt;li data-line=&quot;236&quot;&gt;agent prompt 에 &quot;어떤 질문에서 어떤 tool 을 쓰는지&quot; 가이드라인 명시. 자연어 매핑 정확도가 크게 상승.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;238&quot; data-ke-size=&quot;size16&quot;&gt;데모 결과: 처음 거절당했던 &quot;가장 주문 많은 고객&quot; 질문이 잘 응답. 자연어 &amp;rarr; Cypher 매핑이 시나리오별 tool 분리로 안정화.&lt;/p&gt;
&lt;h2 id=&quot;검증-절차&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;240&quot; data-ke-size=&quot;size26&quot;&gt;검증 절차&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #202020; text-align: start;&quot; data-line=&quot;242&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-line=&quot;242&quot;&gt;docker ps&lt;span&gt;&amp;nbsp;&lt;/span&gt;로 neo4j-community 가 떠 있는지.&lt;/li&gt;
&lt;li data-line=&quot;243&quot;&gt;http://localhost:7474&lt;span&gt;&amp;nbsp;&lt;/span&gt;브라우저 콘솔 로그인.&lt;/li&gt;
&lt;li data-line=&quot;244&quot;&gt;python verify_neo4j.py&lt;span&gt;&amp;nbsp;&lt;/span&gt;로 driver 연결 확인.&lt;/li&gt;
&lt;li data-line=&quot;245&quot;&gt;Langflow 캔버스 사이드바 &amp;rarr; &quot;neo4j_tools&quot; 카테고리 4개 보이는지.&lt;/li&gt;
&lt;li data-line=&quot;246&quot;&gt;데모 질문 5개 (단순 검색&amp;middot;관계 탐색&amp;middot;집계&amp;middot;ranking&amp;middot;복합) 차례로 던져서 정상 응답 확인.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;배운-점&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;248&quot; data-ke-size=&quot;size26&quot;&gt;배운 점&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;250&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;250&quot;&gt;&lt;b&gt;Deployment target 을 PoC 단계부터 정확히 잡자.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;Aura &amp;rarr; self-host 마이그레이션은 가능하지만, RBAC 모델 / 패스워드 정책 / 데이터 dump 모두 추가 작업이 붙는다. &quot;PoC 는 Aura, 운영은 self-host&quot; 라는 단순한 결정이 두 번 일하게 만든다.&lt;/li&gt;
&lt;li data-line=&quot;251&quot;&gt;&lt;b&gt;Langflow agent 의 tool description 이 의도 매칭의 거의 전부.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&quot;랭킹&quot;, &quot;top&quot;, &quot;정렬&quot;, &quot;집계&quot;, &quot;최대&quot; 같은 한국어 키워드를 description 에 자연스럽게 흩뿌려 두면 매핑 정확도가 크게 오른다. 영어로만 적으면 한국어 질의에서 매칭이 약해진다.&lt;/li&gt;
&lt;li data-line=&quot;252&quot;&gt;&lt;b&gt;LANGFLOW_COMPONENTS_PATH&lt;span&gt;&amp;nbsp;&lt;/span&gt;는 RAG 데모 반복 시 필수.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;캔버스 내 등록은 휘발성에 가깝다. 폴더 = 카테고리, 파일 = 컴포넌트의 규칙을 처음부터 따르자.&lt;/li&gt;
&lt;li data-line=&quot;253&quot;&gt;&lt;b&gt;Neo4j Community + APOC 조합은 PoC 까지는 거의 모든 시나리오 커버.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;Multi-DB / RBAC / Causal Clustering 이 필요해지는 순간이 Enterprise 라이선스 검토 시점.&lt;/li&gt;
&lt;li data-line=&quot;254&quot;&gt;&lt;b&gt;자연어 &amp;rarr; DB 매핑 디버깅은 항상 driver 로 직접 한 번 쳐본 뒤 시작하자.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;DB 문제 / tool code 문제 / agent prompt 문제를 분리해야 30분 안에 원인을 좁힐 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;다음-단계&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;256&quot; data-ke-size=&quot;size26&quot;&gt;다음 단계&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;258&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;258&quot;&gt;Custom tool 에&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;자동 tenant_id 주입&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;wrapper 적용해서 application-level RBAC 강화.&lt;/li&gt;
&lt;li data-line=&quot;259&quot;&gt;Cypher 쿼리에 LIMIT 자동 강제로 cost 폭발 방지.&lt;/li&gt;
&lt;li data-line=&quot;260&quot;&gt;agent 가 어떤 tool 을 골랐는지 trace 를 별도 로그로 저장 &amp;rarr; 의도 매칭 실패 케이스 회수.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;참고&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;262&quot; data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;264&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;264&quot;&gt;Neo4j Community Edition (Docker 공식 이미지):&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://hub.docker.com/_/neo4j&quot;&gt;https://hub.docker.com/_/neo4j&lt;/a&gt;&lt;/li&gt;
&lt;li data-line=&quot;265&quot;&gt;Langflow custom components:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://docs.langflow.org/components-custom-components&quot;&gt;https://docs.langflow.org/components-custom-components&lt;/a&gt;&lt;/li&gt;
&lt;li data-line=&quot;266&quot;&gt;APOC procedures:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://neo4j.com/labs/apoc/&quot;&gt;https://neo4j.com/labs/apoc/&lt;/a&gt;&lt;/li&gt;
&lt;li data-line=&quot;267&quot;&gt;GraphRAG 개념:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://neo4j.com/blog/graphrag-manifesto/&quot;&gt;https://neo4j.com/blog/graphrag-manifesto/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <author>임마누엘한</author>
      <guid isPermaLink="true">https://smhanlab.tistory.com/7</guid>
      <comments>https://smhanlab.tistory.com/7#entry7comment</comments>
      <pubDate>Thu, 14 May 2026 15:42:52 +0900</pubDate>
    </item>
    <item>
      <title>Langflow에 한국어 임베딩(jhgan/ko-sroberta-multitask) 커스텀 컴포넌트 붙이기 &amp;ndash; venv 헤맨 후기</title>
      <link>https://smhanlab.tistory.com/6</link>
      <description>&lt;h2 id=&quot;들어가며&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;6&quot; data-ke-size=&quot;size26&quot;&gt;들어가며&lt;/h2&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;8&quot; data-ke-size=&quot;size16&quot;&gt;Langflow 로 한국어 RAG (Retrieval-Augmented Generation) 파이프라인을 만드려고 임베딩 모델을 골라야 했다. OpenAI 의&lt;span&gt;&amp;nbsp;&lt;/span&gt;text-embedding-3-small&lt;span&gt;&amp;nbsp;&lt;/span&gt;같은 상용 임베딩은 품질은 좋지만&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;비용&lt;/b&gt;과&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;폐쇄망 운영 불가능&lt;/b&gt;이라는 두 가지 큰 제약이 있다. 그래서 처음부터&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;로컬에서 돌아가는 한국어 임베딩&lt;/b&gt;을 붙이기로 했다.&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;후보를 두 개로 좁혔다.&lt;/p&gt;
&lt;p&gt;모델출처차원한국어 품질운영&lt;/p&gt;
&lt;table style=&quot;color: #202020; text-align: start; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-line=&quot;12&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody data-line=&quot;14&quot;&gt;
&lt;tr data-line=&quot;14&quot;&gt;
&lt;td&gt;jhgan/ko-sroberta-multitask&lt;/td&gt;
&lt;td&gt;HuggingFace&lt;/td&gt;
&lt;td&gt;768&lt;/td&gt;
&lt;td&gt;⭐⭐⭐⭐⭐ (한국어 특화 학습)&lt;/td&gt;
&lt;td&gt;sentence-transformers 로 CPU 추론 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;15&quot;&gt;
&lt;td&gt;nomic-embed-text&lt;/td&gt;
&lt;td&gt;Ollama&lt;/td&gt;
&lt;td&gt;768&lt;/td&gt;
&lt;td&gt;⭐⭐⭐ (다국어, 한국어는 평이)&lt;/td&gt;
&lt;td&gt;Ollama 데몬으로 호스팅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;17&quot; data-ke-size=&quot;size16&quot;&gt;한국어 검색&amp;middot;RAG 품질만 보면&lt;span&gt;&amp;nbsp;&lt;/span&gt;ko-sroberta-multitask&lt;span&gt;&amp;nbsp;&lt;/span&gt;가 압도적이다. SBERT 계열의 한국어 fine-tuned 모델로, KorNLI / KorSTS 등으로 multi-task 학습되어 있다. 그래서 이걸로 가기로 했다.&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;문제는 Langflow 캔버스에서 이 모델을 어떻게 노출시키냐였다. Langflow 에는 &quot;HuggingFace Embeddings&quot; 라는 빌트인 컴포넌트가 있긴 한데,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;저장된 venv 문제&lt;/b&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;type system 호환성&lt;/b&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;그래프 와이어링 검증&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;세 가지가 차례로 발목을 잡았다. 같은 함정에 빠지지 않게 디테일하게 정리한다.&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;21&quot; data-ke-size=&quot;size16&quot;&gt;핵심 키워드: Langflow, HuggingFace Embeddings, ko-sroberta-multitask, custom component, venv, RAG.&lt;/p&gt;
&lt;h2 id=&quot;시스템-컨텍스트&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;23&quot; data-ke-size=&quot;size26&quot;&gt;시스템 컨텍스트&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;25&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;25&quot;&gt;macOS 로컬에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;langflow&lt;span&gt;&amp;nbsp;&lt;/span&gt;설치 (pip 으로 글로벌 설치).&lt;/li&gt;
&lt;li data-line=&quot;26&quot;&gt;Langflow 가 실행되면 별도 venv&lt;span&gt;&amp;nbsp;&lt;/span&gt;~/.langflow/.langflow-venv/&lt;span&gt;&amp;nbsp;&lt;/span&gt;를 생성해서 그 안에서 컴포넌트를 import 한다.&lt;/li&gt;
&lt;li data-line=&quot;27&quot;&gt;캔버스 흐름: 텍스트 입력 &amp;rarr; 한국어 임베딩 컴포넌트 &amp;rarr; Chroma 벡터스토어 &amp;rarr; 검색.&lt;/li&gt;
&lt;li data-line=&quot;28&quot;&gt;같은 흐름에 Neo4j tool 도 끼우려는 계획 (다음 날 포스트에서 다룬다).&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;문제-상황&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;30&quot; data-ke-size=&quot;size26&quot;&gt;문제 상황&lt;/h2&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;32&quot; data-ke-size=&quot;size16&quot;&gt;langchain_huggingface.HuggingFaceEmbeddings&lt;span&gt;&amp;nbsp;&lt;/span&gt;를 Langflow Custom Component 로 감싸서 캔버스에 올렸더니, build 단계에서 다음과 같이 두 가지 에러가 번갈아 떴다.&lt;/p&gt;
&lt;h3 id=&quot;에러-1--type-convert-indexerror&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;34&quot; data-ke-size=&quot;size23&quot;&gt;에러 1 &amp;mdash; Type Convert IndexError&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;Flow build failed
7.7s
Error building Component Type Convert:
list index out of range
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;43&quot; data-ke-size=&quot;size16&quot;&gt;빌드는 시작되는데 &quot;Type Convert&quot; 라는 내부 컴포넌트에서 list index out of range 로 죽었다. 처음엔 내 코드의 버그라고 생각해서 한참 print 디버깅을 했다.&lt;/p&gt;
&lt;h3 id=&quot;에러-2--output-이-임베딩-타입으로-안-잡힘&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;45&quot; data-ke-size=&quot;size23&quot;&gt;에러 2 &amp;mdash; Output 이 임베딩 타입으로 안 잡힘&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;47&quot; data-ke-size=&quot;size16&quot;&gt;위 에러를 해결하고 나니 다른 문제가 보였다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;내 한국어 임베딩 컴포넌트의 output 이 임베딩 타입으로 인식되지 않아서&lt;/b&gt;, 다운스트림인 Chroma 벡터스토어와 연결이 안 됐다. UI 상으로 와이어를 끌어다 놓을 수가 없는 상태. (Langflow 는 type 이 맞는 컴포넌트끼리만 연결 가능)&lt;/p&gt;
&lt;h3 id=&quot;에러-3--neo4j-모듈-import-실패-부수효과&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;49&quot; data-ke-size=&quot;size23&quot;&gt;에러 3 &amp;mdash; Neo4j 모듈 import 실패 (부수효과)&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;51&quot; data-ke-size=&quot;size16&quot;&gt;같은 흐름에 Neo4j tool 을 추가했더니:&lt;/p&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;ModuleNotFoundError: No module named 'neo4j'
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;57&quot; data-ke-size=&quot;size16&quot;&gt;분명히&lt;span&gt;&amp;nbsp;&lt;/span&gt;pip install neo4j&lt;span&gt;&amp;nbsp;&lt;/span&gt;를 했는데 안 잡혔다. 이게 사실 가장 시간 잡아먹은 함정의 단서였다.&lt;/p&gt;
&lt;h2 id=&quot;시도한-것들-실패한-가설-포함&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;59&quot; data-ke-size=&quot;size26&quot;&gt;시도한 것들 (실패한 가설 포함)&lt;/h2&gt;
&lt;h3 id=&quot;시도-1--시스템-파이썬에-패키지-설치&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;61&quot; data-ke-size=&quot;size23&quot;&gt;시도 1 &amp;mdash; 시스템 파이썬에 패키지 설치&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;63&quot; data-ke-size=&quot;size16&quot;&gt;가장 먼저 시도한 건 평소 하듯이 글로벌 pip 으로 설치.&lt;/p&gt;
&lt;pre class=&quot;cmake&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;pip install langchain-huggingface sentence-transformers neo4j
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;69&quot; data-ke-size=&quot;size16&quot;&gt;설치는 성공. 그런데 Langflow 캔버스에서는 여전히&lt;span&gt;&amp;nbsp;&lt;/span&gt;ModuleNotFoundError. &quot;분명히 깔았는데 왜?&quot; 라는 의문. 시스템 파이썬에서는 잘 import 된다:&lt;/p&gt;
&lt;pre class=&quot;vim&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;python -c &quot;import langchain_huggingface; print(langchain_huggingface.__file__)&quot;
# /usr/local/lib/python3.11/site-packages/langchain_huggingface/__init__.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;76&quot; data-ke-size=&quot;size16&quot;&gt;이때 의심해야 할 건 &quot;Langflow 가 같은 파이썬을 쓰고 있는가&quot; 였는데, 그걸 한참 안 의심했다.&lt;/p&gt;
&lt;h3 id=&quot;시도-2--langflow-가-자체-venv-를-쓰는-걸-발견&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;78&quot; data-ke-size=&quot;size23&quot;&gt;시도 2 &amp;mdash; Langflow 가 자체 venv 를 쓰는 걸 발견&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;80&quot; data-ke-size=&quot;size16&quot;&gt;에러 스택 trace 를 자세히 보니 다음과 같이 찍혀 있었다.&lt;/p&gt;
&lt;pre class=&quot;1c&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;File &quot;/Users/dev/.langflow/.langflow-venv/lib/python3.11/site-packages/langflow/...
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;86&quot; data-ke-size=&quot;size16&quot;&gt;~/.langflow/.langflow-venv/&lt;span&gt;&amp;nbsp;&lt;/span&gt;라는 별도 venv 에서 import 가 일어나고 있었다. Langflow 는 컴포넌트 격리를 위해 자체 venv 를 만든다. 시스템 파이썬에 깐 건 그 venv 에서 안 보이는 게 당연한 상황.&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;88&quot; data-ke-size=&quot;size16&quot;&gt;이걸 알고 나니 첫 번째 해결책이 명확해진다.&lt;/p&gt;
&lt;h3 id=&quot;시도-3--type-convert-에러-추적&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;90&quot; data-ke-size=&quot;size23&quot;&gt;시도 3 &amp;mdash; Type Convert 에러 추적&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;92&quot; data-ke-size=&quot;size16&quot;&gt;list index out of range&lt;span&gt;&amp;nbsp;&lt;/span&gt;가 정확히 어디서 났는지 보려고 Langflow 의 debug 로그를 켰다 (LANGFLOW_LOG_LEVEL=DEBUG). 보니까 내 컴포넌트의 build 메서드가 호출되기 전에 &quot;Type Convert&quot; 단계에서 죽고 있었다.&lt;/p&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;94&quot; data-ke-size=&quot;size16&quot;&gt;원인 후보:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;95&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;95&quot;&gt;(a) 내 컴포넌트의 Output type 선언이 잘못됨&lt;/li&gt;
&lt;li data-line=&quot;96&quot;&gt;(b) 입력으로 빈 list/None 이 들어옴&lt;/li&gt;
&lt;li data-line=&quot;97&quot;&gt;(c) 캔버스 와이어링이 실제로 끊겨 있는데 UI 만 연결처럼 보임&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;99&quot; data-ke-size=&quot;size16&quot;&gt;(c) 가 정답이었다. 내가 끌어다 놓은 와이어가 시각적으로는 붙어 보였지만 type 불일치로 실제로는 binding 이 안 되어 있었고, build 시 입력이 비어 있어서 list slice 에서 죽었다.&lt;/p&gt;
&lt;h3 id=&quot;시도-4--output-타입-명시&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;101&quot; data-ke-size=&quot;size23&quot;&gt;시도 4 &amp;mdash; Output 타입 명시&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;103&quot; data-ke-size=&quot;size16&quot;&gt;(c) 의 근본 원인을 추적했더니, 내&lt;span&gt;&amp;nbsp;&lt;/span&gt;Output&lt;span&gt;&amp;nbsp;&lt;/span&gt;에&lt;span&gt;&amp;nbsp;&lt;/span&gt;types&lt;span&gt;&amp;nbsp;&lt;/span&gt;를 명시 안 한 게 문제였다. Langflow custom component 는 다음과 같이 output 의 타입을 명시해야 한다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;outputs = [
    Output(
        display_name=&quot;Embeddings&quot;,
        name=&quot;embeddings&quot;,
        method=&quot;build_embeddings&quot;,
        types=[&quot;Embeddings&quot;],   # &amp;larr; 이 줄이 결정적
    )
]
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;116&quot; data-ke-size=&quot;size16&quot;&gt;types=[&quot;Embeddings&quot;]&lt;span&gt;&amp;nbsp;&lt;/span&gt;를 빼면 Langflow 의 type system 이 &quot;이 컴포넌트의 출력 타입은 알 수 없음&quot; 으로 판정해서, Chroma 벡터스토어의&lt;span&gt;&amp;nbsp;&lt;/span&gt;embedding&lt;span&gt;&amp;nbsp;&lt;/span&gt;입력(타입: Embeddings)과 매칭이 안 된다. UI 에서 와이어 끌기는 되지만 type guard 에서 silent fail.&lt;/p&gt;
&lt;h3 id=&quot;시도-5--chroma-와-진짜-연결되었는지-검증&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;118&quot; data-ke-size=&quot;size23&quot;&gt;시도 5 &amp;mdash; Chroma 와 진짜 연결되었는지 검증&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;120&quot; data-ke-size=&quot;size16&quot;&gt;Output type 을 고치고 나니 와이어가 살아 있는 게 확인됐다. 검증 방법:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #202020; text-align: start;&quot; data-line=&quot;122&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-line=&quot;122&quot;&gt;입력 텍스트 1개 (&quot;안녕하세요&quot;) 를 흘려보낸다.&lt;/li&gt;
&lt;li data-line=&quot;123&quot;&gt;Chroma 의 collection 에 벡터가 1개 들어갔는지 본다.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;import chromadb
client = chromadb.PersistentClient(path=&quot;/tmp/chroma&quot;)
col = client.get_collection(&quot;test_col&quot;)
print(col.count())  # 1 이 나와야 함
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;132&quot; data-ke-size=&quot;size16&quot;&gt;1 이 나왔으면 성공. 0 이거나 collection 이 없다는 에러가 나오면 임베딩이 흘러가지 않은 것.&lt;/p&gt;
&lt;h2 id=&quot;원인-정리&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;134&quot; data-ke-size=&quot;size26&quot;&gt;원인 정리&lt;/h2&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;136&quot; data-ke-size=&quot;size16&quot;&gt;세 가지가 겹쳐 있었다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #202020; text-align: start;&quot; data-line=&quot;138&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-line=&quot;138&quot;&gt;&lt;b&gt;Langflow 는 별도 venv (~/.langflow/.langflow-venv/) 에서 실행된다.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;시스템 파이썬에 설치한 패키지는 안 보인다.&lt;/li&gt;
&lt;li data-line=&quot;139&quot;&gt;&lt;b&gt;Custom Component 의&lt;span&gt;&amp;nbsp;&lt;/span&gt;Output(types=[...])&lt;span&gt;&amp;nbsp;&lt;/span&gt;가 Langflow 의 type system 의 핵심.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;누락하면 후속 노드와 매칭 안 됨. UI 에서 연결처럼 보여도 silent fail.&lt;/li&gt;
&lt;li data-line=&quot;140&quot;&gt;&lt;b&gt;빈 입력이 흐르면&lt;span&gt;&amp;nbsp;&lt;/span&gt;Type Convert&lt;span&gt;&amp;nbsp;&lt;/span&gt;단계의 list slice 에서 IndexError.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;와이어링이 끊긴 게 진짜 원인인데, 죽는 위치는 다른 곳.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;해결&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;142&quot; data-ke-size=&quot;size26&quot;&gt;해결&lt;/h2&gt;
&lt;h3 id=&quot;1-langflow-가-사용하는-venv-찾고-패키지-설치&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;144&quot; data-ke-size=&quot;size23&quot;&gt;1) Langflow 가 사용하는 venv 찾고 패키지 설치&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;146&quot; data-ke-size=&quot;size16&quot;&gt;에러 스택 trace 에서 venv 경로 확인. 일반적으로는 다음 위치.&lt;/p&gt;
&lt;pre class=&quot;arcade&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;~/.langflow/.langflow-venv
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;152&quot; data-ke-size=&quot;size16&quot;&gt;여기에 직접 설치한다.&lt;/p&gt;
&lt;pre class=&quot;gradle&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;source ~/.langflow/.langflow-venv/bin/activate
pip install langchain-huggingface sentence-transformers neo4j
deactivate
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;160&quot; data-ke-size=&quot;size16&quot;&gt;또는 venv 활성화 없이 직접 venv 의 pip 을 부른다.&lt;/p&gt;
&lt;pre class=&quot;awk&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;~/.langflow/.langflow-venv/bin/pip install \
  langchain-huggingface sentence-transformers neo4j
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;2-custom-component-코드-제대로-된-버전&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;167&quot; data-ke-size=&quot;size23&quot;&gt;2) Custom Component 코드 (제대로 된 버전)&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;from langflow.custom import Component
from langflow.io import StrInput, Output
from langchain_huggingface import HuggingFaceEmbeddings


class KoEmbeddings(Component):
    display_name = &quot;Ko-SRoberta (Local)&quot;
    description = &quot;Korean sentence embeddings using jhgan/ko-sroberta-multitask.&quot;
    icon = &quot;type&quot;

    inputs = [
        StrInput(
            name=&quot;model_name&quot;,
            display_name=&quot;Model name&quot;,
            value=&quot;jhgan/ko-sroberta-multitask&quot;,
            advanced=False,
        ),
        StrInput(
            name=&quot;device&quot;,
            display_name=&quot;Device&quot;,
            value=&quot;cpu&quot;,       # GPU 사용 시 &quot;cuda&quot; 또는 &quot;mps&quot;
            advanced=True,
        ),
    ]

    outputs = [
        Output(
            display_name=&quot;Embeddings&quot;,
            name=&quot;embeddings&quot;,
            method=&quot;build_embeddings&quot;,
            types=[&quot;Embeddings&quot;],   # &amp;larr; 결정적
        )
    ]

    def build_embeddings(self) -&amp;gt; HuggingFaceEmbeddings:
        return HuggingFaceEmbeddings(
            model_name=self.model_name,
            model_kwargs={&quot;device&quot;: self.device},
            encode_kwargs={&quot;normalize_embeddings&quot;: True},
        )
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;212&quot; data-ke-size=&quot;size16&quot;&gt;코드 한 줄씩 의미:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;214&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;214&quot;&gt;display_name&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr; 캔버스에서 보이는 이름. 카테고리는 폴더로 결정 (뒤에 설명).&lt;/li&gt;
&lt;li data-line=&quot;215&quot;&gt;inputs&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr;&lt;span&gt;&amp;nbsp;&lt;/span&gt;StrInput&lt;span&gt;&amp;nbsp;&lt;/span&gt;두 개.&lt;span&gt;&amp;nbsp;&lt;/span&gt;advanced=True&lt;span&gt;&amp;nbsp;&lt;/span&gt;인 항목은 기본 UI 에서 숨겨지고 &quot;Advanced&quot; 토글로 펼친다.&lt;/li&gt;
&lt;li data-line=&quot;216&quot;&gt;outputs&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr;&lt;span&gt;&amp;nbsp;&lt;/span&gt;method=&quot;build_embeddings&quot;&lt;span&gt;&amp;nbsp;&lt;/span&gt;가 호출되어 그 반환값을 다음 컴포넌트로 흘려보낸다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;types=[&quot;Embeddings&quot;]&lt;span&gt;&amp;nbsp;&lt;/span&gt;가 type matching 키.&lt;/li&gt;
&lt;li data-line=&quot;217&quot;&gt;normalize_embeddings=True&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr; 코사인 유사도 검색 시 L2 normalize 가 있으면 dot product 만으로도 동작. Chroma 의 default 와 잘 맞는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;3-langflow-백그라운드-실행&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;219&quot; data-ke-size=&quot;size23&quot;&gt;3) Langflow 백그라운드 실행&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;221&quot; data-ke-size=&quot;size16&quot;&gt;langflow run&lt;span&gt;&amp;nbsp;&lt;/span&gt;명령이 안 잡히면 venv 안의 binary 를 직접 가리키거나&lt;span&gt;&amp;nbsp;&lt;/span&gt;nohup&lt;span&gt;&amp;nbsp;&lt;/span&gt;으로 띄운다.&lt;/p&gt;
&lt;pre class=&quot;jboss-cli&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;nohup ~/.langflow/.langflow-venv/bin/langflow run --port 7860 \
  &amp;gt; ~/langflow.log 2&amp;gt;&amp;amp;1 &amp;amp;
echo $!  # PID 기록해두면 나중에 kill 편함
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;229&quot; data-ke-size=&quot;size16&quot;&gt;langflow.log&lt;span&gt;&amp;nbsp;&lt;/span&gt;의 첫 30줄에 어떤 venv 에서 실행 중인지, 어떤 컴포넌트 path 가 로드되는지 출력된다. 셋업 확인은 이 로그를 보는 게 가장 빠르다.&lt;/p&gt;
&lt;h3 id=&quot;4-선택-사이드바에-영구-등록&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;231&quot; data-ke-size=&quot;size23&quot;&gt;4) (선택) 사이드바에 영구 등록&lt;/h3&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;233&quot; data-ke-size=&quot;size16&quot;&gt;캔버스에 만든 컴포넌트는 그 flow 안에서만 산다. 다른 flow 에서도 쓰려면 파일로 등록해야 한다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #eaeaea; color: #202020; text-align: start;&quot;&gt;&lt;code&gt;export LANGFLOW_COMPONENTS_PATH=$HOME/langflow-components
mkdir -p $HOME/langflow-components/embeddings_korean
# 그 안에 ko_embeddings.py 저장
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;241&quot; data-ke-size=&quot;size16&quot;&gt;폴더 이름이 사이드바의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;카테고리&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;가 되고, 파일 이름이 컴포넌트 이름이 된다. 이 구조를 깨면 사이드바에 안 뜨거나 카테고리가 이상하게 잡힌다.&lt;/p&gt;
&lt;h2 id=&quot;검증-절차&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;243&quot; data-ke-size=&quot;size26&quot;&gt;검증 절차&lt;/h2&gt;
&lt;p style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;245&quot; data-ke-size=&quot;size16&quot;&gt;배포 후 다음 순서로 확인하면 30초 안에 정상 동작을 검증할 수 있다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #202020; text-align: start;&quot; data-line=&quot;247&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-line=&quot;247&quot;&gt;캔버스 좌측 사이드바 &amp;rarr; &quot;Embeddings&quot; 카테고리에&lt;span&gt;&amp;nbsp;&lt;/span&gt;Ko-SRoberta (Local)&lt;span&gt;&amp;nbsp;&lt;/span&gt;가 있는지.&lt;/li&gt;
&lt;li data-line=&quot;248&quot;&gt;끌어다 놓고, 입력 텍스트 한 줄 + Chroma 벡터스토어 + 검색 컴포넌트 까지 와이어.&lt;/li&gt;
&lt;li data-line=&quot;249&quot;&gt;캔버스 우상단 Run.&lt;/li&gt;
&lt;li data-line=&quot;250&quot;&gt;Chroma 의 persist 디렉토리에&lt;span&gt;&amp;nbsp;&lt;/span&gt;chroma.sqlite3&lt;span&gt;&amp;nbsp;&lt;/span&gt;가 생기는지, 그 안에 1개 벡터가 들어갔는지 (위 검증 코드).&lt;/li&gt;
&lt;li data-line=&quot;251&quot;&gt;같은 쿼리로 검색했을 때 cosine similarity 가 0.99 이상 (자기 자신 검색이라면) 나오는지.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id=&quot;배운-점&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;253&quot; data-ke-size=&quot;size26&quot;&gt;배운 점&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;255&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;255&quot;&gt;&lt;b&gt;Langflow custom component 의 의존성은 Langflow venv 에 설치한다.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;시스템 파이썬 &amp;ne; 캔버스 런타임이라는 것을 처음부터 알고 있으면 30분이 줄어든다. 에러 스택 trace 의 경로를 한 번 보면 venv 가 어디 있는지 즉시 보인다.&lt;/li&gt;
&lt;li data-line=&quot;256&quot;&gt;&lt;b&gt;Output(types=[...])&lt;span&gt;&amp;nbsp;&lt;/span&gt;는 캔버스 연결 가능 여부를 결정한다.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;누락 시 와이어가 시각적으로는 그려져도 binding 이 안 되고 build 단계에서 IndexError 등 엉뚱한 곳에서 죽는다. 디버깅 진입점을 잘못 잡기 쉬운 함정.&lt;/li&gt;
&lt;li data-line=&quot;257&quot;&gt;&lt;b&gt;타입 명시는 SBERT 임베딩만의 문제가 아니다.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;Vector store, LLM, Document 등 모든 type 에서 동일하게 적용된다. 새 컴포넌트 만들 때 첫 줄을&lt;span&gt;&amp;nbsp;&lt;/span&gt;types=[...]&lt;span&gt;&amp;nbsp;&lt;/span&gt;부터 적는 게 안전하다.&lt;/li&gt;
&lt;li data-line=&quot;258&quot;&gt;&lt;b&gt;한국어 임베딩 시작점&lt;/b&gt;:&lt;span&gt;&amp;nbsp;&lt;/span&gt;jhgan/ko-sroberta-multitask&lt;span&gt;&amp;nbsp;&lt;/span&gt;가 무난한 기본. RAG quality 가 부족하면&lt;span&gt;&amp;nbsp;&lt;/span&gt;BAAI/bge-m3&lt;span&gt;&amp;nbsp;&lt;/span&gt;로 옮겨도 768차원이라 벡터스토어 재생성 없이 시도 가능.&lt;span&gt;&amp;nbsp;&lt;/span&gt;intfloat/multilingual-e5-large&lt;span&gt;&amp;nbsp;&lt;/span&gt;도 비교 후보.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;다음-단계&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;260&quot; data-ke-size=&quot;size26&quot;&gt;다음 단계&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;262&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;262&quot;&gt;한국어 임베딩 + 일반 텍스트 retrieval 까지는 OK. 다음은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;그래프 RAG&lt;/b&gt;: Neo4j 와 함께 엮어서 entity 관계까지 사용하는 흐름. (다음 날 포스트로 이어진다)&lt;/li&gt;
&lt;li data-line=&quot;263&quot;&gt;production 으로 가려면 임베딩 모델을&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;컨테이너 + GPU&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;로 분리. CPU 단일 호스트는 PoC 까지만.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&quot;참고&quot; style=&quot;color: #202020; text-align: start;&quot; data-line=&quot;265&quot; data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; color: #202020; text-align: start;&quot; data-line=&quot;267&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;267&quot;&gt;LangChain HuggingFaceEmbeddings:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://python.langchain.com/docs/integrations/text_embedding/huggingfacehub&quot;&gt;https://python.langchain.com/docs/integrations/text_embedding/huggingfacehub&lt;/a&gt;&lt;/li&gt;
&lt;li data-line=&quot;268&quot;&gt;ko-sroberta-multitask 모델 카드:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://huggingface.co/jhgan/ko-sroberta-multitask&quot;&gt;https://huggingface.co/jhgan/ko-sroberta-multitask&lt;/a&gt;&lt;/li&gt;
&lt;li data-line=&quot;269&quot;&gt;Langflow custom components 가이드:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://docs.langflow.org/components-custom-components&quot;&gt;https://docs.langflow.org/components-custom-components&lt;/a&gt;&lt;/li&gt;
&lt;li data-line=&quot;270&quot;&gt;sentence-transformers normalize 설명:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://www.sbert.net/docs/usage/semantic_textual_similarity.html&quot;&gt;https://www.sbert.net/docs/usage/semantic_textual_similarity.html&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <author>임마누엘한</author>
      <guid isPermaLink="true">https://smhanlab.tistory.com/6</guid>
      <comments>https://smhanlab.tistory.com/6#entry6comment</comments>
      <pubDate>Thu, 14 May 2026 15:42:22 +0900</pubDate>
    </item>
    <item>
      <title>LangFlow + Neo4j로 자연어 RBAC 그래프 검색 만들기 &amp;mdash; 삽질 기록 총정리</title>
      <link>https://smhanlab.tistory.com/5</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;083c9941-eb10-4b25-90d9-c994f0f80007.png&quot; data-origin-width=&quot;833&quot; data-origin-height=&quot;308&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vzpKe/dJMcafNhDtQ/wjS6m9kU1o0zEz3gi1j9p1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vzpKe/dJMcafNhDtQ/wjS6m9kU1o0zEz3gi1j9p1/img.png&quot; data-alt=&quot;&amp;quot;Netherlands 고객 목록 보여줘&amp;quot;라는 말 한마디로 그래프 Neo4j DB를 뒤지는 LangFlow AI 에이전트를 만들어봤다. 쉬울 줄 알았는데 생각보다 구멍이 많았다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vzpKe/dJMcafNhDtQ/wjS6m9kU1o0zEz3gi1j9p1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvzpKe%2FdJMcafNhDtQ%2FwjS6m9kU1o0zEz3gi1j9p1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;833&quot; height=&quot;308&quot; data-filename=&quot;083c9941-eb10-4b25-90d9-c994f0f80007.png&quot; data-origin-width=&quot;833&quot; data-origin-height=&quot;308&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;&quot;Netherlands 고객 목록 보여줘&quot;라는 말 한마디로 그래프 Neo4j DB를 뒤지는 LangFlow AI 에이전트를 만들어봤다. 쉬울 줄 알았는데 생각보다 구멍이 많았다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 id=&quot;배경&quot; style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;10&quot; data-ke-size=&quot;size26&quot;&gt;배경&lt;/h2&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;e-커머스 주문 데이터를 Neo4j 그래프 DB에 적재해둔 상태에서, 이걸 LangFlow AI Agent를 통해 자연어로 질의할 수 있는 파이프라인을 만들고 싶었다.&lt;/p&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;핵심 요구사항은 두 가지였다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #3b3b3b; text-align: start;&quot; data-line=&quot;16&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-line=&quot;16&quot;&gt;&lt;b&gt;Role-Based Access Control (RBAC)&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;mdash; 직급/권한에 따라 접근 가능한 데이터가 달라야 한다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;reader는 상품&amp;middot;국가만 볼 수 있고,&lt;span&gt;&amp;nbsp;&lt;/span&gt;pii_admin만 이메일&amp;middot;전화번호 같은 PII 데이터에 접근 가능하다.&lt;/li&gt;
&lt;li data-line=&quot;17&quot;&gt;&lt;b&gt;그래프 관계 탐색&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;mdash; &quot;영국 고객들의 주문 목록&quot;처럼 Country &amp;rarr; Customer &amp;rarr; Order를 넘나드는 멀티홉 탐색이 자연어로 동작해야 한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-line=&quot;19&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;환경&quot; style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;21&quot; data-ke-size=&quot;size26&quot;&gt;환경&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;항목버전&lt;/p&gt;
&lt;table style=&quot;color: #3b3b3b; text-align: start; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-line=&quot;23&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody data-line=&quot;25&quot;&gt;
&lt;tr data-line=&quot;25&quot;&gt;
&lt;td&gt;LangFlow&lt;/td&gt;
&lt;td&gt;1.9.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;26&quot;&gt;
&lt;td&gt;Neo4j&lt;/td&gt;
&lt;td&gt;5.27-aura (Enterprise)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;27&quot;&gt;
&lt;td&gt;Python&lt;/td&gt;
&lt;td&gt;3.12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;28&quot;&gt;
&lt;td&gt;neo4j 드라이버&lt;/td&gt;
&lt;td&gt;6.1.0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;30&quot; data-ke-size=&quot;size16&quot;&gt;패키지는 LangFlow가 쓰는 venv에 설치해야 한다. 시스템 Python에 깔아봤자 소용없다.&lt;/p&gt;
&lt;pre class=&quot;awk&quot; style=&quot;background-color: #f8f8f8; color: #3b3b3b; text-align: start;&quot;&gt;&lt;code&gt;/Users/jumpcloud/.langflow/.langflow-venv/bin/pip install neo4j langchain-neo4j
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;36&quot; data-ke-size=&quot;size16&quot;&gt;VS Code Pyright가&lt;span&gt;&amp;nbsp;&lt;/span&gt;langflow,&lt;span&gt;&amp;nbsp;&lt;/span&gt;langchain_core&lt;span&gt;&amp;nbsp;&lt;/span&gt;import에 빨간 줄을 긋는데, IDE가 시스템 Python 경로를 보기 때문이고 LangFlow 실행 시엔 정상 동작한다. 신경 끄면 된다.&lt;/p&gt;
&lt;hr data-line=&quot;38&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;데이터-구조&quot; style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;40&quot; data-ke-size=&quot;size26&quot;&gt;데이터 구조&lt;/h2&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;42&quot; data-ke-size=&quot;size16&quot;&gt;그래프 스키마는 이렇게 생겼다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레이블노드 수접근 레벨&lt;/p&gt;
&lt;table style=&quot;color: #3b3b3b; text-align: start; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-line=&quot;44&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody data-line=&quot;46&quot;&gt;
&lt;tr data-line=&quot;46&quot;&gt;
&lt;td&gt;Product&lt;/td&gt;
&lt;td&gt;3,684&lt;/td&gt;
&lt;td&gt;public&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;47&quot;&gt;
&lt;td&gt;Country&lt;/td&gt;
&lt;td&gt;37&lt;/td&gt;
&lt;td&gt;public&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;48&quot;&gt;
&lt;td&gt;Customer&lt;/td&gt;
&lt;td&gt;4,372&lt;/td&gt;
&lt;td&gt;internal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;49&quot;&gt;
&lt;td&gt;Order&lt;/td&gt;
&lt;td&gt;22,190&lt;/td&gt;
&lt;td&gt;internal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;50&quot;&gt;
&lt;td&gt;OrderLineItem&lt;/td&gt;
&lt;td&gt;406,829&lt;/td&gt;
&lt;td&gt;internal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;51&quot;&gt;
&lt;td&gt;Return&lt;/td&gt;
&lt;td&gt;3,654&lt;/td&gt;
&lt;td&gt;internal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;52&quot;&gt;
&lt;td&gt;ValidCommunity&lt;/td&gt;
&lt;td&gt;1,101&lt;/td&gt;
&lt;td&gt;internal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;53&quot;&gt;
&lt;td&gt;Outlier&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;restricted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;54&quot;&gt;
&lt;td&gt;EmailAddress&lt;/td&gt;
&lt;td&gt;4,213&lt;/td&gt;
&lt;td&gt;confidential (PII)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;55&quot;&gt;
&lt;td&gt;Phone&lt;/td&gt;
&lt;td&gt;4,235&lt;/td&gt;
&lt;td&gt;confidential (PII)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;56&quot;&gt;
&lt;td&gt;Identifier&lt;/td&gt;
&lt;td&gt;4,288&lt;/td&gt;
&lt;td&gt;confidential (PII)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;58&quot; data-ke-size=&quot;size16&quot;&gt;총 노드&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;449,848개&lt;/b&gt;, 관계&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;1,192,605개&lt;/b&gt;다.&lt;/p&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;60&quot; data-ke-size=&quot;size16&quot;&gt;주요 관계 경로는 이렇다.&lt;/p&gt;
&lt;pre class=&quot;less&quot; style=&quot;background-color: #f8f8f8; color: #3b3b3b; text-align: start;&quot;&gt;&lt;code&gt;Country &amp;larr;[LOCATION]─ Customer ─[PURCHASED]&amp;rarr; Order ─[ORDERED]&amp;rarr; OrderLineItem ─[LINE_ITEM]&amp;rarr; Product
                                                  └─[ASSOC_EMAIL]&amp;rarr; EmailAddress  (pii_admin 전용)
                                                  └─[ASSOC_PHONE]&amp;rarr; Phone         (pii_admin 전용)
                                                  └─[ASSOC_ID]──&amp;rarr; Identifier     (pii_admin 전용)
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-line=&quot;69&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;rbac-설계-왜-애플리케이션-레이어인가&quot; style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;71&quot; data-ke-size=&quot;size26&quot;&gt;RBAC 설계: 왜 애플리케이션 레이어인가&lt;/h2&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;73&quot; data-ke-size=&quot;size16&quot;&gt;처음엔 Neo4j Aura에서 DB 레벨 RBAC를 쓰려 했다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;CREATE ROLE,&lt;span&gt;&amp;nbsp;&lt;/span&gt;GRANT,&lt;span&gt;&amp;nbsp;&lt;/span&gt;DENY&lt;span&gt;&amp;nbsp;&lt;/span&gt;같은 Cypher 명령을 쓰면 되겠지 싶었는데, Aura Enterprise는 이걸 플랫폼 레벨에서 막고 있다.&lt;/p&gt;
&lt;pre class=&quot;css&quot; style=&quot;background-color: #f8f8f8; color: #3b3b3b; text-align: start;&quot;&gt;&lt;code&gt;Neo.ClientError.Security.Forbidden
Permission has not been granted for CREATE ROLE
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;80&quot; data-ke-size=&quot;size16&quot;&gt;그래서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;애플리케이션 레이어 RBAC&lt;/b&gt;로 방향을 바꿨다. 쿼리 실행 전에 코드에서 레이블 접근 권한을 체크하는 방식이다.&lt;/p&gt;
&lt;pre class=&quot;prolog&quot; style=&quot;background-color: #f8f8f8; color: #3b3b3b; text-align: start;&quot;&gt;&lt;code&gt;ROLE_ACCESS = {
    &quot;reader&quot;:              [&quot;Product&quot;, &quot;Country&quot;],
    &quot;analyst_role&quot;:        [&quot;Customer&quot;, &quot;Order&quot;, &quot;Product&quot;, &quot;OrderLineItem&quot;, &quot;Country&quot;],
    &quot;pii_restricted_role&quot;: [&quot;Customer&quot;, &quot;Order&quot;, &quot;Product&quot;, &quot;OrderLineItem&quot;,
                            &quot;Country&quot;, &quot;Return&quot;, &quot;ValidCommunity&quot;, &quot;Outlier&quot;],
    &quot;pii_admin&quot;:           [&quot;Customer&quot;, &quot;Order&quot;, &quot;Product&quot;, &quot;OrderLineItem&quot;,
                            &quot;Country&quot;, &quot;Return&quot;, &quot;EmailAddress&quot;, &quot;Phone&quot;,
                            &quot;Identifier&quot;, &quot;ValidCommunity&quot;, &quot;Outlier&quot;],
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;94&quot; data-ke-size=&quot;size16&quot;&gt;권한 체크는 쿼리 실행 전에 빠르게 실패(fail fast)하도록 했다.&lt;/p&gt;
&lt;pre class=&quot;python&quot; style=&quot;background-color: #f8f8f8; color: #3b3b3b; text-align: start;&quot;&gt;&lt;code&gt;def _check_access(self, role: str, label: str) -&amp;gt; tuple[bool, str]:
    allowed = ROLE_ACCESS.get(role)
    if allowed is None:
        return False, f&quot;알 수 없는 Role: '{role}'&quot;
    if label not in allowed:
        return False, f&quot;Role '{role}'은 '{label}' 노드에 접근할 수 없습니다.&quot;
    return True, &quot;&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-line=&quot;106&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;컴포넌트-개발-v1--v2--v3&quot; style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;108&quot; data-ke-size=&quot;size26&quot;&gt;컴포넌트 개발: v1 &amp;rarr; v2 &amp;rarr; v3&lt;/h2&gt;
&lt;h3 id=&quot;v1--일단-돌아가게&quot; style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;110&quot; data-ke-size=&quot;size23&quot;&gt;v1 &amp;mdash; 일단 돌아가게&lt;/h3&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;112&quot; data-ke-size=&quot;size16&quot;&gt;처음 구현은 두 개의 Tool Output을 가진 컴포넌트였다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #3b3b3b; text-align: start;&quot; data-line=&quot;114&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;114&quot;&gt;&lt;b&gt;Search Tool&lt;/b&gt;: keyword로 허용 레이블 전체를 순차 검색&lt;/li&gt;
&lt;li data-line=&quot;115&quot;&gt;&lt;b&gt;Explore Tool&lt;/b&gt;: start_label &amp;rarr; target_label 관계 탐색&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;117&quot; data-ke-size=&quot;size16&quot;&gt;빠르게 붙이는 데는 성공했는데 문제가 산더미였다. 에러 처리 없음, 레이블 인젝션 검증 없음, connection timeout 설정 없음.&lt;/p&gt;
&lt;h3 id=&quot;v2--꼼꼼하게&quot; style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;119&quot; data-ke-size=&quot;size23&quot;&gt;v2 &amp;mdash; 꼼꼼하게&lt;/h3&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;121&quot; data-ke-size=&quot;size16&quot;&gt;에러 처리, 레이블 화이트리스트 검증(_sanitize_label()),&lt;span&gt;&amp;nbsp;&lt;/span&gt;connection_timeout/max_hops&lt;span&gt;&amp;nbsp;&lt;/span&gt;UI 입력,&lt;span&gt;&amp;nbsp;&lt;/span&gt;truncated&lt;span&gt;&amp;nbsp;&lt;/span&gt;플래그 등을 추가했다.&lt;/p&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;123&quot; data-ke-size=&quot;size16&quot;&gt;그리고&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;collect() 메모리 버그&lt;/b&gt;를 고쳤다. 원래 explore 쿼리를 이렇게 짰었는데:&lt;/p&gt;
&lt;pre class=&quot;sql&quot; style=&quot;background-color: #f8f8f8; color: #3b3b3b; text-align: start;&quot;&gt;&lt;code&gt;WITH DISTINCT target
WITH count(target) AS total, collect(target) AS all_targets
RETURN total, [t IN all_targets[..$limit] | properties(t)] AS targets
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;131&quot; data-ke-size=&quot;size16&quot;&gt;Country(United Kingdom) &amp;rarr; Order처럼 결과가 22,190건이면 전체를 메모리에 올린 뒤 잘라내는 방식이라 OOM 위험이 있다. 2개의 쿼리로 분리해서 해결했다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #f8f8f8; color: #3b3b3b; text-align: start;&quot;&gt;&lt;code&gt;# 쿼리 1: 결과 조회 &amp;mdash; DB에서 LIMIT으로 잘라냄
result_rows = s.run(path_match + &quot;RETURN DISTINCT target LIMIT $limit&quot;, ...)

# 쿼리 2: 총 건수 조회 &amp;mdash; 집계 연산만, 메모리 부담 없음
count_row = s.run(path_match + &quot;RETURN count(DISTINCT target) AS total&quot;, ...)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 id=&quot;v3--도구를-하나로&quot; style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;141&quot; data-ke-size=&quot;size23&quot;&gt;v3 &amp;mdash; 도구를 하나로&lt;/h3&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;143&quot; data-ke-size=&quot;size16&quot;&gt;v2를 쓰면서 결정적인 문제가 생겼다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Agent가 Explore Tool을 전혀 안 쓴다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;less&quot; style=&quot;background-color: #f8f8f8; color: #3b3b3b; text-align: start;&quot;&gt;&lt;code&gt;User: &quot;United Kingdom 주문 목록 보여줘&quot;
AI:   Country 노드만 반환 후 &quot;주문은 조회할 수 없습니다&quot; 안내

User: &quot;France 고객들의 주문은 뭐가 있어?&quot;
AI:   이름에 'France'가 포함된 고객(Francesco...)을 Search로 반환

User: &quot;Netherlands 고객 누구누구야?&quot;
AI:   Country 노드만 반환 후 &quot;LOCATION 관계 탐색 지원 안 됨&quot; 안내
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;156&quot; data-ke-size=&quot;size16&quot;&gt;tool description을 아무리 강화해도, System Prompt에 규칙을 아무리 넣어도 안 됐다. Agent 입장에서 두 도구 중 뭘 쓸지 판단 자체가 흔들리는 것이다.&lt;/p&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;158&quot; data-ke-size=&quot;size16&quot;&gt;해결책은 단순했다:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;도구를 하나로 합쳐서 Agent가 선택을 못 하게 만든다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;makefile&quot; style=&quot;background-color: #f8f8f8; color: #3b3b3b; text-align: start;&quot;&gt;&lt;code&gt;기존: neo4j_rbac_search + neo4j_explore_relationships  &amp;rarr;  Agent가 선택
변경: neo4j_graph_query 하나  &amp;rarr;  target_label 유무로 내부 자동 분기
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;background-color: #f8f8f8; color: #3b3b3b; text-align: start;&quot;&gt;&lt;code&gt;class Neo4jGraphQueryInput(BaseModel):
    start_label:   str           # 시작 노드 레이블 (필수)
    start_keyword: str           # 검색 키워드 (필수)
    target_label:  Optional[str] # None &amp;rarr; search, 값 있음 &amp;rarr; explore
    limit:         Optional[int] # 기본 10, 최대 50
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;173&quot; data-ke-size=&quot;size16&quot;&gt;내부 분기:&lt;/p&gt;
&lt;pre class=&quot;bash&quot; style=&quot;background-color: #f8f8f8; color: #3b3b3b; text-align: start;&quot;&gt;&lt;code&gt;if target_label:
    return _explore(driver, role, start_label, start_keyword,
                    target_label, limit, creds[&quot;max_hops&quot;])
else:
    return _search(driver, role, start_label, start_keyword, limit)
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;183&quot; data-ke-size=&quot;size16&quot;&gt;이 변경 후 Netherlands, France, United Kingdom 등 모든 국가 &amp;rarr; 고객/주문 탐색이 정상 동작하기 시작했다.&lt;/p&gt;
&lt;hr data-line=&quot;185&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;또-다른-삽질-pii-이메일-검색-거부&quot; style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;187&quot; data-ke-size=&quot;size26&quot;&gt;또 다른 삽질: PII 이메일 검색 거부&lt;/h2&gt;
&lt;pre class=&quot;1c&quot; style=&quot;background-color: #f8f8f8; color: #3b3b3b; text-align: start;&quot;&gt;&lt;code&gt;User: &quot;zimfam125 이메일 계정 찾아줘&quot;
AI:   &quot;저희 데이터베이스에 포함된 정보가 아니며,
       외부 이메일 계정 검색은 지원하지 않습니다&quot;
       &amp;rarr; 도구 미호출
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;196&quot; data-ke-size=&quot;size16&quot;&gt;Agent가&lt;span&gt;&amp;nbsp;&lt;/span&gt;zimfam125를 외부 이메일 계정 조회 요청으로 판단해서 도구 자체를 안 불렀다. System Prompt에 &quot;e-커머스 DB 어시스턴트&quot;라고만 정의되어 있고,&lt;span&gt;&amp;nbsp;&lt;/span&gt;EmailAddress가 내부 노드 레이블이라는 걸 명시하지 않았기 때문이다.&lt;/p&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;198&quot; data-ke-size=&quot;size16&quot;&gt;tool description에 직접 예시를 박았다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #f8f8f8; color: #3b3b3b; text-align: start;&quot;&gt;&lt;code&gt;&quot;zimfam125 이메일 찾아줘&quot; &amp;rarr; start_label=EmailAddress, start_keyword=zimfam125 (target 없음)
&quot;+44 전화번호&quot;            &amp;rarr; start_label=Phone, start_keyword=+44 (target 없음)
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;205&quot; data-ke-size=&quot;size16&quot;&gt;그 후 정상 동작. LLM에게 &quot;이게 DB 안에 있는 노드야&quot;라고 명시적으로 알려줘야 한다는 교훈.&lt;/p&gt;
&lt;hr data-line=&quot;207&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;또-다른-삽질-validcommunity-데이터-구조-오해&quot; style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;209&quot; data-ke-size=&quot;size26&quot;&gt;또 다른 삽질: ValidCommunity 데이터 구조 오해&lt;/h2&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;211&quot; data-ke-size=&quot;size16&quot;&gt;이상거래 탐지 커뮤니티 데이터를 조회하려고 이런 질문을 넣었다.&lt;/p&gt;
&lt;pre class=&quot;1c&quot; style=&quot;background-color: #f8f8f8; color: #3b3b3b; text-align: start;&quot;&gt;&lt;code&gt;&quot;ValidCommunity에 속한 고객들 보여줘&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;217&quot; data-ke-size=&quot;size16&quot;&gt;48초 대기 후 빈 결과. 처음엔 쿼리 최적화 문제라 생각했는데, 데이터 구조 자체를 잘못 이해하고 있었다.&lt;/p&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;219&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;오해:&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;ValidCommunity &amp;rarr; Customer 관계가 있을 것이다&lt;br /&gt;&lt;b&gt;실제:&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;SHARED_PII는 Customer &amp;rarr; Customer 관계이고, ValidCommunity 노드는 Customer와 직접 연결이 없다. ValidCommunity 노드 자체에&lt;span&gt;&amp;nbsp;&lt;/span&gt;firstName,&lt;span&gt;&amp;nbsp;&lt;/span&gt;lastName,&lt;span&gt;&amp;nbsp;&lt;/span&gt;customerId&lt;span&gt;&amp;nbsp;&lt;/span&gt;등 고객 정보가 내장되어 있다.&lt;/p&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;222&quot; data-ke-size=&quot;size16&quot;&gt;따라서 올바른 접근은 explore가 아니라 ValidCommunity 노드를 직접 search하는 것이다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;background-color: #f8f8f8; color: #3b3b3b; text-align: start;&quot;&gt;&lt;code&gt;# 잘못된 접근 (explore 모드)
start_label=ValidCommunity, target_label=Customer &amp;rarr; 빈 결과

# 올바른 접근 (search 모드)
start_label=ValidCommunity, start_keyword=Johnson &amp;rarr; ValidCommunity 노드 직접 반환
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;232&quot; data-ke-size=&quot;size16&quot;&gt;실제 검색 결과:&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;#고객 ID이름커뮤니티&lt;/p&gt;
&lt;table style=&quot;color: #3b3b3b; text-align: start; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-line=&quot;234&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody data-line=&quot;236&quot;&gt;
&lt;tr data-line=&quot;236&quot;&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;13607&lt;/td&gt;
&lt;td&gt;Danielle Johnson&lt;/td&gt;
&lt;td&gt;4000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;237&quot;&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;12581&lt;/td&gt;
&lt;td&gt;William Johnson&lt;/td&gt;
&lt;td&gt;4000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-line=&quot;239&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;실제-테스트-결과&quot; style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;241&quot; data-ke-size=&quot;size26&quot;&gt;실제 테스트 결과&lt;/h2&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;243&quot; data-ke-size=&quot;size16&quot;&gt;v3 컴포넌트로 진행한 테스트 결과 일부다.&lt;/p&gt;
&lt;h3 id=&quot;국가--주문-탐색&quot; style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;245&quot; data-ke-size=&quot;size23&quot;&gt;국가 &amp;rarr; 주문 탐색&lt;/h3&gt;
&lt;pre class=&quot;1c&quot; style=&quot;background-color: #f8f8f8; color: #3b3b3b; text-align: start;&quot;&gt;&lt;code&gt;질문: &quot;영국(United Kingdom)에서 온 주문 목록 보여줘&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc; color: #3b3b3b; text-align: start;&quot; data-line=&quot;251&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;251&quot;&gt;&lt;b&gt;total_count&lt;/b&gt;: 22,190건&lt;/li&gt;
&lt;li data-line=&quot;252&quot;&gt;상위 10건 반환&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;#주문번호주문일자&lt;/p&gt;
&lt;table style=&quot;color: #3b3b3b; text-align: start; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-line=&quot;254&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody data-line=&quot;256&quot;&gt;
&lt;tr data-line=&quot;256&quot;&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;558390&lt;/td&gt;
&lt;td&gt;2011-06-29&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;257&quot;&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;561031&lt;/td&gt;
&lt;td&gt;2011-07-24&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;258&quot;&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;573746&lt;/td&gt;
&lt;td&gt;2011-11-01&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;pre class=&quot;1c&quot; style=&quot;background-color: #f8f8f8; color: #3b3b3b; text-align: start;&quot;&gt;&lt;code&gt;질문: &quot;France 고객들의 주문은 뭐가 있어?&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc; color: #3b3b3b; text-align: start;&quot; data-line=&quot;264&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;264&quot;&gt;&lt;b&gt;total_count&lt;/b&gt;: 5,983건&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;국가--고객-탐색&quot; style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;266&quot; data-ke-size=&quot;size23&quot;&gt;국가 &amp;rarr; 고객 탐색&lt;/h3&gt;
&lt;pre class=&quot;1c&quot; style=&quot;background-color: #f8f8f8; color: #3b3b3b; text-align: start;&quot;&gt;&lt;code&gt;질문: &quot;Netherlands 고객 누구누구야?&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc; color: #3b3b3b; text-align: start;&quot; data-line=&quot;272&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;272&quot;&gt;&lt;b&gt;total_count&lt;/b&gt;: 9명 (전원 반환)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;#고객 ID이름성&lt;/p&gt;
&lt;table style=&quot;color: #3b3b3b; text-align: start; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-line=&quot;274&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody data-line=&quot;276&quot;&gt;
&lt;tr data-line=&quot;276&quot;&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;14646&lt;/td&gt;
&lt;td&gt;Joannie&lt;/td&gt;
&lt;td&gt;Hodkiewicz&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;277&quot;&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;14682&lt;/td&gt;
&lt;td&gt;Mariah&lt;/td&gt;
&lt;td&gt;Bashirian&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;278&quot;&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;15891&lt;/td&gt;
&lt;td&gt;Huey&lt;/td&gt;
&lt;td&gt;Stroman&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 id=&quot;고객--상품-탐색&quot; style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;280&quot; data-ke-size=&quot;size23&quot;&gt;고객 &amp;rarr; 상품 탐색&lt;/h3&gt;
&lt;pre class=&quot;1c&quot; style=&quot;background-color: #f8f8f8; color: #3b3b3b; text-align: start;&quot;&gt;&lt;code&gt;질문: &quot;고객 17850이 구매한 상품 목록 알려줘&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc; color: #3b3b3b; text-align: start;&quot; data-line=&quot;286&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;286&quot;&gt;고객 Willy Gallegos (customerId=17850)&lt;/li&gt;
&lt;li data-line=&quot;287&quot;&gt;&lt;b&gt;total_count&lt;/b&gt;: 3,682개&lt;/li&gt;
&lt;li data-line=&quot;288&quot;&gt;상위 10개 반환&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&quot;이상거래-탐지-데이터&quot; style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;290&quot; data-ke-size=&quot;size23&quot;&gt;이상거래 탐지 데이터&lt;/h3&gt;
&lt;pre class=&quot;1c&quot; style=&quot;background-color: #f8f8f8; color: #3b3b3b; text-align: start;&quot;&gt;&lt;code&gt;질문: &quot;Outlier 노드 zimfam125 찾아줘&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;296&quot; data-ke-size=&quot;size16&quot;&gt;Outlier로 분류된 항목 5건:&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유형값커뮤니티&lt;/p&gt;
&lt;table style=&quot;color: #3b3b3b; text-align: start; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-line=&quot;298&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody data-line=&quot;300&quot;&gt;
&lt;tr data-line=&quot;300&quot;&gt;
&lt;td&gt;이메일&lt;/td&gt;
&lt;td&gt;&lt;a style=&quot;color: #005fb8;&quot; href=&quot;mailto:zimfam125@gmail.com&quot; data-href=&quot;mailto:zimfam125@gmail.com&quot;&gt;zimfam125@gmail.com&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;4000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;301&quot;&gt;
&lt;td&gt;이메일&lt;/td&gt;
&lt;td&gt;&lt;a style=&quot;color: #005fb8;&quot; href=&quot;mailto:asmith333@gmail.com&quot; data-href=&quot;mailto:asmith333@gmail.com&quot;&gt;asmith333@gmail.com&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;4015&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;302&quot;&gt;
&lt;td&gt;전화번호&lt;/td&gt;
&lt;td&gt;+33 9 50 51 28 09&lt;/td&gt;
&lt;td&gt;4015&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;303&quot;&gt;
&lt;td&gt;정부ID&lt;/td&gt;
&lt;td&gt;7211755002&lt;/td&gt;
&lt;td&gt;4000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;304&quot;&gt;
&lt;td&gt;정부ID&lt;/td&gt;
&lt;td&gt;8773210779&lt;/td&gt;
&lt;td&gt;4015&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-line=&quot;306&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;성능-이슈-가변-길이-경로-탐색의-함정&quot; style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;308&quot; data-ke-size=&quot;size26&quot;&gt;성능 이슈: 가변 길이 경로 탐색의 함정&lt;/h2&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;310&quot; data-ke-size=&quot;size16&quot;&gt;Customer &amp;rarr; Phone 탐색에&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;517초&lt;/b&gt;가 걸렸다. 원인은&lt;span&gt;&amp;nbsp;&lt;/span&gt;[*1..3]&lt;span&gt;&amp;nbsp;&lt;/span&gt;가변 길이 경로 탐색이다.&lt;/p&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;312&quot; data-ke-size=&quot;size16&quot;&gt;Customer 노드 기준으로&lt;span&gt;&amp;nbsp;&lt;/span&gt;[*1..3]을 펼치면 Customer &amp;rarr; Order가 21,200건이고, 각 Order에서 다시 3홉을 탐색하니 경우의 수가 폭발한다.&lt;/p&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;314&quot; data-ke-size=&quot;size16&quot;&gt;해결책은 직접 연결(1홉)이 확실한 경로를 명시적으로 고정하는 것이다.&lt;/p&gt;
&lt;pre class=&quot;nix&quot; style=&quot;background-color: #f8f8f8; color: #3b3b3b; text-align: start;&quot;&gt;&lt;code&gt;_ONE_HOP_PATHS = frozenset([
    (&quot;Customer&quot;, &quot;Order&quot;),        # PURCHASED
    (&quot;Customer&quot;, &quot;Country&quot;),      # LOCATION
    (&quot;Customer&quot;, &quot;EmailAddress&quot;), # ASSOC_EMAIL
    (&quot;Customer&quot;, &quot;Phone&quot;),        # ASSOC_PHONE
    (&quot;Customer&quot;, &quot;Identifier&quot;),   # ASSOC_ID
    (&quot;Order&quot;, &quot;OrderLineItem&quot;),   # ORDERED
    (&quot;Order&quot;, &quot;Return&quot;),
    (&quot;OrderLineItem&quot;, &quot;Product&quot;), # LINE_ITEM
    (&quot;Country&quot;, &quot;Customer&quot;),      # LOCATION 역방향
])

# explore 시 경로 결정
if (start_label, target_label) in _ONE_HOP_PATHS:
    hops = &quot;1..1&quot;   # 고정 1홉 &amp;mdash; 경로 폭발 없음
else:
    hops = f&quot;1..{max_hops}&quot;  # 가변 길이 (max 3)
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-line=&quot;336&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;agent-system-prompt-설계&quot; style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;338&quot; data-ke-size=&quot;size26&quot;&gt;Agent System Prompt 설계&lt;/h2&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;340&quot; data-ke-size=&quot;size16&quot;&gt;도구가 1개가 됐으므로 System Prompt는 파라미터 변환 규칙에 집중했다.&lt;/p&gt;
&lt;pre class=&quot;gherkin&quot; style=&quot;background-color: #f8f8f8; color: #3b3b3b; text-align: start;&quot;&gt;&lt;code&gt;당신은 Neo4j 그래프 DB 기반 e-커머스 질의 어시스턴트입니다.
도구 neo4j_graph_query 하나만 사용합니다.

## 파라미터 변환 규칙

| 질문 패턴                  | start_label  | start_keyword   | target_label |
|---------------------------|--------------|-----------------|--------------|
| &quot;Netherlands 고객 목록&quot;   | Country      | Netherlands     | Customer     |
| &quot;France 주문 현황&quot;        | Country      | France          | Order        |
| &quot;United Kingdom 주문 목록&quot;| Country      | United Kingdom  | Order        |
| &quot;고객 17850 구매 상품&quot;    | Customer     | 17850           | Product      |
| &quot;Willy 주문 내역&quot;         | Customer     | Willy           | Order        |
| &quot;주문 536365 상품&quot;        | Order        | 536365          | Product      |
| &quot;RED 상품 구매 고객&quot;      | Product      | RED             | Customer     |
| &quot;RED 상품 정보&quot;           | Product      | RED             | (없음)       |
| &quot;zimfam125 이메일 찾아줘&quot; | EmailAddress | zimfam125       | (없음)       |
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;361&quot; data-ke-size=&quot;size16&quot;&gt;&quot;규칙을 쓰고 예시를 넣어라&quot;가 LLM Agent System Prompt의 핵심이다. 추상적인 지시보다 구체적인 입출력 매핑 테이블이 훨씬 잘 동작한다.&lt;/p&gt;
&lt;hr data-line=&quot;363&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;버전별-비교-요약&quot; style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;365&quot; data-ke-size=&quot;size26&quot;&gt;버전별 비교 요약&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;항목v1v2v3&lt;/p&gt;
&lt;table style=&quot;color: #3b3b3b; text-align: start; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-line=&quot;367&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody data-line=&quot;369&quot;&gt;
&lt;tr data-line=&quot;369&quot;&gt;
&lt;td&gt;도구 수&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;3 (Search + Explore + Toolkit)&lt;/td&gt;
&lt;td&gt;&lt;b&gt;1&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;370&quot;&gt;
&lt;td&gt;에러 처리&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;td&gt;있음&lt;/td&gt;
&lt;td&gt;있음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;371&quot;&gt;
&lt;td&gt;레이블 인젝션 방지&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;td&gt;있음&lt;/td&gt;
&lt;td&gt;있음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;372&quot;&gt;
&lt;td&gt;Connection Timeout UI&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;td&gt;있음&lt;/td&gt;
&lt;td&gt;있음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;373&quot;&gt;
&lt;td&gt;Max Hops UI&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;td&gt;있음&lt;/td&gt;
&lt;td&gt;있음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;374&quot;&gt;
&lt;td&gt;collect() 메모리 위험&lt;/td&gt;
&lt;td&gt;있음&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;375&quot;&gt;
&lt;td&gt;Agent 도구 선택 실수&lt;/td&gt;
&lt;td&gt;가능&lt;/td&gt;
&lt;td&gt;가능&lt;/td&gt;
&lt;td&gt;&lt;b&gt;불가&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-line=&quot;376&quot;&gt;
&lt;td&gt;1홉 경로 최적화&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;td&gt;없음&lt;/td&gt;
&lt;td&gt;있음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-line=&quot;378&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;적용-방법-요약&quot; style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;380&quot; data-ke-size=&quot;size26&quot;&gt;적용 방법 요약&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #3b3b3b; text-align: start;&quot; data-line=&quot;382&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-line=&quot;382&quot;&gt;LangFlow UI &amp;rarr; Custom Components &amp;rarr;&lt;span&gt;&amp;nbsp;&lt;/span&gt;neo4j_rbac_tool_v3.py&lt;span&gt;&amp;nbsp;&lt;/span&gt;붙여넣기&lt;/li&gt;
&lt;li data-line=&quot;383&quot;&gt;캔버스에 컴포넌트 추가 후 URI / 비밀번호 / Role 설정&lt;/li&gt;
&lt;li data-line=&quot;384&quot;&gt;Graph Query Tool&lt;span&gt;&amp;nbsp;&lt;/span&gt;output &amp;rarr; Agent&lt;span&gt;&amp;nbsp;&lt;/span&gt;Tools&lt;span&gt;&amp;nbsp;&lt;/span&gt;포트 연결&lt;/li&gt;
&lt;li data-line=&quot;385&quot;&gt;Agent Instructions에 System Prompt 붙여넣기&lt;/li&gt;
&lt;li data-line=&quot;386&quot;&gt;테스트: &quot;RED 상품 찾아줘&quot; &amp;rarr; &quot;Netherlands 고객 목록&quot; &amp;rarr; RBAC 차단 확인 순으로 검증&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-line=&quot;388&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 id=&quot;배운-것&quot; style=&quot;color: #3b3b3b; text-align: start;&quot; data-line=&quot;390&quot; data-ke-size=&quot;size26&quot;&gt;배운 것&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; color: #3b3b3b; text-align: start;&quot; data-line=&quot;392&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-line=&quot;392&quot;&gt;&lt;b&gt;LLM Agent에게 도구 선택을 맡기지 마라.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;도구가 2개라면 하나를 못 쓰는 경우가 생긴다. 통합 가능하면 통합하고, Agent는 파라미터만 채우게 하는 게 낫다.&lt;/li&gt;
&lt;li data-line=&quot;393&quot;&gt;&lt;b&gt;tool description은 예시가 전부다.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&quot;관계 탐색 시 사용하세요&quot; 같은 추상적 문구는 효과 없다. &quot;Netherlands 고객 목록 &amp;rarr; start_label=Country, target_label=Customer&quot; 같은 구체적 입출력 매핑을 넣어야 한다.&lt;/li&gt;
&lt;li data-line=&quot;394&quot;&gt;&lt;b&gt;그래프 DB 쿼리는 카디널리티를 먼저 확인하라.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;[*1..3]이 특정 노드에서 출발하면 경로가 수십만 개로 폭발할 수 있다. 알려진 직접 연결 경로는 반드시&lt;span&gt;&amp;nbsp;&lt;/span&gt;[*1..1]로 고정하라.&lt;/li&gt;
&lt;li data-line=&quot;395&quot;&gt;&lt;b&gt;데이터 구조는 가정하지 말고 확인하라.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;ValidCommunity &amp;rarr; Customer 관계가 있을 거라는 가정이 48초짜리 빈 결과를 만들었다. CSV 파일이나 관계 타입을 직접 확인하는 습관이 필요하다.&lt;/li&gt;
&lt;li data-line=&quot;396&quot;&gt;&lt;b&gt;Neo4j Aura는 DB 레벨 RBAC를 지원하지 않는다.&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;처음부터 애플리케이션 레이어 RBAC로 설계하는 게 맞다.&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>AI에이전트</category>
      <category>cypher</category>
      <category>langchain</category>
      <category>Langflow</category>
      <category>LLM</category>
      <category>Neo4j</category>
      <category>Python</category>
      <category>RBAC</category>
      <category>그래프데이터베이스</category>
      <category>커스텀컴포넌트</category>
      <author>임마누엘한</author>
      <guid isPermaLink="true">https://smhanlab.tistory.com/5</guid>
      <comments>https://smhanlab.tistory.com/5#entry5comment</comments>
      <pubDate>Mon, 4 May 2026 16:30:56 +0900</pubDate>
    </item>
    <item>
      <title>Neo4j RBAC 구현 &amp;mdash; Aura 환경에서 제약과 애플리케이션 우회</title>
      <link>https://smhanlab.tistory.com/4</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. Neo4j란 무엇인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Neo4j&lt;/b&gt;는 세계에서 가장 널리 쓰이는 &lt;b&gt;그래프 데이터베이스(Graph Database)&lt;/b&gt; 다. 관계형 데이터베이스(RDB)가 행과 열로 데이터를 저장한다면, 그래프 DB는 &lt;b&gt;노드(Node)&lt;/b&gt; 와 &lt;b&gt;관계(Relationship)&lt;/b&gt; 로 데이터를 저장한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1621&quot; data-origin-height=&quot;918&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/CA9Fg/dJMcacwbnL8/fGeDokZ18qOmRCsgXNcY3k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/CA9Fg/dJMcacwbnL8/fGeDokZ18qOmRCsgXNcY3k/img.png&quot; data-alt=&quot;이번 글에서의 샘플 데이터 (Customer - Order - Product 구조)&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/CA9Fg/dJMcacwbnL8/fGeDokZ18qOmRCsgXNcY3k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FCA9Fg%2FdJMcacwbnL8%2FfGeDokZ18qOmRCsgXNcY3k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1621&quot; height=&quot;918&quot; data-origin-width=&quot;1621&quot; data-origin-height=&quot;918&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;이번 글에서의 샘플 데이터 (Customer - Order - Product 구조)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 그래프 DB인가?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;쇼핑몰을 예로 들면:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;RDB는 Customer, Order, Product 테이블을 만들고 외래키로 연결한다.&lt;/li&gt;
&lt;li&gt;Neo4j는 (Customer)-[:PURCHASED]-&amp;gt;(Order)-[:CONTAINS]-&amp;gt;(Product) 처럼 &lt;b&gt;관계 자체를 1급 시민으로&lt;/b&gt; 다룬다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이로 인해 다음과 같은 질문에 압도적으로 빠르게 답할 수 있다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&quot;이 고객과 비슷한 구매 패턴을 가진 다른 고객은?&quot;&lt;/li&gt;
&lt;li&gt;&quot;전화번호를 공유하는 의심 계정 그룹은?&quot;&lt;/li&gt;
&lt;li&gt;&quot;3단계 이내로 연결된 추천 상품은?&quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Neo4j의 핵심 구성 요소&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요소 설명&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;노드(Node)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;엔티티 (Customer, Product, Order 등)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;관계(Relationship)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;노드 간 연결, 방향과 타입을 가짐&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;레이블(Label)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;노드의 분류 (:Customer, :Product)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;속성(Property)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;노드/관계에 붙는 key-value (name: 'Alice')&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Cypher&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Neo4j 전용 쿼리 언어 (SQL과 비슷한 역할)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Edition 구분&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Community Edition&lt;/b&gt;: 무료, 단일 DB, RBAC 미지원&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Enterprise Edition&lt;/b&gt;: 유료, 다중 DB, 세밀한 RBAC 지원&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Aura&lt;/b&gt;: Neo4j가 직접 운영하는 클라우드 호스팅 (Enterprise 기능 포함)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 실습은 &lt;b&gt;Aura(Enterprise)&lt;/b&gt; 환경에서 진행했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. RBAC이란 무엇인가&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;RBAC(Role-Based Access Control, 역할 기반 접근 제어)&lt;/b&gt; 는 사용자에게 권한을 직접 부여하지 않고 &lt;b&gt;역할(Role)을 매개&lt;/b&gt;로 권한을 관리하는 보안 모델이다.&lt;/p&gt;
&lt;pre class=&quot;gcode&quot;&gt;&lt;code&gt;사용자(User)  &amp;rarr;  역할(Role)  &amp;rarr;  권한(Permission)  &amp;rarr;  리소스(Resource)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 RBAC인가?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;직접 권한 부여 방식의 문제:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;직원 100명에게 각각 권한 설정 &amp;rarr; 관리 지옥&lt;/li&gt;
&lt;li&gt;부서 이동 시마다 일일이 수정해야 함&lt;/li&gt;
&lt;li&gt;누가 어떤 권한을 갖는지 추적 어려움&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RBAC의 장점:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;역할 단위로 묶어서 관리 &amp;rarr; 일괄 변경 가능&lt;/li&gt;
&lt;li&gt;신규 입사자에게 역할만 할당하면 끝&lt;/li&gt;
&lt;li&gt;감사(audit) 추적이 명확&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Neo4j에서의 RBAC 범위&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Neo4j Enterprise RBAC는 매우 세밀한 단위까지 제어 가능하다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;데이터베이스 레벨&lt;/b&gt;: 어떤 DB에 접속할 수 있는가&lt;/li&gt;
&lt;li&gt;&lt;b&gt;레이블 레벨&lt;/b&gt;: 어떤 종류의 노드를 볼 수 있는가 (Customer만 OK, EmailAddress는 차단)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;관계 타입 레벨&lt;/b&gt;: 어떤 관계를 따라갈 수 있는가&lt;/li&gt;
&lt;li&gt;&lt;b&gt;속성 레벨&lt;/b&gt;: 노드의 어떤 필드만 노출할 것인가 (name은 OK, ssn은 차단)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 실습의 &lt;b&gt;목적&lt;/b&gt;은 다음을 확인하는 것이었다:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;RBAC을 실제로 적용할 수 있는지&lt;/li&gt;
&lt;li&gt;그래프/노드 단위 메타데이터를 어떻게 설정하는지&lt;/li&gt;
&lt;li&gt;Role별로 노드 접근을 제한해 검색 범위를 조절할 수 있는지&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 실습 환경 및 인스턴스 정보&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;ef9847da-d9f7-4236-959a-f06b5a0a4964.png&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;525&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/oqWmJ/dJMcahEc60l/exRZshu0XKBN3CeJ7FPNE1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/oqWmJ/dJMcahEc60l/exRZshu0XKBN3CeJ7FPNE1/img.png&quot; data-alt=&quot;데이터 셋 - https://neo4j.com/developer/demos/cx-demo/ 에 대해 위 이미지와 같이 dump에 있는 항목을 드래그 앤 드롭 하면 된다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/oqWmJ/dJMcahEc60l/exRZshu0XKBN3CeJ7FPNE1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FoqWmJ%2FdJMcahEc60l%2FexRZshu0XKBN3CeJ7FPNE1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1024&quot; height=&quot;525&quot; data-filename=&quot;ef9847da-d9f7-4236-959a-f06b5a0a4964.png&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;525&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;데이터 셋 - https://neo4j.com/developer/demos/cx-demo/ 에 대해 위 이미지와 같이 dump에 있는 항목을 드래그 앤 드롭 하면 된다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1059&quot; data-origin-height=&quot;769&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c8IaHY/dJMcadIyG0i/wDLVQuybnHuSaefve1AzpK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c8IaHY/dJMcadIyG0i/wDLVQuybnHuSaefve1AzpK/img.png&quot; data-alt=&quot;Schema&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c8IaHY/dJMcadIyG0i/wDLVQuybnHuSaefve1AzpK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc8IaHY%2FdJMcadIyG0i%2FwDLVQuybnHuSaefve1AzpK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1059&quot; height=&quot;769&quot; data-origin-width=&quot;1059&quot; data-origin-height=&quot;769&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Schema&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;항목 값&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;인스턴스 URI&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;neo4j+s://76303afe.databases.neo4j.io&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Neo4j 버전&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;5.27-aura (Enterprise)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;드라이버&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Python neo4j 패키지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;데이터셋&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;E-commerce (실제 거래 + 그래프 분석 결과)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;연결 코드&lt;/h3&gt;
&lt;pre class=&quot;coffeescript&quot;&gt;&lt;code&gt;from neo4j import GraphDatabase
import json

URI  = 'neo4j+s://76303afe.databases.neo4j.io'
AUTH = ('neo4j', '****')  # 환경변수 NEO4J_PASSWORD 권장

driver = GraphDatabase.driver(URI, auth=AUTH)
driver.verify_connectivity()
print('연결 성공')
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;연결 성공
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  &lt;b&gt;Tip:&lt;/b&gt; 비밀번호는 코드에 하드코딩하지 말고 os.environ.get('NEO4J_PASSWORD')로 가져오는 습관을 들이자. Aura는 비밀번호를 한 번만 보여주므로 안전한 곳에 보관 필수.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 데이터 현황 파악&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;본격적인 RBAC 실습 전에 &lt;b&gt;어떤 데이터가 있는지&lt;/b&gt; 파악해야 한다. Neo4j는 메타정보 조회용 프로시저를 기본 제공한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;전체 노드/관계/레이블 조회&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;with driver.session() as s:
    labels = [r['label'] for r in s.run('CALL db.labels()').data()]
    rel_types = [r['relationshipType'] for r in s.run('CALL db.relationshipTypes()').data()]
    node_count = s.run('MATCH (n) RETURN count(n) as c').single()['c']
    rel_count  = s.run('MATCH ()-[r]-&amp;gt;() RETURN count(r) as c').single()['c']

    print(f'노드 총계  : {node_count:,}')
    print(f'관계 총계  : {rel_count:,}')
    print(f'레이블     : {labels}')
    print(f'관계 타입  : {rel_types}')
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;ada&quot;&gt;&lt;code&gt;노드 총계  : 449,848
관계 총계  : 1,192,605
레이블     : ['Order', 'Product', 'Customer', 'Country', 'OrderLineItem', 'Return',
              'EmailAddress', 'Phone', 'Identifier', 'ValidCommunity', 'Outlier']
관계 타입  : ['PURCHASED', 'ORDERED', 'LOCATION', 'LINE_ITEM', 'PRODUCT',
              'FIRST_ORDER', 'LAST_ORDER', 'NEXT', 'ASSOC_EMAIL', 'ASSOC_PHONE',
              'ASSOC_ID', 'SHARED_PII', 'SIMILAR_PURCHASE_TO']
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;총 &lt;b&gt;약 45만 노드, 119만 관계&lt;/b&gt;의 실전급 데이터셋이다. 단순 거래 데이터(Order, Product)뿐 아니라 &lt;b&gt;PII 노드(EmailAddress, Phone, Identifier)&lt;/b&gt; 와 &lt;b&gt;그래프 분석 결과(ValidCommunity, Outlier)&lt;/b&gt; 까지 포함되어 있어 RBAC 실험 대상으로 더할 나위 없이 좋다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;레이블별 노드 수&lt;/h3&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;with driver.session() as s:
    for label in labels:
        cnt = s.run(f'MATCH (n:{label}) RETURN count(n) as c').single()['c']
        print(f'  {label:&amp;lt;20} {cnt:&amp;gt;8,}')
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;  Order                  22,190
  Product                 3,684
  Customer                4,372
  Country                    37
  OrderLineItem         406,829
  Return                  3,654
  EmailAddress            4,213
  Phone                   4,235
  Identifier              4,288
  ValidCommunity          1,101
  Outlier                     6
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OrderLineItem이 40만 건으로 가장 많다. 뒤에서 이 대용량 노드에 메타데이터를 일괄 적용할 때 &lt;b&gt;메모리 이슈&lt;/b&gt;가 발생하는데, 그 해결법도 다룬다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;RBAC 관점의 데이터 분류&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 데이터셋은 RBAC 실습에 이상적이다:&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분류 레이블 민감도&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;공개 가능&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Product, Country&lt;/td&gt;
&lt;td&gt;public&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;내부용&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Customer, Order, OrderLineItem, Return&lt;/td&gt;
&lt;td&gt;internal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;PII (개인정보)&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;EmailAddress, Phone, Identifier&lt;/td&gt;
&lt;td&gt;confidential&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;분석 결과&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;ValidCommunity, Outlier&lt;/td&gt;
&lt;td&gt;restricted&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 분류를 기반으로 Role별 접근 제한을 설계한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. Neo4j의 기본 Role 구조 이해하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Neo4j Enterprise는 5개의 &lt;b&gt;기본 내장 Role&lt;/b&gt;을 제공한다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;with driver.session() as s:
    roles = s.run('SHOW ROLES').data()

print('=== 현재 Role 목록 ===')
for r in roles:
    print(' ', r['role'])
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;=== 현재 Role 목록 ===
  PUBLIC
  architect
  console_admin_pro_76303afe
  console_member_76303afe
  console_viewer_76303afe
  data_importer_76303afe
  editor
  graphql_app_read_only_76303afe
  graphql_app_read_write_76303afe
  publisher
  reader
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;기본 Role 권한 요약&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Role 읽기 쓰기 스키마 변경 사용자/Role 관리&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;PUBLIC&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;기본 권한&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;reader&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;editor&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅(기존 노드만)&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;publisher&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅(신규 포함)&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;architect&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;admin&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Aura가 추가로 제공하는 Role&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;console_*, graphql_app_*, data_importer_* 같이 인스턴스 ID(76303afe)가 붙은 Role은 &lt;b&gt;Aura 플랫폼이 콘솔과 GraphQL 통합용으로 자동 생성&lt;/b&gt;한 것이다. 사용자가 직접 만든 게 아니다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  &lt;b&gt;포인트:&lt;/b&gt; 기본 Role은 &quot;읽기 vs 쓰기&quot; 같이 굵은 단위의 구분만 가능하다. &lt;b&gt;레이블별/속성별&lt;/b&gt; 같은 세밀한 제어를 하려면 &lt;b&gt;커스텀 Role&lt;/b&gt;을 만들어야 한다. 그게 바로 다음 섹션의 주제다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. RBAC &amp;mdash; 노드 레이블별 접근 제어 (이론)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Neo4j Enterprise의 &lt;b&gt;Fine-Grained Access Control(FGAC, 세밀한 접근 제어)&lt;/b&gt; 를 사용하면 특정 Role에 특정 레이블의 노드만 읽기/쓰기 허용이 가능하다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6-1. 커스텀 Role 생성 &amp;mdash; 이론적 Cypher&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래는 우리가 &lt;b&gt;하고 싶었던&lt;/b&gt; 작업이다. (Aura 제약은 다음 섹션에서 다룬다.)&lt;/p&gt;
&lt;pre class=&quot;puppet&quot;&gt;&lt;code&gt;-- 1) 커스텀 Role 생성
CREATE ROLE analyst_role IF NOT EXISTS;
CREATE ROLE pii_restricted_role IF NOT EXISTS;

-- 2) analyst_role: 거래 관련 레이블만 읽기 허용
GRANT MATCH {*} ON GRAPH neo4j NODES Customer TO analyst_role;
GRANT MATCH {*} ON GRAPH neo4j NODES Order    TO analyst_role;
GRANT MATCH {*} ON GRAPH neo4j NODES Product  TO analyst_role;

-- 3) pii_restricted_role: PII 노드 명시적 차단
DENY  MATCH {*} ON GRAPH neo4j NODES EmailAddress TO pii_restricted_role;
DENY  MATCH {*} ON GRAPH neo4j NODES Phone        TO pii_restricted_role;
DENY  MATCH {*} ON GRAPH neo4j NODES Identifier   TO pii_restricted_role;

-- 4) 속성 수준 제어: Customer에서 customerId, segmentId만 노출
GRANT MATCH {customerId, segmentId} ON GRAPH neo4j NODES Customer TO analyst_role;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6-2. GRANT vs DENY 우선순위&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Neo4j는 &lt;b&gt;DENY가 GRANT보다 우선&lt;/b&gt;한다. 이는 보안 원칙(fail-safe defaults)에 따른 설계다.&lt;/p&gt;
&lt;pre class=&quot;puppet&quot;&gt;&lt;code&gt;GRANT  MATCH {*} ON GRAPH neo4j TO some_role;            -- 모든 노드 허용
DENY   MATCH {*} ON GRAPH neo4j NODES EmailAddress;       -- EmailAddress만 차단
&amp;rarr; 결과: EmailAddress 제외한 모든 노드 접근 가능
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6-3. 권한 부여 가능한 액션&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;액션 의미&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TRAVERSE&lt;/td&gt;
&lt;td&gt;노드/관계의 존재만 확인 (속성 못 봄)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;READ {props}&lt;/td&gt;
&lt;td&gt;지정한 속성 읽기&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MATCH {props}&lt;/td&gt;
&lt;td&gt;TRAVERSE + READ 합친 것&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WRITE&lt;/td&gt;
&lt;td&gt;생성/수정/삭제&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CREATE, DELETE, SET PROPERTY&lt;/td&gt;
&lt;td&gt;세부 쓰기 액션&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6-4. 사용자 생성 및 Role 할당&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;-- 사용자 생성
CREATE USER analyst1 SET PASSWORD 'temp1234' CHANGE NOT REQUIRED;

-- Role 할당
GRANT ROLE analyst_role TO analyst1;

-- 확인
SHOW USER analyst1 PRIVILEGES;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자, 이론은 깔끔하다. 그런데 Aura에서 실행하면&amp;hellip;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. Aura에서 마주한 RBAC 제약&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 Cypher를 Aura에서 그대로 실행하면 다음 오류가 발생한다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;Neo.ClientError.Security.Forbidden 
  &amp;mdash; Permission has not been granted for CREATE ROLE
Neo.ClientError.Security.Forbidden 
  &amp;mdash; Permission has not been granted for ASSIGN PRIVILEGE
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 안 될까? &amp;mdash; 권한 진단&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;neo4j 유저가 실제로 어떤 권한을 갖고 있는지 확인해 보자.&lt;/p&gt;
&lt;pre class=&quot;python&quot;&gt;&lt;code&gt;def try_admin_command(cypher, database='system'):
    try:
        with driver.session(database=database) as s:
            result = s.run(cypher).data()
            return result
    except Exception as e:
        return f'오류: {e}'

print('=== neo4j 유저 현재 권한 ===')
privs = try_admin_command('SHOW USER neo4j PRIVILEGES AS COMMANDS')
if isinstance(privs, list):
    for p in privs:
        print(' ', p.get('command', p))
else:
    print(privs)
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;=== neo4j 유저 현재 권한 ===
  DENY  EXECUTE PROCEDURE aura.* ON DBMS TO $role
  DENY  EXECUTE PROCEDURE gds.internal.* ON DBMS TO $role
  GRANT IMMUTABLE ACCESS ON DATABASE `neo4j` TO $role
  GRANT IMMUTABLE ASSIGN ROLE ON DBMS TO $role
  GRANT IMMUTABLE CONSTRAINT MANAGEMENT ON DATABASE `neo4j` TO $role
  GRANT IMMUTABLE DATABASE MANAGEMENT ON DBMS TO $role
  GRANT IMMUTABLE EXECUTE PROCEDURE * ON DBMS TO $role
  GRANT IMMUTABLE INDEX MANAGEMENT ON DATABASE `neo4j` TO $role
  GRANT IMMUTABLE LOAD ON ALL DATA TO $role
  GRANT IMMUTABLE MATCH {*} ON GRAPH `neo4j` NODE * TO $role
  GRANT IMMUTABLE MATCH {*} ON GRAPH `neo4j` RELATIONSHIP * TO $role
  GRANT IMMUTABLE NAME MANAGEMENT ON DATABASE `neo4j` TO $role
  GRANT IMMUTABLE REMOVE ROLE ON DBMS TO $role
  GRANT IMMUTABLE SHOW ROLE ON DBMS TO $role
  GRANT IMMUTABLE TRANSACTION MANAGEMENT (*) ON DATABASE `neo4j` TO $role
  GRANT IMMUTABLE USER MANAGEMENT ON DBMS TO $role
  GRANT IMMUTABLE WRITE ON GRAPH `neo4j` TO $role
  GRANT LOAD ON ALL DATA TO $role

&amp;rarr; ROLE MANAGEMENT / ASSIGN PRIVILEGE 권한 없음 확인
&amp;rarr; Aura는 Cypher로 커스텀 Role 생성 불가
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 발견&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;권한 보유 여부&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;USER MANAGEMENT&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ASSIGN ROLE&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SHOW ROLE&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;REMOVE ROLE&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;ROLE MANAGEMENT&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;❌&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;ASSIGN PRIVILEGE&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;❌&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;USER MANAGEMENT는 있는데 ROLE MANAGEMENT가 없다. 그래서:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;유저는 만들 수 있음&lt;/li&gt;
&lt;li&gt;기존 Role에 유저를 할당할 수도 있음&lt;/li&gt;
&lt;li&gt;&lt;b&gt;하지만 새 Role을 만들거나 권한을 부여하는 것은 불가&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 이런 제약이?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Aura는 Managed Service&lt;/b&gt;다. Neo4j 측이 시스템 안정성을 위해 system DB의 권한 변경을 &lt;b&gt;Aura Console(웹 UI) 레벨에서만&lt;/b&gt; 허용한다. Cypher로 직접 건드리는 것은 막아둔 것.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;print('=== Aura RBAC 제약 요약 ===')
print('  CREATE ROLE : Forbidden (ROLE MANAGEMENT 권한 없음)')
print('  GRANT/DENY  : Forbidden (ASSIGN PRIVILEGE 권한 없음)')
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;=== Aura RBAC 제약 요약 ===
  CREATE ROLE : Forbidden (ROLE MANAGEMENT 권한 없음)
  GRANT/DENY  : Forbidden (ASSIGN PRIVILEGE 권한 없음)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결 방향 3가지&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;Aura Console에서 GUI로 사용자/Role 관리&lt;/b&gt;: 가장 정석. 하지만 커스텀 Role 생성은 여전히 제한적.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;기본 내장 Role(reader, editor, publisher, architect)을 활용&lt;/b&gt;: 굵은 단위 구분만 가능.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;애플리케이션 레이어 RBAC&lt;/b&gt;: 앱 코드에서 Role별 접근 레이블을 제한 &amp;rarr; &lt;b&gt;이번 실습의 메인 우회법&lt;/b&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  &lt;b&gt;교훈:&lt;/b&gt; 클라우드 매니지드 서비스는 편하지만 &lt;b&gt;자유도가 낮다&lt;/b&gt;. RBAC 같은 시스템 레벨 제어가 필요하면 Self-hosted Enterprise를 고려해야 한다. 또는 셀프호스팅과 Aura를 혼용하는 하이브리드 전략도 가능하다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8. 그래프 단위 메타데이터 관리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Neo4j는 &lt;b&gt;그래프(database) 레벨 메타데이터&lt;/b&gt;를 네이티브로 지원하지 않는다. &quot;이 그래프는 누가 소유하고, 어떤 도메인이며, 민감도는 어떻게 되는가?&quot; 같은 정보를 저장할 공식 장소가 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대신 두 가지 패턴으로 해결한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패턴 A &amp;mdash; Metadata 노드 (권장)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특수 레이블 노드를 만들어 그래프 자체의 메타정보를 저장한다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;(:GraphMetadata {
    name: 'ecommerce',
    owner: 'team-a',
    sensitivity: 'high',
    created: date()
})
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패턴 B &amp;mdash; 노드/관계 속성에 태그&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 노드에 직접 메타정보를 박는다. (다음 섹션에서 사용)&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;(:Customer {
    customerId: '17850',
    _access_level: 'confidential',
    _department: 'sales'
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;_ 접두사를 붙이는 이유: 비즈니스 속성과 메타데이터 속성을 명확히 구분하기 위해서.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패턴 A 실습&lt;/h3&gt;
&lt;pre class=&quot;vim&quot;&gt;&lt;code&gt;with driver.session() as s:
    s.run(&quot;&quot;&quot;
        MERGE (m:GraphMetadata {name: 'ecommerce-graph'})
        SET m.owner       = 'data-team',
            m.sensitivity = 'internal',
            m.domain      = 'retail',
            m.created     = date(),
            m.version     = '1.0'
    &quot;&quot;&quot;)

    meta = s.run('MATCH (m:GraphMetadata) RETURN m').data()
    print('GraphMetadata 노드:')
    for m in meta:
        print(dict(m['m']))
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;gcode&quot;&gt;&lt;code&gt;GraphMetadata 노드:
{
  'owner': 'data-team',
  'created': neo4j.time.Date(2026, 4, 28),
  'domain': 'retail',
  'name': 'ecommerce-graph',
  'sensitivity': 'internal',
  'version': '1.0'
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  &lt;b&gt;MERGE vs CREATE:&lt;/b&gt; MERGE는 &quot;있으면 매치, 없으면 생성&quot;이다. 메타데이터 같이 단일성이 보장되어야 하는 노드에는 CREATE 대신 MERGE를 쓰자. 안 그러면 실행할 때마다 중복 생성된다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;9. 노드 단위 접근 레벨 태깅 (대용량 처리)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 각 노드에 _access_level 속성을 붙여서 Role별 필터링에 활용할 준비를 한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;접근 레벨 매핑 설계&lt;/h3&gt;
&lt;pre class=&quot;1c&quot;&gt;&lt;code&gt;ACCESS_LEVEL_MAP = {
    'Customer'      : 'internal',
    'Order'         : 'internal',
    'Product'       : 'public',
    'OrderLineItem' : 'internal',
    'Country'       : 'public',
    'Return'        : 'internal',
    'EmailAddress'  : 'confidential',  # PII
    'Phone'         : 'confidential',  # PII
    'Identifier'    : 'confidential',  # PII
    'ValidCommunity': 'internal',
    'Outlier'       : 'restricted',
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4단계 분류 기준:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;public&lt;/b&gt;: 누구나 볼 수 있음 (상품 카탈로그, 국가)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;internal&lt;/b&gt;: 회사 내부용 (주문, 고객)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;confidential&lt;/b&gt;: 개인정보 (이메일, 전화번호, 식별자)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;restricted&lt;/b&gt;: 분석가/관리자만 (이상치 분석 결과)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;⚠️ 대용량 처리 시 메모리 이슈&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;순진하게 다음과 같이 작성하면 큰일난다:&lt;/p&gt;
&lt;pre class=&quot;gams&quot;&gt;&lt;code&gt;-- ❌ 위험: OrderLineItem 40만 건을 단일 트랜잭션으로
MATCH (n:OrderLineItem)
SET n._access_level = 'internal'
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;40만 건을 하나의 트랜잭션으로 묶으면 메모리 초과로 실패한다. &lt;b&gt;CALL { } IN TRANSACTIONS OF 1000 ROWS&lt;/b&gt; 배치 처리가 필요하다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;배치 처리 코드&lt;/h3&gt;
&lt;pre class=&quot;pony&quot;&gt;&lt;code&gt;with driver.session() as s:
    for label, level in ACCESS_LEVEL_MAP.items():
        summary = s.run(
            f&quot;&quot;&quot;
            MATCH (n:{label})
            CALL (n) {{
                SET n._access_level = $level
            }} IN TRANSACTIONS OF 1000 ROWS
            &quot;&quot;&quot;,
            level=level
        ).consume()
        updated = summary.counters.properties_set
        print(f'  {label:&amp;lt;20} &amp;rarr; {level:&amp;lt;15} ({updated:,} 속성 SET)')
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;  Customer             &amp;rarr; internal        (4,372 속성 SET)
  Order                &amp;rarr; internal        (22,190 속성 SET)
  Product              &amp;rarr; public          (3,684 속성 SET)
  OrderLineItem        &amp;rarr; internal        (406,829 속성 SET)
  Country              &amp;rarr; public          (37 속성 SET)
  Return               &amp;rarr; internal        (3,654 속성 SET)
  EmailAddress         &amp;rarr; confidential    (4,213 속성 SET)
  Phone                &amp;rarr; confidential    (4,235 속성 SET)
  Identifier           &amp;rarr; confidential    (4,288 속성 SET)
  ValidCommunity       &amp;rarr; internal        (1,101 속성 SET)
  Outlier              &amp;rarr; restricted      (6 속성 SET)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;총 45만 건의 노드에 무사히 메타데이터가 박혔다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 포인트&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;CALL (n) { ... } IN TRANSACTIONS&lt;/b&gt;: Neo4j 5.x의 권장 문법. 변수 스코프((n))를 명시한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;OF 1000 ROWS&lt;/b&gt;: 1000개씩 끊어서 트랜잭션 커밋. 메모리 안전.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;summary.counters.properties_set&lt;/b&gt;: 실제로 몇 개 속성이 변경됐는지 확인.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  &lt;b&gt;참고:&lt;/b&gt; Neo4j 4.x 이전 버전에서는 CALL { ... } IN TRANSACTIONS 대신 APOC 라이브러리의 apoc.periodic.iterate를 사용했었다. 5.x부터는 네이티브 지원되니 APOC 의존성을 줄일 수 있다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;10. Role별 검색 범위 조절 &amp;mdash; 애플리케이션 레이어 패턴&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Aura에서 커스텀 Role을 못 만든다면, &lt;b&gt;앱 레이어에서 Role별 쿼리 범위를 동적으로 제한&lt;/b&gt;하는 방식으로 우회한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;설계 &amp;mdash; Role별 접근 레이블 매핑&lt;/h3&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;ROLE_ACCESS = {
    'analyst_role'        : ['Customer', 'Order', 'Product', 'OrderLineItem', 'Country'],
    'pii_restricted_role' : ['Customer', 'Order', 'Product', 'OrderLineItem', 'Country',
                              'Return', 'ValidCommunity', 'Outlier'],
    'reader'              : ['Product', 'Country'],
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;analyst_role: 거래 분석용. 주문, 고객, 상품 OK. &lt;b&gt;PII 차단&lt;/b&gt;.&lt;/li&gt;
&lt;li&gt;pii_restricted_role: PII만 빼고 다 OK. 분석가용.&lt;/li&gt;
&lt;li&gt;reader: 가장 제한적. 공개 정보만.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Role별 검색 함수&lt;/h3&gt;
&lt;pre class=&quot;ceylon&quot;&gt;&lt;code&gt;def search_by_role(driver, role: str, keyword: str):
    &quot;&quot;&quot;주어진 Role이 접근 가능한 레이블 범위 안에서만 키워드 검색&quot;&quot;&quot;
    allowed_labels = ROLE_ACCESS.get(role, [])
    if not allowed_labels:
        return {'error': f'알 수 없는 Role: {role}'}

    results = {}
    with driver.session() as s:
        for label in allowed_labels:
            rows = s.run(
                f&quot;&quot;&quot;
                MATCH (n:{label})
                WHERE any(key IN keys(n)
                      WHERE NOT key STARTS WITH '_'
                        AND NOT key = 'embedding'
                        AND toString(n[key]) CONTAINS $keyword)
                RETURN n LIMIT 3
                &quot;&quot;&quot;,
                keyword=keyword
            ).data()
            if rows:
                results[label] = [
                    {k: v for k, v in dict(r['n']).items()
                     if k not in ('embedding', '_access_level')}
                    for r in rows
                ]
    return results
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;코드 해부&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;allowed_labels 가드&lt;/b&gt;: Role에 매핑된 레이블만 순회. 다른 레이블은 쿼리 자체가 만들어지지 않음.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;keys(n) + any()&lt;/b&gt;: 노드의 모든 속성 중 하나라도 키워드를 포함하면 매치.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;NOT key STARTS WITH '_'&lt;/b&gt;: 메타데이터 속성(_access_level)은 검색 대상에서 제외.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;NOT key = 'embedding'&lt;/b&gt;: 벡터 임베딩 속성처럼 큰 값은 검색 대상에서 제외.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;결과 후처리&lt;/b&gt;: 응답에서도 _access_level, embedding을 제거해서 클라이언트로 안 새도록 함.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실험 1 &amp;mdash; analyst_role로 'RED' 검색&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;result = search_by_role(driver, 'analyst_role', 'RED')
print(json.dumps(result, default=str, indent=2, ensure_ascii=False))
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;Product&quot;: [
    {
      &quot;description&quot;: &quot;RED WOOLLY HOTTIE WHITE HEART.&quot;,
      &quot;stockCode&quot;: &quot;84029E&quot;
    },
    {
      &quot;description&quot;: &quot;HAND WARMER RED RETROSPOT&quot;,
      &quot;stockCode&quot;: &quot;22632&quot;
    },
    {
      &quot;description&quot;: &quot;RED COAT RACK PARIS FASHION&quot;,
      &quot;stockCode&quot;: &quot;22913&quot;
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;analyst_role에 EmailAddress, Phone, Identifier가 매핑되어 있지 않으므로, &lt;b&gt;'RED'라는 키워드가 PII 노드에 들어있어도 결과에 절대 포함되지 않는다&lt;/b&gt;. 이게 우리가 원하던 &quot;PII 차단&quot; 효과다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실험 2 &amp;mdash; reader로 'United' 검색 (좁은 범위)&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;result = search_by_role(driver, 'reader', 'United')
print(json.dumps(result, default=str, indent=2, ensure_ascii=False))
&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;Country&quot;: [
    {
      &quot;countryName&quot;: &quot;United Kingdom&quot;
    },
    {
      &quot;countryName&quot;: &quot;United Arab Emirates&quot;
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;reader는 Product, Country만 볼 수 있으므로 다른 곳에 'United'가 있어도 안 나온다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이 패턴의 한계&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;앱 코드 신뢰가 전제&lt;/b&gt;: DB가 강제하는 게 아니라 앱이 잘 짜여 있어야 함. 우회 가능.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;여러 클라이언트가 같은 DB 붙으면 일관성 깨짐&lt;/b&gt;: Python 앱은 잘 제한해도 BI 도구가 직접 접속하면 무력.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;권한 변경 시 코드 배포 필요&lt;/b&gt;: DB 레벨 GRANT/DENY는 즉시 반영되지만 이건 코드 수정이 필요.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  &lt;b&gt;현실적 조언:&lt;/b&gt; 진짜 보안이 중요하면 Self-hosted Enterprise로 가서 DB 레벨 RBAC을 쓰자. Aura에서는 이 패턴이 &lt;b&gt;차선책&lt;/b&gt;이다. 프로덕션에서는 API Gateway나 백엔드 서비스 레이어에서 한 번 더 걸러주는 다중 방어가 안전하다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;11. Role별 PII 가시성 제어 &amp;mdash; 고객 상세 조회 시나리오&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞 섹션의 search_by_role은 &lt;b&gt;키워드 검색&lt;/b&gt; 범위를 Role별로 다르게 한 것이었다. 여기서는 한 단계 더 들어가, &lt;b&gt;개별 고객 상세 페이지&lt;/b&gt;를 조회할 때 같은 고객 정보라도 &lt;b&gt;Role에 따라 일부 필드를 마스킹&lt;/b&gt;하는 시나리오를 구현한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 패턴은 실제 서비스에서 가장 흔한 RBAC 요구사항이다:&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;고객 정보 페이지를 누구나 열 수 있되, &lt;b&gt;이메일과 전화번호는 PII 권한이 있는 사람에게만&lt;/b&gt; 보여라.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시나리오 설계&lt;/h3&gt;
&lt;div&gt;유저Role접근 가능 _access_level
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;alice&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;reader&lt;/td&gt;
&lt;td&gt;public&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;bob&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;analyst_role&lt;/td&gt;
&lt;td&gt;public, internal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;carol&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;pii_admin&lt;/td&gt;
&lt;td&gt;public, internal, confidential, restricted&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;섹션 9에서 EmailAddress, Phone, Identifier 노드는 _access_level: 'confidential'로 일괄 태깅했음을 기억하자. 그 태깅이 여기서 결실을 맺는다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Role &amp;rarr; 접근 레벨 매핑&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;python&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;ROLE_ACCESS_LEVELS = {
    'reader':       ['public'],
    'analyst_role': ['public', 'internal'],
    'pii_admin':    ['public', 'internal', 'confidential', 'restricted'],
}

USERS = {
    'alice': {'role': 'reader'},
    'bob':   {'role': 'analyst_role'},
    'carol': {'role': 'pii_admin'},
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞 섹션의 ROLE_ACCESS는 &lt;b&gt;레이블&lt;/b&gt; 단위 매핑이었다면, 이번 ROLE_ACCESS_LEVELS는 &lt;b&gt;민감도 레벨&lt;/b&gt; 단위 매핑이다. 두 패턴을 상황에 맞게 섞어 쓸 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;고객 상세 조회 함수&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;python&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;def get_customer_view(driver, username: str, customer_id: str) -&amp;gt; dict:
    &quot;&quot;&quot;Role에 따라 PII 필드를 마스킹한 고객 정보를 반환&quot;&quot;&quot;
    user    = USERS[username]
    allowed = ROLE_ACCESS_LEVELS[user['role']]
    view    = {'user': username, 'role': user['role']}

    with driver.session() as s:
        # 이메일 (confidential)
        if 'confidential' in allowed:
            row = s.run(
                &quot;MATCH (c:Customer {customerId: $cid})-[:ASSOC_EMAIL]-&amp;gt;(e:EmailAddress) &quot;
                &quot;RETURN e.emailAddress AS email&quot;,
                cid=customer_id
            ).single()
            view['email'] = row['email'] if row else None
        else:
            view['email'] = '*** (confidential 권한 필요)'

        # 전화번호 (confidential)
        if 'confidential' in allowed:
            row = s.run(
                &quot;MATCH (c:Customer {customerId: $cid})-[:ASSOC_PHONE]-&amp;gt;(p:Phone) &quot;
                &quot;RETURN p.phone AS phone&quot;,
                cid=customer_id
            ).single()
            view['phone'] = row['phone'] if row else None
        else:
            view['phone'] = '*** (confidential 권한 필요)'

    return view&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;코드 해부&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;권한 체크가 쿼리보다 먼저&lt;/b&gt;: if 'confidential' in allowed 가드 절이 통과하지 않으면 &lt;b&gt;쿼리 자체가 실행되지 않는다&lt;/b&gt;. DB 부하를 줄이는 동시에 우발적 노출을 막는다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;마스킹 메시지의 친절함&lt;/b&gt;: None이 아니라 '*** (confidential 권한 필요)'라고 명시해서, 프론트엔드에서 &quot;왜 안 보이는지&quot; 사용자에게 설명할 수 있게 한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;그래프 탐색 활용&lt;/b&gt;: (Customer)-[:ASSOC_EMAIL]-&amp;gt;(EmailAddress) 패턴으로 PII가 별도 노드에 격리된 그래프 모델의 장점을 그대로 활용. 관계만 끊으면 한 번에 PII 차단 가능.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실행 결과 &amp;mdash; 고객 ID 17850&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;python&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;python&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;for username in ['alice', 'bob', 'carol']:
    view = get_customer_view(driver, username, '17850')
    print(f&quot;유저: {username:&amp;lt;8} | Role: {view['role']}&quot;)
    print(f&quot;  email : {view['email']}&quot;)
    print(f&quot;  phone : {view['phone']}&quot;)
    print()&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;유저: alice    | Role: reader
  email : *** (confidential 권한 필요)
  phone : *** (confidential 권한 필요)

유저: bob      | Role: analyst_role
  email : *** (confidential 권한 필요)
  phone : *** (confidential 권한 필요)

유저: carol    | Role: pii_admin
  email : willy.gallegos@msn.com
  phone : +44 0518 914745&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;동작 원리 요약&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;gcode&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;        alice (reader)              bob (analyst_role)            carol (pii_admin)
            │                              │                              │
            ▼                              ▼                              ▼
   allowed = ['public']        allowed = ['public',          allowed = ['public',
                                          'internal']                    'internal',
                                                                         'confidential',
                                                                         'restricted']
            │                              │                              │
            ▼                              ▼                              ▼
   'confidential' &amp;notin; allowed    'confidential' &amp;notin; allowed       'confidential' &amp;isin; allowed
            │                              │                              │
            ▼                              ▼                              ▼
       마스킹 ***                       마스킹 ***                    실제 PII 조회 ✅&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;reader와 analyst_role은 &lt;b&gt;이메일&amp;middot;전화번호 쿼리 자체가 실행되지 않는다&lt;/b&gt;. DB까지 가지 않으니 로그에도 흔적이 남지 않고, 우회할 여지도 없다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;두 가지 우회 패턴 비교&lt;/h3&gt;
&lt;div&gt;패턴적용 단위예시장점
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;레이블 단위 제한&lt;/b&gt; (섹션 10)&lt;/td&gt;
&lt;td&gt;검색/리스트 조회&lt;/td&gt;
&lt;td&gt;&quot;PII 노드는 검색 결과에 등장 안 함&quot;&lt;/td&gt;
&lt;td&gt;광범위 차단에 유리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;민감도 레벨 단위 마스킹&lt;/b&gt; (섹션 11)&lt;/td&gt;
&lt;td&gt;상세 조회&lt;/td&gt;
&lt;td&gt;&quot;고객은 보이되 이메일은 마스킹&quot;&lt;/td&gt;
&lt;td&gt;부분 공개에 유리&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실무에서는 두 패턴을 &lt;b&gt;함께&lt;/b&gt; 사용한다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;목록/검색 화면&lt;/b&gt;: 레이블 단위 제한으로 PII 노드 자체를 숨김&lt;/li&gt;
&lt;li&gt;&lt;b&gt;상세 화면&lt;/b&gt;: 레벨 단위 마스킹으로 일부 필드만 공개&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이 패턴이 Aura 제약을 우회하는 이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Neo4j 네이티브 RBAC였다면 다음과 같이 했을 것이다:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;cypher&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;puppet&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;DENY MATCH {*} ON GRAPH neo4j NODES EmailAddress TO reader;
DENY MATCH {*} ON GRAPH neo4j NODES Phone TO reader;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 Aura에서는 이 명령이 막혀 있다(섹션 7 참고). 대신 &lt;b&gt;앱 레이어의 분기문&lt;/b&gt;이 똑같은 효과를 낸다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;네이티브: DB가 쿼리 결과에서 제거 (강제력 &amp;uarr;)&lt;/li&gt;
&lt;li&gt;앱 레이어: 앱이 쿼리 자체를 안 보냄 (유연성 &amp;uarr;, 우회 위험 &amp;uarr;)&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  &lt;b&gt;베스트 프랙티스:&lt;/b&gt; 앱 레이어 RBAC을 쓸 때는 &lt;b&gt;DB 접속 계정도 최소 권한&lt;/b&gt;으로 분리하는 것이 안전하다. 예를 들어 reader 유저용 앱 인스턴스는 Neo4j 내장 reader Role 계정으로 접속시키고, pii_admin용은 별도 계정을 쓰는 식. 그러면 앱 코드의 분기문이 뚫려도 DB 레벨에서 한 번 더 걸린다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;12. RBAC 가능 여부 종합 정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기능 가능 여부 방법&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Role별 레이블 읽기 제한&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;GRANT/DENY MATCH ON NODES &amp;lt;Label&amp;gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Role별 속성 노출 제한&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;GRANT MATCH {prop1, prop2} ON NODES&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;관계 타입별 접근 제한&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;GRANT MATCH ON RELATIONSHIPS &amp;lt;TYPE&amp;gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;데이터베이스(그래프) 단위 접근 제한&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;GRANT ACCESS ON DATABASE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;그래프 레벨 메타데이터 (네이티브)&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;별도 메타데이터 노드 패턴 사용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;노드 단위 행(row) 레벨 보안&lt;/td&gt;
&lt;td&gt;⚠️&lt;/td&gt;
&lt;td&gt;속성 태깅 + 애플리케이션 필터링&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;동적 Role 기반 쿼리 범위 조절&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;애플리케이션 레이어 패턴 (search_by_role)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Aura에서 커스텀 Role 생성&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;Console GUI 또는 Self-hosted 사용&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 결론&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Neo4j Enterprise RBAC는 &lt;b&gt;레이블 / 관계 타입 / 속성&lt;/b&gt; 단위까지 세밀하게 제어 가능하다.&lt;br /&gt;다만 &lt;b&gt;행(row) 레벨 보안&lt;/b&gt;은 네이티브 미지원이므로 &lt;b&gt;속성 태깅 + 애플리케이션 필터링&lt;/b&gt; 조합으로 구현한다.&lt;br /&gt;&lt;b&gt;Aura 환경에서는 커스텀 Role 생성이 막혀 있어&lt;/b&gt; 애플리케이션 레이어 RBAC이 현실적인 대안이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;13. 마무리 &amp;mdash; 배운 점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 실습에서 얻은 인사이트를 정리한다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 문서대로 안 되는 게 정상이다&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공식 문서에는 CREATE ROLE이 멀쩡히 적혀 있다. 그런데 Aura에서 시도하면 막혔다. &lt;b&gt;클라우드 매니지드 서비스는 보안을 위해 일부 기능을 의도적으로 제한&lt;/b&gt;한다. 항상 실제 환경에서 테스트해 봐야 한다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 권한 진단부터 시작하라&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오류가 났을 때 단순히 &quot;안 되네&quot; 하고 끝내지 말고 SHOW USER neo4j PRIVILEGES AS COMMANDS로 &lt;b&gt;현재 가진 권한&lt;/b&gt;을 확인하자. 무엇이 막혀있는지 정확히 알면 우회법도 명확해진다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. 대용량 처리는 항상 배치로&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;40만 건에 한 번에 SET 하려다 메모리 터지는 건 흔한 실수다. CALL { } IN TRANSACTIONS OF N ROWS 패턴을 기본 옵션으로 들여놓자.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;4. 메타데이터 속성에는 접두사&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;_access_level, _department 처럼 메타데이터에 _ 접두사를 붙이면 비즈니스 속성과 명확히 구분되고, 검색/응답 필터링 시 한 번에 걸러낼 수 있다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;5. RBAC는 다중 레이어로&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB 레벨 RBAC + 앱 레벨 필터링 + API Gateway. 어느 한 층이 뚫려도 다음 층이 막아주는 게 현실 보안이다. Aura처럼 한 층이 약하면 다른 층을 강화하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;참고 자료&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://neo4j.com/docs/operations-manual/current/authentication-authorization/&quot;&gt;Neo4j Access Control 공식 문서&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://neo4j.com/docs/cypher-manual/current/subqueries/subqueries-in-transactions/&quot;&gt;Neo4j Cypher Manual &amp;mdash; Subqueries in Transactions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://neo4j.com/docs/aura/auradb/security/&quot;&gt;Neo4j Aura 보안 모델&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글이 Neo4j RBAC을 처음 다루는 분들께 시행착오를 줄여주길 바랍니다. 질문은 댓글로 남겨주세요!&lt;/p&gt;</description>
      <category>aura</category>
      <category>cypher</category>
      <category>Neo4j</category>
      <category>pii</category>
      <category>RBAC</category>
      <category>그래프DB</category>
      <category>데이터베이스보안</category>
      <category>접근제어</category>
      <author>임마누엘한</author>
      <guid isPermaLink="true">https://smhanlab.tistory.com/4</guid>
      <comments>https://smhanlab.tistory.com/4#entry4comment</comments>
      <pubDate>Wed, 29 Apr 2026 10:21:27 +0900</pubDate>
    </item>
  </channel>
</rss>