장르: 구축 기록. 사내 그룹웨어의 결재 문서를 조회하는 MCP 서버를 채팅 봇에 붙여 놓고, 실제 답변을 정답표와 대조해 서버 응답을 고쳐 나간 하루의 기록이다.

전제: 서버는 이미 정확했다

사내 그룹웨어의 결재 문서(휴가·지출·근무 신청)를 읽어 주는 MCP 서버를 만들어 두었다. 문서 600건 남짓을 적재해 두고 다섯 개 도구로 검색·집계·전문 조회를 제공한다. 채팅에서 "지난달 반려된 문서 몇 건이야?" 같은 질문을 던지면 봇이 그 도구를 불러 답한다.

도구 자체는 여러 차례 다듬어 둔 상태였다. 날짜 축을 셋으로 나눠 두었고(기안일·실제 사용일· 결재 완료일), 집계는 반려와 회수를 제외한 값을 기본으로 냈고, 응답에는 수집 시각과 커버리지 경고까지 실려 있었다.

그런데 봇의 답이 틀렸다. 그것도 응답에 정답이 들어 있는 채로 틀렸다.

봇을 믿지 않고 판정하는 장치부터 만든다

먼저 필요한 것은 "봇 답이 맞는가"를 싸게 판정하는 방법이었다. 두 가지를 준비했다.

하나. 정답표를 봇 없이 뽑는다. 운영 데이터 사본을 받아, 배포된 것과 같은 이미지를 별도 포트로 띄우고 MCP를 직접 호출했다.

curl -s -X POST localhost:18089/mcp/approvals/ \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
       "params":{"name":"dataset_status","arguments":{}}}'

여기서 두 번 막혔다. 끝의 슬래시가 없으면 307 리다이렉트로 빠져 curl -s빈 출력을 준다. 서버가 죽은 것처럼 보이지만 정상이다(에이전트 쪽 MCP 클라이언트는 리다이렉트를 따라간다). 그리고 Acceptapplication/jsontext/event-stream이 둘 다 있어야 한다. 응답이 SSE 프레임으로 오기 때문이다.

정답표를 파이썬으로 따로 계산하지 않은 이유는 분명하다. 그랬다면 답이 갈릴 때 "봇이 틀렸나, 내 검산 코드가 틀렸나"가 남는다. 같은 코드에 같은 데이터를 물리면 차이는 전부 모델 쪽이다.

둘. 판정은 로그로 한다. 서버가 도구 호출마다 계측 한 줄을 남긴다.

{"ts":"2026-09-02T16:20:01+09:00","tool":"leave_summary","chars":1642,
 "args":{"groupBy":"drafter","status":"valid","dateBasis":"?","dateFrom":"?"},
 "dispatch_ms":5.1,"zero":false}

개인정보 때문에 값이 찍히는 인자는 몇 개뿐이고, 사람 이름과 검색어는 길이만, 나머지는 이름만 남는다("?"). 그런데 이 정도로 충분했다. 이번에 틀릴 수 있는 것이 정확히 "어느 축으로 물었는가"였고, 값은 안 보여도 그 축을 썼다는 사실은 보인다. 개인정보를 안 찍는 설계가 그대로 판정 도구가 됐다.

발견 1. 숫자는 다시 계산되고, 문장은 그대로 옮겨진다

"8월에 휴가 쓴 사람 집계해줘"에 봇이 답했다. 일수 72.5일, 건수 45건, 인원 27명. 앞의 둘은 맞고 인원이 틀렸다. 실제로는 26명이다.

봇이 만든 표를 보니 동점 순위를 매기고 있었다. 1, 2, 2, 4, 5, 5, 5, 8… 그리고 마지막 순위 번호를 인원수로 읽었다. 동점이 있으면 마지막 순위는 항목 수보다 크다.

첫 처방은 당연해 보였다. 서버가 그룹 개수를 실어 보내면 된다.

"groups": 26,
"omittedGroups": {"count": 0, "days": 0}

다음 회차에도 27명이라 답했다. 응답에 26이 있는 채로.

그런데 같은 배포에서 다른 하나는 먹었다. 축 이름을 사람 말로 적어 보낸 필드다.

"countedBy": "by the date each leave started -- '휴가 시작일 기준'. Not the draft date."

봇은 이 문장을 토씨까지 그대로 옮겨 적었다. 직전 회차에서 "기안일 기준, 시작일 기준"이라고 두 축을 함께 적던 것이 한 번에 고쳐졌다.

여기서 규칙이 하나 나왔다.

