게임 레포 규칙 문서 250KB를 MCP 서버로 자르기
규칙은 다섯 줄에서 시작했다. 안내판이 빈 판으로 보인 사고가 나서 한 줄이 늘었다. 점프가 영구히 잠긴 사고에 또 한 줄, 이펙트가 자기 히트박스에 걸린 사고에 또 한 줄. 다 원인을 찾아서 적어뒀고 같은 사고는 두 번 안 났다. 그렇게 아들과 만드는 로블록스 게임 레포의 CLAUDE.md 가 10KB를 넘었다.
새 세션을 열면 에이전트가 그 10KB를 읽고 시작한다. 필요한 규칙은 그중 두 줄이다. 나머지 문서까지 합치면 250KB인데 그건 아예 안 읽는다. 안 읽은 쪽에 그날 필요한 줄이 있으면 사고가 다시 난다.

트렌딩에 올라온 것들이 같은 얘기를 하고 있었다
이 문제를 어떻게 할지 생각하기 전에, 그날 GitHub 트렌딩을 보고 있었다. 8월 6일 기준으로 하루에 스타를 많이 받은 레포들이 이런 것들이었다.
obra/superpowers 는 방법론을 떼어냈다. 스킬을 배포 가능한 패키지로 만든 것이다. addyosmani/agent-skills 도 같은 것인데 70개 이상 에이전트를 타깃으로 잡았다. TencentCloud/TencentDB-Agent-Memory 는 기억을 떼어내서 개인 파일에 있던 걸 팀 공유 자산으로 올렸고, huangruiteng/loopx 는 루프 상태를 떼어내 목표와 게이트와 증거를 커널로 만들었다.
따로 보면 각각 다른 소식인데, 묶어 보면 전부 같은 일을 하고 있다. 작년까지 각자 CLAUDE.md에 손으로 적던 것을 레이어로 뜯어내는 작업이다. 방법론, 기억, 루프 상태. 여기에 7월 말에 나온 MCP 명세 개정이 붙는다. 상태를 서버가 들고 있지 않게 바뀌면서 클라이언트·서버 구현이 눈에 띄게 단순해졌다.
내 문제도 정확히 그 목록에 들어가는 종류였다. 규칙과 문서를 CLAUDE.md 한 파일에 쌓아 올리는 대신, 물어보면 답하는 층으로 떼어내면 된다. 그리고 그걸 붙이는 규격이 MCP다.
다른 프로젝트에서 같은 문제를 그렇게 푼 걸 본 적이 있었다. 프로젝트 문서를 MCP 도구로 노출하고, 도구 설명에 "작업 시작 전 반드시 호출"을 박아 넣은 서버였다. 도구 여섯 개가 도메인별로 문서를 담당하고, 키워드로 순위를 매겨 상위 몇 개만 돌려준다. 그 구조를 이번에 게임 레포에 옮겼다. 코드를 가져온 게 아니라 형태만 봤다. 도메인 이름도 문서 이름도 전부 다르기 때문에 옮길 것이 애초에 없었다.
옮기려고 재보니 문서의 모양이 달랐다
먼저 양쪽 문서를 셌다.
참고한 쪽은 마크다운 파일이 118개에 총 928KB, 파일당 평균 7.9KB 였다. 도메인별 폴더 6개로 정리돼 있었다. 내 게임 레포는 파일이 14개인데 총 250KB 라 파일당 평균이 18.3KB 다. 정리는 번호 붙인 평평한 파일이었다. 파일 수는 8분의 1인데 파일 하나가 두 배 넘게 두껍다.
여기서 첫 번째 문제가 나왔다. 참고한 서버는 파일 단위로 순위를 매겨 상위 8개를 돌려준다. 파일이 118개고 평균 7.9KB니까 8개면 60KB 남짓이고, 전체의 6%다. 걸러진다.
같은 방식을 게임 레포에 쓰면 문서가 14개인데 8개를 돌려준다. 절반 이상이다. 게다가 파일 하나가 평균 18KB라 반환량이 150KB를 넘는다. 아무것도 걸러지지 않는다. 파일 수가 적고 파일이 큰 문서에서는 파일 단위 검색이 성립하지 않는다.
그래서 단위를 바꿨다. 파일이 아니라 heading으로 쪼갠 섹션을 검색 단위로 삼고, 개수 상한 대신 바이트 예산으로 자른다. 문서를 ##·### 기준으로 쪼개고, 각 조각에 원본 경로와 줄 번호를 붙여서 인덱스를 만들었다. 도메인별 섹션 수는 이렇게 나왔다.
get_rules 는 CLAUDE.md 를 맡아 섹션 6개에 10,466자다. get_combat_design 은 설계서와 게임분석과 로드맵을 묶어 58개 섹션 44,284자, get_gacha_economy 는 소환설계와 수익화와 선물을 묶어 56개 섹션 57,131자다. get_engine_notes 는 학습노트와 워크플로우로 27개 섹션 23,388자, get_release_guide 는 배포와 브랜딩과 스토어아트로 63개 섹션 83,473자, get_status 는 HANDOFF 와 README 로 43개 섹션 75,443자가 나왔다. 섹션 수만 보면 배포 쪽이 63개로 제일 많은데 크기도 83,473자로 제일 크다. 반대로 get_rules 는 섹션이 6개뿐이라 애초에 쪼갤 여지가 거의 없다. 이 차이가 뒤에서 절감률로 그대로 나온다.
get_rules가 섹션 6개뿐인 게 눈에 걸리는데, 이게 나중에 문제가 된다.
이 표를 만들면서 하나 걸렸다. 문서 14개 중 13개만 도구에 배정돼 있었다. 아이콘·썸네일 프롬프트를 적어둔 문서 하나가 어느 도구에도 안 붙어서, 그 서버로는 절대 안 나오는 문서였다. 도구 여섯 개를 손으로 나누면 이런 게 조용히 생긴다. 그래서 "배정 안 된 문서가 있는지"를 세는 확인을 따로 돌렸고, 그 문서는 배포·브랜딩 쪽에 붙였다. 스토어에 올리는 이미지 얘기니까 배포 작업 중에 필요하다.
의존성 없이 짰다
구현 언어를 고를 때 셋 중에 하나였다. 참고한 쪽처럼 TypeScript에 공식 SDK를 붙이거나, Python에 MCP 패키지를 붙이거나, 프로토콜을 직접 구현하거나.
이 레포의 tools/는 이미 순수 python3 스크립트 다섯 개다. 빌드·교차참조 검사·썸네일 생성이 전부 표준 라이브러리로 돌아간다. 이 게임 프로젝트에는 "에셋 의존 0"이라는 규칙이 있는데, 파일만 열면 돌아가야 한다는 뜻이다. 도구 쪽도 같은 정신으로 유지돼 있었다. 여기에 npm install이나 pip install을 요구하는 파일을 하나 넣으면 그게 깨진다.
그래서 직접 구현하는 쪽을 골랐다. 이게 가능한지가 실은 위에서 말한 트렌드의 주장을 시험하는 부분이었다. 명세가 단순해져서 손으로 짤 만하다는 얘기가 맞는지.
맞았다. stdio 전송으로 도는 MCP 서버가 실제로 처리해야 하는 건 JSON-RPC 메서드 세 개다.
initialize— 프로토콜 버전과 서버 정보를 돌려준다tools/list— 도구 목록과 입력 스키마tools/call— 도구 실행
여기에 id가 없는 메시지(알림)를 무시하는 처리와 ping 응답을 더하면 끝이다. 표준 입력에서 한 줄씩 읽어 JSON으로 파싱하고, 표준 출력에 한 줄씩 쓴다. 세션도, 재연결도, 상태 저장도 없다. 이 서버가 기억하는 유일한 상태는 파싱해둔 문서 인덱스이고, 그건 프로토콜과 무관한 캐시다.
결과는 server.py 582줄, 회귀 테스트 105줄. import는 json·os·re·sys·dataclasses·pathlib뿐이고 전부 표준 라이브러리다. 참고한 쪽이 TypeScript 706줄에 SDK 의존성 하나였는데, 줄 수는 거의 같고 의존성만 없어졌다. 명세가 단순해졌다는 주장은 이 규모에서는 사실이었다.
한 가지 정직하게 적어둘 게 있다. initialize에서 프로토콜 버전을 협상하지 않고 클라이언트가 보낸 값을 그대로 되돌려준다. 엄밀하게는 서버가 지원하는 버전을 골라 응답해야 하는데, 로컬 개발 도구라 협상을 엄격하게 할 이유가 없어서 넘어갔다. 여러 클라이언트에 배포할 서버라면 이렇게 두면 안 된다.
"반드시 호출"은 코드가 아니라 설명에 있다
이 패턴의 핵심은 검색 알고리즘이 아니다. 도구 설명 문구다.
"get_rules": "코드를 쓰기 전 반드시 호출. 절대 규칙 10개·구조 원칙·
재발견 비용이 큰 기술 사실을 우선순위로 반환한다."
에이전트는 도구 목록을 보고 언제 무엇을 부를지 스스로 정한다. 설명에 "코드를 쓰기 전 반드시"가 들어 있으면 그 조건이 판단에 들어간다. CLAUDE.md에 "규칙을 지켜라"라고 쓰는 것과 뭐가 다른가 하면, 지시가 호출 가능한 행동에 붙어 있다는 점이 다르다. 문서에 적힌 당부는 컨텍스트 안에서 다른 만 줄과 경쟁하지만, 도구 설명은 도구를 고르는 순간에 읽힌다.
강제는 아니다. 에이전트가 안 부르고 코드를 쓸 수 있다. 훅으로 막는 것과 다르고, 그 차이는 뒤에서 다시 적는다.
grep으로 하면 되지 않나
이 지점에서 바로 나올 반론이 있다. 에이전트는 이미 Grep을 갖고 있다. 서버를 하나 짜는 대신 문서를 검색하게 하면 되지 않나.
재봤다. 회귀 케이스의 질문을 문장 그대로 문서 전체에 리터럴로 grep하면 이렇게 나온다.
안내판이 빈 판으로 보인다, 포즈가 애니메이터에 덮인다, 뽑기 확률 합, 게임 공개가 막혀 있다, 커밋 전에 뭘 돌려야 하나. 다섯 개 전부 0건이 나왔다. 당연하다. 문서에 그 문장이 그대로 적혀 있을 이유가 없다.
그런데 여기서 정직하게 붙일 게 있다. 문장이 아니라 단어로 자르면 grep도 찾는다. "빈 판" 한 단어로 grep하면 1건이 나오고, 그게 정확히 내가 원하는 줄이다. 왜 나오냐면 규칙을 적을 때 증상을 아이 말로 적어놨기 때문이다. "반대로 두면 빈 판으로 보인다"라고 써 있다. "덜컹"도 2건 나온다. 서버가 속도를 넣으면 덜컹거린다고 적어놨다.
그러니까 "grep은 못 찾는다"는 틀린 말이다. 정확한 차이는 두 개다.
첫째, grep은 검색어를 이미 알고 있을 때 듣는다. "빈 판"이 문서에 있는 표현이라는 걸 알아야 그걸로 grep한다. 몰라서 물어보는 상황에서는 그 단어를 못 고른다. 이번에 잘 걸린 것들은 내가 증상을 사람 말로 적어둔 덕분이고, 그건 운이다. 문서에 SurfaceGui.Face로만 적혀 있고 증상이 안 적혀 있으면 grep은 0건이다.
둘째, grep이 돌려주는 단위가 파일이다. 1건을 찾으면 그 파일을 읽어야 하고, CLAUDE.md는 10KB다. 도구는 heading으로 잘린 조각과 원본 줄 번호를 준다. 위 표의 "포즈가 애니메이터에 덮인다"는 4,378바이트짜리 4조각으로 돌아온다. 학습노트 전체는 13KB다.
동의어 층이 하는 일이 첫 번째를 메우는 것이다. "포즈"를 물으면 poses·Motor6D·Transform·PreSimulation·Rig까지 같은 뜻으로 놓고 찾는다. 이건 grep에 없는 것이고, 내가 이 프로젝트에서 어떤 말이 어떤 말과 같은 것인지 알기 때문에 표로 적을 수 있는 것이다. 범용 검색이 대신해줄 수 없는 부분이 여기다.
점수는 heading이 압도하게 뒀다
점수식에서 하나 조정한 게 있다. 참고한 쪽 공식을 그대로 옮기면 이렇게 된다. 파일명이 검색어와 같으면 1200점, 포함하면 700점, 본문에 나오면 등장 횟수당 40점을 최대 10회까지. 그리고 질문의 여러 단어 중 몇 개가 걸렸는지 비율로 최대 600점을 더한다.
섹션 단위로 옮길 때 이 비율을 그대로 뒀다. heading 일치가 본문 빈도를 압도한다. 이유는 문서 크기 때문이다. 이 레포의 섹션은 큰 게 몇 KB씩 되는데, 본문 등장 횟수만으로 매기면 긴 섹션이 늘 이긴다. 어떤 단어든 긴 글에 더 많이 나온다. 반대로 heading은 그 조각이 무엇에 관한 것인지를 사람이 직접 붙여둔 라벨이다. 「확률 · 천장」이라는 heading은 그 섹션이 확률 얘기라는 걸 저자가 보증한 것이고, 본문에 "확률"이 40번 나오는 것보다 강한 증거다.
실제로 그 덕에 걸린 케이스가 있다. "뽑기 확률 합"은 「4. 확률 · 천장」이 아니라 「2.1 확률 합이 119.001% 였다」를 1위로 물어온다. heading에 "확률 합"이 그대로 들어 있어서다. 그게 맞는 답이다. 확률 합이 119%였다는 건 이 프로젝트에서 실제로 났던 사고고, 합을 물어보는 사람이 봐야 하는 건 천장 설계가 아니라 그 사고 기록이다.
코드 펜스 안의 #은 heading이 아니다
문서를 heading으로 쪼갤 때 걸린 것 하나. 마크다운 코드 블록 안에도 #으로 시작하는 줄이 있다. 셸 주석이다. 그걸 heading으로 잡으면 섹션이 코드 블록 중간에서 잘리고, 잘린 조각의 제목이 주석 문장이 된다.
세어보니 이 레포 문서에 그런 줄이 2개 있었다. 아이콘 프롬프트 문서 안이다. 많지 않지만 하나만 있어도 그 섹션은 깨지고, 깨진 걸 알아차릴 방법이 없다. 검색은 여전히 뭔가를 돌려주기 때문이다. 그래서 파서에 펜스 상태를 들고 다니게 했다. ```를 만나면 안팎을 뒤집고, 안에 있는 동안은 heading 판정을 건너뛴다.
같은 이유로 문서를 매 호출마다 다시 읽지 않는다. 250KB를 호출마다 파싱하면 응답이 느려진다. 대신 파일별로 수정 시각을 기억해두고, 바뀐 파일만 다시 읽는다. 문서를 고치는 중에 서버를 재시작하지 않아도 되는 게 이 결정의 실제 이득이다. 규칙을 한 줄 추가하고 바로 물어보면 그 줄이 나온다.
검사를 붙였더니 하나가 빈손으로 돌아왔다
랭킹 로직은 조용히 틀린다. 동의어 하나를 지우면 특정 질문이 엉뚱한 문서로 가는데, 그건 에이전트가 잘못된 코드를 쓴 뒤에야 드러난다. 그래서 실제로 나왔던 질문을 회귀 케이스로 박았다. 아이가 말한 표현을 그대로 썼다.
- "안내판이 빈 판으로 보인다"
- "점프가 안 된다"
- "넉백이 덜컹거린다"
- "뽑기 확률 합"
- "포즈가 애니메이터에 덮인다"
각 질문이 기대한 섹션을 상위 3개 안에 물어오는지 본다. 첫 실행은 11/12였다.
[FAIL] get_engine_notes :: "포즈가 애니메이터에 덮인다"
실제 상위 3: []
0건이다. 하나도 못 찾았다. 원인은 한국어 조사였다. 토큰을 공백으로만 쪼개니까 "포즈가"가 그대로 하나의 토큰이 된다. 동의어 표에 등록해둔 건 "포즈"이고, "포즈가"는 그것과 다른 문자열이다. "애니메이터에"도 같다. 세 토큰 전부 아무 그룹에도 걸리지 않아서 매칭 0으로 떨어졌다.
이게 참고한 쪽에서 드러나지 않았던 이유가 재밌다. 그쪽 회귀 케이스에도 한국어가 있는데 "포커스 업데이트"처럼 조사가 없는 명사뿐이었다. 결함은 원본에도 있었고, 질문의 말투가 달라지자 드러난 것이다. 사내 문서를 검색하는 개발자는 키워드를 던지고, 게임 만드는 초등학생은 문장으로 말한다.
고치는 방법으로 형태소 분석기를 붙이는 건 뺐다. 의존성 0을 유지해야 했다. 대신 조사 목록을 두고 어미를 규칙으로 떼냈다.
KOREAN_PARTICLES = (
"에서는", "으로는", "이라고", "에서도", ...
"은", "는", "이", "가", "을", "를", "에", "의", "도", "만", ...
)
조사가 안 떨어지면 끝 글자를 하나 잘라본다. "덮인다"는 조사가 없으니 "덮인", "덮"으로 줄여서 문서의 "덮어쓴다"에 닿게 한다. 거칠지만 검색은 후보를 넓게 잡아도 heading 점수가 순위를 잡아주기 때문에 성립한다.
고친 뒤 12/12가 됐다. 그 질문은 학습노트의 「애니메이션: 에셋 업로드 없이 하는 법」 섹션을 물어온다. Motor6D.Transform이 Animator에게 매 프레임 덮이니까 RunService.PreSimulation에서 써야 이긴다는 내용인데, 애초에 그 사고를 겪고 적어둔 줄이다.
절감이 나오는 데와 안 나오는 데가 갈렸다
질문마다 도메인 전체 대비 얼마를 돌려주는지 재봤다.
게임 공개가 막혀 있다는 질문은 도메인 전체 83,473자 중 11,921자만 돌려줘서 86% 가 줄었다. 뽑기 확률 합은 57,131자에서 9,228자로 84%, 흑섬 판정창은 44,284자에서 11,865자로 73%, 포즈가 애니메이터에 덮인다는 23,388자에서 4,378자로 81% 다. 그런데 점프가 안 된다는 10,466자에서 8,403자로 20% 밖에 안 줄었고, 커밋 전에 뭘 돌려야 하나는 10,466자에서 9,079자로 13% 에 그쳤다. 뒤의 둘은 둘 다 get_rules 로 들어간 질문이다. 원래 문서가 10KB 뿐이고 섹션이 6개라 어느 조각을 골라도 전체와 크기가 비슷해진다. 자를 게 없는 문서에 검색을 붙이면 이런 숫자가 나온다.
문서가 큰 도메인에서는 73~86% 줄었다. 배포 쪽은 83KB에서 12KB만 오고, 그 12KB에 「오디언스 도달」 섹션이 들어 있다. 공개가 막힌 이유를 여기서 찾게 된다.
문제는 아래 두 줄이다. get_rules는 13~20%밖에 안 줄었다. 이유가 구조적이다. CLAUDE.md가 10KB인데 섹션이 6개뿐이라 조각 하나가 크다. 게다가 「절대 깨면 안 되는 규칙」 섹션은 주제와 무관하게 항상 붙이도록 해뒀는데 그것만 2KB다. 12KB 예산에서 2KB를 고정으로 쓰고 남은 걸로 두세 조각을 담으면 곧 10KB에 닿는다. 원본을 거의 다 주는 셈이다.
즉 이 도구는 규칙 파일에 대해서는 거의 아무것도 안 하고 있다. 정작 "코드 쓰기 전 반드시 호출"이 붙은 도구가 그쪽이다. 문서가 큰 도메인에서 잘 듣고, 제일 자주 부를 도구에서는 안 듣는다.
이건 랭킹을 손봐서 될 일이 아니라 문서를 쪼개야 하는 문제다. 절대 규칙 10개를 한 섹션에 두는 대신 항목별로 나누면 "이펙트가 히트박스에 걸린다"에 4번 규칙만 줄 수 있다. 아직 안 했다. 규칙 파일을 쪼개면 사람이 읽을 때 흐름이 끊기고, 그 손해가 실제 이득보다 큰지 아직 모른다.
증명 안 된 것
여기까지가 이번에 한 것이고, 안 한 것을 분명히 적어둔다.
에이전트가 규칙을 덜 깨는지는 재지 않았다. 측정한 건 검색 정확도와 반환량이다. 맞는 섹션이 상위에 오고 컨텍스트가 줄어든 건 사실이지만, 그래서 사고가 줄었는지는 다음 몇 주의 작업이 있어야 나온다. 지금 "규칙 위반이 줄었다"고 쓰면 그건 측정이 아니라 기대다.
호출을 강제하지 않았다. 도구 설명에 "반드시"라고 써 뒀을 뿐이고 안 부르고 코드를 쓸 수 있다. 진짜 강제는 훅 쪽 일이다. 이 게임 레포에는 이미 check_refs.py가 교차 참조 15종을 검사하고, 그건 모델이 뭐라고 주장해도 안 바뀌는 사실 검사다. 문서를 읽었는지 여부는 그런 종류의 사실이 아니라서 같은 방식으로 못 박는다.
문서가 맞다는 보장은 이 서버가 주지 않는다. 검색만 한다. 틀린 문서를 잘 찾아주면 틀린 답이 더 빨리 온다. 250KB 중에 낡은 줄이 몇 개인지 나는 모른다. 실제로 이 작업을 시작할 때 내 세션 메모에는 게임 레포 경로가 예전 위치로 적혀 있었고, 코드 규모도 "38파일 19,315줄"로 남아 있었다. 세어보니 42파일 24,706줄이었다. 문서는 이렇게 조용히 낡는다.
아직 안 풀린 자리
트렌딩 목록을 보고 시작한 일인데, 결과물은 남의 프레임워크를 설치한 게 아니라 687줄짜리 파이썬 파일 하나였다. 흐름에서 가져온 건 코드가 아니라 문서를 층으로 떼어내라는 방향이었고, 그 방향이 이 레포에 맞는지는 옮겨보고 나서야 알았다. 문서 개수와 크기의 분포가 달라서 검색 단위를 파일에서 섹션으로 바꿔야 했고, 질문의 말투가 달라서 조사 처리를 넣어야 했다. 둘 다 붙여보기 전에는 안 보였다.
지금 상태는 이렇다. 도구 6개, 회귀 12개 통과, 문서 14개 전부 배정, 문서가 큰 도메인에서 73~86% 절감, 규칙 파일에서 13~20%. 다음은 규칙 섹션을 쪼갤지 결정하는 일이고, 그건 몇 주 써보고 사고가 실제로 줄었는지를 본 다음이다.
앞선 글들에서 이 게임이 어떻게 시작됐는지 적었다. 만들라고 하기 전에 공부하라고 시켰다에서 지시 네 줄 중 세 줄을 공부에 쓴 얘기를 했는데, 그때 에이전트가 만든 학습 문서 네 개가 지금 250KB의 시작이었다. 공부를 시켜서 문서를 얻었고, 문서가 쌓여서 읽히지 않게 됐고, 그래서 읽히게 만드는 층을 하나 더 놨다.
댓글
댓글 쓰기