장르: 설계 논의 + 구축 기록. 선택지 셋을 비교해 하나를 적용했고, 적용 도중 두 개의 가정이 뒤집혔다. 뒤집힌 지점과 그것을 어떻게 알아챘는지가 이 글의 중심이다.
하루 만에 설계 원칙을 뒤집다 — RAG 엔진을 콘솔 안으로 들이기에서 파이프라인 전체를 관리자 콘솔 안으로 들이면서 프로젝트(Project)가 최상위 스코프가 됐다. 문서·청크·프로필·평가가 전부 프로젝트로 묶이고, DB 하나가 여러 프로젝트를 호스팅한다.
그래서 오늘 콘솔에 프로젝트를 둘 만들었다. 취업 규정(job-rules)과 비용 규정(cost-rules).
남은 질문은 이것이다. Mattermost의 어떤 채널이 어떤 프로젝트를 보게 할 것인가.
1. 결선 지점은 Mattermost가 아니라 에이전트 프로필이다
먼저 경로를 정확히 짚어야 한다. Mattermost는 MCP 서버를 직접 부르지 않는다.
Mattermost 채널
└→ 에이전트 프로필 (채널당 1개, 격리된 홈 디렉터리)
└→ config.yaml: mcp_servers.gt_docs.url
└→ 검색 MCP 서버 (:8765/mcp)
└→ resolve_project() → PostgreSQL "Project" 행
└→ 임베딩 모델 / 리랭커 / 검색 파라미터 / 기본 청킹 프로필
채널 하나가 프로필 하나에 대응하고, 프로필은 자기 config.yaml에 MCP 서버 URL을 하나 갖는다.
"채널을 프로젝트에 묶는다"는 곧 "프로필의 MCP 연결을 어디로 향하게 하느냐"다.
여기서 알게 된 첫 번째 사실은 서버 쪽이 이미 준비돼 있었다는 것이다. 검색 서버는 프로젝트를
코드로 갖고 있지 않다. 도구 호출마다 Project 테이블을 slug로 조회하므로, 콘솔에서 프로젝트를
만들면 서버 재시작 없이 이미 참조 가능하다.
2. 착수하기 전에 봇은 이미 고장 나 있었다
실행 중이던 컨테이너의 환경변수를 확인하다 발견했다.
DEFAULT_PROJECT=company-rules # 컨테이너
job-rules, cost-rules # DB에 실재하는 프로젝트
콘솔에서 프로젝트를 갈아엎는 동안 컨테이너 환경변수만 옛날 값으로 남아 있었다. 봇에게 규정을 물으면 매번 "프로젝트를 찾을 수 없습니다"가 돌아오는 상태였다.
이 고장이 조용했던 이유가 구조적이다. 도구는 프로젝트 조회 실패를 예외로 던지지 않고 문자열로 반환한다. LLM에게 상황을 설명해주기 위한 설계인데, 그 대가로 헬스체크는 계속 초록이다. 컨테이너는 건강하고, 로그는 조용하고, 봇만 틀린다.
지금은 /healthz의 reranker 필드가 이 역할을 겸한다. 기동 시 리랭커 워밍업이 프로젝트 조회를
거치므로, slug가 틀리면 여기가 false로 떨어진다.
{"status":"ok","reranker":true,"db":true,"projects":2,"chunks":262}
우연히 얻은 신호지만 "환경변수가 실재하는 프로젝트를 가리키는가"를 확인하는 가장 싼 방법이라 문서에 명시해뒀다.
3. 선택지 셋 — 격리를 어디서 만들 것인가
| 격리 강도 | 메모리 | 결선이 보이는 곳 | |
|---|---|---|---|
| A. 인스턴스 분리 | 구조적 | 프로젝트당 ~1.1GB | compose (포트로 간접) |
| B. 서버 공유 + 프롬프트 지시 | 모델 준수 의존 | 1.1GB 고정 | 프로필 페르소나 |
| C. URL 쿼리로 고정 | 구조적 | 1.1GB 고정 | 프로필 config.yaml |
세 선택지가 갈리는 근원은 하나였다. 검색 도구 세 개가 project(slug)를 선택 인자로 받고,
값을 채울지는 LLM이 정한다는 것. 서버에는 강제할 수단이 없었다.
B안은 프로필 페르소나에 "항상 project="cost-rules"를 지정하라"고 적는 방식이다. 간단하지만
모델 준수에 의존한다. C안은 URL에 프로젝트를 못 박는 방식인데 구현이 필요하다(서버가
stateless HTTP로 뜨고 도구 함수가 요청 컨텍스트를 보지 않는다).
A안을 골랐다. 프로젝트마다 컨테이너를 하나씩 띄우고 포트와 기본 프로젝트를 다르게 주면, 프로필이 보는 URL 자체가 달라진다. 코드 변경이 없고 격리가 구조에서 나온다.
4. 대가는 메모리, 그리고 그것이 곱해지는 이유
A안의 비용은 메모리 하나다. 인스턴스 1개 시점의 실측(Docker VM 3.84GiB):
| 컨테이너 | 사용량 |
|---|---|
| 검색 MCP 서버 | 1.09GiB |
| 에이전트 게이트웨이 | 0.77GiB |
| Mattermost | 0.37GiB |
| PostgreSQL ×2 | 0.39GiB |
| 합계 | 2.62GiB / 3.84GiB |
인스턴스를 하나 더 얹으면 3.71GiB, VM의 97%다.
여기서 중요한 것은 리랭커 1.09GiB가 프로세스마다 따로 잡힌다는 사실이다. ONNX 런타임이 세션별로 메모리 아레나를 할당하기 때문에, 같은 모델을 같은 이미지에서 쓰더라도 컨테이너 간에 공유되지 않는다. 이미지를 공유해도 메모리는 공유되지 않는다.
이건 벤치마크가 못 본 것 — MCP SDK 2.0 이관과 직렬로 돌고 있던 검색 서버에서 동시 리랭킹 상한을 1로 둔 것과 같은 제약의 다른 얼굴이다. 그때는 한 프로세스 안에서 아레나가 겹치는 문제였고, 이번엔 프로세스를 늘리는 쪽에서 같은 비용이 나왔다.
Docker Desktop 메모리를 6GB로 올렸다(5.80GiB 인식). 2인스턴스 가동 후 약 3.49GiB, 60%.
compose는 YAML 앵커로 공통부를 묶어서 두 서비스의 차이가 환경변수와 포트뿐임을 코드로 드러냈다.
x-rag-common: &rag-common
build: .
image: rag-mcp:local # 두 서비스가 같은 이미지를 쓴다
restart: unless-stopped
mem_limit: 1500m
services:
rag-mcp-job:
<<: *rag-common
ports: ["8765:8765"]
environment:
<<: *rag-env
DEFAULT_PROJECT: "job-rules"
rag-mcp-cost:
<<: *rag-common
ports: ["8766:8765"] # 호스트 포트만 다르다
environment:
<<: *rag-env
DEFAULT_PROJECT: "cost-rules"
5. 설정 파일은 손으로 고칠 필요가 없었다
사내 규정 문서 RAG — MCP 서버부터 크로스인코더 리랭커까지에
적어둔 연동 절차는 config.yaml에 mcp_servers 블록을 직접 써 넣는 것이었다. 그런데 이
에이전트는 설정 파일 수기 편집을 권하지 않는다(CLI로 쓰라고 안내한다). 두 지시가 충돌했다.
확인해보니 config set이 점 표기 중첩 키를 처리한다.
hermes -p finance config set mcp_servers.gt_docs.url http://host.docker.internal:8766/mcp
hermes -p finance config set mcp_servers.gt_docs.timeout 120
hermes -p finance config set mcp_servers.gt_docs.connect_timeout 30
결과 YAML은 수기 편집본과 바이트 단위로 같았다. timeout: 120이 문자열이 아닌 정수로
들어간 것까지. 수기 편집할 이유가 없었으므로 예전 절차에 정정 노트를 달았다.
6. 가정 하나가 재현됐다 — 기동 순서 경합
Docker VM 메모리를 바꾸면 VM이 재시작되어 컨테이너가 전부 한 번 내려갔다 온다. 그때 이런 로그가 남았다.
04:21:06 qna MCP: registered 7 tool(s) from 1 server(s)
04:21:08 finance MCP: registered 0 tool(s) from 0 server(s) (1 failed)
finance 게이트웨이가 자기 검색 서버 컨테이너보다 먼저 떴다. 에이전트는 MCP 초기 연결을
약 4초 안에 3회만 시도하고 포기하며, 그러면 도구 0개인 상태로 굳는다.
구축 당시 "이런 일이 생길 수 있다"고 적어둔 시나리오였는데 실제로 걸린 것은 처음이다. 가정이 사례가 됐으므로 문서를 실측 기록으로 고쳐 썼다. 조치는 해당 게이트웨이만 재시작하는 것이다(다른 채널은 건드리지 않는다).
교훈은 절차 쪽에 있다. Docker 자체를 재시작한 뒤에는 도구 등록 확인을 반드시 다시 한다. 컨테이너가 한꺼번에 뜨면서 순서가 뒤집히기 때문이다.
7. 검증 명령이 애초에 검증을 하지 못하고 있었다
결선을 끝내고 헤드리스 명령으로 확인했다. 구축 당시 문서에 적어둔 방법이다.
hermes -p qna -z "연차휴가는 며칠까지 쓸 수 있어? 사내 규정 근거를 찾아서 알려줘."
봇은 MCP 대신 파일 시스템을 뒤지기 시작했다. 규정 파일을 못 찾겠으니 경로를 알려달라는 답이 돌아왔다. 그런데 게이트웨이 로그에는 도구 7개 등록이 찍혀 있었다.
원인을 좁힌 것은 대조군이었다. 같은 명령을 예전부터 잘 돌던 dev 프로필에 던졌더니 거기서도
MCP를 쓰지 못했다. 프로필 설정 문제가 아니었다.
헤드리스 실행 경로가 MCP 서버를 초기화하지 않는다. 그 실행의 로그에는 MCP 등록 라인이 한 줄도 남지 않는다(플러그인 디스커버리까지만 찍힌다). 게이트웨이 프로세스와는 다른 경로다.
| 헤드리스 실행으로 확인되는 것 | 확인 안 되는 것 |
|---|---|
| 페르소나 지침이 로드됐는가 | MCP 연결·검색·결선이 맞는가 |
즉 결선을 검증하는 경로는 실제 채널 멘션뿐이다. 구축 당시 문서에 적어둔 검증 절차가 그만큼 낡아 있었다. 문제는 이게 조용히 낡았다는 것이다. 명령은 여전히 그럴듯한 답을 내놓기 때문에, 답의 내용을 읽지 않으면 통과한 것처럼 보인다.
8. 같은 상황에서 두 봇이 다르게 행동한 이유
7절의 실험에는 부산물이 있었다. 도구가 없는 동일한 상황에서 dev는 이렇게 답했다.
사내 문서 검색 도구를 사용할 수 없어 규정 근거를 확인할 수 없습니다.
반면 qna는 파일 검색으로 흘렀다. 차이는 페르소나에 넣어둔 가드 한 문단이었다. 도구 목록에
검색 도구가 없으면 다른 수단으로 대체하지 말고 그 사실을 밝히라는 지시. dev에만 있었다.
새 프로필 둘에 이식하면서 한 줄을 더했다.
이 채널의 문서 범위는 취업 규정이다.
A안에서 특히 필요한 문장이다. 인스턴스가 분리돼 있어서 봇은 자기가 못 보는 문서가 있다는 사실 자체를 모른다. 이 줄이 없으면 범위 밖 질문에 "규정에 없습니다"라고 단정한다. 실제로는 옆 채널에 있는데도.
9. 되짚음 — 인스턴스를 나눠도 격리는 절반이었다
결선이 끝난 뒤 나온 질문 하나가 판을 뒤집었다.
그럼 이제 MCP 도구에 project slug를 넘겨받을 필요가 없는 것 아닌가?
맞는 말이었고, 그 이상이었다. 인자가 스키마에 남아 있는 것 자체가 구멍이었다.
search_docs(query="출장비", project="cost-rules") # 취업 규정 채널의 봇이 호출 가능
프로젝트 조회 함수는 어떤 slug든 받는다. URL을 분리해도 인자가 살아 있으면 그대로 옆 프로젝트를 읽어온다. A안이 구조적으로 막은 것은 인자 누락이지 지정이 아니었다.
게다가 당시 "프로젝트를 찾을 수 없음" 메시지는 이렇게 나왔다.
프로젝트를 찾을 수 없습니다: nope-rules (사용 가능: cost-rules, job-rules)
이 문자열은 도구 결과로 LLM에게 그대로 전달된다. 즉 모델에게 옆 프로젝트의 이름을 알려주는 경로였다. 정보 유출과 격리 우회가 같은 한 줄에서 나왔다. 하루 전에 "잘못 넣을 방법도 없다"고 써둔 문서를 고쳐야 했다.
노출면만 좁히고 진단면은 남긴다
고친 것은 두 가지다.
- 도구 세 개에서
project파라미터 제거. 기본 프로젝트로 고정 - 오류 메시지에서 slug 목록 제거. 목록은 stderr로만 기록
핵심은 프로젝트 조회 함수 자체는 손대지 않은 것이다. 좁힌 것은 MCP 노출면뿐이라, 진단 경로는 그대로 살아 있다.
| 경로 | slug를 어떻게 받나 | 고쳤나 |
|---|---|---|
| MCP 도구 (LLM) | 받지 않음 (기본 프로젝트 고정) | 제거 |
| 스모크 CLI (사람) | 명령행 인자 | 그대로 |
| 등가성 테스트 | 케이스 JSON | 그대로 |
같은 정보가 사람에게는 남고 모델에게는 가지 않는다.
[db] project not found: 'nope-rules'; available: cost-rules, job-rules ← stderr, 운영자용
ProjectNotFound: 프로젝트를 찾을 수 없습니다: nope-rules ← LLM에게 가는 것
테스트를 한 줄도 고치지 않았다. 노출면과 진단면을 분리했기 때문이다.
부수 효과로 선택지의 무게가 뒤집혔다. A안이 코드상 기본값이 되고, B안이 되돌림을 요구하는 쪽이 됐다. 안전한 구성이 기본이어야 하므로 의도한 방향이다.
검증은 시그니처가 아니라 스키마로
함수 시그니처를 읽는 것으로는 부족하다. LLM이 실제로 보는 것은 스키마다.
npx @modelcontextprotocol/inspector --cli http://localhost:8765/mcp --method tools/list
# search_docs ['query', 'top_k']
# list_docs []
# read_article ['doc_id', 'article']
그리고 게이트웨이는 등록 시점에 스키마를 캐시한다. 서버를 다시 구워도 게이트웨이를
재시작하지 않으면 봇은 낡은 스키마(project 인자가 있는)를 계속 들고 있다. 6절의 기동 순서
문제와 합쳐서, 스키마를 바꾼 뒤에는 서버 먼저, 게이트웨이 나중이라는 순서가 강제된다.
10. A안의 진짜 단점 — 결선이 어디에도 안 적혀 있다
프로필 설정 파일에는 프로젝트 이름이 없다. 있는 것은 포트뿐이다.
mcp_servers:
gt_docs:
url: http://host.docker.internal:8765/mcp # job-rules라는 말은 어디에도 없다
slug는 다른 저장소의 compose 파일에 있다. 둘을 잇는 것은 포트 번호 하나뿐이라, compose에서 포트를 바꾸면 프로필 설정은 멀쩡해 보이면서 조용히 다른 프로젝트를 가리킨다.
그래서 결선 확인은 실행 중인 상태에서 역추적한다.
for p in qna finance dev; do
port=$(grep -A3 "^mcp_servers:" ~/.hermes/profiles/$p/config.yaml | grep -oE ':[0-9]+/mcp' | tr -d ':/mcp')
name=$(docker ps --filter "publish=$port" --format '{{.Names}}' | head -1)
slug=$(docker inspect "$name" --format '{{range .Config.Env}}{{println .}}{{end}}' | grep '^DEFAULT_PROJECT=' | cut -d= -f2)
printf "%-8s → :%-5s → %-16s → %s\n" "$p" "$port" "$name" "${slug:-?}"
done
qna → :8765 → rag-mcp-job → job-rules
finance → :8766 → rag-mcp-cost → cost-rules
dev → :8765 → rag-mcp-job → job-rules
사람이 프로필만 열어봤을 때 의도를 알 수 있는 유일한 단서는 8절에서 넣은 "이 채널의 문서 범위는 취업 규정이다" 한 줄이다. 봇의 답변 품질을 위해 넣은 문장인데 결과적으로 결선의 자기기술 역할을 겸하게 됐다.
C안(URL 쿼리)은 메모리뿐 아니라 이 문제도 해결한다. 프로필 설정에 slug가 글자 그대로 박히기 때문이다. 프로젝트가 늘면 C안의 이점은 하나가 아니게 된다.
11. 결과와 검증
#qna → rag-mcp-job :8765 → job-rules (취업 규정, 3문서/146청크)
#finance → rag-mcp-cost :8766 → cost-rules (비용 규정, 4문서/116청크)
| 검증 | 결과 |
|---|---|
| 양쪽 헬스체크 | reranker:true (기본 프로젝트 해석 성공) |
| 도구 스키마 | project 인자 없음 |
| 기본 검색 (취업) | "연차휴가" → 04. 인사규정 › 제14조(년차휴가) |
| 기본 검색 (비용) | "출장 여비 정산" → 07. 여비규정 › 제5조(여비의 가지급 및 정산) |
| 오류 경로 | 예외 메시지 깔끔, 후보 목록은 stderr에만 |
| 도구 재등록 | 프로필 3개 모두 7개 |
| 메모리 | 5.80GiB 중 3.49GiB (60%) |
남은 것은 채널 실멘션이다. 7절 때문에 그것만이 MCP 경로 전체를 지난다. 각 채널에서 범위 안 질문과 범위 밖 질문을 하나씩 던져, 같은 질문에 두 채널이 같은 답을 하지 않는지 본다.
12. 이 작업에서 남는 것
세 가지를 배웠고 셋 다 같은 모양이다. 조용한 실패는 검증을 설계할 때가 아니라 검증이 틀렸을 때 드러난다.
- 환경변수가 없는 프로젝트를 가리켜도 헬스체크는 초록이었다. 실패가 예외가 아니라 문자열이라서.
- 검증 명령은 계속 그럴듯한 답을 냈다. MCP를 초기화하지 않은 채로.
- 격리를 구조로 만들었다고 생각했지만 인자 하나가 남아 있었다. 아무도 그 인자를 쓰지 않아서 안 보였다.
세 번째가 특히 그렇다. 실제 위험은 낮았다. 모델이 스스로 옆 프로젝트의 slug를 떠올릴 이유가 별로 없으니까. 다만 그 이름을 알려주는 경로가 오류 메시지 안에 같이 있었다. 두 개의 낮은 확률이 서로를 높이고 있었던 셈이다.
관련 문서
- 하루 만에 설계 원칙을 뒤집다 — RAG 엔진을 콘솔 안으로 들이기 — 프로젝트가 최상위 스코프가 된 전환. 이 글의 전제
- 사내 규정 문서 RAG — MCP 서버부터 크로스인코더 리랭커까지 — 단일 프로젝트 시절의 구축 절차. 5절과 7절에서 두 곳이 낡은 것으로 확인됐다
- 벤치마크가 못 본 것 — MCP SDK 2.0 이관과 직렬로 돌고 있던 검색 서버 — 리랭커 메모리 아레나 제약의 다른 얼굴
- RAG 관리자 콘솔 만들기 — 단계별 관측에서 알고리즘 실험까지 — 프로젝트·청킹 프로필을 만드는 쪽
- 사내 규정 RAG, 한 줄씩 되묻다 — 하이브리드 검색과 리랭커의 내부 동작 — 검색·게이트 내부 동작