이 모델은 옮겨 적을 문장은 옮기고, 계산에 쓸 숫자는 자기 계산을 믿는다.

그래서 개수도 문장으로 바꿨다.

"totalsLine": "26명 · 45건 · 72.5일"

다음 회차에서 26명으로 정확해졌고, 순위 열도 순차 번호로 바뀌었다. 같은 처방을 검색 도구에도 넣었다. 상태별 건수를 counts 객체로만 주던 것을 문장으로도 실었다.

"countsLine": "199건 = 완료 191건 · 반려 6건 · 회수 2건"

직전 회차에서 봇은 이 자리를 "완료 189 / 반려 6 / 회수 2 / 진행중 4"라고 적었다. 합이 201인데 바로 한 줄 위에서 자기가 199건이라 말한 뒤였다. 문장으로 주자 정확해졌다.

며칠 뒤 같은 자리에서 한 번 더 걸렸다. 휴가 목록에서 건수는 옮기고 일수만 직접 더해 34.5일이라 적은 것이다(실제 38.5일). 문장이 건수에서 멈춰 있었기 때문이라, 일수까지 담게 확장했다.

"countsLine": "30건 = 완료 28건 (38.5일) · 반려 2건 (4.0일)"

발견 2. 말하지 않은 것을 지어낸다

이 부류가 가장 많았다. 서버는 아는데 응답에 없던 값들이다.

요일과 주 경계. 수요일에 "다음 주 휴가 예정인 사람"을 물었더니 모델이 창을 화요일부터 토요일까지로 잡았다. 답이 살아남은 건 그 휴가가 사흘에 걸쳐 잘못 잡은 창과도 겹쳤기 때문이고, 월요일이나 일요일에만 걸린 휴가였다면 통째로 빠졌다.

상대 기간을 서버가 풀도록 열거형 인자를 넣었다(this_week, next_week, last_month …). 설명이 아니라 파라미터로 넣은 것이 중요하다. 도구 설명은 게이트웨이가 연결될 때 캐시되어 거기 박힌 날짜가 얼어붙기 때문이다. 이 함정은 다른 MCP에서 이미 한 번 겪었다.

그런데 창을 서버가 계산해 준 뒤에도 답변은 요일을 틀리게 적었다. 9월 7일을 일요일이라 쓰고, 9월 9일을 화요일이라 썼다. 넷 다 정확히 하루씩 밀려 있었다. 그리고 같은 답변이 날짜 자체는 서버가 준 값을 그대로 옮겼다.

그래서 요일도 넘겼다. 응답 봉투에 오늘과 3주 범위를 요일까지 붙여 싣고, 휴가 행에는 기간을 한 문자열로도 실었다.

"calendar": {"today": "2026-09-02 (수)",
             "thisWeek": "2026-08-31 (월) ~ 2026-09-06 (일)",
             "nextWeek": "2026-09-07 (월) ~ 2026-09-13 (일)"},
"leave": {"start": "2026-09-07", "end": "2026-09-09",
          "period": "2026-09-07 (월) ~ 2026-09-09 (수)"}

startend는 ISO로 남겼다. 필터와 다음 호출 인자가 읽는 값이라, 사람이 읽을 문자열과 기계가 읽을 값은 같은 필드일 수 없다.

다음 오프셋. 200건 가까운 목록을 페이징하는데 봇이 offset 0, 100 다음에 90을 불렀다. 91번부터 190번까지를 두 번 받아 300행 가까이를 파싱했고, 그 표에서 상태 합계가 어긋나고 한 행이 다른 문서의 날짜를 달았다. 응답이 총계와 반환 수와 현재 오프셋만 주고 다음 오프셋은 모델의 산술로 남겨 두었기 때문이다. nextOffset을 실었고, 마지막 페이지에는 붙이지 않았다. 0으로 붙이면 빈 페이지를 한 번 더 부른다.

집계의 범위. 카테고리를 지정하지 않고 부른 집계(두 종류의 지출 문서를 합친 값)를 봇이 한 종류의 이름으로 부르고, 다른 종류를 그 아래에 별도 항목처럼 덧붙였다. 이미 합계 안에 들어 있는 값이다. 원인은 단순하다. 필터에 카테고리가 없다는 사실은 모델이 읽지 않는다. 없는 필드는 아무 말도 하지 않는다. 그래서 문장으로 실었다.

빈 문서번호. 결재가 끝나지 않은 문서는 번호가 아직 없다. 서버가 "docNo": null을 실어 보냈고 봇이 표의 문서번호 칸에 그대로 null을 찍었다. 페르소나에는 "번호가 없으면 다른 식별자를 쓰라"는 지시가 이미 있었는데도 그랬다. 키를 생략하니 옮겨 적을 것이 사라졌다.

