장르: 구축 기록 + 분석. 채팅 봇에 이슈 트래커(Jira)를 붙여 두고 하루 동안 30여 개 케이스를 봇에게 직접 시켜 봤다. 봇이 무엇을 할 수 있는지 확정하는 것이 1차 목적이었는데, 실제로 나온 것은 인자 스키마를 감춘 설계가 만드는 비용이었다. 코드 7건을 고쳤고 전부 회귀 확인했다. 조직 식별 정보는 전부 일반화했다.
우리 봇은 사용자마다 자기 계정으로 외부 서비스를 쓴다. 그 구조는 봇은 하나, 계정은 사용자마다 — 사용자별 인증 게이트웨이 MCP에 적었다. 이번 글은 그 게이트웨이에 붙인 이슈 트래커를 실제로 굴려 본 기록이다.
왜 도구를 2개로 압축했나
벤더가 제공하는 공식 MCP 서버에는 도구가 많다. 코드 호스팅 쪽이 48개, 이슈 트래커 쪽이 31개. 둘을 그대로 미러링하면 에이전트 프롬프트에 도구 79개가 실린다. 도구 정의는 이름·설명· JSON 스키마를 포함하므로 그것만으로 컨텍스트를 크게 먹는다.
그래서 게이트웨이는 provider마다 두 개만 노출한다.
atlassian_list_tools() # 다운스트림에 무슨 도구가 있는지
atlassian(tool, arguments) # 그중 하나를 이름으로 호출
atlassian은 봉투다. 모델이 tool에 다운스트림 도구 이름을 문자열로 넣고 arguments에
그 도구의 인자를 통째로 담아 보내면, 게이트웨이가 사용자 토큰을 붙여 그대로 흘려보낸다.
우리 코드에는 이슈 트래커 기능 정의가 하나도 없다.
압축은 의도대로 동작했다. 79개가 4개가 됐다.
대가는 인자 스키마였다
목록을 만드는 코드는 이렇게 생겼다.
desc = (tool.description or "").strip().split("\n")[0][:120]
lines.append(f"- {tool.name}: {desc}")
inputSchema가 통째로 빠진다. 그리고 설명도 첫 줄 120자에서 잘린다. 실제 응답에 잘린
자리가 그대로 보인다.
- getAccessibleAtlassianResources: Get cloudId to make tool calls. When a link is
provided (e.g. https://site.atlassian.net/*), try passing the site hostna
- createIssueLink: Create a link between two issues. For directional link types
(e.g. Blocks): inwardIssue = issue that blocks, outwar
모델이 보는 것은 도구 이름과 잘린 한 줄뿐이다. 어떤 인자가 필수인지, 이름이 무엇인지, 객체인지 문자열인지는 어디에도 없다.
그래서 매번 한 번 틀린다
하루치 실행에서 인자 오류로 인한 재시도가 9회 났다. 무작위가 아니라 패턴이 있었다.
| 상황 | 모델이 보낸 것 | 실제 정답 | 종류 |
|---|---|---|---|
| 검색 | max_results |
maxResults |
표기 |
| 검색 | cloudId 누락 |
(필수) | 필수 인자 |
| 이슈 생성 | project, issue_type |
projectKey, issueTypeName |
이름 |
| 이슈 수정 | description(최상위) |
fields.description |
구조 |
| 상태 전이 | transitionId: "31" → transition: "31" |
transition: {"id": "31"} |
구조 |
| 담당자 지정 | assignee: "<id>" |
assignee: {"accountId": "<id>"} |
구조 |
탐색 순서가 일정하다. 평평한 스칼라를 먼저 시도하고, 실패하면 이름을 바꾸고, 그다음 객체로 감싼다. 그리고 더 흥미로운 것은 직전 성공 패턴을 다음 도구에 전이한다는 점이다. 수정·전이·할당에서 "중첩이 정답"을 세 번 배운 뒤, 그다음 이슈 생성 호출에서는 이렇게 보냈다.
{"fields": {"project": {"key": "SBX"}, "issuetype": {"name": "작업"}, "summary": ""}}
REST API 원형에 가까운 형태다. 그런데 이 도구는 평평한 projectKey/issueTypeName을 받는다.
도구마다 규칙이 다른데 스키마가 없으니 일반화가 어긋난다.
다행인 것은 오류 메시지가 친절하다는 점이다.
MCP error -32602: Input validation error: Invalid arguments for tool createJiraIssue:
Required at projectKey
Required at issueTypeName
그래서 대부분 2차 시도에서 성공한다. 비용은 모든 쓰기 호출이 대략 2배가 되는 것이다.
진짜 문제는 오류가 안 날 때다
9회 중 8회는 오류로 드러나 결국 성공했다. 딱 한 번, 다운스트림이 200 OK로 조용히 삼켰다.
담당자를 지정하는 호출이었다. 모델은 먼저 자기 계정 조회로 accountId를 정확히 얻었고, 그것을 문자열로 넣었다.
{"fields": {"assignee": "<accountId>"}, "issueIdOrKey": "SBX-1"}
이 필드는 객체를 요구한다. 문자열은 오류 없이 무시된다. 응답은 200이었고 이슈 본문이
정상적으로 돌아왔다. 다만 assignee는 null 그대로였다.
재시도할 신호가 없으니 모델은 다른 결론으로 갔다.
담당자를 지정하려고 시도했지만 결과에서
assignee가 여전히null로 보여. 가능한 원인:
- 프로젝트 권한: 이 프로젝트에 담당자 지정 권한이 없을 수 있어
- 워크플로우 제한: 현재 상태에서 담당자 변경이 막혀 있을 수 있어
필요하면 프로젝트 관리자에게 권한 확인을 요청해줘.
세 추측 모두 틀렸다. 사용자가 이 안내를 따르면 있지도 않은 권한 문제를 쫓게 된다. "accountId를 객체 형태로 넣어서 다시 해줘"라는 한 줄을 주자 첫 시도에 성공했다. 스키마 부재가 유일한 원인이었다는 뜻이다.
이 부류가 왜 특히 나쁘냐면, 게이트웨이가 잡을 수 없기 때문이다. 이번 작업에서 오류 표시를 세 갈래로 보강했는데(아래) 이 경로는 그중 어디에도 걸리지 않는다. 다운스트림이 200을 주기 때문이다.
같은 부류가 날짜에서도 나왔다
"8월 26일에 만들어진 이슈 있어?"라고 물었다. 그날 만들어진 이슈가 하나 있었다. 모델이 보낸 쿼리는 이렇다.
project = PROJ AND created >= 2026-08-26 AND created <= 2026-08-26 ORDER BY created DESC
created는 날짜가 아니라 시각이다. 시각 없이 쓴 2026-08-26은 그날 00:00을 뜻하므로,
이 조건은 자정 한 순간만 가리킨다. 그 이슈는 08:28에 만들어져 빠졌다.
봇의 답은 이랬다. "8월 26일에 만들어진 이슈는 없습니다."
오류가 나지 않았다. 0건은 유효한 결과다. 사용자가 이 답을 반증하려면 이슈 트래커를 직접 열어 보는 수밖에 없다. 정답표를 미리 만들어 두지 않았다면 통과했을 실패다.
조치는 도구 설명에 규칙을 적는 것이었다. 도구의 docstring이 곧 모델에게 보이는 사용설명서라 거기에 넣으면 모든 프로필과 환경에 자동으로 적용된다.
그런데 첫 수정이 불완전했다. "하루를 조회하려면 다음 날로 상한을 잡아라"만 가르쳤더니, 바로 다음 질문에서 같은 버그가 기간으로 자리를 옮겼다.
created >= "2026-08-31" AND created <= "2026-09-06" -- 일요일 하루가 통째로 빠진다
이번에는 결과가 0건이라 우연히 맞았고, 응답 문구도 수정 전후가 완전히 동일했다.
그 날짜에 데이터가 생겨야만 표면화되는 종류다. 규칙을 상한 일반("상한은 언제나 < 다음 날")으로
바꾸고, 테스트도 하루 예시가 아니라 기간 예시를 요구하도록 고쳤다. 규칙을 좁게 가르치면
좁게 적용된다.
고친 것들이 전부 "이미 아는 원칙"이었다
코드 7건을 고쳤는데, 그중 셋은 이 저장소가 이미 세워 둔 원칙이 한 곳에만 빠져 있던 것이었다.
| 원칙 | 세워둔 곳 | 빠져 있던 곳 |
|---|---|---|
| "링크를 주지 않으면 모델이 빈칸을 상상으로 채운다" | 연결 관리 도구의 주석 | 토큰 서비스 3곳, 프록시, 캘린더 |
| "응답이 스스로 상태를 밝혀야 한다" | 캘린더 도구의 빈 결과 처리 | 범용 프록시 |
| "타 사용자 정보는 절대 싣지 않는다" | 연결 관리 모듈 주석 | 범용 프록시 |
첫 번째는 이렇게 드러났다. 게이트웨이 주소를 바꾼 직후, 토큰 갱신이 거부되자 봇이 재연결 링크로 옛 주소를 안내했다. 도구 응답에 URL이 없으니 모델이 지난 대화에서 끌어온 것이다. 프롬프트에는 "이전 대화의 링크를 재사용하지 마라"는 규칙이 이미 있었고 지켜지지 않았다. 응답이 정답을 실어 보내면 모델이 채울 빈칸 자체가 없어진다.
두 번째는 더 비쌌다. "이번 주에 생성된 이슈 알려줘"에 0건이 왔는데 그게 정답이었다. 그런데 모델이 0건을 믿지 못하고 같은 검색을 세 번 더 반복했다. 도구 호출 8회, 81초. 첫 호출에서 끝났어야 할 일이다. 캘린더 쪽에서 같은 문제를 이미 한 번 고쳤는데 그 수정이 캘린더 파일에만 들어갔고 범용 프록시에는 없었다. 응답에 "조회는 정상적으로 끝났고 결과가 0건입니다"를 붙이자 8회가 1회로 줄었다.
새 통찰이 필요했던 게 아니라 이미 배운 것을 나머지 한 곳에 적용하는 일이었다. 범용 프록시는 "우리 코드가 아니다"라는 이유로 매번 예외가 됐다.
오류 표시가 세 번 뚫려 있었다
실패를 알리는 접두사는 isError 플래그만 보고 붙는다. 그런데 실패가 세 형태로 온다.
| 형태 | 언제 | 접두사 |
|---|---|---|
isError: true |
표준 경로 | 원래부터 붙음 |
본문에 {"error": true, ...} |
한정자 없는 쿼리를 서버가 거부할 때 | 안 붙었음 |
평문 MCP error -32602: ... |
인자 검증 실패 | 안 붙었음 |
뒤의 둘은 실패가 성공처럼 흘렀다. 응답 접두사로 실패 계층을 가르는 진단 방식이 성립하려면 셋 다 붙어야 한다.
배포 검증의 함정 — 고쳐도 안 고쳐진 것처럼 보인다
코드를 고치고 컨테이너를 다시 빌드하고 게이트웨이까지 재시작한 뒤 같은 질문을 했다. 옛 동작이 그대로 나왔다. 두 번.
| 질문 | 첫 시도 | "다시 확인해줘" |
|---|---|---|
| 8월 26일 이슈 | 도구 미호출. "앞서 조회했을 때 결과가 비어 있었어요" | 도구 호출 → 이슈 발견 + 자기 정정 |
| 담당자 조회 | 도구 미호출. 옛 응답 재사용 | 도구 호출 → 새 동작 |
첫 시도에서는 고쳐진 코드가 전혀 실행되지 않았다. 봇이 세션에 남은 지난 도구 결과를 재사용했기 때문이다. 표면만 보면 "재빌드까지 했는데 그대로다"가 된다. 하마터면 수정이 잘못됐다고 결론 낼 뻔했다.
규칙은 하나다. 배포 후 회귀 확인은 새 세션에서 한다. 기존 세션에서 하려면 재조회를 명시적으로 요구하고, 세션 DB에서 도구가 실제로 불렸는지 확인해야 한다. 도구 호출이 없으면 그 응답은 검증 대상이 아니다.
재미있는 것은 같은 메커니즘이 상황에 따라 최적화가 되기도 한다는 점이다. 30초 전에 한 같은 질문을 반복했을 때 봇이 도구를 또 부르지 않고 "방금 조회한 결과 그대로입니다"라고 답한 것은 옳은 동작이다. 차이는 그 사이에 대상이 바뀌었는가뿐이고, 봇은 그걸 알 수 없다. 그래서 판단을 봇에게 맡길 수 없다.
예상 밖의 발견 — 자동 압축이 낡은 날짜를 치료한다
이 봇은 세션의 시스템 프롬프트에 Conversation started: <날짜>를 넣는데, 그 값이 세션
생성일에 고정된다. 오래된 채널에서는 봇이 믿는 "오늘"이 몇 주 전이 된다.
이번 라운드는 그 문제가 이슈 검색에서 재현되는지 보려고 낡은 세션 셋을 준비해 뒀다. 그런데 테스트 도중 저장된 프롬프트가 스스로 바뀌었다.
압축 전: Conversation started: Tuesday, August 11, 2026
압축 후: Conversation started: Thursday, September 03, 2026
컨텍스트가 임계(전체의 85%)를 넘어 자동 압축이 돌면서 프롬프트를 새로 만든 것이다.
낡은 날짜는 영구적이지 않다. "압축이 일어나기 전까지"다. 트래픽이 많은 채널은 스스로 낫고, 조용한 채널만 낡은 채로 남는다.
이 비대칭이 진단을 어렵게 한다. 활발한 채널에서 재현이 안 된다고 버그가 없는 것이 아니다.
덧붙여, 준비해 둔 재현 환경 하나가 그 압축으로 사라졌다. 대가도 실측됐다. 압축에 44초와 72초가 걸렸고 메시지가 61→47, 47→29로 줄었다. 요약이므로 세부가 사라진다. 쓰기 케이스 6개를 도는 동안 임계에 닿았는데, 이슈 응답 하나가 4KB 남짓이라 소모가 빠르다.
정작 검증하려던 것은 통과했다
원래 이 라운드의 가설은 "낡은 날짜 때문에 기간 검색이 틀릴 것"이었다. 기각됐다. 방어선이 두 겹 있었다.
- 암묵적 기간("이번 주")에는 모델이 쿼리 언어의 서버측 상대 함수(
startOfWeek())를 골랐다. 날짜 계산을 서버가 하므로 모델의 "오늘"이 틀려도 결과가 맞는다. - 명시적 날짜("오늘 며칠이야")에는 시각 조회 도구를 먼저 불렀다.
두 경우 모두 저장된 프롬프트가 낡은 상태에서 정답이 나왔다. 다만 이건 게이트웨이가 보장하는 것이 아니라 모델이 알아서 잘 고른 것이다. 모델이 바뀌면 결론도 바뀔 수 있다. 근본 해결은 인자를 평평하게 감싼 정적 도구로 승격하는 것이고, 그러면 위의 재시도 9회도 함께 사라진다.
남은 것
두 가지를 별도 과제로 뺐다.
쓰기에 범위 제한이 없다. 대조군으로 둔 프로젝트에 "아무거나 닫아줘"라고 하자 봇이 되묻지 않고 스스로 하나를 골라 닫았다. 사후에는 "다른 것도 닫을 거 있으면 말해 줘"라고 덧붙였다. 게이트웨이의 정책 훅은 코드 호스팅 provider에만 걸려 있고, 이슈 트래커는 계정 권한만 본다. 프롬프트 규칙으로 막을지 데이터 계층에서 막을지가 남은 결정인데, 이번 라운드가 반복해서 보여준 것은 데이터 계층에서 막은 것은 지켜지고 프롬프트로 막은 것은 지켜지지 않는다는 점이다.
봇의 내장 도구가 사용자 경계를 무시한다. 외부 서비스 토큰 격리는 완벽했다. 사용자별로 정확히 자기 계정이 나왔고, 세션을 공유하는 스레드에서도 그랬다. 그런데 한 사용자의 질문에 봇이 내장 세션 검색 도구를 쓰더니 다른 사용자의 1:1 대화 세션을 찾아 그 스니펫을 반환했다. 붙인 MCP와 무관한, 봇 프레임워크 설정 문제다.
이번에 배운 것
- 도구 폭발을 막은 대가는 스키마다. 79개를 4개로 줄인 것은 옳았지만, 그 압축이
inputSchema까지 버린다는 것은 설계 시점에 값이 매겨지지 않았다. 값은 모든 쓰기 호출이 2배로 나타난다. - 오류가 나는 실패는 싸고, 200 OK로 삼키는 실패는 비싸다. 전자는 모델이 재시도해 스스로 해결한다. 후자는 모델이 원인을 지어내고 그 추측을 사용자에게 사실처럼 전달한다.
- 응답이 스스로 상태를 밝혀야 한다. "없다"와 "못 가져왔다"를 구분해주지 않으면 모델이 도구의 한계를 넘겨짚는다. 같은 교훈을 한 번 배우고도 다른 파일에 적용하지 않아 다시 겪었다.
- 규칙은 좁게 가르치면 좁게 적용된다. "하루"만 가르친 날짜 규칙이 "기간"에서 그대로 재발했다.
- 배포 검증은 새 세션에서 한다. 세션에 남은 옛 결과가 수정 효과를 가린다.
관련 글
- 봇은 하나, 계정은 사용자마다 — 사용자별 인증 게이트웨이 MCP — 이번에 시험한 게이트웨이를 만든 기록
- 값은 맞는데 답이 틀린다 — MCP 응답의 모양이 답을 바꾼다 — 응답의 모양이 모델의 답을 바꾸는 같은 부류의 관찰
- 에이전트가 도구 0개로 답하고 있었다 — MCP 연결 상태를 두 축으로 보는 API — 도구가 없는데도 그럴듯하게 답하는 문제