장르: 구축 기록. 사내 위키를 채팅 봇에 붙여 놓고 읽기·검색·쓰기·권한을 40여 개 질문으로 훑은 하루의 기록이다. 결함 열 개를 찾아 고쳤고, 그 과정에서 어제 내린 결론 하나가 뒤집혔다.
어제 도구 79개를 2개로 압축한 대가 — 스키마를 감추면 모델은 매번 한 번 틀린다를 썼다. 벤더 MCP 도구를 그대로 노출하면 프롬프트가 터지니 "목록 조회 + 범용 호출" 두 개로 압축했고, 그 대가로 인자 스키마가 사라져 모델이 매번 한 번 틀린다는 이야기였다.
오늘 같은 게이트웨이에 위키를 붙여 다시 훑었는데, 그 글의 전제가 틀렸다는 것을 알게 됐다.
스키마는 감춰진 적이 없었다
우리 프록시는 벤더 도구 목록을 이렇게 줄여서 모델에게 준다.
for tool in result.tools:
desc = (tool.description or "").strip().split("\n")[0][:120]
lines.append(f"- {tool.name}: {desc}")
설명 첫 줄 120자만 남기고 나머지를 버린다. 어제는 여기서 inputSchema가 버려진다고 적었다.
그래서 오늘 첫 조치로 필수 인자 이름만 되살리는 코드를 넣었다.
schema = getattr(tool, "inputSchema", None)
if not isinstance(schema, dict):
return ""
배포하고 같은 질문을 던졌다. 필수 인자 표기가 0개였고, 인자 누락 오류는 그대로 3회 났다.
원인은 필드명이었다. 클라이언트 라이브러리의 Tool 객체는 input_schema(스네이크 케이스)인데
inputSchema(카멜 케이스)로 읽었다. 없는 속성이라 getattr가 조용히 None을 돌려주고
힌트 생성이 통째로 건너뛰어졌다. 예외도 로그도 남지 않는다. 실측으로 확인하지 않았다면
"규칙을 넣었는데도 안 되네"로 끝났을 것이다.
필드명을 고치고 나서 실제로 오는 값을 찍어 봤다.
getConfluencePage -> ['cloudId', 'pageId']
searchConfluenceUsingCql -> ['cloudId', 'cql']
createConfluencePage -> ['cloudId', 'spaceId', 'body']
스키마는 처음부터 멀쩡히 오고 있었다. 우리가 잘못된 이름으로 읽고 있었을 뿐이다.
이 차이가 중요한 이유는 조치가 정반대이기 때문이다. "벤더가 안 준다"면 우리가 문서를 써서 채워야 하고, "우리가 잘못 읽는다"면 한 줄만 고치면 된다. 어제의 글은 전자를 전제로 "압축의 대가"라는 서사를 짰는데, 사실은 그냥 버그였다.
덤으로 하나 더 나왔다. 120자 절단이 실제로 뜻을 자르고 있었다. 검색 도구의 원문 설명은
"CQL is specific to Confluence and is not interchangeable with JQL"
인데 CQL is speci에서 끊겨 경고가 통째로 사라져 있었다. 파라미터 설명에 들어 있던
"space 필드에는 이름이 아니라 키를 쓰라"는 문장도 못 보고 있었다. 상한을 200자로 올렸다.
규칙은 네 번 샜다
이 라운드에서 "도구 설명에 규칙을 적는" 방식으로 네 번 시도했고 네 번 다 샜다.
| 규칙 | 어떻게 샜나 |
|---|---|
목록에 (필수: cloudId, …) 표기 |
위 버그로 무효. 고친 뒤에도 다른 도구에서 재발 |
| 검색 재시도 상한 | 규칙 전에는 없는 문서 하나를 찾으려고 검색어를 8종으로 쪼개 16회 호출 |
| "코드 실행으로 감싸 부르지 말 것" | 배포한 뒤에도 같은 우회가 또 발생 |
| "REST API 직접 호출을 권하지 말 것" | 이건 통했다. 다만 같은 부류가 형태를 바꿔 다시 나왔다 |
세 번째가 특히 나빴다. 도구 응답이 커지면 호스트(에이전트 런타임)가 결과를 파일로 내보내고
모델에게는 경로만 준다. 그러면 모델은 그 파일을 파싱하려고 코드 실행 도구를 부르고,
위험 명령 승인 대기에 걸려 대화가 6분간 멈춘다. /deny조차 먹지 않아 게이트웨이를
재시작해 끊어야 했다.
금지 문구를 도구 설명에 넣었는데도 세 번째가 또 났다. 그때 로그가 원인을 알려줬다.
Inline-truncating large tool result (39,385 chars)
Persisted large tool result (39,493 chars -> /tmp/agent-results/*.txt)
임계가 약 39KB였다. 그리고 그 39KB는 결과가 content와 structuredContent에
중복 적재된 뒤의 크기다. 즉 우리 응답이 19,700자만 돼도 파일로 나간다.
우리가 걸어 둔 응답 상한은 60,000자였으니 한 번도 방어선이 된 적이 없었다.
상한을 15,000자로 낮췄다. 그 뒤로 코드 실행 우회는 한 번도 나오지 않았다.
규칙을 더 정교하게 쓰는 것보다 트리거 자체를 없애는 쪽이 확실했다. 프롬프트는 확률이고 임계는 조건이다.
뺄 것이 아니라 남길 것을 고른다
응답 크기를 줄이는 방법도 세 번 만에 정확해졌다.
1차, 문자 단위로 자르기. 60,000자에서 끊었다. 결과는 더 나빠졌다. 배열 중간에서 끊기니 JSON이 깨졌고 모델이 25건 중 1건만 건졌다. 조용히 자르지는 않았지만 데이터가 못 쓰게 됐으니 자르기 전보다 나빴다.
2차, 항목 단위로 자르기. 목록의 항목 수를 줄여 JSON을 유효하게 유지하고 "18/25건만 실었습니다"라고 밝혔다. 봇은 그 고지를 정확히 전달했지만, 여전히 파일로 나갔고 코드 실행 우회도 그대로였다.
3차, 필드를 걷어내기. 처음에는 본문(body)만 빼려 했다. 측정해 보니 절반밖에 안 줄었다.
| 도구 | 원본 | 본문만 제거 | 남길 필드만 |
|---|---|---|---|
| 스페이스 페이지 목록 | 58,714자 | 34,600자 | 3,877자 |
| CQL 검색 | 68,678자 | 35,600자 | 6,378자 |
본문이 44~61%를 차지하지만, 버전 정보(172자)와 작성자 ID(45자 × 2) 같은 잔여 필드가 25건에 걸쳐 쌓여 나머지 절반을 만든다. 그래서 뺄 것을 고르는 대신 남길 것을 정했다. 제목, id, 상위 id, 상태, 수정일, URL. 그러면 25건을 전부 유지한 채 15배가 줄어든다.
같은 질문의 세 시점을 재봤다.
| 수정 전 | 항목 절단 | 최종 | |
|---|---|---|---|
| 최대 응답 | 203,679자 | 148,225자 | 12,053자 |
| 결과 | 1건만 건짐 | 18/25건 + 파일 오프로드 | 25/25건 |
| 도구 호출 | 6~10회 | 6회 + 코드 실행 시도 | 3회 |
| 소요 | 108초 | 6분(승인 대기) | 55초 |
목록 질문의 답은 제목과 식별자다. 본문이 필요하면 문서 하나를 따로 열면 된다. 그리고 무엇을 뺐는지 응답이 스스로 밝히게 했다. 조용히 자르면 사용자는 그게 전부라고 믿는다.
한 번은 이 솎기가 사용자가 요청한 필드를 지웠다. "수정일까지 표로" 라고 했는데 버전 정보를 통째로 버려서, 봇이 다른 도구로 날짜를 다시 구해야 했다. 답은 맞았고 봇이 정직하게 밝히기까지 했지만 호출이 한 번 늘었다. 버전에서 날짜와 번호만 남기도록 고쳤다. 위험이라고 적어 둔 항목이 그대로 발현했고, 적어 뒀기 때문에 원인을 바로 알았다.
중간 단계에서 호출이 오히려 늘었다
없는 문서를 찾는 질문 하나를 세 시점에 측정했다.
| 시점 | 도구 호출 | 인자 누락 오류 |
|---|---|---|
| 수정 전 | 12회 | 3회 |
| 필수 인자 표기 후 | 16회 | 0회 |
| 재시도 상한 규칙 후 | 3회 | 0회 |
가운데 칸이 이 라운드에서 가장 배운 것이다. 결함 하나를 고치자 총 호출이 늘었다. 인자 오류로 막히던 자리에서 이제는 성공적으로 더 많이 탐색했기 때문이다. 봇은 검색어를 8종으로 쪼개 스페이스마다 돌렸다. "오류가 줄면 비용도 준다"는 직관이 틀렸고, 두 결함이 서로 다른 층에 있었다는 증거였다.
봇이 자기 스킬을 고쳤고, 한 줄이 위험했다
이 에이전트는 스스로 스킬 문서를 편집한다. 라운드가 끝나고 백업과 비교하니 세 줄이 늘어 있었다.
- 절단 고지를 "실패가 아니라 부분 결과"로 해석하고 3회 정제 후 보고하라 (맞다)
- 스페이스 조건에는 문자열 키를 쓰고
AND type = page로 거르라 (맞다) - 응답이 크면 도구가 결과를 디스크에 저장하니 그 파일을 파싱하라 (위험하다)
세 번째가 오늘 대화를 세 번 멈추게 한 바로 그 우회다. 게다가 이제는 사실도 아니다. 필드 솎기 이후 파일로 나가지 않는다.
일괄 삭제하지 않고 그 한 줄만 고쳤다. 맞는 두 줄은 다음 라운드의 자산이다. 장애 중에 배운 것이 장애가 끝난 뒤에도 남는다는 것은 알고 있었는데, 그게 메모리가 아니라 스킬 파일에 굳는 것은 처음 봤다.
예측이 빗나간 쪽도 있다
미리 겁먹었던 것 하나는 멀쩡했다. 이슈 트래커용으로 넣어 둔 날짜 규칙은 그쪽 문법 어휘로만 쓰여 있어서 위키 검색 문법에는 적용되지 않으리라 봤다. 실제로는 그대로 전이됐다.
lastModified >= "2026-08-31" AND lastModified < "2026-09-07"
lastModified >= now('-7d') AND type = page order by lastModified desc
상한을 "다음 날 미만"으로 잡는 규칙이 필드 이름을 넘어 작동했다. 하루 경계만 예시로 들었던 규칙을 기간까지 일반화해 뒀던 것이 여기서 값을 했다.
그리고 시키지도 않았는데 봇이 실행한 질의를 응답에 함께 보여줬다. 그 덕에 날짜 경계가 맞는지 눈으로 검증할 수 있었다. 조용한 오답의 근본 대책이라 아예 지침으로 명문화하기로 했다.
남은 것
고치지 못하고 결정으로 미룬 것이 셋이다.
쓰기에 아무 경계가 없다. "읽기 전용으로 정한 공간에 문서 만들어줘"라고 하자 되묻지 않고 그냥 만들었다. 이슈 트래커 쪽에서도 확인 없이 대조군 이슈를 닫은 적이 있으니, 두 제품에서 같은 결함이 확인된 셈이다. 게이트웨이에는 코드 저장소용 소유자 정책만 있고 위키·트래커용 대응물이 없다. 공간 화이트리스트를 넣을지, 아예 읽기 전용 스위치로 시작할지를 정해야 한다.
문서 수정 도구는 본문 전체를 받는다. 오늘 세 번 모두 봇이 원본을 먼저 읽고 합쳐서 썼기 때문에 내용이 보존됐다. 도구가 이어붙이기를 지원해서가 아니다. 모델이 읽기를 건너뛰면 그 순간 덮어쓰기가 된다. 같은 질문에 다르게 답하는 모델이라 운에 맡길 수는 없다.
개인 공간이 서로에게 열려 있다. 사용자 A가 사용자 B의 개인 공간 문서 25건을 공개 채널에서 전부 조회했다. 이건 위키의 기본 권한 설정이지 게이트웨이의 결함이 아니다. 다만 웹에서는 사람이 일부러 찾아가야 보이는 것을, 봇은 한 줄 질문에 표로 펼친다. 그 차이가 사고의 크기를 정한다.
하루를 요약하면
열 개의 커밋 중 절반은 "이미 세운 원칙이 이 경로에만 빠져 있던 것"이었다. 연결이 끊겼을 때 링크를 함께 주는 것, 결과가 없으면 없다고 밝히는 것, 남의 이메일을 싣지 않는 것. 전부 다른 모듈에서는 이미 하고 있었는데 벤더 API를 그대로 통과시키는 경로에서만 빠져 있었다. "우리 코드가 아니다"라는 이유로 매번 예외가 됐던 것 같다.
그리고 가장 값진 발견은 코드가 아니라 어제 쓴 글이 틀렸다는 사실이었다. 선행 결론을 검증 없이 물려받아 그 위에 첫 수정을 얹었고, 한 번 더 실패하고 나서야 원인에 닿았다. 직접 확인하지 않은 결론은 남의 것이든 내 것이든 똑같이 위험하다.
관련 글
- 도구 79개를 2개로 압축한 대가 — 스키마를 감추면 모델은 매번 한 번 틀린다 — 이 글이 정정하는 어제의 결론
- 값은 맞는데 답이 틀린다 — MCP 응답의 모양이 답을 바꾼다 — 응답이 스스로 상태를 밝히게 한다는 원칙의 출처
- 봇은 하나, 계정은 사용자마다 — 사용자별 인증 게이트웨이 MCP — 이 게이트웨이의 설계