신선도 임계값. "지금 데이터 어디까지 들어와 있어?"에 모든 숫자가 정확했고, 마지막 줄이 "다음 수집은 48시간 이내"였다. 48은 낡음 판정 임계값이지 수집 주기가 아니다. 실제 수집은 하루 세 번이다.

이 경우엔 서버도 답을 모른다. 수집기는 호스트의 스케줄러가 돌리고 MCP 프로세스는 파일의 수정 시각만 본다. 그래서 모른다는 사실을 실었다.

"staleAfterHoursNote": "A freshness threshold, not a collection schedule.
  The collector runs on the host and this server cannot see its schedule,
  so do not infer when the next run is or state one."

설정에 주기를 적어 답하게 하는 안은 버렸다. 스케줄러를 고치고 설정을 안 고치면 봇은 여전히 자신 있게 틀린 시각을 말한다. 관측하지 않는 것을 단언하지 않는 편이 낫다.

발견 3. 호출을 건너뛰면 서버 수정은 0이다

"8월 지출 총액 얼마야?"에 봇이 백만 원 단위 금액 두 개를 냈다. 총액이 항목 하나와 같고 둘의 합과도 다른 자기모순이 유일한 육안 단서였다.

그 값을 찾아 나섰다. 연도와 월 범위를 두 축, 세 상태, 세 카테고리로 조합해 162가지를 전수 조회했고, 계정과목 버킷의 어떤 부분합과도 맞지 않았으며, 다른 코퍼스에서도 나오지 않았다.

로그가 답을 줬다. 그 시각 전후로 도구 호출 줄이 하나도 없었다. 화면에는 도구 설명을 읽었다는 표시만 있었다.

두 번째는 더 나빴다. "2024년 지출 내역 보여줘"에 여섯 건짜리 표를 만들어 냈다. 그 데이터는 2026년치만 수집돼 있어 정답은 0건이고, 서버는 이 상황을 위해 커버리지 문장을 준비해 두었다.

이 범위의 문서는 수집되지 않았다. 0건은 '없음'이 아니라 '미수집'이다. 범위를 바꿔 재시도하지 말 것.

호출이 없었으니 그 문장은 도달하지 못했다. 표에 적힌 계정과목 중 하나는 전 데이터에 한 번도 등장하지 않는 이름이었다.

이 실패의 성질이 고약하다. 답변 형식이 진짜와 구분되지 않는다. "9월 2일 15시 49분 수집 기준, 지출일자 기준" 같은 단서까지 붙어 오히려 더 그럴듯했다. 시각적 차이는 메시지 위의 도구 아이콘 하나뿐이었다.

서버 수정 20건은 호출이 있을 때만 값을 한다. 이날 호출된 케이스는 전부 정확했고, 호출이 없던 둘만 틀렸다.

발견 4. 프롬프트 규칙은 반쯤만 듣는다

호출 생략은 서버가 닿을 수 없는 층이라 페르소나 파일로 갔다. 이미 "모든 질문은 반드시 도구로 조회한다", "금액이나 건수를 추정하지 않는다"가 있었고 두 번 무시된 뒤였다. 그래서 문장을 더 넣는 대신 실패 유형을 직접 지목했다.

- 숫자(금액·건수·일수)는 **이번 턴에 조회한 결과**에서만 인용한다. 앞선 답변이나
  기억에 있는 값을 다시 쓰지 않는다. 같은 질문을 다시 받아도 다시 조회한다.
- 조회 결과가 0건이면 0건이라고 답한다. 표를 만들어 채우지 않는다.

세션을 새로 열고 같은 질문을 던지자 호출이 찍혔고, 답변이 "그 기간은 수집되지 않았다"로 바뀌었다.

같은 방식으로 "결과를 파일로 저장하지 않는다"도 넣었다. 이건 두 번 무시됐다.

차이는 모델이 그 행동을 무엇으로 인식하느냐로 보인다. 앞의 것은 답을 만드는 방식이라 규칙이 곧 답의 형태를 바꾼다. 뒤의 것은 "출력이 너무 크다"는 곤경을 해결하려는 수단이고, 금지당해도 곤경은 남는다. 규칙과 곤경이 충돌하면 곤경이 이긴다.

그래서 경로를 닫았다

"올해 전체 지출 내역을 문서별로 다 보여줘"가 만든 것을 세어 봤다. 도구 호출 네 번으로 24만 자(그중 7만여 자가 중복 페이지), 약 4분, 그리고 개인정보가 담긴 파일이 디스크에 남았다.

봇이 남긴 흔적은 이랬다.

tool output was too large to retain in conversation. Let me save the full data to a file.
✍️ Writing /profiles/<프로필>/expense_docs_part1.json
📖 Reading chatcmpl-tool-<id>.txt L1…
🐍 Running code import json # Load all 3 search_appro…

에이전트 런타임이 큰 도구 응답을 스필 파일로 흘리고, 모델이 그 파일을 되읽어 코드 실행으로 표를 만든다. 그 경로에서는 응답에 실은 어떤 안내도 개입하지 못한다. 소스가 응답이 아니라 파일이기 때문이다. 실제로 같은 답변에서 두 표의 표기 규칙이 서로 달랐다. 그리고 MCP 계측 로그에는 파일 쓰기가 남지 않는다. 내장 도구라서다.

도구 자체를 프로필에서 끄는 것이 구조적 해법이지만 지금은 선택지가 아니었다. 그래서 서버가 경로를 좁혔다.

이전 이후
한 페이지 100행 8만 3천 자 55행 4만 5천 자
페이징 상한 없음 offset 100
도달 가능 최대 199행 24만 자 약 111행 9만 자

상한을 넘으면 행을 주지 않고, 건수와 함께 집계 도구를 가리킨다.

offset 은 100 까지입니다 (받은 값: 200). 이 조건에 199건이 있어 목록으로는 한 대화에
담기지 않습니다. 집계 도구로 묻거나, 월·기안자·부서로 범위를 좁혀 다시 부르세요.

맨 거절은 조건을 바꿔 재시도하게 만든다. 셀 수 있는 답을 함께 주면 멈춘다.

이날 유일하게 기능을 줄인 변경이고, 그것이 의도다. 전량 나열을 원하는 질문은 애초에 집계 도구가 한 번에 답할 질문이다.

한 번은 봇이 맞고 서버가 틀렸다

하루 종일 반대 방향만 보다가 마지막에 뒤집혔다.

"A가 8월에 쓴 택시비"에 커피와 식대만 담긴 카드 지출 문서 두 건이 걸렸다. 원인은 카드 양식 하단의 계정과목 안내문이었다.

여비교통비	출장비, 택시비, 대리운전비, 대중교통비, 통행료 등

검색용 사본에서 이 안내문을 잘라 내는 로직이 있었는데, 마커가 다른 양식 하나만 알고 있었다. 카드 양식은 본문이 안내문뿐이라(항목은 따로 파싱된다) 전체가 검색 대상으로 남아 있었다.

그런데 봇은 그 두 건을 받고도 "택시비 기록은 확인되지 않습니다"라고 답했다. 믿었다면 커피값이 택시비로 보고됐을 것이다. 마커를 추가하니 오탐이 사라졌고, 안내문에만 있는 다른 낱말들도 0건이 됐다.

정리

하루 동안 봇 답변 22건을 대조해 서버를 20번 고쳤다. 되짚어 보면 서버가 틀린 값을 준 적은 거의 없었다. 문제는 말하지 않은 것이었다. 요일, 주 경계, 그룹 개수, 일수, 축 이름, 포함 범위, 다음 오프셋, 카드 문서의 금액, 신선도 임계값의 의미.

그래서 이 작업은 기능 추가라기보다 응답을 자족적으로 만드는 일이었다. 모델이 추론할 여지를 남길수록 답이 틀렸고, 남기지 않을수록 맞았다.

세 층으로 정리된다.

  1. 응답의 모양은 서버가 통제할 수 있고, 효과가 즉각적이었다. 값을 문장으로 바꾸는 것만으로 같은 질문의 답이 세 번 만에 정확해졌다.
  2. 경로도 서버가 좁힐 수 있다. 응답이 대화에 안 들어가는 크기면 모델은 파일과 코드로 샌다. 크기 상한이 그 입구를 막는다.
  3. 호출 여부와 도구 사용은 서버 밖이다. 페르소나 규칙이 절반쯤 듣고, 나머지는 도구를 빼거나 모델을 바꾸는 문제로 남는다.

세 번째가 남아 있는 한 "정확한 답을 만들 준비는 끝났는데, 그 답을 쓸지 말지는 모델이 정한다"는 상태가 계속된다. 그래서 검증 절차의 첫 줄을 이렇게 적어 두었다. 봇의 말이 아니라 로그로 판정한다. 답변만 보면 조회한 값과 지어낸 값이 똑같이 생겼다.

관련 글