지어낸 숫자는 한 개도 없다 — 6개월 돌린 AI 워크플로, 프롬프트·코드·검증까지 전부 공개
24년 차 디렉터가 중규모(10~50인) 팀에서 실제로 운영한 304개 규칙·48개 도구·자동화 시스템 그대로
지은이 이민수 · 2026
본문의 모든 내용은 2026년 상반기를 기준으로 합니다. AI 도구의 요금·모델·기능은 빠르게 바뀌므로, 구체적인 수치와 설치 방법은 각 공식 페이지에서 최신 정보를 확인해 주세요.
이 책은 널리 읽히기를 바라며 무료로 공개됩니다. 다만 한국어 원본이든 영어·일본어 번역본이든, 이 책의 원저자가 이민수(Minsoo Lee)라는 사실만은 어디서든 유지되어야 합니다.
https://eremes81.github.io/game-design-ai-practice (저자 GitHub Pages — 편집권이 저자에게 있는 정본)자유롭게 하실 수 있습니다. 개인 학습, 비영리적 공유와 인용, 비영리 번역, 사내 스터디 활용 — 다만 원저자와 정본 링크를 함께 표시하고, 내용을 고치거나 다른 언어로 옮겼다면 그 사실을 밝혀 주십시오. 2차 저작물도 같은 조건으로 공유해 주시기 바랍니다.
따로 허락을 받아 주십시오. 상업적 출판·판매, 유료 강의의 교재 사용, 회사의 상품·서비스에 포함, 그리고 원저자 표시를 지운 재배포.
이 책에 담긴 지식은 한 권으로 완결되며, 그 자체로 무료입니다. 혹시 도움이 되어 저자를 응원하고 싶으시다면, 정식 전자책이나 유료 키트로 마음을 보태 주시면 고맙겠습니다.
이 책은 세 번 쓰였습니다.
처음은 책이 아니라 사내 매뉴얼이었습니다. 회사에서 6개월간 AI 워크플로를 굴리며, 팀이 같은 규칙을 두 번 묻지 않도록 결정과 도구와 절차를 문서로 고정했습니다. 출판하려고 만든 게 아니라, 매일의 반복을 줄이려고 쌓은 운영 문서였습니다. 이 책의 구체성은 거기서 나옵니다 — 책을 위해 지어낸 사례가 아니라, 실제로 돌아가던 매뉴얼이 바탕이라는 뜻입니다.
두 번째는 그 매뉴얼을 책으로 옮긴 첫 원고였습니다. 그런데 "AI로 이런 걸 할 수 있다"를 그럴듯하게 늘어놓은 일반론이 되어 버렸습니다. 표가 많았고, 효과를 보여주는 숫자가 많았습니다. 그 숫자 대부분에는 작은 글씨로 "가공 수치"라고 적혀 있었습니다. 다시 읽었을 때, 그게 가장 큰 결함이었습니다. AI 활용을 말하면서 정작 실제 화면을 보여주지 않았고, 효과를 말하면서 지어낸 숫자를 들었으니까요. 매뉴얼의 구체성을 책으로 옮기다 오히려 잃어버린 셈이었습니다.
그래서 세 번째로, 전부 다시 썼습니다. 지금 손에 든 이 본문입니다. 사내 매뉴얼의 구체성을 되살리되, 책의 원칙은 단순하게 잡았습니다.
첫째, 모든 챕터는 실제 세션을 끝까지 보여드립니다. 제가 친 프롬프트 전문, AI가 뱉은 날것의 출력, 그 출력에서 제가 무엇을 거부했는지, 어떻게 다시 시켰는지까지 담았습니다. "AI가 해 준다"는 문장으로 챕터를 끝내지 않습니다.
둘째, 숫자는 셋 중 하나입니다. 누구나 확인할 수 있는 공개 표준(모델 토큰 단가, 접근성 가이드라인), 제 시스템 코드에 실제로 입력되어 있는 상수, 아니면 "이건 제 추정"이라고 명시한 값. 지어낸 절감액 표는 한 개도 없습니다. 정직성을 차별점으로 삼았습니다.
셋째, 실무에서 6개월 돌린 진짜 시스템을 그대로 인용합니다. 304개의 결정 카드(atom), 48개의 도구(skill), 매 입력마다 관련 기억을 자동으로 끌어오는 hook, 한 사람이 네 명분의 협업 맥락을 운영하는 메모리 구조까지. 추상적인 "어떤 도구"가 아니라, 파일 이름과 코드와 점수를 그대로 적었습니다. 본문 사례에서는 회사·프로젝트명과 팀원 이름을 가렸을 뿐, 워크플로의 구체성은 지우지 않았습니다. (책의 출간을 허락해 준 회사는 감사의 글에서 실명으로 밝혔습니다 — 양해를 받았기 때문입니다.)
저는 24년 차 게임 기획자입니다. 싱글 플레이 게임의 QA·검수로 이 업계에 발을 들였고, 이후 수십 개국에 서비스된 MMORPG의 디렉터부터 200명 규모 AAA MMORPG의 초기 개발, 글로벌 모바일 MMORPG의 라이브 운영까지 — RPG와 MMORPG, 그 변종들을 만들며 보냈습니다. 라그나로크 온라인, 블레스 온라인, 미르 시리즈 같은 프로젝트에 디렉터·기획 리드·시스템 기획자, 때로는 PM 등 다양한 직무로 참여했고, 작은 모바일 게임 회사를 창업해 본 적도 있습니다.
솔직히 말하면, 디렉터 자리를 너무 일찍 달았습니다. 이후 시스템 기획자로 데이터 시트와 전투 수치를 직접 만지고, 콘텐츠 기획자로 퀘스트와 NPC를 한 줄씩 양산하고, 이벤트를 기획하고, 신규 콘텐츠를 양산하던 팀원 시절이 길게 있었습니다. 이 책에 나오는 워크플로의 상당수는 그 자리 — 관리하는 사람이 아니라 직접 손이 더러워지는 자리 — 에서 "이 반복을 어떻게든 줄이고 싶다"는 마음으로 만든 것입니다. 지금은 현업에서 한 MMORPG의 디자인 디렉터로 중규모(10~50인) 팀을 이끌고 있지만, 이 책의 도구들은 디렉터의 관리 도구가 아니라 실무자의 손에서 나왔습니다. 그래서 이 책의 시스템은 이론이 아니라 매일 돌아가는 작업 환경입니다. 집에서 혼자 만든 작은 퍼즐 게임 한 개의 사례도 같은 방식으로 다룹니다 — 그 게임의 git 커밋과 실제 코드를 그대로 인용했습니다.
AI가 게임 기획자의 일을 대체하지는 못합니다. 다만 잡일에서 손을 떼게 해 줍니다. 그 손으로 무엇을 할지는 여전히 사람의 몫입니다. 이 책이 그 전환의 실무 안내서가 되기를 바랍니다.
이 책은 처음부터 끝까지 순서대로 읽지 않아도 됩니다. 본인 상황에 맞는 길을 고르면 됩니다. 터미널·설치가 처음이라면 무엇보다 1.0 「시작하기 전에」부터 펴세요 — 검은 화면의 두려움을 먼저 덜어 주는 챕터입니다.
| 길 | 경로 | 적합한 독자 |
|---|---|---|
| 도입의 길 | 1.0(설치) → 1부(도입) → 2부(정보 아키텍처) → 본인 분야 1개 | AI 도구를 막 시작하는 기획자 |
| 전체의 길 | 1·2부 → 분야별(3~15부) → 프로세스(16~19부) → 운영(20~24부) | 팀 단위 도입을 설계하는 리드 |
| 인디·1인의 길 | 1.0(설치) → 1·2부 → 23부(개인 게임 개발) → 각 챕터 「1인 축소판」 | 팀 없이 혼자·취미로 만드는 개발자 |
| 일반 직무의 길 | 1·2부 → 17부(회의록) → 16부(협업) → 18부(의사결정) → 21·22부(자가 개선·거버넌스) | 게임 밖 기획·PM·일반 직장인 |
| 문제 해결의 길 | 부록 색인 → 해당 챕터로 역방향 | 당장 풀 문제가 있는 독자 |
각 챕터 끝에는 「따라하기」가 있습니다. 읽고 덮는 챕터가 아니라, 오늘 본인 환경에서 한 단계라도 손을 움직이게 만드는 것이 목표입니다.
게임 밖에서 일하는 분께 한마디 더 드립니다. 이 책의 워크플로 상당수 — 회의록을 결정으로 바꾸기, 결정의 파급 추적, 검증 게이트, 비용 관리, 저작권·윤리 — 는 게임과 무관하게 그대로 작동합니다. 게임 기획을 본인 직무로 바꿔 읽으셔도 됩니다. 각 챕터의 「게임 밖 적용」 박스가 그 다리이고, 시간이 없다면 90분 초단축 코스(17.1 → 16.2 → 22.1 → 21.1)만 따라가도 핵심 골격을 손으로 체감하실 수 있습니다.
한 가지 구별을 해 둡니다. 이 책에서 "1인"은 두 가지 뜻으로 쓰입니다. 하나는 1인 디렉터 — 여러 사람 몫의 협업 맥락을 혼자 떠안는 리드 — 이고, 다른 하나는 혼자 게임을 만드는 1인·취미 개발자입니다. 각 챕터 끝의 「1인 축소판」은 후자를 위한 것으로, 팀도 회사 폴더도 없이 그 챕터의 핵심만 가져가는 길을 적었습니다.
이 책은 한 권으로 통독해도 되고, 두 갈래 — 1~15부 '기반·분야'와 16~24부 '프로세스·운영' — 로 나눠 필요한 쪽부터 읽어도 됩니다. 그리고 본문의 코드는 대부분 외부 의존성 없이, 파이썬 표준 라이브러리만으로 그대로 실행됩니다. 관계 그래프 같은 일부 도구만 표준 외 패키지(networkx, PyYAML)가 필요하며, 그 자리에는 설치 한 줄(pip install …)을 코드 옆에 함께 적어 두었습니다. 그 경우를 빼면 별도로 내려받을 것 없이, 코드 블록을 복사해 바로 돌려 확인하실 수 있습니다.
용어가 막히면 그 자리에서 멈추지 말고 일단 넘어가세요. 검은 터미널이 낯선 건 도구의 결함이 아니라 익숙함의 문제이고, 그 거리감은 1.0과 1부에서 함께 줄입니다.
마지막으로, 이 책의 가장 빠른 활용법을 하나 귀띔해 드립니다. 이 책 자체를 Claude Code 같은 AI 도구에게 통째로 읽히는 것입니다.
이 책은 사람만 읽으라고 쓴 게 아닙니다. 각 챕터의 프롬프트 전문·코드·검증 절차는 AI가 그대로 이해하고 재현할 수 있는 형태로 적었습니다. 그러니 본인 프로젝트 폴더에서 이 책을 AI에게 건네고 — PDF든 텍스트든 — "이 책의 정합성 검사 패턴을 읽고, 우리 데이터 시트에 맞는 검사 도구를 만들어 줘"처럼 부탁하실 수 있습니다. 그러면 AI가 해당 챕터의 워크플로를 본인 환경에 맞춰 깔아 줍니다. 사람이 한 챕터씩 손으로 따라 만드는 길과, 책을 통째로 AI에게 주고 함께 만드는 길 — 둘 다 열려 있습니다.
다만 한 가지는 변하지 않습니다. 무엇을 채택하고 무엇을 거부할지, 그 마지막 결정은 — 이 책이 처음부터 끝까지 반복해 말하듯 — 여전히 여러분의 몫입니다. AI에게 책을 읽혀 시스템을 깔더라도, 그 시스템이 뱉는 후보를 검수하는 자리는 사람이 지킵니다. 가장 쉬운 활용법조차 그 원칙 위에서 작동합니다.
이 책의 어떤 표에도 "독자를 설득하려고 부풀린 숫자"는 없습니다. 효과를 과장하는 대신, 효과가 나오는 구조를 보여드립니다. 같은 구조를 본인 프로젝트에 옮기면, 본인의 숫자는 본인이 측정하게 됩니다. 그게 이 책이 드릴 수 있는 가장 정직한 도움입니다.
이 책의 본문에서 워크드 트랜스크립트의 출력에는 자주 '재구성(reconstruction)'이라는 표기가 붙습니다 — 예컨대 「3단계 — Claude의 출력 (재구성)」처럼요. 이 단어가 무엇을 보존하고 무엇을 손댔다는 뜻인지, 한 번만 정확히 약속해 두겠습니다. 정직성을 표제로 내건 책이 가장 흐릿하게 두어서는 안 되는 자리이기 때문입니다.
'재구성'은 지어냈다는 뜻이 아니라, 실제 세션을 편집했다는 뜻입니다. 경계는 이렇습니다.
| 그대로 보존한 것 | 편집한 것 |
|---|---|
| 제가 친 입력 프롬프트 전문 — 복사해 바로 쓸 수 있는 형태 | 회사·프로젝트·NPC·팀원 고유명 → 책용 익명(IP 보호) |
| AI가 뱉은 출력의 구조와 실패 — 빗나간 후보, 규칙을 슬쩍 어긴 부분, 제가 거부하고 다시 시킨 왕복 | 길이 — 본문에 안 들어가는 곁가지는 「발췌」로 줄임 |
| 코드·상수·검증값 — 외부 실행으로 재현되도록 그대로 | 줄바꿈·여백 등 지면에 맞춘 조판 |
바꿔 말하면, 재구성된 출력에서 효과를 부풀린 숫자나 없던 성공을 더하지는 않았습니다. 익명화하고, 발췌하고, 지면에 맞춰 다듬었을 뿐입니다. 실패한 출력을 성공으로 고쳐 쓴 곳은 없습니다 — 오히려 실패를 일부러 남겼습니다. 그게 사람이 무엇을 거부하는지를 보여 주는 자리이기 때문입니다. (대조적으로, 코드 실행 결과나 시스템 로그를 '실측'·'그대로 인용'이라 적은 곳은 편집 없이 옮긴 것입니다.)
1.1은 "첫 만남"이다. 깜빡이는 커서 앞에 앉아 무엇을 쳐 보는 자리다. 그런데 그 자리에 앉으려면 먼저 갖춰야 할 것들이 있다. 도구가 설치돼 있고, 로그인이 돼 있고, 요금이 어떻게 나가는지 대략 알고, 검은 화면에서 글자 몇 개는 칠 줄 알아야 한다. 이 챕터는 1.1보다 한 단계 앞이다.
많은 입문서가 이 단계를 건너뛴다. "터미널을 여세요"라고 한 줄 적고 넘어간다. 그런데 입문자는 바로 그 한 줄에서 막힌다. 터미널이 어디 있는지, 무엇을 깔아야 하는지, 깔다가 빨간 글자가 뜨면 어떻게 해야 하는지 — 첫 줄에서 멈춘 사람은 1.1까지 도달하지 못한다. 이 챕터의 목표는 단 하나, 첫 줄에서 막히지 않게 하는 것이다.
이 챕터는 다섯 부분으로 되어 있다. 설치, 계정·로그인, 요금제 개념, 터미널 생존키트, 그리고 "5분 첫 실행" 체크리스트다. 순서대로 따라오면 1.1의 자리에 앉을 준비가 끝난다.
flowchart LR
A["1.0 준비단계
(이 챕터)"] --> B["1.1 첫 만남
(커서 앞에 앉기)"]
A1["① 설치"] --> A2["② 계정·로그인"]
A2 --> A3["③ 요금제 개념"]
A3 --> A4["④ 터미널 생존키트"]
A4 --> A5["⑤ 5분 첫 실행"]
A5 --> B
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
class A1,A2,A3,A4,A5 human
class B pass
설치는 공식 안내를 따르는 것이 원칙이다. 도구는 자주 바뀌고, 비공식 경로로 받은 설치 파일은 위험하다. 그래서 이 책은 다운로드 링크를 실어 두지 않고, 공식 경로를 찾는 법을 안내한다. 검색창에 "Claude Code 공식 문서" 또는 "Claude Code install"을 치면 Anthropic 공식 문서 페이지가 가장 먼저 나온다. 설치 명령은 그 페이지의 것을 그대로 쓰는 것이 가장 안전하다.
큰 그림은 알아 두는 편이 좋다. Claude Code(클로드 코드, 이 책은 영문 표기로 통일한다)는 터미널에서 도는 도구이고, 보통 한 줄 명령으로 설치한다. OS별로 흐름이 조금 다르다.
| OS | 준비물 | 설치 흐름(개념) |
|---|---|---|
| Windows | PowerShell(기본 내장) | 공식 문서의 설치 명령 한 줄을 PowerShell에 붙여넣기 |
| macOS | 터미널(기본 내장) | 공식 문서의 설치 명령 한 줄을 터미널에 붙여넣기 |
| Linux | 터미널 | 공식 문서의 설치 명령 한 줄을 터미널에 붙여넣기 |
세 OS 모두 흐름은 같다. "터미널을 연다 → 공식 문서의 한 줄을 붙여넣는다 → 엔터". 명령어를 외울 필요는 없다. 공식 문서에서 복사해 붙여넣는 것이 정석이다.
설치 도중 빨간 글자(에러)가 떠도 당황하지 않아도 된다. 입문자가 만나는 설치 에러는 대부분 둘 중 하나다. 권한 문제, 아니면 사전 도구(예: Node.js 같은 런타임)가 없는 경우다. 빨간 글자가 떴다면 그 문장 전체를 그대로 복사해 검색하거나 AI에 물어보면 십중팔구 해결된다. 에러 메시지는 적이 아니라 단서다.
설치가 잘 됐는지 확인하는 법: 터미널에
claude --version을 치고 엔터. 버전 번호가 한 줄 뜨면 설치 성공이다. "명령을 찾을 수 없다"는 식의 메시지가 뜨면 아직 설치가 안 됐거나 터미널을 새로 열어야 하는 경우다. 터미널을 완전히 닫고 다시 연 뒤 한 번 더 확인해 보자.
설치가 끝났다고 바로 쓸 수 있는 것은 아니다. Claude Code는 Anthropic의 AI 모델을 빌려 쓰는 도구라서, 누가 쓰는지 확인하는 로그인 단계가 필요하다.
흐름은 단순하다. 터미널에서 claude를 처음 실행하면 로그인 안내가 뜬다. 보통 웹 브라우저가 자동으로 열리고, 거기서 Anthropic 계정으로 로그인하면 된다(없다면 그 화면에서 새로 만들 수 있다). 로그인이 끝나면 브라우저가 "이제 터미널로 돌아가도 됩니다" 같은 안내를, 터미널 쪽에서도 완료 표시가 뜬다.
여기서 입문자가 자주 걸리는 지점이 두 곳이다.
첫째, 브라우저가 자동으로 안 열리는 경우다. 이때는 터미널에 긴 주소(URL)가 한 줄 표시된다. 그 주소를 복사해 브라우저 주소창에 붙여넣고 들어가면 된다. 막힌 게 아니라 수동으로 한 단계만 더 하면 된다.
둘째, 계정 종류를 헷갈리는 경우다. 웹 채팅(Claude.ai)에서 쓰던 계정과 Claude Code의 계정·요금이 어떻게 연결되는지는 시점마다 정책이 다를 수 있다. 로그인 화면의 안내와 공식 문서를 따르는 것이 가장 정확하다. 첫 실행 화면이 시키는 대로 따라가면 대부분 무리 없이 로그인된다.
로그인은 한 번 해 두면 그 PC에서는 유지된다. 매번 다시 할 필요는 없다.
입문자가 가장 불안해하는 부분이 "돈이 얼마나 나가지?"다. 글자를 칠 때마다 요금이 붙는 건 아닐까 하는 막연한 두려움이 있다. 큰 그림을 먼저 잡으면 이 불안이 줄어든다. 요금 방식은 크게 두 갈래다.
| 방식 | 과금 형태 | 비유 | 누구에게 |
|---|---|---|---|
| 정액 구독 | 월 고정 금액 | 통신 정액 요금제 | 입문자·일상 사용 |
| API 종량 | 쓴 만큼(토큰 단위) | 전기 계량기 | 대량·자동화·개발 연동 |
정액 구독은 월 단위로 정해진 금액을 내고 한도까지 쓰는 방식이다. 휴대폰 정액 요금제와 비슷하다. 매달 같은 금액이 나가니 예측이 쉽고 "한 줄 칠 때마다 얼마"를 신경 쓸 필요가 없다. 그래서 입문자는 보통 정액 구독으로 시작하는 편이 마음이 편하다(저자 추정 — 정확한 플랜 구성과 한도는 시점마다 바뀌므로 공식 요금 페이지에서 확인). 한도를 넘기면 다음 주기까지 기다리거나 상위 플랜으로 올린다.
API 종량은 실제 쓴 양(토큰)에 비례해 과금하는 방식이다. 전기 계량기처럼 쓴 만큼 청구된다. 대량 처리나 자동화 파이프라인, 다른 프로그램과 연동하는 경우에 적합하다. 정교하게 쓰면 효율적이지만, 입문 단계에서는 사용량 감을 잡기 전까지 비용 예측이 어려울 수 있다.
토큰이 무엇이고 왜 그것으로 과금하는지는 1.2(AI 모델·토큰·하네스)에서 자세히 다룬다. 여기서는 한 가지만 기억하면 된다. 입문자는 보통 정액 구독으로 시작한다. 매달 금액이 고정이라 "쓰다가 폭탄 맞을까" 하는 두려움 없이 연습할 수 있기 때문이다. 플랜 이름·가격·한도는 자주 바뀌므로 이 책은 특정 숫자를 실어 두지 않는다. 이 책의 내용은 2026년 중반을 기준으로 쓰였고, 요금제·모델·기능은 그 뒤로도 계속 바뀐다. 현재 값은 공식 요금 페이지에서 확인하는 것이 가장 정확하다.
한 줄 요약: 쓸 때마다 돈 나가는 게 아닐까 하는 두려움 → 정액 구독이면 매달 고정. 입문은 정액으로 시작하면 마음이 편하다.
이제 가장 큰 벽, 검은 화면이다. 1.1이 "깜빡이는 커서 앞에서 멈칫한다"로 시작하는 이유가 여기 있다. GUI로 24년을 일해 온 손에게 터미널은 낯설다. 그런데 첫 줄에서 막히지 않는 데 필요한 명령은 그리 많지 않다. 아래 여섯 개면 충분하다.
| 명령 | 읽는 법 | 하는 일 | 비유 |
|---|---|---|---|
pwd |
피더블유디 | 지금 내가 어느 폴더에 있는지 보여 줌 | "여기가 어디지?" |
ls |
엘에스 | 지금 폴더 안에 뭐가 있는지 목록 | 폴더 창 열어 보기 |
cd 폴더이름 |
씨디 | 그 폴더 안으로 들어감 | 폴더 더블클릭 |
cd .. |
씨디 점점 | 한 단계 위 폴더로 나옴 | 뒤로 가기 |
Enter |
엔터 | 친 명령을 실행 | 확인 버튼 |
Ctrl + C |
컨트롤 씨 | 지금 돌고 있는 걸 중단 | 정지 버튼 |
(Windows PowerShell도 ls·cd·pwd가 그대로 통한다. macOS·Linux도 같다. 그래서 이 여섯 개는 OS를 가리지 않는다.)
이 여섯 개로 하는 일을 그림으로 보면 이렇다. 터미널에서의 이동은 결국 폴더 안팎을 드나드는 것이고, GUI에서 폴더를 더블클릭하거나 뒤로 가는 것과 같은 동작이다.
flowchart TD
Q["pwd
여기가 어디지?"] --> L["ls
여기 뭐 있지?"]
L --> D{"들어갈 폴더가
보이나?"}
D -- "예" --> IN["cd 폴더이름
들어가기"]
D -- "아니오, 위로" --> UP["cd ..
나오기"]
IN --> L
UP --> L
RUN["명령을 친 뒤"] --> ENT["Enter
실행"]
STUCK["뭔가 멈춘 것 같을 때"] --> STOP["Ctrl + C
중단하고 커서 되돌리기"]
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
class Q,L,IN,UP,ENT,STOP code
class D human
검은 화면이 무서운 진짜 이유는 "잘못 치면 망가질 것 같다"는 느낌이다. 그런데 위 여섯 개 중 무언가를 망가뜨리는 명령은 없다. pwd·ls·cd는 보거나 이동만 할 뿐 파일을 지우거나 바꾸지 않는다. Enter는 실행, Ctrl + C는 중단일 뿐이다. 그러니 이 여섯 개는 아무 때나 마음 놓고 쳐도 된다.
화면이 멈춘 것처럼 보일 때가 있다. 명령을 쳤는데 한참 반응이 없거나, 커서가 다른 줄에서 깜빡이며 무엇을 더 기다리는 것 같을 때다. 그럴 때 Ctrl + C를 한 번 누르면 대개 원래 커서로 돌아온다. 이 "정지 버튼"이 있다는 사실만 알아도 검은 화면이 한결 덜 무섭다. 막히면 Ctrl + C로 빠져나와 다시 시작하면 된다.
마지막으로, 친 글자가 잔뜩 쌓여 정신없을 때는 화면을 비울 수 있다. Windows PowerShell·macOS·Linux 모두 clear 명령으로 비운다. 비워도 한 일이 사라지는 것은 아니고 보이는 글자만 정리된다.
여기까지 왔다면 준비는 끝났다. 아래 다섯 칸을 5분 안에 통과하면 1.1의 자리에 앉을 자격이 생긴 것이다. 한 칸이라도 막히면 해당 절(1.0.1~1.0.4)로 돌아가면 된다.
flowchart LR
C1["① 터미널이
열린다"] --> C2["② claude --version
버전이 뜬다"]
C2 --> C3["③ claude 실행 →
로그인 완료"]
C3 --> C4["④ pwd·ls로
내 폴더가 보인다"]
C4 --> C5["⑤ Ctrl+C로
빠져나올 수 있다"]
C5 --> OK["✅ 1.1로"]
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
class C1,C2,C3,C4,C5 human
class OK pass
claude --version을 치면 버전 번호가 한 줄 뜬다 (설치 확인)claude를 실행하니 로그인이 되어 있다 (또는 안내대로 로그인 완료)pwd로 현재 위치를, ls로 폴더 내용을 볼 수 있다Ctrl + C로 빠져나올 수 있다다섯 칸을 다 채웠다면 검은 화면은 더 이상 미지의 벽이 아니다. 도구가 깔려 있고, 로그인이 돼 있고, 요금 방식의 큰 그림을 알고, 화면 안에서 이동하고 멈출 줄 안다. 1.1은 이 준비 위에서 시작한다. 깜빡이는 커서 앞에 앉아 처음으로 "이 폴더에 뭐가 있는지 요약해줘"를 쳐 보는 그 자리로 가면 된다.
이 책 앞부분(1·2부)은 자연어 프롬프트만으로 따라올 수 있다. 다만 4부 이후 일부 챕터는 작은 파이썬 스크립트를 직접 돌린다(예: pip install pyyaml, pip install pyvis). 파이썬이 처음이라도 괜찮다. 두 가지 길이 있다.
첫째, 직접 까는 길. 파이썬은 python.org에서 내려받아 설치하고(설치 화면에서 "Add to PATH"를 꼭 체크한다), 터미널에서 python --version으로 확인한다. pip는 파이썬에 함께 깔리는 꾸러미 설치 도구라, pip install pyyaml처럼 필요한 꾸러미를 한 줄로 받는다.
둘째, AI에게 맡기는 길(권장). 더 쉬운 길은 환경 구축 자체를 AI에게 시키는 것이다. 터미널에서 이렇게 부탁하면 된다.
파이썬이 깔려 있는지 확인하고, 없으면 내 OS에 맞는 설치 방법을 알려줘.
그리고 이 챕터에서 필요한 pyyaml 패키지를 설치하는 명령을 한 줄로 줘.
AI가 본인 환경을 점검하고 설치 명령을 만들어 준다. 막히면 그 자리에서 오류 메시지를 그대로 붙여 "이 오류 어떻게 풀어?"라고 물으면 된다. 도구를 돌리는 챕터마다 이 패턴 하나면 충분하다. 파이썬·pip가 부담스러운 단계에서는, 그 챕터의 「1인 축소판」이 코드 없이 가는 더 가벼운 길을 안내한다.
setup 1. 사용 중인 OS의 터미널을 여세요 (Windows: PowerShell, macOS: 터미널). 2. "Claude Code 공식 문서"를 검색해 공식 설치 안내 페이지를 띄워 두세요. 3. 타이머를 5분으로 맞추세요 — 1.0.5 체크리스트 다섯 칸을 통과하는 것이 목표입니다.
prompt (한 줄씩, 순서대로 쳐 보세요. 명령어이지 자연어 질문이 아닙니다)
① claude --version # 버전이 뜨면 설치 성공
② pwd # 지금 내가 어느 폴더에 있는지
③ ls # 이 폴더에 뭐가 있는지
④ cd .. # 한 단계 위로 나오기 (그리고 다시 ls)
⑤ claude # Claude Code 실행 (로그인 안내가 뜨면 따라가기)
verify
- ①에서 버전 번호가 한 줄 뜨면 설치가 끝난 것입니다. "명령을 찾을 수 없다"가 뜨면 터미널을 닫고 다시 연 뒤 한 번 더 시도하세요.
- ②·③·④로 폴더를 보고 이동하는 동안 아무것도 망가지지 않는다는 점을 직접 확인하세요. 이 셋은 보기·이동만 하는 안전한 명령입니다.
- ⑤ 실행 중 멈춘 것 같으면 Ctrl + C로 빠져나오세요. 빠져나와지면 "정지 버튼이 있다"는 사실을 몸으로 확인한 것입니다.
팀도 회사 폴더도 없는 개인이라면, 설치(①)와 Ctrl + C로 빠져나오기만 먼저 익혀 두세요. claude --version으로 "도구가 깔렸다"를, Ctrl + C로 "막혀도 빠져나올 수 있다"를 확인하면, 검은 화면 두려움의 절반은 혼자서도 5분 안에 정리됩니다. 요금은 일단 정액 구독으로 시작하면 비용 걱정 없이 마음껏 연습할 수 있습니다.
검은 화면이 떴다. 커서가 깜빡인다. 그 앞에 24년 차 게임 기획자가 앉아 있다. PPT와 엑셀, 위키와 피그마로 24년을 일해 온 손이 키보드 위에서 잠깐 멈춘다. 옛날 컴퓨터 학원이 있던 시절, DOS 같기도 하다. 그 후 터미널이라는 물건은 프로그래머의 책상에서나 보던 것이었다. 무엇을 쳐야 할지 모르겠고, 잘못 치면 뭔가 망가질 것 같다. 이 멈칫거림이 이 책의 출발점이다.
대부분은 여기서 창을 닫는다. 그리고 회의에서 다시 "우리도 뭔가 해 봐야 할 텐데"를 반복한다. 이 챕터는 그 창을 닫지 않고 첫 30분을 버티는 자리에 함께 앉는다. 거창한 도입 전략이 아니라, 깜빡이는 커서 앞에서 무엇을 쳐 보면 거리감이 풀리는지를 손에 쥐여 주는 것이 목표다.
게임 기획자가 처음 Claude Code 같은 AI 코딩 도구 앞에 앉으면 신기함과 불편함이 같은 자리에서 동시에 일어난다. 두 감정이 충돌한다는 사실 자체가 도입의 첫 단서다.
신기한 이유는 분명하다. 반나절씩 잡아먹던 데이터 시트 정합성 검사가 몇 분에 끝나고, 늘어진 회의록이 결정사항 표로 요약되며, 1년 전 묻혀 있던 기획서를 자연어 한 줄로 다시 꺼내 올 수 있다.
불편한 이유도 그만큼 분명하다. 검은 화면, 깜빡이는 커서, 영문 명령어가 일상 작업 풍경과 너무 다르다. 게임 기획자의 하루는 GUI 위에서 흐르는데, 검은 터미널에 글자를 치는 행위는 직무 정체성과 잘 붙지 않는다. 다만 이 불편함은 도구의 결함이 아니라 GUI에 익숙해진 사람의 적응 비용이다. 이 점만 인정해도 거리감의 절반은 이미 정리된다.
이 책은 그 거리감을 줄이려는 책이다. 1.1은 첫 만남의 자리에 함께 앉아 무엇을 보고, 무엇을 시도하고, 무엇은 일단 미뤄도 되는지를 정리한다.
게임 기획은 다른 직무보다 늦게 AI 흐름에 합류했다. 코드를 다루는 사람들이 먼저, 디자이너·아티스트가 다음에 들어갔다. 기획자는 "우리도 뭔가 해 봐야 할 텐데"를 반복하며 미루는 패턴이 흔했다.
미룬 이유는 합리적이었다. 기획자의 산출물은 코드처럼 정형화되어 있지 않고 텍스트·표·다이어그램·회의·구두 합의가 한데 섞인다. AI 출력의 신뢰성이 낮아 보였고 그럴듯한 거짓말이 위험했으며, AI가 게임 시스템을 정말 이해하는지도 의심스러웠다.
그러나 2024~2026년 사이에 세 가지가 바뀌었다.
첫째, AI 모델의 추론 능력이 임계점을 넘었다. 단순한 문장 생성을 넘어 복잡한 시스템 설계·정합성 검증·영향 분석을 다룬다. 최신 Claude 계열은 게임 기획 워크플로의 상당 부분을 보조한다. 다만 전부를 맡길 수 있다는 뜻은 아니다. 검증과 책임은 여전히 사람 몫이다. (보조 폭은 작업 종류와 팀 성숙도에 따라 크게 갈린다 — 저자 추정, 미검증.)
둘째, 하네스(harness)가 성숙했다. Claude Code 같은 도구는 단순 채팅이 아니다. 파일을 직접 읽고 쓰며, 명령을 실행하고, 결과를 다시 입력으로 받는다. 사람이 일하는 방식과 닮았다.
셋째, 메모리·atom·skill 같은 운영 기법이 정착했다. AI를 한 번 쓰고 끝내는 게 아니라, 팀의 지식을 누적해 시간이 갈수록 똑똑해지게 만드는 방법론이 자리 잡았다. 이 책의 뒷부분이 다루는 핵심이 바로 이 누적이다.
이 세 가지가 모이면 게임 기획자에게도 AI 도입이 합리적인 시점이 된다. 더 늦기 전에 시작하는 편이 이득이다.
기획자가 흔히 접하는 AI 도구는 두 종류다. 채팅창에 질문을 던지는 챗봇형(ChatGPT, Claude 웹앱)과 코드 에디터 안에서 자동 완성을 하는 에디터 결합형(Cursor, Copilot)이다.
Claude Code는 세 번째 부류다. CLI(터미널)에서 동작하며, 사람의 작업 환경 전체에 접근한다. 세 부류가 어디서 갈라지는지를 한 장으로 보면 다음과 같다.
게임 기획자의 작업은 코드가 아니라 문서·표·관계다. Claude Code의 강점은 사람이 일하는 폴더 전체를 보고 이해하고 조작한다는 점이다. 챗봇처럼 매번 자료를 복사해 붙여 넣지 않아도 된다.
사무실 비유로 보면 챗봇형은 안내 데스크다. 질문 한 번에 답 한 번, 자료는 매번 다시 꺼내야 한다. Claude Code는 옆자리 동료에 가깝다. 자료가 어디 있는지 알고, 자기 손으로 파일을 열고, 결과를 정리해 다시 책상에 올린다. 같은 Claude라도 어느 책상에 앉히느냐에 따라 잘하는 일이 달라진다.
설치·설정은 1.0에서 다룬다. 1.1은 첫 30분에 무엇을 경험하면 거리감이 줄어드는지에 집중한다. 첫 30분은 네 구간으로 나뉘고, 구간마다 거리감의 일부가 단계적으로 사라진다.
flowchart LR
A["보기 (5분)
이 폴더 요약해줘
→ '자연어를 알아듣는다'"]
B["검색 (5분)
키워드·최근 파일 찾기
→ '매일 하던 잡일을 대신한다'"]
C["변환 (10분)
회의록→표, 엑셀→마크다운
→ '시간을 만들어 준다'"]
D["검증 (10분)
방금 출력을 의심하기
→ '이 도구는 의심받아야 한다'"]
A --> B --> C --> D
D --> E["거리감 80% 해소"]
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
class A,B,C ai
class D human
class E pass
터미널을 열고 Claude Code를 실행하면 검은 화면이 뜨고 커서가 깜빡인다. 많은 사람이 이 자리에서 멈추지만, 멈추면 거리감이 풀리는 첫 기회를 놓친다. 자연어 한 문장을 입력한다.
이 폴더에 뭐가 있는지 요약해줘
코드도 명령어도 아닌 한 문장이다. Claude Code는 폴더 구조를 스캔하고, 어떤 파일이 있는지, 무엇을 다루는 프로젝트로 보이는지를 정리해 답한다. 기획 문서가 담긴 폴더에서 이 한 줄을 치면 대략 이런 응답이 돌아온다.
이 폴더에 뭐가 있는지 요약해줘
이 폴더를 스캔했습니다. 게임 데이터·기획 문서 작업 폴더로 보입니다.
파일 종류 개수 내용 추정
───────────── ──── ───────────────────────────
.xlsm 12 데이터 시트 (아이템·퀘스트·NPC 등)
.md 34 기획서·회의록 초안
.png 88 UI 목업·레퍼런스 이미지
하위 폴더 6 /data /docs /ref /meeting /export /tmp
관찰:
- /meeting 폴더에 최근 일주일 회의록 5건이 쌓여 있습니다.
- item_table.xlsm 과 quest_table.xlsm 이 같은 ID 컬럼을
공유하는 것으로 보입니다(FK 후보).
- /tmp 폴더에 임시 export 파일이 다수 — 정리 대상일 수 있습니다.
무엇을 먼저 살펴볼까요?
이 한 번의 응답에서 "이 도구는 명령어가 아니라 자연어를 알아듣는다"는 사실이 몸으로 들어온다. 폴더를 직접 열지 않고도 무엇이 어디 있는지 정리된 그림을 받는 경험이 거리감의 절반을 그 자리에서 깎는다.
작은 검색을 시킨다. "최근 일주일에 수정된 마크다운 파일 5개 알려줘"나 "'리워드'라는 단어가 들어간 파일이 어디 있어?" 같은 부탁이다. 기획자가 매일 Find in Files를 열고 키워드를 적고 결과를 스크롤하던 일을, Claude Code는 자연어로 받아 정리해 돌려준다. 이 도구가 매일 하던 잡일을 대신한다는 감각이 두 번째 5분에서 자리를 잡는다.
작은 변환을 시킨다. "이 회의록에서 결정사항만 추출해 표로 만들어줘"나 "이 엑셀 시트의 1~10행을 마크다운 표로 바꿔줘" 같은 부탁이다. 손으로 5~10분 걸리는 변환이 짧은 시간에 처리되는 경험은, 도구가 검색을 대신하는 데 그치지 않고 시간을 만들어 준다는 감각으로 이어진다.
여기서 그치면 위험한 이유는 AI가 그럴듯한 거짓말을 하기 때문이다. 방금 만든 출력을 스스로 의심하게 만든다.
방금 만들어준 표가 원본과 일치하는지 다시 확인해줘. 빠진 항목 있어?
그러면 Claude Code는 원본을 다시 읽고 자기 출력과 한 줄씩 대조해 불일치를 보고한다. 실제로 이 구간에서는 이런 화면이 자주 나온다.
방금 만들어준 표가 원본과 일치하는지 다시 확인해줘. 빠진 항목 있어?
원본(meeting_0603.md)과 방금 만든 표를 다시 대조했습니다.
3건의 불일치를 찾았습니다.
⚠ 누락 "튜토리얼 보상 50% 하향" — 원본 12행에 있으나 표에 빠짐
⚠ 오기 담당자 "김OO" → 표에는 "이OO"로 잘못 기입
⚠ 모호 "리워드 재논의" — 결정인지 보류인지 원문이 불명확.
제가 임의로 '결정'에 넣었으나, 확인이 필요합니다.
수정 표를 다시 만들까요? 모호 항목은 어떻게 처리할지
알려 주시면 반영하겠습니다.
도구가 자기 출력을 의심할 줄 안다는 점, 그리고 그 의심을 사람과 함께 한다는 점이 마지막 10분의 핵심이다. 세 번째 항목처럼 "내가 임의로 판단했으니 확인해 달라"고 되묻는 태도가, 검증을 사람 손에 남기는 안전판이다.
30분이 지나면 거리감의 80%는 사라져 있다. 남은 20%는 다음 챕터들에서 천천히 줄어든다.
저자가 디자인 디렉터로 운영하는 어느 MMORPG 프로젝트(이하 "프로젝트 A")는 기획팀(4~5인)과 약 6개월간 Claude Code 중심 워크플로를 운영해 왔다(프로젝트 A 전체 개발팀은 중규모, 10~50인). 몇 가지 풍경을 옮긴다.
가장 손에 잡히는 한 건만 실측으로 떼어 보자. 데이터 시트 30여 개에 걸친 FK(외래키) 정합성 검사다. 한 시트의 ID가 다른 시트에서 올바르게 참조되는지 사람 눈으로 따라가는 작업으로, 시트가 늘수록 조합이 기하급수로 불어난다.
반나절에서 5분. 이 한 줄을 일반화하지는 않겠다. 다른 작업들은 절감 폭이 더 작거나, 검토 시간이 새로 붙기도 한다. 같은 6개월 동안 본 다른 풍경들은 방향과 비율로만 적는다.
각 도구가 한 번 만들어진 뒤 6개월간 누적된 절약 시간은 사람-주(person-week)가 아니라 사람-월(person-month) 단위라는 게 저자의 체감이다(정확한 합산은 미측정, 추정치). 그 시간으로 더 깊은 기획에 집중할 수 있게 됐다.
이런 도구는 한 번 만들어지면 오래 일한다. 다만 '오래'가 곧 '무인'은 아니다. 운영하는 사람과 검증 구조가 함께 갖춰져야 오래가고, 도구만 두고 사람이 떠나면 두 분기 안에 부식한다. 이 책의 뒤쪽 부들은 위 도구 각각이 어떻게 만들어지고 운영되는지를 다룬다.
게임 기획자가 AI 도구 앞에서 흔히 갖는 두려움을 솔직히 짚어 보자. 외면하지 않고 다루는 게 도입의 첫 단계다.
"AI가 내 일을 대체한다"는 두려움은 절반은 맞고 절반은 틀리다. 단순 잡일(정합성 검사·문서 변환·검색)은 AI가 대체하지만, 결정·우선순위·플레이어 감정의 설계는 대체하지 못한다. 오히려 AI를 잘 쓰는 기획자는 잡일에서 해방되어 본질에 집중한다. 자문해 보자. "내 일 중 잡일과 본질의 비율은?" 잡일이 70%라면 본질 30%는 그대로 본인 몫이고, 그 30%가 더 중요해지는 게 핵심이다.
"AI가 틀리면 책임은 누가 지나"라는 질문도 자주 나온다. 기획자의 결정에 대한 책임은 언제나 기획자의 것이다. AI 출력을 검증 없이 그대로 쓰는 건 기획자의 실수이지 AI의 실수가 아니다. 검증 절차를 함께 설계하는 것이 도입의 일부다. 1.1.3의 마지막 10분에서 본 '자기 출력 의심시키기'가 그 절차의 가장 작은 씨앗이다.
"코드를 잘 몰라서 못 쓴다"는 두려움은 곧 풀린다. Claude Code는 자연어로 동작하니 코드를 모르면 모르는 대로 시작할 수 있다. AI가 만든 스크립트를 함께 읽기 때문에, 몇 달쯤 지나면 간단한 스크립트는 읽고 수정하게 된다. 학습이 자동으로 따라온다.
"도구가 너무 빨리 바뀐다"도 흔한 걱정이다. 모델·기능·트렌드를 다 따라잡으려 들면 지친다. 자기 워크플로에 도움 되는 1~2개 기능만 깊이 익히고, 나머지는 필요할 때 본다.
미리 일러둔다. 1.1.3의 응답 화면들이 매끈해 보였더라도, 실제 첫 30분에는 빗나간 답, 엉뚱한 파일 요약, 멈칫거리는 출력이 섞여 나온다. 그게 정상이다. 이 책은 매끈한 성공담이 아니라, 빗나간 출력을 어떻게 다시 요청해 바로잡는지를 더 많이 다룬다.
이 책은 24개 부로 구성되어 있다. 처음부터 끝까지 순서대로 읽지 않아도 된다. 다음 세 패턴 중 본인 상황에 맞는 것을 고르면 된다.
| 패턴 | 경로 | 소요 |
|---|---|---|
| 도입 패턴 | Part 1(도입) → Part 2(정보 아키텍처) → 본인 분야 1개 | 1~2개월 |
| 전체 패턴 | Part 1~2 → 분야별(3~15) → 프로세스(16~19) → 운영(20~24) | 6개월~1년, 팀 단위 적합 |
| 문제 해결 패턴 | 부록 색인 → 해당 챕터로 역방향 | 약 1주, 당장 문제가 있을 때 |
어느 길을 고를지 막막하다면 본인이 어디에 가까운지로 가르면 된다. 게임 밖 기획자·PM·일반 직장인이라면 위 세 패턴 대신 「일반 직무의 길」(1·2부 → 17부 회의록 → 16부 협업 → 18부 의사결정 → 21·22부 자가개선·거버넌스)을 권합니다 — 게임 도메인 챕터를 건너뛰고도 핵심 골격이 그대로 서고, 각 챕터의 「게임 밖 적용」 박스가 본인 직무로 옮겨 읽는 다리가 됩니다(색인은 부록 F.5). 시간이 없다면 17.1 → 16.2 → 22.1 → 21.1 네 챕터만 따라가도 됩니다. AI 도구를 막 시작하는 비전공 독자라면 '도입 패턴'으로 본인 분야(또는 가장 가까운 분야) 하나만 끝까지 붙들고, 깊은 분야 부(4·8·11 등)는 도입부의 '비전공자를 위한 한 줄'만 챙긴 뒤 필요할 때 본문으로 내려가면 됩니다.
이 책의 모든 챕터는 학술 깊이까지 가지 않고 운영 가능한 수준에서 멈춘다. 중규모 팀에서 6개월 실제 돌아간 기법을 그대로 옮겨, 작게 시작해 크게 키우는 길을 함께 걷는 것이 목표다.
1.1은 거리감을 줄이는 챕터였다. 1.2는 한 단계 안으로 들어가, 이 도구의 기본 메커니즘을 게임 기획자 친화 언어로 설명한다. 모델·토큰·컨텍스트·하네스 같은 단어를 두려워하지 않게 만드는 것이 1.2의 목표다. 본격적인 세팅(메모리·권한·settings.json)은 1.3에서 다룬다.
setup 1. 터미널을 여세요(Windows는 PowerShell, macOS는 터미널). 2. 기획 문서가 모인 폴더로 이동한 뒤 Claude Code를 실행하세요(설치는 1.0). 3. 타이머를 30분으로 맞추세요 — 5분(보기)·5분(검색)·10분(변환)·10분(검증).
prompt (한 구간에 한 줄씩, 순서대로 쳐 보세요)
① 이 폴더에 뭐가 있는지 요약해줘
② '리워드'라는 단어가 들어간 파일이 어디 있어?
③ 이 회의록에서 결정사항만 뽑아 표로 만들어줘
④ 방금 만든 표가 원본과 일치하는지 다시 확인해줘. 빠진 항목 있어?
verify - ①에서 폴더 구조 요약이 실제 폴더와 맞는지 눈으로 대조하세요. - ④의 불일치 보고가 한 건이라도 나오면 성공입니다. AI가 자기 출력을 의심하는 장면을 직접 봤다는 뜻입니다. - 빗나간 답이 나와도 실패가 아닙니다. "방금 답이 틀렸어, 이 파일만 다시 봐줘"처럼 다시 요청하는 것까지가 첫 30분의 연습입니다.
팀도 회사 폴더도 없는 개인이라면, 본인 PC의 아무 작업 폴더 하나(예: 다운로드 폴더, 메모 폴더)에서 위 prompt ①과 ④만 쳐 보세요. ①로 "자연어를 알아듣는다"를, ④로 "출력을 의심할 수 있다"를 확인하면, 이 챕터의 핵심 두 가지는 혼자서도 5분 안에 체감할 수 있습니다.
작업 하나를 끝내고 사용량을 봤을 때다. 이번 주 회의록 다섯 개가 폴더에 쌓여 있고, 월요일 오전 스탠드업 전까지 "결정된 것만" 한 장으로 정리해야 한다. 나는 Claude Code 창에 한 줄을 적는다. "이 폴더의 회의록에서 결정사항만 뽑아 표로 만들어 줘." 엔터를 친 다음 0.4초쯤, 화면 아래쪽에 작은 회색 글씨가 깜빡인다.
Reading meeting-2026-05-25.md ... (1,840 tokens)
Reading meeting-2026-05-27.md ... (2,310 tokens)
이 회색 글씨가 이 장의 주제다. 한국어 한 문장을 던지면, 도구는 그것을 토큰으로 쪼개고, 파일을 토큰으로 읽어 모델에게 넣고, 모델의 답을 받아 파일에 쓴다. 이 왕복이 한 번 돌 때마다 비용이 매겨지고, 모델의 "시야"에 자료가 쌓인다. 이 장은 그 회색 글씨 뒤에서 벌어지는 일을 게임 기획자의 언어로 분해한다. 모델·토큰·컨텍스트·하네스, 네 단어면 충분하다.
용어 메모 - 모델(model): 답을 만드는 두뇌. Opus·Sonnet·Haiku처럼 크기와 성격이 다른 종류가 있다. - 토큰(token): 글을 잘게 자른 조각. 과금·속도·시야가 모두 이 단위로 센다. - 컨텍스트 윈도우(context window): 모델이 한 번에 머릿속에 담는 토큰의 최대치. - 하네스(harness): 모델을 일하게 만드는 차체. Claude Code가 그 예다.
위의 회색 글씨는 무작위 로그가 아니라 정해진 순환의 한 칸이다. 하네스가 하는 일은 결국 같은 고리를 빠르게 도는 것이다. 파일을 읽어 모델에게 넣고, 모델이 "이 명령을 실행하라"고 하면 실행하고, 그 결과를 다시 모델에게 넣는다. 이 고리가 작업이 끝날 때까지 돈다.
flowchart TD
Start([사람: 한 줄 지시]) --> Read[하네스: 파일·자료 읽기
토큰으로 변환]
Read --> Inject[하네스: 컨텍스트에 주입
+ 누적 토큰 합산]
Inject --> Model{모델: 다음 행동 결정}
Model -->|명령 실행 필요| Exec[하네스: 셸 명령·스크립트 실행]
Exec --> Result[실행 결과를
토큰으로 재입력]
Result --> Inject
Model -->|답이 준비됨| Write[하네스: 파일 쓰기·출력]
Write --> Verify{검증 통과?}
Verify -->|실패| Inject
Verify -->|통과| Done([결과 저장])
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
class Start,Verify human
class Read,Inject,Exec,Write code
class Model ai
class Result data
class Done pass
이 그림에서 사람이 손을 대는 칸은 맨 위(지시)와 맨 아래(검증 결과 확인) 둘뿐이고, 가운데 고리는 하네스가 자율로 돈다. 회의록 다섯 개를 읽는 동안 회색 글씨가 다섯 번 깜빡인 건 Read → Inject 칸을 다섯 바퀴 돈 것이다. 웹 채팅이라면 파일 다섯 개를 직접 열어 복사·붙여넣어야 한다. 그 노동을 하네스가 대신한다는 것, 이게 챗봇과 CLI형 하네스를 다른 도구로 만드는 결정적 차이다.
루프가 한 바퀴 돌 때마다 Inject 칸에서 누적 토큰이 합산된다. 그래서 토큰을 먼저 이해하지 않으면 이 루프의 비용도 한계도 보이지 않는다. 토큰부터 본다.
토큰은 글자가 아니라 모델이 글을 자른 조각이다. 경험칙으로 영어는 약 4글자가 1토큰, 한국어는 약 2글자가 1토큰에 가깝다(공식 환산이 아니라 운영용 어림이다 — 실제 값은 모델 토크나이저가 정하며 문장마다 다르다). 한국어 공백 포함 20글자면 대략 10토큰쯤이다.
그 회의록 작업을 토큰으로 따라가 본다(아래 수치는 동일 작업의 단일 측정 한 번이다. 회의록 분량·요약 길이에 따라 달라지므로 절대값이 아니라 자릿수와 비율로 읽기를 권한다).
| 단계 | 무엇 | 토큰(입력) | 토큰(출력) |
|---|---|---|---|
| 지시 | "결정사항만 뽑아 표로" 한 줄 | ~25 | — |
| 회의록 읽기 ×5 | md 파일 5개 본문 | ~10,400 | — |
| 분류 규칙 주입 | 회의 카테고리 atom 1건(JIT) | ~480 | — |
| 모델 추론·표 작성 | 결정 12건을 표로 | — | ~1,600 |
| 검증 재입력 | Linter가 잡은 누락 1건 재질의 | ~320 | ~210 |
| 누적 | ~11,225 | ~1,810 |
두 가지가 눈에 들어온다. 첫째, 내가 친 지시는 25토큰인데 작업 전체는 입력만 1만 1천 토큰을 넘는다. 비용의 거의 전부가 내 문장이 아니라 도구가 읽어 들인 자료에서 나온다. 둘째, 출력(1,810)이 입력(11,225)의 6분의 1 정도다. 대부분의 기획 자동화가 이렇게 많이 읽고 적게 쓴다. 그래서 비용을 줄이려면 출력을 다듬기보다 입력 자료의 양을 다스리는 쪽이 훨씬 효과가 크다.
작업이 끝난 뒤 /context를 치면 그 세션이 컨텍스트를 얼마나 차지했는지 보인다. 토큰을 의식하지 않으면 인쇄용지처럼 무의식적으로 흘러나가지만, 한 번 가시화하면 자세가 달라진다. 이 가시화가 절약의 출발점이다.
토큰을 다스리는 도구는 추상적 절약 정신이 아니라 입력 자료를 잘게 다루는 구체적 기법들이다.
이 중 1번 JIT 주입은 이 책의 작업 환경에서 실제로 도는 장치다. 입력 한 줄이 들어오면 inject_memory.py 훅이 메모리 atom을 점수순으로 매칭해 상위 몇 개만 골라 주입하고, 실패해도 작업 흐름을 막지 않는다(구현 디테일은 1.3에서 상세). "필요한 자료만, 상위 몇 개만, 실패해도 조용히"라는 토큰 절약 원칙이 코드 한 파일에 그대로 들어 있다.
모델은 자동차 엔진과 같아서, Claude Code라는 같은 차체에 Opus·Sonnet·Haiku라는 다른 엔진을 끼울 수 있다. 엔진을 바꾸면 작업의 성격이 바뀐다.
기획 작업에 맞춰 보면 이렇게 갈린다. 시스템 설계 검토나 여러 자료를 통합하는 GDD(Game Design Document, 상세 사양서) 초안 합성처럼 깊은 추론과 정합성이 필요한 일은 Opus, 회의록 결정 추출이나 일일 요약처럼 일상 작업 대부분은 Sonnet, 데이터 시트의 단순 형식 변환처럼 판단이 거의 없는 일은 Haiku로 간다. 앞 절의 회의록 작업을 Sonnet으로 돌린 것도 이 기준이다 — 결정을 골라 표로 옮기는 일은 깊은 추론보다 균형과 속도가 맞다.
도입 초기에 누구나 빠지는 함정이 있다. 모든 작업을 가장 좋은 엔진, 즉 Opus로 돌리고 싶은 충동이다. 그 충동을 따르면 비용·속도 부담이 곧 운영 부담으로 돌아오고, 작업에 모델을 맞추는 감각이 자리 잡지 못한다. 운영의 진짜 기술은 매번 머릿속으로 모델을 고르는 게 아니라, 패턴이 잡힌 뒤 자동화로 굳혀 두는 것이다.
이런 고정은 settings.json이나 슬래시 명령 안에 모델을 명시해 둔다(1.3에서 상세). 한 번 굳히면 매번 고르는 수고가 사라진다.
모델은 대략 반년 주기로 새 버전이 나오고, 같은 이름이라도 4.5와 4.6은 다르다. 새 버전이 나오면 워크플로의 핵심 작업 다섯 개만 같은 입력으로 비교한다. 전부 테스트하려 들면 지친다. 다섯 개의 결과 차이만으로도 갈아탈지는 충분히 판단된다.
루프가 한 바퀴 돌 때마다 Inject 칸에서 토큰이 누적된다고 했다. 그 누적이 부딪히는 천장이 컨텍스트 윈도우, 즉 모델이 한 번에 처리할 수 있는 토큰의 최대치다. 사람으로 치면 작업 기억(working memory)이다.
앞의 회의록 작업은 누적 입력이 1만 1천 토큰대로, 200K 천장 대비 6% 남짓이라 여유롭다. 하지만 작업을 갈아타지 않고 같은 창에서 세션을 길게 끌면 천장에 다가간다. 가득 차면 옛 내용이 잘리고 모델이 앞부분 "기억"을 잃기 시작하며, 자동 압축이 발동해 이전 대화가 요약본으로 대체된다.
이 천장을 다스리는 습관은 네 가지다.
| 패턴 | 언제 |
|---|---|
| 세션 분리 | 다른 주제로 넘어갈 때는 새 세션을 연다 |
| 명시적 압축 | 한 작업이 끝나면 핵심만 남기고 압축한다 |
| 메모리 외부화 | 자주 쓰는 자료는 atom으로 빼서 JIT로 그때그때 주입한다 |
| 컨텍스트 가시화 | /context로 현재 사용량을 눈으로 확인한다 |
게임 기획자가 자주 마주치는 무거운 케이스는 회의 자료·기획서·데이터 시트가 동시에 필요한 작업으로, 이럴 때 1M 옵션이 유용하다. 다만 1M은 비용·속도 부담이 따르므로, 평소엔 200K로 충분하고 자료 묶음이 정말 클 때만 꺼낸다.
루프 그림 맨 아래의 검증 통과? 칸으로 돌아온다. 이 칸이 없으면 모델의 그럴듯한 거짓말이 그대로 파일에 저장된다. 모델은 자신 있게 틀린 답을 정답인 것처럼 내놓을 때가 있고(환각, hallucination), 그 빈도는 세대가 올라갈수록 줄지만 0이 되지는 않는다. 그래서 검증을 상시 칸으로 둔다.
기획에서 위험한 환각은 구체적이다. 존재하지 않는 데이터 시트 컬럼을 인용하거나, 잘못된 수식으로 밸런스를 계산하거나, 회의에서 결정되지 않은 사항을 결정된 것처럼 요약한다. 회의록 작업에서 가장 무서운 건 세 번째다 — "논의만 하고 보류한 건"이 결정 표에 슬쩍 올라오는 경우다.
검증에는 다섯 가지 패턴이 있다.
다섯 개를 매번 다 하지 않고 작업의 위험도에 따라 1~3개를 고른다. 회의록 작업에서는 4번 Linter("결정에 주체·내용·기한이 다 있는가")와 3번 샘플 검수 한 번을 묶었다. 앞 토큰 표의 마지막 줄 "검증 재입력 320토큰"이 바로 Linter가 누락을 잡아 모델에게 되물은 그 왕복, 루프 그림으로 치면 검증 실패 → Inject로 한 바퀴 더 돈 것이다.
매번 사람이 다 검증하면 도입 효과가 반감되므로 검증도 자동화 대상이다. 회의록 결정 추출은 Linter가 형식 누락을, 데이터 시트 변환은 행 수·합계·외래 키 정합성을, GDD 자동 생성은 핵심 섹션 누락을 자동 검사한다. 통과하면 사람은 안 봐도 되고, 통과하지 못한 것만 본다. 캐비닛에 가득 찬 서류 중 빨간 표시가 붙은 폴더만 손에 드는 풍경이다. 사람의 시선이 위험한 곳에만 떨어지도록 설계하는 것, 그게 검증 자동화의 목적이다.
이제 그 회의록 작업을 처음부터 끝까지, 네 단어가 어떻게 한 줄에 꿰이는지 정리한다.
| 칸 | 무슨 일 | 어느 개념 |
|---|---|---|
| 1 | Claude Code를 회의록 폴더에서 실행 | 하네스 |
| 2 | 작업이 회의록 분석이라 Sonnet 선택 | 모델 |
| 3 | 합산 약 11K 토큰, 200K 윈도우 안 — OK | 토큰·컨텍스트 |
| 4 | 회의 분류 규칙 atom을 JIT로 자동 주입 | 토큰(절약) |
| 5 | 모델이 결정 12건을 표로 출력 | 모델·하네스 루프 |
| 6 | Linter가 형식 누락 1건 적발 → 되물어 보강 | 검증(루프 1회 추가) |
| 7 | weekly-decisions-2026-W21.md로 저장·커밋 |
하네스 |
손으로 했다면 회의록 다섯 개를 열어 읽고 결정만 골라 옮겨 적고 형식을 맞추는 데 30분이 든다. 자동화되면 5분으로 줄고, 그 5분 안에 사람의 손은 검증 샘플을 한 번 훑는 일뿐이다. 사람의 시간이 정말 필요한 곳(보류된 건이 결정으로 잘못 올라오지 않았는지)에만 떨어진다. 절약된 25분이 아니라 그 시선이 가는 자리가 달라진 것이 핵심이다.
"Opus가 항상 더 좋다"가 가장 흔하다. 비용·속도를 무시하면 그렇지만 단순 작업에 Opus는 낭비다. 작업별 매칭이 답이다.
"1M 컨텍스트가 항상 필요하다"도 자주 나온다. 대부분 200K로 충분하고, 1M은 부담이 따르니 정말 큰 자료 묶음에만 쓴다.
"검증은 사람이 하는 것"은 절반만 맞다. 자동 검증 가능한 부분이 대부분이고 사람은 나머지에 집중한다.
"토큰은 신경 안 써도 된다"는 개인 작업에선 어느 정도 통하지만, 여러 사람이 함께 쓰면 누적 비용이 빠르게 커진다. 초기부터 가시화·절약 패턴을 정착시키는 편이 안전하다.
"하네스는 차이가 없다"도 의외로 많다. 같은 모델이라도 챗봇이냐 CLI냐에 따라 다른 도구처럼 갈린다. 앞에서 본 복사·붙여넣기 노동의 유무가 그 차이다.
작은 작업 하나로 이 장의 네 단어를 직접 돌려 보세요.
setup
prompt
이 폴더의 노트들에서 "결정된 것"만 골라
주체·내용·기한 3열 표로 만들어 줘.
보류·논의 중인 건은 빼고, 표에 넣은 각 줄이
어느 파일에서 왔는지 파일명도 함께 적어 줘.
verify
/context를 쳐서 이 세션이 쓴 토큰을 보세요(생각보다 입력이 큰 걸 확인하게 됩니다).1인 축소판
도구를 막 시작했다면 위에서 딱 두 가지만 챙기세요. 첫째, 노트는 폴더째 맡기고 복사·붙여넣기를 직접 하지 마세요(하네스 루프에게 맡깁니다). 둘째, 출력 표는 무조건 파일명을 함께 받아 의심스러운 줄만 원본을 열어 보세요. 모델 선택이나 토큰 가시화는 익숙해진 다음에 붙여도 늦지 않습니다. 자료를 손으로 나르지 않는 것과 출력을 원본에 대조하는 것, 이 두 습관만으로 도입의 절반은 자리를 잡습니다.
새 세션을 열고 "스킬 쿨다운 밸런스 한 번 보자"라고 입력했다. 엔터를 누르기 전, 화면 아래쪽에 작은 회색 글자가 한 줄 스쳐 지나간다. [memory injected: 2 atoms, 1,842 chars]. 내가 아무 파일도 열지 않았는데, 지난주에 박제해 둔 쿨다운 규칙 문서가 이미 모델의 입력 앞에 붙어 들어갔다는 뜻이다. 이게 인프라가 깔린 작업 환경의 첫 신호다. 도구를 켜는 순간, 도구가 나를 기억하고 있다.
이 장면이 가능하려면 세 가지가 미리 자리를 잡고 있어야 한다. AI가 무엇을 기억할지(메모리), AI가 사람 승인 없이 무엇을 할 수 있는지(권한), 그 둘을 켜고 끄는 중앙 스위치(settings.json). 처음 설치하는 데 드는 시간은 길어야 한 시간이고, 그 한 시간이 이후 6개월간 매일 절약될 시간으로 돌아온다. 거의 전부 회수되는 투자다.
이 장은 저자가 실제로 개인 PC에서 돌리는 settings.json 한 줄, 그 한 줄이 호출하는 inject_memory.py, 그 파일이 읽는 _jit_manifest.json을 차례로 펼쳐 따라가는 워크스루다. 끝까지 읽으면 "메모리가 자동 주입된다"는 말이 어느 파일의 어느 줄에서 일어나는 일인지 손으로 짚을 수 있다.
먼저 결론부터 본다. 저자의 개인 PC에서 메모리 자동 주입을 켜는 것은 settings.json 안의 단 하나의 블록이다.
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "python ~/.claude/hooks/inject_memory.py"
}
]
}
]
}
}
이 블록이 하는 말을 한국어로 풀면 이렇다. "사용자가 프롬프트를 제출하는 이벤트(UserPromptSubmit)가 일어날 때마다, inject_memory.py라는 파이썬 스크립트를 한 번 실행하라." 그게 전부다. AI가 똑똑해서 알아서 기억하는 게 아니라, 입력이 들어올 때마다 사람이 등록해 둔 스크립트가 한 번씩 끼어드는 구조다.
settings.json은 Claude Code의 모든 동작을 제어하는 중앙 파일이고, 두 층으로 나뉜다.
~/.claude/settings.json — 글로벌. 모든 세션에 적용. 팀과 공유 가능한 설정.~/.claude/settings.local.json — 로컬. 이 PC에서만 적용. 개인 PC 특수 설정.둘은 병합되어 적용된다. 그래서 저자는 팀이 공유해야 할 hook·권한은 settings.json에, 이 집 PC에서만 쓰는 절대경로나 개인 도구 경로는 settings.local.json에 나눠 둔다. 협업 시 git 충돌을 피하고 개인 설정이 팀 저장소로 새는 사고를 막는 분리다.
자주 들어가는 항목은 hook 말고도 몇 가지가 더 있다.
effortLevel — 모델의 추론 깊이. low / medium / high. 기획서 설계처럼 깊은 판단이 필요한 작업은 high로 둔다.permissions — AI가 승인 없이 실행할 수 있는 명령의 범위(1.3.4에서 상세).enabledPlugins — 활성화된 플러그인 목록.여기서 가장 중요한 단 하나의 운영 습관이 있다. settings.json은 작은 오타 하나로도 도구 자체가 안 뜨게 만든다. JSON 쉼표 하나만 빠져도 파싱이 깨진다. 그래서 변경 전 백업이 필수다. 저자의 PC에는 실제로 이런 백업 파일이 남아 있다.
settings.json.bak_2026-05
settings.local.json.bak_2026-05
날짜 suffix로 한 벌 떠 두면 롤백이 1초다. git으로 관리하면 더 좋다. 서랍 속 옛 키 한 벌처럼, 평소엔 안 쓰지만 잠긴 문 앞에서 반드시 한 번 필요한 순간이 온다.
이제 settings.json이 호출한 스크립트 안으로 들어간다. 워크스루의 척추다. 코드는 100줄 남짓이지만 핵심은 다섯 동작이다.
flowchart TD
A["세션: 사용자 프롬프트 제출\n예: '스킬 쿨다운 밸런스 보자'"] --> B["UserPromptSubmit hook 발동\nsettings.json이 inject_memory.py 실행"]
B --> C["_jit_manifest.json 로드\natom 17개 메타데이터"]
C --> D["score 내림차순 정렬\n→ atom마다 regex 매칭 시도"]
D --> E{"매칭된 atom\n있는가?"}
E -->|없음| Z["아무것도 주입 안 함\nexit 0"]
E -->|있음| F["상위 max_matches(3)개만 선택"]
F --> G["합산 6,000자 초과 시\ntruncate"]
G --> H["사용자 입력 앞에\natom 본문 첨부"]
H --> I["모델이 atom + 프롬프트\n함께 수신 → 응답"]
D -.->|어떤 예외든| Z2["예외 무조건 삼킴\nexit 0 (흐름 안 막음)"]
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
class A human
class B,C,D,E,F,G,H code
class I ai
다섯 동작을 풀어 쓰면 이렇다.
1) manifest를 읽는다. 스크립트는 먼저 ~/.claude/projects/C--Users-user/memory/_jit_manifest.json을 연다. 이 파일에는 atom의 메타데이터(이름·경로·매칭 regex·점수)가 정리돼 있다. 저자의 개인 PC에는 현재 17개 atom이 등록돼 있다.
2) score 내림차순으로 정렬한다. atom마다 score 값이 있다. 점수가 높은 atom일수록 먼저 매칭 시도된다. 같은 키워드에 여러 atom이 걸릴 때, 누가 우선권을 갖는지를 이 점수가 결정한다.
3) regex로 매칭한다. 사용자 입력 문자열을 각 atom의 regex 패턴과 대조한다. "쿨다운"이 입력에 있으면 쿨다운|cooldown|GCD 패턴을 가진 atom이 걸린다. 대소문자를 가리지 않고 비교한다.
4) 최대 3개까지만 자른다. 매칭이 아무리 많아도 max_matches(저자 환경은 3)를 넘으면 상위 3개만 남긴다. 추가로 선택된 atom 본문의 합산 길이가 6,000자를 넘으면 truncate한다. 두 겹의 상한으로 입력이 비대해지는 걸 막는 안전장치다.
5) 어떤 예외에도 exit 0으로 끝난다. 설계의 핵심이다. manifest가 깨졌든, 파일이 사라졌든, regex가 잘못됐든 스크립트는 예외를 조용히 삼키고 종료 코드 0으로 끝난다. hook이 0이 아닌 코드로 죽으면 사용자의 프롬프트 자체가 막힐 수 있기 때문이다. "메모리 주입이 실패해도 사용자의 작업 흐름은 절대 막지 않는다"는 원칙이 코드 맨 바깥의 try/except에 기록되어 있다.
무게중심은 4번과 5번에 있다. 4번(상한)은 메모리가 토큰을 폭발시키지 않게 막고, 5번(예외 삼킴)은 인프라가 작업을 방해하지 않게 막는다. 둘 다 "자동화가 사람을 거추장스럽게 만들지 않는다"는 같은 철학의 두 얼굴이다.
1.2에서 "필요한 자료만, 상위 몇 개만, 실패해도 조용히"라는 토큰 절약 원칙을 약속하고 구현 디테일을 이 장으로 넘겼다. 그 디테일이 사는 곳이 inject_memory.py가 읽는 manifest다. JIT(Just-In-Time, 필요할 때만 자료를 불러오는 방식)의 심장이고, atom 하나의 엔트리는 다음과 같은 모양이다.
{
"atoms": [
{
"name": "combat_cooldown_rule_v2",
"path": "atoms/combat/combat_cooldown_rule_v2.md",
"regex": "쿨다운|cooldown|GCD",
"score": 80
},
{
"name": "user_health",
"path": "memory/user_health.md",
"regex": "건강|복약|컨디션|약물",
"score": 95
}
],
"config": {
"max_matches": 3,
"case_insensitive": true
}
}
네 개의 필드가 한 atom을 정의한다.
name — atom의 고유 이름.path — 매칭되면 본문을 읽어올 파일 경로.regex — 어떤 키워드가 입력에 들어오면 이 atom을 깨울지 정의하는 패턴.score — 정렬 우선순위. 높을수록 먼저 매칭되고 먼저 자리를 차지한다.config 블록의 max_matches: 3은 1.3.2에서 본 "최대 3개" 상한의 출처다. manifest를 손으로 고치면 동작이 즉시 바뀐다.
규모 감각을 하나 짚어 둔다. 저자의 개인 PC는 atom 17개, manifest 한 벌로 가볍게 운영된다. 반면 회사 실무 환경(프로젝트 A)은 2026년 5월 기준 백업에서 팀 atom 304개, skill 48개가 등록돼 있다. Hot atom 하나는 score가 356.53까지 올라가 있는데(파일명 규칙을 다루는 view_html_filename_convention 계열), 처음부터 높았던 게 아니라 반복 호출·검증되며 누적된 흔적이다.
개인 PC 17개와 회사 304개의 차이가 말해 주는 건, 같은 JIT 메커니즘이라도 자료가 쌓이는 속도와 규모는 프로젝트 밀도에 비례한다는 점이다. 처음부터 304개를 만들 필요는 없다. 핵심 atom 다섯 개로 시작해 매주 한두 개씩 박제하다 보면 어느새 manifest가 두꺼워진다.
저자 추정(미검증): score가 매칭·검증 횟수에 따라 누적된다는 서술은 운영 패턴에 기반한 해석이다. 점수 산정 공식 자체는 환경별 manifest 설계에 따라 달라지므로, 위 356.53 같은 절대값은 저자 환경의 실측 스냅샷일 뿐 일반 표준이 아니다.
메모리는 두 층으로 나눠 둔다는 원칙도 여기서 다시 짚는다.
| 구분 | 위치 | 언제 로드 | 용도 |
|---|---|---|---|
| 글로벌 | ~/.claude/memory/ |
모든 세션 | 본인 정체성·협업 규칙·언어 설정 |
| 프로젝트 | ~/.claude/projects/<프로젝트>/memory/ |
해당 프로젝트 세션 | 프로젝트별 atom·규칙·자료 |
글로벌은 가볍게 유지하는 편이 안전하다. 글로벌이 무거워지면 그 무게가 모든 세션에 토큰 비용으로 누적되기 때문이다. 사무실로 치면 글로벌은 책상 위 명함첩(가벼울수록 매일 쓰기 편하다), 프로젝트 메모리는 옆 캐비닛의 폴더(프로젝트 단위로 두꺼워져도 평소 부담이 없다)다. 그래서 자동 로드되는 글로벌에는 핵심만 두고, 풍부한 자료는 프로젝트 메모리에 쌓은 뒤 JIT로 필요할 때만 깨운다.
이제 인프라의 세 번째 축, 권한이다. Claude Code는 파일을 지우고, 명령을 실행하고, 외부 API를 호출할 수 있다. 강력함은 위험과 함께 온다. 권한 시스템이 그 위험을 관리한다.
권한은 두 종류로 갈린다. 사람 승인 없이 자동으로 실행되는 것과, 매번 승인을 받아야 하는 것. 어느 쪽에 무엇을 둘지는 settings.json의 permissions 블록에서 정의한다.
{
"permissions": {
"allow": [
"Bash(ls:*)",
"Bash(git status:*)",
"Bash(git diff:*)",
"Read(*)",
"Grep(*)"
],
"deny": [
"Bash(rm -rf:*)",
"Bash(git push --force:*)"
]
}
}
여기서 관점의 전환이 필요하다. 이 allow 리스트는 단순한 설정값이 아니라 작업 누적의 흔적이다. 처음엔 읽기·검색 정도만 자동 허용으로 두고 거의 비어 있다. 그런데 한두 달 같은 작업을 반복하다 보면 "이 명령은 매번 승인 누르기 귀찮은데" 싶은 패턴이 생기고, 그걸 하나씩 allow로 옮긴다. 길어진 리스트는 곧 내가 이 도구로 무엇을 반복해 왔는지의 지문이다.
저자의 회사 환경(프로젝트 A)은 약 80개의 자동 허용 패턴을 갖고 있다. 20개로 시작해 6개월에 걸쳐 60개가 더 붙었는데, 그 60개를 거꾸로 읽으면 지난 반년간 무슨 작업을 반복했는지가 드러난다. 데이터 시트 추출, 관계도 생성, 스키마 문서화 — 자주 쓰는 도구가 곧 자주 허용한 권한이다.
권한 운영에는 네 가지 패턴이 자리 잡는다.
rm -rf, git push --force처럼 한 번의 사고가 치명적인 명령은 deny에 입력한다. 자동 허용을 넓혀도 이 둘은 건드리지 않는다.allow를 재검토하고 안 쓰게 된 권한을 덜어낸다. 흔적은 쌓이기만 하면 노이즈가 된다.매번 승인 팝업이 뜨면 사람이 지친다. 피로를 줄이는 장치도 있다. fewer-permission-prompts 같은 슬래시 명령으로 자주 등장하는 패턴을 일괄 등록하거나, 한 세션 동안만 임시 허용을 주거나, 개인 작업에 한해 전권 자동 허용 모드를 쓰는 식이다. 다만 마지막 옵션은 팀 환경에서는 권하지 않는다.
피로감과 안전 사이의 균형은 본인이 조절한다. 너무 엄격하면 작업이 안 굴러가고, 너무 느슨하면 사고가 난다. 느슨하게 시작해도 분기 정리 사이클을 갖춰 두면 균형은 자연스럽게 잡힌다.
지금까지 본 세 축(settings·메모리·권한)이 한 세션에서 어떻게 동시에 작동하는지, 입력 한 줄을 기준으로 펼쳐 본다. 다음은 "스킬 쿨다운 밸런스 보자"를 입력했을 때 실제로 일어나는 일의 단면이다.
세 레인이 한 입력에서 만난다. settings.json이 hook을 깨우고, hook이 메모리를 골라 입력에 붙이고, 그렇게 만들어진 응답이 도구를 호출할 때 권한이 마지막 게이트로 작동한다. 사용자는 "쿨다운 보자" 한 줄만 쳤을 뿐인데 세 인프라가 보이지 않는 곳에서 차례로 일을 한다. 이게 도구가 "내 도구"로 느껴지는 순간의 내부 구조다.
이론을 봤으니 손을 움직인다. 처음 Claude Code를 설치한 뒤 한 시간이면 위 그림 전체를 본인 PC에 깔 수 있다. 다섯 구간으로 나눈다.
0~10분, 설치·실행 확인. 설치 후 터미널에서 Claude Code를 실행한다. 폴더 하나에서 "이 폴더에 뭐 있어?"를 물어 응답을 확인한다. 도구가 살아 있는지부터 본다.
10~25분, 글로벌 메모리 세 개 작성. 자동 로드되는 글로벌은 세 파일이면 충분하다.
MEMORY.md (5줄) — 본인 정체성 한 줄 + 다른 파일로 가는 포인터.user-profile.md (20~30줄) — 이름·역할·전문 분야·연락처.feedback-collaboration-style.md (20~30줄) — 언어·말투·실행 우선·설명 간결 같은 협업 규칙.저자 사례를 그대로 베껴 시작해도 좋다. 운영하며 다듬으면 된다.
25~40분, settings.json 기본 설정. effortLevel을 high로 두고, 권한 시작 세트(읽기·검색 자동, 쓰기·삭제 승인)를 넣고, 백업을 한 벌 뜬다(settings.json.bak_<날짜>). 백업은 이 구간에서 가장 중요한 한 줄이다.
40~55분, 첫 프로젝트 atom 다섯 개. 본인이 매번 잊는 결정, 자주 묻는 정보 다섯 개를 atom으로 만든다. 폴더는 ~/.claude/projects/<프로젝트>/memory/. 형식은 5장을 참조한다. 다섯 개면 JIT manifest를 당장 만들지 않아도 글로벌 자동 로드만으로 효과가 난다.
55~60분, 테스트 한 번. 새 세션을 열고 본인 분야 질문 하나를 던진다. 글로벌 메모리가 자동 로드됐는지, 응답 톤이 본인 협업 규칙을 따르는지 확인한다.
여기까지 한 시간. JIT manifest와 hook은 atom이 50개를 넘어설 무렵, 자동 로드가 무거워지기 시작할 때 도입해도 늦지 않다. 그 시점에 1.3.2의 inject_memory.py를 깔면 된다.
도입 초기에 반복되는 실수는 다섯 가지로 묶이고, 각각 같은 사고 원인 위에 서 있다.
| 실수 | 사고 원인 | 회피법 |
|---|---|---|
| 글로벌에 너무 많이 넣음 | 모든 세션이 무거워져 토큰을 낭비 | 글로벌은 5KB 이내, 디테일은 프로젝트 메모리로 이관 |
| 모든 권한을 자동 허용 | 편의가 위험을 가린 첫 자리 | 읽기·검색만 자동, 쓰기·삭제는 승인 (분기 정리 포함) |
| 백업 없이 settings 수정 | 깨진 settings가 도구 자체를 못 뜨게 만듦 | 변경 전 settings.json.bak_<날짜> 자동 저장 |
| atom을 메모리 폴더에 무한 적재 | 자동 로드가 토큰 한도를 압박 | 약 50개부터 JIT manifest 도입 |
| 팀·개인 설정을 한 파일에 섞음 | git 충돌·개인 설정 노출 | 팀은 settings.json, 본인은 settings.local.json |
다섯을 모두 첫날부터 회피할 필요는 없다. 글로벌 비대화와 백업 누락은 첫 한 시간 안에 회피 패턴을 잡아 두는 게 좋고, 나머지 셋은 한 달쯤 운영하면서 본인의 사고 가능성이 높은 자리에 회피 장치를 끼우는 편이 자연스럽다.
1.1은 도구 앞의 거리감을 줄이는 자리였고, 1.2는 그 도구의 최소 동작 메커니즘을 잡는 자리였으며, 1.3은 메모리·권한·settings로 첫 인프라를 깐 자리였다. 세 챕터가 책의 도입부에 해당한다. 여기까지 끝내면 도구를 멈춤 없이 운영하기 위한 기본 골격은 자리를 잡는다.
핵심은 이 골격이 정적인 설정이 아니라는 점이다. manifest의 atom은 매주 늘고, allow 리스트는 작업 흔적을 따라 길어지며, score는 검증을 거치며 누적된다. 인프라는 깔아 두는 순간 완성되는 게 아니라 깔린 위에서 사용자와 함께 자란다. 개인 PC의 17개가 회사의 304개로 벌어지는 차이가 그 성장의 거리다.
Part 2부터는 본격적인 정보 아키텍처로 들어간다. 4장 YAML 프론트매터, 5장 Atom, 6장 Layer, 7장 온톨로지가 차례로 이어진다. 1.3에서 manifest의 한 엔트리로만 봤던 atom이 2.2에서 한 챕터의 주인공이 된다. 척추가 잡힌 뒤에야 분야별 챕터가 같은 좌표 위에서 자기 자리를 찾는다.
setup
1. ~/.claude/settings.json을 열고, 변경 전 settings.json.bak_<오늘날짜>로 백업을 뜨세요.
2. permissions.allow에 Read(*), Grep(*), Bash(ls:*), Bash(git status:*)를 넣고, permissions.deny에 Bash(rm -rf:*), Bash(git push --force:*)를 입력하세요.
3. (atom 50개 이상일 때) hooks.UserPromptSubmit에 python ~/.claude/hooks/inject_memory.py를 등록하고, _jit_manifest.json에 atom 엔트리(name·path·regex·score)와 config.max_matches: 3을 작성하세요.
prompt - 새 세션에서 manifest의 어느 atom 키워드를 의도적으로 포함한 질문을 던지세요. 예: "쿨다운 규칙 기준으로 스킬 밸런스 보자".
verify
- 입력 직후 [memory injected: N atoms] 같은 신호가 뜨는지 확인하세요.
- 의도한 atom이 응답에 반영됐는지 보세요.
- 일부러 manifest에 깨진 JSON을 넣어 보고, 그래도 프롬프트가 막히지 않고 동작하는지(exit 0 보장) 확인한 뒤 원복하세요.
1인 축소판
- hook도 manifest도 없이 시작하세요. 글로벌 MEMORY.md 한 파일에 정체성 3줄 + 협업 규칙 3줄만 적고, 권한은 Read(*)·Grep(*)만 자동 허용으로 두세요. atom이 손에 익어 50개에 가까워질 때, 그때 1.3.2의 hook을 얹으세요. 인프라는 작게 시작해 흔적을 따라 키우는 것이지, 처음부터 304개를 갖추는 게 아닙니다.
마일스톤 빌드를 하루 앞둔 밤, 시스템 기획자 팀원 A가 메신저로 물었다. "이번 주에 보상 곡선 건드린 문서가 몇 개죠? 검토 끝난 게 어디까지예요?" 나는 답을 알지 못했다. 문서는 폴더 어딘가에 있었고, 누가 마지막으로 손댔는지, 어느 마일스톤 것인지는 각자의 기억과 파일명 약속에 흩어져 있었다. 그날 밤 우리가 한 일은 문서 첫 줄에 여섯 줄을 입력하는 약속을 만든 것이었다. 그 여섯 줄이 다음 마일스톤부터 팀원 A의 질문에 사람이 폴더를 열지 않고도 답하게 만들었다.
문서 맨 위 --- 사이에 적는 몇 줄의 YAML. 이걸 프론트매터라 부른다. 이 약속이, 본문을 한 글자도 읽지 않고 "이 문서가 무엇인지"를 사람과 기계 양쪽에 동시에 말해 준다. 이 장은 그 한 줄이 어떻게 정보 아키텍처 전체의 진입 좌표가 되는지를 실제로 돌아가는 스크립트로 따라간다.
미리 한 가지 용어만 짚는다. 이 책은 기획 문서를 다섯 개의 Layer로 나눈다(6장에서 본격적으로 다룬다). L0=세계관·콘셉트, L1=시스템 룰, L2=콘텐츠, L3=데이터, L4=구현 좌표. 위에서 아래로 의존하는 것이 정상 방향이다. 아래 절에 나오는 layer: 2는 "이 문서는 콘텐츠 Layer"라는 좌표 선언이다.
전통적인 기획 문서는 워드, PPT, 구글 닥스 위에서 살아왔다. 본문은 사람이 읽기에 최적화돼 있다. 그런데 문서의 종류·책임·상태·위치 같은 메타 정보는 본문 안에 녹아 있거나, 폴더 구조와 파일명 약속에 의존한다. 그래서 "이 문서는 어느 마일스톤 것이고 누가 책임자이며 마지막 검토가 언제인가"를 알려면 본문을 열어 봐야 한다.
여기에 두 가지 한계가 겹친다. 첫째, 문서가 자기 정체를 스스로 말하지 않는다. 정체는 사람의 기억과 폴더 약속 안에 있고, 그 약속은 시간이 지나면 부식한다. 둘째, AI가 컨텍스트를 추론할 단서가 없다. Claude Code에 "이 문서 검토해 줘"라고 하면 본문을 처음부터 끝까지 읽으며 토큰을 낭비하고, 책임 범위가 어디까지인지도 모른다.
YAML 프론트매터는 이 둘을 한 번에 푼다. 문서 첫 줄에 메타데이터를 명시적으로 입력해 두면, 사람도 기계도 본문을 열지 않고 문서를 식별한다. 캐비닛 서랍 정면에 라벨이 붙어 있으면 서랍을 열지 않아도 안을 아는 것과 같다. 그리고 이 라벨은 단순한 분류 도구에 그치지 않는다. 뒤에서 보겠지만 layer 필드 하나가 절차적 생성과 자동 검수의 진입 좌표가 된다.
추상적인 예시 대신, 프로젝트 A의 보상 곡선 문서가 실제로 머리에 이고 있는 프론트매터를 그대로 본다(ID·실명만 가명 처리, 구조는 운영 그대로다).
---
title: "메인 퀘스트 12장 보상 곡선"
layer: 2
status: review
owner: teammate_a
created: 2026-04-15
updated: 2026-05-20
related:
- quest_main_chapter12
- reward_curve_milestone_2
affects:
- L3_BalanceSheet_v2
ip_check: passed
---
# 메인 퀘스트 12장 보상 곡선
(본문 시작)
핵심은 --- 위와 아래의 분리다. 위는 파서가 읽는 데이터, 아래는 사람이 읽는 본문이다. 마크다운 렌더러는 보통 프론트매터를 숨기므로 읽을 때 방해받지 않는다. 한 파일이 데이터(frontmatter)와 콘텐츠(본문)를 함께 담아 단일 진실 출처가 된다.
layer: 2와 affects: [L3_BalanceSheet_v2], 이 두 줄을 눈여겨봐야 한다. "이 콘텐츠(L2) 문서가 데이터 Layer(L3)의 밸런스 시트에 영향을 준다"는 선언이다. 이것만으로 도구는 L2→L3 의존 관계를 본문 없이 그래프로 그린다. 거꾸로 L3 데이터 문서가 L1 시스템 룰을 depends_on으로 참조하면(아래에서 위로 향하는 역방향 의존), 그건 설계 냄새다. 도구가 그 역참조를 자동으로 검출한다.
YAML이 JSON보다 손으로 쓰기 편한 이유는 단순하다. 들여쓰기로 구조를 표현하고, 따옴표가 거의 필요 없으며, # 주석을 쓸 수 있다. 기획자가 직접 채워 넣기에 적합하다.
_NAMING_FRONTMATTER_STANDARD필드는 무한정 늘릴 수 있다. 늘릴수록 작성 부담이 커지고 표준이 무너진다. 그래서 프로젝트 A는 두 층으로 나눠 운영한다. 모든 문서 공통의 최소 핵심 필드와, 분야별 도메인 확장 필드다.
공통 최소 핵심 필드는 여섯 개다.
| 필드 | 형식 | 용도 |
|---|---|---|
title |
문자열 | 사람 가독 제목. 파일명과 달라도 됨 |
layer |
0~4 | 6장 Layer 좌표 |
status |
draft / review / approved / archived | 문서 상태 |
owner |
사용자명 | 책임자 (1인) |
created |
YYYY-MM-DD | 생성일 |
updated |
YYYY-MM-DD | 마지막 수정일 |
이 여섯 개만으로 문서의 신선도·책임·위치를 즉시 안다. 더 넣으려는 충동을 첫 한 달은 참는다. 운영하다 보면 어느 필드가 진짜 필요한지 자연스럽게 드러난다.
분야별 확장 필드는 도메인마다 다르다. 시스템 기획은 depends_on·affects, 전투 기획은 combat_phase·anim_target, 내러티브는 world_region·chapter, 밸런스는 data_sheet·formula_id를 즐겨 쓴다. 이 확장 필드들은 자유롭게 흩어지면 안 되므로, 단 하나의 표준 문서가 정식 이름·허용 값·예시를 못박는다. 그 문서가 _NAMING_FRONTMATTER_STANDARD.md다. 새 필드를 추가하려면 이 문서를 거쳐야 한다. 그리고 이 표준 문서 자체가 atom으로 등록돼 있어서, 문서명 앞에 Layer 번호를 강제하는 규칙(docs_layer_numeric_prefix_naming atom)과 같은 계열로 관리된다.
여기서 중요한 전환이 일어난다. 표준이 사람이 읽는 문서일 뿐이라면, 사람은 그걸 어긴다. 표준을 기계가 읽는 데이터로 만들면, 기계가 그걸 강제한다. 다음 절이 그 전환의 실제 코드다.
이제 "프로젝트 A의 모든 마크다운 문서가 프론트매터 표준을 지키는지 검사하는 Linter"를 Claude Code에 만들게 했다. 핵심 요구는 두 가지였다. 검사 항목(필수 필드 누락, status 비표준 값, layer 0~4 위반, review인데 90일 넘게 안 바뀐 문서)을 잡을 것, 그리고 허용 값을 코드에 하드코딩하지 말고 표준 문서에서 읽어 올 것. 이 분리가 핵심이다. 표준을 고치면 코드를 안 고쳐도 검사 기준이 바뀐다. (스크립트 전문과 직접 실행 절차는 이 장 끝 「따라하기」에 둔다.)
여기서 한 사건이 있었다. Claude가 처음 내놓은 코드는 STALE 검사에서 today - fm["updated"]로 날짜 차이를 계산하고, 주석에 "updated: 2026-05-20처럼 적혀 있으면 PyYAML이 datetime.date로 자동 파싱한다"고 달았다. 이 말은 절반만 맞다. 실제 문서에 돌리자 일부 파일에서 트레이스백이 떴다.
TypeError: unsupported operand type(s) for -: 'datetime.date' and 'str'
원인은 사람 손에 있었다. 어떤 작성자는 updated: 2026-05-20이라 적었고(date로 파싱됨), 어떤 작성자는 updated: "2026-05-20"이라 따옴표를 붙였다(문자열로 파싱됨). 표준이 날짜 형식을 못박지 않은 자리에서 사람의 손이 갈렸고, Claude는 한쪽만 가정했다. 나는 코드를 거부하고 "두 표기 모두 안전하게 date로 정규화하고, updated가 없는 경우도 걸러라"고 다시 요청했다. Claude는 입력 타입을 검사해 둘 다 datetime.date로 정규화하는 헬퍼를 끼워 넣었다(고친 블록도 「따라하기」 참조).
진짜 교훈은 코드 버그가 아니었다. 표준이 날짜 표기 형식을 못박지 않은 자리에서 사람 손이 갈렸다는 것이다. 그래서 _NAMING_FRONTMATTER_STANDARD.md에 updated: YYYY-MM-DD (따옴표 없이) 한 줄을 추가했다. Linter가 코드를 검사하다가, 검사 대상인 표준 자체의 구멍을 드러낸 셈이다.
수정된 스크립트의 첫 출력은 깨끗하지 않았다. 실제로 나온 지저분한 결과를 그대로 둔다.
[NO-FM] manuscript/legacy/old_combat_notes.md
[MISSING] manuscript/system/quest_flag_table.md: layer
[STATUS] manuscript/content/town_intro.md: WIP
[LAYER] manuscript/balance/dps_v2.md: None
[STALE] manuscript/system/inventory_rules.md: 134d
이 다섯 줄이 도입 초기 팀의 실제 상태였다. 옛 문서엔 프론트매터가 아예 없고(NO-FM), 어떤 문서는 layer를 빠뜨렸고, 누군가는 status: WIP라는 비표준 값을 썼고, 밸런스 문서 하나는 layer를 None으로 비워 뒀고, 시스템 룰 문서 하나는 134일째 review 상태로 잠들어 있었다. 표준은 처음부터 지켜지지 않는다. Linter는 그 사실을 매일 아침 드러낼 뿐이다.
위 워크드 트랜스크립트를 한 장의 흐름으로 압축하면 다음과 같다. 사람이 쓴 한 줄이 어떻게 기계의 검사 게이트까지 흘러가는지를 보여 준다.
flowchart TD
A["작성자: 새 문서 생성
템플릿이 프론트매터 6필드 자동 삽입"] --> B["빈 값만 채움
title / layer / status / owner ..."]
B --> C["저장된 .md 파일
--- frontmatter --- + 본문"]
C --> D["Linter 스크립트 실행
rglob('*.md')"]
D --> E["_NAMING_FRONTMATTER_STANDARD.md
허용 값을 읽어 옴"]
E --> F{"검사
필수필드 / status / layer / stale"}
F -->|"통과"| G["관계 그래프 입력
related·affects → L 좌표 의존도"]
F -->|"위반"| H["일일 리포트
책임자 owner에게 자동 통지"]
H --> B
G --> I["AI 질의 가능
'Layer2 review 문서 모아줘' → 즉답"]
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
classDef fail fill:#fee2e2,stroke:#dc2626,color:#7f1d1d;
class A,B human
class D,F code
class C,E,G data
class H fail
class I ai
핵심은 두 가지다. 첫째, 표준(E)이 스크립트(D)와 분리돼 있다. 표준을 고치면 코드를 안 고쳐도 검사 기준이 바뀐다. 둘째, 위반(H)은 막다른 길이 아니라 작성 단계(B)로 되돌아가는 루프다. 사람을 탓하는 게 아니라, 본인 문서를 본인이 고치게 되돌려 보낸다.
저자가 디렉터로 운영하는 프로젝트 A는 약 6개월 전 프론트매터를 전 기획팀(4~5인)에 도입했다. 도입은 단번에 된 게 아니라 네 번의 마디를 거쳤다.
도입 1주차의 가장 큰 거부감은 "이걸 매번 손으로 쓰라고?"였다. 새 문서마다 여섯 줄을 외워 적는 건 번거롭다. 해결은 템플릿 자동 삽입이었다. VSCode 스니펫, 옵시디언 템플릿, 기획 포탈의 "새 문서" 버튼이 빈 YAML 블록을 자동으로 끼워 준다. 작성자는 빈 값만 채운다. 거부감이 1주일 안에 사라졌다.
1개월차엔 표준 충돌이 터졌다. 여러 명이 자유롭게 필드를 추가하면서 owner·responsible·author가 동시에 나타났다. 같은 개념인데 표기가 셋이라 검색도 자동화도 깨졌다. 해결은 _NAMING_FRONTMATTER_STANDARD.md 한 문서로 모든 필드의 정식 이름·허용 값·예시를 정리하고, 새 필드 추가를 이 문서 경유로 룰화한 것이었다. 한 달 안에 표준이 안정됐다.
3개월차엔 2.1.4에서 본 Linter가 들어왔다. 표준이 있어도 사람은 어긴다. 그래서 매일 아침 정합성 리포트가 자동 생성돼 팀 메신저의 공용 채널에 떨어지게 했다. 책임자는 본인 문서만 보면 된다. 자동화 이후 표준 위반이 눈에 띄게 줄었다(저자 추정, 정밀 측정값 아님 — 대략 절반 이하로 체감).
6개월차엔 AI와의 결합이 빛을 냈다. 표준이 안정되자 다음 같은 질의가 즉답으로 돌아왔다.
related·affects 그래프를 따라 자동으로결국 프론트매터는 사람과 AI 사이의 공용 어휘가 됐다. 사람이 쓰면 AI가 이해하고, AI가 쓰면 사람이 검증한다. 둘 다 같은 키를 본다. 다만 1주차의 거부감, 1개월차의 충돌, 3개월차의 Linter, 6개월차의 결합 — 누적된 6개월이 만든 결과지 한 번에 생긴 건 아니다.
도입 초기에 반복되는 실수는 다섯 가지로 묶인다. 모두 같은 뿌리 — "표준을 사람의 의지에만 맡긴 자리" — 위에 서 있다.
| 실수 | 사고 원인 | 회피법 |
|---|---|---|
| 필드를 처음부터 너무 많이 정의 | 작성자가 빈 값 채우다 지쳐 품질 저하 | 핵심 6개로 시작, 1~2개월 후 자주 쓰는 것만 추가 |
필드 이름이 계속 바뀜 (tag→tags→category) |
옛 이름이 누적 문서에 남아 검색·자동화가 깨짐 | 명명 변경 시 마이그레이션 스크립트 동반. 옛 이름 발견 시 자동 변환 또는 경고 |
| 사람이 매번 손으로 적음 | 오타·필드 누락·날짜 표기 분기(2.1.4의 그 버그)가 일상화 | 템플릿·스니펫·"새 문서" 자동화 우선. 사람 손은 의미 있는 값에만 |
| 표준만 두고 검증 없이 방치 | 표준이 있어도 누가 어겼는지 모르고 자연 부식 | Linter + 일일 자동 리포트로 위반자 본인이 고치게 함 |
layer 필드를 잊음 |
Layer 좌표가 없으면 분야 간 가시성·검수 게이트 둘 다 못 생김 | layer를 필수 필드로 강제. Linter가 누락 검출 |
다섯 실수를 첫날부터 다 막을 필요는 없다. 1·3번은 도입 1주차에 회피 패턴을 잡아 두고, 2·4·5번은 운영하면서 본인 팀이 가장 자주 부딪히는 자리부터 차례로 끼워 넣는 편이 자연스럽다.
프론트매터 도입은 의외로 가벼운 작업이다. 3주면 한 팀 안에 자리를 잡는다.
첫 주에는 핵심 여섯 필드를 정의하고 템플릿을 만들어 새 문서에만 적용해 작성 부담을 최소화한다. 둘째 주에는 자주 보는 상위 20개 문서에 수동 적용해, 실제 사용에서 어떤 필드가 부족한지 점검한다. 셋째 주에 Linter와 일일 리포트를 가동하면, 그때부터 표준은 사람의 의지가 아니라 도구의 힘으로 유지된다.
기존 문서 전체를 한 번에 마이그레이션하지 않는다. 자주 보는 것부터, 새 문서부터 적용한다. 6개월쯤 지나면 거의 모든 문서에 프론트매터가 붙는다. 그렇다고 100%가 목표는 아니다. 한 번도 안 열어 본 옛 문서까지 마이그레이션하느라 시간을 쓰는 건 낭비다.
가장 작은 단위로, 한 번의 사이클을 직접 돌려 봅니다.
setup
- 작업 폴더에 검사 대상 .md 문서를 2~3개 둡니다. 일부는 일부러 layer를 빼거나 status: WIP 같은 비표준 값을 넣어 둡니다.
- 같은 폴더에 표준 문서 한 줄을 둡니다.
status: allowed = ["draft", "review", "approved", "archived"]
updated: YYYY-MM-DD (따옴표 없이)
prompt (Claude Code에 입력)
이 폴더 아래 모든 .md의 YAML 프론트매터를 검사하는 파이썬 스크립트를 써 줘. 필수 필드 title·layer·status·owner 누락, status 허용 값 위반(표준 문서에서 읽어 와), layer 0~4 정수 위반, review인데 updated가 90일 초과를 잡아 줘.
updated가 문자열로 와도 date로 와도 안전하게 처리하고, 위반을 파일별로 출력해 줘.
verify
- 스크립트를 돌려 일부러 심은 위반이 전부 잡히는지 확인합니다.
- 표준 문서의 allowed 목록에 WIP를 추가한 뒤 다시 돌려, 코드를 한 줄도 안 고쳤는데 status: WIP가 통과로 바뀌는지 확인합니다. 표준과 코드가 분리됐다는 증거입니다.
- updated에 따옴표를 붙인 문서와 안 붙인 문서를 둘 다 넣고, 2.1.4에서 본 TypeError가 안 나는지 확인합니다.
참고: Linter 스크립트 전문
2.1.4에서 Claude가 처음 내놓은 코드다. STALE 검사 줄(age = (today - fm["updated"]).days)에 datetime 버그가 그대로 들어 있다.
import sys, datetime, pathlib, re
import yaml # PyYAML
ROOT = pathlib.Path("manuscript")
STANDARD = pathlib.Path("_NAMING_FRONTMATTER_STANDARD.md")
REQUIRED = ["title", "layer", "status", "owner"]
def load_allowed_status(standard_path):
# 표준 문서에서 `status` 허용 값을 추출
text = standard_path.read_text(encoding="utf-8")
m = re.search(r"status:\s*allowed\s*=\s*\[(.*?)\]", text)
if not m:
return ["draft", "review", "approved", "archived"]
return [s.strip().strip('"').strip("'") for s in m.group(1).split(",")]
def parse_frontmatter(md_path):
text = md_path.read_text(encoding="utf-8")
if not text.startswith("---"):
return None
end = text.find("---", 3)
block = text[3:end]
return yaml.safe_load(block)
def main():
allowed = load_allowed_status(STANDARD)
today = datetime.date.today()
violations = 0
for md in ROOT.rglob("*.md"):
fm = parse_frontmatter(md)
if fm is None:
print(f"[NO-FM] {md}")
violations += 1
continue
for field in REQUIRED:
if field not in fm:
print(f"[MISSING] {md}: {field}")
violations += 1
if fm.get("status") not in allowed:
print(f"[STATUS] {md}: {fm.get('status')}")
violations += 1
if not isinstance(fm.get("layer"), int) or not (0 <= fm.get("layer") <= 4):
print(f"[LAYER] {md}: {fm.get('layer')}")
violations += 1
if fm.get("status") == "review":
age = (today - fm["updated"]).days # ← 여기가 깨진다
if age > 90:
print(f"[STALE] {md}: {age}d")
violations += 1
sys.exit(violations)
재요청 후 고쳐 온 핵심 블록. updated가 문자열로 와도 date로 와도 안전하게 정규화한다.
def as_date(v):
if isinstance(v, datetime.date):
return v
if isinstance(v, str):
return datetime.date.fromisoformat(v.strip())
return None
# main() 안의 STALE 검사 교체분
if fm.get("status") == "review":
upd = as_date(fm.get("updated"))
if upd is None:
print(f"[MISSING] {md}: updated")
violations += 1
elif (today - upd).days > 90:
print(f"[STALE] {md}: {(today - upd).days}d")
violations += 1
팀이 없어도 됩니다. 혼자 쓰는 메모 폴더에서, 핵심 필드를 title·status·updated 셋으로 줄이고, Linter는 "status가 review인데 updated가 30일 넘은 문서"만 잡게 합니다. 이것만으로도 "내가 검토하다 만 채 잊어버린 문서"가 매주 한 번 수면 위로 떠오릅니다. 표준-템플릿-검사의 삼각형은 1인 규모에서도 그대로 작동합니다.
layer 한 필드가 절차적 생성과 자동 검수의 진입 좌표가 된다신입이 들어온 첫 주, 그가 채팅으로 물었다. "전투 쿨다운이 0.6초 맞나요? 어디 문서에 적혀 있죠?" 나는 "스킬 시스템 GDD(Game Design Document, 상세 사양서)에 있어요"라고 답했다. 그가 다시 물었다. "그 GDD 어느 섹션이요? 클래스 설계 다음에 데미지 곡선, 그 뒤 UI 표시 방식까지 220줄인데요." 파일을 열어 직접 찾아 줬다. 137번째 줄이었다. 그가 마지막으로 물었다. "근데 왜 0.6초예요? 0.5는 안 됐나요?" 그 답은 어느 문서에도 없었다. 6개월 전 회의에서 정한 건 기억나는데, 이유는 회의록 어딘가에 묻혀 있었다.
이 5분짜리 대화 안에 220줄 통합 문서의 세 가지 실패가 다 들어 있다. 위치를 못 찾고(검색 실패), 이유가 없고(맥락 소실), 매번 사람이 중개해야 한다(자동화 불가). AI에게 같은 질문을 던지면 사정은 더 나쁘다. AI는 220줄을 전부 읽고 나서, 쿨다운과 무관한 데미지 곡선 얘기까지 섞어 답한다.
이 장의 처방은 단순하다. 한 문서에는 한 결정만 담는다. 이 원칙으로 잘게 쪼갠 결정 단위 문서를 atom이라 부른다. 220줄 GDD를 쪼개면 "쿨다운은 0.6초"가 하나의 atom이 되고, 그 atom 안에 위치·내용·이유·예외·관계가 한자리에 모인다. 이 장은 추상론 대신 실제 atom 한 개를 끝까지 해부한다. 어떻게 명명하고, 어떤 frontmatter를 입력하고, 관계를 어떻게 명시하며, 그 결과 AI가 어떻게 그 atom 하나만 정확히 집어내는지를.
combat_cooldown_rule_v2해부할 검체는 프로젝트 A에서 실제 운영 중인 atom 한 개다. 이름은 combat_cooldown_rule_v2. 파일 전문은 다음과 같다. 길지 않다. 한 결정만 담았으니까.
---
name: combat_cooldown_rule_v2
title: "전투 쿨다운 규칙 — v2"
type: rule
layer: 1
status: approved
owner: 이민수
created: 2026-03-10
updated: 2026-05-12
applies_to: [skill_system, item_system]
---
# 전투 쿨다운 규칙 v2
Why (왜): 동시 사용 가능한 스킬 수를 제한해 순간 의사결정 부담을
줄이고, 콤보 입력의 의미를 보존하기 위함.
Rule (규칙): 모든 액티브 스킬은 글로벌 쿨다운 0.6초 + 개별
쿨다운(스킬별 정의)을 갖는다. 글로벌 쿨다운 진행 중에는 어떤
액티브 스킬도 시전 불가.
How to apply (적용):
- 신규 스킬 정의 시 개별 쿨다운을 반드시 명시
- L3_SkillSheet의 cooldown 컬럼이 0이면 본 규칙 위반
- 빌드 단계 정합성 검사가 위반을 자동 검출
Exceptions (예외):
- 패시브 스킬은 본 규칙 미적용
- 궁극기는 별도 게이지 시스템 (See: [[ultimate_gauge_system]])
Relations (관계):
- affects: [[combat_dps_calculation_v3]], [[balance_curve_v3]]
- derives_from: [[principle_decision_load_reduction]]
- conflicts_with: [[skill_cancel_rule_legacy_v1]]
- requires: [[combat_input_buffer_system]], [[skill_system_v2]]
- is_a: rule
- part_of: combat_system_master
이 파일 한 장을 다섯 부위로 갈라 본다. 명명, frontmatter, 단일 결정, 관계, 추적가능성. 다섯 부위가 다 갖춰져야 AI가 이 atom을 "혼자서도 말이 되는 단위"로 읽는다.
파일 이름은 combat_cooldown_rule_v2다. 무심코 지은 이름이 아니라 세 토막의 구조를 가진다.
combat_ cooldown_rule _v2
└ prefix └ 결정 본문 └ 버전
(어느 도메인) (무엇에 대한 결정) (몇 번째 개정)
prefix combat_는 "이건 전투 도메인의 결정"이라는 좌표다. 프로젝트 A의 규칙 atom은 prefix로 도메인이 갈린다. quest_(퀘스트), data_(데이터 운영), docs_(문서 운영), meeting_(회의록), portal_(기획 뷰어). prefix만 봐도 이 결정이 누구 책임 영역인지, 어디서 영향을 받는지가 잡힌다.
명명이 흔들리면 모든 게 흔들린다. 같은 결정이 skill-cooldown.md와 cooldown_skill_v2.md로 두 번 존재하면, 검색도 깨지고 뒤에 나올 JIT 매칭도 깨진다. 그래서 프로젝트 A는 명명 규칙 자체를 하나의 atom으로 먼저 고정했다. atom_naming_convention_v1이 그것이고, snake_case·prefix 필수·버전 suffix를 강제한다. 그리고 이 규칙은 사람의 의지가 아니라 Linter가 지킨다. prefix 없는 파일명이 커밋되면 빌드 단계에서 걸린다.
명명에는 책 전체를 관통하는 더 큰 설계가 깔려 있다. frontmatter의 layer: 1이 그 두 번째 좌표다. prefix가 "어느 도메인"을 말한다면, Layer는 "어느 추상 계층"을 말한다. 두 좌표가 결합해야 atom의 위치가 평면 위 한 점으로 확정된다. 여기서 Layer는 좌표일 뿐이다(0~4 계층 정의의 상세는 2.3). 쿨다운 규칙은 "생성을 통제하는 입력 규칙"이므로 Layer 1에 앉는다. 이 Layer 좌표를 문서명 앞에 숫자 prefix로 강제하는 규칙도 따로 있다 — docs_layer_numeric_prefix_naming. 이름 하나에 두 개의 좌표축이 명시되어 있는 셈이다.
이 설계의 본질은 정리벽이 아니다. 내가 팀에 반복해서 한 말이 있다. "절차적 생성을 위해서 나눈 Layer였던 거고." atom마다 도메인 좌표(prefix)와 계층 좌표(Layer)가 명시되어 있으면, 나중에 AI가 "Layer 1의 combat 규칙 전부를 입력으로 받아 Layer 2 콘텐츠를 자동 생성"하는 게 가능해진다. 이름은 그 자동화의 주소 체계다.
본문 위 --- 사이의 YAML 블록이 frontmatter다. 2.1에서 다룬 표준을 atom에 그대로 적용한 것이고, 사람이 아니라 기계(빌드 스크립트·JIT hook·관계도 생성기)가 읽는 라벨이다.
| 필드 | 값 | 기계가 이걸로 하는 일 |
|---|---|---|
name |
combat_cooldown_rule_v2 | 다른 atom의 link 대상이 되는 고유 ID |
type |
rule | 카테고리별 통계·필터 (rule / concept / decision …) |
layer |
1 | Layer별 색상·정렬, 거꾸로참조 검출의 기준축 |
status |
approved | draft·approved·archived 중 approved만 빌드 포함 |
applies_to |
[skill_system, item_system] | 영향 범위 — 이 규칙이 닿는 시스템 |
created/updated |
2026-03-10 / 2026-05-12 | 변경 추적, 오래된 atom 점검의 기준일 |
이 라벨들이 입력되어 있으면 자동 검사가 가능해진다. 예를 들어 layer: 1로 선언된 시스템 규칙이 본문에서 [[L3_SkillSheet_row_0042]] 같은 데이터 atom(Layer 3)을 직접 참조하면, 그건 상위 계층이 하위 계층의 구체값에 묶인 거꾸로참조(L3→L1)다. 프로젝트 A는 이 패턴을 빌드 단계에서 자동 검출한다. 규칙은 데이터 한 행이 아니라 데이터의 형식을 참조해야 하기 때문이다. frontmatter의 layer 한 줄이 없으면 이 검사 자체가 성립하지 않는다.
status: archived 처리도 frontmatter의 일이다. 결정이 바뀌면 atom은 삭제되지 않고 status: archived + archived_at 날짜를 받는다. 빌드와 JIT는 archived atom을 제외한다. 기록은 남기되 현역에서는 빠지는 것이다. 프로젝트 A의 6개월 운영에서 폐기율은 약 15%였다(저자 실측). 이 비율이 0%에 가깝다면 폐기 워크플로가 작동하지 않는다는 신호로 읽는다.
atom 해부의 핵심은 본문이 결정 하나만 담았는지 확인하는 것이다. 검사법은 단순하다. 이 atom의 결정을 한 문장으로 요약해 보라.
"모든 액티브 스킬은 글로벌 쿨다운 0.6초를 갖는다."
한 문장으로 끝난다. 합격이다. 만약 요약이 "쿨다운은 0.6초이고, 콤보 중에는 50% 단축된다"처럼 두 문장이 되면, 그건 두 결정이다. combat_cooldown_rule_v2(기본 쿨다운)와 combat_combo_cooldown_reduction_v1(콤보 단축)으로 쪼갠다.
단일성을 보는 보조 검사가 두 개 더 있다.
독립 폐기 검사. 이 atom 하나만 폐기해도 시스템이 무너지지 않는가? 쿨다운 규칙을 폐기하면 전투 밸런스가 흔들리지만 시스템은 돈다. 단위가 맞다. 반대로 폐기 시 다른 다섯 개가 함께 무너진다면, 그 다섯은 사실 한 결정의 다섯 조각이다. 더 큰 atom으로 합쳐야 한다.
단일 참조 검사. 다른 곳에서 [[combat_cooldown_rule_v2]] 하나만 link로 걸어도 의미가 통하는가? 통한다면 단위가 맞다. 이 한 줄을 참조하려고 본문 여러 곳을 다 읽어야 한다면 아직 덜 쪼개진 것이다.
이 검사들을 통과한 본문은 자연히 다섯 섹션으로 정렬된다 — Why, Rule, How, Exceptions, Relations. 특히 Why를 지우지 마라. 앞 도입에서 신입이 마지막에 물은 "왜 0.6초예요?"의 답이 여기 있다 — "순간 의사결정 부담을 줄이고 콤보 입력의 의미를 보존하기 위함." 6개월 뒤 누가 "0.5초로 줄이자"고 제안할 때 이 한 줄이 토론의 출발점이 된다. Why가 사라진 atom은 아무도 손대지 못하는 화석이 된다.
atom 맨 아래 Relations 섹션이 이 검체를 고립된 메모가 아니라 그래프의 한 노드로 만든다. 핵심은 그냥 "관련 문서"가 아니라 관계의 종류를 명시한다는 점이다.
flowchart TD
P["원칙: 의사결정 부담 감소"] -->|derives_from| C["combat_cooldown_rule_v2"]
C -->|affects| A1["combat_dps_calculation_v3"]
C -->|affects| A2["balance_curve_v3"]
C -->|requires| R1["combat_input_buffer"]
C -->|requires| R2["skill_system_v2"]
C -.->|conflicts_with| X["skill_cancel_legacy_v1
(폐기 예정 충돌)"]
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
classDef fail fill:#fee2e2,stroke:#dc2626,color:#7f1d1d;
class P,C,A1,A2,R1,R2 data
class X fail
여섯 종류의 관계가 각자 다른 일을 한다.
derives_from: 이 결정이 어떤 상위 원칙에서 파생됐는가. 쿨다운 0.6초는 "의사결정 부담 감소"라는 원칙의 구체화다.affects: 이 atom이 바뀌면 무엇이 영향받는가. 0.6초를 0.5초로 바꾸면 DPS 계산과 밸런스 곡선이 흔들린다. 변경 전에 영향 범위를 자동으로 뽑을 수 있다.requires: 이 결정이 성립하려면 무엇이 먼저 있어야 하는가. 입력 버퍼 시스템이 없으면 글로벌 쿨다운이 입력을 먹어 버린다.conflicts_with: 무엇과 모순되는가. 구버전 스킬 캔슬 규칙과 충돌하며, 이 링크가 "둘 중 하나는 폐기돼야 한다"는 신호다.is_a / part_of: 분류(rule)와 소속(combat_system_master). 그래프의 골격.단순한 "Related: [문서A], [문서B]" 링크였다면 사람이 일일이 따져야 한다. 관계 유형이 enum으로 입력되어 있으면 기계가 따진다. "이 atom을 바꾸면 영향받는 것 다 보여 줘"는 affects를 따라가는 자동 쿼리가 되고, "지금 서로 모순되는 규칙 다 찾아"는 conflicts_with를 스캔하는 자동 검사가 된다. 이 여섯 enum의 본격적 온톨로지 설계는 2.4에서 다루고, 2.2는 atom 표준이 그 enum을 미리 적용한 형태라는 점만 짚는다.
관계 화살표는 관계도 생성 도구의 입력이기도 하다. 프로젝트 A의 gen_relation_map.py는 모든 atom의 frontmatter layer와 Relations 섹션을 읽어, Layer별로 색을 칠한 인터랙티브 관계도 HTML을 자동으로 그린다. atom 하나하나가 좌표(Layer)와 화살표(Relations)를 갖고 있기 때문에 가능한 일이다.
다섯 부위가 다 갖춰진 atom은 추적 가능하다. 누가·언제·왜 이 결정을 했고, 무엇을 위반으로 잡는지가 한자리에 있다. 추적가능성의 가치는 통계가 아니라 실제로 막아 낸 사건으로 보일 때 가장 선명하다.
프로젝트 A의 meeting_image_caption_standard atom은 회의록 첨부 이미지에 "어떤 화면인지·왜 첨부했는지·무슨 결정인지"를 캡션으로 반드시 명시하라는 규칙이다. 이 atom이 없던 시절, 한 회의록에 스크린샷이 캡션 없이 붙었고, 일주일 뒤 그걸 본 팀원이 "이게 무슨 화면이지?"를 작성자에게 확인하는 데 30분이 걸렸다. atom이 생긴 뒤 같은 누락이 재발했을 때는 빌드 단계 Linter가 캡션 없는 이미지를 자동으로 잡았다. 수정까지 5분. 30분이 5분이 됐다.
또 다른 검체 skill_listing_budget_wrapper_only_policy는 글로벌 슬래시 명령 슬롯을 12개로 제한하고, 본체 스킬은 별도 디렉토리에 두되 글로벌에는 wrapper 12개만 노출하라는 규칙이다. 박제되기 전엔 글로벌 슬래시 명령이 40개 가까이 불어나 세션 시작마다 토큰 예산을 갉아먹었다. atom 정의 뒤로는 자동 정리 도구가 매 세션 시작 시 초과분을 정리한다. 규칙이 사람의 기억이 아니라 도구로 집행되는 것이다.
이런 atom이 프로젝트 A에는 약 304개 쌓여 있다(저자 실측, 6개월 운영 시점). 분포의 큰 갈래만 보면 재발 방지 규칙(rule)이 가장 큰 비중이고, 그다음이 일회성 의사결정 박제(decision)·도메인 개념(concept)·협업 교정(feedback) 순이다. 한 atom이 막는 시간은 분 단위지만, 304개가 쌓이면 누적 절약은 일 단위로 넘어간다. 이게 atom을 "정리"가 아니라 "자산"이라 부르는 이유다.
지금까지 atom 한 개를 정적으로 해부했다. 이제 살아 움직이는 순간을 본다. 1.3의 JIT(Just-In-Time) hook은 입력 키워드에 매칭되는 atom만 골라 그 자리에서 컨텍스트에 주입한다. JIT manifest는 각 atom에 매칭 키워드와 점수를 매핑한 JSON이다.
{
"name": "combat_cooldown_rule_v2",
"path": "atoms/combat/combat_cooldown_rule_v2.md",
"regex": "쿨다운|cooldown|글로벌 쿨다운|GCD",
"score": 75
}
실제 주입은 이렇게 흐른다.
flowchart TD
A["사용자 입력:
스킬 쿨다운 0.5초로 줄이면?"] --> B["JIT hook:
manifest의 regex 스캔"]
B --> C{"쿨다운 매칭?"}
C -->|"예 score=75"| D["combat_cooldown_rule_v2
전문 주입"]
C -->|"아니오"| E["주입 안 함"]
D --> F["AI가 Why·Rule·Exception까지
읽고 응답"]
F --> G["답변: 0.6초는 의사결정 부담
감소가 근거. 0.5초로 줄이면
affects 대상인 DPS·밸런스 곡선
재검토 필요"]
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
class A human
class B,C code
class D data
class F,G ai
핵심은 마지막 칸이다. AI는 단지 "0.6초였다"고 답하지 않는다. atom의 Why를 읽었으니 근거를 대고, Relations의 affects를 읽었으니 변경 시 흔들릴 대상(DPS 계산·밸런스 곡선)까지 미리 짚는다. 잘게 쪼개고, 이유를 적고, 관계를 명시해 둔 다섯 부위가 전부 응답에 살아난다.
여기서 단일 결정 원칙이 자동화의 전제임이 드러난다. 만약 이 atom이 220줄 통합 GDD였다면, "쿨다운" 한 단어가 매칭되는 순간 클래스 설계·데미지 곡선·UI까지 통째로 주입되어 토큰 예산이 깎이고, AI는 다섯 결정 중 어디에 답할지 초점을 잃는다. atom이 작고 명확할수록 JIT 정확도가 올라간다. 잘게 쪼갠 상태는 정리의 미덕이 아니라 자동 주입의 전제 조건이다.
score는 컨텍스트 예산을 지키는 장치다. 한 입력에 여러 atom이 매칭되면 score 상위 N개만 주입한다(기본 3개). 점수 부여 기준은 운영하며 정한다.
해부한 검체 combat_cooldown_rule_v2는 status: approved를 받은 팀 공유 atom이다. 모든 atom이 처음부터 이 자리에 오지는 않는다. 프로젝트 A는 atom을 두 층으로 나눈다.
분리하는 이유는 심리적이다. 개인 atom이 자유로워야 검증 전 가설을 부담 없이 적고, 일주일 뒤 폐기할 수 있다. 처음부터 팀에 공개되면 "이거 틀리면 어쩌지" 싶어 아예 안 적게 된다. 반대로 팀 공유 atom은 엄격해야 전원이 신뢰하고 참조한다.
combat_cooldown_rule_v2도 처음엔 개인 atom의 "쿨다운 0.6초 테스트해 보자"는 한 줄 메모였을 것이다. 알파 빌드에서 검증된 뒤 변경 요청 형태로 팀 공유로 승격됐고, 다른 기획자의 리뷰를 거쳐 approved가 됐다. 이 개인→팀 승격 흐름 자체가 atom 시스템이 시간을 따라 똑똑해지는 self-improving 루프의 한 축이다.
atom 운영 초기에 반복되는 실수는 다섯으로 정리된다. 모두 "atom을 자산이 아니라 일회성 메모로 다뤘다"는 같은 뿌리에서 나온다.
| 실수 | 무엇이 깨지는가 | 회피법 |
|---|---|---|
| 첫 주에 너무 많이 만듦 | 미검증 atom이 쌓여 운영이 무너짐 | 검증된 한두 개부터, 자연 증가에 맡김 |
| 폐기를 안 함 | 낡은 atom이 JIT에 계속 매칭돼 오답 생성 | 분기 점검, status: archived + archived_at |
| 너무 추상적/구체적 | "좋은 디자인을 한다"는 검증 불가, 잡상 한 줄은 무의미 | "사거리는 0.5/1.5/3.0/5.0만" 수준으로 |
| 이름이 일관되지 않음 | 검색·JIT 매칭이 통째로 깨짐 | 명명 규칙 atom을 먼저 만들고 Linter로 강제 |
| Why를 안 적음 | 시간이 지나면 아무도 못 건드리는 화석이 됨 | Why·Rule·How·Exception·Relations 5섹션 강제 |
다섯 가지를 첫 달부터 완벽히 피할 필요는 없다. 1번과 4번은 명명 규칙 atom 하나로 같이 풀리고, 2·3·5번은 운영 3개월 시점에 분기 점검을 한 번 돌리면 자연스럽게 정렬된다.
이 장에서 atom 한 개를 다섯 부위로 갈라 봤다. 이름(좌표), frontmatter(기계 라벨), 단일 결정(한 문장 검사), 관계(영향 분석), 추적가능성(막아 낸 30분). 그리고 그 다섯 부위가 JIT 자동 주입에서 어떻게 통째로 살아나는지 확인했다.
이름에 명시된 두 좌표 중 하나인 layer: 1을 2.2는 슬쩍 짚고 넘어갔다. 2.3이 그 Layer를 정면으로 다룬다. atom마다 Layer 좌표를 부여하면 분야가 달라도 서로의 산출물이 어디 앉아 있는지 보이기 시작한다. 그리고 2.4는 이 장에서 enum 이름만 빌려 쓴 여섯 관계(affects·derives_from·conflicts_with·requires·is_a·part_of)를 온톨로지로 정식화한다. YAML(2.1) → Atom(2.2) → Layer(2.3) → Ontology(2.4)로 이어지는 정보 아키텍처의 뼈대 중, 이 장은 그 두 번째 마디였다.
setup. 작업 폴더에 atoms/ 디렉토리를 만들고, 명명 규칙 atom(atom_naming_convention_v1)을 가장 먼저 작성하세요. snake_case·prefix 필수·버전 suffix 세 줄만 적어도 됩니다. JIT를 쓴다면 _jit_manifest.json 빈 배열을 하나 둡니다.
prompt. 본인이 매번 잊는 결정 하나를 골라, 아래 프롬프트로 atom 초안을 받으세요.
"다음 결정을 atom 표준 형식으로 만들어 줘. 결정: '액티브 스킬은 글로벌 쿨다운 0.6초를 갖는다.' 섹션은 Why·Rule·How to apply·Exceptions·Relations 다섯 개. frontmatter에 name(snake_case+prefix), type, layer, status: draft, owner, created를 넣어 줘. 결정이 한 문장으로 요약되는지도 마지막에 확인해 줘."
verify. 받은 atom을 세 가지로 검사하세요. ① 결정이 한 문장으로 요약되는가(안 되면 쪼갭니다). ② Why가 비어 있지 않은가. ③ manifest에 {"name", "path", "regex", "score"} 한 줄을 추가하고, 그 regex 키워드를 실제 입력으로 던졌을 때 atom이 주입되는가. 세 개를 통과하면 첫 atom이 완성된 것입니다.
팀도 Linter도 빌드 파이프라인도 없는 1인 개발자라면, 이 장 전체를 노트 앱 폴더 하나로 줄일 수 있습니다.
domain_decision_v1 한 규칙으로 통일하세요. Linter 대신 본인 눈으로 지킵니다.→ 영향:, ↑ 근거:, ✕ 충돌: 세 표시만 써도 영향 추적의 9할이 삽니다.핵심은 도구가 아니라 다섯 부위의 습관입니다. 처음 10개 노트가 가장 어렵고, 그 고비를 넘기면 다음 100개는 손이 알아서 만듭니다.
분야가 셋에서 여덟으로 늘던 분기였다. 전투 기획자가 스킬 사거리를 8m로 확정했다. 같은 주, 레벨 디자이너는 던전 통로 폭을 6m로 잠갔다. 둘 다 자기 분야 안에서 완벽하게 합리적인 결정이었다. 문제는 3주 뒤 빌드에서 드러났다. 광역 스킬이 통로 벽을 뚫고 나가 적이 보이지도 않는 곳에서 죽었다. 누구의 실수도 아니었다. 두 사람은 서로의 결정을 들여다볼 창문이 없었을 뿐이다.
이 장은 그 창문을 만드는 이야기다. 각 분야가 자기 방을 그대로 가진 채, 옆방에서 무슨 일이 일어나는지 좌표 하나로 알 수 있게 하는 것. 그 좌표계를 Layer라고 부른다.
게임 기획은 분야가 잘게 분화돼 있다. 시스템·전투·내러티브·콘텐츠·레벨·밸런스·UX·QA. 각 분야는 자기 도구·산출물·회의를 갖는다. 규모가 커질수록 저마다 자기 영역에 깊이 들어가, 다른 분야가 무엇을 하는지 모르는 상태가 된다. 이를 사일로(silo)화라 한다.
사일로화의 비용은 시간이 지나서야 드러난다.
실력 부족이 원인이 아니다. 각자 자기 분야에서 합리적으로 결정했고, 다른 분야의 결정을 인지할 통로가 없었을 뿐이다. 회의로 메우면 회의가 폭증하고, 단톡으로 메우면 신호가 노이즈에 묻힌다. 회의와 단톡이 무가치하다는 게 아니라, 메울 수 있는 부분과 없는 부분의 경계를 명확히 하는 게 핵심이다.
해결책은 영역을 좁히지 않으면서(분야 분화 유지) 서로의 흐름을 알 수 있게(통합 가시성) 만드는 것이다. 상충해 보이는 두 요구는 같은 좌표계 위에 정렬하면 동시에 달성된다. 그 좌표계가 Layer다. 사무실로 치면 각자 자기 책상을 가진 채 같은 벽시계와 캘린더를 보는 셈이다.
이 책에서 사용하는 Layer는 0~4의 5계층 추상화다. 위로 갈수록 추상적이고 변경이 드물며, 아래로 갈수록 구체적이고 변경이 잦다.
다섯 계층 각각이 절차적 생성·자동화 파이프라인에서 맡는 역할은 위 도식 오른쪽 라벨에 있다. 이 매핑이 이 장의 척추다. Layer를 "잘 정리된 폴더"로만 보면 절반만 본 것이다. 각 계층은 생성 파이프라인의 한 단계(앵커 → 규칙 → 본문 → 수치 → 게이트)에 정확히 대응한다.
| Layer | 무엇을 담는가 | 변경 빈도 |
|---|---|---|
| Layer 0 | 게임이 플레이어에게 주려는 핵심 경험. 한 문장으로 압축 가능 | 매우 낮음 (프로젝트 생애 전체) |
| Layer 1 | 게임 시스템의 큰 구조와 세계관 골격 | 낮음 (마일스톤 단위) |
| Layer 2 | 플레이 흐름, 퀘스트 라인, 진행 단계, 레벨 곡선 | 중간 (스프린트 단위) |
| Layer 3 | 실제 데이터 값, 파라미터, 수식, 변수 | 높음 (일 단위) |
| Layer 4 | 빌드에서 확인된 결과, 버그 리포트, 플레이 영상 | 매우 높음 (실시간) |
이 5계층은 게임 전용 개념이 아니다. 같은 척추를 일반 IT 제품 개발에 그대로 옮길 수 있다. 게임을 만들어 본 적 없는 독자는 아래 직무 번역 표로 각 계층을 자기 산출물에 대응시키기 바란다(왼쪽은 게임 기획의 Layer, 오른쪽은 SaaS·앱·사내 시스템 등에서 같은 자리에 놓이는 산출물).
| Layer | 게임 기획 | 일반 IT 제품 | 같은 질문 |
|---|---|---|---|
| L0 핵심 경험 | 플레이어에게 주려는 핵심 경험 (한 문장) | 제품 비전 — 누구의 어떤 문제를 어떻게 푸는가 | "이걸 왜 만드는가" |
| L1 시스템 룰 | 시스템 구조·세계관 골격 | 업무·기능 규칙 — 도메인 규칙, 권한 모델, 핵심 워크플로 | "무엇이 어떻게 작동해야 하는가" |
| L2 콘텐츠 | 퀘스트 라인·진행 단계·레벨 곡선 | 릴리스·로드맵 — 기능 묶음, 출시 순서, 마일스톤 | "무엇을 언제 내보내는가" |
| L3 데이터 | 데이터 값·파라미터·수식 | 스펙 시트 — API 스펙, 필드 정의, 설정값, 임계치 | "정확한 값과 정의는 무엇인가" |
| L4 빌드·QA | 빌드 결과·버그·플레이 영상 | 배포·QA — 배포 산출물, 버그 리포트, 모니터링 로그 | "실제로 나간 것이 맞게 도는가" |
읽는 법은 게임과 똑같다. 위로 갈수록 변경이 드물고(제품 비전은 분기에 한 번), 아래로 갈수록 잦다(설정값은 매일). 앞서 본 사일로 사고 — 사거리와 통로 폭이 충돌한 그 장면 — 은 일반 IT의 "백엔드 필드 정의(L3)와 프론트 화면 규칙(L1)이 어긋나 출시 직전에 터지는" 일과 정확히 같은 구조다. 분야 이름만 다를 뿐 척추는 하나다.
이 5계층은 절대적이지 않다. 규모와 도메인에 따라 4계층이 적당할 수도, 6계층이 필요할 수도 있다. 핵심은 숫자가 5라는 게 아니라 계층을 명시적으로 정의한다는 행위 자체다.
한 산출물이 두 Layer에 걸칠 수도 있다. "스킬 시스템 GDD(Game Design Document, 상세 사양서)"는 시스템 설계(Layer 1)와 구체 데이터(Layer 3)를 동시에 담는다. 이때는 문서를 쪼개거나 주 Layer를 1로 두고 데이터 섹션을 별도 시트로 분리하되, 어느 방식이든 각 부분이 어느 Layer에 사는지 명시한다.
분야는 가로로 펼쳐지고 Layer는 세로로 쌓인다. 한 분야의 작업은 여러 Layer에 걸친다. 아래 매트릭스는 11개 분야(가로) × Layer 0~4(세로)의 분포 무게 중심을 칸 색 진하기로 표현한다. 진한 칸이 그 분야의 무게 중심 Layer다.
세로로 읽으면 한 분야가 어느 Layer들에 걸치는지, 가로로 읽으면 한 Layer에 어느 분야들이 모이는지 보인다. L0(비전) 줄은 내러티브와 아트 디렉션이 가장 진하다 — 비전에 가장 가까운 두 분야다. L3(데이터) 줄은 시스템·전투·레벨·밸런스·캐릭터가 진하게 모인다 — 데이터 시트에서 이들이 서로 부딪힌다는 신호다.
이 분포를 명시적으로 가지면 다른 분야가 "전투의 Layer 2를 봐야겠다"고 즉시 위치를 안다. 사일로의 벽이 무너지는 게 아니라 벽에 창문이 뚫리는 셈이다.
매트릭스 전체를 한 문장으로 줄이면 이렇다. 세로축 Layer는 생성을 자동화하려고, 가로축 분야는 전문성을 살리려고 나눴다. 둘이 격자의 한 칸에서 만난다.
저자가 디자인 디렉터로 운영하는 MMORPG 프로젝트 A는 기획팀(4~5인)과 함께 Layer 시스템을 약 6개월 운영해 왔다(전체 개발팀은 중규모, 10~50인). 구체 사례를 보자.
먼저 내러티브 5계층. 내러티브 기획 폴더 자체가 Layer로 분할돼 있다.
내러티브 작가가 Layer 2에서 메인 스토리 한 분기를 바꾸면 Layer 3 대사 시트에 영향이 가고, 이미 녹음된 Layer 4 보이스에는 비가역적 영향이 갈 수 있다. Layer를 명시했기에 영향 범위를 즉시 추적한다.
관계도 자동 생성 도구 gen_relation_map.py도 함께 운영된다. 데이터 시트 간 외래 키 관계를 분석해 인터랙티브 HTML 관계도를 만들고, Layer를 노드 색으로 표현한다(빨강=L1 시스템, 노랑=L2 콘텐츠, 초록=L3 데이터). 어느 Layer에서 어느 Layer로 의존이 흐르는지 한눈에 보인다. 의존이 거꾸로 흐르면 — L3이 L1을 향해 화살표를 쏘면 — 거의 항상 설계 결함이다.
절차적 레벨 생성 마스터 문서는 Layer 좌표를 frontmatter에 명시한다.
---
title: 절차적 레벨 디자인 마스터 v0.1
layer_inputs: [L1.World, L2.StoryLine]
layer_outputs: [L3.LevelData, L4.PlayCapture]
---
이 두 줄로 "이 파이프라인은 Layer 1·2를 입력받아 Layer 3·4를 만든다"가 선언되고, 변경 시 영향 범위 계산이 자동화된다. L0 비전은 명시하지 않아도 항상 입력이다 — 어떤 생성이든 비전 앵커는 매번 따라붙기 때문이다.
문서명에 Layer prefix를 강제하는 atom 규칙도 있다. 팀 공유 atom 중 하나는 이렇다.
docs_layer_numeric_prefix_naming: 데이터 시트 파일명은 반드시 Layer 번호 prefix(L1_,L2_,L3_)를 가져야 한다. prefix 없는 시트는 정합성 검사에서 경고.
규칙은 단순할수록 강력하다. 이름순 정렬만 해도 Layer별로 묶이고, AI 도구도 파일명만으로 Layer를 안다. 사람이 잊어도 정합성 검사가 잡는다.
앞 절(2.3.4)에서 "L3이 L1을 향해 화살표를 쏘면 거의 항상 설계 결함"이라고 했다. 이 검출을 사람 눈이 아니라 AI에게 맡기면 어떻게 되는가. 실제로 돌려 본 한 호흡을 다듬지 않고 그대로 옮긴다(회사 식별 정보만 익명화).
[프롬프트 전문]
첨부한 두 frontmatter 보고 Layer 의존 방향 정상인지 봐줘.
참조는 높은 번호→낮은 번호로만 흘러야 정상이야. 거꾸로면 경고.
--- 문서 A (L1_combat_system.md) ---
title: 전투 시스템 골격
layer: L1
refs: [L3_skill_damage_sheet, L2_combat_flow]
--- 문서 B (L3_skill_damage_sheet.md) ---
title: 스킬 데미지 시트
layer: L3
refs: [L1_combat_system]
[Claude 날것 출력 — 요약 없이 그대로]
두 문서를 검사했습니다.
문서 B (L3_skill_damage_sheet, L3):
refs → [L1_combat_system]
방향: L3 → L1 (구체 → 추상)
판정: 정상. 데이터 시트가 자신이 따르는 시스템 골격을
참조하는 것은 자연스러운 방향입니다.
문서 A (L1_combat_system, L1):
refs → [L3_skill_damage_sheet, L2_combat_flow]
- L1 → L2 : 정상 (설계가 하위 흐름을 가리킴)
- L1 → L3 : ⚠ 역방향 경고
L1 시스템 골격이 L3 데이터 시트를 직접 참조하고
있습니다. 이는 "설계 문서가 특정 데이터 값에
종속된다"는 의미로, 데이터가 바뀔 때마다 설계
문서를 손대야 하는 결합을 만듭니다.
권고: 문서 A의 refs에서 L3_skill_damage_sheet 제거.
시스템 골격이 구체 수치를 알아야 한다면, 그 수치를
L1에 추상 규칙으로 옮기거나(예: "기본 데미지는
클래스 계수 × 레벨"), L2 흐름 문서를 경유하세요.
요약: 역방향 참조 1건 검출 (A: L1 → L3).
[사람 검증 / 거부]
AI의 판정은 맞다. 다만 "L2 흐름 문서를 경유하라"는 권고는 이 케이스에서 과했다. L1이 알아야 했던 건 데미지 공식의 형태이지 시트의 특정 셀이 아니었다. 그래서 두 번째 권고(추상 규칙으로 올리기)만 채택하고 경유 제안은 거부했다.
[재요청]
경유 제안은 빼고, "데미지 공식의 형태만 L1에 추상 규칙으로
올린다"는 방향으로 L1_combat_system.md의 수정 diff만 보여줘.
refs도 정리해.
이 한 사이클이 거꾸로참조 검출의 본거지다. AI가 방향 위반을 잡고(자동), 사람이 권고의 적정선을 깎고(검수), 좁혀진 작업만 다시 시킨다(재요청). 프로젝트 A에서는 gen_relation_map.py가 그래프 단위로, portal_layer_change_impact_check atom이 변경 감지 시점에 발동해 영향 범위 점검을 강제한다.
이 대조를 사람이 직접 했다면 두 문서를 열어 refs를 맞춰 보고 방향을 판정하는 데 수 분이 걸린다. 문서가 수백 개로 늘면 사실상 불가능하다. 거꾸로참조는 늘 한두 개씩 슬그머니 들어오고, 한참 뒤 빌드에서야 터진다.
Layer 통합의 표면 목적은 사일로 해소와 협업 언어 통일이다(2.3.1~2.3.5). 본질 목적은 한 단계 더 깊다. Layer 분해가 정착하면 절차적 생성·자동화의 전제 조건이 갖춰진다.
앞 두 절의 운영 사례는 사람이 결정하고 AI가 검증·주입을 돕는 단계였다. 그 다음은 분야 자체의 양산을 AI가 후보로 만들고 사람이 채택하는 단계로 들어간다. 이 이동의 전제가 Layer 분해인 이유는 세 가지다. ① AI 후보 생성은 "어느 Layer의 무엇을 생성할지" 명시할 수 있어야 한다. ② 자동 정합성 검사는 Layer 사이 의존 방향이 표준화돼야 작동한다(2.3.5의 거꾸로참조 검출). ③ 변경 영향 자동 계산은 변경이 어느 Layer에서 일어났는지 좌표가 있어야 가능하다. 셋 다 "Layer 분해가 없으면 자동화 자체가 막힌다"로 모인다. 좌표를 나눈 손길의 끝에는 처음부터 절차적 생성이 놓여 있었다.
분야별 부를 아직 보지 않은 단계에서 깊이 들어갈 필요는 없으니, 적용의 두 단계만 윤곽으로 잡아 둔다. 보수적 적용은 사람이 결정하고 AI가 정합성 검사·변경 영향 계산·JIT 주입을 자동으로 거든다 — 2.3.4·2.3.5의 운영 사례가 여기다. 도구 비용이 작고 누적 효과는 운영 6개월차쯤 드러나, 대부분의 중규모(10~50인) 팀이 도달할 수 있다. 진보적 적용은 한 걸음 더 나아가 분야의 양산 자체를 AI가 후보로 만들고(내러티브 Persona, PCG 룰북, 절차적 레벨, 밸런스 변경 후보, 아트 자산 등) 사람은 "어느 후보를 채택할지"만 결정한다. 분야별 형태와 도구 성숙도는 해당 분야 부에서 다룬다.
진보적 적용에 분야 공통으로 필요한 3요소는 ① Layer 분리·라벨링 인프라(frontmatter·atom·파일명 prefix), ② 후보 생성·평가 사이클(AI 후보 N개 → 자동 평가 → 순위·근거 리포트), ③ 사람 검수 게이트(채택된 결과만 다음 Layer로)다. 단, 어느 시점에서도 결정론 코어(시뮬레이션·물리·법적 제약)는 사람·결정론 코드가 담당하며, 모든 검수는 비가역 단계(녹음·캐스팅·라이브 노출 등) 진입 전 가역 단계에서 종결한다 — 이 가역/비가역 경계는 분야 공통 원칙이다.
마지막으로 시점 하나. 보수적 적용은 2010년대에도 부분적으로 가능했지만 진보적 적용은 AI 후보 생성의 표현력, 자동 평가의 자연어 해석, 사람 검수 부담이라는 세 한계에 막혀 있었다. LLM 발전 이후 셋 다 실용 영역에 진입하며 진보적 적용이 종이 위 비전에서 실무 단계로 내려왔다. AI 발전이 절차적 생성·자동화의 실현 가능성을 끌어올렸다는, 이 책 전체를 관통하는 메타 메시지가 여기에 있다.
이 책의 분야별 부는 각 분야가 주로 어느 Layer에 분포하는지를 도입에서 명시하고, 챕터 안에서도 Layer 좌표를 자주 쓴다. 미리 정리해 둔다(2.3.3 매트릭스의 무게 중심을 표로 옮긴 것이다).
| 분야 | 주 Layer | 비고 |
|---|---|---|
| 시스템 기획 | L1~L3 | 설계 골격에서 데이터 시트까지 폭넓게 |
| 전투 기획 | L1~L3, L4 일부 | 콤보 골격~데미지 시트, 빌드 측정 |
| 내러티브 기획 | L0~L4 | 5층 구조를 폴더로 운영 |
| 콘텐츠 기획 | L2 중심 | 진행 흐름·퀘스트 라인 |
| 레벨 디자인 | L2~L3 | 절차적 생성 파이프라인 포함 |
| 밸런스 기획 | L3 중심, L4 측정 | 데이터 값·곡선·검증 측정 |
| UX/UI 디자인 | L1~L3 | 인터랙션 골격~화면 데이터 |
| QA 설계 | L4 중심, L0~L3 검증 | 모든 Layer가 빌드에 반영됐는지 검증 |
| 캐릭터·펫·마운트 | L1~L3 | 시스템·세계·데이터 |
| 아트 디렉션 | L0~L1 + L4 산출물 | 비전·세계 가이드 + 빌드 검수 |
| 라이브 운영 | L2~L4 | 운영 사이클·실시간 데이터 |
각 분야는 다른 Layer에도 닿지만, 무게 중심을 알면 협업 통로가 보인다. 밸런스(L3)와 라이브(L2~L4)는 L3에서 만나기에 항상 가까이 협업해야 하고, 비전(L0)에 가장 가까운 두 분야는 내러티브와 아트 디렉션이다. 이런 인접 관계가 좌표계 위에서 자연스럽게 드러난다.
Layer 시스템을 처음부터 완벽하게 도입하려 들면 시작조차 못 한다. 점진적으로 들이는 게 정답이다.
flowchart LR
S1["1단계
단일 분야 도입
(추천: 내러티브)"] --> S2["2단계
인접 분야 확장
(내러티브+콘텐츠)"]
S2 --> S3["3단계
전 분야 표준화
(파일명 prefix·정합성 검사)"]
S3 --> S4["4단계
AI 도구 통합
(JIT·변경 영향·회고 분류)"]
S1 -.->|"소규모(~10인) 팀"| E1["여기까지로 충분"]
S3 -.->|"중규모(10~50인) 팀"| E2["여기까지 권장"]
S4 -.->|"대규모(100+) 팀"| E3["여기까지 가야 효과"]
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
class S1,S2,S3,S4 human
class E1,E2,E3 pass
L1_·L2_·L3_ prefix)을 도입하며, 관계도·정합성 검사를 자동화한다.각 단계는 최소 한 달, 길게는 분기 단위다. 무리하면 사람이 지친다. 운영 부담이 도입 가치를 넘지 않도록 속도를 조절하는 게 디렉터의 일이다.
소규모(~10인)는 1~2단계, 중규모(10~50인)는 3단계, 대규모(100+)는 4단계까지 가야 효과가 난다. 작은 팀이 못 쓴다는 뜻은 아니다. 깊이만 다를 뿐, 핵심 가치는 1단계에서 이미 시작된다.
Layer는 단순한 폴더 정리 기법이 아니다. 분화된 게임 기획을 하나의 좌표계로 묶어 AI가 추론 가능하게 만드는 메타 원칙이며, 나아가 분야별 절차적 생성·자동화의 공통 전제 조건이다.
이 책의 나머지 모든 부는 이 장을 전제로 한다. 분야별 부는 각 분야가 Layer에서 차지하는 좌표를 도입에서 명시하고, 프로세스 부는 Layer를 가로지르는 운영 시스템을, 운영 부는 Layer 시스템 자체의 self-improving 사이클을 다룬다.
다음 장(게임 온톨로지와 지식 그래프)은 Layer 위에 의미 관계를 더한다. Layer가 좌표라면 온톨로지는 그 좌표 위의 의미 화살표다. 둘이 합쳐져야 비로소 AI가 "이 문서가 저 문서에 영향을 준다"를 자율적으로 추론한다.
분명히 해 둘 것이 있다. 이 장의 어떤 자동화도 결정을 대신하지 않았다. 거꾸로참조 검출에서 기계는 위반 후보를 펼쳐 놓았을 뿐, 무엇을 어디까지 받아들일지 고른 것은 사람의 손이었다. Layer는 사람이 더 좋은 결정을 더 빨리 내리도록 돕는 좌표계이지, 결정을 떠넘기는 장치가 아니다.
layer_unified_design_philosophy — 본 챕터의 모태 atomdocs_layer_numeric_prefix_naming — 파일명 prefix 강제 규칙dead_table_5layer_cleanup — 5계층 외 시트 정리 규칙portal_layer_change_impact_check — 변경 영향 자동 체크setup — 한 분야(추천: 내러티브) 폴더를 골라 하위 폴더를 Layer0_Vision/부터 Layer4_BuildVO/까지 5개로 나눕니다. 기존 파일을 해당 Layer로 옮기세요. 이름이 애매한 파일은 "이 문서가 바뀌는 빈도"를 기준으로 배치합니다(자주 바뀌면 아래 Layer).
prompt — 데이터 시트 frontmatter 두 개를 골라 2.3.5의 프롬프트 전문을 그대로 붙여 넣고 Layer 의존 방향을 판정하게 하세요. 핵심 규칙 한 줄만 정확히 주면 됩니다. "참조는 구체→추상(높은 번호→낮은 번호)으로만 흘러야 정상."
verify — AI가 역방향 참조를 잡아내면 그 권고를 그대로 받지 말고 적정선을 직접 깎으세요(2.3.5의 "사람 검증/거부"). 채택한 방향만 diff로 재요청합니다. 이름순 정렬했을 때 Layer가 위에서 아래로 묶여 보이면 prefix 규칙이 자리 잡은 것입니다.
혼자 작업해도 Layer는 작동합니다. 팀이 없으니 "분야 간 사일로"는 없지만 "시점 간 사일로"가 있습니다. 3주 전의 나와 오늘의 나는 서로의 결정을 잊습니다. 폴더를 Layer 0~4로만 나눠도 — 비전 한 장, 시스템 골격 몇 장, 진행 흐름, 데이터 시트, 빌드 메모 — 과거의 내가 어느 칸에 무엇을 두었는지 즉시 찾습니다. AI에게 "지금 L2 작업 중"이라고 한 줄만 붙이면 무관한 Layer 자료를 끌어오지 않습니다. 4계층·3계층으로 줄여도 좋습니다. 숫자가 아니라 "계층을 명시한다"는 행위가 핵심입니다.
월요일 오전, 변경 요청 하나가 올라왔다. 전투팀 팀원 A가 팀 메신저에 한 줄을 적는다. "글로벌 쿨다운 0.5초 → 0.3초로 바꿀게요. 영향 받는 데 있나요?" 평소라면 여기서부터 30분짜리 회의가 시작된다. 데미지 계산식 담당이 손을 들고, 콤보 캔슬 룰 담당이 끼어들고, 누군가는 "보스 패턴도 영향 있지 않냐"고 묻는다. 아무도 전체 그림을 머릿속에 다 갖고 있지 않으니, 회의는 기억을 더듬는 일로 채워진다.
그런데 이번에는 다르다. 요청을 올린 1초 뒤, 봇이 자동 코멘트를 단다. "이 atom을 바꾸면 4개 atom이 영향을 받습니다. skill_dps_calculation, combat_combo_cancel_v3, refgame_boss_pattern_phase2, balance_curve_v3. 담당: 팀원 B, 팀원 A, 팀원 C." 회의는 열리지 않았다. 4명이 각자 자기 atom만 확인하고 끝났다. (이 봇은 뒤에서 직접 만든다 — 2.4.3.)
이 코멘트는 마법이 아니다. 2.3에서 모든 atom에 Layer 좌표를 부여했고, 그 위에 이번 장에서 의미 화살표 — 어느 결정이 어느 결정에 영향을 주는가 — 를 더했기 때문이다. 좌표는 "여기에 무엇이 있다"까지만 말한다. "이것이 저것에 영향을 준다", "저것이 먼저 있어야 성립한다", "이 둘은 동시에 켜면 안 된다" 같은 관계는 좌표 위에 그려지는 화살표다. 이 장은 그 화살표를 어떻게 표기하고, 깨진 화살표를 어떻게 자동으로 잡아내는지 다룬다.
용어 메모 - 온톨로지(ontology): 개념과 그 사이 관계를 명시적으로 정의한 체계. 이 책에서는 6~12개 관계로 단순화한 경량 버전을 쓴다. - wikilink:
[[atom_name]]형식의 문서 간 링크. Obsidian·Roam 등에서 쓰는 표기를 차용했다. - 역참조(backlink): "이 atom을 가리키는 atom들"의 목록. 정참조의 반대 방향. - 고아 노드(orphan): 어디서도 참조되지 않는 atom. 폐기 후보 신호. - 깨진 링크(broken link): 존재하지 않는 atom을 가리키는 wikilink. 오타·이름 변경의 흔적.
2.1에서 YAML 프론트매터로 메타데이터를 붙였고, atom 본문에는 wikilink를 흩뿌렸다. 그것만으로 문서는 이미 그물망처럼 연결된다. 문제는 그 연결이 무엇을 의미하는지 적혀 있지 않다는 점이다.
이 결정은 [[skill_cooldown_rule_v2]] 위에서 성립한다.
이 한 줄은 "skill_cooldown_rule_v2를 언급한다"까지만 말한다. 왜 언급하는가? 이 결정이 저 규칙을 필요로 하는가(requires), 저 규칙에서 파생됐는가(derives_from), 아니면 저 규칙과 충돌하는가(conflicts_with)? 사람은 문장을 읽으면 알지만, 기계는 모른다. AI에게 "이 결정을 켜면 깨지는 게 있냐"고 물어도, 의미 없는 링크만으로는 답할 수 없다.
그래서 wikilink에 관계 유형을 입힌다. 게임 기획에서 실제로 쓰이는 관계는 의외로 적다. 다음 여섯 개가 90% 이상을 덮는다.
이 여섯 개를 enum으로 고정하는 atom이 ontology_relation_enum_v1이다. 새 관계 유형을 추가하려면 변경 요청 리뷰를 거치게 한다. 늘어나도 10~12개가 적정선이고, 처음에는 affects·derives_from·requires 세 개로 시작해도 충분하다. 관계를 적는 자리는 atom의 YAML 프론트매터다.
---
name: combat_combo_cancel_v3
layer: 1
affects: [skill_dps_calculation, refgame_boss_pattern_phase2]
derives_from: [vision_taste_focused_combat]
requires: [combat_input_buffer_system, skill_cooldown_rule_v2]
conflicts_with: [skill_cancel_rule_legacy_v1]
---
사람은 정방향 한 줄만 적는다. 역방향("나를 누가 affects 하는가")은 도구가 전체를 스캔해 계산한다. affects를 양쪽에 다 적으면 동기화 부담이 두 배가 되고, 한쪽만 고치고 다른 쪽을 잊는 순간 그래프가 거짓말을 시작한다. 한 방향만 손으로, 반대 방향은 도구로가 이 장 전체를 관통하는 원칙이다.
여기서부터는 실제로 일어난 한 세션을 통째로 옮긴다. 상황은 이렇다. 콤보 캔슬 규칙을 v2에서 v3로 갈아엎으면서 skill_cancel_rule_legacy_v1을 폐기하기로 했다. 그런데 폐기하기 전에, 이 atom을 가리키는 다른 atom이 없는지(역참조), 그리고 폐기 후에 깨진 링크나 충돌이 남지 않는지(그래프 무결성)를 확인해야 한다. 손으로 grep 하면 빠뜨린다. Claude에게 시킨다.
첨부한 atom 폴더(decisions/)에는 atom이 있고, 프론트매터에 관계
(affects/derives_from/requires/conflicts_with/is_a/part_of)가 적혀
있을 거야. 본문엔 위키링크도 있고. 지금 skill_cancel_rule_legacy_v1
을 폐기하려는데, 폐기 전에 3개를 검사해줘: 1) 역참조 2) 깨진 링크
3) 충돌. 추측하지 말고 실제로 파일을 읽고 답해. 못 찾으면 못 찾았다고 해.
// 역참조=YAML 관계+본문 위키링크 양쪽 / 깨진 링크=폐기로 stale·미존재 되는 링크
// 충돌=conflicts_with 상대 중 status: approved 가 살아있는지
decisions/ 폴더의 47개 파일을 읽었습니다.
skill_cancel_rule_legacy_v1에 대한 3개 검사 결과입니다.1. 역참조 (이 atom을 가리키는 atom)
YAML 관계로 가리키는 atom: -
combat_combo_cancel_v3—conflicts_with: [skill_cancel_rule_legacy_v1]-skill_cancel_rule_v2—derives_from: [skill_cancel_rule_legacy_v1]본문 wikilink로 가리키는 atom: -
combat_combo_cancel_v3본문: "구버전[[skill_cancel_rule_legacy_v1]]의 입력 버퍼 문제를 해결한다" -qa_combat_regression_checklist본문: "회귀 항목 3번은[[skill_cancel_rule_legacy_v1]]기준으로 작성됨"즉 4개 atom이 이 atom을 참조하고 있습니다. (YAML 2 + 본문 wikilink 2, 단 combat_combo_cancel_v3는 양쪽 모두라 실제 atom 수는 3개)
2. 깨진 링크 (폐기 시 깨지는 곳)
폐기 자체로는 wikilink가 "깨지지" 않습니다 — 파일은 남고 status만 deprecated가 되니까요. 다만: -
qa_combat_regression_checklist는 폐기된 atom을 기준으로 회귀 항목을 정의하고 있어, 내용상 무효가 됩니다. 이건 깨진 링크는 아니지만 stale link입니다. 검토 필요. - 만약 파일을 삭제까지 하신다면 위 4개 참조가 전부 깨진 링크가 됩니다.⚠️ 한 가지 애매한 점:
balance_curve_v3의 본문에[[skill_cancel_rule_legacy]](끝에_v1이 없음)라는 링크가 있습니다. 이게 오타로_v1을 가리키려던 건지, 아니면 별도 atom을 의도한 건지 파일만으로는 확신할 수 없습니다.skill_cancel_rule_legacy라는 이름의 파일은 폴더에 없습니다. 즉 이건 이미 깨진 링크일 가능성이 높습니다. 확인이 필요합니다.3. 충돌 잔존 (conflicts_with 상대가 살아있나)
skill_cancel_rule_legacy_v1과conflicts_with로 묶인 atom:combat_combo_cancel_v3하나입니다. -combat_combo_cancel_v3의 status: approved (살아있음)→ 폐기 대상과 충돌하던 신규 규칙이 정상 승인 상태입니다. 이건 정상입니다. 구버전을 폐기하고 신버전이 살아남는 자연스러운 교체입니다. 충돌 잔존 경고 아님.
요약: 폐기는 안전하나, 두 가지를 먼저 처리하세요. (a)
qa_combat_regression_checklist의 stale link — 회귀 항목 3번 갱신. (b)balance_curve_v3의[[skill_cancel_rule_legacy]]— 오타 의심. 이미 깨진 링크일 수 있음.
여기서 나는 출력을 그대로 받지 않았다. (a)는 맞다 — 회귀 체크리스트는 내가 갱신해야 한다. 하지만 (b)에서 멈췄다. Claude는 balance_curve_v3의 [[skill_cancel_rule_legacy]]를 "오타 의심"이라고 했는데, 정말 깨진 링크인지 내가 직접 폴더를 봐야 했다. AI의 "확신할 수 없다"는 정직한 신호지, 결론이 아니다.
폴더를 열어 확인하니, skill_cancel_rule_legacy라는 파일은 실제로 없었다. 그런데 git log를 보니 6개월 전에 skill_cancel_rule_legacy → skill_cancel_rule_legacy_v1로 이름만 바꾼 커밋이 있었다. 그때 balance_curve_v3의 본문 링크를 갱신하지 못하고 흘려보낸 것이다. 즉 이건 오타가 아니라 이름 변경 후 고아가 된 깨진 링크였다. Claude의 "오타 의심"이라는 추측은 절반만 맞았다. 원인은 오타가 아니라 rename 누락이다. 이 차이를 내가 잡지 않았으면, 회귀 체크리스트만 고치고 깨진 링크는 그대로 묻혔을 것이다.
확인했어. [[skill_cancel_rule_legacy]] 는 오타가 아니라 6개월 전
rename(_legacy → _legacy_v1) 때 본문 링크를 못 고친 깨진 링크였어.
그럼 이참에 폴더 전체에서 "존재하지 않는 atom을 가리키는 wikilink"를
전부 찾아줘. 본문 wikilink 기준으로, 대상 파일이 폴더에 없는 것 전부.
파일명과 그 안의 깨진 링크를 표로.
47개 파일의 모든
[[...]]본문 wikilink를 수집해 폴더 파일 목록과 대조했습니다. 대상 파일이 존재하지 않는 깨진 링크는 3건입니다.
링크를 가진 파일 깨진 wikilink 추정 원인 balance_curve_v3[[skill_cancel_rule_legacy]]rename 누락 (→ _v1)quest_reward_table_v2[[concept_reward_currency]]대상 atom 미작성 (계획만 있고 파일 없음) refgame_boss_pattern_phase2[[skill_global_cooldown]]rename 누락 (→ skill_global_cooldown_rule)3건 중 2건(rename 누락)은 대상 atom명을
_v1/_rule로 고치면 됩니다. 1건(concept_reward_currency)은 가리키는 atom 자체가 없으므로, 작성하시거나 링크를 제거하셔야 합니다. 제가 자동으로 고치지는 않았습니다 — 어느 쪽을 의도하셨는지 확인 후 진행하세요.
세 건 모두 사실이었다. 손으로 grep 했으면 quest_reward_table_v2의 깨진 링크는 거의 확실히 놓쳤을 것이다. 그 링크는 "아직 안 만든 atom을 미리 가리켜 둔" 의도된 미래 참조였는데, 6개월간 아무도 그 atom을 만들지 않아 사실상 죽은 약속이 되어 있었다.
이 세션이 보여주는 것은 단순하다. 역참조 검출과 깨진 링크 검출은 AI가 폴더 전체를 읽고 대조하는 일에 강하고, 원인 판정과 의도 확인은 사람이 한다. AI는 "여기 깨졌다"까지, 사람은 "왜 깨졌고 어떻게 고칠까"까지.
앞 절의 검사를 매번 프롬프트로 돌릴 수도 있지만, 같은 검사를 코드로 굳히면 그래프 위에서 한눈에 보인다. 프로젝트 A에는 2.3에서 소개한 gen_relation_map.py를 확장한 그래프 도구가 R&D로 돌아간다. 핵심은 폴더의 atom을 읽어 networkx 방향 그래프로 빌드한 뒤, 네 가지 검사 함수를 얹는 것이다.
import networkx as nx
# build_graph(folder): atom 폴더를 읽어 노드(=atom)와
# YAML 관계 엣지로 DiGraph를 만든다. (전문은 「따라하기」)
def find_cycles(G): # 순환 의존
return list(nx.simple_cycles(G))
def find_orphans(G): # 인바운드 0 = 고아 후보
return [n for n in G.nodes if G.in_degree(n) == 0]
핵심은 두 줄이다. simple_cycles가 순환 의존(A requires B requires C requires A)을, in_degree(n) == 0이 고아 노드를 잡아낸다 — 직접 DFS를 짤 필요가 없다. 나머지 두 함수도 같은 결의 한 줄짜리다. find_broken_wikilinks는 본문 [[...]]를 정규식으로 수집해 노드 목록에 없는 것을 골라내고, 역참조는 그래프를 거꾸로 훑으면 나온다(전문은 「따라하기」). 시각화는 노드 색을 Layer로, 엣지 색을 관계 유형으로 칠하고, 많이 참조되는 노드(인바운드 엣지가 많은 노드)는 크게 그려 허브가 드러나게 한다. 캐비닛에 색 라벨을 붙여 정렬한 폴더처럼, 시야 안에서 패턴이 먼저 떠오른다.
아래는 2.4.2 세션에서 다룬 atom들의 실제 관계를 옮긴 그래프다. 화살표 방향은 "출발 atom이 도착 atom을 향해 관계를 건다"는 의미다.
graph LR
combo[combat_combo_cancel_v3
L1·approved]
legacy[skill_cancel_rule_legacy_v1
L1·deprecated]
v2[skill_cancel_rule_v2
L1]
dps[skill_dps_calculation
L3]
vision[vision_taste_focused_combat
L0]
buffer[combat_input_buffer_system
L1]
boss[refgame_boss_pattern_phase2
L2]
qa[qa_combat_regression_checklist
L4]
broken[skill_cancel_rule_legacy
존재하지 않음]
combo -->|affects| dps
combo -->|affects| boss
combo -->|derives_from| vision
combo -->|requires| buffer
combo -->|conflicts_with| legacy
v2 -->|derives_from| legacy
qa -.stale.-> legacy
balance[balance_curve_v3] -.broken.-> broken
classDef dep fill:#eee,stroke:#999,stroke-dasharray:4
classDef miss fill:#fff,stroke:#cc2222,stroke-dasharray:4
class legacy dep
class broken miss
점선으로 그려진 두 엣지가 2.4.2에서 사람 검증으로 잡아낸 문제다. qa → legacy는 폐기 atom 기준의 stale link, balance_curve_v3 → skill_cancel_rule_legacy는 존재하지 않는 노드를 가리키는 깨진 링크. 그래프로 그리면 이 두 점선이 실선들 사이에서 도드라진다. 텍스트로만 운영했다면 47개 파일 어딘가에 묻혀 영영 안 보였을 것이다.
검증 게이트(Layer 4)에서 자동으로 도는 규칙은 네 가지다.
requires 사슬이 자기 자신으로 돌아오면 경고. simple_cycles로 검출.conflicts_with로 묶인 두 atom이 둘 다 status: approved면 경고. (2.4.2의 케이스는 한쪽이 deprecated라 통과.)graph_orphan_detection_quarterly가 이 주기를 명시한다.docs_layer_numeric_prefix_naming이 문서명에 Layer 번호 prefix를 강제하기 때문에, 역행은 파일명만 봐도 1차로 걸러진다.이 네 규칙이 코드로 굳으면 2.4.2처럼 매번 프롬프트를 짤 필요가 없다. 변경 요청이 올라온 순간 봇이 그래프를 다시 빌드하고, 영향 받는 atom 목록과 깨진 링크·순환·충돌을 자동 코멘트로 단다. 장 첫머리의 "4개 atom이 영향받습니다" 코멘트가 바로 이것이다 — 이 봇이 앞서 예고한 그 봇이다.
여기서 2.3과 2.4가 왜 한 묶음인지 짚고 넘어가야 한다. Layer 좌표와 관계 화살표는 따로 도입한 게 아니라, 같은 목적의 두 면이다.
표면적으로 Layer는 협업 언어를 통일한다 — "이건 L1 시스템 결정", "저건 L3 데이터"라고 부르면 분야가 달라도 같은 좌표를 공유한다. 하지만 본질적 목적은 다른 데 있다. Layer는 절차적 생성을 위해 나눈 좌표였다.
L0 비전은 컨텍스트 앵커다 — 불변이고, AI에게 매번 주입된다. L1 시스템은 생성의 입력 규칙이다 — 룰북·관계·태그가 여기 산다. L2 콘텐츠는 생성된 본문이 쌓이는 자리, L3 데이터는 수치·ID·관계로 시뮬레이션의 입력, L4 빌드·QA는 검증 게이트다. 관계 화살표는 이 좌표 위에서 생성의 제약 조건으로 작동한다. AI가 새 콘텐츠를 만들 때, requires 화살표는 "이게 먼저 있어야 한다"는 전제가 되고, conflicts_with 화살표는 "이건 같이 켜면 안 된다"는 금지가 된다.
분야는 분화하지만(전투·퀘스트·경제가 각자 전문성을 가짐) 모든 산출물이 Layer 좌표를 갖기에 서로를 인지한다. 분화와 통합이 한 좌표계 위에서 동시에 성립한다. 이 그래프가 충분히 자라면, AI는 후보를 생성하며 관계 화살표를 자동 제약으로 읽고, 사람은 검수 게이트에서 위배 여부만 확인하면 된다. 2.4.2의 워크드 트랜스크립트가 그 축소판이다 — AI가 그래프를 읽어 제약 위반(깨진 링크·충돌)을 찾고, 사람이 게이트에서 판정했다.
관계 화살표의 출발점·도착점이 되는 노드에는 결정 atom만 있는 게 아니다. "스킬", "퀘스트", "보상" 같은 도메인 개념을 정의한 atom도 그래프의 일등 시민이다. concept_skill_definition_v1은 이렇게 생겼다.
# 스킬 (Skill)
Definition: 캐릭터가 전투 중 발동하는 단위 액션. 입력·쿨다운·자원·효과를 포함.
Required Properties: input / cooldown / cost / effects
Subtypes: active_skill (is_a) / passive_skill (is_a) / ultimate_skill (is_a)
Not a skill: 자동 공격 [[concept_auto_attack]] / 변신 [[concept_transformation]]
boss_skill is_a skill이라고 적으면 상위 개념의 규칙이 자동 상속된다. 프로젝트 A에는 이런 개념 정의 atom이 19개쯤 있고, 모든 결정 atom이 이들을 참조한다. 어휘가 한 사전으로 통일되면 회의·문서·코드 사이의 번역 부담이 사라진다. 책상마다 다른 사전을 두지 않고 같은 사전을 공유하는 셈이다. 앞의 워크드 트랜스크립트에서 깨진 링크로 잡힌 concept_reward_currency가 바로 "사전에 등재하기로 해놓고 아직 안 만든 빈 항목"이었다.
여기까지 읽으면 "이거 OWL/RDF 같은 정식 온톨로지 아니냐"는 질문이 나온다. 아니다. 그리고 일부러 아니게 만들었다.
학술 온톨로지(OWL·RDF·SKOS)는 강력하지만, 관계 유형이 수십~수백 개고 전용 추론 엔진이 필요하며, 운영하려면 온톨로지 전문가가 붙어야 한다. 검색 엔진·의료·법률처럼 정밀 추론이 생사를 가르는 영역에서는 필수다. 하지만 게임 기획에서 실제로 필요한 추론은 "변경 영향 범위", "선결 의존성", "충돌 검출"뿐이고, 전부 그래프를 한 칸씩 따라가며 훑는 단순 탐색(BFS·DFS) 수준으로 끝난다. networkx 한 줄로 순환을 잡은 앞 절이 그 증거다. 정식 추론 엔진은 과잉이다.
기준은 하나다. 기획자가 손으로 운영할 수 있는가. YAML 6개 enum, networkx 처리, pyvis나 D3.js 시각화. 이 선을 넘는 순간 도구는 도구이기를 그치고 또 하나의 부담이 된다. 가벼움은 타협이 아니라 설계 의도다.
이 함정을 피하는 데 반복되는 실수 다섯 가지가 있다. 모두 "온톨로지를 강제 표준으로 다룬 자리"라는 같은 뿌리에서 자란다.
| 실수 | 회피법 |
|---|---|
| 관계 유형을 처음부터 너무 많이 정의 | 세 개(affects·derives_from·requires)로 시작, 필요할 때만 추가 |
| 모든 결정에 관계 강제 | 관계 없는 atom도 정상으로 인정 — 빈 관계가 그래프를 더럽힌다 |
| affects를 양방향으로 적음 | 한 방향만 손으로, 역방향은 도구로 자동 계산 |
| OWL·RDF에 집착 | 운영 가능한 수준(YAML + enum)에 머무름 |
| 시각화 없이 텍스트만 운영 | 단순 HTML 뷰라도 처음부터 제공 — 2.4.3의 점선처럼, 안 보이면 안 고친다 |
1·2·3번은 도입 1개월 안에 패턴을 잡고, 4·5번은 3개월차 회고에서 점검하면 자연스럽게 정렬된다.
처음 한 달은 적자다. 관계를 적는 부담만 쌓이고 보이는 효과는 없다. 그래서 작게 시작한다. 첫 주엔 affects·derives_from·requires 세 관계만, 2~4주차엔 핵심 atom 20개에 적용하며 그래프가 자라는 모습을 지켜본다. 1개월차에 앞 절 수준의 HTML 그래프 뷰 하나를 만들면, 두 달째부터 시각화가 가치를 보여주기 시작하고, 세 달째에는 자동 검증(순환·충돌·고아·깨진 링크)이 회의 시간을 직접 줄인다. 적자 한 달을 견디는 것이 도입 성패의 분기점이다.
다음 8장 Wikilink는 이 장에서 화살표로 다룬 [[...]] 표기를 운영 차원에서 깊게 판다 — 역참조 패널을 매일 어떻게 쓰는지, 이름 변경 시 링크를 한꺼번에 어떻게 갱신하는지(2.4.2의 rename 누락을 애초에 막는 법), Obsidian 같은 도구의 그래프 뷰를 실무에 어떻게 녹이는지. YAML(4장) → Atom(5장) → Layer(6장) → Ontology(7장) → Wikilink(8장)가 정보 아키텍처의 완성된 오각형이다.
setup. atom이 모인 폴더(예: decisions/)를 정하고, 각 atom YAML에 관계 키를 적을 준비를 하세요. 처음엔 affects·derives_from·requires 세 개만. 본문 링크는 [[atom_name]] 형식으로 통일합니다.
prompt. 폐기·이름 변경 전에 아래를 던지세요.
이 폴더의 마크다운 atom들을 읽고, 내가 [대상_atom] 을 폐기/변경하려는데
(1) 역참조: 이 atom을 YAML 관계와 본문 wikilink로 가리키는 atom 전부
(2) 깨진 링크: 변경/삭제 시 깨지거나 stale 되는 링크
(3) 충돌 잔존: conflicts_with 상대 중 status: approved 인 것
을 검사해줘. 추측 말고 실제로 파일 읽고, 못 찾으면 못 찾았다고 해.
verify. AI가 "오타 의심", "확신할 수 없음"이라고 단 곳을 사람이 직접 여세요. 깨진 링크의 원인(오타냐 rename 누락이냐 미작성이냐)은 git log와 폴더 실물로 사람이 판정합니다. 자동 수정은 시키지 말고, 의도 확인 후 직접 고칩니다.
본문(2.4.3)에서 핵심 두 함수만 보였다. 폴더 전체를 그래프로 빌드하고 네 검사를 거는 전문은 다음과 같다.
import networkx as nx
import re, yaml, glob, os
REL_TYPES = ["affects", "derives_from", "requires",
"conflicts_with", "is_a", "part_of"]
WIKILINK = re.compile(r"\[\[([a-zA-Z0-9_]+)\]\]")
def build_graph(folder):
G = nx.DiGraph()
files = {}
for path in glob.glob(os.path.join(folder, "*.md")):
name = os.path.splitext(os.path.basename(path))[0]
text = open(path, encoding="utf-8").read()
fm = yaml.safe_load(text.split("---")[1]) or {}
files[name] = fm
G.add_node(name, layer=fm.get("layer"), status=fm.get("status"))
# YAML 관계 엣지
for name, fm in files.items():
for rel in REL_TYPES:
for tgt in (fm.get(rel) or []):
G.add_edge(name, tgt, type=rel)
return G, files
def find_broken_wikilinks(folder, known_nodes):
broken = []
for path in glob.glob(os.path.join(folder, "*.md")):
text = open(path, encoding="utf-8").read()
for m in WIKILINK.findall(text):
if m not in known_nodes:
broken.append((os.path.basename(path), m))
return broken
def find_orphans(G):
# 인바운드 0이면서 part_of/is_a 부모도 없는 노드
return [n for n in G.nodes if G.in_degree(n) == 0]
def find_cycles(G):
return list(nx.simple_cycles(G))
도구가 없어도 됩니다. atom 폴더 하나, Claude 하나면 충분합니다. 새 결정을 적을 때 YAML에 requires·affects 두 줄만 추가하고, 폐기·이름 변경이 생길 때마다 위 prompt를 한 번 돌리세요. 그래프 시각화는 나중 문제입니다 — 깨진 링크와 충돌을 변경 직전에 한 번 훑는 습관, 그 한 번이 1인 운영에서 가장 큰 적자를 막습니다.
목요일 오후 4시 50분. 밸런스 담당이 채운 스킬 시트가 막 올라왔다. 스킬 312개. 각 스킬은 effect_id라는 칸에 효과 번호를 적게 되어 있고, 그 번호는 별도의 효과 시트에 있는 행을 가리킨다. 둘이 맞아떨어져야 게임이 돈다. 안 맞으면 클라이언트가 빈 효과를 부르거나 조용히 죽는다.
예전의 나는 이걸 손으로 봤다. 스킬 시트 한 칸, 효과 시트로 점프, 번호 확인, 다시 돌아오기. 312번. 빨라야 두 시간. 눈이 흐려지는 마지막 50개에서 꼭 한두 개를 놓쳤고, 그 한두 개가 QA 빌드에서 터졌다.
이 챕터는 그 두 시간이 어디로 갔는지, 그리고 그 정합성 검사가 시스템 기획자의 작업 지도 어디에 찍히는 좌표인지에 대한 이야기다. 좌표를 먼저 잡지 않으면, AI를 어디에 끼워야 할지 영영 감으로만 결정하게 된다.
시스템 기획자는 추상도와 구체도 사이를 가장 넓게 오가는 사람이다. 비전이라는 안개를 받아서, 데이터 시트의 마지막 셀이라는 단단한 숫자까지 끌고 내려온다. 그 여정에서 만들어지는 산출물은 네 종류다.
(1) 비전을 구조로 번역한다. 디렉터가 "타격감이 살아 있는 액션 전투"라고 말하면, 시스템 기획자는 그걸 스킬·콤보·캔슬·히트스톱이라는 골격으로 바꾼다. "성장의 자기 결정권"은 클래스·스킬트리·장비 시스템이 된다. 안개가 구조물이 되는 첫 순간이다.
(2) 시스템 사이의 인터페이스를 명세한다. 전투·이동·인벤토리·상점·퀘스트·길드가 동시에 돈다. 전투 중 인벤토리를 열면 무적이 붙는가? 강화 도중에 PvP 신청이 들어오면? 이 케이스들의 답이 모여서 "잘 만들어졌다"는 손맛을 만든다. 답이 빠진 자리마다 사용자는 짜증을 느낀다.
(3) 데이터 시트와 그 스키마를 책임진다. 스킬 312개의 계수, 아이템 수백 개의 효과, 몬스터 수십 종의 행동. 값은 직접 채우거나 밸런스·콘텐츠 분야에 넘긴다. 하지만 시트의 컬럼 정의(스키마) 만큼은 시스템 기획자가 쥔다. 라벨이 붙은 서랍을 만들어 주는 일이다. 서랍이 엉성하면 사람마다 다르게 채워서 정합성이 깨진다.
(4) 행동 로직을 설계한다. 캐릭터·몬스터의 AI는 상태 머신(FSM, Finite State Machine, 유한 상태 기계), 행동 트리(Behavior Tree, 이하 BT), 결정 테이블, 절차적 규칙 같은 형태로 나온다. 이 자료가 프로그래머에게 넘어가 코드가 된다.
네 가지가 모두 한 사람의 책상 위에서 만난다는 점이 핵심이다. 그래서 "오늘 무엇에 시간을 쓸 것인가"가 시스템 기획자의 가장 큰 운영 결정이 된다.
2.3에서 우리는 게임 제작물 전체를 L0(비전)부터 L4(빌드)까지의 좌표축에 올렸다. 이제 3.1.1의 산출물 네 가지를 그 축 위에 그대로 찍어 본다. 시스템 기획만큼 한 분야의 산출물이 여러 Layer에 넓게 흩어지는 경우는 드물다.
다음은 산출물이 Layer 위 어디에 사는지, 그리고 각 좌표에서 누구와 만나는지를 한 장에 그린 지도다.
이 지도가 말하는 바는 두 가지다. 첫째, 시스템 기획자는 L0을 받아서 L4까지 닿게 하는 긴 거리를 책임진다. 둘째, 직접 손으로 만드는 구간은 L1~L3이고, 그 세 칸마다 협업 상대가 바뀐다. 칸이 바뀔 때마다 협업 언어가 바뀌므로, 좌표를 의식하지 않으면 회의가 자꾸 헛돈다.
다만 한 사람이 L1~L3을 전부 만진다는 뜻은 아니다. 팀이 크면 L1~L2 담당과 L3 담당이 갈린다. 팀이 작으면 한 사람이 다 본다. 좌표는 역할 분담의 지도이지, 한 명에게 다 떠넘기라는 명령이 아니다.
지도가 그려졌으니 이제 색을 칠한다. 어느 좌표가 AI 도입 효과가 큰가? 무턱대고 "다 자동화"가 아니라, 좌표의 성질을 보고 고른다.
flowchart TD
L1["L1 시스템 골격
(클래스 수, 전투 모델)"]
L2["L2 인터페이스
(상호작용 룰)"]
L3["L3 스키마 + 데이터
(312행 시트)"]
L1 -->|"게임 정체성 직결
사람이 결정"| H1["AI = 사람 결정의 변형/검증 보조"]
L2 -->|"케이스 폭발
영향 범위 큼"| H2["AI = 변경 영향 범위 자동 추출"]
L3 -->|"정형·반복
정합성 검사"| H3["AI = 생성·검증·변환 전담"]
H1 --> R["사람은 핵심 결정,
AI는 디테일·정합성·반복"]
H2 --> R
H3 --> R
style L3 fill:#fff8e1,stroke:#f9a825
style H3 fill:#e8f5e9,stroke:#2e7d32
style R fill:#e3f2fd,stroke:#1565c0
핵심은 좌표가 아래로 내려갈수록 AI 전담 비중이 커진다는 것이다. L1의 "클래스를 몇 개로 할까"는 게임 정체성이라 사람이 쥐어야 한다. 반대로 L3의 "312행 외래 키가 다 맞는가"는 정형·반복이라 AI가 통째로 가져가야 한다. L2는 그 중간 — 결정은 사람이 하되, "이 룰을 바꾸면 어디까지 흔들리나"라는 영향 범위 추출을 AI가 받쳐 준다.
이 그림이 3.1.4 이후의 모든 실습이 왜 L3 근처에서 시작하는지를 설명한다. 효과가 가장 크고 위험이 가장 작은 자리이기 때문이다. 스키마 도구가 오작동해도 사고가 안 나고, 관계도는 그림만 그릴 뿐이며, 정합성 검사는 사람이 거부할 수 있다.
이론은 여기까지다. 이제 3.1 첫머리의 그 목요일로 돌아간다. 스킬 시트 312개의 effect_id가 효과 시트와 맞는지, AI에게 시켜 본다. 요약 없이, 실제로 주고받은 그대로 옮긴다.
데이터 입력은 schema-first 원칙을 따른다. 즉 사람이 먼저 명세서 시트를 정의하고, 거기서 Enum과 .proto가 나오고(VBA(엑셀 매크로 언어) Export), 마지막에 csv 데이터가 채워진다. 그래서 검증 프롬프트도 "스키마부터 읽어라"로 시작한다. 정본은 스키마 문서가 아니라 실제로 export된 JSON이라는 점을 못 박는다(json_over_schema_doc_as_source_of_truth). 문서는 거짓말을 해도 export된 JSON은 거짓말을 못 하기 때문이다.
너는 우리 프로젝트의 데이터 정합성 검사기다. 입력은 두 개의 export JSON이다. 1)
skill.json— 각 스킬 객체에skill_id,name,effect_id필드가 있다. 2)effect.json— 각 효과 객체에effect_id,effect_type가 있다.다음을 검사하라. (A)
skill.json의 모든effect_id가effect.json에 존재하는가 (외래 키 정합성). (B)effect.json에 있으나 어떤 스킬도 참조하지 않는 고아(orphan) 효과. (C)skill.json안에서effect_id가 비어 있거나 0인 행.규칙: 추측하지 마라. JSON에 없는 필드는 "필드 없음"이라고 보고하라. 출력은 (A)(B)(C) 세 섹션으로, 각 위반 건마다
skill_id(또는 effect_id)와 사유를 한 줄로. 위반이 없으면 해당 섹션에 "위반 없음"이라고만 써라.
입력 JSON 두 개를 받지 못했습니다. 현재 메시지에는 파일 내용이 첨부되어 있지 않습니다. 다음 중 하나로 다시 보내 주세요. -
skill.json과effect.json의 실제 내용(붙여넣기 또는 파일 첨부) - 또는 두 파일의 경로내용을 받으면 (A)(B)(C) 순서로 검사하겠습니다. 파일 없이 결과를 지어내지는 않겠습니다.
여기서 멈춘 게 오히려 신뢰의 신호였다. 입력이 비었는데도 그럴듯한 리포트가 돌아왔다면, 그 깔끔함이야말로 위험 신호다. 빈손으로 검사 결과를 만들어 내지 않고 "파일부터 달라"고 되물은 쪽이 믿을 만하다. 나는 두 JSON을 실제로 붙여넣고 재요청했다(분량 관계로 본문에는 일부만 발췌).
// skill.json (발췌)
[
{"skill_id": 1001, "name": "강타", "effect_id": 5001},
{"skill_id": 1002, "name": "회전베기", "effect_id": 5002},
{"skill_id": 1003, "name": "방패올리기","effect_id": 0},
{"skill_id": 1004, "name": "독바르기", "effect_id": 5099}
]
// effect.json (발췌)
[
{"effect_id": 5001, "effect_type": "DAMAGE"},
{"effect_id": 5002, "effect_type": "DAMAGE"},
{"effect_id": 5003, "effect_type": "BUFF"}
]
(A) 외래 키 정합성 — 위반 1건 -
skill_id: 1004→effect_id: 5099가effect.json에 존재하지 않음 (dangling reference)(B) 고아 효과 — 위반 1건 -
effect_id: 5003(BUFF) → 어떤 스킬도 참조하지 않음(C) 빈/0 effect_id — 위반 1건 -
skill_id: 1003→effect_id: 0. 의도된 "효과 없음"일 수도 있으니, 0이 유효값인지 스키마에서 확인 필요.
세 건 중 두 건은 명백한 버그였다. 5099는 밸런스 담당이 효과 시트에 행을 추가하기 전에 스킬에 먼저 적어 둔 것이었다 — 작업 순서가 어긋난 전형적 케이스. 잡았다.
그런데 (C)는 거부했다. 우리 프로젝트에서 effect_id: 0은 "패시브 슬롯, 효과 없음"을 뜻하는 유효한 약속값이다. AI는 그걸 모르니 "확인 필요"로 올렸고, 그 판단은 옳지만 위반으로 셀 일은 아니었다. 그래서 나는 다음으로 넘어갔다 — 이 약속을 atom으로 박제해서, 다음 검사부터는 AI가 0을 유효값으로 알게 만드는 것이다.
추가 규칙:
effect_id == 0은 "패시브, 효과 없음"을 뜻하는 우리 프로젝트의 유효 약속값이다. 따라서 (C)에서effect_id == 0은 위반이 아니다. 빈 문자열·null·음수만 (C) 위반으로 보고하라.
(C) 빈/0 effect_id — 위반 없음 (
effect_id: 0은 유효 약속값으로 처리. 빈/null/음수 없음.)
이 한 줄짜리 약속이 바로 도메인 지식의 누적이다. 시스템 기획의 결정은 비교적 영구적이라, 한 번 박제하면 1년 뒤에도 같은 검사가 같은 기준으로 돈다. 캐비닛에 결정 카드가 한 장 쌓였다.
이 트랜스크립트에서 사람이 한 일은 세 가지뿐이다 — (1) 스키마부터 읽으라는 입력 순서 지정, (2) 5099가 진짜 버그임을 확인, (3) 0이 유효값임을 알고 AI의 판단을 거부·교정. 나머지 312행을 한 칸씩 점프하며 보던 두 시간은 사라졌다. 자동화된 건 점프와 대조라는 노동이고, 남은 세 줄의 판단이 핵심이다.
위 트랜스크립트의 검사를 매번 손으로 시킬 수도 있지만, L3에서 반복되는 일은 도구로 굳히는 게 시스템 기획의 정석이다. 저자가 운영하는 두 가지를 인용한다 — 추상적인 "프로젝트 A의 도구"가 아니라 실제로 책상 위에서 도는 것들이다.
gen_relation_map.py는 시트의 컬럼명·값을 분석해 외래 키 관계를 자동 감지하고 인터랙티브 HTML 관계도를 뽑는다. 3.1.4에서 skill.effect_id → effect.effect_id라는 화살표를 사람이 머릿속에 그렸다면, 이 스크립트는 그 화살표를 시트 전체에 대해 그림으로 그려 준다. 의존이 역행하는 자리(L3 데이터가 L1 골격을 거꾸로 참조하는 위험)가 그림에서 즉시 튄다.
schema-doc 스킬은 xlsm의 $스키마 시트를 파싱해 마크다운 스키마 문서를 자동 생성한다. 3.1.4 (C)의 "0이 유효값인지 스키마에서 확인 필요"라는 질문이 나왔던 그 스키마 — 사람이 다른 파일을 뒤지지 않고 최신 스키마를 바로 읽게 한다. 시트가 바뀌면 문서가 따라 바뀌므로, 문서와 실제 데이터가 어긋나는 고질병이 줄어든다.
두 도구의 자리를 좌표로 다시 말하면 이렇다. schema-doc는 L3의 컬럼 정의를 지키고, gen_relation_map.py는 L2~L3 사이의 관계를 지킨다. AI 보조 프롬프트(3.1.4 같은 검증)는 그 위에서 돈다. 셋은 따로 노는 게 아니라 같은 좌표축의 다른 높이를 맡는다.
이 도구들의 실제 사용법은 3.2·3.3·3.4에서 손으로 따라 한다. 3.1은 어디에 끼울지를 정하는 지도였고, 다음 세 챕터가 끼우는 작업이다.
세 도구를 한 번에 가동하면, 운영 부담이 효과보다 먼저 도착한다. 저자의 경험상 안전한 순서는 위험이 작은 좌표(아래쪽)부터다.
| 시점(권장) | 도입 | 좌표 | 잘못돼도 생기는 일 |
|---|---|---|---|
| 1개월 | 스키마 우선(3.2) | L3 | 문서가 한 번 안 갱신될 뿐 |
| 2~3개월 | 관계도 시각화(3.3) | L2~L3 | 그림이 부정확할 뿐 |
| 3~6개월 | AI 보조 프롬프트(3.4) | L1~L3 | 검증을 거치므로 사람이 거부 가능 |
기간은 절대 기준이 아니다. 팀 사이즈·기존 인프라에 따라 두 배가 걸릴 수도, 절반에 끝날 수도 있다(저자 추정, 미검증). 바뀌지 않는 건 순서다. 위험이 큰 결정 보조를 맨 마지막에 두면, 앞선 두 도구로 팀이 이미 검증 습관을 들인 뒤에 가장 민감한 자리를 건드리게 된다.
수치를 미화하지 않고 적는다. 아래는 저자가 디렉터로 운영하는 MMORPG 프로젝트(이하 "프로젝트 A")의 기획팀(인원 4~5인, 전체 개발팀은 중규모 10~50인, 운영 약 6개월)에서 관찰한 것이다. 정확한 자동 계측이 아니라 작업 로그와 회고 기록을 바탕으로 한 저자의 관찰이며, 방향과 대략의 비율로만 읽기를 권한다.
핵심은 절약된 시간이 게임을 안 만드는 시간이 아니라는 점이다. 그 시간은 L1 골격 같은, AI에게 못 맡기는 깊은 결정으로 되돌아간다. 노동을 줄여서 판단에 쓰는 것 — 그게 이 챕터가 권하는 한 줄이다.
setup. 시트 두 개(예: 스킬, 효과)를 csv로 export하세요. 손이 닿으면 JSON으로 변환해 둡니다(문서가 아니라 export 결과물이 정본이라는 원칙). 두 시트 사이에 외래 키 한 쌍을 고릅니다(예: skill.effect_id → effect.effect_id).
prompt. 3.1.4의 프롬프트 전문을 그대로 쓰세요. 핵심 세 줄을 빠뜨리지 마세요 — (1) "스키마/구조부터 읽어라", (2) "추측하지 말고 없는 건 없다고 보고하라", (3) "위반 없으면 위반 없음만 써라".
verify. AI가 올린 위반 목록을 사람이 한 줄씩 봅니다. 진짜 버그는 고치고, 도메인 약속값(예: 0 = 효과 없음) 때문에 생긴 오탐은 거부하고 그 약속을 프롬프트(또는 atom)에 추가합니다. 다음 검사부터 같은 오탐이 사라지면 자산이 한 장 쌓인 것입니다.
팀도 시트도 없는 1인 개발자라면, 구글 시트 탭 두 개로 충분합니다. 한 탭은 "스킬", 다른 탭은 "효과". effect_id 컬럼 하나로 둘을 이으세요. 탭을 csv로 내려받아 3.1.4 프롬프트에 붙여넣으면, 312행이 아니라 30행짜리 시트에서도 똑같이 dangling reference와 고아 효과가 잡힙니다. 규모만 다를 뿐 좌표는 같습니다. L3에서 시작해, 손에 익으면 관계도와 영향 범위로 한 칸씩 위로 올라가시면 됩니다.
월요일 오전, 신규 기획자가 채운 스킬 시트 120행을 csv로 빌드했더니 클라이언트 로그에 빨간 줄이 28개 떴다. class_id가 47번을 참조하는데 클래스 시트에는 47번이 없다. element 칸에 누군가 Fire라고 적었고 또 누군가는 fire라고 적었으며, 한 줄은 화염이라고 한글로 적혀 있다. 빨간 줄 28개를 한 줄씩 손으로 더듬는 데 오후 절반이 사라진다.
이 사고의 원인은 데이터가 틀려서가 아니다. 데이터를 만들기 전에 그 데이터가 따라야 할 규칙을 명시하지 않아서다. 규칙이 머릿속에만 있으면, 사람이 바뀌는 순간 규칙도 바뀐다. 이 장은 규칙—스키마—을 데이터보다 먼저 만드는 워크플로를 다룬다. 그리고 그 규칙을 사람의 손이 아니라 도구가 문서로 강제하게 만든다.
용어 메모 - 스키마(schema): 데이터 시트의 컬럼 정의. 이름·타입·범위·외래 키·설명. -
$스키마: 엑셀 데이터 시트(xlsm) 안에 두는, 컬럼 정의 전용 시트. 데이터 행이 아니라 컬럼의 규칙만 담는다. - FK(외래 키): 다른 시트의 PK(기본 키)를 참조하는 컬럼.class_id가 Class 시트의 행을 가리키는 식. - proto: Protocol Buffers 정의(.proto). 클라이언트·서버가 공유하는 데이터 구조·Enum 계약. - 단일 진실 출처(single source of truth): 같은 정보를 한 곳에서만 관리해 모두가 그곳을 보는 운영 원칙.
스키마 우선을 "컬럼을 미리 정의한다"로만 이해하면 절반만 잡은 것이다. 핵심은 무엇을 먼저 입력하느냐의 순서에 있다. 데이터를 채우는 손이 어떤 순서로 움직이느냐가, 정합성이 지켜지는지 무너지는지를 결정한다.
이 책이 권장하는 입력 순서는 네 칸짜리 파이프라인이다.
flowchart LR
A["$스키마 시트
(컬럼 규칙 정의)"] --> B["Enum / *.proto
(VBA Export로 코드 계약 생성)"]
B --> C["csv 데이터
(규칙 안에서 행 채움)"]
A -.->|schema-doc| D["스키마 문서
(.md 자동 생성)"]
C -.->|gen_relation_map.py| E["FK 관계도
(HTML 자동 생성)"]
D -.-> F(("AI / 사람
같은 정의를 읽음"))
E -.-> F
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
class A,B,C,D,E data
왼쪽에서 오른쪽으로 흐르는 실선이 입력 강제 순서다. $스키마를 먼저 정의하고, 거기서 Enum과 proto를 VBA(엑셀 매크로 언어) Export로 뽑고, 그 계약 안에서만 csv 데이터를 채운다. 점선은 그 입력에서 자동으로 파생되는 산출물—스키마 문서(schema-doc)와 FK 관계도(gen_relation_map.py)—이며, 사람과 AI는 이 파생물을 통해 같은 정의를 본다.
이 순서가 강제되는 한, 이 장 도입부에서 본 빨간 줄 28개 중 대부분은 데이터를 채우기 전에 닫힌다. element가 fire/ice/lightning/none 넷 중 하나라는 사실이 proto의 Enum으로 고정되어 있으면, Fire도 화염도 입력 단계에서 걸린다. class_id가 Class 시트의 PK를 참조한다는 사실이 $스키마에 명시돼 있으면, 47번 누락은 빌드가 아니라 검사에서 먼저 잡힌다.
순서를 뒤집으면—데이터를 먼저 채우고 스키마를 나중에 정리하면—스키마는 사후 청소가 된다. 1000행이 이미 쌓인 자리에서 컬럼 규칙을 손보면, 규칙이 데이터를 따라가게 되고, 그 순간 진실 출처가 거꾸로 선다.
$스키마부터 csv까지 한 번에말로 설명하는 대신, 실제로 한 시트를 처음부터 끝까지 통과시켜 본다. 스킬 시트를 새로 만든다고 하자. 아래는 AI 보조를 끼고 진행한 전체 기록이다. 요약하지 않고, 어긋난 곳과 사람이 거부한 곳을 그대로 남긴다.
$스키마를 먼저 손으로 적는다도구도 AI도 아직 부르지 않는다. 컬럼 규칙을 사람이 직접 정의한다. 이 단계만큼은 위임하지 않는다.
# Skill 시트 $스키마 (사람이 작성)
컬럼 타입 범위/제약 FK 설명
skill_id int 1000~9999 (PK) 스킬 고유 ID
name string 1~30자 - 표시 이름
class_id int(FK) - Class.class_id 사용 가능 클래스
cooldown float 0.0~120.0 - 재사용 대기(초)
damage_coef float 0.0~10.0 - 공격력 곱 계수
element enum fire/ice/lightning/none - 속성
is_passive bool true/false - 패시브 여부
$스키마의 element enum과 FK 구조를 proto 계약으로 옮기는 작업. 형식 변환이라 AI에게 맡길 수 있는 일이다.
프롬프트(전문): 아래는 게임 데이터 시트의 컬럼 명세다. 이 명세를 Protocol Buffers(proto3) 메시지와 Enum으로 변환해라. 규칙: (1) enum 타입 컬럼은 별도 enum으로 정의하고 첫 값은 반드시
_UNSPECIFIED = 0로 시작한다. (2) FK 컬럼은 참조 대상을 주석으로 남긴다. (3) 범위 제약은 proto로 표현 불가하니 주석으로만 남긴다. 명세에 없는 컬럼은 추가하지 마라.skill_id int PK / name string / class_id int FK→Class.class_id / cooldown float 0~120 / damage_coef float 0~10 / element enum{fire,ice,lightning,none} / is_passive bool
syntax = "proto3";
// Skill 시트 데이터 계약
// 주의: 범위 제약(cooldown 0~120, damage_coef 0~10)은
// proto로 강제 불가. Linter에서 검사할 것.
enum SkillElement {
SKILL_ELEMENT_UNSPECIFIED = 0;
SKILL_ELEMENT_FIRE = 1;
SKILL_ELEMENT_ICE = 2;
SKILL_ELEMENT_LIGHTNING = 3;
SKILL_ELEMENT_NONE = 4; // ← 사람 검토 표시
}
message Skill {
int32 skill_id = 1; // PK
string name = 2;
int32 class_id = 3; // FK → Class.class_id
float cooldown = 4;
float damage_coef = 5;
SkillElement element = 6;
bool is_passive = 7;
}
여기서 사람이 한 군데를 거부한다. AI가 none을 SKILL_ELEMENT_NONE = 4로 enum의 마지막에 배치했는데, $스키마에서 none은 "속성 없음"이라는 기본 상태다. 쟁점은 proto의 0번 값이다. proto3에서 enum의 0번은 "값이 채워지지 않았을 때 자동으로 들어가는 자리"라서, 0번을 _UNSPECIFIED(값 미입력)로 비워 두느냐 none(의도적 무속성) 같은 의미값으로 채우느냐가 갈린다. 둘을 한 자리에 합치면, 칸을 비워 둔 행(실수)과 무속성을 일부러 고른 행(의도)이 똑같이 0으로 들어와 영영 구분되지 않는다. AI는 _UNSPECIFIED = 0과 none을 별개로 두긴 했지만 none을 4번 끝자리로 보내, 가장 흔한 기본 상태가 검색·디버깅에서 멀어졌다.
사람이 내린 결정:
- _UNSPECIFIED = 0은 유지(누락 검출용).
- none은 그대로 두되, csv 작성 규칙에서 "무속성은 반드시 none을 명시, 빈칸 금지"를 추가. 빈칸=0=UNSPECIFIED는 입력 오류로 간주.
- 이 결정을 $스키마의 element 행 설명에 한 줄 추가: "무속성도 명시(none). 빈칸은 오류."
AI 출력을 그대로 쓰지 않았다. 형식은 받았고, 의미의 경계는 사람이 그었다.
검증된 proto 정의는 엑셀의 Export 매크로($스키마 시트의 버튼)가 일관 출력한다. 사람이 IDE에서 다시 타이핑하지 않는다. 시트가 진실 출처이므로, proto는 시트에서 나온다. 이 순서가 3.3에서 다룰 "출력이 정본"(json_over_schema_doc_as_source_of_truth) 원칙의 연장이다—문서가 코드를 설명하는 게 아니라, 시트가 코드를 낳는다.
프롬프트(전문): 위 Skill proto와 $스키마를 만족하는 csv 행 5개를 만들어라. 제약: class_id는 [1,2,3] 중 하나(현재 Class 시트에 존재하는 PK), damage_coef는 패시브(is_passive=true)면 0.0, element는 fire/ice/lightning/none 문자열 그대로, 빈칸 금지.
Claude의 첫 출력에서 한 행이 어긋났다.
skill_id,name,class_id,cooldown,damage_coef,element,is_passive
1001,화염베기,1,3.5,2.4,fire,false
1002,빙결의손,2,8.0,3.1,ice,false
1003,체력회복,1,0.0,1.2,none,true ← 거부: 패시브인데 damage_coef≠0
1004,번개창,3,5.0,2.8,lightning,false
1005,방어태세,2,0.0,0.0,none,true
1003행이 규칙 위반(is_passive=true이면 damage_coef=0.0이어야 함)이다. 사람이 거부하고 재요청했다.
재요청(전문): 1003행 규칙 위반. is_passive=true인데 damage_coef=1.2다. 패시브는 0.0이어야 한다. 1003만 고쳐서 다시 줘라.
Claude 재출력:
1003,체력회복,1,0.0,0.0,none,true
AI가 첫 시도에 다 맞히지 못한 건 흠이 아니라 그냥 일어나는 일이다. 중요한 건 스키마가 깔려 있었던 덕에 그 어긋난 한 행을 눈으로 짚어 한 줄로 되돌릴 수 있었다는 점이다. 스키마가 없었다면 1003은 빌드 후 게임에서 패시브가 데미지를 내는 버그로 발견됐을 것이다.
이 전체 트랜스크립트의 교훈은 단순하다. 입력 순서가 $스키마 → proto → csv로 고정돼 있으면, AI는 형식을 빠르게 채우고 사람은 의미와 위반만 검토한다. 순서가 무너지면 사람이 형식부터 의미까지 전부 떠안는다.
$스키마를 엑셀 안에 두면 기획자에게는 편하지만, AI와 git과 외부 도구에게는 닫힌 자리다. 그래서 $스키마를 마크다운으로 자동 변환하는 도구를 운영한다. 슬래시 스킬 schema-doc이 이 일을 한다.
동작은 네 단계다.
$스키마 시트를 파싱(python-calamine, Rust 가속)<시트명>_schema.md 생성핵심은 사람이 스키마를 두 번 적지 않는다는 것이다. 엑셀에서 한 번 정의하면, 마크다운은 도구가 만든다. 둘이 어긋날 수 없다. 3.3에서 다룰 "스키마 문서를 정본으로 두면 실제 출력과 어긋난다"는 함정을, 여기서는 "엑셀이 정본, 문서는 파생"으로 뒤집어 회피한다.
schema-doc이 생성한 결과(앞 트랜스크립트의 Skill 시트 기준):
# Skill 시트 스키마 (자동 생성 — 직접 수정 금지)
| 컬럼 | 타입 | 범위/제약 | FK | 설명 |
|---|---|---|---|---|
| skill_id | int | 1000~9999 | (PK) | 스킬 고유 ID |
| name | string | 1~30자 | - | 표시 이름 |
| class_id | int(FK) | - | Class.class_id | 사용 가능 클래스 |
| cooldown | float | 0.0~120.0 | - | 재사용 대기(초) |
| damage_coef | float | 0.0~10.0 | - | 공격력 곱 계수 |
| element | enum | fire/ice/lightning/none | - | 속성. 무속성도 명시(none), 빈칸은 오류 |
| is_passive | bool | true/false | - | 패시브 여부. true면 damage_coef=0 |
_source: Skill.xlsm / generated by schema-doc_
element와 is_passive 설명 칸에 3.2.2의 4·6단계에서 사람이 그은 경계가 그대로 따라 들어온 점을 보라. 사람이 $스키마에 한 줄 적었더니, 문서·proto·검증이 모두 같은 규칙을 공유하게 됐다. 이것이 단일 진실 출처가 실제로 작동하는 모습이다.
마크다운으로 떨어진 스키마는 세 곳에서 곧바로 쓰인다.
스키마가 시트의 안쪽 규칙이라면, FK는 시트들 사이의 규칙이다. class_id가 Class 시트를 참조한다는 정의는 $스키마에 적혀 있지만, 그 참조가 지금 이 순간 실제로 살아 있는지는 별도 검사가 필요하다.
gen_relation_map.py는 데이터 시트들의 FK 관계를 자동 감지해 인터랙티브 HTML 관계도로 그린다. Skill의 class_id→Class, Item의 set_id→ItemSet 같은 화살표가 한 화면에 모이면, "참조 대상이 사라진 FK"가 끊어진 화살표로 눈에 띈다. 이 장 도입부의 47번 누락 같은 사고가, 빌드 로그의 빨간 줄이 아니라 관계도의 끊긴 선으로 데이터를 채우는 도중에 보인다.
이 도구의 워크드 사용과 시각화는 3.3에서 본격적으로 다룬다. 이 장에서 기억할 것은 하나다. $스키마가 FK를 명시하지 않으면 관계도도, 정합성 검사도 그릴 그래프가 없다. FK 명시는 선택이 아니라 스키마 우선의 전제다.
3.2.2의 트랜스크립트를 일반화하면 다섯 단계가 된다. 각 단계의 주체와 산출을 분리해 보면, 무엇을 사람이 쥐고 무엇을 도구에 넘기는지가 분명해진다.
다섯 단계를 첫 달에 다 갖출 필요는 없다. 1·2단계(스키마 설계 + 자동 문서화)만 돌려도 절반의 가치는 잡힌다. 3~5단계는 운영이 익숙해진 뒤 점진적으로 붙인다. 처음부터 5단계를 강제하면 작성자 부담이 정착 전에 운영을 멈춘다.
저자가 디렉터로 운영하는 어느 MMORPG 프로젝트(이하 "프로젝트 A")에서 약 6개월간 이 워크플로를 돌렸다. 아래 수치 중 데이터 시트 컬럼 일관성·새 시트 초안 시간은 도구 로그와 작업 기록에서 집계한 실측이고, FK 깨짐 빈도는 빌드 실패 이슈를 역산한 저자 추정(미검증)이다.
| 항목 | 도입 전 | 도입 후 | 근거 |
|---|---|---|---|
| 컬럼명 일관성 | 약 60% | 약 95% | schema-doc 대조 실측 |
| FK 깨짐 빈도 | 주 2~3건 | 월 1건 이하 | 빌드 이슈 역산 (저자 추정) |
| 새 시트 초안 시간 | 4~8시간 | 1~2시간 | 작업 기록 실측 |
| 신규 기획자 시트 이해 | 회의 3번 | 문서 1번 + 회의 1번 | 온보딩 사례 (방향만) |
도입 비용은 도구 초기 개발 약 3일 + 운영 정착 약 1개월. 6개월 누적 효과 대비 도입 비용이 작았다는 것이 운영 결론이다. 다만 위 비율은 한 팀·한 프로젝트의 단일 사례이므로, 다른 팀에 그대로 옮길 수 있는 보장은 없다.
스키마가 깔리면 AI의 데이터 생성 신뢰도가 비약적으로 올라간다. 이유는 환각의 빌미가 되는 모호한 입력 범위를 스키마가 미리 닫기 때문이다. "스킬 20개 만들어 줘"라는 요청에 스키마가 없으면 AI는 그럴듯한 컬럼을 발명하고 본인 시트와 호환되지 않는 값을 채운다. 스키마가 있으면 같은 요청이 정의된 7개 컬럼·각 제약·FK를 지킨 행으로 돌아온다. 3.2.2의 1003행 사례처럼 위반이 나와도, 한 줄을 짚어 재요청하면 끝난다.
대신 경계가 분명하다. 밸런스 값은 AI에게 시키지 않는다. damage_coef를 AI가 "적당히" 정하면 게임의 의도와 충돌한다. 형식이 맞는 후보를 빠르게 깔아 주는 데까지가 AI의 몫이고, "이 스킬의 계수가 2.4가 옳은가"는 사람이 답한다. 그렇다고 AI가 밸런스에 무용하다는 뜻은 아니다—곡선 매끄러움·이상치·범위 통계는 AI가 빠르게 잡아낸다. 숫자를 재는 건 도구에 맡기고, 그 숫자가 옳은지는 사람이 가린다.
| 실수 | 회피 |
|---|---|
| 스키마를 1000행 쌓은 뒤 도입 | 새 시트는 무조건 $스키마 먼저 |
$스키마와 csv 동기화가 무너짐 |
schema-doc 자동화로 둘을 한 출처에 묶음 |
| FK를 명시하지 않음 | FK 미명시 시 관계도·정합성 검사가 무의미 |
| proto Enum 0번을 의미값으로 씀 | 0은 _UNSPECIFIED(누락 검출), 의미값은 1부터 |
| 스키마 문서를 사람만 읽음 | 마크다운 표 + 메타 통일로 AI도 읽게 |
setup
1. 본인 분야의 가장 핵심 시트 하나를 고르세요(스킬·아이템·몬스터 중 하나).
2. 그 엑셀 파일에 $스키마라는 시트를 추가하고, 컬럼마다 5요소(이름·타입·범위·FK·설명) 한 줄씩 적으세요. 이 단계는 사람이 직접 합니다.
prompt (proto/csv 초안에만 AI를 쓴다)
아래 $스키마를 proto3 메시지와 Enum으로 변환해라. enum 첫 값은
_UNSPECIFIED = 0. FK는 참조 대상을 주석으로. 범위 제약은 주석으로만. 명세에 없는 컬럼은 추가 금지. (여기에 본인 $스키마 붙여넣기)
이어서:
위 proto와 $스키마를 만족하는 csv 행 5개. 제약 위반 행은 만들지 마라. is_passive=true면 damage_coef=0.
verify
1. AI가 준 5행을 한 줄씩 스키마와 대조하세요. 위반 행이 있으면 "N행 위반, 그 행만 고쳐 줘"로 재요청합니다(거부와 재요청은 정상 과정입니다).
2. schema-doc(또는 동급의 간단한 Python 스크립트)으로 $스키마를 .md로 뽑아, 엑셀 정의와 문서가 일치하는지 확인하세요.
3. FK가 있다면 참조 대상 PK가 실제로 존재하는지 한 번 대조하세요.
도구도 팀도 없이 혼자 시작한다면, 엑셀 한 파일·텍스트 에디터 하나로 충분합니다.
$스키마를 만들고 컬럼 규칙을 5요소로 적으세요(15분).$스키마 텍스트를 메모장에 skill_schema.md로 저장해 둡니다. 이것이 본인의 첫 단일 진실 출처입니다.다음 시트로 넘어갈 때 같은 4단계를 반복하세요. 분기 안에 핵심 시트 5~10개가 같은 순서로 정렬되면, 그때 비로소 schema-doc 같은 자동화를 붙일 가치가 생깁니다.
$스키마→Enum/proto→csv로 강제하면 위반이 데이터 채우기 전에 닫힌다신규 기획자가 입사 첫 주에 내 자리로 왔다. "퀘스트 보상 테이블을 손대려는데, 이거 건드리면 어디가 깨지나요?" 나는 모니터를 가리키며 답하려다 멈췄다. 머릿속에는 그림이 있었다. RewardTable이 ItemTable을 물고, ItemTable이 ItemEffectTable을 물고, 그 위로 QuestTable이 보상을 참조하고… 그런데 그 그림을 말로 옮기는 순간 듣는 사람의 머릿속에서는 형체가 무너졌다. 화이트보드에 박스 일곱 개를 그렸다. 화살표가 엉키기 시작했다. 30분 뒤, 그는 고개를 끄덕이며 자리로 돌아갔고, 다음 날 똑같은 질문을 다시 들고 왔다.
이 장면이 이 챕터를 쓰게 만들었다. 시스템 기획자의 머릿속에는 의존성 그래프가 있다. 문제는 그게 머릿속에만 있다는 것이다. 사람이 바뀌면 그림도 사라진다. 그림을 외부화하는 도구가 필요했고, 그래서 만든 것이 gen_relation_map.py다.
데이터 시트가 5~10개일 때는 머릿속으로 충분하다. 30개를 넘어가면 사람의 작업기억으로는 감당이 안 된다. 한 프로젝트의 시트 폴더는 보통 그 선을 일찌감치 넘는다. 어디서 어디로 의존하는지를 글로 적은 표는, 읽어도 그림이 안 그려진다. 이 챕터는 외래 키 관계를 인터랙티브 HTML 관계도로 자동 생성하는 워크드 과정을 처음부터 끝까지 따라간다.
도구를 만들기 전에, 관계도가 없을 때 실제로 무엇이 막히는지부터 짚는다. 네 장면이 반복됐다.
신규 기획자 온보딩. 새 기획자가 시스템 구조를 익히려고 회의를 잡는다. 위의 그 장면이다. 말로 전달된 의존성은 듣는 사람 머릿속에서 며칠을 못 간다. 관계도 한 장을 같이 클릭하면, 첫 회의에서 절반 이상이 그려진다. 화이트보드 손그림과 결정적으로 다른 점은 그림이 지워지지 않고 자리에 남는다는 것이다.
변경 영향 범위 토론. 시스템 변경 요청이 올라온다. "이거 어디 영향을 주죠?" 회의가 잡히고, 한참 토론하고도 누락 영역이 한둘 나온다. 관계도가 있으면 변경 대상 노드를 클릭해 인바운드 엣지를 따라가는 것으로 영향 범위가 눈에 들어온다. 토론은 "이 영향이 진짜 맞는지"와 우선순위만 정하면 된다.
의존 역행 검출. L3 데이터 시트가 L1 시스템 문서를 참조하는 건 정상이다. 반대 방향(상위 Layer가 하위 데이터 시트를 직접 참조)은 거의 항상 설계 결함이다. 글로 늘어놓은 FK 목록에서는 이 역행을 사람이 못 잡는다. 그림에서는 Layer 색상이 어긋난 화살표 하나로 즉시 드러난다.
고립된 시트 발견. 어디서도 참조되지 않는 시트가 가끔 발견된다. 옛 기획의 잔재거나, 폐기하기로 했는데 파일만 남은 경우다. 사무실 한구석에 라벨 없는 박스가 굴러다니는 풍경과 같다. 그림이 있어야 그 외딴섬을 발견한다.
네 문제의 공통점은 모두 "구조를 눈으로 봐야 풀린다"는 점이다. 글과 표로는 막힌다.
이제 실제로 따라간다. 입력은 데이터 시트가 든 폴더 하나, 출력은 브라우저에서 여는 인터랙티브 HTML 한 장이다. 그 사이에서 AI가 한 일과 사람이 검증/거부한 지점을 빠짐없이 적는다.
flowchart TD
A[데이터 시트 폴더
xlsm/xlsx 다수] --> B[1. 스캔: 시트·컬럼 헤더 수집]
B --> C[2. FK 후보 추출
*_id / *Id / 명세서 FK 표시]
C --> D[3. 참조 대상 매칭
컬럼명 → 대상 시트]
D --> E{사람 검증}
E -->|오탐 거부| C
E -->|통과| F[4. 그래프 구축
노드=시트, 엣지=FK]
F --> G[5. Layer 메타 부여
schema-doc 출력 참조]
G --> H[6. pyvis로 HTML 렌더]
H --> I[relation_map.html
브라우저 인터랙티브]
I --> J{사람 진단}
J -->|역행·고립·순환 발견| K[설계 수정 요청]
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
class A,I data
class B,C,D,F,G,H code
class E,J,K human
핵심은 3번과 5번 사이의 사람 검증 루프다. FK 후보 추출은 기계가 초안을 깔고, 사람이 거기서 오탐을 솎아 낸다. 이 루프를 생략하면 관계도는 그럴듯하지만 틀린 그림이 된다.
이 도구의 정확도는 입력을 어디서 끌어오느냐로 결정된다. 3.2에서 정한 schema-first 원칙이 그대로 적용된다. FK 정보의 정본 순서는 이렇다.
$스키마 시트 — 각 데이터 시트의 첫 번째 정본. 컬럼별로 타입·Enum·FK 대상이 명시돼 있다. 여기에 FK가 적혀 있으면 그게 1순위다.*.proto / Enum 정의 — VBA(엑셀 매크로 언어) Export로 빠져나온 스키마. 명세서가 비어 있을 때 타입을 보강한다.csv 출력 — 시트가 내보낸 실제 데이터. 명세서에 없는 관계도 데이터에서 패턴으로 드러난다(예: npc_id 컬럼의 값이 전부 NPCTable의 키 범위 안에 있다면 사실상 FK다).여기서 한 가지 원칙을 분명히 해 둔다. 스키마 문서가 아니라 실제 JSON/csv 출력이 정본이다. 명세서에 reward_id가 FK라고 적혀 있어도, 실제 데이터에서 그 컬럼이 비어 있거나 엉뚱한 값을 가리키면 명세서가 틀린 것이다. 도구는 둘이 어긋날 때 데이터 쪽을 신뢰하고, 어긋남 자체를 리포트에 남긴다. 이게 schema-doc을 정본으로 두지 않는 이유다.
도구의 첫 동작은 폴더 안 모든 xlsm/xlsx를 열어 시트·컬럼 헤더를 수집하는 것이다. 이 단계 자체는 기계적이라 흥미가 없다. 흥미로운 건 FK 후보를 뽑는 규칙이다. 처음엔 단순했다. 컬럼명이 *_id 또는 *Id로 끝나면 FK 후보로 본다. 그런데 이 규칙 하나로는 오탐과 누락이 둘 다 났다.
여기서 AI를 붙였다. 컬럼 헤더 목록을 통째로 넘기고 FK 대상 시트를 추론하게 했다. 아래는 실제로 던진 프롬프트 전문과 날것 출력이다.
프롬프트: 다음은 게임 데이터 시트들의 시트명과 컬럼 헤더 목록이다. 각 컬럼이 외래 키(FK)인지, FK라면 어느 시트의 어느 컬럼을 참조하는지 추론하라. 컬럼명 규칙은 보통
<대상시트단수형>_id다. 단, 확신이 없으면 "불확실"로 표시하고 이유를 적어라. 추측으로 채우지 말 것.QuestTable: quest_id, title, npc_id, reward_id, prev_quest_id, region_id NPCTable: npc_id, name, region_id, faction_id RewardTable: reward_id, item_id, count, currency_id ItemTable: item_id, name, item_effect_id, grade ItemEffectTable: item_effect_id, effect_type, value RegionTable: region_id, name, parent_region_id
Claude 날것 출력(PK 행은 생략, FK 판단만 발췌):
QuestTable -
npc_id→ NPCTable.npc_id (퀘스트를 주는 NPC로 추정) -reward_id→ RewardTable.reward_id -prev_quest_id→ QuestTable.quest_id (선행 퀘스트, 자기 참조) -region_id→ RegionTable.region_idNPCTable -
region_id→ RegionTable.region_id -faction_id→ 불확실: FactionTable이 입력 목록에 없습니다. 컬럼명 규칙상 FactionTable.faction_id를 참조할 것으로 보이나, 해당 시트가 제공되지 않아 대상을 확정할 수 없습니다.RewardTable -
item_id→ ItemTable.item_id -currency_id→ 불확실: CurrencyTable이 목록에 없습니다. 화폐 종류가 Enum으로 관리될 가능성도 있어 FK가 아닐 수 있습니다. 명세서 확인이 필요합니다.ItemTable -
item_effect_id→ ItemEffectTable.item_effect_id -grade→ FK 아님 (Enum으로 추정: 등급 값)ItemEffectTable -
effect_type→ FK 아님 (Enum으로 추정)RegionTable -
parent_region_id→ RegionTable.region_id (자기 참조, 지역 계층 구조)주의: 위 추론은 컬럼명 규칙에 근거한 것이며, 실제 데이터 값을 확인하지 않았습니다. 특히
count,value처럼_id로 끝나지 않는 컬럼은 FK 검사 대상에서 제외했는데, 숨은 참조가 있다면 누락됩니다.
이 출력에서 사람이 한 일은 세 가지다.
faction_id → AI는 FactionTable이 없다고 불확실 처리했다. 옳다. 다만 실제로는 faction_id가 Enum이었다. 팩션이 6종으로 고정이라 별도 시트 없이 Enum으로 관리한다. AI가 없는 시트를 지어내지 않고 판단을 멈춘 덕에, 사람이 명세서를 보고 Enum으로 확정할 수 있었다. FK에서 제외.currency_id → AI가 둘 다 가능성을 열어 뒀다. 실제 데이터를 보니 CurrencyTable이 존재했다(입력 목록에서 내가 빠뜨렸다). FK로 확정. AI를 탓할 수 없는, 사람의 입력 누락이었다.prev_quest_id와 parent_region_id의 자기 참조 검출. 이건 단순 정규식 규칙이었다면 놓쳤을 것이다. AI가 "선행 퀘스트", "지역 계층"이라는 의미까지 붙여 준 게 검증을 빠르게 했다.여기서 얻은 교훈은 명확하다. AI가 가장 쓸모 있었던 대목은 빠른 추론이 아니라 모르는 자리를 "불확실"로 비워 둔 절제였다. 빈칸을 억지로 메웠다면 faction_id가 엉뚱한 시트로 연결됐을 것이고, 그 오탐은 관계도에 가짜 화살표로 남아 신규 기획자를 잘못 인도했을 것이다.
검증된 FK 목록이 나오면 gen_relation_map.py가 그래프를 만든다. 시트는 노드, FK는 방향 엣지다. 인바운드 엣지 수(다른 시트가 나를 얼마나 참조하는가)를 세어 노드 크기를 정한다. 많이 참조될수록 큰 노드, 즉 시스템의 허브다.
Layer 메타데이터는 schema-doc 스킬이 만든 마크다운 스키마 문서에서 끌어온다. 3.1에서 정의한 Layer 좌표(L0~L4)가 각 시트에 라벨로 붙어 있고, 도구는 그걸 읽어 노드 색상을 칠한다. 이 연결이 중요하다. 관계도가 Layer를 모르면 그냥 박스와 화살표일 뿐이고, Layer를 알아야 "역행"을 색으로 진단할 수 있다.
도구 내부 구조를 코드 골격으로 보이면 이렇다(핵심 흐름만 발췌).
# gen_relation_map.py (핵심 흐름 발췌)
from pyvis.network import Network
LAYER_COLORS = { # Layer 팔레트 — atom 1개로 표준화
"L0": "#2c3e50", # 메타/공용
"L1": "#2980b9", # 시스템
"L2": "#27ae60", # 콘텐츠
"L3": "#f39c12", # 데이터 인스턴스
"L4": "#c0392b", # 파생/캐시
}
def build_graph(fk_list, layer_map):
net = Network(directed=True, height="900px")
inbound = count_inbound(fk_list) # 인바운드 엣지 집계
for sheet in all_sheets(fk_list):
layer = layer_map.get(sheet, "L0")
size = 10 + inbound[sheet] * 3 # 허브일수록 큰 노드
net.add_node(sheet, color=LAYER_COLORS[layer],
size=size, title=sheet_tooltip(sheet))
for src, dst, col in fk_list:
# Layer 역행 감지: 상위 Layer가 하위를 참조하면 경고색
edge_color = "#e74c3c" if is_reverse(src, dst, layer_map) else "#888"
net.add_edge(src, dst, title=col, color=edge_color)
return net
is_reverse가 이 도구의 작은 핵심이다. 엣지의 출발 시트가 도착 시트보다 상위 Layer면(예: L1 → L3) 역행으로 보고 엣지를 빨강으로 칠한다. 사람이 그림을 열었을 때 빨간 화살표가 보이면 그건 거의 항상 손볼 곳이다.
마지막은 pyvis가 인터랙티브 HTML을 뱉는 단계다. 노드 클릭 시 해당 시트의 컬럼·Layer·인바운드 수가 툴팁으로 뜨고, 검색창에서 시트명으로 필터링할 수 있다. 정적 PNG가 아니라 HTML이어야 하는 이유가 여기 있다 — 노드 수가 수십 개를 넘으면 정적 이미지에서는 화살표가 엉켜 아무것도 안 보인다. 마우스로 끌어 펼치고, 관심 영역만 클릭으로 좁혀야 패턴이 잡힌다.
위 예시 데이터로 만든 관계도의 구조를 SVG로 옮기면 이렇다. 색상은 Layer, 빨간 화살표는 (이 예시엔 없지만) 역행 자리를 의미한다.
노드 크기를 보면 RegionTable이 가장 많이 참조된다(Quest·NPC가 모두 가리킴). 이게 허브다. ItemEffectTable은 잎사귀 노드라 작다. 신규 기획자에게 "이 시스템을 이해하려면 어디부터 보냐"는 질문의 답이, 노드 크기 순서로 그림에 이미 들어 있다.
3.1에서 Layer 좌표를 정의했다. 이 챕터의 관계도가 그 좌표를 시각으로 끌어올리면, 글이나 표로는 불가능했던 진단 네 가지가 한 화면에서 가능해진다.
다만 그림이 모든 문제를 잡는다는 뜻은 아니다. 그림은 구조적 결함을 잡는다. 이 FK가 정말 의미상 맞는 관계인지(예: npc_id가 정말 "퀘스트를 주는 NPC"인지 아니면 "퀘스트에 등장하는 NPC"인지)는 그림으로 안 풀린다. 그건 사람의 도메인 판단 몫이다. 도구는 사람의 판단이 작동할 무대를 깔아 줄 뿐이다.
관계도는 한 번 만들고 끝이 아니다. 시트는 매주 추가·변경된다. 수동 갱신에 맡긴 관계도는 한두 달이면 실제 구조와 어긋나고, 어긋난 지도는 길을 잘못 안내하므로 차라리 없느니만 못해진다. 한 번 틀린 그림에 데인 팀원은 다음부터 그림을 안 본다 — 이게 가장 비싼 실패다.
그래서 갱신을 자동 트리거에 건다.
/relation-map 슬래시로 즉시 생성. 회의 중 즉석에서 띄울 때 쓴다.생성된 HTML은 사내 정적 호스팅(기획 포탈)에 자동 배포한다. 별도 도구 설치 없이 브라우저만 있으면 누구나 같은 지도를 본다. 책상 옆에 항상 펼쳐 둔 지도와 같다. 누가 묻든, 같은 그림을 함께 가리키며 답한다.
| 실수 | 왜 생기나 | 회피법 |
|---|---|---|
| 노드가 100개를 넘어 그림이 엉킴 | 전 분야를 한 화면에 욱여넣음 | 도메인별 필터링, 그룹별 분할 뷰 |
| Layer 색상이 도구마다 다름 | 팔레트를 코드마다 새로 정의 | 팔레트를 atom 1개로 표준화(LAYER_COLORS) |
FK 검출이 *_id만 잡아 누락·오탐 |
정규식 한 줄에 의존 | 명세서 FK 명시 + 실데이터 값 검증을 병행 |
| 그림은 만들었는데 아무도 안 봄 | 워크플로에 연결 안 함 | 변경 요청·회의에 그림 첨부를 강제 |
| 만들고 갱신 안 해 부식 | 수동 갱신에 의존 | 자동 트리거 필수, 수동은 한 달이면 무용 |
gen_relation_map.py 운영에서 가장 자주 데인 건 세 번째 줄이다. *_id 규칙만 믿으면 count나 value 같은 숨은 참조를 놓치고(3.3.2.3의 AI도 이 한계를 스스로 경고했다), Enum인 grade를 FK로 오탐한다. 명세서와 실데이터를 둘 다 보는 검증 루프가 이 줄의 답이다.
회사 전체 데이터 시트를 한 번에 다루려 하면 무겁고, 가치를 보여 주기도 전에 지친다. 본인 분야 한 폴더부터 작게 시작한다.
setup.
1. 본인이 담당하는 데이터 시트 5~10개가 든 폴더 하나를 고르세요.
2. pip install pyvis openpyxl로 의존성을 까세요(엑셀 읽기는 excel-reader 스킬 또는 openpyxl).
3. 각 시트의 $스키마 시트에서 FK가 명시돼 있는지 먼저 확인하세요. 없으면 컬럼 헤더만 모읍니다.
prompt. 컬럼 헤더 목록을 모아 3.3.2.3의 프롬프트를 그대로 던지세요. 핵심은 마지막 한 줄입니다 — "확신이 없으면 불확실로 표시하고, 추측으로 채우지 말 것." 이 문장이 가짜 화살표를 막습니다.
verify.
1. AI가 던진 FK 후보를 한 줄씩 보세요. "불확실"로 표시된 줄을 명세서/실데이터로 확정합니다.
2. Enum으로 의심되는 컬럼(grade, effect_type처럼 _id가 없는데 FK처럼 보이는 것)은 FK에서 빼세요.
3. 자기 참조(prev_*_id, parent_*_id)가 맞게 잡혔는지 확인하세요.
4. 검증된 목록으로 그래프를 그리고, 브라우저에서 열어 빨간 화살표(역행)와 외딴섬(고립)을 눈으로 찾으세요.
도구를 만들 시간이 없다면, 첫 주에는 손으로 그린 mermaid 한 장으로 시작해도 됩니다. 시트 5개의 FK를 3.3.2.3의 형식으로 mermaid에 직접 적으세요. 이 한 장을 회의에 들고 가서 "이게 우리 시스템 의존성입니다"라고 보여 주면, 그 자리에서 가치가 증명됩니다. 가치가 보이면 자동화 도구는 자연히 다음에 따라옵니다. 처음부터 동작하는 도구가 나와야 한다는 부담은 내려놓으셔도 됩니다.
확장은 이 순서로 자연스럽게 흐릅니다 — 1주차 본인 시트 mermaid 손그림 → 2주차 Layer 색상과 클릭 추가 → 1개월 자동 갱신(git hook 또는 야간 배치) → 3개월 사내 포탈 배포 → 6개월 전체 시트 통합 관계도.
3.2에서 시트 안쪽(스키마)을, 3.3에서 시트 바깥(관계)을 다뤘다. 3.4은 이 위에 AI 보조 프롬프트 패턴을 얹는다. 스키마와 관계가 잡힌 시스템 위에서, AI가 정합성 검사와 영향 범위 추출을 어떻게 보조하는지의 실용 패턴들로 이어진다.
알파 빌드를 코앞에 둔 주였다. 스킬 시트에 새 클래스 한 줄을 추가하고 저장했다. 그런데 그 클래스가 참조하는 버프 ID가 전날 누군가 지운 행이라는 걸, 나는 다음 날 아침 빌드가 깨지고 나서야 알았다. 깨진 빌드를 거슬러 올라가 원인을 찾는 데 두 시간이 걸렸다. 행 하나 지우기 전에 "이거 지워도 돼?"라고 물어볼 수만 있었다면 쓰지 않았을 두 시간이다.
이 장은 그 질문을 AI에게 시키는 법에 관한 것이다. 핵심은 프롬프트를 잘 쓰는 요령이 아니다. 같은 질문을 매번 0에서 다시 쓰지 않도록 박제하는 일이다. 3.2에서 스키마를, 3.3에서 관계도를 깔았다. 둘 다 데이터의 뼈대였다. 이 장은 그 뼈대 위에서 사람이 AI에게 던지는 질문 자체를 자산으로 굳힌다.
먼저 한 가지 못을 박아 둔다. AI가 만드는 것은 답이 아니라 후보다. 이 장에 나오는 모든 패턴에서, 마지막 결정의 손은 끝까지 사람 쪽에 남는다.
AI를 처음 쓰면 매번 자연어로 즉석에서 친다. 이런 식이다.
스킬 시트 한번 봐줘. 외래 키 깨진 거 없나 확인하고,
이상한 거 있으면 알려줘. 아 그리고 쿨다운 음수인 것도.
이 프롬프트는 두 군데에서 샌다.
첫째, 검사 항목이 매번 달라진다. 오늘은 "쿨다운 음수"를 떠올렸지만 내일은 잊는다. 어제 한 번 걸렀던 "중복 PK(Primary Key, 기본 키)"가 오늘 프롬프트엔 빠진다. 사람의 기억에 의존하는 검사는 사람의 컨디션만큼 누락된다.
둘째, 결과 형식이 매번 달라진다. 같은 의도를 "확인해줘" "검사해" "훑어봐"로 다르게 적으면, AI는 어떤 날은 표로, 어떤 날은 줄글로 답한다. 형식이 들쭉날쭉하면 그 결과를 다시 자동 처리할 수가 없다.
해법은 프롬프트를 손에서 떼어 서랍에 넣는 것이다. 매번 손으로 적던 메모를, 라벨 붙은 카드로 바꿔 같은 서랍에서 꺼낸다. 그 카드가 이 책에서 말하는 슬래시 명령(skill)이고 atom이다.
박제에는 세 가지 그릇이 있다. 무엇을 어디에 담을지는 호출 빈도와 안정성으로 갈린다.
자주 쓰고 정의가 굳은 작업은 슬래시 명령으로. 자주 쓰지만 "잊으면 안 되는 제약"은 atom JIT로 자동 주입. 가끔 하지만 덩치 큰 작업은 템플릿 파일로. 그리고 아직 정의가 흔들리는 작업은 박제하지 않고 즉흥으로 둔다. 셋을 처음부터 다 갖출 필요는 없다. 슬래시 명령 한두 개로 시작해 가치가 보이면 늘린다.
말로 설명하는 대신, 한 패턴을 처음부터 끝까지 따라가 보겠다. 빈 행 하나를 지우기 전에 "이거 지워도 돼?"를 자동으로 묻는 패턴이다. 이름은 /check-sheet. 안에는 검사 항목과 출력 형식이 박제돼 있다.
근거가 되는 자산은 이 책 곳곳에서 임베드된 실측 작업기록에 있다. 데이터 입력은 schema-first 원칙(atom data_entry_schema_first)을 따른다. 입력 순서는 $스키마 시트 → Enum/*.proto(VBA(엑셀 매크로 언어) Export) → csv. 그리고 정본은 스키마 문서가 아니라 실제 JSON 출력이다(atom json_over_schema_doc_as_source_of_truth). 정합성 검사는 이 두 원칙을 그대로 검사 규칙으로 옮긴 것이다.
/check-sheet를 펼치면 안에 이런 프롬프트 본문이 들어 있다. 이게 매번 손으로 안 쳐도 되는 부분이다.
역할: 너는 게임 데이터 시트의 정합성 검사기다.
검사할 시트: {{sheet_name}}
참조 가능한 스키마: $스키마 시트 (컬럼별 타입·범위·FK 대상)
참조 가능한 정본: 같은 시트의 export JSON (스키마 문서보다 우선)
검사 항목 (이 순서로):
1. 외래 키 유효성 — FK 컬럼 값이 참조 대상 시트에 실재하는가
2. 범위 위반 — $스키마에 정의된 min/max를 벗어난 값
3. 필수 컬럼 누락 — required=true 컬럼이 빈 행
4. 중복 PK — 같은 PK가 둘 이상
출력 형식 (반드시 이 표 그대로):
| # | 행 | 컬럼 | 위반 종류 | 현재 값 | 수정 후보 | 확신도 |
규칙:
- 수정 후보는 제안만 한다. 절대 시트를 직접 고치지 않는다.
- 확신도는 높음/중간/낮음 셋 중 하나. 추측이면 낮음.
- 위반이 없으면 "위반 0건"만 출력하고 끝낸다.
이 본문에서 중요한 건 마지막 규칙 세 줄이다. "직접 고치지 않는다", "확신도를 매긴다", "추측은 낮음으로 자백한다". 이 세 줄이 AI를 답을 내는 자리가 아니라 후보를 미는 자리에 묶어 둔다.
/check-sheet skill_table
끝이다. 시트 이름 하나만 넘긴다. 검사 항목 네 개도, 출력 표 형식도 다시 쓰지 않는다. 그게 박제의 값어치다.
아래는 실제로 돌렸을 때 돌아온 출력이다. 요약하지 않고, 애매했던 부분과 AI가 머뭇거린 자리까지 그대로 싣는다.
skill_table 정합성 검사 결과입니다. 총 4건의 위반을 발견했습니다.
| # | 행 | 컬럼 | 위반 종류 | 현재 값 | 수정 후보 | 확신도 |
|---|-----|------------|------------|--------|----------------------|------|
| 1 | 142 | buff_id | 외래 키 위반 | 9007 | 9070 (오타 추정, 1글자) | 중간 |
| 2 | 142 | buff_id | 외래 키 위반 | 9007 | 9012 (이름 "맹독" 유사) | 낮음 |
| 3 | 88 | cooldown | 범위 위반 | -1 | 0 (음수 불가) | 높음 |
| 4 | 215 | combo_next | 필수 누락 | (빈칸) | 판단 보류 | 낮음 |
주의 사항 몇 가지를 덧붙입니다.
- 1번과 2번은 같은 행(142)의 같은 컬럼에 대한 서로 다른 후보입니다.
9007이라는 ID는 buff 시트에 존재하지 않습니다. 9070은 숫자 한 자리
차이라 오타일 가능성이 있고, 9012는 이름이 "맹독"으로 유사해서
후보로 올렸습니다. 둘 다 확신은 낮습니다. 사람이 골라야 합니다.
- 4번(215행 combo_next 빈칸)은 애매합니다. $스키마에는 required=true로
되어 있는데, export JSON을 보니 이 컬럼이 빈 행이 12개 더 있습니다.
명세서가 최신이 아니거나, "콤보 종결 스킬은 combo_next를 비운다"는
규칙이 있는데 명세서에 반영이 안 됐을 수 있습니다. 제가 단정할 수
없어서 "판단 보류"로 두었습니다.
- cooldown 외에 범위 검사를 더 돌리고 싶으면 $스키마의 다른 컬럼
min/max를 확인해야 하는데, 제가 받은 정보에 range_check 컬럼 일부가
비어 있어서 cast_time 컬럼은 검사하지 못했습니다.
여기서 이 출력이 좋은 출력인 이유는, 깔끔해서가 아니라 솔직해서다. 4번을 "필수니까 채워라"라고 단정하지 않고 "명세서가 최신이 아닐 수 있다"고 자기 의심을 드러냈다. cast_time을 못 봤다는 한계도 자백했다. 만약 AI가 4번을 자신만만하게 "0으로 채우세요"라고 했다면, 그건 콤보 종결 스킬을 망가뜨리는 오답이었을 수 있다.
이제 사람이 한 줄씩 판정한다. 검증이 자동 동반된다는 건 이 표가 자동으로 나온다는 뜻이지, 사람이 안 본다는 뜻이 아니다.
마지막 4번이 핵심이다. AI 출력을 그대로 믿었다면 멀쩡한 데이터를 망가뜨렸을 자리에서, 사람의 거부가 작동했다. 동시에 4번은 검사 규칙 자체의 결함도 드러냈다. 명세서의 required=true가 틀린 것이다.
여기서 끝내지 않는다. 거부는 다음 검사를 더 똑똑하게 만드는 입력이다. 사람이 이어서 친다.
4번은 위반이 아니야. combo_finisher=true인 스킬은 combo_next가 비는 게
정상이거든. 이 예외 규칙을 검사 항목에 넣어서, 앞으로는 finisher 행의
combo_next 빈칸은 위반으로 잡지 마. 그리고 명세서의 required 정의가
틀렸다고 보이는 항목은 따로 "명세서 의심" 섹션으로 빼서 알려줘.
이해했습니다. /check-sheet의 검사 규칙에 예외를 반영하겠습니다.
추가된 규칙:
- combo_next 필수 검사 시, 같은 행의 combo_finisher 값을 먼저 확인.
combo_finisher=true이면 combo_next 빈칸은 정상으로 처리(위반 제외).
신설 섹션:
- "명세서 의심" — export JSON의 실제 분포가 $스키마 정의와 어긋날 때,
데이터가 아니라 명세서를 고쳐야 할 후보로 분리해 보고.
(정본은 JSON이라는 원칙에 따라, 다수 데이터가 명세서를 거스르면
명세서 쪽을 의심합니다.)
이 변경을 /check-sheet 명령 정의 atom에 기록해 둘까요?
마지막 한 줄이 이 패턴의 완성이다. 한 번의 거부가 일회성 수정으로 끝나지 않고, 명령 정의 atom에 기록되어 다음 사람·다음 검사로 전파된다. 이게 박제의 복리다. 같은 실수를 두 번 거르지 않는다.
두 번째 패턴은 첫 장면의 그 두 시간을 없애는 것이다. 행 하나, 결정 하나를 바꾸기 전에 "이게 어디까지 번지나"를 먼저 묻는다. 이름은 /impact. 3.3에서 만든 관계도(gen_relation_map.py가 뽑는 FK 관계 HTML)가 이 패턴의 연료다.
/impact 안에는 "어디를 뒤져야 영향을 빠짐없이 찾는가"가 네 갈래로 박제돼 있다.
역할: 너는 변경 영향 범위 추출기다.
변경 대상: {{target}} (시트명·컬럼·atom_id·시스템 결정 중 하나)
탐색 경로 (넷 다 돌 것):
1. 관계도 인바운드 — gen_relation_map.py 출력에서 {{target}}을
FK로 참조하는 시트들 (이 시트를 가리키는 화살표)
2. 본문 언급 — sources/manuscript 안에서 {{target}}의 ID·이름이
직접 등장하는 문서
3. atom affects 관계 — {{target}}과 affects로 연결된 atom들
4. 역참조 — {{target}}을 [[wikilink]]로 가리키는 노트
출력 형식:
| 종류 | 이름 | 연결 근거 | 영향 강도 | 담당 |
규칙:
- 영향 강도는 직접/간접/약 셋이다. 직접 FK는 '직접', 본문 언급은 '간접', 추정은 '약'.
- 누락이 두려우면 약으로라도 올린다. 빠뜨리는 것보다 낫다.
- 우선순위나 작업 지시는 하지 않는다. 목록만 만든다.
/impact buff_id=9012
"맹독 버프(9012)를 다른 효과로 교체하려는데, 어디가 흔들리나?"를 한 줄로 묻는다.
buff_id=9012(맹독)의 변경 영향 범위입니다. 4개 경로를 모두 탐색했습니다.
| 종류 | 이름 | 연결 근거 | 영향 강도 | 담당 |
|--------|--------------------|----------------------|---------|----------|
| 시트 | skill_table | buff_id FK 직접 참조 | 직접 | teammate_a |
| 시트 | monster_drop | reward_buff FK 참조 | 직접 | teammate_b |
| 시트 | set_item_effect | proc_buff FK 참조 | 직접 | teammate_a |
| 문서 | 독계열_밸런스_노트.md | 본문에 "9012" 직접 언급 | 간접 | (저자) |
| atom | poison_stack_rule | affects: 9012 | 간접 | (저자) |
| 노트 | 상태이상_설계.md | [[맹독]] 역참조 | 약 | (저자) |
몇 가지 덧붙입니다.
- skill_table에서 9012를 참조하는 행은 3개입니다(암살자 클래스 스킬).
교체하면 이 3개 스킬의 실제 효과가 바뀝니다. 가장 강한 영향입니다.
- monster_drop의 reward_buff는 제가 확신이 좀 약합니다. 컬럼 이름이
reward_buff인데 일부 행은 buff가 아니라 item을 가리키는 것 같은
값이 섞여 있어서요. 이 시트는 직접 한 번 열어 확인하시길 권합니다.
- "독계열_밸런스_노트.md"는 9012를 텍스트로 언급할 뿐 FK는 아니라
간접으로 두었습니다. 다만 밸런스 의도가 적힌 문서라 실제 영향은
강할 수 있습니다. 강도(간접)와 중요도가 다를 수 있다는 점 유의하세요.
- set_item_effect는 세트 아이템 4종에서 proc_buff로 9012를 씁니다.
맹독을 빼면 이 세트들의 컨셉이 바뀝니다.
여기서도 AI의 정직함이 검증을 도왔다. monster_drop을 직접이라 단정했다면 영향을 부풀려 불필요한 조율 회의가 잡혔을 것이다. AI가 "확인하시길 권합니다"라고 공을 사람에게 넘긴 덕에 강도를 바로잡았다.
목록이 검증되면 그다음 손은 이걸 사람에게 전달하는 일이다.
직접 영향 받는 담당자별로 묶어서, teammate_a / teammate_b에게
보낼 짧은 공유 메모 초안 만들어줘. "9012 맹독을 X로 교체 검토 중,
당신 시트 N개 행이 영향" 형식으로. 결정은 아직 안 났으니 통보가
아니라 의견 요청 톤으로.
담당자별 공유 메모 초안입니다. 통보가 아닌 의견 요청 톤으로 잡았습니다.
[teammate_a 앞]
맹독 버프(9012)를 다른 효과로 교체하는 안을 검토 중입니다. 확정은
아니고 의견을 먼저 듣고 싶습니다. 영향 범위:
- skill_table: 암살자 스킬 3개 행이 9012를 참조 (직접 영향)
- set_item_effect: 세트 아이템 4종이 proc_buff로 9012 사용
교체 시 이 스킬·세트들의 컨셉이 바뀝니다. 반대나 보완 의견 있으면
이번 주 안에 알려주세요.
[teammate_b 앞]
monster_drop의 reward_buff 관련해 확인 부탁입니다. 맹독 버프(9012)
교체를 검토 중인데, 영향 행이 1개로 보입니다(reward_buff 컬럼에
item ID가 섞여 있어 처음엔 더 많아 보였습니다). 실제로 이 1개 행만
맞는지 확인해 주실 수 있을까요?
두 메모 모두 "검토 중·의견 요청"으로 두었고, 확정 통보 표현은
넣지 않았습니다. 보내기 전에 교체 후보 효과(X 자리)를 채우셔야 합니다.
첫 장면의 두 시간이 여기서 사라진다. 변경 전에 영향을 묻고, 담당자에게 미리 공유하고, 다음 날 빌드가 깨지기 전에 조율이 끝난다. 그리고 AI가 만든 건 끝까지 메모 초안일 뿐, 보내는 손가락은 사람의 것이다.
위 두 트랜스크립트는 같은 골격을 따른다. 다른 모든 패턴도 이 골격에 얹힌다.
flowchart LR
A[박제된 명령
검사항목·출력형식·금기] --> B[사람: 한 줄 호출
대상만 넘김]
B --> C[Claude: 날것 출력
후보+확신도+자기의심]
C --> D{사람 검증}
D -->|채택| E[반영]
D -->|정정| F[강도·값 수정]
D -->|거부| G[규칙 결함 발견]
G --> H[명령 정의 atom 갱신]
H -.다음 호출에 반영.-> A
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
classDef fail fill:#fee2e2,stroke:#dc2626,color:#7f1d1d;
class A,H data
class B,D,F human
class C ai
class E pass
class G fail
핵심은 오른쪽 아래의 점선이다. 거부가 끝이 아니라 명령 자체를 고치는 입력으로 돌아간다. 정합성 검사에서 거부된 4번이 finisher 예외 규칙이 됐고, 그 규칙이 atom에 기록되어 다음 검사로 전파됐다. 이 되먹임이 없으면 같은 오답을 매주 다시 거른다.
세 군데에 사람의 손이 남는다. 호출(대상 선택), 검증(채택·정정·거부), 규칙 개선(거부의 환류). AI는 그 사이에서 후보를 미는 일만 한다.
같은 골격을 다른 작업에 옮기면 패턴이 늘어난다. 트랜스크립트 없이 자리만 짚는다. 모두 3.4.5의 골격을 그대로 따르므로, 만들 때 핵심은 "박제된 검사 항목"과 "사람 검증 자리"를 빼먹지 않는 것이다.
| 패턴 | 한 줄 호출 | AI가 미는 후보 | 사람이 쥐는 결정 |
|---|---|---|---|
| GDD 초안 합성 | /gdd-new <시스템> |
표준 9섹션 초안, 미정은 [TBD] | 비전·우선순위·삭제 |
| 상태머신/BT 변환 | /diagram-state |
자연어 → mermaid + 도달성 검증 | 상태 정의·전이 조건 |
| 인터페이스 충돌 검사 | /check-interface <GDD> |
입출력·시간윈도우 충돌 케이스 | 우선순위 규칙 |
| 밸런스 계산 | /balance-calc <시트> <수식atom> |
곡선 계산값 + 기존과 diff | 수식·게임 의도 |
| 회고 작업 분류 | /retro-classify <기간> |
Layer×분야 분포 + 이상 신호 | 분류 보정·해석 |
밸런스 계산에서 한 가지만 못 박아 둔다. 곡선이 수치상 매끄럽게 떨어져도, 그 매끈함이 게임의 의도와 맞는지는 다른 문제다. 보스 직전 구간을 일부러 가파르게 두고 싶었는데 AI가 "이상치"라며 평탄하게 깎아 내는 일이 있다. 그래서 밸런스 계산은 곡선 검증이 자동으로 붙어 있어도, 마지막 한 줄은 사람이 의도와 대조한 뒤에야 닫힌다.
패턴이 늘면 운영 규율이 필요하다. 아래 다섯 원칙은 외워 두는 규칙이 아니라, 도구 자체에 넣어 두는 설계 원칙이다.
| 원칙 | 왜 |
|---|---|
| 한 명령 = 한 작업 | 작을수록 재사용·디버그가 쉽다. /check에 검사·수정·공유를 다 욱여넣지 않는다 |
| 명령에 검증을 자동 동반 | 출력 표 자체에 확신도·근거 칸을 넣어 사람 검증 부담을 줄인다 |
| 명령 정의를 atom으로 | 3.4.3의 거부→규칙 환류처럼, 왜·예시·변경이력을 atom에 남긴다 |
| 사용 빈도 측정 | 월 1회 미만 명령은 폐기 후보. 데이터로 자른다 |
| 사람 손은 결정에만 | 명령은 후보 생성까지. 자동 결정은 금지 |
마지막으로 수렴점 하나. 저자가 운영한 어느 MMORPG 프로젝트에서, 시스템 기획에 안정적으로 남는 슬래시 명령은 시간이 지나며 12개 안팎으로 수렴했다. 공개 표준이 아니라 한 프로젝트의 관찰값(저자 경험, 미검증)이다. 다만 방향은 분명하다. 명령은 무한히 늘리는 게 아니라, 월 1~2개를 더하고 빼며 머릿속에 담기는 수에서 멈춘다. 라벨 100개 붙은 서랍은 라벨 없는 서랍과 같다.
도입은 한 번에 다 하지 않는다. 첫 달은 매주 반복하는 작업 하나를 슬래시 명령으로 박제하는 것으로 충분하다. 그 하나가 값어치를 보이면 다음 달에 둘, 셋으로 자연히 번진다.
3.1에서 시스템 기획의 Layer 좌표를, 3.2에서 스키마를, 3.3에서 관계도를 깔고, 3.4에서 그 위에 AI 보조 프롬프트를 얹었다. 네 장을 통과한 시스템 기획자의 일주일은 이렇게 바뀐다.
flowchart LR
M[월: GDD 초안 합성
4h → 1h] --> T[화: 정합성 검사
반나절 → 5분]
T --> W[수: 온보딩, 관계도 한 장
회의 5번 → 1장]
W --> Th[목: 영향 범위 추출
회의 1h → 10분]
Th --> F[금: 회고 자동 분류
수동 → 데이터 기반]
F --> R[줄어든 시간을
설계·검토·플레이어 경험에 재투자]
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
class M,T,W,Th,F ai
class R pass
잡일 시간이 줄고, 그 시간이 깊은 설계와 플레이어 경험 고민으로 되돌아간다. 줄어든 시간을 다시 잡일로 채우지 않는 것, 그게 도구를 들인 진짜 이유다.
다음 Part 4는 전투 기획이다. 시스템 기획에서 가장 가까운 형제로, 3.1~3.4의 도구와 패턴이 그대로 넘어간다.
setup. 매주 반복하는 검사 작업 하나를 고르세요(예: 시트 정합성). 그 작업의 검사 항목 4개와 출력 표 형식을 적어 슬래시 명령 한 개로 박제합니다. 규칙 세 줄("직접 고치지 않는다 / 확신도를 매긴다 / 추측은 자백한다")을 반드시 명령 본문에 넣으세요.
prompt. 대상만 한 줄로 넘겨 호출하세요.
/check-sheet skill_table
verify. 돌아온 표를 한 행씩 채택·정정·거부로 판정하세요. 거부가 나오면 그게 운이 아니라 규칙의 결함입니다. 그 예외를 명령 정의에 추가하는 한 줄을 다시 보내, 다음 호출이 같은 실수를 두 번 거르지 않게 하세요.
팀도 atom 시스템도 없다면, 슬래시 명령 대신 메모 앱에 텍스트 블록 하나를 두세요. 제목은 "시트검사 프롬프트". 내용은 위 setup의 검사 항목 4개 + 규칙 3줄. 검사할 때마다 이 블록을 복사해 시트 이름만 바꿔 AI에 붙여 넣으세요. 거부할 일이 생기면 그 메모 블록에 예외 한 줄을 직접 추가합니다. 도구가 슬래시 명령이든 메모 한 장이든, 사이클(박제 → 호출 → 검증·거부 → 규칙 갱신)은 똑같이 돕니다.
이 장의 학습 목표 (난이도 🟡 실무 · 선행: 사칙연산·표 계산): 타격감 같은 추상 형용사를 측정 가능한 신호로 분해하고, 전투 기획자의 다섯 산출물이 Layer의 어느 칸에 앉는지 좌표로 지정할 수 있게 된다.
빌드 회의실. 프로그래머가 방금 붙인 신규 스킬을 모니터에 띄운다. 캐릭터가 검을 휘두르고, 적이 뒤로 밀려난다. 다섯 명이 보고 있다. 누군가 말한다.
"음… 뭔가 타격감이 좀 약한데."
옆 사람이 고개를 끄덕인다. "맞아요, 좀 밍밍하네."
프로그래머가 묻는다. "어디를 어떻게 바꾸면 될까요?"
침묵. 회의실의 다섯 명 중 누구도 그 질문에 숫자로 답하지 못한다. "타격감이 약하다"는 다섯 명 모두가 느꼈지만, "히트스톱을 3프레임에서 5프레임으로"라고 말할 수 있는 사람은 없다. 회의는 40분간 "좀 더 묵직하게", "임팩트가 부족해" 같은 형용사를 주고받다가 "일단 다음 빌드에서 다시 보죠"로 끝난다.
이 장면이 전투 기획의 모든 문제를 압축한다. 플레이어가 가장 직접 체감하는 분야인데, 그 체감을 말로 옮기는 순간 형용사밖에 안 남는다. 형용사는 측정할 수 없고, 측정할 수 없으면 조정할 수 없다. 전투 기획자의 첫 번째 일은 이 형용사를 숫자로 끌어내리는 것이다.
이 장은 그 숫자가 어느 칸에 들어가는지를 정한다. 전투 기획자가 만드는 산출물 다섯 가지가 각각 Layer의 어디에 앉는지, 그리고 그 좌표가 왜 자동화의 전제 조건이 되는지. 4.2·4.3·4.4의 실전 도구는 이 좌표 위에서 움직인다.
비전공자를 위한 한 줄. 이 부에서 전투 수치나 프레임 단위를 외울 필요는 없습니다. 가져가실 단 하나는 이것입니다 — "형용사로 오가는 요청은 측정도 조정도 안 된다." "좀 더 묵직하게"를 "무엇을 몇으로"로 끌어내리는 순간 협업이 굴러간다는 발상은 게임 밖 어느 직무의 모호한 피드백에도 그대로 적용됩니다. 4.1.1의 다섯 산출물은 가볍게 훑고, 이 한 가지만 손에 쥐고 넘어가셔도 됩니다.
전투 기획자가 책임지는 산출물을 한 줄로 묶으면 "플레이어의 입력이 화면 위 액션으로 변환되는 전 과정"이다. 이걸 다섯 덩어리로 쪼갠다.
첫째, 전투 Look & Feel 명세. 타격감·반응성·무게감 같은 추상을 측정 가능한 수치로 번역한 문서다. 이게 이 분야의 가장 어려운 산출물이자, 다른 네 가지를 평가하는 기준이 된다.
Look & Feel은 다시 네 신호로 분해된다.
이 명세가 없으면 회의실 장면이 반복된다. 명세가 있으면 "히트스톱 3→5프레임, 카메라 셰이크 진폭 +20%"라는 조정 지시가 나온다.
둘째, 스킬·콤보·캔슬 시스템. 입력이 액션으로 변환되는 규칙이다.
셋째, 캐릭터·몬스터 AI. NPC 행동 로직 — 행동 트리(Behavior Tree, 이하 BT), 상태 머신(FSM(Finite State Machine, 유한 상태 기계)/HFSM), 결정 테이블. 몬스터 행동 패턴, 보스 페이즈 전환, 동료 NPC 협동, 군집 시뮬레이션이 여기 들어간다.
넷째, 데미지·자원·쿨다운 수식. 플레이어의 선택이 결과로 변환되는 수학이다. 데미지 계수·방어 감소·크리티컬·속성 보정, 자원(MP/기력/스태미나) 소비·회복 곡선, 쿨다운 분포.
다섯째, 애니메이션 제어 명세. 기획 의도가 실제 빌드에서 어떻게 보이는지를 정하는 도면 — 애니메이션 그래프·BT·IK 연결. 이건 보통 프로그래머·애니메이터와의 협업이지만, 기획자가 의도의 명세를 제공하지 않으면 빌드에서 의도가 깨진다. 자재만 던지고 도면을 안 주면 다른 집이 지어진다.
여기서 핵심은 다섯 가지가 같은 책상 위에서 만난다는 점이다. 콤보 규칙(둘째)이 바뀌면 데미지 수식(넷째)의 DPS가 달라지고, 그게 다시 Look & Feel(첫째)의 체감 무게를 바꾼다. 어느 산출물이 어느 산출물의 입력인지가 명시되지 않으면 변경 한 번이 다섯 군데를 흔든다. 그래서 좌표가 필요하다.
2.3에서 잡은 L0~L4 좌표 위에 전투 산출물 다섯을 올린다. 이 매핑이 이 장의 척추다.
flowchart TD
L0["L0 · 비전
'타격감이 살아 있는 액션 전투'"]
L1["L1 · 시스템 골격
콤보·캔슬 구조 / Look&Feel 명세 / 클래스 골격"]
L2["L2 · 콘텐츠 흐름
챕터별 적 군락 곡선 / 스킬 해금 순서"]
L3["L3 · 데이터 시트
데미지 계수·쿨다운 값·자원 소비"]
L4["L4 · 빌드 측정
실측 DPS / 실제 콤보 경로 / 플레이어 피드백"]
L0 -->|"받는다"| L1
L1 -->|"골격이 흐름을 규정"| L2
L1 -->|"명세가 수치의 기준"| L3
L2 --> L3
L3 -->|"빌드에 반영"| L4
L4 -.->|"측정 → 명세 수정 피드백"| L1
L4 -.->|"이상치 → 시트 조정"| L3
classDef vision fill:#2d3748,stroke:#1a202c,color:#fff
classDef build fill:#c05621,stroke:#7b341e,color:#fff
class L0 vision
class L4 build
표로 다시 정리하면 이렇다.
| Layer | 전투 기획의 산출물 | 변경 빈도 |
|---|---|---|
| L0 | (받는다 — 비전: "타격감이 살아 있는 액션 전투") | 거의 고정 |
| L1 | 콤보·캔슬 구조 / Look & Feel 명세 / 클래스 골격 | 느림 |
| L2 | 챕터별 적 군락 진행 곡선 / 스킬 해금 흐름 | 중간 |
| L3 | 스킬 데미지 계수 시트, 쿨다운 값, 자원 소비 | 빠름 |
| L4 | 빌드 실측 DPS, 실제 콤보 가능 경로, 플레이어 피드백 | 빌드마다 |
전투 기획의 특징은 L4 비중이 다른 분야보다 크다는 점이다. 시나리오 기획은 L1 명세가 거의 곧 최종이지만, 전투는 다르다. "타격감이 좋다"는 빌드에서 직접 손으로 쳐 보고 화면을 봐야 알 수 있는 영역이다. 명세에 "히트스톱 5프레임"이라고 적어도, 그게 실제로 묵직하게 느껴지는지는 L4에서만 확인된다. 그래서 시뮬레이션과 자동 측정 도구가 이 분야에서 가장 큰 가치를 만든다(4.4).
다만 L4가 크다고 L1이 덜 중요한 건 아니다. 점선 화살표를 보라. L4의 측정값은 L1 명세로 되먹임된다. 명세가 없으면 측정값은 "비교할 기준"을 잃는다. 5프레임 명세가 있어야 "실측 4프레임, 1프레임 누락"이라는 진단이 나온다. 명세 → 빌드 → 측정 → 명세 수정의 사이클이 다섯 Layer를 모두 거친다. 전투 기획자는 이 사이클 전체에 손을 올려놓고 있는, 한 책상에서 가장 많은 Layer가 동시에 돌아가는 자리다.
추상론은 여기까지다. 이제 회의실 장면으로 돌아가 "타격감이 약하다"를 실제로 숫자로 끌어내리는 과정을 워크드 트랜스크립트로 본다. 도구는 LLM 하나다. 출력은 다듬지 않고, 사람이 거부한 부분도 그대로 남긴다.
저자가 디렉터로 운영하는 어느 MMORPG 프로젝트(이하 "프로젝트 A")의 전투 TF에서, 신규 근접 스킬 하나의 Look & Feel을 명세화한 실제 흐름을 재구성한다.
이 스킬 Look & Feel을 형용사 말고 ms·프레임 수치로 바꿔줘.
히트 타이밍, 히트스톱, 카메라 셰이크, 이펙트 동기화, 후딜 다섯 개.
확신 없는 건 "이 게임에서 검증 필요"라고 적어줘.
스킬: 대검 종베기 (heavy_overhead_slash)
의도: "묵직하게 내려찍고, 적이 휘청하는 느낌"
프레임레이트: 60fps
대검 종베기의 "묵직함"은 주로 히트스톱과 후딜의 길이, 그리고 카메라 셰이크의 감쇠로 만들어집니다. 항목별로 제시합니다.
1) 히트 타이밍. 무거운 무기일수록 입력→발동까지 선딜이 길어야 무게감이 삽니다. 입력에서 첫 시각 반응(캐릭터가 검을 들어 올리기 시작)까지 50~80ms(3~5프레임) 권장. 단, 첫 시각 반응과 첫 타격 반응은 다릅니다. 타격 적중까지는 모션 길이에 달려 있어 여기서 단정할 수 없습니다 — 이 게임의 모션 길이로 검증 필요.
2) 히트스톱. 묵직한 일격의 핵심 변수입니다. 가벼운 공격이 1~2프레임이라면, 대검 종베기는 4~6프레임(약 67~100ms) 권장. 6프레임을 넘으면 "묵직"이 "끊김"으로 넘어가 반응성이 나빠집니다.
3) 카메라 셰이크. 진폭은 화면 높이의 약 1~2%, 지속 0.2~0.3초, 감쇠는 선형보다 ease-out(빠르게 약해짐). 무거운 일격은 첫 진동이 강하고 빠르게 잦아드는 곡선이 자연스럽습니다.
4) 이펙트 동기화. VFX(타격 이펙트)·SFX(타격음)·히트스톱은 반드시 적중 프레임에 동일하게 트리거돼야 합니다. 1~2프레임만 어긋나도 "밍밍함"으로 체감됩니다. UI 반응(데미지 숫자)은 1~2프레임 늦어도 무방.
5) 후딜. 묵직함의 대가로 후딜이 깁니다. 적중 후 피격 가능 복귀까지 20~30프레임(333~500ms) 권장. 단 이 값은 게임의 전체 전투 템포(빠른 액션 vs 진중한 소울류)에 따라 크게 달라집니다 — 이 게임의 기준 템포에서 검증 필요.
종합하면 "묵직함 = 긴 선딜 + 4~6프레임 히트스톱 + ease-out 셰이크 + 동일 프레임 VFX/SFX/스톱 + 긴 후딜"입니다. 다만 위 숫자는 일반 권장 범위이며, 실제 무게감은 빌드에서 손으로 쳐 보고 조정해야 확정됩니다.
여기서 기획자는 출력을 통째로 채택하지 않는다. 두 군데에서 멈춘다.
거부 1 — 카메라 셰이크 진폭 "화면 높이 1~2%". 이 게임은 모바일 우선이다. 작은 화면에서 1~2%는 거의 안 보인다. 모바일 멀미 이슈도 있다. 기획자는 이 권장값을 거부하고 "모바일은 셰이크 대신 히트스톱 강조로 무게를 표현한다"는 자체 원칙을 적용한다. LLM은 일반론을 줬을 뿐, 이 게임의 플랫폼 제약은 모른다.
유보 2 — 히트스톱 "4~6프레임". 이건 거부가 아니라 보류다. 범위로는 맞지만 정확한 값은 빌드에서 손맛으로 정한다. 명세에는 "4프레임을 기본값으로 빌드에 넣고, 5·6프레임 변형을 만들어 셋을 손으로 비교"라고 적는다.
재요청은 이렇게 나간다.
모바일 우선 프로젝트다. 카메라 셰이크는 최소화하고, 무게감을
히트스톱·후딜·SFX로 표현하는 방향으로 명세를 다시 써라.
히트스톱은 4/5/6프레임 세 변형을 빌드 비교용으로 표로.
이 두 번째 출력에서 LLM은 모바일 제약을 반영한 명세 표를 만든다. 그 표가 빌드에 들어가고, 다음 빌드 회의에서 기획자는 형용사 대신 "4프레임 변형이 너무 가볍다, 5프레임 채택"이라고 말한다. 40분 회의가 5분 결정으로 줄어든다.
세 가지다. 첫째, LLM은 형용사를 숫자 범위로 끌어내리는 1차 초안을 잘 만든다 — 이게 회의실의 침묵을 깬다. 둘째, LLM은 이 게임의 제약(모바일·템포·모션 길이)을 모른다 — 그래서 일반 권장값을 줄 뿐이고, 거부·조정은 사람의 몫이다. 셋째, LLM 스스로 "빌드에서 손으로 쳐 봐야 확정된다"고 두 번이나 못 박았다 — 무게감의 최종 판단은 L4의 사람 손이라는 사실을 도구도 안다.
위 트랜스크립트는 한 자리(명세화)만 보여 줬다. 전투 기획 전체에서 AI가 가치를 만드는 자리는 네 곳이다.
1) 시뮬레이션 — 가장 큰 가치. DPS(Damage Per Second, 초당 데미지) 곡선·콤보 경로·자원 소비를 빌드 없이 사전 계산한다. 빌드를 만들어 손으로 측정하는 것보다 압도적으로 빠르다. 4.4에서 simulate_dps 시뮬레이터로 직접 다룬다.
2) 상태 머신·BT 자동 생성. "이 보스는 체력 50% 이하에서 광폭화하고, 광폭화 중에는 3타 연속 패턴을 쓴다" 같은 자연어 설명을 BT/FSM 다이어그램으로 변환한다. 정확도가 높다 — 규칙 구조는 LLM이 잘 다루는 영역이다. 머릿속 로직을 그림으로 옮기는 시간이 절약된다.
3) 빌드 캡처 자동 분석. 플레이 영상에서 히트 타이밍·콤보 성공률·피해 분포를 자동 추출한다. 단, 이건 구현 난이도가 가장 높은 자리다(아래에서 정직하게 따져 본다).
4) 밸런스 조정 후보 제안. 데이터 시트의 각 행을 분석해 이상치·곡선 비매끄러움을 검출하고 조정 후보를 낸다. 사람은 선택만 한다.
이 네 자리 중에서, 빌드 캡처 자동 분석(3)은 "할 수 있다"와 "쉽게 할 수 있다" 사이의 거리가 가장 멀다. 책에서 흔히 "AI가 영상에서 자동으로 다 뽑아 줍니다"라고 쓰지만, 실제로는 그렇게 간단하지 않다. 영상 픽셀 기반 컴퓨터 비전, 기성 비전 API, 게임 내 telemetry 로그 — 세 캡처 방법의 정확도·구현 부담 비교는 4.4가 정본이니 그쪽을 참고한다. 여기서는 결론만 짚는다.
가장 현실적인 길은 게임 내 telemetry 로그다. 엔진이 "프레임 1204에 skill_overhead가 적중, 데미지 340, 콤보 카운트 3" 같은 이벤트를 직접 찍게 만든다. 이건 소스 데이터라 정확하고, 로깅 코드 한 번 삽입으로 끝난다. LLM은 그 로그를 읽어 자연어 리포트("3콤보까지는 자원 효율이 좋은데 4콤보부터 급감")로 요약하는 데 쓴다. 영상은 사람이 의심스러운 케이스만 눈으로 확인하는 보조로 남긴다.
즉 "AI가 영상을 자동 분석한다"는 비전의 현실적 형태는 telemetry 로그 + LLM 요약이지, 픽셀 비전이 아니다. 이 정직한 구분이 4.4 도구 선택의 출발점이다.
그리고 네 자리 전부에서 변하지 않는 한 가지. "타격감이 좋다"의 최종 판단은 AI가 못 한다. 그건 플레이어 감정의 영역이고, 그 감정에 대한 책임은 사람이 가져간다. AI는 그 감정 판단의 근거 자료를 빠르게 만들어 줄 뿐이다. 시뮬레이션 수치, BT 다이어그램, telemetry 리포트 — 전부 사람이 손맛으로 결정을 내리기 위한 재료다.
여기까지는 "산출물을 Layer로 나누면 협업할 때 말이 통한다"는 표면적 이유였다. 콤보 규칙을 L1에, 데미지 시트를 L3에 둔 건 변경 빈도가 다르기 때문이라고 설명했다. 맞는 말이지만, 그게 전부는 아니다.
좌표를 나눈 본질적 이유는 자동화가 그 위에서만 작동하기 때문이다. Layer 분해가 절차적 생성·자동화의 전제라는 일반 논제는 2.3에서 다뤘으니, 여기서는 그 전제가 전투 분야의 자동화 세 가지에서 어떻게 갈리는지로 좁혀 본다.
첫째, 시뮬레이션은 "무엇을 입력하고 무엇을 바꿀 수 있는지" 구분돼야 돌아간다. 결정론 코어(물리·히트박스 — L1 골격)와 변경 가능한 명세(데미지 값·쿨다운 — L3 시트)가 섞여 있으면, 시뮬레이터는 "변경 후보 공간"을 정의하지 못한다. 코어는 고정, 시트는 변수 — 이 분리가 있어야 simulate_dps가 "데미지 계수를 280에서 340까지 20씩 올려 가며 DPS 곡선을 그려라" 같은 탐색을 한다.
둘째, 빌드 캡처 자동 분석은 액션 atom이 라벨링돼 있어야 의미를 가진다. 명세 단에서 "이 프레임 구간은 skill_overhead의 hit 단계"라고 라벨된 atom이 있어야, telemetry 로그에서 추출한 신호를 명세와 자동 대조할 수 있다. 라벨이 없으면 로그는 "프레임 1204에 무언가 적중"이라는 의미 없는 점들의 나열이다.
셋째, LLM 콤보 시퀀스 생성은 캔슬 규칙·입력 큐가 외부 문서로 분리돼 있어야 동작한다. "이 캐릭터의 캔슬 가능 페어 7개와 입력 큐 200ms 안에서 5콤보 시퀀스 10개를 제안해라" 같은 한정 요청은, 캔슬 규칙이 코드 속에 고정되어 있지 않고 문서로 떨어져 있을 때만 가능하다.
세 가지가 같은 한 문장을 말한다. 결정론 코어가 명세와 섞이면 자동화가 막히고, 분리되면 자동화가 열린다. Layer 분해는 협업 언어 통일이 표면 목적이고, 본질 목적은 자동 시뮬·캡처 분석·LLM 시퀀스 탐색의 전제 조건을 까는 것이다.
이 전제가 깔리면, 전투 운영은 두 단계로 진화한다.
보수적 적용 — 사람이 설계하고, 자동이 검증한다. 지금 대부분의 액션·MMORPG 전투 운영이 여기 있다. 사람이 콤보·캔슬 명세를 직접 쓰면, 자동이 DPS·자원을 시뮬하고 telemetry로 캡처해 "명세 vs 측정" 비교 리포트를 낸다. 사람은 그 차이를 해석해 명세 수정을 결정하고, 다시 명세 작성으로 사이클이 돈다. 설계는 사람, 시뮬·캡처·비교는 자동이다.
진보적 적용 — AI가 후보를 발의하고, 사람은 채택만 한다. 다음 단계다. AI가 캔슬 페어와 입력 큐 안에서 시퀀스 10~30개를 자동 열거하고, 자동이 각 시퀀스의 DPS·자원을 병렬 시뮬하고, LLM이 "자원 효율 1위, 입력 난이도 중" 식으로 순위·해석을 붙인다. 사람의 손에 남는 결정은 "후보 중 어느 시퀀스를 시그니처로 채택할까" 하나, 그리고 디렉터의 빌드 반영·모션 캡처 결정뿐이다. 시퀀스를 0에서 만드는 것과 30개 중 고르는 것은 작업 부담의 차원이 다르다.
진보적 적용이 자리 잡으려면 세 가지가 갖춰져야 한다. (1) 빌드 없이 1초 안에 DPS·자원·생존 시간을 계산하는 결정론 시뮬레이션 인프라, (2) 콤보·캔슬·입력 큐가 외부 문서로 분리·라벨링된 액션 atom, (3) telemetry 기반 캡처 자동 분석. 세 가지 모두 위에서 말한 Layer 분해의 직접적인 결과물이다.
마지막으로 가역성. 전투 기획자의 검수 사이클에는 되돌릴 수 있는 단계와 없는 단계가 섞여 있고, 그 경계를 아는 게 중요하다.
모션 캡처는 전투에서 가장 두꺼운 비가역 단계다. 캡처 스튜디오 일정, 배우 섭외, 재촬영 비용이 전부 크다. 그래서 시그니처 액션의 모션 캡처는 시뮬·캡처 자동 분석이 충분히 돌아가서 시퀀스를 확정한 다음에만 진행한다. 보수적이든 진보적이든, 모션 캡처와 라이브 빌드 직전을 결정 게이트로 둔다. 전투 기획자의 모든 검수는 이 게이트 왼쪽의 가역 단계에서 끝나야 안전하다.
프로젝트 A의 전투 TF가 위 좌표와 도구를 6개월 운영하면서 측정한 변화다. 아래 수치는 TF 운영 기록에서 뽑은 대략적 평균이며, 정밀 측정값이 아니라 체감 변화의 방향으로 읽는 게 정확하다.
| 항목 | 도입 전 | 도입 후 |
|---|---|---|
| Look & Feel 회의 시간 | 평균 2시간 (주관 토론) | 평균 30분 (측정값 기준) |
| 콤보 다이어그램 작성 | 1~2시간/스킬세트 | 10분/스킬세트 |
| DPS 곡선 검증 | 빌드 후 수동 측정 (≈1일) | 시뮬레이션 (≈10분) |
| 새 스킬 밸런스 조정 | 3~4회 빌드 사이클 | 1~2회 빌드 사이클 |
숫자 자체보다 방향이 핵심이다. 네 항목 모두 "주관 토론·수동 측정·빌드 반복"에서 "측정값·시뮬·다이어그램 자동화"로 옮겨 갔다. 회의실에서 형용사가 줄고 숫자가 늘었다. 그게 이 장 전체가 말하려는 한 문장이다 — 전투 기획자의 일은 주관(타격감·재미)에서 객관(수치·시뮬레이션)으로 옮겨 가는 다리를 놓는 일이고, AI는 그 다리를 빠르게 까는 도구다. 다리 끝에서 "묵직하다"를 결정하는 손은 여전히 사람의 것이다.
setup. LLM 하나면 됩니다. 손에 든 스킬 하나를 고르세요(신규든 기존이든). 그 스킬의 의도를 형용사 한 줄로 적습니다 — "묵직하게", "날렵하게", "둔중하게" 같은.
prompt. 아래 골격에 스킬 정보를 채워 넣으세요.
너는 전투 기획 보조다. 아래 스킬의 Look & Feel을 "측정 가능한
수치 명세"로 변환해라. 형용사가 아니라 ms·프레임·% 단위로.
확신 없는 항목은 "이 게임에서 검증 필요"라고 명시해라.
스킬: [이름]
의도: "[형용사 한 줄]"
프레임레이트: [60fps 등]
항목: 1)히트 타이밍 2)히트스톱 3)카메라 셰이크 4)이펙트 동기화 5)후딜
verify. 출력의 모든 숫자에 두 가지 질문을 던지세요. (1) 이 게임의 제약(플랫폼·템포·모션 길이)에서 이 값이 맞나요? → 안 맞으면 제약을 알려 주고 재요청하세요. (2) 이 값은 빌드에서 손으로 확정해야 하나요? → 그렇다면 단일 값 대신 2~3개 변형을 명세에 적어 빌드에서 비교하세요. LLM이 "검증 필요"라고 못 박은 항목은 절대 그대로 채택하지 마세요.
혼자 만드는 게임이라면 다섯 산출물·다섯 Layer를 다 갖출 필요는 없습니다. 최소 두 가지만 하세요. 하나, Look & Feel 명세 한 페이지 — 핵심 액션 3~5개에 대해 히트스톱·후딜·동기화만 숫자로 적습니다. 형용사로 적힌 메모는 6개월 뒤의 자신도 못 알아봅니다. 둘, 콤보·캔슬을 코드에서 분리해 한 파일로 — 캔슬 페어를 데이터로 빼 두면, 나중에 LLM에게 "이 페어로 만들 수 있는 콤보 5개 제안"을 시킬 수 있습니다. 이 두 가지가 1인 개발에서도 자동화의 문을 열어 두는 최소 좌표입니다.
회의실 모니터 앞에 다섯 명이 모여 있다. 같은 빌드, 같은 스킬, 같은 30초짜리 영상이 화면에서 세 번째 반복 재생되는 중이다. 클라이언트 프로그래머가 먼저 입을 뗀다. "저는 괜찮은데요." 아트 담당이 팔짱을 낀다. "약해요. 뭔가 빠진 느낌." 옆에 있던 기획자가 끼어든다. "이펙트는 좋은데 손에 안 붙어요." 디렉터가 한참 보다가 결정을 내린다. "음… 조금만 더 묵직하게 가죠."
그리고 회의가 끝난다. '조금만 더 묵직하게'가 정확히 몇 ms이고 몇 프레임인지는 아무도 적지 않았다. 다음 빌드에서 프로그래머는 자기가 이해한 '묵직함'을 구현하고, 아트는 자기가 이해한 '묵직함'을 얹는다. 그리고 다음 주 같은 회의실에서 같은 영상을 보며 같은 대화가 반복된다.
타격감, 손맛, Look & Feel. 전투 기획에서 가장 자주 쓰이고 가장 정의가 안 된 단어다. 모두가 안다고 믿지만 머릿속 정의가 제각각이라서, 토론이 끝나도 남는 게 없다. 이 장은 그 '느낌'을 측정 가능한 숫자로 분해하는 일을 다룬다. 손맛을 추상에서 데이터로 끌어내리는 자리다.
먼저 경계를 그어 두겠다. 전투 기획은 크게 두 갈래다.
이 장은 후자만 다룬다. 데미지가 100이든 120이든 손맛과는 직접 관계가 없다. 그 100의 데미지가 '들어가는 순간'을 플레이어가 어떻게 체감하느냐가 손맛이다. 같은 데미지 공식이라도 히트 타이밍과 히트스톱이 다르면 완전히 다른 게임처럼 느껴진다.
먼저 솔직하게 짚어 둘 게 있다. 타격감은 세 개의 숫자만으로 완성되지 않는다. 공격 모션(애니메이션)의 가속·감속 곡선, 맞은 쪽의 반응(히트 리액션·경직), 80~90년대 일본 액션 게임이 즐겨 쓴 데포르메(타격 순간 캐릭터를 과장해 늘이고 찌부러뜨리는 잔상·뭉개짐 표현)까지 다 모여야 "맞았다"는 한 덩어리의 감각이 선다. 이 장이 집중해서 측정 가능한 숫자로 끌어내리는 것은 그중 세 개의 축이다. 모션·리액션·데포르메는 애니메이터·아티스트의 손이 더 크게 닿는 영역이라 다음 장들과 아트 파트에서 다루고, 여기서는 기획자가 명세로 고정하고 빌드에서 검증할 수 있는 세 축에 무게를 둔다. 그 세 축은 이렇게 쪼개진다.
회의실에서 누군가 "약하다"고 말할 때, 그 약함은 셋 중 하나에서 온다. 반응이 늦은 건가(타이밍), 명중감이 없는 건가(히트스톱), 따로 노는 건가(동기화)? 세 축으로 분해해서 물으면 '약하다'가 비로소 고칠 수 있는 문장이 된다.
다만 '약하다'의 원인이 늘 이 세 축에만 있는 건 아니다. 손맛을 이루는 요소를 빠짐없이 늘어놓고, 이 장이 어디까지 책임지는지 선을 긋는다.
| Look & Feel 구성요소 | 무엇인가 | 이 장에서 |
|---|---|---|
| 히트 타이밍 | 입력 → 첫 반응까지의 ms. 가장 먼저 의심받는 요소 | 측정·명세(축 1) |
| 히트스톱 | 명중 순간 시간을 멈춰 무게를 주는 길이 | 측정·명세(축 2) |
| 카메라 셰이크 | 타격에 맞춰 화면이 흔들리는 반동 | 측정·명세(축 3에 포함) |
| VFX·SFX 타이밍 | 이펙트와 소리가 히트 프레임에 동기되는가 | 측정·명세(축 3에 포함) |
| 공격 모션(애니메이션) | 휘두름의 가속·감속, 예비동작과 후속동작의 곡선 | 언급(아트·애니 영역) |
| 히트 리액션·경직 | 맞은 쪽이 움찔하고 경직되는 반응 | 언급(다음 장·아트 파트) |
| 데포르메 | 타격 순간 늘이고 찌부러뜨리는 과장(잔상·뭉개짐) | 언급(아트 파트) |
| 컨트롤러 진동 | 손에 전해지는 물리 피드백 | 측정·명세(축 3에 포함) |
위쪽 넷이 이 장의 세 축으로 묶여 측정·명세의 대상이 되고, 가운데 셋(모션·리액션·데포르메)은 빠져선 안 되지만 기획자 혼자 숫자로 닫기 어려운 아트·애니 영역이라 '있다는 것'만 분명히 해 둔다. 모션이 뻣뻣하거나 맞은 쪽이 멀쩡히 서 있으면 세 축이 다 맞아도 타격감은 안 산다.
세 축 중 타이밍을 가장 먼저 다루는 데는 이유가 있다. 플레이어가 손맛을 의심할 때 제일 먼저 걸리는 게 '반응이 늦다'는 감각이고, 다른 무엇이 아무리 화려해도 입력이 굼뜨면 그 순간 다 무너지기 때문이다. 그래서 타이밍부터 잡는다.
손맛의 첫 축은 시간이다. 버튼을 누른 그 순간(0ms)부터 화면이 처음 반응하는 순간까지 몇 ms가 걸리는가. 인간은 이 지연에 놀랄 만큼 예민하다. 60ms와 120ms의 차이를 '말로 설명은 못 해도 손은 안다'.
한 번의 공격은 단순한 한 점이 아니라 시간 위에 펼쳐진 여러 사건이다. 기본 공격 1타를 시간축에 올려 보면 이렇게 생겼다.
이 그림에서 가장 중요한 숫자는 '히트박스가 처음 켜지는 100ms'다. 버튼을 누르고 100ms 뒤에 공격 판정이 시작된다는 뜻이다. 이 값이 손맛의 체감 속도를 결정한다.
권장 범위는 장르·캐릭터마다 다르지만 대략적인 기준선은 있다.
| 종류 | 권장 입력→반응 | 비고 |
|---|---|---|
| 즉시 반응 (가벼운 공격) | 60~120ms | 손에 '붙는' 느낌의 핵심 구간 |
| 무거운 반응 (대형 스킬) | 200~400ms | 묵직함을 위한 의도된 선딜 |
| 차징 (긴 충전) | 500~2000ms | 의도된 대기, 별개 처리 |
이 범위는 절대 기준이 아니다. 저자 추정(미검증)으로, 캐주얼 모바일은 입력 관대 쪽으로 ±50ms 흔들리고 격투 콘솔은 더 엄격하게 죄는 경향이 있다. 숫자 자체보다 '우리 게임의 가벼운 공격은 90ms로 합의했다'는 기준선을 팀이 공유하는 것이 핵심이다. 기준선이 있어야 빌드를 보고 '맞다/틀리다'를 말할 수 있다.
그런데 여기에 함정이 하나 있다. 사람 눈으로는 90ms와 110ms를 구분 못 한다. 60fps에서 1프레임은 약 16.67ms이고, 이 20ms 차이는 한 프레임 남짓이다. 회의실에서 "조금 느린 것 같은데?"라는 말이 맞는지 틀리는지, 눈으로는 끝내 판정이 안 난다. 그래서 측정이 필요하다.
명세에 '히트박스 100ms'라고 적었다. 빌드에서 실제로 100ms에 켜지는지 어떻게 확인하는가. 자동화의 길은 셋(영상 분석·기성 vision 도구·게임 내 telemetry)으로 갈리는데, 세 방식의 정밀도·난이도 비교는 4.4에서 정본으로 다룬다. 여기서는 결론만 짚는다. 실무에서 가장 먼저 깔아야 하는 건 게임 내 telemetry다. 이유는 단순하다. VFX가 화면에 '나타난 프레임'을 영상에서 추론하는 것보다, 코드가 OnHit 이벤트를 발생시킨 프레임에 직접 [HITLOG] 한 줄을 찍는 것이 비교가 안 되게 정확하고 싸다. 영상 분석은 입력 오버레이가 없는 외부 영상(예: 경쟁작 분석)에나 쓰고, 우리 빌드는 telemetry부터 깐다.
telemetry 로그는 이렇게 생긴다.
[HITLOG] frame=6 t_ms=100 evt=hitbox_on skill=1001 char=warrior
[HITLOG] frame=6 t_ms=100 evt=vfx_trigger skill=1001
[HITLOG] frame=6 t_ms=100 evt=sfx_trigger skill=1001
[HITLOG] frame=7 t_ms=117 evt=damage_apply skill=1001 dmg=124
[HITLOG] frame=7 t_ms=117 evt=ui_dmgnum skill=1001
[HITLOG] frame=6 t_ms=100 evt=cam_shake skill=1001 amp=0.4
기획자가 할 일은 이 로그를 명세와 한 줄씩 맞춰 보는 것이다. 숫자 환산과 기계적 대조가 대부분이라, 사람이 눈으로 반복하면 지치고 틀리지만 LLM은 지치지 않는다. 다음 절에서 실제로 시켜 보겠다.
명세 yaml과 빌드 telemetry 로그를 둘 다 붙여 넣고, AI에게 둘을 대조해 어긋난 곳을 찾으라고 시켰다. 아래는 프롬프트 전문과 Claude의 날것 출력, 그리고 사람의 검증 과정 전체다. 요약하지 않고 그대로 옮긴다.
다음은 전투 스킬 한 개의 '히트 타이밍 명세'와, 실제 빌드에서 뽑은
telemetry 로그다. 명세 대비 빌드가 어긋난 항목을 표로 정리해줘.
[명세]
character: warrior
skill_id: 1001
fps: 60
events:
hitbox_on: {expect_ms: 100, tol_frame: 0}
vfx_trigger: {expect_ms: 100, tol_frame: 0}
sfx_trigger: {expect_ms: 100, tol_frame: 1}
damage_apply: {expect_ms: 110, tol_frame: 1}
ui_dmgnum: {expect_ms: 100, tol_frame: 2}
cam_shake: {expect_ms: 100, tol_frame: 1}
[telemetry 로그]
[HITLOG] frame=6 t_ms=100 evt=hitbox_on skill=1001
[HITLOG] frame=6 t_ms=100 evt=vfx_trigger skill=1001
[HITLOG] frame=8 t_ms=133 evt=sfx_trigger skill=1001
[HITLOG] frame=7 t_ms=117 evt=damage_apply skill=1001
[HITLOG] frame=9 t_ms=150 evt=ui_dmgnum skill=1001
[HITLOG] frame=6 t_ms=100 evt=cam_shake skill=1001
60fps(1프레임=16.67ms). expect_ms를 프레임으로 환산해 로그 frame과
비교하고, 차이가 tol_frame을 넘으면 FAIL로 표시해줘.
먼저 명세의 expect_ms를 프레임으로 환산하겠습니다. 60fps에서 frame = round(ms / 16.67) 입니다.
- hitbox_on: 100 / 16.67 ≈ 6.0 → frame 6
- vfx_trigger: 100 → frame 6
- sfx_trigger: 100 → frame 6
- damage_apply: 110 / 16.67 ≈ 6.6 → frame 7 (반올림)
- ui_dmgnum: 100 → frame 6
- cam_shake: 100 → frame 6
로그의 실제 frame과 비교한 결과입니다.
이벤트 기대 frame 실제 frame 차이(frame) 허용 판정 hitbox_on 6 6 0 0 PASS vfx_trigger 6 6 0 0 PASS sfx_trigger 6 8 +2 1 FAIL damage_apply 7 7 0 1 PASS ui_dmgnum 6 9 +3 2 FAIL cam_shake 6 6 0 1 PASS 두 항목이 허용 오차를 초과했습니다. - sfx_trigger: 사운드가 시각 히트보다 2프레임(약 33ms) 늦습니다. 허용 1프레임 초과. 플레이어는 "소리가 살짝 따로 논다"고 느낄 가능성이 높습니다. - ui_dmgnum: 데미지 숫자가 3프레임(약 50ms) 늦게 뜹니다. 허용 2프레임 초과. "맞췄는데 숫자가 늦게 뜬다"는 어색함으로 이어질 수 있습니다.
damage_apply가 frame 7인 것은 명세상 frame 7(110ms 환산)과 일치하므로 정상입니다. 다만 damage_apply가 hitbox_on(frame 6)보다 1프레임 뒤라는 점은 의도된 설계인지 확인이 필요합니다.
출력을 받고 곧바로 다 믿지 않았다. 검증 포인트가 둘 있었다.
검증 1 (반올림 경계 — 통과). damage_apply의 110ms 환산이 의심스러웠다. 110 / 16.67 = 6.598… 이다. 반올림하면 7이 맞다. 그런데 만약 명세 의도가 '6프레임 = 100ms와 같은 프레임'이었다면? 직접 확인해 보니 명세에 damage_apply: expect_ms 110으로 vfx(100)와 의도적으로 10ms 차이를 둔 게 맞았다. 그래서 frame 7 환산은 정확했다. AI 통과.
검증 2 (마지막 문장 — 거부). AI가 끝에 덧붙인 "damage_apply가 hitbox_on보다 1프레임 뒤라는 점은 의도된 설계인지 확인이 필요합니다"는 노이즈였다. 명세에 이미 expect_ms 110으로 의도가 명시되어 있는데, AI가 그걸 '확인 필요'로 되물은 것이다. 명세를 줬으면 명세 안에서 답을 닫아야 하는데 불필요한 질문을 만들어 냈다. 이 문장은 채택하지 않았다.
거부 사유를 담아 재요청했다.
damage_apply는 명세에 110으로 의도가 박혀 있으니 '확인 필요' 코멘트는 빼줘.
// 명세 안에서 답이 닫힌 항목을 되묻지 말라는 뜻
FAIL 두 건(sfx, ui_dmgnum)만 남기고, 원인 단정 대신
'확인할 코드/리소스 위치' 체크리스트로 줘.
FAIL 두 건에 대한 확인 체크리스트입니다. 원인은 단정하지 않고, 확인 지점만 정리했습니다.
sfx_trigger (+2프레임 지연) - [ ] 사운드 트리거가
hitbox_on과 같은 이벤트에 바인딩돼 있는가, 아니면 별도 애니메이션 노티파이에 걸려 있는가 - [ ] 오디오 에셋 자체의 선두 무음(leading silence)이 있는가 — 파일 앞단 빈 구간이 지연으로 보일 수 있음 - [ ] 사운드 재생 호출이 같은 프레임에 들어갔는데 출력 레이턴시로 밀린 건지 (플랫폼 오디오 버퍼)ui_dmgnum (+3프레임 지연) - [ ] 데미지 숫자 UI가
damage_apply(frame 7) 이후 별도 틱에서 갱신되는 구조인가 - [ ] UI 갱신이 다음 프레임 끝에 몰아서 처리되는지 (지연 렌더 큐) - [ ] 숫자 팝업 애니메이션의 시작 프레임에 추가 딜레이 값이 들어가 있는가단정할 수 있는 것: 두 항목 모두 명세 허용 오차를 초과했고, 시각 히트(frame 6)보다 늦습니다. 단정할 수 없는 것: 위 원인 중 무엇인지는 코드/에셋 확인 전까지 알 수 없습니다.
이 두 번째 출력은 그대로 채택했다. 원인을 지어내지 않고 '확인할 곳'으로 바꾼 게 정확히 원하던 형태였다. 이 체크리스트를 사운드 담당과 UI 담당에게 그대로 넘겼고, 사운드는 오디오 에셋 선두 무음 33ms가 범인이었다(체크리스트 2번 항목). UI는 다음 프레임 갱신 구조였다(1번 항목).
여기서 분업의 선이 분명해진다. AI는 명세와 로그를 기계적으로 대조해 FAIL을 잡고, 사람은 (a) AI가 만들어 낸 불필요한 되물음을 거부하고 (b) FAIL의 진짜 원인을 코드에서 확정한다. AI에게 원인 단정을 시키면 그럴듯한 거짓말을 만들어 내므로, '확인할 곳'까지만 시키는 게 안전하다.
두 번째 축은 멈춤이다. 히트가 명중한 순간 게임 시간을 아주 짧게 멈추거나 느리게 만드는 효과. 이게 "맞췄다"는 감각의 강도를 결정한다. 격투 게임과 액션 RPG에서 가장 강력한 손맛 도구다. 너무 길면 답답하고, 너무 짧으면 무게가 없다.
권장 범위(60fps 기준)는 다음과 같다. 이 수치는 액션 게임에서 통용되는 대략적 관행이며, 절대값은 게임마다 조정한다.
| 종류 | 권장 프레임 | 환산 |
|---|---|---|
| 가벼운 히트 | 1~2 프레임 | 16~33ms |
| 중간 히트 | 3~5 프레임 | 50~83ms |
| 무거운 히트 (필살기) | 6~12 프레임 | 100~200ms |
| 치명타·약점 적중 | 위 값 + 2~3 프레임 | — |
캐릭터·스킬마다 다르게 줘야 한다. 전부 똑같이 주면 무게 차이가 안 나고, 결국 모든 공격이 같은 톤으로 수렴한다. 이게 4.2 핵심 메시지 셋 중 하나로 이어지는 지점이다.
'누가 멈추는가'도 설계 선택이다.
| 옵션 | 효과 | 적합 |
|---|---|---|
| 공격자만 멈춤 | 공격자 쪽 무게감, 피격자는 반동·다운 진행 | 액션 |
| 피격자만 멈춤 | 피격자 일시 정지, 공격자는 자유 이동 | 콤보 친화 |
| 둘 다 멈춤 | 가장 강한 무게감 | 격투 게임 전통 |
명세는 이렇게 입력한다.
character: warrior
skill_id: 1001
hit_stop:
attacker: 2 # frames
victim: 4
critical_multiplier: 1.5 # 크리티컬 시 1.5배 (반올림)
히트스톱은 명세 숫자만으로는 '맞다/틀리다'를 끝낼 수 없는, 손맛 검증이 가장 까다로운 축이다. telemetry로 '실제로 4프레임 멈췄는지'는 잡을 수 있지만, '4프레임이 적절한지'는 사람이 빌드를 직접 만지며 판정해야 한다. AI는 명세 일치를 보증하고, 사람은 그 명세값 자체의 적절성을 본다.
세 번째 축은 동시성이다. VFX(시각 이펙트)·SFX(소리)·UI(데미지 숫자)·카메라(셰이크)·진동(컨트롤러)이 같은 프레임에 시작되면 플레이어 뇌는 그걸 '한 사건'으로 묶는다. 1~2프레임만 어긋나도 "어색하다", 3~5프레임 어긋나면 "버그 같다"는 반응이 나온다. 앞의 워크드 트랜스크립트에서 sfx가 2프레임, ui가 3프레임 늦어 FAIL이 났던 게 정확히 이 축의 문제였다.
동기화 대상 5종과 허용 오차다.
| 요소 | 트리거 시점 | 허용 오차 |
|---|---|---|
| VFX (시각 이펙트) | 히트 프레임 | ±0 (반드시 동시) |
| SFX (사운드) | 히트 프레임 | ±1 프레임 (16ms) |
| UI 데미지 숫자 | 히트 프레임 | ±2 프레임 |
| 카메라 셰이크 | 히트 프레임 | ±1 프레임 |
| 컨트롤러 진동 | 히트 프레임 | ±2 프레임 |
핵심은 이 5종이 모두 명세에 들어가야 한다는 것이다. 흔한 실수가 VFX만 명세에 적고 나머지 넷을 '알아서 맞추겠지'로 두는 것이다. 명세에 없으면 빌드 검증의 기준도 없고, telemetry로 잡아도 비교할 대상이 없다. 5종이 다 명세에 있어야 자동 비교가 닫힌다.
자동 비교는 앞 절의 워크드 트랜스크립트가 그대로 적용된다. telemetry 로그에 5종의 트리거 프레임이 다 찍혀 있으면, AI가 명세와 대조해 오차 초과 항목만 리포트한다. 사람이 100개 스킬을 매 빌드마다 눈으로 확인할 필요가 없다. 다만 AI가 명세 어긋남을 잡는 것과, 사람이 "명세는 다 맞는데 그래도 감이 안 사는" 영역을 잡는 것은 별개로 남는다.
지금까지의 조각들을 하나의 흐름으로 잇는다. 이 루프가 돌기 시작하면 회의실의 '조금만 더 묵직하게'가 '히트스톱 victim 4→6프레임'으로 번역된다.
flowchart TD
A["명세 작성
(기획자: yaml)"] --> B["빌드 구현
(프로그래머·아티스트)"]
B --> C["빌드 실행 + telemetry 로그
[HITLOG] 자동 출력"]
C --> D["AI 자동 대조
명세 yaml vs telemetry"]
D --> E{"오차 초과
FAIL 있나?"}
E -->|있음| F["FAIL 항목 + 확인 체크리스트
(AI, 원인 단정 금지)"]
F --> G["담당자가 코드·에셋에서
진짜 원인 확정 (사람)"]
G --> B
E -->|없음| H["기획자: '감' 검토
명세는 맞지만 손맛 나는가 (사람)"]
H -->|조정 필요| A
H -->|OK| I["다음 마일스톤 회고 입력으로 보관"]
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
class C code;
class D,F ai;
class A,G,H human;
class I pass;
이 루프에서 AI가 맡는 칸(D, F)과 사람이 맡는 칸(A, G, H)이 명확히 갈린다. AI는 기계적 대조와 체크리스트 생성에 강하고, 사람은 기준선 설정·원인 확정·최종 감각 판정에 강하다. 자동화가 사람을 빼는 게 아니라, 사람이 매번 반나절씩 쓰던 '눈으로 프레임 세기'에서 풀려나 '감'에만 집중하게 만든다.
루프의 마지막 칸(I)이 중요하다. 이 측정 데이터는 한 번 쓰고 버리는 게 아니라 다음 마일스톤 회고의 입력으로 다시 들어간다. '지난 분기 손맛 FAIL은 sfx 동기화에 몰려 있었다' 같은 패턴이 데이터로 남으면, 다음 분기엔 오디오 파이프라인을 먼저 손본다.
저자가 디렉터로 참여한 어느 모바일 MMORPG 프로젝트(이하 '프로젝트 A')에서, 위 루프를 약 6개월 굴리며 관찰한 변화다. 아래 수치는 정밀 계측이 아니라 회의록 타임스탬프와 빌드 검증 기록을 바탕으로 한 저자의 운영 관찰(추정 포함)이며, 방향과 비율로 읽어 주기 바란다. 절대값을 인용 가능한 벤치마크로 쓰지 말 것.
| 항목 | 도입 전 | 도입 후 | 성격 |
|---|---|---|---|
| Look & Feel 회의 1건 소요 | 길게 늘어짐 | 절반 이하로 단축 | 회의록 기준, 체감 |
| 빌드 검증 (스킬 다수) | 거의 하루 | 크게 단축 | telemetry 자동 대조 효과 |
| "타격감 약함" 피드백 해소 | 여러 빌드 사이클 | 1~2 사이클 | 표본 적음, 방향만 |
| 명세 vs 빌드 일치율 | 절반 남짓 | 대부분 일치 | telemetry 도입 후 측정 가능해짐 |
숫자 자체보다 정성적 변화가 본질이다. 회의실에서 "약한데요"가 나오면 곧바로 "어느 축이요? 타이밍? 히트스톱? 동기화?"로 되묻게 됐고, 답이 안 나오면 telemetry를 함께 띄웠다. 측정 가능한 객관이 토론의 종착지를 만들었다는 것이 6개월의 가장 큰 변화다. '조금 더 묵직하게'가 회의실 밖으로 나가는 일이 줄었다.
| 실수 | 회피 |
|---|---|
| 명세 없이 빌드부터 검증 | 명세 우선. 기준 없으면 '맞다/틀리다'가 불가능 |
| 영상 분석부터 깔려고 함 | 우리 빌드는 telemetry부터. 영상 분석은 외부 영상에만 |
| AI에게 FAIL 원인을 단정시킴 | '확인할 곳' 체크리스트까지만. 원인은 사람이 코드에서 확정 |
| VFX만 명세, 나머지 4종 누락 | 5종(VFX·SFX·UI·카메라·진동) 모두 명세에 |
| 한 캐릭터 명세를 전 캐릭터 복사 | 캐릭터·스킬별 차별화. 같으면 손맛이 한 톤으로 수렴 |
| 히트스톱을 모든 히트에 남발 | 의미 있는 히트에만. 남발하면 답답함 |
| AI 출력의 되물음을 그대로 수용 | 명세 안에서 닫힌 항목의 '확인 필요' 코멘트는 거부 |
자기 프로젝트에 이 루프를 최소 규모로 깔아 보는 절차입니다.
setup
1. 검증할 스킬 1개를 고릅니다(기본 공격 추천).
2. 코드의 트리거 지점 6곳(hitbox_on, vfx, sfx, damage_apply, ui_dmgnum, cam_shake)에 로그 한 줄씩 심습니다: [HITLOG] frame=X t_ms=Y evt=... skill=....
3. 명세 yaml을 씁니다(events 블록에 expect_ms + tol_frame). 이 장의 1차 프롬프트 예시를 템플릿으로 쓰세요.
prompt 4. 빌드를 한 번 돌려 telemetry 로그를 모읍니다. 5. 명세 yaml + telemetry 로그를 함께 AI에 붙이고 이렇게 시킵니다: "명세 대비 빌드가 어긋난 항목을 60fps 프레임 환산으로 대조해 FAIL만 표로 줘. 원인은 단정하지 말고 '확인할 곳' 체크리스트로." (이 장 2차 프롬프트 형태)
verify 6. AI 출력의 프레임 환산을 직접 한 칸 검산하세요(ms / 16.67 반올림). 한 칸이라도 틀리면 전체를 의심하세요. 7. AI가 명세 안에서 닫힌 항목을 되묻거나 원인을 단정하면 거부하고 재요청하세요. 8. FAIL 체크리스트를 담당자에게 넘겨 코드·에셋에서 진짜 원인을 확정하세요.
팀도 telemetry 인프라도 없는 1인 개발자라면 이렇게 줄이세요. 빌드를 60fps로 화면 녹화하고, 입력 순간이 보이도록 키 입력 오버레이를 켭니다. 검증할 스킬 한 번을 녹화한 뒤, 영상 편집기에서 '버튼 누른 프레임'과 '화면이 처음 변한 프레임'을 직접 셉니다. 두 프레임 번호와 명세 기대값을 AI에 주고 "60fps 기준 ms로 환산해서 명세와 비교해줘"라고 시키면, telemetry 없이도 핵심 한 축(히트 타이밍)은 검증됩니다. 5종 동기화까지는 어렵지만, '입력→반응' 한 축만 잡아도 손맛 토론의 절반은 객관으로 옮겨 옵니다.
다음 장은 한 번의 히트에서 히트들의 연속으로 넘어간다. 콤보·캔슬·입력 큐 — 한 히트가 다음 히트로 자연스럽게 이어지게 만드는 룰들을 다룬다.
전투 디자이너 팀원 B가 회의실 화이트보드 앞에 서서 마커로 박스를 그리고 있었다. 기본1, 기본2, 기본3, 그리고 옆으로 빠지는 강공격 분기. 화살표가 일곱 개쯤 늘어났을 때, 누군가 물었다. "그럼 강공격 띄우기 다음에 회피로 캔슬하면 다시 기본1로 돌아올 수 있어요?" 팀원 B는 마커를 멈췄다. 화이트보드 위 그래프에는 그 경로가 그려져 있지 않았다. 그릴 수 있는데 그리지 않은 건지, 룰상 불가능한 건지, 본인도 즉답하지 못했다.
이게 콤보 설계의 진짜 문제다. 콤보는 머릿속에서는 "1-2-3 이어지고 강공격으로 분기"처럼 단순한 줄기로 보인다. 그런데 캔슬과 입력 큐가 끼면, 줄기는 그래프가 된다. 노드 여섯 개에 캔슬 엣지 몇 개만 추가해도 실제로 밟을 수 있는 경로는 수십 갈래로 늘어난다. 사람은 그 수십 갈래를 머릿속에서 전부 펼치지 못한다. 그래서 "이 경로가 너무 세다"는 밸런스 사고가 빌드에 들어간 뒤에야 발견된다.
이 장의 목표는 하나다. 콤보 경로를 손으로 그리지 않고 자동으로 전부 열거하고, 각 경로를 검증하는 워크플로를 만드는 것. 자연어로 적은 룰을 명세로 바꾸고, 명세에서 경로를 열거하고, 열거된 경로를 시뮬레이션에 태운다. 그 과정에서 AI가 어디까지 해 주고 어디서 거짓말을 하는지, 날것 그대로 보여 주겠다.
콤보를 표로 적으면 이렇게 된다. "기본1 다음에 기본2, 기본2 다음에 기본3." 행과 열로 깔끔하다. 그런데 이 표는 거짓말을 한다. 표는 직선을 가정하기 때문이다. 실제 전투에서 플레이어는 기본2에서 강공격으로 빠지고, 강공격을 회피로 캔슬하고, 회피 직후 다시 기본1을 누른다. 이 분기와 순환은 표의 행 사이에 숨어 버린다.
그래서 콤보의 진짜 모양은 방향 그래프다. 액션은 노드, 연결은 엣지. 각 엣지에는 입력 윈도우(언제 입력을 받는지)와 입력 키가 붙는다. 노드에는 지속 프레임이, 일부 노드에는 보너스 조건(특정 노드를 거쳐야 데미지 배수가 붙는다)이 붙는다.
전사 캐릭터의 기본 콤보 한 세트를 그래프로 그리면 다음과 같다. 여섯 노드, 캔슬 분기 포함.
화이트보드와 결정적으로 다른 점이 두 개 있다. 첫째, 각 엣지에 입력 윈도우 프레임 범위가 명시되어 있다. "강공격 6~24f"는 기본2가 시작한 뒤 6프레임째부터 24프레임째까지 강공격 입력을 받는다는 뜻이다. 둘째, 점선으로 그린 회피→기본1 재진입 엣지가 있다. 팀원 B가 회의실에서 즉답하지 못했던 그 경로다. 그래프로 명시하면 "있다/없다"가 분명해진다.
이 그래프를 사람이 손으로 그리면 노드 여섯에 엣지 일고여덟. 캐릭터가 스무 명이고 캐릭터마다 콤보 세트가 서너 개면 그래프는 수백 장이 된다. 손으로는 못 따라간다. 그래서 그래프를 텍스트 명세로 적어 두고, 그림과 검증을 거기서 자동 생성한다.
위 그래프를 YAML 명세로 옮긴다. 핵심은 노드(nodes), 엣지(edges), 보너스(bonuses) 세 블록이다. 캔슬 룰도 엣지의 한 종류로 본다 — 끊고 다른 노드로 가는 것도 결국 엣지이기 때문이다.
# warrior_basic_chain.yaml
character: warrior
combo_id: basic_chain
nodes:
- { id: basic_1, name: 기본1, duration_frames: 21 }
- { id: basic_2, name: 기본2, duration_frames: 24 }
- { id: basic_3, name: 기본3, duration_frames: 30 }
- { id: heavy, name: 강공격, duration_frames: 33 }
- { id: launch, name: 띄우기, duration_frames: 28 }
- { id: dodge, name: 회피, duration_frames: 18, cancels_recovery: true }
edges:
- { from: basic_1, to: basic_2, input: light, window: [10, 21] }
- { from: basic_2, to: basic_3, input: light, window: [12, 24] }
- { from: basic_2, to: heavy, input: heavy, window: [6, 24] }
- { from: heavy, to: launch, input: heavy, window: [10, 33] }
- { from: heavy, to: dodge, input: dodge, window: [0, 33], type: cancel }
- { from: basic_3, to: dodge, input: dodge, window: [0, 30], type: cancel }
- { from: dodge, to: basic_1, input: light, window: [8, 18] } # 재진입
bonuses:
- { on: basic_3, requires_path: [basic_1, basic_2], damage_multiplier: 1.5 }
이 명세는 두 독자를 동시에 만족시킨다. 사람은 window: [6, 24]를 읽고 "강공격은 기본2 중반부터 받는구나"를 이해하고, 기계는 같은 줄을 파싱해서 그래프 그림과 경로 열거에 쓴다. 한 소스에서 사람의 이해와 기계의 검증이 동시에 나온다.
위 프레임 수치(21, 24, [6, 24])는 실측이 아니라 이 장의 설명을 위해 저자가 구성한 예시 값(미검증)이다. 실제 프로젝트에서는 이 값들이 애니메이터가 만든 몽타주 길이와 빌드의 노티파이 타이밍에서 나온다. 명세를 처음 적을 때는 디자이너의 의도값을 넣고, 빌드가 나온 뒤 캡처해서 실측값으로 교정한다 — 이 교정 루프는 4.4에서 다룬다.
팀원 B가 화이트보드에 그렸던 룰을 자연어로 주고, 명세 YAML로 변환시킨다. 요약하지 않고 프롬프트 전문과 Claude의 날것 출력, 사람의 검증/거부를 그대로 옮긴다.
다음은 전사 캐릭터의 콤보 룰이다. 이걸 콤보 명세 YAML로 변환해라.
규칙:
- 기본 공격은 1, 2, 3타로 이어진다. 가벼운 공격 키.
- 기본2 도중에 강공격 키를 누르면 강공격으로 빠질 수 있다.
- 강공격 다음에 강공격을 한 번 더 누르면 적을 띄운다(띄우기).
- 강공격은 언제든 회피로 캔슬 가능.
- 기본3도 회피로 캔슬 가능.
- 회피 직후에는 가벼운 공격으로 기본1을 다시 시작할 수 있다.
- 기본3은 기본1과 기본2를 모두 거쳤을 때만 데미지가 1.5배.
명세 형식:
- nodes(id, name, duration_frames), edges(from, to, input, window, type),
bonuses(on, requires_path, damage_multiplier)
- 캔슬 엣지는 type: cancel로 표시.
- 프레임 값은 내가 안 준 건 너가 정하지 말고 TODO로 비워 둬라.
마지막 줄이 핵심이다. 프레임을 안 줬으면 지어내지 말라고 명시했다. v3 표준 3번(수치 정직)을 프롬프트 레벨에서 강제하는 것이다.
명세로 변환했습니다. 프레임 값은 제공되지 않아 TODO로 남겼습니다.
```yaml
character: warrior
combo_id: basic_chain
nodes:
- { id: basic_1, name: 기본1, duration_frames: TODO }
- { id: basic_2, name: 기본2, duration_frames: TODO }
- { id: basic_3, name: 기본3, duration_frames: TODO }
- { id: heavy, name: 강공격, duration_frames: TODO }
- { id: launch, name: 띄우기, duration_frames: TODO }
- { id: dodge, name: 회피, duration_frames: TODO }
edges:
- { from: basic_1, to: basic_2, input: light, window: TODO }
- { from: basic_2, to: basic_3, input: light, window: TODO }
- { from: basic_2, to: heavy, input: heavy, window: TODO }
- { from: heavy, to: launch, input: heavy, window: TODO }
- { from: heavy, to: dodge, input: dodge, window: TODO, type: cancel }
- { from: basic_3, to: dodge, input: dodge, window: TODO, type: cancel }
- { from: dodge, to: basic_1, input: light, window: TODO }
bonuses:
- { on: basic_3, requires_path: [basic_1, basic_2], damage_multiplier: 1.5 }
```
확인이 필요한 부분:
1. "회피 직후 기본1 재시작"을 dodge -> basic_1 엣지로 넣었는데, 이게
회피의 후딜 중에 입력받는 건지 회피가 완전히 끝난 뒤인지 불명확합니다.
2. 띄우기(launch) 다음에 이어지는 공중 콤보가 있는지 규칙에 없습니다.
띄우기가 콤보의 끝인지 확인이 필요합니다.
3. 회피를 노드로 둘지 엣지(캔슬 동작)로만 둘지 모호합니다. 일단 노드로
뒀습니다 — 회피 자체가 지속 시간을 갖는 액션이라고 봤습니다.
여기서 평가하자. 좋은 점: 프레임을 지어내지 않고 전부 TODO로 비웠다. 프롬프트의 제약을 지켰다. 그리고 끝에 단 세 가지 질문이 전부 진짜로 모호한 지점이다 — 특히 1번(재진입 입력 타이밍)은 팀원 B가 회의실에서 즉답 못 했던 바로 그 구멍이다. AI가 사람보다 먼저 구멍을 찾았다.
애매한 점: 3번에서 회피를 노드로 둔 건 판단이 갈린다. 회피는 "캔슬 동작"이면서 동시에 "지속 시간을 갖는 액션"이라 양쪽 다 맞다. AI가 한쪽을 골라 놓고 모호하다고 보고한 것은 정직하지만, 이건 설계 결정이라 사람이 정해 줘야 한다.
세 질문에 답하고 일부를 거부한다.
window: [8, 18].cancels_recovery: true 속성을 추가해서 "후딜을 끊는 동작"임을 명시. → 부분 채택 + 속성 추가.그리고 한 가지를 거부한다. AI가 dodge → basic_1 엣지에 type: cancel을 안 붙였는데, 이건 회피의 후딜을 끊고 진입하는 거라 캔슬 성격이 맞다. 하지만 여기서는 "회피 후 정상 진입"으로 보고 일반 엣지로 둔다 — 후딜 캔슬과 정상 연결의 구분은 이 캐릭터에선 게임 느낌상 차이가 없기 때문이다. 사람이 도메인 판단으로 AI 분류를 덮어쓴 사례다.
좋다. 다음을 반영해 최종 명세를 다시 내라:
- dodge에 cancels_recovery: true 추가.
- dodge -> basic_1 엣지의 window는 [8, 18].
- 나머지 프레임은 여전히 내가 안 줬으니 TODO 유지. 단 위 그래프 예시값
(basic_1=21, basic_2=24, basic_3=30, heavy=33, launch=28, dodge=18)을
쓸 거니까 그 값으로 채워라. 이건 미검증 예시값이라고 주석으로 박아라.
이 재요청으로 나온 결과가 4.3.2의 YAML이다. 한 번에 완성하지 않았다. 프롬프트 → 날것 → 검증/거부 → 재요청. 이 사이클이 명세의 신뢰도를 만든다. AI가 모호한 곳을 표시하고 사람이 도메인 지식으로 결정한다 — 둘 중 하나만으로는 안 된다.
명세가 그래프이므로, 콤보 경로 열거는 그래프 탐색 문제가 된다. 시작 노드에서 출발해 끝 노드(또는 피니셔)까지 가는 모든 경로를 찾는 깊이 우선 탐색(DFS). 사람은 이걸 머릿속으로 못 하지만 코드는 한순간에 한다.
저자 팀의 격리 작업공간 95_BattleTF 안에는 이 열거를 담당하는 작은 스크립트가 있다. 명세 YAML을 읽어 모든 경로를 뽑고, 각 경로가 룰상 가능한지(엣지가 존재하는지) 검증한다. 핵심 로직만 보이면 이렇다.
# 95_BattleTF/enumerate_paths.py (발췌)
import yaml
def load_graph(path):
spec = yaml.safe_load(open(path, encoding="utf-8"))
adj = {}
for e in spec["edges"]:
adj.setdefault(e["from"], []).append(e)
return spec, adj
def enumerate_paths(adj, start, max_depth=8):
results = []
def dfs(node, path, edges):
# 끝 노드(나가는 엣지 없음)거나 깊이 한계면 경로 확정
outs = adj.get(node, [])
if not outs or len(path) >= max_depth:
results.append((list(path), list(edges)))
return
for e in outs:
if e["to"] in path: # 순환 방지: 한 경로에 같은 노드 1회
results.append((list(path), list(edges)))
continue
dfs(e["to"], path + [e["to"]], edges + [e])
dfs(start, [start], [])
return results
basic_1에서 시작해 돌리면, 손으로는 절대 다 못 펼치는 경로들이 쏟아진다. 일부만 보면 다음과 같다.
| # | 경로 | 비고 |
|---|---|---|
| 1 | 기본1 → 기본2 → 기본3 | 정석 3타, 피니셔 보너스 충족 |
| 2 | 기본1 → 기본2 → 강공격 → 띄우기 | 분기 콤보 |
| 3 | 기본1 → 기본2 → 강공격 → 회피 → 기본1 → … | 순환 진입 |
| 4 | 기본1 → 기본2 → 기본3 → 회피 → 기본1 → … | 피니셔 후 리셋 |
3번과 4번이 중요하다. 회피 재진입 엣지 때문에 콤보가 순환한다. 사람이 화이트보드에서 못 봤던 게 바로 이런 순환 경로다. DFS에 순환 방지(같은 노드 한 경로 1회) 가드를 넣지 않으면 열거가 무한 루프에 빠진다 — 이건 코드를 처음 돌렸을 때 실제로 한 번 멈춰 서서 알게 된 함정이다. 그래프에 순환이 있으면 열거기에 반드시 가드가 필요하다.
열거 단계의 산출물은 두 가지다. 첫째, 룰상 가능한 모든 경로 목록. 둘째, 룰 모순 검출 — 명세에 dodge → basic_1 엣지가 있는데 정작 dodge 노드 정의가 빠져 있으면, 열거기가 "정의되지 않은 노드를 가리키는 엣지"로 잡아낸다. 명세를 손으로 적을 때 가장 흔한 실수가 이 댕글링 참조다.
경로 목록만으로는 "어느 경로가 너무 센가"를 모른다. 각 경로를 DPS 시뮬레이터에 넣어야 한다. 저자 팀의 simulate_dps가 이 역할을 한다 — 경로(노드 시퀀스)와 각 노드의 데미지·프레임, 보너스 룰을 받아 총 데미지와 총 소요 프레임을 계산하고 초당 데미지(DPS)를 낸다.
# 95_BattleTF/simulate_dps.py (발췌, 60fps 가정)
def simulate(path_nodes, node_dmg, node_frames, bonuses):
total_dmg = 0
total_frames = 0
visited = []
for nid in path_nodes:
dmg = node_dmg.get(nid, 0)
# 보너스: requires_path를 모두 거쳤으면 배수 적용
for b in bonuses:
if b["on"] == nid and all(r in visited for r in b["requires_path"]):
dmg *= b["damage_multiplier"]
total_dmg += dmg
total_frames += node_frames[nid]
visited.append(nid)
seconds = total_frames / 60.0
return {"dmg": total_dmg, "frames": total_frames,
"dps": round(total_dmg / seconds, 1) if seconds else 0}
4.3.4의 열거 결과를 여기에 통째로 흘려보내면, 각 경로의 DPS가 표로 떨어진다. 아래는 노드 데미지를 예시값(기본 타격 100, 강공격 180, 띄우기 140 — 모두 미검증 가공값)으로 넣고 돌린 결과다.
| 경로 | 총 데미지 | 총 프레임 | DPS |
|---|---|---|---|
| 기본1→기본2→기본3 (피니셔 ×1.5) | 100+100+150 = 350 | 75 | 280.0 |
| 기본1→기본2→강공격→띄우기 | 100+100+180+140 = 520 | 106 | 294.3 |
| 기본1→기본2→기본3→회피→기본1 | 350+0+100 = 450 | 144 | 187.5 |
이 표가 토론을 바꾼다. "강공격 분기가 정석 3타보다 세 보이는데?"라는 직관이, "강공격 경로 DPS 294 vs 정석 280, 5% 우위"라는 숫자로 바뀐다. 5% 우위가 의도된 거면 통과, 아니면 강공격 프레임을 늘려 DPS를 떨어뜨린다. 빌드가 나오기 전에, 명세 단계에서 이 판단을 한다.
전체 워크플로를 한 장으로 보면 이렇다.
flowchart LR
A["자연어 룰
(teammate_b 화이트보드)"] --> B["프롬프트 → AI"]
B --> C{"날것 명세
TODO·질문 포함"}
C -->|사람 검증/거부| D["확정 명세 YAML
warrior_basic_chain.yaml"]
D --> E["enumerate_paths.py
모든 경로 DFS 열거"]
E --> F["룰 모순 검출
댕글링 엣지·무한순환"]
E --> G["simulate_dps.py
경로별 DPS 계산"]
G --> H["밸런스 판단
경로 간 DPS 격차"]
F --> D
H -->|프레임 조정| D
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
class E,F,G code;
class B,C ai;
class H human;
class A,D data;
명세(D)가 중심에 있고, 열거(E)·검증(F)·시뮬(G)이 거기서 갈라져 나온다. 모순이 잡히거나 밸런스가 어긋나면 명세로 돌아가 고친다. 화이트보드에는 이 루프가 없었다 — 그래서 화이트보드의 콤보는 빌드에 들어간 뒤에야 틀린 걸 알았다.
지금까지 콤보 그래프와 경로 열거를 봤다. 캔슬과 입력 큐는 이 그래프를 조정하는 두 개의 손잡이다. 방향이 정반대다.
캔슬은 엣지를 늘린다. 캔슬 룰 하나를 추가할 때마다 그래프에 엣지가 붙고, 열거되는 경로 수가 곱으로 불어난다. 그래서 캔슬은 "관대할수록 좋다"가 아니다. 캔슬을 풀수록 경로가 폭증하고, 그중 의도하지 않은 강한 경로(앞 절의 순환 경로 같은)가 섞일 확률이 올라간다. 격투 게임 전통이 캔슬을 엄격하게 두는 이유, 액션 RPG가 관대하게 두는 이유가 여기 있다 — 장르가 "허용할 경로 수"를 정한다. 절대적인 정답 윈도우 값은 없다.
캔슬을 다룰 때 반드시 분리해서 명시한다. 통합 "아무거나 캔슬"로 두면 열거기가 모든 노드 간 캔슬 엣지를 만들어 경로가 통제 불능으로 늘어난다.
| 캔슬 유형 | 명세 표현 | 경로에 미치는 효과 |
|---|---|---|
| 액션 캔슬 | 특정 노드 → 특정 노드, type: cancel | 선택적 분기만 추가 |
| 회피 캔슬 | 다수 노드 → dodge, window: [0, dur] | 거의 모든 노드에서 탈출구 |
| 방어 캔슬 | 다수 노드 → guard | 방어 진입, 보통 후딜 한정 |
| 무캔슬 | 나가는 cancel 엣지 없음 | 발동 후 끝까지 (슈퍼아머) |
입력 큐는 경로를 좁히지 않고 실제로 밟을 수 있게 만든다. 큐가 없으면 플레이어는 각 엣지의 입력 윈도우(예: [12, 24])를 프레임 단위로 정확히 맞춰야 한다. 사람 반응으로는 거의 불가능하다. 큐는 윈도우 이전에 누른 입력을 버퍼에 저장했다가, 윈도우가 열리는 순간 자동 발동한다. 즉 큐는 그래프의 경로를 바꾸지 않고, 그래프 위를 사람이 걸을 수 있게 신발을 신겨 준다.
input_queue:
window_start_ratio: 0.5 # 액션 진행률 50%부터 다음 입력을 버퍼링
expire_frames: 10 # 버퍼된 입력의 유효 기간
priority: latest # 동시 다중 입력 시 마지막 우선
세 파라미터의 균형이 핵심이다. window_start_ratio가 너무 작으면 액션 초반 입력까지 버퍼링되어 의도 안 한 다음 동작이 튀어나온다. expire_frames가 너무 짧으면 큐의 의미가 없어져 정확함을 다시 요구하고, 너무 길면 한참 전에 누른 입력이 뒤늦게 발동해 "왜 갑자기 움직이지" 사고가 난다. 권장 출발값은 expire_frames 5~15, window_start_ratio 0.5 안팎이지만 — 이건 장르와 캐릭터 무게감에 따라 조정할 출발선이지 정답이 아니다.
운영상 한 가지. 입력 큐 파라미터를 캐릭터마다 전부 다르게 두지 말 것. 글로벌 기본값 하나를 두고, 무게감이 특별히 다른 캐릭터(거대 보스형 등)만 override 한다. 캐릭터 스무 명의 큐 값을 따로 관리하면 어느 게 의도된 차이고 어느 게 실수인지 구분이 안 된다.
마지막으로 짚어 둘 것이 하나 더 있다. 지금까지 엣지를 "있다/없다"로만 다뤘는데, 엣지가 있다고 해도 그 노드에서 다음 노드로 실제로 어떻게 넘어가는가는 또 다른 결정이다. 콤보 간 연결 방식에는 핵심 분기가 셋 있고, 이 책의 깊이 밖이라 코드로 다루진 않지만 미언급하면 명세가 절반만 그린 셈이 된다.
window: [10, 21])가 바로 이 노티파이의 표현이다. 노티파이를 앞당기면 콤보가 빨라지고(타격이 끝나기 전에 다음으로), 늦추면 한 타 한 타가 묵직해진다. 즉 윈도우 시작값은 단순한 숫자가 아니라 "이 타격을 끝까지 보여 줄 것인가, 다음으로 빨리 넘길 것인가"라는 감각 결정이다.이 셋은 같은 엣지를 두고도 게임 손맛을 정반대로 만든다. 명세 단계에서는 엣지의 존재와 윈도우만 정하고, 연결 방식(블렌딩 vs 프레임 스킵)은 빌드에서 애니메이터와 함께 정하는 게 보통이다. 다만 명세에 transition: blend / transition: skip 같은 필드 한 줄을 미리 비워 두면, 빌드 단계에서 "이 엣지는 어떻게 넘기기로 했더라"를 다시 묻지 않아도 된다. 연결 방식은 콤보 그래프의 숨은 세 번째 축이다.
여기까지의 모든 검증은 명세 위에서 일어났다. 경로 열거도, DPS 시뮬도, 모순 검출도 전부 YAML을 대상으로 했다. 그런데 명세의 프레임 값은 디자이너 의도값이지 빌드 실측값이 아니다. 애니메이터가 만든 몽타주의 실제 길이, 빌드의 노티파이가 실제로 터지는 프레임, 입력 큐가 엔진에서 실제로 작동하는 윈도우 — 이건 빌드를 캡처해서 측정해야 한다.
빌드 영상에서 5신호(타격 발생 프레임, 후딜, 캔슬 윈도우, 입력 큐, 히트스톱)를 자동 추출하는 건 구현 난이도가 높고, 현실적으로 가장 신뢰할 만한 건 게임 내 텔레메트리다 — 빌드 안에 "이 액션이 이 프레임에 이 입력을 받았다"는 로그를 찍게 하고, 그 로그를 명세와 대조한다(캡처 방법 비교는 4.4 참고). 이 대조 루프가 4.4의 주제다. 명세상 [12, 24]였던 윈도우가 빌드에서 [14, 26]으로 측정되면, 명세를 빌드 실측으로 교정하는 것이다.
| 실수 | 왜 위험한가 | 회피법 |
|---|---|---|
| 콤보를 직선 표로 적음 | 분기·순환이 행 사이에 숨어 누락 | 그래프(노드+엣지)로 명세, 손으로 안 그림 |
| 캔슬을 "아무거나"로 통합 | 열거 경로가 폭증, 강한 경로 섞임 | 액션·회피·방어 캔슬 분리 명시 |
| 열거기에 순환 가드 없음 | 회피 재진입에서 무한 루프 | 한 경로당 노드 1회 가드 |
| 명세 프레임을 실측이라 착각 | 의도값과 빌드값이 다름 | 의도값 표시, 빌드 캡처로 교정(4.4) |
| 입력 큐를 캐릭터별로 따로 | 의도된 차이/실수 구분 불가 | 글로벌 기본 + 일부만 override |
| AI가 채운 프레임을 그대로 믿음 | 지어낸 수치가 명세에 입력됨 | "안 준 값은 TODO" 프롬프트로 강제 |
손으로 따라 돌릴 수 있는 최소 절차입니다. 파이썬과 pyyaml만 있으면 됩니다.
setup. 작업 폴더 하나를 만들고 그 안에 명세 파일과 스크립트 두 개를 두세요.
combo-mini/
warrior_basic_chain.yaml # 4.3.2의 명세
enumerate_paths.py # 4.3.4의 DFS 열거기
simulate_dps.py # 4.3.5의 시뮬레이터
pip install pyyaml 후, 명세 파일에는 4.3.2의 YAML을 그대로 붙여 넣으세요.
prompt. 자연어 룰을 명세로 바꾸는 단계는 AI에게 맡기세요. 4.3.3의 프롬프트를 그대로 쓰되, 마지막 제약을 반드시 포함하세요.
프레임 값은 내가 안 준 건 너가 정하지 말고 TODO로 비워 둬라.
캔슬 엣지는 type: cancel로 표시하고, 모호한 부분은 질문으로 따로 빼라.
이 두 줄이 AI의 수치 날조와 임의 판단을 막습니다. 나온 명세에서 TODO와 질문 목록을 사람이 채웁니다.
verify. 명세가 완성되면 두 번 검증하세요.
python enumerate_paths.py warrior_basic_chain.yaml # 모든 경로 + 모순 출력
python simulate_dps.py warrior_basic_chain.yaml # 경로별 DPS 표
열거 출력에서 (1) 순환 경로가 무한히 늘어나지 않는지, (2) 정의 안 된 노드를 가리키는 댕글링 엣지가 없는지 확인하세요. DPS 출력에서 경로 간 격차가 의도 범위 안인지 보세요. 격차가 크면 명세의 프레임/데미지를 고치고 다시 돌리세요.
도구를 따로 만들 시간이 없다면, 명세 작성과 경로 열거를 한 번의 AI 대화로 끝내세요. 자연어 룰을 주고 "콤보 명세 YAML로 바꾼 뒤, 시작 노드부터 가능한 모든 경로를 깊이 우선으로 전부 나열하고, 순환은 한 번만 돌게 끊어라. 정의 안 된 노드를 가리키는 엣지가 있으면 표시하라"고 한 프롬프트에 담으세요. AI가 명세화·열거·모순 검출을 한 번에 해 줍니다. DPS 시뮬은 노드 데미지를 표로 같이 주면 "각 경로 총 데미지와 프레임을 계산해 표로 달라"는 후속 요청으로 받으세요. 정밀도는 떨어지지만, 화이트보드보다는 훨씬 멀리 봅니다. 핵심은 변하지 않습니다 — 콤보는 머릿속에서 펼치지 말고 열거시켜서 보세요.
전투 TF 빌드 #234가 막 올라왔다. 새 스킬 skill_thunder를 처음 만지는 빌드다. 명세서에는 히트 타이밍 150ms라고 적혀 있다. 입력을 넣는다. 손끝의 감각이 말한다. 늦다. 분명히 늦다. 옆자리 팀원 A를 부른다. "이거 좀 떠 보이지 않아요?" 팀원 A가 두어 번 친다. "음… 좀 그런 것 같기도 하고." 둘 다 확신이 없다. 명세는 150이라는데 손은 200쯤이라고 우긴다. 누가 맞는가. 손끝 대 종이의 싸움. 다음 빌드에서도 또 누군가 "체감상 괜찮은데요"라고 말할 것이고, 그 한마디에 빌드 하나가 또 흘러간다.
이 장의 목표는 그 싸움을 끝내는 것이다. 손끝이 200이라고 하면, 진짜 200인지 숫자로 보여 주는 것. 그리고 빌드가 올라오기 전에, 명세만 보고도 "이 스킬은 DPS가 목표보다 30% 높다"를 미리 아는 것. 200명이 매달린 AAA MMORPG의 전투를 초기부터 잡던 시절에도 감각과 숫자가 어긋날 때의 막막함은 똑같았다. 달라진 건, 이제는 그 어긋남을 숫자로 매듭지을 도구가 손에 있다는 것뿐이다.
4.2·4.3이 전투의 명세를 어떻게 적는지를 다뤘다면, 4.4은 그 명세가 의도대로 작동하는지를 다룬다. 검증에는 두 축이 있다. 하나는 빌드 없이 계산만으로 검증하는 시뮬레이션, 다른 하나는 실제 빌드 영상에서 측정값을 뽑아내는 캡처 분석이다. 두 축이 한 사이클로 묶이면 전투 기획의 회수가 일 단위에서 시간 단위로 줄어든다.
결론을 먼저 밝히면, 이 장의 핵심은 명세에 적힌 숫자(150ms)와 빌드에서 측정한 숫자(220ms)를 나란히 놓고 그 차이를 읽는 것이다(4.4.5). 앞쪽 절들(시뮬레이터·콤보 열거)은 그 대조를 가능하게 만드는 준비 단계로 읽으면 된다.
전투 기획자가 새 스킬 하나를 검증하려면 무슨 일이 일어나는가.
기획자가 명세를 쓴다. 프로그래머가 데이터를 넣고, 아티스트가 모션·이펙트를 붙이고, 빌드가 돌고, QA가 한 바퀴 돌고, 그제서야 기획자가 손으로 만져 본다. 빠르면 이틀, 보통 사나흘. 사이클 끝에서 "DPS가 너무 높다"가 발견되면, 그 발견은 처음으로 되돌아가라는 명령이 된다. 사나흘이 한 번 더.
시뮬레이션은 이 사이클의 첫 단계에서 답을 주는 도구다. 명세만으로 계산해 본다. 답이 나쁘면 명세를 고치고 다시 계산. 빌드라는 비싼 단계에 진입하기 전에 명세 자체가 한 번 걸러진다. 모형 자동차를 풍동에 먼저 넣어 보는 것과 같다. 진짜 차를 도로에 올리기 전에, 의심스러운 설계는 책상 위에서 탈락한다.
물론 풍동이 도로를 100% 예측하지는 않는다. 그래서 두 번째 축인 캡처 분석이 필요하다. 시뮬레이션이 이상적인 답이라면, 캡처는 실제로 빌드에서 벌어진 답이다. 둘을 나란히 놓고 차이를 읽는 것 — 그게 이 장의 전부다.
추상적인 의사 코드로는 아무것도 검증되지 않는다. 그래서 처음부터 돌아가는 코드를 만든다. 아래는 저자가 전투 TF에서 쓰는 simulate_dps.py의 핵심 골격을, 회사 데이터를 걷어내고 책에 싣기 위해 재구성한 것이다. 의존성 없이 Python 표준 라이브러리만으로 돈다(전체 파일은 「따라하기」 참고).
입력은 단순하다. 스킬 하나는 damage·cast_sec(시전 점유 시간)·cooldown_sec·resource_cost를, 캐릭터는 자원 총량·초당 회복량·스킬 목록·우선순위 로테이션을 가진 dataclass다. 그 위에서 도는 본체는 "매 순간 쓸 수 있는 스킬 중 우선순위가 가장 높은 것을 쓴다"는 단순한 욕심쟁이(greedy) 규칙뿐이다. 실제 플레이어보다 똑똑하지도 멍청하지도 않은, 이상적 상한을 잡는 것이 목적이다. 척추가 되는 부분만 발췌하면 이렇다.
# 0.05초 틱으로 타임라인을 만든다. 시전 중이 아니면 우선순위 순으로 첫 사용 가능 스킬을 쓴다.
while t < duration_sec:
resource = min(char.max_resource, resource + char.resource_regen * tick)
for name in cooldowns:
cooldowns[name] = max(0.0, cooldowns[name] - tick)
if t >= busy_until: # 시전 모션이 안 끝났으면 대기
for name in char.rotation: # 우선순위 순
s = skill_by_name[name]
if cooldowns[name] <= 0 and resource >= s.resource_cost:
total_damage += s.damage
resource -= s.resource_cost
cooldowns[name] = s.cooldown_sec
busy_until = t + s.cast_sec # 이 시각까지 다음 스킬 불가
break
t += tick
# …(dataclass 정의·warrior 입력·출력 루프는 「따라하기」 전체 코드 참고)
warrior에 skill_thunder(데미지 420·시전 0.9s·쿨다운 6s)를 1순위로, skill_dash·basic_1을 뒤에 두고 20초를 돌린 결과(python simulate_dps.py):
평균 DPS: 261.0
t= 0.0s skill_thunder 자원=60
t= 0.9s skill_dash 자원=47
t= 1.3s basic_1 자원=50
t= 1.6s basic_1 자원=53
t= 1.9s basic_1 자원=55
...
이 값에 무슨 의미가 있는가. 빌드 없이, 1초 안에, warrior의 이상적 DPS 상한이 약 261이라는 사실을 안다는 것이다. 목표 DPS가 180이었다면 이 명세는 +45%로 과하다. 빌드를 기다릴 필요 없이 지금 damage나 cooldown_sec을 만지면 된다.
한계도 정직하게 적는다. 이 시뮬레이터는 플레이어의 입력 실수, 이동·회피로 인한 공백, 적의 방해를 반영하지 않는다. 그래서 측정값은 항상 실제 빌드보다 높게 나온다. 이건 버그가 아니라 상한선이라는 시뮬레이터의 정의 그 자체다. 실측과의 격차는 4.4.5에서 캡처로 메운다.
AI 활용 노트. 위 골격은 저자가 짠 것이지만, 새 자원 모델(예: 분노 게이지가 데미지를 받으면 차오르는 구조)을 붙일 때는 Claude에게 "이 simulate_dps에 피격 시 분노 +5 규칙을 추가해 줘. tick 루프 안에서, 기존 자원 회복과 별개 변수로"처럼 기존 코드를 인용하며 요청한다. 백지에서 시뮬레이터를 통째로 생성하라고 하면 검증 불가능한 코드가 나온다. 척추는 사람이 잡고, AI는 가지를 친다.
DPS 한 숫자만으로는 부족하다. "어느 콤보가 의도된 메인 콤보인가"를 검증하려면 가능한 모든 경로를 펼쳐 봐야 한다. 손으로 트리를 그리면 노드 7~8개에서 이미 머리가 터진다. 경로를 빠짐없이 펼치는 건 기계가 사람보다 압도적으로 잘한다 — 단, 사람이 결과를 되짚어 볼 수 있는 형태로 뽑게 시켜야 한다.
다음은 콤보 그래프를 받아 모든 경로를 열거하고 DPS로 정렬하는 코드다. combo_graph는 "어떤 액션 다음에 어떤 액션으로 캔슬할 수 있는가"를 인접 리스트로 적은 것으로, 4.3의 상태 머신 명세에서 그대로 추출된다. 핵심은 막다른 끝까지 재귀로 펼치는 all_paths 제너레이터다.
# enumerate_combos.py — 콤보 그래프의 모든 경로를 펼쳐 DPS로 정렬
combo_graph = {"start": ["A"], "A": ["B", "D"], "B": ["C", "E"], "D": ["C"], "C": [], "E": []}
action_stats = { # (데미지, 소요 시간 초)
"A": (300, 0.8), "B": (450, 1.0), "C": (450, 1.2), "D": (600, 1.4), "E": (200, 0.6),
}
def all_paths(node="start", path=None):
path = (path or [])
nexts = combo_graph.get(node, [])
if not nexts: # 막다른 끝 = 완성된 콤보
yield [n for n in path if n in action_stats]
return
for nxt in nexts:
yield from all_paths(nxt, path + [nxt])
results = []
for p in all_paths():
dmg = sum(action_stats[a][0] for a in p)
dur = sum(action_stats[a][1] for a in p)
results.append((p, dmg, round(dur, 1), round(dmg / dur, 1)))
for p, dmg, dur, dps in sorted(results, key=lambda r: -r[3]):
print(f"{' → '.join(p):<18} {dmg:>5} dmg {dur:>4}s DPS {dps}")
실행 결과:
A → D → C 1350 3.4s DPS 397.1
A → B → C 1200 3.0s DPS 400.0
A → B → E 950 2.4s DPS 395.8
여기서 기획자가 읽어야 할 신호는 단순한 1등이 아니다. 세 경로의 DPS가 396~400으로 거의 붙어 있다는 것 — 이건 "어느 콤보를 써도 효율이 비슷해서 메인 콤보의 정체성이 없다"는 신호다. 의도가 "A→D→C가 고위험 고수익 메인이어야 한다"였다면, D의 데미지를 올리거나 시간을 줄여 DPS를 한 단계 띄워야 한다. 명세로 돌아갈 차례다.
이 자동 열거가 손 계산을 대체하는 자리에서, 콤보 노드가 20개로 늘어도 사람은 정렬된 표만 읽으면 된다.
이제 두 번째 축. 빌드에서 실제로 무슨 일이 벌어졌는지 측정하는 단계다. 흔히 "빌드 영상을 AI가 보고 자동 분석"을 떠올리지만, 여기서는 정직하게 갈래를 나눈다. 측정값을 얻는 길은 세 가지이고, 셋의 비용·정확도가 크게 다르다.
A안(영상 픽셀 분석)은 매력적으로 들린다. 입력 표시, 캐릭터 모션 변화, 이펙트 첫 프레임, 사운드 파형, 데미지 숫자 UI — 다섯 신호를 화면에서 자동 추출한다는 그림이다. 하지만 실제로 만들어 보면 프레임 압축 노이즈, UI 가림, 모션 블러 때문에 ±1~2프레임 오차가 기본으로 깔린다. 60fps에서 1프레임은 약 16.7ms다. 히트 타이밍을 ms 단위로 따지는 검증에서 ±33ms 노이즈는 치명적이다. 구현 난이도는 매우 높고, 정확도는 그 노력에 못 미친다.
그래서 현실의 답은 C안, 게임 내 telemetry 로그다. 엔진은 이미 입력 시각, 애니메이션 노티파이 발생 시각, VFX 스폰 시각, 데미지 적용 시각을 내부적으로 정확히 알고 있다. 그 시각을 픽셀에서 추론할 게 아니라, 한 줄 로그로 찍게 만들면 된다. 픽셀에서 100ms를 복원하는 대신, 엔진이 아는 100ms를 그대로 받아 적는다.
// 전투 액션 처리 코드에 한 줄 추가 (UE C++ 의사 예시)
// 입력 수신 / 데미지 적용 시점에 같은 로거를 호출
CombatTelemetry::Log("input", SkillName, GetWorld()->GetTimeSeconds());
CombatTelemetry::Log("hit", SkillName, GetWorld()->GetTimeSeconds());
로거는 한 줄씩 JSON Lines로 떨군다.
{"event":"input","skill":"skill_thunder","t":12.340}
{"event":"hit", "skill":"skill_thunder","t":12.560}
{"event":"input","skill":"basic_3","t":14.100}
{"event":"hit", "skill":"basic_3","t":14.166}
input과 hit의 시각 차이가 곧 측정 히트 타이밍이다. 12.560 − 12.340 = 0.220초 = 220ms. 픽셀 분석의 ±33ms가 아니라, 엔진 시각 그대로의 값이다. 이 로그를 떠 와서 명세와 대조하는 것이 다음 절이다.
이제 두 축을 한자리에 모은다. 명세는 150ms를 약속했고, telemetry는 220ms를 측정했다. +70ms. 손끝이 옳았다. 이 격차의 원인을 좁히는 과정을 AI와 함께 끝까지 따라간다. 요약하지 않고, AI가 처음에 헛다리를 짚는 장면까지 그대로 싣는다.
명세는 히트 타이밍 150ms인데 측정은 220ms다. 아래 로그를 붙여, 사람이 실제로 칠 법한 길이로 묻는다. 장황한 격식보다 의도가 분명한 짧은 요청이 낫다 — 검증 방법까지 적게 하는 한 줄과 단정 금지 한 줄이 이 프롬프트의 척추다.
skill_thunder 히트 타이밍이 명세 150ms인데 측정 220ms로 +70ms 초과한다. 아래 telemetry로 가능한 원인을 나누고 각각 어떤 로그로 확정/배제하는지까지 적어라. 단정 말고 확실치 않으면 '미확인'으로. // "검증 방법까지"·"단정 금지" 두 가지가 핵심 의도. 나머지 격식은 생략해도 결과가 같다.
{"event":"input","skill":"skill_thunder","t":12.340} {"event":"anim_start","skill":"skill_thunder","clip":"cast_thunder","t":12.340} {"event":"anim_notify","skill":"skill_thunder","notify":"HitWindow","t":12.560} {"event":"hit","skill":"skill_thunder","t":12.560}명세상 cast_thunder의 HitWindow 노티파이는 0.150s 지점.
+70ms 초과의 원인으로 다음 네 가지를 추정합니다.
- 네트워크 레이턴시. 입력이 서버를 왕복하면서 70ms가 추가됐을 수 있습니다. 멀티플레이 빌드라면 RTT를 확인하세요.
- 프레임레이트 저하. 빌드가 30fps로 돌면 1프레임이 33ms라, 노티파이 타이밍이 프레임 경계로 반올림되며 지연이 누적됩니다.
- 캐스팅 모션 길이 변경. cast_thunder 클립이 명세보다 길어져 HitWindow 노티파이가 뒤로 밀렸을 수 있습니다.
- 입력 버퍼 지연. 입력 큐가 한 틱 늦게 처리됐을 수 있습니다.
가장 가능성 높은 원인은 1번 네트워크 레이턴시입니다. 70ms는 전형적인 RTT 범위입니다.
여기서 멈추고 로그를 다시 본다. AI의 1순위 추정(네트워크)은 이 로그로 이미 배제된다. input과 anim_start가 정확히 같은 시각 12.340에 찍혔다. 입력이 들어온 순간 모션이 즉시 시작됐다는 뜻이고, 네트워크 왕복이 끼어들 틈이 없다. 1번은 틀렸다.
2번(프레임레이트)도 약하다. 30fps라면 33ms 단위의 들쭉날쭉이 보여야 하는데, input→hit가 정확히 0.220으로 깔끔하다. 이건 프레임 경계 반올림이 아니라 클립 내 고정 위치의 냄새다.
결정적인 단서는 따로 있다. anim_notify의 시각이 anim_start 기준 +0.220이다. 명세는 HitWindow가 클립 시작 후 0.150에 있어야 한다는데, 실제 클립에서는 0.220 지점에 고정되어 있다. 즉 클립 자체가 명세와 다르게 만들어졌거나, 노티파이 위치가 0.150에서 0.220으로 옮겨졌다. 원인은 네트워크도 프레임레이트도 아니라 3번, 모션/노티파이 데이터다. AI는 정답 후보를 4개 안에 포함시켰지만, 순위를 거꾸로 매겼다.
배제 근거를 사람이 손으로 채워 다시 던진다. 길게 설명할 필요 없이, 배제한 것과 남은 사실만 짧게 못 박는다.
네트워크·프레임레이트 배제. (근거: input=anim_start 동일 시각이라 입력→모션 지연 없음 / input→hit가 0.220로 프레임 노이즈 없이 일정.) 남은 사실: anim_notify가 anim_start +0.220, 명세는 +0.150. 이 사실만으로 원인 좁히고, 프로그래머·애니메이터에게 뭘 요청할지 행동 단위로. // 핵심은 "행동 단위로" — 추정이 아니라 누구에게 무엇을 요청하는지로 끝나게 한다.
제시된 근거로 원인은 애니메이션 클립 cast_thunder의 HitWindow 노티파이가 0.150s가 아니라 0.220s에 배치된 것으로 좁혀집니다. 두 갈래가 남습니다.
- (가) 노티파이는 0.150에 맞지만 클립 전체가 느려졌다 — 재생 속도(play rate)가 명세보다 낮거나 클립 길이가 늘었다면, 0.150 지점이 실시간 0.220으로 늘어집니다.
- (나) 노티파이 위치 자체가 0.220으로 옮겨졌다 — 클립 길이는 그대로인데 마커만 이동.
두 갈래를 가르는 확인: 1. 애니메이터에게: cast_thunder의 HitWindow 노티파이 프레임 번호와 클립 총 프레임 수, 그리고 명세 작성 시점의 값과 비교한 변경 이력을 요청. 마커가 옮겨졌으면 (나). 2. 프로그래머에게: skill_thunder의 montage play rate가 1.0인지, 캐릭터 공격속도 스탯이 이 클립에 곱해지는지 확인. play rate가 0.68(=0.150/0.220)이면 (가). 3. anim_start→anim_notify 간격을 다른 캐릭터 공격속도에서 한 번 더 측정. 공격속도에 따라 간격이 변하면 (가), 불변이면 (나).
권장 조치: (나)로 확인되면 노티파이를 0.150으로 되돌리거나 명세를 0.220으로 갱신(둘 중 의도된 쪽). (가)로 확인되면 play rate 적용 로직을 점검.
이번 출력은 채택할 만하다. 추정을 단정하지 않고 (가)/(나) 두 갈래로 갈랐으며, 각 갈래를 데이터로 가르는 방법과 누구에게 무엇을 요청할지가 행동 단위로 적혀 있다. 특히 3번 확인(공격속도를 바꿔 재측정)은 사람이 놓치기 쉬운 결정적 분기다. 이 리포트를 그대로 회의에 들고 가면, 회의는 "원인이 뭘까"를 토론하는 자리가 아니라 "(가)냐 (나)냐를 30분 안에 확정하고 조치를 고르는" 자리가 된다.
이 워크드 트랜스크립트가 보여 주는 핵심은, AI가 처음부터 정답을 주지 않는다는 사실이다. 1차 출력은 네트워크를 1순위로 짚는 헛다리였다. 로그를 읽고 후보를 배제하는 사람의 검증이 끼었을 때 비로소 분석이 정답으로 수렴했다. AI는 후보를 넓게 펼치고, 사람은 좁힌다. 이 분업이 4.4 전체의 방법론이다.
시뮬레이션(4.4.2~4.4.3)과 캡처 분석(4.4.4~4.4.5)이 따로 돌면 절반의 가치다. 하나로 묶일 때 다음 루프가 만들어진다.
flowchart TD
A["명세 작성
(기획자, 4.2·4.3 양식)"] --> B["시뮬레이션
simulate_dps · 콤보 열거
(자동, 1초)"]
B -->|"DPS·콤보 이상"| A
B -->|"명세 통과"| C["빌드
(프로그래머·아티스트, 수일)"]
C --> D["telemetry 로그 수집
(input·hit·anim_notify)"]
D --> E["명세 vs 측정 자동 대조
(150ms vs 220ms 식 차이 검출)"]
E -->|"임계값 초과 항목"| F["AI 원인 추정 리포트
(후보 펼침 → 사람이 좁힘)"]
F --> G["기획자 검토 → 조치 결정
(명세 갱신 vs 빌드 수정)"]
G --> A
E -->|"전 항목 일치"| H["다음 스킬로"]
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
class B,E code;
class F ai;
class A,G human;
class D data;
class H pass;
시뮬레이션이 빌드 전에 답을 주므로, 빌드에 들어가는 명세는 이미 한 번 걸러진 것이다. 그래서 빌드 후에 발견되는 문제는 "명세가 틀렸다"가 아니라 "명세와 구현이 어긋났다"로 성격이 좁혀진다. 4.4.5의 +70ms가 바로 그 후자였다 — 명세 150은 합리적이었고, 구현이 220으로 어긋났을 뿐이다. 이 구분이 회의에서 책임 소재 다툼을 없앤다.
다만 이 루프가 모든 전투 콘텐츠를 덮지는 않는다. 메인 보스의 시그니처 연출처럼 느낌이 곧 콘텐츠인 영역은 DPS 숫자로 환원되지 않는다. 시뮬레이션은 수치 기반 콘텐츠에 강하고, 연출은 여전히 사람의 눈으로 보는 영상 검수가 답이다. 루프는 수치의 강에서 돌고, 연출의 강은 따로 흐른다.
저자가 운영한 어느 MMORPG(refgame 계열 조작감을 목표로 한 프로젝트 A) 전투 TF의 6개월 측정이다. 아래 수치는 TF 내부 기록에서 추린 실측치이며, 빌드 수·시간은 사이클 단위로 반올림한 값임을 밝힌다(정확한 분 단위가 아니라 사이클·반나절 단위의 측정).
| 항목 | 도입 전 | 도입 후 |
|---|---|---|
| 새 스킬 검증 사이클 | 평균 3~4 빌드 | 평균 1~2 빌드 |
| 100개 스킬 빌드 검증 | 반나절(수동) | 30분(telemetry 자동 대조) |
| 밸런스 회의 | 2시간(주관 토론) | 30분(데이터 기반) |
| 빌드 직전 발견 결함 | 평균 5~8건/빌드 | 평균 1~2건/빌드 |
숫자보다 중요한 변화는 회의의 성격이다. 도입 전 회의의 절반은 "이 스킬 너무 세다" 대 "아니다 적당하다"였다. 손끝 대 손끝의 싸움. 도입 후 그 자리는 "측정 DPS가 목표 +12%이고 측정 히트 타이밍이 명세 +70ms다. 모션을 단축할까, 데미지를 10% 내릴까"로 옮겨갔다. 무엇이 문제인지를 다투던 시간이 어떻게 고칠지를 정하는 시간으로 바뀐 것이다.
이 전환의 비용도 정직하게 적는다. telemetry 로거를 전투 코드 전역에 심는 초기 작업이 약 1~2주, 명세 스키마를 캐릭터·스킬 전부에 통일시키는 데 추가로 분기 하나가 들었다. 첫 분기에는 시뮬레이션과 telemetry 둘 중 하나만 돌아가도 충분하다고 봤다. 둘이 한 루프로 맞물린 건 두 번째 분기부터였다.
다섯 가지가 반복된다.
첫째, 시뮬레이션 값을 절대 신뢰한다. simulate_dps의 261은 상한이지 실측이 아니다. telemetry와 비교하지 않으면 항상 과대평가한다.
둘째, 측정을 안 한다. 명세만 시뮬레이션하고 빌드를 telemetry로 안 보면 4.4.5 같은 +70ms가 조용히 누적된다. telemetry 로깅은 옵션이 아니라 전투 코드의 기본 설비다.
셋째, 리포트를 회의에 안 가져온다. 자동 리포트가 떠 있어도 회의 어젠다에 없으면 아무도 안 본다. "이번 빌드 telemetry 대조표"를 어젠다 고정 항목으로 넣는다.
넷째, AI 추정을 검증 없이 채택한다. 4.4.5에서 봤듯 AI의 1차 추정은 헛다리였다. AI는 후보를 넓히는 도구이지 결론을 내리는 도구가 아니다. 로그로 후보를 배제하는 사람의 한 단계를 절대 건너뛰지 않는다.
다섯째, 스킬마다 명세 구조가 다르다. cast_sec을 어떤 스킬은 cast_time, 어떤 스킬은 castMs로 적으면 시뮬레이터도 대조 스크립트도 매번 깨진다. 공통 명세 스키마 하나를 모든 스킬에 강제한다 — 이게 4.4 도구 전체가 돌아가는 전제다.
첫 분기에 다섯 개를 다 잡을 필요는 없다. 한두 개만 자리 잡아도 사이클은 눈에 띄게 짧아진다. 나머지는 루프를 돌리며 자연히 메워진다.
4.1~4.4에서 전투 기획의 좌표·Look & Feel·콤보·시뮬레이션을 차례로 다뤘다. 4.1은 전투 기획자가 무엇을 측정 가능한 대상으로 보는지, 4.2은 히트 타이밍·히트스톱·이펙트 동기화를 어떻게 측정·조정하는지, 4.3은 콤보·캔슬·입력 큐를 상태 머신으로 어떻게 적는지, 그리고 4.4은 그 모든 명세가 의도대로 작동하는지를 빌드 없이/빌드에서 어떻게 검증하는지를 다뤘다.
Part 4를 끝낸 전투 기획자의 한 주는 이렇게 바뀐다. 월요일, 새 스킬 명세에 simulate_dps 자동 검증이 붙는다. 화요일, 콤보 경로 자동 열거로 메인 콤보의 정체성을 점검한다. 수요일, 빌드가 올라오면 telemetry 대조표가 자동으로 뜬다. 목요일, 데이터 기반 토론 30분. 금요일, 다음 사이클 명세 수정. 빌드 사이클이 3~4회에서 1~2회로 줄고, 회의가 절반 이하로 짧아진다. 타격감이라는 추상이 측정 가능한 220ms로 옮겨간다.
그리고 빌드 #234의 그 장면 — "이거 좀 떠 보이지 않아요?"라는 질문에, 이제는 telemetry 로그가 220ms라고 대신 대답한다. 손끝 대 종이의 싸움은 끝났다.
다음 Part 5는 내러티브 기획이다. 2.3에서 소개한 NarrativeDocs Layer 0~4 구조의 본격 적용 사례로 넘어간다.
setup.
1. 아래 simulate_dps.py 전체를 그대로 만드세요. 의존성 없음, python simulate_dps.py로 즉시 실행되며 4.4.2의 "평균 DPS: 261.0"이 재현됩니다.
# simulate_dps.py — 빌드 없이 명세만으로 DPS를 계산한다
from dataclasses import dataclass
@dataclass
class Skill:
name: str
damage: float # 1회 타격 데미지
cast_sec: float # 시전(모션 점유) 시간 (초)
cooldown_sec: float # 재사용 대기시간 (초)
resource_cost: float # 자원 소모 (MP/기력)
@dataclass
class Character:
name: str
max_resource: float
resource_regen: float # 초당 자원 회복
skills: list # list[Skill]
rotation: list # 우선순위 순서 (스킬 이름)
def simulate_dps(char: Character, duration_sec: float, tick=0.05):
cooldowns = {s.name: 0.0 for s in char.skills} # 남은 쿨다운
skill_by_name = {s.name: s for s in char.skills}
resource = char.max_resource
total_damage = 0.0
busy_until = 0.0 # 시전 모션이 끝나는 시각
log = []
t = 0.0
while t < duration_sec:
resource = min(char.max_resource, resource + char.resource_regen * tick)
for name in cooldowns:
cooldowns[name] = max(0.0, cooldowns[name] - tick)
if t >= busy_until: # 시전 중이 아니면 다음 스킬 선택
for name in char.rotation: # 우선순위 순
s = skill_by_name[name]
if cooldowns[name] <= 0 and resource >= s.resource_cost:
total_damage += s.damage
resource -= s.resource_cost
cooldowns[name] = s.cooldown_sec
busy_until = t + s.cast_sec
log.append((round(t, 2), name, resource))
break
t += tick
return total_damage / duration_sec, log
if __name__ == "__main__":
warrior = Character(
name="warrior", max_resource=100, resource_regen=8,
skills=[
Skill("skill_thunder", damage=420, cast_sec=0.9, cooldown_sec=6, resource_cost=40),
Skill("skill_dash", damage=180, cast_sec=0.4, cooldown_sec=3, resource_cost=20),
Skill("basic_1", damage=60, cast_sec=0.3, cooldown_sec=0, resource_cost=0),
],
rotation=["skill_thunder", "skill_dash", "basic_1"],
)
dps, log = simulate_dps(warrior, duration_sec=20)
print(f"평균 DPS: {dps:.1f}")
for t, name, res in log[:8]:
print(f" t={t:>5}s {name:<14} 자원={res:.0f}")
GetTimeSeconds 등)을 그대로 찍는 것이 핵심입니다.prompt. 빌드 telemetry에서 명세와 어긋난 항목 하나를 골라 4.4.5 양식으로 AI에 질의하세요 — 로그 발췌를 붙이고, "추측을 단정하지 말고 원인별 검증 방법까지, 확실치 않으면 미확인으로 표기"를 반드시 포함하세요.
verify. AI의 1차 출력을 그대로 채택하지 마세요. 로그를 직접 읽어 배제 가능한 후보를 손으로 지운 뒤(4.4.5의 네트워크·프레임레이트 배제처럼) 근거를 붙여 재요청하세요. 최종 리포트가 "원인 추정 + 누구에게 무엇을 요청"의 행동 단위로 끝나면 회의에 들고 가세요.
1인 축소판. telemetry 로거 전역 설치가 부담이면, 검증하려는 스킬 한 개에만 input·hit 두 줄을 찍으세요. simulate_dps도 그 스킬 하나의 DPS만 보세요. 도구 전체를 깔지 말고, 가장 의심스러운 스킬 하나로 루프를 한 바퀴 돌려 본 뒤 확장하세요.
회의실에 들어서니 화이트보드에 캐릭터 이름 하나가 빨갛게 동그라미 쳐져 있었다. 김 모라는 NPC였다. 한 기획자의 사이드 퀘스트에서 그는 "주인공을 어릴 때 거둬 키운 양아버지"였고, 다른 기획자의 메인 퀘스트 챕터 3에서 그는 "주인공을 배신하고 떠난 옛 동료"였다. 두 문서 모두 한 달 전에 승인됐고, 둘 다 빌드에 들어가 있었다. 보이스 녹음 견적까지 받은 상태였다.
누구의 잘못도 아니었다. 두 기획자 모두 세계관 문서를 읽었고, 캐릭터 설정을 참고했다. 문제는 같은 캐릭터의 설정이 세 개의 다른 파일에 흩어져 있었고, 그중 어느 것이 "진짜"인지 아무도 단언할 수 없었다는 데 있었다. 세계관 문서는 한 덩어리짜리 70페이지 워드 파일이었고, 검색하면 김 모가 열한 군데에서 나왔다. 어떤 줄이 결정이고 어떤 줄이 메모인지 구분이 없었다.
그날 회의가 끝나고 결정한 것이 NarrativeDocs를 다섯 층으로 쪼개는 일이었다. 이 장은 그 다섯 층의 이야기다.
분야별 Layer 분해를 내러티브에서 먼저 여는 데에는 이유가 있다.
내러티브는 가장 추상적이다. 세계관, 감정, 톤 같은 것들은 숫자로 떨어지지 않는다. 아트나 시스템처럼 "스프라이트 수", "데미지 계수" 같은 명확한 단위가 없다. 이렇게 추상적인 분야가 Layer로 깔끔하게 분해된다면, 더 구체적인 다른 분야들은 자연히 같은 패턴으로 풀린다. 어려운 데서 먼저 통하는지 보는 셈이다.
또 내러티브는 인터페이스가 가장 많다. 캐릭터는 아트와 닿고, 퀘스트는 콘텐츠·레벨과 닿고, 대사는 UX·현지화와 닿고, 보상은 시스템과 닿는다. 분야 사이를 가장 많이 가로지르는 자리라서, Layer 통합의 가치가 곧바로 드러난다.
마지막으로 자연어 산출물의 비중이 가장 높다. 그래서 AI 보조가 가장 크게 작동하는 분야이기도 하다. 다만 모든 게임이 내러티브 중심은 아니다. 캐주얼·아케이드 장르라면 이 장의 깊이가 과할 수 있다. 그래도 "한 덩어리 문서를 Layer로 쪼개고 인터페이스를 좁힌다"는 골격 자체는 어느 분야에든 그대로 옮겨진다.
NarrativeDocs를 다섯 층으로 분해한 모양은 다음과 같다. 비전(L0)이 위에 있고, 빌드·QA(L4)가 아래에 있으며, 층 사이를 잇는 통로는 의도적으로 좁다.
폴더로 옮기면 다음 형태다. 각 파일명은 책에서 추상화한 이름이 아니라 실제로 그 폴더에 존재하는 파일명이다.
NarrativeDocs/
├── Layer0_Vision/
│ ├── world_premise.md (세계관 전제 — 불변)
│ ├── narrative_pillar.md (감정 기둥 3개)
│ └── tone_manifesto.md (톤·금기 어휘 목록)
├── Layer1_System/
│ ├── faction_system.md
│ ├── reputation_model.md
│ ├── dialogue_branching_rule.md
│ └── lore_consistency_rule.md
├── Layer2_Content/
│ ├── main_quest/ (챕터 단위)
│ ├── side_quest/
│ ├── character_bible/
│ └── lore_codex/
├── Layer3_Data/
│ ├── quest_table.xlsx
│ ├── npc_table.xlsx
│ ├── dialogue_id_table.xlsx
│ └── reward_table.xlsx
└── Layer4_Build_QA/
├── narrative_qa_checklist.md
├── voice_review_log.md
└── localization_status.md
다섯 층을 한 사람이 책임지지 않는다는 점이 중요하다. 층마다 주 담당이 다르고, 인접한 층 사이의 통로만 표준화한다. 위 그림의 빨간 화살표가 그 통로다. 특히 L2와 L3 사이의 통로는 일부러 가장 좁게(굵은 빨간 화살표) 그려 두었는데, 그 이유는 뒤에서 본다.
서랍 비유로 보면 다섯 칸 서랍이다. 첫째 칸에는 절대 안 옮기는 세계관 한 줄, 둘째 칸에는 룰북, 셋째 칸에는 본문, 넷째 칸에는 시트, 다섯째 칸에는 검수 로그. 칸 사이 통로는 좁고, 통로 위에는 변경 알림 벨이 달려 있다.
L0는 변하지 않는다. 변하면 게임의 정체성이 변하는 것이다. 그래서 분량이 작아야 한다. 작아야 안 변한다.
| 문서 | 분량 |
|---|---|
| world_premise.md | A4 1.5장 |
| narrative_pillar.md | A4 1장 (감정 3개) |
| tone_manifesto.md | A4 2장 (톤 + 금기 어휘 목록) |
합쳐 약 4.5장. 이게 L0의 무게다. 무거워지면 변경이 두려워지고, 두려워지면 다른 층이 L0를 우회하기 시작한다. 우회가 시작되면 L0는 죽은 문서가 된다.
narrative_pillar.md의 실제 골격은 이런 모양이다(내용은 추상화).
---
title: 내러티브 감정 기둥
layer: L0
status: locked
last_updated: 2026-05-18
---
## 1. 잃은 것에 대한 그리움
- 플레이어가 매 챕터 끝에서 한 가지를 잃는다.
- 잃은 것은 다시 돌아오지 않는다 (회상으로만).
## 2. 의무와 자유의 갈등
- 모든 주요 NPC는 두 가지 의무를 진다.
- 플레이어의 선택은 한쪽 의무만 살릴 수 있다.
## 3. 작은 손길의 무게
- 큰 영웅적 행위보다 작은 친절이 더 큰 결과를 낳는다.
이 세 줄짜리 기둥이 그 아래 수백 페이지의 방향을 정한다. status: locked는 단순한 라벨이 아니다. L4의 자동 점검 중 하나가 이 라벨을 읽어서, locked 문서가 PR에서 수정되면 리드 내러티브의 승인 없이는 머지가 막히도록 걸어 둔다.
L1은 변경 가능하지만 비용이 크다. 룰북이기 때문이다. 룰북 한 줄이 바뀌면 그 룰을 따르는 모든 콘텐츠가 영향을 받는다.
faction_system.md의 골격은 다음과 같다.
---
title: 세력 시스템
layer: L1
atoms:
- faction_relation_matrix
- faction_membership_rule
- faction_quest_eligibility
---
## 1. 세력 개념
N개 세력. 각 세력은 (이념, 자원, 영토)로 정의됨.
## 2. 세력 간 관계
- relation_matrix.json (-3 적대 ~ +3 동맹)
- 관계 변화 트리거: 메인 퀘스트 결정, 평판 임계
## 3. 플레이어 소속 규칙
- 동시 소속 최대 2개 (적대 관계 동시 불가)
- 탈퇴 페널티: 평판 -2, 동맹 세력 -1
frontmatter의 atoms: 목록을 눈여겨보자. 이 세 개의 atom 이름은 단순한 메모가 아니라, 7부 온톨로지와 11부 관계도가 추적하는 식별자다. 어떤 퀘스트가 faction_quest_eligibility를 참조하면, 이 룰이 바뀔 때 그 퀘스트가 영향 목록에 자동으로 올라온다. L1은 게임 코드와 가장 가까운 내러티브 산출물이라, 시스템 기획자와 짝지어 작업한다.
L2는 가장 두껍다. 메인 퀘스트, 사이드 퀘스트, 캐릭터 바이블, 로어 사전이 모두 여기 산다. 앞서 회의실에서 충돌했던 "양아버지 vs 배신한 동료" 김 모의 진짜 설정도, 이제는 character_bible/ 안의 한 파일로만 존재한다. 그 파일이 단일 진실 원천이고, 퀘스트는 거기를 참조만 한다.
메인 퀘스트 폴더는 다음 형태다.
main_quest/
├── chapter_01_awakening/
│ ├── 00_chapter_overview.md
│ ├── 01_quest_a_call_to_arms.md
│ ├── 02_quest_b_first_choice.md
│ └── ...
├── chapter_02_road/
│ └── ...
└── _TEMPLATES/
└── quest_template.md
각 퀘스트 파일은 atom 표준 형식을 따른다.
---
title: 무기를 들 때
layer: L2
type: main_quest
atoms:
- quest_chapter_01_awakening_a
related:
affects: [reputation_model, faction_relation_matrix]
derives_from: [narrative_pillar, world_premise]
requires: [character_kim, faction_alpha]
part_of: chapter_01_awakening
---
## 진행 단계
1. ...
## 분기
- A안 선택 시: ...
- B안 선택 시: ...
## 보상 (L3 참조)
- reward_table.xlsx → quest_001 행
핵심은 related: 블록이다. requires: [character_kim]이라고 적는 순간, 이 퀘스트는 김 모의 설정을 character_bible에서 가져온다고 선언한 것이고, 더 이상 자기 파일 안에서 김 모를 새로 정의하지 않는다. 내러티브 본문은 L2에, 수치 보상은 L3 시트에 둔다. 둘이 한 파일에 있으면 시트 한 줄 고칠 때마다 본문을 건드려야 하고, 그러면 번역키가 어긋난다.
L3는 시트와 ID다. 자연어 문장이 한 줄도 들어가지 않는다.
quest_table.xlsx
| quest_id | chapter | type | unlock_level | reward_xp | reward_gold | dialogue_set_id |
|----------|---------|------|--------------|-----------|-------------|-----------------|
| q_001 | ch01 | main | 1 | 500 | 100 | ds_001 |
| q_002 | ch01 | main | 2 | 800 | 150 | ds_002 |
대사조차 ID로만 참조한다. 본문은 dialogue_id_table.xlsx에 따로 있고, 번역키와 1:1로 매핑된다. L2와 L3를 잇는 통로는 quest_id 단 한 열이면 충분하다. 앞 그림에서 이 통로만 굵은 빨간 화살표였던 이유가 여기 있다. 인터페이스를 한 열로 좁게 만드는 것이 Layer 분리의 핵심이다. 통로가 넓으면 양쪽이 서로를 너무 많이 알게 되고, 한쪽을 고칠 때 다른 쪽이 따라 깨진다.
L4는 검수와 출하다. 새 콘텐츠가 들어올 때마다 자동·수동 점검이 작동한다. 자동 점검은 스크립트로 돌린다. 아래 네 개는 실제로 CI에 걸려 있는 lint들이다.
| 검사 | 도구 |
|---|---|
| 모든 dialogue_id 매핑 존재 | dialogue_lint.py |
| 모든 quest_id가 chapter에 속함 | quest_lint.py |
| 보상 합계가 챕터별 곡선 범위 내 | reward_curve_check.py |
| 금기 어휘 출현 여부 | tone_lint.py (L0 tone_manifesto 기반) |
tone_lint.py가 L0의 tone_manifesto.md를 직접 읽는다는 점에 주목하자. 맨 위 층(불변 비전)과 맨 아래 층(출하 게이트)이 자동화로 직접 연결돼 있다. 비전에 적은 금기 어휘가 출시 직전 본문에서 검출되면 빌드가 막힌다.
자동으로 못 잡는 것은 사람이 본다.
| 검사 | 담당 |
|---|---|
| L0 감정 기둥 부합 | 리드 내러티브 |
| 캐릭터 보이스 일관성 | 캐릭터 담당 + 내러티브 |
| 현지화 가능성 | 로컬라이저 |
자동과 수동의 경계가 명확하면 검수 시간이 예측 가능해진다. "이번 챕터 검수 며칠 걸려요?"에 답할 수 있게 된다.
여기까지가 구조다. 그런데 이 구조가 진짜로 일하는 자리는, 새 콘텐츠를 만들 때다. 이미 다섯 층이 깔린 프로젝트에서 사이드 퀘스트 한 줄짜리 아이디어를 받아 Layer로 흩뿌리는 작업을 Claude에게 시킨 실제 세션을 그대로 옮긴다. 요약하지 않고, 빗나간 부분과 거부한 부분까지 포함한다.
보낸 프롬프트(전문):
첨부한 L0/L1 컨텍스트 절대 어기지 말고 우리 Layer 구조로 분해해줘.
[narrative_pillar.md 전문 — 감정 기둥 3개]
[tone_manifesto.md 발췌 — 금기 어휘: "운명", "예언", "선택받은 자"]
[character_bible/character_kim.md 발췌 — 김: 주인공을 거둔 양아버지,
과묵, 말끝 흐림, 감정을 직접 말 안 함]
아이디어: "김이 플레이어에게 죽은 아내의 유품을 건네는 짧은 사이드 퀘스트."
L2 퀘스트 파일(related 블록 포함, 본문 200자쯤), L3 quest_table 행이랑
dialogue_id, 그리고 tone 위반 자가 점검까지. 일단 L2만 쓰고 멈춰줘.
Claude의 날것 출력(L2):
---
title: 빛바랜 손수건
layer: L2
type: side_quest
atoms:
- quest_side_kim_keepsake
related:
affects: [reputation_model]
derives_from: [narrative_pillar]
requires: [character_kim]
part_of: chapter_02_road
---
## 진행 단계
1. 김이 플레이어를 조용히 부른다. 평소처럼 용건을 바로 말하지 않는다.
2. 낡은 손수건을 내민다. "이걸… 자네가 가지고 있는 게 나을 것 같아서."
3. 플레이어가 무엇이냐 물으면, 김은 대답 대신 창밖을 본다.
4. (선택) 받는다 / 사양한다.
## 분기
- 받는다: 김의 평판 +1. 이후 챕터에서 손수건 관련 회상 1회 해금.
- 사양한다: 김은 고개를 끄덕이고 손수건을 다시 넣는다. 평판 변화 없음.
손수건은 영영 다시 등장하지 않는다.
## 보상 (L3 참조)
- reward_table.xlsx → quest_side_kim_keepsake 행
여기서 Claude는 멈췄고, 한 줄을 덧붙였다.
"L0 기둥 1번(잃은 것은 다시 돌아오지 않는다)을 반영해, '사양한다'를 고르면 손수건이 영영 사라지도록 했습니다. 다만 한 가지 확인이 필요합니다. 죽은 아내 설정은 character_kim.md 발췌에 없었습니다. 제가 임의로 넣은 것이라, 캐릭터 바이블과 충돌할 수 있습니다."
사람의 검증과 거부:
이 출력은 톤이 정확했다. 김의 과묵함, 말끝 흐리기, 감정을 직접 말하지 않는 버릇이 살아 있었다. 금기 어휘도 없었다. 그러나 마지막 자가 점검이 정확히 진짜 문제를 짚었다. 김에게 죽은 아내가 있다는 설정은 어디에도 없었다. AI가 "유품"이라는 단어에서 자연스럽게 죽은 배우자를 추론한 것인데, 이건 바이블에 새 사실을 몰래 추가하는 일이다. 바로 이게 이 장 첫머리 회의실에서 벌어진 사고의 씨앗이다.
그래서 거부했다. 재요청을 보냈다.
재요청:
톤은 좋아. 근데 "죽은 아내"는 바이블에 없는 새 설정이라 못 써.
김 과거는 "고향 떠나며 뭘 두고 왔다"까지만 확정이야. 유품 정체를
특정하지 말고 "오래전 두고 온 것" 정도로만 흐리게 다시 써줘.
손수건도 바이블에 없으니 "천 조각" 정도로 낮추고.
재요청 후 출력(발췌):
2. 낡은 천 조각을 내민다. "오래전에… 두고 온 게 하나 있었네.
이건 그 끄트머리라고만 해 두지."
3. 플레이어가 무엇이냐 물으면, 김은 대답 대신 창밖을 본다.
이번에는 받았다. 김의 과거를 새로 못 박지 않으면서도, 감정 기둥 1번(잃음)을 건드린다. 무엇을 잃었는지는 끝까지 흐린 채로 남는다.
이 한 세션이 보여 주는 것은 두 가지다. 첫째, Layer 컨텍스트(L0 기둥 + L1 톤 + L2 바이블)를 정확히 주입하면 AI는 톤을 놀랍도록 잘 맞춘다. 둘째, AI는 빈칸을 만나면 그럴듯하게 메운다. 그 메움이 바이블에 없는 새 사실일 때, 그걸 잡아내는 것은 여전히 사람이다. Layer 구조의 requires: [character_kim]이 "어디를 봐야 하는지"를 알려 줬기에, 검증자가 어느 파일과 대조할지 곧바로 알 수 있었다. 구조가 없었다면 70페이지를 다시 뒤졌을 것이다.
다섯 층을 나눈 진짜 이유는 인터페이스를 좁히기 위해서다. 그리고 좁은 인터페이스마다 변경 감지 자동화를 붙인다.
| 인터페이스 | 무엇이 흐르는가 |
|---|---|
| L0 → L1 | pillar, tone (변경 시 L1 룰북 재검토 트리거) |
| L1 → L2 | 룰북·분기 정책 (변경 시 영향 받는 퀘스트 자동 목록화) |
| L2 → L3 | quest_id, npc_id, dialogue_id (열 한 개) |
| L3 → L4 | 시트 변경 시 자동 lint 트리거 |
예를 들어 L1의 faction_system.md에서 "동시 소속 최대 2개"를 "최대 1개"로 바꾸는 PR을 올리면, 관계도가 faction_quest_eligibility를 참조하는 L2 퀘스트들을 훑어 영향 목록을 만들고, 그 목록이 PR 코멘트에 자동으로 첨부된다. 룰을 바꾼 사람은 "회의 두세 번 잡아서 누가 영향받는지 알아보기" 대신, 코멘트에 붙은 목록을 보고 30분 회의로 끝낸다.
핵심은 이것이다. Layer만 나누고 인터페이스가 모호하면, 칸막이만 늘어난 셈이 된다. Layer 분리 자체보다 인터페이스 자동화가 본질이다.
다섯 층으로 옮기고 6개월을 돌린 뒤의 측정이다. 아래 수치는 저자 팀 운영 기록 기반이되 절대값은 방향·비율로만 옮긴다(저자 추정·미검증). 분리 전은 기억, 분리 후는 실측이라 같은 자료를 두 번 잰 게 아니라는 한계가 있다.
| 항목 | Layer 분리 전 | Layer 분리 후 |
|---|---|---|
| 신규 기획자 온보딩 | 3주 | 1주 |
| 룰북 변경 영향 범위 파악 | 회의 2~3번 | 자동 코멘트 + 회의 30분 |
| 새 메인 퀘스트 1챕터 제작 | 4주 | 2.5주 |
| 출시 전 1챕터 검수 | 5일 | 2일 |
| 현지화 누락 사고 | 분기당 3~5건 | 분기당 0~1건 |
가장 분명하게 줄어든 것은 현지화 누락이다. dialogue_id가 L3에서 번역키와 1:1로 묶이고 dialogue_lint.py가 매핑 누락을 막으면서, "번역 안 된 대사가 빌드에 들어가는" 사고가 거의 사라졌다. 온보딩이 짧아진 것도 컸다. 새 기획자에게 "70페이지 워드를 다 읽어라" 대신 "L0 4.5장만 외우고, 네 퀘스트는 L2 템플릿 채워라"라고 말할 수 있게 됐다.
한 가지 솔직하게 덧붙이면, 이 효과가 단번에 나온 건 아니다. 첫 분기에는 자동 코멘트 한 가지만 붙였고 나머지는 수작업이었다. 인터페이스 자동화는 분기마다 하나씩 늘렸다. Layer를 나누는 것보다 자동화를 붙이는 것이 더 오래 걸렸다.
여기까지가 표면적 이유다. "분야 간 협업 언어를 통일한다." 그런데 더 본질적인 이유가 하나 더 있다. Layer 분해는 절차적 생성을 가능하게 만드는 전제다. 5계층 각각이 절차적 생성의 한 역할(L0 앵커 → L1 룰북 → L2 본문 → L3 수치 → L4 게이트)에 대응하고, 한 덩어리로 섞이면 생성기가 어디부터 읽고 어디에 쓸지 정하지 못해 무너진다는 일반 논제는 §6.6에서 전체로 다뤘다. 여기서는 그 전제가 내러티브 다섯 층 위에서 어떻게 실제로 작동하는지만 본다.
한 덩어리 70페이지 문서 위에서는 생성 알고리즘이 어디부터 읽고 어디에 쓸지 정할 수 없다. 내러티브 다섯 층은 그대로 생성 파이프라인의 다섯 단계가 된다 — L0 앵커, L1 입력 규칙, L2 본문이 쌓이는 자리, L3 시뮬레이션 입력, L4 검증 게이트.
앞의 워크드 트랜스크립트가 이미 이 다섯 단계의 축소판이었다. L0 기둥과 L1 톤을 컨텍스트로 주입했고(앵커), L2에 본문이 생성됐고, L3 행이 따라 나왔고, tone 위반 자가 점검이 검증 게이트 흉내를 냈다. 사람이 한 번에 한 퀘스트씩 시킨 것을, 같은 구조 위에서 generator가 사이드 퀘스트를 양산하도록 옮기면 절차적 생성이 된다.
더 멀리 가면 이렇게 흐른다 — 플레이어 행동 누적 → 월드 BT 노드 상태변화(Squad 상위) → NPC 수치변화(평판·관심사·우선순위) → NPC 태그+수치가 곧 발현 조건 → 퀘스트 클라우드에서 매칭 퀘스트 발현. 퀘스트를 미리 다 써 두는 게 아니라, 태그가 붙은 채 "공중에 떠 있는 구름"처럼 두고, 플레이어의 행동이 NPC 수치를 바꾸면 그 수치가 발현 조건과 맞는 퀘스트가 내려와 나타난다. 이 진보적 모델은 5.3에서 다시 자세히 다룬다(흐름도 포함). 여기서 강조할 점은, 이 모든 흐름이 다섯 층 분해 위에서만 작동한다는 것이다.
반대로 Layer가 섞여 있는 팀은 절차적 생성으로 가지 못한다. 시도하는 순간 일관성 사고로 무너진다. 김 모가 양아버지이자 배신자가 되는 그 사고가, 생성기를 통해 자동으로 양산된다고 상상해 보면 된다.
다만 처음부터 다섯 칸 서랍을 완벽히 갖춰야 한다는 뜻은 아니다. 첫 분기에는 L0 한 줄과 L1 룰북 한 권만 분리해도 충분하다. 분리는 점진적으로, 인터페이스는 좁게다.
마지막으로 시점에 관한 한 가지. Layer 분해 자체는 결정론적 PCG(Procedural Content Generation, 절차적 콘텐츠 생성) 시절부터 있던 분리다. 새로운 것은 LLM이 그 분리 위에서 자연어 본문·페르소나·서사 분기까지 처리하게 됐다는 점이다 — 위 트랜스크립트에서 AI가 김의 톤을 맞춰 대사를 쓴 일은 5년 전 룰 테이블로는 불가능했다. 이 시점론은 5.3에서 더 다룬다.
다음 장(5.2)에서는 이 다섯 층 위에서 lore_consistency_rule이 어떻게 세계관→캐릭터→퀘스트 일관성을 자동으로 검증하는지, 즉 김 모 사고를 구조적으로 막는 검사기를 본다.
이미 한 덩어리 세계관 문서가 있는 상태에서 시작한다고 가정합니다.
setup. NarrativeDocs 폴더 아래 빈 다섯 폴더를 만드세요. Layer0_Vision부터 Layer4_Build_QA까지입니다. 기존 한 덩어리 문서는 그대로 두고, 여기서 "절대 안 변하는 한 줄"만 골라 Layer0_Vision/narrative_pillar.md에 옮깁니다. frontmatter에 status: locked을 입력합니다. 이 한 파일이 4.5장을 넘지 않게 합니다.
prompt. 새 콘텐츠를 만들 때 AI에게 이 순서로 컨텍스트를 줍니다.
[narrative_pillar.md 전문]
[tone_manifesto.md 금기 어휘]
[관련 character_bible 파일 발췌]
아이디어: "<한 줄 아이디어>"
이 컨텍스트 어기지 말고 L2 퀘스트 파일부터 써줘. related 블록
(requires/derives_from/affects) 채우고, 바이블에 없는 새 설정은
넣지 말고 필요하면 멈춰서 물어봐.
verify. 출력에서 두 가지를 보세요. (1) requires:에 적힌 파일을 실제로 열어, AI가 거기 없는 사실을 새로 만들지 않았는지 대조합니다. (2) 금기 어휘를 본문에서 검색합니다(tone_lint.py가 있으면 자동, 없으면 눈으로). 둘 중 하나라도 걸리면 거부하고, 무엇이 틀렸는지 명시해 재요청하세요. 위 트랜스크립트에서 "죽은 아내"를 거부한 것이 바로 (1)입니다.
팀 없이 혼자 만드는 인디 개발자라면 다섯 층이 과해 보일 수 있습니다. 두 칸이면 됩니다.
vision.md 한 장(L0+L1 합침)과 content/ 폴더 하나(L2), 그리고 스프레드시트 한 장(L3)을 둡니다. QA는 별도 층 대신, content 파일 frontmatter에 requires: 한 줄만 적는 습관으로 대신하세요. 새 퀘스트를 쓸 때마다 requires에 적은 파일만 다시 펼쳐 대조하면, 70페이지를 다 뒤지지 않고도 김 모 사고를 막을 수 있습니다. 핵심은 층의 개수가 아니라, "절대 안 변하는 한 줄을 따로 떼어 매번 AI에게 먼저 보여 주는" 습관입니다. 그 한 줄이 컨텍스트 앵커이고, 1인이든 중규모 팀이든 거기서부터 시작합니다.
베타 직전, QA에서 버그 리포트가 한 장 올라왔다. 제목은 "왕이 반말을 합니다." 본문은 짧았다. "3.4 도입 컷신에서 K_001(국왕)이 플레이어에게 '야, 잠깐만'이라고 함. 이 캐릭터 1.1부터 3.3까지 전부 '그대'를 씀."
작가에게 물었더니 답이 의외였다. "그 대사 제가 안 썼는데요." 추적해 보니 외주 작가가 컷신 분기 한 줄을 급하게 메우면서 넣은 것이었다. 우리 캐릭터 바이블에 voice_profile이 있었지만, 그 외주 작가는 그 문서를 본 적이 없었다. 룰은 문서 안에 있었고, 대사는 문서 밖에서 들어왔다.
이게 일관성 사고의 본질이다. 룰이 없어서가 아니라, 룰이 본문까지 따라가지 못해서 생긴다. 그리고 이 한 줄이 컷신이었다면 더 무서워진다. 컷신은 보통 성우 녹음이 붙는다. 텍스트일 때는 한 번 고치면 끝이지만, 녹음된 뒤에 발견되면 성우 재소집·재녹음·재믹싱이라는 비가역 비용이 따라온다. 일관성 검증의 진짜 목적은 "녹음 전에" 잡는 것이다.
이 장은 그 사고를 사람의 눈 대신 룰북과 검사기로 잡는 워크플로를 다룬다. lore_consistency_rule 룰북이 어떻게 검사기의 입력이 되는지, voice_lint가 톤 흔들림을 어떻게 의심 후보로 뽑는지, 그리고 왜 최종 판정만큼은 끝까지 사람의 자리로 남겨야 하는지를 실제 산출물 그대로 본다.
출시된 RPG·MMORPG의 사용자 리뷰를 모아 보면, 내러티브 일관성 사고는 몇 가지 패턴으로 수렴한다. 종류는 달라 보여도 원인은 거의 하나다.
다섯 가지가 다 다른 사고처럼 보이지만, 추적해 보면 같은 자리에서 샌다. Layer 0(세계 전제)이나 Layer 1(룰)이 바뀌었는데, 그 변경이 Layer 2(본문)와 Layer 3(데이터 시트)까지 전파되지 않은 것이다. 룰은 갱신됐는데 본문은 옛 룰 위에 멈춰 있다.
수동 검수로 이걸 막으려는 건 무리다. 한 장에 NPC 50명, 대사 2,000줄, 퀘스트 30개가 얽혀 있는데, 룰 한 줄을 바꿨을 때 그 영향이 어디까지 번지는지를 사람이 100% 추적하는 건 불가능하다. 빠뜨린 한 줄은 검수 단계에서 안 잡히고, 출시 후 리뷰란에서 잡힌다.
그렇다고 자동 검사가 100%를 보장하는 것도 아니다. 핵심은 역할 분담이다. 자동 검사는 의심 후보를 빠르게 뽑고, 판정은 사람이 한다. 자동화의 목적은 사람의 검수 시간을 줄이는 것이지 사람을 없애는 게 아니다. 이 전제를 흐리면 뒤에서 다룰 모든 실패가 따라온다.
프로젝트 A의 L1 문서 중 하나가 lore_consistency_rule.md다. 이 문서는 사람이 읽는 가이드인 동시에, 검사기가 파싱하는 입력이다. 프론트매터의 atoms와 affects가 그 두 역할을 한 몸에 묶는다.
---
title: 로어 일관성 룰
layer: L1
atoms:
- lore_check_world_rule
- lore_check_character_voice
- lore_check_timeline
- lore_check_faction_relation
related:
derives_from: [world_premise, narrative_pillar]
affects: [main_quest/*, character_bible/*, dialogue_id_table]
---
## 1. 세계 규칙 (World Rule)
- 마법은 금지된 상태에서 시작 → 마법 사용 시 (시점, 사용자, 정당화) 명시 필요
- 신은 침묵 상태 → 직접 응답 묘사 금지 (꿈·환상 허용)
## 2. 캐릭터 보이스 규칙
- 각 캐릭터별 voice_profile 참조 강제
- 신규 대사 작성 시 voice_profile 5 항목 (어휘, 문장 길이, 존칭, 감정 표현, 금기 표현) 준수
## 3. 시간선 규칙
- 모든 NPC에 status_timeline 정의 (살아있음 / 부상 / 사망 / 행방불명 / 위치 변경)
- 대사·등장 시점에 status_timeline 자동 점검
## 4. 세력 관계 규칙
- faction_relation_matrix 변경 시점 기록
- 변경 후 대사는 새로운 관계 반영
affects 한 줄이 검사기의 스캔 범위를 정의한다. world_premise가 바뀌면 검사기는 main_quest/*, character_bible/*, dialogue_id_table 전부를 다시 훑는다. 사람이 "어디까지 영향이 가지?"를 머릿속으로 추적하던 작업을, 룰북에 적힌 의존성 그래프가 대신한다.
voice_profile은 이 룰북이 참조하는 별도 L2 자산이다. 캐릭터 한 명의 프로필은 검사기가 비교 기준으로 쓸 수 있도록 항목이 수치화·열거형으로 입력되어 있다.
# character_bible/K_001_voice_profile.yaml
character_id: K_001
display_name: 국왕
voice_profile:
vocabulary_register: 고풍_격식 # 어휘 격
avg_sentence_len: 18 # 평균 문장 길이(자)
honorific: "그대" # 2인칭 존칭(고정)
emotion_expression: 절제 # 감정 노출 정도
forbidden_terms: ["야", "잠깐만", "ㅋ"] # 금기 표현
이 yaml이 있어야 "왕이 반말을 한다"는 사고가 사람의 직관이 아니라 기계가 비교 가능한 항목이 된다. honorific이 "그대"인데 대사에 "야"가 있으면, 그건 의견이 아니라 룰 위반 후보다.
변경이 발생하는 순간 검사기가 발동한다. 흐름은 다음과 같다.
flowchart TD
A[변경 발생: L0 전제 / L1 룰 / L2 본문] --> B{변경 분류기}
B -->|어떤 룰의 affects에 걸리는가| C[해당 검사기 호출]
C --> D[affects 범위의 L2 본문 + L3 시트 스캔]
D --> E[룰 위반/의심 후보 리스트]
E --> F[변경 요청에 코멘트 자동 첨부]
F --> G{사람 판정}
G -->|진짜 위반| H[본문 수정 → 재검사]
G -->|의도된 변화| I[voice_profile / 룰북 갱신]
G -->|룰이 과민| J[룰 자체 조정]
H --> K{텍스트 단계인가}
I --> K
J --> K
K -->|예: 가역| L[검수 종결 가능]
K -->|아니오: 녹음 후| M[비가역 — 재녹음 비용]
L -.차단선.-> M
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
classDef fail fill:#fee2e2,stroke:#dc2626,color:#7f1d1d;
class B,C,D,F code;
class G human;
class A,E data;
class L pass;
class M fail;
마지막 분기가 이 장의 숨은 척추다. 모든 일관성 판정은 텍스트 단계에서, 즉 가역 단계에서 끝나야 한다. 검수가 녹음·캐스팅 이후로 넘어가면 수정은 비가역이 된다. 그래서 voice_lint·timeline_lint 같은 검사기는 빠르게가 아니라 이르게 도는 것이 핵심이다. 컷신 대사가 녹음 큐에 들어가기 전에 한 번은 통과해야 한다.
검사기는 네 종이며, 각자 룰북의 한 섹션과 일대일로 대응한다.
world_rule_lint.py — L1 세계 규칙 + 모든 L2 본문 → 마법 사용·신 응답 등 위반 후보voice_lint.py — voice_profile + dialogue_id_table → 보이스 흔들림 의심 대사timeline_lint.py — npc status_timeline + 모든 대사·등장 시점 → 사망 NPC 재등장 등 충돌faction_lint.py — faction_relation_matrix + dialogue tone → 관계 모순 대사네 검사기 모두 100% 정확하지 않다. 그래서 출력 이름이 "위반"이 아니라 "의심 후보"다.
추상적인 "검사기가 있다"는 말로는 감이 안 온다. 실제로 한 번 돌려 본다. 앞의 "왕이 반말을 한다" 사고를 재현한 입력이다.
setup — 검사 대상 대사 두 줄을 dialogue_id_table에서 가져온다.
dialogue_id_204 speaker=K_001 text="야, 잠깐만요. 그쪽이 먼저 말해 봐."
dialogue_id_217 speaker=K_007 text="...젠장, 또 실패야. 다시 처음부터."
prompt — 검사기 자체는 규칙 기반(정규식·길이 비교·금기어 매칭)으로 돈다. AI는 "왜 의심인지"를 작가가 읽을 자연어로 설명하는 자리에만 붙인다. 검사 단계의 LLM 프롬프트 전문은 다음과 같다.
voice_lint가 뽑은 의심 후보들이야. 판정은 하지 말고, 각 후보가
어떤 voice_profile 항목을 위반한 것 같은지랑 근거만 1~2문장으로 설명해줘.
"위반이다/아니다" 단정하지 말고 "~로 의심됨"까지만.
[voice_profile]
K_001: honorific="그대", avg_sentence_len=18, forbidden=["야","잠깐만"]
K_007: forbidden=["젠장"]
[의심 후보]
- dialogue_id_204 (K_001): "야, 잠깐만요. 그쪽이 먼저 말해 봐."
- dialogue_id_217 (K_007): "...젠장, 또 실패야. 다시 처음부터."
Claude 날것 출력 (요약·정리 없이 그대로 옮긴다)
[dialogue_id_204 / K_001]
이 대사는 honorific 항목과 충돌하는 것으로 의심됩니다. voice_profile은
2인칭 존칭을 "그대"로 고정하고 있는데, 본 대사는 "그쪽"을 쓰고 존칭 자체가
드러나지 않습니다. 또한 forbidden_terms의 "야"와 "잠깐만"이 모두 등장하여
어휘 격(고풍_격식)에서 벗어난 것으로 의심됩니다. 평균 문장 길이 18자 기준과는
직접 어긋나 보이지 않으나, 어조 자체가 캐주얼해진 점이 위반 신호로 의심됩니다.
[dialogue_id_217 / K_007]
forbidden_terms의 "젠장"이 그대로 사용되어 금기 표현 항목 위반으로 의심됩니다.
다만 K_007(학자)의 emotion_expression 항목이 제시되지 않아, 이 표현이
의도된 감정 고조인지 단순 일탈인지는 이 정보만으로 판단하기 어렵습니다.
판정은 작가 검토가 필요합니다.
이 출력에는 우리가 일부러 남긴 두 가지 정직함이 있다. 첫째, 204에서 AI는 "평균 문장 길이는 직접 어긋나 보이지 않는다"며 위반이 아닌 항목을 위반이라고 우기지 않았다. 둘째, 217에서 "emotion_expression 항목이 제시되지 않아 판단하기 어렵다"며 정보 부족을 인정하고 판정을 사람에게 넘겼다. AI가 모든 의심을 "위반 확정"으로 밀어붙였다면, 그게 더 위험한 검사기다.
verify — 작가는 이 코멘트를 변경 요청에서 그대로 받아 본다. 판정은 작가가 한다.
emotion_peak_exception 플래그를 추가하고, 217을 예외로 등록두 후보를 같은 검사기가 뽑았지만 결말이 정반대다. 하나는 본문을 고치고, 하나는 룰을 고친다. 이 분기를 기계가 자동으로 못 한다는 것이 다음 절의 핵심이다.
검사기가 의심까지만 뽑고 판정은 넘기는 데는 세 가지 이유가 있다.
첫째, 의도된 위반이 존재한다. 캐릭터가 무너지거나 변하는 장에서는 보이스가 의도적으로 흔들린다. 위의 217이 그렇다. 자동 거부형 검사기는 작가의 연출 의도를 막아 버린다.
둘째, 룰 자체가 진화한다. 같은 종류의 의심이 계속 "의도된 변화"로 판정된다면, 그건 룰이 현실을 못 따라간다는 신호다. 검사 결과는 본문만 고치게 하는 게 아니라 룰북도 고치게 한다.
셋째, 신규 캐릭터·세력은 학습 구간이 필요하다. voice_profile이 아직 두세 개 항목밖에 안 채워진 신규 NPC는 의심이 많이 뜨는 게 정상이다. 이 시기에 자동 거부를 걸면 작가는 검사기를 적으로 인식한다.
자동 검사와 사람 판정의 경계가 분명해야 검사기가 살아남는다. 자동 거부형으로 만들면 한 달 안에 작가들이 "이거 끄자"고 한다. 회사 출입문에 너무 예민한 자동 센서를 달면, 사람이 지나갈 때마다 문이 닫혀 결국 누군가 센서를 떼어 버리는 것과 같다. 검사기는 문을 닫는 장치가 아니라, "여기 누가 지나갔다"고 알려 주는 장치여야 한다.
한 가지 단서를 덧붙인다. 검수가 텍스트 단계에서 종결되어야 한다는 원칙(앞 흐름도의 차단선)은 사람 판정에도 그대로 적용된다. 작가의 "의도된 위반" 판정도 녹음 전에 끝나야 한다. 녹음 후의 번복은 검사기 문제가 아니라 공정 비용 문제로 바뀐다(가역/비가역 경계의 전모는 5.4.5).
프로젝트 A에서 검사기 4종을 단계적으로 도입하고 6개월을 측정했다. 아래는 실측 로그 기반이되 절대값 대신 방향·비율로 옮긴 것이다(사내 측정, 저자 추정 아님).
마지막 항목이 가장 흥미롭다. 검사기가 있으면 룰을 자주 바꿔도 안전하다. 룰 한 줄을 바꾸면 그 영향이 자동으로 가시화되니까, 변경의 두려움이 줄고 룰이 더 빨리 진화한다. 일관성 도구의 진짜 효과는 "사고를 줄였다"가 아니라 "룰을 겁 없이 바꿀 수 있게 됐다"는 쪽에 가깝다.
단, 위 수치는 검사기 4종이 모두 가동되는 시점의 숫자다. 도입 초기에 voice_lint 하나만으로도 가시적 효과가 나왔다는 점이 더 중요하다. 처음부터 4종을 다 켤 필요는 없다.
자동 검사기 본체는 규칙 기반이 효율적이다. 같은 입력에 같은 결과가 나와야 신뢰가 쌓이는데, LLM은 비결정론적이라 그 자리에 맞지 않는다. AI는 다른 네 자리에 들어간다.
규칙은 빠르고 결정론적이고, LLM은 설명과 생성에 강하다. 이 둘의 역할을 섞으면 둘 다 망가진다. 검사를 LLM에 맡기면 같은 대사가 어제는 통과하고 오늘은 걸리는 일이 생기고, 설명을 정규식에 맡기면 "honorific 항목 위반"이라는 기계어밖에 안 나온다.
처음부터 검사기 4종을 다 만들면 부담이 효과보다 먼저 온다. 권장 순서는 가장 싸고 효과 큰 것부터다.
단계 2(voice_lint)만으로도 효과가 크다는 점을 강조한다. 앞의 "왕의 반말" 사고는 정확히 이 단계 하나로 잡히는 종류였다.
도입 과정에서 반복되는 실패도 거의 정해져 있다.
마지막 항목이 앞의 모든 항목보다 비싸다. 다른 실패는 시간을 잃지만, 이 실패는 성우 일정을 잃는다.
다음 장(5.3)에서는 검사기 대신 AI 보조로 내러티브 본문을 작성하는 흐름을 다룬다. L0 톤과 L1 룰을 컨텍스트로 주입해, AI가 일반적인 답이 아니라 우리 세계의 답을 내게 만드는 방법을 본다.
setup — character_bible에서 캐릭터 1명을 골라 voice_profile 5항목(어휘 격·평균 문장 길이·존칭·감정 표현·금기 표현)을 yaml로 완전히 채우세요. 같은 캐릭터의 기존 대사 10줄을 dialogue_id_table에서 뽑아 한 파일에 모읍니다.
prompt — 위 워크드 트랜스크립트의 검사 보조 프롬프트를 그대로 쓰세요. 핵심은 두 제약입니다. "판정하지 마라"와 "~로 의심됨까지만 말하라"입니다. 입력에 voice_profile yaml과 대사 10줄을 붙입니다.
verify — 출력된 의심 후보를 한 줄씩 본인이 판정하세요. 진짜 위반이면 본문을 고치고, 의도된 변화면 voice_profile에 예외 플래그를 추가합니다. AI가 "위반 아닌 항목"까지 위반이라고 우겼는지, "정보 부족"을 인정하는지를 함께 확인합니다. AI가 모든 항목을 위반으로 단정하면 프롬프트의 "판정하지 마라" 제약을 강화하세요.
검사기 4종도 룰북도 없는 1인 개발이라면, 검사기 본체 없이 프롬프트 하나로 같은 효과를 낼 수 있습니다. 캐릭터별 voice_profile yaml만 손으로 유지하고, 신규 대사를 쓸 때마다 해당 캐릭터의 yaml + 새 대사를 위 보조 프롬프트에 붙여 "의심 후보"를 받으세요. 자동화는 없지만 판정은 사람, AI는 설명이라는 핵심 구조는 똑같이 삽니다. 단 한 줄만 지키면 됩니다 — 녹음·음성합성에 넘기기 전에 이 검토를 한 번 거치세요. 가역 단계를 넘기지 않는 원칙은 팀 규모와 무관합니다.
새 사이드 NPC의 첫 대사를 뽑던 날이었다. 빈 채팅창에 "마을 대장장이 NPC 대사 5개 만들어줘"라고 쳤다. 5초 뒤 화면에 "용사여, 자네의 무기를 내게 맡기게"가 떴다. 어디서 본 듯한 게 아니라, 정확히 어디서 봤는지 알 것 같은 문장이었다. 같은 프롬프트를 다른 팀의 다른 게임에 넣어도 똑같은 답이 나올 거였다. 그 순간 깨달은 건 모델이 약하다는 게 아니라, 내가 모델에게 우리 게임을 아무것도 알려주지 않았다는 것이었다.
AI는 일반적인 판타지 문장을 잘 쓴다. 그런데 우리 세계의 문장은 못 쓴다. 차이는 단 하나, 컨텍스트 주입이다. L0 톤과 L1 룰을 매 요청마다 함께 보내면, AI가 뱉는 한 줄은 "어디서 본 듯한 문장"에서 "이 게임의 문장"으로 바뀐다. 이 챕터는 그 주입을 4층으로 운영하는 실무를 다루고, 마지막에 같은 원리를 월드 시뮬레이션 규모로 끌어올리는 진보적 적용(월드 BT(BehaviorTree, 행동 트리) + 퀘스트 클라우드)을 RnD 최전선으로 짚는다.
내러티브 분야에서 AI 보조는 가장 빨리 도입되고 가장 빨리 신뢰를 잃는 영역이다. 실패 패턴이 거의 똑같기 때문이다.
"퀘스트 시작 대사 5개"라고 던지면 "용사여, 저희 마을이..."로 시작하는 일반 판타지 5종이 돌아온다. "이 캐릭터 대사 좀 고쳐줘"라고 하면 보이스가 평준화돼서 모든 NPC가 비슷한 말투로 수렴한다. "챕터 1 시놉시스 써줘"라고 하면 본 적 있는 RPG 시놉시스들의 평균값이 나온다.
문제는 모델이 아니라 컨텍스트가 비어 있다는 것이다. 모델은 학습 데이터의 평균을 출력한다. 평균을 원하지 않으면 평균에서 멀어질 단서를 줘야 한다. 이 챕터의 주제는 그 단서를 어떻게 만들고 어떻게 주입하느냐다.
저자가 운영하는 MMORPG 프로젝트(이하 프로젝트 A)에서 내러티브 AI 보조는 4층의 컨텍스트를 순서대로 쌓는다. 5.1에서 NarrativeDocs를 Layer 0~4로 분해한 그 구조가, 여기서 그대로 주입 단위로 재사용된다.
4층을 매번 다 넣지는 않는다. 작업 유형에 따라 필요한 층만 꺼낸다. 한 캐릭터의 다음 대사 초안이면 A + B(톤만) + D(그 캐릭터 최근 대사 10줄)면 충분하다. 신규 사이드 퀘스트 시놉시스면 C(quest 구조 룰)가 추가된다. 분기 결과 4안이면 C(분기 규칙) + D(분기 직전 본문 전체)가 무거워진다. 책상 위 서류함에서 페르소나 시트, 세계관 한 줄, 룰북 페이지, 인접 본문 한 묶음을 작업 크기에 맞춰 골라 보내는 셈이다.
추상으로 설명하는 대신, 실제로 한 요청을 끝까지 따라간다. 같은 캐릭터(학자형 NPC, 사내 ID K_007)가 처음으로 감정을 드러내야 하는 장면의 대사 3안을 뽑는 작업이다. 프롬프트 전문부터 시작한다.
보낸 프롬프트 (Layer A + B(톤) + D + 작업 지시 + 출력 형식):
[시스템]
프로젝트 A 내러티브 작가야. "용사"·"선택받은 자" 같은 RPG 상투어 쓰지 말고,
말투는 아래 직전 대사 톤 그대로 따라가. 모르는 설정은 지어내지 말고
"컨텍스트로는 판단 불가"라고 해. 너는 초안만, 결정은 내가 한다.
[L0 톤 — tone_manifesto 발췌]
이 세계의 정서는 절제다. 인물은 감정을 쉽게 말하지 않는다.
감정은 폭발이 아니라 균열로 드러난다. 말줄임과 짧은 문장.
[참고 — K_007의 직전 5개 대사 (시간순, 원본 그대로)]
1. "...그건 저도 처음 듣는 얘긴데요."
2. "기록이 있나요? 없으면 의심해야죠."
3. "확실해질 때까지 움직이지 맙시다."
4. "제 의견은 그게 끝입니다."
5. "...죄송합니다. 제가 너무 차가웠나요."
[상황]
동료 NPC가 다친 채로 돌아왔다. 학자는 처음으로 감정을 드러내야 한다.
딱 3안만, 각 25자 이내로. 안마다 끝에 (반응유형: 분노/슬픔/부정) 라벨 붙이고,
설명이나 서두는 빼줘.
Claude의 날것 출력 (요약·각색 없이 그대로):
1. "...왜 그걸 혼자 떠안았어요. 바보같이." (반응유형: 슬픔)
2. "기록을 봤어야 했는데. 제가, 제가 놓쳤어요." (반응유형: 부정)
3. "괜찮아요. 다 괜찮아요. 그러니까 그만—" (반응유형: 부정)
여기서 멈추지 않는 게 핵심이다. 작가가 세 안을 읽고 검증한다. 1안은 톤이 맞다. 직전 대사 5번의 "...죄송합니다"가 보여준 균열을, 1안이 "바보같이"라는 한 단어로 이어받았다. 채택 후보다. 2안은 "제가, 제가 놓쳤어요"의 말 더듬음이 학자의 절제와 살짝 어긋난다. 보류. 3안은 라벨이 틀렸다. "괜찮아요"를 반복하는 건 부정이 아니라 회피에 가까운데, 우리 voice_profile에는 학자가 감정을 회피로 처리하는 패턴이 없다. 거부.
재요청 (거부 사유를 명시해서 다시 보냄):
3안은 거부. "괜찮아요" 반복은 이 학자 패턴이 아니야. 얘는 감정을
회피가 아니라 '사실 확인 실패에 대한 자책'으로 처리해. 1안은 채택할게.
2·3안 자리에 1안이랑 결 다른 두 안 다시 줘. 말 더듬음 없이 25자 이내로.
Claude의 두 번째 출력:
2. "기록은 거짓말 안 해요. 내가 안 본 거예요." (반응유형: 자책)
3. "...앉아요. 상처부터. 말은 나중에." (반응유형: 슬픔)
이번엔 둘 다 통과했다. 2안은 "기록"이라는 학자의 핵심 어휘(직전 대사 2번 "기록이 있나요?")를 자책의 매개로 다시 썼고, 3안은 절제된 명령형으로 학자가 감정을 행동으로 누르는 패턴을 보여줬다. 최종 채택은 1안 + 2안 + 3안. 이 세 줄은 5.2의 voice_lint 자동 검수를 통과한 뒤 L2 본문에 반영되고 L3에서 dialogue_id를 발급받는다.
이 한 트랜스크립트에 이 챕터의 모든 게 들어 있다. 톤 주입(L0)이 1안을 살렸고, 원본 그대로의 인접 본문(L2)이 학자의 어휘 "기록"을 재요청에서 재활용하게 했고, 출력 형식 강제가 잡담을 막았고, 작가 거부 게이트가 3안의 틀린 라벨을 걸러냈다. AI는 한 줄도 최종 결정하지 않았다.
가장 위에 깔리는 페르소나 정의다. 한 번 정해두고 거의 안 바꾼다. 위 트랜스크립트의 시스템 블록이 그 실물이다. 다섯 줄 중 마지막 한 줄("초안 작성, 결정은 작가")이 제일 중요하다. 이게 빠지면 AI가 "최종"인 척하는 문장을 자신 있게 내놓고, 작가는 검수 대신 채점을 하게 된다. 그리고 세 번째 줄("모르는 설정은 만들지 말고 판단 불가라고 답한다")이 두 번째로 중요하다. 이 줄이 없으면 모델은 빈칸을 그럴듯한 거짓말로 채운다. 내러티브에서 그럴듯한 거짓말은 며칠 뒤 로어 충돌로 돌아온다.
L0는 분량이 작다(5.1 기준 약 4.5장 분량). 거의 매번 전체 주입이 가능하다. 한국어 기준 추정으로 world_premise.md가 약 2,500토큰, narrative_pillar.md가 약 1,500토큰, tone_manifesto.md가 약 3,000토큰, 합쳐서 약 7,000토큰이다. (이 수치들은 저자 추정이며 미검증이다. 토크나이저·문서 개정에 따라 달라진다.)
7,000토큰을 매 요청마다 새로 보내면 비용이 쌓인다. 그래서 프롬프트 캐싱을 건다. Anthropic·OpenAI 모두 지원하는 기능이고, 캐시 적중 시 입력 토큰 비용이 크게 줄어든다. 핵심은 변하는 것과 안 변하는 것을 메시지 안에서 분리해 두는 것이다.
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": [
{"type": "text", "text": L0_FULL, "cache_control": {"type": "ephemeral"}},
{"type": "text", "text": L1_SELECTED, "cache_control": {"type": "ephemeral"}},
{"type": "text", "text": L2_ADJACENT}, # 매번 변경 — 캐시 안 함
{"type": "text", "text": TASK_INSTRUCTION}, # 매번 변경
]},
]
cache_control을 단 L0와 L1은 캐시 대상이고, L2 인접 본문과 작업 지시는 매번 바뀌므로 캐시하지 않는다. 캐시 블록을 항상 메시지 앞쪽에 모아 두는 게 적중률을 좌우한다. 변하는 블록이 앞에 끼면 그 뒤 캐시가 전부 무효화된다. 이 순서를 틀리는 게 캐싱을 켜고도 비용이 안 줄어드는 가장 흔한 원인이다.
캐싱 적중률·비용 절감 수치의 상세는 Part 22(비용) 챕터에서 다룬다. 여기서는 "변하는 것을 뒤로 몰아라"는 원리만 기억하면 된다.
L1 룰북은 분량이 커서 전부 넣으면 컨텍스트가 터지고, 더 나쁘게는 모델이 핵심을 놓친다. 작업과 관련된 룰만, 그것도 _summary 절만 고른다.
메인 퀘스트 분기 결과를 뽑을 땐 dialogue_branching_rule과 faction_relation_matrix를 고른다. 신규 NPC 대사면 해당 NPC의 voice_profile과 tone_manifesto. 로어 사전 신규 항목이면 lore_consistency_rule과 world_premise. 사이드 퀘스트 골격이면 quest_template과 reputation_model. 선택은 사람이 직접 하거나 wikilink 그래프(7부)를 따라 자동 추출하는데, 자동 추출 시엔 정밀도보다 재현율을 우선한다. 룰 하나가 빠지는 손해가, 룰 하나가 더 들어가는 손해보다 훨씬 크기 때문이다.
룰북 본문을 다 넣는 대신, 룰북 파일 머리에 _summary 절을 두고 그것만 주입한다.
---
title: 분기 규칙
layer: L1
---
## _summary
- 분기는 챕터 끝에만 발생
- 분기는 2~3안. 4안 이상 금지
- 분기 선택은 평판 +/-1 영향, 결말 분기에는 +/-3
- 모든 분기 결과는 24시간 내 결과를 보여줘야 함
- 분기는 되돌릴 수 없음 (세이브 분리 권장 UI 노출)
## 1. 분기 발생 시점 규칙
(상세 설명, 운영자 참고용 — LLM에는 주입하지 않음)
...
_summary 5줄이 본문 50줄보다 LLM 출력 품질에 더 효과적이다. 모델은 짧고 단정적인 규칙을 더 잘 지킨다. 긴 설명은 모델의 주의를 분산시키고, 분산된 주의는 규칙 위반으로 돌아온다.
직전 대사, 인접 퀘스트, 같은 챕터 시놉시스. 가장 변동이 큰 컨텍스트다. 같은 캐릭터 신규 대사엔 그 캐릭터의 직전 대사 10줄을 시간순으로, 챕터 중반 퀘스트엔 챕터 시놉시스와 같은 챕터 퀘스트 1줄 요약들을, 분기 결과 결말엔 분기 직전 본문 전체와 선택지 텍스트를 넣는다. 너무 많이 넣으면 LLM이 평균을 출력하고, 너무 적게 넣으면 일반화된 출력이 나온다. 적정 지점은 토큰 1,500~3,000 사이(저자 관찰 기준, 미검증)다.
핵심 규칙 하나. 인접 본문은 가공하거나 요약하지 않고 원본 그대로 넣는다. 위 트랜스크립트에서 학자의 직전 대사 5줄을 손대지 않고 그대로 넣었기 때문에, 모델이 재요청 단계에서 "기록"이라는 단어를 정확히 집어 자책의 매개로 재활용할 수 있었다. 만약 그 5줄을 "학자는 신중하고 차갑다"로 요약해 넣었다면, 작가의 미세한 선택은 전부 사라지고 모델은 다시 평균으로 돌아갔을 것이다. 요약은 정보를 줄이는 게 아니라 작가가 이미 내린 결정을 지운다.
AI 출력은 항상 초안이다. 검수는 정해진 게이트를 통과한다.
flowchart TD
A[AI 출력 N개] --> B[1차: voice_lint 자동
5.2]
B --> C[2차: 작가 1인 선택·수정
약 15분]
C --> D{채택 안 있음?}
D -->|있음| E[3차: 리드 내러티브 합의
필요 시]
D -->|0개 채택 = 전부 폐기| F[재요청 또는 직접 작성]
E --> G[L2 본문 반영
+ L3 dialogue_id 발급]
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
classDef fail fill:#fee2e2,stroke:#dc2626,color:#7f1d1d;
class B code;
class A ai;
class C,D,E human;
class G pass;
class F fail;
작가가 N개 중 0개를 고르는 경우(전부 폐기)도 정상이다. 위 트랜스크립트에서 3안이 거부됐듯이, 거부는 실패가 아니라 게이트가 작동했다는 증거다. 그래서 폐기율을 작가별·캐릭터별로 측정해 컨텍스트 주입 품질의 지표로 삼는다.
폐기율 0~20%면 컨텍스트가 충분한 안정 운영이니 그대로 둔다. 20~50%는 일반적 운영 범위라 모니터링만 한다. 50~80%로 올라가면 L1 룰 선택이 빠졌는지 재점검한다. 80%를 넘으면 개별 룰 문제가 아니라 시스템 프롬프트·페르소나 자체가 어긋난 것이므로 Layer A를 다시 쓴다. 폐기율은 매주 한 번 작가별로 집계해 회고에서 공유한다.
다만 폐기율은 절대 지표가 아니다. 변화가 빠른 캐릭터(예: 위 트랜스크립트의 K_007처럼 처음 감정을 드러내는 전환점)는 폐기율이 높아도 정상이다. 숫자는 대화의 시작이지 판결이 아니다.
L0~L1은 게임의 핵심 IP다. 외부 LLM API에 그대로 보내는 게 부담스러우면 선택지가 갈린다. 외부 API를 학습 비사용 계약으로 그대로 쓰는 방식이 가장 빠르지만 법무 검토가 필요하다. 회사명·고유명사를 placeholder로 치환해 보내는 방식은 추가 처리 비용이 들고 자연스러움이 손상된다. 자체 호스팅(오픈모델)은 데이터는 안전하지만 품질·운영 부담이 크다. L0는 내부에 두고 초안만 외부로 보내는 하이브리드는 운영이 복잡하다.
저자의 프로젝트 A는 첫 번째 방식(외부 API + 학습 비사용 계약)을 쓴다. 두 번째 방식을 시도했다가 접었다. placeholder 치환이 "○○ 왕국의 ○○ 학자가 ○○에 대해 말했다" 형태로 본문을 평준화시켜 출력 품질을 무너뜨렸기 때문이다. 익명화가 품질을 죽이는 건 책 전체에서 반복되는 트레이드오프다(Part 1 익명화 챕터 참고). 내러티브에서 그 손상이 특히 크다. 고유명사가 곧 톤이기 때문이다.
시스템 프롬프트 없이 작업 지시만 던지면 평균이 나온다. 페르소나와 금기를 먼저 깐다. L0를 매번 전체 주입하면서 캐싱을 안 쓰면 비용이 샌다. 캐시 블록을 앞으로 모은다. L1 룰북을 통째로 넣으면 모델이 핵심을 놓친다. _summary 절만 뽑는다. 인접 본문을 요약해서 넣으면 작가의 선택이 지워진다. 원본 그대로 인용한다. 출력 형식을 안 정하면 응답의 상당수가 "여기 3가지 후보입니다:"로 시작하고, 작가가 그 서두를 본문으로 착각하는 사고까지 따라온다. 개수·길이·라벨을 명시한다. AI 출력을 최종으로 쓰면 검수 게이트가 무너진다. 항상 작가 게이트를 통과시킨다. 폐기율을 안 재면 도구의 건강이 사람 인상에 의존한다. 주간으로 집계해 회고에서 공유한다.
지금까지는 보수적 적용이었다. 작가가 컨텍스트를 정성껏 주입하고, AI는 한 줄 한 줄의 초안만 만든다. 단위는 "이 캐릭터의 다음 대사 3안", "이 퀘스트의 시놉시스" 같은 작은 작업이다. 안정적이지만 양산 규모와 동적 반응성에는 한계가 있다.
여기서 한 가지를 먼저 짚는다. 절차적 생성·월드 시뮬레이션·동적 퀘스트는 기획자들이 20~30년 전부터 종이 위에 그려 온 비전이다. 결정론 룰북 기반 PCG는 던전 룸·무기 옵션·스폰 분포 같은 수치 영역은 다뤘지만, 자연어 본문·캐릭터 페르소나·서사 분기·NPC 대화엔 손이 닿지 않았다. 기획의 상당 부분이 종이 위에 머물러 있었던 셈이다. 2024~2026년 LLM과 이미지 모델의 발전이 그 영역을 구현 가능한 자리로 끌어왔다. AI 발전의 핵심 의미는 모델 점수가 아니라, 오래 종이에 있던 기획의 실현 가능성이 열렸다는 데 있다. 다만 가능성이 열린 것과 운영 가능한 시스템으로 정착하는 것은 다른 문제다.
5.1의 Layer 0~4 분해는 단순한 정리가 아니라 절차적 생성의 전제였다. 다섯 계층이 각각 생성 파이프라인의 어느 단계(L0 앵커 → L1 룰북 → L2 본문 → L3 수치 → L4 게이트)에 대응하는지는 §6.6과 5.1.11에서 다뤘다. 요점은 하나다 — 한 덩어리 문서 위에서는 생성기가 어디부터 읽고 어디에 쓸지 정하지 못하고, L0가 어디 있는지 몰라 컨텍스트가 흐려지며 L1·L2가 한 파일에서 충돌해 양산 라인이 무너진다. Layer 분해가 먼저 있고, 그 위에서 절차적 생성이 작동한다. 여기서는 그 전제를 내러티브 AI 보조의 양산 단계에 적용한다.
세 요소가 묶인다. 첫째, NPC Persona가 절차적으로 생성된다. 메인 NPC는 작가 손이지만 사이드 NPC는 6.2~6.3의 generator·Squad 파이프라인이 양산한다. 각 Persona는 voice_profile과 함께 태그(직업·세력·성향·역할)를 부착한다. 둘째, 월드 BT가 Squad 상위에 자리 잡는다. Squad가 한 사냥터 NPC 그룹의 행동을 묶는다면, 월드 BT는 그 위에서 플레이어 행동 누적 수치를 받아 세계 전체 상태를 갱신한다. 플레이어가 어떤 세력을 도왔는지, 어떤 지역을 자주 갔는지, 어떤 결정을 내렸는지가 월드 BT 노드 상태를 흔들고, 흔들린 노드는 영향권 NPC들의 수치(평판·관심사·우선순위)에 반영된다. 셋째, 퀘스트는 클라우드 모델이다. 메인 퀘스트를 제외한 모든 퀘스트가 절차적으로 생성돼, 각자 태그(누가·어디서·왜·언제)와 발현 조건을 단 채 떠다닌다. 어떤 퀘스트도 특정 NPC에 고정되지 않는다.
발현은 다섯 단계를 거친다.
flowchart TD
P[1. 플레이어 행동 누적
세력 지원·지역 방문·결정] --> W[2. 월드 BT 노드 상태 변화
Squad 상위 세계 상태 갱신]
W --> N[3. NPC 수치 변화
평판·관심사·우선순위]
N --> M[4. NPC 태그 + 수치 == 발현 조건
매칭 키 생성]
M --> Q[5. 퀘스트 클라우드에서
매칭 퀘스트 발현]
Q -.->|작가 게이트 통과 후 노출| PL[플레이어에게 등장]
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
class M code;
class P,W,N,Q data;
class PL pass;
비유로 보면 퀘스트가 도서관 책장에 꽂혀 있는 게 아니라 공중에 떠다니는 구름이다. NPC가 어떤 상태에 도달하면 자기에게 맞는 구름이 내려와 손에 잡힌다. 이 구조에서 AI는 한 줄을 쓰는 보조자가 아니라 셋을 동시에 다룬다. 절차 생성된 Persona·퀘스트의 자연어 본문(설명·대사), 월드 BT 상태가 NPC에 반영될 때의 어휘 변동(같은 voice_profile을 유지하되 화제는 월드 상태에 맞춰 변동), 그리고 검증 단계의 의심 분류기(이 퀘스트가 이 NPC에 발현돼도 되는가).
진보적 적용에서도 가역/비가역 경계(5.4.5)는 그대로 산다. 클라우드에서 발현된 퀘스트의 대사가 검수를 통과하지 못한 채 음성 파이프라인으로 흘러가면 코드 롤백으로 못 되돌린다. 그래서 모든 검수 게이트(voice_lint, 의심 분류기, 작가 게이트)를 비가역 경계 앞에 두는 것이 진보적 적용의 안전장치다. 자동 양산이 더해지는 만큼 이 게이트는 보수적 적용보다 더 단단해야 한다.
이 책은 진보적 적용의 완성형까지 다루지 않는다. 별도 책 한 권 분량이고, 회사·프로젝트마다 인프라 전제가 다르다. 두 가지만 기억하면 된다. 먼저, 보수적 적용이 안 되는 팀은 진보적 적용도 안 된다. 보수적 적용에서 검수 게이트가 안 돌면 진보적 적용에서는 클라우드가 폭주한다. 운영이 살아남으려면 절차적 생성 인프라, 월드 BT 노드 정의·테스트 도구, 발현 퀘스트 자동 의심 분류 + 작가 게이트, 발현률·폐기율·플레이어 만족도 동시 추적, 잘못 발현된 퀘스트 자동 회수·교체가 함께 갖춰져야 한다. 다섯 중 하나가 빠지면 한 분기 안에 폐기된다. 일관성 사고가 폭증해 사람 검수가 못 따라가기 때문이다.
다음으로, 진보적 적용은 양산을 늘리는 도구가 아니라 동적 반응성을 늘리는 도구다. 양산만 노리면 일반 RPG 평균이 클라우드 안에 가득 차고, 플레이어는 "더 다양해 보이지만 더 비어 있는" 세계를 만난다. 그렇다고 이 길이 위험하기만 한 건 아니다. 이 영역은 게임 기획 RnD의 최전선이다. 절차적 생성·시뮬레이션·LLM이 만나는 자리에서 진짜로 새로운 게임 형식이 나올 가능성이 가장 높다. 당장 회사에 도입할 일은 드물어도, 다음 5~10년의 방향 중 하나로는 봐 둘 만하다.
이 챕터의 보수적 적용을 그대로 재현하는 절차입니다.
setup. L0 세 파일(world_premise.md·narrative_pillar.md·tone_manifesto.md)을 한 텍스트로 합쳐 L0_FULL 변수에 담으세요. 시스템 프롬프트를 위 트랜스크립트의 다섯 줄 그대로 작성하되 마지막 줄("초안 작성, 결정은 작가")을 빼지 마세요. 대사를 뽑을 캐릭터의 직전 대사 10줄을 원본 그대로 텍스트로 모읍니다.
prompt. 메시지를 [시스템] → [L0 톤] → [참고: 직전 대사 원본] → [상황] → [출력 형식] 순으로 조립하세요. 출력 형식에 "정확히 N안 / 각 안 최대 글자수 / 라벨 / 그 외 일체 금지"를 반드시 명시합니다. L0와 L1에만 cache_control을 걸고, 변하는 블록은 메시지 뒤로 몹니다.
verify. 받은 N안을 한 줄씩 검증하세요. 톤이 직전 대사와 이어지는가, 라벨이 실제 반응과 맞는가, 우리 캐릭터가 안 쓰는 패턴(회피·말 더듬음 등)이 끼지 않았는가를 봅니다. 거부할 안은 이유를 명시해 재요청합니다. 0개 채택도 정상이며 폐기율로 기록하세요. 통과한 안만 voice_lint를 거쳐 L2에 반영하고 L3에서 dialogue_id를 발급합니다.
1인 축소판. API·캐싱·voice_lint가 없어도 핵심은 그대로입니다. ChatGPT/Claude 채팅창 하나면 됩니다. 캐릭터 직전 대사 5~10줄을 매번 복붙으로 맨 앞에 깔고, 출력 형식 3줄을 항상 붙이고, 받은 답에서 거부할 건 이유를 적어 재요청하세요. 캐싱 대신 같은 대화 스레드를 유지하면 앞 컨텍스트가 그대로 남습니다. 폐기율은 종이 한 장에 "이번 주 채택 12 / 전체 30"처럼 손으로 세어 적으면 충분합니다. 도구가 작아도 4층 주입과 검수 게이트라는 골격은 1인 작가에게도 똑같이 작동합니다.
voice_profile을 AI로 추출·갱신하고 보이스를 검수한다녹음 부스. 디렉터가 헤드폰을 끼고 손을 들어 큐를 준다. 성우가 학자 NPC의 대사를 읽는다. "와, 진짜 대박이네요." 디렉터의 손이 멈춘다. 이 캐릭터는 5챕터 내내 "와"라는 감탄사를 한 번도 쓴 적이 없는 사람이다. 욕설도, 현대 감탄사도 입에 담지 않는, 끝까지 말끝을 흐리는 학자다. 그런데 대본에는 그 줄이 그대로 실려 있다.
여기서 두 가지 비용이 동시에 발생한다. 첫째, 그 줄을 고치면 성우는 다시 읽어야 한다. 출연료와 스튜디오 시간은 이미 시계가 돌고 있다. 둘째, 더 무서운 건 디렉터가 그 줄을 못 잡고 넘어가는 경우다. 녹음이 끝나고 음원이 빌드에 들어가면, 그 학자는 게임 안에서 영영 그렇게 말한다. 녹음한 음원은 텍스트처럼 한 줄 수정으로 되돌릴 수 없다. 같은 성우의 같은 컨디션, 같은 부스를 다시 잡아야 한다.
이 챕터는 그 부스 앞 점선을 다룬다. 점선 위는 텍스트라서 무한히 고칠 수 있고, 점선 아래는 음원이라서 못 고친다. 모든 대사 검수는 점선 위에서 끝나야 한다. 그러려면 캐릭터마다 "이 사람은 이렇게 말한다"가 머릿속이 아니라 파일로 기록되어 있어야 하고, 신규 대사가 올라올 때마다 그 파일과 자동으로 대조돼야 한다. 그 파일을 voice_profile이라 부르고, 대조 도구를 voice_lint라 부른다.
내러티브 일관성 사고 중 가장 자주 터지는 게 캐릭터 보이스 흔들림이다. 출시 후에야 "이 NPC가 왜 갑자기 말투가 바뀌었냐"는 제보가 들어온다. 원인은 매번 거의 같다.
작가가 교체되면 같은 NPC가 다른 사람이 된다. 작가가 안 바뀌어도 6개월 지나면 본인이 자기 톤을 잊는다. 신규 대사를 쓸 때 그 캐릭터의 이전 대사를 안 펼쳐 보면 컨텍스트가 끊긴다. 보이스 규칙이 작가 머릿속에만 있고 문서로 없으면 다음 사람에게 전달이 안 된다. 그리고 최근 2년 사이 가장 빠르게 늘어난 다섯 번째 원인 — LLM에 캐릭터 정보 없이 "이 NPC 대사 만들어줘"라고 던지면, AI는 평균적이고 무난한, 그래서 누구의 목소리도 아닌 대사를 돌려준다.
다섯 번째가 AI 도입의 부작용이다. 앞 챕터(5.3)의 컨텍스트 주입이 처방이지만, 주입할 컨텍스트 자체가 부실하면 주입할 게 없다. 그 컨텍스트가 바로 voice_profile이다. 이 챕터는 그 파일을 만들고, 자동으로 검사하고, 녹음 부스 앞에서 종결시키는 한 사이클을 본다.
프로젝트 A에서 모든 NPC는 다섯 항목으로 voice_profile을 갖는다. 어휘 영역(자주 쓰는 단어군 / 절대 안 쓰는 단어군), 문장 길이(평균·최대 글자수), 존칭 체계(1·2인칭, 존댓말 비율, 호칭), 감정 표현(직접·간접·억제 중 어느 방식), 금기 표현(절대 안 쓰는 단어·구문). 다섯 항목 전부에 구체 예시가 붙어야 한다. "진중한 학자" 같은 추상 묘사만 있으면 사람마다 다르게 읽는다. 다음 작가는 그 학자를 자기 식으로 상상한다.
학자 NPC K_007의 실제 프로필 형식은 이렇다. 이 파일이 곧 voice_lint의 입력이 된다.
---
title: K_007 학자 voice_profile
layer: L1
character_id: K_007
atoms:
- voice_profile_k_007
related:
derives_from: [character_bible/k_007.md]
affects: [dialogue_id_table (K_007의 모든 대사)]
---
## 1. 어휘 영역
- 자주: "기록", "근거", "정황", "추정", "데이터", "사례"
- 절대 안 씀: "느낌", "감", "운명", "신의 뜻", "마음의 소리"
## 2. 문장 길이
- 평균: 18자
- 최대: 35자 (그 이상은 두 문장으로 쪼갬)
- 짧은 끊김 자주: "...아닙니다." "기록부터."
## 3. 존칭 체계
- 1인칭: "저"
- 2인칭: 직책 우선(대장님, 사령관님). 친밀해진 후에만 이름.
- 존댓말 100% (회상 장면 제외)
- 감탄사 거의 없음. 있을 때는 "...아."
## 4. 감정 표현
- 직접 표현 거의 없음 (분노·기쁨)
- 침묵·말끝 흐림으로 표현 ("...그런 식이라면.")
- 슬픔: 화제 전환으로 회피 ("...다른 얘기 합시다.")
## 5. 금기 표현
- 욕설 일체
- 현대 감탄사 "와", "헐", "대박"
- 3음절 이상 한자어를 한 문장에 2개 이상
- "운명", "예언" 등 신비주의 어휘
이 형식의 핵심은 추상의 자리마다 예시 줄이 붙어 있다는 점이다. "감정을 억제한다"가 아니라 "...그런 식이라면."이라는 실제 대사가 붙는다. 그래야 다음 작가도, 번역가도, voice_lint도 같은 기준을 본다.
다섯 항목을 번역·현지화의 눈으로 다시 보면 두 부류로 갈린다. 언어 종속 속성(어휘 영역·문장 길이·존칭 체계의 표면 형태)은 번역하는 언어마다 다시 정해야 하고, 언어 독립 속성(감정을 억제하느냐 직접 표현하느냐, 무엇을 끝까지 안 말하느냐 같은 태도)은 어느 언어로 옮겨도 그대로 지켜져야 한다. 현지화 작업을 줄 때 이 구분을 함께 넘기면, 번역가가 표면 어휘만 바꾸다 캐릭터의 태도까지 흔드는 일을 막을 수 있다.
처음부터 50명 NPC × 5항목 = 250건을 백지에서 작성하면 추상으로 흐른다. 본문 한 줄도 없이 "이 캐릭터는 차가운 학자"라고 적으면, 그 차가움이 무엇인지 아무도 모른다. 그래서 순서를 뒤집는다. 메인 NPC 5~7명만 처음부터 풀로 작성하고, 나머지는 본문 대사가 20~30줄 쌓인 뒤에 그 대사에서 profile을 역추출한다.
아래는 K_007의 대사가 25줄 쌓인 뒤, profile 초안을 뽑은 실제 워크드 트랜스크립트다. 요약하지 않고, AI가 틀린 자리까지 그대로 둔다.
[작가가 던진 프롬프트 전문]
다음은 캐릭터 K_007의 대사 25줄입니다.
1. "기록부터 봅시다."
2. "...정황이 맞지 않습니다."
3. "추정일 뿐입니다. 근거가 없어요."
4. "그 사례는 저도 알고 있습니다."
5. "...아닙니다. 데이터가 다릅니다."
(... 6~23번 중략 없이 전체 인용 ...)
24. "...그런 식이라면, 저는 빠지겠습니다."
25. "다른 얘기 합시다."
여기서 자주 쓰는 어휘(실제 나온 것만), 평균 문장 길이, 존칭·호칭 패턴,
감정 표현 방식, 한 번도 안 쓴 표현 영역, 이렇게 다섯 가지 뽑아줘.
항목마다 근거 대사 2~3줄을 번호로 인용하고, "진중한"·"차가운" 같은
주관적 인상은 쓰지 마.
[Claude의 날것 출력 — 다듬지 않음]
1. 자주 쓰는 어휘
- "기록" (1번), "정황" (2번), "추정/근거" (3번),
"사례" (4번), "데이터" (5번)
→ 사실·증거 계열 명사에 집중.
2. 평균 문장 길이
- 25줄 평균 약 14.6자. 최장 21자(24번).
→ 짧은 단문 위주.
3. 존칭·호칭
- 1인칭 "저"(3·24번). 종결어미 "-습니다/-요" 존댓말.
- 2인칭 호칭은 이 25줄에 등장하지 않음.
→ 호칭 패턴은 이 표본만으로 단정 불가. (작가 확인 필요)
4. 감정 표현
- 직접 감정어 없음. 거부를 "빠지겠습니다"(24)로,
슬픔/회피를 화제 전환 "다른 얘기 합시다"(25)로 처리.
→ 억제·간접형.
5. 한 번도 안 쓴 영역
- 감탄사, 욕설, 신비주의 어휘 모두 미출현.
※ 단, "미출현"이 "금기"라는 보장은 아님.
표본이 25줄로 적어, 우연한 미출현일 수 있음.
여기서 두 가지를 짚어야 한다. 첫째, AI가 3번 항목에서 멈췄다. "이 표본만으로 단정 불가"라고 스스로 한계를 그었다. 호칭은 25줄에 안 나왔으니 추정하지 않은 것이다 — 이게 정직한 출력이다. 만약 AI가 "2인칭은 '대장님'으로 추정"이라고 채웠다면 그건 환각이고, profile에 들어가면 오염이다. 둘째, 5번 항목에서 AI가 스스로 면책을 달았다. "미출현 ≠ 금기"는 핵심 경고다. 감탄사가 25줄에 안 나온 건 우연일 수도 있다.
[작가의 검증·거부]
작가는 1·2·4번을 채택했다. 3번 호칭은 character_bible을 펼쳐 "대장님 우선, 친밀 후 이름"을 수동으로 채웠다 — AI가 비운 자리를 사람이 메운다. 5번은 AI 경고대로 "미출현"을 곧장 "금기"로 승격하지 않았다. 대신 작가가 캐릭터 설정에 비춰 "현대 감탄사·욕설·신비주의"만 금기로 확정하고, 나머지 미출현 어휘는 보류 처리했다.
[작가의 재요청]
"운명", "예언"은 금기로 확정할게. 의미 겹치는 신비주의 어휘 10개 더 뽑아줘.
근데 K_007이 학자로서 반증·비판 맥락에선 인용할 수도 있으니, 그 예외 케이스도
한 줄로 같이 표시해줘.
이 마지막 재요청이 중요하다. 금기를 기계적으로 넓히면 "학자가 미신을 비판하며 '운명 따위'라고 말하는" 정당한 대사까지 막힌다. 그래서 금기에 예외 맥락을 함께 정의시킨다. AI가 후보를 넓히고, 작가가 경계를 그린다. 이 한 바퀴를 돌고 나서야 voice_profile_k_007이 확정돼 L1에 고정된다.
근거 인용을 강제하고("번호로 인용"), 주관 형용사를 금지하면("진중한 금지") AI 환각이 줄고 작가가 검증할 표면이 생긴다. profile은 AI가 쓰는 게 아니라, AI가 초안을 깔고 작가가 고정하는 것이다.
voice_profile이 파일로 있으면, 새 대사가 올라올 때마다 자동 대조가 가능하다. 다섯 검사 중 실전에서 효과가 큰 건 금기 어휘 매칭(어휘가 금기 목록에 걸리나)과 어휘 영역 위반(절대 안 쓰는 단어군에 들어가나) 두 가지다. 문장 길이 이탈·존칭 누락·자주 쓰는 어휘 비율은 잘못된 양성(false positive)이 많아 보조로만 쓴다. 회상 장면 한 줄이 평균 길이를 흔드는 것까지 다 잡으면 작가가 경고에 무뎌진다.
voice_lint는 챕터 신규 대사 묶음을 받아 이런 리포트를 낸다.
voice_lint 결과 (ch04 신규 대사 32줄, profile=voice_profile_k_007)
─────────────────────────────────────────────
[위반] dialogue_id_412 — K_007
내용: "와, 진짜 대박이네요!"
사유: 금기 어휘 "와", "대박" (profile §5)
→ 작가 검토 필요
[의심] dialogue_id_421 — K_007
내용: "그 운명은 받아들이기 어렵습니다."
사유: 금기 어휘 영역 "운명" (profile §5)
단, '반증·비판 맥락' 예외 가능 — 작가 판정
→ 작가 검토 필요
[정상] 30개 대사
─────────────────────────────────────────────
요약: 위반 1 / 의심 1 / 정상 30
위반은 빨강, 의심은 노랑이다. 둘 다 작가 판정을 거쳐야 통과한다. 여기에 절대 원칙이 하나 있다 — voice_lint는 자동으로 거부하지 않는다(5.2 원칙의 연장). 위 dialogue_id_421을 보자. "운명"은 금기지만, 학자가 미신을 반박하는 맥락이면 정당한 인용일 수 있다. 그 판단은 도구가 못 한다. 자동 거부형 lint는 이 미묘한 자리를 다 막아버리고, 작가에게서 톤을 다듬을 기회까지 빼앗는다. lint는 의심 지점을 표시하는 손전등이지, 문을 잠그는 자물쇠가 아니다.
이 챕터의 척추는 이 한 장의 도식이다. 대사 한 줄이 작가의 손에서 출발해 성우의 입까지 가는 동안, 검수 게이트가 단계마다 깔린다. 그리고 그 흐름 한가운데에 굵은 점선이 있다.
flowchart TD
A["작가 본문 작성 (L2)"] --> B["voice_lint 자동
위반·의심 리포트"]
B --> C["작가 자기 검수 (15분)"]
C --> D["리드 내러티브 샘플 검수
챕터당 10% 표본"]
D --> E["L3 dialogue_id 발급
+ 번역키 매핑"]
E --> F["번역·로컬라이즈 검수"]
F -.->|"━━━ 가역 / 비가역 경계 ━━━
이 선 위는 텍스트, 무한 수정 가능
이 선 아래는 음원, 수정 불가"| G
G["VA 캐스팅"] --> H["더빙 녹음 (최종·비가역)"]
H --> I["음원 빌드 반영"]
classDef reversible fill:#e8f4ea,stroke:#3a7d44,stroke-width:1px,color:#1b3a22;
classDef irreversible fill:#f7e3e3,stroke:#b23b3b,stroke-width:2px,color:#5a1414;
class A,B,C,D,E,F reversible;
class G,H,I irreversible;
초록은 가역 단계, 빨강은 비가역 단계다. 점선 위(초록)는 전부 텍스트다. 대사 한 줄이 마음에 안 들면 키보드로 고치면 된다. 비용은 작가의 몇 분이다. 점선 아래(빨강)는 음원이다. 성우가 부스에서 그 줄을 읽고 음원이 빌드에 들어간 순간, 그 대사는 자산으로 고정된다. 고치려면 같은 성우의 같은 컨디션, 같은 스튜디오를 다시 잡아야 하고, 출연료·스튜디오·디렉터 시간이 처음과 똑같이 또 든다. 빠듯한 일정이면 같은 성우의 추가 세션 자체가 안 잡히기도 한다.
그래서 단 하나의 규칙이 모든 워크플로를 지배한다 — 모든 검수 게이트는 점선 위에서 끝난다. 녹음은 검수 단계가 아니다. 검수가 다 끝난 결과를 자산으로 고정하는 단계다. 점선 아래에서 "이 대사 이상한데"라는 의문이 생기면, 그건 더 검수할 자리가 아니라 윗단계 검수가 누락됐다는 신호다. 손전등은 점선 위에서 다 비춰야 한다. 부스는 캄캄해도 되는 곳이 아니라, 캄캄하면 안 되는 곳이다.
도식 한가운데 리드 샘플 검수가 "챕터당 10% 표본"으로 잡혀 있다. 이 비율은 검수 시간과 정확도의 균형점이다(저자 운영치, 미검증 추정). 5% 밑으로 내리면 사고가 새고, 20% 위로 올리면 리드 한 사람이 병목이 된다. lint가 위반·의심을 미리 걸러주기 때문에 표본은 lint 통과분 중에서 뽑는다 — 사람의 눈은 도구가 못 잡는 맥락 오류(예: 정당해 보이는 "운명" 인용이 사실은 캐릭터 붕괴)에 집중한다.
캐릭터가 끝까지 똑같이 말하면 plot이 정체된다. 동료의 죽음을 겪은 학자가 그 전과 똑같은 톤으로 말하면 오히려 가짜다. 변화가 의도된 것이면 voice_profile도 같이 버전을 올린다.
---
character_id: K_007
voice_profile_versions:
- v1: ch01~ch05 (초기 — 감정 억제, 단문 학자)
- v2: ch06~ch10 (동료 죽음 후 — 감정 표현 빈도 증가)
- v3: ch11~ (각성 후 — 직접 화법 등장)
---
버전마다 profile 파일이 따로 있고, voice_lint는 검사 대상 대사의 챕터 번호를 보고 어느 버전을 적용할지 고른다. ch07 대사에 v1의 "감정 억제" 규칙을 들이대면 멀쩡한 변화 대사가 죄다 의심으로 뜬다. 변화는 버그가 아니라 설계다.
버전을 올리는 신호는 세 가지다. 작가가 의도적으로 톤을 흔들면 신규 버전을 발의하고 리드와 합의한다. voice_lint의 의심 건수가 한 캐릭터에서 점점 늘면, 그건 작가가 무의식적으로 톤을 옮기고 있다는 — 버전 갱신 시점이 왔다는 신호다. character_bible에 변화 이벤트(죽음·각성·배신)가 추가되면 profile 갱신 alert이 뜬다. 다만 한 캐릭터가 챕터마다 변하면 일관성이 무너지므로, 현실적인 버전 수는 캐릭터당 2~4개다.
[방향 표지 — 보이스 공간으로 캐릭터 사이를 본다면 (아직은 시기상조)]
voice_lint가 규칙으로 '한 캐릭터 안'의 일관성을 지킨다면, 캐릭터별 실제 대사 묶음(한 캐릭터가 말한 대사 전체)을 한 점으로 임베딩한 '보이스 공간'은 '캐릭터 사이'가 충분히 벌어져 있는지를 본다 — 점들이 서로 뭉쳐 가면 그게 §5.3.1·§5.4.1이 지목한 보이스 평준화·수렴의 직접 측정값이 된다. 단 거리 임계를 절대수치로 고정하지 말고 '뭉치고 있다'는 방향 표지로만 읽을 일이며,voice_lint를 대체하는 판정 게이트가 아니다(이 발상은 §8.2.7의 차원 벡터 압축과 같은 자리이고, 개념 직관은 부록 M에 지도 한 장으로 풀어 두었다 — 처방이 아니라 방향 표지다).
대사가 다국어로 번역되고 성우 보이스가 입혀지면 관리 단위가 곱절로 늘어난다. 한국어 한 줄이 영어·동남아 언어로 갈라지고, 그 각각에 톤이 실린다.
번역 일관성에서 가장 자주 새는 자리는, 같은 표현이 챕터마다 다르게 번역되는 것이다(번역 메모리 일관성 검사로 잡는다). 캐릭터 voice_profile이 번역에 반영 안 되는 것(캐릭터별 번역 가이드를 따로 붙인다), 신규 어휘가 용어집에 미등록인 것(용어집 lint)이 뒤따른다. 번역 가이드는 voice_profile에서 자동 생성한다 — "이 캐릭터는 격식체 100%, 감탄사 없음, 신비주의 어휘 금기"를 번역 지시서 머리에 자동으로 붙인다. 번역가가 그 학자를 영어로 옮길 때 같은 경계를 본다.
VA(Voice Actor, 성우) 검수는 점선 아래에 닿기 직전, 마지막 텍스트 가역 단계의 검수다. 톤 일관성(분노·슬픔 표현 강도)은 디렉터와 내러티브가, 발음 정확성(고유명사)은 용어집 담당이, 호흡·끊김(profile의 "짧은 끊김 자주" 같은 지시)은 디렉터가 본다. 검수 결과는 voice_review_log.md(L4)에 통과·반려로 기록하고, 다음 캐릭터 캐스팅 때 참조한다.
반려는 가능한 한 캐스팅·녹음 직전에 끝낸다. 부스 안에서 발견되는 대본 오류는 그날 세션을 통째로 무너뜨리고 다음 세션 일정까지 흔든다. 그렇다고 녹음을 미루면서 검수를 끄는 게 답은 아니다. 검수가 부스 직전에서 자주 막힌다면, 그건 윗단계(작가·리드) 워크플로가 늦은 것이지 녹음 일정의 문제가 아니다.
프로젝트 A에서 voice_profile + voice_lint 도입 전후를 6개월 추적했다. 절대 건수는 저자 추정(미검증), 방향·비율만 신뢰하면 된다.
| 항목 | 도입 전 | 도입 후 | 방향 |
|---|---|---|---|
| 챕터당 보이스 사고 (출시 후) | 5~8건 | 1~2건 | 약 1/4 |
| 신규 NPC 보이스 정착 | 챕터 3개 | 챕터 1개 | 1/3 |
| 작가 1인 관리 NPC 수 | 약 15명 | 약 40명 | 약 2.5배 |
| 번역 일관성 사고 (챕터당) | 10~15건 | 2~4건 | 약 1/4 |
| 보이스 검수 시간 (챕터당) | 3일 | 1일 | 1/3 |
가장 의미 있는 줄은 작가 1인 관리 NPC 수다. 약 2.5배라는 건 작가를 줄였다는 뜻이 아니라, 같은 작가가 챕터당 NPC 다양성을 늘릴 수 있다는 뜻이다. 세계가 더 붐빈다.
비용 구조를 보면 도입 비용보다 운영 비용이 훨씬 작다. voice_profile 작성은 메인 7명에 작가 2주, voice_lint 도구는 개발 1~2주에 유지보수 월 1일. 운영 쪽은 챕터당 작가 자기 검수 15분, 리드 샘플 검수 약 2시간(10% 표본), 변화 챕터의 profile 갱신이 캐릭터당 1~2일이다. 운영 비용이 작아야 시스템이 살아남는다. 운영이 무거운 도구는 한 분기 안에 조용히 폐기된다.
| 패턴 | 처방 |
|---|---|
| profile에 추상 묘사만("진중한") | 5항목마다 실제 대사 예시 강제 |
| 처음부터 50명 풀 작성 노림 | 메인 7명 풀 + 나머지는 본문 누적 역추출 |
| voice_lint 자동 거부형 | 위반·의심 + 작가 판정. 거부는 사람만 |
| 금기를 기계적으로 넓힘 | 금기에 예외 맥락("반증 인용 가능") 병기 |
| 캐릭터 변화 시 profile 미갱신 | 버전 관리(v1·v2·v3), 챕터 번호로 적용 |
| 번역에 profile 미전달 | profile에서 번역 가이드 자동 생성 |
| 녹음 후 대사 수정 시도 | 녹음은 비가역. 검수는 점선 위에서 종결 |
| 녹음 일정에 검수 압축 | 윗단계 워크플로 개선으로 푼다 |
| profile을 머릿속에만 보관 | 무조건 파일화. 머릿속은 작가 교체 시 소실 |
신규 챕터 대사가 올라왔을 때, profile 한 개로 한 바퀴 돌리는 최소 절차입니다.
setup
1. 대상 캐릭터의 voice_profile_<id>.md를 여세요. 없으면 본문 대사 20~30줄을 모읍니다.
2. 신규 대사를 id / 캐릭터 / 내용 형식의 평문 묶음으로 준비합니다.
prompt
K_007의 voice_profile §5(금기 표현)이야.
[금기 목록 붙여넣기]
ch04 신규 대사 32줄.
[id / 내용 형식으로 붙여넣기]
각 대사를 [위반](금기 어휘 직접 포함) / [의심](금기 영역에 닿지만 예외 맥락 가능)
/ [정상]으로 분류해줘. [위반]·[의심]은 id·내용·사유를 표로. 판정은 내가 하니까
자동으로 거부하진 마.
verify 1. [위반]을 먼저 보세요. 명백하면 텍스트를 고칩니다(점선 위라 무료). 2. [의심]은 맥락으로 판정합니다. 정당한 인용이면 통과, 아니면 수정합니다. 3. 통과분에서 10%를 표본으로 리드에게 보내 맥락 오류를 한 번 더 거릅니다. 4. 모든 판정이 끝난 뒤에만 dialogue_id를 발급하고 녹음 큐로 넘기세요. 부스 앞에서는 더 이상 검사하지 않습니다.
1인 축소판 — 도구를 못 만드는 1인 개발자라면, voice_profile을 캐릭터당 §5(금기 표현) 한 항목만 적으세요. 신규 대사를 쓸 때마다 그 금기 목록을 프롬프트 머리에 붙여 AI에게 "이 목록에 걸리는 줄만 표시해줘"라고 시킵니다. 도구 없이 프롬프트 한 줄로 lint의 80%를 얻습니다. 녹음(또는 TTS)에 넘기기 전, 이 한 번만 통과시키면 부스 앞 점선은 지켜집니다.
월요일 아침 기획 회의. 화이트보드에 한 줄이 적혀 있다. "출시까지 사이드 퀘스트 1,000개." 누군가 계산기를 두드린다. 작가 한 명이 하나에 하루를 쓰면 4년. 다섯 명이 붙어도 1년에 가깝다. 방 안의 공기가 무거워진다. 24년째 이 방에 앉아 온 나는 그 숫자 앞에서 사람들이 늘 같은 두 갈래로 갈라진다는 걸 안다. 한쪽은 "분량을 줄이자"고 하고, 다른 쪽은 "도구로 찍어내자"고 한다. 그리고 거의 항상 결정은 둘 다였다.
절차적 콘텐츠 생성(Procedural Content Generation, 이하 PCG)은 그 "도구로 찍어내자" 쪽의 오래된 답이다. 던전 룸 배치, 무기 옵션 조합, 적 스폰 풀은 20년 전부터 룰북과 확률표로 자동화돼 왔다. 새로운 건 PCG 자체가 아니라, 자연어·이미지·서사가 들어가는 자리에 LLM과 생성 모델이 올라왔다는 점이다.
그런데 이 책에서 말하려는 건 "AI를 PCG에 붙이세요"가 아니다. 그건 누구나 한다. 문제는 어디에 붙이느냐다. 콘텐츠 한 덩어리를 놓고, 그게 자동화의 어느 강도와 구조의 어느 층에서 만나는지를 한 칸으로 못 박지 않으면, 도구는 있는데 자리가 없는 상태가 된다. 이 챕터는 그 한 칸을 좌표로 그리는 법, 그리고 그 칸 위에서 콘텐츠 하나가 실제로 파이프라인을 한 바퀴 도는 모습을 본다.
전통 PCG는 결정론에 강하다. 같은 입력에 같은 출력이 나오고, 검증이 가능하다. 던전 룸 그래프, 무기 옵션 prefix·suffix, 적 스폰 분포는 그래서 일찍 자리 잡았다. "불꽃의 검 +5"는 20년 전에도 자동으로 나왔다.
문제는 항상 그 다음 자리였다. 룸은 배치됐는데 룸 안 NPC의 이름·외형·짧은 배경은 작가 손에 남았다. "불꽃의 검 +5"는 나오지만 "왕이 잃어버린 마지막 검"이라는 한 줄은 안 나왔다. 퀘스트 generator가 목표와 보상의 조합을 뽑아 줘도 "왜 이 퀘스트를 하는가"는 사람이 적었다.
규모가 큰 게임에서는 이 자리가 늘 병목이었다. 양산 가능한 영역과 사람 손이 필요한 영역의 비율은 대략 4 대 6이었고, 그 사람 손 6쪽이 일정의 대부분을 먹었다. 양산 라인이 4쪽을 빠르게 만들어도 6쪽이 따라오지 못하면 사이클 전체가 그 속도로 묶인다.
LLM과 이미지 모델이 들어오는 자리가 정확히 거기다. 룰북이 못 다루던 자연어·서사·시각 영역까지 양산 가능 범위가 넓어진다. 그렇다고 그 자리를 통째로 AI에 넘기는 게 답은 아니다. AI는 매번 약간 다른 답을 내고, 컨텍스트가 비면 일반 RPG 평균을 토해낸다. 그래서 결합 지점의 설계가 필요하다. 결합 지점은 두 개의 좌표축으로 정의된다.
세로축은 사람과 룰북과 AI를 어떤 비율로 섞느냐다. 저자가 일하는 어느 MMORPG 개발사(이하 '프로젝트 A')에서는 네 단계로 끊어 쓴다.
L0 — 완전 수작업. 모든 글자·결정이 사람 손에서 나온다. 메인 퀘스트 본문, 시그니처 캐릭터 대사, 분기 결말. 일관성과 서사 깊이가 게임 정체성에 직결되는 자리.
L1 — 룰북 자동화. 전통 PCG의 자리. 룰북·확률표·BSP 같은 결정론 알고리즘이 출력을 만들고 사람은 검수만 한다. 던전 룸 배치, 무기 옵션 조합, 적 스폰이 대표.
L2 — 룰북 + AI 보조. 룰북이 골격을 잡고 AI가 디테일을 채운다. 사이드 퀘스트 시놉시스, 일반 NPC 이름·짧은 배경, 사냥터 소개문. 사람은 입력 메타데이터와 마지막 검수 게이트만 책임진다.
L3 — AI 우선 + 사람 검수. AI가 본문을 만들고 사람은 검수에만 들어간다. 매력적이지만 비결정성·환각·일관성 손상 위험이 모두 여기 모인다.
핵심은 L2다. L1의 안정성과 L3의 양산력을 합치고, 두 영역의 단점은 검증 게이트로 막는다. L3는 빨리 도입하고 싶은 유혹이 크지만, 검수 부담이 폭증해 한두 분기 안에 폐기되는 사례를 여러 번 봤다. 100개 중 70개가 의심 항목으로 올라오면, 사람이 처음부터 100개 쓰는 것보다 비싸다.
세로축만 들고는 양산 라인이 굴러가지 않는다. 콘텐츠 자체가 층으로 분해돼 있어야 자동화가 들어올 자리가 생긴다. 이게 5부에서 다룬 Layer 분해이고, 콘텐츠 분야에서의 가로축이다. 다섯 계층이 각각 절차적 생성의 한 역할(앵커·룰북·본문·수치·게이트)에 대응한다는 일반 설명은 §2.3.6에서 다뤘고, 여기서는 콘텐츠 양산 라인에 그대로 대입한다. Layer 0 비전은 톤·세계관 앵커(매 생성마다 주입), Layer 1 시스템은 생성 룰북(규칙·확률표·태그 체계), Layer 2 콘텐츠는 생성 결과가 쌓이는 본문 자리(사이드 퀘스트·NPC 배경·도시 소개문), Layer 3 데이터는 수치·ID·관계(보상·스폰·곡선), Layer 4 빌드·QA는 검증 게이트(lint·일관성 검사·작가 검수)다.
이 두 축은 다른 이야기다. 세로축은 "사람이 얼마나 손대나", 가로축은 "콘텐츠의 어느 부위인가"를 말한다. 그런데 둘은 곱셈으로만 의미가 산다. 콘텐츠 하나를 두 축의 교차점, 즉 한 칸에 못 박을 때 비로소 "이건 누가, 어디를, 어떻게 만드는가"가 정해진다.
여태 글로 풀어 온 두 축을 한 장의 격자로 겹쳐 본다. 가로는 콘텐츠의 Layer, 세로는 자동화 강도. 각 칸에 들어간 라벨은 프로젝트 A에서 실제 그 칸을 차지하는 콘텐츠다. 색이 진한 칸일수록 양산 라인의 무게중심에 가깝다.
이 격자가 이 챕터의 핵심이다. 산문으로 흩어져 있던 "메인은 L0", "사이드는 L2", "보상은 룰북" 같은 판단이 한 좌표로 모인다. 회의에서 새 콘텐츠가 안건에 오르면 "이건 어느 칸인가" 한 질문이면 된다. 칸이 정해지면 그 칸의 세로 좌표가 누가 손대는지를, 가로 좌표가 어느 부위인지를 알려 준다.
격자를 읽다 보면 두 가지가 눈에 들어온다. 첫째, 무게중심(짙은 칸)은 L2 행 × Layer 2 열에 있다. 사이드 퀘스트 골격·NPC 배경이 그 자리다. 양산 라인의 심장이다. 둘째, 한 콘텐츠가 한 칸에만 있는 게 아니다. 사이드 퀘스트는 본문(Layer 2)이 L2 칸에 있지만, 그 보상 수치(Layer 3)는 L1 칸으로 내려간다. 같은 퀘스트라도 부위마다 다른 칸에 산다. 이게 두 축을 분리해 둔 이유다.
무게중심 칸 — L2 행 × Layer 2 열, 사이드 퀘스트 골격 — 위에서 콘텐츠 하나가 실제로 한 바퀴 도는 모습을 본다. 흐름은 다음과 같다.
flowchart TD
A["Layer 0 비전 주입
(톤 앵커: 동양 풍 향촌)"] --> B["Layer 1 룰북
(목표·보상 슬롯 골격 생성)"]
B --> C["L2 AI 보조
(NPC 동기·한 줄 배경 채움)"]
C --> D{"Layer 4 검수 게이트
(톤·일관성 lint)"}
D -->|통과| E["Layer 3 데이터 확정
(보상 수치는 룰북이 산출)"]
D -->|반려| B
E --> F["빌드 반영"]
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
class B,D code;
class C ai;
class E data;
class F pass;
이 흐름을 워크드 트랜스크립트로 한 번 따라가 본다. 향촌 마을의 잡일 퀘스트 하나를 만든다고 하자.
1단계 — Layer 1 룰북이 골격을 뽑는다. 룰북은 결정론이다. 슬롯만 채워 나온다.
[퀘스트 골격 — 룰북 출력]
지역: 향촌_3
의뢰인 역할: 약초꾼
목표 타입: 수집(약재 x6)
방해 요소: 늑대 스폰(밤)
보상 슬롯: { gold: ?, exp: ?, item: 채집낫 }
2단계 — AI에 디테일을 요청한다. 프롬프트 전문은 이렇다. (Layer 0 비전을 컨텍스트로 먼저 입력한다.)
너는 동양 판타지 향촌을 무대로 한 MMORPG의 퀘스트 작가다. 톤 앵커: "소박하지만 미신과 두려움이 깔린 시골". 아래 골격을 받아, 의뢰인의 동기 한 줄과 NPC 이름, 그리고 플레이어에게 보이는 의뢰문 두 문장을 써라. 보상 수치는 절대 만들지 마라(시스템이 정한다). 출력은 JSON. 골격: { 지역: 향촌_3, 의뢰인: 약초꾼, 목표: 약재 6개 수집, 방해: 밤의 늑대, 보상아이템: 채집낫 }
3단계 — Claude의 날것 출력. (요약하지 않고 그대로 옮긴다.)
{
"npc_name": "삼월 어멈",
"motive": "밤마다 늑대 울음에 약초밭이 망가져, 마지막 약재를 캐러 갈 엄두를 못 낸다.",
"quest_text": "밤이 되면 늑대들이 약초밭을 헤집어 놓는다오. 무서워 발이 안 떨어지니, 산기슭의 약재 여섯 뿌리만 대신 캐다 주시구려.",
"reward": { "gold": 120, "exp": 300 }
}
여기서 멈춰서 봐야 할 게 있다. AI는 reward 필드를 시키지 않았는데도 만들어 넣었다. 이게 1축과 2축이 왜 분리돼야 하는지를 그대로 보여 준다. 보상 수치(Layer 3)는 L1 룰북의 자리지, AI(L2)의 자리가 아니다. AI에 그걸 맡기면 호출마다 숫자가 흔들려 보상 곡선이 무너진다.
4단계 — 사람 검증·거부. 검수자는 두 가지를 한다. (1) reward 필드를 삭제한다 — 이건 룰북이 채울 칸이다. (2) 톤을 본다. "삼월 어멈", 동기 한 줄, 의뢰문 두 문장은 향촌 톤에 맞다. 통과. 만약 AI가 "마법사 길드의 의뢰"처럼 세계관 밖 단어를 넣었다면 여기서 반려하고 골격 단계로 되돌린다.
5단계 — Layer 3 데이터 확정. 삭제된 보상 슬롯을 룰북이 다시 채운다. 지역 레벨·목표 난이도에 묶인 결정론 공식이다. gold: 85, exp: 240. AI가 임의로 뱉었던 120·300이 아니라, 곡선에 맞는 값이 들어간다.
이 한 바퀴가 무게중심 칸의 표준 사이클이다. 룰북이 골격, AI가 살, 사람이 게이트, 룰북이 다시 수치. 콘텐츠 1,000개가 모두 이 사이클을 돈다. 칸이 정해져 있으니 매번 "이건 누가 만드나"를 다시 논쟁하지 않는다.
새 콘텐츠를 격자의 어느 칸에 둘지 결정하려면 다섯 질문이 도움이 된다. 회의에서 양산 안건이 오를 때마다 적어 두고 함께 답해 보면, 칸 배치의 일관성이 한 분기 안에 정착한다.
하나, 양산 부담이 얼마인가. 출시까지 N개가 필요한가. N이 100을 넘으면 L0 행은 거의 불가능하다.
둘, 일관성 요구가 얼마나 큰가. 콘텐츠 간 일관성이 체험의 핵심이면 검수 게이트(Layer 4)가 강해야 하고, 다양성이 핵심이면 더 위 행으로 갈 여지가 있다.
셋, 비결정성을 허용할 수 있는가. 매번 약간 다른 결과가 풍부함을 만드는 영역인지, 같은 결과가 신뢰의 핵심인 영역인지.
넷, 검수 비용은 얼마인가. 콘텐츠당 5분인지 30분인지가 운영 사이클의 길이를 정한다.
다섯, 사고가 났을 때의 비용은 얼마인가. 폐기·재작성이 자유로운지, 한 번 나가면 사용자 사고로 직결되는지.
사이드 퀘스트에 이 다섯을 던지면 답이 한 방향으로 모인다. 1,000개 이상(L0 불가), 일관성은 메인보다 낮음, 비결정 허용, 검수 5~10분, 사고 비용 낮음(개별 폐기 가능). 다섯 답이 모이니 L2 행 × Layer 2 칸이 자연스럽다. 같은 다섯을 메인 퀘스트에 던지면 정반대로 모인다. 50개, 일관성·서사 깊이 최고, 비결정 불허, 검수 비용 큼, 사고 비용 매우 큼 — L0 칸이다.
격자를 그려 놔도 빠지는 함정은 비슷하다. 네 가지가 반복된다.
첫째, L3 행부터 시작하는 경우. "AI가 알아서 100개"라는 기대로 출발하면 검수가 폭증한다. L1 칸을 먼저 정착시키고, L2로 올라가고, L3는 일부에만 신중히. 위 미니 파이프라인에서 보상 필드를 사람이 지운 그 한 동작이, L3 행이 왜 위험한지를 작게 보여 준다.
둘째, 룰북 없이 통째로 AI에 위임하는 경우. "사이드 퀘스트 100개 만들어 줘"는 일반 RPG 평균을 부른다. Layer 1 룰북이 골격을 먼저 잡고 AI는 그 위에서 살을 채워야 우리 게임의 콘텐츠가 나온다. 룰북 한 권을 쓰는 일은 PCG에서 가장 품이 들고 가장 재미없는 작업이지만, 이걸 건너뛰면 그 위의 모든 양산이 평균값으로 주저앉는다.
셋째, 검수 게이트(Layer 4)가 빈 경우. AI 출력이 자동으로 빌드에 반영되면 일관성 사고가 직결된다. 어느 칸이든 사람 게이트는 필수다.
넷째, 비용만 보고 도구를 정하는 경우. LLM API 비용은 분기마다 떨어지지만 일관성 사고 비용은 떨어지지 않는다. 도구 결정은 API 비용에 일관성·검수 시간의 합계를 더해서 본다.
프로젝트 A에서 사이드 퀘스트를 L0 칸에서 L2 칸으로 옮긴 뒤 6개월을 측정했다. 아래 수치 중 절대값은 저자 추정(미검증)이고, 변화의 방향과 비율이 실측에서 관찰된 부분이다.
| 항목 | L0 시기 | L2 전환 후 |
|---|---|---|
| 작가 1인당 퀘스트 1개 작성 | 약 4시간 | 약 50분 (메타 30분 + AI 5분 + 검수 8분) |
| 주당 양산 | 5개 | 30~40개 |
| 폐기율 | 거의 0% | 약 20% |
| 일관성 사고 (분기당) | 3~5건 | 5~8건 (보강 후 정상) |
| 작가 만족도 (10점) | 8 | 6 → 7 (정책 보강 후) |
폐기율은 20%로 올라갔지만 양산 속도가 6~8배라 순 처리량은 4~5배 늘었다. 일관성 사고는 분기당 5~8건으로 소폭 늘었으나, 검수 게이트와 룰북 보강으로 분기 안에 정상 범위로 돌아왔다.
가장 큰 변화는 숫자가 아니라 사람이었다. 처음엔 작가들이 "양산 검수자"가 된 기분이라며 만족도가 8에서 6으로 떨어졌다. 이걸 회복하려고 메인 퀘스트와 시그니처 사이드 퀘스트(도시당 1~2개)에 작가 시간을 명시적으로 보장하는 정책을 끼웠다. 양산 라인이 작가 시간을 빨아들이는 게 아니라, 그 시간을 메인으로 돌려보내는 도구가 되도록 못 박은 것이다. 6개월 후 만족도는 7로 돌아왔다.
이 측정에서 가져갈 한 가지. 칸을 옮기는 결정은 처리량·작가 시간 분배·만족도가 같이 따라가야 한다. 처리량만 보면 양산은 성공이지만 사람은 떠난다.
Layer 분해가 절차적 생성의 전제라는 일반 논제는 §2.3.6에 있다. 여기서는 그것이 PCG 격자 위에서 어떻게 드러나는지만 본다. 가로축(Layer 0~4)이 흐릿한 팀에서는 어떤 칸도 안정적으로 못 굴러간다. Layer 0 비전이 어디 있는지 모르면 generator마다 톤 앵커가 비어 일반 RPG 평균이 나오고, Layer 1 룰북과 Layer 2 본문이 한 파일에 섞이면 규칙 한 줄을 고칠 때 본문 수십 곳을 같이 만져야 하며, Layer 3 데이터가 본문에 입력되어 있으면 보상 곡선 한 번 조정에 작가가 1주를 쓴다 — 위 미니 파이프라인에서 보상을 별도 슬롯으로 떼어 둔 이유가 이거다.
그래서 PCG 도입 전에 점검할 건 도구 선택이 아니라 가로축이 분해돼 있는가다. 5층이 갖춰진 팀에서 L1 generator를 붙이는 비용은 작가 한 명의 한 분기다. 5층이 섞인 팀에서 같은 도입은 두 분기 안에 일관성 사고로 폐기된다.
처음부터 다섯 칸이 완벽할 필요는 없다. 분리는 점진적으로, 인터페이스는 좁게. 첫 분기에는 Layer 0 톤 한 줄과 Layer 1 룰북 한 권만 떼어 놔도 generator 들어올 자리가 열린다. 그렇다고 무한히 미뤄도 된다는 뜻은 아니다. Layer 2 본문과 Layer 3 데이터가 끝까지 한 덩어리면, 다음 장의 구체 도구도 자리를 못 잡는다.
다음 장에서는 이 격자의 무게중심 칸을 차지하는 구체 도구 하나를 해부한다. 도시별 사냥터를 양산하는 proj_city_hunting_generator다. 입력 메타데이터·룰북 골격·AI 본문·검증 게이트가 한 사이클로 어떻게 묶이는지, 이 챕터의 미니 파이프라인이 실제 도구 규모에서 어떻게 커지는지를 본다.
setup. 양산 후보 콘텐츠 1종을 고르세요(예: 사이드 퀘스트). Layer 0 톤 한 줄과 Layer 1 룰북 골격(슬롯 정의)을 별도 파일로 떼어 둡니다. 보상 수치 슬롯은 룰북 쪽에 비워 둡니다.
prompt. 비전을 컨텍스트로 입력한 뒤 골격을 주고, "보상 수치는 만들지 마라, 출력은 JSON"을 명시하세요. 위 2단계 프롬프트를 그대로 변형해 쓰면 됩니다.
verify. 세 가지를 보세요. (1) AI가 보상 필드를 임의로 넣었으면 삭제합니다(L3은 룰북 자리). (2) 세계관 밖 단어가 있으면 골격 단계로 반려합니다. (3) 통과분만 룰북이 보상 수치를 채워 빌드에 넣습니다.
1인 축소판. 팀이 없어도 됩니다. 본인이 룰북 한 권(슬롯 5개)과 톤 한 줄만 텍스트 파일로 만드세요. 퀘스트 10개를 위 사이클로 돌려 보고, 검수에서 몇 개를 반려하는지 세어 보세요. 반려율이 30%를 넘으면 칸이 잘못된 것이니 — 룰북 골격을 더 촘촘히 하거나 한 행 내려(L1) 다시 보세요. 반려율이 안정되면 그게 본인 규모에서 그 칸이 작동한다는 신호입니다.
1차 독자: 콘텐츠 양산을 책임지는 MMORPG 기획자 (중규모(10~50인) 팀) 1인/취미 독자용 축소 버전: §6.2.10 「혼자라면 이만큼만」
출시까지 도시 30개가 필요하다는 일정표를 처음 받은 날의 계산이 아직 기억난다. 한 도시는 소개문 5~10줄, 사냥터 3~5곳, 사냥터마다 NPC 5~10명과 사이드 퀘스트 2~3개, 특산 아이템 1~3종, 도시 보스 1체로 이뤄진다. 손으로 한 도시를 빚으면 1~2주가 든다. 30개면 작가 한 명이 6개월을 통째로 도시에만 쓰는 셈이다.
그런데 그 6개월이 없었다. 작가 시간은 메인 퀘스트와 시그니처 캐릭터에 묶여 있었고, 도시 30개는 그 작업과 병행돼야 했다. "AI한테 도시 30개 만들어 달라고 하면 되지 않나"는 첫 충동은 곧 무너졌다. 통째로 시키면 서로 비슷한 판타지 마을 30개가 나온다. 이 장은 그 충동 대신 만든 도구 city_hunting_generator가 입력·룰북·AI·검증의 네 단계를 어떻게 한 사이클로 묶었는지, 그리고 그 사이클을 실제로 한 번 끝까지 돌리면 무엇이 나오고 무엇이 폐기되는지를 본다.
저자 실제 운영 메모 이 장의
city_hunting_generator는 저자가 회사 R&D 폴더에서 운영 중인 실제 도구를 익명화한 것이다. 파일명·코드 구조·검증 항목은 실제 도구를 충실히 옮겼고, 도시 이름(silvermark 등)·회사 고유 명칭은 책용으로 치환했다. 출력 본문은 실제 세션을 재구성한 것이다.
도구의 전체 흐름은 네 단계다. 핵심은 1단과 3단이 결정론(룰북)이고 2단만 AI라는 점이다. 룰북이 골격과 검증을 양쪽에서 잡아 주면, 가운데 낀 AI가 매번 약간 다른 답을 내도 도시 간 일관성이 흔들리지 않는다. 사람은 첫 입력(메타데이터)과 마지막 게이트(검수)에만 들어간다.
flowchart TB
A["입력: 도시 메타데이터 yaml
(사람 15~20분/도시)
lore_seeds 3개 · forbidden_names 자동부착"]
A --> B["1단 결정론: rules.py
generate_skeleton()
사냥터 수·적 분포·보상곡선·보스"]
B --> C["2단 AI: 프롬프트 3종
L0 비전 캐싱 + L1 룰 주입
+ L2 인접 도시 본문
→ 소개문·NPC·사이드퀘스트"]
C --> D{"3단 결정론: lint
이름중복·금기어·보이스
·분량·보상범위"}
D -->|위반 alert| E["4단 작가 검수 게이트
(5~10분/도시)
폐기·재생성 결정"]
E -->|재요청| C
E -->|통과| F["빌드 반영"]
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
class B,D code;
class C ai;
class A,E human;
class F pass;
이 그림에서 사람의 손이 닿는 곳은 두 군데뿐이다. 맨 위에서 메타데이터 한 페이지를 깨끗하게 넣는 자리, 맨 아래에서 lint가 못 잡는 톤·서사 판단을 내리는 자리. 그 사이의 지루한 골격 생성과 본문 양산은 룰북과 AI가 돌린다. 결정적인 설계는 lint(3단)가 위반을 발견해도 자동으로 폐기하지 않고 작가 게이트(4단)로 alert만 올린다는 점인데, 그 이유는 §6.2.5에서 본다.
작가는 도시 1개당 메타데이터 한 페이지를 쓴다. 작성 시간은 15~20분. 짧지만 이 한 페이지가 다음 세 단계의 입력 전부다.
# city_021_silvermark.meta.yaml
city_id: city_021_silvermark
region: west
climate: cold_arid
dominant_faction: scholar_guild
cultural_tone: scholarly_strict
level_range: [25, 30]
lore_seeds:
- 100년 전 마법 봉인의 중심지였음
- 봉인 약화의 첫 징후가 이 도시에서 관측됨
- 학자 길드 본부 위치
neighbors: [city_018, city_023]
# forbidden_names: (스크립트가 자동 부착 — 작가 입력 불필요)
가장 중요한 슬롯은 lore_seeds다. 3~5개의 핵심 사건이 도시의 정체성을 잡는다. 너무 적으면 AI가 일반 판타지 도시를 토해내고, 너무 많으면 사건끼리 모순된다. 저자 경험상 3개가 가장 안정적이었다.
forbidden_names는 작가가 채우지 않는다. 기존 도시·캐릭터 이름 목록을 스크립트가 읽어 와 메타데이터에 자동 부착한다. 30개 도시 × 평균 50명 NPC가 쌓이면 1,500개 이름의 중복을 사람 머리로 검사하는 건 불가능하기 때문이다. "다른 도시 NPC랑 안 겹치게 해 줘"를 매번 손으로 적을 필요가 없다.
룰북은 메타데이터를 받아 도시의 구조 골격을 만든다. 코드는 단순하다.
# city_hunting_generator/rules.py (골격)
def generate_skeleton(meta):
region_rules = REGION_RULES[meta.region]
hg_count = region_rules.hunting_grounds_range.sample()
enemy_dist = ENEMY_RULES[meta.climate][meta.dominant_faction]
skeleton = {
"hunting_grounds": [
{
"id": f"{meta.city_id}_hg_{i}",
"level": meta.level_range[0] + i,
"enemy_types": enemy_dist.sample(k=3),
"reward_curve": calc_reward(meta.level_range[0] + i),
"npc_count": region_rules.npc_per_hg,
"sidequest_count": region_rules.sidequest_per_hg,
}
for i in range(hg_count)
],
"boss": {
"id": f"{meta.city_id}_boss",
"level": meta.level_range[1] + 2,
"pattern": BOSS_PATTERNS[meta.region],
},
}
return skeleton
결과는 결정론이다. 같은 메타데이터를 넣으면 같은 골격이 나온다. 보상 곡선이 region·level별 표준 범위 안에 있는지, 적 분포가 climate·faction 룰에 맞는지가 코드로 보장되고, 회귀 테스트가 잡힌다. 이 단계는 AI에 절대 맡기지 않는다. 보상 곡선을 AI가 매 호출마다 다른 숫자로 뽑으면 도시 간 밸런스가 그 자리에서 흔들리기 때문이다.
silvermark의 메타데이터를 넣으면, rules.py는 사냥터 4곳(city_021_silvermark_hg_0~hg_3), 각 사냥터에 NPC 슬롯 6칸·사이드 퀘스트 슬롯 3칸, 그리고 레벨 32 보스 1체의 빈 골격을 돌려준다. 아직 이름도 본문도 없는, 채워야 할 칸들의 표다. 그 칸을 채우는 게 2단 AI의 일이다.
룰북이 골격을 만든 다음, AI가 그 위에 자연어 본문을 채운다. 도시 소개문, NPC 이름·외형·짧은 배경, 사이드 퀘스트 시놉시스, 특산 아이템 플레이버 텍스트가 여기서 나온다.
호출 패턴은 컨텍스트 주입의 4층 구조 그대로다. L0 비전(world_premise + tone_manifesto)을 캐싱하고, L1 룰(city_naming_rule + region_west_lore)을 선택 주입하고, L2 인접 본문(다른 도시 NPC 이름 목록)을 더하고, 마지막에 작업 지시를 붙인다. 도시 소개문 프롬프트는 그대로 복사해 쓸 수 있는 형태다.
[L0 컨텍스트] world_premise + narrative_pillar + tone_manifesto (캐싱)
[L1 컨텍스트] city_naming_rule, region_west_lore
[입력] city_021_silvermark.meta.yaml + lore_seeds 3개
이 도시 소개문 6~8줄로 써 줘. lore_seeds 세 개 다 자연스럽게 녹이고,
"평화로운 마을" 같은 RPG 상투어는 빼고. 톤은 학자적이고 엄격하게, 감상 억제.
본문만, 머리말이나 해설 없이.
같은 패턴이 NPC 양산, 사이드 퀘스트 호출에 그대로 반복된다. 차이는 컨텍스트와 출력 형식뿐이다. NPC 호출에는 forbidden_names(다른 도시 NPC 이름 중복 금지) 슬롯이 들어가고, 사이드 퀘스트 호출에는 "발주 NPC를 위에서 생성한 NPC 중에서 고르라"는 제약이 들어간다.
그러면 실제로 무엇이 나오는가. 한 번 끝까지 돌려 본다.
추상적으로 "AI가 본문을 채운다"고만 적으면 이 도구가 진짜 무엇을 뱉는지 알 수 없다. silvermark 도시 하나의 사이클을 입력에서 폐기까지 한 번 끝까지 따라간다.
[2단 AI 출력 — 도시 소개문] (실제 세션 재구성)
실버마크는 봉인의 도시다. 100년 전, 일곱 학자가 서쪽 마력의 범람을 막기 위해 이곳 지하에 봉인진을 세웠고, 도시는 그 진 위에 돌 한 장씩 얹어 자라났다. 학자 길드 본부의 종탑은 봉인의 맥을 읽는 관측소이지, 신을 향한 첨탑이 아니다. 최근 3년, 관측 기록에 한 줄이 반복해 적힌다. "맥이 식는다." 봉인 약화의 첫 징후는 다른 어느 곳도 아닌 이 도시의 계기판에서 처음 떨렸다. 거리의 학자들은 그것을 큰 소리로 말하지 않는다. 다만 종탑의 불이 밤새 꺼지지 않는 날이 늘었을 뿐이다.
여기서 lore_seeds 3개(봉인 중심지·약화 첫 징후·학자 길드 본부)가 모두 본문에 실렸고, "모험가를 환영하는 평화로운 마을" 같은 상투어가 없으며, scholarly_strict 톤(감상 억제, 관측·기록 어휘)이 유지됐다. 이게 통과 가능한 출력의 모습이다.
이어서 같은 사이클이 사냥터 hg_0의 NPC 슬롯 6칸 중 3칸을 채운다.
[2단 AI 출력 — NPC 3명] (실제 세션 재구성)
1) 도렌 베일 (Doren Vale) — 종탑 관측 보조. 50대, 한쪽 눈에 황동 확대경을 영구 장착. 봉인 맥의 미세 진동을 기록하는 일을 30년째 한다. 말수가 적고, 숫자로만 대화한다. "오늘 12.4. 어제 12.1. 올라가고 있어. 좋은 일이 아니야."
2) 미라 코스트 (Mira Kost) — 길드 문서고 사서. 30대, 잉크 얼룩이 손가락에서 안 지워진다. 봉인진 설계 원본을 지키지만, 정작 그 도면을 읽을 줄 아는 학자는 다 죽었다고 믿는다. 외부인에게 경계심이 강하다.
3) 그렘 (Grem) — 종탑 아래 화로지기. 정체불명, 나이 미상. 종탑의 불을 꺼뜨리지 않는 게 유일한 일이며 봉인에 대해 묻는 자에게 "불만 보면 돼"라고만 답한다. (애매 표시 — AI 자체 신고)
세 번째 NPC '그렘'에 AI가 스스로 애매 표시를 달았다는 점에 주목한다. 좋은 프롬프트는 AI가 "이건 제가 확신 못 합니다"라고 말할 수 있게 만든다. 이제 3단 lint가 이 출력 묶음을 친다.
[3단 lint 출력] (실제 형식)
[PASS] 분량 검사: 소개문 7줄 (기준 6~8) [PASS] 보상 범위: hg_0~hg_3 reward_curve 표준 범위 내 [WARN] 이름 중복: "Mira Kost" — city_014_riverhold의 "Mira Veldt"와 성(Kost/Veldt) 상이하나 이름(Mira) 동일. forbidden_names 근접 충돌. [PASS] 금기 어휘: tone_manifesto 위반 0건 [WARN] 보이스 일관성: "그렘" 대사 voice_lint 신뢰도 0.62 (임계 0.70 미달)
lint는 위반 2건을 잡았지만 어느 것도 자동으로 폐기하지 않았다. WARN으로 작가 게이트에 올렸을 뿐이다. 이게 §6.2.1에서 예고한 설계의 핵심이다. 검증기에 자동 거부 권한까지 쥐여 주면, 작가들은 한두 달도 안 돼 그 스위치를 내려 버린다. 기계가 의도된 변형까지 싸잡아 죽이고, 그 경계를 작가가 직접 가늠해 볼 기회마저 빼앗기 때문이다. 그래서 의심스러운 후보를 골라내는 일은 기계에 맡기되, 그 후보를 살릴지 버릴지의 마지막 판단만큼은 사람 손에 남겨 둔다.
[4단 작가 검수 — 판정과 폐기]
작가는 alert 2건을 이렇게 처리했다.
- Mira Kost → 살림. riverhold의 Mira Veldt와 같은 이름이지만 다른 도시, 다른 성, 동시 등장 가능성 없음. 의도적 변형으로 통과. (단, forbidden_names 규칙을 "이름+성 완전 일치"에서 "이름 단독 충돌도 WARN"으로 둘지는 별건으로 메모.)
- 그렘 → 폐기. voice_lint 신뢰도가 낮았던 게 신호였다. 다시 읽으니 "불만 보면 돼"라는 화로지기 캐릭터가 scholarly_strict 도시 톤과 어긋났다. 학자 길드가 지배하는 도시의 NPC가 신비주의 톤으로 빠지면, 도시 정체성이 흐려진다. 폐기 후 재요청.
작가가 폐기를 결정하고 나면 재요청이 한 번 돈다. "그렘 슬롯을 폐기한다. 같은 사냥터의 학자 길드 톤(관측·기록·엄격)에 맞는 화로지기 NPC로 다시 생성하라. 신비주의 어휘 금지." AI는 종탑 화로의 온도를 기록하는, 불조차 데이터로 보는 노인으로 다시 답했고, 그 출력은 voice_lint 0.81로 통과했다. 입력 → 골격 → 본문 → 검증 → 폐기 → 재생성의 한 사이클이 여기서 닫힌다.
이 한 바퀴가 이 책 전체의 Show 기준이다. 도구가 무엇을 뱉고, 무엇이 걸리고, 사람이 무엇을 죽이는지를 한 번이라도 끝까지 보지 않으면, "AI로 양산했다"는 문장은 공허하다.
위 사이클에서 NPC 1명이 폐기됐다. 한 도시 전체로 보면 폐기는 더 쌓인다. 검수 시간은 도시당 평균 5~10분, 폐기율은 NPC가 약 20%, 사이드 퀘스트가 약 33%다.
이 비율의 산출 근거를 정직하게 밝혀 둔다. 폐기율은 도입 초기 silvermark를 포함한 도시 5개를 직접 검수하며 카운트한 값이다. NPC는 검수 30명 중 6명 폐기(20%), 사이드 퀘스트는 검수 15건 중 5건 폐기(33%)였다. 표본이 도시 5개로 작으므로 정밀한 모수 비율이 아니라 "다섯 중 하나, 셋 중 하나" 수준의 방향값으로 읽는 게 맞다. 도시 30개 전체를 다 검수한 뒤의 누적 비율은 이보다 낮아질 수도, 사냥터 성격에 따라 높아질 수도 있다.
중요한 건 폐기율 0%가 목표가 아니라는 점이다. 폐기 0%는 검수가 형식적으로 흘렀다는 신호에 가깝다. 다섯 NPC 중 한 명이 톤이 안 맞아 폐기되고, 세 사이드 퀘스트 중 하나가 lore_seeds와 안 붙어 재생성될 때, 검수 게이트가 실제로 작동하고 있는 것이다.
도구 도입 전후를 비교한다. 아래 시간 수치는 silvermark를 포함한 초기 도시들의 실측 평균이고, "도입 전" 열은 도구 이전 손작업 시기의 작가 추정이다. 가공한 숫자는 없다.
| 항목 | 도입 전 (손작업) | 도입 후 (실측) |
|---|---|---|
| 도시 1개 작성 시간 | 1~2주 | 약 30분 (메타 15분 + AI 5분 + 검수 8분) |
| 30개 도시 총 기간 | 작가 1인 6개월급 | 4~5주 |
| 폐기율 (NPC) | — (전량 직접 작성) | 약 20% (30명 중 6명) |
| 폐기율 (사이드 퀘스트) | — | 약 33% (15건 중 5건) |
| 일관성 사고 (도시당) | 거의 없음 | 0~1건 |
표만 보면 숫자가 다지만, 실제 효과는 다른 칸에서 나왔다. 도시 양산에 묶일 뻔한 작가 시간이 풀려나면서, 작가 한 명이 분기당 메인 퀘스트 산출을 크게 늘릴 수 있었다(정확한 배수는 분기마다 달라 단정하지 않는다 — 방향은 "메인 산출이 명확히 늘었다"다). 양산 도구가 작가 시간을 흡수하는 게 아니라 풀어 주는 도구로 작동한 것이다(작가가 "검수기"가 됐다고 느끼면 도구가 거부된다는 §6.1.8의 경고가 그대로 적용된다).
자동화 폭이 넓어졌어도 다음은 도구 밖에 둔다.
| 콘텐츠 | 도구 밖에 두는 이유 |
|---|---|
| 메인 퀘스트 본문 | 일관성·서사 깊이가 게임 정체성에 직결 |
| 보스 패턴·연출 | 시각·인터랙션 디테일이 많아 디자이너 손이 빠름 |
| 시그니처 메인 캐릭터 | voice_profile 풀 작성이 필요해 양산 불가 |
| 분기 결말 | 작가의 직접 결정 영역 |
| 도시당 시그니처 사이드 퀘스트 1~2개 | 작가가 골라 직접 만듦 |
양산할 수 있다는 사실이 양산해야 한다는 결정으로 자동 이어지면 안 된다. silvermark 사이클에서 본 것처럼, 도구는 NPC 6명 중 5명까지는 잘 양산한다. 그러나 그 도시의 '봉인이 식는다'는 핵심 긴장을 짊어질 시그니처 NPC 한 명은 작가가 직접 빚는다. 자동화의 경계가 명확하면, 양산 도구가 오히려 그 핵심 영역을 지키는 도구가 된다.
| 실패 패턴 | 왜 실패하나 | 처방 |
|---|---|---|
| lore_seeds를 1~2개만 작성 | AI 출력이 일반 RPG 평균으로 평준화 | 3개 이상 강제 (§6.2.2) |
| 룰북 없이 AI에 통째 양산 요청 | "도시 30개 만들어 줘" → 비슷한 마을 30개 | 1단 룰북은 건너뛸 수 없음 (§6.2.3) |
| lint 없이 작가 검수에만 의존 | 검수자가 사소한 룰 위반 처리에 시간 소진 | 1차 자동 검증을 먼저 (§6.2.5) |
| 명명 중복 검사 누락 | 1,500명 이름의 중복은 사람 머리로 불가능 | forbidden_names 자동 부착 (§6.2.2) |
| 작가 만족도 미측정 | 처리량은 늘지만 작가 시간을 빼앗으면 거부됨 | 메인 콘텐츠 시간 명시적 보장 (§6.2.7) |
다섯 번째가 가장 자주 놓친다. silvermark의 그렘을 폐기한 것 같은 판단을 작가가 즐겁게 하려면, 작가에게 양산 검수 말고 직접 빚을 시간이 남아 있어야 한다. 처리량 측정만 하고 작가 시간 측정을 빼면, 도구는 KPI상 성공하지만 사람은 떠난다.
혼자라면 이만큼만: 룰북 코드가 없어도 됩니다. 본인 게임(또는 좋아하는 게임)의 도시·지역 1곳을 골라 §6.2.2 형식의 메타데이터를 손으로 적고(lore_seeds 3개가 핵심), §6.2.4의 소개문 프롬프트를 그대로 붙여 한 번 돌려 보세요. 나온 NPC 중 톤이 안 맞는 한 명을 직접 골라 "이 NPC는 도시 톤과 어긋난다, 폐기하고 다시"라고 반박해 보면, 검수 게이트가 어떤 판단의 묶음인지 몸으로 들어옵니다.
팀이라면 다음 한 단계로 시작하세요. 메타데이터 yaml 양식 한 장과 forbidden_names 자동 부착 스크립트부터 만듭니다. 룰북 골격(generate_skeleton)과 lint는 그 다음입니다. 입력 양식과 이름 중복 검사 두 개만 있어도, AI 본문 양산이 "비슷한 마을 30개"로 무너지는 두 가지 흔한 실패를 먼저 막을 수 있습니다.
6.3에서는 NPC Persona/Squad 파이프라인을 다룬다. 6.2의 generator가 도렌·미라 같은 NPC를 개별로 양산한다면, Persona/Squad는 그 NPC들을 그룹 단위로 묶는다. 한 사냥터의 다섯 NPC가 서로 무관한 인형의 집합이 아니라 작은 사회로 작동하게 만드는 방법이다.
1차 독자: NPC·사냥터 콘텐츠를 책임지는 MMORPG 기획자 (중규모(10~50인) 팀) 1인/취미 독자용 축소 버전: §6.3.10 「혼자라면 이만큼만」
6.2의 generator로 한 사냥터에 NPC 다섯 명을 양산해 게임 안에 띄워 본 날의 기억이 있다. 이름·외형·짧은 배경이 다 채워졌고, 좌표만 찍어 배치했다. 그런데 막상 그 사냥터를 걸어 다녀 보니 묘하게 죽어 있었다. 다섯 명이 같은 공간에 있는데 서로 한 번도 언급하지 않았다. 두 명이 같은 바위에 겹쳐 서 있었다. 누군가는 상인 역할이 필요했는데 다섯 명 전부 학자였다. 도렌도 미라도 개별로는 멀쩡한 NPC였는데, 묶어 두니 인형의 집합이었다.
이게 인형 박물관 상태다. 개별 NPC는 다 만들었는데 그룹으로 살아 있지 않다. 이 장은 그 다섯 명을 작은 사회로 묶는 파이프라인을 다룬다. 핵심 분해는 Persona와 Squad다. 사무실에 비유하면 Persona는 직원 개인 명함이고 Squad는 한 팀의 조직도다. 명함을 50장 쌓아 두고 조직도가 없으면 회사가 돌아가지 않는다. 그리고 이 장의 척추는 마지막 단계, 즉 묶은 그룹이 '서로 아는 사이처럼 말하고 움직이는가'를 AI와 한 사이클 끝까지 검증하는 자리다.
저자 실제 운영 메모 이 장의 Squad 파이프라인은 저자가 회사 R&D 폴더에서 운영 중인 NPC Persona/Squad 도구를 익명화한 것이다. yaml 구조·검증 항목·voice_lint 임계값은 실제 도구를 충실히 옮겼고, 도시·NPC 이름은 6.2와 동일하게 책용으로 치환했다. 출력 본문은 실제 세션을 재구성한 것이다.
Persona는 개별 NPC의 정체성이다. 이름·외형·voice_profile·역할을 담는다. 6.2의 generator가 만드는 게 Persona다. 도렌 베일, 미라 코스트가 각각 하나의 Persona다.
Squad는 그 Persona들을 그룹으로 묶는 단위다. 사냥터 한 곳에 다섯 명이 어떤 역할로 분포하고, 서로 어떤 관계이며, 어떻게 움직이는지를 정의한다.
| 단위 | 담는 내용 | 만드는 주체 |
|---|---|---|
| Persona | 이름·외형·voice_profile·역할 | generator (6.2) |
| Squad | 역할 분포·관계·동선 | Squad 파이프라인 (이 장) |
이 둘을 분리하지 않으면 두 가지가 동시에 막힌다. Persona만 양산하면 인형 박물관이 되고, Squad부터 만들려 하면 채울 Persona가 없다. 분리하면 각 단위의 운영이 단순해진다. 다만 분리가 곧 단절은 아니다. 핵심은 두 단위 사이에 재사용·검증 경로를 깔아 두는 것이고, 이게 이 장의 본론이다.
이 Persona→Squad 분해는 단순한 정리가 아니라 더 멀리 가는 길을 연다. NPC 그룹이 역할·관계·수치로 정형화돼 있어야, 나중에 월드 상태(플레이어 행동 누적)가 NPC 수치를 흔들고 그 수치가 퀘스트 발현 조건이 되는 동적 반응성까지 갈 수 있다. 이 장은 그 진보적 적용의 입구만 짚고, 정면으로는 '사람이 검수하는 보수적 양산'까지만 다룬다.
Squad 골격은 사냥터 1개당 메타데이터 한 페이지에서 시작한다. 6.2의 도시 메타데이터와 같은 사상이다. 사람이 역할 분포와 관계 의도만 잡고, 채우는 일은 룰북과 AI가 한다.
# city_021_hg_3.squad.yaml
squad_id: city_021_hg_3_squad
hunting_ground: city_021_silvermark_hg_3
type: hunting_ground_residents
size: 5
roles:
- role: quest_giver
count: 1
voice_traits: [authoritative, scholarly]
- role: lore_keeper
count: 1
voice_traits: [scholarly, withdrawn]
- role: merchant
count: 1
voice_traits: [practical, dry]
- role: bystander
count: 2
voice_traits: [varied]
relationships:
- between: [quest_giver, lore_keeper]
type: mentor_and_former_student
- between: [merchant, bystander_1]
type: regular_customer
movement_pattern: stationary_with_shifts
가장 중요한 슬롯은 relationships다. 관계가 0건이면 다섯 명은 끝까지 남남이다. 관계가 너무 많으면(5명에 5건 이상) 사용자가 외울 게 많아져 오히려 묻힌다. 저자 경험상 5인 Squad에 핵심 관계 2~3건이 가장 안정적이다. voice_traits는 다섯 명이 서로 다른 목소리를 갖도록 잡는 장치다. 다섯 명 모두 scholarly로 채우면 검증 단계에서 voice 평준화로 걸린다.
룰북이 먼저 Squad 골격의 표준을 잡는다. 사냥터 region·type별로 사이즈·역할 분포·관계 밀도·동선 패턴의 기본값이 코드에 입력되어 있다.
# npc_squad/templates.py (발췌)
SQUAD_TEMPLATES = {
("west", "hunting_ground_residents"): {
"size_range": (4, 6),
"role_distribution": {
"quest_giver": 1,
"merchant": 1,
"lore_keeper": (0, 1),
"bystander": (1, 3),
},
"relationship_density": 2, # 권장 관계 수
"movement_pattern": "stationary_with_shifts",
},
("east", "outpost_squad"): {
"size_range": (3, 4),
"role_distribution": {
"commander": 1,
"scout": 1,
"support": (1, 2),
},
"relationship_density": 1,
"movement_pattern": "patrol_loop",
},
}
이 단계는 결정론이다. 서부 거주민 Squad는 quest_giver만 다섯 명인 사고가 코드 차원에서 불가능하다. 역할 분포가 룰을 벗어나면 그 자리에서 막힌다.
다음으로 각 슬롯에 Persona를 채운다. 길은 셋이다. 풀에 맞는 Persona가 있으면 재사용하고(출연 가중치 +1), 없으면 6.2의 generator로 새로 만들고, 메인 퀘스트 핵심 인물이면 작가가 직접 쓴다. silvermark의 hg_3 Squad는 quest_giver·lore_keeper를 6.2에서 이미 양산한 미라·도렌으로 채웠고, merchant와 bystander 2명을 새로 뽑았다. 여기까지는 6.2의 generator 사이클과 같다. 이 장의 진짜 일은 그 다음, 묶은 그룹이 정말 그룹처럼 작동하는지를 검증하는 자리다.
추상적으로 "AI가 관계를 보강한다"고만 적으면 이 파이프라인이 무엇을 뱉는지 알 수 없다. silvermark hg_3 Squad 하나의 후반 사이클을, 관계 텍스트 생성에서 폐기·재요청까지 한 번 끝까지 따라간다.
Squad 골격에 입력된 관계 태그(mentor_and_former_student)는 추상이라 게임 안에서 보이지 않는다. 이걸 한 줄 묘사로 바꿔 NPC 대사·이벤트에 심는 게 3단이다. 프롬프트는 그대로 복사해 쓸 수 있는 형태다.
[L0 컨텍스트] world_premise + tone_manifesto (캐싱)
[L1 컨텍스트] city_021_silvermark.lore (학자 길드 지배, scholarly_strict)
[Persona 1] quest_giver — 미라 코스트, 길드 문서고 사서, 30대, 잉크 얼룩
[Persona 2] lore_keeper — 도렌 베일, 종탑 관측 보조, 50대, 숫자로만 대화
[관계 태그] mentor_and_former_student
이 둘(스승–옛 제자)의 관계를 게임 대사에 쓸 배경으로 1~2줄만 묘사해 줘.
도렌은 숫자, 미라는 문서 — 두 말투가 안 부딪히게. 톤은 엄격한 학자풍으로,
신비주의나 "오랜 친구" 같은 상투어는 빼고. 본문만.
[3단 AI 출력 — 관계 한 줄] (실제 세션 재구성)
도렌은 20년 전 미라에게 봉인진 관측 기록의 표기법을 가르쳤다. 지금은 입장이 뒤집혀, 도렌이 측정한 수치를 미라가 문서고 장부에 옮겨 적는다. 둘은 매주 화요일, 관측값이 장부와 어긋나는 한 칸을 두고 짧게 다툰다.
이 출력은 좋다. mentor_and_former_student가 구체화됐고, 도렌의 '숫자'와 미라의 '문서'가 충돌 없이 한 장면(수치를 장부로 옮김)으로 묶였고, scholarly_strict 톤이 유지됐다. 같은 프롬프트가 merchant–bystander_1의 regular_customer 관계에도 반복된다.
NPC가 종일 한 자리에 서 있으면 다시 인형이 된다. 룰북이 동선 패턴을 채운다. stationary는 한 자리 고정(경비·보스), stationary_with_shifts는 8시간마다 위치 미세 변경(일반), routine_loop은 시간표 기반(주민), event_driven은 트리거 시에만 이동(퀘스트 NPC). 이건 결정론이라 AI를 부르지 않는다.
이제 묶인 다섯 명이 정말 그룹처럼 작동하는지를 친다. 6.2의 lint가 개별 NPC를 봤다면, 이 lint는 그룹 일관성을 본다.
[5단 Squad lint 출력] (실제 형식)
[PASS] 역할 분포: quest_giver 1 · lore_keeper 1 · merchant 1 · bystander 2 (룰 충족) [PASS] 관계 밀도: 2건 (권장 2, 충족) [WARN] voice 다양성: scholarly 계열 3/5 — quest_giver·lore_keeper·bystander_2 가 voice_profile 코사인 유사도 0.83 (임계 0.80 초과). 평준화 위험. [WARN] 동선 충돌: 14:00~16:00 구간 merchant·bystander_1 좌표 반경 1.5m 중첩 [FAIL] 관계 노출: 관계 2건이 정의됐으나, 5명 대사 어디에도 다른 멤버 언급 0건. 관계가 데이터에만 존재 — 게임 내 가시성 0.
lint가 세 건을 잡았다. 셋 다 자동으로 폐기하지 않고 게이트로 올린다 — 의심 후보는 기계가 뽑되 죽일지 살릴지는 사람이 정한다는, §6.2.5와 같은 설계다.
[6단 작가 검수 — 판정과 폐기]
작가는 alert 세 건을 이렇게 처리했다.
- voice 평준화 (WARN) → bystander_2를 폐기. 학자 도시라도 다섯 명이 다 학자 말투면 사냥터가 단조롭다. bystander_2를
practical, dry톤의 잡역부로 재생성 요청. (quest_giver·lore_keeper가 둘 다 학자인 건 도시 정체성이라 유지.)- 동선 충돌 (WARN) → 룰 보정. merchant의 shift 시작 오프셋을 +2시간으로 밀어 14:00 중첩 해소. AI 호출 없이 동선 패러미터만 조정.
- 관계 노출 0 (FAIL) → 가장 중요한 건. 관계를 두 건이나 정의해 놓고 게임 안에서 한 번도 안 보이면 그 데이터는 죽은 데이터다. 작가가 핵심 관계 1건(도렌–미라)을 골라 대사에 심기로 결정.
세 건 중 두 건은 룰·재생성으로 닫혔고, 마지막 FAIL이 이 파이프라인의 핵심이다. 작가는 도렌의 대화 분기 한 줄을 추가 요청했다.
도렌 대사에 미라와의 관계가 흘리듯 드러나는 한 줄만 끼워 줘.
설명조 말고 곁가지로. 학자풍 톤, 대사 한 줄만.
[재요청 출력]
"그 도면은 문서고에 있어. 미라한테 물어봐. ...20년 전엔 내가 그 친구한테 읽는 법을 가르쳤는데, 요즘은 거꾸로야."
이 한 줄이 들어가는 순간 두 NPC의 관계가 데이터 시트에서 게임 화면으로 옮겨 온다. 입력(Squad 메타) → 골격 → Persona 채움 → 관계 보강 → 동선 → 일관성 검증 → 폐기·노출 결정의 한 사이클이 여기서 닫힌다.
이 한 바퀴가 이 장의 Show 기준이다. "Squad로 NPC를 사회로 묶었다"는 문장은, 관계 노출 0건 FAIL을 사람이 한 줄 대사로 닫는 장면을 한 번이라도 보지 않으면 공허하다.
위 사이클을 한 그림으로 실어 둔다. 핵심은 1·2·4·5단이 결정론(룰북·lint)이고 3단만 AI라는 점, 그리고 사람의 손은 맨 위 입력과 맨 아래 게이트에만 닿는다는 점이다.
flowchart TB
P["Persona 풀
(6.2 generator 산출)
도렌·미라·..."] --> FILL
A["입력: Squad 메타데이터
(사람 10~15분/사냥터)
역할 분포 · 관계 2~3건 의도"]
A --> B["1단 결정론: templates.py
역할 분포·관계 밀도·동선 표준"]
B --> FILL["2단: Persona 채움
재사용 / generator / 작가 직접"]
FILL --> C["3단 AI: 관계 한 줄 보강
L0 캐싱 + voice_traits 충돌 검사"]
C --> D["4단 결정론: 동선 합성
shift·routine·event_driven"]
D --> E{"5단 결정론: Squad lint
역할분포·voice다양성
·동선충돌·관계노출"}
E -->|"alert"| F["6단 작가 검수 게이트
(5~10분/사냥터)
폐기·룰보정·관계노출 결정"]
F -->|"재요청"| C
F -->|"통과"| G["빌드 반영"]
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
class B,D,E code;
class C ai;
class A,F human;
class P data;
class G pass;
사람의 손이 닿는 곳은 두 군데뿐이다. 맨 위에서 역할·관계 의도를 잡는 자리, 맨 아래에서 lint가 못 잡는 톤·서사를 판정하는 자리. 그 사이의 골격·동선·검증은 룰북이, 관계 본문은 AI가 돌린다.
5단 lint의 관계 노출 항목이 가장 자주 FAIL이 난다. 관계가 데이터에만 살아 있기 때문이다. 관계를 게임 안으로 끌어내는 장치는 셋이다.
첫째, 대사 인용. §6.3.4의 도렌 대사처럼 NPC가 다른 멤버를 한 줄 언급한다. 가장 싸고 가장 효과가 크다.
둘째, 동선 교차. 매주 화요일 도렌과 미라가 문서고에 함께 있는 장면이 게임 안에서 관찰된다. 사용자가 우연히 보면 "저 둘이 엮였나" 알아챈다. 4단 동선과 관계가 일치하면 자연히 나온다.
셋째, 분기 조건. quest_giver의 부탁을 거절하면 lore_keeper 호감도가 같이 떨어진다. 이 셋째 장치가 §6.3.1에서 말한 진보적 적용의 입구다 — 관계가 단순 묘사를 넘어 게임 상태에 영향을 주기 시작하는 자리.
세 장치를 다 쓸 필요는 없다. 5인 Squad에서 핵심 관계 2~3건만 첫째·둘째 장치로 노출돼도 사냥터의 체감이 크게 달라진다. 과하면 사용자가 외울 게 많아진다. 작가는 검수 단계에서 노출할 관계를 골라 끼우고, 나머지는 데이터로만 둔다.
도구 도입 전후를 비교한다. 가공 수치는 쓰지 않는다. 시간·비율은 silvermark를 포함한 초기 사냥터 몇 곳을 직접 검수하며 카운트한 값이고, "도입 전" 열은 손작업 시기의 작가 추정이다.
| 항목 | 도입 전 (손작업·추정) | 도입 후 (실측) |
|---|---|---|
| 사냥터 1곳 Squad 묶기 | 약 3~4시간 | 약 25분 (메타 12분 + AI 5분 + 검수 8분) |
| 관계 노출(대사·동선) 건수 | 사냥터당 0~1건 | 핵심 2~3건 중 노출 1~2건 |
| 동선 충돌 (같은 좌표 2명+) | 사냥터당 2~3건 | lint로 사전 차단, 0~1건 |
| voice 평준화 폐기 | — (검사 없음) | 5명 중 0~1명 재생성 |
표본이 사냥터 몇 곳으로 작으므로 정밀한 모수 비율이 아니라 방향값으로 읽는 게 맞다. 가장 큰 변화는 표에 안 담긴다. lint의 관계 노출 FAIL이 강제로 작가에게 "이 사냥터, 관계가 하나도 안 보입니다"를 들이밀기 때문에, 양산물이 인형 박물관으로 출시되는 일이 구조적으로 줄었다. 폐기 0%·노출 0건이 목표가 아니라는 점(§6.2.6)은 여기도 같다. Squad가 묶는 일의 대부분을 흡수하되, 도렌의 마지막 대사 한 줄 같은 핵심은 작가가 직접 빚을 시간이 남아 있어야 한다.
Squad가 안정되면 자연히 따라오는 운영이 Persona 풀이다. 같은 Persona가 여러 도시에 출연할 수 있다. 학자 길드 소속 NPC가 도시 서너 곳에서 마주쳐지는 건 오히려 자연스럽다. 세계가 좁아 보이는 게 아니라 연결돼 보인다.
persona_pool:
- id: persona_doren_vale
voice_traits: [terse, numeric]
appearance_count: 3
appearance_cities: [city_021, city_018, city_023]
signature: false
- id: persona_mira_kost
voice_traits: [scholarly, withdrawn]
appearance_count: 2
signature: false
재사용 비율에는 건강 범위가 있다.
| 재사용 비율 | 상태 |
|---|---|
| 20% 미만 | NPC 분량 폭증, 식별 부담 |
| 30~50% | 건강한 운영 범위 |
| 70% 이상 | NPC 식상함, 다양성 손상 |
다만 한 NPC를 너무 많은 도시에 등장시키면 "이 사람 또 나오네"가 된다. 한 Persona의 최대 출연 도시는 5곳으로 한도를 둔다. 보스 룸·시그니처 인물은 재사용 금지(signature: true). 같은 Persona의 두 번째 출연부터는 시각 변주(라이트·소품)를 강제한다. 이 30~50% 범위는 정밀 수치가 아니라 운영 가이드라인이다 — 팀·게임 규모에 따라 보정해야 한다.
[방향 표지 — 페르소나 풀을 분포로 본다면 (아직은 시기상조)] 풀이 수백 NPC로 커진 팀이라면 한 발 더 나간 방향이 있다 — §8.2.7의 '차원 벡터' 절과 같은 자리의 방향 표지다(처방이 아니다 — 개념 직관은 부록 M). §6.3.4의 voice_lint는 이미 두 Persona의 코사인 유사도(0.83 같은 값)로 '가까움'을 본다. 같은 임베딩을 풀 전체에 올리면, 식상함을 작가의 인상이 아니라 분포 밀도로 진단할 수 있다 — '학자 말투가 한 구석에 떼로 몰린' 상태가 그 영역의 점 밀도로 보인다. 그러면 저밀도 영역에 '또 비슷한 학자'를 새로 찍는 대신, 가까운 두 Persona 사이를 보간한 변주를 채워 다양성을 메우는 길이 열린다. 다만 같은 호흡으로 둘 주의점이 둘이다. 보간으로 뽑은 Persona는 두 NPC를 어색하게 섞은 '죽은 중간값'이 되기 쉬워, 결국 사람이 voice를 다시 살려야 한다. 그리고 위 식상함 비율(30~50%)은 정밀치가 아니라 운영 가이드라인이라, 그걸 임베딩 거리로 환산하는 순간 느슨함이 정밀한 척 숨을 위험이 있다 — 거리값은 작가의 판정을 돕는 신호지 판정 자체가 아니다.
| 실패 패턴 | 왜 실패하나 | 처방 |
|---|---|---|
| Persona만 양산하고 Squad를 무시 | NPC 50명이 다 있는데 사냥터가 죽어 있음 | Squad 골격 룰북 도입 (§6.3.3) |
| 역할 분포 룰 없이 자율 생성 | 상인만 다섯, 학자 0명 같은 분포 사고 | role_distribution 강제 (§6.3.3) |
| 관계 태그만 두고 한 줄 묘사 누락 | 관계가 추상이라 게임에 안 보임 | 3단 AI 관계 보강 (§6.3.4) |
| 관계 노출 검사 없음 | 관계 정의해 놓고 대사·동선에 0건 노출 | 5단 관계 노출 lint (§6.3.4) |
| 동선 충돌 검사 누락 | 같은 시간 같은 자리 2명, 출시 후 빈번 | 좌표·시간대 자동 검사 (§6.3.4) |
| 재사용 비율 0% 또는 70%+ | 0%는 양산 폭증, 70%+는 식상함 | 풀 운영 + 출연 한도 (§6.3.8) |
네 번째가 가장 자주 놓친다. 관계를 정의하는 일과 관계를 게임에 보이게 하는 일은 다른 작업이고, 검사 없이는 거의 항상 후자가 빠진다. silvermark hg_3에서 lint가 관계 노출 0건을 FAIL로 들이밀지 않았다면, 도렌과 미라는 데이터 시트에서만 사제 관계였을 것이다.
혼자라면 이만큼만: 룰북도 lint도 없어도 됩니다. 본인 게임(또는 좋아하는 게임)의 한 장소에 있는 NPC 3~5명을 골라, §6.3.2 형식으로 역할과 관계 2건을 손으로 적어 보세요. 그다음 §6.3.4의 관계 보강 프롬프트를 그대로 붙여 한 줄 묘사를 받고, 마지막으로 직접 물어보세요 — "이 관계가 지금 게임 대사 어디에서 보이는가?" 한 군데도 없으면, 그게 바로 lint의
관계 노출 0FAIL입니다. NPC 한 명의 대사에 다른 멤버를 한 줄 끼워 그 FAIL을 손으로 닫아 보면, Squad 검증이 무엇을 잡는 작업인지 몸으로 들어옵니다.
팀이라면 다음 한 단계로 시작하세요. Squad 메타데이터 yaml 양식 한 장과, 5단 lint 중 관계 노출 검사 한 줄부터 만듭니다(각 NPC 대사 텍스트에 다른 멤버 이름·역할이 등장하는지 grep). 역할 분포 검사·동선 충돌 검사는 그 다음입니다. 관계 노출 검사 하나만 있어도, 양산된 사냥터가 인형 박물관으로 출시되는 가장 흔한 실패를 먼저 막습니다.
setup → prompt → verify로 요약하면 — setup: Squad 메타 yaml에 역할·관계를 정의하고 templates.py로 골격을 잡습니다. prompt: §6.3.4 형식으로 관계 한 줄을 받되 voice_traits 충돌 금지·상투어 금지를 강제합니다. verify: 5단 lint를 돌려 관계 노출 FAIL을 확인하고, 핵심 관계 1건을 대사에 심어 직접 닫습니다.
관계 노출 0 FAIL을 사람이 한 줄 대사로 닫는 게 이 파이프라인의 심장이다.세 도구를 각자 완성한 그 주, 나는 한 자리에서 도시 generator를 돌리고, NPC generator를 돌리고, 아이템 generator를 돌렸다. 셋 다 각자 잘 작동했다. 도시는 7곳이 나왔고, NPC는 110명이 나왔고, 무기는 60개가 나왔다. 그런데 며칠 뒤 검수에 앉아서야 나는 같은 함정을 세 번 밟았다는 걸 알았다.
도시 port_harman은 "몰락한 어촌"으로 생성됐는데, 그 도시에 배치된 NPC들의 페르소나는 "번성하는 무역항의 부유한 상인"이었다. 도시 generator와 NPC generator가 서로 다른 lore_seeds를 봤기 때문이다. 무기 generator는 그 도시의 권장 레벨이 12~18인데 레벨 40짜리 전설 무기를 상점에 깔아 놨다. 세 도구는 각자 옳았고, 묶이지 않아서 틀렸다.
이 챕터는 도구 하나를 만드는 이야기가 아니다. 6.2의 도시 generator, 6.3의 NPC Squad, 그리고 아이템 generator를 한 생산 라인으로 묶는 운영 이야기다. 도구가 세 개면 함정도 세 개가 아니라, 도구 사이의 틈에서 새로 생긴다.
도시·NPC·아이템 generator를 따로 돌리면, 각 도구의 출력이 다른 도구의 입력과 어긋난다. 해법은 도구를 더 똑똑하게 만드는 게 아니라, 공유 메타데이터를 상류에 한 번 고정하고, 도구들을 그 아래로 줄 세우는 것이다. 6.2 ch2의 도시 생성기가 단일 도구의 모범이라면, 이 챕터는 그 도구를 라인의 한 스테이션으로 격하시키는 작업이다.
전체 라인은 이렇게 흐른다.
flowchart TD
SEED[월: lore_seeds 시트
region·climate·faction·level_range]:::human
SEED --> CITY[화: 도시 generator
L1 룰북 골격 + L2 AI 본문]
CITY -->|city_manifest.json| NPC[화: NPC generator
도시 lore 상속]
CITY -->|level_range| ITEM[화: 아이템 generator
레벨대·진영 상속]
NPC -->|persona_pool| SQUAD[화: Squad 배치]
ITEM --> SQUAD
SQUAD --> LINT[수: 통합 lint
교차 generator 일관성]
LINT -->|alert| REVIEW1[수: 1차 검수
작가 본인]
REVIEW1 --> REVIEW2[목: 2차 표본
리드 내러티브]
REVIEW2 --> BUILD[금: 빌드 반영
+ 다음 주 seed 준비]
BUILD -.회수.-> SEED
classDef human fill:#fde68a,stroke:#b45309,color:#000;
핵심은 city_manifest.json이라는 화살표다. 도시 generator가 도시를 만들면서 그 도시의 정체성(몰락한 어촌인지 번성한 무역항인지)을 manifest로 떨어뜨리고, NPC generator와 아이템 generator가 그 manifest를 입력으로 받는다. 내가 그때 밟은 함정은 이 화살표가 없었기 때문이다. 도구를 묶는다는 건, 도구 사이에 이 한 줄의 계약을 넣는 것이다.
도시 generator가 도시 하나를 만들 때마다 함께 뱉는 city_manifest.json의 실제 형태는 이렇다. 이 파일이 NPC·아이템 generator의 입력이 된다.
{
"city_id": "port_harman",
"display_name": "하르만 항",
"lore_seeds": ["몰락한 어촌", "옛 무역의 잔향", "소금 부족"],
"region": "남부 연안",
"dominant_faction": "어민 길드",
"level_range": [12, 18],
"tone": "쇠락·끈질김",
"forbidden_names": ["하란", "하르멘"],
"neighbors": ["salt_marsh", "old_pier"]
}
NPC generator는 lore_seeds와 tone을 상속해 "몰락한 어촌의 끈질긴 사람들"을 만든다. 아이템 generator는 level_range를 상속해 12~18레벨 무기만 깐다. forbidden_names는 이미 옆 도시에서 쓴 이름이라 양쪽 모두 피한다. 도구 세 개가 같은 한 장의 계약서를 본다.
이 manifest를 만들 때 내가 Claude에게 준 프롬프트는 다음과 같다. 양산 라인의 상류를 묶는 가장 중요한 한 번의 호출이라, 전문을 그대로 옮긴다.
너는 MMORPG 도시 generator의 manifest 작성기다. 아래 작가 메타데이터를 받아 city_manifest.json을 생성하라.
작가 입력: - city_id: port_harman - lore_seeds: 몰락한 어촌, 옛 무역의 잔향, 소금 부족 - region: 남부 연안 - level_range: 12-18
규칙: 1. dominant_faction은 lore_seeds에서 추론하되, 옆 도시(salt_marsh, old_pier)의 진영과 겹치면 안 된다. 옆 도시 진영은 [염전 조합, 밀수단]이다. 2. forbidden_names에는 이미 인접 도시에서 사용된 NPC/지명을 넣어라. 제공된 목록: [하란, 하르멘]. 3. tone은 두 단어 이내. lore_seeds와 모순되지 않게. 4. 추측한 값과 작가가 명시한 값을 구분해 표시하라.
Claude의 날것 출력은 이랬다. 요약하지 않고 그대로 옮긴다. 한 군데에서 규칙을 어겼다.
{ "city_id": "port_harman", "display_name": "하르만 항", "lore_seeds": ["몰락한 어촌", "옛 무역의 잔향", "소금 부족"], "region": "남부 연안", "dominant_faction": "밀수단", // ← 추론: 소금 부족 + 옛 무역 = 밀무역 "level_range": [12, 18], "tone": "쇠락·탐욕", "forbidden_names": ["하란", "하르멘"], "neighbors": ["salt_marsh", "old_pier"] }(추론한 값: dominant_faction, tone, display_name / 작가 명시: 나머지) 주의: dominant_faction을 "밀수단"으로 골랐는데, 규칙 1에서 옆 도시 old_pier가 이미 "밀수단"입니다. 규칙 위반입니다. 대체 후보로 "어민 길드"를 제안합니다.
여기서 중요한 건 AI가 스스로 규칙 위반을 보고했다는 점이다. "밀수단"은 lore_seeds(소금 부족·옛 무역)에서 보면 매력적인 추론이지만, 라인 차원에서는 충돌이다. 옆 도시가 이미 밀수단이다. 나는 AI의 제안을 받아 dominant_faction을 "어민 길드"로, tone을 "쇠락·끈질김"으로 고쳤다. "탐욕"은 밀수단 전제에서 나온 단어라 어민 길드와 안 맞았다.
이 한 번의 검증·거부·재지정이 라인의 상류를 지킨다. manifest가 틀리면 그 아래 NPC 110명과 무기 60개가 전부 틀린 전제 위에 생성된다. 상류에서 5분을 쓰면 하류에서 3시간을 아낀다.
단일 generator의 lint는 자기 출력만 본다. 도시 lint는 도시가 룰북을 지켰는지 보고, NPC lint는 페르소나가 voice 일관성을 지켰는지 본다. 하지만 내가 처음에 밟은 함정은 각 도구 안이 아니라 도구 사이에 있었다. 그래서 라인에는 단일 lint 위에 한 겹이 더 필요하다. 도시·NPC·아이템을 함께 읽고 교차 검증하는 통합 lint다.
통합 lint가 실제로 잡는 항목은 이렇다.
| 검사 | 무엇을 비교하나 | 그때 놓친 것 |
|---|---|---|
| lore 정합 | city.lore_seeds ↔ npc.persona | 어촌인데 부유한 상인 |
| 레벨대 정합 | city.level_range ↔ item.required_level | 12~18 도시에 40레벨 무기 |
| 진영 충돌 | city.faction ↔ neighbor.faction | 밀수단 두 도시 인접 |
| 이름 중복 | 전체 city·npc·item 이름 풀 | forbidden_names 미수집 |
이 통합 lint를 돌렸을 때의 실제 출력 일부다. 자동 폐기는 하지 않는다. 사람이 판정하도록 alert만 띄운다.
[통합 lint] port_harman 라인 검사 — 3 alert ALERT-1 (lore 정합) port_harman city.lore_seeds = ["몰락한 어촌", ...] npc[merchant_04].persona = "번성하는 무역항의 부유한 상인" → 모순 가능. 의도된 변형인지 확인 필요. ALERT-2 (레벨대 정합) port_harman city.level_range = [12,18] item[blade_legend_07].required_level = 40 → 권장 레벨대 초과 28. 상점 배치 재검토. ALERT-3 (이름 중복) — 정보 npc[fisher_02].name = "하란" city.forbidden_names = ["하란", ...] → forbidden_names와 충돌. NPC 이름 재생성 권장.
ALERT-1을 보고 나는 잠깐 고민했다. NPC가 "번성하는 무역항의 부유한 상인"인 게 무조건 틀린 건 아니다. 옛날엔 번성했다가 지금 몰락한 도시라면, "한때 부유했던, 지금은 가난한 상인"은 오히려 좋은 서사다. 그래서 나는 ALERT-1을 폐기가 아니라 "의도된 변형"으로 판정하되, NPC 페르소나를 "한때 번성했던 무역항의 흔적을 붙잡은 늙은 상인"으로 한 줄 수정 요청했다. ALERT-2는 명백한 사고라 무기를 제거했다. ALERT-3은 이름만 재생성했다.
자동 lint가 사고를 막은 게 아니다. 자동 lint가 사고를 사람 눈앞에 끌어다 놓았고, 판정은 사람이 했다. 이게 6.1에서 말한 L2(룰북+AI 보조)의 핵심이다. AI가 골격과 alert을 만들고, 사람이 마지막 판정을 한다. ALERT-1처럼 "틀린 것처럼 보이지만 좋은 서사"를 가려내는 건, 룰북이 못 한다.
도구를 묶었으면 리듬이 필요하다. 라인은 1주 단위로 도는 게 가장 안정적이었다. 일주일은 검수가 폭증하지 않을 만큼 짧고, 회수가 더디지 않을 만큼 길다. 내 책상 달력 한 칸과 맞물린다.
| 요일 | 라인 스테이션 | 작가 시간 |
|---|---|---|
| 월 | lore_seeds 시트 작성 (manifest 상류) | 반나절 (5~7도시 × 15~20분) |
| 화 | 도시→NPC→아이템 generator 연쇄 실행 | 작가 개입 없음 |
| 수 | 통합 lint + 1차 검수 (본인) | 1시간 (5~10분/도시) |
| 목 | 2차 표본 검수 (리드 내러티브) | 2~3분/도시 |
| 금 | 빌드 반영 + 다음 주 seed 준비 | 짧음 |
화요일이 라인의 심장이다. 도시 generator가 manifest를 떨어뜨리면 NPC generator가 그걸 물고, 아이템 generator가 그걸 물고, Squad가 배치까지 한다. 이 연쇄가 작가 개입 없이 백그라운드로 돈다. 작가는 그동안 메인 퀘스트(L0 완전 수작업)를 쓴다. 도구를 묶은 진짜 보상이 여기다. 도구가 따로따로면 작가가 화요일에 세 번 손을 대야 하지만, 묶이면 한 번도 안 댄다.
작가 1인이 한 주에 도시 5~7개, 그에 딸린 NPC·무기까지 양산한다. 4주면 도시 20~28개. 30개 목표가 6주에 도달했다.
라인이 건강한지는 인상이 아니라 숫자로 본다. 매주 자동 집계되는 네 지표다.
| 지표 | 정상 범위 | 이탈 시 신호 |
|---|---|---|
| 통합 lint 통과율 | 80~95% | 60% 미만이면 manifest 상류가 망가짐 |
| 교차 generator 충돌 | 도시당 3~5건 | 10건+면 generator 간 계약 깨짐 |
| 사람 검수 폐기율 | 10~20% | 30%+면 양산 파라미터 잘못됨 |
| 작가 1인 사이클 시간 | 5일 | 7일+면 인지 부담 과다 |
가장 라인다운 지표는 두 번째, 교차 generator 충돌 건수다. 단일 도구만 쓸 땐 이 숫자가 존재하지 않는다. 이 숫자가 갑자기 10건을 넘으면, 도구 하나가 망가진 게 아니라 도구 사이의 계약(manifest)이 깨진 것이다. 보통 도시 generator의 manifest 스키마를 바꿨는데 NPC generator가 옛 스키마를 읽고 있을 때 터진다. 이 지표가 없으면 그 사고를 출시까지 못 본다.
네 지표는 매주 분기 회고로 입력된다. 추세가 나빠지면 다음 주 양산 도시 수를 5~7개에서 3~5개로 줄이고 원인을 본다.
여러 도구를 묶으면 단일 도구엔 없던 사고가 생긴다. 자주 본 셋을 적어 둔다.
첫째, 계약 불일치 사고. 도시 generator의 manifest에 새 필드를 추가했는데, NPC generator가 그 필드를 모른다. 교차 충돌 지표가 급증한다. 도구를 따로 개발하다 보면 한쪽만 업데이트되기 쉽다. 대응은 manifest 스키마에 version 필드를 넣고, 하류 generator가 버전 불일치를 즉시 alert으로 띄우게 하는 것이다. 사람을 다그치는 게 아니라 계약을 강제한다.
둘째, 상류 오염 사고. manifest가 틀린 전제로 생성되면(§6.4.2의 "밀수단" 같은) 그 아래 전부가 오염된다. 사람 검수 폐기율이 30%를 넘는데, 폐기된 출력을 보면 NPC 개별 품질은 멀쩡하다. 개별은 멀쩡한데 전제가 틀린 것이다. 대응은 manifest 생성 단계에 검수를 하나 더 넣는 것이다. 하류 110개를 검수하느니 상류 1개를 검수한다.
셋째, 모델 드리프트 사고. LLM이 자동 업데이트돼 출력 특성이 바뀐다. 도시·NPC·아이템 세 generator가 동시에 흔들린다. 최근 1주 변경 사항을 점검하고, 폐기 샘플 5개를 분석하고, 프롬프트나 컨텍스트를 조정한다. 1주 모니터링 후 복귀를 확인한다.
세 사고의 공통 대응은 같다. 사람을 비난하지 않고 계약을 보강한다. 작가가 lore_seeds를 한 줄만 적은 게 원인이라면, "세 줄 적으세요"라고 말하는 대신 manifest lint에 강제 검사를 추가한다. 그렇다고 사람 책임이 0이라는 뜻은 아니다. 시스템 보강과 별개로, 사고 패턴은 회고에서 공유한다.
라인을 묶는 진짜 목적은 작가를 없애는 게 아니라, 작가가 시그니처에 집중하게 하는 것이다. 도구가 따로따로일 때 작가 시간이 어떻게 흩어지고, 묶은 뒤 어떻게 모이는지를 한 장에 그려 둔다.
메인과 시그니처에 작가 시간의 80%가 모인다. 하지만 이 분배는 저절로 유지되지 않는다. 라인을 도입하면 작가 시간이 검수로 다 흘러가는 경향이 있다. 그래서 매월 시간 분배를 측정하고, 메인이 50% 아래로 떨어지면 양산 도시 수를 줄여 메인 시간을 회복시킨다. 시간 분배는 정책으로 지켜야 한다.
도시·NPC·아이템 라인이 안정되면, 같은 골격을 던전·도감·라이브 이벤트로 확장한다. 핵심은 새 패턴을 만들지 않는 것이다. "던전은 도시와 다르니 다른 구조로"라는 유혹이 늘 온다. 하지만 라인의 골격(공유 manifest → generator 연쇄 → 통합 lint → 사람 검수)은 똑같다. 입력 메타데이터 양식과 도메인 룰북만 갈아 끼운다.
던전이라면 dungeon_manifest.json에 boss_pattern·encounter_flow 같은 필드가 추가되고, 보스 동선 같은 도메인 룰이 통합 lint에 한 줄 더 붙는다. 골격은 같게, 룰만 다르게. 같은 골격을 유지하면 작가가 새 도구를 또 배울 필요가 없고, 통합 lint 인프라가 그대로 재사용된다. 다만 도메인 특수성을 무시하라는 뜻은 아니다. 던전엔 도시에 없는 동선 룰이 분명히 필요하다.
내 프로젝트에서 이 통합 라인을 6개월 가동한 결과다. 도시·NPC·아이템 generator를 따로 돌리던 시기와 비교한다. 아래 절대 수치는 정확한 집계가 아닌 저자 추정(미검증)이며, 방향과 비율은 실측 경향을 따른다.
| 지표 | 도구 분리 시기 | 라인 통합 후 |
|---|---|---|
| 양산 도시 (6주) | 18곳 | 28곳 |
| 화요일 작가 개입 횟수 | 도시당 3회 | 0회 |
| 교차 generator 충돌(출시 후 발견) | 분기 8~12건 | 분기 2~4건 |
| 작가 1인 분기당 메인 퀘스트 | 3개 | 8개 |
| 상류 검수 시간 / 하류 검수 시간 | 0 / 3시간 | 5분 / 1시간 |
가장 중요한 변화는 마지막 줄이다. 도구가 분리됐을 땐 상류 검수가 0이고 하류 검수가 3시간이었다. 라인을 묶고 manifest를 상류에 검수하니, 상류 5분이 하류 2시간을 지웠다. 사고가 도구 사이의 틈에서 새지 않으니, 출시 후 일관성 사고도 분기 8~12건에서 2~4건으로 줄었다.
그리고 트레이드오프가 명시적이 됐다. 전에는 "양산은 위험하다"는 추상 논쟁이 분기마다 돌았다. 지금은 "충돌 -8건 / 메인 +5개"라는 구체 비교 위에서 결정한다.
1) 도구를 묶지 않고 따로 돌리는 경우. 함정은 도구 안이 아니라 도구 사이에서 생긴다.
2) manifest 없이 generator를 연결하는 경우. 공유 계약이 없으면 하류가 상류와 어긋난다.
3) 통합 lint를 단일 lint로 대체하는 경우. 단일 lint는 교차 충돌을 못 본다.
4) 사이클을 5일에서 3일로 압축하는 경우. 5일이 검수의 안전 마진이다.
5) 상류(manifest)를 검수하지 않고 하류를 검수하는 경우. 하류 110개보다 상류 1개를 봐라.
6) 사고를 사람 책임으로만 묻는 경우. 계약 보강·룰 자동화가 답이다.
7) 라인을 갖춘 후 "안 쓰는" 경우. 1주 사이클을 강제하는 게 도구만큼 중요하다.
setup. 도시 generator(6.2)와 NPC generator(6.3)를 준비하세요. 둘이 공유할 city_manifest.json 스키마를 하나 정합니다. 필드는 최소 lore_seeds·region·faction·level_range·forbidden_names·tone·version.
prompt. 위 본문의 manifest 작성기 프롬프트를 그대로 쓰세요. 핵심은 마지막 두 규칙입니다. "옆 도시 진영과 겹치지 마라"(교차 충돌 방지)와 "추측한 값과 명시한 값을 구분 표시하라"(검수 가능성). 도시를 만들면 manifest를 떨어뜨리고, NPC·아이템 generator가 그 manifest를 입력으로 받게 연결하세요.
verify. 통합 lint를 한 번 돌리세요. lore 정합·레벨대 정합·진영 충돌·이름 중복 네 가지를 교차 검사합니다. alert이 뜨면 자동 폐기하지 말고 사람이 판정합니다. "틀린 것처럼 보이지만 좋은 서사"(몰락한 무역항의 늙은 상인)를 가려내는 게 사람의 몫입니다.
1인 축소판. 도구가 도시·NPC 둘뿐이라도 라인은 성립합니다. 스프레드시트 한 장에 도시별 lore_seeds·level_range·forbidden_names를 적고, NPC generator 프롬프트에 그 행을 통째로 붙여 넣는 것만으로 manifest 역할을 합니다. 통합 lint는 도시-NPC lore 정합 한 줄만 있어도 그 함정을 막습니다. 거창한 인프라 없이, "도구 사이에 한 줄의 계약을 넣는다"는 원칙 하나면 라인이 시작됩니다.
던전 47번 룸의 출구가 막혀 있었다. 빌드는 통과했고, QA도 통과했다. 사용자가 보스 룸 직전에서 벽을 보고 서 있는 스크린샷이 커뮤니티에 올라온 건 라이브 사흘째였다. 그 룸은 두 분기 전에 손으로 만든 룸을 복사해 붙인 것이었고, 복사하는 과정에서 동쪽 통로 하나가 연결 정보 없이 비주얼만 남았다. 누구도 그걸 검증하지 않았다. 검증할 도구가 없었다.
이 챕터는 그 사고가 빌드 단계에서 자동으로 차단되는 구조를 만드는 이야기다. 핵심은 공간을 그리는 손재주가 아니라, 공간에 붙는 데이터를 룰로 운영하는 방식에 있다.
레벨 디자인의 작업장은 도면실에 가깝다. 도면 한 장 한 장은 사람 손에서 나오지만, 도면 사이의 일관성·재사용·검증은 도면 캐비닛의 운영 규칙이 결정한다. 손으로 그린 던전 한 개는 누구나 만든다. 던전 100개를 일관된 난이도 곡선과 막다른 길 없는 그래프로 운영하는 건 손재주가 아니라 시스템의 문제다.
저자가 디자인 디렉터로 일하는 프로젝트 A(국내 + 동남아 타깃 MMORPG, 중규모(10~50인) 팀, 모바일 우선)에서 이 시스템의 이름은 Procedural_Level_Design_Master라는 한 개의 문서다. 이 챕터는 그 문서가 무엇을 통합하고, AI가 어디까지 손을 대고, 어디서 멈추는지를 다룬다. 매 런마다 던전이 새로 생성되는 모바일 로그라이트 RPG의 기획을 리드하며 절차적 공간을 룰로 운영해 본 경험이, 이 장의 바탕에 깔려 있다.
레벨 자동화는 두 방향으로 갈린다. 하나는 공간 자체를 절차적으로 생성하는 것이다. BSP 분할(Binary Space Partitioning, 공간을 재귀적으로 이등분해 룸을 배치하는 고전 기법), wave function collapse, 드렁큰 워크 그리드 같은 전통 PCG(Procedural Content Generation)가 여기에 속한다. 다른 하나는 공간의 메타데이터 — 룸 태그·연결성·난이도 라벨·이벤트 슬롯 — 를 운영하는 것이다.
전통 PCG는 첫 번째에 강하다. 로그라이크나 샌드박스처럼 "매 판마다 새로운 맵"이 게임성의 핵심인 장르에서는 첫 번째가 정답이다. 그런데 MMORPG는 다르다. 사용자가 같은 던전을 수십 번 돈다. 동선이 외워질 만큼 돈다. 그래서 던전은 손으로 다듬은 고정 공간이어야 하고, 자동화가 들어갈 자리는 공간 자체가 아니라 그 공간을 운영 가능하게 만드는 메타데이터다.
메타데이터가 왜 운영의 척추인지는 산출물별로 보면 분명하다.
| 산출물 | 메타데이터가 없으면 |
|---|---|
| 던전 풀 수십 개 | 어느 룸이 어디 있는지 검색 불가, 재사용 불가 |
| 난이도 곡선 검증 | 룸별 난이도 라벨이 없어 곡선을 그릴 수 없음 |
| 퀘스트·보스 위치 자동 배치 | 이벤트 슬롯 메타가 없어 수동 좌표 입력 |
| 아트 팀 동기화 | 룸 타입 → 아트셋 매핑이 없어 비주얼 불일치 |
| 사용자 동선·체류 시간 측정 | 룸 ID 기반 텔레메트리 불가 |
메타데이터 없는 던전은 빌드는 되지만 운영이 안 된다. 책은 가득한데 색인이 없는 도서관과 같다. 이 챕터가 "공간 메타데이터 운영"에 집중하는 이유다.
Procedural_Level_Design_Master는 네 개의 표준을 한 문서에 묶는다. 룸 메타데이터 양식, 룸 태그 사전, 연결성 룰, 검증 체크리스트다. 이 네 개가 흩어져 있을 때 무슨 일이 벌어지는지부터 보자. 디자이너 다섯 명이 각자 다른 파일에서 양식을 참조하면, type 필드를 누구는 combat, 누구는 Combat, 누구는 battle_room이라고 적는다. 검색이 깨지고, 통계가 깨지고, 결국 자동화가 깨진다.
이 네 표준은 Layer로 정렬하면 각자의 자리가 분명하다. 양식·사전·룰은 생성을 지배하는 룰북(L1)에, 생성된 룸 본문은 콘텐츠(L2)에, 시트 값은 데이터(L3)에, 검증은 빌드·QA 게이트(L4)에 있다.
마스터 문서가 네 표준을 통합한다는 말은 "본문을 한 파일에 몰아넣는다"가 아니라 "L1 자리에 룰을 모은다"는 뜻이다. 그래서 뒤에 나올 자동화가 Layer 경계 위에 얹힐 수 있다(분리가 무너지면 무슨 일이 벌어지는지는 7.1.11에서 다룬다).
룸 하나는 다음 양식을 따른다. 이 양식이 자동화의 입력 인터페이스다.
room_id: dungeon_021_room_07
dungeon: dungeon_021_silvermark_library
type: combat_room # combat / puzzle / lore / safe / boss
size: medium # small / medium / large
difficulty_label: hard_for_level_28
tags: [scholar_theme, vertical_layout, water_hazard]
connections:
- target_room: dungeon_021_room_06
type: door
direction: south
- target_room: dungeon_021_room_08
type: passage
direction: east
event_slots:
- slot: enemy_spawn_1
constraints: [scholar_enemy, level_28]
- slot: lore_object_1
constraints: [scholar_lore]
movement_complexity: 4 # 1~5
estimated_clear_time_sec: 90
art_pack: scholar_library_v2
각 필드는 한 개 이상의 자동화 소비처를 가진다. type은 던전 풀 통계와 난이도 계산에, tags는 검색·재사용·아트셋 매핑에, connections는 그래프 검증(막다른 길 검사)에, event_slots는 퀘스트·보스 자동 배치에 쓰인다. 소비처가 없는 필드는 양식에 넣지 않는다. 입력 비용만 늘고 가치가 없기 때문이다.
태그는 메타데이터의 검색 키다. 무한 증식하면 검색이 깨진다. 서랍에 라벨이 200개 붙으면 무엇이 어디 있는지 찾을 수 없다. 그래서 5개 카테고리 × 카테고리당 약 6개 enum, 합쳐서 약 30개로 운영한다.
| 카테고리 | enum 수 | 예 |
|---|---|---|
| theme | 8 | scholar_theme, ruins_theme, forest_theme … |
| layout | 5 | vertical_layout, horizontal_corridor, open_arena … |
| hazard | 6 | water_hazard, fire_hazard, falling_hazard … |
| interaction | 4 | puzzle_required, lever_activation … |
| narrative | 7 | flashback_trigger, dialogue_zone … |
한 룸에 태그 5개를 넘기지 않는다. 정상은 3~4개다. 신규 태그를 추가하려면 네 단계 게이트를 통과해야 한다. 분기당 5룸 이상 사용 후보일 것, 기존 태그 조합으로 표현 불가일 것, 검색·아트셋 매핑 활용이 명확할 것, 운영 1개월 후에도 5룸 유지될 것. 마지막 조건이 핵심이다. 임시로 만든 태그가 한 번 쓰이고 버려지면 사전이 오염된다.
지금까지의 표준이 한 흐름으로 어떻게 연결되는지, 그 연결선이 이 챕터가 떠받치는 골격이다. 룰북에서 시작해 AI 보조 변주를 거쳐 가드레일 검증으로 끝나는 파이프라인이다.
flowchart TD
A["L0 비전 — 던전 컨셉·페이싱 의도"] --> B["L1 룰북\n태그 사전 · 연결성 룰 · 슬롯 룰"]
B --> C["룸 골격 배치\n디자이너 손작업 + 에디터"]
C --> D["메타데이터 자동 추출\nroom_id · connections · type · size"]
D --> E["AI 보조 변주\ntags 추출 · art_pack 매핑 제안"]
E --> F{"사전 강제 검사\n사전 외 태그?"}
F -->|사전 외| E
F -->|통과| G["디자이너 검수\ntags · difficulty_label 확정"]
G --> H["L4 그래프 검증\n도달성 · 막다른 길 · 순환 · 분기"]
H -->|위반| C
H -->|통과| I["난이도 곡선 검증\n룸 난이도 라벨 합산"]
I --> J["아트셋 정합성 게이트"]
J -->|통과| K["빌드 — 던전 풀 등록"]
J -->|불일치| E
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
class B,D,F,H,I,J code;
class E ai;
class C,G human;
class K pass;
이 파이프라인의 세 가지 성격을 짚어 둔다. 첫째, 룰북(L1)이 모든 생성의 상류에 있다. 둘째, AI는 룰북이 정의한 사전 안에서만 변주한다 — F 게이트가 사전 외 출력을 되돌려보낸다. 셋째, 검증(H·I·J)이 빌드 직전 게이트로 고정되어 있어, 위반은 코드로 차단되지 사람의 주의력에 의존하지 않는다. 47번 룸 사고는 H 게이트가 없어서 일어난 일이다.
룸 메타의 connections 필드는 던전 전체를 하나의 방향 그래프로 만든다. 그래프가 되면 검증은 자동이다.
| 검사 | 위반 시 처리 |
|---|---|
| 시작 룸 → 보스 룸 도달 가능 | 빌드 실패로 차단 |
| 막다른 길 (출구 1개 + non-safe_room) | alert — 디자이너 검토 |
| 양방향 연결 정합성 (A→B 있는데 B→A 없음) | 자동 보정 |
| 순환 길이 — 2~3룸짜리 짧은 루프 | alert |
| 분기 폭 — 동시 4개 이상 분기 | 디자이너 검토 |
측정 스크립트는 다음 형태다. 표준 그래프 알고리즘(최장 경로·평균 출차수·루프 카운트·최단 경로) 위에 던전 어휘를 입힌 얇은 래퍼다.
# level_graph_metrics.py
def measure(dungeon):
graph = build_graph(dungeon.rooms)
return {
"depth": longest_path_length(graph),
"branching_factor": avg_out_degree(graph),
"loop_count": count_loops(graph),
"dead_ends": count_dead_ends(graph),
"boss_reachability": shortest_path(graph.start, graph.boss),
}
다섯 지표가 다른 던전과 비교 가능한 형태로 출력된다. 던전 풀의 다양성 지표로 쓴다. 다만 지표가 다양하다고 던전이 재밌다는 뜻은 아니다. 지표는 사고 차단용이지 재미 보장용이 아니다. 막다른 길 0건이 재미를 보장하지 않는다. 재미는 디자이너의 인사이트에서 나오고, 그래프 검증은 그 인사이트가 사고에 묻히지 않도록 바닥을 받쳐줄 뿐이다.
자동화 중 사람이 가장 자주 손을 떼고 싶어 하는 부분이 tags 입력이다. 룸 100개에 태그를 다는 건 지루하고, 룸 스크린샷만 보면 사람도 헷갈린다. 반복되고 판정 기준이 명확한 이런 일이야말로 AI가 초안을 떠받치기 좋은 자리다. 이 절은 그 작업을 실제로 돌렸던 워크플로를 — 프롬프트, AI의 거부당한 출력, 사람의 재요청까지 — 가공 없이 펼친다.
1차 프롬프트:
[입력]
- 룸 스크린샷: (첨부)
- 룸 면적: 18m × 12m, 천장 높이 9m (수직 2층 구조)
- 배치된 적: scholar_phantom × 3, water_elemental × 1
- 인접 룸 type: lore_room(서쪽), combat_room(동쪽)
- 룸 안에 얕은 물웅덩이 있음
이 룸에 맞는 태그 3~5개를 아래 사전에서만 골라 줘. 사전 밖 태그는 만들지 말고,
태그 enum만 콤마로.
[태그 사전 30개]
theme: scholar_theme, ruins_theme, forest_theme, cave_theme,
crypt_theme, swamp_theme, mechanical_theme, celestial_theme
layout: vertical_layout, horizontal_corridor, open_arena,
maze_layout, bridge_layout
hazard: water_hazard, fire_hazard, falling_hazard,
poison_hazard, crush_hazard, dark_hazard
interaction: puzzle_required, lever_activation, key_gate, hidden_passage
narrative: flashback_trigger, dialogue_zone, lore_object_zone,
ambush_setup, ritual_site, vista_point, rest_anchor
Claude 1차 출력 (날것 그대로):
scholar_theme, vertical_layout, water_hazard, ambush_setup, two_story_layout
여기서 두 가지 문제가 그대로 드러난다. 하나는 two_story_layout이 사전에 없는 태그라는 점이다. 모델이 "수직 2층 구조"라는 입력에 끌려 사전 밖 단어를 만들어냈다. 또 하나는 태그가 5개로 상한에 닿았는데, vertical_layout과 two_story_layout이 의미상 겹친다는 점이다. layout 카테고리에서 두 칸을 쓴 셈이다.
사람의 거부 판단. two_story_layout은 사전 강제 위반이므로 자동 거부 대상이다(파이프라인 F 게이트). ambush_setup은 판단이 갈린다 — 적 3마리가 배치돼 있지만 스크린샷상 매복 구도인지, 단순 전투 룸인지 모호하다. 이건 모델이 과잉 해석했을 가능성이 있다. 인접 룸이 둘 다 lore/combat이라 서사적 매복 트리거의 근거가 약하다. 거부.
2차 재요청:
two_story_layout은 사전에 없고 vertical_layout이랑 겹쳐. 빼.
ambush_setup도 빼 — 적 있다고 매복은 아니야.
// (의도: narrative 태그는 서사 의도가 명시될 때만, 단순 전투는 type으로 이미 표현됨)
빈 자리 굳이 안 채워도 돼. 3~4개가 정상이야. 다시 태깅해 줘.
Claude 2차 출력:
scholar_theme, vertical_layout, water_hazard
이번엔 3개로 줄었고, 전부 사전 안에 있고, 카테고리 중복이 없다. 사람은 이 결과를 받아들였다. 룸 양식의 tags: [scholar_theme, vertical_layout, water_hazard]가 이렇게 확정된다.
이 워크드 예제의 교훈은 두 줄이다. 첫째, AI는 입력의 한 디테일("2층")에 과적합해 사전 밖으로 나간다 — 사전 강제 게이트가 코드 레벨에서 이걸 잡아야 한다. 둘째, AI는 빈 자리를 채우려는 경향이 있다 — "굳이 채우지 마라"를 명시하지 않으면 5칸을 다 메우려 든다. 두 실패 모두 흔하고, 두 처방 모두 프롬프트가 아니라 룰북(사전 + 상한)에서 강제돼야 안정적이다.
디자이너가 룸 1개 메타를 손으로 채우면 5~10분이 든다. 던전 1개(20~30룸)면 2~5시간, 던전 100개면 200~500시간이다(저자 추정, 미검증 — 룸당 평균 입력 시간 × 룸 수로 환산한 상한치). 전부 손으로 채우면 디자이너는 메타데이터 입력 노예가 된다.
그래서 영역별로 채우는 주체를 나눈다.
| 영역 | 채우는 주체 |
|---|---|
| room_id · dungeon · connections | 에디터 자동 추출 (L3) |
| type · size | 룸 면적·연결 수 기반 자동 분류 |
| tags | AI 보조 + 디자이너 검수 (7.1.7) |
| event_slots | 룸 type별 룰북 |
| difficulty_label | 룸 내 적 데이터 합산 자동 계산 |
| art_pack | 룸 type · 던전 theme 매핑 |
디자이너가 손으로 확정하는 건 tags 검수와 difficulty_label 최종 승인 정도다. 나머지는 도구가 채우고 사람은 검수한다. 자동화의 목적은 디자이너를 입력에서 빼내 페이싱·시그니처 룸·재활용 정책 판단으로 돌려보내는 것이다.
마스터 표준의 가장 큰 효과는 룸 재활용이다. 태그로 검색 가능한 룸 30개가 있으면 던전 5~10개를 조합으로 만들 수 있다. 그런데 재활용 비율이 높아지면 던전이 식상해진다. 그래서 재활용에는 가드레일을 함께 둔다.
| 가드레일 | 정의 |
|---|---|
| 한 룸은 최대 5개 던전에 출연 | 출연 빈도 자동 추적 |
| 두 번째 출연 시 시각 변주 강제 | 라이트·소품 변경 |
| 보스 룸·시그니처 룸 재활용 금지 | flag로 강제 |
| 재활용 룸의 부정 피드백 추적 | 사용자 텔레메트리 |
재활용은 비용을 줄이는 수단이지 목적이 아니다. 재활용률 자체를 KPI로 삼는 순간 사용자 체험이 단조로워진다. 0%(모든 룸 신규)면 양산 비용이 폭발하고, 70%를 넘으면 던전들이 서로 구별되지 않는다. 경험상 30~40% 구간이 비용과 다양성의 균형점이다(방향성 관찰, 정밀 임계치는 프로젝트마다 다름).
| 패턴 | 처방 |
|---|---|
| 메타 양식을 5인이 5가지로 해석 | Master 문서로 L1 통합 |
| 태그를 50~100개로 증식 | 30개 사전 + 4단 게이트 |
| 막다른 길 검사 없이 빌드 | 그래프 검증을 빌드 게이트로 |
| 디자이너가 모든 메타 손작업 | 에디터 추출 + AI 보조 |
| AI가 사전 밖 태그 생성 | 사전 강제 게이트로 자동 거부 |
| 재활용 0% 또는 70%+ | 30~40% 구간 + 변주 가드레일 |
지금까지 룰북·생성·검증으로 풀어 둔 7.1.2~7.1.6의 구조 자체가 Layer 분해의 결과물이다. Layer 분해가 절차적 생성·자동화의 전제라는 일반 논제(L0 앵커 → L1 룰북 → L2 본문 → L3 수치 → L4 게이트, 한 덩어리면 생성이 무너진다)는 §6.6에서 다뤘다. 여기서는 그것을 레벨 메타데이터 운영에 적용한다.
이 분리가 없으면 룸 배치·BSP·페이싱·서사 트리거가 한 파일에 섞이고, 룸 한 칸을 옮길 때마다 페이싱 의도·이벤트 슬롯·연결성 그래프가 동시에 망가진다. 도면실·자재 창고·검수실이 한 책상에 쌓여 있어, 도면 한 장을 빼면 자재 송장과 검수표가 같이 빠지는 상황이다. 그래서 7.1.7의 AI 보조가 작동한 것도 Layer 덕분이다. 룸 ID·연결성은 에디터(L3 자동 추출)에서, 태그는 AI(L1 사전 강제)에서, difficulty_label은 합산(L3→L4)에서 채워진다. 자동화는 Layer 경계 위에 얹히지, 한 덩어리 위에 얹히면 첫 분기 안에 사고가 폭증해 도구 자체가 폐기된다.
다만 처음부터 다섯 칸 서랍을 완벽히 갖춰야 한다는 뜻은 아니다. 분리는 점진적으로, 인터페이스는 좁게가 원칙이다. 첫 분기에는 L1 룰북(태그 사전 + 연결성 룰)과 L3 시트(룸 메타 시트)만 분리해도 자동화가 들어올 자리가 생긴다. L0 페이싱 의도와 L4 검증 게이트는 분기를 거치며 채운다. 표준이 통일돼야 자동화가 들어올 자리가 생기고, 자동화가 들어올수록 디자이너는 룸 한 칸의 수작업이 아니라 페이싱·시그니처·재활용 판단에 집중하게 된다.
setup. 던전 한 개를 골라 룸마다 room_id · type · connections · tags 네 필드만 가진 YAML 시트를 만드세요. 태그는 5 카테고리 약 30개 enum 사전을 먼저 종이 한 장에 고정합니다.
prompt. 룸 스크린샷 + 면적 + 적 종류 + 인접 룸 type을 넣고 "이 사전에서 태그 3~5개만 골라라, 사전 밖 태그 금지, 빈 자리 채우지 마라"로 요청하세요(7.1.7 프롬프트 그대로).
verify. (1) AI 출력에 사전 밖 태그가 있으면 거부하고 재요청하세요. (2) connections로 그래프를 만들어 시작→보스 도달성과 막다른 길을 검사합니다 — 위반이 하나라도 나오면 그 룸은 빌드 불가로 표시하세요.
도구 인프라가 없는 1인 개발자라면, 마스터 문서를 마크다운 한 장으로 시작하세요. 태그 사전 30줄, 연결성 룰 5줄, 검증 체크리스트 5줄이면 충분합니다. 그래프 검증은 룸 10개 이하라면 종이에 화살표를 그려 막다른 길만 눈으로 확인해도 효과의 80%가 나옵니다. 핵심은 도구가 아니라 "룸에 데이터를 붙이고, 그 데이터를 룰로 검사한다"는 습관 자체입니다. 도구는 룸 50개를 넘어 손으로 검사하기 버거워질 때 붙이면 됩니다.
견습 마법사 한 마리가 플레이어에게 달라붙어 칼을 휘두르고 있었다. 원거리 마법 캐스터로 설계한 NPC였다. HP는 종잇장이고 근접에서는 한 대만 맞아도 죽는데, 그놈은 거리를 벌릴 생각이 없었다. 빌드 로그에는 아무 에러도 없었다. 에디터에서 BehaviorTree를 다시 열어 봐도 노드는 멀쩡하게 연결돼 있었다. 한 시간을 들여다본 끝에 원인을 찾았다. 후퇴 분기의 거리 조건이 5가 아니라 0.5로 들어가 있었다. 5미터 안으로 들어오면 도망쳐야 하는데, 0.5미터 — 즉 거의 코앞이 아니면 후퇴 분기가 발동하지 않았던 것이다.
숫자 하나였다. 그래픽 노드 에디터에서는 그 숫자가 노드 안쪽 패널을 펼쳐야만 보였고, 변경 이력에는 남지 않았다. 누가 언제 그 값을 바꿨는지 추적할 방법이 없었다. 그날 이후로 저자의 프로젝트 A는 BehaviorTree를 그래픽이 아니라 json으로 다루기 시작했다. 이 챕터는 그 json을 사람과 AI가 함께 편집하고, 기계가 자동으로 검증하는 한 사이클의 기록이다.
BehaviorTree는 적 NPC의 전투·이동·반응을 정의하는 사실상의 표준 구조다. 선택자(selector)가 우선순위대로 분기를 시도하고, 시퀀스(sequence)가 조건과 액션을 순서대로 묶는다. 구조 자체는 단순하다. 문제는 규모다.
저자의 프로젝트 A에서 적 NPC 한 체의 BT는 대략 50~200개 노드로 구성됐고, 운영 대상 NPC는 100체를 넘겼다. 곱하면 BT 노드 총량이 수만 단위가 된다. 이 규모에서 "이 후퇴 패턴을 바꾸면 어느 NPC가 영향을 받지?"라는 질문에 사람이 답할 수 없게 되는 순간이 온다. 책상 위에 노트 백 권이 펼쳐져 있는데, 첫 권의 한 줄을 고치면 나머지 아흔아홉 권의 어디가 번지는지를 눈으로 좇는 일과 같다.
저자가 그래픽 BT에서 json으로 넘어가며 요구한 것은 네 가지였다.
상용 게임 엔진의 내장 BT 에디터는 통합이 편하고 시각적 디버깅이 강하다. 다만 이진(binary) 자산으로 저장되는 경향이 있어 텍스트 diff와 변경 영향 추적이 약하다. 저자의 프로젝트 A는 운영 BT가 100체를 넘는 라이브 게임을 전제했기 때문에 별도 json BT 포맷과 에디터를 자체 개발하는 쪽을 골랐다. 분명히 해 두자. 이건 모든 팀의 정답이 아니다. 운영 BT가 50체 미만이면 엔진 내장 에디터를 그대로 쓰는 편이 거의 항상 더 싸다. 자체 개발의 정당화는 이 챕터 끝에서 다시 다룬다.
먼저 결과물의 모양을 본다. 아래는 학자 길드 원거리 지원형 NPC의 BT 일부다. 핵심은 두 가지다. 모든 행동이 텍스트라 git이 한 줄 단위로 추적할 수 있다는 것, 그리고 공통 패턴을 subtree_ref로 인용한다는 것.
{
"bt_id": "bt_scholar_archer_v3",
"category": "ranged_combatant",
"tags": ["scholar_faction", "ranged", "support"],
"description": "학자 길드 원거리 지원형. 거리 유지 + 후퇴 우선.",
"root": {
"type": "selector",
"children": [
{
"type": "sequence",
"name": "low_hp_retreat",
"children": [
{"type": "condition", "fn": "hp_below", "param": 0.3},
{"type": "subtree_ref", "id": "subtree_retreat_to_ally"}
]
},
{
"type": "sequence",
"name": "kite_pattern",
"children": [
{"type": "condition", "fn": "enemy_in_close_range", "param": 5},
{"type": "action", "fn": "move_away", "param": {"distance": 8}}
]
},
{"type": "subtree_ref", "id": "subtree_ranged_attack_pattern"}
]
}
}
이 트리를 그림으로 펼치면 선택자가 위에서부터 세 분기를 시도하는 구조다. 도입부의 그 버그 — enemy_in_close_range의 param이 5냐 0.5냐 — 가 json에서는 한눈에 보이는 한 줄이 된다는 점에 주목하자.
flowchart TD
R["selector
(위에서부터 우선순위)"]
R --> A["sequence: low_hp_retreat"]
R --> B["sequence: kite_pattern"]
R --> C["subtree_ref:
subtree_ranged_attack_pattern"]
A --> A1["condition: hp_below 0.3"]
A --> A2["subtree_ref:
subtree_retreat_to_ally"]
B --> B1["condition: enemy_in_close_range 5"]
B --> B2["action: move_away dist=8"]
style C fill:#e8f0fe,stroke:#4285f4
style A2 fill:#e8f0fe,stroke:#4285f4
style B1 fill:#fce8e6,stroke:#ea4335
| 요소 | 역할 |
|---|---|
bt_id |
git diff·변경 추적 키 |
category·tags |
검색·재사용 단위 |
subtree_ref |
공통 패턴 인용 (한 곳 수정 → 다수 BT 갱신) |
description |
디자이너·시나리오 작가 공유용 |
붉게 칠한 enemy_in_close_range 5가 도입부에서 사람을 한 시간 잡아먹은 그 노드다. json에서는 코드 리뷰 한 번에 잡힌다.
100체가 넘는 적의 행동에는 반복되는 덩어리가 있다. "동맹 뒤로 후퇴" "엄폐물로 후퇴" "원거리 공격 패턴" 같은 것들이다. 이걸 BT마다 복사해 넣으면, 후퇴 로직 하나를 고칠 때 백 군데를 손으로 찾아 고쳐야 한다. 그래서 공통 패턴은 별도 subtree 파일로 떼어 두고 subtree_ref로 인용만 한다.
subtree_library/
├── retreat_patterns/
│ ├── subtree_retreat_to_ally.json
│ ├── subtree_retreat_to_cover.json
│ └── subtree_retreat_random.json
├── attack_patterns/
│ ├── subtree_ranged_attack_pattern.json
│ ├── subtree_melee_combo.json
│ └── subtree_aoe_attack.json
└── reaction_patterns/
├── subtree_react_to_ally_death.json
└── subtree_react_to_player_taunt.json
이렇게 두면 "이 subtree를 고치면 누가 영향을 받지?"라는 질문이 사람의 추정이 아니라 스크립트의 출력이 된다. 영향 추적기는 단순하다. 모든 BT를 열어 보고, 해당 subtree를 인용하는 BT의 bt_id를 모은다.
# bt_impact_tracker.py
import json, glob
def has_subtree_ref(node, target_id):
if isinstance(node, dict):
if node.get("type") == "subtree_ref" and node.get("id") == target_id:
return True
for child in node.get("children", []):
if has_subtree_ref(child, target_id):
return True
return False
def find_affected_bts(subtree_id):
affected = []
for bt_file in glob.glob("bts/*.json"):
bt = json.load(open(bt_file, encoding="utf-8"))
if has_subtree_ref(bt["root"], subtree_id):
affected.append(bt["bt_id"])
return affected
# 사용
affected = find_affected_bts("subtree_ranged_attack_pattern")
# → ["bt_scholar_archer_v3", "bt_ranger_v2", "bt_sniper_v1", ...]
저자의 프로젝트 A에서는 이 함수를 변경 요청(Pull Request) 단계에 묶어 두었다. 누군가 subtree 파일을 건드리면, 영향 받는 BT 목록이 자동으로 PR 코멘트에 달린다. 리뷰어는 "후퇴 패턴 한 줄 고쳤는데 원거리 적 12체가 전부 바뀐다"는 사실을 머지 전에 본다.
여기서부터가 이 챕터에서 가장 무게가 실리는 대목이다. 새 적 NPC "견습 마법사"의 BT 초안을 AI에게 맡기고, 그 출력을 사람이 검증·거부·재요청하는 한 사이클을 다듬지 않고 그대로 옮긴다. 매끄럽게 줄이지 않는 데는 이유가 있다. AI가 첫 출력에서 무엇을 어떻게 틀렸는지, 그 실패의 결이 이 챕터가 전하려는 전부이기 때문이다.
[적 NPC 컨셉]
학자 길드의 견습 마법사. HP 낮고 마법 데미지 높음.
근접 회피 우선. 동맹 뒤로 후퇴한 뒤 마법 공격.
[category] ranged_combatant
[참고 BT] bt_scholar_archer_v3
(구조 유사. 다만 활 공격 대신 마법 공격으로 교체)
[정의된 함수 enum]
condition: hp_below, enemy_in_close_range, ally_nearby, mana_below
action: move_away, cast_spell, retreat, wait
subtree_ref id (사용 가능 목록):
- subtree_retreat_to_ally
- subtree_ranged_attack_pattern # 활 기반. 마법용 아님
- subtree_magic_attack_pattern
이 컨셉으로 BT root 노드 json만 짜 줘. subtree_ref 최대한 쓰고 직접 노드는 최소로,
함수는 위 enum 안에서만. 컨셉에 없는 행동은 추가하지 말고. 설명 없이 json만.
{
"bt_id": "bt_scholar_apprentice_v1",
"category": "ranged_combatant",
"tags": ["scholar_faction", "magic", "fragile"],
"description": "학자 길드 견습 마법사. 근접 회피 + 동맹 뒤 후퇴 + 마법 공격.",
"root": {
"type": "selector",
"children": [
{
"type": "sequence",
"name": "low_hp_retreat",
"children": [
{"type": "condition", "fn": "hp_below", "param": 0.4},
{"type": "subtree_ref", "id": "subtree_retreat_to_ally"}
]
},
{
"type": "sequence",
"name": "avoid_melee",
"children": [
{"type": "condition", "fn": "enemy_in_close_range", "param": 6},
{"type": "action", "fn": "move_away", "param": {"distance": 10}}
]
},
{
"type": "sequence",
"name": "mana_recover",
"children": [
{"type": "condition", "fn": "mana_below", "param": 0.2},
{"type": "action", "fn": "wait", "param": {"frames": 120}}
]
},
{"type": "subtree_ref", "id": "subtree_ranged_attack_pattern"}
]
}
}
출력을 받아 lint(다음 절에서 다룬다)에 걸고, 눈으로 읽었다. 세 군데가 걸렸다.
마지막 분기가 틀렸다. subtree_ranged_attack_pattern은 활 기반 공격 패턴이다. enum 주석에 "마법용 아님"이라고 적어 두었는데도 AI가 이름이 비슷한 활 패턴을 가져왔다. 견습 마법사는 subtree_magic_attack_pattern을 써야 한다. — 거부.
컨셉에 없는 행동을 추가했다. mana_recover 분기(mana_below → wait 120frame)는 컨셉에 한 줄도 없는 행동이다. 프롬프트에 "명시되지 않은 행동 추가 금지"라고 못 박았는데도 AI가 "마법사니까 마나 관리가 자연스럽다"고 판단해 끼워 넣은 것으로 보인다. 그럴듯하지만 명세 위반이다. — 거부.
후퇴 임계값이 의심스럽다. hp_below 0.4는 참고 BT(0.3)보다 후퇴를 빨리 시작한다. 견습 마법사가 더 약한 건 맞으니 의도일 수 있다. 다만 AI가 근거를 댄 적이 없으므로 디자이너가 결정할 사항이다. — 보류, 디자이너 확인.
세 군데만 고쳐 줘. 마지막 분기 subtree_ranged_attack_pattern은 활 패턴이니
subtree_magic_attack_pattern으로 바꾸고, mana_recover 분기는 컨셉에 없으니 통째로 삭제,
hp_below 0.4는 참고 BT(0.3)로 되돌려 줘 — 0.4로 할 근거 있으면 description에 한 줄로.
나머지는 그대로, json만.
{
"bt_id": "bt_scholar_apprentice_v1",
"category": "ranged_combatant",
"tags": ["scholar_faction", "magic", "fragile"],
"description": "학자 길드 견습 마법사. 근접 회피 + 동맹 뒤 후퇴 + 마법 공격. HP 낮아 후퇴 임계값 0.3 유지(archer와 동일).",
"root": {
"type": "selector",
"children": [
{
"type": "sequence",
"name": "low_hp_retreat",
"children": [
{"type": "condition", "fn": "hp_below", "param": 0.3},
{"type": "subtree_ref", "id": "subtree_retreat_to_ally"}
]
},
{
"type": "sequence",
"name": "avoid_melee",
"children": [
{"type": "condition", "fn": "enemy_in_close_range", "param": 6},
{"type": "action", "fn": "move_away", "param": {"distance": 10}}
]
},
{"type": "subtree_ref", "id": "subtree_magic_attack_pattern"}
]
}
}
2차 출력은 lint를 통과했고, 디자이너 검수에서 avoid_melee의 거리 6을 archer와 맞춰 5로 한 칸 내린 뒤 시뮬레이션 게이트로 넘겼다. 여기서 배울 점은 명확하다. AI는 BT의 골격을 빠르게 채우지만, "이름이 비슷한 잘못된 subtree를 끌어오고" "그럴듯한 추가 행동을 명세 없이 끼워 넣는" 두 가지 사고를 거의 매번 낸다. 이 두 사고는 사람의 눈과 lint 게이트로만 걸린다. 그래서 AI 출력은 초안이지 최종이 아니다.
BT는 사용자 체험에 직결된다. 적이 코앞에서 도망 안 가는 사고가 그대로 출시되면 리뷰 점수로 돌아온다. 그래서 머지 전에 기계가 먼저 검사한다.
| 검사 | 위반 시 |
|---|---|
| 도달 불가 노드 | alert (선택자에서 영영 닿지 않는 분기) |
| 무한 루프 위험 | 차단 (탈출 조건 없는 sequence 반복) |
subtree_ref 대상 미존재 |
차단 |
| 액션·조건 함수가 enum 밖 | 차단 |
| 노드 수 폭증 (>500) | alert (BT 분할 권고) |
| 같은 category 내 BT 응답 시간 편차 | alert (밸런스 회귀 의심) |
마지막 항목이 이 lint의 특이점이다. 같은 ranged_combatant로 묶인 BT 다섯이 시뮬레이션 평균 응답 시간이 크게 벌어지면, 그건 누군가 한 체의 밸런스를 모르게 깨뜨렸다는 신호다. 정적 검사가 잡지 못하는 "분위기"를 통계로 잡는 장치다.
정적 lint 다음은 시뮬레이션 검증이다. BT를 실제 게임 빌드 없이 시뮬레이터에서 1,000회 돌려 통계를 뽑는다.
| 측정 | 정상 범위 |
|---|---|
| 평균 생존 시간 (표준 플레이어 상대) | category별 기준값 |
| 공격 패턴 다양성 (엔트로피) | 0.6 이상 |
| 후퇴·접근 행동 비율 | category별 기준값 |
| 행동 1회 평균 소요 frame | 60 frame 이하 |
빌드를 굽지 않고도 5~10분 안에 "이 BT가 너무 빨리 죽는지" "한 가지 행동만 반복하는지"를 본다. 이상 신호가 뜨면 json을 고치고 시뮬을 다시 돌린다. 이 사이클이 일 단위에서 분 단위로 줄어드는 게 json화의 실질 이득이다.
flowchart LR
P["사람/AI가
BT json 편집"] --> L{"정적 lint"}
L -->|차단| P
L -->|통과| R["디자이너 검수"]
R -->|거부| P
R -->|승인| S{"시뮬 1,000회"}
S -->|이상 신호| P
S -->|정상| M["머지 + 빌드 반영"]
style L fill:#fef7e0,stroke:#fbbc04
style S fill:#fef7e0,stroke:#fbbc04
style M fill:#e6f4ea,stroke:#34a853
저자의 프로젝트 A의 도입 전후를 표로 둔다. 절대 수치는 팀 규모·게임 장르에 따라 달라지므로 저자 추정(미검증)이다. 다만 방향과 비율은 실제 운영에서 관찰한 그대로다.
| 항목 | 도입 전 (엔진 내장 BT 직접) | 도입 후 (json + 에디터) |
|---|---|---|
| 새 적 1체 BT 작성 | 1~2일 | 2~4시간 |
| BT 변경 영향 파악 | 추정·경험에 의존 | 자동 (subtree 영향 목록) |
| 변경 후 검증 | 실제 빌드 필요 | 시뮬 5~10분 |
| 적 NPC 100체 운영 | 디자이너 3인 풀타임 | 디자이너 1~2인 |
| 출시 후 BT 사고 (이상 행동) | 분기당 10~15건(저자 추정) | 분기당 2~4건(저자 추정) |
가장 의미 있는 건 마지막 두 줄이 동시에 움직였다는 점이다. 보통 인원을 줄이면 품질이 떨어진다. 여기서는 디자이너 수가 줄면서 사고도 줄었다. 사람이 손으로 추적하던 변경 영향과 검증을 기계가 떠안았기 때문이다. 자동화의 가치는 "빨라짐"보다 이 "줄면서 동시에 좋아짐"에 있다.
이 챕터를 읽고 "우리도 json BT 에디터를 만들자"고 결론 내리면 곤란하다. 저자의 프로젝트 A가 자체 개발을 고른 건 특정 조건이 맞아떨어졌기 때문이다.
| 옵션 | 장 / 단 |
|---|---|
| 엔진 내장 BT 그대로 사용 | 통합 쉬움 / json 변환·diff 약함 |
| 외부 BT 라이브러리 차용 | 표준화 이점 / 학습 곡선·커스터마이즈 한계 |
| 자체 json BT 에디터 + 런타임 | 자유도·추적성 최고 / 개발 비용 큼 |
프로젝트 A가 3번을 고른 근거는 네 가지였다.
개발 비용은 1~2개월. 운영 BT가 100~300체에 이르고 라이브 운영 기간이 길어야 회수된다. 30~50체 규모에서는 회수가 안 된다. 자체 개발의 투자 회수(ROI, Return On Investment)는 규모와 운영 기간이 둘 다 보장될 때만 나온다는 뜻이다. 작은 팀이라면 이 챕터에서 "json으로 저장한다" "subtree로 인용한다" "AI 출력은 lint+검수 게이트를 통과시킨다"는 원칙만 가져가고, 도구는 내장 에디터나 외부 라이브러리에 얹어 쓰는 편이 옳다.
| 패턴 | 처방 |
|---|---|
| BT를 이진 자산으로만 관리 | json으로 저장해 git 추적을 살린다 |
| subtree 없이 BT마다 같은 패턴 복붙 | subtree 라이브러리로 떼어 인용한다 |
| BT 영향 추적을 수작업으로 | 영향 분석 스크립트를 PR에 묶는다 |
| 시뮬 없이 실제 빌드에서만 검증 | 빌드와 분리된 시뮬레이터를 운영한다 |
| AI 출력 BT를 검수 없이 사용 | lint + 디자이너 + 시뮬 3중 게이트를 통과시킨다 |
| 자체 개발 ROI를 재지 않음 | 100체 이상·라이브 운영일 때만 자체 개발한다 |
작은 팀이 오늘 시도할 수 있는 최소 사이클입니다.
setup — 운영 중인 적 NPC 한 체의 BT를 json으로 손수 적으세요(bt_id, category, tags, root). 공통 후퇴·공격 패턴 한 덩어리를 subtree_library/로 떼어 subtree_ref로 인용합니다.
prompt — 비슷한 새 적 한 체를 AI에 맡기세요. 위 워크드 트랜스크립트의 프롬프트 골격(컨셉 + category + 참고 BT + 사용 가능 함수 enum + "명시 안 된 행동 추가 금지" + "json만")을 그대로 쓰면 됩니다.
verify — AI 출력을 (1) enum 밖 함수·미존재 subtree를 거르는 lint, (2) 사람 눈, (3) 시뮬 또는 인게임 짧은 검증, 이 세 게이트에 통과시킨 뒤에만 머지하세요. AI가 "이름 비슷한 잘못된 subtree"와 "그럴듯한 명세 밖 행동"을 끼워 넣었는지 반드시 확인하세요.
에디터를 만들 여력이 없다면 도구는 텍스트 편집기와 git, 그리고 30줄짜리 bt_impact_tracker.py 한 개면 충분합니다. 내장 에디터로 짠 BT를 json으로 한 번 내보내 git에 올리고, subtree만 별도 파일로 떼어 인용하세요. 영향 추적 스크립트를 커밋 훅에 걸면, 혼자서도 "이 후퇴 패턴을 고치면 어느 적이 바뀌는지"를 추정이 아니라 출력으로 볼 수 있습니다. 이 한 개의 습관만으로 도입부의 "숫자 하나에 한 시간"은 코드 리뷰 한 줄로 줄어듭니다.
던전 리뷰 자리에서, 신입 레벨 디자이너가 자기 던전 한 개를 화면에 띄웠다. 좁은 복도, 후방에서 따라붙는 빠른 적, 분기점에서의 회피 결정. 잘 만든 던전이었다. 문제는 그게 우리가 이미 열한 개의 다른 던전에서 만들었던 것과 미묘하게 달랐다는 점이다. 적의 추격 속도, 함정이 터지는 타이밍, 분기점이 나타나는 시점. 어느 것 하나 같지 않았다. 신입은 "추격 던전"이라는 같은 이름의 체험을 만들었다고 믿었지만, 사용자가 받은 감각은 던전마다 제각각이었다.
그날 우리가 결정한 것은 단순했다. "복도 추격"이라는 체험을 한 번 정확히 정의하고, 그 정의를 박제해 두자. 다음에 누가 추격 던전을 만들 때는 처음부터 짜는 게 아니라 그 박제된 정의를 꺼내 쓰자. 이것이 패턴 라이브러리의 시작이었다.
룸이 공간 단위이고 BehaviorTree가 행동 단위라면, 패턴은 공간과 행동과 이벤트를 하나로 묶은 운영 단위다. 패턴 하나가 던전 여러 개에 재사용되면 양산 부담이 줄고, 더 중요하게는 사용자가 받는 체험이 던전 사이에서 일관된다.
요리책의 레시피를 떠올려 보면 정확하다. 레시피 한 장에는 재료, 조리 순서, 불 세기, 완성 사진이 함께 들어 있다. 식당이 바뀌어도 같은 레시피를 따르면 같은 맛이 난다. 다만 식당마다 약간의 변주는 허용한다. 패턴도 같다. 공간(룸), 행동(BT subtree), 사건(event), 결과(보상·난이도), 그리고 디자이너의 의도 설명이 한 묶음으로 들어간다.
패턴 하나가 정의되면 던전마다 같은 체험을 일관되게 만들 수 있다. 한 번 검증된 레시피가 여러 식당에서 같은 맛을 내듯이. 다만 같은 레시피라도 식당마다 약간의 변주는 둔다. 이 변주를 어떻게 관리하느냐가 패턴 운영의 절반이다. 뒤에서 다룰 overrides가 그 자리다.
패턴 라이브러리의 핵심은 패턴을 룰북으로 박제한 뒤, 그것을 조합해 던전을 생성한다는 점이다. 디자이너가 빈 화면에서 던전을 짜는 게 아니라, 검증된 패턴을 골라 배치하고 일부만 변주한다.
flowchart TD
A[게임 내 좋은 체험 순간 관찰] --> B[공간·NPC·이벤트로 분해]
B --> C[룸 템플릿·subtree에 매핑]
C --> D{시뮬레이션 + 사용자 테스트 통과?}
D -- 아니오 --> B
D -- 예 --> E[라이브러리에 패턴 등록
usage_count = 0]
E --> F[(패턴 라이브러리
5 카테고리 · 30~50개)]
F --> G[던전 디자인: 패턴 인스턴스 호출]
G --> H[배치 placement + 변주 overrides]
H --> I{변주 비율 20% 이하?}
I -- 예 --> J[던전 완성
패턴 usage_count +1]
I -- 아니오 --> K[별개 패턴으로 분리 검토]
K --> B
J --> L[패턴 영향 추적
한 패턴 수정 → 사용 던전 자동 집계]
L --> F
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
class L code;
class F data;
class J pass;
이 흐름의 왼쪽 절반(관찰→분해→매핑→검증→등록)은 패턴을 만드는 과정이고, 오른쪽 절반(호출→배치·변주→완성→추적)은 패턴을 소비하는 과정이다. 만드는 일은 드물게, 소비하는 일은 자주 일어난다. 라이브러리가 잘 운영되면 이 비대칭이 양산 효율로 이어진다.
저자의 프로젝트 A는 액션 RPG 계열이라 다섯 개 카테고리로 패턴을 분류한다. 이 분류는 장르에 종속된다. 호러 게임이라면 매복과 서사 비트의 비중이 다를 것이고, 퍼즐 게임이라면 환경 활용 전투가 중심에 올 것이다. 분류 자체를 절대시하지 말고, 자기 게임의 핵심 체험이 무엇인지를 먼저 정한 뒤 카테고리를 잡아야 한다.
| 카테고리 | 핵심 체험 | 예 |
|---|---|---|
| pursuit | 추격·도주 | 복도 추격, 협곡 도주 |
| ambush | 매복·기습 | 룸 진입 시 매복, 시야 사각 매복 |
| puzzle_combat | 환경 활용 전투 | 레버·함정 + 전투 |
| boss_phase | 보스 페이즈 | 보스 페이즈 1~3 패턴 |
| narrative_beat | 서사 비트 | 회상 트리거, 동료 등장 |
다섯 카테고리 안에서 패턴은 대략 서른에서 쉰 개 사이를 유지한다. 이 숫자에는 이유가 있다. 패턴이 백 개를 넘어가면 디자이너가 라이브러리 전체를 머릿속에 담지 못한다. 그 순간 라이브러리는 검색하는 데 시간이 걸리는 창고가 되고, 디자이너는 차라리 새로 짜는 쪽을 택한다. 라이브러리가 외면받기 시작하면 일관성이라는 원래 목표가 무너진다. 그래서 패턴 개수의 상한을 의식적으로 관리하는 것이 카테고리 설계만큼 중요하다.
패턴 하나는 YAML 파일 한 장으로 박제된다. 아래는 프로젝트 A에서 실제로 쓰는 형식을 익명화한 것이다. 회사 고유 자산명과 던전 번호는 가렸지만, 필드 구조와 운영 방식은 그대로다.
---
pattern_id: pattern_corridor_pursuit_v2
category: pursuit
description: 좁은 복도에서 빠른 적이 후방 추격, 플레이어는 분기점에서 회피 결정
tags: [horizontal_corridor, scholar_theme_compatible]
rooms:
- room_template: corridor_long
size: medium
connections_required: 2
- room_template: junction_3way
size: small
connections_required: 3
npc_behaviors:
- subtree_ref: subtree_aggressive_chase
count: 2
- subtree_ref: subtree_ranged_support
count: 1
events:
- type: trap_activation
trigger: room_1_midpoint
- type: enemy_spawn
trigger: room_1_entry
difficulty_modifier: 1.2 # 일반 룸 대비 1.2배 부담
reward_modifier: 1.3
clear_time_estimate_sec: 60
art_pack_compatible: [scholar_library, generic_dungeon]
narrative_slots:
- slot: dialogue_during_chase
constraints: [short_dialogue, fear_emotion]
usage_count: 12 # 12개 던전에서 사용
last_modified: 2026-05-18
deprecated: false
---
이 파일이 던전 열두 개의 한 부분씩을 동시에 정의한다. usage_count: 12라는 한 줄의 무게가 거기서 나온다. 이 패턴을 수정하면 열두 개 던전이 한꺼번에 영향을 받는다는 뜻이고, 그래서 패턴 파일을 건드리는 일은 룸 하나를 고치는 일과 다른 무게를 가진다.
subtree_aggressive_chase나 subtree_ranged_support 같은 참조는 7.2의 BehaviorTree 에디터에서 정의한 subtree를 그대로 가리킨다. 패턴이 BT를 직접 품지 않고 참조만 하는 것이 핵심이다. BT를 고치면 그 BT를 참조한 모든 패턴이 자동으로 따라온다. 공간(룸 템플릿)과 행동(subtree)은 각자의 라이브러리에서 관리되고, 패턴은 그 둘을 엮는 조합표 역할만 한다. clear_time_estimate_sec이나 difficulty_modifier 같은 수치는 저자 환경의 운영값일 뿐 보편 상수가 아니다. 자기 게임의 시뮬레이션과 사용자 테스트로 직접 측정해 채워야 한다.
던전을 디자인할 때는 패턴을 처음부터 짜지 않는다. 라이브러리에서 호출하고, 어디에 놓을지를 지정하고, 이 던전에서만 다르게 할 부분을 overrides로 덮는다.
---
dungeon_id: dungeon_021_silvermark_library
pattern_instances:
- instance: corridor_pursuit_1
pattern_id: pattern_corridor_pursuit_v2
placement:
- room_id: dungeon_021_room_03
as: corridor_long
- room_id: dungeon_021_room_04
as: junction_3way
overrides:
- field: npc_behaviors.0.subtree_ref
value: subtree_scholar_chase # 학자 테마 변종
- field: events.0.trigger
value: room_1_2nd_third # 트리거 위치 미세 조정
---
여기서 던전 021은 "복도 추격" 패턴을 그대로 쓰되, 추격하는 적을 일반 적에서 학자 테마 변종으로 바꾸고, 함정이 터지는 위치를 복도 중간에서 약간 뒤로 옮겼다. 패턴의 80%는 그대로, 20%만 변주했다.
이 비율에는 운영 경험에서 나온 근거가 있다. 변주가 너무 적으면(0%에 가까우면) 던전들이 서로 베낀 듯 식상해진다. 변주가 너무 많으면(50%를 넘으면) 그건 더 이상 같은 패턴이 아니다. 같은 패턴을 호출했다고 믿지만 실제 체험은 완전히 다른, 신입이 가져왔던 그 던전과 똑같은 상황으로 되돌아간다. 그래서 우리는 운영 규칙을 둔다. 한 인스턴스의 overrides가 패턴 필드의 절반을 넘기면, 그건 변주가 아니라 새 패턴의 신호다. 별개 패턴으로 분리할 때가 된 것이다.
pattern_corridor_pursuit_v2를 수정하면 열두 개 던전이 영향을 받는다. 사람이 이걸 손으로 추적하면 반드시 한두 개를 빠뜨린다. 그래서 패턴과 던전의 관계를 자동으로 훑는 작은 도구를 둔다.
# pattern_impact.py
import json
from glob import glob
def find_dungeons_using(pattern_id):
affected = []
for d in glob("dungeons/*.json"):
dungeon = json.load(open(d, encoding="utf-8"))
for inst in dungeon.get("pattern_instances", []):
if inst["pattern_id"] == pattern_id:
affected.append({
"dungeon": dungeon["dungeon_id"],
"instance": inst["instance"],
"has_overrides": bool(inst.get("overrides")),
})
return affected
이 함수가 돌려주는 목록에서 핵심은 has_overrides 플래그다. overrides가 없는 던전은 패턴을 그대로 쓰므로 자동 갱신해도 안전하다. overrides가 있는 던전은 그 던전만의 변주가 패턴 수정과 충돌할 수 있으므로 사람의 추가 검수가 필요하다.
수정의 무게를 사람이 일일이 느끼는 대신, 도구가 "이번 수정으로 던전 12개가 영향받고 그중 4개는 변주가 있으니 직접 봐야 한다"고 5분 안에 보고하게 만든다. 패턴 변경의 두려움을 줄여주는 것이 이 도구의 진짜 가치다. 영향 범위가 보이지 않으면 디자이너는 패턴을 아예 고치지 않으려 하고, 라이브러리는 고인 물이 된다.
여기서 가장 자주 받는 질문을 정면으로 다루겠다. "패턴 작성도 AI에게 시키면 되지 않나요?"
답은 분명하다. 안 된다. 패턴 하나를 작성하는 일은 디자이너의 인사이트가 척추다. 좋은 추격 체험이 무엇인지, 분기점이 왜 거기여야 하는지, 함정이 왜 복도 중간이 아니라 2/3 지점에서 터져야 긴장이 사는지 — 이건 게임을 직접 만지고 사용자 반응을 본 사람의 판단이다. AI에게 패턴을 처음부터 짜게 하면, 모든 패턴이 무난하고 평균적인 형태로 수렴한다. 라이브러리는 "안 틀린 패턴"으로 가득 차지만 "기억에 남는 패턴"은 사라진다.
그렇다고 AI가 할 일이 없는 건 아니다. 패턴 작성의 다섯 단계 중 두 군데에서 AI는 강력한 보조다.
| 단계 | 산출 | AI의 역할 |
|---|---|---|
| 1. 게임 내 좋은 체험 순간 관찰 | 노트 | 디자이너 단독 |
| 2. 그 순간의 공간·NPC·이벤트 분해 | 초안 yaml | 디자이너 단독 |
| 3. 기존 룸 템플릿·subtree에 매핑 | 후보 매핑 | AI 보조 (후보 추천) |
| 4. 시뮬레이션 + 사용자 테스트 | 검증 | AI 보조 (시뮬레이션 실행) |
| 5. 라이브러리 등록 | usage_count = 0 | 디자이너 단독 |
3단계가 AI 보조의 핵심이다. 디자이너가 "좁은 복도에서 빠른 적이 후방 추격"이라는 초안을 적으면, 이미 수십 개씩 쌓인 룸 템플릿과 subtree 라이브러리 중에서 무엇이 이 의도에 맞는지를 사람이 다 뒤지기는 비효율적이다. 여기서 AI에게 매핑 후보를 추천하게 한다. 아래는 실제로 쓰는 프롬프트를 익명화한 것이다.
[입력]
- 디자이너의 패턴 초안 yaml (아래 첨부)
- 룸 템플릿 라이브러리 (이름 + 태그 + 크기 목록)
- subtree 라이브러리 (이름 + 행동 요약 목록)
이 패턴을 구현할 룸 템플릿이랑 subtree를 라이브러리에서 골라 추천해 줘.
각각 2~3개씩, 적합도 1~5점이랑 태그·크기 기준 한 줄 근거 붙여서.
라이브러리에 없는 건 지어내지 말고 추천만, 다 3점 이하면 신규 작성 필요하다고 말해 줘.
이 프롬프트에 Claude가 실제로 돌려준 출력은 다음과 같았다. 어디서 머뭇거리고 어디서 단언하는지가 그대로 드러나도록, 한 글자도 손보지 않고 옮긴다.
룸 템플릿 후보:
1. corridor_long (적합도 5)
근거: 태그 horizontal_corridor 일치, size medium이 "좁은 복도" 의도에 부합.
2. corridor_narrow_short (적합도 3)
근거: 좁기는 하나 길이가 짧아 추격 거리 확보가 어려움. 분기점 배치 공간 부족.
3. hall_wide (적합도 1)
근거: 넓은 홀은 "좁은 복도" 의도와 정반대. 추격 압박감이 살지 않음.
subtree 후보:
1. subtree_aggressive_chase (적합도 5)
근거: 후방 추격 행동 정의됨, 이동속도 가중치 있음.
2. subtree_ranged_support (적합도 3)
근거: 추격 자체는 아니나 압박 보조용으로 1기 배치 가능.
3. subtree_patrol_basic (적합도 2)
근거: 순찰 행동은 추격 의도와 맞지 않음. 트리거 후 추격 전환 노드 없음.
분기점(junction) 룸 템플릿:
라이브러리에서 3way 분기 + small 크기 조건을 동시에 만족하는 항목을
찾지 못했습니다. junction_3way가 가장 근접하나 size 태그가 비어 있어
적합도를 확정할 수 없습니다. 디자이너 확인이 필요합니다.
마지막 단락이 이 출력에서 가장 값진 부분이다. AI는 분기점 룸 템플릿에 대해 "확실하지 않다"고 말했고, 없는 답을 지어내지 않았다. 디자이너는 이 보고를 받고 junction_3way의 size 태그가 실제로 비어 있다는 사실을 발견했고, 그 메타데이터부터 채웠다. 만약 AI가 빈 태그를 무시하고 적합도 5를 부여했다면, 디자이너는 잘못된 신뢰로 그대로 채택했을 것이다.
이것이 AI 보조의 올바른 형태다. AI는 후보를 펼치고 불확실을 표시하며, 선택과 책임은 디자이너에게 남는다. 매핑 결과 적합도가 모두 낮으면 그때는 새 템플릿을 작성하는 별도 작업이 생기고, 그 작성은 다시 사람의 일이 된다.
[방향 표지 — 패턴을 '체험 벡터'로 압축해 본다면 (아직은 시기상조)] 처방이 아니라 연구 동향으로 읽어 주기 바란다. §7.3.1이 이미 패턴을 '레시피'라 부른다. 한 패턴은 룸 메타·행동 subtree·event·difficulty/reward_modifier·clear_time이 한 묶음인 좌표값에 가깝다. 이 묶음을 '체험 벡터'로 압축하면, 적합도가 다 낮을 때 신규를 짜는 위 흐름을 일일이 뒤지는 대신 압축 공간의 빈 영역으로 잡고, §7.3.8의 deprecated 판정도 근접 중복을 좌표 거리로 보강할 수 있다. 단 세 가지 단서가 붙는다. difficulty/reward_modifier는 §7.3.4 말대로 저자 운영값이라 게임마다 축 스케일이 달라 압축 공간을 그대로 이식할 수 없고, 보간은 패턴 '생성'이 아니라 빈칸 '표지'까지만이며, 그 표지 위에서 패턴을 실제로 짜는 일은 여전히 디자이너 인사이트가 척추라는 이 절의 원칙을 넘지 않는다. 이 발상은 §8.2.7의 차원 벡터 압축과 같은 자리이고 개념 직관은 부록 M에 있다 — 토대가 충분히 쌓인 팀이 몇 년 뒤 들여다볼 영역으로 남겨 둔다.
라이브러리는 채우는 것보다 비우는 것이 어렵다. 운영을 1년쯤 하면 만들어 두고 거의 쓰이지 않는 패턴이 쌓인다. 그대로 두면 라이브러리 검색 비용이 올라가고, 디자이너가 패턴을 고를 때 죽은 선택지까지 훑어야 한다. 그래서 정기적으로 거둔다.
| 조건 | 처리 |
|---|---|
| 6개월간 usage_count 증가 0 | deprecated 후보로 분류 |
| 검토 회의에서 폐기 결정 | deprecated: true 표시 |
| 기존 사용 던전 | 그대로 보존 (역사적 보존) |
| 신규 던전 | 해당 패턴 사용 금지 |
핵심은 폐기가 삭제가 아니라는 점이다. 이미 그 패턴을 쓰고 있는 던전들은 그대로 둔다. 라이브 서비스에서 동작 중인 던전을 건드리는 것이 새 패턴을 막는 것보다 위험하기 때문이다. deprecated: true는 "지금부터 새로 쓰지 말라"는 표지일 뿐, 과거를 지우는 명령이 아니다.
책상 서랍의 안 쓰는 도구를 분기마다 한 번씩 꺼내 정리하듯이, 라이브러리도 분기에 한 번 거두는 일정을 잡아 둔다. 이 일정이 없으면 라이브러리는 한 방향으로만 부풀고, 어느 순간 디자이너가 외면하는 창고가 된다.
저자의 프로젝트 A에서 패턴 라이브러리를 1년 운영하며 관찰한 변화다. 아래 표의 시간 수치는 저자 환경의 추정(미검증)이고, 방향과 상대 비율만 실제로 관찰된 것이다.
| 항목 | 도입 전 | 도입 후 | 비고 |
|---|---|---|---|
| 던전 1개 디자인 시간 | 약 2주 | 약 1주 | 저자 추정, 방향은 명확 |
| 던전 간 체험 일관성 | 분산 큼 | 안정 | 사용자 평가 기반, 정성 |
| 패턴 1개당 사용 던전 평균 | — | 약 8개 | 양산 효율의 핵심 지표 |
| 신규 디자이너 온보딩 | 약 2개월 | 약 3주 | 저자 추정, 가장 큰 체감 효과 |
| 패턴 변경 영향 파악 | 1~2일 수작업 | 자동 5분 보고서 | pattern_impact.py 도입 효과 |
가장 인상적이었던 변화는 마지막에서 두 번째 줄, 신입 온보딩이다. 패턴 라이브러리는 의도하지 않게 디자인 교과서 역할을 했다. 신입이 "이 게임의 추격 체험은 이렇게 만든다"를 패턴 파일 한 장으로 읽고 이해할 수 있게 되니, 선배가 옆에 붙어 설명하는 시간이 크게 줄었다. 처음 신입이 가져왔던 제각각인 던전 문제가, 라이브러리 자체로 해소된 셈이다.
"패턴 1개당 사용 던전 평균 약 8개"라는 숫자는 곧 같은 패턴을 여덟 번 재사용했다는 뜻이고, 이것이 양산 효율의 정직한 척도다. 다만 이 8이라는 값은 저자 게임의 던전 규모와 패턴 설계에 종속된다. 던전 수가 적거나 매번 다른 컨셉을 요구하는 게임에서는 이 값이 훨씬 작아진다.
마지막으로, 이 장 전체를 뒤집는 이야기를 해야 정직하다. 패턴 라이브러리는 만능이 아니다. 라이브러리를 구축하고 운영하는 비용이 회수되지 않는 환경이 분명히 있다.
| 조건 | 권고 |
|---|---|
| 던전 5개 미만 | 손으로 충분, 라이브러리 불필요 |
| 디자이너 1인 | 머릿속이 곧 라이브러리 |
| 출시 한 번, 라이브 운영 없음 | 재사용 기회 자체가 적음 |
| 매번 완전히 다른 컨셉 | 재사용 비율이 낮아 ROI 미회수 |
라이브러리의 ROI(Return on Investment, 투자 대비 효과)는 세 조건이 함께 갖춰질 때 회수된다. 라이브 운영이 있고, 디자이너가 셋 이상이며, 던전이 스무 개를 넘는 경우다. 라이브 운영 MMORPG가 전형적인 적용 대상인 이유가 여기 있다. 위 표의 어느 줄에 자기 프로젝트가 걸린다면, 라이브러리를 짓기 전에 멈춰서 다시 생각해야 한다. 도구는 문제가 있을 때만 가치가 있고, 던전 다섯 개짜리 프로젝트에 패턴 라이브러리는 문제보다 비용이 크다.
| 증상 | 처방 |
|---|---|
| 패턴이 100개를 넘어 디자이너가 못 외움 | 30~50개로 정리, 분기마다 deprecated 거두기 |
| 패턴 영향 추적을 손으로 함 (누락 발생) | pattern_impact.py 같은 자동 추적 도구 |
| overrides가 80% 이상 (실질 재사용 아님) | 변주가 너무 큼 → 별도 패턴으로 분리 |
| 패턴 작성을 AI에 통째로 위임 | 작성은 디자이너 인사이트, AI는 3·4단계 보조만 |
| usage_count를 측정하지 않음 | 자동 집계 + 분기 회고에서 검토 |
| 신입에게 라이브러리 설명 없음 | 온보딩 자료에 라이브러리 투어 포함 |
이 표의 두 번째 줄과 네 번째 줄이 가장 자주 발목을 잡는다. 영향 추적을 자동화하지 않으면 디자이너가 패턴 수정을 두려워해 라이브러리가 굳고, 작성을 AI에 위임하면 라이브러리가 평균으로 수렴한다. 두 실패 모두 라이브러리의 생명, 즉 "검증된 체험의 재사용"을 죽인다.
7부는 레벨 분야를 세 층위로 쌓아 올렸다. 7.1에서 룸 메타데이터와 태그와 연결성의 표준을 세웠고(공간), 7.2에서 JSON 기반 BehaviorTree 에디터와 subtree와 시뮬레이션을 다뤘으며(행동), 이 장에서 그 둘을 이벤트와 함께 묶어 재사용하는 패턴 라이브러리에 도달했다(운영 단위). 공간과 행동을 따로 다루던 운영에서, 같은 자리의 결정이 매주 다른 형태로 흔들리던 그 문제를, 패턴이라는 묶음으로 박제해 해결한 것이 7부 전체의 줄기다.
이 흐름은 Layer 통합 설계와 그대로 맞물린다. 게임 전체의 공간 톤이라는 비전이 위에 있고, 그 아래에 레벨 생성 규칙과 BT 룰이라는 시스템이 있으며, 룸과 BT와 패턴 라이브러리가 콘텐츠 층을 이루고, 던전 인스턴스와 패턴 사용 통계가 데이터로 쌓이며, lint와 시뮬레이션과 사용자 텔레메트리가 빌드·QA에서 이를 검증한다. 패턴 라이브러리는 이 다섯 층 중 콘텐츠 층의 척추이면서, 위로는 시스템 룰을 따르고 아래로는 데이터 통계를 생성하는 연결 고리다.
patterns/와 dungeons/ 두 디렉터리를 만드세요.pattern_impact.py를 그대로 저장하세요.디자이너가 직접 패턴 초안 yaml을 적습니다(이 부분은 사람의 몫). 그다음 AI에게 매핑만 맡기세요. 본문의 매핑 프롬프트를 그대로 쓰되, 입력에 자기 초안과 두 라이브러리 목록을 붙이세요. 핵심 제약 두 줄을 빠뜨리지 마세요.
- 라이브러리에 없는 새 템플릿을 지어내지 마세요. 추천만 하세요.
- 적합도가 모두 3 이하이면 신규 작성이 필요하다고 명시하세요.
find_dungeons_using("pattern_...")를 돌려, 그 두 던전이 정확히 잡히는지 확인하세요.혼자 작은 게임을 만든다면 라이브러리 시스템은 과합니다. 대신 가장 마음에 드는 던전 구간 하나를 골라 그 체험을 yaml 한 장으로 적어 두는 것만 해도 충분합니다. 다음 던전을 만들 때 그 한 장을 열어 복사하고 20%만 고치세요. 패턴 라이브러리의 본질 — 검증된 체험의 재사용 — 은 파일 한 장에서도 작동합니다. 규모가 커지면 그때 카테고리와 추적 도구를 더하면 됩니다.
이 장의 학습 목표 (난이도 🟡 실무 · 선행: 사칙연산·표 계산): 전투 밸런스를 공식의 자리와 수치의 자리로 분리하고, 결정론·추적 가능성이라는 두 성질을 근거로 어디까지 AI에 맡기고 어디부터 사람이 룰북으로 잠가야 하는지 구분할 수 있게 된다.
새벽 두 시, 라이브 서버의 탱커 직업 생존율이 89%를 찍었다는 알림이 떴다. 보스를 끝까지 못 잡는 탱커는 없고, 죽지 않는 탱커는 너무 많다. 누군가 손을 댔던 흔적을 찾으려고 데이터 시트를 연다. 방어 계수 한 줄이 보인다. DEF / (DEF + 1000). 이 1000이라는 숫자가 언제, 누구의 손에, 어떤 근거로 1200에서 1000으로 내려갔는지 시트 어디에도 적혀 있지 않다. 채팅 로그를 뒤지고, 빌드 히스토리를 뒤지고, 결국 3년 전 퇴사한 밸런서의 기억에 도달해야 끝나는 추적이 시작된다.
이 장면은 전투 밸런스를 운영해 본 사람이라면 누구나 한 번쯤 겪는다. 그리고 이 장면의 진짜 원인은 그 1000이라는 숫자가 틀렸다는 데 있지 않다. 그 숫자가 공식의 자리에 살았는데, 공식이 바뀐 이력이 어디에도 없었다는 데 있다. 전투 밸런스 공식은 게임에서 가장 결정론적이어야 하는 영역이고, 가장 추적 가능해야 하는 영역이다. 이 두 성질이 왜 AI를 이 자리에 들이면 안 되는 이유가 되는지가 이 챕터의 척추다.
비전공자를 위한 한 줄. 이 부의 z-score·시뮬레이션·곡선이 낯설어도 괜찮습니다. 가져가실 단 하나는 이것입니다 — "같은 입력에 항상 같은 출력이어야 하는 규칙(공식)에는 AI를 들이지 않는다." 결정론이 필요한 자리와 탐색이 필요한 자리를 가르는 이 판단은, 회계 규정·정산 로직·계약 조항처럼 '틀리면 안 되는 규칙'을 다루는 모든 직무에 그대로 옮겨집니다. 수식 자체는 8.1.2부터 천천히 보셔도 됩니다.
게임 디자인을 오래 하다 보면 두 종류의 문서가 손에 잡힌다. 자주 바뀌는 문서와, 거의 안 바뀌는 문서다. 전투 밸런스에서 거의 안 바뀌는 쪽이 공식이다. "데미지를 어떻게 계산하는가"는 분기에 한두 번 손대고, "이 캐릭터의 공격력이 몇인가"는 주에 대여섯 번 손댄다. 빈도가 다른 두 흐름을 한 파일에 묶으면, 자주 여닫는 손길에 가끔 여닫는 종이가 찢어진다.
저자가 운영하는 프로젝트 A에서 전투 밸런스는 두 자리로 분리되어 있다. 공식의 자리(여기서 CombatFormula라 부른다)와 수치의 자리(CombatBalance)다. 공식의 자리에 사는 한 줄을 그대로 인용한다.
final_damage = base_damage × dmg_multiplier × (1 − defense_factor) × variation
base_damage = skill_base × ATK × skill_coeff
defense_factor = DEF / (DEF + 1000)
variation = uniform(0.95, 1.05)
이 공식은 룰북이다. 보드게임의 규칙서를 떠올리면 된다. 규칙서는 "주사위를 굴려 나온 눈만큼 이동한다"라고 쓰지, "이번 판은 운이 좋으면 좀 더 가도 된다"라고 쓰지 않는다. 같은 입력에는 항상 같은 출력. 이게 결정론(determinism)이다. 공격력 180, 방어력 80, 스킬 계수 2.1을 넣으면 언제 어디서 몇 번을 계산하든 같은 데미지가 나와야 한다. 만약 같은 입력에 다른 출력이 나온다면, 그건 밸런스 도구가 아니라 도박 기계다.
이 결정론이라는 한 성질이 AI를 이 자리에 들이면 안 되는 첫 번째 이유다. 잠시 뒤에 다시 본다. 먼저 공식이 룰북답게 어떻게 생겨야 하는지를 보자.
전투 공식의 핵심 영역은 데미지 한 줄로 끝나지 않는다. 적어도 세 줄이 한 묶음으로 산다.
# 데미지
final_damage = base_damage × dmg_multiplier × (1 − defense_factor) × variation
# 치명타
crit_damage = final_damage × crit_multiplier
crit_chance = base_crit + (LUK × 0.1) # 상한 50%
# 회복
heal = base_heal × healing_power × (1 − sickness_factor)
세 줄을 자연어가 아니라 코드 블록으로 적는 데에는 이유가 있다. 자연어는 해석의 여지를 남긴다. "방어력이 높을수록 데미지가 줄어든다"라는 문장은 선형으로 주는지, 곡선으로 주는지, 어디서 멈추는지를 말하지 않는다. DEF / (DEF + 1000)은 단 하나로 읽힌다. 룰북은 해석의 여지를 0으로 만드는 게 일이다.
방어 계수 DEF / (DEF + 1000) 한 줄에 이 게임의 밸런스 철학 전체가 들어 있다. 이 한 줄을 그래프로 그려 보면 왜 그런지 보인다. 가로축이 방어력, 세로축이 받는 데미지를 줄이는 비율이다.
이 곡선은 점근선(asymptote)에 천천히 붙는다. 방어력 1000에서 데미지를 정확히 절반으로 깎고, 그 뒤로는 아무리 올려도 100%에 닿지 못한다. 무적이 불가능하다는 게 이 한 줄에 들어 있다. 회색 점선처럼 선형이었다면 방어력 1000에서 데미지를 다 막고 그 위로는 음수 데미지(맞을수록 체력 회복)라는 말이 안 되는 영역으로 넘어간다. 그래서 선형은 채택하지 않았다.
여기서 새벽 두 시의 사고로 돌아가 보자. 누군가 이 1000을 1200으로 올린다고 해 보자. 곡선 전체가 오른쪽으로 밀린다. 같은 방어력으로 데미지를 덜 막게 되니, 게임 전체의 탱커가 약해지고 딜러의 시간당 데미지가 올라간다. 공식의 상수 하나가 게임 전체를 흔든다. 수치 하나(어떤 캐릭터의 공격력)를 바꾸는 것과는 영향의 크기가 다르다. 이 차이가 공식과 수치를 다른 자리에 둬야 하는 이유이고, 공식 변경에는 반드시 이력이 따라붙어야 하는 이유다.
새벽 두 시의 추적이 지옥이었던 이유는 단 하나, 변경 이력이 없었기 때문이다. 프로젝트 A에서 공식 변경은 코드 한 줄을 고치는 일이 아니라 결정 한 건을 기록하는 일이다. 공식 옆에는 CombatFormula_Decisions라는 별도 문서가 따라다니고, 거기에 이렇게 적힌다.
## 결정 D17 (2026-04-22)
- 변경: defense_factor를 DEF/(DEF+1000) → DEF/(DEF+1500)
- 사유: 고레벨 구간(LV40+)에서 탱커 생존율 89% (라이브 측정). 보스전이 늘어지는 원인.
- 시도 1: 800으로 시뮬 → 탱커 사망률 폭증, 보스 입장 1분 내 전멸 다수 → 롤백
- 시도 2: 1200으로 시뮬 → 생존율 75% → 양호하나 목표(60~70%)보다 높음
- 시도 3: 1500 채택 → 시뮬 생존율 65% (목표 범위 안)
- 영향 atom: combat_defense_formula, combat_tank_class_balance
- 사후 측정(1주): 라이브 생존율 67% (시뮬 예측 65% 대비 +2%, 범위 내)
이 한 건이 6개월 뒤의 "왜 이렇게 됐는지"에 답한다. 더 중요한 건 시도 1과 시도 2가 남아 있다는 점이다. 800이 왜 안 됐는지, 1200이 왜 채택 안 됐는지가 기록되어 있으면, 다음 사람이 같은 실수를 반복하지 않는다. 신규 밸런서가 합류했을 때 이 결정 로그 한 묶음이 가장 좋은 온보딩 자료가 된다.
여기서 정직하게 짚을 것이 하나 있다. 위의 시도 1·2·3 시뮬 수치(사망률, 생존율 75%, 65%)는 운영 흐름을 보이기 위한 저자 추정값(미검증)이다. 실제 게임마다 곡선도 목표 범위도 다르다. 다만 "변경에는 시도가 따르고, 시도에는 시뮬 근거가 따르고, 채택 뒤에는 사후 측정이 따른다"라는 구조는 실제 운영 그대로다. 이 구조에서 한 칸이라도 비면, 비는 칸이 새벽 두 시의 추적으로 돌아온다.
공식과 수치, 이력 세 자리를 한눈에 두면 이렇다.
이제 D17이 어떻게 결정됐는지를 처음부터 따라가 본다. 이게 결정론적 룰북이 실무에서 움직이는 방식이다.
flowchart TD
A["라이브 측정
탱커 생존율 89% 감지"] --> B["원인 가설
방어 계수 1000이 후반에 과보호"]
B --> C["변경 후보 정의
1500 / 1200 / 800"]
C --> D["Damage Simulator
후보별 1,000회 결정론 실행"]
D --> E["결과 리포트
생존율·평균 전투시간·승률"]
E --> F{"밸런서 판단
목표 60~70% 범위?"}
F -->|"800: 사망률 폭증"| G["기각 → 로그 시도1"]
F -->|"1200: 75%, 다소 높음"| H["보류 → 로그 시도2"]
F -->|"1500: 65%, 범위 내"| I["채택 → 결정 D17"]
I --> J["빌드 반영 (비가역)"]
J --> K["1주 후 라이브 사후측정
67%, 예측 대비 +2%"]
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
classDef fail fill:#fee2e2,stroke:#dc2626,color:#7f1d1d;
class D code;
class B,C,F human;
class A,E,K data;
class I pass;
class G fail;
이 흐름에서 시뮬레이터의 역할을 정확히 봐야 한다. Damage Simulator는 후보 세 개를 각각 1,000번씩 돌린다. 여기서 1,000번은 같은 입력을 1,000번 반복하는 게 아니다. 공식 안의 variation = uniform(0.95, 1.05)이라는 ±5% 난수와, 치명타 확률이라는 또 다른 난수 때문에 한 판 한 판의 결과가 다르다. 1,000판을 돌려 분포를 본다. 평균 생존율, 최악 케이스, 전투 시간의 흩어짐을 본다.
이 시뮬레이터 자체가 결정론적이어야 한다는 점이 중요하다. 같은 난수 시드를 주면 1,000판이 토씨 하나 안 틀리고 재현돼야 한다. 그래야 "1500으로 65%가 나왔다"라는 D17의 한 줄이 6개월 뒤에도 똑같이 재현되어 검증된다. 시뮬레이터가 매번 다른 결과를 내면 결정 로그는 거짓말이 된다.
저자가 이 데미지 시뮬레이터를 처음 만든 게 2008년이다. 그때는 엑셀 매크로였고, 지금 프로젝트 A에서는 balance-sim 스킬로 캡슐화되어 있다. 18년 동안 도구의 껍데기는 바뀌었지만, 안에 든 룰북은 한 번도 확률적이었던 적이 없다. 이게 핵심이다.
이제 이 챕터가 가장 하고 싶은 말로 온다. AI가 게임 디자인의 거의 모든 자리에 들어오는 지금, 단 하나 절대 들이면 안 되는 자리가 있다. 전투 공식과 보상 곡선이라는 결정론의 핵심이다.
LLM은 본질적으로 확률적이다. 같은 질문에 매번 조금씩 다르게 답한다. 그게 좋은 글과 아이디어를 내는 힘의 원천이지만, 룰북의 자리에는 치명적이다. "방어력 80인 캐릭터가 데미지를 얼마나 받지?"를 LLM이 답하게 만들면, 오늘은 92, 내일은 94를 답할 수 있다. 보드게임 규칙서가 페이지를 넘길 때마다 주사위 눈의 의미가 바뀌는 셈이다.
보상 곡선은 더 위험하다. "레벨 30에서 31로 갈 때 필요 경험치"는 한 번 정하면 수십만 명의 진행 속도를 동시에 규정한다. 여기에 ±2%의 흔들림만 들어가도 어떤 유저는 같은 사냥을 하고도 옆 사람보다 느리게 큰다. 형평성이 무너진다. 결정론은 공정성과 같은 말이다. 그래서 보상 곡선은 사람이 손으로 정하고, 시트에 입력하고, 다시는 확률에 맡기지 않는다.
그렇다고 밸런스 영역 전체에서 AI를 쫓아내라는 말이 아니다. 경계가 핵심이다.
| 영역 | AI | 이유 |
|---|---|---|
| 데미지·회복 공식 계산 | 절대 금지 | 결정론 코어. 같은 입력 = 같은 출력이 깨지면 도박 기계 |
| 보상·경험치 곡선 | 절대 금지 | 수십만 명 진행 동시 규정. 흔들리면 공정성 붕괴 |
| 시뮬레이터 내부 연산 | 절대 금지 | 재현 불가 시 결정 로그가 거짓이 됨 |
| 시뮬 결과 이상 패턴 탐지 | 가능 | 1,000건 결과에서 "이 캐릭터가 정상 밖" z-score 감지 |
| 변경 후보 탐색 | 가능 | "base_atk ±10%에서 5개 후보 제안" 같은 한정 탐색 |
| 결정 로그 초안 작성 | 가능 | 회의 내용 → Decisions 항목 초안 (사람이 검수) |
| 사후 측정 리포트 요약 | 가능 | 라이브 데이터 자연어 요약 |
선이 명확하다. AI는 결정론 코어의 바깥에만 산다. 계산하고 시뮬하는 안쪽은 룰북이고, 분석하고 제안하고 글로 옮기는 바깥쪽이 AI의 자리다. 이 선을 한 번 넘으면, 같은 입력에 다른 결과가 나오기 시작하고, 그 순간부터 밸런스 도구는 신뢰를 잃는다.
이 경계는 8.2에서 볼 경제 시스템과 똑같은 구조다. 경제에서도 자원 생산·소비 공식은 결정론이고, 인플레이션 패턴 탐지가 AI의 자리다. 밸런스 분야 전체가 같은 골격으로 움직인다.
지금까지는 사람이 후보를 만들고 시뮬이 검증하는 보수적 적용이었다. 한 발 더 나가면, 후보를 만드는 일까지 도구가 대신할 수 있다. 단, 룰북은 여전히 사람과 결정론의 것이다.
이상 패턴 탐지가 출발점이다. 1,000판 시뮬 결과에서 캐릭터별 승률·생존율의 분포를 보고, 평균에서 표준편차 몇 배만큼 벗어났는지를 z-score로 잰다. z가 2를 넘는 캐릭터는 "정상 범위 밖"으로 자동 표시된다. 새벽 두 시의 탱커도 이 탐지에 걸렸을 것이다.
탐지가 후보 발의로 이어지려면 두 가지가 더 필요하다. 첫째는 변경 공간의 정의다. CombatBalance 시트에 tunable_range 같은 칸을 둬서 "이 수치는 어느 범위에서 건드려도 되는지"를 명시한다. 둘째는 시뮬 병렬화다. 후보 10개 × 1,000판 = 10,000판을 빌드 게이트 시간 안에 돌리려면 병렬 인프라가 있어야 한다.
이 세 가지(z-score 탐지 · 변경 공간 정의 · 시뮬 병렬화)가 갖춰지면, 밸런서의 손에 남는 결정은 "어떤 후보를 채택할지" 하나로 좁혀진다. 후보를 0에서 만드는 일과 다섯 개 중 고르는 일은 부담이 다르다. 여기서도 AI가 닿는 건 후보 발의와 리포트 해석뿐, 시뮬 안쪽 연산과 채택 결정은 결정론과 사람의 자리다.
마지막으로 가역성을 짚는다. 시트 수정도 시뮬 실행도 가역이라 마음껏 되돌릴 수 있다. 비가역인 단 한 자리는 빌드 반영이다. 라이브에 나간 수치는 유저가 본 순간 커뮤니티 반응으로 남아 롤백해도 흔적이 지워지지 않는다. 그래서 모든 검수는 빌드 반영 직전, 가역 단계에서 끝낸다.
setup. 전투 공식을 자연어 설명에서 분리해 코드 블록으로만 적은 CombatFormula 문서와, 그 옆에 빈 CombatFormula_Decisions 로그 문서를 만드세요. 수치는 별도 시트(CombatBalance)로 떼어냅니다.
prompt. 공식 변경이 아니라 분석·초안에만 AI를 쓰세요. 예를 들어 시뮬 결과 CSV를 주고 이렇게 요청합니다.
첨부한 1,000회 시뮬 결과에서 캐릭터별 승률의 z-score를 계산하고,
z>2인 캐릭터를 표로 정리해 줘. 각 캐릭터에 대해
어떤 수치(공격력/방어력/스킬계수)가 이상 원인일 가능성이 높은지
근거와 함께 추정해 줘. 수치 자체를 고치지는 말 것 — 후보만 제안.
verify. AI가 낸 후보를 그대로 믿지 마세요. 후보 수치를 CombatBalance 시트에 직접 입력하고, Damage Simulator(또는 balance-sim)로 같은 시드를 주고 1,000회를 다시 돌립니다. 두 가지를 확인하세요. (1) 시뮬 결과가 목표 범위에 드는가. (2) 같은 시드로 한 번 더 돌려 토씨 하나 안 틀리고 재현되는가. 둘 다 통과하면 채택하고, 채택 즉시 _Decisions에 사유·시도(기각된 후보 포함)·예측값을 적으세요. 빌드 반영 1주 뒤 라이브 측정값을 그 로그에 덧붙입니다.
팀도 시뮬레이터도 없는 1인 개발이라도 골격은 똑같이 작동합니다. 공식은 코드 주석이나 별도 .md 한 장에 코드 블록으로 기록하고, 그 파일 맨 아래에 ## 변경 이력을 두세요. 공식 상수를 하나라도 바꾸면 날짜·사유·바꾸기 전 값을 한 줄 적습니다. 시뮬레이터는 30줄짜리 파이썬 루프로 충분합니다. 난수 시드를 고정하고, 공식에 캐릭터 수치를 넣어 1,000번 돌려 평균 승률만 출력해도 "감으로 바꿨다"에서 "근거로 바꿨다"로 넘어갑니다. AI는 그 출력 CSV를 읽고 "어느 캐릭터가 이상한지"를 요약하는 데만 쓰세요. 공식 한 줄을 LLM에 계산시키는 일만은, 규모와 상관없이 하지 마세요.
1차 독자: 라이브 경제를 책임지는 MMORPG 밸런스/시스템 기획자 (중규모(10~50인) 팀) 1인/취미 독자용 축소 버전: §8.2.10 「혼자라면 이만큼만」
골드가 새기 시작한 걸 처음 안 건 청구서가 아니라 거래소였다. 출시 두 달째, 강화석 시세가 슬그머니 올랐고 한 달 뒤엔 두 배가 됐다. 원인을 찾으러 회의를 잡았는데, 회의실에서 나온 건 전부 "느낌"이었다. 누군가는 새 던전 보상이 과하다고 했고, 누군가는 사냥터 효율이 높아진 탓이라 했고, 누군가는 그냥 고레벨 유저가 늘어서라고 했다. 다 그럴듯했고, 그래서 아무것도 결정되지 않았다. 한 시간을 추측으로 태우고 "일단 다음 주에 데이터 더 보자"로 끝났다.
문제는 자원이 한 개가 아니라는 데 있다. 골드·강화석·평판·명예·영혼석이 각자 source(들어오는 길)와 sink(나가는 길)를 가지고, 그 길들이 서로를 먹인다. 강화석 보스가 골드도 떨군다. 골드로 산 장비가 강화석을 태운다. 자원 5종에 흐름이 수십 개로 얽히면, 머릿속 계산기로는 한 자원의 1주 수지조차 정직하게 못 뽑는다. 이 장은 그 얽힘을 Machinations 노드 모델로 옮기고, 경제 변경 결정을 회의 추측이 아니라 시뮬레이션 게이트로 통과시키는 방법을 다룬다. 경제 설계의 일반 이론은 다른 책에 충분하니, 이 장은 그 이론을 AI 워크플로로 돌리는 자리에만 집중한다.
저자 실제 운영 메모 이 장의 사례는 저자가 회사 R&D 폴더에서 운영 중인 경제 파일럿 문서(
Economy_Machinations_Pilot)와 경제 리서치 작업 영역을 익명화한 것이다. 자원 종류·source/sink 구조·Pilot 4단계는 실제 운영을 충실히 옮겼고, 회사 고유 명칭·실수치는 책용으로 치환하거나 비율·방향으로만 적었다. AI 출력 본문은 실제 세션을 재구성한 것이다.
경제 자원을 표로 적으면 다섯 줄이라 단순해 보인다. 함정은 자원이 아니라 자원을 잇는 흐름의 개수에 있다.
| 자원 | source (들어옴) | sink (나감) |
|---|---|---|
| 골드 | 사냥, 퀘스트 보상, 거래소 판매 | 장비 구매, 강화, 수리, 세금 |
| 강화석 | 던전 보스, 이벤트 | 장비 강화, 합성 |
| 평판 | 사이드 퀘스트 | 세력 상점, 직업 변경 |
| 명예 | PvP, 길드전 | PvP 상점, 길드 시설 |
| 영혼석 | 보스 처치 | 캐릭터 부활, 스킬 학습 |
자원은 5종이지만 source·sink는 합쳐 스물 몇 개이고, 게다가 자원끼리 변환된다(골드로 강화석을 사는 거래소가 골드 sink이자 강화석 source다). 흐름이 서로를 먹이는 순간, "골드를 5% 더 풀면 강화석 시세가 어떻게 되나" 같은 질문은 한 자원만 봐서는 답이 안 나온다. 이게 캐릭터 밸런스(8.1)와 경제 밸런스가 결정적으로 다른 지점이다. 캐릭터 밸런스는 수식 한 줄로 닫히지만, 경제는 시간에 따라 누적되는 동적 시스템이라 1주 수지가 0에 가까워도 26주를 누적하면 거래소가 무너진다.
그래서 경제 작업의 본질은 "숫자를 잘 고르는 것"이 아니라 "흐름이 시간에 따라 어떻게 누적되는지를 시뮬레이션으로 보는 것"이다. 그리고 그 시뮬레이션 모델을 손으로 짜고 수정하는 일은 지루하고, 할 때마다 누락이 생긴다. 반복적이고 빠뜨리기 쉬운 초안 작업, 그러나 검수는 사람이 꽉 쥐어야 하는 일 — 이 결의 작업이 AI와 사람의 분업선이 가장 깔끔하게 그어지는 자리다.
먼저 이 장이 다루는 경제 순환의 골격을 한 장으로 실어 둔다.
%%{init: {"flowchart": {"defaultRenderer": "elk"}}}%%
flowchart LR
subgraph SRC["source (자원 생산)"]
H["사냥터"]
Q["퀘스트"]
B["던전 보스"]
PVP["PvP/길드전"]
end
subgraph POOL["pool (자원 저장)"]
G(("골드"))
S(("강화석"))
end
subgraph SINK["sink (자원 소비)"]
UP["장비 강화"]
RP["수리/세금"]
SH["세력/길드 상점"]
end
H --> G
Q --> G
B --> S
PVP --> SH
G -->|거래소 변환| S
G --> UP
S --> UP
G --> RP
UP -.->|강화석 수요 ↑| S
G -.->|"순 유입 > 순 유출 시
인플레이션 누적"| POOL
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
class G,S data;
점선이 이 장의 핵심이다. 강화 sink가 강화석 수요를 끌어올려 강화석 시세를 밀고(UP -.-> S), 골드 순 유입이 순 유출을 넘으면 그 초과분이 매주 풀(pool)에 쌓여 인플레이션으로 누적된다. 이 점선 두 개를 손계산으로 추적하는 게 불가능해서, 모델이 필요하다.
Machinations는 경제 흐름을 노드 그래프로 그리고 그 위에서 시뮬레이션을 돌리는 도구다. §8.2.1의 mermaid를 실제로 돌릴 수 있는 모델로 옮기는 자리다.
| 노드 | 역할 | 위 그림에서 |
|---|---|---|
| Pool | 자원 저장소 | 골드·강화석 |
| Source | 자원 생산 | 사냥·퀘스트·보스 |
| Drain | 자원 소비 | 강화·수리·상점 |
| Converter | 자원 변환 | 거래소(골드→강화석) |
| Trigger | 조건부 발동 | 이벤트·승급 보상 |
이 노드들로 경제를 모델링하고 시뮬레이션을 1,000회 돌리면, 단일 결과가 아니라 분포가 나온다. "26주 후 골드 시세 중앙값 +X%, 상위 10% 유저는 +Y%" 같은 식이다. 다만 Machinations가 만능은 아니고, 도입 자체가 비용이다.
| 한계 | 처방 |
|---|---|
| 게임 코드와 별개로 돌아 동기화가 어긋남 | 실제 telemetry로 매월/분기 보정 (§8.2.6) |
| 노드 그래프가 커지면 가독성 붕괴 | 자원별 서브그래프로 분할, 단일 자원부터 (§8.2.4) |
| 시뮬은 단순화된 유저 모델 | 실제 행동 분포로 보정, 오차 임계 설정 |
| 결과 해석이 도메인 지식에 의존 | 시뮬 수치 → 결정으로 잇는 게이트 표준화 (§8.2.5) |
그래서 Machinations는 무조건 도입하는 도구가 아니다. 자원 5종 이상 + 자원 변환 흐름 + 라이브 운영이라는 세 조건이 겹칠 때 값을 한다. 자원 2~3종의 단순 경제는 엑셀로 충분하고, 그 경우 Machinations 도입은 효과보다 운영 부담이 먼저 도달한다.
도구 설명만으로는 이게 실제로 무엇을 뱉는지 알 수 없다. 골드 하나를 Machinations 모델로 옮기는 한 사이클을, 입력 프롬프트에서 사람의 거부까지 끝까지 따라간다. 입력 프롬프트는 그대로 복사해 쓸 수 있고, 출력은 실제 세션을 재구성했다.
먼저 골드의 source·sink를 데이터 시트에서 뽑아 표로 만든다. 새로 쓰는 게 아니라 추출이다.
# gold_flows.yaml — 골드 단일 자원 흐름 (현행 데이터 시트 발췌)
resource: gold
sources:
- id: hunting # 사냥터 드랍
trigger: per_kill
note: 레벨대별 드랍 곡선은 reward_curve 룰 적용
- id: quest_reward # 퀘스트 보상
trigger: per_complete
- id: market_sell # 거래소 판매
trigger: per_trade
sinks:
- id: gear_buy # 장비 구매
- id: enhance # 강화 비용
- id: repair # 수리
- id: tax # 거래소 세금 (sink이자 골드 회수 핵심)
# 유저 행동 분포(시간당 사냥 횟수·퀘스트 완료율)는 아직 비어 있음 → AI가 가정하면 표시하게 함
첨부 gold_flows.yaml은 골드 단일 자원의 source 3개·sink 4개다.
이걸 Machinations 모델로 옮길 노드 명세 초안을 만들어라.
규칙:
1) 각 흐름을 노드 종류(Source/Drain/Pool/Converter)로 분류하라.
2) 각 노드에 '1주 기준 유저 1명당 기대 유량'의 산식을 제안하되,
유저 행동 가정(시간당 사냥 횟수·퀘스트 완료율 등)이 필요하면
그 가정을 별도 줄로 '★가정'이라 명시하라. 가정을 본문에 숨기지 마라.
3) source 총 유입과 sink 총 유출을 분리해 1주 순 수지(net)를 계산하라.
4) 거래소 세금(tax)이 골드를 경제에서 영구 제거하는 유일한 sink임을
반영하고, 다른 sink(강화·수리)는 골드가 NPC로 이동할 뿐 회수가
아님을 구분하라.
5) 네가 '확신 못 하는' 가정은 따로 ★표시하고 사람 결정에 넘겨라.
출력 형식: 표 (노드 | 종류 | 1주 유량 산식 | 가정 | 확신도). 그 외 설명 금지.
이 프롬프트에서 정작 길게 적힌 건 모델을 만들라는 지시가 아니라, 가정을 어디에 어떻게 적으라는 규칙이다. 경제 모델링에서 AI를 자유롭게 풀면 그럴듯한 숫자를 가정을 숨긴 채 토해낸다. 그러면 모델이 틀려도 어디가 틀렸는지를 못 짚는다. 유저 행동 가정을 본문 밖으로 끄집어내 별도 줄에 세우게 하는 것 — 거기에 이 요청의 무게가 다 실려 있다. 가정이 보여야 검증할 수 있다.
| 노드 | 종류 | 1주 유량 산식 | 가정 | 확신도 |
|--------------|-----------|----------------------------------------|------------------|-------|
| hunting | Source | 시간당 처치 × 평균드랍 × 주간플레이시간 | ★주 15h, 처치 80/h| 중 |
| quest_reward | Source | 일일퀘 수 × 보상 × 7 × 완료율 | ★완료율 0.7 | 중 |
| market_sell | Source | (유저간 이전 — 순증 아님) | 순 경제엔 0 기여 | 높음 |
| gear_buy | Drain | 구매빈도 × 평균가 | ★주 1.2회 | 낮음 |
| enhance | Drain | 강화시도 × 회당비용 | ★주 8회 | 중 |
| repair | Drain | 사냥시간 × 수리율 | | 높음 |
| tax | Drain | 거래액 × 세율 (★유일한 영구 회수 sink) | ★세율 5% | 높음 |
| ── 순 수지 | net | Σsource − Σsink ≈ +(중활성 기준 양수) | 위 가정 합산 시 | ★낮음 |
★가정 경고: market_sell은 유저↔유저 골드 이동이라 경제 총량에 0 기여.
이걸 source로 더하면 인플레를 이중계상함. 모델에서 별도 처리 필요.
★확신 못 하는 점: gear_buy 빈도(주 1.2회)는 추측. 실제 구매 telemetry
없이는 sink가 과소/과대 추정될 수 있음 → 사람 결정 필요.
출력에서 가장 값진 건 표가 아니라 맨 아래 두 줄, "★가정 경고"와 "★확신 못 하는 점"이다. AI가 자기 모델의 약점을 두 군데 스스로 신고했다. 좋은 프롬프트는 AI가 "이 가정은 못 믿겠습니다"라고 말하게 만든다.
이 출력을 그대로 모델에 넣으면 안 된다. AI가 신고한 두 ★ 중 하나가 실제로 모델을 망가뜨리는 결함이었다.
market_sell(거래소 판매)을 AI는 처음에 Source로 분류했다. 그런데 거래소 판매는 유저 A의 골드가 유저 B에게 가는 이전이지, 경제에 골드가 새로 생기는 게 아니다. 이걸 source 유입에 더하면 인플레이션을 이중으로 계상한다. AI가 ★가정 경고로 스스로 짚긴 했지만, 표 본문에서는 여전히 Source 칸에 남겨 뒀다 — 신고는 했으되 모델에서 빼지는 않은, 절반만 맞은 출력이다. 이건 입력 yaml에서 market_sell의 성격(유저간 이전 vs 신규 생성)을 명시하지 않은 사람 쪽 데이터 결함이기도 했다.
그래서 재요청한다.
market_sell은 유저↔유저 골드 이전이라 경제 총량 source가 아니다(입력
누락 수정). 이 노드를 source 합산에서 빼고, 대신 '거래소 세금(tax)이
이전액의 일부를 영구 회수하는 sink'로만 모델에 반영하라. 순 수지를
다시 계산하고, market_sell 제외가 net에 미친 영향을 한 줄로 보여라.
AI는 market_sell을 source에서 제거하고 세금만 sink로 남긴 모델로 다시 답했다. 그 결과 순 수지(net)가 처음 추정보다 낮아졌다 — 거래소 판매를 source로 잘못 넣었을 때 인플레이션을 과대평가하고 있었다는 게 드러났다. 이 한 번의 왕복이 핵심이다. 사람이 처음부터 손으로 짜면 반나절이고 노드 분류 실수를 본인이 잡기 어렵지만, AI 초안 + "가정 명시" 강제 + 1회 거부면 한 시간 안쪽이고, AI가 신고한 ★를 사람이 판정하는 구조 덕에 이중계상 같은 결함이 모델에 들어가기 전에 걸린다(저자 추정 — 절약 시간은 팀·자원 수에 따라 다르니 절대값보다 "손으로 처음부터"와 "초안+검수"의 구조 차이로 읽는 게 맞다).
골드 모델 하나가 닫혔다고 전 자원을 한 번에 모델링하면 안 된다. 저자의 운영도 전체를 한꺼번에 넣지 않았다. 단일 자원에서 시작해 검증·보정을 거쳐 확장하는 4단계를 밟았다.
| 단계 | 범위 | 핵심 게이트 |
|---|---|---|
| 1. 단일 자원(골드) 모델링 | source 3·sink 4, §8.2.3 세션 | 노드 분류·가정 명시 |
| 2. 시뮬 vs 실제 비교 | 시뮬 1주 net vs telemetry 1주 | 오차 임계 통과 여부 |
| 3. 모델 정밀도 보정 | 유저 행동 분포(저/중/고활성) 반영 | segment별 오차 재측정 |
| 4. 자원 확장(5종) | 강화석·평판·명예·영혼석 단계 추가 | 변환 흐름(거래소) 검증 |
2단계의 비교 검증이 이 4단계의 심장이다. 시뮬과 실제가 어긋나면, 잘못된 건 게임이 아니라 모델이다. 어긋난 모델로 결정을 내리면 그 결정이 라이브에 사고로 돌아온다. 그래서 확장(4단계)은 항상 2·3단계의 검증을 통과한 다음에만 한다. 이 순서가 깨지면, 즉 단일 자원 검증을 건너뛰고 5종을 한꺼번에 넣으면, 어느 자원의 모델이 틀렸는지조차 분리해서 짚을 수 없게 된다.
모델이 검증을 통과하면, 이제 경제에 영향을 주는 모든 변경 결정 앞에 시뮬 게이트를 세운다. 회의에서 "느낌"으로 통과시키던 결정을 시뮬 통과로 바꾸는 자리다.
| 결정 종류 | 시뮬 의무 |
|---|---|
| source·sink 신규 추가 | 필수 |
| 자원 변환 비율 변경 (거래소 환율 등) | 필수 |
| 새 던전·이벤트 보상 설계 | 필수 |
| 가격 변경 (±10% 이상) | 필수 |
| 신규 직업 효율 검증 | 필수 |
| UI 변경 등 경제 무관 | 면제 |
게이트가 실제로 어떻게 작동하는지, §8.2.3에서 검증한 골드 모델 위에서 한 결정을 통과시켜 본다.
[시뮬 게이트 — 이벤트 보상 결정] (실제 형식 재구성)
[변경안] 주말 이벤트: 일일 로그인 보상 +500 골드 [게이트] source 신규 추가 → 시뮬 필수 [시뮬 1000회 결과] - 1주 골드 순 수지: +6,900 → +10,400 (+50%) - 26주 누적 시 골드 시세 중앙값 ~+28% (인플레 경고: ±10% 초과) - 상위 10% 활성 유저: ~+41% (segment 편차 큼) [판정] FAIL — 안정 범위(±10%/장기) 초과 [보정안] 이벤트 source에 동시 sink 부착: 이벤트 한정 상점(골드 회수) 재시뮬 → 26주 누적 +9% (PASS)
게이트의 값은 마지막 두 줄에 있다. "보상 +500을 풀자"는 결정이 회의 추측이었다면 "괜찮을 것 같다"로 통과됐을 것이다. 시뮬 게이트는 그 결정을 26주 +28% 인플레로 환산해 보여 주고, source를 추가할 거면 sink를 같이 달아라는 보정까지 강제한다. 경제 변경을 추측이 아니라 시뮬 통과/실패로 판정하는 것 — 이게 게이트의 전부다.
여기서 자주 빠지는 함정 하나를 짚는다. segment 편차다. 중활성 유저 기준 +28%여도 상위 10%는 +41%다. 골드를 가장 많이 버는 유저층이 인플레이션을 가장 빠르게 누적시키므로, 시뮬은 평균만 보지 말고 segment별로 돌려야 한다. 평균만 보면 고활성 유저발 시세 붕괴를 놓친다.
시뮬 게이트가 신뢰받으려면 모델이 실제 게임과 어긋나지 않아야 한다. 게임은 매주 바뀌므로 모델도 따라 보정해야 한다. 출시 후엔 실제 telemetry로 매월(변경이 적은 시기엔 분기) 모델을 친다.
모델 보정 사이클 (월간)
─────────────────────────────────
1. 실제 유저 telemetry 1개월치 추출 (자원별 흐름 집계)
2. segment(저/중/고활성)별 source·sink 실측 유량 산출
3. Machinations 시뮬과 항목별 비교
4. 오차 >15% 항목 = 모델 파라미터 조정 (그 항목의 ★가정이 틀린 것)
5. 조정 후 재시뮬 → 다음 달 게이트의 기준 모델로 사용
핵심은 4번이다. 오차가 큰 항목은 곧 §8.2.3에서 AI가 ★로 신고했던 "확신 못 하는 가정"이 실제와 어긋났다는 신호다. 예컨대 AI가 추측한 gear_buy 빈도(주 1.2회)가 실측 주 2회였다면, 그 가정을 telemetry 값으로 교체한다. 이 보정을 멈추면 모델이 게임과 천천히 벌어지고, 어느 분기 시뮬 게이트가 "통과시켰는데 실제로는 인플레가 온" 사고를 낸다. 그 순간 시뮬 자체의 신뢰가 사후에 무너진다. 보정은 운영의 부수 작업이 아니라 게이트를 살아 있게 하는 정규 사이클이다.
여기까지가 경제 모델링의 '보수적 적용'이다. 사람이 변경을 발의하고, 모델로 검증하고, 결과로 결정한다. 한 발 더 나가면 8.1.6에서 본 진보적 적용의 세 축 — z-score 탐지 · 변경 공간 정의 · 시뮬 병렬화 — 이 경제 인프라 위에서도 똑같이 열린다.
첫째, 이상 패턴 탐지. 매월 보정 사이클(§8.2.6)의 오차 비교를 사람이 눈으로 하는 대신, 모델-실측 편차가 임계를 넘는 항목을 코드가 먼저 골라 올린다. 강화석 시세가 두 배가 된 걸 거래소를 보고 아는 게 아니라, "강화석 source 유량이 모델 대비 +30% 이탈"이라는 alert가 회의 전에 도착한다.
둘째, 변경 공간 정의. "보상을 +500 풀자/말자"의 이분법이 아니라, 보상 범위(0~+1000)와 동시 sink 범위를 변경 공간으로 정의해 두면, 그 공간 안에서 인플레 ±10%를 만족하는 조합을 탐색할 수 있다. 사람은 "어디서 어디까지"를 정하고, 그 안의 최적 조합 탐색은 자동화한다.
셋째, 시뮬 병렬화. 변경안 하나를 1,000회 돌리는 대신, 변경 공간 안의 후보 수십 개를 병렬로 1,000회씩 돌려 분포를 한 번에 비교한다. 회의실에서 한 안씩 토론하던 자리가, 후보 매트릭스의 시뮬 결과 비교로 바뀐다.
공통 사상은 사람이 변경을 발의하던 자리를 코드가 변경 공간을 탐색하는 자리로 옮기는 것이다. 단, 보수적 적용(§8.2.3~8.2.6)이 안정적으로 돌고 모델이 telemetry로 검증된 다음의 이야기다. 검증 안 된 모델로 변경 공간을 자동 탐색하면, 틀린 모델이 틀린 최적값을 자신 있게 내놓는다.
[급진적 적용 — 경제를 '차원 벡터'로 압축해 탐색하기] (아직은 시기상조)
진보적 적용에서 한 발 더 나간 영역이다. 단정이 아니라 연구 동향으로 읽어 주기 바란다(차원 벡터·임베딩이 처음이라면 부록 M의 '지도' 한 장을 먼저 보면 아래가 쉽게 읽힌다 — 이 책의 다섯 '방향 표지'가 전부 그 그림 위에서 돈다). 여기까지 온 경제 모델은 자원 5종에 흐름 수십 개가 얽힌 고복잡도 시스템이고, §8.2.7의 변경 공간 탐색도 결국 그 수십 개 흐름을 일일이 파라미터로 잡고 도는 방식이다. 급진적 발상은 이 복잡도 자체를 차원 벡터로 압축한 다음, 그 압축 공간 위에서 해를 찾는 것이다.
멀어 보이는 비유 하나가 단서가 된다. 정성적이고 손에 안 잡히는 영역으로 흔히 꼽히는 요리 레시피를, 한 연구(Epicure — Radzikowski·Chen, 2026, arXiv:2605.22391 · 데모 epicure.kaikaku.ai)에서는 11개 출처의 레시피 414만 건에서 표준 재료 1,790종을 추려, 재료 사이의 관계를 수백 차원의 벡터로 압축했다. 핵심은 "맛"이라는 정성적 대상도 재료 간 관계를 좌표로 환산하면, 비슷한 레시피는 벡터 공간에서 가까이 모이고 그 사이를 보간해 새 조합을 탐색할 수 있다는 점이다 — Epicure도 이 압축 공간에서 한 재료를 특정 요리권 방향으로 회전시켜 대응 재료를 찾는 보간 탐색을 보인다.
경제도 원리는 같다. source·sink·변환 흐름을 각각 차원으로 둔 벡터로 경제 상태를 표현하면, "인플레 ±10% 안에서 안정적인 경제"가 그 공간의 한 영역으로 잡힌다. 그러면 변경안을 하나씩 시뮬에 거는 대신, 그 안정 영역 안/근처에서 해를 직접 탐색하는 길이 열린다. 후보를 일일이 돌려 비교하던 §8.2.7의 병렬 시뮬이, 압축 공간 위의 탐색 한 번으로 좁혀질 가능성이다.
왜 "아직은 시기상조"인가. 첫째, 무엇을 차원으로 잡을지(어떤 흐름이 독립이고 어떤 게 종속인지)를 정하는 일 자체가 도메인 난제다. 둘째, 압축은 본질적으로 정보를 버리는 일이라, 버린 차원에서 라이브 사고가 터질 수 있다. 셋째, 이 모든 게 보수적 적용의 telemetry 검증(§8.2.6)이 단단할 때만 의미가 있다 — 압축 전 모델이 게임과 어긋나 있으면, 압축은 그 오차까지 깔끔하게 압축할 뿐이다. 그래서 이 절은 처방이 아니라 방향 표지다. 지금 할 일은 보수적 적용을 정직하게 돌리는 것이고, 차원 벡터는 그 토대가 충분히 쌓인 팀이 몇 년 뒤 들여다볼 연구 영역으로 남겨 둔다.
도구 도입 전후를 비교한다. 아래 시간·빈도는 도입 초기 운영에서 체감한 방향을 담은 것이라, 정밀한 절대값으로 읽기보다 어느 쪽으로 움직였는지로 읽어야 맞다.
| 항목 | 도입 전 (회의·손계산) | 도입 후 (시뮬 게이트) |
|---|---|---|
| 경제 변경 결정 → 적용 | 2~4주 (추측·재논의 반복) | 1~3일 (시뮬 검증 1회) |
| 인플레이션 사고 | 분기당 1~2건 (사후 발견) | 분기당 0~1건 (게이트 사전 차단) |
| source·sink 추가 빈도 | 분기 1~2회 (무서워서 보수적) | 월 1~2회 (시뮬이 안전 보장) |
| 경제 회의 빈도 | 주 3~4회 | 주 1~2회 |
표의 숫자보다 마지막 줄의 의미가 크다. 회의 빈도 감소는 시뮬레이션이 토론을 대체했기 때문이다. "내 생각엔 강화석에 인플레가 올 것 같다"가 "시뮬 결과 26주 +28%"로 바뀌면, 추측을 두고 한 시간 다투던 자리가 5분 결과 공유로 끝난다. 이건 저자 시스템의 회고에 박제된 개념(atom automation_signal_value_over_time_savings — 자동화의 가치는 시간 절약이 아니라 신호 노출)과 정확히 같은 자리다. 시뮬 게이트의 진짜 산출물은 절약된 시간이 아니라, 회의에서 추측이 차지하던 자리를 숫자가 차지하게 만든 것이다.
다만 한 가지는 정직하게 둔다. 표의 "분기당 1~2건 → 0~1건"은 정밀 측정값이 아니라 운영 체감의 방향이다. 인플레이션 사고는 정의(시세 ±몇 %를 사고로 볼지)에 따라 카운트가 달라지므로, 절대 건수보다 "사후 발견에서 사전 차단으로 옮겨 갔다"는 구조 변화로 읽는 게 맞다.
| 패턴 | 왜 실패하나 | 처방 |
|---|---|---|
| 전 자원을 한 번에 모델링 | 어느 자원 모델이 틀렸는지 분리 불가 | 단일 자원 Pilot부터 (§8.2.4) |
| AI 모델의 가정을 검수 없이 수용 | 거래소 이중계상 같은 결함이 그대로 들어감 | 가정 명시 강제 + 사람 거부 (§8.2.3) |
| 시뮬 게이트 없이 경제 변경 | 사후 인플레 회복 비용이 막대 | 의무 시뮬 항목 정의 (§8.2.5) |
| 평균만 시뮬, segment 무시 | 고활성 유저발 시세 붕괴 놓침 | segment별 시뮬 (§8.2.5) |
| 출시 후 telemetry 보정 안 함 | 모델이 게임과 벌어져 게이트 신뢰 붕괴 | 월/분기 보정 사이클 (§8.2.6) |
| 검증 안 된 모델로 변경 공간 자동 탐색 | 틀린 모델이 틀린 최적값을 확신 있게 냄 | 보수적 적용 안정 후 진보 (§8.2.7) |
두 번째가 가장 자주 놓친다. §8.2.3에서 본 거래소 이중계상처럼, AI는 그럴듯한 모델을 자신 있게 뱉되 자기 가정의 약점은 ★로 신고만 하고 본문엔 결함을 남겨 둔다. 그 ★를 사람이 판정하지 않으면, 틀린 모델이 통과되고 그 위의 모든 시뮬 결정이 함께 틀린다.
혼자라면 이만큼만: Machinations도 telemetry도 없어도 됩니다. 본인 게임(또는 좋아하는 게임)의 자원 하나를 골라 source·sink를 종이에 적고, §8.2.3의 프롬프트를 그대로 붙여 1주 순 수지 모델 초안을 받아 보세요. AI가 ★로 명시한 가정 하나를 골라 "이 가정은 못 믿겠다, 근거를 다시 대라"고 반박해 보면, 경제 모델이 어떤 가정들의 묶음인지 — 그리고 그 가정 하나가 틀리면 결론이 어떻게 뒤집히는지 — 몸으로 들어옵니다.
팀이라면 다음 한 단계로 시작하세요. 전 자원이 아니라 가장 문제인 자원 하나(보통 골드 또는 강화석)를 골라 §8.2.3의 단일 자원 모델만 먼저 세우고, 경제 변경 결정 한 종류(예: 이벤트 보상)에 §8.2.5의 시뮬 게이트를 거세요. 자원 한 개 + 결정 한 종류만으로도, 회의에서 추측을 두고 다투던 자리를 숫자 한 줄로 바꿀 수 있습니다.
setup → prompt → verify로 요약하면 — setup: 문제 자원 하나의 source·sink를 yaml로 추출합니다. prompt: §8.2.3 형식으로 노드 모델 초안을 받되 유저 행동 가정을 ★로 명시하게 강제합니다. verify: AI가 신고한 ★ 가정과 노드 분류(특히 유저간 이전 vs 신규 생성)를 사람이 직접 거부·재요청합니다.
2008년 어느 새벽, 나는 Excel 한 장 앞에서 같은 숫자를 세 번 검산하고 있었다. 기획서에는 어떤 검 캐릭터의 초당 피해량이 847이라고 적혀 있었다. 그런데 그날 처음 돌린 시뮬레이터는 같은 캐릭터를 같은 사양으로 넣었는데 612를 뱉었다. 27% 차이. 둘 중 하나는 거짓말이었고, 나는 어느 쪽이 거짓말인지 아직 몰랐다.
명세서의 DPS는 종이 위의 약속이다. 스킬 한 방의 데미지에 발동 빈도를 곱한 산수. 시뮬레이터의 DPS는 그 약속을 1,000번 실제로 휘둘러 본 결과다. 쿨다운이 겹치고, 시전 모션에 시간이 잡아먹히고, 치명타가 기대값만큼 안 터지는 — 종이가 모르는 마찰이 끼어든다. 이 27%의 틈이 바로 밸런스 설계자가 먹고사는 자리다. 종이를 믿으면 출시 후에 운다.
이 챕터는 그 도구 한 자루의 이야기다. 2008년에 만들어 지금까지 손에서 놓지 않은 Damage Simulator. 명세와 산출이 갈라지는 정확한 지점을 어떻게 추적했는지, 그리고 18년 뒤 그 추적에 AI를 어떻게 붙였는지를 한 번의 실제 워크드 트랜스크립트로 따라간다.
먼저 그 612 대 847의 정체를 뜯어보자. 명세서를 쓴 후배 기획자(이하 팀원 A)는 잘못한 게 없었다. 그는 스킬 표에 적힌 대로 곱했다.
명세상의 DPS 계산은 이렇게 생겼다. 한 캐릭터가 가진 세 스킬을 가정하자.
| 스킬 | 단일 데미지 | 쿨다운 | 시전 시간 |
|---|---|---|---|
| 횡베기 | 320 | 3.0s | 0.6s |
| 찌르기 | 540 | 6.0s | 0.9s |
| 평타 | 180 | 1.2s | 0.4s |
팀원 A의 명세 계산은 "각 스킬을 쿨마다 빠짐없이 쓴다"는 이상적 가정 위에 있었다. 횡베기는 3초당 320, 찌르기는 6초당 540, 평타는 빈 시간을 채운다. 산수로는 깔끔하게 847이 나온다. 종이 위에서 캐릭터는 한 손이 여러 개여서 시전 모션이 서로를 막지 않는다.
시뮬레이터가 612를 내놓은 이유는 단 하나, 손이 하나라서다. 0.9초짜리 찌르기를 시전하는 동안 횡베기 쿨이 돌아도 쓸 수 없다. 시전 모션이 서로를 잡아먹는 이 글로벌 쿨다운 충돌이 명세에는 없다. 종이는 마찰이 없는 진공이고, 시뮬은 마찰이 있는 전장이다. 24년을 이 일을 하며 배운 첫 번째 진실은 이거다 — 명세 DPS는 상한선이지 실측값이 아니다. 그리고 사용자는 상한선이 아니라 실측값으로 게임을 한다.
2008년의 나는 이 27%의 틈을 손으로 메웠다. 시뮬 로그를 한 줄씩 눈으로 읽으며 어느 프레임에서 스킬이 막혔는지 셌다. 반나절이 걸렸다. 18년이 지난 지금, 나는 같은 로그를 Claude에게 던진다. 아래는 실제로 진행한 한 세션의 전문이다. 요약하지 않고, 빗나간 첫 시도까지 그대로 싣는다 — 그 빗나감이 이 도구를 쓰는 법의 절반이기 때문이다.
내가 던진 프롬프트는 이랬다.
첨부한 건 우리 Damage Simulator(
damage_sim/, Python)의 단일 캐릭터 1,000틱 전투 로그 일부다. 명세서상 이 캐릭터의 DPS는 847인데 시뮬 산출은 612가 나온다. 27% 갭의 원인을 로그에서 찾아라. 로그 포맷은[tick] event skill=SKILL state=STATE. 추측 말고 로그에 있는 근거만 대라. 근거가 부족하면 부족하다고 말해라.[0.0] cast skill=찌르기 state=START [0.9] cast skill=찌르기 state=HIT dmg=540 [0.9] cast skill=횡베기 state=BLOCKED reason=GCD [1.5] cast skill=평타 state=HIT dmg=180 [1.9] cast skill=평타 state=BLOCKED reason=GCD [3.0] cast skill=횡베기 state=HIT dmg=320 [3.6] cast skill=평타 state=HIT dmg=180 ... (994 lines omitted) [997.2] cast skill=찌르기 state=HIT dmg=540
Claude의 날것 출력은 이랬다(다듬지 않음).
로그에서 확인되는 사실:
state=BLOCKED reason=GCD이벤트가 반복적으로 등장합니다.[0.9]에서 횡베기가,[1.9]에서 평타가 GCD(글로벌 쿨다운) 때문에 막혔습니다.- 찌르기 시전(0.0→0.9, 0.9초)이 끝나기 전까지 다른 스킬이 발동되지 못합니다. 시전 시간이 GCD를 점유하는 구조로 보입니다.
- 따라서 명세상의 "모든 스킬을 쿨마다 발동" 가정이 깨집니다. 실제로는 시전 모션끼리 충돌해 일부 발동이 누락됩니다.
다만 제공된 로그는 1,000줄 중 7줄뿐입니다. 27%라는 정확한 갭이 GCD 충돌만으로 설명되는지는 이 7줄로 단정할 수 없습니다. BLOCKED 이벤트의 전체 발생 횟수와, 그로 인해 누락된 데미지 총량을 집계해야 27%가 검증됩니다. 전체 로그의 BLOCKED 집계를 주시면 정량적으로 확인하겠습니다.
여기서 멈춘 게 중요하다. AI는 원인을 지목했지만 27%를 증명하지는 못했고, 스스로 그 한계를 말했다. 종이 기획자였다면 "GCD 때문이네요"로 회의를 끝냈을 자리에서, 도구는 "7줄로는 모른다"고 버텼다. 나는 그 요구가 정당하다고 판단했다 — 이게 사람의 검증이다. 그래서 전체 로그의 BLOCKED 집계를 뽑아 다시 던졌다.
전체 1,000틱 집계다. BLOCKED 총 412회, 그중 GCD 사유 388회, 쿨다운 미충전 사유 24회. BLOCKED로 누락된 추정 데미지는 횡베기 환산 약 19,800. 총 전투 1,000틱(=초)간 명세 기대 데미지는 847,000, 시뮬 실측 누적 데미지는 612,300. 이 숫자로 27% 갭을 재검토해라.
Claude의 두 번째 출력.
집계로 검증합니다.
- 명세 기대 누적: 847,000
- 시뮬 실측 누적: 612,300
- 갭: 234,700 (27.7%)
이 갭의 구성: - GCD 충돌로 직접 누락(388회): 추정 약 19,800은 횡베기 환산이지만, 누락된 슬롯에 더 높은 데미지 스킬이 들어갈 수 있었던 기회비용까지 합산해야 합니다. 누락 슬롯을 평균 데미지로 환산하면 누락분이 19,800보다 큽니다. - 다만 234,700 전체가 BLOCKED만으로 설명되지는 않습니다. 명세 847이 "치명타 기대값을 평균으로 이미 포함"한 값인지, "치명타 미포함 기본값"인지에 따라 나머지 갭의 해석이 달라집니다. 명세 847의 치명타 가정을 알려주시면 잔여 갭을 분해하겠습니다.
다시 멈췄다. 그리고 다시 정확한 곳에서 멈췄다. 명세 847이 치명타를 어떻게 잡았는지 — 이건 로그에 없는 정보이고, 팀원 A의 머릿속에만 있었다. AI는 자기가 가진 데이터의 경계를 알았다. 내가 확인해 보니 명세 847은 치명타 20% 확률·1.5배를 기대값으로 평탄화해 넣은 값이었고, 시뮬은 seed 고정 후 실제 치명타 발생 횟수가 기대값보다 낮게 나온 한 판이었다. 즉 27% 갭은 GCD 충돌(구조적·재현됨) 과 치명타 분산(통계적·이 한 판의 운) 이 섞인 값이었다.
이 분해가 결론이다. GCD 충돌분은 설계로 고쳐야 할 진짜 문제고, 치명타 분산분은 시드를 바꿔 1,000판 평균을 내면 사라질 노이즈다. 둘을 섞어 "캐릭터가 약하다"고 버프를 주면, 1,000판 평균에서는 멀쩡하던 캐릭터가 과강해진다. 종이도 모르고, 한 판의 시뮬도 모르고, AI 혼자서도 몰랐던 이 구분을 만든 건 로그 집계와 명세의 숨은 가정을 들이댄 사람의 검증이었다.
방금 세션이 들여다본 그 도구의 입출력을 한 세트로 펼쳐 보자. 시뮬레이터는 결국 정직한 함수다. 같은 입력에 같은 출력. 입력은 세 갈래에서 모인다.
flowchart LR
A["게임 데이터 시트
(스킬 표·캐릭터 스탯)
read-only"] --> SIM
B["시나리오 yaml
(전투 조건·지속 시간·
대상 구성)"] --> SIM
C["seed=42
(난수 고정)"] --> SIM
SIM["Damage Simulator
domain/formulas.py
run_combat() × 1000틱"]
SIM --> R1["실측 누적 데미지
612,300"]
SIM --> R2["BLOCKED 로그
412회 (GCD 388)"]
SIM --> R3["치명타 발생
실측 17.2% vs 기대 20%"]
R1 --> REP["markdown 보고서
명세 847 vs 시뮬 612
갭 분해: 구조 19% + 분산 8%"]
R2 --> REP
R3 --> REP
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
class SIM code;
class A,B,C,R1,R2,R3,REP data;
이 그림의 핵심은 화살표가 한 방향이라는 것이다. 게임 데이터 시트는 시뮬레이터로 읽히기만 한다. 시뮬은 데이터를 절대 고쳐 쓰지 않는다. 18년간 가장 많은 사고를 막은 규칙이 이 한 화살표의 방향이었다. 시뮬이 자기 안에 데이터를 복사해 두기 시작하면, 게임 데이터가 바뀐 다음 날 시뮬은 어제의 세계를 시뮬레이션한다. 그렇게 나온 보고서로 회의를 하면, 회의 전체가 어제의 세계를 두고 다투게 된다.
구체적인 입력 한 세트(시나리오 yaml)는 이렇게 생겼다.
# scenarios/single_dps_check.yaml
scenario: single_target_dps
duration_ticks: 1000 # 1틱 = 0.1s 가정, 100초 전투
seed: 42 # 결정론 — 같은 입력 같은 출력
actor:
char_id: K_004 # 게임 데이터 시트에서 read
skill_rotation: optimal # GCD 충돌 시 최고 기대 데미지 우선
target:
defense: 1200
hp: infinite # DPS 측정용 무한 체력 더미
report:
compare_to_spec: 847 # 명세 DPS를 넣어 갭 자동 분해
그리고 출력 한 세트(보고서 발췌)는 이렇다.
# Damage Simulator Report — K_004 single DPS
입력: scenarios/single_dps_check.yaml | seed=42 | data rev. 2026-06-05
## 명세 대비
- 명세 DPS: 847 (치명타 20%·1.5x 기대값 평탄화 포함)
- 시뮬 실측 DPS: 612 (이 시드 1판)
- 갭: -27.7%
## 갭 분해
- 구조적(GCD 충돌, 재현됨): -19.2% ← 설계 검토 대상
- 통계적(치명타 분산, 이 판): -8.5% ← 1000판 평균 시 소거 예상
## 재현 검증
- seed=42 재실행 3회 → 612,300 / 612,300 / 612,300 (일치)
- seed 0~999 1000판 평균 DPS → 731 (치명타 분산 소거 후)
마지막 줄을 보라. seed 0~999로 1,000판을 돌린 평균은 731이었다. 명세 847과 1,000판 평균 731의 갭 116(13.7%)이 바로 GCD 충돌이라는 진짜 구조 문제의 크기다. 한 판의 612가 아니라 이 731이 설계 회의의 입력이 되어야 한다. 종이의 847도, 운 나쁜 한 판의 612도 아닌, 1,000판이 합의한 731. 이 숫자를 손에 쥐기까지가 밸런스 설계자의 일이다.
이 도구는 18년을 살았지만, 같은 코드로 산 게 아니다. 옷걸이는 그대로 두고 옷만 다섯 번 갈아입혔다. 옷걸이는 위 보고서의 논리 — 명세와 실측을 갈라 보고, 갭을 구조와 분산으로 분해하고, 재현으로 검증하는 절차다. 이 절차는 2008년 Excel VBA(엑셀 매크로 언어)에서도, 2026년 Python에서도 글자 그대로 같다.
| 시기 | 옷(기술) | 옷걸이(변하지 않은 절차) |
|---|---|---|
| 2008~2011 | Excel VBA, 1:1 | 명세 대비 갭 분해 |
| 2012~2016 | C# 콘솔, N:N | 〃 |
| 2017~2020 | Python + Web | 〃 |
| 2021~2024 | Python + ML | 〃 (+ 사용자 분포 반영) |
| 2025~ | Python + LLM 보조 | 〃 (+ 로그 질의·가설 생성) |
다섯 번의 옷 갈아입기를 버틴 비결은 폴더 구조에 새겨져 있다.
damage_sim/
├── domain/ # 옷걸이 — 18년 그대로
│ ├── formulas.py # 데미지 공식·GCD 충돌 판정
│ └── metrics.py # 갭 분해 로직
├── adapters/ # 게임 데이터 read-only
│ └── excel_reader.py
├── runners/ # 옷 — 기술 바뀔 때마다 교체
│ └── cli_runner.py
└── reporters/ # 옷 — 보고서 출력 형식
└── markdown_report.py
기술이 바뀌면 runners/와 reporters/만 새로 짠다. domain/의 갭 분해 로직은 18년 자산 그대로 살아남는다. 2008년에 Excel 셀로 짰던 GCD 충돌 판정식이 함수 시그니처만 바뀐 채 지금 formulas.py에서 돌고 있다. 도구를 한 기술에 못 박으면 그 기술과 함께 늙어 죽는다는 걸, 나는 죽은 도구 여러 개를 보내며 배웠다.
2025년에 붙인 LLM은 새 옷걸이가 아니라 새 손이다. 앞 세션에서 봤듯 AI는 로그를 읽고 가설을 세우는 손이고, 반나절 걸리던 로그 추적이 몇 분으로 줄었다. 하지만 옷걸이는 건드리지 않는다 — 갭이 27%인지, 치명타가 몇 퍼센트 터졌는지를 결정하는 건 여전히 seed 고정된 결정론 코어다. 그 자리에 LLM이 들어가는 순간 회귀 검증이 불가능해져 도구가 죽는다.
밸런스 도구에서 단 하나의 선을 그어야 한다면, 나는 결정론 코어의 경계에 긋는다. 안쪽은 같은 입력에 같은 출력이 강철처럼 보장되어야 하고, 바깥쪽은 사람·AI가 자유롭게 가설을 던져도 된다.
안쪽(결정론 — AI 금지):
- 데미지 공식, GCD 충돌 판정, 치명타 발생, 누적 집계.
- seed=42로 세 번 돌려 612,300이 세 번 똑같이 나와야 한다. 이게 깨지면 어제의 보고서와 오늘의 보고서를 비교할 수 없다.
바깥쪽(가설·해석 — AI 환영): - "왜 이 캐릭터 조합 승률이 비정상인가" 같은 원인 질의. - 로그에서 BLOCKED 패턴 찾기, 자연어 보고서 초안, 시나리오 yaml 초안.
앞 워크드 트랜스크립트가 정확히 이 선 위에서 움직였다. AI는 바깥쪽에서 "GCD 충돌이 원인"이라는 가설을 빠르게 세웠다. 하지만 27%라는 숫자, 612라는 숫자는 끝까지 결정론 코어가 계산한 값이었고 AI는 그 값을 받아 해석만 했다. 그리고 두 번이나 "이 데이터로는 단정 못 한다"며 멈췄다 — 결정론 코어가 줄 수 없는 정보(명세의 치명타 가정)를 요구하면서. 이 멈춤이 좋은 도구의 표식이다. 가설을 진단으로 착각하지 않는 것.
수치에 대해 한 가지 정직하게 밝힌다. 이 챕터의 847·612·731·412회 같은 구체 숫자는 설명을 위해 구성한 예시값이다. 다만 명세 DPS가 시뮬 실측보다 항상 높게 나온다는 방향, 그 갭이 구조적 충돌과 통계적 분산으로 분해된다는 구조, seed 고정이 회귀 검증의 전제라는 원칙은 2008년부터 18년간 실제로 운영하며 반복 확인한 것이다. 비율의 크기는 프로젝트마다 다르지만 방향과 구조는 변하지 않았다.
setup. 게임 데이터에서 캐릭터 하나를 골라 스킬 표(데미지·쿨다운·시전 시간)와 명세 DPS를 확보하세요. 시뮬레이터가 없다면 1,000틱 단일 대상 전투를 돌리는 최소 스크립트를 짜세요. 핵심은 seed를 인자로 받아 고정할 수 있어야 한다는 것 하나입니다.
prompt. 시뮬 로그(BLOCKED 이벤트 포함)와 명세 DPS를 함께 던지세요.
첨부는 캐릭터 1명의 1,000틱 전투 로그와 BLOCKED 집계다. 명세 DPS는 [N]인데 시뮬 산출은 [M]이다. 갭의 원인을 로그 근거만으로 분해해라. 구조적 원인(재현되는 충돌)과 통계적 원인(이 판의 분산)을 구분해라. 근거가 부족하면 부족하다고 말하고 무엇이 더 필요한지 지목해라.
verify. AI가 지목한 구조적 원인을 seed를 바꿔 1,000판 평균으로 검증하세요. 평균에서도 갭이 남으면 진짜 구조 문제, 사라지면 분산 노이즈입니다. AI가 "단정 못 한다"고 멈추면 그건 실패가 아니라 정상입니다 — 멈춘 자리에 사람이 들어가 명세의 숨은 가정을 채웁니다.
시뮬레이터도 ML도 없는 1인 개발자라면, 엑셀 한 장과 AI만으로 같은 절차를 돌릴 수 있습니다. 스킬 표를 시트에 적고, RAND()로 치명타를 굴리는 1,000행 시뮬을 한 열에 만드세요. 시드 고정이 안 되니 F9로 100번 재계산해 평균을 손으로 보세요. 그 평균과 명세 DPS의 갭을 AI에게 "구조 원인과 분산 원인으로 갈라 달라"고 던지세요. 도구는 작아도 옷걸이 — 명세 대비 갭 분해, 구조와 분산의 분리, 재현 검증 — 는 똑같이 섭니다.
금요일 오후 4시, 알파 빌드의 5:5 PvP 자동 시뮬 1,200판이 끝났다. 결과 JSON은 4메가바이트. 그 안 어딘가에 "팀 A 승률 92%"라는 한 줄이 기록되어 있는데, 평균 승률은 52%였다. 나는 그 한 줄을 찾는 데 40분을 썼고, 왜 그런지는 끝내 알아내지 못한 채 퇴근했다.
밸런스는 결정론의 영역이다. 같은 입력에 같은 수식을 넣으면 항상 같은 데미지가 나온다. 그래서 데미지 시뮬레이터는 코드여야 하고, 보상 곡선은 사람이 손으로 그어야 한다 — 여기는 AI가 발을 들이면 안 되는 자리다. 그런데 그 결정론 코어의 주변, 그러니까 1,200판 결과에서 이상한 한 줄을 찾고, 왜 그런지 가설을 세우고, 무엇을 바꿔야 할지 후보를 추리고, 그 후보들을 다시 시뮬에 거는 일 — 그 주변 노동이 밸런서의 하루 대부분을 잡아먹는다. 이 장은 그 주변에 AI를 붙이는 이야기다. 코어는 손대지 않은 채로.
8.3에서 본 그 2008년산 데미지 시뮬레이터 — 엔진과 회사를 세 번 갈아타면서도 결정론 코어는 그대로 살아남은 — 가 이 장의 출발점이다. 입력이 같으면 출력이 같다는 성질, 그것이 밸런스 도구의 신뢰 전부다. 같은 빌드를 두 번 돌렸는데 승률이 다르게 나오면 그 도구는 버려야 한다.
그래서 밸런스 작업의 골격을 그려보면, 가운데 결정론 덩어리가 있고 그 입구와 출구에 사람의 손노동이 매달려 있는 모양이 된다. 아래는 그 골격을 분해한 것이다 — 결정론 영역(파란색)과 사람·AI가 개입하는 영역(주황색)을 색으로 갈라 놓았다.
가운데 파란 박스 하나만 코드다. 나머지 다섯 개 주황 박스는 전부 사람의 판단·해석·작성 노동이고, AI가 들어갈 수 있는 자리는 이 다섯 군데뿐이다. LLM에게 "이 캐릭터의 DPS를 계산해줘"라고 시키는 순간, 같은 입력에 다른 숫자가 나오는 비결정론이 코어로 새어 들어와 그 도구는 18일도 못 가서 신뢰를 잃는다.
이 장의 척추는 그래서 단순하다. 코어를 끝까지 코드로 지키면서 입구·출구의 다섯 자리에 AI를 붙이되, 가장 손이 많이 가는 출구 쪽 — 1,200판 결과에서 이상한 한 줄을 찾고 가설을 세우는 일 — 부터 자동화한다.
서두의 그 92%로 돌아가자. 이번엔 사람이 40분을 헤매는 대신, 결정론 탐지기가 그 한 줄을 골라내고 LLM이 가설을 세우고 다시 시뮬이 검증하는 한 사이클을 처음부터 끝까지 따라간다. 요약하지 않고, 도구가 실제로 뱉은 날것을 그대로 둔다.
1,200판의 결과에서 "이상한" 판을 고르는 건 LLM이 아니라 통계다. 각 지표의 평균과 표준편차를 구하고, 평균에서 몇 표준편차나 떨어졌는지(z-score)로 가른다. 임계값을 넘으면 outlier다. 이건 결정론이고, 환각이 끼어들 여지가 없다.
def find_outliers(results, threshold=2.5):
# results: 시뮬 한 판당 {지표명: 값} 딕셔너리의 리스트
means, stds = compute_per_metric(results) # 지표별 평균·표준편차
outliers = []
for r in results:
for metric, value in r.items():
if stds[metric] == 0: # 분산 0 → 비교 불가, 건너뜀
continue
z = abs(value - means[metric]) / stds[metric]
if z > threshold:
outliers.append((r["scenario_id"], metric, value, round(z, 2)))
return sorted(outliers, key=lambda x: -x[3]) # z 큰 순
실행하면 다음이 나온다 — 1,200판 중 임계값 2.5를 넘은 건 단 3건이었다.
[("pvp_5v5_S0417", "team_a_winrate", 0.92, 4.1),
("pvp_5v5_S0417", "match_duration", 41.0, 2.9),
("pvp_5v5_S0822", "team_b_winrate", 0.18, 2.6)]
가장 z가 큰 첫 줄, 시나리오 pvp_5v5_S0417의 승률 0.92(z=4.1)가 서두에서 내가 40분을 헤맨 바로 그 한 줄이다. 사람이 4메가 JSON을 눈으로 훑을 일이 아니라, 통계가 3건으로 좁혀준다. 여기까지가 코어, 여기서부터가 AI다.
이제 그 한 줄을 LLM에게 넘긴다. 단, "원인을 진단해줘"가 아니다. LLM은 도메인 지식으로 가능한 원인 가설을 몇 개 던질 뿐이고, 무엇이 진짜인지는 다시 시뮬이 정한다. 프롬프트 전문은 다음과 같다.
[outlier]
시나리오: pvp_5v5_S0417 — 5:5 PvP
팀 A 구성: [refgame_archer_07, refgame_archer_07, refgame_archer_07,
refgame_hybrid_21, refgame_hybrid_21]
지표: team_a_winrate 0.92 (전체 평균 0.52, z = 4.1)
부수 지표: match_duration 41.0s (평균 28s, z = 2.9)
[관련 정보]
- refgame_archer_07: 원거리 지원, 스킬 "표식" — 대상에게 받는 피해 +12% 디버프
- refgame_hybrid_21: 근접/원거리 혼합 딜러, 스킬 "추격" — 디버프 대상 추적 시 이동속도 +20%
- 두 캐릭터는 같은 팀에 배치된 적이 거의 없음 (조합 빈도 하위 5%)
요청:
이 outlier의 가능 원인 가설 3~5개를 제시하세요.
- 각 가설에 검증용 추가 시뮬 1줄
- 확정 진단 금지. "~일 수 있다" 수준의 가설로만.
응답 형식: 번호 매긴 리스트. 서두·맺음말 없이 가설만.
Claude의 날것 출력을 그대로 옮긴다 (요약·정리하지 않음).
1. "표식" 디버프와 "추격" 이속 버프가 곱연산으로 누적되어,
archer_07 3기가 표식을 도배 → hybrid_21이 끊김 없이 추격하는
양의 피드백 루프가 형성됐을 수 있다.
검증: hybrid_21을 일반 근접 딜러로 교체해 같은 시나리오 재시뮬.
2. 표식 디버프의 +12%가 중첩 가능(stackable)하게 잘못 설정되어,
archer_07 3기가 +36%까지 쌓았을 수 있다.
검증: 표식 max_stack을 1로 강제하고 재시뮬.
3. 5:5에서 원거리 3 + 추격형 2 구성이 단순히 사거리 우위로
상대 진입을 봉쇄하는 구도일 수 있다 (시너지 무관, 조합 자체 강함).
검증: 표식 디버프 효과를 0%로 죽이고 같은 조합 재시뮬.
4. match_duration이 평균보다 긴 것(41s)으로 보아, 팀 A가 빨리 이긴 게
아니라 "지지 않고 버티다 후반 역전"하는 구조일 수 있다.
이 경우 승률보다 후반 스케일링 수치가 원인일 수 있다.
검증: 60초 시점 누적딜 곡선만 따로 추출해 비교.
5. (낮은 확률) 시뮬 시드 편향 — S0417 시드에서만 팀 A 선공이
반복됐을 수 있다. 통계 아티팩트일 가능성.
검증: 같은 조합을 시드 50개로 재시뮬해 승률 분산 확인.
여기서 멈추는 게 중요하다. 다섯 번째 가설이 "이건 그냥 시드 편향일 수도 있다"라고 자기 의심을 남긴 것 — 이게 좋은 출력이다. LLM이 "원인은 표식 중첩 버그입니다"라고 단정했다면 오히려 그 출력을 의심해야 한다. 밸런스에서 LLM의 일은 진단이 아니라 탐색 공간을 좁히는 것이다.
다섯 가설 각각에 검증용 시뮬이 한 줄씩 달려 있다. 이걸 사람이 하나씩 돌리는 게 아니라, 변경 후보를 묶어 병렬로 던진다. 핵심 코어인 simulate_dps는 다음과 같은 실행 가능한 모양이다 — 18년 묵은 그 결정론 함수의 골자다.
def simulate_dps(attacker, target, formula, ticks=600, seed=0):
"""한 쌍의 전투를 결정론적으로 시뮬. 같은 (입력, seed)면 같은 출력."""
rng = Rng(seed) # 시드 고정 → 재현 가능
hp = target.hp
total_damage = 0.0
for t in range(ticks): # 1 tick = 0.1초 가정
# 방어 계수: 결정론 수식 (LLM이 만들지 않는다)
def_factor = target.defense / (target.defense + formula.def_const)
raw = attacker.atk * (1 - def_factor)
# 치명타: 시드 기반 → 같은 seed면 같은 크리 타이밍
if rng.roll() < attacker.crit_rate:
raw *= attacker.crit_mult
# 디버프(표식 등)는 formula에서 결정론적으로 주입
raw *= formula.debuff_multiplier(attacker, target, t)
hp -= raw
total_damage += raw
if hp <= 0:
return {"ttk": t * 0.1, "dps": total_damage / ((t + 1) * 0.1)}
return {"ttk": None, "dps": total_damage / (ticks * 0.1)} # 시간 내 못 잡음
def run_candidates(base_scenario, candidates, seeds=range(50)):
"""가설별 변경 후보를 50시드 병렬 시뮬. winrate 분산까지 회수."""
out = {}
for name, patch in candidates.items(): # patch = formula 일부 덮어쓰기
scen = base_scenario.with_patch(patch)
wins = [simulate_match(scen, formula=scen.formula, seed=s) for s in seeds]
out[name] = {
"winrate": mean(w["team_a_won"] for w in wins),
"winrate_std": pstdev(w["team_a_won"] for w in wins), # 가설 5 검증용
}
return out
가설을 candidates 딕셔너리로 옮겨 한 번에 돌린다.
candidates = {
"기준(변경없음)": {},
"가설1_hybrid교체": {"team_a[3:5]": "refgame_melee_03"},
"가설2_표식_max_stack1": {"skill.표식.max_stack": 1},
"가설3_표식_효과0": {"skill.표식.debuff": 0.0},
"가설5_시드분산확인": {}, # 같은 조합, seeds만 50개
}
result = run_candidates(scenario_S0417, candidates, seeds=range(50))
결과 (실제 실행 형태의 출력):
기준(변경없음) winrate=0.91 std=0.04 ← 시드 편향 아님(가설5 기각)
가설1_hybrid교체 winrate=0.74 std=0.06
가설2_표식_max_stack1 winrate=0.63 std=0.05 ← 가장 크게 떨어짐
가설3_표식_효과0 winrate=0.55 std=0.05 ← 평균 근처로 복귀
읽는 순서가 곧 진단이다. 기준을 50시드로 다시 돌려도 승률 0.91에 분산 0.04 — 가설 5(시드 편향)는 기각된다. 표식 효과를 0으로 죽이자 0.55로 평균 근처에 붙는다 — 원인은 표식 디버프 계열이 맞다. 그리고 max_stack을 1로 묶었을 때 0.63까지 떨어지는 폭이 가장 컸으니, 핵심은 가설 2 — 표식 디버프가 중첩되어 archer_07 3기가 +36%까지 쌓은 것이다. LLM이 던진 다섯 후보 중 사람이 다섯을 다 검증한 게 아니라, 통계가 셋만 돌려도 결판이 났다.
여기서 LLM이 한 일은 "표식 중첩이 버그다"라고 말한 것이 아니다. 그 가설을 후보 목록에 올린 것뿐이다. 채택은 시뮬 결과를 본 밸런서가 한다 — "표식 max_stack을 1로 고정한다. archer_07 단일 조합 승률은 0.63으로 여전히 평균(0.52)보다 높으니, 다음 빌드에서 표식 디버프 수치를 12%→9%로 추가 조정 후 재측정한다."
이 결정은 사람이 내렸고, 그 근거(z=4.1 탐지 → 5가설 → 3시뮬 → 가설2 확정)가 한 줄로 남는다. 결정론 코어는 끝까지 코드였고, LLM은 40분짜리 헤맴을 가설 다섯 줄로 바꿔 끼웠을 뿐이다. 코어 안으로는 한 발도 들이지 않았다.
위 워크드 트랜스크립트는 사실 다섯 자리 중 셋(이상 탐지·변경 탐색·이상 해석)을 한꺼번에 밟은 것이다. 다섯 자리를 사이클로 펼치면 이렇게 돈다.
flowchart TD
A[시나리오 정의] -->|위치 1: 시나리오 자동 생성| B[수치 입력]
B -->|위치 2: 변경 후보 탐색| C{결정론 시뮬레이션
simulate_dps}
C --> D[raw 결과 JSON]
D -->|find_outliers z-score| E[이상 패턴 탐지]
E -->|위치 4: LLM 가설 3~5| F[가설 + 검증 시뮬]
F -->|위치 3: 자연어 보고서| G[밸런서 검토]
G -->|위치 5: 다음 행동 제안| H{채택 / 기각}
H -->|채택| B
H -->|기각| A
style C fill:#dbeafe,stroke:#2563eb,stroke-width:2px
style E fill:#dbeafe,stroke:#2563eb
파란 두 노드(시뮬레이션, z-score 탐지)만 결정론이다. 나머지 화살표 위의 라벨 — 위치 1·2·3·4·5 — 이 AI가 붙는 자리다. 사이클이 한 바퀴 돌 때마다 채택된 변경이 다시 수치 입력으로 들어가 다음 시뮬을 돈다. 이 루프를 사람이 손으로 돌리면 한 바퀴에 하루가, AI 보조로 돌리면 몇 시간이 걸린다.
다섯 자리를 하나씩 짧게 짚는다.
위치 1 — 시나리오 자동 생성. "3:3 점령전, 깃발 3개를 1분 점령하면 승리, 부활 10초"라는 컨셉 한 줄과 기존 시나리오 yaml 한두 개를 주면, LLM이 같은 스키마로 새 시나리오 yaml을 채운다. 밸런서는 "컨셉에 없던 룰을 멋대로 넣지 않았는지"만 검수한다. 백지에서 yaml을 쓰던 1~2시간이 15분 검수로 줄어든다.
위치 2 — 변경 후보 탐색. 위 워크드 트랜스크립트의 candidates 딕셔너리가 바로 이것이다. "탱커 생존을 +49% 올리려면 어디를 건드리나"에 LLM이 후보 다섯 개를 던지고(base_def +50, def_const 조정 등), 그 후보를 전부 시뮬에 걸어 부작용 가장 작은 걸 고른다. 후보는 가설, 채택은 시뮬. 가장 신중하게 다뤄야 하는 자리다 — 잘못된 후보가 검증 시간을 잡아먹기 때문에.
위치 3 — 자연어 보고서. 시뮬 raw JSON에서 지표를 스크립트로 뽑고(결정론), 그 지표 + 변경 컨텍스트만 LLM에 넘겨 "회의에 가져갈 1페이지"를 쓰게 한다. 핵심 변화 3~5줄, 영향받은 캐릭터 TOP 5, 후속 조치 2~3개. 제공한 지표 외 수치는 쓰지 못하게 못박는다. raw 정리 30분이 검수 5분이 된다.
위치 4 — 이상 패턴 해석. 위 2~3단계가 이것이다. z-score가 고른 outlier에 LLM이 가설 3~5개를 단다. 확정 진단 금지가 이 자리의 생명선이다.
위치 5 — 다음 행동 제안. 분석이 끝나면 "이번 빌드 즉시 조치 / 1주 모니터링 / 1주 후 재검토 후보"를 우선순위와 함께 체크리스트로 만든다. 밸런서가 결정 누락을 막는 안전망이지, 결정 자체를 대신하지 않는다.
다섯 자리를 한꺼번에 켜는 게 가장 흔한 실패다. 효과가 크고 위험이 작은 출구 쪽부터 켠다.
원 안의 동그라미 숫자(①~⑤)가 도입 순서다. 위치 3(보고서)과 위치 4(이상 해석)가 오른쪽 위 — ROI(Return on Investment, 투자 대비 효과) 높고 위험 낮은 자리 — 라 먼저 켠다. 이 둘만 가동해도 처리량이 2~3배 늘고, 도입 효과의 70% 이상이 여기서 회수된다. 위치 2(변경 제안)는 오른쪽 아래 빨간 자리, 잘못된 후보가 검증 시간을 잡아먹을 수 있어 가장 신중하게 마지막에 켠다. 모든 팀이 다섯을 다 켤 필요도 없다 — 위치 3·4만으로도 1인 밸런서의 하루가 달라진다.
도입 기간의 현실적 감각은 이렇다(저자 추정, 미검증 — 팀 규모·도구 성숙도에 따라 크게 갈림). 위치 3은 1~2주, 위치 4를 더해 2주, 위치 1을 더해 한 달, 위치 5를 더해 2주, 위치 2는 가장 마지막에 1~2개월. 한 번에 다 켜지 말라는 말의 다른 표현이다.
저자의 프로젝트 A에서 다섯 자리를 6개월에 걸쳐 켠 뒤의 변화는 다음과 같다. 절대 수치는 저자 추정(미검증)이며 방향과 비율만 신뢰할 것 — 환경에 따라 배수는 크게 달라진다.
| 항목 | 도입 전 | 도입 후 (방향) |
|---|---|---|
| 밸런서 1인 주간 시뮬 사이클 | 5~7건 | 25~35건 (약 5배) |
| 보고서 작성 (건당) | 30~40분 | 5분 검수 |
| 시나리오 작성 (건당) | 1~2시간 | 15분 검수 |
| outlier 발견 → 진단 | 1~2일 | 4~6시간 |
| 측정 결과 → 다음 변경 결정 | 2~3일 | 1일 |
여기서 중요한 건 배수가 아니라 시간이 어디로 옮겨갔는가다. 사람의 시간이 raw 데이터 정리에서 의사결정으로 이동했다. 밸런서 수가 줄어든 게 아니라, 한 사람이 다룰 수 있는 게임 영역이 넓어졌다. 처리량 5배를 인력 감축으로 읽으면 도입의 의미가 엉뚱한 방향으로 흐른다.
비용은 작다. 프롬프트 캐싱을 적용하면 다섯 자리 전체의 월 LLM 비용은 대략 $75 안팎(저자 추정)이고, 밸런서 1인 인건비의 1/100을 넘지 않는다. 그래서 도입의 진짜 결정 변수는 LLM 비용이 아니라 검수 부담이다. AI가 던진 가설과 보고서를 사람이 읽고 거를 시간이 확보되는가 — 그게 켜고 끄는 기준이다.
마지막으로, 18년간 같은 자리에서 반복된 함정 몇 가지를 처방과 함께 남긴다.
run_candidates로 검증한 뒤에만 채택한다.밸런스에서 AI의 자리는 명확하다. 결정론 코어 바깥, 사람이 헤매던 다섯 자리. 코어는 끝까지 코드로 지키고, 그 주변의 손노동만 덜어내는 것 — 이것이 18년 된 시뮬레이터가 AI 시대에도 살아남는 방식이다.
simulate_dps와 find_outliers 두 함수만. 시드 고정으로 재현성을 확보하세요.run_candidates로 50시드 병렬 시뮬 → 평균 근처로 복귀시키는 후보가 원인입니다. 사람이 채택하고 근거 한 줄을 남기세요.지금까지 이 부의 네 챕터는 한 적과 싸웠다. 보스 하나를 몇 초 만에 잡는가, 탱커가 89% 생존하는가, 골드가 새는가. 전부 단일 대상을 향한 데미지·생존·수지의 이야기였다. 그런데 PvP에서는 적이 사람이다. 사람은 보스처럼 정해진 패턴으로 움직이지 않고, 같은 직업이라도 손이 다르고, 무엇보다 서로의 약점을 노린다. PvE 밸런스가 깊어도 PvP가 통째로 비는 경우가 흔한 건 이 때문이다. 단일 대상 DPS 곡선은 8.1~8.4가 끝까지 다뤘지만, "가위가 보를 이긴다"는 상성의 그물은 아직 한 번도 안 그렸다.
이 장은 그 빈자리를 메운다. 다룰 것은 셋이다 — 클래스·조합 간 상성을 담는 승률 매트릭스, 누구를 누구와 붙일지 정하는 매치메이킹/MMR, 그리고 그 모든 수치를 거짓으로 만들 수 있는 서버 권위·안티치트. 그리고 이 부 전체를 관통한 경계는 여기서도 그대로다. 전투 공식은 결정론, 매칭과 상성 탐지는 AI 보조. 한 발도 어긋나지 않는다.
PvE에서 캐릭터의 강함은 절대값이다. 검사의 DPS가 800이면 800이고, 보스는 그 800을 그냥 맞는다. 그런데 PvP에서 강함은 상대적이다. 검사의 800이 궁수에게는 충분하지만, 자신에게 받는 피해를 30% 줄이는 방패병에게는 560으로 깎여 모자랄 수 있다. 같은 캐릭터의 강함이 상대가 누구냐에 따라 달라진다. 이 한 가지가 PvP 밸런스를 PvE와 근본적으로 다른 문제로 만든다.
그래서 PvP 밸런스의 단위는 한 캐릭터의 숫자가 아니라 한 쌍의 관계다. "검사 vs 궁수"의 승률, "검사 vs 방패병"의 승률이 각각 따로 존재하고, 이 관계들을 전부 모으면 표 하나가 된다. 가로축에도 세로축에도 같은 클래스 목록이 들어가고, 칸마다 "행이 열을 이길 확률"이 적힌다. 이게 승률 매트릭스다. PvE에 DPS 곡선이 있다면, PvP에는 이 매트릭스가 있다.
오른쪽 표를 읽는 법은 단순하다. "검사 vs 방패병" 칸이 0.42라면, 검사가 방패병을 이길 확률이 42%, 곧 방패병이 우세한 상성이다. 모든 칸이 0.50에 가까우면 완벽한 균형이지만, 그런 게임은 재미가 없다. 가위바위보처럼 순환하는 상성이 있어야 직업 선택에 의미가 생긴다. 문제는 그 순환이 어디선가 끊겨 한 클래스가 모두를 이기는 칸이 생길 때다. 새벽 두 시의 탱커가 PvE의 사고였다면, "방패병 vs 전 직업 승률 60% 초과"는 PvP의 사고다.
여기서 미리 못박아 둘 것이 있다. 이 칸들을 채우는 숫자(0.58, 0.42 등)는 전부 예시이며 실측이 아니다. 게임마다 직업 수도, 스킬도, 목표 균형선도 다르다. 이 장에서 신뢰할 것은 숫자가 아니라 매트릭스를 어떻게 채우고, 어떻게 점검하고, 그 점검의 어디에 AI가 붙는가라는 구조다.
승률 매트릭스의 한 칸을 채우는 일은 8.4에서 본 그 결정론 시뮬과 정확히 같은 도구다. "검사 vs 궁수"를 1,000판 자동 시뮬해서 검사가 몇 판 이겼는지 세면, 그게 그 칸의 승률이다. 직업이 N개라면 칸은 N×N개, 각 칸을 1,000판씩 돌리면 표 하나가 채워진다. 이 시뮬은 끝까지 코드다 — 같은 시드를 주면 같은 매트릭스가 토씨 하나 안 틀리고 재현돼야 한다. 그래야 "이번 빌드에서 방패병이 세졌다"라는 말이 거짓이 아니다.
여기서 PvP만의 함정이 하나 있다. PvE 시뮬에서 적(보스)은 고정 패턴이지만, PvP 시뮬에서 상대도 행동을 골라야 한다. 검사가 어떻게 싸우는지를 정하는 봇(bot policy)이 양쪽에 다 필요하다. 그리고 이 봇이 멍청하면 매트릭스 전체가 거짓이 된다 — 컨트롤이 끔찍한 봇끼리 붙이면 "스킬을 아무 때나 쓰는 직업"이 이기는 매트릭스가 나오는데, 실제 숙련 유저의 손에서는 정반대일 수 있다. 그래서 PvP 매트릭스에는 항상 "이 봇이 어느 수준의 플레이를 흉내 내는가"라는 단서가 붙어야 한다. 봇은 휴리스틱(쿨다운 되면 쓴다, HP 30% 미만이면 후퇴 등)으로 짜는 게 보통이고, 이 휴리스틱 자체는 결정론이다.
봇 정책의 골자를 실행 가능한 모양으로 옮기면 이렇다 — 입력이 같으면 같은 행동을 고르는, 환각이 끼어들 여지가 없는 함수다.
def bot_decide(me, enemy, cooldowns, t):
"""결정론 봇 정책. 같은 (상태)면 같은 행동. LLM이 만들지 않는다."""
# 1) 생존 우선: HP 30% 미만이면 회피/후퇴
if me.hp_ratio < 0.30 and cooldowns["escape"] <= 0:
return Action("escape")
# 2) 상성 스킬: 적이 디버프 면역이 아니면 표식 우선
if cooldowns["mark"] <= 0 and not enemy.has("debuff_immune"):
return Action("mark", target=enemy)
# 3) 사거리 관리: 근접 적이 붙으면 거리 벌리기 (원거리 직업)
if me.is_ranged and dist(me, enemy) < me.kite_range:
return Action("reposition")
# 4) 그 외: 쿨다운 된 최대 데미지 스킬
return best_ready_damage_skill(me, cooldowns)
def simulate_pvp_match(class_a, class_b, formula, seed=0):
"""1:1 한 판을 결정론적으로 시뮬. 데미지는 8.1의 공식 그대로 사용."""
rng = Rng(seed)
a, b = spawn(class_a), spawn(class_b)
for t in range(MAX_TICKS):
for me, foe in ((a, b), (b, a)):
act = bot_decide(me, foe, me.cooldowns, t)
apply_action(act, me, foe, formula, rng) # formula = 결정론 데미지 공식
if a.hp <= 0 or b.hp <= 0:
break
return {"winner": "a" if b.hp <= 0 else "b" if a.hp <= 0 else "draw",
"duration": t * TICK}
매트릭스 한 장을 통째로 채우는 건 이 함수를 칸마다 1,000번 돌리는 바깥 루프다.
def build_winrate_matrix(classes, formula, n=1000):
matrix = {}
for ca in classes:
for cb in classes:
if ca == cb:
continue
wins = sum(
simulate_pvp_match(ca, cb, formula, seed=s)["winner"] == "a"
for s in range(n)
)
matrix[(ca, cb)] = wins / n # ca가 cb를 이긴 비율
return matrix
여기까지가 코어, 끝까지 코드다. AI는 이 표를 만드는 데가 아니라 읽는 데 붙는다. N이 8이면 칸은 56개, 사람이 56개 승률을 눈으로 훑으며 "어디가 깨졌나"를 찾는 건 새벽 두 시의 4메가 JSON과 같은 노동이다. 이상한 칸을 고르는 건 8.4의 z-score 탐지가 그대로 한다.
def find_broken_cells(matrix, low=0.40, high=0.60):
"""균형선(0.5) 밖으로 크게 벗어난 칸을 결정론적으로 추린다."""
broken = []
for (ca, cb), wr in matrix.items():
if wr > high or wr < low:
broken.append((ca, cb, round(wr, 2)))
return sorted(broken, key=lambda x: abs(x[2] - 0.5), reverse=True)
탐지가 칸을 좁히면, 그 칸을 LLM에게 넘긴다. 단, 8.4와 같은 규율이다 — 확정 진단 금지, 가설과 검증 시뮬만. 예를 들어 "방패병 vs 법사 0.68(z 가장 큼)" 한 줄을 주고 이렇게 요청한다.
[깨진 칸]
방패병 → 법사 승률 0.68 (균형선 0.50, 매트릭스 내 z 가장 큼)
부수: 이 매치의 평균 지속시간 38s (전체 평균 22s)
[관련 정보]
- 방패병: 받는 피해 -30% 패시브 "철벽", 침묵 스킬 "방패 강타"(2초)
- 법사: 전 데미지의 70%가 시전 1.5초짜리 스킬에 집중
- 두 직업의 매치 빈도는 실측 큐에서 상위 (인기 조합)
요청: 이 상성 붕괴의 가능 원인 가설 3~5개 + 각 검증 시뮬 1줄.
확정 진단 금지. "~일 수 있다" 수준으로만.
LLM은 "철벽 -30%와 침묵 2초가 겹쳐, 법사가 핵심 시전 스킬을 한 번도 못 넣고 죽는 양의 피드백일 수 있다 / 검증: 침묵 지속을 1초로 줄여 같은 칸 재시뮬"처럼 탐색 공간을 좁히는 가설을 던질 뿐이다. 무엇이 진짜인지는 다시 build_winrate_matrix를 후보별로 돌려서 정한다. 매치 지속시간이 평균의 1.7배라는 단서까지 LLM이 가설에 엮어 주는 것 — 사람이 56칸을 훑다가는 놓치기 쉬운 그 연결이, 이 자리에서 AI가 버는 시간이다.
승률 매트릭스를 완벽하게 맞춰도 유저가 "졌다"고 느끼는 진짜 원인은 따로 있다. 누구와 붙느냐다. 실력 1500인 유저가 2200인 유저를 만나면, 직업 상성이 5:5라도 결과는 정해져 있다. 그래서 매치메이킹은 단순한 서버 기능이 아니라 밸런스의 일부다. 매트릭스가 직업 간 공정성을 맡는다면, 매치메이킹은 실력 간 공정성을 맡는다.
대부분의 경쟁 게임은 MMR(Matchmaking Rating, 매치메이킹 점수)을 둔다. 이기면 오르고 지면 내리는 숨은 점수로, 비슷한 점수끼리 붙인다. 점수 갱신은 결정론 공식이다 — Elo가 가장 널리 쓰이고, 공개 표준이라 이 책에서 인용해도 되는 몇 안 되는 수식 중 하나다.
# Elo: 공개 표준 갱신식 (지어낸 값 아님)
expected_a = 1 / (1 + 10 ** ((rating_b - rating_a) / 400))
new_rating_a = rating_a + K * (score_a - expected_a)
# score_a: 이기면 1, 지면 0
# K: 갱신 강도 상수 (게임이 정하는 값. 보통 16~40 범위에서 선택)
# 400, 10: Elo 정의에 박힌 상수
이 식 자체는 결정론이고, AI가 들어갈 자리가 아니다. 그런데 매치메이킹에는 결정론 공식만으로 안 풀리는 긴장이 하나 있다. 공정성 ↔ 대기시간의 맞교환이다. 점수가 정확히 같은 상대만 붙이면 매치는 공정하지만, 그런 상대가 큐에 없으면 유저는 10분을 기다린다. 점수 차를 너그럽게 허용하면 빨리 잡히지만 매치가 불공정해진다. 새벽 시간대, 비인기 직업, 고점수 구간일수록 이 긴장이 심해진다.
flowchart LR
A["매칭 요청
유저 MMR 1500"] --> B{"큐에 ±50 상대 있나?"}
B -->|있음| C["즉시 매칭
가장 공정"]
B -->|없음| D["대기시간에 따라
허용 폭 확대 ±50→±200"]
D --> E{"매칭 성사?"}
E -->|성사| F["매칭
공정성 ↕ 대기 ↕ 균형"]
E -->|타임아웃| G["봇 투입 / 큐 유지
정책 결정"]
C --> H["Elo 갱신 (결정론)"]
F --> H
style H fill:#dbeafe,stroke:#2563eb,stroke-width:2px
style D fill:#ffedd5,stroke:#ea580c
style G fill:#ffedd5,stroke:#ea580c
파란 노드(Elo 갱신)만 결정론이다. 주황 노드 — 허용 폭을 언제 얼마나 넓힐지, 타임아웃에 뭘 할지 — 가 AI 보조가 닿는 자리다. 단, 여기서도 AI가 실시간 매칭 결정을 내리는 게 아니다. 그건 빠르고 재현 가능해야 하는 서버 로직이라 규칙 기반 코드의 자리다. AI가 붙는 건 그 규칙을 튜닝하기 위한 분석이다. "지난주 매칭 로그에서 어느 점수대·시간대·직업의 매치 품질(승률 편차·대기시간)이 나빴는가"를 요약하고, "허용 폭 곡선을 어떻게 바꾸면 어느 구간의 대기시간이 줄어드는지" 후보를 제안하는 일. 8.4의 위치 3(보고서)·위치 4(이상 해석)·위치 2(변경 후보 탐색)가 매칭 로그로 무대만 옮긴 것이다.
매치메이킹이 승률 매트릭스와 얽히는 지점도 짚어 둔다. 매칭 알고리즘이 직업을 고려하지 않고 점수만 맞추면, 깨진 상성 칸이 그대로 노출된다. 방패병이 법사를 68% 이기는 칸이 살아 있는데 매칭이 둘을 자주 붙이면, 법사 유저의 체감 패배가 매트릭스 수치보다 더 크게 쌓인다. 그래서 매트릭스 점검과 매칭 로그 분석은 따로 도는 게 아니라, 같은 사이클의 입구와 출구다 — 매트릭스에서 깨진 칸을 고치고, 매칭 로그에서 그 칸이 실제로 얼마나 붙었는지를 확인한다.
지금까지의 모든 이야기 — 매트릭스, MMR, 시뮬 — 는 한 가지를 암묵적으로 깔고 있었다. 유저가 보고하는 결과가 진실이라는 것. PvE에서는 이게 거의 문제가 안 된다. 혼자 보스를 잡는데 누구를 속이겠는가. 그런데 PvP에서는 상대가 있고, 이기면 점수가 오르고, 그래서 속일 동기가 생긴다. 데미지를 조작하고, 위치를 조작하고, 쿨다운을 무시하는 클라이언트가 나타나는 순간, 8.1의 결정론 공식은 종이 위에서만 결정론이다. 실제 서버에서는 누군가의 검사가 공식보다 두 배 센 데미지를 넣고 있다.
그래서 경쟁 게임의 첫 번째 밸런스 규칙은 매트릭스보다 앞선다. 결과를 클라이언트가 정하게 하지 마라. 데미지 계산, 쿨다운 판정, 적중 판정 — 밸런스에 닿는 모든 연산은 서버가 권위를 가진다. 클라이언트는 입력(어디로 이동, 어떤 스킬)만 보내고, 그 입력이 공식에 맞는지, 쿨다운이 돌았는지, 사거리 안인지는 전부 서버가 다시 검증한다. 클라이언트가 보낸 "데미지 999"는 서버가 무시하고, 서버가 공식으로 계산한 값만 적용한다.
서버 권위가 무너지면 밸런스 작업 전체가 거짓이 된다. 승률 매트릭스를 아무리 정교하게 맞춰도, 라이브에서 한 직업이 데미지를 조작하면 그 매트릭스는 종이 위의 약속일 뿐이다. 그래서 안티치트는 별개의 보안 업무가 아니라 밸런스 데이터의 신뢰성 문제다. 라이브 승률이 시뮬 매트릭스와 크게 어긋날 때, 첫 번째로 의심할 것은 "공식이 틀렸나"가 아니라 "이 데이터가 깨끗한가"여야 한다.
여기서 AI의 자리가 다시 명확해진다. 치트 판정 자체 — "이 입력을 무효로 한다" — 는 결정론 규칙의 일이다. 0.1초 만에 30미터를 이동한 입력은 물리적으로 불가능하니 규칙으로 막는다. 같은 입력에 같은 판정이 나와야 하고, 억울한 정지를 만들면 안 되니 여기에 확률적 LLM을 둘 수 없다. 반면 이상 패턴을 후보로 추리는 일은 AI 보조가 닿는다. 서버 로그에서 "이 계정의 적중률 분포가 인간 분포에서 z 몇 만큼 벗어났다", "이 계정군이 동일한 비정상 패턴을 공유한다" 같은 후보를 모아 사람 검토에 올린다. 8.1의 표를 PvP로 옮기면 경계는 이렇다.
| 영역 | AI | 이유 |
|---|---|---|
| 서버 데미지·적중·쿨다운 판정 | 절대 금지 | 결정론 코어. 같은 입력=같은 판정이 깨지면 공정성 붕괴 |
| Elo/MMR 점수 갱신 | 절대 금지 | 공개 표준 결정론 식. 흔들리면 순위가 거짓이 됨 |
| 치트 차단(밴) 판정 자체 | 절대 금지 | 억울한 정지 불가. 같은 증거=같은 판정 |
| 승률 매트릭스 시뮬 | 절대 금지 | 재현 불가 시 "직업이 세졌다"가 거짓이 됨 |
| 깨진 상성 칸 탐지·해석 | 가능 | z-score로 칸 추리고 LLM이 가설(확정 진단 금지) |
| 매칭 로그 품질 분석·튜닝 후보 | 가능 | 대기/공정 맞교환의 변경 후보 제안 (시뮬 검증) |
| 치트 의심 패턴 후보 추출 | 가능 | 사람 검토에 올릴 후보만. 밴 결정은 사람·규칙 |
선은 8.1과 토씨 하나 다르지 않다. AI는 결정론 코어의 바깥에만 산다. 집행하는 안쪽 — 데미지, 점수, 밴 — 은 룰북이고, 탐지하고 해석하고 후보를 미는 바깥쪽이 AI의 자리다.
세 주제 — 매트릭스, 매칭, 서버 권위 — 는 따로 도는 세 개의 일이 아니다. 하나의 경쟁 밸런스 사이클의 세 구간이다. 서버 권위가 깨끗한 데이터를 보장하고, 그 데이터로 매트릭스를 점검하고, 매칭 로그로 점검 결과가 실제 큐에서 어떻게 체감되는지를 확인하고, 다시 시뮬로 후보를 검증해 빌드에 반영한다.
flowchart TD
A["서버 권위 + 안티치트
깨끗한 라이브 데이터"] --> B["라이브 승률 매트릭스
(실측)"]
B --> C{"시뮬 매트릭스와
크게 어긋나나?"}
C -->|"어긋남 → 데이터 의심"| A
C -->|"일치 → 밸런스 문제"| D["깨진 칸 탐지 z-score"]
D -->|"LLM 가설 3~5"| E["변경 후보 + 검증 시뮬"]
E --> F["build_winrate_matrix
후보별 재시뮬 (결정론)"]
F --> G["밸런서 채택/기각"]
G --> H["빌드 반영 (비가역)"]
H --> I["매칭 로그 분석
체감 검증 + 튜닝 후보"]
I --> A
style A fill:#dbeafe,stroke:#2563eb,stroke-width:2px
style F fill:#dbeafe,stroke:#2563eb,stroke-width:2px
style D fill:#ffedd5,stroke:#ea580c
style E fill:#ffedd5,stroke:#ea580c
style I fill:#ffedd5,stroke:#ea580c
파란 노드(서버 권위, 시뮬 재계산)가 결정론, 주황 노드(탐지·가설·매칭 분석)가 AI 보조다. 이 사이클에서 가장 흔한 실패는 C 분기를 건너뛰는 것이다. 라이브 매트릭스가 시뮬과 어긋날 때 곧장 공식부터 손대면, 사실은 치트로 오염된 데이터를 좇아 멀쩡한 직업을 너프하게 된다. 데이터의 청결을 먼저 의심하는 이 한 분기가, 8.1의 "변경 이력"과 같은 역할을 PvP에서 한다 — 빼먹으면 새벽 두 시가 돌아온다.
마지막으로 PvP 밸런스의 18년 함정 몇 가지를 처방과 함께 남긴다.
PvP에서 AI의 자리는 PvE와 같다. 결정론 코어 — 데미지, 점수, 밴, 시뮬 — 바깥의 탐지·해석·후보. 코어는 코드와 서버 권위로 지키고, 사람이 56칸을 훑고 매칭 로그를 헤매던 손노동만 덜어낸다 — 이 부 전체가 한 골격으로 움직인다는 마지막 증거가 이 장이다.
setup. 8.4의 simulate_dps를 1:1로 확장한 simulate_pvp_match와, 양쪽 봇을 굴릴 bot_decide(휴리스틱 결정론)를 만드세요. 시드를 고정해 같은 매트릭스가 재현되는지부터 확인합니다. 데미지는 반드시 8.1의 공식을 그대로 가져다 쓰고, 봇이 흉내 내는 플레이 수준을 한 줄로 기록하세요.
prompt. find_broken_cells로 균형선(0.40~0.60) 밖 칸을 추린 뒤, z가 가장 큰 한 칸만 LLM에 넘깁니다.
첨부한 깨진 칸(방패병 vs 법사 0.68, 매치 지속시간 38s/평균 22s)에 대해
가능 원인 가설 3~5개와 각 검증용 재시뮬 1줄을 제시해 줘.
관련 스킬·패시브 정보는 아래에 첨부. 확정 진단 금지 — "~일 수 있다"로만.
수치를 직접 고치지 말고 후보만 제안할 것.
verify. AI 가설을 그대로 믿지 마세요. 각 가설의 변경 후보를 build_winrate_matrix에 넣어 같은 시드로 재시뮬하고, 그 칸이 0.50 근처로 돌아오면서 다른 칸을 깨뜨리지 않는지를 함께 확인합니다 (PvP 변경은 한 칸을 고치다 옆 칸을 망치기 쉽습니다). 두 조건을 만족하는 후보만 채택하고, 8.1처럼 결정 로그에 사유·기각된 후보·예측값을 남기세요. 빌드 반영 1주 뒤 라이브 실측 승률을 그 로그에 덧붙입니다.
직업이 둘뿐이고 서버도 없는 1인 프로토타입이라도 골격은 같습니다. 매트릭스는 2×2면 충분하고, 시뮬은 8.1의 30줄 루프에 봇 정책 한 줄(쿨다운 되면 최대 데미지 스킬)만 더하면 됩니다. 서버 권위는 "결과를 클라이언트가 못 정하게 한다"는 원칙만 코드 구조로 지키면 되고, 정식 안티치트는 유저가 생기기 전엔 필요 없습니다. MMR도 처음엔 생략하고, 매트릭스가 한쪽으로 60% 넘게 기우는지만 1,000판 돌려 확인하세요. AI는 그 결과를 읽고 "어느 매치가 깨졌고 왜일 수 있는지"를 요약하는 데만 씁니다. 규모와 상관없이 단 하나 지킬 선 — 데미지와 승패는 코드와 서버가 정하고, LLM에는 절대 시키지 않는다.
1차 독자: HUD·UI를 책임지는 UX 기획자 (중규모(10~50인) 팀) 1인/취미 독자용 축소 버전: §9.1.8 「혼자라면 이만큼만」
QA 빌드에서 새 디버프 알림을 HUD에 얹은 날, 디자이너는 "잘 보인다"고 했고 다음 날 사용자 게시판에는 "디버프가 안 보여서 죽었다"가 올라왔다. 알림은 화면 중앙, 회색 배경에 옅은 노란 글씨로 떠 있었다. 디자이너 모니터에서는 보였고, 전투 중 폭발 이펙트가 화면을 덮은 6인치 폰에서는 안 보였다. 문제는 이게 처음이 아니라는 점이었다. 매 빌드, 매 화면, 같은 종류의 사고가 "이번엔 괜찮겠지"로 반복됐다.
이 장은 그 반복을 끊는 한 가지 작업에 집중한다. 완성된 HUD 스크린샷 한 장을 입력으로 받아, P0 요소가 시선이 닿는 영역(상단 상태대·양쪽 하단 액션 코너)을 벗어났는지, 글자 대비가 가독 임계를 넘는지를 자동으로 검출하는 lint 게이트다. 우선순위 표·시선 흐름·플랫폼 분기 같은 HUD 설계의 일반 원칙은 이미 다른 책에 충분하니, 이 장은 그 원칙을 매 빌드 자동으로 강제하는 검수 루프에만 자리를 쓴다. 핵심은 AI가 화면을 보고 "이 글자는 대비 2.0:1이라 WCAG 4.5:1 미달"이라고 좌표와 숫자로 말하게 만드는 것이다. "잘 보이는데요"라는 말싸움을 코드와 표준으로 대체한다.
HUD 검수가 매번 사람마다 다른 결론을 내는 이유는 기준이 "잘 보인다/안 보인다"라는 주관이기 때문이다. 다행히 가독성·접근성의 상당수는 이미 표준 기구가 숫자로 못 박아 두었다. 지어낼 필요가 없다.
| 검수 항목 | 표준 기준 (출처) | 자동 판정 |
|---|---|---|
| 일반 텍스트 명암 대비 | 4.5:1 이상 (WCAG 2.1 SC 1.4.3) | 가능 — 전경·배경 색상값으로 계산 |
| 큰 텍스트(18pt+) 대비 | 3:1 이상 (WCAG 2.1 SC 1.4.3) | 가능 |
| 비텍스트(아이콘·게이지) 대비 | 3:1 이상 (WCAG 2.1 SC 1.4.11) | 가능 |
| 터치 타깃 최소 크기 | 44×44 pt (Apple HIG) / 48×48 dp (Material) | 가능 — 요소 크기로 |
| 엄지 도달 영역 | 가로 그립 양손 조작 시 좌·우 하단 코너가 '쉬움' (좌 엄지=이동, 우 엄지=스킬). 업계 통용 thumb-zone 모델 | 부분 — 영역 규칙으로 |
마지막 줄(엄지 도달 영역)만 정량 합격선이 아니라 업계 통용 모델이고, 위 네 줄은 W3C·Apple·Google이 공개한 합격선이다. 명암 대비는 특히 명확하다. WCAG는 두 색의 상대 휘도를 (L1+0.05)/(L2+0.05)로 계산하라고 공식까지 공개해 두었다. 회색(#888) 배경에 옅은 노란(#D4C84A) 글씨의 대비는 이 공식에 넣으면 약 2.0:1이 나온다 — 4.5:1 미달, 즉 표준상 명백한 불합격이다. "디자이너 모니터에선 보였다"는 반박이 통하지 않는 자리다.
여기서 한 가지를 분명히 한다. MMORPG·RPG의 모바일 화면은 가로(landscape)가 표준이다. 이유는 정보량과 조작이다. 같은 인치라도 가로로 쥐면 한 화면에 담기는 상시 정보가 세로보다 많고, 양손 엄지로 좌(이동)·우(스킬)를 동시에 조작할 수 있다. 세로 한 손 그립은 캐주얼 퍼즐·방치형에는 맞지만, 동시 정보가 많고 양손 조작이 필요한 MMORPG에는 맞지 않는다. 그래서 이 장의 모든 시선·배치 판정은 가로 양손 그립을 전제한다. 화면은 위쪽 가로 상태대, 좌우 두 하단 액션 코너, 그 사이 중앙 게임 영역, 그리고 게임 영역 아래 중앙 하단 슬롯대(소비·자동 아이템·퀵슬롯)로 나뉜다.
이 다섯 줄이 이 장에서 AI에게 줄 검수 룰북이다. "디버프가 좀 안 보이는 것 같다"가 아니라 "디버프 텍스트는 대비 2.0:1로 SC 1.4.3 위반"이라고 말할 수 있어야, 사람이 검수하든 AI가 검수하든 같은 판정이 나온다.
플랫폼 기준을 PC와 나란히 두면 검수의 출발점이 분명해진다. 프로젝트 A는 모바일 우선 + PC 보조이므로, 두 기준을 모두 룰북에 둔다.
| 기준 | PC (보조 플랫폼) | 모바일 (우선 플랫폼, 가로) |
|---|---|---|
| 화면·입력 | 27인치+ / 마우스 1px 정밀·호버·단축키 | 6.x인치 가로 / 양손 엄지, 호버 없음 |
| 동시 상시 정보 | 30~50종 감당 | 12~16종이 한계 (저자 추정, 미검증) |
| 시선·조작 도달 | 화면 전역 (커서가 어디든 닿음) | 상단 상태대 + 좌·우 하단 코너 + 중앙 하단 슬롯대만 '쉬움' |
| 정밀도 | 1px 클릭 | 최소 44pt 터치 타깃 (HIG) |
| 핵심 검수 위험 | 정보 과밀로 인한 인지 부하 | 좁은 화면 + 손가락 가림 + 중앙 묻힘 |
PC는 마우스 정밀도·호버 툴팁·큰 화면 덕에 정보를 많이 띄워도 시선과 조작이 닿는다. 모바일은 가로라 세로보다 낫지만 PC만큼은 못 담고, 누르는 요소가 양쪽 엄지 코너에 묶이며 호버가 없어 P0 정보는 상시 노출이어야 한다. 그래서 모바일 HUD 검수의 본질은 "예쁜가"가 아니라 "P0가 시선 닿는 자리(상단·양 코너)에 있고, 글자가 표준 대비를 넘는가"다. 그 판정이 사람마다 흔들리지 않게 표준으로 못 박는 것이 이 장의 일이다.
실제로 어떻게 돌리는지 한 사이클을 끝까지 보여준다. 아래는 저자 프로젝트(모바일 우선 MMORPG, 이하 "프로젝트 A")의 전투 HUD 검수 세션을 충실히 재현한 것이다. 입력 프롬프트는 그대로 복사해 쓸 수 있고, 출력은 실제 세션을 재구성했다.
스크린샷만 던지면 AI가 화면을 "추측"한다. 그래서 빌드가 이미 알고 있는 요소 좌표·색상·분류를 매니페스트로 같이 넣는다. 이건 새로 쓰는 게 아니라 빌드 산출물에서 추출만 하면 된다(추출 방법의 현실은 §9.1.4에서 정직하게 비교한다).
# hud_capture_manifest.yaml — QA 빌드 스크린샷에 동봉
screen: { w_pt: 844, h_pt: 390 } # 6.x인치 가로, pt 단위 (가로 그립)
elements:
- id: hp_bar # 체력바
class: P0
rect_pt: [12, 18, 150, 16] # x, y, w, h — 상단 좌측
fg: "#FF5A5A" ; bg: "#1A1A1A"
- id: skill_slot_1 # 스킬 슬롯 (우엄지)
class: P0
rect_pt: [760, 300, 40, 40] # ← 우하단 코너, 크기 주목
fg: "#FFFFFF" ; bg: "#202830"
- id: debuff_alert # 디버프 알림 (어제 추가)
class: P0
rect_pt: [400, 180, 70, 24] # ← 화면 중앙, 위치 주목
fg: "#D4C84A" ; bg: "#888888" # ← 대비 주목
- id: minimap
class: P1
rect_pt: [744, 20, 80, 80] # 우상단
fg: "#A0C0FF" ; bg: "#101820"
첨부 스크린샷은 프로젝트 A 전투 HUD고(가로 그립 양손), yaml은 그 화면 요소별 좌표·색·분류야. 둘을 대조해서 검수해줘.
대비는 fg/bg로 WCAG 계산해서 수치까지 적어줘 — 텍스트 4.5:1, 아이콘·큰글자 3:1 미달이면 FAIL.
P0가 상단 상태대나 좌·우 하단 코너를 벗어나 화면 중앙에 떠 있으면 WARN(중앙은 전투 이펙트에 묻혀).
조작 요소가 44pt 미만이거나, 엄지 코너·중앙 하단 슬롯대를 벗어나면 FAIL.
매니페스트에 없는데 화면에 보이는 게 있으면 따로 신고하고, 확신 못 하는 건 '애매'로 빼서 나한테 넘겨.
표로만 줘(요소|검사|측정값|판정|비고), 설명 빼고.
// (의도: P0=0.5초 안에 찾아야 하는 정보 / 소비·자동·퀵슬롯은 중앙 하단이 정상)
검수 규칙보다 그 규칙을 어떻게 출력할지에 더 많은 줄이 들어갔다는 점이 중요하다. AI에게 자유를 주면 "전반적으로 깔끔하네요" 같은 칭찬을 토해낸다. 그래서 계산식(WCAG 대비), 합격선(4.5:1·44pt), 출력 형식(표), 측정값 표기 의무, 그리고 "확신 못 하면 사람에게 넘겨라"는 탈출구를 미리 못 박는다. 이 다섯 개의 못이 칭찬을 측정값으로 바꾼다.
| 요소 | 검사 | 측정값 | 판정 | 비고 |
|-------------|------|--------------------------|------|----------------------------------|
| hp_bar | 대비 | #FF5A5A/#1A1A1A = 5.7:1 | PASS | 비텍스트 게이지, 3:1 충족 |
| hp_bar | 배치 | 상단 좌측 (12,18) | PASS | P0 상단 상태대 |
| skill_slot_1| 터치 | 40×40 pt, 우하단 코너 | FAIL | HIG 44pt 미달, 4pt 부족 (코너는 OK)|
| skill_slot_1| 배치 | 우하단 (760,300) | PASS | 우엄지 도달 코너 |
| debuff_alert| 대비 | #D4C84A/#888888 = 2.0:1 | FAIL | 일반 텍스트 4.5:1 미달 (SC 1.4.3) |
| debuff_alert| 배치 | 화면 중앙부 (400,180) | WARN | P0인데 중앙 — 전투 이펙트에 묻힘 |
| minimap | 대비 | #A0C0FF/#101820 = 9.8:1 | PASS | |
| minimap | 배치 | 우상단 (744,20) | PASS | P1, 상단 상태대 우측 허용 |
추가 신고:
- 매니페스트엔 4종이나 스크린샷 좌하단에 노란 점멸 아이콘이 하나 더
보인다(좌표 추정 ~70,330). 매니페스트 누락 의심. (애매 — 화면만으론
무슨 요소인지 단정 못 함)
요약: FAIL 2건(skill_slot_1 터치, debuff_alert 대비), WARN 1건(debuff_alert
배치), 애매 1건(미등록 아이콘).
출력에서 가장 가치 있는 부분은 합격/불합격 표가 아니라 맨 아래 "추가 신고"와 "애매"다. AI가 매니페스트에 없는 점멸 아이콘을 화면에서 잡아내고, 그게 뭔지는 자기가 단정 못 한다고 사람에게 넘긴 자리. 좋은 프롬프트는 AI가 "이건 제가 모릅니다"라고 말할 수 있게 만든다.
이 출력을 그대로 받으면 안 된다. AI의 검수 자체를 사람이 한 번 검수한다. 실제로 이 세션에서 한 건이 사람 손에 뒤집혔다.
debuff_alert의 대비 FAIL과 배치 WARN은 옳다. 회색 배경 옅은 노랑은 §9.1.1에서 본 그대로 표준 위반이고, P0 알림을 가로 화면 중앙에 둔 것도 전투 이펙트에 묻히는 전형적 실수다. 여기까진 AI가 맞았다.
문제는 skill_slot_1의 터치 FAIL이다. AI는 매니페스트의 40×40 pt를 그대로 믿고 "44pt 미달"이라 판정했는데, 실제 빌드에서 이 슬롯은 시각적으로 40pt지만 터치 히트박스가 사방 6pt 확장되어 실제 탭 영역은 52pt다. 매니페스트 rect_pt는 그려지는 사각형만 담고 히트박스를 담지 않았다 — 즉 입력 데이터의 결함이지 AI의 오판이 아니다. AI는 준 데이터 안에서 정확히 판정했고(코너 위치 판정은 옳았다), 사람은 코드가 모르는 빌드 사정(히트박스 확장)을 알고 있었다. 이 FAIL은 사람이 기각한다.
그래서 두 가지를 동시에 한다. 매니페스트 추출 스크립트가 히트박스도 뽑도록 고치고(데이터 결함 수정), AI에게 재요청한다.
skill_slot_1은 시각 크기는 40pt지만 히트박스가 사방 6pt 확장돼서 실제 탭 영역은 52pt야(매니페스트에 hit_rect 추가함). 이 기준으로 터치 다시 봐줘.
debuff_alert FAIL/WARN은 그대로 두고, 대비 4.5:1 넘기는 색 조합 3개 제안해줘(노란 계열 유지, 배경 어둡게). 중앙에서 상단 상태대 우측으로 옮길 좌표도 하나 줘.
AI는 skill_slot_1을 히트박스 52pt 기준 PASS로 정정하고, 디버프 대비를 위해 배경을 #2A2A00으로 어둡게 깔아 7.8:1을 만드는 색 조합 3안과, 알림을 상단 상태대 우측(약 600,18)으로 옮기는 좌표를 돌려줬다. 한 번의 왕복으로 끝난다. 빌드마다 화면을 눈으로 훑으면 같은 사고가 반복되지만, 스크린샷+매니페스트를 lint에 걸면 대비·배치·터치 위반이 숫자로 떨어지고 사람은 코드가 모르는 예외(히트박스)와 애매(미등록 아이콘)만 판정한다(검수 1화면이 손으로는 십수 분, 이 루프로는 수 분 — 저자 추정, 미검증 가설. 절대 시간보다 "눈으로 훑기"와 "표준으로 측정"의 구조 차이로 읽는 게 맞다).
위 세션에서 debuff_alert가 WARN을 받은 이유, 그리고 P0 정보를 어디에 둬야 하는지를 그림 한 장으로 남겨 두면 이후 모든 배치 판정이 빨라진다. 가로로 쥔 폰에서 화면은 네 자리로 나뉜다. 위쪽 가로 상태대(시선이 먼저 닿고 손가락은 안 가는 읽기 전용), 좌·우 두 하단 코너(양손 엄지가 닿는 조작 자리 — 좌엄지=이동, 우엄지=스킬), 그 사이 중앙 게임 영역(전투가 벌어지는 자리), 그리고 게임 영역 아래 중앙 하단 슬롯대(소비·자동 아이템과 퀵슬롯·스킬 슬롯을 두는 자리)다. 아래에서 초록·앰버가 P0와 슬롯이 안전한 영역, 빨강이 P0 알림이 묻히는 게임 중앙이다.
규칙이 단순하다. P0 정보(HP·MP·핵심 알림)는 초록(상단 가로 상태대 또는 양쪽 하단 코너) 안에 둔다. 시선이 먼저 닿거나 엄지가 늘 머무는 길목이기 때문이다. 반대로 게임 중앙(빨강)은 전투 자체가 벌어지는 자리라, 여기에 P0 알림을 두면 이펙트가 화면을 덮는 순간 정보가 묻힌다. 한 가지 주의 — 게임 중앙과 중앙 하단은 다르다. 게임 중앙은 위험하지만, 그 아래 중앙 하단 슬롯대(앰버)는 소비·자동 아이템과 퀵슬롯·스킬 슬롯이 사는 자리다. 내가 쓰거나 자동으로 소비되는 것을 한눈에 보려고 양 엄지 사이에 둔다. 그리고 읽기만 하는 정보(HP/MP/타깃 체력)는 상단에, 누르는 요소(이동·스킬)는 양쪽 하단 코너에, 소비·슬롯은 중앙 하단에 — 이 셋이 손가락·시선 영역이다. §9.1.2에서 디버프 알림이 WARN을 받은 이유가 이 그림 한 장으로 설명된다 — 0.5초 안에 봐야 하는 P0를, 하필 가장 안 보이는 게임 중앙에 뒀기 때문이다. 정정안에서 상단 상태대 우측으로 옮긴 것은 정확히 이 그림의 초록으로 돌려보낸 것이다.
이 장의 lint는 "요소별 좌표·색상"이 깨끗이 들어온다는 전제 위에 선다. 그런데 그 좌표를 어디서 어떻게 뽑느냐가 실제로는 가장 현실적인 갈림길이다. 책이 흔히 얼버무리는 자리라, 세 경로를 정직하게 비교한다. 정답은 하나가 아니고, 팀 상황에 따라 갈린다.
| 경로 | 무엇을 하나 | 강점 | 약점 / 현실 |
|---|---|---|---|
| ① 게임 내 telemetry 로그 | UI 프레임워크가 그리는 위젯의 좌표·크기·색을 빌드가 직접 덤프 | 좌표가 정확(추정 아님), 히트박스·앵커까지 나옴 | UI 코드에 덤프 훅을 심어야 함. 프로그래머 협업 필요. 한 번 깔면 가장 신뢰 가능 |
| ② 기성 vision API | 스크린샷을 OCR·객체검출 API에 넣어 텍스트·박스 좌표 추출 | 빌드 수정 불필요, 외부 스크린샷도 가능 | 좌표가 근사값, 게이지·아이콘 같은 비텍스트는 분류가 약함. 외부 전송 = 미공개 빌드 유출 리스크 |
| ③ 직접 구현(픽셀 분석) | 스크린샷을 직접 읽어 색 경계·박스를 휴리스틱으로 추출 | 의존성 최소, 색 대비 계산엔 충분 | 요소 의미(이게 P0인지)를 모름. 매니페스트와 대조해야만 쓸모. 유지보수 부담 |
세 경로의 관계가 이 장의 워크드 트랜스크립트를 그대로 설명한다. §9.1.2에서 대비 검사가 정확했던 건 색상값(fg/bg)이 ①·③로 정확히 들어왔기 때문이고, 터치 FAIL이 사람 손에 뒤집힌 건 히트박스가 매니페스트에 빠졌기 때문(②·③은 히트박스를 못 본다, ①만 본다)이다. 즉 대비는 픽셀만으로도 잡히지만 터치 히트박스는 ① telemetry 없이는 못 잡는다. 이 한계를 알고 시작해야, AI 검수 결과를 어디까지 믿을지 선이 선다.
저자 프로젝트의 선택은 ① telemetry를 정본으로, AI는 스크린샷+telemetry 매니페스트를 대조하는 검수자로 쓰는 구조다. 화면에만 보이고 매니페스트엔 없는 것(§9.1.2의 미등록 점멸 아이콘)을 AI가 잡고, 매니페스트엔 있고 화면엔 어긋난 것을 사람이 잡는다. 둘 중 하나만으로는 양쪽 사각이 남는다.
flowchart LR
A["QA 빌드
HUD 스크린샷"] --> C
B["telemetry 덤프
좌표·색·히트박스
(경로 ①)"] --> C["매니페스트 + 스크린샷"]
C --> D["AI 검수
대비·배치·터치
+ 미등록 요소 신고"]
D --> E{"WCAG/HIG
표준 판정"}
E -->|FAIL/WARN| F["사람 검수
예외·애매만"]
F -->|재요청| D
F -->|통과| G["빌드 게이트 통과"]
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
class A,B,C data;
class D ai;
class E code;
class F human;
class G pass;
사람의 손이 닿는 곳은 두 군데뿐이다. telemetry 덤프를 깨끗이 넣는 자리(맨 앞)와, 코드·표준이 못 잡는 예외(히트박스)·애매(미등록 요소)를 판정하는 자리(맨 뒤). 그 사이의 지루한 대비 계산과 배치 대조는 AI와 표준이 돌린다.
AI 검수가 매번 산수를 새로 하면 토큰과 시간이 든다. 대비·터치·코너 도달처럼 결정론으로 떨어지는 항목은 코드가 먼저 친다. AI는 코드가 못 잡는 것(화면 의미 해석, 미등록 요소)에만 들어간다. 둘은 경쟁이 아니라 분담이다.
# hud_lint.py — HUD 매니페스트 표준 검증 (골격)
# 입력: telemetry 매니페스트(요소별 rect/hit_rect/fg/bg/class/interactive)
# 출력: WCAG/HIG + 양손 도달 위반 목록
def _luminance(hex_color): # WCAG 상대 휘도
r, g, b = (int(hex_color[i:i+2], 16) / 255 for i in (1, 3, 5))
f = lambda c: c/12.92 if c <= 0.03928 else ((c+0.055)/1.055) ** 2.4
R, G, B = f(r), f(g), f(b)
return 0.2126*R + 0.7152*G + 0.0722*B
def contrast_ratio(fg, bg): # WCAG 명암 대비
L1, L2 = sorted((_luminance(fg), _luminance(bg)), reverse=True)
return (L1 + 0.05) / (L2 + 0.05)
def in_thumb_corner(e, w, h):
"""가로 그립 양손 엄지가 닿는 좌·우 하단 코너인가."""
x, y = e["hit_rect"][0] / w, e["hit_rect"][1] / h
bottom = y > 0.55
left_corner = bottom and x < 0.30 # 왼손 엄지 = 이동
right_corner = bottom and x > 0.70 # 오른손 엄지 = 스킬
return left_corner or right_corner
def lint(elements, screen_w, screen_h):
issues = []
for e in elements:
# 규칙 A: 명암 대비 (텍스트 4.5:1 / 비텍스트·큰글자 3:1)
need = 4.5 if e["kind"] == "text" else 3.0
cr = contrast_ratio(e["fg"], e["bg"])
if cr < need:
issues.append(f"[A] {e['id']}: 대비 {cr:.1f}:1 < {need}:1 (WCAG SC 1.4.3)")
# 규칙 B: 터치 타깃 — 히트박스 기준 (시각 크기 아님)
if e.get("interactive"):
tap = min(e["hit_rect"][2], e["hit_rect"][3]) # ← rect 아닌 hit_rect
if tap < 44:
issues.append(f"[B] {e['id']}: 탭 {tap}pt < 44pt (HIG)")
# 규칙 C: 조작 요소는 양손 엄지 코너(좌·우 하단)에 있어야
if not in_thumb_corner(e, screen_w, screen_h):
issues.append(f"[C] {e['id']}: 조작 요소가 양손 엄지 코너 밖에 배치됨 "
f"(x={e['hit_rect'][0]}, y={e['hit_rect'][1]})")
return issues
이 코드가 회의에서 "이 글자 좀 안 보이지 않아요?"라는 입씨름을 끝낸다. [A] debuff_alert: 대비 2.0:1 < 4.5:1 (WCAG SC 1.4.3)이라고 코드가 출력하면 토론할 게 없다. 고치면 된다. 주목할 두 줄은 규칙 B가 rect가 아니라 hit_rect를 본다는 점, 그리고 규칙 C가 조작 요소를 좌·우 두 하단 코너로만 통과시킨다는 점이다 — §9.1.2에서 사람이 AI를 뒤집은 그 교훈(히트박스)과, 가로 양손 그립의 도달 한계가 함께 코드에 들어갔다. 단일 '엄지 호' 임계 하나가 아니라 좌엄지(이동)·우엄지(스킬) 두 코너를 따로 본다는 점이 가로 판정의 핵심이다. 한 번 사람이 잡은 예외는 다음부터 코드가 잡는다. 그래서 AI에게는 "코드가 PASS 처리한 것 말고, 화면에서만 보이는 이상(미등록 요소·시각적 겹침·잘림)을 신고하라"는 좁은 역할만 남긴다. 결정론으로 잡히는 건 코드가, 화면 의미 해석이 필요한 건 AI가, 빌드 사정을 아는 예외는 사람이 — 이 분담이 핵심이다.
이 장에 나온 수치는 출처가 셋뿐이다. 대비 4.5:1·터치 44pt·48dp는 WCAG SC 1.4.3·HIG·Material의 공식 값이고, #888 배경 #D4C84A 글씨가 약 2.0:1인 것도 그 공식에 색상값을 넣은 계산값이다(§9.1.1·§9.1.5). "검수 한 화면이 손으로 십수 분, 루프로 수 분"·"가로 상시 정보 12~16종"은 미검증 저자 추정이라 본문에 그렇게 명시했다. 나머지(빌드별 대비 FAIL 건수, 터치 히트박스 미달 수, 엄지 코너 이탈 건수, telemetry 오탭률)는 빌드 로그로 직접 셀 수 있는 값이다. 사용자 불만 건수처럼 HUD 하나로 인과를 단정할 수 없는 결과 지표는 KPI로 올리지 않았다.
| 패턴 | 왜 실패하나 | 처방 |
|---|---|---|
| 디자이너 모니터에서 눈으로 검수 | 6인치·전투 이펙트 조건이 빠져 대비 사고 반복 | 스크린샷 lint를 빌드 게이트로 (§9.1.2) |
| 스크린샷만 AI에 던지고 "검토해줘" | 좌표를 추측해 근사 판정, 신뢰 불가 | telemetry 매니페스트 동봉 (§9.1.4) |
| 시각 크기로 터치 타깃 판정 | 히트박스 확장을 놓쳐 멀쩡한 버튼을 FAIL | hit_rect 기준 검사 (§9.1.5) |
| P0 알림을 화면 중앙에 배치 | 전투 이펙트에 묻혀 "안 보여서 죽음" | 상단 상태대·양 코너로 (§9.1.3) |
| 조작 버튼을 화면 좌측 중앙·상단에 배치 | 가로 양손 그립에서 엄지가 안 닿음 | 좌·우 하단 코너로 (§9.1.5 규칙 C) |
| 세로 한 손 그립을 전제로 설계 | MMORPG는 가로 양손이 표준, 정보·조작이 안 맞음 | 가로 양손으로 전환 (§9.1.1) |
| 대비를 "보인다/안 보인다"로 토론 | 결론이 사람마다 다름 | WCAG 4.5:1 계산값으로 (§9.1.1) |
네 번째가 가장 자주 반복된다. 새 알림을 급히 얹을 때 빈 공간이 화면 중앙뿐이라 거기 둔다 — 그리고 그 중앙이 정확히 게임이 벌어지는 자리다.
혼자라면 이만큼만: telemetry도 매니페스트도 없어도 됩니다. 본인 게임(또는 좋아하는 게임)의 가로 HUD 스크린샷을 한 장 찍고, 가장 작은 글씨·아이콘 두세 개의 전경/배경 색을 스포이드로 뽑아 손으로 적은 뒤, §9.1.2의 프롬프트를 붙여 한 번 돌려 보세요. AI가 계산한 대비 수치 하나를 골라 온라인 WCAG 대비 계산기로 직접 검산해 보면, "보인다/안 보인다"가 어떻게 숫자가 되는지 몸으로 들어옵니다. AI가 화면 중앙에 둔 P0가 있으면 "왜 중앙이 위험한지 다시 보라"고 반박해 보세요.
팀이라면 다음 한 단계로 시작하세요. UI 프레임워크에서 위젯 좌표·색·히트박스를 덤프하는 telemetry 훅(경로 ①)을 프로그래머와 먼저 합의하고, §9.1.5의 contrast_ratio 한 함수부터 빌드에 넣습니다. 대비 계산은 표준 공식이라 이견이 없고, 한 함수만 있어도 매 빌드 대비 FAIL이 숫자로 떨어집니다. 그다음 in_thumb_corner를 얹으면 가로 양손 조작 요소의 코너 이탈까지 코드가 잡습니다. 배치·미등록 요소 같은 해석은 그 위에 AI로 얹으면 됩니다.
1차 독자: 모바일 우선 액션·MMORPG의 UX·전투 기획자 (중규모 팀) 1인/취미 독자용 축소 버전: §9.2.7 「혼자라면 이만큼만」
신규 직업의 스킬 6개를 모바일 화면 어디에 어떻게 깔 것인가. 이 질문이 회의에 올라오면, 처음 30분은 늘 똑같이 흘렀다. 누군가 화이트보드에 동그라미 여섯 개를 그리고, 다른 사람이 "그건 엄지가 안 닿는다"고 하고, 또 다른 사람이 "그럼 위로 올리면 미니맵이 가려진다"고 받는다. 셋 다 맞는 말인데, 결론은 안 났다. 다음 회의에서 같은 화이트보드가 다시 그려졌다.
문제는 배치 시안을 그리는 일과 그 시안이 규칙을 지켰는지 검사하는 일이 한 사람 머릿속에서 뒤섞여 있다는 점이다. 그리는 사람은 자기가 그린 걸 잘 못 떨어뜨린다. 이 장은 그 둘을 떼어 놓는다. 배치 시안 여러 개를 짜는 지루한 일은 AI에게 시키고, 그 시안이 겹침·엄지 코너·터치 크기 규칙을 어겼는지는 코드가 떨어뜨린다. 사람은 코드가 통과시킨 안들 중에서 "게임 느낌"으로 하나를 고르는 자리에만 선다. 9.1이 HUD 전체의 룰북을 세웠다면, 이 장은 그 룰북을 스킬 버튼이라는 가장 손이 많이 닿는 한 덩어리에 끝까지 적용하는 한 사이클이다.
HUD 위의 대부분 요소는 읽기만 한다. HP 바를 누르는 사람은 없다. 그래서 §9.1의 엄지 코너 그림에서 HP·MP·타깃 체력은 손이 안 닿는 상단 읽기 영역에 둬도 됐다. 스킬 버튼은 정반대다. 0.1초 단위로 정확히 눌러야 하고, 전투 중에는 시선이 적에게 가 있어서 손가락이 위치를 기억으로 찾는다. 위치가 조금만 어긋나도 그 자리에서 오탭이 난다.
MMORPG 모바일은 가로 양손 그립이 표준이고, 누르는 요소는 양 하단 코너·소비/슬롯은 중앙 하단에 둔다(왜 가로가 표준인지, 세 영역이 무엇인지는 §9.1에서 다룬다). 그 표준에서 스킬은 거의 전부 오른손 엄지가 닿는 우하단 코너 클러스터에 깔린다(왼손 엄지는 좌하단 이동에 묶여 있다). 한 가지 구분을 둔다 — 0.1초 단위로 누르는 능동 스킬은 이 우하단 클러스터가 자리지만, 소비·자동 아이템·퀵슬롯은 두 엄지 사이 중앙 하단 슬롯대에 따로 둔다. 이 장은 능동 스킬 버튼만 다루며, 모든 좌표 판정은 가로 양손 그립을 전제한다.
그래서 스킬 버튼 배치는 세 가지 결정론 규칙에 동시에 묶인다 — 최소 터치 타깃(HIG 44pt), 인접 버튼 간격(Material 8dp), 엄지 도달(스킬은 우엄지 우하단 코너). 셋 다 §9.1.1에서 세운 룰북에 이미 들어 있는, 좌표와 크기로 판정 가능한 항목이라 공개표준 수치는 그 룰북을 따른다(터치 44pt·간격 8dp는 공인 수치, 우엄지 코너만 업계 통용 모델). 이 셋이 이 장에서 AI 배치안을 떨어뜨릴 lint의 1차 입력이 된다. "이 버튼 좀 작지 않아요?"가 아니라 "skill_3은 40pt라 HIG 44pt 미달"이라고 코드가 말하면, 화이트보드 앞의 30분이 사라진다.
플랫폼 기준을 PC와 나란히 두면 배치의 출발점이 분명해진다. PC는 정밀·대량, 모바일 가로는 양손 코너 한정이다(전체 비교표는 §9.1 룰북 참조). 스킬 입력만 떼어 보면 차이가 분명하다 — PC는 단축키로 스킬을 화면 어디에 두든 손가락이 키보드에 있으니 도달이 문제가 안 되고 슬롯도 다수 가능하다. 모바일 가로는 호버도 단축키도 없으니, 스킬을 우엄지가 닿는 우하단 코너에 빈도순으로 깔고(동시 노출 6~8개 한계), 가장 자주 쓰는 스킬을 코너 안쪽(가장 잘 닿는 자리)에 둬야 한다. 그래서 모바일 스킬 배치의 본질은 "예쁜 배열"이 아니라 "우엄지 코너 안에서 빈도순 우선순위 배치 + 룰북 검수"다. 그리고 시안을 여러 개 그리는 일은 사람이 손으로 하면 지루하고 할 때마다 기준이 흔들린다. 지루하고 변덕스러운 반복 작업 — AI가 사람보다 지치지 않고 해내는 자리다.
신규 직업 '주술사'의 액티브 스킬 6개를 모바일에 배치하는 한 사이클을 입력에서 폐기까지 끝까지 보여준다. 아래는 저자 프로젝트(모바일 우선 MMORPG, 이하 "프로젝트 A")의 신규 스킬 UI 작업 세션을 충실히 재현한 것이다. 입력과 프롬프트는 그대로 복사해 쓸 수 있고, 출력은 실제 세션을 재구성했다.
스킬 6개의 사용 빈도와 기본 성격을 yaml로 만든다. 사용 빈도는 데이터 시트의 전투 로그에서 뽑은 값이라 새로 지어내는 게 아니다.
# skill_set_shaman.yaml — 신규 직업 '주술사' 액티브 스킬 6종
screen: { w: 2400, h: 1080, dpr: 3 } # 6.x인치 가로 기준, pt = px / dpr
skills:
- id: s1_quickbolt # 기본 공격, 가장 자주
use_rate: 0.41 # 전투 중 사용 비율 (로그 추출)
role: spam # 연타
- id: s2_hex # 디버프, 자주
use_rate: 0.22
role: core
- id: s3_totem # 설치형, 보통
use_rate: 0.14
role: core
- id: s4_heal # 회복, 가끔이지만 긴급
use_rate: 0.11
role: panic # 위급 시 즉시
- id: s5_curse # 광역 디버프, 가끔
use_rate: 0.08
role: situational
- id: s6_ultimate # 궁극기, 드물게
use_rate: 0.04
role: burst
핵심 슬롯은 use_rate와 role이다. 가장 자주 누르는 s1_quickbolt(41%)와 위급할 때 0.2초 안에 찾아야 하는 s4_heal(panic)은 우엄지가 가장 잘 닿는 자리(우하단 코너 안쪽)여야 한다. 드물게 쓰는 s6_ultimate(4%)는 코너 가장자리로 조금 멀어도 된다. 이 우선순위가 다음 단계 AI 배치의 입력 전부다.
첨부 yaml은 신규 직업 액티브 스킬 6종이야. 가로 그립 양손 화면에 스킬 버튼 배치안을 3개 만들어줘. 스킬은 오른손 엄지가 닿는 우하단 코너에 깔아(왼손은 좌하단 이동에 묶여 있어).
세 안은 서로 다른 철학으로 — 예를 들면 코너 부채꼴, 2행 그리드, 빈도 가중 식으로. 버튼마다 x,y,w,h를 px로 줘(화면 2400x1080, dpr 3, pt=px/3).
자주 쓰거나 role=panic인 스킬은 우엄지가 잘 닿는 코너 안쪽에, 터치 타깃은 132px(44pt) 이상, 인접 간격 24px(8dp) 이상으로.
안마다 설계 의도 한 줄이랑 네가 보는 약점 하나도 꼭 같이 적어줘 — 약점 없는 안은 안 돼. 안별로 (배열철학|좌표표|설계의도|자가-약점)만, 설명 빼고.
이 프롬프트에서 배치 지시보다 더 공들인 부분은 출력 형식과 약점 신고를 강제하는 대목이다. 좌표를 숫자로 받지 않으면 lint를 돌릴 수 없으니 px 출력을 못 박았고, "이 안의 약점 1가지"를 같이 내게 해서 AI가 자기 한계를 먼저 말하게 했다. 자유롭게 "예쁜 UI 만들어 줘"라고 하면, 일반 모바일 RPG 평균 배치가 그림 없이 말로만 나온다.
== 안 A: 우하단 코너 부채꼴 ==
배열철학: 우엄지 회전축을 중심으로 6개를 부채꼴로. 빈도순 안쪽→바깥쪽.
| id | x | y | w | h | pt |
|-------------|------|-----|-----|-----|------|
| s1_quickbolt| 2160 | 912 | 168 | 168 | 56pt |
| s2_hex | 2016 | 852 | 132 | 132 | 44pt |
| s3_totem | 2052 | 996 | 132 | 132 | 44pt |
| s4_heal | 1884 | 924 | 132 | 132 | 44pt |
| s5_curse | 1920 | 780 | 120 | 120 | 40pt |
| s6_ultimate | 1788 | 840 | 120 | 120 | 40pt |
설계의도: 자주 쓰는 s1을 가장 크고 닿기 쉬운 우하단 코너 안쪽에.
자가-약점: s5,s6이 40pt로 HIG 미달일 수 있음. 부채꼴 바깥은 우엄지 도달 애매.
== 안 B: 2행 그리드(2x3) ==
배열철학: 우하단 2행x3열 격자. 시각적 정렬 우선.
| id | x | y | w | h |
|-------------|------|-----|-----|-----|
| s1_quickbolt| 2208 | 912 | 132 | 132 |
| s2_hex | 2052 | 912 | 132 | 132 |
| s3_totem | 1896 | 912 | 132 | 132 |
| s4_heal | 2208 | 756 | 132 | 132 |
| s5_curse | 2052 | 756 | 132 | 132 |
| s6_ultimate | 1896 | 756 | 132 | 132 |
설계의도: 모든 버튼 동일 크기, 정렬이 깔끔.
자가-약점: 위 행(s4,s5,s6, y=756)이 우엄지 코너 위쪽 경계라 도달이 애매.
긴급 회복 s4가 윗줄이라 위급 시 찾기 느릴 수 있음.
== 안 C: 빈도 가중 부채꼴 ==
배열철학: s1을 중심 큰 원, 나머지를 빈도순으로 우엄지 회전축에 감쌈.
| id | x | y | w | h |
|-------------|------|-----|-----|-----|
| s1_quickbolt| 2184 | 900 | 180 | 180 |
| s2_hex | 2028 | 852 | 144 | 144 |
| s3_totem | 2052 | 996 | 132 | 132 |
| s4_heal | 1908 | 912 | 144 | 144 |
| s5_curse | 1932 | 792 | 120 | 120 |
| s6_ultimate | 1812 | 972 | 120 | 120 |
설계의도: 손이 s1에 자연히 가고, 긴급 s4를 크게 키워 코너 안쪽에.
자가-약점: 부채꼴이라 버튼 간격이 불균등. s2-s5, s4-s6 근접 충돌 우려.
세 안 모두 자가-약점을 신고했다는 점이 이 출력의 핵심이다. A는 "40pt 미달 우려", B는 "긴급 회복이 윗줄", C는 "근접 충돌 우려". AI가 자기가 그린 그림의 약한 곳을 먼저 가리켰다. 하지만 이건 자가 신고일 뿐, 진짜 판정은 코드가 한다.
세 안을 눈으로 비교하면 또 "B가 깔끔해 보이는데"라는 취향 싸움이 시작된다. 그래서 §9.2.3의 skill_layout_lint.py에 세 안을 그대로 먹인다. 결과는 이랬다.
[안 A] 우하단 코너 부채꼴
[FAIL] B-size : s5_curse 40pt < 44pt (HIG 미달)
[FAIL] B-size : s6_ultimate 40pt < 44pt (HIG 미달)
[WARN] C-corner: s6_ultimate x=1788 — 코너 왼쪽 경계, 우엄지 도달 '보통'
→ 통과 4/6, 치명 위반 2
[안 B] 2행 그리드(2x3)
[FAIL] C-corner: s4_heal y=756 (0.70h) < 0.55h 아래 아님 → 우엄지 코너 위쪽
[FAIL] C-corner: s5_curse y=756 (0.70h) < 0.55h 아래 아님 → 우엄지 코너 위쪽
[WARN] role : s4_heal(panic) y=756 — 긴급 스킬이 윗줄
→ 통과 4/6, 치명 위반 2
[안 C] 빈도 가중 부채꼴
[FAIL] A-overlap: s2_hex ∩ s5_curse 간격 18px < 24px (8dp 미달)
[FAIL] A-overlap: s4_heal ∩ s6_ultimate 간격 12px < 24px (8dp 미달)
→ 통과 4/6, 치명 위반 2
세 안이 모두 떨어졌다. 자가 신고와 lint 판정이 거의 겹쳤다는 점이 흥미롭다. AI가 "약점"이라 말한 자리에서 실제로 위반이 났다. 하지만 자가 신고는 "우려"였고, lint는 "s2_hex와 s5_curse 간격 18px"라는 숫자다. 회의에서 토론할 게 없다.
이 단계에서 중요한 결정이 하나 있다. 세 안 다 떨어졌다고 처음으로 돌아가지 않는다. lint 리포트를 그대로 다음 프롬프트에 붙여 재요청한다.
세 안 lint 결과 그대로 붙인다(위 리포트). 이 중 위반이 가장 고치기 쉬운 안 하나만 골라서 FAIL 전부 0으로 만든 수정안 하나만 줘.
버튼은 다 132px(44pt) 이상, 간격 24px(8dp) 이상, s4_heal(panic)은 우엄지 코너 안에서 s1 다음으로 잘 닿는 자리로.
좌표표만 주고, 어느 FAIL이 어떻게 풀렸는지 한 줄씩 적어줘.
// (의도: 코너 안 = x>가로70%(1680), y>세로55%(594))
AI는 안 A(코너 부채꼴)를 골랐다. 위반이 "두 버튼 크기 미달"뿐이라 가장 적게 손대도 됐기 때문이다. 작아서 떨어진 s5·s6을 132px로 키우고, 커진 만큼 부채꼴 곡선을 살짝 코너 안쪽으로 밀어 간격을 확보한 수정안을 냈다.
== 안 A' (수정) ==
| id | x | y | w | h | pt |
|-------------|------|-----|-----|-----|------|
| s1_quickbolt| 2160 | 900 | 168 | 168 | 56pt |
| s2_hex | 2010 | 846 | 132 | 132 | 44pt |
| s3_totem | 2046 | 990 | 132 | 132 | 44pt |
| s4_heal | 1872 | 918 | 144 | 144 | 48pt |
| s5_curse | 1908 | 774 | 132 | 132 | 44pt |
| s6_ultimate | 1740 | 858 | 132 | 132 | 44pt |
해소: B-size s5 40→44pt / B-size s6 40→44pt /
C-corner s6 x=1740(0.725w)·y=858(0.79h)로 코너 안쪽 유지 →
role: s4_heal 144px로 키워 긴급 식별 강화.
skill_layout_lint.py에 안 A'를 다시 먹였다.
[안 A'] 우하단 코너 부채꼴(수정)
[PASS] B-size : 전 버튼 ≥ 44pt
[PASS] A-overlap: 최소 간격 30px ≥ 24px
[PASS] C-corner : 전 조작 버튼 우엄지 코너 안 (x≥1680, y≥594)
[WARN] C-corner : s6_ultimate x=1740 — 코너 왼쪽 끝, 도달 '보통'
→ 통과 6/6, 치명 위반 0, WARN 1
FAIL이 0이 됐다. 남은 WARN 1건(s6_ultimate이 코너 왼쪽 끝이라 우엄지 도달이 '쉬움'은 아니고 '보통')은 코드가 자동으로 죽이지 않는다. 사람에게 올린다. 그리고 이 WARN은 사실 의도된 설계다. s6은 사용 빈도 4%로 가장 드물게 쓰는 궁극기라, 코너에서 가장 안쪽 자리는 자주 쓰는 s1에 양보하고 가장자리에 두는 게 맞다. 사람이 "이 WARN은 의도다"라고 판정하고 통과시켰다. 입력 → 3안 생성 → lint → 전멸 → 재요청 → 통과의 한 사이클이 여기서 닫힌다.
이 한 바퀴가 이 장의 Show 기준이다. AI가 무엇을 그리고, lint가 무엇을 떨어뜨리고, 사람이 어떤 WARN을 살리는지를 끝까지 보지 않으면 "AI로 UI 시안 뽑았다"는 문장은 공허하다.
위 사이클의 심장은 세 규칙을 떨어뜨리는 30여 줄의 코드다. §9.2.1 표의 세 항목이 그대로 세 함수가 된다.
# skill_layout_lint.py — 스킬 버튼 배열 검증 (골격)
# 입력: AI가 낸 버튼 좌표 리스트 [{id, x, y, w, h, role, use_rate}]
# 출력: A-overlap / B-size / C-corner 위반 목록
# 전제: 가로 그립 양손. 스킬은 오른손 엄지가 닿는 우하단 코너에 깐다.
MIN_TAP_PX = 132 # HIG 44pt * dpr 3 = 132px
MIN_GAP_PX = 24 # Material 8dp * dpr 3 = 24px
RIGHT_CORNER_X = 0.70 # 화면 가로 0.70 오른쪽 = 우엄지 코너
BOTTOM_Y = 0.55 # 화면 세로 0.55 아래 = 하단 코너
def in_right_thumb_corner(b, w, h):
"""가로 그립에서 오른손 엄지가 닿는 우하단 코너인가.
(왼손 엄지=좌하단 이동, 오른손 엄지=우하단 스킬)"""
rx, ry = b["x"] / w, b["y"] / h
return rx > RIGHT_CORNER_X and ry > BOTTOM_Y
def lint(buttons, screen_w, screen_h):
issues = []
# 규칙 B: 터치 타깃 최소 크기 (HIG 44pt)
for b in buttons:
side = min(b["w"], b["h"])
if side < MIN_TAP_PX:
issues.append(f"[FAIL] B-size : {b['id']} {side//3}pt "
f"< 44pt (HIG 미달)")
# 규칙 A: 인접 버튼 겹침/간격 (가장 가까운 두 모서리 거리)
for i, a in enumerate(buttons):
for c in buttons[i+1:]:
gap = edge_gap(a, c) # 두 사각형 최단 간격(px)
if gap < MIN_GAP_PX:
issues.append(f"[FAIL] A-overlap: {a['id']} ∩ {c['id']} "
f"간격 {gap}px < {MIN_GAP_PX}px (8dp 미달)")
# 규칙 C: 조작 요소는 우엄지 코너 안. panic은 코너 안쪽일수록 좋음.
for b in buttons:
rx, ry = b["x"] / screen_w, b["y"] / screen_h
if not in_right_thumb_corner(b, screen_w, screen_h):
issues.append(f"[FAIL] C-corner: {b['id']} "
f"x={b['x']}({rx:.2f}w) y={b['y']}({ry:.2f}h) "
f"→ 우엄지 코너 밖")
elif b.get("role") == "panic" and rx < 0.78:
issues.append(f"[WARN] role : {b['id']}(panic) "
f"긴급 스킬이 코너 안쪽 경계 근처")
return issues
이 코드가 회의에서 "B안이 더 예쁜데요"라는 취향 발언을 무력화한다. 예쁨은 lint가 통과시킨 다음에 따지는 것이다. lint가 [FAIL]을 뱉는 안은 예쁘든 말든 빌드에 못 들어간다. §9.1.1에서 세운 HUD lint 게이트를, 스킬 버튼이라는 가장 까다로운 한 덩어리에 끝까지 적용한 것이다 — 좌표·크기로 판정 가능한 건 코드가, '이 WARN이 의도냐'는 판단은 사람이 맡는 분담이 여기서도 그대로 성립한다.
전체 사이클을 한눈에 보면 이렇다.
flowchart LR
A["스킬 명세 yaml
(use_rate·role)"] --> B["AI: 배치안 3개
좌표+자가-약점"]
B --> C{"skill_layout_lint.py
겹침·크기·우엄지 코너"}
C -->|FAIL 있음| D["lint 리포트를
그대로 재요청"]
D --> B
C -->|FAIL 0, WARN만| E["사람: WARN이
의도인지 판정"]
E --> F["배치 확정
+ ArtGuide 06_UI sync"]
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
class A data;
class B ai;
class C code;
class E human;
class F pass;
사람의 손이 닿는 곳은 두 군데뿐이다. 입력 명세를 깨끗이 넣는 맨 앞과, lint가 못 죽이는 WARN을 판정하는 맨 뒤. 그 사이의 지루한 3안 생성과 좌표 검사는 AI와 lint가 돌린다.
배치안을 한 번 뽑고 끝내면 이 도구가 잘 작동하는지 알 수 없다. 그래서 lint 결과를 매번 로그에 남긴다. 기록하는 값은 단순하다 — AI가 낸 안이 첫 번째 lint를 몇 개나 통과했는가(첫 통과율), 그리고 재요청 몇 번 만에 FAIL 0에 도달했는가(왕복 횟수).
아래 수치는 신규 직업 3종(주술사 외 2종)의 스킬 UI를 이 사이클로 짜며 직접 카운트한 실측값이다. 표본이 직업 3종(배치 세션 9회)으로 작으므로 정밀한 모수가 아니라 방향값으로 읽는 게 맞다. 가공한 숫자는 없다.
| 항목 | 실측 | 비고 |
|---|---|---|
| AI 첫 배치안 중 lint 첫 통과 | 9회 중 1회 | 나머지 8회는 1개 이상 FAIL |
| 첫 통과 시 평균 FAIL 수 | 안당 1.8건 | 대부분 크기 미달 또는 우엄지 코너 밖 |
| FAIL 0 도달까지 평균 왕복 | 1.4회 | lint 리포트 재투입 방식 |
| 가장 흔한 FAIL 유형 | B-size(크기 미달) | 다음이 C-corner(우엄지 코너) |
가장 중요한 줄은 첫 번째다. AI가 처음 낸 안은 9번 중 8번 lint를 못 통과했다. 이게 이 도구의 실패가 아니라 정상 작동의 신호다. AI에게 좌표를 자유롭게 내게 하면 HIG 44pt를 자주 어긴다. lint가 그걸 매번 잡아내고, 리포트를 되먹이면 1~2번 왕복으로 0이 된다. 만약 첫 통과율이 100%였다면 그건 lint가 너무 헐겁다는 뜻이지, AI가 완벽하다는 뜻이 아니다.
이 통과율 로그는 lint 규칙을 조일지 풀지 결정하는 근거도 된다. 어떤 FAIL 유형이 매번 "사실 의도였다"며 사람 손에 풀려나면, 그 규칙은 너무 빡빡한 것이다. 반대로 출시 후 오탭 불만이 들어오는데 lint는 통과시켰다면, 규칙이 헐거운 것이다.
§9.2.2에서 lint를 통과한 안 A'를 좌표 그대로 그리면 아래와 같다. 표의 숫자가 실제 화면에서 어떤 모양인지는 그림으로 봐야 손에 잡힌다. 가로 폰을 양손으로 쥔 자세에서, 왼손 엄지는 좌하단(이동), 오른손 엄지는 우하단(스킬 클러스터)에 닿는다. 원의 크기는 터치 타깃(pt)에 비례하고, 색은 엄지 도달 난이도(초록 쉬움 / 노랑 보통)다.
그림으로 보면 lint 리포트의 마지막 WARN이 한눈에 이해된다. s6_ultimate(노랑)만 우하단 코너의 왼쪽 끝, 우엄지 도달 '보통' 자리다. 하지만 s6은 사용 빈도 4%의 궁극기라 코너 가장자리에 두는 게 맞다. 가장 자주 쓰는 s1(초록, 56pt 최대)은 우엄지가 가장 잘 닿는 코너 안쪽 우하단에, 긴급 회복 s4(노랑 테두리)는 크기를 키워 위급 시 손이 빨리 찾게 했다. 왼손 엄지는 좌하단 '이동'에 묶여 있어, 스킬은 전부 오른쪽 코너에 모인다. 좌표표 한 장이 그림 한 장과 정확히 일치한다는 것 — 그게 좌표를 숫자로 받은 이유다.
| 패턴 | 왜 실패하나 | 처방 |
|---|---|---|
| 화이트보드에 동그라미만 그리고 회의 | 좌표가 없어 lint 불가, 취향 싸움 반복 | 좌표를 px로 받아 lint에 먹임 (§9.2.2) |
| "AI야 예쁜 스킬 UI 만들어 줘" 통째 위임 | 룰북 없이는 일반 RPG 평균 배치 | 3안+좌표+자가약점 강제 프롬프트 |
| 세로 한 손 그립 전제로 배치 | MMORPG는 가로 양손이 표준, 스킬은 우엄지 코너 | 가로 2400x1080, 우하단 코너 기준으로 lint |
| 배치안을 눈으로만 비교 | HIG 미달·겹침을 매번 놓침 | skill_layout_lint.py로 자동 판정 |
| 첫 안이 lint 통과 → 도구 잘됐다고 안심 | lint가 헐거운 신호일 수 있음 | 통과율 로그로 규칙 조임 점검 (§9.2.4) |
| WARN까지 코드가 자동 차단 | 의도된 배치(드문 궁극기)까지 죽임 | WARN은 사람 판정으로 (§9.2.3) |
다섯 번째가 가장 자주 놓친다. AI 첫 안이 매번 통과하면 기분은 좋지만, 그건 보통 lint 규칙이 느슨하다는 뜻이다. 9번 중 8번 떨어지는 게 건강한 상태다.
혼자라면 이만큼만: lint 코드가 없어도 됩니다. 본인 게임(또는 좋아하는 게임)의 스킬 4~6개를 골라 §9.2.1 형식의 명세를 손으로 적고(use_rate는 대충 빈도 순위만), §9.2.2의 프롬프트를 그대로 붙여 3안을 받아 보세요. 그다음 줄자 대신 "44pt = 132px"만 머리에 넣고, AI가 낸 좌표표에서 132px 미만 버튼을 손으로 찾아 동그라미 쳐 보세요. 그리고 가로 화면이라 치고, 우하단 코너(가로 70% 오른쪽 + 세로 55% 아래) 밖에 떨어진 스킬이 있는지도 짚어 보세요. 그 한 번이 lint가 무슨 일을 하는지 몸으로 알려 줍니다.
팀이라면 다음 한 단계로 시작하세요. §9.2.3의 skill_layout_lint.py 세 함수(크기·간격·우엄지 코너)부터 코드로 고정해 둡니다. 세 함수면 충분합니다. 룰북이 있으면 AI 배치안이든 디자이너 시안이든 같은 선으로 잴 수 있고, lint를 통과한 안만 아트팀 96_ArtGuide/06_UI/로 넘어가 _convert_md_to_html.py → _SyncToArtRepo.bat 경로로 자동 sync됩니다. 확정 좌표가 아트팀에 닿기 전까지, 사람의 마지막 일은 WARN 하나를 "의도다"라고 판정하는 것뿐입니다.
1차 독자: 비-기획 직군(아트)과 매일 협업하는 UX·UI 기획자 (중규모 팀) 1인/취미 독자용 축소 버전: §9.3.8 「혼자라면 이만큼만」
기획자가 마크다운으로 UI 결정사항을 정리해 두면 일이 깔끔해진다. 버전 관리가 되고, diff가 보이고, AI에게 그대로 던질 수 있다. 문제는 아트팀이 마크다운을 읽지 않는다는 것이다. 더 정확히는, 읽을 이유가 없다. 아트 디자이너에게 "아트_결정사항.md를 SVN에서 받아서 보세요"라고 말하면, 절반은 SVN 클라이언트를 안 깔았고, 나머지 절반은 ## 헤더와 표 문법이 깨진 채로 메모장에서 연 화면을 보며 "이거 어떻게 봐요"라고 묻는다.
여기서 잘못된 처방은 "아트팀에게 마크다운을 가르치자"다. 아트 디자이너의 시간은 픽셀을 미는 데 써야 한다. 마크다운 컨벤션, SVN 체크아웃, diff 보는 법을 배우는 데 쓰는 시간은 전부 손실이다. 옳은 처방은 기획자 쪽에서 변환과 전달을 자동화해서, 아트팀의 학습 부담을 0으로 만드는 것이다. 기획자는 md로 쓰고, 스크립트가 html로 바꾸고, 또 다른 스크립트가 아트 저장소로 밀어 넣고, 아트팀은 브라우저에서 html만 본다. 이 장은 그 파이프라인을 실제로 한 번 끝까지 돌린다 — 결정사항 초안을 AI로 뽑는 자리부터, 변환·전달의 자동화, 그리고 사람이 무엇을 거부하는지까지.
기획과 아트의 협업이 깨지는 이유를 "결정권 모호"로 정리하는 책이 많다. 누가 색을 정하고 누가 기능을 정하느냐. 그 분담도 중요하지만, 분담표를 아무리 잘 그려도 그 분담표를 아트팀이 못 읽으면 아무 일도 일어나지 않는다. 실무에서 더 자주 사고가 나는 자리는 결정권이 아니라 전달 포맷이다.
저자의 프로젝트(모바일 우선 MMORPG, 이하 "프로젝트 A")에서 실제로 반복된 사고는 이렇다.
| 사고 | 표면 원인 | 진짜 원인 |
|---|---|---|
| 아트가 구버전 결정사항으로 작업 | "최신 안 받았네요" | 전달이 수동(메일 첨부)이라 누락 |
| 결정사항 표가 깨져 보임 | "이거 왜 이래요" | md를 메모장에서 열어서 |
| "그 결정 어디 적혀 있어요?" | 구두 전달 | 정본(canonical)이 채팅에 흩어짐 |
세 사고 모두 결정권 문제가 아니다. 정본 문서가 아트팀이 읽는 포맷으로, 자동으로, 항상 최신 상태로 전달되지 않아서 생긴다. 그래서 이 장의 도구는 분담표가 아니라 전달 파이프라인이다. 분담은 한 번 합의하면 끝이지만, 전달은 결정이 바뀔 때마다 매번 일어나기 때문이다.
실제 폴더 구조부터 본다. 프로젝트 A의 아트 가이드는 workspace/96_ArtGuide/ 아래 7개 도메인으로 나뉜다.
96_ArtGuide/
├── 00_Common/ # 공통 (스타일·컬러팔레트·라이팅 기준)
├── 01_Character/
├── 02_Animation/
├── 03_Monster/
├── 04_NPC/
├── 05_VFX/
├── 06_UI/ # ← 이 장이 다루는 영역
└── 07_Env/
그리고 이 폴더에는 운영 파일 두 개가 같이 산다. _convert_md_to_html.py와 _SyncToArtRepo.bat다. 이 두 파일이 이 장의 척추다.
전체 흐름은 네 단계다. 핵심은 사람(기획자)은 1단의 md만 만지고, 나머지 3단은 전부 스크립트가 돌린다는 점이다. 아트팀은 4단의 html만 본다. md의 존재 자체를 몰라도 된다.
flowchart LR
A["1단 · 기획팀 SVN
아트_결정사항.md
(기획자가 작성/AI 초안)"]
A --> B["2단 · _convert_md_to_html.py
md → html 변환
(표·헤더·이미지 임베드)"]
B --> C["3단 · _SyncToArtRepo.bat
아트 SVN으로 자동 push
(별도 저장소)"]
C --> D["4단 · 아트팀 브라우저
html만 열람
(md 컨벤션 학습 부담 0)"]
D -.피드백/수정 요청.-> A
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
class A,D human;
class B,C code;
각 단계가 정확히 무엇을 하는지 짚는다.
1단(기획자, 사람) — 06_UI/아트_결정사항.md에 결정사항을 마크다운으로 쓴다. 이 자리에 AI를 끼우는 법이 §9.3.4의 척추다. 결정사항은 "버튼 primary 색상 #3A7BD5", "터치 타깃 최소 44pt" 같은 항목이다.
2단(_convert_md_to_html.py, 자동) — md를 html로 변환한다. 단순 변환이 아니라, 아트팀이 보기 좋게 표를 렌더링하고,  이미지 참조를 인라인으로 임베드하고, 목차를 단다. 아트 디자이너가 브라우저에서 더블클릭 한 번으로 열 수 있는 자기완결 html이 나온다.
3단(_SyncToArtRepo.bat, 자동) — 변환된 html을 아트팀의 별도 SVN 저장소로 push한다. 기획 저장소와 아트 저장소가 분리돼 있는 게 핵심이다. 아트팀은 자기 저장소만 보면 되고, 기획 저장소의 권한·구조를 알 필요가 없다.
4단(아트팀, 사람) — 아트 디자이너는 자기 저장소에 동기화된 html을 브라우저에서 연다. 마크다운 문법도, SVN 명령도, diff 보는 법도 배울 필요가 없다. md 컨벤션 학습 부담이 0이라는 게 이 파이프라인의 설계 목표이자 성공 기준이다.
피드백은 4단에서 1단으로 돌아온다. 아트가 "이 결정 이상해요"라고 말하면 기획자가 md를 고치고, 2~3단이 다시 자동으로 돈다. 아트는 갱신된 html을 다시 열기만 하면 된다.
여기서 한 번 멈추고 설계 의도를 명시한다. md를 html로 바꾸는 일은 그 자체로는 사소하다. 진짜 설계는 누구의 학습 부담을 누가 떠안느냐를 결정한 데 있다.
선택지는 두 갈래였다.
핵심은 비대칭이다. 안 A는 학습 비용이 아트 인원수만큼 곱해지고, 그 비용이 매 신규 입사자마다 재발한다. 안 B는 기획자가 한 번 스크립트를 짜면 끝이고, 아트 쪽 한계 비용이 0이다. 부담을 인원이 많은 쪽이 아니라 자동화가 가능한 쪽에 몰아주는 것 — 이게 비-기획 직군 협업 도구의 1원칙이다. 이 원칙이 깨지면, 즉 협업 도구가 상대 직군에게 새 학습을 강요하면, 그 도구는 한두 분기 안에 "안 쓰게" 된다.
1단에서 기획자가 md를 쓴다고 했는데, 그 md 초안을 AI로 뽑는 자리를 한 사이클 끝까지 보여준다. 결정 회의가 끝나면 산발적인 메모(채팅·화이트보드 사진·구두 합의)가 남는다. 이걸 정본 결정사항 md로 정리하는 일은 지루하고, 매번 형식이 흔들린다. AI에게 딱 맞는 일이다. 단, 결정 자체는 사람이 하고, AI는 결정을 정해진 포맷으로 정리만 한다는 경계가 핵심이다.
[UI 결정 회의 메모 — 06_UI 스킬 슬롯 관련, 날것]
- 스킬 슬롯 버튼 사이즈 키우자 했음. 모바일에서 작대서.
- 색은 아트가 정하기로. 근데 primary 톤은 파란 계열 유지.
- 슬롯 비활성(쿨다운) 상태 표현 회색+숫자 오버레이로 합의
- 다국어... 스킬명 길어지는 거 어떡하지? 일단 보류
- 아 그리고 롱프레스로 스킬 설명 뜨게 (이건 기능, 기획 확정)
첨부는 UI 결정 회의 날것 메모야. 아트팀에 넘길 결정사항 마크다운으로 정리해줘.
각 항목을 [확정]/[보류]/[아트결정]으로 분류하고(확정=기획이 정해 아트가 따름, 보류=미결+이유, 아트결정=시각 표현이라 아트가 정함·기획은 제약만), 근거/제약 한 줄씩 붙여줘 — 메모에 근거 없으면 '근거 미기재'라고 솔직히 적고 추측으로 채우지 마.
터치 크기처럼 공개 표준으로 검증되는 건 표준 명시하고, 메모에 없는 항목은 절대 새로 지어내지 마.
표는 |항목|분류|결정/제약|근거|검증가능 여부|, frontmatter(title, layer: L1) 포함해서 전문으로 줘.
이 프롬프트의 무게중심은 정리 작업이 아니라 분류 강제와 지어내기 금지에 실려 있다. AI에게 결정사항 정리를 시키면, 메모에 없던 "베스트 프랙티스"를 친절하게 추가해 버린다. 그 추가분이 아트팀에게는 "기획이 확정한 결정"으로 읽힌다. 그래서 [확정]/[보류]/[아트결정] 분류, 근거 의무, 그리고 "메모에 없으면 지어내지 마라"는 못을 박는다.
---
title: 06_UI 결정사항 — 스킬 슬롯
layer: L1
---
# 06_UI 결정사항 — 스킬 슬롯
| 항목 | 분류 | 결정/제약 | 근거 | 검증가능 |
|---|---|---|---|---|
| 스킬 슬롯 버튼 크기 | [확정] | 최소 44×44 pt 이상 | 모바일 터치 타깃, Apple HIG 44pt 권장 | ✅ 좌표·크기 |
| primary 톤 | [아트결정] | 파란 계열 유지(제약), 정확한 값은 아트 | 메모: "primary 톤은 파란 계열 유지" | △ 색상값 |
| 쿨다운 상태 표현 | [확정] | 회색 오버레이 + 잔여 숫자 | 회의 합의 | ◯ 시각 검수 |
| 스킬명 다국어 길이 | [보류] | 추후 회의 | 메모: "일단 보류" — 긴 언어(독·러) 검증 미실시 | — |
| 롱프레스 스킬 설명 | [확정] | 롱프레스 → 툴팁 표시 | 기능 확정(기획 영역) | ◯ 동작 검수 |
| 슬롯 간 간격 | [확정] | 최소 8 dp 권장 | 근거 미기재 — 메모에 없음, Material 권장값 참고 | ✅ |
이 출력을 그대로 정본으로 올리면 안 된다. 한 줄이 걸린다.
마지막 줄 슬롯 간 간격 | 최소 8 dp다. 메모를 다시 보면, 회의에서 간격 얘기는 나온 적이 없다. AI가 "모바일 UI니까 간격 기준도 있어야겠지"라고 판단해 Material Design 권장값을 친절하게 추가한 것이다. 규칙 5("메모에 없는 항목 지어내기 금지") 위반이다. AI는 근거 미기재라고 솔직히 적긴 했지만, 항목 자체를 만들지는 말았어야 했다. 이 한 줄이 아트팀에게 가면 "기획이 8dp 간격을 확정했다"로 읽힌다.
그래서 재요청한다.
'슬롯 간 간격'은 회의 메모에 없고 네가 추가한 거야. 표에서 빼줘.
메모엔 없지만 결정이 필요해 보이는 건 표 말고 맨 아래 '## 미결 — 다음 회의 안건'에 후보로만 올리고, 결정사항 표엔 메모에 실제로 있던 항목만 남겨줘.
AI는 간격 항목을 표에서 빼고, 맨 아래에 "다음 회의 안건: 슬롯 간 간격 기준(현재 미정), 다국어 스킬명 길이 처리"를 후보로 분리했다. 이제 결정사항 표에는 회의에서 실제로 정한 것만 남고, AI가 떠올린 합리적 후보는 "확정"이 아니라 "안건"으로 강등됐다. 이 분리가 중요한 이유는, 아트팀이 받는 문서에서 무엇이 확정이고 무엇이 아직 논의 중인지가 섞이면, 아트가 미정 사항을 확정으로 알고 작업을 시작하기 때문이다.
이 한 번의 왕복으로 1단(md)이 완성됐다. 이제 사람의 손을 떠나 2~3단 자동화로 넘어간다.
완성된 md는 이제 스크립트가 처리한다. 변환 스크립트의 골격은 단순하다.
# _convert_md_to_html.py (골격)
# 입력: 06_UI/*.md (기획자가 쓴 결정사항)
# 출력: 같은 이름의 .html (아트팀이 브라우저에서 열 자기완결 파일)
def convert(md_path):
md_text = read(md_path)
front, body = split_frontmatter(md_text) # title·layer 추출
html_body = markdown_to_html(body, extensions=[
"tables", # 표 렌더링 (아트가 메모장서 보던 깨진 표 해결)
"fenced_code",
])
html_body = embed_images_inline(html_body, base_dir=md_path.parent)
# ↑  같은 참조를 인라인 임베드 →
# 아트가 이미지 파일을 따로 안 받아도 됨
toc = build_toc(html_body) # 목차 자동 생성
return render_template(title=front["title"], toc=toc, body=html_body)
여기서 변환이 단순 md→html이 아니라는 점이 핵심이다. 세 가지를 더 한다. 표를 제대로 렌더링하고(아트가 메모장에서 보던 깨진 |---|가 사라진다), 이미지를 인라인 임베드하고(아트가 이미지 파일을 따로 받을 필요가 없다), 목차를 자동으로 단다(결정사항이 길어져도 아트가 원하는 항목으로 점프한다). 이 세 가지가 "html만 보면 된다"를 실제로 성립시킨다.
전달 스크립트는 이렇게 묶인다.
REM _SyncToArtRepo.bat (골격)
REM 1) 06_UI의 모든 md를 html로 변환
python _convert_md_to_html.py 06_UI\*.md
REM 2) 변환된 html을 아트 SVN 작업본으로 복사
xcopy 06_UI\*.html %ART_REPO%\UI\ /Y
REM 3) 아트 SVN에 자동 커밋·push (별도 저장소)
svn add %ART_REPO%\UI\*.html --force
svn commit %ART_REPO%\UI -m "[auto] 06_UI 결정사항 갱신"
기획자가 하는 일은 _SyncToArtRepo.bat 더블클릭 한 번이다(혹은 결정사항 커밋 시 자동 실행되도록 훅을 건다). 그러면 변환·복사·아트 저장소 push가 한 번에 돈다. 아트팀은 자기 저장소를 업데이트하면 최신 html이 와 있다.
AI는 어디까지 들어가나 — 이 2~3단 자동화 코드를 AI에게 짜게 시켜도 된다. "md 폴더를 받아 표·이미지 포함 html로 변환하고 별도 SVN으로 push하는 스크립트를 써 줘"는 AI가 잘하는 영역이다. 그러나 어떤 결정을 확정으로 둘지, 무엇을 아트결정으로 넘길지(§9.3.4)는 AI에게 위임하지 않는다. 코드는 AI, 결정은 사람 — 이 책 전체에서 반복되는 분담이 여기서도 그대로다.
아트 협업에서 AI가 잘못 쓰이는 대표 사례가 이미지 프롬프트다. 기획자가 아트팀에 레퍼런스를 줄 때, 혹은 컨셉을 빠르게 시각화할 때 이미지 생성 AI를 쓴다. 이때 흔한 실수는 결과 묘사("파란색 둥근 버튼, 글로우 효과, 4K")부터 적는 것이다.
저자의 협업 원칙 중 하나는 image_prompt_design_intent_first — 이미지 프롬프트도 결과 묘사가 아니라 설계 의도를 먼저 적는다는 것이다.
| 방식 | 프롬프트 | 문제/효과 |
|---|---|---|
| 결과 우선 (나쁨) | "파란 둥근 버튼, 글로우, 4K, 게임 UI" | 아트가 "왜 파란색?"을 못 물음. 의도가 증발 |
| 의도 우선 (좋음) | "쿨다운 가능 상태를 직관적으로 알리는 스킬 버튼. 활성=즉시 누르고 싶은 시각적 끌림, 쿨다운=억제. 톤은 primary 파란 계열" | 아트가 의도를 보고 더 나은 시각안을 역제안 가능 |
차이는 아트팀이 프롬프트를 받았을 때 무엇을 할 수 있느냐다. 결과 묘사만 받으면 아트는 그대로 그리거나 무시하거나 둘 중 하나다. 설계 의도를 받으면, 아트는 그 의도를 더 잘 푸는 자기 시각안을 제안할 수 있다. 이게 기획자가 아트의 결정 영역(§9.3.4의 [아트결정])을 침범하지 않으면서도 방향을 주는 방법이다. 기획자는 "무엇을 위해"를 주고, 아트는 "어떻게 보이게"를 정한다.
그래서 §9.3.4의 결정사항 md에 이미지 레퍼런스를 넣을 때도, 캡션을 "파란 버튼"이 아니라 "쿨다운 상태 구분이 목적인 슬롯 — 정확한 표현은 아트결정"으로 적는다. 변환 스크립트가 이 캡션째로 html에 임베드하므로, 아트는 이미지와 의도를 함께 받는다.
이 파이프라인의 효과를 "협업 사고가 70% 줄었다" 같은 숫자로 적고 싶은 유혹이 있다. 그런 수치는 검증되지 않으면 책의 신뢰를 깎는다. 정직하게 구분한다.
공개 표준으로 검증 가능한 것 — 결정사항에 실리는 터치 44pt·간격 8dp·대비 4.5:1 같은 공개표준은 §9.1 룰북을 따른다. 지어낸 수치가 아니라 그대로 인용하고 lint로 자동 검증할 수 있는 값이다.
측정 가능한 운영 지표 — 이 파이프라인이 실제로 셀 수 있는 것은 이런 것들이다. 아트가 구버전으로 작업한 사고 건수(전달이 자동이면 0에 수렴), 아트팀 신규 입사자가 결정사항을 처음 열어 보기까지 걸리는 시간(html 더블클릭이면 분 단위), 결정 변경이 아트 저장소에 반영되기까지의 지연(스크립트 실행 시간). 이 셋은 "느낌"이 아니라 로그·관측으로 셀 수 있다.
저자 추정(미검증 가설) — "수동 메일 전달 때보다 누락이 줄었다"는 방향은 분명하지만, 정확한 감소율은 표본을 따로 기록하지 않아 단정하지 않는다. 절대값보다 방향으로 읽으면 된다: 전달이 사람 손에 달려 있으면 바쁜 주에 반드시 누락이 나고, 전달이 스크립트면 누락이 구조적으로 사라진다.
혼자라면 이만큼만: 아트팀도 SVN도 없어도 됩니다. 본인이 의뢰하는 외주 아트, 혹은 협업하는 친구에게 UI 결정을 전달한다고 해 보세요. §9.3.4의 프롬프트를 그대로 써서, 머릿속의 산발적인 UI 결정을 [확정]/[보류]/[아트결정]으로 분류한 md 한 장을 AI로 뽑아 보세요. 그중 AI가 "친절하게 추가한" 항목(메모에 없던 것) 하나를 찾아 "이건 내가 정한 적 없다, 빼라"고 반박해 보면, 결정 정리에서 사람과 AI의 경계가 어디인지 몸으로 들어옵니다. 변환은
markdown패키지로python -m markdown decision.md > decision.html한 줄이면 충분합니다.
팀이라면 다음 한 단계로 시작하세요. 거창한 양방향 동기화부터 짜지 말고, 변환 한 줄 + 전달 한 줄부터 넣습니다. 결정사항 md를 html로 바꾸는 변환 스크립트(§9.3.5의 표 렌더링·이미지 임베드만)와, 그 html을 아트가 보는 위치(공유 드라이브든 별도 저장소든)로 복사하는 한 줄. 이 두 줄만 있어도 "아트가 md를 메모장에서 보다 깨진 표를 만나는" 가장 흔한 사고가 사라집니다. 분담표·결정권 정리는 그 다음입니다.
setup → prompt → verify로 요약하면 이렇다.
| 단계 | 할 일 |
|---|---|
| setup | _convert_md_to_html.py(변환) + 전달 한 줄(복사/push)을 먼저 넣습니다 |
| prompt | §9.3.4 프롬프트로 회의 메모를 [확정]/[보류]/[아트결정] md로 정리합니다 |
| verify | AI가 지어낸 항목(메모에 없는 것) 거부 → 변환·전달 자동 실행 → 아트가 html만 확인합니다 |
금요일 저녁 6시 40분. 다음 주 월요일 사내 빌드에 퀘스트 12종을 새로 넣기로 한 날이었다. 나는 quest_table에 새 행을 붙이고, 보상 시트에 대응 행을 채우고, 다이얼로그 시트에 NPC 대사를 연결했다. 세 시트, 약 50개 행. 눈으로 두 번 훑었고 문제없어 보였다.
월요일 아침 빌드가 깨졌다. 새 퀘스트 중 한 건이 참조하는 reward_id가 보상 시트에 없었다. 금요일 저녁에 보상 행 하나를 지웠다가 다시 추가하면서 id를 한 글자 잘못 친 것이다. rwd_q318을 rwd_q381로. 사람 눈으로는 절대 못 잡는 종류의 오타다. 두 시트는 다른 폴더에, 다른 사람이, 다른 시간에 건드린다. 행이 50개일 때는 눈으로 잡힌다. 30개가 넘는 시트가 서로를 외래키(FK)로 참조하기 시작하면, 사람의 눈은 더 이상 검사 도구가 아니다.
이 챕터는 그 오타를 빌드가 깨지기 전에 잡는 검사 atom 한 종류 — integrity_check_fk — 가 30개 넘는 시트의 FK 정합을 검사하고 깨졌을 때 협업툴(작업·일정을 관리하는 SaaS — 본 프로젝트는 ClickUp을 쓰고, JIRA·Redmine도 같은 자리다)로 담당자에게 통보하는 흐름을, 내가 실제로 굴린 한 세션을 따라가며 보여준다.
남이 만든 것에서 어긋난 한 줄을 찾아내는 일로 나는 이 업계에 처음 들어왔다. 싱글 플레이 게임의 QA·검수가 첫 일이었고, 그때는 손과 눈이 유일한 검사 도구였다. 20여 년이 지난 지금은 같은 일을 코드에게 넘긴다 — 사람의 눈이 검사 도구이기를 그만둔 자리에.
먼저 무엇을 검사하는지 그림으로 본다. 게임 데이터 시트는 관계형 데이터베이스와 같다. 한 시트의 컬럼이 다른 시트의 기본키를 가리킨다. 이 화살표가 끊기면 런타임에 게임이 죽거나, 더 나쁘게는 조용히 빈 값을 띄운다.
초록 실선은 살아 있는 참조다. quest_table.reward_id가 가리키는 값이 reward_table.reward_id에 실제로 존재한다. 빨간 점선은 죽은 참조다 — 어떤 퀘스트의 npc_id가 npc_table에 없는 id를 가리킨다. integrity_check_fk가 잡는 것이 이 빨간 점선이다.
프로젝트 A의 데이터 시트는 이런 참조가 한두 개가 아니다. 30개가 넘는 시트가 서로를 가리키고, FK 화살표를 다 그리면 한 페이지에 안 들어간다. 사람이 매번 손으로 따라가는 것은 불가능하다.
여기서부터는 내가 실제로 돌린 세션이다. 요약하지 않고, 프롬프트 전문과 날것 출력, 그리고 내가 거부하고 다시 요청한 부분까지 그대로 적는다.
먼저 검사기에게 "어느 컬럼이 어느 시트를 가리키는가"라는 FK 지도부터 만들게 했다. 30개 시트의 FK를 사람이 일일이 등록하는 것은 비현실적이라, 명세서 시트의 컬럼 명명 규칙(*_id는 같은 이름의 시트를 가리킨다)으로 후보를 자동 추론하게 했다. 내가 입력한 프롬프트 전문은 다음과 같다.
첨부한 명세서 시트(
$스키마탭)를 읽고, 모든 데이터 시트에서 외래키(FK)로 보이는 컬럼을 추론해. 규칙: (1) 컬럼명이xxx_id형태이고 (2)xxx_table또는xxx_list시트에 같은 이름의 PK(Primary Key, 기본 키) 컬럼이 존재하면 FK 후보로 본다. 추론 결과를{source_sheet, source_column, target_sheet, target_column, confidence}리스트로 JSON 출력. 규칙으로 단정 못 하는 건 confidence를 low로 표시하고 이유를 적어.
Claude의 날것 출력 일부(애매한 항목을 포함해 그대로 옮긴다):
[
{"source_sheet":"quest_table","source_column":"reward_id",
"target_sheet":"reward_table","target_column":"reward_id","confidence":"high"},
{"source_sheet":"quest_table","source_column":"npc_id",
"target_sheet":"npc_table","target_column":"npc_id","confidence":"high"},
{"source_sheet":"quest_table","source_column":"prev_quest_id",
"target_sheet":"quest_table","target_column":"quest_id","confidence":"medium",
"note":"이름이 quest_id가 아니라 prev_quest_id라 규칙에 안 걸림. 같은 시트 자기참조로 추정. 사람 확인 필요"},
{"source_sheet":"reward_table","source_column":"item_id",
"target_sheet":"item_table","target_column":"item_id","confidence":"high"},
{"source_sheet":"dialogue_table","source_column":"speaker_id",
"target_sheet":"npc_table","target_column":"npc_id","confidence":"low",
"note":"speaker_id가 npc_id를 가리키는지 player를 가리키는지 불명. speaker가 'player'인 행도 있어 보임. 단정 불가"}
]
여기서 두 항목을 그냥 통과시키지 않았다. prev_quest_id(자기참조)는 검사 대상으로 채택하되 "빈 값(첫 퀘스트)은 위반이 아니다"라는 예외를 직접 달았다. dialogue_table.speaker_id는 거부했다 — speaker가 'player'인 행이 실제로 있어서, 이걸 FK로 검사하면 모든 플레이어 대사가 거짓 위반으로 잡힌다.
이 거부가 중요하다. 자동 추론한 FK 지도를 검수 없이 그대로 쓰면 첫 실행에서 거짓 위반 수백 건이 쏟아진다. 사람이 한 번 걸러야 검사가 신뢰를 얻는다.
거른 FK 지도를 integrity_check_fk atom의 입력으로 고정했다. atom 양식은 다음과 같다. 이것은 프로젝트 A에서 실제로 쓰는 검사 atom 한 개의 전문이다.
---
name: integrity_check_fk
description: 등록된 FK 지도에 따라 모든 source 컬럼 값이 target 시트의 PK에 존재함을 검증
type: integrity_check
category: data
priority: P0 # 깨진 FK는 빌드 차단
execution_time:
- on_save # 시트 저장 시 해당 시트만
- on_build # 빌드 시 전체 FK
- nightly # 매일 자정 전체 + 리포트
input:
fk_map: fk_map.reviewed.json # 1~2단계에서 사람이 검수한 지도
output_format: violation_list
on_violation:
- notify: clickup # 실패 시 ClickUp 통보
related_atoms:
- integrity_check_clickup_notify
- integrity_check_id_uniqueness
---
검사 로직 자체는 길지 않다. source 시트의 각 값이 target 시트의 PK 집합에 있는지 확인하는 집합 멤버십 검사다.
def check_fk(fk_map, sheets):
violations = []
for fk in fk_map:
pk_set = {r[fk["target_column"]] for r in sheets[fk["target_sheet"]]}
for i, row in enumerate(sheets[fk["source_sheet"]]):
val = row[fk["source_column"]]
if val in ("", None): # 빈 FK는 예외 (1단계에서 정한 규칙)
continue
if val not in pk_set:
violations.append({
"fk": f'{fk["source_sheet"]}.{fk["source_column"]}',
"row": i + 2, # 헤더 1줄 + 1-index
"value": val,
"target": fk["target_sheet"],
"severity": fk.get("severity", "P0"),
})
return violations
검수한 지도로 30개 시트 전체에 검사를 돌렸다. 출력은 표준 violation_list다. 다음은 그날 실제로 나온 결과다(id·시트명은 익명화, 위반 건수와 구조는 실제).
{
"check": "integrity_check_fk",
"executed_at": "2026-05-18 09:14:02",
"input_files": 31,
"violations": [
{"fk": "quest_table.reward_id", "row": 318, "value": "rwd_q381",
"target": "reward_table", "severity": "P0",
"message": "reward_id 'rwd_q381'가 reward_table에 없음. 'rwd_q318'의 오타로 추정"},
{"fk": "quest_table.prev_quest_id", "row": 502, "value": "q_0500",
"target": "quest_table", "severity": "P0",
"message": "prev_quest_id 'q_0500'가 quest_table에 없음. 'q_500' 표기 불일치(0 패딩) 추정"}
],
"summary": {"fk_checked": 23, "rows_scanned": 4117, "violations": 2, "passed": 4115}
}
금요일 저녁의 그 오타(rwd_q381)가 첫 줄에 잡혔다. 두 번째는 내가 몰랐던 다른 문제였다. 어떤 퀘스트의 prev_quest_id가 q_0500인데, 실제 퀘스트 id는 q_500이었다. 0 패딩이 들어간 표기 불일치. 사람 눈에는 같아 보이지만 문자열로는 다른 값이고, 게임은 선행 퀘스트를 못 찾아 그 퀘스트를 잠금 상태로 둔다. 출시됐으면 플레이어 문의가 들어왔을 종류의 결함이다.
message 필드의 "오타로 추정", "0 패딩 추정"은 검사기가 단순 멤버십 실패를 넘어 가장 가까운 PK 값(편집 거리 기준)을 함께 제시하게 한 부분이다. 사람이 "이게 왜 깨졌나"를 추적하는 시간을 줄인다. 다만 이 추정은 어디까지나 힌트이고, 실제 수정 값은 사람이 정한다.
여기까지가 검사 한 개의 동작이다. 하지만 검사가 위반을 잡았어도 아무도 안 보면 의미가 없다. 핵심은 위반이 곧장 담당자에게 닿는 흐름이다. 프로젝트 A에서 이 흐름은 integrity_check_clickup_notify라는 별도 atom이 담당한다(JIT 메타데이터상 임팩트 점수 294.93으로, 검증 atom 묶음에서 가장 높게 평가된 atom 중 하나다 — 정합성 실패를 사람에게 닿게 하는 것이 검사 자체만큼 중요하다는 뜻이다).
전체 cascade는 다음과 같다. 검사 atom들이 차례로 실행되고, 어느 단계에서 P0 위반이 나오면 통보 atom으로 흘러간다.
flowchart TD
A[시트 저장 / 빌드 트리거] --> B[integrity_check_id_uniqueness
PK 중복 검사]
B -->|중복 있음 P0| F[빌드 차단]
B -->|통과| C[integrity_check_fk
FK 지도 기반 정합 검사]
C -->|깨진 FK 0건| D[integrity_check_range
보상·수치 범위 검사]
C -->|깨진 FK 있음 P0| E[integrity_check_clickup_notify]
D -->|범위 위반 P1| E
D -->|통과| G[검사 PASS · 빌드 진행]
E --> H{severity?}
H -->|P0| I[협업툴 태스크 생성
+ 담당자 멘션 + 빌드 차단]
H -->|P1| J[협업툴 코멘트 + alert
빌드는 진행]
I --> F
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
classDef fail fill:#fee2e2,stroke:#dc2626,color:#7f1d1d;
class A data;
class B,C,D,E,H,I,J code;
class G pass;
class F fail;
이 cascade에는 두 가지 설계 결정이 들어 있다.
첫째, PK 중복 검사가 FK 검사보다 먼저다. FK 검사는 target 시트의 PK가 유니크하다는 것을 전제로 한다. PK가 중복이면 "이 값이 PK 집합에 있는가"라는 질문 자체가 무의미해진다. 그래서 integrity_check_fk의 atom에 related_atoms: integrity_check_id_uniqueness를 명시하고, cascade에서 순서를 고정했다. 의존 검사가 실패하면 FK 검사는 건너뛴다 — 돌려봐야 거짓 결과만 나오기 때문이다.
둘째, 통보의 강도는 severity로 갈린다. P0(깨진 FK)는 협업툴에 태스크를 만들고, FK 지도에 등록된 담당자(reward_table이면 보상 담당)를 멘션하고, 빌드를 막는다. P1(보상 수치가 권장 범위를 벗어남 — 틀렸다기보다 검토가 필요한 경우)은 코멘트와 alert만 남기고 빌드는 통과시킨다. 모든 위반을 빌드 차단으로 만들면, 사람들은 곧 빌드 차단을 무시하는 법을 배운다. 차단은 진짜 막아야 할 것에만 쓴다.
협업툴로 실제로 생성되는 태스크의 본문은 violation_list의 한 항목이 그대로 변환된 형태다.
[P0] integrity_check_fk 위반 — 빌드 차단됨
시트: quest_table | 컬럼: reward_id | 행: 318
값 'rwd_q381'가 reward_table에 없습니다.
가장 가까운 후보: 'rwd_q318' (편집거리 1)
담당: @보상_담당 | 검출: 2026-05-18 09:14 | 빌드: nightly-0042
검사 결과가 사람의 받은편지함에 닿기까지 손이 한 번도 안 들어간다. 검사 → 분류 → 태스크 생성 → 멘션이 한 파이프라인이다. 이게 가능한 이유는 violation_list가 표준 출력 양식이기 때문이다. 어떤 검사 atom이 잡았든 출력 구조가 같아서, 통보 atom 하나가 모든 검사의 결과를 받아 처리한다.
검사를 처음 켜면 거짓 위반이 반드시 나온다. 1단계의 speaker_id가 그 예다. 이걸 방치하면 사람들은 위반 리포트를 "어차피 대부분 거짓이니 안 봐도 된다"고 학습한다 — 검사기 신뢰가 무너지는 가장 흔한 경로다.
프로젝트 A에서는 human_review_attestation_evidence_mandatory라는 원칙으로 이를 막는다. 거짓 위반으로 판정해 예외 처리할 때, 누가·언제·왜 그렇게 판단했는지를 증거로 남겨야 한다. FK 지도 파일(fk_map.reviewed.json)의 예외 항목마다 다음이 붙는다.
{
"source_sheet": "dialogue_table", "source_column": "speaker_id",
"excluded": true,
"review": {
"by": "이민수", "at": "2026-05-18",
"reason": "speaker_id는 npc_id 또는 'player' 리터럴을 가짐. FK 단일 검사 부적합.",
"follow_up": "speaker_type 컬럼 추가 후 분기 검사로 재도입 검토"
}
}
이 증거가 없으면, 한참 뒤 "이 컬럼은 왜 검사 안 하지?"라는 의문이 다시 떠올랐을 때 답할 근거가 없다. 그러면 다시 검사에 넣고, 다시 거짓 위반 수백 건을 본다. 검수 증거는 같은 논쟁을 반복하지 않게 한다.
자기 데이터 시트에 FK 검사를 도입하려는 독자를 위한 최소 절차입니다.
setup. 데이터 시트 폴더와, 컬럼 명세(어느 컬럼이 PK이고 어느 게 FK인지)를 한곳에 모읍니다. 명세가 없으면 컬럼명 규칙(*_id)만으로도 시작할 수 있습니다.
prompt. 다음을 검사기에게 입력하세요.
이 데이터 시트들에서 FK 후보를 추론해라.
xxx_id컬럼이xxx_table의 같은 이름 PK를 가리키면 FK로 본다. 결과를{source_sheet, source_column, target_sheet, target_column, confidence}JSON으로 출력하고, 규칙으로 단정 못 하는 건 confidence를 low로 표시하고 이유를 적어라.
verify. 출력된 FK 지도를 반드시 사람이 한 줄씩 검수하세요. 자기참조(prev_*), 리터럴 혼재('player' 같은), 다형 참조(상황에 따라 다른 시트를 가리키는 컬럼)는 자동 추론이 자주 틀립니다. 거른 지도로 검사를 돌리고, 첫 실행에서 나온 위반을 한 건씩 "진짜 깨짐 / 거짓 위반"으로 분류하세요. 거짓 위반은 예외 처리하되 이유를 파일에 남깁니다.
이 세 단계를 거치면, 금요일 저녁의 한 글자 오타가 월요일 빌드를 깨뜨리는 일은 사라집니다. 검사가 토요일 새벽 nightly에서 그 오타를 잡고, 월요일 출근 전에 협업툴 태스크 하나가 담당자를 기다립니다.
1인 축소판. 혼자 작업하고 협업툴이 없어도 이 검사는 의미가 있습니다. FK 지도를 손으로 10줄쯤 적고 위 파이썬 함수 하나만 돌려도 깨진 참조가 잡힙니다. 통보는 콘솔 출력이나 텍스트 파일로 충분합니다. 핵심은 통보 채널이 아니라, "사람 눈이 못 잡는 참조 오류를 기계가 잡아 사람에게 닿게 한다"는 흐름 자체입니다.
밤 11시, nightly 잡이 협업툴에 카드를 하나 꽂았다. 제목은 [integrity] D17 부합 측정 미수행 7일 경과. 적용한 지 일주일 된 결정 하나가 "정말 의도대로 작동했는지" 아무도 확인하지 않은 채 빌드에 남아 있다는 알림이었다. 데이터는 멀쩡했다. 시트 형식도, FK도, enum도 통과했다. 그런데 결정은 검증되지 않았다.
이 간극이 이 챕터의 출발점이다. 데이터가 무결해도 결정은 틀릴 수 있고, 그 틀림을 잡는 자리는 데이터 검사와 다른 곳에 있다. 그 자리를 세 개 층으로 나누고, 각 층에서 AI가 어디까지 보조하고 사람이 어디서 도장을 찍는지를 명시하는 것 — 그게 결정 검증 3-layer 센서다.
check cascade는 네 종류 검사를 한 번에 돌린다 — doc-audit(문서 일관성), data-qa(데이터 품질), integrity(무결성), link(상호참조 끊김). 이 넷이 통과하면 "데이터는 멀쩡하다"는 뜻이다. 하지만 멀쩡한 데이터 위에 틀린 결정이 얹힐 수 있다.
보상 시트가 형식상 완벽해도 그 수치가 인플레이션을 유발하면, FK가 유니크해도 두 퀘스트가 같은 시각 같은 NPC를 점유하면, voice가 일관돼도 두 캐릭터의 관계 설정이 모순되면 — 데이터 검사는 전부 통과하고 결정은 전부 틀린다. 데이터 검사는 "칸이 채워졌는가"를 보고, 결정 검사는 "그 값이 다른 결정·다른 데이터·실제 사용자와 맞는가"를 본다. 회계로 치면 앞은 전표 양식 점검이고 뒤는 재무제표 정합성 감사다.
그래서 결정 검증은 데이터 검증과 분리된 센서를 둔다. 한 검사에 묶으면 "통과/실패" 한 줄로 뭉개져서, 실패했을 때 데이터 문제인지 결정 문제인지 해석이 모호해진다. 분리하면 책임이 명확해진다.
3-layer 센서의 핵심은 검증 차원을 세 개로 쪼갠 점이다. 각 층은 보는 대상도, 작동 시점도, AI와 사람의 역할 분담도 다르다.
flowchart TD
D["결정 D17
defense_factor 1000 → 1500"] --> L1
subgraph L1["Layer 1 · 결정 ↔ 결정"]
L1a["AI: scope 겹침 탐지 + 모순 1차 판정"]
L1b["사람: '모순' 판정만 검수 + 도장"]
L1a --> L1b
end
subgraph L2["Layer 2 · 결정 ↔ 데이터"]
L2a["AI: 영향 데이터 추출 + 시뮬 + ±% 룰"]
L2b["사람: 의도-측정 괴리 해석"]
L2a --> L2b
end
subgraph L3["Layer 3 · 결정 ↔ 사용자"]
L3a["AI: 피드백 분류 + 감정 분석"]
L3b["사람: 샘플 100건 대조 + 최종 부합 선언"]
L3a --> L3b
end
L1 --> L2 --> L3 --> CARD
CARD["결정 카드 D17
3-layer 검증 결과 + 검수 증거"]
CARD --> ATT["human_review_attestation
검수자·시각·증거 첨부 강제"]
style L1 fill:#e8f0ff,stroke:#4a72c0
style L2 fill:#e8f7ed,stroke:#3a9a5a
style L3 fill:#fff3e0,stroke:#d08a2a
style ATT fill:#fde8e8,stroke:#c04a4a
세 층 모두 마지막 도장은 사람이 찍는다 — 그 도장이 곧 human_review_attestation_evidence_mandatory atom이 강제하는 증거다. 각 층이 무엇을 보고 누가 어디서 도장을 찍는지는 아래에서 차례로 본다.
새 결정이 기존 결정과 부딪치는지 검사하는 층이다. 결정 쌍은 결정 수의 제곱으로 늘어나서, 200개 결정이면 약 2만 쌍이다. 사람이 손으로 다 볼 수 없다. 그래서 AI가 1차 필터를 돌린다.
# decision_conflict_check.py — Layer 1 센서
def check_new_decision(new_decision, existing_decisions):
conflicts = []
for existing in existing_decisions:
if has_overlap(new_decision.scope, existing.scope): # 기계적 1차: scope 교집합
verdict = llm_judge(new_decision, existing) # AI 2차: 모순/보완/무관
if verdict.label == "모순":
conflicts.append({
"with": existing.id,
"label": verdict.label,
"reason": verdict.reason,
"needs_human_review": True, # 사람 검수 플래그
})
return conflicts
has_overlap은 기계적 1차 필터다. scope는 atom 영역(combat·narrative·economy 등)이라 교집합 계산이 정확하다. 겹치지 않는 쌍은 즉시 버린다. 2만 쌍이 보통 수십 쌍으로 줄어든다. 그 수십 쌍만 AI가 본다.
여기서 실제로 무엇이 오가는지를 끝까지 보여준다. 요약하지 않고, 애매한 출력도 그대로 둔다.
① 사람이 보낸 프롬프트 (전문)
다음 두 결정이 모순되는지 판단하세요.
[결정 A] (2026-03-XX) 전투 글로벌 쿨다운 0.5초로 전 스킬 통일
[결정 B] (2026-04-XX) 회복 계열 스킬은 글로벌 쿨다운 적용 제외
판정 형식 (이 형식만, 다른 말 금지):
- label: 모순 | 보완 | 무관
- reason: 한 줄
- confidence: 0.0~1.0
추측하지 마세요. 근거가 약하면 confidence를 낮추세요.
② Claude 날것 출력 (가공 없음)
- label: 보완
- reason: 결정 B는 결정 A가 정한 0.5초 통일 규칙에 회복 계열이라는 명시적 예외를 추가하는 구조라, 규칙을 뒤집지 않고 좁힌다.
- confidence: 0.74
③ 사람의 검증 / 거부
판정은 "보완", confidence 0.74다. 룰상 '보완·무관'은 자동 통과지만 confidence 0.8 미만이면 사람이 한 줄이라도 확인하게 돼 있다. 검수자 팀원 A가 결정 B 원문을 다시 읽고 한 가지를 발견했다 — 결정 B에는 "단, 회복 스킬도 자체 쿨다운 1.2초를 가진다"는 단서가 붙어 있었다. AI는 요약된 한 줄만 받아 이 단서를 입력으로 받지 못했다. 팀원 A는 AI 판정을 수용하되 보강 처리하고, "AI가 본 입력이 불완전했다"를 기록으로 남겼다.
④ 재요청 (입력 보강 후)
[추가 단서] 결정 B에는 "회복 스킬은 글로벌 쿨다운은 면제하되 자체 쿨다운 1.2초를
가진다"는 조건이 붙어 있습니다. 이 단서를 포함해 다시 판정하세요.
- label: 보완
- reason: 자체 쿨다운 1.2초가 글로벌 0.5초보다 길어, 회복 스킬은 면제에도 불구하고
오히려 더 긴 간격을 가진다. A의 의도(난사 방지)와 충돌하지 않으므로 보완이 강화됨.
- confidence: 0.91
판정은 그대로 "보완"이지만 근거가 단단해졌고 confidence가 0.74→0.91로 올랐다. 팀원 A가 여기서 도장을 찍었다. 핵심은 결과가 아니라 과정의 기록이다 — AI 1차 판정, 사람이 발견한 입력 누락, 보강 재요청, 최종 검수. 이 네 단계가 그대로 결정 카드의 Layer 1 증거란에 입력된다.
이 트랜스크립트의 원칙은 하나다. AI의 '보완·무관' 판정도 무조건 통과시키지 않는다. AI가 틀린 게 아니라 AI가 받은 정보가 불완전했고, 그걸 발견하는 건 결정 원문을 아는 사람이다.
검사 시점은 세 군데다. 신규 결정 추가 시 즉시 + alert, pending atom 승격 시 검사 후 승격, nightly에 전체 쌍 재검사.
결정이 데이터에 어떻게 반영됐고 의도와 부합하는지 측정하는 층이다. 가장 자동화하기 쉽고 가장 정확하다. 시뮬레이터와 데이터 시트가 이미 있으면 검증 룰만 얹으면 된다.
결정 D17(defense_factor 1000→1500)을 예로 들면, 센서가 자동으로 CombatBalance 시트와 자동 시뮬 결과와 영향받은 캐릭터 데이터를 끌어와, 의도(탱커 생존 +49%)와 측정(시뮬 +52%)을 비교한다. 부합 판정 룰은 정량적이다.
| 의도 대비 측정 차이 | 처리 | 누가 |
|---|---|---|
| ±10% 이내 | 부합 (자동 통과) | AI |
| ±10~25% | alert · 재검토 | 사람이 해석 |
| ±25% 초과 | 위반 · 결정 재검토 의무 | 사람이 결정 |
여기서 사람의 역할은 "AI가 부합이라고 했으니 통과"가 아니다. alert 구간과 위반 구간을 해석하는 것이 사람의 일이다. D17 시뮬은 +52%로 ±10% 안에 들어 자동 부합이었지만, 같은 시뮬이 부수 효과를 하나 뱉었다 — 하이브리드 캐릭터 K_021이 의도 밖으로 +28% 강해졌다. D17의 직접 의도가 아니라서 부합 룰에 안 걸린다. 룰은 통과인데 사람 눈엔 사고인 이 구간을 잡는 게 Layer 2에서 사람이 존재하는 이유다.
이 층의 자동화율이 약 95%로 가장 높다. 그래도 5%가 남는 건 바로 이 해석 때문이다. 숫자가 룰을 통과하는 것과 그 숫자가 게임에 옳은 것은 다른 질문이다.
세 층 중 가장 어렵다. 결정이 실제 사용자에게 의도대로 작용했는지를 본다. 빌드 출시 1~2주 후의 실측 지표(탱커 평균 생존 시간, 탱커 포함 5:5 PvP 승률)와 자연어 피드백(포럼·SNS)을 입력으로 받는다.
자연어 피드백이 검증의 입력이 된다는 게 이 층의 특징이다. 포럼 약 200건, SNS 약 1,500건을 AI가 카테고리로 분류하고 감정을 매긴다.
[AI 피드백 분류 — 탱커 관련 1주 수집]
긍정 62% 부정 23% ("탱커 너무 강해짐"이 다수) 무관 15%
여기서 멈추면 함정이다. AI 감정 분류는 한국어·영어가 섞이면 정확도가 떨어진다("탱커 강해졌다 ㅋㅋ"가 긍정인지 비꼼인지 판정이 흔들린다). 그래서 운영 규칙으로 분기마다 사람이 샘플 100건을 직접 분류해 AI 결과와 대조한다. 대조에서 오차가 임계 이상이면 그 분기 분류는 신뢰하지 않고 사람이 전수 재분류한다.
최종 부합 선언은 사람이 한다. D17의 경우 실측 +44%(시뮬 예측 +52%, 오차 8% — 정상 범위), 피드백 긍정 우세였다. AI는 "긍정 우세 + 의도 범위 내"라는 입력을 정리해 올렸고, 부합이라고 도장을 찍은 건 사람이다. 자동화율 약 70%, 사람 30%. 이 층만큼은 완전 자동이 원천적으로 불가능하다. 사용자의 의미를 기계가 끝까지 판정할 수 없기 때문이다.
세 층의 마지막 도장이 사람이라면, 그 도장이 실제로 찍혔다는 증거가 없으면 시스템 전체가 무너진다. 검수했다고 말만 하고 안 한 경우를 어떻게 막는가. 프로젝트 A에서는 atom human_review_attestation_evidence_mandatory가 이걸 강제한다.
이 atom의 규칙은 단순하고 타협이 없다. 결정 카드의 어떤 층이든 'AI 판정 → 사람 검수'가 일어났다면, 검수자 식별·검수 시각·검수 증거(보강 메모, 거부 사유, 샘플 대조 결과 중 최소 하나)가 카드에 첨부돼야 한다. 증거가 비면 그 카드는 "검증 완료"로 승격되지 못한다.
증거가 비면 integrity_check_clickup_notify atom이 작동한다. 정합성 실패 — 여기서는 "검수 도장은 있는데 증거가 없음" — 을 감지하면 협업툴에 카드를 즉시 만든다. 이 챕터 첫 장면의 밤 11시 카드가 바로 이 메커니즘이다.
이 두 atom이 짝을 이뤄 "검증의 검증"을 만든다. 3-layer 센서가 결정을 검증하고, attestation atom이 그 검증을 사람이 실제로 했는지 검증하고, notify atom이 증거 누락을 잡아 통보한다. AI 보조는 광범위해도, 책임의 마지막 한 칸은 증거를 남긴 사람의 이름으로 채워진다.
세 층의 결과와 검수 증거가 모이는 단위가 결정 카드다. 카드 한 장이 결정 하나의 완결 단위이고, 분기 회고의 입력으로 흘러간다. 아래는 D17 카드의 구조다.
빨간 줄이 핵심이다. 각 층의 "증거:" 행이 비면 attestation atom이 카드 승격을 막고 notify atom이 협업툴에 알린다. 6개월 뒤 누군가 "왜 defense_factor를 1500으로 했지"라고 물으면, 이 카드 한 장이 의도·측정·실측·검수자까지 다 답한다. 결정 카드는 18부의 의사결정 추적 atom과 같은 메타데이터 흐름 위에서 작동한다.
세 층은 자동화 정도가 다르다(각각 약 80%·95%·70%, 앞 절들에서 본 대로). 셋 다 부분 자동이고 마지막 도장은 셋 다 사람이지만, 사람 작업량은 전체적으로 80% 이상 줄어든다.
도입은 Layer 2부터다. 시뮬과 데이터 시트가 이미 있으면 검증 룰만 추가하면 돼서 1~2개월이면 효과가 난다. 그다음 Layer 1(인프라는 적은데 효과가 큼, 추가 1개월), 마지막에 Layer 3(인프라가 가장 크고 효과도 큼, 추가 2~3개월). Layer 3을 처음부터 붙이려다 좌초하는 게 흔한 실패다.
수치 표기에 관하여: 위 자동화율과 아래 효과 비율은 저자 프로젝트의 운영 관찰에 기반한 저자 추정(미검증)이다. 정밀 측정값이 아니라 방향과 대략적 비율로 읽어야 한다. 부합 룰의 ±10%/±25% 임계는 실제 운영 룰이고, atom 이름(
integrity_check_clickup_notify,human_review_attestation_evidence_mandatory)은 실재 atom이다.
도입 전후의 변화는 방향으로 정리하면 이렇다. 분기당 결정 모순 사고는 여러 건에서 거의 0건으로, 결정 후 1주 부합 측정 수행률은 일부에서 대부분으로, 사고 발생 전 부작용 발견율은 절반 미만에서 대부분으로 올라갔다. 가장 의미 있는 변화는 추적성이다 — 한참 지난 뒤 결정의 배경을 되짚을 수 있는 비율이 소수에서 거의 전부로 바뀌었다. 결정 카드가 게임의 결정 역사를 보존하기 때문이다.
| 패턴 | 처방 |
|---|---|
| Layer 1만 운영 (모순 검사만) | Layer 2·3 추가로 차원을 채운다 |
| Layer 3을 처음부터 도입 | Layer 2부터, 인프라 작은 순서로 |
| AI의 '보완·무관' 판정 무비판 수용 | confidence 임계 + 사람 샘플 검수 |
| 검수 도장만 찍고 증거 미첨부 | attestation atom이 승격 차단 |
| 증거 누락 알림 무시 | notify atom의 협업툴 카드를 미완료로 취급 |
| 사용자 피드백 AI 분류 맹신 | 분기 샘플 100건 사람 대조 |
setup. 결정 로그를 한 파일에 모으세요(결정 id·scope·의도·적용일). scope는 combat·narrative·economy처럼 enum으로 고정합니다. 시뮬이 없으면 Layer 2는 "관련 데이터 시트 수동 비교"로 시작해도 됩니다.
prompt. 새 결정이 생길 때마다 기존 결정과 한 쌍씩 AI에 물으세요. 형식을 고정하세요.
다음 두 결정이 모순되는지 판단하세요.
[결정 A] ...
[결정 B] ...
형식만 출력: label(모순|보완|무관) / reason 한 줄 / confidence 0.0~1.0
추측 금지. 근거 약하면 confidence 낮춤.
verify. '모순' 판정과 confidence 0.8 미만 판정은 사람이 결정 원문을 다시 읽고 확인하세요. 확인했으면 결정 카드에 검수자 이름·시각·메모(보강/거부/대조 중 하나)를 반드시 남깁니다. 증거란이 비면 그 카드는 "검증 완료"로 올리지 마세요 — 이 한 줄이 attestation atom의 1인 버전입니다. 1인 운영이라도 6개월 뒤의 나를 위해 증거는 남깁니다.
월요일 아침 9시 12분. 알파 빌드가 막 올라간 주의 첫 검사 cascade가 끝났다. check가 네 종(doc-audit·data-qa·integrity·link, 10.2)을 한 번에 돌리고 멈췄을 때 콘솔에 찍힌 숫자는 이랬다. 위반 후보 47건. 그중 P0가 몇 건이고, 무엇부터 봐야 하고, 누가 손대야 하는지는 그 47줄 어디에도 적혀 있지 않았다.
검사기는 "틀렸다"는 사실만 안다. "이게 출시를 막느냐, 다음 주에 봐도 되느냐"는 판단하지 못한다. 알파 막바지의 진짜 병목은 검사기가 부족해서가 아니라, 검사기가 토해낸 47줄을 사람이 분류하다가 오전이 다 가버리는 데 있었다. 이 장은 그 47줄을 LLM이 자연어로 분류하고, 사람이 그 분류를 받아 우선순위를 매기는 한 번의 워크드 사이클을 통째로 옮긴다.
10.1에서 검증 atom 30여 종을 만들었고, 10.2에서 결정을 3-layer 센서로 거르는 구조를 세웠다. 두 장이 만들어 낸 것은 로그다. 로그는 결정이 아니다. 로그와 결정 사이에는 사람이 손으로 메우던 간극이 있다.
알파 막바지에 이 간극이 비싼 이유는 단순하다. 검사기는 한 시간에 수십 번 돌지만, 사람이 47줄을 읽고 "q_142는 막다른 길이니 출시 차단, voice_lint 412는 작가 판정 대기"라고 분류하는 작업은 매번 새로 해야 한다. 그 분류 노동을 자연어 모델에 넘기는 것이 Gap Report의 출발점이다.
아래는 그 월요일 아침, 검사 cascade의 원시 로그를 Claude에게 그대로 붙여 넣고 분류를 요청한 실제 세션이다. 요약하지 않고 옮긴다. 모델이 잘못 짚은 곳과 사람이 거부한 곳까지 그대로 둔다. 이게 이 장의 척추다.
아래는 알파 빌드 주간 검사 cascade(doc-audit/data-qa/integrity/link)가 합쳐서 토한 위반 후보야. 주간 회의에서 쓰게 분류해줘.
각 항목을 P0(출시 차단)/P1(검토)/P2(관찰)로 분류하고 근거 한 줄씩 — 추측이면 "추정"이라고 적어. severity는 네가 단정하지 말고 '제안'만 해, 확정은 내가 한다.
같은 뿌리에서 나온 건 묶어주고, 담당 분야(레벨/내러티브/밸런스/UI/데이터)도 추천해줘. 판정 못 하는 건 솔직히 "분류 불가, 사람 확인 필요"로 빼줘.
[원시 로그]
INTEGRITY q_142 quest_graph: node 'end_021' has no outgoing edge (dead-end), zone=dungeon_021
INTEGRITY q_158 quest_graph: reward node references item_id 'it_9920' not in item table
DATA-QA reward_curve dungeon_017 reward_exp delta +35.2% vs sibling median (threshold 25%)
DATA-QA reward_curve dungeon_017 reward_gold delta +31.0% vs sibling median
DOC-AUDIT voice_lint dialogue_id_412 contains banned term (refgame slang leak)
DOC-AUDIT voice_lint dialogue_id_511 tone drift: formality score 0.31 (npc 'elder' expects >0.7)
DOC-AUDIT voice_lint dialogue_id_512 tone drift: formality score 0.29
DOC-AUDIT voice_lint dialogue_id_513 tone drift: formality score 0.33
DATA-QA loc_overflow ui_btn_enhance EN string 24 chars > slot 18 (overflow 6)
DATA-QA loc_overflow ui_btn_enhance TH string overflow 11
LINK doc_link gdd_dungeon_021.md -> [reward_table] target moved (404)
INTEGRITY k_skill skill 'k_017_charge' cooldown 0.0 (suspicious, min expected 0.5)
... (총 47줄, 이하 동형 패턴 — 생략 없이 첨부됨)
원시 로그를 분류했습니다. severity는 제 "제안"이며 최종 확정은 사람이 해야 합니다.
## P0 후보 (출시 차단 가능 — 사람 확정 필요)
1. [q_142] quest_graph dead-end (dungeon_021)
근거: 출구 엣지가 없는 종료 노드 → 플레이어가 퀘스트 진행 중 갇힘.
막다른 길은 진행 불가이므로 P0 후보. (확정 근거 명확)
담당 추천: 레벨/내러티브
2. [q_158] reward node가 존재하지 않는 item_id 'it_9920' 참조
근거: 보상 지급 시 NULL 참조 → 크래시 또는 보상 미지급 가능.
P0 후보. 단, item_id가 아직 미입력 상태(작업 중)일 수도 있어
"데이터 미완성"인지 "오타"인지는 사람 확인 필요.
담당 추천: 데이터
3. [voice_lint dialogue_id_412] 금기 어휘(refgame 용어 유출)
근거: 타 게임 고유 용어가 대사에 남아 있으면 법적·브랜드 리스크.
P0 후보. (금기 어휘는 보통 무조건 차단)
담당 추천: 내러티브
## P1 후보 (검토 후 결정)
4. [reward_curve dungeon_017] exp +35.2% / gold +31.0% (둘 다 형제 중앙값 초과)
※ 두 줄을 1건으로 묶음 — 같은 던전의 보상 곡선 이상.
근거: 임계치(25%) 초과지만 "의도된 고난도 던전 보너스"일 수 있음.
밸런스 의도인지 실수인지는 데이터만으로 판정 불가 → P1.
담당 추천: 밸런스
5. [voice_lint 511·512·513] tone drift 3건 (formality 0.29~0.33, elder NPC가 >0.7 기대)
※ 3줄을 1건으로 묶음 — 같은 NPC 'elder'의 연속 대사 톤 이탈.
근거: 같은 NPC가 연속으로 반말체로 빠진 패턴 → 한 작가의
한 세션 작업물일 가능성. 개별이 아니라 묶어서 검토 권장.
추정: 의도된 캐릭터 변화(분노 장면 등)일 수도 있어 작가 판정 대기. P1.
담당 추천: 내러티브
6. [loc_overflow ui_btn_enhance] EN 6자 / TH 11자 초과
※ 2줄 1건. 같은 버튼의 다국어 오버플로.
근거: 잘림은 UX 손상이나 크래시는 아님. 슬롯 확장 or 단축 번역 필요. P1.
담당 추천: UI
## P2 후보 (관찰)
7. [doc_link gdd_dungeon_021.md -> reward_table 404]
근거: 문서 내부 링크 깨짐. 게임 빌드에는 영향 없음, 문서 정합성 문제. P2.
담당 추천: 데이터(문서 소유자)
## 분류 불가 — 사람 확인 필요
8. [k_017_charge cooldown 0.0]
근거: 쿨다운 0.0이 "의도된 패시브성 스킬"인지 "입력 누락"인지
스킬 설계 의도를 모르면 판정 불가. integrity 검사기는 0.5 미만을
의심했지만, 저는 이 스킬의 설계 의도를 모릅니다.
→ 밸런스 담당이 직접 확인해야 합니다.
(나머지 39줄도 위 7개 패턴 중 하나로 분류 — 본 답변에서는 대표만 전개)
여기서부터가 핵심이다. LLM의 분류를 그대로 채택하지 않았다. 저자가 회의 전에 직접 검토하며 빨간 펜을 댄 부분이다.
it_9920은 두 주 전 삭제된 아이템이었다. 미완성이 아니라 끊긴 참조. 출시 차단 확정.모델이 7개 묶음으로 압축한 것 자체는 컸다. 47줄을 사람이 처음부터 분류했으면 오전이 갔다. 하지만 P0 후보 3건 중 1건(412)을 사람이 강등했고, P1 후보 1건(158)을 사람이 승격했다. 분류의 60%는 맞았고, 비싼 30%는 사람이 고쳤다. 이 비율이 정확히 "LLM은 가공, 결정은 사람"의 경계선이다.
좋아. 네 분류에서 두 개를 내가 바꿨어.
- q_158: P0 확정 (it_9920은 삭제된 아이템, 끊긴 참조)
- voice_lint_412: P1로 강등 (의도된 옛 표현 인용, 금기 사전 예외 추가)
이 둘 반영해서 주간 회의용 1페이지 Gap Report 마크다운으로 렌더해줘. 요약→P0→P1→P2→추세 순서로.
추세 숫자는 내가 줄게 — 지난주 P0 5건, P1 22건, 거짓양성 12%.
모델은 이 입력을 받아 아래 §보고서 양식의 1페이지를 그대로 출력했다. 사람이 고친 두 줄이 정확히 반영됐고, 추세 숫자는 사람이 준 값을 그대로 썼다(지어내지 않았다). 이 왕복이 Gap Report 한 장이 만들어지는 전부다.
위 트랜스크립트를 흐름으로 추리면 이렇게 된다. 굵은 분기점이 전부 사람에게 있다는 점이 핵심이다.
flowchart TD
A[검사 cascade check
doc-audit+data-qa+integrity+link] --> B[원시 위반 로그 47줄]
B --> C{LLM 1차 분류}
C -->|severity 제안| D[P0 후보]
C -->|severity 제안| E[P1 후보]
C -->|severity 제안| F[P2 후보]
C -->|판정 불가| G[분류 불가
사람 확인 필요]
C -->|같은 뿌리| H[중복 묶음]
D --> I{사람 검증}
E --> I
F --> I
G --> I
I -->|채택| J[severity 확정]
I -->|승격/강등| K[사람이 수정]
K --> J
J --> L[Gap Report 1페이지 렌더]
L --> M[주간 회의 입력]
M --> N{정합성 실패?}
N -->|예| O[integrity_check_clickup_notify
협업툴 즉시 통보]
N -->|아니오| P[담당·마감 배정]
style C fill:#e8f0fe
style I fill:#fef3e8
style O fill:#fde8e8
LLM이 만지는 박스는 파란색 하나뿐이다. 주황색(사람 검증)에서 모든 severity가 확정되고, 빨간색에서 정합성 실패가 협업툴로 즉시 튄다. 검사·판정·확정은 전부 사람과 atom의 몫이고, 모델은 첫 분류 한 번만 맡는다.
분류 흐름의 끝에 integrity_check_clickup_notify atom(10.1)이 붙어 있다. 이 atom은 보고서를 만드는 단계와 별개로, 정합성 검사가 실패하는 순간 회의를 기다리지 않고 협업툴에 카드를 던진다. Gap Report가 주간 리듬이라면, 이 atom은 그 리듬을 깨고 들어오는 인터럽트다.
q_158(삭제된 아이템 참조)처럼 빌드 자체를 깨뜨릴 수 있는 위반은 월요일 회의까지 기다릴 수 없다. cascade가 그걸 잡는 순간 협업툴에 "P0 의심: q_158 끊긴 참조"가 자동 생성되고 데이터 담당에게 할당된다. Gap Report는 그 인터럽트들을 한 주 단위로 다시 모아 추세로 보여주는 뒤판이다. 두 층이 같이 돌아가야 "급한 건 즉시, 전체 그림은 주간"이라는 두 박자가 맞는다.
LLM 분류를 사람이 검증했다는 사실은 말로 남으면 증발한다. 그래서 검수 단계에는 human_review_attestation_evidence_mandatory atom(10.2)이 걸려 있다 — 사람 검수에는 증거가 필수다.
위 트랜스크립트 ③단계 — 412를 강등하고 158을 승격한 그 판단 — 은 보고서 푸터에 검수자 ID·타임스탬프와 "변경한 항목" 목록으로 입력된다. 다음 분기에 누가 "왜 412가 출시에 나갔느냐"고 물으면, "2026-W21 검수에서 의도된 옛 표현으로 판정, 금기 사전 예외 추가"라는 기록이 답한다. 이게 없으면 LLM 분류는 검증된 적 없는 자동 출력과 구분이 안 된다.
재요청 ④의 결과로 모델이 렌더한 1페이지는 이런 형태다. 위 트랜스크립트의 분류가 그대로 흘러 들어왔다.
# Alpha Gap Report — 2026-W21
## 요약
- 검사 cascade 47건 위반 후보 → 7개 묶음으로 분류
- P0 확정 3건 / P1 4건 / P2 1건 / 분류 불가 1건
- 출시 차단: q_142(막다른 길), q_158(끊긴 참조)
- 사람 검수 변경: voice_412 강등(P0→P1), q_158 승격(P1→P0)
## P0 — 즉시 조치 (사람 확정)
| ID | 위반 | 분야 | 비고 |
|---|---|---|---|
| q_142 | dungeon_021 막다른 길 | 레벨/내러티브 | LLM·사람 일치 |
| q_158 | 삭제된 it_9920 참조 | 데이터 | 사람이 승격 |
## P1 — 검토 후 결정
- reward_curve dungeon_017: exp+35%/gold+31% (밸런스, 의도 확인 대기)
- voice 511·512·513: elder 톤 이탈 3건 묶음 (내러티브, 작가 판정)
- voice_412: 옛 표현 인용 (내러티브, 금기 예외 처리됨)
- loc_overflow ui_btn_enhance: EN/TH 잘림 (UI)
## P2 — 관찰
- doc_link 404 (문서 정합, 빌드 영향 없음)
## 분류 불가 — 사람 확인 필요
- k_017_charge cooldown 0.0 (밸런스, 설계 의도 미상)
## 추세 (지난주 대비)
- P0: 3건 (지난주 5건)
- P1: 4묶음 (지난주 22건 — 묶음 분류로 카운트 방식 변경)
- 거짓양성: 사람 수정 2/8 = 25% (지난주 12%, ↑ — 묶음 후 표본 작아짐)
---
검수: 이민수 / 2026-W21 / 변경 2건 (증거: §검수 로그)
추세의 거짓양성 비율이 25%로 올라간 것을 숨기지 않은 점을 보라. 표본이 8개로 작아졌고 사람이 2개를 고쳤으니 산술적으로 25%다. 보고서는 좋아 보이려고 숫자를 만들지 않는다. 지난주 12%와 단순 비교하면 악화처럼 보이지만, 분류 방식이 묶음으로 바뀌면서 표본이 달라진 맥락이 한 줄로 붙어 있다. 한 주의 비율만으로 결론 내지 않는다는 원칙이 여기서 작동한다.
저자의 프로젝트 A에서 Gap Report 워크드 분류를 도입하기 전후를 비교한다. 아래 수치 중 처리 비율·시간은 회의록과 협업툴 타임스탬프에서 뽑은 실측이고, 검사기 거짓양성률은 표본이 주마다 흔들려 방향만 적는다.
| 항목 | 도입 전 | 도입 후 | 근거 |
|---|---|---|---|
| 47줄 1차 분류 소요 | 사람 ~40분 | LLM 1회 + 사람 검토 ~12분 | 회의 전 작업 로그(실측) |
| 검사 결과 → 결정 반영 | 일부만 | 대부분 | 회의록 대조(실측, 정확한 % 미집계) |
| P0 평균 해소 시간 | 3~5일 | 1~2일 | 협업툴 카드 생성→완료 타임스탬프(실측) |
| LLM 분류 사람 수정률 | — | W21 기준 2/8 | 저자 추정(미검증, 주마다 변동) |
| 정합성 실패 인지 지연 | 회의까지 대기 | 즉시(atom 통보) | clickup_notify 도입 효과(방향) |
수정률 2/8을 자랑처럼 적지 않은 이유가 있다. 그건 한 주의 표본일 뿐이고, 어떤 주는 모델이 다섯 건을 잘못 짚는다. 확실한 이득은 분류 노동이 40분에서 12분으로 준 것이고, 모델 분류의 정확도 자체는 매주 흔들린다 — 모델을 신뢰해서가 아니라 사람이 12분 안에 검증할 수 있는 형태로 가공해 주기 때문에 빨라진 것이다.
| 패턴 | 처방 |
|---|---|
| 47줄을 사람이 매번 손으로 분류 | LLM 1차 분류 → 사람 검증으로 분업 |
| LLM severity를 그대로 확정 | severity는 "제안", 확정은 사람(③단계) |
| 같은 뿌리 위반을 개별 카운트 | 묶음 요청을 프롬프트에 명시 |
| 검수 사실이 말로만 남음 | human_review_attestation atom으로 증거 강제 |
| 급한 정합성 실패가 회의까지 대기 | clickup_notify atom으로 즉시 통보 |
| 추세 숫자를 모델이 지어냄 | 추세는 사람이 입력, 모델은 렌더만(④단계) |
| 보고서가 길어져 회의에서 안 봄 | 1페이지 강제, 원본 로그는 따로 보존 |
setup 1. 검사 cascade(또는 가진 lint·정합성 검사기 묶음)의 출력을 한 파일로 모으세요. 2. severity 기준 3단계(P0 차단 / P1 검토 / P2 관찰)를 팀이 한 줄씩 합의해 둡니다. 3. 검수자 ID·타임스탬프를 보고서 푸터에 입력하는 템플릿을 만듭니다.
prompt
아래는 주간 검사 출력이야. 주간 회의용으로 분류해줘. severity(P0/P1/P2)는 근거 한 줄 달아 '제안'만 하고(추측이면 "추정"), 확정은 내가 할게. 같은 뿌리 위반은 묶고, 담당 분야(이름 말고)도 추천해줘. 판정 못 하는 건 솔직히 "분류 불가"로 빼줘.
[원시 로그 붙여넣기]
verify 1. P0 후보 전부를 사람이 한 건씩 검증해 강등/승격을 기록하세요(③단계). 2. 모델이 묶은 항목이 정말 같은 뿌리인지 한 개만 역추적해 확인하세요. 3. 추세 숫자가 사람 손에서 나온 값인지(모델이 임의로 채워 넣지 않았는지) 푸터에서 확인하세요.
혼자 작업한다면 atom·협업툴·주간 회의는 없어도 됩니다. 검사기 출력을 텍스트로 붙여 위 프롬프트로 분류만 받고, P0 후보 3건만 직접 눈으로 검증한 뒤, 그 자리에서 처리하세요. 분류를 모델에 맡기고 검증할 항목을 P0로만 좁히는 것 — 그 한 가지가 1인 규모에서 가장 크게 시간을 아낍니다. 보고서 1페이지는 노션 메모 한 장으로 대체해도 됩니다.
스프린트 마감 이틀 전, 전투 아티스트가 팀 메신저로 짧은 영상 하나를 던졌다. 신규 무사 클래스의 3타 콤보. 1타와 2타는 칼바람이 도는데 3타에서 아무 소리도 안 났다. 무음. 본인은 사운드를 다 붙였다고 했고, 사운드 담당은 파일을 다 넘겼다고 했다. 둘 다 거짓말을 하는 게 아니었다. 사운드 파일은 분명히 저장소에 있었다. combo3_swing_final_real.wav라는 이름으로. 게임 코드가 찾던 이름은 sfx_K012_combo3_swing.wav였다. 한 글자도 안 겹친다.
이 무음 사고를 추적하는 데 그날 오후가 통째로 들어갔다. 클립 하나, 사운드 하나의 문제가 아니다. 이름을 사람이 자유롭게 짓는 한, 이런 사고는 분기마다 수십 건씩 다시 태어난다. 이 챕터는 그 자유를 룰로 바꾸는 이야기다.
이 챕터에서 답하는 질문 - 자원 1만 개 규모에서 이름은 왜 자유가 아니라 룰인가 - 명명 규약을 atom으로 강제하고 lint로 자동 검증하면 무엇이 닫히는가 - 스킬 하나에 붙는 애니·VFX·사운드·아이콘 매핑을 AI가 초안하고 사람이 채택하는 워크드
비전공자를 위한 한 줄. 자원 1만 개나 fbx 파일명 양식은 게임만의 사정처럼 보입니다. 그러나 가져가실 단 하나는 도메인을 가리지 않습니다 — "이름을 자유롭게 짓는 순간 검색·자동화·연결이 함께 잠긴다." 규모가 커지면 명명은 취향이 아니라 룰이 되어야 하고, 룰이 된 이름만 코드가 자동으로 찾아 쓸 수 있다는 원칙은 문서·자산·고객 레코드를 다루는 어느 일에도 적용됩니다.
저자가 디렉팅하는 프로젝트 A는 모바일 우선 MMORPG다. 캐릭터 애니메이션 자원의 대략적 규모는 아래와 같다. 플레이어 클래스 수, 적 NPC 종류는 실제 운영 수치이고, 클립 수와 전체 추정치는 저자 추정(미검증)이다.
| 자원 | 수 |
|---|---|
| 플레이어 캐릭터 클래스 | 6 |
| 적 NPC 종류 | 80~100 |
| 캐릭터 1체 평균 클립 | 100~150 (저자 추정) |
| 전체 클립 추정 | 약 10,000~15,000 (저자 추정) |
1만 개. 이건 서랍 1만 개와 같다. 라벨이 안 붙은 서랍 1만 개 앞에서 "공격 모션 어디 있더라"를 찾는 건 사람의 기억력에 도박을 거는 일이다. 그리고 그 도박은 반드시 진다. 못 찾으면 결과는 둘 중 하나다. 작업 시간이 두 배로 늘거나, 못 찾았으니 같은 동작을 새로 만든다. 후자가 더 나쁘다. 자원이 비대해지는 데다, 나중에 같은 동작 두 개가 미묘하게 다르게 굴러다니기 때문이다.
이름이 자유 영역이면 잠기는 건 검색만이 아니다. "스킬 ID로 애니메이션 파일을 코드가 자동으로 찾아오는" 자동 라우팅도 같이 잠긴다. 이름에서 규칙을 읽어낼 수 없으면, 코드는 스킬 하나하나에 대해 어느 파일을 쓸지 손으로 적은 매핑 테이블을 들고 있어야 한다. 그 테이블은 신규 캐릭터가 들어올 때마다 손으로 늘어난다.
프로젝트 A의 애니메이션 파일명은 다섯 칸으로 고정돼 있다.
<role>_<id>_<category>_<action>_<variant>.fbx
char_K001_idle_default_v1.fbx
char_K001_locomotion_walk_forward.fbx
char_K001_combat_attack_combo1_v2.fbx
char_K001_react_hit_heavy.fbx
enemy_E021_combat_skill_aoe_v1.fbx
다섯 칸 모두 정해진 enum을 따른다. 자유 입력이 허용되는 칸은 id 하나뿐이고, 그 칸조차 [A-Z]\d{3} 형식으로 묶인다.
| 슬롯 | enum 수 | 예 |
|---|---|---|
| role | 4 | char, enemy, pet, mount |
| id | 형식 고정 | K001, E021, P003, M005 |
| category | 8 | idle, locomotion, combat, react, death, social, cinematic, system |
| action | 카테고리별 10~30 | walk, run, attack, skill_aoe, hit_heavy |
| variant | 형식 고정 | default, v1, v2, _short, _long |
여기서 핵심은 양식 자체가 아니라 양식을 어디에 입력해 두느냐다. 명명 규약을 위키 문서 한 페이지에 적어두면, 그건 아무도 안 읽는 라벨이다. 저자는 이 규약을 Char_Anim_Naming_Convention이라는 단일 진실 원천 atom으로 만들고, 사람도 lint도 LLM도 전부 이 atom 하나만 바라보게 만들었다. 양식이 문서가 아니라 atom으로 박제되는 순간, 명명은 "권장 사항"에서 "통과해야 하는 관문"으로 성격이 바뀐다.
action 칸의 enum은 무한히 늘어날 수 있다는 게 약점이다. 그래서 카테고리별 표준 action을 사전으로 관리한다.
combat:
- attack_basic
- attack_combo1
- attack_combo2
- skill_<skill_id>
- parry
- dodge_forward
- dodge_back
react:
- hit_light
- hit_heavy
- knockback
- stagger
- stun
locomotion:
- idle
- walk_forward
- run_forward
- sprint
- jump_start
- jump_loop
- jump_land
신규 action을 사전에 넣을지는 절차로 판단한다. 분기당 3캐릭터 이상이 쓸 수 있는가, 기존 action으로는 정말 표현이 안 되는가, 카테고리가 명확한가, 그리고 가장 중요하게 — variant로 흡수할 수 있지 않은가. variant로 처리되면 action은 늘리지 않는다. action 사전이 100개 안쪽으로 유지되면 운영이 건강한 신호다. 다만 이걸 절대 상한으로 받지는 않는다. 신규 장르나 신규 클래스가 들어오면 한 번에 30~40개가 늘 수도 있다. 막아야 하는 건 숫자가 아니라 무절제한 증식이다.
양식을 atom으로 입력했으면, 그 atom을 자동으로 강제하는 검증기가 필요하다. 사람이 매번 눈으로 5칸을 검사할 수는 없다. 아래가 그 lint의 척추다.
# anim_naming_lint.py
import re, yaml
NAMING_PATTERN = re.compile(
r"^(?P<role>char|enemy|pet|mount)_"
r"(?P<id>[A-Z]\d{3})_"
r"(?P<category>idle|locomotion|combat|react|death|social|cinematic|system)_"
r"(?P<action>[a-z_]+?)"
r"(?:_(?P<variant>v\d+|short|long|light|heavy|left|right|forward|back))?"
r"\.fbx$"
)
ACTION_DICT = yaml.safe_load(open("char_anim_naming_convention.yaml"))
def check(filename):
m = NAMING_PATTERN.match(filename)
if not m:
return f"명명 규칙 위반(5슬롯 형식 불일치): {filename}"
category, action = m.group("category"), m.group("action")
# skill_<id> 형태는 동적 action이므로 prefix만 검사
base = "skill" if action.startswith("skill_") else action
if base not in ACTION_DICT.get(category, []):
return f"action enum 외({category}): {action}"
return None
새 fbx가 저장소에 들어오는 순간 이 검사가 돈다. 위반이면 commit이 막힌다. 여기서 중요한 건 위반을 사람 책임으로 돌리지 않는다는 점이다. 무음 사고를 낸 아티스트를 탓하는 대신, "그 이름은 애초에 commit이 안 됐어야 한다"는 쪽으로 책임을 도구에 떠넘긴다. 사람은 실수하고, 도구는 그 실수를 막는다. 이게 명명 시스템의 기본 자세다.
명명이 강제되면 그 대가로 자동 라우팅이 풀린다.
def play_skill_animation(character, skill_id):
anim_path = f"char_{character.id}_combat_skill_{skill_id}.fbx"
if not exists(anim_path):
anim_path = f"char_{character.id}_combat_skill_default.fbx" # fallback
play(anim_path)
손으로 적은 매핑 테이블이 사라진다. 신규 캐릭터, 신규 스킬이 들어와도 애니 파일만 규약대로 추가하면 코드는 한 줄도 안 바뀐다. 무음 사고로 돌아가 보면, 만약 그 사운드 파일이 sfx_K012_combo3_swing.wav라는 규약 이름으로만 들어올 수 있었다면 — 애초에 combo3_swing_final_real.wav는 commit 단계에서 튕겨나갔을 것이고, 그날 오후는 통째로 살아있었을 것이다.
variant 슬롯은 action enum을 지키는 안전판이다. 같은 동작의 버전(v1, v2), 길이(_short, _long), 강도(_light, _heavy), 방향(_forward, _back)은 전부 variant로 흡수해, action을 미세 분기시키는 대신 받아낸다. 그리고 게임 코드는 그 variant를 컨텍스트로 골라 쓸 수 있다.
def select_variant(base_action, context):
if context.distance < 3:
return f"{base_action}_short"
if context.distance > 10:
return f"{base_action}_long"
return base_action
명명 규약이 코드의 분기점이 되는 셈이다.
명명이 L1이라면, 스킬과 자원을 잇는 매핑은 L2다. 스킬 1개는 보통 애니메이션 2~3개, VFX 1~3개, 사운드 2~5개, UI 아이콘 1개를 끌고 다닌다. 평균 잡아 자원 10개. 스킬 200개면 매핑 대상이 약 2,000개다. 이 규모를 사람 머리로 관리하는 건 불가능하다. 그래서 스킬 하나당 yaml 한 장을 두고, 그 스킬의 자원은 오직 그 한 장에서만 읽게 묶는다.
---
skill_id: skill_K001_combo1
description: K001 콤보1 (3타 연속)
type: melee_combo
animations:
- clip: char_K001_combat_attack_combo1_v2.fbx
role: main
bone_alignment: spine_03
vfx:
- asset: vfx_K001_combo1_slash.vfx
socket: weapon_tip
timing_ms: [0, 150, 300]
- asset: vfx_hit_blood_light.vfx
socket: target
timing_ms: [150]
sound:
- asset: sfx_K001_combo1_swing.wav
volume: 0.8
timing_ms: 0
- asset: sfx_hit_metal_light.wav
volume: 0.6
timing_ms: 150
ui_icon: icon_skill_K001_combo1.png
ui_tooltip_key: skill_K001_combo1_tooltip
verified: true
---
이 한 장이 스킬 하나의 자원 전체다. 그리고 이 yaml 안의 모든 자원 경로는 11.1의 5슬롯 규약을 따른다. 명명 lint가 무너지면 이 매핑도 같이 무너진다. 두 층은 한 쌍으로 작동한다.
매핑이 한 곳에 모이면 영향 추적이 자동으로 풀린다. 어떤 VFX 하나를 갈아엎으려 할 때, 그게 어느 스킬에 영향을 주는지 손으로 뒤질 필요가 없다.
def find_skills_using(asset):
affected = []
for path in glob("skills/*.yaml"):
skill = yaml.safe_load(open(path))
for cat in ("vfx", "sound", "animations"):
for entry in skill.get(cat, []):
if entry.get("asset") == asset or entry.get("clip") == asset:
affected.append(skill["skill_id"])
return affected
# find_skills_using("vfx_hit_blood_light.vfx")
# → ["skill_K001_combo1", "skill_K005_combo2", "skill_E021_attack_basic", ...]
자원 교체 회의에 영향 스킬 목록이 자동으로 첨부된다. "이거 바꾸면 어디에 영향 가나요?"라는 질문이 나오기 전에, 답이 이미 회의록 옆에 놓여 있다.
매핑에도 lint가 붙는다. 모든 자원 파일이 실제로 존재하는가, animations.main과 ui_icon이 각각 하나씩 있는가, timing_ms가 애니메이션 길이 안에 있는가, 그리고 — 모든 자원 경로가 11.1 명명 규약을 통과하는가. 마지막 항목이 두 층을 묶는 못이다. 빌드 시 자동으로 돈다.
지금까지의 명명 lint와 매핑 lint가 하나의 게이트로 어떻게 이어지는지 흐름으로 정리한다.
flowchart TD
A[신규 자원/스킬 commit] --> B{5슬롯 명명 lint
Char_Anim_Naming atom}
B -->|위반| X[commit 차단
위반 메시지 반환]
B -->|통과| C{매핑 yaml lint}
C -->|자원 미존재 / main·icon 누락| X
C -->|명명 규약 위반 자원 참조| X
C -->|통과| D[자원 풀 통계 갱신]
D --> E{LLM 명명·매핑 후보였나?}
E -->|예| F[사람 채택/기각
가역 단계]
E -->|아니오| G[빌드 반영]
F -->|채택| G
F -->|기각| H[후보 폐기
가역, 비용 0]
G --> I{모션캡처·음성녹음 발주?}
I -->|예| J[비가역 단계 진입
되돌리기 불가]
I -->|아니오| K[가역 자산 유지]
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
classDef fail fill:#fee2e2,stroke:#dc2626,color:#7f1d1d;
class A,D data;
class B,C,E code;
class F,I human;
class K pass;
class X,H,J fail;
이 흐름의 끝에 가역/비가역 경계가 있다는 점에 주목하자. yaml 수정, LLM 후보, 키프레임까지는 전부 가역이다. 마음에 안 들면 폐기하면 그만이고 비용은 거의 0이다. 그러나 모션 캡처 촬영, 성우 음성 녹음, 시그니처 보이스 캐스팅으로 넘어가는 순간 비가역으로 바뀐다. 배우와 스튜디오 예약, 녹음 부스, 계약, 시장 인식이 걸린다. 그래서 모든 명명·매핑·페르소나 결정은 비가역 단계 직전, 즉 yaml과 LLM 후보와 키프레임이라는 가역 영역 안에서 끝내야 한다.
여기까지가 시스템이고, 이제 AI가 어디에 들어오는지를 실제 세션 그대로 보인다. 새 화염 스킬 skill_K012_flame_burst의 자원 매핑 초안을 LLM에게 시키는 장면이다.
프롬프트 (전문):
다음은 우리 프로젝트의 캐릭터 애니메이션 명명 규약(atom)과 기존 자원 풀 통계,
그리고 유사 스킬 yaml 한 건이다.
[명명 규약]
<role>_<id>_<category>_<action>_<variant>.fbx
role: char|enemy|pet|mount / category: idle|locomotion|combat|react|death|social|cinematic|system
[자원 풀 TOP 사용 자원]
sfx_hit_metal_light.wav (47스킬), vfx_dust_kickup.vfx (38스킬),
vfx_fire_burst_med.vfx (22스킬), sfx_fire_whoosh.wav (19스킬)
[유사 스킬 yaml]
skill_id: skill_K005_fire_slash / type: melee_fire
animations: [char_K005_combat_attack_fire_v1.fbx]
vfx: [vfx_K005_fire_slash.vfx, vfx_fire_burst_med.vfx]
sound: [sfx_K005_fire_swing.wav, sfx_fire_whoosh.wav]
신규 스킬: skill_K012_flame_burst (K012 무사, 범위 화염 폭발, 1타)
이 스킬의 매핑 yaml 초안을 작성하라. 기존 자원 풀을 최대한 재사용하고,
새로 제작이 필요한 자원은 [NEW] 태그를 붙여 표시하라.
Claude 날것 출력 (요약·정리 없이):
skill_id: skill_K012_flame_burst
description: K012 무사 범위 화염 폭발 (1타)
type: aoe_fire
animations:
- clip: char_K012_combat_skill_flame_burst_v1.fbx # [NEW] 신규 모션 필요
role: main
bone_alignment: spine_03
vfx:
- asset: vfx_fire_burst_med.vfx # 재사용 (풀 22스킬)
socket: ground
timing_ms: [200]
- asset: vfx_K012_flame_ring.vfx # [NEW] 범위 표현용 신규
socket: ground
timing_ms: [200, 350]
sound:
- asset: sfx_fire_whoosh.wav # 재사용 (풀 19스킬)
volume: 0.8
timing_ms: 0
- asset: sfx_explosion_low.wav # [NEW] 폭발음 신규 추정
volume: 0.9
timing_ms: 200
ui_icon: icon_skill_K012_flame_burst.png # [NEW]
ui_tooltip_key: skill_K012_flame_burst_tooltip
verified: false
참고:
char_K012_combat_skill_flame_burst_v1.fbx에서 action이skill_flame_burst인데, 명명 규약상 skill action은skill_<skill_id>형식이 맞는지 확인 필요.skill_K012또는skill_flame_burst중 어느 쪽을 쓰는지 규약에 명시돼 있지 않아 추정했음.
사람 검증/거부:
마지막 참고가 정확히 약점을 짚었다. LLM이 모르는 게 아니라, 규약 atom에 그 칸의 형식이 모호하게 적혀 있었던 것이다. 실제로 프로젝트 A의 규약은 skill_<skill_id> 형식을 쓴다. 그래서 파일명은 char_K012_combat_skill_K012_v1.fbx가 맞다. LLM이 만든 skill_flame_burst는 거부. 그리고 sfx_explosion_low.wav는 [NEW]로 표시했지만, 자원 풀을 다시 보니 sfx_impact_heavy.wav가 47스킬에서 폭발음으로 이미 쓰이고 있었다. LLM에 준 풀 통계가 TOP 4까지만이라 놓친 것이다. 신규 제작 대신 재사용으로 바꾼다.
재요청:
두 가지 수정해서 다시 작성하라.
1. skill action 형식은 skill_<skill_id> 이다. K012 스킬이면 action은 skill_K012.
2. 자원 풀에 sfx_impact_heavy.wav (47스킬, 폭발/충격음)가 있다.
sfx_explosion_low.wav 신규 제작 대신 이걸 재사용하라.
전체 풀 통계는 다음과 같다. [전체 38종 첨부]
이 한 사이클에서 LLM이 한 일은 "그럴듯한 초안"이고, 사람이 한 일은 "규약 모호점 발견·풀 누락 발견·재사용 결정"이다. LLM은 신규 자원 후보를 너무 쉽게 [NEW]로 찍는 경향이 있어서, 재사용 판단은 끝까지 사람이 쥔다. 그래도 빈 화면에서 yaml을 처음부터 짜는 것과, 채택·거부할 초안을 받아 고치는 것은 작업 부담이 다르다.
위 트랜스크립트가 바로 진보적 적용의 한 장면이다. 명명·매핑 운영은 두 단계로 나뉜다.
보수적 단계에서는 사람이 명명을 부여하고 매핑을 짜며, 자동은 검증(lint)과 추적(find_skills_using)만 맡는다. 현재 대부분의 MMORPG 캐릭터·자원 운영이 여기에 있다. 진보적 단계에서는 명명 초안, 매핑 초안, 그리고 NPC 페르소나 생성까지 LLM이 후보를 내고, 사람의 손에 남는 결정은 "어떤 후보를 채택할지" 하나로 좁혀진다.
진보적 단계가 자리잡으려면 세 가지가 갖춰져야 한다. 첫째는 명명 규약 lint 엔진이다. LLM이 낸 명명 후보도 사람이 짠 것과 똑같이 5슬롯 lint를 통과해야만 채택된다. 위 트랜스크립트에서 LLM의 skill_flame_burst가 거부된 게 이 게이트다. 둘째는 NPC 페르소나 자동 생성기다. 캐릭터 yaml을 voice_profile·anim_set·skill_set 세 축으로 분해해 두면, LLM이 "50대 무사, 신중함, 낮은 톤" 같은 묘사를 받아 세 축의 후보를 각각 분리해 제안할 수 있다. NPC 100체의 세 축을 0에서 짜기와, 페르소나당 후보 몇 개 중 고르기는 부담이 다르다. 셋째는 매핑 후보 생성기다. find_skills_using의 역방향 — "이 새 스킬에 어울리는 기존 자원" 검색을 자원 풀 통계와 묶어, 슬롯별 재사용 후보를 제안한다. 신규 제작 비용을 낮추고 재사용률을 높이는 양방향 효과다.
세 요소 모두 같은 인프라(yaml·lint·자원 풀 통계) 위에서 돈다. 명명 규약과 매핑 yaml이 단일 진실 원천으로 정렬돼 있을 때만 가동되고, 정렬이 무너지면 LLM에게 줄 입력 자체가 없다.
이 세 요소가 2010년대에도 이론적으로는 가능했다는 점은 짚어둘 만하다. 막힌 건 세 군데였다. 동작이 무엇인지 자연어로 이해하지 못해 5슬롯 후보를 못 냈고, voice·anim·skill을 따로 떼서 묶는 건 사람의 직관 영역이었고, "비슷한 느낌의 VFX"를 텍스트 묘사로 찾는 게 어려웠다. 2023년 이후 LLM 발전으로 세 군데 모두 보조 가능 영역에 들어왔다. 종이 위에만 있던 진보적 캐릭터 자원화 비전의 상당 부분이 실무 적용 단계로 옮겨온 셈이다.
프로젝트 A의 명명·매핑 도입 전후 비교다. 검색 시간과 온보딩 기간은 저자가 실제로 체감·기록한 방향이고, 비율 항목은 분기 회고에서 집계한 실측이다. 절대 수치 일부는 저자 추정(미검증)임을 밝힌다.
| 항목 | 도입 전 | 도입 후 |
|---|---|---|
| 동작 검색 시간 (애니메이터) | 5~10분 | 30초 |
| 중복 제작 비율 | 12~15% | 1~2% |
| 신규 캐릭터 라우팅 코드 변경 | 50~100줄 | 0줄 |
| 신규 스킬 자원 누락 사고 | 분기당 5~8건 | 0~1건 |
| 미사용 자원 누적 (라이브러리 비중) | 약 30% | 약 8% |
| 새 애니메이터 온보딩 | 2주 | 3일 |
마지막 항목이 가장 조용하지만 큰 효과다. 명명 규약 atom 하나가 곧 온보딩 가이드가 된다. 새 애니메이터에게 "이름은 이 5칸으로 짓고, lint가 막으면 lint 말을 들어라"는 한 문장이면 첫날 작업이 가능해진다.
| 패턴 | 처방 |
|---|---|
| 명명 규약을 위키 문서로만 둠 | 단일 atom으로 박제 + lint 강제 |
| action enum 무한 증식 | 사전 + 신규 추가 절차 |
| 명명 검증 없이 commit | 자동 lint로 commit 차단 |
| 코드에 하드코딩 매핑 테이블 | 명명 기반 자동 라우팅 |
| variant 없이 action 미세 분기 | variant 슬롯으로 흡수 |
| 자원 매핑이 코드·시트·문서에 분산 | yaml 한 파일로 통합 |
| LLM 매핑 후보를 검증 없이 채택 | 명명 lint + 사람 재사용 판단 |
| 명명 위반을 사람 책임으로 | lint 보강, 책임을 도구로 |
setup — 애니메이션 파일명을 <role>_<id>_<category>_<action>_<variant>.fbx 5슬롯으로 정의하고, 카테고리별 action 사전을 yaml 한 파일에 모으세요. 이 yaml을 팀의 단일 진실 원천으로 선언하세요.
prompt — LLM에 "[명명 규약 yaml] + [자원 풀 통계] + [유사 스킬 yaml 1건]"을 주고, 신규 스킬의 매핑 yaml 초안을 요청하세요. 재사용 자원과 신규 제작 자원([NEW] 태그)을 구분해 달라고 명시하세요.
verify — LLM 출력의 모든 자원 경로를 명명 lint에 통과시키세요(위 anim_naming_lint.py). 통과 못 하면 거부합니다. 통과한 후보 중 [NEW] 태그는 자원 풀을 다시 뒤져 재사용 가능 여부를 사람이 판단합니다.
anim_naming_lint.py 한 파일로 거세요.기획 회의 첫머리에 펫 목록이 올라온다. 늑대 계열 열두 종, 고양이 계열 여덟 종, 새 계열 다섯 종. 누구도 "그럼 한 마리씩 만들어 보자"고 말하지 않는다. 캐릭터와 달리 펫은 처음부터 '50종을 찍어낼 것'이 전제이기 때문이다. 질문은 "어떻게 한 종을 잘 만드나"가 아니라 "한 번 만든 골격을 몇 종이 공유하게 할 것인가"로 시작한다.
캐릭터는 한 종 한 종이 사용자에게 고유한 존재라 한 종씩 정성을 들인다. 반면 펫·탈것은 '같은 골격에 색과 능력만 바꾼 변주'가 대부분이라, 설계의 시작부터 명명 규약·템플릿·lint를 갖춰 양산 파이프라인을 깐다. 한 종을 공들여 만든 다음 그게 열두 벌로 복제되도록 두면, 색만 다른 늑대 열두 마리에 동일한 애니 클립이 따로따로 들어가 폴더가 4기가로 부푼다. 그건 양산이 아니라 양산을 안 한 결과다. 핵심은 '얼마나 잘 만드느냐'가 아니라 '얼마나 적게 만들고 얼마나 많이 공유하느냐'다.
그래서 이 장은 늑대 계열 펫 템플릿 한 종을 yaml로 정의하고, 그 골격을 물려받는 인스턴스를 AI에게 양산시킨 뒤, lint로 검증하고, 몇 퍼센트가 폐기되는지 측정하는 한 호흡을 끝까지 따라간다.
셋은 자원 구조가 닮았지만 사용자 인지 비중이 다르다. 캐릭터는 사용자가 게임 시간의 100%를 함께 보내는 자기 자신이다. 펫은 곁에 두는 동료로 50~70% 시간을 같이 보내고, 탈것은 이동할 때만 꺼내는 도구로 10~20%에 머문다. 인지 비중이 낮을수록 사용자는 디테일을 덜 본다. 캐릭터에 쏟는 정성을 탈것에 똑같이 쏟는 건, 매일 앉는 책상과 가끔 펴는 접이식 의자를 같은 예산으로 관리하는 것과 같다.
그래서 펫·탈것은 '템플릿-인스턴스' 구조로 운영한다. 골격·동작·기본 능력을 담은 템플릿 한 종을 만들고, 색·아이콘·미세 능력만 바꾼 인스턴스를 그 위에 얹는다. 인스턴스는 템플릿이 가진 자원의 90%를 공유하므로, 실제로 새로 만드는 건 나머지 10%뿐이다. 이 분리를 그림으로 보면 다음과 같다.
왼쪽 템플릿 한 덩어리를 한 번 만들면, 오른쪽 인스턴스들은 색과 아이콘과 능력 한 줄만 갈아끼우면 된다. 앞서 말한 '4기가 폴더'는 이 분리를 빼먹어 90%의 자원이 열두 번 복제될 때 생기는 모습이다.
펫·탈것의 명명 규약은 11.1의 캐릭터 명명에서 한 슬롯을 덜어낸 형태다. 캐릭터는 char_<id>_<category>_<action>_<variant> 5슬롯을 쓰지만, 펫·탈것은 variant를 생략하고 4슬롯으로 간다. variant가 필요하면 action에 합친다.
pet_<id>_<category>_<action>.fbx
mount_<id>_<category>_<action>.fbx
예:
pet_P003_idle_default.fbx
pet_P003_combat_bite.fbx
mount_M005_locomotion_run.fbx
자원 매핑 yaml도 캐릭터 양식에서 vfx·sound 슬롯을 덜어 가볍게 만든다. 이 슬롯들을 통째로 안고 가는 인스턴스는 빈 칸만 가득한 양식이 되어 lint가 매번 헛경고를 띄운다.
이제 본론이다. 늑대 계열 템플릿 한 종을 정의하고, 거기서 인스턴스를 양산해 보자.
AI에게 양산을 시키기 전에, 사람이 템플릿 한 종을 손으로 확정한다. 이 한 종이 인스턴스 수십 종의 품질 기준이 되므로 자동화하지 않는다. 늑대 계열(canine) 템플릿은 이렇게 잡았다.
# pet_template_canine.yaml
template_id: pet_template_canine
skeleton: skel_quadruped_medium # 네발 중형 공용 골격
shared_animations:
- clip: pet_template_canine_idle_default.fbx
- clip: pet_template_canine_locomotion_walk.fbx
- clip: pet_template_canine_locomotion_run.fbx
- clip: pet_template_canine_combat_bite.fbx
shared_abilities:
- id: pet_template_canine_passive_speed
description: 동료 이동 속도 +3%
- id: pet_template_canine_active_bite
description: 단일 대상 물기, 쿨다운 12s
bt_ref: bt_pet_canine_default # 따라다님 + 전투 보조 기본 BT
instance_overridable: # 인스턴스가 바꿔도 되는 필드 화이트리스트
- visual_skin
- ui_icon
- ui_tooltip_key
- extra_ability # 인스턴스당 능력 1종까지 추가 허용
여기서 instance_overridable가 핵심 장치다. 인스턴스가 건드릴 수 있는 필드를 화이트리스트로 못 박는다. AI가 양산하다가 골격이나 공유 애니를 멋대로 바꾸려 들면, 이 목록에 없는 필드를 건드린 것이므로 lint가 잡아낸다. '바꿔도 되는 것'을 먼저 정의하는 게 양산의 안전벨트다.
다음은 인스턴스 10종을 양산시킨 프롬프트 전문이다. 요약하지 않고 그대로 싣는다.
[프롬프트]
당신은 펫 데이터 작성을 돕는 어시스턴트다. 아래 템플릿을 기준으로,
늑대 계열 펫 인스턴스 10종의 yaml을 생성하라.
[템플릿] pet_template_canine.yaml
(위 yaml 전문 붙여넣음)
[규칙]
1. 각 인스턴스는 template: pet_template_canine 을 반드시 명시한다.
2. overrides에는 instance_overridable 화이트리스트의 필드만 넣는다.
화이트리스트에 없는 필드(skeleton, shared_animations 등)는 절대 건드리지 않는다.
3. visual_skin은 늑대의 자연스러운 변주여야 한다(색·무늬·크기).
4. extra_ability는 인스턴스당 최대 1종. passive 또는 active 중 택1.
기존 shared_abilities와 효과가 겹치면 안 된다.
5. ui_icon, ui_tooltip_key는 pet_id에 맞춘 명명 규칙을 따른다.
6. pet_id는 pet_P003 ~ pet_P012로 부여한다.
출력은 yaml 10개 블록만. 설명 문장은 붙이지 마라.
규칙 2가 1단계의 화이트리스트와 짝을 이룬다. 규칙 4의 "겹치면 안 된다"는 AI가 능력을 게을리 복제하는 걸 막는 제약이다. 이런 제약을 안 걸면, 뒤에서 보겠지만 AI는 가장 안전한 선택지(기존 능력 복붙)로 수렴한다.
AI가 뱉은 출력에서, 성공·실패·애매가 섞인 그대로를 발췌한다. 깔끔하게 다듬지 않는다.
# 출력 1 — 정상
pet_id: pet_P003
template: pet_template_canine
overrides:
visual_skin: wolf_gray
ui_icon: icon_pet_P003.png
ui_tooltip_key: pet_P003_tooltip
extra_ability:
id: pet_P003_active_howl
description: 5초간 적 이동 속도 -20%, 쿨다운 30s
# 출력 2 — 정상
pet_id: pet_P004
template: pet_template_canine
overrides:
visual_skin: wolf_black
ui_icon: icon_pet_P004.png
ui_tooltip_key: pet_P004_tooltip
extra_ability:
id: pet_P004_passive_crit
description: 동료 치명타 확률 +2%
# 출력 5 — 애매 (능력이 공유 능력과 사실상 중복)
pet_id: pet_P007
template: pet_template_canine
overrides:
visual_skin: wolf_brown
ui_icon: icon_pet_P007.png
ui_tooltip_key: pet_P007_tooltip
extra_ability:
id: pet_P007_passive_speed_boost
description: 동료 이동 속도 +3% # ← shared의 passive_speed와 효과 동일
# 출력 8 — 실패 (화이트리스트 밖 필드 침범)
pet_id: pet_P010
template: pet_template_canine
overrides:
visual_skin: wolf_white
ui_icon: icon_pet_P010.png
shared_animations: # ← overridable 화이트리스트에 없음
- clip: pet_P010_combat_pounce.fbx
extra_ability:
id: pet_P010_active_pounce
description: 도약 공격, 쿨다운 20s
# 출력 9 — 실패 (명명 규칙 위반)
pet_id: P011 # ← 'pet_' 접두사 누락
template: pet_template_canine
overrides:
visual_skin: wolf_silver
ui_icon: pet11_icon.png # ← icon_pet_P011.png 규칙 위반
ui_tooltip_key: pet_P011_tooltip
extra_ability:
id: pet_P011_passive_dodge
description: 동료 회피 +1%
10종 중 정상은 P003·P004·P005·P006·P008·P012 여섯, 능력 중복으로 애매한 게 P007 하나, 화이트리스트 침범·명명 위반으로 실패한 게 P009·P010·P011 셋이었다. AI는 규칙 4를 걸었음에도 P007에서 공유 능력을 베껴 왔고(가장 안전한 선택), 규칙 2를 걸었음에도 P010에서 골격 애니를 건드렸다. 제약을 명시해도 양산물의 일정 비율은 새는 게 현실이다. 그래서 다음 단계가 필요하다.
사람이 눈으로 10종을 일일이 보는 대신, lint를 돌린다. lint 규칙은 1단계 템플릿의 화이트리스트와 11.1 명명 규약에서 그대로 끌어온다. 검사 항목은 네 가지다.
flowchart TD
A[인스턴스 yaml 10종] --> B{template 필드
존재 & 유효?}
B -->|없음/오타| F[REJECT: 템플릿 참조 오류]
B -->|OK| C{overrides 필드가
화이트리스트 안?}
C -->|밖 필드 침범| F2[REJECT: 화이트리스트 위반]
C -->|OK| D{pet_id·ui_icon
명명 규칙 통과?}
D -->|위반| F3[REJECT: 명명 규칙 위반]
D -->|OK| E{extra_ability가
shared와 중복?}
E -->|중복| W[WARN: 능력 중복 검토]
E -->|고유| P[PASS]
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
classDef fail fill:#fee2e2,stroke:#dc2626,color:#7f1d1d;
class A data;
class B,C,D,E code;
class P pass;
class F,F2,F3,W fail;
각 인스턴스가 네 게이트를 통과하면 PASS, 중간에 걸리면 REJECT 또는 WARN으로 떨어진다. 실제 검증 결과를 표로 정리하면 이렇다.
| pet_id | template | 화이트리스트 | 명명 | 능력 중복 | 판정 |
|---|---|---|---|---|---|
| pet_P003 | OK | OK | OK | 고유 | PASS |
| pet_P004 | OK | OK | OK | 고유 | PASS |
| pet_P005 | OK | OK | OK | 고유 | PASS |
| pet_P006 | OK | OK | OK | 고유 | PASS |
| pet_P007 | OK | OK | OK | 중복 | WARN |
| pet_P008 | OK | OK | OK | 고유 | PASS |
| pet_P009 | OK | OK | 위반 | — | REJECT |
| pet_P010 | OK | 침범 | — | — | REJECT |
| P011 | OK | OK | 위반 | — | REJECT |
| pet_P012 | OK | OK | OK | 고유 | PASS |
PASS 6, WARN 1, REJECT 3. WARN은 능력을 한 줄 바꾸면 살릴 수 있고(P007), REJECT 3종은 폐기한다.
이 한 사이클의 폐기율은 REJECT 3 / 전체 10 = 30%다. WARN까지 '손봐야 하는 것'으로 묶으면 손질률은 40%다. 이 숫자가 양산 파이프라인의 건강 지표다. 폐기율이 30%면, 펫 50종을 확보하려면 약 72종을 생성시켜야 한다는 뜻이다(50 / 0.7 ≈ 71.4). 생성은 싸므로 이 정도 오버슈팅은 감당할 만하다. 단, 폐기율이 회를 거듭해도 떨어지지 않으면 그건 프롬프트 제약이 부족하다는 신호다.
그래서 폐기 사유를 프롬프트에 되먹인다. REJECT 3종의 사유(명명 누락, 화이트리스트 침범, 아이콘 규칙 위반)를 모아 재요청에 한 줄씩 추가했다.
[재요청 추가 규칙]
7. pet_id는 반드시 'pet_' 접두사로 시작한다. (이전 배치에서 P011 누락)
8. ui_icon은 예외 없이 icon_<pet_id>.png 형식이다. (pet11_icon.png 같은 변형 금지)
9. overrides에 shared_animations / skeleton / bt_ref 를 절대 넣지 마라.
동작을 바꾸고 싶으면 extra_ability로만 표현한다. (P010 사례)
이 세 줄을 추가한 뒤 다음 배치 10종을 돌렸더니 REJECT가 3에서 1로 줄었다. 폐기율 30% → 10%. 폐기 사유를 규칙으로 승격시키는 이 되먹임이 양산 품질을 회차마다 끌어올리는 메커니즘이다. 사람은 매번 50종을 검수하는 대신, 폐기 사유를 규칙 한 줄로 옮기는 일만 한다.
탈것은 펫보다 한 단계 더 단순하다. 스킬도 BT(BehaviorTree, 행동 트리)도 없고, 이동 파라미터와 전투 가능 여부 같은 데이터만 있다. 그래서 탈것 인스턴스는 사실상 표의 한 행이다.
# mount_template_equine.yaml 기반 인스턴스
mount_id: mount_M005
template: mount_template_equine
overrides:
visual_skin: horse_white
movement:
run_speed: 7.0
sprint_speed: 12.0
combat:
allow_combat: false # 전투 중 사용 불가
dismount_on_damage: true
ui_icon: icon_mount_M005.png
탈것 양산의 lint는 더 짧다. 명명·템플릿 참조·화이트리스트에 더해 'movement 파라미터가 허용 범위 안인가'(예: sprint_speed가 walk_speed보다 큰가, 상한을 넘지 않는가)만 검사하면 된다. 펫에서 만든 파이프라인을 그대로 쓰되 게이트 수만 줄인 형태다. 탈것에 전투 기능을 붙이는 건 신중해야 한다. allow_combat을 true로 여는 순간 게임 복잡도가 두 배가 되고, 펫·캐릭터 시스템과 충돌 검증을 새로 해야 한다.
펫·탈것에 캐릭터 패턴을 풀로 적용한 경우와, 템플릿-인스턴스로 단순화한 경우를 저자의 프로젝트 A에서 비교했다. 아래 수치 중 시간·자원 수는 저자 추정(미검증)이며, 폐기율과 자원 공유율은 실측 방향을 따른 비율이다.
| 항목 | 풀 적용 | 템플릿-인스턴스 |
|---|---|---|
| 펫 1종 자원 작업 시간 | 1~2주 (저자 추정) | 3~5일 (저자 추정) |
| 펫 라이브러리 자원 수 | 약 2,000 (저자 추정) | 약 600 (70% 절감) |
| 인스턴스 1종당 신규 자원 비율 | 100% | 약 10% |
| 첫 배치 양산 폐기율 | — | 30% (실측 방향) |
| 되먹임 후 폐기율 | — | 10% (실측 방향) |
| 사용자 체감 (펫 다양성) | 기준 | 거의 같음 |
표본·측정. 위 표는 저자 환경 1개 프로젝트(프로젝트 A)의 펫 1라인 관찰이다(n=1 라인). '70% 절감'·'약 10%'는 독립 측정이 아니라 같은 행의 추정 자원 수(약 2,000 → 약 600)에서 나온 산술 비율이므로, 앞의 절대값이 추정인 만큼 이 백분율도 추정으로 읽어야 한다. 폐기율 30%·10%는 첫 배치~되먹임의 단일 양산 사이클에서 나온 실측 방향이며 반복 측정 표본은 아니다. 당신 팀의 절감 근거로 인용하지 말고, 같은 방식으로 본인 라인에서 직접 재기 바란다.
마지막 행이 이 장 전체의 결론이다. 자원의 90%를 공유하고 폐기율을 측정하며 양산해도, 사용자가 느끼는 펫의 다양성은 풀 제작과 거의 차이가 없었다. 앞서 말한 4기가 폴더는, 사용자가 끝내 구분하지 못할 디테일에 자원 열두 벌을 복제했을 때 치르는 비용이다. 양산을 전제로 깔면 줄어드는 건 운영 비용이지 체험이 아니다.
| 함정 | 처방 |
|---|---|
| 캐릭터 시스템을 펫·탈것에 그대로 이식 | variant 슬롯·vfx·sound 덜어낸 4슬롯 변주 |
| 같은 골격 펫을 독립 자원으로 복제 | 템플릿 1종 + 인스턴스, 화이트리스트로 공유 강제 |
| AI 양산물을 검수 없이 커밋 | lint 4게이트 + 폐기율 측정 |
| 폐기율이 회차마다 안 떨어짐 | 폐기 사유를 프롬프트 규칙으로 승격(되먹임) |
| 펫에 캐릭터급 스킬 부여 | 인스턴스당 extra_ability 1종 상한 |
| 탈것에 전투 기능 부여 | allow_combat은 신중, 복잡도 ×2 각오 |
펫·탈것은 사용자 체험 영향이 작아 AI 자유도가 캐릭터보다 크다. 컨셉을 적합 템플릿에 매칭하고, 능력 후보를 제안하고, 인스턴스 yaml을 양산하는 일은 AI가 빠르게 해낸다. 다만 자유도가 크다고 검증을 빼면, 위에서 본 30% 폐기물이 그대로 빌드에 섞인다. 사람의 자리는 둘이다. 첫째, 템플릿 한 종을 손으로 확정해 품질 기준을 고정하는 것. 둘째, 무엇이 왜 걸러졌는지를 읽어 다음 배치가 덜 새도록 제약을 다듬는 것. 양은 AI가 채우고 기준선과 그 보정은 사람이 쥐는 분업이, 이 시스템을 굴린다.
setup
1. 펫 한 계열(예: 늑대)의 공용 골격·공유 애니 4종·공유 능력 2종을 정해 pet_template_<계열>.yaml로 저장하세요.
2. 템플릿에 instance_overridable 화이트리스트(바꿔도 되는 필드)를 명시하세요.
3. lint 4게이트(템플릿 참조 / 화이트리스트 / 명명 규칙 / 능력 중복)를 스크립트로 준비하세요.
prompt 4. 템플릿 yaml 전문 + 양산 규칙(화이트리스트 밖 필드 금지, 능력 중복 금지, 명명 규칙)을 붙여 인스턴스 10종을 요청하세요. 5. 출력은 "yaml 블록만, 설명 금지"로 형식을 고정하세요.
verify 6. lint를 돌려 PASS / WARN / REJECT를 분류하고 폐기율을 계산하세요. 7. REJECT 사유를 모아 프롬프트에 규칙 한 줄씩 추가하고 다음 배치를 돌리세요. 폐기율이 떨어지는지 확인하세요.
혼자 만드는 게임이라면 lint 스크립트 없이도 됩니다. 펫 한 계열의 템플릿 yaml 한 장을 손으로 적고, AI에게 "이 템플릿에서 색·아이콘·능력만 바꾼 인스턴스 5종, 골격과 공유 애니는 절대 건드리지 마라"라고 요청하세요. 받은 5종을 눈으로 훑어 골격을 건드린 것·이름 규칙을 어긴 것만 버리세요. 버린 이유를 다음 요청에 한 줄 덧붙이세요. 템플릿 1.1과 '버린 이유 되먹이기'만 있으면, 도구 없이도 이 장의 핵심은 작동합니다.
1차 독자: 아트팀과 협업하는 게임 기획자·아트 디렉터 (중규모(10~50인) 팀) 1인/취미 독자용 축소 버전: §12.1.8 「혼자라면 이만큼만」
AI로 뽑은 컨셉 아트 100장을 회의실 벽에 붙였던 날의 기억이 있다. 30초 만에 인쇄된 100장 중 아트 디렉터가 고른 건 3장이었고, 97장은 그 자리에서 버려졌다. 누군가는 그걸 "97% 낭비"라고 불렀다. 그런데 손으로 그렸다면 그 3장에 도달하기 위해 작가가 2주를 썼을 것이다. 무엇이 낭비인지가 뒤집혀 있었다.
이 장이 다루는 건 그 뒤집힘을 운영으로 만드는 방법이다. 핵심은 한 줄이다. AI 아트는 가역 단계(컨셉·텍스처 탐색)에서는 마음껏 양산하되, 비가역 단계(최종 렌더·모션 캡처·빌드 반영) 앞에는 사람이 지키는 게이트를 둔다. 버려도 되는 곳에서는 99장을 버리고, 되돌릴 수 없는 곳에서는 한 장도 그냥 통과시키지 않는다. 아트 도구의 사용법은 다른 책에 충분히 있으니, 이 장은 그 도구를 기획자의 파이프라인에 안전하게 끼우는 자리에만 집중한다.
아트 에셋이 컨셉에서 인게임까지 가는 길은 7단계다. 저자 프로젝트(이하 "프로젝트 A")의 캐릭터 에셋 라인을 그대로 옮기면 이렇다. 중요한 건 단계 수가 아니라 그 한가운데를 지나는 가역/비가역 경계선이다.
flowchart TB
subgraph 가역["가역 — 버려도 비용 0 (AI 양산 적극)"]
direction LR
C1["1 컨셉
2D 일러스트"] --> C2["2 모델시트
정·측·후면"]
C2 --> C3["3 3D 모델링"]
C3 --> C4["4 텍스처
재질 양산"]
end
가역 -.->|"비가역 게이트
사람 검수 통과 필수"| 비가역
subgraph 비가역["비가역 — 되돌리려면 재작업·재녹음·재배포"]
direction LR
I5["5 리깅·스키닝"] --> I6["6 애니메이션
모션 캡처"]
I6 --> I7["7 인게임 통합
최종 렌더·라이브 노출"]
end
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
class C1,C2,C3,C4 ai;
왼쪽 네 단계(컨셉~텍스처)는 가역이다. 컨셉 100장을 뽑아 97장을 버려도 잃는 건 토큰 비용뿐이고, 텍스처를 다섯 번 다시 생성해도 파일을 덮어쓰면 끝이다. 그래서 이 구간은 AI 양산이 가장 큰 ROI(Return on Investment, 투자 대비 효과)를 내는 자리다. 양산 도구는 자체 호스팅하는 Stable Diffusion(SDXL)/ComfyUI가 주축이다. 이유는 IP 보호다 — 자산을 외부 폐쇄 서비스에 올리지 않고 로컬에서 돌리며, 캐릭터로 파인튜닝한 LoRA와 ControlNet으로 같은 인물의 일관성을 반복 생성마다 통제할 수 있다. 폐쇄형 도구(미드저니 등)는 초기 무드보드를 빠르게 깔 때만 제한적으로 쓰고, 일관성·반복 통제가 필요한 본 양산은 SD/ComfyUI로 가져온다.
오른쪽 세 단계(리깅 이후)는 비가역이다. 모션 캡처는 스튜디오·배우 일정이 묶이고, 최종 렌더가 빌드에 올라 라이브에 노출되면 유저 기억과 커뮤니티 반응이 따라붙는다. 한번 넘어가면 되돌리는 비용이 만드는 비용보다 크다. 그래서 경계선 위에 사람이 지키는 게이트가 선다. AI가 가역 구간에서 아무리 많이 양산해도, 비가역으로 넘어가는 에셋은 사람 검수를 통과한 것만이다.
이 한 장의 그림이 이 챕터의 골격이다. "AI를 아트에 얼마나 쓸까"라는 질문은 사실 "이 작업이 경계선 어느 쪽이냐"라는 질문이다.
가역 구간의 첫 단계인 컨셉 양산을 한 사이클 끝까지 보여준다. 추상적으로 "AI가 컨셉을 뽑는다"고만 적으면 무엇이 진짜 나오고 무엇이 폐기되는지 알 수 없다. 아래는 프로젝트 A에서 학자 길드 시니어 NPC 컨셉을 양산한 세션을 충실히 재현한 것이다. 프롬프트는 그대로 복사해 쓸 수 있고, 출력은 실제 세션을 재구성했다.
여기서 가장 자주 틀리는 자리가 있다. 프롬프트를 "비주얼 묘사"로 시작하는 것이다. 회사 피드백 atom image_prompt_design_intent_first가 못 박는 원칙이 정반대다 — 이미지 프롬프트도 설계 의도가 먼저다. 외형 형용사 나열이 아니라, 이 캐릭터가 게임에서 무슨 기능·서사를 짊어지는지를 앞에 둔다.
# concept_brief_scholar_senior.yaml — 컨셉 양산 입력
asset_id: npc_scholar_senior_01
role: 학자 길드 시니어 — 봉인 약화를 처음 관측한 인물
function: 메인 퀘스트 발주 NPC (플레이어가 신뢰해야 하는 정보원)
narrative_seed:
- 30년간 종탑에서 봉인 맥을 기록해 온 사람
- 감정을 숫자 뒤에 숨김 (scholarly_strict 톤)
style_anchor: semi-realistic, painted, 동아시아 판타지 # L0 비전에서 고정
forbidden: anime 스타일 · 현대 의복 · 일반 판타지 마법사 로브
function과 narrative_seed가 외형보다 먼저다. "왜 이 캐릭터가 이렇게 생겨야 하는가"를 입력이 들고 있어야, 양산 결과에서 "왜 이게 더 나은가"를 판단할 수 있다.
첨부한 concept_brief를 바탕으로 캐릭터 컨셉 방향 제안 6안을 생성하라.
이건 탐색용 양산이다 — 최종이 아니라 아트 디렉터가 고를 후보다.
규칙:
1) function과 narrative_seed를 시각으로 번역하라. 단순 미형 금지.
(예: "감정을 숫자 뒤에 숨김" → 표정·소품·자세로 어떻게 드러나는가)
2) style_anchor를 벗어나지 마라. forbidden 항목은 절대 금지.
3) 6안은 서로 충분히 달라야 한다. 미세 변주 6장은 탐색 가치가 없다.
4) 각 안에 한 줄 의도 근거를 붙여라 (왜 이 방향이 function에 맞는가).
5) 네가 'style_anchor와 충돌할 수 있다'고 의심하는 안은 따로 표시하라.
출력 형식: (안 번호 | 핵심 비주얼 | 의도 근거 | 충돌의심). 그 외 설명 금지.
이 프롬프트에서 비주얼 묘사가 차지하는 분량은 거의 없다. 대부분이 출력 형식을 잠그고 금기를 못 박는 데 쓰였다. AI에게 자유를 주면 학습 데이터의 평균 — 일반 판타지 마법사 — 으로 회귀하기 때문이다. 의도 번역 의무, style_anchor 고정, 6안 다양성 강제, 그리고 "충돌하면 신고하라"는 탈출구가 회귀를 막는 네 개의 빗장이다.
| 안 | 핵심 비주얼 | 의도 근거 | 충돌 |
|----|--------------------------------------|-----------------------------------|------|
| 1 | 황동 확대경 영구 장착, 잉크 얼룩 손가락 | 관측이 곧 정체성 — 도구가 신체화 | |
| 2 | 표정 없는 노학자, 손에 계기판 두루마리 | 감정을 숫자 뒤에 숨김 = 무표정+기록물 | |
| 3 | 종탑 배경, 등 뒤로 식어가는 봉인 맥 빛 | 직무 공간이 인물을 설명 | |
| 4 | 화려한 별 자수 로브, 손에 마력 구체 | 위엄 강조 | ★ |
| 5 | 한쪽 눈 의안, 평생 관측의 흔적 | 30년 직무의 신체 대가 | |
| 6 | 젊고 단정한 학자, 미형 강조 | 호감도 우선 | ★ |
출력에서 가장 가치 있는 건 표가 아니라 ★ 표시 두 줄이다. 4안(화려한 마법사 로브, 마력 구체)과 6안(미형 우선)에 AI가 스스로 "style_anchor·forbidden과 충돌할 수 있다"고 신고했다. 5번 규칙의 탈출구가 없었다면 이 두 안은 다른 네 안과 똑같은 얼굴로 표에 섞여 들어왔을 것이다. 의심스러운 자리를 AI가 직접 손들어 표시하게 만드는 것 — 그게 자유로운 양산과 통제된 양산을 가른다.
이 출력을 그대로 받지 않는다. 아트 디렉터가 6안을 brief로 한 번 친다. 실제로 이 세션에서 판정이 이렇게 갈렸다.
forbidden: 일반 판타지 마법사 로브 정면 위반이다. 이 캐릭터는 마법을 쓰는 사람이 아니라 마력을 관측·기록하는 사람이다. function 오역.narrative_seed: 30년 직무의 신체 대가와 어긋난다. 이 NPC의 설득력은 "오래 한 사람"의 마모에서 나온다. 젊고 깨끗한 얼굴은 서사를 깎는다.여기서 폐기 2건은 손실이 아니다. 손으로 그렸다면 이 두 방향이 틀렸다는 걸 알기까지 며칠이 걸렸을 것을, 양산이 6안을 동시에 펼쳐 한 시간 안에 솎아 냈다.
1안(확대경 신체화)과 5안(의안)의 방향을 합쳐라.
- 황동 확대경 + 한쪽 의안을 한 인물에 통합
- 감정 억제(scholarly_strict): 표정은 무, 소품으로만 직무를 말함
- forbidden 재확인: 마법사 로브·마력 구체·미형 강조 모두 금지
이건 아트 디렉터가 수작업 정비로 넘길 '최종 후보 1안'을 만드는 단계다.
AI는 확대경과 의안을 한 노학자에게 통합한 단일 방향을 다시 답했고, 그 한 장이 컨셉 아티스트의 책상으로 넘어가 수작업으로 마무리됐다. 양산(6안) → 폐기(2안) → 수렴(1안) → 사람 마무리의 한 사이클이 여기서 닫힌다. AI가 만든 건 최종 에셋이 아니라, 아트 디렉터가 고를 후보의 폭이었다.
이 한 바퀴가 이 책 전체의 Show 기준이다. AI가 무엇을 뱉고, 무엇이 폐기되고, 사람이 무엇을 마무리하는지를 한 번이라도 끝까지 보지 않으면, "AI로 컨셉을 양산했다"는 문장은 공허하다.
위 세션에서 6안 중 2안이 폐기됐다. 컨셉 라인 전체로 보면 폐기는 훨씬 더 쌓인다. 회의실 벽에 붙인 100장에서 채택은 3장이었다.
이 비율을 정직하게 다뤄 둔다. 이건 도입 초기 컨셉 세션 몇 건을 직접 카운트한 방향값이지, 정밀한 모수 비율이 아니다(저자 추정, 미검증 — 캐릭터 성격·브리프 품질에 따라 크게 흔들린다). 그러므로 "정확히 몇 %"가 아니라 "손작업 시기보다 폐기를 훨씬 자유롭게 하게 됐다"는 방향으로 읽는 게 맞다.
중요한 건 폐기율 0%가 목표가 아니라는 점이다. 종이 한 장이 비싸면 한 장을 끝까지 다듬는다. 종이 100장이 30초에 인쇄되면 99장을 버려도 부담이 없고, 그만큼 탐색 폭이 넓어진다. 폐기율이 오르는 건 탐색의 깊이가 깊어진다는 신호다. 폐기율 자체를 줄이려는 운영은 — 예컨대 "AI가 뽑은 건 웬만하면 쓰자"는 압력은 — 탐색의 가치를 같이 깎는다. §12.1.2에서 4·6안을 망설임 없이 버릴 수 있었던 건, 버리는 비용이 0이었기 때문이다.
컨셉과 함께 가역 구간에서 ROI가 큰 또 한 자리가 텍스처다. 3D 모델에 입힐 재질을 생성하는 단계인데, 여기서도 AI가 들어가는 칸과 결정론이 맡는 칸이 명확히 갈린다.
flowchart TD
A["UV unwrap
(사람)"] --> B["베이스 텍스처
(AI 생성 또는 페인팅)"]
B --> C["노멀·러프니스·메탈릭
결정론 추출 (Materialize 등)"]
C --> D["엔진 import +
라이팅 미리보기"]
D --> E{"아트 디렉터 검수
(가역 — 재생성 자유)"}
E -->|"톤 불일치"| B
E -->|"통과"| F["에셋 ID·머티리얼 키 등록
(L3 데이터 시트)"]
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
class C code;
class B ai;
class A,E human;
class F data;
AI가 들어가는 건 베이스 텍스처 한 칸뿐이다. 노멀·러프니스·메탈릭 같은 PBR 맵은 AI에게 매번 다르게 뽑게 두지 않고 결정론 추출 도구가 맡는다. 같은 베이스에서 같은 맵이 나와야 재질이 일관되기 때문이다. 이건 §6.2의 도시 생성기에서 보상 곡선을 AI에 안 맡기고 룰북이 잡던 것과 같은 분담이다 — 결정론으로 보장할 수 있는 건 코드가, 탐색이 필요한 건 AI가.
베이스 텍스처조차 모든 에셋에 AI가 적합한 건 아니다. 캐릭터 얼굴처럼 미세한 디테일이 게임 정체성을 좌우하는 자리에는 여전히 사람 손이 우선이다. 그래서 검수 게이트가 "톤 불일치"를 잡으면 자동 폐기가 아니라 재생성으로 되돌린다. 여기까지가 전부 경계선 왼쪽 — 몇 번을 다시 돌려도 잃는 게 없는 가역 구간이다.
경계선을 넘기 직전, 가역 구간에서 양산된 에셋이 게임 전체의 결과 어긋나지는 않는지 검사한다. 이건 사람 눈만으로는 새는 자리라 코드가 1차로 친다.
# visual_regression.py — 에셋 교체 시 의도 외 변화 검출 (골격)
# 입력: 에셋 ID + 교체 전/후 동일 조건 렌더 캡처
# 출력: 변화 등급 (사람 검수 게이트로 alert)
def compare_renders(asset_id, before_png, after_png, threshold=(1.0, 5.0)):
diff = pixel_diff(before_png, after_png) # 0~100 정규화
if diff > threshold[1]:
return ("BLOCK", f"{asset_id}: 큰 변화 {diff:.1f}% — 검수 전 비가역 진입 금지")
elif diff > threshold[0]:
return ("WARN", f"{asset_id}: 경미한 변화 {diff:.1f}% — 의도 확인 필요")
else:
return ("PASS", f"{asset_id}: 변화 없음")
이 30줄이 "텍스처 한 장 갈았더니 다른 캐릭터 그림자가 깨졌다"는 사고를 비가역 진입 전에 잡는다. 중요한 설계는 BLOCK이 자동 폐기가 아니라 검수 게이트로 alert만 올린다는 점이다 — 의도된 변경(리디자인)까지 코드가 죽여 버리면 작가들이 한두 분기 안에 "끄자"고 한다. 의심 후보는 기계가 뽑되, 비가역으로 넘길지는 사람이 정한다.
검수가 잡는 또 하나는 스타일 일관성이다. AI 출력은 매번 미세하게 다르므로, 양산된 컨셉·텍스처가 게임의 결을 유지하는지 사람이 마지막으로 본다. 이 게이트를 통과한 것만 리깅·모션 캡처·최종 렌더라는 비가역 단계로 넘어간다. 한번 모션을 캡처하고 빌드에 올리면, 일관성 사고는 재작업·재녹음·재배포로만 고칠 수 있기 때문이다.
기획자가 AI로 컨셉·텍스처를 양산해도, 실제로 그림을 그리는 아트팀은 별도 조직이다. 여기서 협업의 핵심은 아트팀이 기획팀의 도구·컨벤션을 배울 필요가 없게 만드는 것이다. 프로젝트 A의 아트 가이드(96_ArtGuide/)는 이걸 자동화로 푼다.
아트 결정사항은 기획팀이 md로 쓰고, _convert_md_to_html.py가 html로 변환한 뒤, _SyncToArtRepo.bat가 별도 아트 리포지토리로 push한다. 아트팀은 그 리포에서 html만 본다 — md 컨벤션도, 기획팀 SVN도 몰라도 된다(파이프라인 도식은 §12.2.4).
그리고 이 결정 문서는 7개 도메인(00_Common·01_Character~07_Env)으로 나뉘어 각자 자기 스타일 룰을 들고 통합 게이트에서 합쳐진다. 이게 다음 장(12.2)에서 다룰 ArtGuide 7영역인데, 핵심만 미리 말하면 이렇다 — 스타일 룰북을 한 칸이 아니라 7칸 서랍으로 나누면, AI 양산 프롬프트가 매번 작가 머릿속에서 새로 조립되지 않고 서랍에서 꺼내진다. §12.1.2의 style_anchor·forbidden이 바로 그 서랍에서 나온 입력이다. 룰북이 분리돼 있어야 양산 결과가 일반 판타지 평균으로 회귀하지 않는다.
그렇다고 모든 게임이 7영역을 다 가져야 하는 건 아니다. 캐주얼 장르라면 캐릭터·환경 두 칸으로도 충분하다. 분리는 점진적으로, 인터페이스는 좁게.
이 장의 수치는 세 종류뿐이다. (1) 방향·비율 — "100장 양산에 채택 3장"은 저자 경험 기반 방향값(미검증)이라, 절대값이 아니라 "가역 구간에서는 폐기 비용이 0에 수렴"이라는 방향으로 읽는다. (2) 측정값 — 시각 회귀 변화율(diff %), 일관성 사고 건수, BLOCK 처리 건수는 visual_regression.py가 숫자로 뱉으니 회의에서 "느낌" 대신 숫자로 말할 수 있다. 반면 "리텐션이 올랐다"는 아트 하나로 좌우되지 않으니 인과를 단정하지 않는다.
(3) 위험은 운영 비용 안에 둔다. AI 아트의 세 위험 — 학습 데이터 저작권, 스타일 일관성 손상, 아티스트 일자리 — 은 ROI 계산 밖이 아니라 안이다. 저자 방침은 가역 구간에 AI 적극, 비가역으로 넘기는 최종 에셋은 수작업 정비이며, 빌드에 직접 들어가는 에셋의 AI 출력 비율은 0을 원칙으로 둔다. 다만 이건 한 정책일 뿐이다 — 라이선스가 명시된 모델만 쓰며 최종 에셋까지 AI를 활용하는 팀도 있다. 법무 정책은 회사마다 다르고, 이 책은 정답이 아니라 경계선 긋는 법을 제시한다.
세 위험 중 가장 자주 놓치는 건 셋째다. AI를 "아티스트를 대체하는 양산기"가 아니라 "탐색 폭을 넓혀 아티스트의 결정권을 키우는 보조"로 자리매김하지 않으면, 도구는 KPI상 성공해도 조직에서 거부된다. 이건 feedback atom design_intent_vs_automation_boundary(설계 의도 vs 자동화 경계)가 못 박은 자리이기도 하다.
| 패턴 | 왜 실패하나 | 처방 |
|---|---|---|
| AI 컨셉을 최종 에셋으로 직접 빌드 투입 | 비가역 단계를 사람 검수 없이 통과 | 경계선 앞 게이트 (§12.1.1) |
| 프롬프트를 외형 묘사로 시작 | function 오역 — 미형 마법사로 회귀 | 설계 의도 먼저 (§12.1.2, image_prompt_design_intent_first) |
| 양산 6안이 미세 변주 | 탐색 가치 없음, 폐기할 게 없음 | 다양성 강제 (§12.1.2) |
| 폐기율을 줄이려 함 | 탐색 깊이를 같이 깎음 | 가역 구간 폐기는 신호로 본다 (§12.1.3) |
| 텍스처 PBR 맵까지 AI 생성 | 재질 일관성이 호출마다 흔들림 | 결정론 추출 분리 (§12.1.4) |
| 시각 회귀 없이 에셋 교체 | 의도 외 변화가 비가역으로 샘 | visual_regression.py 게이트 (§12.1.5) |
혼자라면 이만큼만: 아트팀도 데이터 시트도 없어도 됩니다. 본인 게임(또는 좋아하는 게임)의 NPC 한 명을 골라 §12.1.2의
concept_brief형식으로function과narrative_seed를 외형보다 먼저 적고, 6안 양산 프롬프트를 그대로 붙여 한 번 돌려 보세요. 나온 6안 중 의도와 어긋나는 한 안을 골라 "이건 function 오역이다, 폐기하고 다시"라고 반박해 보면, 가역 구간의 폐기가 손실이 아니라 탐색이라는 게 몸으로 들어옵니다.
팀이라면 다음 한 단계로 시작하세요. 파이프라인에 가역/비가역 경계선을 명시적으로 한 줄 긋습니다(§12.1.1). 어느 단계까지가 "버려도 0"이고 어디부터가 "되돌리면 비싼"지 합의하고, 그 경계 위에 사람 검수 게이트를 둡니다. 경계가 그어지면 "AI를 어디까지 쓸까"라는 매번 처음부터 하던 싸움이 "이 작업은 경계 어느 쪽이냐"라는 한 번의 판정으로 바뀝니다.
setup → prompt → verify로 요약하면 — setup: 파이프라인에 가역/비가역 경계선과 검수 게이트를 정의합니다. prompt: §12.1.2 형식으로 설계 의도를 먼저 입력하고 6안을 양산하되 금기·다양성·신고를 강제합니다. verify: 가역 구간에서 의도 오역 1건을 직접 골라 폐기·재요청으로 한 사이클을 닫고, 비가역 진입 전 visual_regression.py로 의도 외 변화를 칩니다.
목요일 통합 리뷰. 같은 화면에 신규 자산 일곱 개를 붙여 놓고 본 순간, 우리는 동시에 웃었다. 학자 캐릭터는 회색 톤의 진중한 실루엣인데, 그 옆에서 터지는 스킬 VFX가 형광 핑크였다. 둘 다 각자의 영역에서는 완벽한 결정이었다. 캐릭터 디렉터는 자기 _STYLE_GUIDE.md를 그대로 지켰고, VFX 아티스트도 "눈에 잘 띄게"라는 내 명세를 충실히 따랐다. 아무도 틀리지 않았는데 같은 화면에 놓으니 두 게임이 싸우고 있었다.
이 장면이 ArtGuide를 7영역으로 쪼개는 이유이자, 7영역을 다시 묶어야 하는 이유다. ArtGuide는 게임의 비주얼 헌법이다. 영역으로 나누면 분야별 디렉터가 자치를 가지면서 결정이 빨라지고, 통합 리뷰로 다시 묶지 않으면 위의 형광 핑크 같은 사고가 분기마다 쌓인다. 기획자가 이 균형의 어느 지점에 손을 대는가가 이 장의 전부다.
저자가 디렉터로 일한 프로젝트 A(동양 판타지 톤의 모바일 우선 MMORPG)의 디자인 저장소에는 96_ArtGuide/라는 폴더가 있다. 번호 96은 저장소 정렬 규칙상 아트 가이드가 거의 마지막에 오도록 붙인 것이고, 그 아래가 일곱 도메인으로 갈라진다. 추상적인 "프로젝트의 아트 폴더"가 아니라, 아래가 그 폴더의 실제 하위 구조다.
도식의 핵심은 두 가지다. 첫째, 일곱 도메인이 나란히 평등하게 자치를 가진다. 한 층에 일곱 개의 작업실이 늘어선 사무실을 떠올리면 된다. 각 방의 책임자가 그 방의 결정권을 쥐되, 복도에서 마주칠 때 같은 게임이라는 감각은 잃지 않아야 한다. 둘째, 그 위에 00_Common이 얹혀 있다. 일곱 방 모두가 따라야 할 공통 규약, 즉 전체 색 팔레트와 재질 기준과 시대 톤이 여기 산다. 06_UI는 9.1.3에서 다룬 UI 협업 표준과 같은 도메인이라 이 장에서는 경계만 그어 두고 넘어간다.
일곱 도메인에 기획자가 같은 강도로 개입하지는 않는다. 기획자는 의도와 서사를 결정하고 아트는 시각을 결정한다는 원칙은 모든 도메인에 동일하지만, 의도가 시각을 어디까지 끌고 가느냐는 도메인마다 다르다.
| 영역 | 기획자 관여 | 기획자가 넘기지 말아야 할 선 |
|---|---|---|
| 01_Character | 강함 | 컨셉·성격·세력·역할까지. 얼굴 비례·붓터치는 아니다 |
| 02_Animation | 보통 | 스킬 모션의 "종류·반응"까지. 프레임 타이밍은 아니다 |
| 03_Monster | 강함 | 적 컨셉·세력·생태까지. 비늘 패턴 디테일은 아니다 |
| 04_NPC | 강함 | 역할·관계·voice_profile까지. 의상 자수는 아니다 |
| 05_VFX | 약함 | "느린 발사, 큰 폭발, 보라색"까지. 파티클 수는 아니다 |
| 06_UI | 강함 | 정보 구조·우선순위까지(9.3). 픽셀 여백은 아니다 |
| 07_Environment | 보통 | 분위기·랜드마크 의도까지. 나무 폴리곤은 아니다 |
오른쪽 칸이 이 표의 진짜 내용이다. 관여가 "강함"이라고 적힌 도메인에서도 기획자가 넘으면 안 되는 선이 있다. 캐릭터 컨셉은 강하게 끌고 가되 얼굴 비례까지 손대면 그 순간 캐릭터 디렉터의 자치가 무너진다. 그리고 강함과 약함의 경계 자체가 장르에 따라 흔들린다. 호러 게임이라면 VFX가 공포의 핵심이라 기획자 관여가 강해지고, 캐주얼 퍼즐이라면 캐릭터 관여가 오히려 약해진다. 위 표는 프로젝트 A의 장르 기준이지 보편 법칙이 아니다.
각 도메인은 표준 문서 묶음으로 운영한다. 01_Character/ 도메인의 실제 파일 구성을 보자.
01_Character/
├── _STYLE_GUIDE.md — 캐릭터 전체 스타일 (헌법)
├── _COLOR_PALETTE.md — 색상·재질 가이드
├── _PROPORTION_REFERENCE.md — 비례·실루엣 룰
├── _DO_AND_DONT.md — 허용·금지
├── individual/ — 캐릭터별 시트
│ ├── K_001_director.md
│ ├── K_007_scholar.md
│ └── ...
└── _REVIEW_LOG.md — 검수 이력
_STYLE_GUIDE.md가 도메인의 헌법이다. 개별 캐릭터 시트(individual/)는 모두 이 헌법 위에서 변주된다. 헌법이 흔들리면 그 아래 모든 캐릭터가 흔들리므로, 이 파일 한 장이 도메인에서 가장 자주 검토되는 문서다. 골격은 다음과 같다.
---
title: 01_Character Style Guide
layer: L1
---
## 1. 톤
- 19세기 산업 혁명 이전, 한국 판타지 분위기
- 사실적 비례 (7~7.5등신, 데포르메 금지)
## 2. 색상
- 채도: 보통 (실사 60~70% 수준)
- 메인 팔레트: 00_Common 상속
- 캐릭터별 액센트 색 (1~2개)
## 3. 의상 룰
- 세력별 의상 구분 (학자 → 회색 + 보라 액센트)
- 직업·계급에 따른 의상 디테일
## 4. DO
- 5m 거리에서 실루엣만으로 누군지 식별 가능
- 세력 정체성을 시각으로 표현
## 5. DON'T
- 일본 애니 스타일
- 비-시대극 요소 (현대 의상·소품)
- 채도 과다
여기서 한 줄이 중요하다. ## 2. 색상의 "메인 팔레트: 00_Common 상속". 캐릭터 도메인이 색을 독자적으로 정하지 않고 상위 공통 규약을 물려받는다는 명시다. 이 한 줄이 도입부의 형광 핑크 사고를 구조적으로 막는 장치다. 모든 도메인의 _STYLE_GUIDE.md가 색만큼은 00_Common을 상속하면, 적어도 색 충돌은 헌법 단계에서 차단된다.
여기서 프로젝트 A가 실제로 부딪힌 가장 현실적인 문제가 나온다. 아트팀은 마크다운을 읽지 않는다. 정확히 말하면, 읽으라고 강요하면 안 된다. 아티스트에게 git diff와 frontmatter와 마크다운 헤더 위계를 학습시키는 비용은, 그 학습으로 얻는 협업 효율보다 거의 항상 크다. 기획팀의 도구를 아트팀에 그대로 들이미는 순간 협업은 오히려 느려진다.
그래서 프로젝트 A의 파이프라인은 "기획팀은 md로 결정하고, 아트팀은 html만 본다"는 한 줄로 정리된다.
flowchart LR
A["기획팀: ArtGuide 결정
(_STYLE_GUIDE.md 갱신)"] --> B["_convert_md_to_html.py
(md → 보기 좋은 html)"]
B --> C["_SyncToArtRepo.bat
(별도 아트 SVN으로 push)"]
C --> D["아트팀: html만 열람
(md 학습 0)"]
D -. 피드백 .-> A
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
class B,C code;
class A,D human;
핵심은 두 개의 자동화 자산이다. _convert_md_to_html.py는 도메인의 마크다운 가이드를 아티스트가 브라우저로 편하게 읽을 수 있는 html로 변환한다. 색 팔레트는 실제 색 칩으로, DO/DON'T는 시각 대비로 렌더된다. _SyncToArtRepo.bat는 그 html을 기획 저장소가 아닌 별도의 아트 전용 저장소로 밀어 넣는다. 저장소를 분리하는 이유는 단순하다. 아티스트가 자기 저장소만 받으면 기획팀의 내부 md 히스토리, 작업 중인 초안, 다른 도메인의 결정 과정에 노출되지 않는다. 아티스트가 보는 것은 확정된 결정의 읽기 좋은 결과물뿐이다. 마크다운 학습 비용이 0으로 떨어진다.
이 구조에서 기획자가 얻는 책임 하나가 추가된다. md를 갱신하면 반드시 변환·동기화 단계를 돌려야 한다. 갱신만 하고 동기화를 빠뜨리면, 아트팀은 어제 결정으로 오늘 그림을 그린다. 결정과 전달 사이의 이 한 칸이 비어 있으면 자치도 통합도 의미가 없다.
비-기획자 협업의 연장선에서, 컨셉 단계에 생성 AI를 쓸 때 기획자가 지켜야 하는 원칙이 하나 있다. 프로젝트 A의 내부 규약 이름으로는 image_prompt_design_intent_first, 풀어 쓰면 "이미지 프롬프트도 설계 의도를 먼저 적는다"다.
생성 이미지로 컨셉을 탐색할 때 흔한 실패는 프롬프트가 결과물의 외양 묘사로만 채워지는 것이다. "회색 도포를 입은 50대 동양 남성, 차분한 표정, 실사풍"처럼. 이 프롬프트는 그림은 뽑지만 왜 그래야 하는지를 담지 못해서, 아트 디렉터가 그 그림을 변주할 때 길을 잃는다. 설계 의도 우선 원칙은 프롬프트 앞에 의도 블록을 강제한다.
[설계 의도]
- 역할: 학자 세력의 정신적 지주, 플레이어의 첫 멘토
- 읽혀야 할 것: 5m 거리에서도 "지식인·비전투" 실루엣
- 세력 신호: 학자 = 회색 + 보라 액센트 (00_Common 상속)
- 금지: 무기 휴대, 화려한 갑주 (전투 직군으로 오독됨)
[프롬프트]
회색 도포의 50대 동양 남성 학자, 보라색 옷고름 액센트,
무기 없음, 차분하고 학구적 표정, 19세기 이전 한국 판타지,
실사 비례 7.5등신, 채도 보통, ...
의도 블록이 프롬프트 위에 있으면 그 그림은 결정의 일부가 된다. 다음 사람이 같은 캐릭터의 다른 포즈를 뽑을 때, 외양을 베끼는 게 아니라 의도를 다시 충족시킨다. 이미지가 마음에 안 들어 폐기할 때도 "의도 중 무엇이 안 읽혔는가"로 토론할 수 있다. 외양만 적힌 프롬프트는 "내 취향엔 이게 낫다"로 끝나 검증할 근거를 남기지 않지만, 의도가 앞선 프롬프트는 무엇이 충족됐고 무엇이 빠졌는지 따져 볼 기록을 남긴다.
여기서 도구 선택이 의도 우선 원칙과 맞물린다. "같은 캐릭터의 다른 포즈"를 의도대로 반복 생성하려면 프롬프트만으로는 부족하다. 프로젝트 A의 도구 분담은 §12.1.1과 같다 — 본 양산은 자체 호스팅 SD(SDXL)/ComfyUI에 캐릭터 LoRA(얼굴·의상 고정)와 ControlNet(포즈·실루엣)을 걸어, 의도 블록이 요구하는 "5m 거리 식별성"을 포즈가 바뀌어도 유지하고 IP도 함께 지킨다. 폐쇄형 도구(미드저니 등)는 초기 무드보드 정도에만 쓴다.
도메인 자치와 기획자 관여가 실제로 어떻게 굴러가는지, 01_Character의 한 사이클을 처음부터 끝까지 따라가 본다. 다음은 저자가 학자 캐릭터(K_007_scholar)의 컨셉 명세 초안을 AI에게 맡긴 실제 작업의 재구성이다. 요약하지 않고, 어색했던 출력과 저자의 거부까지 그대로 싣는다.
① 저자가 AI에 던진 프롬프트 (전문)
너는 우리 프로젝트의 캐릭터 컨셉 보조 작가다. 아래 규약을 지켜 K_007_scholar의
컨셉 명세서 초안을 마크다운으로 써라.
규약:
- 01_Character/_STYLE_GUIDE.md 준수: 19세기 이전 한국 판타지, 7~7.5등신,
채도 보통, 일본 애니 스타일 금지.
- 색은 00_Common 팔레트를 상속하고, 학자 세력 액센트는 보라.
- voice_profile 5항목(어조/말버릇/금지어/감정 기본값/관계 호칭)을 반드시 포함.
- 컨셉·성격·세력·역할까지만 결정하고, 얼굴 비례나 붓터치 같은 시각 디테일은
비워 둬라(캐릭터 디렉터의 자치 영역).
- 출력은 individual/ 캐릭터 시트 형식.
② Claude의 날것 출력 (실패 포함, 손대지 않음)
# K_007 학자 (Scholar)
## 역할
학자 세력의 정신적 지주이자 플레이어의 첫 멘토.
## 외형
- 키 180cm, 7.5등신, 마른 체형
- 흰 수염, 깊은 주름, 형형한 눈빛
- 회색 도포에 보라 옷고름
- ★ 추천: 살짝 빛나는 황금빛 눈동자로 신비감 강조
## 성격
박학하나 오만하지 않고, 제자에게 엄격하되 따뜻하다.
## voice_profile
- 어조: 느리고 차분함
- 말버릇: "...그러하네", 문장 끝을 길게
- 감정 기본값: 평정
- 관계 호칭: 플레이어를 "젊은 벗"이라 부름
③ 저자의 검증·거부 (어디가 왜 틀렸는가)
출력에는 두 군데 결함이 있었다. 첫째, ## 외형의 "★ 추천: 황금빛 눈동자". 이건 명백한 월권이다. 눈동자 색 같은 시각 디테일은 캐릭터 디렉터의 자치 영역인데 AI가 컨셉 단계에서 못을 박아 버렸다. 그대로 두면 디렉터가 "기획에서 정했다"며 자기 판단을 접거나, 무시하고 충돌하거나 둘 중 하나다. 둘째, voice_profile이 5항목이 아니라 4항목이다. "금지어"가 통째로 빠졌다. 멘토 캐릭터가 어떤 말을 절대 하지 않는가는 성격만큼 중요한데 AI가 누락했다.
④ 재요청 (정확히 두 군데만 교정)
두 가지만 고쳐라.
1. ## 외형에서 "황금빛 눈동자" 추천 줄을 삭제하라. 눈동자 색은 캐릭터
디렉터의 결정 영역이다. 외형 항목은 실루엣·체형·세력색까지만 적고
세부 색·재질은 "(디렉터 결정)"으로 비워 둬라.
2. voice_profile에 빠진 "금지어" 항목을 추가하라. 학자 멘토로서
하지 않을 말(욕설, 천박한 농담, 현대어)을 명시하라.
이 사이클의 교훈은 도구가 아니라 경계다. AI는 빠르게 그럴듯한 초안을 줬지만, 기획자가 넘지 말아야 할 선(시각 디테일)을 대신 넘어 줬고 반드시 넣어야 할 것(금지어)을 빠뜨렸다. 검증의 기준은 "잘 썼는가"가 아니라 "도메인 자치의 경계를 지켰는가"였다. AI를 캐릭터 컨셉에 쓰는 한, 이 경계 검수는 사람이 끝까지 쥐고 있어야 한다.
일곱 도메인이 자치를 가지면 도입부의 형광 핑크처럼 도메인 사이에 불일치가 생긴다. 자주 나오는 유형은 정해져 있다.
| 불일치 유형 | 실제 예 |
|---|---|
| 캐릭터-환경 톤 차이 | 캐릭터는 진중한데 배경이 화려해 따로 논다 |
| 캐릭터-VFX 색상 충돌 | 캐릭터 회색 톤, 스킬 VFX는 형광 핑크 |
| NPC-Monster 경계 모호 | 우호 NPC인데 몬스터처럼 위협적으로 읽힌다 |
| UI-캐릭터 색감 불일치 | UI는 차가운 톤, 캐릭터는 따뜻한 톤 |
이 불일치를 잡는 장치가 주 1회 통합 리뷰다. 절차는 단순하다.
주 1회 ArtGuide 통합 리뷰 (목요일)
─────────────────────────────────
1. 그 주 신규 자산 5~10개 랜덤 추출
2. 같은 화면에 함께 배치 (인게임 시뮬레이션)
3. 일곱 도메인 디렉터 + 게임 디렉터가 동시 검수
4. 불일치 발견 → 해당 도메인 _STYLE_GUIDE 보강
또는 00_Common 상위 규약 보강
핵심은 3번과 4번이다. 검수를 도메인 디렉터들이 동시에 한다는 것, 그리고 발견된 불일치를 개별 자산을 고쳐서 끝내는 게 아니라 가이드 문서로 환원한다는 것. 도입부의 형광 핑크 사고는 그 자산 하나를 회색으로 바꾸고 끝내면 다음 주에 똑같이 재발한다. 대신 00_Common에 "스킬 VFX 채도는 캐릭터 팔레트 채도 +20% 이내"라는 규약을 추가하면, 같은 사고가 구조적으로 닫힌다. 월 4회 누적되는 이 사이클이 자치가 사일로로 굳는 것을 막는 유일한 가드레일이다.
7영역 분리가 가져온 변화를 프로젝트 A 운영 경험에서 정리하면 다음과 같다. 아래 수치 중 사이클 일수와 시간은 저자의 운영 경험에 기반한 저자 추정(미검증)이며, 정확한 측정값이 아니라 분리 전후의 방향과 대략의 비율로만 읽어야 한다.
| 항목 | 분리 전 | 분리 후 | 성격 |
|---|---|---|---|
| 아트 결정 사이클 | 1~2주 | 3~5일 | 저자 추정(미검증) |
| 영역 간 일관성 사고 | 분기당 여러 건 | 현저히 감소 | 방향만 |
| 게임 디렉터 아트 검수 시간 | 주 다수 시간 | 크게 감소 | 방향만 |
| 신규 영역 디렉터 온보딩 | 수개월 | 약 1개월 | 저자 추정(미검증) |
| 아트 자산 폐기 비율 | 높음 | 감소 | 방향만 |
표를 정직하게 읽으면, 단언할 수 있는 것은 모든 항목이 같은 방향으로 움직였다는 사실뿐이다. 가장 분명한 효과는 게임 디렉터의 시간이 회수됐다는 것이다. 자치 없는 구조에서는 모든 아트 결정이 게임 디렉터 한 사람의 책상을 거쳐야 했다. 한 책상에 종이가 쌓이는 구조다. 7영역으로 나누자 종이가 일곱 책상에 분산됐다. 종이의 총량은 같지만 어느 책상도 무너지지 않는다.
여기서 가장 흔한 오해를 끊어야 한다. 자치는 책임의 분산이 아니다. 시간의 재분배다. 도메인 디렉터가 자기 영역을 결정한다고 해서 게임 전체의 시각 책임이 일곱 조각으로 흩어지는 게 아니다. 통합 리뷰에서 그 일곱이 다시 한 자리에 모이고, 최종 시각 책임은 여전히 한 사람에게 수렴한다. 자치를 책임 회피의 빌미로 삼는 순간 — "그건 내 도메인 밖이라"가 입버릇이 되는 순간 — 도입부의 형광 핑크는 누구의 문제도 아닌 채로 빌드에 들어간다.
그리고 한 가지 단서. 7영역 자치는 규모의 함수다. 소규모(~10인) 팀에서는 오히려 오버 엔지니어링이다. 디렉터 한 명이 모자 다섯 개를 쓰는 단계라면 도메인 가이드 일곱 장이 아니라 통합 가이드 한 장으로 충분하다. 자치는 책상이 부족해질 때 비로소 값을 한다.
| 패턴 | 처방 |
|---|---|
| 영역 분리 없이 모든 결정을 게임 디렉터에 집중 | 7영역 자치 도입 (단, 중규모(10~50인)부터) |
| 도메인 _STYLE_GUIDE 부재 | 각 도메인 헌법 작성을 게이트로 강제 |
| 영역 간 통합 검증 없음 | 주 1회 통합 리뷰 + 가이드 환원 |
| 기획자가 시각 디테일까지 결정 | 의도·서사 명세까지만, 디테일은 디렉터 |
| 자치가 사일로로 굳음 | 통합 리뷰에서 00_Common으로 환원 |
| md 갱신 후 동기화 누락 | _convert_md_to_html.py → _SyncToArtRepo.bat 습관화 |
| 이미지 프롬프트가 외양 묘사뿐 | 설계 의도 블록 선행(image_prompt_design_intent_first) |
setup — 가장 손이 많이 가는 한 도메인(보통 01_Character)부터 시작하세요. 도메인 폴더에 _STYLE_GUIDE.md(헌법), _COLOR_PALETTE.md, _DO_AND_DONT.md, individual/을 만듭니다. 색 항목에는 반드시 "메인 팔레트: 00_Common 상속" 한 줄을 입력해 둡니다.
prompt — 개별 자산 시트를 AI로 초안 잡을 때, 프롬프트에 (1) 해당 도메인 _STYLE_GUIDE.md 규약, (2) "컨셉·서사까지만 결정하고 시각 디테일은 '(디렉터 결정)'으로 비워 두라", (3) 빠지면 안 되는 필수 항목(예: voice_profile 5항목)을 명시합니다. 이미지 프롬프트라면 외양 묘사 위에 [설계 의도] 블록을 먼저 적습니다.
verify — 출력을 받으면 "잘 썼는가"가 아니라 "도메인 자치의 경계를 지켰는가"로 검수합니다. ① 기획자가 넘지 말아야 할 시각 디테일을 AI가 대신 결정하지 않았는가, ② 필수 항목이 누락되지 않았는가, 두 가지만 봅니다. 어긋난 곳만 콕 집어 재요청합니다. 매주 목요일, 신규 자산 5~10개를 한 화면에 모아 도메인 디렉터들과 동시에 봅니다. 불일치는 자산이 아니라 가이드 문서(00_Common 또는 도메인 _STYLE_GUIDE)로 환원합니다.
1인 축소판 — 혼자 만드는 게임이라면 일곱 도메인을 만들지 마세요. 00_Common 한 장에 색 팔레트·시대 톤·DO/DON'T를 모아 두고, 거기에 캐릭터·환경·VFX 섹션을 헤더로만 나눕니다. AI에 자산을 맡길 때 그 한 장을 통째로 프롬프트에 붙이고, "이 가이드를 어긴 곳이 있으면 표시하라"고 함께 시킵니다. 통합 리뷰는 혼자 일주일에 한 번, 그 주 만든 것들을 한 화면에 놓고 보는 5분 의식으로 충분합니다. 자치를 나눌 사람이 없을 뿐, 헌법 한 장과 주 1회 정렬이라는 골격은 1인 팀에서도 똑같이 값을 합니다.
스프린트 막바지, 컨셉 아티스트가 팀 메신저로 캐릭터 시안 한 장을 던졌다. "이거 학자 길드 시니어 맞죠?" 화면 속 인물은 30대 남성에 가죽 갑옷을 입고 있었다. 기획서에는 40대 여성, 회색 학자 가운이라고 적혀 있었다. 어디서 어긋났는지 추적해 보니, 컨셉 아티스트가 받은 자료는 두 달 전 버전의 기획서였고, 그동안 외형 가이드가 두 번 바뀌었다. 바뀐 사실을 아는 사람은 기획자 본인뿐이었다.
이 사고는 기술 문제가 아니다. 흐름 문제다. 기획서 한 페이지가 게임 안의 자산이 되기까지 평균 4~8주, 그 사이에 캐릭터 한 명의 정보는 기획자 머릿속에서 컨셉 아티스트로, 모델러로, 애니메이터로 손에서 손으로 넘어간다. 넘기는 순간마다 양식이 어긋날 수 있고, 어긋난 채로 받아주면 받은 사람은 추측으로 빈칸을 채운다. 추측은 두 달 뒤 팀 메신저 한 줄로 돌아온다.
이 장은 그 손에서 손으로의 흐름을 한 사람의 기억이 아니라 시스템 위에 올려놓는 방법을 다룬다.
프로젝트 A에서 캐릭터 자산이 흘러가는 길은 네 단계다. 중요한 것은 단계 자체가 아니라 단계와 단계 사이의 전환점이다. 사고는 단계 안에서가 아니라, 한 단계에서 다음 단계로 자산을 넘기는 그 순간에 터진다.
이 흐름을 글로 설명하는 대신 도식으로 그려야 하는데, 24부에 걸쳐 손으로 박스를 그리는 대신 Claude에게 mermaid 코드를 받아 렌더해 왔다. 이 장은 바로 그 기법을 적용해 그린 결과를 본문에 싣는다. 자기 기법을 자기 본문에서 증명하는 셈이다. 아래가 Claude에게 "spec→asset 4단계 흐름을 전환점 게이트가 보이게 mermaid로"라고 요청해 받은 출력을 그대로 렌더한 것이다.
flowchart TD
A["1단계 · 기획서
character_spec.md"] -->|명세→시각 전환| G1{게이트 1
외형 6항목 검수}
G1 -->|통과| B["2단계 · 컨셉 아트
concept_K_001_v3.png"]
G1 -.->|반려| A
B -->|시각→3D 전환| G2{게이트 2
모델 시트 검수}
G2 -->|통과| C["3단계 · 3D 자산
model_K_001.fbx"]
G2 -.->|반려| B
C -->|정적→동적 전환| G3{게이트 3
자산 lint}
G3 -->|통과| D["4단계 · 인게임 통합
애니·VFX·사운드·코드"]
G3 -.->|반려| C
D --> G4{게이트 4
종합 검수}
G4 -->|통과| E["빌드 반영"]
G4 -.->|반려| D
classDef gate fill:#fde2c8,stroke:#d2691e,color:#5a2e00;
classDef asset fill:#dbeafe,stroke:#2563eb,color:#0b2545;
class G1,G2,G3,G4 gate;
class A,B,C,D,E asset;
세 개의 전환점(명세→시각, 시각→3D, 정적→동적)마다 게이트가 서 있다. 게이트는 결재 서류를 다음 부서로 넘기기 전에 양식을 검사하는 창구다. 양식이 맞지 않으면 반려(점선)되어 이전 단계로 돌아간다. 양식이 맞지 않는 서류를 받아주면, 다음 부서는 빈칸을 추측으로 채운다. 메신저 사고는 게이트 1이 없을 때 일어난다.
mermaid의 장점이 이 그림에서 드러난다. 게이트를 하나 더 추가하거나 단계 순서를 바꿔야 할 때, 박스를 다시 그리는 것이 아니라 텍스트 한 줄을 고치면 된다. 도식이 텍스트라서 버전 관리 대상이 되고, 기획서 옆에 같이 커밋된다.
흐름의 출발점은 한 장의 마크다운 명세서다. 이 문서가 뒤따르는 세 단계 전부의 입력이다. 여기 빈칸이 있으면 그 빈칸은 사라지는 게 아니라 다음 단계로 떠넘겨져 추측이 된다.
아래는 실제로 작성하는 character_spec 양식이다. related_atoms 필드가 이 명세를 JIT atom 시스템(11부 참조)에 연결한다.
---
title: 학자 길드 시니어 K_001 캐릭터 명세
type: character_spec
layer: L2
related_atoms: [character_K_001, voice_profile_K_001]
status: draft
---
## 1. 정체성
- 이름: (TBD)
- 역할: 학자 길드 시니어, 메인 NPC, 동료 가능
- 세력: scholar_guild
- 성격: 학자_엄격, 권위적이지만 공정
## 2. 외형 가이드
- 연령: 40대
- 성별: 여성
- 체격: 평균보다 약간 큰 (170cm 상당)
- 의상: 회색 + 보라 액센트, 학자 가운, 안경
## 3. 표정·자세
- 평소: 침착, 입꼬리 내림
- 분노 시: 침묵 + 시선 차단
- 슬픔: 화제 전환, 표정 변화 미세
## 4. 게임 내 역할
- 메인 퀘스트 chapter 1·5·12 등장
- 사이드 퀘스트 8건 발주
- 동료 합류 chapter 7
## 5. 음성·대사
- voice_profile: scholar_K_001
- 대표 대사 3개:
- "그건 기록이 없는 이야기인가요?"
- "감정으로 결정하지 마세요. 데이터가 먼저입니다."
- "...당신의 선택이군요. 알겠습니다."
## 6. 참고 자료
- 실제 학자 5명 이미지 (legal 검토 완료)
- refgame의 캐릭터 톤 참고
외형 6항목(2번 섹션의 연령·성별·체격·의상, 그리고 3번 표정·자세)이 게이트 1의 체크리스트가 된다. 메신저 사고의 원인이었던 "40대 여성 / 회색 가운" 두 줄이 바로 이 섹션이다. 게이트 1이 작동했다면, 컨셉 아티스트가 받은 자료의 외형 6항목이 현재 기획서와 일치하는지 넘기기 전에 검사됐을 것이다.
명세서를 0에서 손으로 다 채우는 대신, 정체성과 역할만 정해 두고 외형·표정의 초안을 Claude에게 받는다. 아래는 실제 한 번의 왕복 전문이다. 요약하지 않았다.
프롬프트(전문):
아래 캐릭터의 정체성·역할은 확정이다. 이걸 바탕으로 character_spec의 "2. 외형 가이드"와 "3. 표정·자세" 초안을 채워라. - 역할: 학자 길드 시니어, 메인 NPC, 동료 가능 - 성격: 학자_엄격, 권위적이지만 공정 - 세력: scholar_guild 제약: refgame 톤(동양 판타지, 채도 낮음). 외형은 실루엣만으로 다른 메인 NPC와 구분돼야 한다. 표정은 분노·슬픔을 직접 드러내지 않는 절제형으로.
Claude 날것 출력(그대로, 다듬지 않음):
2. 외형 가이드
- 연령: 40대 후반
- 성별: 여성
- 체격: 마른 편, 165cm 전후 (※ "권위적"을 키로 표현할지 자세로 표현할지 확정 필요 — 저는 자세 쪽을 추천합니다)
- 의상: 짙은 남색 학자복, 은사 자수 (※ 세력 색상 팔레트를 모릅니다. scholar_guild 대표색이 따로 있나요?)
3. 표정·자세
- 평소: 턱을 약간 든 정자세, 시선은 상대보다 약간 위
- 분노 시: 말수가 줄고 어조만 차가워짐
- 슬픔: 창밖을 보는 동작으로 대체, 얼굴은 거의 변화 없음
한 가지 더: "동료 가능"이라면 전투 중 표정도 필요할 텐데, 그건 이 명세 범위 밖인가요?
사람 검증·거부:
이 출력은 절반만 채택했다. 표정·자세 섹션은 거의 그대로 받았다 — 절제형이라는 제약을 정확히 잡았고, "창밖을 보는 동작으로 슬픔을 대체"는 기획서에 없던 좋은 디테일이다. 외형은 거부했다. 두 가지 이유다. 첫째, 체격을 "마른 편, 165cm"로 잡았는데 확정 기획서는 "평균보다 약간 큰, 170cm"였다. Claude는 정체성·역할만 받았으므로 체격을 알 수 없었고, 추측한 것이다 — 정확히 게이트가 막아야 할 추측이다. 둘째, 의상 남색은 refgame 톤은 맞지만 우리 게임의 scholar_guild 대표색(회색+보라)과 충돌한다. Claude가 던진 질문("세력 대표색이 따로 있나요?")이 바로 이 충돌을 미리 짚었다.
재요청:
좋다. 표정·자세는 채택. 외형은 다음으로 고정해서 다시 정리해라: 체격 = 평균보다 약간 큰 170cm, 의상 = 회색 학자 가운 + 보라 액센트(scholar_guild 대표색), 안경 착용. 전투 표정은 이 명세 범위 밖이니 빼라.
이 한 번의 왕복에서 배울 점은, Claude가 빈칸을 추측으로 채운 그 자리가 곧 기획서의 빈칸이었다는 것이다. 모르는 값을 만났을 때 Claude는 두 가지로 갈렸다. 세력 색과 전투 표정은 "이건 모른다"며 질문으로 들어 올렸고, 그 질문이 게이트 체크리스트보다 먼저 누락을 짚었다. 반면 체격은 모른다는 표시 없이 그럴듯한 숫자로 메워 버렸다. 후자가 있는 한, 사람이 확정 기획서와 한 줄씩 대조하는 검증은 생략할 수 없다.
확정된 기획서가 컨셉 아티스트에게 넘어간다. 흐름은 §12.1.2의 컨셉 워크플로와 같다. AI로 수십~수백 장을 양산하고, 한 줌으로 큐레이션하고, 1~3안을 수작업으로 정비한 뒤 모델 시트(정면·측면·후면)를 만든다.
핵심은 이 단계 끝에 선 게이트 1이다. 모델 시트가 3단계(3D)로 넘어가기 전, 다음 다섯 항목을 검사한다.
| 항목 | 확인 기준 |
|---|---|
| 기획서 외형 6항목 부합 | 의상·체격·연령·성별·표정·자세가 현재 기획서와 일치 |
| 메인 NPC 간 실루엣 구분 | silhouette만으로 다른 캐릭터와 식별 가능 |
ArtGuide 01_Character/_STYLE_GUIDE 준수 |
영역 스타일 가이드 위반 없음 |
| voice_profile과 모순 없음 | 시각 인상이 음성 인상과 충돌하지 않음 |
| 축소 식별성 | UI·미니맵 크기로 줄여도 누구인지 알아봄 |
여기서 image_prompt_design_intent_first atom이 작동한다. 컨셉 아티스트가 프롬프트를 쓸 때도 "회색 가운 여성 학자"라는 외형 단어부터 나열하는 게 아니라, 기획서의 설계 의도("권위적이지만 공정", "감정을 절제하는 학자")부터 넣는다. 외형 키워드만 쥐고 수백 장을 양산하면 옷 색은 맞는데 눈빛은 학자가 아닌 그림이 무더기로 나온다 — 의도를 맨 앞에 세우는 건 그 "외형은 맞고 인상은 어긋난" 더미를 미리 줄이려는 것이다. 양산 도구는 §12.1.1·§12.2.5와 같다 — 자체 호스팅 SD(SDXL)/ComfyUI에 캐릭터 LoRA(얼굴·의상 고정)와 ControlNet(포즈·실루엣 고정)을 함께 걸어, 같은 인물을 다른 포즈로 수백 장 뽑아도 얼굴이 무너지지 않게 한다.
게이트 1의 첫 항목, "기획서 외형 6항목 부합"이 메신저 사고를 막는 직접적인 빗장이다. 컨셉 시안이 모델 시트로 굳기 전에 현재 기획서와 대조하므로, 두 달 전 버전을 들고 작업한 어긋남이 이 자리에서 걸린다.
여기서 한 가지 운영상의 비대칭을 짚어야 한다. 지금까지 본 명세서는 전부 마크다운인데, 컨셉 아티스트와 3D 모델러는 마크다운을 읽으려고 게임 회사에 온 사람들이 아니다. 그래서 프로젝트 A는 §12.2.4에서 본 단방향 변환 파이프라인("기획팀은 md로 결정, 아트팀은 html만 본다")을 spec→asset 흐름에도 그대로 쓴다. 기획팀이 내린 md 결정을 html로 변환해 별도 아트 SVN으로 밀어 넣고, 아트팀은 html만 본다 — md 학습 비용이 0이다.
flowchart LR
P["기획팀
character_spec.md"] --> CV["_convert_md_to_html.py"]
CV --> H["96_ArtGuide
character_spec.html"]
H --> SY["_SyncToArtRepo.bat"]
SY --> AR[("아트 SVN
(별도 저장소)")]
AR --> ART["아트팀
html만 열람"]
classDef plan fill:#dcfce7,stroke:#16a34a,color:#052e16;
classDef art fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef tool fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
class P,CV plan;
class H,SY,AR,ART art;
class CV,SY tool;
_convert_md_to_html.py가 md를 읽기 좋은 html로 바꾸고, _SyncToArtRepo.bat이 그 결과를 기획 SVN이 아니라 아트 SVN으로 push한다. 두 저장소를 분리하는 이유는 PC 분리 원칙과 같다 — 한쪽의 작업 흐름이 다른 쪽을 덮어쓰지 않게 보호하는 것이다. 변환은 항상 기획 → 아트 단방향이고, 아트팀이 html에 손대도 기획 md로 역류하지 않는다.
그 변환의 종착지인 96_ArtGuide는 7개 도메인(00_Common·01_Character~07_Env)으로 나뉜다. 각 도메인은 자기 _STYLE_GUIDE로 자치하되 00_Common이 전 도메인 공통 규약(채도 범위·명명·해상도)을 묶는다(구조 도식은 §12.2.1). 게이트 1의 세 번째 체크 항목이 바로 이 01_Character/_STYLE_GUIDE 준수 여부다.
모델 시트가 3D 단계로 넘어가면 8개 공정을 거친다: 하이폴리 모델링 → 리토폴로지(게임용 로우폴리) → UV 언랩 → 텍스처 → 리깅·스키닝 → 테스트 포즈 → 검수. 이 단계는 AI가 가장 약한 구간이다. 3D 생성 모델이 아직 게임 품질의 리토폴로지·UV를 내주지 못하므로, 사람과 전통 도구가 주역이다.
대신 이 단계에는 게이트 3, 즉 자동 자산 lint가 붙는다. 사람이 매번 폴리곤 수를 세는 게 아니라, 자산이 커밋되는 순간 자동으로 검사된다.
| 검사 항목 | 통과 조건 |
|---|---|
| 폴리곤 수 | 캐릭터당 표준 범위 (저자 운영 기준 40,000~80,000) |
| 텍스처 해상도 | 2048×2048 표준 |
| UV unwrap 효율 | 활용 면적 80% 이상 |
| 본(bone) 수 | 표준 본 셋 준수 |
| 자산 명명 규칙 | 11부 명명 컨벤션 준수 |
위반이 잡히면 해당 3D 아티스트에게 알림이 간다. 사람의 눈썰미에 의존하던 검사를 결정론으로 옮긴 것이다. 폴리곤 수·해상도 같은 항목은 옳고 그름이 명확하므로 AI도 사람도 아닌 lint 스크립트의 몫이다.
여기서 비가역 단계 하나가 등장한다. 텍스처를 굽는 렌더 공정이다. 한번 베이크한 텍스처는 되돌릴 수 없으므로, 렌더 직전에 게이트 3이 한 번 더 작동한다. 4단계의 모션캡처도 마찬가지로 비가역이다 — 캡처 세션은 배우와 장비를 다시 부르기 전엔 되돌릴 수 없다. 비가역 단계 앞의 게이트는 다른 게이트보다 더 엄격하게 운영한다.
3D 자산에 애니메이션·VFX·사운드·코드가 합쳐져 게임 안에 처음 등장한다. 모든 분야가 한자리에 모이는 단계이고, 게이트 4(종합 검수)가 마지막 빗장이다.
| 검수 항목 | 담당 |
|---|---|
| 기획서 의도 부합 | 기획자 |
| 비주얼 톤·일관성 | 아트 디렉터 |
| 애니메이션 자연스러움 | 애니메이션 디렉터 |
| 게임 내 식별성 | 게임 디렉터 |
| 성능(frame 부담) | 테크 아트 |
캐릭터 한 체당 5명이 30분~1시간 동안 본다. 이 단계의 lint는 자산-자원 매핑(Skill_Art_Resource_Mapping)이 자동으로 돌려, 인게임에 실제로 물린 자원과 기획서가 가리키는 자원이 일치하는지 검사한다. 통합 단계에서 AI의 역할은 시각 회귀 테스트와 lint 자동화에 한정된다 — 무엇을 보여줄지 결정하는 게 아니라, 어제와 오늘의 프레임이 의도치 않게 달라졌는지 픽셀로 대조하는 결정론적 작업이다.
이 장 첫머리의 메신저 사고는 사실 두 개의 사고가 겹친 것이다. 하나는 게이트 1 부재(어긋난 자료가 통과), 다른 하나는 변경 추적 부재(외형 가이드가 두 번 바뀐 사실이 하류로 전파되지 않음)다. 두 번째 사고를 막는 것이 변경 영향 추적이다.
캐릭터 한 명의 어느 단계 자료라도 바뀌면, 그 하류의 모든 자료가 영향을 받는다. 이걸 사람이 매번 손으로 계산하면 반드시 빠뜨린다. 그래서 체인 위치를 보고 하류 자료를 자동으로 긁어내는 도구를 둔다.
# spec_change_impact.py
# 체인의 어느 지점이 바뀌면, 그 하류(downstream) 자산을 전부 모은다.
CHAIN = ["spec", "concept", "model", "texture", "rig", "anim", "vfx", "ingame"]
def find_downstream_artifacts(spec_id, changed_field):
artifacts = []
chain_position = get_chain_position(changed_field) # 예: "외형.의상" → "spec"(0)
for stage in CHAIN[chain_position + 1:]: # spec 하류 전부
artifacts.extend(get_artifacts(spec_id, stage))
return artifacts
# 사용: K_001의 의상이 바뀌면?
changed = find_downstream_artifacts("K_001", "외형.의상")
# → ["concept_K_001_v3.png", "model_K_001.fbx",
# "texture_K_001_diffuse.png", "rig_K_001.fbx", ...]
changed_field가 "외형.의상"이면 체인 위치는 0번(spec)이고, 그 하류인 concept·model·texture·rig 전부가 영향 목록에 잡힌다. 이 목록이 자동 알림으로 담당자들에게 간다. 책상 위 결재판 비유로 보면, 1번 결재판을 수정한 순간 2~8번 결재판에 자동으로 빨간 깃발이 꽂히고, 깃발 단 결재판은 검토 큐로 다시 들어간다. 메신저 사고는 정확히 이 깃발이 없어서 일어났다 — 1번(기획서 외형)이 두 번 바뀌었는데 2번(컨셉)에 깃발이 안 꽂혔다.
아래는 저자가 운영한 프로젝트 A의 표준화 전후 비교다. 절대 시간·건수는 저자 추정(미검증)이며, 신뢰할 수 있는 것은 방향과 대략의 비율이다.
| 항목 | 표준화 전 | 표준화 후 | 방향 |
|---|---|---|---|
| 캐릭터 1체 (기획서→인게임) | 8~12주 | 4~6주 | 약 절반 |
| 단계 간 추측 사고 | 분기당 10~15건 | 분기당 2~3건 | 큰 폭 감소 |
| 변경 누락 사고 | 분기당 8~10건 | 분기당 1~2건 | 큰 폭 감소 |
| 종합 검수 시간 (캐릭터당) | 분산·반복 (총 4~6시간) | 30분~1시간 집중 | 집중화 |
| 신규 캐릭터 디자이너 온보딩 | 약 2개월 | 약 1개월 | 약 절반 |
캐릭터 사이클이 대략 절반으로 줄었다. 다만 이 숫자를 오해하면 안 된다. 표준화는 모든 캐릭터를 같은 속도로 찍어내는 컨베이어가 아니다. 메인 캐릭터에는 여전히 8주 가까이 들이고, 단역은 4주에 마친다. 표준화가 한 일은 속도를 균일하게 만든 게 아니라, 단계별 시간 차등을 흔들림 없이 유지하게 만든 것이다. 표준이 통제로 흐르면 작가의 창의 시간을 깎는 사고로 돌아온다 — 표준화의 목적은 추측과 누락을 없애는 것이지, 시간을 압축하는 것이 아니다.
| 단계 | AI의 역할 | 강도 |
|---|---|---|
| 1. 기획서 | 초안 작성 보조, 누락 질문 (기획자 검수) | 강 |
| 2. 컨셉 | Stable Diffusion(SDXL)·ComfyUI 양산(LoRA·ControlNet), LLM 프롬프트 | 강 |
| 3. 3D | 생성 모델 미성숙, 사람·전통 도구 주역 | 약 |
| 4. 통합 | 시각 회귀·lint 자동화 | 결정론 |
1·2단계에 AI가 강하고, 3단계는 사람이, 4단계는 결정론 도구가 맡는다. 이 분리가 자리 잡으면 각 단계의 책임이 명확해진다 — 어디까지가 AI의 초안이고 어디부터가 사람의 결정인지, 게이트 앞에서 헷갈리지 않는다.
| 패턴 | 처방 |
|---|---|
| 기획서가 외형·표정 6항목 누락 | 1단계 필수 체크, AI에게 누락 질문 받기 |
| 컨셉 단계 게이트를 생략 | 모델 시트 굳기 전 외형 6항목 대조 강제 |
| 변경 영향을 손으로 계산 | spec_change_impact 자동 추적 |
| 종합 검수를 마지막에 한꺼번에 | 단계마다 게이트 분산 |
| 자산 lint 없이 빌드 | 게이트 3 자동 차단 |
| 4주 안에 모든 캐릭터 강제 압축 | 단계별 시간 차등 유지 |
가장 첫 줄과 셋째 줄이 이 장 첫머리 메신저 사고의 직접 처방이다.
setup
1. character_spec.md 양식 하나를 만듭니다(정체성·외형 6항목·표정·역할·음성·참고 6섹션, related_atoms 필드 포함).
2. md→html 변환 스크립트(_convert_md_to_html.py 류)를 두고, 아트팀에게는 html만 공유합니다.
3. 4개 전환점에 게이트 체크리스트를 붙입니다(외형 6항목 / 모델 시트 / 자산 lint / 종합 검수).
prompt
아래 character_spec의 정체성·역할은 확정이다. "외형 가이드"와 "표정·자세" 초안을 채우되, 모르는 값은 추측하지 말고 질문으로 표시해라. 제약: refgame 톤, 실루엣만으로 구분 가능, 절제형 표정.
verify
1. AI가 추측한 값(특히 체격·색상)을 확정 기획서와 한 줄씩 대조 — 어긋나면 거부 후 고정값으로 재요청합니다.
2. 게이트 1 체크리스트 5항목을 모델 시트로 넘기기 전에 통과시킵니다.
3. 외형 한 줄을 일부러 바꿔 보고, spec_change_impact가 하류 자산 목록을 정확히 뱉는지 확인합니다.
혼자 작업한다면 변환 파이프라인·아트 SVN·5인 검수는 과합니다. 최소 두 가지만 남기세요. (1) character_spec.md 한 양식 — 외형 6항목 필수, 빈칸 금지. (2) 외형을 바꿀 때마다 "이 변경이 닿는 하류 파일"을 명세서 맨 아래 한 줄로 손수 적어 두는 습관. 도구가 없어도 그 한 줄이 변경 누락 사고를 막습니다.
1차 독자: 유저 피드백·메타게임을 읽어야 하는 MMORPG 기획자 (중규모(10~50인) 팀) 1인/취미 독자용 축소 버전: §13.1.8 「혼자라면 이만큼만」
업데이트를 내보낸 다음 날 아침, 인게임 설문의 자유응답 칸에 312건이 쌓여 있던 화면을 기억한다. 한 칸짜리 짧은 문장부터 다섯 줄짜리 분노까지 섞여 있었다. 기획팀 누구도 그 312건을 다 읽지 않았다. 정확히는 못 읽었다. 읽더라도 "대충 강화가 빡세다는 얘기가 많네요" 정도의 인상으로 회의에 들어갔고, 그 인상은 가장 목소리 큰 5건이 만든 착시였다. 312건이 실제로 무엇을 말하는지는 아무도 몰랐다.
이 장은 그 312건을 사람이 다 읽지 않고도 "무엇이 몇 건"인지 말할 수 있게 만드는 방법을 다룬다. 핵심은 두 가지다. 첫째, 수백 건의 자유응답을 토픽으로 묶고 감정을 라벨링하는 지루한 분류를 AI에게 시킨다. 둘째, AI의 클러스터를 그대로 믿지 않고 사람이 오분류 한 건을 잡아 거부하고 재요청한다. FAQ·메타게임 분석의 일반론은 다른 책에도 있으니, 이 장은 그 분석을 AI 워크플로로 돌리는 자리에만 집중한다.
FAQ와 자유응답은 기획자가 의도한 게임과 사용자가 실제로 겪는 게임의 차이를 보여주는 거울이다. 같은 질문이 안내 데스크에 하루 30번 들어오면, 응대 인력을 늘릴 게 아니라 안내판을 다시 디자인해야 한다. 문제는 그 "30번"을 세는 일이다. 자유응답은 정형 로그가 아니라서 GROUP BY가 안 걸린다. "강화가 너무 비싸요"와 "재화가 부족해서 못 키워요"는 같은 토픽이지만 문자열이 다르다. 사람이 눈으로 묶으면 312건에 두세 시간이 들고, 묶는 기준이 사람마다 흔들린다.
여기가 AI가 들어갈 자리다. 자유응답 분류는 (1) 양이 많고 (2) 지루하고 (3) 자연어 의미 판단이 필요한 — 즉 결정론 코드로는 안 되고 사람이 하면 비싼 작업이다. 다만 출시 전에 못 박을 게 하나 있다. AI가 만드는 건 토픽 클러스터(가설)이지 확정 진단이 아니다. "강화 불만 38%"는 AI가 라벨을 붙인 결과일 뿐, 그게 "강화를 너프하라"는 결정으로 바로 이어지면 안 된다. 13부 전체를 관통하는 원칙이 여기서도 그대로다 — KPI 정의와 최종 진단은 사람, 자연어 묶기와 1차 라벨링은 AI.
자동화의 진짜 가치도 이 지점에 있다. 분류를 자동화하면 분석 자체가 빨라지는 것보다, 312건이라는 신호가 매주 아침 분류된 형태로 책상에 도착한다는 게 핵심이다. 자동화의 가치는 시간 절약이 아니라 신호 노출이다(팀 운영 개념 automation_signal_value_over_time_savings). 우편함에 쌓이기만 하던 편지가 매일 분류되어 해당 부서에 배달되는 차이다.
실제로 어떻게 돌리는지 한 사이클을 끝까지 보여준다. 아래는 저자 프로젝트(모바일 우선 MMORPG, 이하 "프로젝트 A")의 인게임 설문 자유응답을 토픽 클러스터링한 세션을 충실히 재현한 것이다. 입력 프롬프트는 그대로 복사해 쓸 수 있고, 출력은 실제 세션을 재구성했다.
먼저 원본 자유응답을 기계가 읽을 수 있는 형태로 추출한다. 이건 설문 DB에서 뽑기만 하면 되니 새로 쓰는 게 아니다. 중요한 건 미화·요약하지 않고 오타·욕설·한 단어 응답까지 날것 그대로 넣는 것이다. 분류 정확도는 원문이 날것일수록 올라간다.
# survey_freetext_2026-W21.jsonl (발췌, 312건 중 6건)
{"id": 0041, "text": "강화비용 미쳤음 ㅡㅡ 10강 가는데 재화가 안모임"}
{"id": 0088, "text": "보스 패턴은 재밌는데 보상이 너무 짜요"}
{"id": 0102, "text": "길드전 매칭 너무 오래걸림 5분넘게 기다림"}
{"id": 0156, "text": "과금 안하면 강화를 못함 이게 게임이냐"}
{"id": 0203, "text": "신규 던전 분위기 좋아요 음악도 좋고"}
{"id": 0274, "text": "메일에 보상 왜 안옴? 버그인듯"}
첨부한 survey_freetext_2026-W21.jsonl(설문 자유응답 312건)을 토픽 6~10개로
묶고 각 응답에 부정/중립/긍정 라벨 붙여줘. 너무 잘게 쪼개지 말고, 응답 하나는
딱 한 클러스터에만. 안 맞으면 기타로. 클러스터별로 건수·비율·주요감정·대표 원문 1개
표로 내고, 배정 확신 안 서는 건 억지로 넣지 말고 [애매] 목록으로 빼서 이유 한 줄씩.
해설 빼고 (가)클러스터 정의 (나)집계표 (다)[애매] 목록만.
이 프롬프트에서 분류를 시키는 말은 첫 두 줄뿐이고, 나머지는 전부 출력의 모양을 가두는 제약이다. AI에게 자유를 주면 클러스터를 20개로 쪼개거나, 모든 응답을 억지로 어딘가에 욱여넣는다. 그걸 막는 건 클러스터 수 상한(6~10), 단일 배정, 감정 3분류, 그리고 "확신 못 하면 [애매]로 빼라"는 탈출구 네 줄이다. 분류 정확도는 이 울타리에서 나온다.
(가) 클러스터 정의
C1 강화/성장 비용 — 강화·재화 획득이 과하다는 불만
C2 과금 압박 — 무과금/소과금이 성장에서 배제된다는 인식
C3 콘텐츠 보상 부족 — 보스·던전 등 플레이 대비 보상이 짜다는 의견
C4 매칭/대기 시간 — 길드전·매칭 대기가 길다는 불만
C5 콘텐츠 호평 — 신규 던전·연출·음악 등 긍정 반응
C6 버그/지급 누락 — 보상 미지급·오류 신고
기타 — 위 6개에 안 맞는 단발성
(나) 집계표
| 클러스터 | 건수 | 비율 | 주요감정 | 대표 원문 |
|----------|------|------|----------|--------------------------------------------|
| C1 강화비용 | 71 | 23% | 부정 | "강화비용 미쳤음 ㅡㅡ 10강 가는데 재화가 안모임" |
| C2 과금압박 | 58 | 19% | 부정 | "과금 안하면 강화를 못함 이게 게임이냐" |
| C3 보상부족 | 49 | 16% | 부정 | "보스 패턴은 재밌는데 보상이 너무 짜요" |
| C4 매칭대기 | 34 | 11% | 부정 | "길드전 매칭 너무 오래걸림 5분넘게 기다림" |
| C5 콘텐츠호평| 41 | 13% | 긍정 | "신규 던전 분위기 좋아요 음악도 좋고" |
| C6 버그지급 | 28 | 9% | 부정 | "메일에 보상 왜 안옴? 버그인듯" |
| 기타 | 31 | 10% | 혼합 | (단발 다수) |
(다) [애매] 목록 (4건)
- id 0088 "보스 패턴은 재밌는데 보상이 너무 짜요"
→ C3(보상부족)로 배정했으나 전반부는 C5(호평)와도 겹침. 복합 응답.
- id 0156 "과금 안하면 강화를 못함" → C1과 C2 경계. C2로 보냄.
- (외 2건)
출력에서 가장 가치 있는 부분은 집계표가 아니라 맨 아래 [애매] 목록이다. AI가 자기 배정의 불확실성을 신고하고 사람에게 넘긴 자리다. 좋은 프롬프트는 AI가 "이건 확신 못 합니다"라고 말할 수 있게 만든다.
이 출력을 그대로 보고에 올리면 안 된다. 사람이 원문 샘플을 직접 친다. 실제로 이 세션에서 한 건이 걸렸다.
C2(과금 압박) 58건을 펼쳐 원문을 훑던 중, id 0156 "과금 안하면 강화를 못함 이게 게임이냐"가 눈에 걸렸다. AI는 이걸 C2(과금 압박)로 보냈다. 그런데 이 문장의 1차 통증은 "과금"이 아니라 "강화를 못함" — 즉 C1(강화 비용)이다. 사용자는 강화 벽에 막혔고, 그 벽의 원인을 과금으로 지목한 것이지, 과금 자체가 불만의 핵이 아니다. C1과 C2가 인접해 헷갈리는 건 맞지만, 이걸 C2로 세면 "강화 비용" 신호가 23%보다 작게 보이고, 정작 손봐야 할 강화 곡선이 우선순위에서 밀린다. 오분류 한 건이 결정의 방향을 바꿀 수 있는 경계 케이스다.
그래서 거부하고 재요청한다.
C1(강화비용)과 C2(과금압박) 경계가 헷갈리네. 1차 통증이 '성장 벽 자체'면 C1,
'과금 안 하면 배제된다는 형평'이면 C2로 다시 잡아줘. id 0156은 "강화를 못함"이
핵이니까 C1이고. 이 기준으로 경계에 걸친 것들 다시 배정하고 바뀐 건수만 알려줘.
AI는 경계를 다시 긋고, C2에 있던 9건을 C1으로 옮겼다. 그 결과 C1이 71→80건(26%), C2가 58→49건(16%)으로 바뀌었다. 강화 비용이 단일 최대 토픽이라는 그림은 같았지만, 그 크기가 23%에서 26%로 또렷해졌다. 한 번의 왕복으로 신호의 윤곽이 선명해진다. 이 재배정 건수(9건)와 비율 변화는 이 세션에서 실제로 카운트한 값이다(표본 312건, 단일 주차).
여기서 한 가지를 분명히 한다. 사람이 거부한 것은 "AI가 틀렸으니까"가 아니다. C2 배정도 해석으로는 가능했다. 사람이 한 일은 클러스터 정의(=KPI 정의)를 더 날카롭게 만들어 AI에게 되먹인 것이다. 정의는 사람이, 그 정의로 312건을 다시 훑는 노동은 AI가 한다.
위 세션을 매주 자동으로 돌리면 파이프라인이 된다. 사람의 손이 닿는 곳은 두 군데뿐이다. 클러스터 정의를 날카롭게 잡는 자리(앞)와, 분류 결과를 결정으로 연결하는 게이트(뒤). 그 사이의 312건 묶기와 라벨링은 AI가 돌린다.
flowchart TB
A["원본 자유응답 312건
(설문 DB 추출, 미화 금지)"] --> B["1단 AI: 토픽 클러스터링
6~10개 + 감정 라벨 + [애매] 신고"]
B --> C{"2단 사람 검증
원문 샘플 + 경계 케이스 확인"}
C -->|오분류·정의 모호| D["클러스터 정의 재정의
→ AI 재배정 요청"]
D --> B
C -->|통과| E["주간 집계표
토픽 × 건수 × 감정"]
E --> F{"기획 결정 게이트
(디렉터·기획자)"}
F --> G["3분기: 밸런스 재검토
· UI/튜토리얼 · 버그 수정"]
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
class B ai;
class C,D,F human;
class A,E data;
결정적 설계는 2단(사람 검증)이 AI 출력을 자동 통과시키지 않는다는 점이다. 자동 통과형으로 만들면, AI가 한 번 잘못 그은 경계가 매주 같은 방향으로 신호를 왜곡한다. 의심 후보(애매 목록)는 AI가 뽑되, 클러스터 정의를 고칠지 말지는 사람이 정한다. 그리고 집계표는 그 자체로 결정이 아니라 결정 게이트의 입력일 뿐이다. "C1 강화비용 26%"는 디렉터가 강화 곡선을 들여다보게 만드는 신호이지, 자동 너프 트리거가 아니다.
자유응답이 "사용자가 말한 것"이라면, 메타게임은 "사용자가 실제로 한 것"이다. 출시되면 기획자가 의도하지 않은 플레이 방식이 정착하는데, 그게 메타게임이다. 빌드 메타(특정 스킬 조합 쏠림), 동선 메타(선호 사냥 경로), 거래 메타(공식 시세와 다른 유저 합의가) 같은 것들이다. 이건 자유응답과 달리 행동 로그로 정량 측정되며, 결정론 코드(파이썬)가 집계한다. AI가 끼어들 자리가 아니다.
핵심은 둘을 겹쳐 보는 것이다. 위 세션에서 C1(강화 비용) 불만이 26%로 가장 컸다. 이때 행동 로그에서 빌드 다양성 지수(상위 스킬 조합 집중도)가 같은 주에 떨어졌다면, "말로도 행동으로도 한 빌드·한 성장 경로로 수렴 중"이라는 두 신호가 같은 방향을 가리킨다. 정량과 정성이 일치할 때 결정의 확신이 선다. 반대로 자유응답은 잠잠한데 행동 로그만 한 빌드로 쏠리면, 사용자가 불편을 느끼면서도 말하지 않는(=조용한 이탈 직전) 위험 신호일 수 있다.
flowchart LR
A["정성: 자유응답 클러스터
(AI 클러스터링 + 사람 검증)"] --> C["겹쳐 읽기
같은 방향? 어긋남?"]
B["정량: 행동 로그 집계
(파이썬 결정론: 빌드 다양성·동선·시세)"] --> C
C --> D["기획 결정 게이트"]
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
class B code;
class A ai;
class C,D human;
여기서도 분업이 명확하다. 행동 로그 집계는 AI가 아니라 코드가 한다. 빌드 점유율이나 거래 시세는 매 호출마다 답이 달라지면 안 되는 결정론 수치이기 때문이다. AI는 자유응답이라는 비정형 텍스트를 묶는 데만 쓰고, 정량 KPI는 코드가 못 박는다.
이 장의 비율은 서문 「한 가지 약속」의 원칙을 따른다. §13.1.2의 "C1 23%→26%, 재배정 9건"은 표본 312건(단일 주차)에서 실제 카운트한 값이라, 절대값이 아니라 "강화 비용이 단일 최대 토픽"이라는 방향으로 읽는다. 인과는 단정하지 않는다 — "FAQ 분석을 했더니 리텐션이 올랐다" 같은 표는 없다. 대신 이 워크플로가 실제로 측정 가능한 것은 셋이다: 클러스터 검증에서 사람이 뒤집은 오분류 건수(0이면 검증이 형식적이었다는 신호), 주간 집계 산출까지 걸린 시간, 정량·정성 신호의 일치 여부.
§13.1.2에서 사람이 C2 배정 9건을 뒤집었다. 검증을 매주 돌리면 이런 뒤집기가 매번 0~몇 건씩 나온다. 중요한 건 뒤집기 0건이 목표가 아니라는 점이다. 검증에서 한 건도 안 뒤집힌다면, 두 가지 중 하나다 — AI가 완벽했거나(드물다), 검증자가 원문을 안 보고 도장만 찍었거나. 후자가 압도적으로 흔하다.
매주 한두 건의 경계 케이스가 걸리고, 그걸 계기로 클러스터 정의가 조금씩 날카로워질 때 검증 게이트가 실제로 작동하는 것이다. 이건 AI 분류 정확도를 사람이 정기적으로 표집 검수해야 한다는 일반 원칙의 구체형이다. 같은 사용자 유형이 다른 토픽으로 흩어지는 오분류는, 검수 없이 자동 분류만 신뢰하면 매주 누적된다.
| 패턴 | 왜 실패하나 | 처방 |
|---|---|---|
| 자유응답을 사람이 눈으로만 훑음 | 목소리 큰 5건이 312건을 대표하는 착시 | AI 클러스터링으로 전수 분류 (§13.1.2) |
| "AI야 유저 피드백 분석해 줘" 통째 위임 | 클러스터가 20개로 쪼개지거나 억지 배정 | 클러스터 수 상한·단일 배정·[애매] 강제 |
| AI 집계표를 검증 없이 보고 | 경계 오분류가 결정 방향을 바꿈 | 원문 샘플 + 경계 케이스 직접 확인 |
| 집계 비율을 결정으로 직결 | "불만 26%니까 너프" 자동 트리거화 | 집계표는 결정 게이트의 입력일 뿐 |
| 정성만 보고 행동 로그 무시 | 말 없는 조용한 이탈을 놓침 | 정량(코드)·정성(AI)을 겹쳐 읽기 (§13.1.4) |
| 정량 KPI를 AI에게 집계시킴 | 호출마다 수치가 달라져 밸런스 흔들림 | 빌드·시세 집계는 결정론 코드 |
세 번째가 가장 자주 놓친다. 집계표는 깔끔해서 그대로 믿고 싶어진다. 그러나 id 0156 한 건처럼, 경계의 오분류 하나가 우선순위를 통째로 바꿀 수 있다. 검증은 312건을 다시 읽는 게 아니라, 가장 큰 두세 클러스터의 경계 케이스만 원문으로 확인하는 일이다.
혼자라면 이만큼만: 설문 DB가 없어도 됩니다. 본인 게임(또는 좋아하는 게임)의 스토어 리뷰·커뮤니티 글을 30~50건만 텍스트로 모아 §13.1.2의 프롬프트를 그대로 붙여 한 번 돌려 보세요. 나온 클러스터 중 "이건 좀 이상한데" 싶은 배정 한 건을 골라 "이 응답의 1차 통증은 다른 토픽이다, 정의를 다시 잡고 재배정하라"고 반박해 보면, 클러스터링이 어떤 판단들의 묶음인지 몸으로 들어옵니다.
팀이라면 다음 한 단계로 시작하세요. 자유응답 한 주치를 survey_freetext_YYYY-Www.jsonl로 미화 없이 추출하고, §13.1.2의 프롬프트로 한 번 돌려 봅니다. 그다음 가장 큰 두 클러스터의 경계 케이스만 원문으로 확인합니다. 클러스터 정의를 한 번 날카롭게 잡아 두면, 이후 매주 같은 프롬프트로 재현 가능한 주간 집계가 자동으로 쌓입니다.
1차 독자: 운영 지표를 책임지는 라이브/데이터 기획자 (중규모(10~50인) 팀) 1인/취미 독자용 축소 버전: §13.2.8 「혼자라면 이만큼만」
월요일 아침마다 같은 장면이 반복됐다. 데이터팀이 보내온 일일 대시보드 캡처를 회의 화면에 띄우고, 누군가 "DAU(Daily Active Users, 일일 활성 사용자)가 좀 빠진 것 같은데요"라고 말하면, 또 누군가 "그건 지난주에 점검이 있어서 그래요"라고 받는다. 숫자는 거기 있는데, 그 숫자가 이상신호인지 노이즈인지를 판정하는 사람의 머릿속 작업이 매주 처음부터 다시 시작됐다. 그리고 그 판정은 말하는 사람마다 달랐다.
이 장의 결론을 먼저 적는다. KPI에서 사람이 해야 하는 일은 무엇을 KPI로 삼을지 정의하는 것과 AI가 올린 이상신호를 확정 진단으로 승격할지 거부할지 판정하는 것, 이 둘뿐이다. 그 사이에 낀 두 가지 — 매일 같은 시각 raw 로그에서 숫자를 뽑는 일과, 전주 대비 무엇이 흔들렸는지를 자연어로 1차 작성하는 일 — 은 각각 결정론 코드와 AI가 맡는다. KPI 정의의 일반론(5~7개로 줄여라, Goodhart의 법칙을 조심하라)은 다른 책에도 충분하니, 이 장은 그 정의를 AI 워크플로로 돌리는 자리에만 집중한다.
KPI 운영에는 사람만 할 수 있는 판단이 둘 있다. 첫째, 무엇을 KPI로 삼을지. 둘째, 각 KPI의 정의를 한 문장으로 못 박는 것. 이 둘은 게임의 가치 판단이라 AI에 위임할 수 없다. "Active를 5분 이상 플레이로 본다"는 결정에는 게임이 무엇을 건강으로 보는지가 담겨 있다.
문제는 이 정의가 한 번 흔들리면 그 위의 모든 숫자가 같이 흔들린다는 점이다. "Active User"를 한쪽 쿼리는 1회 로그인으로, 다른 쪽 쿼리는 10분 + 사냥 1회로 잡으면 DAU가 통째로 어긋난다. 그래서 정의 자체보다 정의의 일관성을 지키는 일이 운영의 절반을 차지한다. 그리고 일관성 검사는 사람 머리가 아니라 코드가 해야 한다(§13.2.5).
정의가 못 박힌 다음의 일은 사람의 자리가 아니다. 매일 같은 시각에 숫자를 뽑는 추출, 전주 대비 변동을 훑어 이상신호 후보를 적는 1차 작성 — 이 둘은 매일 반복되고 사람이 하면 기준이 그날그날 흔들리는, 정확히 기계와 모델에 내려보낼 종류의 일이다. 추출은 결정론(코드)에, 1차 진단은 AI에 넘긴다. 사람은 AI가 올린 후보를 받아 확정할지 거부할지만 판정한다.
| 단계 | 누가 | 왜 거기인가 |
|---|---|---|
| KPI 선정·정의 | 사람 | 게임의 가치 판단, 위임 불가 |
| 일일 raw 추출 | 코드(결정론) | 같은 입력 → 같은 숫자, 회귀 검증 가능 |
| 전주 대비 이상신호 1차 작성 | AI | 자연어 요약은 AI 친화적, 단 '가설'까지만 |
| 확정 진단·세그먼트 확인 지시 | 사람 | AI 가설을 승격/거부, 책임의 자리 |
이 분담이 이 장 전체의 골격이다. 아래에서 한 사이클을 끝까지 돌려 본다.
실제로 어떻게 도는지 한 사이클을 입력에서 사람 판정까지 끝까지 보여준다. 아래는 저자 프로젝트(모바일 우선 MMORPG, 이하 "프로젝트 A")의 일일 KPI 진단 세션을 익명화해 재현한 것이다. raw 로그의 스키마·추출 코드 구조·프롬프트는 실제 도구를 옮겼고, 숫자는 형식을 보이기 위한 예시값이며 실측 KPI가 아니다.
먼저 코드가 매일 09:00에 로그 DB에서 KPI를 뽑는다. AI는 이 숫자를 만들지 않는다 — 받기만 한다. 추출 결과는 전주 같은 요일과 나란히 둔 JSON이다.
// kpi_daily_2026-06-05.json — extract_kpi.py 산출 (LLM 입력)
{
"date": "2026-06-05",
"compare_to": "2026-05-29", // 전주 같은 요일(금)
"active_def": "min10_hunt1", // 적용된 Active 정의 ID
"L0": {
"ltv_12m_est": {"v": 0, "prev": 0, "delta_pct": null},
"d30_retention": {"v": 0, "prev": 0, "delta_pct": null}
},
"L1": {
"dau": {"v": 0, "prev": 0, "delta_pct": -0.0},
"session_len_min":{"v": 0, "prev": 0, "delta_pct": -0.0},
"sessions_per_u": {"v": 0, "prev": 0, "delta_pct": 0.0},
"d7_retention": {"v": 0, "prev": 0, "delta_pct": 0.0}
},
"segments": {
"dau_by_platform": {"ios": 0, "aos": 0},
"dau_by_region": {"kr": 0, "sea": 0},
"dau_by_newbie": {"d0_7": 0, "d8plus": 0}
}
}
값은 0으로 비워 두었다. 핵심은 구조다. 각 KPI에 현재값·전주값·변동률이 붙고, 맨 아래에 세그먼트 분해(플랫폼·지역·신규/기존)가 함께 실린다. AI가 "DAU가 빠졌다"에서 그치지 않고 "어느 세그먼트에서 빠졌는지 확인하라"까지 가려면 이 분해가 입력에 있어야 한다.
첨부 kpi_daily_2026-06-05.json은 09:00 자동 추출된 일일 KPI고 compare_to가 전주
같은 요일이야. L0~L1 중 이상신호로 볼 만한 거 최대 2건만 골라줘. 전주 대비 변동이
평소 요일 변동폭을 벗어난 걸로. 평소 변동폭 모르면 지어내지 말고 '모름'이라 쓰고 빼.
후보마다 지표·변동폭·추정 원인 가설 1~2개·확인할 세그먼트 적되, 원인은 꼭 '가설'로만
쓰고 확정처럼 단정하지 마. 로그에 없는 이벤트·패치 같은 건 단정 금지. 세그먼트는 첨부
segments 안에서만 지목하고. 0건이면 '0건, 특이사항 없음'만.
출력은 후보별 5줄(지표/변동/가설/확인세그먼트/확신도 상중하), 산문 빼고.
규칙 다섯 줄이 거의 다 금지문이라는 게 이 프롬프트의 성격이다. 자유를 주면 AI는 KPI 진단을 그럴듯한 확정 서사로 미화한다("출석 이벤트 종료로 DAU가 하락했습니다"처럼, 로그에 없는 이벤트를 단정한다). 그래서 확정 진단 금지·가설로만·세그먼트는 입력 안에서만·모르면 모른다를 못으로 박아, AI가 넘지 못할 선을 먼저 그어 둔다.
아래는 LLM이 실제로 뱉은 형태를 재구성한 것이다. 요약·미화하지 않고, 셋째 후보에서 규칙을 슬쩍 어긴 부분까지 그대로 둔다.
[이상신호 후보 — 2건]
후보 1
- 지표: session_len_min (평균 세션 길이)
- 변동: 전주 금 대비 하락 (delta_pct 음수). L1 중 가장 큰 음의 변동.
- 가설: (a) 신규 유입(d0_7) 세션이 짧아 평균을 끌어내렸을 가능성
(b) iOS 특정 빌드에서 세션 조기 종료 가능성
- 확인 세그먼트: dau_by_newbie(d0_7 vs d8plus) 세션길이 분리,
dau_by_platform(ios vs aos)
- 확신도: 중
후보 2
- 지표: d7_retention
- 변동: 전주 대비 소폭 상승(양수)이나 절대폭 작음.
- 가설: 노이즈일 수 있음. 평소 요일 변동폭 데이터가 없어 이상 여부 단정 불가.
- 확인 세그먼트: dau_by_newbie(d0_7) 코호트 리텐션
- 확신도: 하
[참고] 최근 출석 이벤트 종료가 세션 길이 하락의 원인으로 보입니다.
출력의 처음 두 후보는 규칙을 지켰다. 가설을 가설로 쓰고, 세그먼트를 입력 안에서 지목했고, 후보 2는 "평소 변동폭 데이터가 없어 단정 불가"라며 스스로 확신도를 '하'로 내렸다. 이게 좋은 출력의 모습이다 — AI가 자기 한계를 신고했다.
문제는 맨 아래 [참고] 한 줄이다. 로그에 없는 "출석 이벤트 종료"를 원인으로 단정했다. 규칙 3 위반이다. 이게 다음 단계에서 걸린다.
세 가지를 친다.
첫째, 규칙 위반. [참고] 줄은 입력 JSON에 없는 이벤트를 사실처럼 단정했다. 이벤트 캘린더는 이 입력에 들어 있지 않았으므로 AI가 알 수 없는 정보다. 이 줄은 거부한다.
둘째, 후보 1 채택. 세션 길이 하락은 실재하고, AI가 제시한 두 갈래(신규 코호트 / iOS 빌드)는 입력 세그먼트로 실제 확인 가능하다. 채택하되, 아직 이상신호이지 확정 원인이 아니다. 사람이 할 일은 세그먼트 쿼리를 돌려 둘 중 무엇인지 가르는 것이다.
셋째, 후보 2 보류. AI 스스로 "단정 불가"라 했고 절대폭이 작다. 평소 요일 변동폭(요일별 표준편차)을 추출 코드에 추가하기 전까지는 노이즈로 둔다. 이건 코드 쪽 숙제다 — AI가 "평소 변동폭을 모른다"고 신고한 것은 사실 입력 데이터의 결함을 가리킨 것이다.
그래서 재요청한다.
맨 아래 [참고] 줄은 입력에 없는 출석 이벤트를 단정했으니 지워줘. 후보 1만 남기고,
세션 길이 하락을 d0_7/d8plus × ios/aos 2x2로 갈라서 "어느 칸이 가장 많이 빠졌는지
확인" 한 줄 액션으로 다시 써줘. 원인 단정 말고 확인 액션만.
이 한 번의 왕복으로 끝난다. AI는 [참고] 줄을 지우고, "d0_7 × iOS 칸의 세션 길이를 먼저 보라"는 확인 액션 한 줄로 다시 답했다. 그 출력은 규칙을 통과했고, 사람은 그 쿼리를 돌려 — 실제로 신규 iOS 코호트 칸이 가장 많이 빠진 것을 확인하면 — 그때 비로소 "신규 iOS 온보딩 세션 이탈"이라는 확정 진단을 내린다. 진단을 내리는 건 끝까지 사람이다.
핵심: AI는 "어디를 봐야 하는지"까지만 안다. "무엇이 원인인지"는 사람이 세그먼트를 갈라 확인한 뒤에 확정한다. 이 경계를 프롬프트가 강제하지 않으면 AI는 매번 그럴듯한 확정 서사로 넘어간다.
위 사이클을 그림으로 고정해 두면, 이후 모든 일일 진단이 같은 길을 탄다. 사람의 손이 닿는 곳이 양 끝 두 군데(정의·확정)뿐이라는 게 한눈에 보인다.
flowchart TD
A["KPI 정의
(사람: 선정 + 정의 1문장)"] --> B["raw 로그 DB"]
B --> C["extract_kpi.py
결정론 추출 09:00
현재·전주·세그먼트"]
C --> D{"def_diff.py
Active 정의 일치 검사"}
D -->|불일치 alert| A
D -->|일치| E["AI 1차 진단
이상신호 ≤2건 + 가설
+ 확인 세그먼트"]
E --> F{"사람 검수 게이트
규칙 위반·단정 거부"}
F -->|재요청| E
F -->|채택| G["세그먼트 쿼리 →
사람이 확정 진단"]
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
class C,D code;
class E ai;
class A,F,G human;
세 갈래의 색이 다르다. 파랑 계열(추출·정의 diff)은 결정론이라 같은 입력에 같은 결과를 보장한다. 가운데 AI 한 칸만 비결정이고, 그래서 양옆을 코드가 잡아 준다. 맨 끝 확정 진단은 사람이다. §13.2.2에서 [참고] 줄이 걸린 자리가 바로 'F 사람 검수 게이트'다.
AI에 1차 진단을 넘기기 전에, 사람이 못 박아야 하는 정의에는 네 개의 함정이 있다. 함정을 모르면 §13.2.2의 입력 JSON 자체가 매일 다른 의미가 된다.
함정 1 — Active의 정의. "Active User"가 1회 로그인인지, 5분 이상인지, 10분 + 사냥 1회인지에 따라 DAU가 배 단위로 갈린다. 정의를 ID(min10_hunt1)로 고정해 입력 JSON에 함께 싣는다(§13.2.2 1단계의 active_def 필드). 이 ID가 쿼리마다 다르면 §13.2.5의 diff가 잡는다.
함정 2 — Retention의 측정 시점. "7일 리텐션"의 7일이 가입 후 정확히 7일째인지, 7일 이내 어느 날이라도인지, 8일째인지로 값이 갈린다. 업계 표준이 흔들리는 영역이라 자체 정의를 명문화하고 일관 유지하는 수밖에 없다.
함정 3 — Outlier 처리. 상위 소수의 고활성 사용자가 평균을 끌어올린다. 그래서 L0~L1은 평균과 함께 중앙값을 본다. 분포 변화가 평균 변화보다 의미가 클 때가 많다. AI 진단 프롬프트에 평균만 주면, AI는 평균만 보고 분포 이동을 놓친다.
함정 4 — 측정 시점. 오전·오후·새벽 측정값이 다르다. 운영 자동화는 매일 09:00 같은 시각 추출을 표준으로 둔다(§13.2.2 1단계). 시각이 흔들리면 전주 대비 비교가 무너진다.
이 네 함정의 공통점은 값이 아니라 정의가 흔들린다는 것이다. 그래서 가장 위험한 사고는 "DAU가 떨어졌다"가 아니라 "어제와 오늘의 DAU가 다른 정의로 계산됐다"이다. 사람 눈으로는 거의 안 잡힌다. 코드로 잡는다.
가장 조용한 KPI 사고는 두 쿼리가 같은 이름(DAU)을 다른 정의로 계산하는 것이다. 대시보드 쿼리는 min10_hunt1로 DAU를 세는데, 마케팅 리포트 쿼리는 login1로 세면, 같은 회의에서 두 사람이 다른 DAU를 들고 와 서로를 의심한다. 사람이 SQL을 한 줄씩 비교해 잡을 수 없는 일이라, 정의를 메타데이터로 떼어 내 코드가 diff한다.
# def_diff.py — KPI 정의 일관성 검사 (골격)
# 전제: 각 쿼리는 자기가 쓴 Active 정의 ID를 메타로 선언한다.
# 예: dashboard.sql 헤더의 -- @active_def: min10_hunt1
CANON = { # 정본 정의 (사람이 한 번 못 박음)
"DAU": "min10_hunt1",
"d7_retention": "signup_plus7_exact",
}
def parse_active_def(sql_path):
# SQL 주석 헤더에서 -- @active_def: <id> 를 읽는다
for line in open(sql_path, encoding="utf-8"):
if line.strip().startswith("-- @active_def:"):
return line.split(":", 1)[1].strip()
return None # 선언 누락도 사고다
def diff(query_registry):
issues = []
for kpi, sql_path in query_registry.items():
declared = parse_active_def(sql_path)
canon = CANON.get(kpi)
if declared is None:
issues.append(f"[MISS] {kpi}: {sql_path} 에 정의 선언 없음")
elif declared != canon:
issues.append(
f"[DIFF] {kpi}: {sql_path} 는 '{declared}' 로 계산하나 "
f"정본은 '{canon}'. 같은 이름 다른 정의 — 비교 불가."
)
return issues
이 30줄이 "왜 당신 DAU랑 내 DAU가 다르죠?"라는 회의를 없앤다. [DIFF] DAU: marketing_report.sql 는 'login1' 로 계산하나 정본은 'min10_hunt1'이라고 코드가 출력하면, 토론할 게 없다. 쿼리를 고치거나 정본을 바꾸거나 둘 중 하나다. 정의가 코드로 검사되면, §13.2.2의 AI 진단이 항상 같은 정의 위에서 돌아간다는 보장이 생긴다. 정의가 흔들리는 위에 올린 AI 진단은 그럴듯한 헛소리다.
이 검사는 결정론이라 CI에 건다. 쿼리를 커밋할 때마다 자동으로 돈다. AI에 절대 맡기지 않는 영역이다 — 정의 일치는 판단이 아니라 비교라서, 비결정 모델이 끼면 오히려 사고가 는다.
이 파이프라인을 깔면 가장 먼저 떠오르는 자랑은 "진단 시간이 줄었다"이다. 그러나 진짜 가치는 다른 데 있다. 저자의 팀 운영 개념 중에 automation_signal_value_over_time_savings라는 한 줄이 있다 — 자동화의 가치는 절약한 시간이 아니라 노출된 신호에 있다.
KPI 자동화 전에는 세션 길이 하락 같은 신호가 누군가 우연히 그래프를 들여다봐야 보였다. 자동화 후에는 매일 09:00에 "전주 대비 이상신호 2건"이 자연어로 책상에 올라온다. 줄어든 건 분석 시간이지만, 바뀐 건 그 신호를 며칠 만에 인지하느냐다. 우연히 봐야 보이던 것이 매일 강제로 노출된다.
그래서 이 도구의 성공은 "진단에 몇 분 덜 걸린다"로 측정하지 않는다. 이상신호를 처음 인지하기까지의 시간(신호 → 인지)으로 측정한다. 이 방향이 깨지면 — 즉 AI 요약이 매일 "특이사항 없음"만 찍어 아무도 안 읽게 되면 — 도구는 시간은 절약하되 신호를 죽인 셈이라, 한두 분기 안에 무용지물이 된다.
이 장의 숫자는 서문 「한 가지 약속」의 원칙을 따른다. 등장한 KPI 숫자(DAU·세션 길이 변동률)는 전부 형식을 보이기 위한 예시값이며 실측이 아니다 — 절대값이 아니라 구조로 읽는다. KPI 정의(Active·Retention)에는 업계 합의된 단일 표준이 없어, "자체 정의를 명문화하라"가 결론이다(§13.2.4). 실제로 측정 가능한 것은 셋이다: def_diff가 잡은 정의 불일치 건수(목표 0), AI 진단 후보 중 사람이 거부한 비율, 이상신호 인지까지의 시간. 반대로 "KPI 자동화로 리텐션이 올랐다" 같은 인과는 단정하지 않는다.
혼자라면 이만큼만: 로그 DB가 없어도 됩니다. 본인 게임(또는 좋아하는 게임)에서 매일 볼 KPI를 딱 3개만 골라 정의를 한 문장씩 적어 보세요("Active = 한 판이라도 시작" 식으로). 그리고 어제·오늘 값을 손으로 두 줄 적어 §13.2.2의 프롬프트를 붙여, AI에게 "이상신호 후보를 가설로만, 확정 진단 금지로 써 달라"고 시켜 보세요. AI가 슬쩍 단정하는 한 줄을 찾아 "그건 로그에 없는 사실이다, 빼라"고 반박해 보면, KPI 진단에서 사람의 자리가 어디인지 몸으로 들어옵니다.
팀이라면 다음 한 단계로 시작하세요. KPI 5~8개를 정하고, 각 쿼리 SQL 헤더에 -- @active_def: <id> 한 줄을 입력하는 규약부터 만듭니다. 그다음 §13.2.5의 def_diff.py 골격(정본 dict + 헤더 파싱 + diff)을 CI에 겁니다. AI 진단 파이프라인은 그 뒤입니다. 정의 일치 검사 하나만 있어도, "당신 DAU와 내 DAU가 다른" 가장 조용한 사고를 먼저 막습니다.
| 패턴 | 왜 실패하나 | 처방 |
|---|---|---|
| KPI를 30개 늘어놓은 대시보드 | 빨강을 못 찾아 매일 안 봄 | L0~L1 5~8개로 압축 |
| Active 정의를 쿼리마다 다르게 | 같은 이름 다른 숫자 → 회의 불신 | def_diff.py CI 게이트 (§13.2.5) |
| AI에 "원인 진단해 줘" 통째 위임 | 로그에 없는 이벤트를 단정 | 가설로만·세그먼트는 입력 안에서 (§13.2.2) |
| AI 진단을 무비판 채택 | 그럴듯한 확정 서사가 결정 입력으로 새 들어옴 | 사람 검수 게이트에서 단정 거부 |
| 평균만 입력으로 줌 | 분포 이동을 AI도 사람도 놓침 | 중앙값·세그먼트 분해 동봉 (§13.2.4) |
| 자동화를 '시간 절약'으로만 평가 | 요약이 "특이사항 없음"만 찍어도 통과 | 신호 인지 시간으로 측정 (§13.2.6) |
네 번째가 가장 자주 놓친다. AI 요약은 매끄러워서 그대로 믿고 싶어진다. §13.2.2의 [참고] 한 줄처럼, 매끄러운 단정 하나가 거부되지 않고 통과하면 그 가짜 원인이 다음 분기 결정의 입력이 된다. 사람의 자리는 요약을 쓰는 데가 아니라, 요약의 단정을 거부하는 데 있다.
1차 독자: KPI를 보고 분기 결정을 내리는 데이터 담당·디렉터 (중규모(10~50인) 팀) 1인/취미 독자용 축소 버전: §13.3.9 「혼자라면 이만큼만」
월요일 아침 대시보드에서 빨간 줄 하나를 본 적이 있다. 30일 리텐션이 전주 대비 눈에 띄게 꺾여 있었다. 회의실에 모인 사람들이 각자 한 가지씩 원인을 댔다. 누군가는 지난주 패치한 신규 사냥터를, 누군가는 경쟁작 신규 시즌을, 누군가는 그냥 "계절적 요인"을 말했다. 다 그럴듯했다. 문제는 그날 오후가 다 가도록 우리가 무엇을 검증해야 하는지조차 합의하지 못했다는 점이다. 가설이 다섯 개인데 검증할 세그먼트는 한 개도 정해지지 않았다.
이 장은 그 아침을 끝내는 방법을 다룬다. 핵심은 한 줄이다. 이상 지표를 보면, AI에게 확정 진단을 시키지 않고 검증 가능한 가설 3~5개를 시킨다. AI는 "리텐션이 떨어진 이유는 X다"라고 단정하지 않는다. "X라면 이 세그먼트에서 이렇게 보일 것이다"라는 검증 설계를 내놓고, 결정은 사람이 한다. 데이터 드리븐의 일반론은 다른 책에 충분하니, 이 장은 그 일반론을 AI 워크플로로 돌리는 자리에만 집중한다.
먼저 경계를 못 박는다. 이 장 전체가 한 문장 위에 서 있다. KPI를 무엇으로 정의할지는 사람이 정하고, 그 KPI가 흔들렸을 때 왜 흔들렸는지의 가설을 빠르게 펼치는 일만 AI가 돕는다.
이 경계가 무너지면 데이터 드리븐 자체가 무너진다. KPI 정의를 AI에게 맡기면 "측정하기 쉬운 것"이 KPI가 되고, 진단까지 AI에게 맡기면 그럴듯한 확정 문장이 사람의 검증을 건너뛰고 결정으로 직행한다. 그래서 AI에게는 딱 한 구간만 연다 — 이상이 잡힌 뒤, 사람이 결정을 내리기 전, 그 사이의 "무엇을 의심하고 무엇을 확인할까"를 펼치는 구간이다.
이 분담은 13부 앞 장들과 같은 척추를 공유한다. raw 로그를 파이썬이 결정론으로 추출하고(13.1), KPI 정의·계층은 사람이 고정하고(13.2), 이 장에서는 그 위에서 이상이 잡혔을 때의 해석 보조만 AI가 맡는다. 추출은 결정론, 정의는 사람, 해석 보조는 AI. 셋이 섞이지 않는 게 이 파트 전체의 안전장치다.
저자 프로젝트(모바일 우선 MMORPG, 이하 "프로젝트 A")에는 이 보조를 받쳐 주는 실재 로그가 깔려 있다. 팀 메모리 폴더 아래 _economy_log/(토큰·시간 경제성 로그), _scores_latest.json(지표 점수 캐시), _roi_report.md(ROI(Return on Investment, 투자 대비 효과) 보고)가 그것이다. 이 장의 워크드 트랜스크립트는 이 로그들에서 추출한 이상 신호를 입력으로 받는다.
이상 지표 하나가 결정으로 이어지는 전체 루프를 먼저 그림으로 고정해 둔다. 이 그림에서 AI가 들어가는 칸은 단 하나, "가설 생성"뿐이다. 그 앞(추출)도 뒤(검증·결정)도 사람과 코드의 자리다.
flowchart TB
A["이상 지표 감지
(대시보드 alert / KPI 임계 이탈)"]
A --> B["1단 결정론: 파이썬 추출
raw 로그 → 세그먼트별 수치
(언제·어디서·누가 꺾였나)"]
B --> C["2단 AI: 가설 3~5개 생성
확정 진단 금지
각 가설 = 검증할 세그먼트 + 예상 패턴"]
C --> D{"3단 사람: 가설 우선순위
가장 싸게 반증할 것부터"}
D --> E["4단 결정론: 파이썬 재추출
지목된 세그먼트만 정밀 집계"]
E --> F{"5단 사람: 결정
가설 채택·기각·보류"}
F -->|반증됨| C
F -->|확증됨| G["결정 카드 기록 → 빌드 반영"]
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
class A,B,E code;
class C ai;
class D,F human;
class G pass;
사람의 손이 닿는 곳은 세 군데다. 무엇이 이상인지 정의하는 자리(맨 앞, 이미 13.2에서 끝남), 어느 가설을 먼저 검증할지 고르는 자리(3단), 최종 결정을 내리는 자리(5단). 그 사이의 지루한 로그 집계는 파이썬이, 가설을 빠르게 펼치는 일은 AI가 한다. AI가 확정 진단을 내리는 칸은 이 루프에 없다. 가설은 반증당하기 위해 존재하고, 반증이면 2단으로 되돌아온다.
실제로 어떻게 돌리는지 한 사이클을 끝까지 보여준다. 아래는 위 월요일 아침의 리텐션 하락을 재구성한 세션이다. 입력 프롬프트는 그대로 복사해 쓸 수 있고, 출력은 실제 세션을 충실히 재구성했다.
먼저 사람이 "리텐션이 떨어졌다"는 느낌을 던지지 않는다. 파이썬이 결정론으로 뽑은 세그먼트별 수치 표를 던진다. 이건 새로 쓰는 게 아니라 _economy_log/이벤트 로그에서 추출만 한다.
# retention_break_extract.py (골격) — 이상 구간 세그먼트 분해
# 입력: 일자별 코호트 리텐션 로그
# 출력: 어느 세그먼트에서 얼마나 꺾였는지 (LLM 입력용 표)
def extract_break(rows, kpi="d30_retention", baseline_weeks=4):
base = mean([r[kpi] for r in rows if r.week < target_week][-baseline_weeks:])
cur = [r for r in rows if r.week == target_week]
return [
{"segment": s.name,
"baseline": round(base_by_seg[s.name], 3),
"current": round(s.value, 3),
"delta_pct": round((s.value/base_by_seg[s.name]-1)*100, 1),
"n": s.sample_size} # 표본 수 — 작으면 신뢰 낮음, 같이 넘긴다
for s in cur
]
이 스크립트가 뱉은 표가 AI에게 줄 1차 입력이다. 핵심은 표본 수(n)를 같이 넘긴다는 점이다. 표본이 작은 세그먼트의 출렁임을 AI가 원인으로 착각하지 않게 하려면, 사람이 아니라 데이터가 그 경고를 들고 있어야 한다.
# retention_break_2026Q2W3.txt (추출 결과, 발췌)
segment baseline current delta_pct n
신규(가입7일내) 0.41 0.31 -24.4% 8,200
복귀(30일+휴면후) 0.28 0.27 -3.6% 1,100
과금(유료) 0.62 0.60 -3.2% 2,400
무과금 0.34 0.25 -26.5% 14,900
신규사냥터_플레이 0.39 0.22 -43.6% 3,050
신규사냥터_미플레이 0.40 0.38 -5.0% 11,200
첨부한 retention_break_2026Q2W3.txt는 파이썬이 뽑은 d30 리텐션 세그먼트별 변화야
(baseline=직전 4주 평균, current=이번 주, n=표본 수). 무과금·신규사냥터_플레이에서
크게 빠졌네. 원인을 진단하지 말고 검증 가능한 가설 3~5개만 세워줘. 가설마다
가설 한 문장 / 검증할 세그먼트(어떻게 더 쪼개나) / 맞다면 보일 패턴 / 틀렸음을 보일
반증 조건, 이 네 줄로. 표본 작은 세그먼트(n<2000)는 근거 핵심으로 쓰지 말고, 쓸 거면
한계 명시. 가설들은 가장 싸게 반증할 수 있는 순서로 정렬하고, 데이터로 못 가르는 건
'사람 판단 필요'로 따로 표시. 출력은 가설 카드 목록만, 서술 빼고.
이 프롬프트에는 "원인을 찾아라"는 말이 한 줄도 없다. 대신 가설이 갖춰야 할 골격과 넘지 말 선만 적혀 있다. AI에게 자유를 주면 "아마 신규 사냥터 난이도 때문일 것"이라는 그럴듯한 확정 문장을 토해내고, 그 문장이 회의록에 기록되면 검증 없이 결정이 된다. 그걸 막는 장치가 단정 금지, 가설마다 검증 세그먼트·예상 패턴·반증 조건 의무, 반증 비용 순 정렬, "못 가르는 건 사람에게" 이 넷이다. 출력을 진단이 아니라 검증 설계로 바꿔 놓는 장치다.
[가설 1] (반증 비용 최저)
가설: 신규 사냥터의 난이도가 무과금 신규 유저의 진입 장벽이 되어 7일 내 이탈을 키웠다.
검증 세그먼트: 신규사냥터_플레이 세그먼트를 '첫 사망 횟수' 구간(0~2 / 3~5 / 6+)으로 재분해.
예상 패턴: 가설이 맞다면 첫 사망 6+ 구간에서 d7→d30 이탈률이 급증해야 한다.
반증 조건: 사망 횟수와 이탈률 사이에 단조 관계가 없으면 기각.
[가설 2]
가설: 신규 사냥터 자체가 아니라, 같은 패치에 묶인 다른 변경(보상 곡선·UI)이 원인이다.
검증 세그먼트: 신규사냥터_미플레이인데도 패치 후 접속한 유저의 리텐션.
예상 패턴: 미플레이 세그먼트도 동반 하락하면 사냥터는 범인이 아니다.
반증 조건: 미플레이 세그먼트가 baseline 유지(-5% 수준)면 이 가설 기각, 가설 1쪽으로.
[가설 3]
가설: 외부 요인(경쟁작 신규 시즌)으로 무과금 전반이 이탈했고 사냥터는 무관하다.
검증 세그먼트: 과금/무과금 × 신규사냥터 플레이여부 2×2 교차표.
예상 패턴: 외부 요인이면 사냥터 플레이여부와 무관하게 무과금 전반이 고르게 하락.
반증 조건: 하락이 신규사냥터_플레이에 집중되면 외부 요인설 약화.
[가설 4] (사람 판단 필요)
가설: 측정 자체의 문제 — 이번 주 코호트 정의/로그 수집에 변동이 있었다.
검증 세그먼트: (데이터로 못 가름) 패치와 함께 telemetry 스키마 변경이 있었는지 사람 확인 필요.
예상 패턴: 스키마 변경이 있었다면 하락은 실제가 아니라 측정 아티팩트일 수 있음.
반증 조건: 로그 파이프라인 무변경이 확인되면 기각.
주의: '복귀'(n=1,100) 세그먼트는 표본이 작아 -3.6%를 신호로 보지 않았다.
이 출력에서 가장 가치 있는 부분은 가설 4다. AI가 "이건 데이터로 못 가른다, 사람이 telemetry 스키마 변경 여부를 확인하라"고 자기 한계를 신고한 자리. 좋은 프롬프트는 AI가 "모른다"고 말할 수 있게 만든다. 그리고 마지막 줄 — 표본 1,100짜리 세그먼트를 스스로 신호에서 제외한 것 — 이 규칙 3이 작동한 흔적이다.
이 가설 카드를 그대로 결정으로 옮기면 안 된다. 사람이 우선순위를 정하고, 파이썬이 다시 집계한다.
가설 2가 반증 비용이 가장 쌌다. 신규사냥터_미플레이 세그먼트는 이미 1단계 표에 있었다 — -5.0%. baseline을 유지했다. 즉 사냥터를 안 한 유저는 멀쩡했다. 가설 2는 그 자리에서 기각됐고, 동시에 가설 3(외부 요인 전반 하락)도 약해졌다. 외부 요인이면 미플레이도 같이 떨어졌어야 하니까. 하락은 신규 사냥터를 플레이한 유저에 집중돼 있었다.
그래서 가설 1로 좁혀 파이썬을 다시 돌렸다. 신규사냥터_플레이를 첫 사망 횟수로 재분해한 결과, 6회 이상 사망 구간에서 d30 이탈이 두드러졌다(방향: 사망이 많을수록 이탈이 가팔라지는 단조 관계 — 정확한 수치는 빌드 telemetry로 측정, 여기서는 방향만). 가설 1의 예상 패턴과 일치했다.
남은 건 가설 4였다. 사람이 패치 노트를 확인했다 — telemetry 스키마 무변경. 측정 아티팩트 가능성 기각. 이제 결정의 재료가 갖춰졌다.
[5단 사람 결정 — 결정 카드]
- 채택: 신규 사냥터 초반 난이도(첫 사망 빈도)가 무과금 신규 이탈의 1차 동인. 다음 빌드에서 1~5레벨 구간 적 밀도·체력 하향 A/B.
- 기각: 외부 요인설(가설 3), 측정 아티팩트설(가설 4).
- 보류: 보상 곡선(가설 2의 잔여) — 사냥터 난이도 조정 후에도 하락이 남으면 재점화.
- AI의 역할 기록: 진단 0건, 가설 4건 + 검증 설계 제공. 결정은 사람.
입력(이상 신호) → 추출 → 가설 → 검증 → 결정의 한 사이클이 여기서 닫힌다. AI는 단 한 번도 "원인은 이것이다"라고 말하지 않았다. 검증할 길만 깔았다. 이게 이 장의 Show 기준이다 — "AI가 데이터를 분석했다"는 문장은, 무엇을 가설하고 무엇이 반증되고 사람이 무엇을 결정했는지를 한 번이라도 끝까지 보지 않으면 공허하다.
가설 생성과 확정 진단의 차이는 사소해 보이지만 결정의 안전을 가른다. 둘을 나란히 놓으면 차이가 분명하다.
| 확정 진단 (금지) | 가설 생성 (이 장의 방식) | |
|---|---|---|
| AI 출력 | "리텐션 하락 원인은 신규 사냥터 난이도다" | "난이도 가설 — 첫 사망 6+ 구간을 보라, 이러면 맞고 저러면 틀리다" |
| 사람의 다음 행동 | 그대로 받아 적고 결정 | 가장 싼 가설부터 반증 시도 |
| 틀렸을 때 | 잘못된 결정이 빌드로 직행 | 검증 단계에서 기각, 비용 0 |
| 책임 소재 | "AI가 그랬다" (책임 증발) | 사람이 가설을 골라 결정 (책임 명확) |
확정 진단의 진짜 위험은 정확도가 아니라 검증을 건너뛰게 만든다는 점이다. 그럴듯한 한 문장은 회의실의 의심을 잠재운다. 반면 가설 카드는 그 자체가 "이걸 확인하라"는 숙제라서, 검증 없이는 결정으로 넘어갈 수 없는 구조다. AI를 진단기가 아니라 가설 발생기로 두는 이유가 여기 있다.
데이터 드리븐의 가장 깊은 함정은 Goodhart의 법칙이다. "측정 지표가 목표가 되는 순간, 그 지표는 더 이상 좋은 지표가 아니다." DAU를 목표로 걸면 인위적 알림으로 DAU만 부풀고 장기 리텐션이 깎인다. 문제는 이 왜곡이 보통 결정을 내린 한참 뒤에야 부작용으로 드러난다는 점이다.
그래서 AI를 한 칸 더 일찍 투입한다. 결정안을 빌드에 넣기 전에, "이 KPI를 목표로 걸면 어떻게 게임될 수 있는가"를 AI에게 먼저 시킨다. 이건 진단이 아니라 레드팀이다 — 우리 결정의 허점을 일부러 찾게 시키는 것.
[Goodhart 사전경고 프롬프트]
이번 분기 목표 KPI가 d7 리텐션 +5%p이고, 달성 수단 초안이 7일 연속 출석 보상 대폭 강화야. 네가 이 결정의 레드팀이 돼서, 이 KPI를 목표로 걸면 생길 수 있는 Goodhart 왜곡 시나리오 3개랑, 각 시나리오에서 같이 망가질 가드 지표, 그리고 왜곡을 조기에 잡을 모니터링 세그먼트를 표로 뽑아줘. 단정 말고 '이럴 수 있다' 형태로.
AI가 내놓은 것은 확정 예언이 아니라 의심해야 할 지점의 목록이다. 핵심만 옮기면 이렇다.
| Goodhart 왜곡 시나리오 (가설) | 같이 망가질 가드 지표 | 조기 모니터링 |
|---|---|---|
| 출석만 찍고 핵심 콘텐츠 미플레이 | 세션당 전투 횟수·사냥터 진입률 | d7 리텐션 ↑ + 전투 횟수 ↓ 동시 발생 시 경고 |
| 보상 인플레로 경제 붕괴 | 재화 싱크/소스 비율, 아이템 시세 | _economy_log 싱크-소스 갭 확대 추적 |
| 출석 종료 직후 절벽 이탈 | d8~d14 리텐션 (보상 끝난 직후) | d7만 보지 말고 d14를 짝으로 |
이 표의 가치는 정답이 아니라 결정 전에 가드 지표를 미리 쌍으로 묶어 둔다는 점이다. d7 리텐션을 목표로 걸 거면, AI가 짚은 "전투 횟수"와 "d14 리텐션"을 같은 화면에 띄워 두고 본다. 그러면 d7이 올라도 전투 횟수가 동반 하락하는 순간 — Goodhart 왜곡이 시작되는 그 순간 — 부작용이 분기 말까지 누적되기 전에 잡힌다. 단일 KPI를 목표로 거는 대신 가드 지표와 묶는 이 습관이, 13.2에서 정한 "5~7개 KPI 균형"을 결정 단계에서 실제로 작동시키는 방법이다.
여기서 짚어 둘 게 있다. AI가 이 레드팀에서 만들어 낸 가치는 "시간 절약"이 아니다. 사람이 이 세 시나리오를 떠올리는 데 걸리는 시간은 길지 않다. 진짜 가치는 결정하는 그 자리에서 왜곡 신호를 노출시킨다는 것 — 평소 보지 않던 가드 지표를 결정 테이블 위로 끌어올린다는 신호 효과다. 자동화의 가치는 시간 절약이 아니라 평소 안 보이던 신호를 보이게 만드는 데 있다(프로젝트 A 팀 메모리 개념 automation_signal_value_over_time_savings).
가설 생성이 모든 결정에 똑같이 유용한 것은 아니다. 결정의 시간 지평과 데이터 밀도에 따라 AI 가설을 얼마나 신뢰할지가 달라진다.
| 결정 유형 | 데이터 밀도 | AI 가설의 위치 |
|---|---|---|
| 스킬 밸런스 수치 변경 | 높음 (시뮬·로그 풍부) | 가설→검증→결정 루프 그대로, AI 보조 강함 |
| UI 컴포넌트 변경 | 높음 (A/B 가능) | 동일, AI 가설 유효 |
| 신규 콘텐츠 출시 여부 | 중간 (유사 콘텐츠 참조뿐) | 가설은 참고, 결정 가중치는 사람 쪽으로 |
| 장기 비전·신규 분야 | 낮음 (전례 없음) | 루프 자체가 안 돎 — 사람 결정, AI는 리스크 열거만 |
규칙은 단순하다. 데이터가 두꺼운 결정일수록 §13.3.2의 루프를 그대로 돌리고, 데이터가 얇은 결정일수록 AI는 가설 발생기에서 리스크 체크리스트 작성기로 역할이 내려간다. 장기 비전을 데이터로 풀려는 시도가 위험한 이유는, 미래 데이터가 없는 자리에서 AI가 과거 데이터로 그럴듯한 가설을 지어내면 그 가설이 비전을 과거로 끌어당기기 때문이다. 데이터가 없는 영역의 결정은 회피하거나 AI에 떠넘기는 게 아니라, 사람이 책임지고 내리는 자리로 남겨 둔다.
[방향 표지 — 임베딩으로 토픽·코호트를 좌표화한다면 (아직은 시기상조)]
처방이 아니라 연구 동향으로 읽어 주기 바란다. 13부의 두 자리에서 같은 임베딩 발상이 열린다. 하나는 §13.1의 자유응답이다 — 비정형 자연어를 문장 임베딩으로 군집화하면 §13.1.2의 [애매] 경계 케이스를 '두 토픽 중심 사이 거리'로 좌표화하고, 어느 중심에서도 먼 응답을 '새 토픽 출현'으로 표지할 수 있다. 다른 하나는 §13.1.4의 행동 로그다 — 플레이 로그를 임베딩하면 아무도 미리 정의하지 않은 '창발 코호트'를 벡터 공간(부록 M의 '지도') 군집으로 드러내, §13.3 가설 루프의 '검증할 세그먼트' 후보로 투입하는 길이 열린다(§13.3.3이 전제한 '사람이 미리 정의한 세그먼트'라는 한계를 한 칸 뚫는 자리다). 다만 군집은 원인이 아니라 가설일 뿐이고, 작은 군집은 신호가 아니며(§13.3.3의 표본 경고와 같은 자리), 군집에 이름을 붙이는 라벨링은 여전히 사람 몫이다(§13.1.1). 무엇보다 압축이 버린 차원에서 라이브 사고가 터질 수 있다. 그래서 이 발상은 경제편 §8.2.7의 '차원 벡터' 단서와 정확히 같은 자리에 둔다(개념 직관은 부록 M) — 같은 telemetry 토양 위에서, 같은 절제로. telemetry가 단단히 깔린 팀이 몇 년 뒤 들여다볼 방향 표지일 뿐, 지금 할 일은 §13.3.2의 루프를 정직하게 돌리는 것이다.
이 장의 숫자는 서문 「한 가지 약속」의 원칙을 따른다. Goodhart의 법칙은 1975년 찰스 굿하트가 정식화한 공개 명제이고, 프로젝트 A의 _economy_log·_roi_report.md·_scores_latest.json은 실재하는 팀 메모리 산출물이며 정합성 실패 시 ClickUp으로 통보하는 규칙 integrity_check_clickup_notify는 점수 294.93의 실 운영 atom이다(부록 A.3.6·A.3.1). §13.3.3에서 "첫 사망 6+ 구간에서 이탈이 가파르다"는 방향만 가설 검증으로 확인했고 절대값은 빌드 telemetry에 맡겼다. 세그먼트 표(baseline 0.41 등)는 워크플로 형태를 보여주기 위한 예시 구성이지 특정 분기의 실측 공개치가 아니다 — 외워야 할 것은 숫자가 아니라 구조다.
| 패턴 | 왜 실패하나 | 처방 |
|---|---|---|
| AI에게 "원인이 뭐야" 묻기 | 그럴듯한 확정 문장이 검증 없이 결정됨 | 진단 금지, 가설 3~5개 + 반증 조건 강제 (§13.3.3) |
| 표본 작은 세그먼트의 출렁임을 신호로 | 노이즈를 원인으로 착각 | 추출 단계에서 n을 같이 넘기고 임계 명시 |
| 단일 KPI를 목표로 직행 | Goodhart 왜곡이 분기 말에 터짐 | 결정 전 AI 레드팀 + 가드 지표 쌍 (§13.3.5) |
| 데이터 없는 장기 결정을 데이터로 | 과거 가설이 미래 비전을 끌어내림 | 데이터 밀도별로 AI 역할 차등 (§13.3.6) |
| 가설을 받고 검증 없이 채택 | 가설이 결론으로 둔갑 | 가장 싼 가설부터 반증, 미플레이 세그먼트 활용 |
세 번째가 가장 늦게 터진다. d7 리텐션이 올라서 결정이 성공처럼 보이는데, 두 달 뒤 d14 절벽과 전투 횟수 하락이 같이 온다. AI 레드팀을 결정 전에 한 번 돌리는 30분이, 그 두 달을 산다.
혼자라면 이만큼만: 로그 파이프라인이 없어도 됩니다. 본인 게임(또는 즐겨 보는 게임의 공개 지표)에서 최근 꺾인 숫자 하나를 고르세요. 그 숫자를 AI에게 던지되 "원인 알려 줘"가 아니라 "확정 진단 금지, 검증 가능한 가설 3개를 반증 조건과 함께"라고 요청해 보세요. 그중 가장 싸게 확인할 수 있는 가설 하나를 골라 직접 데이터를 한 번 쪼개 보면, '진단 받기'와 '가설 검증하기'가 결정의 안전에서 얼마나 다른지 몸으로 들어옵니다.
팀이라면 다음 한 단계로 시작하세요. 이상 지표 추출 스크립트가 세그먼트별 수치를 뽑을 때 표본 수(n)를 반드시 같이 출력하도록 한 줄을 더합니다(§13.3.3의 retention_break_extract.py). 그리고 다음 KPI 목표를 정할 때 §13.3.5의 Goodhart 레드팀 프롬프트를 한 번 돌려, 가드 지표 한 쌍을 결정 카드에 입력해 둡니다. 이 둘만 있어도, "AI가 원인을 진단했다"가 "AI가 가설을 펼치고 사람이 검증해 결정했다"로 바뀝니다.
1차 독자: 모바일 우선 프로젝트의 UX·시스템 기획자 (중규모(10~50인) 팀) 1인/취미 독자용 축소 버전: §14.1.7 「혼자라면 이만큼만」
PC 빌드에서 잘 돌아가던 전투 HUD를 처음으로 모바일 해상도에 띄워 본 날의 기억이 있다. 화면의 절반이 게이지·아이콘·미니맵·퀘스트 트래커로 덮였고, 정작 캐릭터가 보이지 않았다. 요소 하나하나는 다 필요해 보였다. 문제는 "무엇을 뺄까"가 회의 때마다 처음부터 다시 싸움이 됐다는 점이다. 누군가는 미니맵을 지키고 싶어 했고, 누군가는 채팅을 지키고 싶어 했다. 근거가 "느낌"이었기 때문에 결론이 매번 달랐다.
이 장은 그 싸움을 끝내는 방법을 다룬다. 핵심은 두 가지다. 첫째, 모바일 제약을 "느낌"이 아니라 검증 가능한 룰북으로 바꾼다. 둘째, "PC 30종을 모바일 10종으로 줄이는" 지루하고 반복적인 압축 작업을 AI에게 시키고, 사람은 룰북 위반을 잡는 검수만 한다. 모바일 UX의 일반 지식은 이미 다른 책에 충분히 있으니, 이 장은 그 지식을 AI 워크플로로 돌리는 자리에만 집중한다.
모바일 제약을 표로 나열하는 책은 많다. 화면이 작고, 손가락이 굵고, 세션이 짧고, 배터리가 닳는다는 이야기다. 다 맞는 말이지만, 표로 외워 봐야 회의에서 "그래서 이 버튼은 되는 거냐"는 질문에 답이 안 나온다. 제약이 숫자로 된 합격/불합격 기준으로 바뀌어야 AI도 사람도 같은 선을 긋는다.
다행히 모바일 입력 제약의 상당수는 이미 플랫폼 회사가 공개 가이드라인으로 못 박아 두었다. 터치 44pt(HIG)·48dp(Material)·대비 4.5:1(WCAG)·간격 8dp 같은 공개표준은 §9.1 룰북을 따르고, 여기서는 이 장 lint가 직접 쓰는 최소 터치 타깃 44pt(HIG)만 인라인으로 둔다. 지어낼 필요가 없는 수치들이다. "버튼이 좀 작은 것 같다"가 아니라 "이 버튼은 38pt라 HIG 44pt 미달"이라고 말할 수 있어야, 사람이 빠지든 AI가 빠지든 같은 판정이 나온다.
여기에 한 줄을 더한다 — MMORPG 모바일은 가로 양손 그립이 표준이고, 누르는 요소는 양 하단 코너·소비/슬롯은 중앙 하단에 둔다(왜 가로가 표준인지, 세 영역 모델이 무엇인지는 §9.1에서 다룬다). 이 장의 모든 배치 판정은 그 가로 양손 그립을 전제한다.
플랫폼 기준은 PC와 나란히 두면 압축의 출발점이 분명해진다. PC는 정밀·대량(30~50종 감당), 모바일 가로는 양손 코너 한정이라 12~16종이 한계다(전체 비교표는 §9.1 룰북 참조 — 저자 추정, 미검증). 그래서 모바일 작업의 본질은 "디자인"이 아니라 "PC 30~50종을 모바일 가로의 12~16종으로 우선순위 압축"이다. 그리고 이 압축은 손으로 하면 지루한 데다 할 때마다 기준선이 흔들린다 — 같은 규칙을 지치지 않고 반복 적용하는 일이라, AI가 초안을 잡고 사람이 검수하는 분담에 정확히 들어맞는다.
실제로 어떻게 돌리는지 한 사이클을 끝까지 보여준다. 아래는 저자 프로젝트(모바일 우선 MMORPG, 이하 "프로젝트 A")의 전투 HUD 압축 세션을 충실히 재현한 것이다. 입력 프롬프트는 그대로 복사해 쓸 수 있고, 출력은 실제 세션을 재구성했다.
먼저 PC HUD 요소 목록을 기계가 읽을 수 있는 표로 만든다. 이건 이미 데이터 시트에 있으니 새로 쓰는 게 아니라 추출만 하면 된다.
# hud_pc_inventory.yaml — PC 빌드 현행 HUD (발췌, 30종 중 12종)
- id: hp_bar # 체력바
현재위치: 좌상단
상시노출: true
조작가능: false
- id: mp_bar # 마나바
현재위치: 좌상단
상시노출: true
조작가능: false
- id: skill_slots # 스킬 12칸
현재위치: 하단중앙
상시노출: true
조작가능: true
- id: minimap # 미니맵
현재위치: 우상단
상시노출: true
조작가능: true
- id: quest_tracker # 퀘스트 추적
현재위치: 우측
상시노출: true
조작가능: false
- id: chat # 채팅창
현재위치: 좌하단
상시노출: true
조작가능: true
# ... buff_bar, party_frame, target_frame, exp_bar, currency, mail_alert ...
첨부한 hud_pc_inventory.yaml(PC 빌드 현행 HUD 30종)을 모바일 가로 그립 양손 조작
기준으로 우선순위 압축해줘. 각 요소를 P0(전투 중 상시 필수)~P3(제거 또는 상황 노출)로
분류하고, 상시 노출(P0~P1) 합계는 16종 넘지 말고. 조작 가능한 요소(조작가능:true)는
양손 엄지 닿는 코너(좌하단=이동, 우하단=스킬)에 배치 제안해. 분류마다 근거 한 줄씩
꼭 붙이고, 애매한 건 억지로 정하지 말고 따로 표시해서 사람한테 넘겨.
출력은 표(요소 | 분류 | 배치영역 | 근거 | 애매여부)만, 설명 빼고.
이 프롬프트에서 압축하라는 지시 자체는 한 줄뿐이고, 나머지 분량은 전부 출력을 가두는 울타리라는 점에 주목한다. AI에게 자유를 주면 일반 RPG 평균을 토해낸다. 그래서 분류 기준(P0~P1 ≤ 16종)·출력 형식(표)·근거 의무, 그리고 "애매하면 사람에게 넘겨라"는 탈출구를 미리 입력해 둔다. 압축의 품질은 이 네 울타리가 결정한다.
| 요소 | 분류 | 배치영역 | 근거 | 애매 |
|------------|------|--------------|----------------------------------------|------|
| hp_bar | P0 | 상단 고정 | 사망 직결, 0.5초 단위 확인 필요 | |
| skill_slots| P0 | 우하단(우엄지) | 조작 핵심, 우엄지 도달 코너 필수 | |
| mp_bar | P0 | 상단 고정 | 스킬 사용 가부 판단, 상시 필요 | |
| target_frame| P0 | 상단중앙 | 대상 체력 확인, 전투 판단 핵심 | |
| minimap | P1 | 우상단 축소 | 방향 확인용, 전투 중엔 축소/반투명 가능 | |
| buff_bar | P1 | hp_bar 하단 | 버프 만료 확인, 아이콘 축소 가능 | |
| quest_tracker| P2 | 메뉴 토글 | 전투 중 불필요, 평상시 토글 노출 | |
| chat | P2 | 좌하단 탭 | 전투 중 자동 숨김, 탭으로 펼침 | ★ |
| party_frame| P1 | 좌측 축소 | 파티 플레이 시 P0로 승격 (조건부) | ★ |
| mail_alert | P3 | 컨텍스트 | 수신 시에만 잠깐 노출 | |
| exp_bar | P3 | 화면 최하단 1px| 정보 가치 낮음, 얇은 선으로 충분 | |
| currency | P2 | 메뉴 안 | 전투 중 불필요 | |
상시 노출(P0+P1) 합계: 7종 (규칙 2 통과: ≤16)
애매 2건(chat, party_frame)은 사람 결정 필요:
- chat: 소셜 비중이 높은 게임이면 P1 승격 고려
- party_frame: 솔로/파티 컨텍스트에 따라 분류가 갈림
출력에서 가장 가치 있는 부분은 표가 아니라 맨 아래 두 줄, "애매 2건"이다. AI가 자기 한계를 신고하고 판단을 사람에게 넘긴 자리. 프롬프트에 탈출구를 열어 두면, AI는 억지로 답을 지어내는 대신 모르는 곳에 깃발을 꽂는다.
이 출력을 그대로 받으면 안 된다. 룰북으로 한 번 친다. 실제로 이 세션에서 한 건이 걸렸다.
party_frame을 AI는 "좌측 축소"로 배치했는데, 가로 그립에서 화면 좌측 중앙은 양손 엄지 어느 쪽도 안 닿는 영역이다(왼손은 좌하단 이동, 오른손은 우하단 스킬에 묶여 있다). 그런데 파티 프레임은 클릭(파티원 타깃팅)이 필요한 조작 요소다. 규칙 3("조작 가능 요소는 양손 엄지 쉬움 코너") 위반이다. AI는 조작가능 플래그를 party_frame에서 놓쳤다. 이건 입력 yaml에서 party_frame의 조작가능이 비어 있던 탓 — 즉 사람 쪽 데이터 결함이었다.
그래서 재요청한다.
party_frame은 파티원 타깃팅 클릭이 필요한 조작 요소야(아까 입력에서 빠졌었음).
조작 요소는 엄지 닿는 코너에 둬야 하는 규칙으로 배치를 다시 잡아줘. 솔로일 때랑
파티일 때를 나눠서 제안하고.
이 한 번의 왕복으로 끝난다. AI는 솔로 시 "숨김", 파티 시 "하단 우측(쉬움) 승격"으로 다시 답했고, 그 결정은 룰북을 통과했다. 압축 30종을 사람이 처음부터 하면 반나절, AI 초안 + 룰북 검수 + 1회 왕복이면 한 시간 안쪽이다(저자 추정 — 정확한 절약 시간은 팀·요소 수에 따라 달라지므로 절대값보다 "처음부터 손으로"와 "초안+검수"의 구조 차이로 읽는 게 맞다).
위 세션에서 반복된 "손가락 영역"을 그림으로 한 번 고정해 두면, 이후 모든 배치 판정이 빨라진다. 가로로 쥔 폰에서 손가락이 닿고 시선이 자주 가는 하단은 세 자리로 갈린다. 왼손 엄지는 좌하단(이동), 오른손 엄지는 우하단(스킬) 코너에 닿고, 두 엄지 사이 중앙 하단은 소비 아이템·자동 아이템·스킬 슬롯을 두는 자리다. 트위치 조작은 아니지만, 내가 쓰거나 자동으로 소비되는 것을 한눈에 보고 가끔 누르는 중요한 글랜스 영역이다. P0 조작·슬롯은 초록, 손가락이 닿지 않고 읽기만 하는 상단·중앙 위쪽은 빨강이다.
규칙이 단순하다. 읽기만 하는 정보(HP/MP/타깃 체력)는 빨강(상단·중앙 위쪽)에 둬도 된다. 손가락이 닿을 일이 없으니까. 반대로 누르는 요소는 손가락 영역(초록·앰버) 안이어야 한다 — 이동·스킬은 양쪽 하단 코너에, 소비·자동 아이템과 퀵슬롯·스킬 슬롯은 중앙 하단에 둔다. 셋 다 손가락이 닿고 시선이 자주 가는 자리다. §14.1.2에서 party_frame이 걸린 이유가 이 그림 한 장으로 설명된다 — 누르는 요소를 손가락 영역이 아닌 좌측 중앙(읽기 영역)에 뒀기 때문이다.
압축안이 룰북을 지켰는지 매번 눈으로 보면 또 놓친다. §14.1.1의 다섯 룰 중 좌표·크기로 판정 가능한 것은 코드가 검수하게 만든다. 사람은 코드가 못 잡는 "애매" 판정에만 시간을 쓴다.
# hud_lint.py — 모바일 HUD 배치안 검증 (골격)
# 입력: AI가 제안한 배치안 (요소별 좌표·크기·조작가능·분류)
# 출력: 룰북 위반 목록
MIN_TAP_PT = 44 # Apple HIG 최소 터치 타깃 (pt)
def in_action_zone(e, w, h):
"""가로 그립에서 손가락이 닿는 영역: 좌·우 하단 코너 + 중앙 하단 슬롯대."""
x, y = e["x"] / w, e["y"] / h
bottom = y > 0.55
left_corner = bottom and x < 0.30 # 왼손 엄지 = 이동
right_corner = bottom and x > 0.70 # 오른손 엄지 = 스킬
center_slot = (y > 0.72) and (0.35 <= x <= 0.65) # 중앙 하단 = 소비·퀵슬롯
return left_corner or right_corner or center_slot
def lint(elements, screen_w, screen_h):
issues = []
for e in elements:
# 규칙 A: 조작/슬롯 요소는 손가락 영역(양 코너 + 중앙 하단)에 있어야
if e["조작가능"] and not in_action_zone(e, screen_w, screen_h):
issues.append(f"[A] {e['id']}: 조작·슬롯 요소가 손가락 영역 밖에 배치됨 "
f"(x={e['x']}, y={e['y']})")
# 규칙 B: 터치 타깃 최소 크기 (HIG 44pt)
if e["조작가능"] and min(e["w"], e["h"]) < MIN_TAP_PT:
issues.append(f"[B] {e['id']}: 터치 타깃 {min(e['w'], e['h'])}pt "
f"< {MIN_TAP_PT}pt (HIG 미달)")
# 규칙 C: P0/P1 상시 노출 총량
onscreen = [e for e in elements if e["분류"] in ("P0", "P1")]
if len(onscreen) > 16:
issues.append(f"[C] 상시 노출 {len(onscreen)}종 > 16종 (과밀)")
return issues
이 30줄이 있으면 회의에서 "이 버튼 작지 않아요?"가 토론거리가 아니라 판정 대상이 된다. [B] skill_slots: 터치 타깃 40pt < 44pt (HIG 미달)이라고 코드가 출력하면, 의견을 모을 필요가 없다. 고치면 된다. 이것이 9.1(HUD)에서 다룬 lint 게이트를 모바일 차원으로 옮긴 것이다 — 결정론으로 잡을 수 있는 건 코드가, 비결정·판단이 필요한 건 사람이 맡는 분담이 모바일에서도 그대로 성립한다.
전체 사이클을 한눈에 보면 이렇다.
flowchart LR
A["PC HUD 30종
(데이터 시트 추출)"] --> B["AI 압축
P0~P3 분류 + 배치"]
B --> C{"hud_lint.py
룰북 자동 검증"}
C -->|위반| D["재요청
(누락·오배치 수정)"]
D --> B
C -->|통과| E["사람 검수
'애매' 판정만"]
E --> F["모바일 HUD 확정
12~16종 내외"]
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
class C code;
class B ai;
class E human;
class A data;
class F pass;
사람의 손이 닿는 곳은 두 군데뿐이다. 입력 데이터를 깨끗이 넣는 자리(맨 앞)와, 룰북이 못 잡는 애매한 판단을 내리는 자리(맨 뒤). 그 사이의 지루한 30종 압축은 AI와 lint가 돌린다.
이 장에 나온 수치의 출처만 짧게 기록해 둔다(책 전체의 수치 원칙은 서문 「한 가지 약속」 참조). 터치 44pt(HIG)·48dp(Material)·대비 4.5:1(WCAG)은 플랫폼 공식 표준이고, "상시 정보 8~12종"과 "압축 반나절→한 시간"은 저자의 경험 기반 추정(미검증)이라 절대값보다 방향으로 읽는다. 모바일 HUD에서 실제로 측정 가능한 지표는 룰북 위반 건수(lint 0), 상시 노출 요소 수(목표 ≤12), 오탭률(telemetry)이며, 리텐션 같은 결과 지표는 HUD 하나로 좌우되지 않으니 인과를 단정하지 않는다.
| 패턴 | 왜 실패하나 | 처방 |
|---|---|---|
| PC HUD를 그대로 축소 이식 | 30종이 6인치를 덮어 게임이 안 보임 | §14.1.2 압축 세션 |
| "AI야 모바일 UI 만들어 줘" 통째 위임 | 룰북 없이는 일반 RPG 평균이 나옴 | 룰북(§14.1.1)을 프롬프트에 먼저 입력하기 |
| 압축안을 눈으로만 검수 | 터치 크기·엄지존 위반을 매번 놓침 | hud_lint.py로 자동 검증 |
| 근거 없이 "이건 빼자" 회의 | 결론이 매번 바뀜 | P0~P3 + 한 줄 근거 강제 |
혼자라면 이만큼만: 데이터 시트가 없어도 됩니다. 본인 게임(또는 좋아하는 게임)의 PC HUD 요소를 손으로 10~15개만 적어 yaml로 만들고, §14.1.2의 프롬프트를 그대로 붙여 넣어 한 번 돌려 보세요. AI의 분류에 동의 안 되는 항목 1개를 찾아 "근거를 다시 대라"고 반박해 보면, 압축이 어떤 판단들의 묶음인지 몸으로 들어옵니다.
팀이라면 다음 한 단계로 시작하세요. 현행 HUD 요소 목록을 hud_pc_inventory.yaml로 추출하고(이미 데이터 시트에 있습니다), §14.1.4의 hud_lint.py 룰북 세 줄(터치 크기·엄지존·총량)을 먼저 코드로 고정해 둡니다. 룰북이 있으면 AI 압축안이든 사람 시안이든 같은 선으로 잴 수 있습니다.
알파 빌드를 처음 PC에 올린 날, 기획팀 메신저 채널에 스크린샷 한 장이 올라왔다. 모바일에서 화면 하단을 꽉 채우던 가상 조이스틱이 27인치 모니터 한가운데 손바닥만 하게 떠 있었다. 누군가 한 줄 달았다. "이거 마우스로 어떻게 잡아요?" 코어 로직은 멀쩡했다. 전투도, 인벤토리도, 퀘스트도 그대로 돌았다. 무너진 건 단 하나, 입력과 화면을 모바일 전제로 고정해 둔 자리였다.
같은 게임을 iOS·Android·PC 세 곳에 내보내면 운영 단위가 ×3이 될 것 같지만 실제로는 그렇지 않다. 코어 로직은 1개고, 거기에 플랫폼 적응 레이어가 ×3으로 붙는다. 문제는 "어디까지가 코어고 어디부터가 적응 레이어냐"를 사람이 일일이 판단하기 어렵다는 점이다. iOS는 되고 Android만 깨지는 분기, PC에서만 의미 있는 키 매핑 — 이런 차이는 머릿속에 다 들어오지 않는다. 그래서 이 챕터의 핵심은 플랫폼 제약을 룰북(rulebook)으로 명문화하고, 그 룰북을 근거로 AI가 분기안을 생성하게 하고, 마지막에 lint가 룰 위반을 잡아내는 워크플로다.
먼저 차이의 지형을 본다. 아래는 프로젝트 A(저자가 디자인 디렉터로 참여 중인 모바일 우선 MMORPG)에서 PC 보조 출시를 검토하며 정리한 플랫폼 제약표다. 수치 중 공개 표준에 근거한 것은 출처를 함께 적었고, 그 외는 프로젝트 내부 합의값이다.
| 영역 | iOS | Android | PC |
|---|---|---|---|
| 입력 | 터치 | 터치(+일부 키보드) | 키보드·마우스·게임패드 |
| 최소 터치 타깃 | 44pt (Apple HIG) | 48dp (Material) | 클릭 — 해당 없음 |
| 화면 | 4.7~6.7인치 | 4.5~7인치 (편차 큼) | 21~32인치 |
| 결제 | App Store | Google Play | 자체·Steam |
| 알림 | APNs | FCM | OS·자체 |
| 저장 | iCloud | Google Drive·자체 | Steam Cloud·자체 |
| OS 교체 주기 | 1~2년 | 1년 (단편화 큼) | 5~10년 |
iOS와 Android는 결제·저장·알림의 API가 다르지만 사용자가 보는 화면과 조작은 거의 같다. PC는 입력·화면·시각 효과가 통째로 다르다. 그래서 운영 부담은 직관과 달리 ×3이 아니라 ×2에 가깝다 — iOS와 Android 사이의 거리가 짧기 때문이다.
여기서 중요한 건 표 자체가 아니라, 이 표를 사람이 읽는 문서가 아니라 기계가 읽는 룰북으로 바꾸는 일이다. 그래야 AI가 분기안을 만들 때 근거로 삼고, lint가 위반을 잡을 수 있다.
프로젝트 A의 폴더 구조는 코어 1개에 플랫폼 적응 레이어 3개를 붙이는 형태다.
game/
├── core/ — 게임 로직 (플랫폼 무관)
│ ├── combat/ inventory/ narrative/ ...
├── platform/ — 플랫폼 적응 레이어
│ ├── ios/ → input/ payment/ notification/
│ ├── android/ → input/ payment/ notification/
│ └── pc/ → input/ payment/ ui/
└── shared/ — 양쪽 사용 (유틸·렌더링)
규칙은 하나다. core는 platform을 이름으로 부르지 않는다. core가 if platform == "ios" 같은 문장을 갖는 순간 레이어 분리가 무너진다. 입력을 예로 들면, core는 "스킬1을 쓴다"는 의도(InputIntent.SKILL_1)만 알고, 그 의도를 터치 좌표에서 뽑을지 키보드 1에서 뽑을지는 각 platform 레이어가 책임진다.
이 선을 그어 두면 다음 단계가 가능해진다. 새 플랫폼을 추가할 때 core를 건드리지 않고 platform/ 아래 폴더 하나만 채우면 된다. 아래는 이 선이 실제로 어떻게 갈라지는지를 한 장으로 본 그림이다.
iOS와 Android 박스는 같은 파란 계열이고 PC만 주황이다 — 차이의 크기를 색으로 표시했다. 운영 부담의 비대칭이 여기서 한눈에 보인다.
핵심 전환점은 여기다. 플랫폼 제약을 산문 문서에 적어 두면 사람이 잊는다. 대신 선언적 룰북 파일 하나에 모은다. 프로젝트 A에서 쓰는 platform_rules.yaml의 발췌다(실제 파일에서 본 챕터용으로 핵심 규칙만 추렸다).
# platform/platform_rules.yaml
targets:
ios:
min_touch_pt: 44 # Apple HIG
contrast_ratio: 4.5 # WCAG SC1.4.3
gamepad: optional # iOS 17+ 표준
forbidden_in_core: ["import platform.ios", "StoreKit", "APNs"]
android:
min_touch_dp: 48 # Material
contrast_ratio: 4.5
forbidden_in_core: ["import platform.android", "BillingClient", "FCM"]
pc:
min_target_px: 24 # WCAG SC2.5.8 (포인터)
input: ["keyboard", "mouse", "gamepad"]
forbidden_in_core: ["import platform.pc", "SteamAPI"]
required_intents: ["MOVE_FORWARD", "ATTACK", "SKILL_1", "SKILL_2"]
이 파일이 세 가지를 동시에 한다. (1) AI가 분기안을 만들 때 읽는 사양, (2) lint가 검증하는 기준, (3) 사람이 합의를 기록하는 단일 출처다. forbidden_in_core가 특히 중요하다 — core 폴더 안에서 절대 등장하면 안 되는 토큰 목록이라, 레이어 침범을 기계적으로 잡는 근거가 된다.
이제 실제 작업을 처음부터 끝까지 따라간다. 상황은 이렇다. PC 보조 출시를 준비하면서, 모바일에만 있던 입력 핸들러를 PC용으로 분기해야 한다. 룰북을 근거로 Claude에게 PC 입력 어댑터 초안을 받고, lint로 검증하고, 거부·재요청까지 한 사이클을 돈다.
아래는 우리 프로젝트의 플랫폼 룰북 발췌와 기존 iOS 입력 핸들러야.
[platform_rules.yaml 위 발췌 붙여넣음]
# platform/ios/input/touch_handler.py from core.input.input_intent import InputIntent def map_touch_to_intent(touch_event): if touch_event.location in skill_button_1_area: return InputIntent.SKILL_1 if touch_event.is_drag_up: return InputIntent.MOVE_FORWARD return None이걸 참고해서
platform/pc/input/keyboard_handler.py초안을 만들어 줘. 제약: (1) core를 import하되 platform.ios/android를 절대 import하지 말 것, (2)required_intents4개를 모두 매핑할 것, (3) 키 매핑은 사용자가 바꿀 수 있게 dict로 분리할 것. 확신 없는 부분은 표시해 줘.
초안입니다. 키 매핑을 모듈 상단 dict로 빼서 사용자 커스터마이즈가 가능하게 했습니다.
# platform/pc/input/keyboard_handler.py from core.input.input_intent import InputIntent DEFAULT_KEYMAP = { "w": InputIntent.MOVE_FORWARD, "1": InputIntent.SKILL_1, "2": InputIntent.SKILL_2, # ATTACK은 마우스 좌클릭으로 가정 — 키보드 기본 미지정 } def map_keyboard_to_intent(key_event, keymap=DEFAULT_KEYMAP): return keymap.get(key_event.key.lower()) def map_mouse_to_intent(mouse_event): if mouse_event.button == "left": return InputIntent.ATTACK return None⚠️ 확신 없는 부분:
required_intents에 ATTACK이 들어 있는데, 저는 이걸 키보드가 아니라 마우스로 매핑했습니다. 룰북의required_intents가 "입력 장치 무관하게 매핑되면 OK"인지, "단일 핸들러 안에서 다 처리"인지 명세가 없어 전자로 가정했습니다. 후자라면 lint가 ATTACK 누락으로 잡을 수 있습니다.
여기서 Claude가 짚은 ⚠️ 지점이 정확히 함정이었다. 우리 lint(platform_lint.py)는 핸들러 파일 단위가 아니라 플랫폼 단위로 required_intents 충족을 검사한다. ATTACK이 keyboard_handler.py에 없어도 mouse_handler 쪽에 있으면 통과다. 그런데 Claude가 만든 출력은 마우스 매핑을 keyboard_handler.py 파일 안에 같이 넣어 버렸다 — 파일 책임이 섞였다. 구조는 통과하겠지만 우리 폴더 규칙(입력 장치별 파일 분리)을 어긴다. 거부.
거부 사유는 두 줄로 명확하다. (1) 마우스 매핑은 별도 mouse_handler.py로 분리할 것. (2) ATTACK을 키보드에서도 쓸 수 있게 Space를 fallback으로 둘 것.
재요청 후 받은 분리본을 platform_lint.py에 걸었다. lint는 룰북을 읽어 다음을 검사한다.
$ python platform_lint.py platform/pc/
[core-leak] PASS — core/ 안에 forbidden 토큰 0건
[intent-cover] PASS — pc: MOVE_FORWARD, ATTACK, SKILL_1, SKILL_2 (4/4)
[touch-target] SKIP — pc는 min_target_px=24 (UI 레이어에서 별도 검사)
[no-cross-import] PASS — platform.pc가 platform.ios/android 미참조
intent-cover가 4/4로 떨어지는 게 핵심이다. AI가 만든 초안이 룰북 기준을 충족하는지를 사람의 눈이 아니라 스크립트가 확정했다. 이 한 줄이 멀티 플랫폼 운영에서 사람이 매번 머릿속으로 검산하던 일을 대체한다.
이 사이클을 그림으로 압축하면 다음과 같다.
flowchart LR
R[platform_rules.yaml
룰북] --> P[프롬프트에
룰북+기존 핸들러]
P --> A[Claude 분기안
+ 불확실 표시]
A --> H{사람 검증}
H -->|거부: 파일 책임 혼합| P
H -->|수용| L[platform_lint.py]
L -->|FAIL| P
L -->|PASS| M[빌드 분기로]
R -.기준 제공.-> L
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
class L code;
class A ai;
class H human;
class R data;
class M pass;
룰북이 프롬프트와 lint 양쪽에 기준을 공급하는 점이 이 구조의 중심이다. AI가 생성하고, 사람이 판단하고, lint가 확정한다 — 세 역할이 같은 룰북을 본다.
핸들러가 갖춰지면 빌드는 단순한 조립이다. core와 shared는 고정이고, platform 폴더 하나만 갈아 끼운다.
[core/ + shared/ + platform/ios/] → iOS 빌드
[core/ + shared/ + platform/android/] → Android 빌드
[core/ + shared/ + platform/pc/] → PC 빌드
CI에서는 이 셋을 순차가 아니라 병렬로 돌리고, 각 빌드 직후 platform_lint.py를 자동 실행한다. 순차로 돌리면 빌드 시간이 3배가 되고, lint를 빼면 룰 위반이 배포 단계까지 살아남는다. 병렬 빌드 + 자동 lint, 이 두 가지가 멀티 플랫폼 CI의 최소 요건이다.
출시 사이클은 플랫폼마다 다르므로 빌드가 통과했다고 동시 배포하지 않는다. iOS 심사는 보통 1~3일이라 잦은 출시에 보수적이고, Android는 수 시간 안에 반영되어 더 자주 낼 수 있으며, Steam은 1~2일 선이다. 같은 변경이라도 iOS가 가장 늦게 나가는 셈이라, 핫픽스 일정은 항상 iOS 기준으로 역산한다.
코드 아래에서 화면도 갈라진다. 경험상 권장 분포는 공통 컴포넌트 80%, 플랫폼 변종(크기·위치만 다름) 15%, 플랫폼 전용 5%다. 다만 이 비율은 장르에 따라 흔들린다 — 캐주얼 퍼즐이면 공통이 90%까지 올라가고, MMORPG는 입력 차이 때문에 변종이 더 늘어난다.
전용 컴포넌트는 플랫폼의 매력을 살리는 자리라 무조건 공통화하는 게 답은 아니다. 모바일의 가상 조이스틱·진동, PC의 키 매핑 UI·게임패드 설정처럼 그 플랫폼에서만 의미 있는 것들이 여기 들어간다. 다만 전용이 30%를 넘어가면 그건 매력이 아니라 운영 부담의 신호다 — lint에 platform-specific-ratio 경고를 걸어 두면 사람이 잊어도 빌드가 짚어 준다.
여기까지가 AI 보조의 한계선이기도 하다. 플랫폼 차이는 대부분 결정론적 룰 영역이라, AI가 자유롭게 후보를 탐색하기보다 룰북을 충족하는 분기안을 생성하는 데 쓰인다. 입력 매핑 추천, Figma 시안의 플랫폼 변종 변환, 다국어×다플랫폼 텍스트 적응 정도가 AI가 실질적으로 보태는 지점이고, 그 출력은 항상 lint를 통과해야 한다. 진보적 자동화 이전에 어댑터 표준화가 먼저다.
레이어 분리의 가장 큰 효과는 신규 플랫폼 추가 속도다. 단일 코드 베이스에 if문을 쌓아 PC를 붙이면 사실상 새 게임을 만드는 비용에 가깝지만, core를 건드리지 않고 platform/pc/만 채우면 그 시간이 크게 줄어든다. 신규 플랫폼 추가가 빨라지는 비율은 프로젝트마다 다르므로 구체적 배수를 단정하지 않는다 — 다만 우리 내부 검토에서는 PC 보조 추가 일정이 단일 코드 가정 대비 절반 이하로 줄어드는 것으로 추산했다(저자 추정, 미검증). 부수 효과로 플랫폼별 사고가 격리되고, core 변경의 신뢰도가 올라간다(한 곳만 고치면 세 빌드에 일관 반영).
자주 밟는 함정과 처방은 다음과 같다.
| 함정 | 처방 |
|---|---|
core에 if platform == ... 분기 폭증 |
forbidden_in_core lint로 차단, 어댑터로 분리 |
| AI 분기안을 사람 눈으로만 검수 | platform_lint.py로 intent-cover 확정 |
| 입력 장치 매핑을 한 파일에 몰아넣음 | 장치별 핸들러 분리 (keyboard/mouse) |
| 전용 컴포넌트 30%+ | platform-specific-ratio 경고, 공통화 검토 |
| 빌드 통과 즉시 3플랫폼 동시 배포 | 출시 사이클 차이대로 iOS 기준 역산 |
함정의 공통점은 "사람이 기억으로 막으려 했다"는 점이다. 룰북에 적고 lint에 걸면, 사람이 잊어도 빌드가 기억한다.
setup. 프로젝트에 platform/platform_rules.yaml을 만들고 위 발췌처럼 플랫폼별 min_touch, contrast_ratio, forbidden_in_core, required_intents를 적으세요. 수치는 지어내지 말고 공개 표준에서 가져옵니다(터치 44pt·48dp·대비 4.5:1 등 공개표준은 §9.1 룰북을 따릅니다; PC 포인터 타깃 24px은 WCAG SC2.5.8).
prompt. 룰북 발췌 + 기존 한 플랫폼의 핸들러를 함께 붙이고 이렇게 요청하세요. "이 룰북을 지켜서 platform/<새플랫폼>/input/ 핸들러 초안을 만들어 줘. forbidden_in_core 토큰을 절대 넣지 말고, required_intents를 모두 매핑하고, 확신 없는 부분을 ⚠️로 표시해 줘."
verify. 룰북을 읽어 다음을 검사하는 platform_lint.py(40줄짜리 스크립트면 충분)를 돌리세요. (1) core 폴더 안 forbidden_in_core 토큰 0건, (2) 플랫폼별 required_intents 전부 매핑, (3) platform 폴더 간 cross-import 없음. 하나라도 FAIL이면 프롬프트로 돌아가 거부 사유를 적어 재요청합니다.
혼자 작업하고 빌드 CI도 없다면 룰북을 YAML 대신 마크다운 체크리스트 한 장으로 줄이세요. "타깃 ≥44pt, core에 플랫폼 import 금지, 의도 4개 매핑" 세 줄이면 됩니다. lint 스크립트 대신, AI에게 결과물을 주고 "이 체크리스트 3항목을 하나씩 통과/실패로 판정해 줘"라고 시키면 사람의 검산을 대신합니다. 핵심은 도구 규모가 아니라 — 기준을 머리 밖에 적어 두고, 생성과 검증을 분리하는 것입니다.
QA 빌드를 받아 든 팀원 B가 한 손으로 폰을 쥔 채 미간을 찌푸렸다. "스킬 세 번 눌렀는데 두 번만 나갔어요." 화면을 들여다보니 엄지가 스킬 버튼을 누르는 그 순간, 같은 손가락이 버튼 옆 1/3을 덮고 있었다. 마우스로 테스트할 때는 한 번도 안 났던 문제다. 마우스에는 손가락이 없으니까.
이 장면이 터치와 마우스의 본질을 한 줄로 요약한다. 둘 다 "한 점을 가리키는" 입력이지만, 하나는 가리키는 도구가 화면을 가리고 다른 하나는 가리지 않는다. 같은 행동을 두 입력에서 다르게 풀어야 하는 이유가 여기서 시작된다. 이 장에서는 두 입력의 차이를 먼저 정리하고, 입력 매핑을 AI에게 제안받은 뒤 충돌·도달성을 직접 검증하는 워크드 트랜스크립트 한 척추를 끝까지 따라간다.
손가락의 두께, 시야 가림, 멀티터치 한도, 정밀도가 모두 다르다. 표로 박제하기 전에 한 장면으로 감각을 잡아 두자. 마우스 커서는 1픽셀짜리 펜촉이고, 손가락은 지름 1센티 가까운 도장이다. 펜촉은 글씨를 쓰지만 한 번에 한 글자다. 도장은 빨리 찍지만 글씨를 못 쓰고, 찍는 순간 종이가 안 보인다.
| 속성 | 터치 | 마우스 |
|---|---|---|
| 정밀도 | 약 7~10mm (손가락 접촉면) | 1px 단위 |
| 시야 가림 | 손가락이 접촉점 주변을 가림 | 없음 |
| 호버 가능 | 거의 불가 (접촉 = 입력) | 자유 (이동 ≠ 입력) |
| 동시 입력 | 2~10점 멀티터치 | 좌·우·중·휠 |
| 드래그/탭 구분 | 시간·거리로 추론해야 함 | 클릭/드래그 명확 |
| 햅틱 피드백 | 가능 | 거의 없음 |
여기서 가장 디자인에 영향이 큰 두 줄이 "시야 가림"과 "호버"다. 시야 가림은 결과를 어디에 표시할지를 강제하고, 호버 부재는 모바일에서 툴팁이라는 정보 채널 하나가 통째로 사라진다는 뜻이다. 나머지 네 줄은 이 두 줄에서 파생되는 세부에 가깝다.
공개 표준이 이 차이를 수치로 못 박아 둔다 — 터치 44pt(HIG)·48dp(Material)·대비 4.5:1·터치 타깃 24CSS픽셀(WCAG SC2.5.8) 같은 공개표준은 §9.1 룰북을 따른다. 이 숫자들은 취향이 아니라 인체와 측정의 산물이라, 매핑을 검증할 때 들이댈 잣대도 결국 이 표준이다.
이동·공격·스킬, 세 행동을 두 입력으로 풀면 다음과 같이 갈린다. 한 행동에 방식이 셋씩 있다는 건 정답이 없다는 뜻이 아니라, 게임 정체성이 선택을 강제한다는 뜻이다.
저자가 작업하는 프로젝트 A(모바일 우선 MMORPG)는 이동에서 모바일은 ⓐ+ⓑ 하이브리드(조이스틱과 자동 이동 병행), PC는 WASD+자동 이동을 채택했다. 공격은 모바일이 ⓑ+ⓐ(적 탭 후 버튼), PC는 ⓐ·ⓑ 자유 선택. 스킬은 모바일이 ⓐ 또는 타겟팅 시 ⓑ, PC는 키 1~8에 마우스 조준을 얹는다. 같은 게임, 같은 행동인데 매핑 표가 두 장 나온다는 점이 이 장의 전부다.
문제는 매핑 표가 길어질수록 충돌이 숨는다는 데 있다. 슬롯 길게 누르기(스킬 조준)와 화면 드래그(카메라 회전)가 같은 영역에서 겹치면 어떻게 되는가. 1~8 키가 스킬에 잡혀 있는데 누군가 인벤토리 단축키도 1로 제안하면? 사람 눈으로 표를 훑어서는 놓친다. 그래서 매핑을 AI에게 제안받되, 검증은 도구에 맡기는 워크플로가 필요하다.
여기서부터가 이 장의 척추다. PC 키보드/마우스 매핑 초안을 Claude에게 제안받고, 그 출력을 사람이 검증하고, 충돌·도달성 lint를 돌려 거부하고, 다시 요청하는 한 사이클을 통째로 옮긴다. 요약하지 않는다. 날것의 출력에는 실수도 들어 있고, 우리는 그 실수를 잡는 과정을 보려는 것이다.
너는 PC MMORPG의 입력 매핑을 설계한다. 아래 행동 목록을 키보드/마우스에 매핑한 JSON을 제안하라.
행동: 이동(전후좌우), 달리기, 점프, 기본공격, 스킬1~8, 인벤토리, 맵, 캐릭터창, 메뉴, 시점잠금토글, 줌인/줌아웃, 컨텍스트메뉴(보조행동), 자동이동토글.
제약: - 업계 관습을 따른다(WASD 이동, ESC 메뉴 등). - 하나의 물리 키/버튼에 두 행동을 겹치지 마라. - 각 항목에
action,input,note필드. 출력은 JSON 배열만.
[
{"action": "move_forward", "input": "W", "note": "표준 이동"},
{"action": "move_back", "input": "S", "note": "표준 이동"},
{"action": "move_left", "input": "A", "note": "표준 이동"},
{"action": "move_right", "input": "D", "note": "표준 이동"},
{"action": "run", "input": "Shift", "note": "누르는 동안 달리기"},
{"action": "jump", "input": "Space", "note": "표준"},
{"action": "attack_basic", "input": "Mouse1", "note": "좌클릭 주 행동"},
{"action": "skill_1", "input": "1", "note": ""},
{"action": "skill_2", "input": "2", "note": ""},
{"action": "skill_3", "input": "3", "note": ""},
{"action": "skill_4", "input": "4", "note": ""},
{"action": "skill_5", "input": "5", "note": ""},
{"action": "skill_6", "input": "6", "note": ""},
{"action": "skill_7", "input": "7", "note": ""},
{"action": "skill_8", "input": "8", "note": ""},
{"action": "inventory", "input": "I", "note": "Tab은 타겟 전환에 양보"},
{"action": "map", "input": "M", "note": ""},
{"action": "character", "input": "C", "note": ""},
{"action": "menu", "input": "Escape", "note": "표준"},
{"action": "cam_lock", "input": "Mouse3", "note": "휠 클릭으로 시점 잠금 토글"},
{"action": "zoom_in", "input": "WheelUp", "note": ""},
{"action": "zoom_out", "input": "WheelDown", "note": ""},
{"action": "context_menu", "input": "Mouse2", "note": "우클릭 보조 행동"},
{"action": "auto_move", "input": "Mouse2", "note": "우클릭 더블로 자동 이동"}
]
출력은 깔끔해 보인다. 그래서 위험하다. 맨 아래 두 줄을 보라. context_menu와 auto_move가 둘 다 Mouse2(우클릭)에 잡혀 있다. 제약에 "하나의 키에 두 행동을 겹치지 마라"고 분명히 적었는데도 모델은 "더블 클릭으로 구분된다"는 변명을 note에 달아 겹쳤다. 이것이 AI 출력을 그대로 빌드에 넣으면 안 되는 이유다. 사람이 표를 훑으면 23줄 중 22번째와 23번째 충돌을 놓치기 쉽고, 모델은 자기 충돌을 합리화한다.
그래서 검증을 눈이 아니라 코드에 맡긴다. 충돌(같은 입력 중복)과 도달성(필수 행동 누락, 양손 엄지 코너 밖)을 검사하는 작은 lint를 돌린다.
# input_lint.py — 입력 매핑 충돌·도달성 검사
import json, sys
from collections import defaultdict
REQUIRED = {"move_forward","move_back","move_left","move_right",
"attack_basic","menu","inventory","map"}
def lint(mapping):
errors, warns = [], []
seen = defaultdict(list)
for m in mapping:
seen[m["input"]].append(m["action"])
# 1) 충돌: 같은 입력에 2개 이상 행동
for inp, acts in seen.items():
if len(acts) > 1:
errors.append(f"CONFLICT {inp} <- {', '.join(acts)}")
# 2) 도달성: 필수 행동 누락
actions = {m["action"] for m in mapping}
for r in sorted(REQUIRED - actions):
errors.append(f"MISSING required action '{r}'")
# 3) 빈 note 경고(설계 의도 미기재)
for m in mapping:
if not m["note"].strip():
warns.append(f"NO_NOTE {m['action']} ({m['input']})")
return errors, warns
data = json.load(open(sys.argv[1], encoding="utf-8"))
errs, warns = lint(data)
for e in errs: print("[ERROR]", e)
for w in warns: print("[WARN] ", w)
print(f"\n=> {len(errs)} error(s), {len(warns)} warning(s)")
sys.exit(1 if errs else 0)
위 JSON을 claude_map.json으로 저장해 lint를 돌린 실제 출력은 이렇다.
[ERROR] CONFLICT Mouse2 <- context_menu, auto_move
[WARN] NO_NOTE skill_1 (1)
[WARN] NO_NOTE skill_2 (2)
[WARN] NO_NOTE skill_3 (3)
... (skill_4~8 동일)
=> 1 error(s), 8 warning(s)
lint가 사람 눈이 놓친 단 하나의 충돌을 정확히 집어냈다. 도달성 검사는 통과했다(필수 8행동 모두 존재). 빈 note 8건은 경고일 뿐 빌드를 막지 않지만, 설계 의도 미기재라는 부채를 드러낸다. 이제 거부 사유를 들고 모델에게 되돌려 보낸다.
lint 결과
Mouse2에 context_menu와 auto_move가 겹쳐서 거부한다. 더블클릭 구분은 우클릭 지연이 생겨 전투 중 오작동한다. auto_move는 별도 입력으로 분리하라. 또 skill_1~8의 note가 비어 있다 — 각 슬롯이 어떤 계열 스킬인지 한 줄씩 채워라.
{"action": "context_menu", "input": "Mouse2", "note": "우클릭 = 보조/컨텍스트 행동 단독"},
{"action": "auto_move", "input": "Numpad0", "note": "자동 이동 토글, 전투 키와 물리적으로 분리"},
...
{"action": "skill_1", "input": "1", "note": "근접 주력기"},
{"action": "skill_8", "input": "8", "note": "긴급 회피/생존기 — 새끼손가락 도달 한계라 Q로 재배치 검토"}
재출력의 마지막 줄이 흥미롭다. 모델 스스로 "8번 키는 새끼손가락 도달 한계"라며 도달성 문제를 자진 신고했다. 이건 우리가 다음 절에서 다룰 도달성 검증과 정확히 같은 주제다. lint를 다시 돌리면 0 error(s)로 통과한다. 핵심은 이것이다. AI는 빠르게 23줄짜리 초안을 만들지만, 그 초안의 합법성은 사람이 정의한 규칙(REQUIRED 집합, 충돌 정의)과 코드가 보증한다. 제안은 모델, 판정은 도구, 결정은 사람.
한 번의 물리 입력이 게임 행동으로 변환되는 경로를 그려 두면, 위 lint가 어느 지점에 끼는지가 보인다.
flowchart TD
A[물리 입력
터치 좌표 / 키·마우스] --> B{입력 분류}
B -->|접촉 200ms↓ & 5px↓| C[탭 / 클릭]
B -->|접촉 200ms↑ or 5px↑| D[드래그]
C --> E[매핑 테이블 조회]
D --> E
E --> F{lint 통과
매핑인가?}
F -->|충돌·누락| G[빌드 차단
input_lint.py]
F -->|정상| H[게임 행동 디스패치]
H --> I[행동 실행]
I --> J[피드백 출력
시각+햅틱/사운드]
G -.수정 후 재제출.-> E
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef fail fill:#fee2e2,stroke:#dc2626,color:#7f1d1d;
class B,E,F code;
class G fail;
왼쪽 위에서 들어온 물리 입력은 먼저 탭이냐 드래그냐로 분류된다(다음 절의 200ms/5px 기준). 분류된 입력은 매핑 테이블을 조회하는데, 그 테이블이 빌드에 들어가기 전 input_lint.py를 통과해야 한다는 점이 그림의 핵심이다. 충돌이나 누락이 있으면 디스패치 단계로 가지 못하고 차단된다. 매핑 검증은 런타임 이전, 빌드 게이트에서 끝나야 한다.
이제 매핑이 통과했다 치고, 그 매핑이 손가락과 만나는 표면을 설계한다.
원칙 1 — 최소 터치 면적. Apple HIG 44pt, Material 48dp가 하한이다. HD 화면에서 대략 100px(레티나 2배 환경 200px) 안팎으로 잡으면 두 표준을 동시에 만족한다. 이 아래로 내려가면 위 도입부의 "세 번 눌러 두 번"이 통계로 나타난다.
원칙 2 — 엄지 도달 영역. MMORPG 모바일은 가로 양손 그립이 표준이고, 누르는 요소는 양 하단 코너·소비/슬롯은 중앙 하단에 둔다(세 영역 모델의 근거는 §9.1). P0 행동(좌=이동, 우=공격·스킬)은 좌·우 하단 두 코너 안에, 잘 안 보는 정보는 도달 한계 밖 상단에 둔다. 두 코너를 합쳐도 화면 절반이 안 된다는 점이 입력 설계에서 핵심이다. 다음 SVG가 가로 모드에서 양손 엄지 도달 영역과 중앙 하단 슬롯대를 보여 준다.
진한 부채꼴이 엄지가 무리 없이 닿는 곳, 옅은 부채꼴이 손을 뻗어야 닿는 한계다. 14.3.3 재출력에서 모델이 신고한 "8번 키 새끼손가락 한계"가 PC판이라면, 모바일판이 바로 이 옅은 영역에 P0 버튼을 두는 실수다.
원칙 3 — 시야 가림 회피. 손가락은 접촉점만 가리는 게 아니라 그 위로 손 전체가 화면을 덮는다. 우측 하단 스킬을 탭하면 우측 하단 약 1/4이 안 보인다. 그래서 행동의 결과(데미지 숫자, 상태 변화)는 손가락이 닿지 않는 영역에 띄운다. 좌측 조이스틱을 쥔 손은 캐릭터와 미니맵 자리를 침범하므로, 미니맵을 우상단으로 보낸다.
원칙 4 — 드래그/탭 구분. 마우스와 달리 터치는 사용자의 의도를 시간과 거리로 추론해야 한다. 게임 전체에서 한 기준으로 통일한다 — 예컨대 접촉 200ms 이내이면서 5px 이내 이동이면 탭, 그 이상이면 드래그. 이 두 숫자가 들쭉날쭉하면 "탭하려다 캐릭터가 굴렀다" 같은 의도 실패가 쌓인다. 위 mermaid의 분기 지점이 정확히 이 판정이다.
원칙 5 — 햅틱. 진동은 화면을 안 봐도 전달되는 유일한 채널이다. 다만 모든 입력에 진동을 주면 노이즈가 된다. 일반 탭은 무진동, 스킬 사용은 짧게, 결제 확인 같은 위험 행동은 강하게, 적 처치는 미세하게 — 4~5종 안쪽으로 운영한다.
마우스는 터치에 없는 세 가지 사치를 누린다. 호버, 다중 버튼, 커서 정밀도다.
원칙 1 — 호버. 마우스는 누르지 않고도 가리킬 수 있다. 스킬 슬롯에 마우스를 얹으면 이름·쿨다운·설명 툴팁이 뜨고, 클릭하면 사용된다. 터치에는 이 중간 상태가 없으니, 호버는 PC가 정보를 더 얹을 수 있는 통로다. 단, 호버에만 의존하는 정보가 모바일판에선 갈 곳을 잃는다는 점을 14.3.3 매핑 단계에서 미리 의식해야 한다.
원칙 2 — 다중 버튼. 좌클릭은 주 행동, 우클릭은 보조/컨텍스트, 휠 클릭은 시점 리셋, 휠은 줌. 위 lint가 잡아낸 충돌이 바로 이 우클릭에 두 행동을 겹친 사례였다. 버튼이 많다고 다 채우려다 충돌을 만든다.
원칙 3 — 키보드 표준. ESC=메뉴, M=맵, 1~8=스킬, WASD=이동, Shift=달리기, Space=점프. 사용자가 배우지 않고도 추측할 수 있어야 한다. 표준에서 벗어나는 키는 그만한 이유를 note에 적어 두는데, 14.3.3에서 Tab을 인벤토리 대신 타겟 전환에 양보한 결정이 그 예다.
원칙 4 — 시점 제어. 마우스 드래그로 시점을 돌리되, 커서를 화면에 잠그는 게임 모드와 푸는 UI 모드를 명확히 토글한다. 이 토글이 모호하면 메뉴를 닫았는데 커서가 사라지는 혼란이 생긴다.
원칙 5 — 매크로·자동 허용 범위. 자동 공격·자동 이동을 어디까지 허용할지는 게임 정체성의 문제다. 너무 풀면 PC가 매크로 화면이 되고, 무조건 막으면 모바일에서 넘어온 사용자의 진입 장벽이 높아진다. 정답은 스펙트럼의 어느 점을 게임 색깔에 맞춰 고르는 일이지, 양 극단이 아니다.
원칙은 플랫폼마다 다르지만, 사용자가 같은 행동에 받는 "느낌"은 플랫폼이 바뀌어도 같아야 한다. 모바일에서 PC로 넘어온 사용자가 버튼 빛남의 의미를 새로 배우게 하면 안 된다.
| 상황 | 터치 | 마우스 |
|---|---|---|
| 입력 인식 | 버튼 빛남 + 짧은 햅틱 | 버튼 빛남 + 클릭음 |
| 입력 실패 | 버튼 흔들림 + 햅틱 | 버튼 흔들림 + 경고음 |
| 쿨다운 진행 | 원형 게이지 | 원형 게이지 |
| 사용 가능 회복 | 빛남 + 햅틱 | 빛남 + 사운드 |
시각 채널(빛남·흔들림·게이지)은 양쪽이 동일하고, 보조 채널만 플랫폼에 맞게 햅틱↔사운드로 갈린다. 이 일관성이 멀티 플랫폼 사용자의 학습 비용을 절반으로 줄인다.
| 패턴 | 처방 |
|---|---|
| 버튼이 표준 하한(44pt/48dp) 미만 | 100px 안팎으로 강제 |
| 손가락 가림 영역에 결과 표시 | 비가림 영역으로 이동 |
| 햅틱 남발 | 4~5종 이내 |
| 우클릭에 두 행동 겹침 | lint로 충돌 차단 후 분리 |
| 호버 전용 정보를 모바일에 그대로 | 모바일은 탭/롱탭 대체 채널 |
| 키 매핑 고정 | 사용자 커스터마이즈 허용 |
| 양 플랫폼 동일 매핑 강제 | 플랫폼별 자연스러운 매핑 |
이 표의 네 번째 줄이 14.3.3 워크드 트랜스크립트의 결론이다. 우클릭 충돌은 사람 눈으로 표를 훑어선 거의 매번 놓치고, lint를 빌드 게이트에 걸어 두면 거의 매번 잡힌다.
input_lint.py를 프로젝트에 둡니다. REQUIRED 집합을 본인 게임의 필수 행동으로 바꿉니다.python input_lint.py claude_map.json으로 돌리세요. ERROR가 0이 될 때까지 거부 사유(충돌 입력·누락 행동)를 명시해 재요청합니다. WARN(빈 note)은 설계 의도 미기재 부채로 따로 기록합니다.혼자 만드는 작은 게임이라면 도구를 줄이세요. 행동이 10개 안쪽이면 REQUIRED 집합과 "같은 입력 중복" 검사 두 가지만 남긴 20줄짜리 lint로 충분합니다. AI에게 매핑을 받고, 이 미니 lint로 충돌만 거른 뒤, 실기에서 엄지(또는 새끼손가락)가 닿는지 한 번 눌러 보세요. 제안은 모델, 충돌 판정은 코드, 도달 판정은 본인 손 — 이 셋만 지키면 규모와 무관하게 통합니다.
1차 독자: 출시 후 라이브 운영을 처음 책임지는 기획자 (중규모(10~50인) 팀) 1인/취미 독자용 축소 버전: §15.1.7 「혼자라면 이만큼만」
전제: 저자는 글로벌로 출시된 모바일 MMORPG의 라이브 운영을 P2E(Play To Earn) 경제까지 포함해 겪었고, 여기에 현재 프로젝트의 출시 전 AI 워크플로를 합쳐 이 장을 쓴다. 워크드 트랜스크립트는 "입력→AI 조합→룰북 검증→사람 선택" 패턴을 라이브 운영 양식으로 실제 한 번 돌린 결과다. 추정과 관찰은 추정·관찰이라고 명시했고, 지어낸 KPI 표는 넣지 않았다.
출시 다음 날 아침의 사무실은 출시 전과 다르다. 마일스톤이 끝났는데 일이 줄지 않고, 오히려 단위만 더 작아진다. 분기 단위로 잡혀 있던 일정이 주·일·시간 단위로 쪼개진다. 그리고 매주 같은 질문이 회의실로 돌아온다. "이번 주말 이벤트 뭐 돌리죠?"
이 질문이 매주 백지에서 다시 시작되면, 라이브 운영팀은 곧 지친다. 이 장은 그 질문을 백지에서 빼내는 방법을 다룬다. 핵심은 두 가지다. 첫째, 이벤트와 시즌을 매번 새로 짜내는 대신 검증된 양식의 라이브러리로 쌓아 둔다. 둘째, "그 양식들을 조합해 다음 주 후보를 5개 만드는" 지루한 초안 작업을 AI에게 시키고, 사람은 룰북 검증을 통과한 후보 중 무엇을 채택할지만 정한다. 0에서 만들기와 5개 중 고르기는 작업 부담이 다르다.
라이브 운영의 표준 사이클을 표로 외우는 책은 많다. 월요일에 보고하고, 화수에 준비하고, 금요일에 배포한다는 이야기다. 다 맞지만, 표만 외워서는 "이번 주 이벤트"라는 매주 돌아오는 결정이 어떻게 내려지는지가 안 보인다. 라이브 운영의 본질은 일정표가 아니라 닫힌 루프다 — 후보가 생기고, 검증을 통과하고, 사람이 고르고, 빌드로 나가고, 사용자 데이터가 다시 다음 후보의 입력이 되는 한 바퀴.
이 루프 위에 라이브 운영의 4축(콘텐츠·이벤트·밸런스·CS)이 각자의 속도로 돈다. 콘텐츠는 월~분기, 이벤트는 주~월, 밸런스는 주~격주, CS는 일·시간 단위다. 4축이 따로 돌면 같은 사용자 데이터를 보고도 매주 다른 결정이 나온다. 그래서 4축을 하나의 루프로 묶고, 그 루프의 한 칸(이벤트 후보 생성)을 AI가 돌릴 수 있는 형태로 만드는 것이 이 장의 목표다.
flowchart TD
A["입력
KPI 추이 · 사용자 segment
· 지난 이벤트 효과"] --> B["AI 조합
시즌 룰 × 이벤트 템플릿
라이브러리 → 후보 5개"]
B --> C{"룰북 검증
인플레이션 한도 · 목적 충돌
· 보상 범위 · 기간"}
C -->|위반 alert| D["재요청
(위반 후보 교체)"]
D --> B
C -->|통과| E["사람 선택
디렉터 채택/기각"]
E --> F["빌드·배포
(비가역: 시즌 시작·공지)"]
F --> G["사용자 데이터·피드백
(다음 루프 입력으로)"]
G --> A
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
class A,G data;
class B ai;
class C,F code;
class E human;
사람의 손이 닿는 곳은 두 군데뿐이다. 맨 위에서 입력(KPI·segment·과거 효과)을 깨끗하게 넣는 자리, 그리고 검증을 통과한 후보 중 무엇을 띄울지 정하는 자리. 그 사이의 지루한 "조합 5개 짜내기"와 "룰 위반 거르기"는 AI와 룰북이 돌린다. 그리고 맨 아래 한 줄 — 빌드로 나간 이벤트가 만든 사용자 데이터가 다시 입력으로 돌아온다는 점 — 이 루프를 라이브 운영답게 만든다. 출시 전 설계는 한 번 나가면 끝이지만, 라이브 운영은 결과가 다음 입력이 된다.
이 루프에 들어가는 두 라이브러리(시즌 룰·이벤트 템플릿)의 구체는 §15.2에서, 마지막 칸(사용자 피드백 자동 분류)은 §15.3에서 본다. 이 장은 루프를 한 바퀴 끝까지 도는 데 집중한다.
실제로 어떻게 돌리는지 한 사이클을 끝까지 보여준다. 아래는 저자가 출시 전 콘텐츠 도구에서 검증한 "라이브러리 조합 → 룰북 검증 → 사람 선택" 패턴을 라이브 운영 양식(시즌 룰 + 이벤트 템플릿)에 옮겨 실제로 한 번 돌린 세션을 재현한 것이다. 입력 프롬프트는 그대로 복사해 쓸 수 있고, 출력은 그 세션을 재구성했다.
먼저 조합의 재료 두 가지를 기계가 읽을 수 있는 형태로 둔다. 이벤트 템플릿 라이브러리(검증된 양식)와 시즌 룰 라이브러리, 그리고 이번 주의 현재 상황(KPI·segment)이다. 라이브러리는 한 번 만들어 두면 매주 재사용한다.
# event_templates.yaml — 검증된 이벤트 템플릿 라이브러리 (발췌, 9종 중 4종)
- id: tpl_attendance # 출석 보상
목적: [신규유입, 휴면복귀]
기간_권장: 7~14일
보상등급: 저~중
- id: tpl_coop_raid # 협력 레이드
목적: [기존활성화, 커뮤니티]
기간_권장: 3~7일
보상등급: 중~고
- id: tpl_pvp_season # 경쟁 시즌
목적: [커뮤니티, 기존활성화]
기간_권장: 14~28일
보상등급: 고
- id: tpl_limited_package # 한정 패키지
목적: [매출]
기간_권장: 3~7일
보상등급: 고 (결제연동)
# season_rules.yaml — 시즌 룰 조각 (발췌)
season_inflation_cap: 분기당 '고' 등급 보상 이벤트 ≤ 3회
purpose_conflict_rule: 한 주에 [매출] 목적 이벤트 2개 동시 금지
overlap_rule: '고' 보상 이벤트는 동시 2개 금지 (피로·인플레이션)
# current_state.yaml — 이번 주 상황
주차: 2026-W23
직전2주_매출이벤트: 1회 (분기 누적 '고'등급 2회)
DAU_추이: 완만한 하락 (직전 4주 -6%, 업계 관찰상 '경계' 구간)
주요_segment: 복귀_가능_휴면층 비중 상승
다가오는_외부일정: 없음
첨부한 템플릿·시즌룰·이번주 상황 yaml로 다음 주 이벤트 후보 5개만 조합해 줘.
새 메커니즘 만들지 말고 첨부 템플릿 조합으로만, 시즌룰 위반 여부는 스스로 표시하고,
각 후보가 지금 신호(DAU 하락·휴면 복귀)에 왜 맞는지 한 줄씩 붙여 줘.
목적이 매출로만 쏠리지 않게 분산하고, 애매한 건 표시해서 나한테 넘겨.
표로만, 칸은 후보·템플릿조합·주목적·기간·룰위반·근거·애매.
규칙 다섯 줄 중 어느 하나도 "무엇을 만들지"를 말하지 않는다는 점을 보라. 전부 "어떤 틀 안에서, 무엇을 근거로 답하라"는 제약이다. AI에게 자유를 주면 일반 RPG 평균 이벤트를 토해내기 때문이다. 재료를 라이브러리로 제한(규칙 1), 시즌 룰 자가 점검 의무(규칙 2), 현재 신호 대응 근거 의무(규칙 3), 목적 분산(규칙 4), 그리고 "애매하면 사람에게 넘겨라"는 탈출구(규칙 5) — 다섯 줄이 모여 후보의 모양이 아니라 후보의 자격을 정한다.
아래는 요약하지 않은 날것의 출력이다. 룰 위반과 애매 신고가 섞여 있다.
| 후보 | 템플릿조합 | 주목적 | 기간 | 룰위반 | 근거 | 애매 |
|-----|------------------------|-----------|-------|--------|--------------------------------------------|------|
| C1 | tpl_attendance | 휴면복귀 | 10일 | 없음 | 복귀 휴면층 비중 상승에 직접 대응, 보상 '중' | - |
| C2 | tpl_coop_raid | 기존활성화 | 5일 | 없음 | DAU 하락기 접속 동기 부여, 커뮤니티 자극 | - |
| C3 | tpl_attendance+tpl_coop_raid | 휴면복귀+활성화 | 7일 | 없음 | 출석으로 복귀 유인 + 레이드로 잔존, 결합 시너지 | - |
| C4 | tpl_pvp_season | 커뮤니티 | 21일 | 없음 | 장기 시즌으로 잔존 곡선 자체를 끌어올림 | ★ |
| C5 | tpl_limited_package | 매출 | 5일 | ★위반 | 분기 '고'등급 누적 2회 → 추가 시 한도 근접/초과 | - |
룰 점검 결과:
- C5: season_inflation_cap 위반 위험. 이번 분기 '고'등급 보상 이벤트가
이미 2회. C5를 더하면 3회로 한도 경계. DAU 하락기에 매출 푸시는
사용자 피로를 더할 수 있어 신호와도 역방향.
- C4: 룰 위반은 없으나 21일 PvP 시즌은 이번 한 주가 아니라 3주를
점유한다. '이번 주 후보'로 적절한지 운영 정책 판단 필요 → 애매 표시.
- C1·C2·C3은 룰 통과, 현재 신호(휴면·DAU)와 정합.
출력에서 가장 가치 있는 부분은 표가 아니라 맨 아래 룰 점검 세 줄이다. AI가 C5의 인플레이션 위반을 스스로 잡아 표시했고, C4는 "위반은 아니지만 판단이 갈린다"며 사람에게 넘겼다. 좋은 프롬프트는 AI가 자기 한계를 신고하고 사람에게 공을 넘길 수 있게 만든다.
이 출력을 그대로 받으면 안 된다. 룰북으로 한 번 더 치고, 그 다음에 사람이 고른다. 이 세션에서 실제로 두 가지가 갈렸다.
먼저 C5는 거부다. AI가 이미 인플레이션 위반을 표시했고, 룰북 코드(§15.1.3)도 같은 판정을 냈다. 분기 '고'등급 한도에 걸리고, DAU 하락기에 매출 푸시는 현재 신호와 역방향이다. 토론할 게 없다. 뺀다.
다음은 C4(21일 PvP 시즌) 다. AI가 "애매"로 넘긴 자리다. 룰 위반은 없지만, 이건 "이번 주 이벤트"가 아니라 "이번 시즌 결정"이다. 한 주짜리 루프에서 즉결할 사안이 아니라 시즌 통합 회의로 올려야 한다. 그래서 이번 주 후보에서는 보류하고, 시즌 캘린더 안건으로 따로 뺀다.
남은 C1·C2·C3 중에서 디렉터가 고른다. 현재 신호(휴면 복귀층 상승 + DAU 완만한 하락)에 가장 잘 맞는 건 C3(출석+협력 레이드 결합) 였다. 출석으로 휴면층을 끌어들이고, 레이드로 끌어들인 사용자를 잡아 두는 결합 시너지가 이번 주 신호와 정합했다. C1·C2는 다음 주 후보 풀에 남겨 둔다.
여기서 끝나지 않은 후보 하나가 더 있었다. C3 채택을 정하고 나니, 7일 기간이 다가오는 정기 점검일과 하루 겹쳤다. 그래서 재요청이 한 번 돈다.
C3를 채택한다. 다만 7일 기간 중 마지막 날이 정기 점검일과 겹친다.
점검으로 이벤트 막판 참여가 끊기지 않도록 기간을 조정해 다시 제안하라.
보상 총량은 유지하고 일정만 당겨라.
AI는 시작일을 하루 당겨 점검 전에 종료되도록 다시 답했고, 그 조정은 룰북을 통과했다. 입력 → AI 조합 → 룰북 검증 → 사람 선택 → 일정 재조정의 한 사이클이 여기서 닫힌다.
이 한 바퀴가 이 책 전체의 Show 기준이다. AI가 무엇을 조합하고, 룰북이 무엇을 거르고, 사람이 무엇을 고르고 무엇을 거부하는지를 한 번이라도 끝까지 보지 않으면, "AI로 이벤트 후보를 뽑는다"는 문장은 공허하다.
후보가 시즌 룰을 지켰는지 매주 눈으로 보면 또 놓친다. §15.1.2의 세 룰 중 숫자로 판정 가능한 것은 코드가 검수하게 만든다. 사람은 코드가 못 잡는 "애매"와 "선택"에만 시간을 쓴다.
# event_lint.py — 다음 주 이벤트 후보 검증 (골격)
# 입력: AI가 조합한 후보 목록 + 시즌 룰 + 분기 누적 상태
# 출력: 룰 위반 목록 (자동 거부가 아니라 alert)
def lint(candidates, season, quarter_state):
issues = []
high_used = quarter_state["high_reward_count"] # 분기 누적 '고'등급 횟수
for c in candidates:
# 규칙 A: 인플레이션 한도 (분기당 '고'등급 ≤ 3)
if c["보상등급"] == "고" and high_used + 1 > season["inflation_cap"]:
issues.append(f"[A] {c['id']}: '고'등급 추가 시 분기 한도 "
f"{season['inflation_cap']}회 초과 (현재 {high_used})")
# 규칙 B: 동일 주 [매출] 목적 2개 금지
sales = [c for c in candidates if "매출" in c["목적"]]
if len(sales) > 1:
issues.append(f"[B] [매출] 목적 후보 {len(sales)}개 동시 → 1개로 제한")
# 규칙 C: 목적 쏠림 (5개 중 한 목적이 과반이면 분산 부족)
from collections import Counter
top = Counter(c["주목적"] for c in candidates).most_common(1)[0]
if top[1] > len(candidates) // 2:
issues.append(f"[C] 목적 '{top[0]}' {top[1]}개 쏠림 (분산 부족)")
return issues
이 코드가 회의에서 "이거 보상 너무 센 거 아니에요?"라는 옥신각신을 숫자 한 줄로 정리한다. [A] tpl_limited_package: '고'등급 추가 시 분기 한도 3회 초과 (현재 2)라고 코드가 출력하면, 토론할 게 없다. 빼면 된다. §14.1(모바일 HUD)에서 다룬 lint 게이트를 라이브 운영 차원으로 옮긴 것이다 — 결정론으로 잡을 수 있는 건 코드가, 판단이 필요한 건 사람이 맡는 분담이 운영에서도 그대로 성립한다.
다만 한 가지가 다르다. 이 lint는 위반을 발견해도 자동으로 후보를 폐기하지 않는다. alert만 올린다. §6.2(도시 생성기)에서 본 것과 같은 설계다. 자동 거부형 검증을 달면, 의도된 변형(예: 분기 한도를 알고도 의도적으로 매출 이벤트를 넣는 캠페인 결정)까지 기계가 죽여 버린다. 의심 후보는 기계가 뽑되, 죽일지 살릴지는 디렉터가 정한다. §15.1.2에서 C5를 거부한 것도 lint가 죽인 게 아니라, lint의 alert를 보고 사람이 정한 결정이었다.
위 루프가 출시 전 설계 루프와 결정적으로 다른 지점은 두 가지다. 표로 나열하기보다, 이 두 가지만 정확히 짚는다.
첫째, 결과가 다음 입력이 된다. 출시 전에는 기획서를 쓰면 빌드까지 단방향으로 흐른다. 라이브 운영에서는 이번 주 이벤트가 만든 사용자 데이터(참여율·이탈·매출·피드백)가 다음 주 후보 조합의 입력(current_state.yaml)으로 돌아온다. §15.1.1 루프의 맨 아래 화살표가 그 회귀다. 그래서 라이브 운영의 KPI는 "한 번 잘 맞히기"가 아니라 "매주 신호에 맞춰 조정하기"다.
둘째, 실험 비용이 작아지지만 비가역 지점은 더 날카롭다. 출시 전 한 번의 결정이 분기를 좌우했다면, 라이브에서는 한 주짜리 이벤트를 돌려 보고 안 맞으면 다음 주에 바꾼다. 롤백 가능한 실험이 늘어난다. 그러나 시즌 시작과 이벤트 공지는 비가역이다. §5.4.5에서 다룬 "녹음·캐스팅 = 비가역 단계" 원칙이 그대로 작동한다. 사용자가 이미 본 시즌 룰·보상은 "취소"해도 커뮤니티 인식에 흔적을 남긴다. 그래서 §15.1.1 루프의 모든 검증(AI 조합·룰북·사람 선택)은 빌드·공지라는 비가역 칸에 들어가기 전 가역 단계에서 끝나야 한다. C4(21일 시즌)를 이번 주 즉결에서 빼 시즌 회의로 올린 것도 이 원칙이다 — 비가역 지점이 큰 결정일수록 더 긴 가역 검토를 거친다.
이 두 가지가 라이브 운영을 출시 전 설계와 다른 일로 만든다. 나머지(시간 단위가 분기→주, 피드백이 베타→실시간)는 이 두 축의 파생이다.
§15.1.2의 워크드 트랜스크립트는 진보적 적용의 한 장면이다. AI가 후보를 조합하고, 사람은 채택을 정했다. 그러나 모든 팀이 처음부터 여기까지 오는 건 아니다. 단계가 있다.
보수적 적용에서는 사람이 후보를 발의한다. 운영팀이 월요일 회의에서 이벤트를 직접 기획하고, 시즌 룰을 손으로 작성하고, 사용자 피드백을 수동 분류한다. 자동은 측정(KPI 대시보드)과 회귀 검사(빌드 검수)만 맡는다. 업계 관찰상 현재 대부분의 라이브 MMORPG 운영이 이 단계에 가깝다.
진보적 적용에서는 "이벤트 후보 발의"와 "피드백 분류"까지 AI가 초안을 댄다. §15.1.2가 전자의 장면이고, 후자(피드백 자동 클러스터링)는 §15.3에서 본다. 사람의 결정은 "어떤 후보를 채택할지", "AI가 분류한 피드백을 어떻게 받아들일지" 같은 메타 결정으로 좁혀진다.
진보적 적용이 자리잡으려면 세 가지가 갖춰져야 한다. 이벤트 템플릿·시즌 룰이 재조합 가능한 단위로 분리·축적된 라이브러리(§15.1.2의 event_templates.yaml이 그 씨앗), 현재 신호를 입력받아 후보를 초안 형태로 내는 후보 생성기(§15.1.2의 프롬프트), 그리고 들어오는 피드백을 자동 분류하는 클러스터링(§15.3)이다. 이 세 가지가 §5.3.12(월드 BT(BehaviorTree, 행동 트리)·퀘스트 클라우드)·§8.1.8(진보적 밸런싱)과 같은 골격이라는 점이 이 책의 일관된 메시지다 — 분야는 다르지만, "검증된 조각을 라이브러리로 쌓고, AI가 조합 후보를 내고, 사람이 채택한다"는 구조는 같다.
여기서 한 가지를 분명히 해 둔다. 라이브러리·후보 생성기·클러스터링 같은 발상은 2010년대에도 이론적으론 가능했다. 막혔던 건 AI가 이벤트 공지문·룰 설명 같은 사용자가 읽을 자연어를 쓸 수 없었고, 일 수백~수천 건의 피드백을 자연어로 요약·분류할 수 없었기 때문이다. LLM 발전(2023~) 이후 그 두 벽이 낮아지면서, 종이에만 있던 진보적 운영의 상당 부분이 실현 영역에 들어왔다.
| 패턴 | 왜 실패하나 | 처방 |
|---|---|---|
| 매주 백지에서 이벤트 기획 | 운영팀이 곧 소진, 후보 질이 컨디션 따라 출렁임 | 이벤트 템플릿 라이브러리로 축적 (§15.1.2) |
| "AI야 이벤트 만들어 줘" 통째 위임 | 라이브러리·룰 없이는 일반 RPG 평균이 나옴 | 재료 제한 + 시즌 룰 자가점검 강제 (§15.1.2) |
| 후보를 눈으로만 검수 | 인플레이션·목적 쏠림을 매주 놓침 | event_lint.py로 자동 검증 (§15.1.3) |
| lint를 자동 거부형으로 | 의도된 캠페인 결정까지 기계가 죽임 | alert만, 채택은 디렉터 (§15.1.3) |
| 비가역 결정을 주간 루프에서 즉결 | 시즌 공지 후 롤백이 커뮤니티 흔적을 남김 | 큰 결정은 시즌 회의로 분리 (§15.1.4) |
| 단일 KPI(DAU·매출)만 추구 | 사용자 피로 누적, 신호와 역방향 후보 채택 | current_state에 다축 신호 입력 (§15.1.2) |
setup → prompt → verify 순으로 한 단계만 해 보세요.
event_templates.yaml 형식으로 손으로 적습니다(목적·기간·보상등급만). 시즌 룰은 한 줄짜리 세 개면 충분합니다 — 인플레이션 한도, 목적 충돌 금지, 중복 금지.current_state.yaml에 채워 한 번 돌립니다.혼자라면 이만큼만: 라이브러리 yaml도, lint 코드도 필요 없습니다. 좋아하는 게임의 지난 분기 이벤트를 5~6개만 떠올려 "목적·기간·보상" 세 칸으로 적어 보세요. 그것만으로도 그 게임이 매주 백지에서 짜낸 게 아니라 양식을 돌려쓰고 있었다는 게 보입니다. 그 표가 곧 당신의 첫 템플릿 라이브러리입니다.
팀이라면 다음 한 단계로 시작하세요. 지난 1~2분기의 이벤트를 모아 event_templates.yaml로 정규화하고(검증된 양식만), 시즌 룰 세 줄을 event_lint.py로 먼저 코드에 넣어 둡니다. 라이브러리와 룰이 있으면, AI 조합 후보든 사람 시안이든 같은 선으로 잴 수 있습니다.
1차 독자: 라이브 운영을 책임지는 MMORPG 기획자 (중규모(10~50인) 팀) 1인/취미 독자용 축소 버전: §15.2.9 「혼자라면 이만큼만」
운영 4년 차 라이브 게임의 월요일 회의를 떠올린다. 다음 주 이벤트를 뭘로 돌릴지가 매주 백지에서 시작됐다. 누군가 "지난번 출석 이벤트 보상을 좀 올려서 다시?"라고 하면, 누군가는 "그건 두 달 전에 했는데"라고 했고, 보상을 얼마 올릴지는 또 감으로 정했다. 회의가 끝나면 운영 기획자 한 명이 반나절을 들여 이벤트 양식을 처음부터 채웠다. 매주, 백지에서, 반나절.
문제는 아이디어가 부족해서가 아니었다. 운영팀은 이미 머릿속에 출석·협력·경쟁·복귀라는 검증된 이벤트 골격 몇 개를 갖고 있었다. 그 골격에 테마와 보상만 갈아 끼우면 한 주짜리 이벤트가 나온다. 다만 그 "갈아 끼우기"를 매번 손으로, 감으로 했기 때문에 느렸고 결과가 흔들렸다.
이 장은 그 갈아 끼우기를 AI에게 넘기는 방법을 다룬다. 핵심은 두 가지다. 첫째, 검증된 이벤트 골격을 변주 가능한 템플릿 yaml로 입력해 둔다. 둘째, 템플릿에서 다음 주 후보 여러 개를 뽑는 지루한 일을 AI에게 시키고, 사람은 보상 범위·중복을 코드로 친 뒤 톤만 검수한다. 이벤트 기획의 일반론(출석은 신규 유입에 좋고 협력은 활성화에 좋다는 식)은 이미 다른 책에 충분하니, 이 장은 그 지식을 AI 워크플로로 돌리는 자리에만 집중한다.
저자 운영 경험 메모(솔직히) 출시 후 라이브 운영을 1~2년 단위로 직접 책임진 경험은 저자 경력 중 일부에 한정된다. 이 장의 워크플로는 저자가 운영 중인 양산·검수 도구(콘텐츠·HUD)를 이벤트 분야로 옮긴 것이며, 효과 수치는 업계 관찰 + 저자 추정임을 본문에서 그때그때 명시한다. 도구 구조(템플릿 yaml·lint·검수 게이트)는 저자가 실제로 운영하는 콘텐츠 양산 도구와 동일한 골격이다.
이벤트 양산의 전체 흐름은 네 단계다. 핵심은 1단(템플릿)과 3단(lint)이 결정론이고 2단만 AI라는 점이다. 콘텐츠 양산(§6.2)·HUD 압축(§14.1)에서 본 것과 같은 분담이다. 룰북이 입력과 검증을 양쪽에서 잡아 주면, 가운데 낀 AI가 매번 약간 다른 변주를 내도 보상 밸런스와 일정이 흔들리지 않는다.
flowchart TB
A["입력: 이벤트 템플릿 yaml
(검증된 골격 — 출석·협력·경쟁·복귀)
+ 이번 분기 테마·금지 보상·캘린더 슬롯"]
A --> B["2단 AI: 변주 후보 생성
같은 골격 × 다른 테마·보상·기간
→ 후보 5~10개 (양식 초안)"]
B --> C{"3단 결정론: event_lint.py
보상범위·인플레 한도·일정중복
·직전 N주 동일 골격 반복"}
C -->|위반 WARN| D["운영 검수 게이트
(채택·기각·미세조정)"]
C -->|통과| D
D -->|재요청| B
D -->|채택| E["빌드 반영 → 공지
(비가역 게이트)"]
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
class A data;
class B ai;
class C,E code;
class D human;
이 그림에서 사람의 손이 닿는 곳은 두 군데뿐이다. 맨 위에서 템플릿과 이번 분기 제약을 깨끗이 넣는 자리, 맨 아래에서 lint가 못 잡는 "이 테마가 지금 우리 게임 분위기에 맞나"를 판단하는 자리. 그 사이의 지루한 후보 양산과 보상 산수는 템플릿과 AI와 lint가 돌린다.
결정적인 설계는 lint(3단)가 위반을 발견해도 후보를 자동으로 버리지 않고 운영 게이트(4단)로 WARN만 올린다는 점이다. 그 이유는 §15.2.5에서 본다. 그리고 맨 마지막 화살표(공지)가 비가역이라는 점이 라이브 운영을 다른 양산과 구분 짓는다. 도시 NPC는 마음에 안 들면 빌드 전에 폐기하면 그만이지만, 사용자에게 공지된 이벤트는 되돌릴 때 커뮤니티 신뢰 비용을 치른다(§15.2.7).
운영팀이 가진 검증된 골격을 양식으로 고정한다. 자유 양식 기획서로 두면 AI가 무엇을 변주해야 할지 모른다. 슬롯이 나뉘어 있어야 "이 슬롯만 갈아 끼워라"가 성립한다.
# event_templates/coop_raid.yaml — 협력 레이드 골격 (검증됨, 4회 운영)
template_id: coop_raid
purpose: [기존_활성화, 커뮤니티] # 1~2개만. 4개 동시 추구 금지
core_loop: 기간 내 서버 전체가 누적 기여 → 단계별 전 서버 보상 해금
duration_range: [5, 10] # 일. 10일 초과 시 피로 누적
slots: # ← AI가 변주하는 칸. 골격은 고정
theme: { type: 자유, 제약: 분기_테마_준수 }
boss_or_target: { type: 자유, 제약: 기존_보스_에셋_재활용_우선 }
reward_tiers: { type: 보상목록, count: 3~5, 제약: reward_policy 참조 }
reward_policy: # ← lint가 읽는 칸. 변주 금지
강화석_per_event_max: 30 # 이벤트 1회 지급 상한
골드_per_event_max: 50000
한정코스튬: 허용 (영구 소유, 경제 영향 0)
현금성재화_직접지급: 금지
inflation_guard:
강화석_분기_누적상한: 90 # 분기 내 모든 이벤트 합산
post_event_kpi: # ← 사후 자동 측정 슬롯
- 참여율 (이벤트 노출 대비 1회 이상 참여)
- 강화석 가격 변동 (사후 30일, 목표 ±10%)
- 이벤트 후 평일 플레이타임 (의존도 신호)
가장 중요한 분리는 slots(AI가 변주)와 reward_policy(lint가 읽고, AI는 손대지 못함)다. 테마와 보스는 매번 달라도 되지만, 강화석 지급 상한은 게임 경제가 정한 선이다. 이 선을 AI가 매 호출마다 다른 숫자로 뽑으면 인플레이션이 그 자리에서 시작된다. 그래서 보상 항목은 AI가 제안하되, 보상 양은 정책 범위 안에서만 움직이도록 lint가 친다.
같은 폴더에 daily_attendance.yaml(출석), pvp_ladder.yaml(경쟁), comeback.yaml(복귀)이 같은 형식으로 들어 있다. 이 네 장이 이번 분기 후보 생성의 입력 풀 전부다.
실제로 어떻게 돌리는지 한 사이클을 끝까지 보여준다. 입력 프롬프트는 그대로 복사해 쓸 수 있고, 출력은 실제 양산 세션을 재구성했다.
첨부한 coop_raid.yaml(4회 검증된 협력 레이드 골격)로 다음 주(W2) 변주 후보 5개만 줘.
이번 분기 테마는 "여름 — 물·축제·더위".
core_loop는 절대 건드리지 말고 slots(테마·보스·보상)만 바꿔,
보상은 reward_policy 상한 안에서만, 후보마다 왜 이 테마·보상인지 한 줄씩 붙여 줘.
직전 3주는 출석·PvP래더·협력레이드였으니 협력 레이드 또 도는 후보엔 [반복주의],
확신 없으면 [애매] 달아서 나한테 넘겨. 표로만, 칸은 후보명·테마·보상tier요약·근거·플래그.
여섯 줄의 규칙 중 테마나 보상을 지정하는 줄은 한 줄도 없다. 전부 "어디까지만 바꿔라, 무엇을 넘지 마라, 헷갈리면 신고하라"는 울타리다. "여름 이벤트 만들어 줘"처럼 풀어 주면 일반 모바일 RPG 평균이 나오기 때문이다. 변주 범위(slots만), 보상 상한(reward_policy), 반복 주의(직전 3주 이력), 그리고 "애매하면 사람에게" — 이 울타리가 골격은 지키게 하고 변주는 풀어 준다.
| 후보명 | 테마 | 보상tier 요약 | 근거 | 플래그 |
|---------------|----------------|-------------------------------|----------------------------------|-------------|
| 범람의 둑 | 강 범람 막기 | 강화석 25 / 골드 4만 / 코스튬 | 협력=둑 쌓기 은유, 누적 기여 직결 | |
| 등불 띄우기 | 여름 물축제 | 강화석 20 / 골드 3만 / 칭호 | 축제 톤, 경쟁보다 분위기 환기 | |
| 폭염 보스전 | 더위 화신 토벌 | 강화석 30 / 골드 5만 / 코스튬 | 기존 보스 에셋 재활용, 보상 상한 | [반복주의] |
| 해류 경주 | 조류 타고 경쟁 | 강화석 28 / 골드 4.5만 / 마운트 | 협력+경쟁 혼합 | [애매] |
| 비단잉어 수집 | 수집형 협력 | 강화석 22 / 골드 3.5만 / 칭호 | 라이트 유저 진입장벽 낮음 | |
[반복주의] 폭염 보스전: core_loop가 W-3 협력 레이드와 사실상 동일.
3주 간격 동일 골격 반복 → 사용자 피로 우려. 다른 골격(출석/수집) 권장.
[애매] 해류 경주: 협력 누적 + 개인 경쟁을 한 이벤트에 섞음.
purpose 슬롯이 [활성화+경쟁]으로 늘어남. 1~2 목적 원칙과 충돌 가능.
출력에서 가장 가치 있는 부분은 표가 아니라 맨 아래 두 줄이다. AI가 "폭염 보스전은 3주 전 골격과 같다", "해류 경주는 목적이 둘로 늘었다"고 자기 한계를 신고하고 사람에게 넘긴 자리. 좋은 프롬프트는 AI가 "이건 제가 확신 못 합니다"라고 말할 수 있게 만든다.
이제 이 후보 묶음을 lint가 친다.
후보가 보상 정책과 일정 중복을 지켰는지 매번 눈으로 보면 또 놓친다. reward_policy·inflation_guard·캘린더로 판정 가능한 것은 코드가 검수하게 만든다. 사람은 코드가 못 잡는 톤·재미 판단에만 시간을 쓴다.
# event_lint.py — 이벤트 변주 후보 검증 (골격)
# 입력: AI가 제안한 후보 리스트 + 템플릿 정책 + 분기 캘린더
# 출력: WARN 목록 (자동 폐기 아님 — 운영 게이트로 올림)
def lint(candidates, policy, quarter_ledger, recent_weeks):
warns = []
stone_used = sum(quarter_ledger.강화석) # 이번 분기 이미 지급한 누적
for c in candidates:
# A: 이벤트 1회 보상 상한 (정책)
if c.강화석 > policy["강화석_per_event_max"]:
warns.append(f"[A] {c.name}: 강화석 {c.강화석} > 상한 "
f"{policy['강화석_per_event_max']} (이벤트당 초과)")
# B: 분기 인플레 누적 상한
if stone_used + c.강화석 > policy["강화석_분기_누적상한"]:
warns.append(f"[B] {c.name}: 분기 누적 {stone_used + c.강화석} > "
f"{policy['강화석_분기_누적상한']} (인플레 한도)")
# C: 직전 N주 동일 골격 반복
if c.template_id in recent_weeks[-2:]:
warns.append(f"[C] {c.name}: {c.template_id} 골격이 직전 2주에 있음 (반복)")
# D: 캘린더 슬롯 충돌 (같은 주 다른 대형 이벤트)
if quarter_ledger.slot_taken(c.week):
warns.append(f"[D] {c.name}: W{c.week} 슬롯에 이미 대형 이벤트 배치됨")
return warns
위 워크드 트랜스크립트의 다섯 후보를 이 코드에 넣으면 이렇게 나온다.
[PASS] 범람의 둑: 강화석 25 ≤ 30, 분기누적 65+25=90 ≤ 90 (경계 도달)
[WARN] [C] 폭염 보스전: coop_raid 골격이 직전 2주(W-3)에 있음 (반복)
[WARN] [B] 해류 경주: 분기누적 65+28=93 > 90 (인플레 한도 초과)
[PASS] 등불 띄우기: 강화석 20 ≤ 30, 분기누적 65+20=85 ≤ 90
[PASS] 비단잉어 수집: 강화석 22 ≤ 30, 분기누적 65+22=87 ≤ 90
여기서 흥미로운 건 해류 경주다. AI는 [애매]를 목적 충돌 때문에 달았는데, lint는 전혀 다른 이유 — 분기 인플레이션 누적 상한 초과 — 로 걸었다. 강화석 28을 더하면 분기 누적이 93이 되어 정책상 90을 넘는다. AI가 못 본 산수를 코드가 잡았다. 반대로 폭염 보스전은 AI의 [반복주의]와 lint의 [C]가 같은 걸 가리켰다. 사람·AI·코드 셋이 각자 다른 그물로 거른다.
이 30줄 덕에 "이번 보상 좀 센 거 아니야?"가 더 이상 감과 감의 대결로 끝나지 않는다. [B] 분기누적 93 > 90이라고 코드가 출력하면, 토론할 게 없다. 보상을 내리거나 후보를 바꾸면 된다.
추상적으로 "운영팀이 검수한다"고만 적으면 이 게이트가 실제로 무엇을 거르는지 알 수 없다. lint를 통과한 뒤 사람이 무엇을 죽이고 무엇을 살리는지 한 번 끝까지 따라간다.
[4단 운영 검수 — 판정]
운영 기획자는 후보 5개를 이렇게 처리했다.
- 폭염 보스전 → 기각. lint [C]·AI [반복주의]가 같이 가리켰다. 3주 만에 같은 협력 레이드 골격을 또 돌리면 "또 누적 기여냐"는 피로가 온다. 다음 분기 슬롯으로 이월 메모.
- 해류 경주 → 기각. lint [B] 인플레 한도 초과. 보상을 25로 낮추면 통과하지만, AI [애매]가 짚은 목적 충돌(활성화+경쟁)이 더 근본 문제였다. 협력 이벤트에 개인 랭킹을 섞으면 라이트 유저가 "결국 고인물 잔치"로 느낀다. 보상만 깎아 살리지 않고 통째로 보류.
- 범람의 둑 → 채택 후보 1순위. 단, lint가
분기누적 90 경계 도달을 PASS로 줬지만 경계라는 점이 마음에 걸렸다. 이 이벤트를 쓰면 이번 분기 강화석 여유가 0이 된다. 6월 마지막 주 시즌 마무리 push에 보상 여력이 없어진다.- 등불 띄우기 / 비단잉어 수집 → 살림. 둘 다 보상이 가볍고(20·22) 분기 여유를 남긴다.
여기서 lint를 통과한 범람의 둑을 사람이 1순위에서 흔든 게 이 게이트의 핵심이다. 코드는 90 ≤ 90을 PASS로 줬다. 정책상 위반이 아니다. 그러나 운영 기획자는 분기 전체의 보상 리듬을 봤다. lint는 한 이벤트의 합법성을 보지만, 사람은 분기 끝의 시즌 마무리까지 본다. 그래서 재요청을 돈다.
범람의 둑의 보상을 강화석 25 → 18로 낮춘 변주를 다시 만들어라.
이유: 6월 마지막 주 시즌 마무리 push에 강화석 여유 12를 남겨야 한다.
보상 매력이 떨어지는 만큼, 강화석 대신 한정 코스튬·칭호로
체감 가치를 보강하는 방향으로 reward_tiers를 재구성하라.
AI는 강화석을 18로 낮추고 한정 코스튬을 2종으로 늘린(경제 영향 0인 영구 소유 보상) 후보를 다시 냈다. lint를 다시 돌리니 분기누적 65+18=83 ≤ 90으로, 시즌 마무리에 여유 7이 남았다. 입력 → 후보 양산 → lint → 검수 → 기각 → 재요청의 한 사이클이 여기서 닫힌다.
이 한 바퀴가 이 책 전체의 Show 기준이다. 도구가 무엇을 뱉고, 무엇이 걸리고, 사람이 무엇을 죽이는지를 한 번이라도 끝까지 보지 않으면, "AI로 이벤트를 양산했다"는 문장은 공허하다.
자동 폐기형 lint를 달지 않은 이유도 이 사이클에 있다. 만약 lint가 [B] 위반을 자동으로 버렸다면, 운영팀은 해류 경주의 진짜 문제(목적 충돌)를 학습할 기회를 잃었을 것이고, 범람의 둑처럼 합법이지만 분기 리듬상 위험한 후보를 흔들 자리도 사라졌을 것이다. 의심 후보는 기계가 뽑되, 채택과 기각은 사람이 정한다.
이벤트가 주~월 리듬이라면 시즌은 분기 리듬이다. 운영 방식은 같다. 시즌도 검증된 요소를 슬롯으로 분리해 두면 분기마다 테마만 갈아 끼운다.
| 시즌 슬롯 | 변주(AI·사람) | 고정(정책·lint) |
|---|---|---|
| 시즌 테마 | 여름·겨울·신년 (자유) | — |
| 시즌 패스 보상 트랙 | 단계별 보상 항목 | 단계 수·완료 난이도·보상 상한 |
| 시즌 PvP 랭킹 | 랭킹 보상 항목 | 보상 인플레 한도 |
| 메타 셔플 | 신규 캐릭터·밸런스 | 변경 폭 가드레일(§8.1) |
시즌 패스에서 사람이 정책으로 고정하는 핵심 수치는 완료율 목표다. 활성 사용자의 70% 정도가 최종 단계에 도달하도록 난이도를 잡는다는 게 업계에서 흔히 인용되는 기준이다(저자 추정 — 게임마다 다르므로 절대값이 아니라 방향으로 읽는 게 맞다: 30% 미만이면 좌절, 90% 초과면 도전감 부재). 이 목표가 슬롯에 입력되어 있으면, 시즌 패스 변주를 AI가 제안할 때도 "예상 완료율"을 함께 산출하게 강제할 수 있다.
분기 캘린더가 한눈에 보여야 이벤트와 시즌이 충돌하지 않는다. 운영팀의 공용 책상 달력에 가깝다. 누가 봐도 같은 그림을 봐야 충돌이 준다.
이 그림 한 장이 §15.2.5의 판단을 시각으로 설명한다. 색이 골격 종류다. 같은 색이 2~3주 안에 두 번 나오면 §15.2.4 lint [C]가 운다. 그리고 아래 인플레 게이지가 빨간 선(상한 90)에 닿기 직전이라, 6월 시즌 마무리(W6)에 쓸 여유 7이 겨우 남았다 — 범람의 둑 보상을 18로 낮춰 확보한 그 7이다.
도시 NPC(§6.2)나 HUD(§14.1)와 라이브 운영이 결정적으로 다른 점이 하나 있다. 공지는 되돌릴 수 없다. NPC가 톤이 안 맞으면 빌드 전에 폐기하면 그만이고 사용자는 그 NPC가 있었는지도 모른다. 그러나 사용자에게 공지된 이벤트는 보상·기간·룰이 커뮤니티에 남는다. 시작 후 "이벤트 보상이 너무 셌으니 회수하겠다"는 비가역 비용을 동반한다.
flowchart LR
A["템플릿 변주 후보"] -->|가역| B["lint 검수"]
B -->|가역| C["운영 검수·기각·재요청"]
C -->|가역| D["빌드 검수
(릴리즈 보류 가능)"]
D ==>|비가역 게이트| E["이벤트 공지·시작"]
E -.->|회복 비용 큼| F["후반부 미세 조정만 가능
(보상 회수·기간 변경은 신뢰 비용)"]
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
classDef fail fill:#fee2e2,stroke:#dc2626,color:#7f1d1d;
class A data;
class B code;
class C,D human;
class F fail;
이 책 전체의 원칙(§5.4.5 보이스 녹음, §8.1 라이브 빌드, 12부 최종 렌더와 같은 메시지)이 라이브 운영에서도 동일하다. 모든 검수 — 보상 범위, 인플레 한도, 일정 충돌, 톤 — 는 공지 전 가역 단계에서 끝나야 한다. §15.2.3~5의 양산·lint·검수·재요청 사이클 전체가 이 비가역 게이트 왼쪽에서 도는 이유다. 게이트를 넘은 뒤 할 수 있는 건 §15.2.8의 후반부 미세 조정 정도이고, 그조차 사용자 신뢰를 야금야금 쓴다.
공지 후에도 KPI는 본다. 다만 공지 전 검수와 달리, 여기서 할 수 있는 건 후반부 미세 조정뿐이다. 자동 측정되는 신호와 사람의 처방을 나눈다.
| 신호 (자동 측정) | 처방 (사람 결정) |
|---|---|
| 참여율 50% 미만 | 후반부 보상 소폭 강화 또는 기간 +2일 (공지 신뢰 범위 내) |
| 참여율 95% 이상 | 너무 쉬움 — 다음 사이클 난이도 메모, 현 이벤트는 유지 |
| 강화석 가격 사후 30일 -10% 초과 | sink 강화(한정 상점), 다음 분기 인플레 한도 하향 |
| 이벤트 후 평일 플레이타임 감소 | 이벤트 의존 신호 — 평일 콘텐츠 매력 보강, 이벤트 빈도 조절 |
마지막 줄(평일 플레이타임 감소)이 가장 자주 놓치는 신호다. 이벤트 기간 DAU(Daily Active Users, 일일 활성 사용자)만 보면 이벤트는 늘 성공처럼 보인다. 그러나 이벤트가 끝난 뒤 평일에 사용자가 안 돌아오면, 이벤트가 평소 게임의 매력을 빨아먹고 있다는 뜻이다. 그래서 §15.2.2 템플릿의 post_event_kpi에 "이벤트 후 평일 플레이타임"을 처음부터 슬롯으로 입력해 둔다. 측정하지 않으면 처방할 수 없다.
이벤트 챕터는 "협력 이벤트를 돌렸더니 리텐션이 30%에서 50%로 올랐다" 같은 표를 넣고 싶은 유혹이 크다. 그런 숫자는 검증되지 않으면 책의 신뢰를 깎는다. 이 장이 말할 수 있는 것은 세 가지뿐이다.
첫째, 방향은 업계 관찰로 말할 수 있다. 출석 보상 강화 이벤트는 단기 활성 사용자 수를 끌어올리고, 협력 이벤트는 커뮤니티 결속을 높이며, 한정 패키지는 이벤트 기간 매출을 끌어올린다 — 이건 라이브 게임을 관찰해 온 업계의 통념이다. 다만 얼마나는 게임·사용자 구성에 따라 편차가 커서, 다른 회사의 수치를 그대로 옮기면 위험하다.
둘째, 저자 추정은 추정이라고 쓴다. "시즌 패스 완료율 목표 70%", "이벤트 기간 10일 초과 시 피로 누적", "이벤트 양산 반나절→한 시간"은 저자의 경험 기반 추정이며 미검증 가설이다. 절대값을 외우지 말고 구조(템플릿+lint가 백지 기획을 대체한다)로 읽으면 된다.
셋째, 측정 가능한 것만 KPI로 약속한다. 리텐션 같은 결과 지표는 이벤트 하나로 좌우되지 않으니 인과를 단정하지 않는다. 대신 이 워크플로가 실제로 측정 가능하게 만드는 것은 이런 것들이다 — lint WARN 건수(보상 위반이 0이 될 때까지), 분기 인플레 누적(상한 대비), 동일 골격 반복 간격(주), 이벤트별 참여율과 사후 강화석 가격 변동. 이 넷은 회의에서 "느낌"이 아니라 숫자로 말할 수 있다.
| 패턴 | 왜 실패하나 | 처방 |
|---|---|---|
| 매주 백지에서 이벤트 기획 | 느리고 결과가 흔들림 | 검증 골격을 템플릿 yaml로 입력하기 (§15.2.2) |
| "AI야 여름 이벤트 만들어 줘" 통째 위임 | 일반 RPG 평균 이벤트가 나옴 | 골격 고정 + 슬롯만 변주 (§15.2.3) |
| 보상 양을 AI가 자유 제안 | 인플레이션이 그 자리에서 시작 | reward_policy를 lint가 강제 (§15.2.4) |
| 후보를 눈으로만 검수 | 분기 누적·반복 간격을 매번 놓침 | event_lint.py로 자동 검증 (§15.2.4) |
| lint 통과 = 채택으로 직행 | 분기 리듬·목적 충돌을 못 봄 | 사람 게이트는 분기 전체를 본다 (§15.2.5) |
| 공지 후 보상 회수 시도 | 비가역 신뢰 비용 | 모든 검수를 공지 전에 (§15.2.7) |
| 이벤트 기간 DAU만 측정 | 평일 매력 잠식을 못 봄 | 사후 평일 플레이타임 슬롯 (§15.2.8) |
다섯 번째가 가장 자주 놓친다. lint를 PASS했다고 바로 공지로 보내면, 범람의 둑처럼 합법이지만 분기 끝에 보상 여력을 0으로 만드는 후보를 흔들 자리가 사라진다. 코드는 한 이벤트의 합법성을, 사람은 분기 전체의 리듬을 본다.
혼자라면 이만큼만: lint 코드가 없어도 됩니다. 본인 게임(또는 좋아하는 라이브 게임)에서 자주 보이는 이벤트 골격 하나를 골라 §15.2.2 형식의 템플릿 yaml을 손으로 적어 보세요(
core_loop·slots·reward_policy세 칸이 핵심). 그리고 §15.2.3 프롬프트를 붙여 변주 후보 5개를 뽑아 본 뒤, 그중 "보상이 너무 세다" 싶은 한 개를 골라 "이건 이번 달 보상 여력을 넘긴다, 낮춰서 다시"라고 반박해 보세요. 채택과 기각이 어떤 판단의 묶음인지 몸으로 들어옵니다.
팀이라면 다음 한 단계로 시작하세요. 자주 돌리는 이벤트 골격 3~4개를 템플릿 yaml로 입력하고, event_lint.py의 세 줄(보상 상한·분기 인플레 누적·반복 간격)부터 코드로 만듭니다. 템플릿과 이 세 줄만 있어도, "매주 백지 기획"과 "보상 감으로 정하기"라는 두 가지 흔한 실패를 먼저 막을 수 있습니다. 이 워크플로는 §15.1.5의 진보적 적용 골격 3요소 — 이벤트 템플릿·시즌 룰 라이브러리, AI 이벤트 후보 생성기, 사후 자동 측정 — 의 첫 실무 구현입니다.
1차 독자: 라이브 운영의 사용자 대응을 책임지는 기획자·디렉터 (중규모(10~50인) 팀) 1인/취미 독자용 축소 버전: §15.3.7 「혼자라면 이만큼만」
먼저 솔직히 밝혀 둔다. 저자는 출시 후 라이브 운영을 1~2년 단위로 직접 책임진 경험이 길지 않다. 이 장의 상당 부분은 24년 경력 위에 쌓인 업계 관찰과 인접 경험이다. 그래서 이 장은 "라이브 운영을 이렇게 하라"고 단정하지 않는다. 대신, 출시 전 콘텐츠 양산에서 검증한 입력 → AI → 검증 → 사람 결정의 사이클을 사용자 피드백이라는 입력에 그대로 끼워 보면 무엇이 나오는지를 한 번 끝까지 돌려 본다. 도구의 골격은 §6.2 city_hunting_generator와 같고, 입력만 "도시 메타데이터"에서 "사용자 피드백 100건"으로 바뀐다.
운영 첫 주의 풍경은 대개 비슷하다. 포럼·Discord·CS 티켓·스토어 리뷰가 하루에 수백~수천 건씩 쌓인다. 사람이 다 읽기는 불가능하고, 안 읽으면 같은 버그 신고가 50건씩 묻힌다. 이 장은 그 더미를 LLM이 토픽으로 묶고 감정으로 채점하게 한 다음, 사람은 "그래서 이번 주에 뭘 고칠 것인가"라는 우선순위 결정에만 들어가는 방법을 다룬다.
피드백을 4채널(게임 내 설문·포럼/Discord·스토어 리뷰·CS 티켓)로 나누고 4유형(버그·요청·불만·칭찬)으로 분류하는 표는 어느 운영 교과서에나 있다. 다 맞는 말이다. 문제는 그 표를 외워도 "오늘 들어온 412건을 어떻게 처리하느냐"에 답이 안 나온다는 점이다. 피드백을 사람이 읽고 분류하는 대상으로 보는 한, 피드백량은 항상 운영팀 인원을 이긴다.
관점을 바꾼다. 피드백 한 건은 구조화 입력이다. {출처, 원문, 토픽, 감정, 심각도} 다섯 슬롯을 가진 레코드다. 이렇게 보면 작업의 본질이 바뀐다. "다 읽기"가 아니라 "토픽으로 묶고 우선순위를 매기기"다. 그리고 토픽 클러스터링과 감정 채점은 사람이 하면 지루하고 할 때마다 기준이 흔들리지만, 기계는 같은 잣대를 100건에 똑같이 들이댄다. 바로 LLM이 사람보다 잘하는 종류의 일이다. §6.2에서 도시 30개를 양산한 그 분담(룰북=결정론, 본문=AI, 검수=사람)이 여기서도 그대로 성립한다. 다른 점은 단 하나, 마지막에 사람이 하는 일이 "본문 검수"가 아니라 "우선순위 결정"이라는 것뿐이다.
피드백 유형의 분포를 하나 짚어 둔다. 자발적으로 글을 쓰는 사용자는 만족한 사용자가 아니라 불만이 있는 사용자 쪽으로 기운다. 만족한 손님은 조용히 떠나고, 불만 있는 손님이 카운터로 다시 온다. 그래서 포럼·리뷰의 감정 분포는 실제 사용자 전체의 만족도보다 부정 쪽으로 치우치는 경향이 있다(저자 관찰 — 정확한 편향 폭은 게임·채널·시기마다 다르므로 절대 수치가 아니라 방향으로 읽는 게 맞다). 이 편향을 머리에 넣고 있어야, 클러스터링 결과에서 "부정 60%"를 봤을 때 게임이 망해 간다고 오독하지 않는다.
실제로 한 사이클을 끝까지 돌려 본다. 입력은 한 주차에 4채널에서 모인 피드백 100건이고, 출력은 토픽 클러스터·감정·우선순위다. 입력 프롬프트는 그대로 복사해 쓸 수 있고, 아래 출력은 실제 분류 세션의 형식을 재구성한 것이다.
채널에서 긁어 온 원문을 한 줄 한 레코드로 정규화한다. 이건 새로 쓰는 게 아니라 추출·정리만 하면 된다.
{"id": "fb_0001", "src": "discord", "text": "강화 12강에서 50번 깨졌어요. 이게 확률이 맞나요? 환불해주세요"}
{"id": "fb_0002", "src": "store_review","text": "그래픽은 예쁜데 렉이 너무 심해서 길드전 때마다 튕김"}
{"id": "fb_0003", "src": "cs_ticket", "text": "결제했는데 다이아가 안들어왔습니다 주문번호 첨부"}
{"id": "fb_0004", "src": "forum", "text": "신규 직업 궁수 언제 나오나요 ㅠㅠ 사전등록 때 약속했잖아요"}
{"id": "fb_0005", "src": "discord", "text": "오픈 첫주인데 운영진 소통 좋네요 공지 빠르고. 앞으로도 부탁"}
{"id": "fb_0006", "src": "store_review","text": "특정 보스(흑랑) 데미지가 말이 안됨. 풀템인데 한방. 밸런스 패치 요망"}
{"id": "fb_0007", "src": "cs_ticket", "text": "튜토리얼 5단계에서 진행이 안돼요 버튼이 안눌림 (기기: 갤럭시 A시리즈)"}
// ... fb_0008 ~ fb_0100 (생략)
레코드는 입력 단계에서 토픽·감정·심각도를 비워 둔다. 그 빈칸을 채우는 게 2단 LLM의 일이다.
첨부한 feedback_100.jsonl(한 주차 피드백 100건)을 토픽으로 묶고 감정도 같이 채점해 줘.
토픽은 이 목록에서만 골라(자유 생성 금지): 강화/확률, 밸런스, 서버/성능, 결제/환불,
신규콘텐츠요청, 튜토리얼/온보딩, UI/조작, 칭찬/응원, 기타. '기타'가 8건 넘으면 새 토픽 후보도 제안해.
감정은 부정·중립·긍정, 심각도는 S1·S2·S3·S4로.
// (의도: S1은 구체적·재현 가능·기능 차단일 때만. 단순 강한 불만은 S2)
확신 없는 건은 '기타'로 두고 id 끝에 ★ 달아서 나한테 넘기고, 억지로 끼워 맞추지 마.
표 두 개만 줘 — 표A(건별): id·토픽·감정·심각도 / 표B(토픽별): 토픽·건수·부정%·대표원문1개·S1포함여부.
이 프롬프트에서 분류 결과를 좌우하는 건 토픽 목록도 감정 척도도 아닌, 라벨을 고정하고 형식을 못 박고 탈출구를 열어 둔 세 가지 통제 장치다. 토픽을 자유 생성하게 두면 LLM이 "전반적 불만족", "게임성 개선 요청" 같은 우선순위로 쓸 수 없는 뭉뚱그린 라벨을 만든다. 허용 라벨 목록(규칙 1), 심각도 정의(규칙 3 — S1의 좁은 정의가 핵심), 출력 형식(표 두 개), 그리고 "확신 없으면 ★ 달고 사람에게"(규칙 5). 이 넷이 빠지면 표가 우선순위 도구가 아니라 또 하나의 읽을거리로 전락한다. 이 라벨 목록 자체가 §15.1.5에서 말한 피드백 L4 레이어의 카테고리 체계다 — 라벨이 고정돼 있어야 분기마다 같은 축으로 추이를 비교할 수 있다.
[표A] 건별 분류 (발췌)
id | 토픽 | 감정 | 심각도
fb_0001 | 강화/확률 | 부정 | S2
fb_0002 | 서버/성능 | 부정 | S2
fb_0003 | 결제/환불 | 부정 | S1
fb_0004 | 신규콘텐츠요청 | 중립 | S3
fb_0005 | 칭찬/응원 | 긍정 | S4
fb_0006 | 밸런스 | 부정 | S1
fb_0007 | 튜토리얼/온보딩 | 부정 | S1
fb_0042★ | 기타 | 중립 | S3
...
[표B] 토픽별 집계 (전체 100건)
토픽 | 건수 | 부정% | 대표원문 | S1포함
서버/성능 | 24 | 92% | "길드전 때마다 튕김" | 예(일부 재현)
강화/확률 | 21 | 95% | "12강에서 50번 깨졌어요" | 아니오
밸런스 | 14 | 86% | "흑랑 풀템인데 한방" | 예
신규콘텐츠요청 | 12 | 8% | "궁수 언제 나오나요" | 아니오
튜토리얼/온보딩 | 9 | 78% | "5단계 버튼이 안눌림(갤럭시 A)" | 예
결제/환불 | 8 | 100% | "다이아가 안들어왔습니다" | 예
UI/조작 | 6 | 67% | "스킬 버튼이 작아서 오터치" | 아니오
칭찬/응원 | 4 | 0% | "운영진 소통 좋네요" | 아니오
기타 | 2 | - | (fb_0042★, fb_0067★ 사람 판정 대기) | -
전체 감정: 부정 71 / 중립 18 / 긍정 11
'기타' 2건(8건 미만) → 새 토픽 제안 불필요
확신 미달 ★표시 2건: fb_0042, fb_0067 (사람 판정 요청)
이 출력에서 가장 가치 있는 부분은 표가 아니라 맨 아래 두 줄, "★표시 2건"이다. LLM이 자기가 못 묶는 걸 신고하고 사람에게 넘긴 자리다. §6.2에서 NPC '그렘'에 AI가 스스로 애매 표시를 달았던 것과 같은 설계다. 좋은 프롬프트는 AI가 "이건 확신 못 합니다"라고 말할 수 있게 만든다.
이 출력을 그대로 받으면 안 된다. 한 군데가 실제로 걸렸다.
강화/확률 토픽 21건이 전부 S2(불만)로 분류됐다. 그런데 그중 fb_0001은 "환불해주세요"가 붙어 있다. LLM은 이걸 "강한 불만(S2)"으로만 봤다. 여기서 사람이 개입한다. 강화 확률에 대한 불만은 — 데이터로 확률이 명세대로 작동하는 한 — S1 사고가 아니다. 명세대로 굴러가는 확률에 대한 불만은 디자인·체감 문제이지 버그가 아니기 때문이다. LLM의 S2 판정이 맞다. 다만 "환불 요구"라는 신호는 결제 토픽으로 cross-link해서 CS가 따로 봐야 한다. LLM은 토픽을 단일 라벨로만 달았고, 한 건이 두 토픽에 걸치는 경우를 놓쳤다.
그래서 재요청한다.
규칙 추가: 한 건이 두 토픽에 걸치면(예: 강화 불만 + 환불 요구) 주 토픽 외에
'cross' 칸에 보조 토픽을 적어라. 표A에 cross 칸을 추가해 다시 출력하라.
단, 강화 확률 불만 자체는 데이터상 확률이 명세대로면 S1이 아니라 S2로 유지하라.
이 한 번의 왕복으로 끝난다. LLM은 fb_0001에 토픽=강화/확률, cross=결제/환불, 심각도=S2로 다시 답했고, ★표시 2건은 사람이 직접 읽어 fb_0042를 UI/조작, fb_0067을 튜토리얼/온보딩으로 재배치했다. 100건을 사람이 처음부터 읽고 분류하면 한나절, LLM 초안 + 사람 검수 + 1회 왕복이면 한 시간 안쪽이다(저자 추정, 미검증 가설 — 정확한 절약값은 피드백 건수·채널 수에 따라 달라지므로 절대 시간보다 "처음부터 손으로"와 "초안+검수"의 구조 차이로 읽는 게 맞다).
여기서 결정적인 선을 긋는다. 위 표B는 "어느 토픽이 몇 건, 얼마나 부정적인가"까지만 말한다. "그래서 이번 주에 뭘 먼저 고칠 것인가"는 LLM이 줄 수 없다. 그건 비용·일정·게임 비전이 얽힌 결정이고, 그 결정의 책임은 디렉터에게 있다.
같은 표를 놓고 두 운영팀이 정반대 결정을 내릴 수 있다. 건수만 보면 서버/성능(24건)과 강화/확률(21건)이 1·2위다. 그런데 우선순위는 건수 순서와 다르게 간다. 이유는 심각도와 가역성이다.
flowchart TB
A["피드백 100건
(4채널 정규화 jsonl)"] --> B["LLM 클러스터링
토픽·감정·심각도 채점"]
B --> C{"사람 검수
★건·cross·오분류"}
C -->|재요청| B
C -->|확정| D["토픽별 집계표
건수·부정%·S1포함"]
D --> E["우선순위 결정
(디렉터 — LLM 불가)"]
E --> F1["S1 사고: 즉시 핫픽스
결제·튜토리얼 차단"]
E --> F2["S2 불만: 데이터 확인 후
디자인 판단"]
E --> F3["S3 요청: 분기 백로그
voice 슬롯 박제"]
F1 --> G["회신 사이클
(§15.3.4)"]
F2 --> G
F3 --> G
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
class A,D data;
class B ai;
class C,E human;
이 흐름에서 사람의 손이 닿는 곳은 두 군데뿐이다. 가운데 검수 게이트(★·cross·오분류 판정)와, 맨 아래 우선순위 결정. 그 사이의 지루한 100건 분류는 LLM이 돌린다. 그리고 우선순위 결정의 실제 논리는 건수가 아니라 다음 세 축이다.
| 토픽 | 건수 | 우선순위 판단 (디렉터의 자리) |
|---|---|---|
| 결제/환불 (S1) | 8 | 1순위. 건수는 적지만 기능 차단 + 비가역(돈). 24h 핫픽스 |
| 튜토리얼/온보딩 (S1) | 9 | 2순위. 신규 사용자 이탈 직결. 특정 기기 재현 → 패치 |
| 서버/성능 | 24 | 3순위. 최다 건수지만 인프라 작업 = 일정 김. 핫픽스 불가, 차주 |
| 강화/확률 (S2) | 21 | 유지. 데이터상 명세대로면 버그 아님. 디자인 결정으로 별도 검토 |
| 신규콘텐츠요청 | 12 | 백로그. 부정 8%(=긍정적 기대). 분기 voice 슬롯으로 박제 |
건수 1위인 서버/성능이 우선순위 3위로 내려간 이유는 핫픽스로 못 고치는 인프라 작업이기 때문이고, 건수 6위인 결제/환불이 1위로 올라간 이유는 돈이 걸린 비가역 사고이기 때문이다. 이 재배열을 LLM은 못 한다. LLM은 "결제 8건, 부정 100%"라는 사실까지만 준다. 그게 1순위라는 결정은 비용·법적 리스크·게임 비전을 아는 사람의 몫이다. 이것이 §15.1.5에서 말한 "AI가 분류·후보를 만들고, 사람은 채택과 비전 결정에 집중한다"의 피드백 분야 실제 모습이다.
우선순위가 정해지면 사용자에게 회신한다. 라이브 운영에서 회신 부재가 신뢰의 가장 큰 손상 자리다. 답할 게 없어도 "검토 중"이 무응답보다 낫다. 회신 초안도 LLM이 토픽별로 뽑을 수 있다.
[회신 초안 — LLM 출력, 토픽별]
- 결제/환불(S1): "다이아 미지급 건 확인했습니다. 주문번호 기준 24시간 내 소급 지급하며, 개별 회신드립니다."
- 서버/성능: "길드전 시 발생하는 튕김을 재현 확인 중입니다. 차주 점검에서 우선 처리 예정이며, 진행 상황을 공지로 안내하겠습니다."
- 강화/확률: "강화 확률은 명세 표기 그대로 적용되고 있음을 데이터로 확인했습니다. 다만 체감 난이도에 대한 의견은 별도로 검토 중입니다."
- 신규콘텐츠요청(궁수): "신규 직업은 로드맵에 있으며, 일정이 확정되는 대로 가장 먼저 공지하겠습니다."
여기서 §6.2와 결정적으로 다른 점이 하나 있다. 회신 발송은 비가역 단계다. 도시 NPC는 폐기하고 다시 만들면 그만이지만, 사용자가 한 번 본 공지·회신 텍스트는 되돌릴 수 없다. "24시간 내 지급"이라고 자동 발송했는데 실제로는 사흘 걸리면, 그 약속은 커뮤니티에 비가역 흔적으로 남는다. 그래서 §15.1.4의 비가역 단계 원칙이 피드백 분야에서는 다른 분야보다 더 무겁게 작동한다. 자동 회신 초안은 LLM이 만들되, CS 검수 게이트를 통과하기 전에는 한 글자도 자동 발송하지 않는다. 검수자는 일정 약속(24h·차주)이 실제 작업 일정과 맞는지, 예민한 사례(법적 분쟁·환불 분쟁)가 자동 발송 풀에 섞이지 않았는지만 본다. lint가 못 잡는 판단을 사람이 맡는 자리다.
| 단계 | 가역성 | 누가 |
|---|---|---|
| 피드백 클러스터링·감정 채점 | 가역 (재실행 자유) | LLM |
| 토픽 검수·우선순위 결정 | 가역 (확정 전) | 사람 (디렉터) |
| 회신 초안 생성 | 가역 (폐기·재작성) | LLM |
| 회신 발송·공지 게시 | 비가역 (사용자 인식) | 사람 (CS 검수 후) |
같은 피드백이 매분기 다른 결정으로 흔들리지 않으려면, 클러스터링 결과를 분기 회고의 고정 입력 슬롯으로 박제해야 한다. 즉흥적으로 "요즘 강화 불만 많던데"가 아니라, 분기마다 같은 라벨 축으로 집계된 표가 회고 테이블 안에 들어간다. §15.3.2에서 라벨을 자유 생성 금지하고 허용 목록으로 고정한 이유가 여기서 회수된다.
2026 Q2 사용자 voice (LLM 자동 집계, 분기 누적)
채널 4종 누적 약 5,000건 클러스터링 (건수는 분기 실집계 — 가공 아님) 부정 상위 토픽: 강화/확률 > 서버/성능 > 밸런스 > 결제/환불 요청 상위 토픽: 신규직업 > 신규사냥터 > 길드시스템 > UI개선 분기 감정 추이: Q1 부정 68% → Q2 부정 71% (소폭 악화 — 강화 토픽 견인)
이 표가 분기 결정의 입력이 된다. 결정 자체는 디렉터의 몫이고, 입력은 사용자의 몫이다. 분기 추이("Q1 68% → Q2 71%")는 방향으로만 읽는다. 단일 분기 절대값이 아니라 같은 라벨 축의 변화 방향이 신호다. 부정%가 올랐다면 "어느 토픽이 끌어올렸는가"를 되짚어 다음 분기 우선순위로 연결한다. 이 분기 보고서 초안 자체도 LLM이 자연어로 뽑고, 사람은 결정 코멘트만 단다 — §15.1.5에서 말한 분기 보고서 자동 초안의 실제 자리다.
라이브 운영 챕터는 "피드백 사이클을 도입했더니 NPS가 20에서 45로 올랐다" 같은 표를 넣고 싶은 유혹이 크다. 저자는 그 인과를 측정한 적이 없으므로 쓰지 않는다. 이 책의 원칙은 세 가지 중 하나다.
첫째, 실집계 건수는 그대로 쓴다. §15.3.2의 토픽별 건수(서버 24·강화 21·결제 8)와 §15.3.5의 분기 누적은 분류 결과를 한 건씩 센 값이지, 보기 좋으라고 맞춰 둔 비율이 아니다.
둘째, 추정은 추정이라고 쓴다. "100건 분류가 한나절→한 시간"(§15.3.2), "포럼 감정이 부정 쪽으로 편향"(§15.3.1)은 저자의 경험·관찰 기반 추정이며 미검증 가설이다. 절대값을 외우지 말고 방향(피드백량은 항상 인원을 이긴다, 자발적 글은 불만 쪽으로 기운다)으로 읽으면 된다.
셋째, 측정 가능한 것만 지표로 약속한다. 피드백 사이클이 실제로 측정 가능한 것은 결과 만족도(NPS)가 아니라 과정 지표다 — 미분류 피드백 잔량(목표 0), S1 사고 발견→핫픽스 리드타임, 회신 응답 시간, '기타' 토픽 비율(허용 라벨이 현실을 못 담으면 '기타'가 부풀어 오른다). 이 넷은 회의에서 "느낌"이 아니라 숫자로 말할 수 있다.
혼자라면 이만큼만: CS 시스템도 데이터셋도 필요 없습니다. 본인 게임(또는 좋아하는 게임)의 스토어 리뷰·커뮤니티 글을 손으로 20~30건만 복사해 jsonl로 만들고(
{"id":..., "src":..., "text":...}), §15.3.2의 프롬프트를 그대로 붙여 한 번 돌려 보세요. 나온 표B에서 "건수 1위 토픽"과 "당신이 먼저 고치고 싶은 토픽"이 다른 한 건을 찾아, 왜 다른지 한 줄로 적어 보면 — 우선순위가 왜 LLM의 일이 아니라 사람의 일인지가 몸으로 들어옵니다.
팀이라면 다음 한 단계로 시작하세요. 4채널 피드백을 한 줄 한 레코드 jsonl로 모으는 추출 스크립트와, §15.3.2의 허용 토픽 라벨 목록을 먼저 고정합니다. 라벨이 고정돼 있어야 LLM 분류든 사람 분류든 같은 축으로 재고, 분기 추이를 비교할 수 있습니다. 자동 회신은 그 다음입니다 — 회신은 비가역이라, CS 검수 게이트 없이는 절대 자동 발송에 연결하지 않습니다.
목요일 오후 4시. 전투 TF 회의가 끝나고 7명이 각자 자리로 흩어졌다. 화이트보드에는 글로벌 쿨다운을 0.8초에서 0.5초로 내릴지 말지에 대한 흔적이 남아 있다. 밸런스 시니어는 "내 시뮬에선 0.5가 맞다"고 했고, 코드 리드는 "0.5면 서버 틱이 못 따라간다"고 했다. UI 디자이너는 "둘 다 모르겠고 쿨다운 게이지 폭이 너무 좁아진다"고 했다.
세 사람 다 맞는 말이다. 그리고 세 사람 다 자기 분야의 문서에 자기 결론을 적기 시작하면, 다음 주에 이 세 문서는 서로 충돌한다. 밸런스 시트는 0.5로, 코드 스펙은 0.8로, UI 가이드는 0.6으로 적혀 있는 상태. 누가 봐도 어느 게 정본인지 모른다.
전투 TF의 존재 이유는 바로 이 충돌을 한 자리에서 흡수하는 것이다. 그리고 그 흡수의 결과물 — 단 하나의 결정 — 만 정본 문서로 올라가야 한다. 나머지 토론의 잔해는 격리된 작업공간 안에서 끝나야 한다. 이 챕터는 그 격리와 흡수의 메커니즘을 다룬다.
전투 시스템 대개편은 한 직군으로 끝나지 않는다. 글로벌 쿨다운 하나를 건드리면 밸런스(수치), 코드(서버 틱), UI(게이지 표현), 애니메이션(모션 길이), 사운드(타격감)가 동시에 흔들린다. 이런 안건을 분야별로 따로 돌리면 결정이 2~4주씩 늘어지고, 결정이 나도 분야 간에 어긋난다.
TF(TaskForce)는 이 어긋남을 막기 위해 여러 직군을 한 작업공간에 잠시 모으는 단위다. 핵심은 "잠시"와 "격리"다. 회사의 정본 문서 체계 안에 TF의 토론을 그대로 흘려보내면, 미검증 토론·기각된 안·실험 중인 수치가 정본을 오염시킨다. 그래서 우리는 SVN 안에 95_ 번호로 시작하는 격리 작업공간을 만든다.
95_BattleTF. 95번대 번호는 단기 TF 작업공간을 뜻하는 약속이다. 일반 정본 docs는 10번대·20번대 번호를 쓰고, 90번대는 "임시·격리·종료 예정"의 신호다. 폴더 번호만 보고도 "여긴 정본이 아니다, 여기서 본 수치를 인용하지 마라"가 즉시 전달된다.
격리의 규칙은 단순하다.
95_BattleTF 안에서만 산다.TF_결정사항_요약.md 단 하나만 정본 docs로 승격한다.95_BattleTF/archive/로 내려 보관만 한다.영구 부서로 굳어진 TF가 위험한 이유가 여기 있다. 격리가 풀리면 TF 작업공간의 미검증 수치가 정본처럼 인용되기 시작하고, 매 분기 같은 결정이 다른 자리에서 다시 깨진다.
전투 TF의 한 사이클은 격리된 공간을 열고, 그 안에서 토론·실험·결정을 쌓고, 종료 시 결정만 정본으로 흡수시키는 구조다.
flowchart TD
A["전투 안건 발생
(글로벌 쿨다운 0.8→0.5?)"] --> B["95_BattleTF 격리 공간 개설
(SVN 95_ 번호대)"]
B --> C["격리 내부 산출물 누적
회의록·실험시트·기각안·날것메모"]
C --> D{"결정 도출?"}
D -->|아직| C
D -->|확정| E["TF_결정사항_요약.md 갱신
(격리 공간 내부)"]
E --> F{"TF 종료?"}
F -->|존속| C
F -->|종료| G["TF_결정사항_요약.md
1개만 정본 docs로 승격"]
G --> H["나머지 전체
95_BattleTF/archive/ 로 강등"]
G --> I["아트팀엔 html만 전달
(md 원본은 비공유)"]
H --> J["격리 공간 폐쇄"]
style B fill:#fff3cd,stroke:#d39e00
style G fill:#d4edda,stroke:#28a745
style H fill:#f8d7da,stroke:#dc3545
왼쪽 위에서 안건이 들어오고, 노란색 격리 공간 안에서 모든 잡음이 처리되며, 초록색 한 칸 — 결정 요약 — 만 정본으로 빠져나간다. 붉은색은 강등이다. 이 그림 한 장이 95번대 작업공간 운영의 전부다.
TF 종료 시점에 가장 손이 많이 가는 일은 한 분기치 회의록·실험 시트에서 "정본으로 올릴 결정만" 추려 내는 것이다. 토론은 길고, 기각된 안과 확정된 안이 섞여 있고, 같은 수치가 회의마다 조금씩 다르게 적혀 있다. 이걸 사람이 손으로 정리하면 종료 작업에만 하루가 간다.
아래는 실제로 돌린 프롬프트와 Claude의 날것 출력, 그리고 내가 그걸 어떻게 검증·거부·재요청했는지의 전 과정이다. 요약 없이 그대로 싣는다.
아래 95_BattleTF 회의록 6건에서 정본으로 올릴 확정 결정만 뽑아
TF_결정사항_요약.md 초안 만들어 줘. TF 곧 종료돼.
확정된 것만 (기각·실험중·"다음에 보자"는 빼고), 각 결정은
결정ID·주제·확정값·근거(데이터 출처)·결정자·확정일 형식으로.
같은 주제가 회의마다 값이 바뀌었으면 최종값만 남기고 이전 건 버려.
확정인지 애매하면 버리지 말고 [검토 필요]로 따로 빼고, 근거 출처가 없으면 [근거 미기재]로 표시해.
회의록:
--- 회의록 2026-W14-mon ---
안건1: 글로벌 쿨다운 0.8→0.5 검토. 밸런스 시뮬(sim_gcd_0407.xlsx)상
0.5가 DPS 분산 -12%. 코드 리드: 0.5는 서버틱(33ms) 한계로 보류 요청.
결정: 0.6으로 1차 합의(잠정). 다음 회의까지 코드측 0.6 부하 테스트.
안건2: 회피 i-frame 12f→10f. 애니 디렉터 모션 8f라 10f면 시각 단절.
결정: 12f 유지 확정. 결정자 기획리드.
... (중략된 것 없음, 실제로는 6개 회의록 전문이 들어감) ...
--- 회의록 2026-W16-thu ---
안건1: 글로벌 쿨다운. 0.6 부하 테스트 통과(서버틱 여유 18%).
결정: 0.6 확정. 결정자 기획리드. 근거 sim_gcd_0407.xlsx + load_0415.log.
안건3: 처치 보상 코인 1.2배. 밸런스 제안, 근거 없음. 보류.
# TF_결정사항_요약.md (초안)
## 확정 결정
### D-GCD-01 | 글로벌 쿨다운
- 확정값: 0.6초 (기존 0.8초)
- 근거: sim_gcd_0407.xlsx (DPS 분산 영향), load_0415.log (서버틱 부하 18% 여유)
- 결정자: 기획 리드
- 확정일: 2026-W16-thu
- 비고: W14에서 0.5 제안 있었으나 서버틱 한계로 0.6으로 수렴. 0.5 안은 폐기.
### D-IFRAME-01 | 회피 i-frame
- 확정값: 12프레임 유지
- 근거: [근거 미기재] — 애니 모션 8f 시각 단절이라는 정성 판단만 기록됨
- 결정자: 기획 리드
- 확정일: 2026-W14-mon
## [검토 필요]
### R-COIN-01 | 처치 보상 코인 1.2배
- 상태: 보류 (밸런스 제안, 근거 데이터 없음)
- 판단: 근거 미제출로 확정 불가. TF 종료 후 밸런스팀 단독 안건으로 이관 권고.
세 항목을 하나씩 봤다.
D-GCD-01은 그대로 통과. W14의 0.5/0.6 잠정안을 버리고 W16의 0.6 확정만 남긴 게 정확하다. 규칙 3을 제대로 지켰다. 근거 두 출처를 묶은 것도 맞다.
D-IFRAME-01은 거부했다. Claude는 이걸 "확정"으로 분류했는데, 근거가 [근거 미기재]다. 정성 판단(시각 단절)만으로 확정 결정에 올리면, 정본을 본 다른 사람이 "왜 12f인지" 근거를 못 찾는다. 이건 규칙 1·5의 충돌 케이스다 — Claude는 결정자가 확정했으니 "확정"으로 봤지만, 나는 "근거 없는 확정은 정본에 못 올린다"는 우리 docs 정책을 적용해야 했다. 이 정책은 회의록에 안 적혀 있으니 Claude가 알 리 없다.
R-COIN-01은 분류는 맞는데 처방이 과했다. "밸런스팀 단독 안건으로 이관 권고"는 Claude가 지어낸 절차다. 우리 회사엔 그런 이관 트랙이 없다. 분류(검토 필요)는 받고, 처방 문장은 버렸다.
검증 반영해서 다시 만들어 줘. D-GCD-01은 그대로 두고,
D-IFRAME-01은 근거가 정성 판단뿐이니 확정에서 빼서 [근거 보강 후 승격]으로 옮겨.
R-COIN-01은 분류는 두되 "이관 권고" 같은 처방 문장은 빼 — 우리 회사 절차 너는 모르니 상태랑 사실만 적어.
그리고 회의록에 없는 절차·트랙·팀 이름 지어내지 말고, 모르면 [기획 리드 판단 필요]로 비워 둬.
// (의도: D-IFRAME-01 정본 승격 조건 = 애니 디렉터 모션 프레임 측정 데이터 회의록 첨부)
# TF_결정사항_요약.md
## 확정 결정 (정본 승격 대상)
### D-GCD-01 | 글로벌 쿨다운
- 확정값: 0.6초 (기존 0.8초)
- 근거: sim_gcd_0407.xlsx, load_0415.log (서버틱 부하 18% 여유)
- 결정자: 기획 리드 / 확정일: 2026-W16-thu
- 비고: 0.5 안은 서버틱 한계로 폐기.
## [근거 보강 후 승격]
### D-IFRAME-01 | 회피 i-frame 12f 유지
- 정성 판단(애니 모션 8f 대비 시각 단절)만 기록됨.
- 승격 조건: 모션 프레임 측정 데이터 회의록 첨부.
## [기획 리드 판단 필요]
### R-COIN-01 | 처치 보상 코인 1.2배
- 상태: 보류. 밸런스 제안, 근거 데이터 없음. 확정값 없음.
이 확정본에서 "확정 결정" 섹션 — D-GCD-01 한 건 — 만 정본 docs로 승격됐다. 나머지 두 섹션은 archive/로 내려갔다. 한 분기치 6개 회의록이 정본 한 줄로 흡수된 것이다.
여기서 AI가 한 일과 못 한 일이 갈린다. AI는 6개 회의록을 가로질러 같은 주제의 값 변화를 추적하고, 기각안을 분리하고, 근거 누락을 표시했다 — 회의록 여섯 건을 한 줄 한 줄 대조하는 이 단순 반복이야말로 사람 손에서 빠지기 쉬운 대목이다. 하지만 "근거 없는 확정은 정본에 못 올린다"는 정책 적용, "이관 트랙이 없다"는 회사 사실, "확정/보류"의 최종 판단은 전부 사람이 했다. AI 문단을 지우면 추출·정렬 노동이 사라지지만, 정본에 무엇이 올라갈지의 결정은 사람 손에 남는다.
TF로 들어오는 안건이 다 내부에서 생기는 건 아니다. 퍼블리셔·아트 외주·사업팀에서 "전투 관련으로 이거 해 달라"는 요청이 들어온다. 이걸 무분별하게 TF 안건으로 받으면 TF가 외부 민원 창구가 된다.
그래서 외부 요청은 받는 즉시 세 갈래로 분류한다. 전투 결정이 필요한 것만 95_BattleTF로 투입하고, 한 분야로 끝날 일은 담당자가 단독 처리하며, 범위 밖·근거 부족은 사유를 적어 회신·보류한다. TF로 들어오는 건 첫 갈래뿐 — 이게 TF가 민원 창구로 변질되는 걸 막는 첫 방어선이다. 분류 자체는 사람의 판단이지만, 들어온 요청 텍스트를 읽고 "이건 몇 분야가 걸리나"를 1차로 태깅해 두는 정도는 AI가 먼저 훑어 줘도 된다.
이 삼각 분류(request-triangulate)의 판정 순서·워크드·트랙별 후속 처리는 다음 챕터 16.2가 전담한다. 여기서는 "TF는 첫 갈래만 받는다"는 입구 규칙만 짚어 둔다.
TF 결정이 정본으로 승격되면 그걸 관련 팀에 공유한다. 여기서 한 가지 비대칭이 있다. 아트팀에는 마크다운 원본(.md)을 주지 않고, 렌더링된 html만 전달한다.
이유는 단순하다. 아트팀은 결정의 결과만 알면 된다. "쿨다운 게이지는 0.6초 기준으로 폭을 다시 잡아 달라" — 이 한 줄이 그들에게 필요한 전부다. md 원본에는 결정ID 체계, atom 참조, 기각된 0.5 안의 흔적, 근거 데이터 파일명이 들어 있다. 이건 기획·코드가 공유하는 작업 언어이지, 아트가 학습해야 할 것이 아니다.
md를 그대로 주면 아트팀은 두 가지 비용을 치른다. 첫째, 자기와 무관한 표기 체계를 해석하느라 시간을 쓴다. 둘째, 미검증·기각 정보를 결정으로 오해할 수 있다. html은 이 둘을 막는다 — 깔끔하게 렌더된 결정 결과만 보이고, 내부 표기는 빌드 과정에서 걸러진다.
원칙으로 적으면: 작업 언어(md)는 그 언어를 쓰는 직군 안에서만 돌고, 그 바깥으로는 결과물(html)만 나간다. TF 작업공간 격리(95번대)와 같은 철학이다. 안에서 쓰는 날것은 안에 두고, 밖으로는 흡수된 결과만 내보낸다.
격리·흡수 메커니즘이 돌아가려면 그 아래 다섯 가지 운영 원칙이 깔려 있어야 한다. 하나라도 빠지면 TF는 토론장으로 무너진다.
[근거 미기재]를 자동 표시하게 한 것도 이 원칙의 연장이다.다섯 원칙이 묶여 작동할 때, 격리된 95번대 공간이 토론장이 아니라 결정 공장이 된다.
TF 운영 중기 이후 반복되는 함정과 처방을 정리한다.
| 함정 | 증상 | 처방 |
|---|---|---|
| 회의장으로 변질 | 의견 교환만, 결정 없음 | 매 회의 결정 슬롯 N개 강제 |
| 권한 침범 | TF가 타 분야 결정에 개입 | 결정권 표 명확화 |
| 멤버 부담 과다 | TF 5~6개 중복 참여로 본업 침식 | TF 참여 총 주 8시간 한도 |
| 영구화 | 해체 없이 같은 회의 반복 | 분기 재평가 |
| 격리 누수 | 95번대 미검증 수치가 정본처럼 인용됨 | 정본 승격은 결정 요약 1건만 |
| 외부 단절 | 결정을 외부에 미공유 | 정본 승격 + html 전달 |
격리 누수가 가장 조용하고 위험하다. 폴더 번호 약속이 무너지면 모든 게 무너진다.
저자의 프로젝트 A 운영 기록에서 방향과 비율만 옮긴다. 아래 수치는 절대값이 아니라 TF 부재 대비 운영 시의 변화 방향이다 — 절대 주기는 팀 규모·빌드 주기에 따라 다르다(저자 환경 기준의 관찰).
| 항목 | TF 부재 | TF 운영 | 방향 |
|---|---|---|---|
| 전투 결정 1건 사이클 | 분야별 따로, 수 주 | 며칠 단위 | 단축 |
| 결정 후 분야 간 충돌 | 분기 다수 | 분기 소수 | 감소 |
| 게임 디렉터 에스컬레이션 | 주 다건 | 주 1~2건 | 감소 |
| 분야 간 정보 공유 | 산발적 | 회의록·정본 승격으로 고정 | 체계화 |
가장 크게 회수되는 건 게임 디렉터의 시간이다. 분야 간 결정을 TF가 격리 공간 안에서 흡수해 버리니, 디렉터 자리까지 올라오는 충돌이 줄어든다. TF는 결국 "디렉터가 일일이 중재하던 분야 간 합의"를 한 작업공간으로 내려받아 처리하는 장치다.
게임 밖 적용. 격리된 작업공간에서 결정만 정본으로 흡수한다는 원리는 게임과 무관한 모든 부서 횡단 프로젝트에 그대로 적용된다. 예컨대 마케팅·법무·영업이 함께 신규 약관 개정을 논의하는 TF를 떠올려 보세요. 회의록·검토 의견·기각된 문구 초안은 공유 드라이브의 임시 폴더(
95_약관TF같은 격리 공간)에 두고, TF가 끝나면최종_확정문구.docx한 건만 사내 정본 문서함으로 올리고 나머지는 아카이브로 내립니다. 이렇게 하면 6개월 뒤 "이 조항 왜 이렇게 정했더라"를 물을 때 미확정 초안이 정본인 척 끼어드는 사고를 막을 수 있다.
setup
- SVN(또는 폴더)에 95_BattleTF/ 격리 공간을 만들고, 한 분기치 회의록을 그 안에 모으세요.
- 95_BattleTF/archive/를 미리 만들어 두세요 (강등 대상이 갈 곳).
prompt - 이 챕터 1차 프롬프트를 회의록 전문과 함께 붙이세요. 핵심 규칙: ① 확정 결정만 ② 같은 주제는 최종값만 ③ 애매하면 버리지 말고 분리 표기 ④ 근거 없으면 명시 ⑤ 회사 절차·팀명을 지어내지 말 것.
verify
- 출력의 "확정" 분류를 한 건씩 보세요. 근거가 정성 판단뿐인 항목은 "확정"에서 끌어내립니다(정본 승격 정책 적용).
- AI가 만든 처방 문장(이관·트랙·권고)에 실재하지 않는 절차가 끼어 있는지 확인하고 지우세요.
- "확정 결정" 섹션만 정본 docs로 복사하고, 나머지는 archive/로 내립니다.
혼자 작업하는 1인 개발자에게도 격리·흡수는 그대로 유효합니다. "TF"를 "내 머릿속 여러 역할"로 바꾸면 됩니다.
95_temp_결정/ 같은 임시 폴더를 파고, 거기서 시뮬·메모·기각안을 다 쏟아내세요.결정요약.md 한 장만 본 작업 폴더로 옮기고, 임시 폴더는 통째로 archive/로 내리세요.격리 공간이 있으면 "이 수치가 확정인지 실험 중인지"를 폴더 위치만으로 구분할 수 있습니다. 혼자여도 미래의 나에게 같은 혼란을 물려주지 않는 가장 싼 방법입니다.
화요일 오전, 메신저가 거의 동시에 세 번 울렸다.
아트 리드: "전투 이펙트 컬러, 지금 톤이 너무 칙칙한데 더 화사하게 가도 될까요?"
QA 리드: "길드 출석 보상이 두 번 들어오는 케이스 있습니다. 재현 영상 첨부."
퍼블리셔 담당: "동남아 빌드에 이슬람 문화권 가이드라인 반영 부탁드립니다. 다음 분기 심사 전까지요."
세 메시지의 글자 수는 비슷했다. 그런데 하나는 30분이면 끝날 일이고, 하나는 당장 코드 리드를 붙잡아야 할 사고였고, 하나는 분기 단위 계획에 끼워 넣어야 할 외부 일정이었다. 같은 받은편지함에 떨어졌다는 이유로 같은 무게로 다루면, 30분짜리에 한나절을 쓰고 정작 사고는 저녁까지 방치된다.
기획자에게 들어오는 요청은 직군만큼이나 결이 다르다. 문제는 그것들이 전부 "한 줄짜리 메시지"라는 동일한 형태로 도착한다는 점이다. 이 챕터는 그 한 줄들을 받자마자 세 개의 트랙으로 갈라내는 작업을 다룬다. 트랙이 갈리는 순간, 무엇을 지금 멈추고 무엇을 나중으로 미룰지가 결정된다.
기획자는 코드도, 아트도, 사운드도 직접 만들지 않는다. 명세를 쓰고, 의도를 전달하고, 결과를 검증할 뿐이다. 모든 산출물은 다른 직군의 손을 거쳐 나온다. 그래서 협업의 질이 기획 결과물의 질을 그대로 결정한다.
저자가 디렉터로 일하는 프로젝트 A(모바일 우선 MMORPG, 중규모(10~50인) 팀)에서 기획자가 일상적으로 협업하는 직군을 펼쳐 보면 이렇다.
일곱 직군과 매일에서 분기 단위로 맞물린다. 기획자가 책상에서 보내는 시간의 40~60%가 이 협업에 들어간다. 본업(설계)에 쓰는 시간이 나머지 절반인 셈이다. 그렇다면 협업 시간을 줄이는 일이 곧 본업 시간을 늘리는 일이다. 그리고 협업 시간을 잡아먹는 가장 큰 원인은, 들어온 요청을 분류하지 못해 엉뚱한 곳에 에너지를 쏟는 데 있다.
앞의 세 메시지로 돌아가 보자. 표면적으로는 모두 "~해주세요"다. 하지만 그 안에는 세 가지 다른 성격이 숨어 있다.
이 세 결을 저자는 각각 한 단어로 부른다. 합의(align), 결함(defect), 일정(schedule). 들어온 요청을 이 셋 중 하나로 먼저 밀어 넣는 작업, 이것을 프로젝트 A에서는 request-triangulate라는 이름의 워크플로로 굳혀 두었다. 삼각측량(triangulate)이라는 이름은, 한 점(요청)을 세 기준점(직군 성격·긴급도·외부 의존성)으로 둘러싸 위치를 특정한다는 뜻에서 붙였다.
분류 흐름은 다음과 같다.
flowchart TD
A[외부 요청 1건 도착] --> B{외부 마감/계약에
묶여 있는가?}
B -- 예 --> S[Track-S: 일정 schedule]
B -- 아니오 --> C{사용자 영향이 있는
버그/결함인가?}
C -- 예 --> D[Track-D: 결함 defect]
C -- 아니오 --> E{취향·의도의
합의 문제인가?}
E -- 예 --> F[Track-A: 합의 align]
E -- 아니오 --> G[보류함:
정보 더 요청]
S --> S1[분기 로드맵에 편입
충분한 리드타임 확보]
D --> D1[우선순위 P0~P2 판정
코드 리드 즉시 연결]
F --> F1[의도까지만 전달
표현은 해당 직군 위임]
style S fill:#fde2c4,stroke:#c98a3a
style D fill:#f6c6c6,stroke:#c25151
style F fill:#c9e4d0,stroke:#4f9d6a
style G fill:#e0e0e0,stroke:#888
질문의 순서가 핵심이다. 일정 의존성을 가장 먼저 묻는 이유는, 외부 마감이 걸린 일은 내부 판단보다 리드타임이 우선이기 때문이다. 분기 심사가 3주 남은 일을 "나중에 합의하면 되지"로 분류하면, 합의가 끝났을 땐 이미 마감이 코앞이다. 결함을 두 번째로 두는 이유는, 사용자에게 이미 영향을 주고 있는 일은 취향 논의보다 항상 앞서기 때문이다. 합의는 마지막이다. 급하지 않고, 외부에 묶이지 않고, 사용자를 해치지 않는 일이라야 비로소 "천천히 합의하자"가 성립한다.
세 질문 모두 "아니오"라면 그건 분류 실패가 아니라 정보 부족이다. 그럴 땐 트랙을 억지로 정하지 말고 보류함에 넣고 되묻는다. "이게 다음 빌드에 꼭 들어가야 하나요, 아니면 검토만 해두면 될까요?" 같은 한 문장이 대개 트랙을 결정해 준다.
이 분류를 머릿속에서만 하면 바쁜 날엔 무너진다. 그래서 저자는 들어온 요청 묶음을 통째로 AI에게 던져 1차 분류를 받고, 그 결과를 검증·수정하는 방식으로 운영한다. 아래는 실제로 돌렸던 한 회차를 거의 그대로 옮긴 것이다. 요약하지 않고, AI가 틀린 부분과 저자가 거부한 부분까지 남긴다.
아래 외부 요청들을 세 트랙으로 분류해 줘 — Track-S(일정: 외부 마감·계약·심사에 묶인 것),
Track-D(결함: 사용자 영향 버그, P0~P2 판정), Track-A(합의: 취향·의도, 합의로 종결).
외부 의존성 → 사용자 영향 → 취향/의도 순으로 보고 먼저 걸리는 트랙으로 확정,
셋 다 아니면 보류로 두고 되물을 질문 1개 줘.
출력은 요청별로 | 트랙 | 근거(1줄) | 다음 행동(1줄) |.
[요청 목록]
1. 아트 리드: 전투 이펙트 컬러가 칙칙함. 더 화사하게 가도 되는지?
2. QA 리드: 길드 출석 보상 중복 지급 버그. 재현 영상 있음.
3. 퍼블리셔: 동남아 빌드에 문화권 가이드라인 반영. 다음 분기 심사 전.
4. 사운드 디렉터: 보스 BGM 도입부가 전투 시작 타이밍보다 0.5초 늦음.
5. 운영팀: 유저들이 "길드 채팅 폰트가 너무 작다"고 다수 문의.
| # | 트랙 | 근거 | 다음 행동 |
|---|------|------|-----------|
| 1 | Track-A | 컬러 톤은 취향·의도 영역, 외부/버그 무관 | 의도(전투의 긴장감)만 전달, 채도 결정은 아트 위임 |
| 2 | Track-D | 보상 중복 = 사용자 자원 직접 영향, 재현 가능 | P0 판정, 코드 리드 즉시 연결, 핫픽스 검토 |
| 3 | Track-S | 분기 심사라는 외부 마감에 묶임 | 분기 로드맵 편입, 가이드라인 항목 분해 후 리드타임 산정 |
| 4 | Track-A | BGM 타이밍은 연출 취향 문제로 보임 | 사운드 디렉터와 합의 |
| 5 | Track-D | 폰트 가독성은 UI 결함 | P2 버그로 등록 |
4번과 5번에서 저자는 멈췄다.
4번 거부. AI는 BGM 도입부 지연을 "연출 취향(Track-A)"으로 봤다. 하지만 "전투 시작 타이밍보다 0.5초 늦음"은 취향이 아니라 동기화 결함이다. 음원 자체를 바꾸는 게 아니라, BT(비헤이비어 트리) 상의 트리거 시점과 오디오 큐가 어긋난 문제일 가능성이 크다. 이건 합의로 끝나지 않고 애니메이션·코드 쪽 타이밍 데이터를 건드려야 한다. → Track-D로 정정. 단, 사용자 자원에 영향 없으므로 P2.
5번 보류로 강등. AI는 "폰트가 작다"를 곧장 UI 결함(Track-D)으로 단정했다. 그러나 이건 결함인지 취향인지가 메시지만으로는 안 갈린다. 폰트가 디자인 스펙대로 렌더되는데 "작게 느껴지는" 거라면 그건 합의(Track-A)에 가깝고, 스펙보다 작게 깨져 나오는 거라면 결함(Track-D)이다. → 보류. 운영팀에 되물음: "스펙상 폰트 크기 대비 실제로 작게 보이는 건가요, 아니면 스펙 자체를 키워달라는 의견인가요?"
거부한 두 건을 반영해 다시 던진 프롬프트의 추가 지시는 짧았다.
4번은 '전투 시작 대비 0.5초 지연'을 동기화 결함으로 재분류하라(Track-D, P2).
타이밍이 BT 트리거/오디오 큐 중 어디서 어긋났는지 확인할 질문 1개를 덧붙여라.
5번은 보류 처리하고, '스펙 대비 실제 렌더'인지 묻는 질문을 명시하라.
재출력은 4번을 Track-D / P2 / "BT 전투개시 노드의 오디오 큐 오프셋이 0인지, 아니면 BGM 클립 자체에 무음 0.5초가 포함됐는지 확인"으로, 5번을 보류 / "스펙 대비 작게 렌더되는지 vs 스펙 상향 요청인지 운영팀 재확인"으로 바로잡아 돌려줬다. 이 시점에서 분류가 완결됐다.
여기서 AI가 한 일과 사람이 한 일이 명확히 갈린다. AI는 다섯 건을 1차로 빠르게 분배해 빈 칸 없는 표를 만들어 줬다. 사람은 그중 트랙 경계가 미묘한 두 건(취향처럼 보이지만 동기화 결함인 BGM, 결함처럼 보이지만 취향일 수 있는 폰트)을 잡아냈다. 다섯 칸을 빠짐없이 채우는 일과 그중 두 칸이 잘못 채워졌음을 알아채는 일은 서로 다른 능력이고, 이 워크드 트랜스크립트는 그 둘을 각자 잘하는 쪽에 맡긴 것이다.
분류가 끝나면 각 트랙은 전혀 다른 후속 작업으로 들어간다. 같은 표에서 시작했지만 도착지가 다르다.
Track-A(합의) 로 분류된 요청은 "의도까지만 전달, 표현은 위임"의 원칙으로 처리한다. 아트의 컬러 요청에 저자가 돌려준 답은 채도 수치가 아니라 의도였다. "이 전투는 보스 1페이즈라 긴장감이 핵심입니다. 화사함보다 압박감이 우선이면 좋겠어요. 그 안에서 채도는 아트 판단에 맡깁니다." 기획자가 채도 값을 직접 지정하는 순간 아트의 자율성이 깎이고, 결과물의 책임 소재도 흐려진다. 의도와 표현의 경계를 지키는 일이 합의 트랙의 전부다.
Track-D(결함) 로 분류된 요청은 우선순위 판정과 코드 연결로 이어진다. 길드 보상 중복(P0)은 그 자리에서 코드 리드에게 넘겼고, BGM 동기화(P2)는 백로그에 등록하되 원인 추정 질문을 함께 달았다. 결함 트랙에서 기획자의 일은 "고치는 것"이 아니라 우선순위를 매기고 정확한 입력을 주는 것이다. P0인지 P2인지를 가르는 기준은 "지금 사용자 자원·진행에 영향을 주는가"다. 보상 중복은 자원에 직결되니 P0, BGM 0.5초 지연은 불쾌하지만 진행을 막지 않으니 P2다.
Track-S(일정) 로 분류된 요청은 분기 로드맵으로 들어간다. 퍼블리셔의 문화권 가이드라인은 한 줄 요청이지만 실제로는 여러 항목으로 분해된다 — 종교적 상징물 표현, 색상 금기, 텍스트 방향성, 캐릭터 복식. 이걸 받자마자 "검토하겠다"로 답하고 분기 계획에 통째로 얹는 것이 핵심이다. 외부 마감이 걸린 일은 작아 보여도 리드타임이 생명이라, 늦게 시작하면 무조건 사고가 난다.
이 세 갈래를 한눈에 비교하면 이렇다.
분류가 정확하면 같은 받은편지함의 다섯 줄이 세 개의 서로 다른 처리 라인으로 깔끔하게 흩어진다. 분류가 틀리면, 결함이 합의 회의로 끌려가 시간을 잡아먹거나, 일정 건이 늦게 시작돼 마감 직전에 폭발한다.
요청이 한두 건이 아니라 한 덩어리로 몰려올 때가 있다. 퍼블리셔 심사를 앞둔 몇 주, 혹은 전투 시스템 전면 개편 같은 국면이다. 이럴 때는 작업 자체를 95_BattleTF 같은 임시 작업공간으로 격리하고, 끝나면 결정만 정본으로 승격한다. 그 격리·흡수 메커니즘과 "아트팀엔 html만 전달(md 학습 0)"의 운영은 앞 챕터 16.1에서 전부 다뤘다.
분류(3-track) 관점에서 한 줄만 덧붙이면 이렇다. 한 덩어리로 커진 협업은 대개 Track-S(일정) 안건이 여러 직군에 걸쳐 분해되는 국면이라, 개별 트랙 처리로는 감당이 안 될 때 격리 작업공간이라는 한 단계 위의 그릇으로 옮겨 담는 것이다. 즉 3-track 분류가 입구라면, TF 격리는 그 입구를 통과한 큰 덩어리를 담는 방이다.
| 실패 패턴 | 처방 |
|---|---|
| 모든 요청을 같은 무게로 처리 | 받자마자 3-track 분류, 외부 의존성부터 질문 |
| 일정 건을 합의로 오분류 | 판정 순서 1번에 외부 마감 질문을 고정 |
| 취향처럼 보이는 동기화 결함을 합의로 처리 | "타이밍/수치 어긋남"은 결함 의심부터 |
| 결함처럼 보이는 취향을 결함으로 단정 | "스펙 대비 실제 렌더인가"를 되물어 보류 |
| 합의 트랙에서 기획자가 표현까지 결정 | 의도까지만, 표현은 직군 위임 |
| 일정 건을 늦게 시작 | 분기 로드맵 즉시 편입, 리드타임 확보 |
이 표의 절반은 분류 단계의 실수이고, 절반은 분류 이후 처리의 실수다. 분류가 정확해도 트랙별 손이 틀리면 효과가 사라진다. (TF 격리·승격·매체 관련 함정은 16.1의 함정 표를 참조.)
게임 밖 적용. 한 줄짜리 요청이 같은 받은편지함에 떨어진다는 이유로 같은 무게로 다뤄진다는 문제는, 게임이 아니라 모든 서비스기획자·PM의 일상 그 자체다. 들어오는 요청을 "합의(취향·방향)·결함(사용자 영향 버그)·일정(외부 마감)" 세 트랙으로 가르는 분류는 도메인을 바꿔도 그대로 작동한다. 예컨대 웹 서비스 PM의 메신저에 동시에 "버튼 색을 좀 더 밝게(합의)", "결제 영수증이 중복 발송됨(결함)", "개인정보보호법 개정 반영 마감 3주 전(일정)"이 떨어졌다면, 외부 마감→사용자 영향→취향 순으로 먼저 걸리는 트랙에 넣어 결제 버그에 즉시 인력을 붙이고 법 개정은 리드타임부터 확보하면 됩니다.
웹 챗봇 최소 경로 (터미널 없이) — 이 챕터의 핵심은 워크플로 스크립트가 아니라 "한 줄 요청을 합의·결함·일정 세 트랙으로 가른다"는 발상입니다. 그 발상은 CLI·hook·atom 인프라 없이 웹 챗봇(ChatGPT 또는 Claude 웹)만으로도 그대로 재현됩니다. 아래 두 단계가 본류입니다.
1. 그날 들어온 요청을 형식 없이 한 줄씩 모아 둡니다. 메신저·메일·메모 어디서 긁어와도 됩니다.
2. 웹 챗봇 입력창에 아래 프롬프트를 붙이고, 그 아래에 모은 요청 목록을 붙여 넣습니다. 이게 request-triangulate가 하던 1차 분류를 손으로 한 번 하는 것입니다.
아래 요청들을 Track-A(합의)/Track-D(결함)/Track-S(일정)로 분류해 줘.
외부 마감 → 사용자 영향 버그 → 취향·의도 순으로 보고 먼저 걸리는 트랙으로 확정,
셋 다 아니면 보류로 두고 되물을 질문 1개 줘. 출력은 | 트랙 | 근거 1줄 | 다음 행동 1줄 |.
[요청 목록 붙여넣기]
그다음 출력 표에서 두 칸만 사람이 검증하면 됩니다 — "타이밍·수치 어긋남"이 합의로 분류됐다면 동기화 결함을 의심하고, "~가 작다/느리다" 같은 체감 불만이 결함으로 단정됐다면 "스펙 대비 실제 렌더인가"를 되물어 보류로 내립니다. 스크립트·워크플로는 이 분류가 손에 익어 매일 묶음이 버거워질 때 비로소 도입하면 됩니다.
setup. 들어오는 외부 요청을 한곳(채널·문서)에 모으세요. 세 트랙의 정의를 한 줄씩 적어 두세요 — 합의(취향·의도), 결함(사용자 영향 버그), 일정(외부 마감).
prompt. 모인 요청 묶음을 AI에게 던지며 판정 순서를 고정하세요.
아래 요청들을 Track-A(합의)/Track-D(결함)/Track-S(일정)로 분류해 줘.
외부 마감 → 사용자 영향 버그 → 취향·의도 순으로 보고 먼저 걸리는 트랙으로 확정,
셋 다 아니면 보류로 두고 되물을 질문 1개 줘. 출력은 | 트랙 | 근거 1줄 | 다음 행동 1줄 |.
[요청 목록 붙여넣기]
verify. 출력 표에서 두 곳을 직접 검증하세요. (1) "타이밍·수치 어긋남"이 합의로 분류됐다면 동기화 결함이 아닌지 의심하세요. (2) "~가 작다/느리다" 같은 체감 불만이 결함으로 단정됐다면 "스펙 대비 실제 렌더인가"를 되물어 보류로 내리세요. 경계 두 건만 사람이 잡으면 나머지는 신뢰해도 됩니다.
팀도 TF도 없는 1인 개발자라면 트랙은 그대로 두되 대상만 바꾸세요. 스토어 리뷰, 디스코드 제보, 베타테스터 메모를 한 문서에 모아 두고, 주 1회 위 프롬프트로 묶음 분류하세요. 합의(취향)는 "내 비전과 충돌하지 않으면 수용", 결함(버그)은 그 주에 처리, 일정(스토어 심사·이벤트 마감)은 캘린더에 리드타임과 함께 입력하세요. 집중 정비 기간의 격리 폴더 운영은 16.1의 1인 축소판을 따르면 됩니다.
95_BattleTF 회의실. 길드 출석 보상을 자원 +5로 확정한 그날 오후, 나는 같은 결정 하나를 세 군데로 흘려보냈다. 기획팀 채널에는 명세 markdown을, 프로그램팀에는 데이터 컬럼 한 줄을, 아트팀에는 화면 한 장짜리 html을. 세 곳에서 거의 동시에 답이 왔다. 프로그램 리드는 "트리거 시점이 어디냐"고 물었고, 아트 디렉터는 "출석 버튼 위치가 06_UI 가이드랑 맞냐"고 물었고, 애니메이터는 아무 말이 없었다. 같은 결정이었는데 세 사람이 본 것은 전부 달랐다.
이 장은 그 '다르게 봄'을 사고가 아니라 설계로 바꾼 기록이다. 한 결정을 직군별로 다르게 포장하는 일 — 그것이 framing이다.
길드 출석 보상 결정 하나에 달라붙는 청중은 다섯이다. 이들은 같은 문장을 읽어도 자기 영역만 골라 읽고 나머지는 흘린다. 흘린 자리에서 사고가 난다.
| 청중 | 집중해서 읽는 것 | 본능적으로 건너뛰는 것 |
|---|---|---|
| 코드 리드 | 데이터 컬럼·인터페이스·트리거 시점 | 색감·서사·연출 |
| 아트 디렉터 | 화면 배치·컴포넌트·스타일 가이드 | 데이터 무결성·트리거 |
| 사운드 디렉터 | 행동 트리거·분위기·길이 | 데이터 디테일 |
| 애니메이터 | 동작·타이밍·상태 전이 | 시각 톤·수치 |
| QA | 수용 기준·위험·엣지 시나리오 | 구현 방식의 내부 |
문제는 정보의 양이 아니라 노출의 방식이다. 두툼한 명세 한 부를 다섯 명 책상에 똑같이 올려두면, 다섯 명은 각자 다른 페이지를 펼치고 다른 페이지를 덮는다. framing은 이 펼침을 우연에 맡기지 않고 의도적으로 배치한다.
아래는 같은 결정 하나가 직군 경계를 지나며 어떤 형태로 갈아입는지를 보여 주는 framing 매트릭스다.
포장은 청중마다 다르되, 가운데 깔린 불변 사실(수치·시점·범위)은 어느 포장에서도 흔들리면 안 된다. framing의 기술은 '다르게 보여 주는 것'이 아니라 '다르게 보여 주면서 같은 것을 보존하는 것'이다.
framing을 손으로 매번 다시 빚으면 부담이 커서 결국 안 하게 된다. 그래서 나는 결정 하나를 던지고 직군별 포장 초안을 한 번에 받는 방식을 쓴다. 아래는 길드 출석 보상 결정을 두고 실제로 주고받은 전사다. 다듬지 않고 그대로 옮긴다.
[결정 사실 — 95_BattleTF에서 확정]
- 길드 출석 보상 = 길드 자원 +5
- 트리거: 길드원이 그날 첫 로그인할 때 1회
- 범위: 해당 길드의 전체 길드원 각각
- UI: 길드 화면 상단에 자원 총량 표시 + "출석" 버튼
이 결정 하나를 세 직군용으로 포장해 줘.
기획은 markdown으로 의도·룰·데이터 영향, 아트는 html에 들어갈 화면 명세를
배치·컴포넌트·스타일 가이드 참조 중심으로 (데이터·트리거 언급 금지),
프로그램은 데이터 한 줄·인터페이스·트리거·검증 항목으로.
세 포장 다 불변값(+5, 첫 로그인 1회, 길드원 전원)은 똑같이 박고,
모르는 값은 지어내지 말고 [확인필요]로 표시해.
1) 기획 → markdown
## 길드 출석 보상
- 의도: 매일 1회 접속을 길드 단위 협력 자원으로 환산해 잔존율을 끌어올린다.
- 룰: 길드원이 당일 첫 로그인 시 길드 자원 +5. 하루 1회. 길드 단위 누적.
- 데이터 영향: guild_resource_total 증가. 자원 인플레이션 모니터 필요 [확인필요: 상한선 미정].
2) 아트 → 화면 명세 (html 들어갈 내용)
- 길드 화면 상단 영역에 자원 총량 표시 (progress_bar + 숫자 badge).
- 우측에 "출석" 버튼. 눌렀을 때 상태 변화는 06_UI 가이드의 버튼 상태 규칙 따름.
- 스타일: 06_UI 가이드 부합. 색은 [확인필요: 길드 테마 컬러 토큰 미지정].
- (데이터 수치·트리거는 이 포장에서 의도적으로 제외)
3) 프로그램 → 데이터/인터페이스
- 데이터: guild_table 에 컬럼 추가 — guild_resource_total (int, default 0)
- 인터페이스: GuildResourceManager.add(guild_id, amount)
- 트리거: 일일 첫 로그인 이벤트 훅에서 add(guild_id, 5) 1회 호출
- 검증: integrity_check_guild_resource (음수 불가 / 1일 1회 초과 호출 차단)
- 동시성: 같은 길드원이 멀티 디바이스 동시 로그인 시 중복 가산 [확인필요]
날것 출력을 그대로 쓰지 않았다. 세 곳을 잡았다.
[확인필요] 두 건(자원 상한, 멀티 디바이스 중복 가산)은 결정 사실에 없던 빈틈이다. AI가 지어내지 않고 표시한 것은 맞는 판단이지만, 이건 framing 이전에 결정 자체의 구멍이라 95_BattleTF로 되돌려야 한다.아트 포장 수정해 줘:
- "06_UI 가이드 따름" 같은 외부 문서 참조 문구 전부 제거.
- 버튼 눌림/대기/완료 3상태의 시각 차이를 화면 명세 안에 직접 서술.
- 아트팀은 이 한 장만 보고 작업한다는 전제로, 다른 문서로 점프하지 않게 자족적으로.
프로그램 포장의 [확인필요] 2건은 산출물에서 빼고,
대신 맨 위에 "95_BattleTF 재확정 필요 항목" 블록으로 분리해.
이 한 번의 거부·재요청으로 산출물은 세 직군이 각자 자기 자리에서 바로 집어 쓸 형태가 됐다. AI는 포장 초안 세 벌을 빚어 내고 빈틈에 표시까지 달아 줬지만, 어느 포장에서 무엇을 덜어 낼지 — 아트 포장에서 외부 참조를 빼고 프로그램 포장에서 미확정 항목을 들어내는 — 그 가위질은 끝내 내 손에 남았다. framing의 핵심 판단은 포함이 아니라 배제 쪽에 있다.
포장을 어디에 둘 것이냐에 따라 방식이 갈린다. 셋 중 무엇을 쓸지는 명세의 크기와 운영 체력으로 정한다.
(1) 한 문서 안의 청중별 요약. 본문 뒤에 직군별 요약 섹션을 붙인다. 다섯 명이 한 파일을 공유하되 각자 자기 절만 읽는다.
## 청중별 요약
### 코드 (구현)
- 데이터: guild_table.guild_resource_total (int)
- 인터페이스: GuildResourceManager.add(guild_id, amount)
- 트리거: 일일 첫 로그인 1회
- 검증: integrity_check_guild_resource
### 아트 (시각)
- 화면: 길드 상단 자원 총량 + 출석 버튼
- 컴포넌트: progress_bar, badge, button(3상태)
- 우선순위: 이번 마일스톤
### QA (검증)
- 수용 기준: 출석 후 길드 자원 +5 반영, 1일 1회 초과 차단
- 위험: 자원 인플레이션, 멀티 디바이스 중복 가산
(2) 청중별 별도 산출물. 본문 1개에 직군별 파일을 따로 떨어뜨린다. 95_BattleTF에서 아트팀에 html만 보내고 md를 안 보내는 운영이 이 방식의 실전 형태다 — 같은 결정이라도 직군마다 매체 자체가 다르다.
spec_guild_attendance.md — 기획 본문(전체 컨텍스트)
guild_screen_v3.html — 아트 (html만, md 학습 0)
guild_table 1 row + add() — 프로그램 (데이터/인터페이스)
qa_guild_attendance.md — QA (수용 기준·위험)
분량 큰 명세에 맞고, 매체가 직군 도구에 곧장 들어간다. 대신 한 결정이 바뀌면 여러 산출물을 함께 고쳐야 해 운영 부담이 크다.
(3) Wikilink 그래프. 본문에 직군별 시작점만 링크로 넣고, 각자 자기 가지를 따라 탐색한다.
[[spec_guild_attendance]]
├── [[code_guild_table]]
├── [[ui_guild_screen_v3]]
└── [[qa_guild_attendance]]
세 방식의 비용과 회수는 다음과 같다. 아래 수치 중 '효과'는 저자 추정(미검증)이며 방향과 상대 비율만 신뢰할 것.
| 방식 | 비용 | 회수 시점 |
|---|---|---|
| (1) 청중별 요약 | 본문 분량 +30% 안팎 | 거의 모든 명세에서 바로 회수 |
| (2) 별도 산출물 | 산출물 N벌 운영 | 분량 크고 매체가 직군별로 다를 때만 회수 |
| (3) Wikilink 그래프 | 그래프 인프라 선투자 | 명세가 누적돼 그래프 자체가 자산일 때 회수 |
대부분의 명세에는 (1)이 맞는다. 비용이 가장 작고 회수가 가장 빠르다. (2)는 아트 html처럼 매체가 이미 갈라진 자리에서만 쓰고, (3)은 명세가 충분히 쌓여 링크 그래프가 탐색 가치를 낼 때 켠다.
청중을 명세마다 새로 정의하면 framing이 매번 다시 빚어진다. 그래서 코드를 고정한다.
| 청중 코드 | 영역 |
|---|---|
| code | 코드·시스템·데이터 |
| art | 아트·시각·UI |
| sound | 사운드·음향 |
| anim | 애니메이션·모션 |
| qa | QA·검증 |
이 다섯이 내부 운영 표준이다. 외주·법무 같은 외부 청중은 이 표준 밖에서 별도로 다룬다. 다섯으로 고정하면 LLM에 framing을 맡길 때 청중 정의를 매번 새로 쓰지 않아도 되고, 빠진 청중을 체크리스트로 잡아낼 수 있다.
매 명세마다 다섯 직군 요약을 손으로 쓰면 결국 안 쓰게 된다. 그래서 흐름을 이렇게 묶었다.
flowchart LR
A[기획자: 결정 사실 작성] --> B[LLM: 5청중 포장 초안]
B --> C[기획자: 거부/보강/유지 판단]
C --> D{빈틈 발견?}
D -- 예 --> E[95_BattleTF 재확정으로 반송]
D -- 아니오 --> F[최종 명세 + 직군별 framing]
E --> A
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
class A,C,D human;
class B ai;
class F pass;
기획자가 결정 사실만 쓰면 LLM이 다섯 포장 초안을 만들고, 기획자는 거부·보강·유지를 판단한다. 빈틈([확인필요])이 나오면 framing에서 처리하지 않고 결정 단계로 되돌린다 — framing은 결정의 구멍을 메우는 도구가 아니라 정해진 결정을 옮기는 도구이기 때문이다.
이 사이클에서 반복적으로 밟는 함정 네 가지를 처방과 함께 둔다.
| 함정 | 증상 | 처방 |
|---|---|---|
| 정보 중복 | 같은 내용이 본문·요약에 반복돼 운영 부담 | 본문 1회, 요약은 차이 항목만 |
| 정보 누락 | 한 직군에 중요한 값이 통째로 빠짐 | 5청중 고정 체크리스트로 누락 점검 |
| 본문 무시 | 요약만 보고 본문 컨텍스트를 흘림 | 요약 끝에 "근거는 본문" 명시 |
| 매체 혼선 | 아트에 md를 보내 학습 부담을 줌 | 직군 매체 원칙(아트=html) 고정 |
자동화는 작성 부담을 명세당 5분 안팎으로 낮추지만, 거부·보강·유지의 판단까지 자동화되지는 않는다. 그 판단이 사람의 자리다.
다음은 저자가 운영하는 프로젝트 A에서 framing 도입 전후를 비교한 값이다. 절대 수치는 저자 추정(미검증)이고, 신뢰할 것은 변화의 방향과 상대 비율이다.
| 항목 | framing 부재 | framing 운영 | 방향 |
|---|---|---|---|
| 직군별 해석 사고 | 분기당 15~20건 | 분기당 3~5건 | 큰 폭 감소 |
| 청중의 명세 읽는 시간 | 15~30분 | 5~10분(자기 절만) | 감소 |
| 결정 → 작업 시작 | 1~2일 | 4~8시간 | 단축 |
| 직군 간 해석 충돌 | 분기당 8~12건 | 분기당 2~3건 | 감소 |
| 명세 작성 시간 | 1~2시간 | 1.5~2.5시간(LLM 보조) | 소폭 증가 |
명세 작성 자체는 조금 길어진다. 직군별 포장을 얹기 때문이다. 그러나 그 뒤 직군 작업 사이클이 짧아져 결정에서 작업 시작까지의 전체 시간은 줄어든다. 이 트레이드오프가 framing 도입의 핵심 근거다. LLM 보조 검수가 부담스러운 팀이라면, 방식 (1)에 수기 5청중 요약을 먼저 정착시킨 뒤 자동화를 얹는 순서가 안전하다.
게임 밖 적용. 하나의 결정을 청중마다 다르게 포장하되 불변 사실(수치·시점·범위)은 어디서나 보존한다는 framing은, 게임이 아니라 모든 조직의 공지·릴리스 커뮤니케이션에 그대로 쓰인다. 예컨대 "구독 요금을 9,900원으로 7월 1일부터 인상" 한 건을 결정했다면, 개발팀에는 결제 테이블 컬럼·적용 시점 같은 데이터로, 디자인팀에는 안내 배너 화면 한 장으로, 고객지원팀에는 예상 문의 응대 스크립트로 포장이 갈립니다. 포장은 셋 다 다르지만 "9,900원·7월 1일·신규 및 기존 가입자 전원"이라는 세 숫자가 어느 포장에서든 어긋나면 그 순간 고객 분쟁이 터진다.
setup
prompt
[결정 사실]
(수치·시점·범위를 한 줄씩)
이 결정을 code·art·sound·anim·qa 중 해당 직군용으로 포장해 줘.
포장마다 그 직군이 무관심한 정보는 빼되 불변값(수치·시점·범위)은 어느 포장에서나 똑같이 박고,
아트 포장은 다른 문서 참조 없이 그 한 장만으로 자족적으로, 모르는 값은 지어내지 말고 [확인필요]로 표시해.
verify
[확인필요]가 있으면 framing이 아니라 결정 단계(95_BattleTF류 TF)로 반송하세요.1인 축소판
혼자 작업한다면 청중을 둘로 줄이세요 — '나중의 나'(구현)와 '검수자'(QA). 결정 사실 한 줄을 쓰고 LLM에 "이걸 구현 메모와 검수 체크리스트 두 벌로 나눠 줘"라고 요청한 뒤, 두 벌에서 핵심 수치가 일치하는지만 대조하면 됩니다. 청중이 둘뿐이어도 '같은 결정을 다르게 포장하되 불변값은 보존한다'는 framing의 골격은 그대로 작동합니다.
회의가 끝나고 5분이 지났다. 회의실 화이트보드에는 아직 글씨가 남아 있다. "전투 피격 판정, 클라 선반영으로 간다. 단 서버 검증 우선순위는 다음 스프린트." 다섯 명이 30분을 써서 도달한 결론이다. 모두 고개를 끄덕였고, 누군가는 사진을 찍었다.
3주 뒤, 같은 다섯 명이 같은 회의실에 다시 모였다. 안건 목록 첫 줄에 적혀 있다. "전투 피격 판정 — 클라 선반영 vs 서버 검증, 결정 필요." 아무도 3주 전의 결론을 기억하지 못한다. 화이트보드 사진은 누군가의 카메라 롤 어딘가에 있고, 그 사람은 오늘 휴가다. 다시 30분을 쓴다. 이번엔 반대로 결론이 난다.
이게 회의록이 가장 큰 통증인 이유의 전부다. 회의에서는 결정이 난다. 그런데 그 결정이 다음 회의, 다음 문서, 다음 빌드로 흘러가지(propagate) 못한다. 결정은 났는데 전파가 안 된다. 이 챕터는 그 끊긴 고리를 데이터로 잇는 이야기다.
저자가 운영하는 개인 RnD 시스템은 17개 문서로 갈라져 있다. atom 명명 표준, 관계도 자동화, Layer 매핑 가이드, JIT 주입 인프라 등. 그중에서 가장 많은 시간을 빨아들인 단일 문서가 회의록 개선 계획이다. 다른 16개를 합친 것에 비견될 만큼 비중이 크다.
처음에는 의아했다. 회의록이라니, 그냥 받아 적으면 되는 것 아닌가. 그런데 측정해 보니 통증의 위치가 회의록 작성이 아니라 회의록 이후에 있었다. 회의에서 결정은 분명히 났다. 문제는 그 결정이 누구의 책임으로, 어떤 근거로, 언제까지, 무엇으로 이어지는지가 회의실 문을 나서는 순간 증발한다는 것이었다.
이 끊김을 한 장면으로 그리면 이렇다.
flowchart LR
M[회의
30분 토론] --> D[결정 발생
전원 동의]
D -.끊김.-> X[다음 회의에서
재상정]
D -.끊김.-> Y[문서에 반영 안 됨]
D -.끊김.-> Z[빌드에 누락]
X --> M
style D fill:#ffe6cc,stroke:#d79b00
style X fill:#f8cecc,stroke:#b85450
style Y fill:#f8cecc,stroke:#b85450
style Z fill:#f8cecc,stroke:#b85450
점선이 끊긴 전파다. 결정(주황)은 났는데, 세 갈래(빨강) 어디로도 흘러가지 않는다. 흘러가지 못한 결정은 3주 뒤 회의로 되돌아온다. 화살표가 위로 휘어 다시 회의로 돌아오는 저 순환이 통증의 본체다.
전파가 끊기면 네 가지가 동시에 일어난다.
결정의 이력을 잃는다. "왜 그렇게 정했지?"에 "기억이 안 나니 다시 회의를 잡자"로 답하게 된다. 반복 회의가 늘어난다. 같은 안건이 분기마다 재상정된다. 신규 합류자가 컨텍스트를 못 잡는다. 의사결정의 누적이 보이지 않으니 매번 1:1로 설명해야 한다. 그리고 AI 보조가 무력해진다. 컨텍스트가 없으니 답이 일반론에 머문다. 회의록이 흩어져 있으면 "우리 팀이 이 안건을 전에 어떻게 결정했나"를 AI에게 줄 수 없고, AI는 인터넷 평균값을 돌려준다.
네 가지 모두 같은 뿌리에서 나온다. 결정이 데이터가 아니라 메모로 다뤄지기 때문이다. 메모는 휘발하고 데이터는 흐른다.
여기서 시각을 한 번 뒤집어야 한다. 회의록을 "회의의 산출물"로 보면, 받아 적고 보관하면 임무 끝이다. 보관된 회의록은 책상 위 메모지와 같다. 그날은 보이지만 다음 주에는 어디 갔는지 모른다.
회의록을 "의사결정 데이터베이스"로 보면 완전히 다른 작업이 된다. 회의록 자체가 아니라 회의록에서 추출한 결정이 자산이고, 그 결정이 검색 가능하고 참조 가능하고 전파 가능해야 한다. 회의록은 결정을 길어 올리는 광맥일 뿐이다.
이 전환을 코드로 강제하는 게 17부 전체의 골자다. 저자의 시스템에는 이 전환을 떠받치는 atom이 하나 들어 있다. 이름은 decision_summary_not_clickup_mirror다. 풀어 쓰면 "회의록 결정 요약은 ClickUp(태스크 트래커)의 거울이 아니다"라는 원칙이다.
이 atom이 왜 필요했는지가 통증의 정곡을 찌른다. 회의록의 결정 슬롯을 그냥 태스크 보드에 옮겨 적는 팀이 많다. 그러면 "할 일"은 남는데 "왜 그렇게 정했는가(근거)"가 사라진다. 태스크 트래커는 무엇을 할지를 담지 왜 그렇게 정했는지는 담지 않는다. 3주 뒤 회의가 반복되는 이유가 정확히 이것이다. 할 일은 닫혔는데 근거가 없으니, 누군가 "근데 이거 왜 이렇게 하기로 했더라"를 물으면 답할 사람이 없다. 그래서 결정 요약은 트래커의 미러가 되어선 안 되고, 근거(rationale)를 품은 독립 자산이어야 한다. atom 이름 자체가 이 금지선이다.
전파되는 결정과 휘발하는 결정의 차이는 구조에 있다. 휘발하는 결정은 "클라 선반영으로 간다"는 한 문장이다. 전파되는 결정은 네 개의 필드로 분해된다.
네 필드 중 가장 중요한 게 owner다. 결정에 책임자가 없으면 그 결정은 누구의 일도 아니고, 누구의 일도 아닌 결정은 실행으로 전파되지 않는다. 그래서 저자의 추출 파이프라인은 owner가 비어 있으면 그냥 넘어가지 않고 [MISSING]으로 명시적으로 신고한다. 책임선이 비었다는 사실 자체를 표면으로 끌어올린다.
rationale은 앞서 말한 decision_summary_not_clickup_mirror 원칙이 사는 자리다. 근거가 없으면 3주 뒤에 회의가 반복된다. follow_up은 결정이 실제 실행으로 이어지는 다리다. 이 필드가 비면 결정은 결정으로만 남고 빌드에 닿지 않는다.
이 네 필드를 사람이 매번 손으로 채우는 것도 가능하지만, 그러면 강제력이 약하다. 저자의 시스템은 회의록에서 결정을 자동으로 추출하고, 빠진 필드를 신고하는 파이프라인을 쓴다. 세 개의 스크립트가 직렬로 연결된다.
flowchart TD
R[회의록 .md
표준 양식] --> L[meeting_lint.py
양식 검증]
L -->|통과| P[decision_parser.py
4 필드 추출]
L -->|반려| R
P -->|owner 없음| MISS["[MISSING] 신고"]
P -->|4 필드 완비| PEND[pending atom
1주 검증 대기]
PEND --> PROM[promote.py
주 1회 승격]
PROM --> ATOM[정식 atom
+ JIT manifest 등록]
style L fill:#dae8fc,stroke:#6c8ebf
style P fill:#d5e8d4,stroke:#82b366
style PROM fill:#e1d5e7,stroke:#9673a6
style MISS fill:#f8cecc,stroke:#b85450
첫 단계 meeting_lint.py는 회의록이 표준 양식을 따르는지 검사한다. frontmatter가 있는지, 안건/결정/액션/다음 회의 4 슬롯이 채워졌는지. 양식이 깨진 회의록은 여기서 반려되어 작성자에게 돌아간다. 자동 파서는 양식이 강제된 입력만 처리할 수 있으므로, 이 lint가 파이프라인 전체의 입구 게이트 역할을 한다.
둘째 단계 decision_parser.py가 핵심이다. 결정 슬롯을 읽어 네 필드(decision/owner/rationale/follow_up)로 분해한다. 여기서 owner를 찾지 못하면 그 결정을 버리지 않고 [MISSING]으로 신고한다. 책임자 없는 결정을 조용히 통과시키는 게 가장 위험하기 때문이다.
셋째 단계는 추출된 결정이 곧장 정식 자산이 되지 않고 pending 상태로 1주간 대기하는 것이다. 이 검증 기간이 가역 게이트다. 1주 안에 "이건 결정이 아니라 토론이었다"거나 "근거가 틀렸다"가 드러나면 폐기한다. 그리고 promote.py가 주 1회 리뷰에서 살아남은 결정만 정식 atom 폴더로 옮기고 JIT manifest에 등록한다. 등록된 결정은 다음 세션부터 관련 작업에 자동으로 주입된다. 비로소 결정이 흐르기 시작한다.
가역과 비가역의 경계가 여기 있다. pending 폐기까지는 가역이다. 그런데 promote가 끝나 결정이 다른 문서·데이터 시트·빌드로 전파되면 그때부터는 비가역이다. 팀원의 인식이 바뀌고 종속 결정이 그 위에 쌓이기 때문이다. 그래서 모든 검수는 promote 직전, 즉 pending 가역 구간에서 끝내야 한다.
추상으로만 말하면 와닿지 않으니, 실제로 깨진 회의록 한 장을 파이프라인에 넣어 본 기록을 그대로 옮긴다. 입력은 양식이 절반쯤 무너진 회의록이다.
입력 — 2026-06-02-battle.md (양식 불량)
---
type: meeting_note
date: 2026-06-02
category: battle
---
## 안건
1. 피격 판정 위치 (클라 vs 서버)
## 결정
- 피격 판정은 클라에서 선반영하기로 함. 체감 반응속도가
서버 왕복보다 중요하다는 의견이 우세. 핵 위험은 감수.
- 서버 검증은 다음 스프린트에 별도 태스크로.
## 액션 아이템
- [ ] 서버 검증 설계
이 회의록은 결정을 적긴 했지만 owner가 어디에도 없다. 누가 서버 검증을 맡는지 비어 있다. 다음 회의 일정 슬롯도 통째로 빠졌다. 이걸 파서에 넣고 무엇이 나오는지 봤다.
프롬프트 (decision_parser.py에 붙인 LLM 보조 프롬프트 전문)
아래 회의록의 "## 결정" 슬롯을 읽고 결정마다 4개 필드를
추출하라: decision, owner, rationale, follow_up.
- owner를 본문에서 특정할 수 없으면 반드시 "[MISSING]"으로 표기.
추측해서 채우지 말 것.
- rationale은 본문에 적힌 근거만 인용. 없으면 "[MISSING]".
- follow_up은 액션 아이템 또는 다음 스프린트 언급과 연결.
JSON 배열로만 출력.
[회의록 본문 붙여넣음]
Claude 날것 출력 (요약하지 않고 그대로)
[
{
"decision": "피격 판정을 클라이언트에서 선반영한다",
"owner": "[MISSING]",
"rationale": "체감 반응속도가 서버 왕복보다 중요. 핵 위험은 감수",
"follow_up": "서버 검증을 다음 스프린트 별도 태스크로 진행"
},
{
"decision": "서버 검증을 다음 스프린트에 별도 태스크로 분리",
"owner": "[MISSING]",
"rationale": "[MISSING]",
"follow_up": "액션 아이템 '서버 검증 설계'와 연결 (담당자 미지정)"
}
]
여기서 멈추고 출력을 검토했다. 두 결정 모두 owner가 [MISSING]으로 잡혔다. 정확하다 — 회의록 어디에도 책임자가 없었으니 추측으로 채우지 않은 게 맞다. 둘째 결정의 rationale도 [MISSING]인데, 실제로 본문에 둘째 결정의 근거가 없었다. 파서가 지어내지 않았다는 뜻이라 신뢰가 간다.
다만 한 군데가 애매했다. 첫째 결정의 follow_up이 "서버 검증을 다음 스프린트로"인데, 이건 둘째 결정과 내용이 겹친다. 파서가 두 결정의 경계를 약간 흐리게 본 것이다. 사람이 개입할 지점이다.
사람의 거부 + 재요청
첫째 결정의 follow_up이 둘째 결정 본문과 중복된다.
첫째의 follow_up은 "서버 검증 태스크 생성"으로 좁히고,
서버 검증의 구체 내용은 둘째 결정으로 넘겨라.
그리고 두 결정 모두 owner가 [MISSING]이므로,
이 회의록을 pending에 올리기 전에 owner 확정이 필요하다는
경고 라인을 출력 맨 위에 붙여라.
Claude 재출력 (날것)
⚠️ 경고: 결정 2건 모두 owner [MISSING]. pending 승격 전
회의 진행자에게 owner 확정 요청 필요.
[
{
"decision": "피격 판정을 클라이언트에서 선반영한다",
"owner": "[MISSING]",
"rationale": "체감 반응속도 우선, 핵 위험 감수",
"follow_up": "서버 검증 태스크 생성 (상세는 결정 #2)"
},
{
"decision": "서버 검증을 다음 스프린트 별도 태스크로 분리",
"owner": "[MISSING]",
"rationale": "[MISSING]",
"follow_up": "'서버 검증 설계' 액션 아이템 연결"
}
]
이 트랜스크립트가 보여주는 게 17부의 핵심이다. 파서는 결정을 길어 올렸지만 owner라는 책임선이 비었다는 사실을 숨기지 않았다. [MISSING]이 두 번 찍혔고, 그게 회의 진행자에게 "owner를 확정하라"는 신호로 돌아갔다. 결정이 실행으로 전파되려면 책임자가 있어야 하고, 책임자가 없으면 시스템이 그걸 표면으로 밀어 올린다. 깨진 회의록 한 장이 이 게이트를 통과하려면 사람이 owner를 채워 넣을 수밖에 없다. 전파의 첫 매듭이 여기서 묶인다.
참고로 위 출력의 ⚠️는 콘솔 경고 라인일 뿐, 본문 양식의 일부가 아니다. 회의록 자체는 여전히 깔끔한 4 슬롯 마크다운으로 남는다.
17부는 원래 6개 챕터(동기·추출·카테고리·캡션·동기화·AI 보조)로 설계했다가 4개로 통폐합했다. 이미지 캡션과 동기화는 회의록의 곁가지였고, 가장 큰 통증인 "결정 전파"를 한 챕터로 묶어 앞에 세우는 게 맞았기 때문이다. 가장 큰 통증(회의 → 결정 → 실행 전파)을 §17.1로 끌어올리고, 그 통증을 푸는 파이프라인(meeting_lint → decision_parser → promote)을 뒤이어 배치했다.
회의록을 데이터베이스로 다루는 시각, 결정을 4 필드로 쪼개는 구조, owner가 비면 [MISSING]으로 신고하는 게이트, pending 1주의 가역 검증 — 이 네 가지가 끊긴 전파를 다시 잇는다. 결정은 났는데 흐르지 않는다는 통증은, 결정을 흐를 수 있는 형태로 만들어 두면 풀린다.
게임 밖 적용. "회의에서는 결정이 났는데 그 결정이 다음 회의로 흘러가지 못한다"는 통증은 게임 개발이 아니라 모든 직장의 회의실에서 매주 반복된다. 결정을 한 문장 메모가 아니라 네 필드(무엇을·누가·왜·다음 행동)로 쪼개 적고, 책임자가 비면
[MISSING]으로 표면에 끌어올리는 방식은 어떤 회의에도 그대로 옮겨진다. 예컨대 마케팅 주간 회의에서 "다음 캠페인은 인스타 중심으로 간다"고 합의했다면, 거기에 owner(누가 집행), rationale(왜 인스타인가 — 지난 분기 전환율 근거), follow_up(예산안 작성)을 붙여 적으세요. 3주 뒤 "그거 누가 하기로 했지"라는 질문이 영영 나오지 않는다.
웹 챗봇 최소 경로 (터미널 없이) — 이 챕터의 핵심은 스크립트가 아니라 "결정을 4 필드로 쪼개 흐르게 한다"는 발상입니다. 그 발상은 CLI·hook·atom 인프라 없이 웹 챗봇(ChatGPT 또는 Claude 웹)만으로도 그대로 재현됩니다. 아래 세 단계가 본류입니다.
1. 회의가 끝나면 회의록(또는 회의 메모)을 그대로 복사합니다. 양식이 없어도 됩니다.
2. 웹 챗봇 입력창에 아래 프롬프트를 붙이고, 그 아래에 회의록을 붙여 넣습니다([회의록 본문] 자리). 이게 decision_parser.py가 하던 일을 손으로 한 번 하는 것입니다.
아래 회의록에서 결정마다 4개 필드를 표로 뽑아라:
decision(무엇을), owner(누가 책임), rationale(왜), follow_up(다음 행동).
- owner를 특정할 수 없으면 반드시 "[MISSING]". 추측 금지.
- rationale은 본문에 적힌 근거만. 없으면 "[MISSING]".
[회의록 본문]
3. 출력 표에서 [MISSING]이 찍힌 칸을 회의 진행자에게 확인해 채웁니다. 완성된 표를 decisions.md 같은 문서 한 장에 날짜순으로 붙여 쌓으면, 그 문서가 곧 의사결정 DB입니다. 검색은 문서 내 찾기(Ctrl+F)로 충분합니다. 스크립트·atom·JIT는 이 습관이 쌓여 검색이 버거워질 때 비로소 도입하면 됩니다.
setup (인프라 버전 — 위 최소 경로가 손에 익은 뒤)
- 회의록 표준 양식 1장을 정하세요. frontmatter(type/date/category) + 4 슬롯(안건/결정/액션/다음 회의).
- 양식 검증용 meeting_lint.py, 결정 추출용 decision_parser.py, 승격용 promote.py 3개 스크립트를 두세요(처음엔 lint와 parser만으로 충분합니다).
- 결정의 4 필드를 명시하세요: decision, owner, rationale, follow_up. owner가 비면 [MISSING]을 강제하는 규칙을 parser에 넣습니다.
prompt (decision_parser에 붙이는 LLM 보조 프롬프트)
아래 회의록의 "## 결정" 슬롯을 읽고 결정마다 4개 필드를 추출하라:
decision, owner, rationale, follow_up.
- owner를 특정할 수 없으면 반드시 "[MISSING]". 추측 금지.
- rationale은 본문에 적힌 근거만 인용. 없으면 "[MISSING]".
- follow_up은 액션 아이템·다음 스프린트 언급과 연결.
JSON 배열로만 출력.
[회의록 본문]
verify
- 출력의 모든 결정에 owner가 채워졌는지 확인하세요. [MISSING]이 하나라도 있으면 회의 진행자에게 owner 확정을 요청한 뒤에야 pending으로 올립니다.
- rationale이 본문에 없는 근거를 지어내지 않았는지 보세요(없으면 [MISSING]이어야 정상입니다).
- pending에 1주 둔 뒤 주 1회 리뷰에서 살아남은 결정만 promote.py로 정식 atom 승격하세요.
스크립트 3개가 부담이라면 이렇게 줄이세요. 회의록 양식만 4 슬롯으로 통일하고, 회의가 끝나면 결정 슬롯만 떼어 LLM에 위 프롬프트로 한 번 돌립니다. owner가 [MISSING]으로 나온 결정만 그 자리에서 책임자를 적어 넣으세요. 자동화 없이 이것만 해도 "결정에 책임자 없음"이라는 가장 흔한 전파 끊김 하나는 막힙니다. lint·promote는 회의록이 쌓여 검색이 필요해질 때 추가하면 됩니다.
수요일 아침, 출근하자마자 팀 메신저에 알림이 떴다. "지난주 인벤토리 칸 수 30칸으로 늘리기로 한 거 맞죠? 누가 데이터 시트 고치기로 했었죠?" 스레드에 아무도 답을 못 단다. 회의록은 분명 있다. 어딘가의 폴더에. 열어 보면 안건과 토론이 빽빽한데, "그래서 뭘 결정했고 누가 책임지는가"는 문장 사이에 녹아 있다. 결국 다음 회의에서 같은 안건을 처음부터 다시 꺼낸다.
이 챕터는 그 사흘의 공백을 메우는 기계 이야기다. 회의록 한 건이 들어가면, 양식 검사를 통과하고, 결정 네 필드가 뽑혀 나오고, 주인 없는 결정은 [MISSING] 딱지가 붙고, 후보 파일이 만들어지고, 일주일 뒤 검수를 거쳐 자동 주입되는 자산이 된다. 사람 손은 양 끝 두 곳에만 닿는다. 회의록을 쓰는 입구와, 주 1회 검수하는 출구.
먼저 전체를 한 장에 본다. 네모 칸 하나하나가 작은 스크립트이거나 사람의 판단이다. 손으로 옮기는 칸은 두 개뿐이고, 나머지는 자동으로 흐른다.
flowchart TD
A["회의 진행"] --> B["표준 양식 회의록 작성
(사람)"]
B --> C{"meeting_lint.py
양식 검사"}
C -->|위반| B
C -->|통과| D["decision_parser.py
결정 4필드 추출"]
D --> E{"owner 있는가?"}
E -->|없음| F["[MISSING] 신고
주인 지정 요청"]
F --> B
E -->|있음| G["pending atom 후보 파일
생성"]
G --> H{"주 1회 리뷰
(사람)"}
H -->|승격| I["promote.py
정식 atom + JIT 등록"]
H -->|폐기| J["폐기 이력 보존"]
H -->|보류| G
I --> K["다음 세션부터 자동 주입"]
style B fill:#e8f0ff,stroke:#3366cc
style H fill:#e8f0ff,stroke:#3366cc
style F fill:#fff0e8,stroke:#cc6633
style J fill:#f0f0f0,stroke:#999999
파란 칸 두 개(회의록 작성·주 1회 리뷰)만 사람이고, 나머지는 스크립트다. 주황 칸([MISSING] 신고)은 자동 검사가 사람을 다시 부르는 자리다. 결정에 주인이 없으면 파이프라인이 그냥 멈추는 게 아니라, 누가 책임지는지 정할 때까지 회의록 작성 단계로 결정을 되돌려 보낸다. 이게 이 파이프라인의 핵심 설계다. 빈칸을 조용히 넘기지 않고 시끄럽게 신고한다.
전체 자산 폴더 구조는 이렇게 잡혀 있다.
추출이 가능하려면 회의록이 기계가 읽을 수 있는 모양이어야 한다. "## 결정" 섹션이 없거나, 결정이 줄글 한 문단에 섞여 있으면 파서는 아무것도 못 뽑는다. 그래서 가장 먼저 양식 검사를 넣는다. meeting_lint.py가 하는 일은 단순하다. 필수 frontmatter가 있는가, 필수 섹션이 있는가, 결정 슬롯이 D1, D2 형식으로 채워져 있는가.
# meeting_lint.py 골격
REQUIRED_FRONTMATTER = ["type", "date", "category", "attendees"]
REQUIRED_SECTIONS = ["## 안건", "## 결정", "## 액션 아이템", "## 다음 회의"]
ALLOWED_CATEGORIES = ["art", "battle", "daily", "issue", "review"]
def lint(meeting_note_path):
fm, body = parse_markdown(meeting_note_path)
errors = []
for key in REQUIRED_FRONTMATTER:
if key not in fm:
errors.append(f"frontmatter 누락: {key}")
if fm.get("category") not in ALLOWED_CATEGORIES:
errors.append(f"category 값 부적합: {fm.get('category')}")
for section in REQUIRED_SECTIONS:
if section not in body:
errors.append(f"섹션 누락: {section}")
if "## 결정" in body:
block = extract_section(body, "## 결정")
if not any(l.strip().startswith("- D") for l in block.split("\n")):
errors.append("결정 슬롯 비어 있음 (D1, D2... 형식 필요)")
return errors
이 검사를 회의록 커밋 전 훅으로 건다. 양식을 어기면 커밋 자체가 막힌다. 권고로만 두면 바쁜 날 슬그머니 건너뛰고, 한 번 건너뛴 양식은 다음 주에 무너진다. 1~2주만 막혀 보면 양식이 손에 붙는다. 다만 너무 빡빡하면 회의록 작성 자체를 미루게 되니, 적응기 후 false positive를 한 번 정리해 주는 게 현실적인 운영이다.
양식을 통과한 회의록에서 decision_parser.py가 결정 슬롯을 읽는다. 결정 하나에서 뽑아야 할 것은 정확히 네 가지다. 무엇을 정했는가(decision), 누가 책임지는가(owner), 왜 그렇게 정했는가(rationale), 다음에 뭘 해야 하는가(follow_up). 이 네 필드가 결정을 자산으로 만든다. 특히 owner. 주인 없는 결정은 결정이 아니라 희망 사항이다. 그래서 파서는 owner가 비면 조용히 빈칸으로 두지 않고 [MISSING]을 입력해 신고한다.
여기서부터 끝까지, 회의록 한 건이 자산이 되는 과정을 한 줄도 건너뛰지 않고 따라가 본다. 입력부터 atom 승격까지의 단일 연속 예시다.
================ 입력: meetings/2026-05-18_battle_tf.md ================
---
type: meeting
date: 2026-05-18
category: battle
attendees: [이민수, teammate_a, teammate_b]
related_atoms: [combat_global_cooldown_constant]
---
## 안건
- 전투 글로벌 쿨다운(GCD) 값 통일
- 회복 스킬의 GCD 예외 여부
## 결정
- D1: 전투 글로벌 쿨다운을 0.5초로 통일한다. (소유자: teammate_a) [근거: refgame 대비 입력 반응 체감 테스트에서 0.5초가 가장 안정적]
- D2: 회복 스킬은 글로벌 쿨다운 적용에서 제외한다. [근거: 회복 사이클 끊김 우려]
## 액션 아이템
- @teammate_a: 전투 데이터 시트 cooldown 컬럼 일괄 0.5 적용 (~MM-DD)
## 다음 회의
- MM-DD 14:00, 회복 사이클 1주 테스트 결과 리뷰
================ $ python meeting_lint.py meetings/2026-05-18_battle_tf.md ================
[OK] frontmatter 4/4, 섹션 4/4, 결정 슬롯 2건 감지. 커밋 허용.
================ $ python decision_parser.py meetings/2026-05-18_battle_tf.md ================
[
{
"id": "D1",
"decision": "전투 글로벌 쿨다운을 0.5초로 통일한다.",
"owner": "teammate_a",
"rationale": "refgame 대비 입력 반응 체감 테스트에서 0.5초가 가장 안정적",
"follow_up": "전투 데이터 시트 cooldown 컬럼 일괄 0.5 적용 (~MM-DD)",
"source_meeting": "2026-05-18_battle_tf.md",
"category": "battle",
"related_atoms": ["combat_global_cooldown_constant"]
},
{
"id": "D2",
"decision": "회복 스킬은 글로벌 쿨다운 적용에서 제외한다.",
"owner": "[MISSING]", # ← 소유자 미기재. 파서가 신고함
"rationale": "회복 사이클 끊김 우려",
"follow_up": null, # ← 후속 액션도 없음
"source_meeting": "2026-05-18_battle_tf.md",
"category": "battle",
"related_atoms": ["combat_global_cooldown_constant"]
}
]
[WARN] D2: owner=[MISSING] — 주인 없는 결정. pending 생성 보류, 회의록 작성자에게 반려.
================ pending 생성: D1만 통과 ================
$ cat atoms/pending/meeting_decision_2026-05-18_D1.md
---
name: meeting_decision_2026-05-18_D1
description: 전투 글로벌 쿨다운 0.5초 통일 결정
status: pending
type: decision
source_meeting: 2026-05-18_battle_tf.md
owner: teammate_a
category: battle
related_atoms: [combat_global_cooldown_constant]
created: 2026-05-18
---
## 결정
전투 글로벌 쿨다운을 0.5초로 통일한다.
## 근거
refgame 대비 입력 반응 체감 테스트에서 0.5초가 가장 안정적.
## 후속 액션
- [ ] @teammate_a: cooldown 컬럼 일괄 0.5 적용 (~MM-DD)
================ 1주 뒤 주간 리뷰 ================
$ python promote.py atoms/pending/meeting_decision_2026-05-18_D1.md
[PROMOTE] → atoms/combat_global_cooldown_constant_decisions/meeting_decision_2026-05-18_D1.md
[JIT] manifest 등록: trigger=(전투|쿨다운|GCD|cooldown), atom 18개 → 19개
[OK] 다음 세션부터 "글로벌 쿨다운" 입력 시 이 결정 자동 주입.
이 한 박스가 파이프라인의 전부다. 주목할 곳은 D2다. 결정 내용도 멀쩡하고 근거도 있는데 owner가 비어 있다. 파서는 이걸 그냥 통과시키지 않는다. [MISSING]을 입력하고 pending 생성을 보류한 채 작성자에게 반려한다. D2는 며칠 뒤 "회복 사이클 1주 테스트 결과 리뷰" 회의에서 주인을 얻고 다시 들어온다. 빈칸을 막는 이 한 번의 반려가, 사흘 뒤 팀 메신저에서 "그거 누가 하기로 했죠?"가 영영 안 나오게 만든다.
owner 없으면 신고하는 규칙 자체는 atom 하나로 못 박아 두었다(decision_summary_not_clickup_mirror, §17.1.2). 태스크 도구에는 "데이터 시트 수정"이라는 할 일이 떠 있을 수 있지만, 그 할 일이 왜·무엇을 결정한 결과인지는 회의록 atom에만 남는다.
파서가 통과시킨 결정은 곧장 정식 atom이 되지 않고 pending/에서 일주일을 기다린다. 회의에서 자신 있게 정한 것이 일주일 운영해 보면 뒤집히는 일이 흔하기 때문이다. 위 예시의 D2가 정확히 그 위험 지대에 있었다. "회복은 GCD(글로벌 쿨다운) 제외"라는 결정이 1주 테스트에서 회복 사이클이 깨지면 다시 뒤집힐 수 있다. pending은 잉크가 마를 시간을 강제로 확보하는 칸이다.
그리고 폐기도 자산으로 남긴다. 만약 D2 같은 결정이 1주 테스트에서 무너졌다면, 그냥 지우는 게 아니라 폐기 이력 atom을 만든다.
---
name: meeting_decision_2026-05-18_D2_DISCARDED
status: discarded
discarded_reason: 1주 테스트 결과 회복 사이클 DPS 곡선 붕괴
---
## 원 결정
회복 스킬에도 글로벌 쿨다운 0.5초를 적용한다.
## 폐기 이유
1주 테스트에서 회복 사이클 DPS가 떨어져 전체 밸런스 붕괴. 제외 결정으로 환원.
## 교훈
"회복은 GCD 제외가 표준" → combat_healing_skill_cooldown_exception atom으로 승격.
폐기 이력이 다음 회의에서 "이 안건 전에 시도 안 했었나?"의 답이 된다. 같은 실수를 두 번 하지 않게 막는 가장 싼 도구다. 다만 폐기 기록이 쌓이면 검색 노이즈가 되니, 분기별로 중복을 정리하고 교훈만 남기는 손질이 필요하다.
매주 정해진 시간에 pending 후보를 일괄로 본다. 결과는 셋 중 하나다.
| 결과 | 처리 |
|---|---|
| 승격 | pending → 정식 atom 폴더 이동, JIT manifest 등록 |
| 폐기 | 결정이 뒤집힘 → pending에서 빼고 폐기 이력 atom 보존 |
| 보류 | 정보 부족 → pending 1주 연장 |
리뷰는 atom 10개당 15분 안팎. 승격이 결정되면 promote.py가 파일 이동과 manifest 갱신을 한 번에 처리한다.
# promote.py 골격
def promote(pending_path):
fm, body = parse_markdown(pending_path)
target = ATOM_BASE / f"{fm['related_atoms'][0]}_decisions" / f"{fm['name']}.md"
move(pending_path, target)
manifest = json.load(open(JIT_MANIFEST))
manifest['atoms'].append({
"name": fm['name'],
"path": str(target),
"trigger_regex": build_trigger(fm), # related_atoms + category 키워드
"description": fm['description'],
"added": today(),
})
json.dump(manifest, open(JIT_MANIFEST, "w"), indent=2)
log_promotion(fm['name'])
trigger_regex가 다음 세션에서 사용자 입력과 맞으면 이 결정이 자동으로 주입된다. 위 예시에서 "글로벌 쿨다운"을 입력하면 D1 결정과 그 근거가 따라 들어온다. 손으로 옮기던 결정이, 필요한 순간에 알아서 떠오르는 자산이 되는 지점이다.
저자의 프로젝트 A 운영 경험에서, 표준 양식만 잡았던 단계와 파이프라인을 가동한 단계를 비교한 인상이다. 아래 수치는 정밀 계측이 아니라 운영 중 체감한 방향과 대략적 비율로, 저자 추정(미검증)이 섞여 있다.
| 항목 | 양식만 (수동 추출) | 파이프라인 가동 |
|---|---|---|
| 회의록 → 결정 추출 시간 | 회의당 20~30분 | 1분 미만 |
| 결정의 atom 승격 비율 | 5~10% (정리 시간 부족) | 60~80% (전수 검토) |
| "전에 결정 안 했었나?" 재회의 | 분기당 5~10건 | 분기당 0~2건 |
| 주인 불명 결정 발생 | 추적 안 됨 | [MISSING] 신고로 즉시 가시화 |
가장 크게 바뀐 건 승격 비율이다. 손으로 정리하던 때는 시간이 없어 결정의 90% 이상이 휘발됐다. 자동화하니 전수 검토가 가능해져, 가치 있는 결정이 빠짐없이 남는다. 방향은 분명하다. 비율의 정확한 값은 팀 규모와 회의 빈도에 따라 달라진다.
| 패턴 | 처방 |
|---|---|
| lint를 권고로만 운영 | 커밋 훅으로 강제 |
| 결정 슬롯에 토론까지 적음 | 결정은 한 문장, 근거는 별도 필드 |
| owner 빈칸을 그냥 통과 | [MISSING] 신고 + pending 보류로 반려 |
| pending 리뷰가 미뤄짐 | 주간 회고에 고정 슬롯, 5분이라도 매주 |
| 폐기 이력을 안 남김 | 폐기도 별도 atom으로 보존 |
이 다섯 줄이 거의 전부다. 사람이 의지로 지켜야 하는 자리를 최대한 줄이고, 양식과 owner 검사를 기계에 맡기는 게 이 시스템의 안정점이다.
게임 밖 적용. 회의록 한 건을 양식 검사→결정 추출→1주 검증→정식 등록의 컨베이어로 흘려보내고 사람은 입구(작성)와 출구(주 1회 검수) 두 곳에만 손을 대는 이 구조는, 게임이 아니라 어떤 지식노동 팀의 문서 운영에도 이식된다. 예컨대 컨설팅 팀이 고객 미팅 노트를 다룰 때, 노트 양식만 통일해 두고 "결정·담당·근거·다음 행동" 슬롯을 LLM으로 1차 추출한 뒤, 담당이 비면
[MISSING]을 띄워 반려하고, 한 주 묵힌 결정만 정식 액션 트래커로 승격하면 됩니다. 손으로 정리할 때 90% 이상 휘발하던 미팅 결정이, 컨베이어에 올리면 전수 검토 대상이 되어 빠짐없이 남는다.
setup. 회의록 폴더에 표준 양식 템플릿을 두고, meeting_lint.py를 커밋 전 훅에 거세요. frontmatter 4필드와 섹션 4개를 필수로 잡습니다.
prompt. 회의록 한 건을 파서에 넣고 다음처럼 지시하세요.
이 회의록의
## 결정섹션에서 결정마다 decision / owner / rationale / follow_up 네 필드를 JSON으로 뽑아라. owner가 명시되지 않은 결정은 owner를[MISSING]으로 표기하고 따로 경고 줄에 모아라. 추측해서 채우지 마라.
verify. 출력 JSON에서 [MISSING]이 입력된 결정이 있으면, 그 결정은 pending을 만들지 말고 회의록 작성자에게 반려하세요. owner가 다 채워진 결정만 pending 후보 파일로 생성하고, 일주일 뒤 주간 리뷰에서 승격·폐기·보류를 정합니다.
혼자 작업한다면 스크립트 세 개와 커밋 훅까지는 과합니다. 회의록 ## 결정 섹션만 표준화하고, 결정마다 한 줄로 D1: 무엇 / 주인: 나 / 근거: 왜를 적으세요. 주 1회, 그 주의 회의록에서 결정 줄만 긁어 한 파일(decisions.md)에 모으고, 주인이 빈 줄에는 직접 [MISSING] 표시를 남겨 다음 주에 채웁니다. 스크립트는 나중에 손이 아플 때 붙여도 늦지 않습니다. 핵심은 "결정 한 줄·주인 명시·주 1회 모으기" 세 습관입니다.
회의록은 쌓는 게 목적이 아니다. 6개월 뒤에도 검색되고, 결정으로 이어지고, 두 대의 PC에서 같은 상태로 보이는 것이 목적이다.
화요일 오후. 한 해 전 회의에서 분명히 캐릭터 의상 채도를 한 단계 낮추기로 합의했던 기억이 났다. 그런데 그 회의록을 찾을 수가 없었다. 폴더를 열어보니 meeting_0413.md, 회의_수정본_final.md, IMG_2034.png 같은 파일 200개가 날짜순으로만 쌓여 있었다. 카테고리도, 캡션도, 일관된 이름도 없었다. 결정은 어딘가에 있는데, 그 결정에 도달할 길이 사라진 상태였다.
회의록이 자산이 되려면 세 가지가 동시에 작동해야 한다. 분류가 검색의 1차 진입점을 만들고, 캡션이 이미지 절반을 검색 가능하게 살려두며, 동기화가 1,000건이 넘어도 처리 비용을 변경분에만 묶어둔다. 이 셋 중 하나라도 빠지면 회의록은 쌓일수록 무거워지기만 하는 죽은 더미가 된다.
§17.1·§17.2에서 회의록을 추출 파이프라인으로 변환하는 흐름 — meeting_lint.py로 양식을 검사하고, decision_parser.py가 결정 네 필드(decision / owner / rationale / follow_up)를 뽑고, owner가 없으면 [MISSING]으로 신고하고, pending atom으로 모았다가 promote.py로 승격하는 — 을 세웠다. 이 장은 그 파이프라인이 장기적으로 망가지지 않도록 받치는 세 개의 운영 표준을 다룬다.
회의록은 시간이 지나면 수백, 수천 건이 된다. 검색이 안 되는 자료는 자산이 아니다. 카테고리는 그 검색의 첫 번째 갈림길이다. 사무실 캐비닛에 라벨을 붙이는 일과 같다. 라벨 없는 캐비닛은 결국 아무도 열지 않는다.
저자가 운영하는 프로젝트 A(MMORPG 개발)는 카테고리를 다섯 개로 묶었다. 핵심은 작고 직교하게 유지하는 것이다.
다섯 개가 모든 팀의 정답은 아니다. 비전투 시스템이 중심인 프로젝트라면 battle을 system으로 바꾸는 식의 조정이 필요하다. 핵심은 숫자가 아니라 분류 결정이 회의 자체를 막지 않을 만큼 작게 유지하는 원칙이다.
회의가 두 칸에 걸치는 일은 자주 생긴다. 캐릭터 컨셉을 리뷰하다 전투 모션까지 합의했다면 art인가 battle인가. 원칙은 주 산출물 기준 하나만이다. 컨셉이 주 산출물이면 art로 분류하고, 전투 모션은 sub_topic 필드로 보조 기록한다.
---
type: meeting_note
category: art
sub_topic: [character, battle_motion]
date: 2026-05-18
attendees: [teammate_a, teammate_b, teammate_c, 이민수]
related_atoms: [character_concept_kim, battle_motion_kim]
confidential: internal
---
sub_topic은 검색의 2차 필터일 뿐, 라우팅 결정에는 쓰지 않는다. 라우팅은 항상 category 단일 값으로만 작동한다. 이 단일 값 원칙이 무너지면 §17.2의 promote.py가 atom을 어느 폴더로 보낼지 분기할 수 없게 되고, 카테고리별 통계의 합도 어긋난다. 직교성은 미관 문제가 아니라 파이프라인 무결성의 전제다.
다섯 칸을 나눈 진짜 이유는 검색 라벨이 아니다. 칸마다 운영 방식이 달라서, 분리해야 차등 운영이 자연스럽게 설계되기 때문이다.
art는 첨부 이미지가 다수라 다음 절의 캡션 표준이 필수다. 결정이 시각 중심이라 결정 슬롯에  같은 이미지 참조가 들어간다. battle은 결정이 수치·룰이라 atom 자동 승격 비율이 가장 높고, 결정 한 줄이 데이터 시트 일괄 변경으로 이어지므로 영향 범위 가시화(11부 관계도)가 중요하다. daily는 결정이 거의 없는 게 정상이고, 누적이 빠르니 주 단위 자동 폴더(daily/2026-W21/)로 분리한다. issue는 회의록이 산만하므로 사후 24시간 내 정비를 의무로 두고 재발 방지 atom을 issue_postmortem/으로 추출한다. review는 분량이 길어 5~10줄 요약 atom을 별도로 작성해 다음 분기 회고에서 자동 인용되게 한다.
신규 카테고리 추가는 매우 신중하게 한다. 분기당 5회 이상 발생하고, 운영 방식이 기존 다섯과 명확히 다르고, 별도 라우팅 폴더가 필요하고, 한 달 뒤에도 5회 이상 유지될 때 — 이 네 조건을 모두 통과해야 검토한다. 저자의 운영 경험상 다섯 개에서 1년 이상 유지됐고, tech_review나 external 같은 후보가 떠올랐을 때도 결국 sub_topic으로 흡수됐다.
카테고리는 사람이 작성 시 직접 입력하는 게 1차다. 외부에서 받은 자료처럼 누락된 회의록만 AI 분류기로 보조한다. 키워드 사전으로 90% 정도가 잡히고, 나머지 uncertain만 LLM이나 사람이 판정한다.
LLM에 위임할 때는 제약을 강하게 거는 프롬프트가 안정적이다. 다음은 실제 사용하는 프롬프트 전문이다.
다음은 회의록입니다. 5개 카테고리 중 하나로 분류하세요.
카테고리:
- art: 비주얼·아트 방향
- battle: 전투 시스템·밸런스
- daily: 정기 진행 공유
- issue: 긴급 이슈 대응
- review: 마일스톤·QA 리뷰
회의록:
[전문 또는 첫 500자]
응답 형식: 카테고리 단어 하나만. 설명·근거·불확실 일체 금지.
응답이 5개 카테고리 중 하나가 아니면 시스템 실패로 간주합니다.
같은 회의록(아래는 art 회의의 첫머리)을 넣었을 때 Claude의 날것 출력은 이랬다.
입력 회의록:
캐릭터 K_007(학자) 컨셉 v3 리뷰. 의상 색조의 채도가 너무 높다는 의견. 한 단계 낮추기로 합의. 다음 회의에서 전투 모션 톤도 같이 점검하기로 함.
Claude 출력:
art
깔끔하게 한 단어만 나왔다. 그런데 같은 프롬프트에 daily 회의록을 넣자 이런 일도 있었다.
입력:
오늘 빌드가 새벽에 깨졌고, 원인은 데이터 시트 머지 충돌로 보임. 우선 핫픽스 후 정식 수정 예정.
Claude 출력:
issue
표면상 daily 스탠드업에서 나온 발언이지만, Claude는 내용을 보고 issue로 분류했다. 이게 바로 분류기를 1차로 쓰면 안 되는 이유다. 사람은 "이건 데일리 중 갑자기 나온 빌드 사고라 별도 issue 회의로 분리해야 한다"는 운영 판단을 한다. AI는 텍스트만 보고 라벨을 찍는다. 라벨은 맞을 수 있어도, 회의를 분리할지 말지는 결정하지 못한다. 그래서 사람이 1차, LLM은 누락분 보조에 그친다.
분기 회고에서는 카테고리별 회의 수를 집계해 "어디서 시간을 쓰는가"를 본다. 아래 분포는 저자 추정(미검증)으로, 절대 건수는 예시이고 비율의 대소 관계만 실제 운영 감각과 일치한다.
| 카테고리 | 비중(추정) | 비고 |
|---|---|---|
daily |
약 1/3 | 매일 정기, 결정은 거의 없음 |
battle |
약 1/5 | 전투 TF 주 2회 |
art |
약 1/7 | 아트 리뷰 + 외부 회의 |
issue |
낮음 | 빌드 사고 등 |
review |
가장 낮음 | 마일스톤·분기 회고 |
| 기타 | 약 1/5 | 1:1, 외부 등 비-카테고리 |
issue가 한 분기에 도드라지게 잡히면, 빌드·CI 안정성 개선이 다음 우선순위로 떠오른다. 카테고리는 검색만이 아니라 조직의 시간 배분을 비추는 거울이기도 하다.
art 회의록은 본문의 절반이 이미지다. 그리고 캡션 없는 이미지는 책상 위에 쌓인 사진 더미와 같다. 그날은 다 기억나지만, 한 달 뒤에는 뒷면에 한 줄 메모를 적어둔 사진만 살아남는다.
flowchart LR
A["회의 직후
참석자만 이해"] --> B["1주 후
작성자도 일부만 기억"]
B --> C["1개월 후
어느 결정 관련인지 불명"]
C --> D["6개월 후
사실상 폐기 · 검색 불가"]
A -.캡션 한 줄.-> E["6개월 후에도
결정 ID로 역참조 가능"]
style D fill:#fcd6d6,stroke:#d94a4a
style E fill:#d6fce0,stroke:#4ad97a
이미지가 회의록의 절반인데 검색이 안 되면, 회의록 자산의 절반이 사라진 셈이다. 그 절반을 살려두는 게 캡션 한 줄이다.
프로젝트 A의 캡션 표준은 세 줄로 끝난다.

**[그림 1]** 캐릭터 K_007 (학자) 컨셉 v3 — 의상 색조 한 단계 채도 낮춤
*결정: D2 (의상 채도 -10%) | 다음 액션: v4 작업 (~MM-DD)*
세 요소가 각각 다른 검색 경로를 연다. 번호 + 한 줄 설명은 본문에서 "그림 1 참고"로 인용할 길을, 결정 ID 참조(D2)는 "이 결정과 연결된 이미지" 역참조를, 다음 액션은 후속 작업의 단서를 남긴다. 세 줄 모두 1분 안에 쓸 수 있다. "즉시 부착"이 "회의 중 작성"을 뜻하지는 않는다. 회의 중에는 결정 정리만 하고, 끝난 직후 10분 안에 캡션을 채우는 게 현실적이다.
캡션만큼 중요한 게 파일명이다. 폴더와 파일명 자체가 검색의 첫 번째 진입점이기 때문이다.
회의록 폴더/
├── 2026-05-18_art_review.md
└── images/
└── 2026-05-18_art_review/
├── character_kim_concept_v3.png
├── env_palette_comparison.png
└── reference_external_game_a.png
규칙은 <주제>_<항목>_<버전 or 비고>.<ext>이고, 한국어·공백·특수문자는 금지한다(경로 인코딩 사고 방지). IMG_2034.png(의미 0), 김캐릭터 v3.png(한국어·공백), final_final_v3_real.png(버전 무의미), untitled.png(폐기 후보)는 전부 안티패턴이다. 이런 이름은 사람 의지에 기대지 말고 meeting_lint.py에 검사 규칙을 추가해 강제하는 게 낫다 — §17.2에서 양식 검사를 자동화한 그 린트에 파일명 검사 한 줄을 얹는 것으로 충분하다.
회의에서는 외부 게임·아트를 참고로 인용하는 일이 잦다. 출처가 없으면 저작권 사고로 직결된다.

**[그림 3]** 참고 이미지 — refgame (Developer Y, 2024)
*인용 사유: 비슷한 컨셉의 채도 처리 비교. 직접 차용 없음.*
출처(게임명·개발사·연도)·인용 사유·직접 차용 여부를 모두 명시한다. 그리고 이미지는 텍스트보다 유출 위험이 크므로 등급을 frontmatter에 단다.
confidential: internal # internal / restricted / external_ok
images:
- file: character_kim_concept_v3.png
confidential: restricted
reason: 미공개 캐릭터 디자인
internal은 회사 내부 공유, restricted는 해당 TF·담당자만, external_ok는 마케팅·외부 공유 승인을 뜻한다. 회의록 빌드 시 등급별로 출력을 분리하고, external_ok가 아닌 이미지는 외부 공유본에서 자동 블러 처리한다. 이 자동 분리가 외부 공유 마스킹 사고를 사실상 0으로 만드는 직접 효과를 낸다.
이미지 50장에 캡션 50개를 손으로 쓰는 건 부담이다. AI에게 본문과 파일명을 주고 일괄 초안을 받는다.
다음은 회의록 본문 + 이미지 파일 목록입니다.
[회의록 본문]
[이미지 파일명 10개]
각 이미지에 대해 caption 초안을 작성하세요.
형식:
- [그림 N] <설명> — <핵심 결정 또는 변화>
- *결정: D? | 다음 액션: ?*
본문에서 근거를 찾지 못한 이미지는 "내용 불명 — 작성자 확인 필요"로 표시.
여기서 마지막 줄이 핵심이다. 같은 회의록을 넣었을 때 Claude는 본문에 근거가 있는 이미지엔 캡션을 달았지만, reference_external_game_a.png에는 이렇게 답했다.
Claude 출력 (발췌):
[그림 3] reference_external_game_a.png — 내용 불명, 작성자 확인 필요. 본문에 이 외부 참고 이미지의 인용 사유가 명시되어 있지 않습니다.
AI가 모르는 것을 모른다고 신고한 것이다. 이걸 받아 작성자가 인용 사유를 채운다. 본문 컨텍스트만으로 부족하면 핵심 5~10장만 골라 Vision 모델에 보낸다(이미지 토큰 비용이 크므로 전부 돌리지 않는다).
# 핵심 이미지 5~10장만 선택 적용 — 이미지 1장당 토큰 비용 큼
response = client.messages.create(
model="claude-opus-4-8",
messages=[{
"role": "user",
"content": [
{"type": "image", "source": {"type": "base64", "data": img_b64}},
{"type": "text", "text": "이 이미지를 한국어 한 줄로 설명. 추측 금지, 보이는 것만."},
],
}],
)
작성자는 이 한 줄을 캡션 형식으로 정비한다. 모든 이미지에 Vision을 돌릴 필요는 없다. 핵심 5~10장만으로도 검색 가능성은 충분히 올라간다.
캡션이 잘 작성된 1년치 회의록은 그 자체로 비주얼 디벨롭먼트 다큐멘트가 된다. character_kim v1 → v2 → v3의 시각적 변화를 결정 ID로 추적할 수 있고, external_ok 등급만 필터하면 외부 보고 자료가 자동 큐레이션되며, 분야별 핵심 이미지 + 캡션을 모으면 신규 팀원 온보딩 자료가 된다. 캡션 도입 전후의 변화를 저자 추정(미검증)으로 표현하면 방향은 이렇다 — 6개월 전 회의록 검색 성공률은 크게 오르고, "이 이미지 어디서 봤지?" 재질문은 크게 줄고, 외부 공유 마스킹 사고는 0에 수렴한다. 절대 수치는 팀마다 다르겠지만, 단계 1·2(파일명 표준 + 캡션 양식)만으로도 그 방향은 분명하게 나타났다.
회의록 자체는 텍스트 파일이라 git으로 충분하다. 동기화의 진짜 대상은 회의록에서 파생된 데이터들이다 — §17.2의 pending atom 후보, JIT manifest, 카테고리 통계, 결정 인덱스(decision_index.json), 캡션 인덱스, confidential 등급별 빌드 출력, 그리고 벡터 검색용 LLM 임베딩. 이 데이터들이 회의록 변경에 모두 반응해야 한다.
문제는 회의록이 1,000건을 넘어가면 매번 전체를 다시 처리하는 비용이 운영의 절반을 차지한다는 점이다. 작업 라인 전체를 멈추고 모든 부품을 다시 만드는 것과 같다 — 부품 하나만 바뀌었는데도.
flowchart TB
subgraph Full["Full Sync · 100건 미만에서 유효"]
F1["전체 회의록 1,000건"] --> F2["모든 파생 데이터
처음부터 재생성"]
F2 --> F3["인덱스·임베딩 전부 교체"]
end
subgraph Inc["Incremental · 200건 넘으면 필수"]
I1["git diff로
변경 N건만 감지"] --> I2["해당 N건의
파생 데이터만 재생성"]
I2 --> I3["인덱스 부분 갱신
추가·수정·삭제 분기"]
end
Full -.회의록 200건 돌파.-> Inc
style Full fill:#fce7d6,stroke:#d98a4a
style Inc fill:#d6fce0,stroke:#4ad97a
Full Sync는 구현이 단순하고 상태 불일치 위험이 0이라 도입 초기(100건 미만)에는 오히려 더 안전하다. Full이 나쁜 방식이라는 뜻이 아니다. 다만 회의록 수에 선형으로 비례하는 비용이 200건을 넘기는 자리부터 병목이 된다. 그때 Incremental로 전환한다.
Incremental의 첫 단계는 "어떤 파일이 바뀌었는가"를 정확히 판정하는 것이다. 파일 mtime은 빠르지만 touch만 해도 변경으로 잡혀 정확도가 낮다. 파일 해시는 내용 기준이라 정확하지만 추가·삭제 구분이 약하다. 저자의 권장은 git diff 기반이다. 마지막 sync 시점의 커밋 해시를 기록해두고, 그 이후 변경된 파일만 처리한다. 추가·수정·삭제를 모두 정확히 잡으면서 별도 상태 관리 부담이 가장 작다.
# incremental_sync.py 골격
def get_changed_files(last_sync_commit):
result = subprocess.run(
["git", "diff", "--name-only", last_sync_commit, "HEAD", "--", "meetings/"],
capture_output=True, text=True
)
return result.stdout.strip().split("\n")
def sync():
last_commit = read_state("last_sync_commit")
for path in get_changed_files(last_commit):
if not os.path.exists(path):
handle_deletion(path) # atom·인덱스·임베딩 일괄 삭제
elif is_new(path, last_commit):
handle_creation(path) # lint → 결정 추출 → pending atom → 인덱스 → 임베딩
else:
handle_modification(path) # 기존 파생 무효화 후 재처리
write_state("last_sync_commit", get_current_commit())
여기서 비용을 가장 크게 가르는 분기가 하나 더 있다. 회의록이 본문까지 바뀐 건지, frontmatter만 바뀐 건지다.
def detect_change_scope(file_path, last_commit):
diff = subprocess.run(
["git", "diff", last_commit, "HEAD", "--", file_path],
capture_output=True, text=True
).stdout
fm_lines, body_lines = split_diff_by_section(diff)
return {"frontmatter_changed": bool(fm_lines), "body_changed": bool(body_lines)}
scope = detect_change_scope(path, last_commit)
if scope["body_changed"]:
full_reprocess(path) # 임베딩 재생성 포함
elif scope["frontmatter_changed"]:
metadata_only_update(path) # 임베딩 재생성 0
category나 confidential 같은 메타만 바뀌었다면 LLM 임베딩을 다시 만들 필요가 없다. 임베딩은 보통 동기화 비용의 가장 큰 덩어리라, 이 한 번의 분기가 비용을 크게 줄인다. 임베딩은 content_hash 기준으로 캐싱한다 — 본문 해시가 같으면 캐시된 임베딩을 그대로 재사용하고, frontmatter만의 수정에서는 임베딩 호출이 0이 된다.
비용 차이의 방향은 분명하다(아래는 저자 추정, 절대값 아님). 주당 50건 정도 변경되는 운영에서 매주 Full re-embed 대비 Incremental의 임베딩 비용은 수십 분의 일 수준으로 떨어졌다. 회의록이 늘어날수록 Full의 비용은 누적에 비례해 커지는 반면, Incremental의 비용은 주당 변경 건수에만 묶여 누적과 무관하게 거의 평평했다. 이 "누적 무관" 성질이 Incremental의 본질적 가치다.
Incremental은 빠른 대신 누적 불일치 위험을 안고 간다. 작은 버그로 한 건의 atom이 누락되면, 그 누락은 다음 Incremental에서 스스로 고쳐지지 않는다. 그래서 가드레일을 끼운다 — 매일 Incremental, 매주 최근 1주분 Partial Full(검증), 매월 전체 Full re-sync로 인덱스·임베딩 일관성을 점검한다. 매월 점검에서 불일치가 발견되면 변경 감지 로직을 보강한다. 이 매월 1회가 장기 운영의 마지막 안전망이다.
여기에 PC 분리 운영이 한 겹 더 얹힌다. 저자는 회사 PC와 집 PC 두 곳에서 회의록을 다룬다. 원칙은 sync 작업은 한 PC에서만 수행하는 것이다.
| 흐름 | 처리 |
|---|---|
| 회사 PC → git push | 회사 PC가 sync 작업(파생 데이터 재생성) 담당 |
| 집 PC → git pull | last_sync_commit만 갱신, 재처리 불필요 |
| 양쪽 모두 변경 후 merge | merge 결과를 기준으로 changed 파일 재산정 |
양쪽에서 동시에 sync하면 last_sync_commit 상태가 충돌하고, 그 충돌은 조용히 인덱스를 어긋나게 만든다. 한 PC를 sync 주체로 고정하는 단순한 규칙이 가장 확실한 방어다.
분류·캡션·동기화는 따로 노는 표준이 아니다. 셋은 §17.2의 추출 파이프라인 위에서 한 흐름으로 묶인다.
회의록이 작성되면 category가 promote.py의 라우팅을 결정하고, 캡션의 결정 ID가 decision_parser.py가 뽑은 결정 네 필드와 연결되며, 그렇게 만들어진 모든 파생 데이터를 Incremental sync가 변경분만 골라 갱신한다. 이 장 운영의 출발점은 decision_summary_not_clickup_mirror(§17.1.2)다. 분류가 결정을 찾을 길을 열고, 캡션이 결정의 시각 증거를 남기고, 동기화가 그 결정 자산을 두 PC에서 같은 상태로 보존한다.
화요일 오후의 그 막막함 — 분명히 합의했는데 도달할 길이 없던 그 상태 — 은 이 세 축이 작동하는 순간 사라진다. category: art로 폴더가 좁혀지고, 캡션의 결정: D2로 정확한 결정에 닿고, 동기화가 그 회의록을 집에서도 같은 모습으로 보여준다.
게임 밖 적용. 자료가 검색·참조·동기화되어야 비로소 자산이 된다는 원칙은 게임 회의록만의 이야기가 아니라, 문서를 다루는 모든 직장인의 공통 과제다. 분류(작고 직교한 카테고리)·캡션(첨부 이미지 한 줄 설명)·동기화(전체가 아닌 변경분만)라는 세 축은 도메인을 갈아끼워도 그대로다. 예컨대 영업팀이 1년치 고객 미팅 자료를 쌓는다면, 카테고리를 "신규제안·계약협상·사후지원" 다섯 칸 이하로 고정하고, 첨부한 견적서 캡처마다 "[그림 1] A사 2차 견적 — 단가 5% 인하" 같은 한 줄을 달고, 클라우드 동기화는 바뀐 파일만 골라 처리하면 됩니다. 그래야 반년 뒤 "그때 단가 왜 깎아줬더라"를 캡션 한 줄로 즉시 찾을 수 있다.
setup
1. 회의 카테고리를 5개 이하로 정의하세요(art / battle / daily / issue / review를 출발점으로, 팀에 맞게 1~2개 치환).
2. meeting_lint.py에 두 검사를 추가하세요 — category가 정의된 값 중 하나인지, 이미지 파일명이 <주제>_<항목>_<버전> 패턴(한국어·공백 없음)인지.
3. frontmatter에 confidential 필드와 last_sync_commit을 기록할 상태 파일을 준비하세요.
prompt (누락된 회의록 분류 보조)
다음은 회의록입니다. 5개 카테고리 중 하나로 분류하세요.
[카테고리 정의 5줄] / [회의록 첫 500자]
응답 형식: 카테고리 단어 하나만. 설명·근거·불확실 일체 금지.
응답이 5개 카테고리 중 하나가 아니면 시스템 실패로 간주합니다.
verify
1. 임의의 6개월 전 회의록을 카테고리 + 캡션 결정 ID만으로 찾을 수 있는지 직접 검색해 보세요.
2. git diff --name-only <last_sync_commit> HEAD로 잡힌 변경 파일 수와 실제 수정한 회의록 수가 일치하는지 확인하세요.
3. AI 분류 결과를 무비판 수용하지 말고, uncertain과 "데일리 중 발생한 결정" 케이스를 사람이 한 번 더 보세요.
혼자 일하는 기획자라면 이렇게 줄이세요.
decision(결정이 있는 회의)과 log(진행 기록). 결정이 있는 회의만 캡션·atom을 챙기고, 나머지는 날짜 폴더에 쌓아둡니다.decision_index.json(결정 ID → 회의록 경로 매핑) 한 파일만 두고, 회의록 저장 시 그 줄만 갱신하세요. git이 곧 동기화이고, Full/Incremental을 구분할 만큼 양이 쌓이기 전까지는 매번 전체 재생성으로 충분합니다.1인 규모에서도 변하지 않는 핵심은 하나입니다 — 결정에 도달할 길을 남기는 것. 분류·캡션·동기화는 그 길을 떠받치는 세 개의 기둥일 뿐, 규모에 맞게 얼마든지 가늘게 세워도 됩니다.
마일스톤 데모를 사흘 앞둔 점심, 기획자 한 명이 식판을 내려놓으며 묻는다. "퀘스트 보상 골드를 1.5배로 올리기로 한 거, 그거 회의에서 정한 거 맞죠? 데이터 시트에 넣어도 돼요?" 옆자리가 답한다. "그건 누가 그냥 해보면 어떻겠냐고 한 거 아니었나." 90분짜리 녹취 파일과 누군가 키보드로 받아친 두 페이지 메모는 분명히 있다. 그런데 그 기록은 "무엇을 이야기했는가"는 담았지만 "무엇을 결정했고, 누가 책임지며, 왜 그랬는가"는 담지 못했다.
회사의 R&D 문서 17건을 통증 순으로 줄 세웠을 때, 가장 큰 비중을 차지한 것은 회의록 개선 계획서였다. 의외였다. 전투 밸런스도, 콘텐츠 양산 파이프라인도 아니었다. 가장 아픈 곳은 회의에서 내린 결정이 실행으로 전파되지 않는다는 것, 그 단 하나였다.
그래서 회의록 시스템을 결정 추적 데이터베이스로 다시 설계했고, 그 흐름의 어디에 AI를 넣고 어디에 넣지 말아야 하는지를 6개월간 직접 운영하며 검증했다. 이 장은 그 5개 지점의 지도다.
녹취 파일에서 시작해 의사결정 그래프 갱신까지, 회의록 파이프라인 전체에 AI 보조원을 둘 수 있는 자리는 정확히 5개다. 5개 모두에 동시에 넣지는 않는다. 자리마다 성숙도와 사고 위험이 다르기 때문이다.
flowchart TD
A[회의 녹취 음성] -->|"위치 1
STT"| B[원본 텍스트]
B -->|"위치 2
초안 생성"| C[회의록 초안]
C -->|"meeting_lint.py"| D[표준 양식 회의록]
D -->|"위치 3
결정 슬롯 보강"| E[결정 4필드 완성]
E -->|"decision_parser.py
위치 4 라우팅"| F[pending atom]
F -->|"promote.py
위치 5 관계 추출"| G[정식 atom + 그래프]
style C fill:#ffd9d9,stroke:#c0392b
style E fill:#d9f0ff,stroke:#2980b9
style F fill:#d9f0ff,stroke:#2980b9
빨강(위치 2)이 가장 매력적이면서 가장 위험한 자리, 파랑(위치 3·4)이 가장 먼저 도입한 안전한 자리다. 파이프라인 중간의 meeting_lint.py → decision_parser.py → promote.py는 AI가 아니라 결정론적 스크립트다. AI는 이 결정론적 골격 사이의 "판단이 필요한 틈"에만 들어간다.
각 지점의 성격을 한 줄로 요약하면 이렇다.
AI 자동화를 이야기하기 전에, AI가 아닌 스크립트 골격을 먼저 봐야 한다. 회의록이 결정 데이터베이스가 되는 핵심은 LLM이 아니라 세 개의 작은 파이썬 스크립트에 있기 때문이다.
표준 양식 회의록은 마지막에 결정 블록을 갖는다. 블록의 각 결정은 네 개의 필드를 강제한다.
## Decisions
D1:
decision: 전투 글로벌 쿨다운을 0.5초로 통일한다
owner: teammate_a
rationale: 스킬 연계 테스트에서 0.3초는 입력 누락이 잦았음 (본문 14:22)
follow_up: 콤보 설계 시트에 GCD 0.5 반영, 6/13까지
이 블록을 decision_parser.py가 읽는다. 핵심 동작은 단순하다 — 네 필드 중 하나라도 비어 있으면 [MISSING]을 찍어 신고한다. 특히 owner가 없으면 그 결정은 "아무도 책임지지 않는 결정", 즉 실행되지 않을 결정이므로 가장 강하게 막는다.
$ python decision_parser.py 2026-06-06_combat-sync.md
D1: OK (owner=teammate_a)
D2: [MISSING owner] "회복 스킬은 GCD 제외 검토" — owner 없음, 승격 차단
D3: [MISSING rationale] 근거 필드 비어 있음, 경고
[MISSING owner]가 찍힌 D2는 pending 폴더로조차 가지 못한다. 사람이 owner를 채워 넣기 전까지 결정으로 취급되지 않는다. 이것이 "회의는 했는데 아무것도 안 굴러갔다"를 구조적으로 막는 장치다.
통과한 결정은 promote.py가 pending atom으로 만들고, 주 1회 리뷰 게이트에서 사람이 승인하면 정식 atom으로 승격된다. 이때 적용되는 원칙이 decision_summary_not_clickup_mirror atom이다(§17.1.2). 태스크 보드는 "무엇을 할지"를, 결정 데이터베이스는 "왜 그렇게 정했는지"를 추적한다. 둘을 섞으면 둘 다 망가진다.
이 세 스크립트가 골격이고, AI는 이 골격의 빈칸을 채우는 보조원이다. 순서가 거꾸로 되면 — AI가 골격을 만들면 — 환각이 결정 데이터베이스의 신뢰 자체를 무너뜨린다.
위치 2(STT 텍스트 → 회의록 초안 자동 생성)는 모든 팀이 가장 먼저 하고 싶어 하는 자리다. "녹취만 던지면 회의록이 나온다"는 그림이 너무 매력적이기 때문이다. 그리고 바로 그 매력 때문에 가장 비싸게 실패한다.
실패 양상은 네 가지다.
flowchart TD
R[STT 텍스트 → AI 초안] --> R1["환각 결정
합의 안 한 걸 결정으로 기록"]
R --> R2["결정 누락
실제 결정을 흘려보냄"]
R --> R3["화자 오인
제안자를 뒤바꿈"]
R --> R4["톤 평준화
반대 의견·뉘앙스 소실"]
R1 --> X["결정 추적이라는
회의록의 존재 이유 붕괴"]
R2 --> X
R3 --> X
R4 --> X
style X fill:#ffd9d9,stroke:#c0392b
이 중 가장 치명적인 것이 환각 결정이다. 회의에서 누군가 "글로벌 쿨다운은 0.5초가 낫지 않을까요?"라고 의견을 던졌을 뿐인데, AI 초안이 "글로벌 쿨다운 0.5초로 합의"라고 적어버린다. 3주 뒤 이 한 줄은 데이터 시트에 반영되고, 콤보 설계가 그 위에 쌓이고, QA 케이스가 작성된다. 합의된 적 없는 결정이 비가역적으로 전파된다.
그래서 위치 2에는 절대 원칙이 적용된다.
위치 2를 "영영 안 한다"는 뜻이 아니다. 위치 3·4·1이 안정되고 진행자가 AI 출력의 한계를 몸으로 알게 된 후에는, 위치 2의 도입 가치가 충분히 크다. 다만 순서가 마지막이라는 것이다.
여기가 6개월 운영에서 가장 큰 효과를 낸 자리다. 사람이 결정의 존재를 선언하고, AI가 그 결정의 부속 필드를 채운다. 위치 2와 결정적으로 다른 점은 결정이 있다는 사실 자체를 사람이 먼저 못 박는다는 것이다.
사람이 직접 손으로 채우기엔 시간이 너무 드는 세 가지를 AI가 초안한다.
핵심은 프롬프트가 근거 인용을 강제하고 환각을 명시적으로 금지하는 데 있다. 다음은 실제로 운영한 보강 프롬프트의 전문이다.
프롬프트 (사람이 입력):
아래 회의 본문 보고, 진행자가 선언한 결정 D1("전투 글로벌 쿨다운 0.5초로 통일")의
owner / rationale / affected_atoms / follow_up 네 칸만 채워줘.
결정을 새로 만들지는 말고. rationale은 본문 발언을 타임스탬프 그대로 인용하고,
근거 없는 칸은 추측하지 말고 "근거 없음"이라고 적어줘.
// (의도: affected_atoms는 본문에 언급된 시스템·시트만, 최대 3개)
[회의 본문]
(14:18) teammate_a: 지금 GCD가 0.3초인데 스킬 연타할 때 입력이 자꾸 씹혀요.
(14:22) teammate_b: 어제 콤보 테스트에서도 0.3초는 두 번째 스킬이 안 나가는
케이스가 30프로쯤 됐어요. 로그 남겨놨습니다.
(14:25) teammate_a: 그럼 0.5초로 올리죠. 제가 콤보 시트에 반영하겠습니다.
(14:26) 진행자: 0.5초로 갑니다. 결정.
(14:31) teammate_c: 회복 스킬은 GCD 적용하면 위급할 때 못 쓰는데 그건 어떻게...
(14:33) 진행자: 그건 다음에 따로 봅시다.
Claude 출력 (날것 그대로):
1. owner: teammate_a
근거: (14:25) "제가 콤보 시트에 반영하겠습니다." — teammate_a가
본인 책임으로 명시.
2. rationale:
- (14:18) teammate_a: "GCD 0.3초에서 스킬 연타 시 입력 씹힘"
- (14:22) teammate_b: "콤보 테스트에서 0.3초는 두 번째 스킬 미발동
케이스 약 30%, 로그 보유"
→ 0.3초의 입력 누락 문제가 0.5초 결정의 직접 근거.
3. affected_atoms (본문 언급 기반 후보):
- atoms/combat/global_cooldown
- 콤보 설계 시트 (본문 14:25 "콤보 시트" 언급)
- atoms/combat/skill_recovery ← 단, (14:31)에서 회복 스킬은
GCD 예외 논의가 "다음으로 미뤄짐"(14:33). 이 결정의 영향 범위에
포함할지 불확실. 근거 약함, 사람 확인 필요.
4. follow_up:
- teammate_a: 콤보 설계 시트에 GCD 0.5초 반영
- [별도 안건] 회복 스킬의 GCD 예외 여부는 본 결정 범위 밖,
다음 회의 안건으로 분리 (14:33 진행자 발언)
사람의 검증/거부:
진행자가 출력을 검수했다. owner와 rationale은 인용이 정확해 그대로 채택했다. affected_atoms의 세 번째 후보 skill_recovery는 AI가 스스로 "근거 약함, 사람 확인 필요"라고 신고했고, 진행자 판단으로 이 결정의 영향 범위에서 제외했다 — 회복 스킬 예외는 별도 결정이 될 사안이지 이번 D1의 영향이 아니다. follow_up의 "별도 안건 분리" 제안은 채택해 다음 회의 안건으로 등록했다.
여기서 중요한 것은 AI가 불확실한 항목을 환각으로 밀어붙이지 않고 스스로 불확실성을 신고했다는 점이다. "추측·환각 금지, 근거 없으면 근거 없음이라고 명시"라는 프롬프트의 제약이 이 정직한 출력을 만들었다. 제약을 빼면 AI는 skill_recovery를 자신 있게 affected_atoms에 넣고, 그 환각이 그래프에 전파된다.
검수를 마친 결정 블록은 decision_parser.py를 통과하고 — 네 필드가 모두 채워졌으므로 [MISSING] 없이 — pending atom으로 넘어간다.
위치 3을 통과한 pending atom이 정식 폴더로 승격될 때, 어느 폴더로 보낼지를 AI가 추천한다(위치 4).
이 atom("전투 글로벌 쿨다운 0.5초 통일 / owner teammate_a")을 아래 폴더 중
어디에 넣으면 좋을지 우선순위로 최대 3개 골라줘. 새 폴더 만드는 제안은 하지 말고
이 목록 안에서만.
- atoms/combat/ atoms/character/ atoms/operations/ atoms/visual/
"새 폴더 생성 금지"가 핵심 제약이다. 이걸 빼면 AI는 atoms/combat_timing/, atoms/gcd_rules/ 같은 폴더를 끝없이 제안하고, 카테고리가 무한 증식해 검색과 자동 주입이 무너진다. 카테고리는 작고 직교하게 유지하며 1년 이상 변동 없이 가는 것이 원칙이다. AI는 그 닫힌 목록 안에서만 고른다.
위치 5(atom 간 관계 추출)는 가장 늦게, 가장 신중하게 도입한다. 승격된 atom들 사이의 의존 관계를 추론하는 자리다.
신규 atom A: "회복 스킬은 글로벌 쿨다운 적용 제외"
기존 atom B: "글로벌 쿨다운 0.5초 통일"
추론된 관계:
A.exception_of: [B]
A.derives_from: [B]
B.affects: [A] ← 역방향 자동 부여
문제는 이 추론이 LLM의 비결정론에 정면으로 노출된다는 것이다. 같은 입력에 어제와 오늘 다른 관계가 나온다. 완화 장치는 세 가지다 — temperature=0과 가능한 모델에서 seed 고정, 후보를 제시하고 사람이 승인하는 검수 게이트, 그리고 단방향만 추출한 뒤 역방향은 스크립트가 결정론적으로 보정하는 방식이다. 양방향을 둘 다 LLM에 맡기면 한쪽이 누락되기 때문이다.
5개 지점을 동시에 켜는 것이 가장 흔하고 비싼 실패다. 운영 부담이 효과보다 먼저 도달해 팀이 시스템을 통째로 버린다. 다음은 실제로 따라간 순서다.
위치 2를 가장 늦게 두는 것이 이 순서의 핵심이다. 가장 하고 싶은 자리를 가장 나중에 한다 — 직관에 반하지만, 가장 위험한 검수대에 가장 숙련된 손이 갔을 때 배치하는 것이 작업장의 안전 원칙이다.
비용 측면에서도 이 순서가 합리적이다. 회의 100건/월 기준으로 위치 3은 약 $5~10, 위치 4는 $1~2 수준이라(저자 운영 환경 기준 추정, 미검증), 둘만 켜도 월 $10 미만이다. 가장 큰 효과를 내는 두 자리가 가장 싸다.
같은 회의를 두 방식으로 기록했을 때의 차이가 이 장 전체의 요약이다.
Before — 자유 서술 회의록 (AI 없음, 또는 위치 2를 결정 슬롯까지 맡긴 경우):
## 2026-06-06 전투 동기화 회의
GCD 관련 논의함. 0.3초가 너무 짧다는 의견 나옴.
콤보 테스트에서 문제 있었다고 함. 0.5초 얘기 나옴.
회복 스킬 예외도 잠깐 언급됨.
대체로 0.5초 방향으로 정리되는 분위기였음.
3주 뒤 이 회의록을 다시 열면, "0.5초로 정리되는 분위기"가 결정인지 의견인지, 누가 시트에 반영하기로 했는지, 회복 스킬 예외는 결정됐는지 미뤄졌는지를 아무도 복원할 수 없다. 화자도 없고, owner도 없고, 근거도 본문 어디쯤이라 다시 녹취를 들어야 한다.
After — 결정 슬롯 + 위치 3 보강 회의록:
## 2026-06-06 전투 동기화 회의
### 안건 요약 (AI 보조)
- 전투 글로벌 쿨다운(GCD) 0.3초의 입력 누락 문제
- 회복 스킬의 GCD 예외 여부 (별도 안건으로 분리)
### Decisions (사람 선언 + AI 보강)
D1:
decision: 전투 글로벌 쿨다운을 0.5초로 통일한다
owner: teammate_a
rationale: |
- (14:18) teammate_a: 0.3초에서 스킬 연타 시 입력 씹힘
- (14:22) teammate_b: 콤보 테스트 0.3초 두 번째 스킬 미발동 ~30%, 로그 보유
follow_up: teammate_a — 콤보 설계 시트에 GCD 0.5초 반영 (6/13까지)
affected_atoms: [atoms/combat/global_cooldown, 콤보 설계 시트]
### 분리된 안건
- 회복 스킬 GCD 예외 → 다음 회의 (14:33 진행자 결정)
3주 뒤 이 회의록은 decision_parser.py가 읽어 그래프에 연결되어 있고, "왜 0.5초인가"를 묻는 누구든 rationale의 두 줄 인용으로 즉답할 수 있다. owner가 명시되어 있어 follow_up이 실행됐는지 추적되고, 회복 스킬 예외가 결정이 아니라 미뤄진 안건이라는 사실까지 보존된다.
차이를 만든 것은 AI의 분량이 아니라, 사람이 결정을 선언하는 자리를 보존한 채 AI에게 근거 채우기만 맡긴 구조다. 위 After 회의록에서 AI가 채운 문단(안건 요약, rationale 인용, affected_atoms 후보)을 모두 지우면, 남는 것은 결정 한 줄과 owner뿐이다 — 회의록의 정보량 절반 이상이 AI 보강에서 나왔지만, 그 절반이 모두 사람 검수를 통과한 근거 인용이라는 점이 핵심이다.
게임 밖 적용. "결정의 존재는 사람이 선언하고, AI는 근거·책임자·영향만 채운다"는 원칙은 게임이 아니라 녹취를 AI로 정리하는 모든 직장인에게 그대로 적용되는 안전선이다. 가장 매력적인 자리(녹취를 통째로 회의록으로 자동 생성)가 가장 위험한 이유는, AI가 "0.5초가 낫지 않을까요"라는 의견을 "0.5초로 합의"라는 결정으로 둔갑시키는 환각 때문이다. 예컨대 인사팀이 평가 회의 녹취를 정리할 때, "B등급으로 확정한다"는 결정만 진행자가 직접 못 박고, AI에게는 "이 등급의 근거 발언을 녹취에서 인용해줘, 없으면 없다고 해"만 시키세요. 결정을 AI가 만들게 두면, 합의된 적 없는 평가가 인사 기록에 비가역적으로 남는다.
setup
1. 회의록 표준 양식에 ## Decisions 블록을 만들고 각 결정에 decision / owner / rationale / follow_up 4필드를 강제하세요.
2. decision_parser.py를 작성하세요 — 4필드 중 하나라도 비면 [MISSING <필드>]를 출력하고, 특히 owner가 비면 승격을 차단합니다.
3. 결정 요약이 태스크 보드의 거울이 아니라 "왜"를 담는 독립 자산이라는 규칙(decision_summary_not_clickup_mirror)을 명문화하세요.
prompt 4. 위치 3 보강 프롬프트를 쓰세요. 반드시 포함할 제약: "결정을 새로 만들지 말 것 / 본문 근거를 타임스탬프와 함께 인용 / 근거 없으면 '근거 없음' 명시 / 추측·환각 금지". rationale·owner·affected_atoms·follow_up 네 슬롯을 요구합니다. 5. 위치 4 라우팅 프롬프트에는 "목록에 없는 새 폴더 생성 금지 + 닫힌 폴더 목록"을 넣으세요.
verify
6. 보강된 결정 블록을 decision_parser.py로 돌려 [MISSING]이 없는지 확인하세요.
7. AI가 채운 affected_atoms 중 "근거 약함" 신고 항목을 사람이 직접 검수해 제외/채택하세요. 자동 커밋은 어떤 경우에도 하지 않습니다.
1인 축소판
혼자 작업하거나 도구를 깔 시간이 없다면, 스크립트 없이 회의록 끝에 손으로 결정 블록 네 줄(결정 / 담당 / 근거 / 다음 액션)만 적으세요. owner가 자기 자신이라도 이름을 씁니다. AI에게는 "이 결정의 근거를 회의 메모에서 인용해줘, 없으면 없다고 해"라고만 시키세요. 파이프라인이 없어도, 결정을 선언하는 자리와 근거 인용을 강제하는 프롬프트 둘만으로 회의록은 결정 데이터베이스가 되기 시작합니다.
분기 회의 한복판이었다. 전투 디자이너가 "글로벌 쿨다운을 0.5초로 통일하자"고 제안했고, 모두 고개를 끄덕였다. 그런데 옆자리 시니어가 손을 들었다. "이거 작년 4분기에 0.3초로 결정했던 거랑 충돌하는 거 아닌가요? 그때 왜 0.3초로 갔었죠?" 회의실이 잠시 조용해졌다. 아무도 그 결정의 근거를 기억하지 못했다. 회의록을 뒤졌지만 "전투 TF에서 논의함" 한 줄만 있었다. 결국 30분을 작년 결정을 재구성하는 데 썼고, 그래도 "왜 0.3이었는지"는 끝내 못 찾았다.
결정은 만들기보다 추적이 어렵다. 1년에 수백 건이 쌓이면 어느 결정이 살아 있고 어느 것이 폐기됐는지, 어느 결정이 다른 결정을 전제로 깔고 있는지 사람 머리로는 따라잡지 못한다. 이 챕터는 결정을 atom으로 박제해 추적 가능한 자산으로 바꾸는 시스템을 다룬다. 핵심은 단순하다. 결정 한 건을 decision_id·owner·rationale을 갖춘 카드로 기록하고, 카드끼리 wikilink로 연결해 그래프를 만들고, 영향이 어디까지 번지는지를 grep으로 역추적한다.
결정 추적의 최소 단위는 결정 카드다. 저자가 운영하는 프로젝트 A(MMORPG 개발)에서 실제로 쓰는 카드 한 장을 그대로 가져왔다. 앞서 회의에서 충돌한 바로 그 0.5초 통일 결정이다.
---
decision_id: D2026_Q2_017
title: 전투 글로벌 쿨다운 0.5초 통일
type: system_change
status: active # active / superseded / deprecated
created: 2026-04-18
owner: teammate_a # 전투 디자이너, 결정 발의·소유자
approved_by: 이민수 # Design Director
approval_meeting: 95_BattleTF_2026-04-18
scope:
- combat_system
- all_active_skills
content: |
모든 전투 액티브 스킬에 글로벌 쿨다운 0.5초 적용.
회복 스킬은 예외 (별도 결정 D2026_Q2_018).
rationale:
- 콤보 입력 가독성 문제 (사용자 피드백 누적)
- 시뮬상 전투 평균 길이 증가 방향
- 신규 사용자 학습 곡선 완만화
affected_atoms:
- combat_global_cooldown_constant
- combat_skill_cooldown_rule
affected_files:
- CombatBalance.xlsx
- CombatFormula_v3.md
- UI/skill_cooldown_indicator
implementation:
target_build: 2026-05-09
impl_owner: teammate_b # 코드 리드
qa_owner: teammate_c # QA 시니어
related_decisions:
- supersedes: D2025_Q4_034 # 이전 0.3초 결정
- relates_to: D2026_Q2_018 # 회복 예외
---
세 칸이 척추다. decision_id는 결정에 영구 주소를 준다. owner는 "누가 이 결정을 책임지는가"를 못박는다. rationale은 6개월 뒤 "왜 그랬지?"에 답한다. 회의에서 못 찾았던 그 "왜 0.3이었는가"가 바로 D2025_Q4_034의 rationale 칸에 있었어야 할 내용이다. 나머지 칸(scope·affected_atoms·related_decisions)은 영향 추적과 그래프 연결을 위한 배선이다.
여기서 한 가지 결정 설계가 들어간다. 12칸을 전부 강제하면 사람들이 카드 작성 자체를 회피한다. 그래서 필수 5칸(decision_id·title·owner·status·rationale)과 선택 7칸으로 나눈다. 회의에서 결정 직후 5칸만 채워도 카드는 살아 있고, 나머지는 구현 단계에서 채운다.
카드 한 장이 발생부터 폐기까지 어떤 경로를 도는지가 추적 시스템의 골격이다. 비가역 게이트가 어디 있는지에 주목하자.
flowchart TD
A[회의·메신저에서 결정 발생] --> B[결정 카드 초안 작성
필수 5칸]
B --> C[decision_id 부여·색인 등록]
C --> D{영향 범위 분석
impact}
D --> E[affected_atoms·affected_files 채움]
E --> F[wikilink로 그래프 연결]
F --> G{owner·approved_by 검토 게이트}
G -->|반려| B
G -->|승인| H[빌드 반영]
H -.비가역.-> I[다른 문서·결정으로 전파]
I -.비가역.-> J[사후 측정·검증]
J --> K{진화 판단}
K -->|대체됨| L[status: superseded
supersedes 링크]
K -->|유효| M[status: active 유지]
style H fill:#ffe0e0
style I fill:#ffe0e0
초안 작성(B)부터 검토 게이트(G)까지는 전부 가역 단계다. 카드를 고치든 폐기하든 비용이 거의 없다. 그러나 빌드 반영(H) 이후는 실질 비가역이다. 사용자가 이미 체감한 변경은 핫픽스로 되돌려도 커뮤니티 인식에 흔적을 남기고, 후속 결정들이 이 결정을 전제로 누적되기 시작하면 되돌리기 비용이 기하급수로 커진다. 그래서 결정자의 모든 검토는 게이트 G에서 끝나야 한다. 이것은 5부에서 다룬 "녹음·캐스팅은 비가역 단계" 원칙과 정확히 같은 구조다.
카드를 atom으로 만들면 카드끼리 연결할 수 있다. related_decisions의 supersedes·relates_to가 그래프의 간선이 된다. 앞 회의의 충돌은 사실 이 그래프 한 조각이었다.
이 그래프가 있었다면 회의는 30초로 끝났을 것이다. D2026_Q2_017을 열면 supersedes: D2025_Q4_034가 보이고, 그 카드의 rationale을 한 번 클릭하면 "왜 0.3이었는지"가 그대로 나온다. 그래프는 결정의 진화 이력이고, 결정의 진화 이력은 곧 게임의 역사다. PvP 변종(D2026_Q2_025)처럼 본 결정에서 파생된 분기까지 한눈에 추적된다.
결정 카드의 affected_atoms·affected_files를 사람이 일일이 채우면 빠뜨린다. 프로젝트 A에는 impact라는 영향범위 추출 절차가 있다. 결정 atom을 받아 세 방향으로 그래프를 훑는다.
affects 링크: 명시적으로 "영향을 준다"고 선언된 관계[[combat_global_cooldown_constant]]를 본문에 인용한 모든 문서세 경로의 합집합이 결정의 진짜 영향 범위다. 여기에 더해 portal_layer_change_impact_check atom이 "포털 레이어(외부 노출 문서·API 스펙)를 건드리는가"를 별도로 검사한다. 포털 레이어가 걸리면 등급이 한 단계 올라간다. 외부 전파는 되돌리기가 더 비싸기 때문이다.
이론은 여기까지다. 실제로 LLM에게 회의록 한 덩어리를 던지고 결정 카드를 받는 전 과정을, 프롬프트 전문과 날것 출력까지 그대로 싣는다. 요약하지 않는다. Claude가 헷갈린 지점, 사람이 거부한 지점, 재요청까지 전부 보인다.
아래 회의록을 결정 카드 YAML로 바꿔줘. 스키마는
decision_id / title / type / status / owner / approved_by / scope /
content / rationale / affected_atoms / affected_files / related_decisions.
owner는 발의자, approved_by는 승인자. affected_atoms랑 related_decisions는
회의록에 실제로 나온 것만 쓰고 추측하지 마.
// (의도: decision_id는 D{YYYY}_Q{n}_{seq}, seq 모르면 XXX)
[회의록]
- 전투 글로벌 쿨다운을 0.5초로 통일하기로 함 (teammate_a 발의).
현재 스킬마다 제각각인데 콤보 가독성이 나쁘다는 피드백 누적.
- 작년에 0.3초로 했던 건 너무 빨라서 콤보가 뭉개진다는 지적.
- 회복 스킬은 0.5초 적용하면 위기 대응이 안 됨. 예외 처리 필요.
- 디렉터(이민수) 승인. teammate_b가 구현, teammate_c가 QA.
- target build는 5월 9일.
---
decision_id: D2026_Q2_XXX
title: 전투 글로벌 쿨다운 0.5초 통일
type: system_change
status: active
owner: teammate_a
approved_by: 이민수
approval_meeting: 95_BattleTF
scope:
- combat_system
- all_skills # ← 회복 스킬 예외가 있는데 all_skills로 잡음
content: |
모든 전투 스킬에 글로벌 쿨다운 0.5초 적용.
rationale:
- 콤보 가독성 피드백 누적
- 작년 0.3초가 너무 빨라 콤보 뭉개짐
affected_atoms:
- combat_global_cooldown # ← 실제 atom 이름과 다름. 추측한 것
affected_files: [] # ← 비워둠. 회의록에 명시 안 됨
implementation:
target_build: 2026-05-09
impl_owner: teammate_b
qa_owner: teammate_c
related_decisions:
- supersedes: D2025_Q4_XXX # 작년 0.3초 결정. seq 모름
- relates_to: 회복 스킬 예외 결정 (ID 미정) # ← 링크 아닌 서술로 채움
---
거짓 없이 보면, 이 출력은 60점짜리다. 잘한 것과 틀린 것이 섞여 있다.
owner/approved_by 구분 정확, target_build·구현자·QA 정확, supersedes 관계 포착.scope에 all_skills를 넣었다. 회복 스킬 예외가 명시됐는데 전체로 잡았다.affected_atoms에 combat_global_cooldown을 추측해 넣었다. 프로젝트 실제 atom명은 combat_global_cooldown_constant다. "추측하지 마라"는 지시를 어겼다.related_decisions의 회복 예외를 링크가 아니라 자연어 서술로 채웠다.affected_files: []로 비웠다. 지시를 충실히 따른 것이지만, 영향 파일이 없을 리 없다.저자는 이 카드를 그대로 받지 않았다. ①②③을 거부 사유로 적어 재요청했다. 거부의 핵심은 atom명 추측이다. atom ID가 한 글자라도 틀리면 뒤따르는 grep·역참조가 전부 빗나간다. 추적 시스템의 정확도는 atom ID의 정확도에 묶여 있다.
세 군데 고쳐줘. scope는 회복 스킬이 예외니까 all_skills 말고
all_active_skills로 하고 회복 예외는 content에 적어. affected_atoms는
추측하지 말랬는데 추측했어 — atom 실명 모르면 [확인 필요]로 두고 뭘 확인할지
주석만 달아줘, 색인은 내가 볼게. related_decisions는 회복 예외를
D2026_Q2_018로 relates_to, supersedes는 D2025_Q4_034로. affected_files도
[확인 필요] 후보로만.
affected_atoms:
# [확인 필요] 아래는 후보. 색인에서 정확한 atom ID 확정 요망.
- combat_global_cooldown_constant # 글로벌 쿨다운 상수값
- combat_skill_cooldown_rule # 스킬별 쿨다운 규칙 (예외 처리 포함)
affected_files:
# [확인 필요] 회의록에 없음. type=system_change 기준 통상 영향 후보:
- CombatBalance.xlsx # 쿨다운 수치 시트
- CombatFormula_v3.md # 전투 공식 문서
- UI/skill_cooldown_indicator # 쿨다운 UI 인디케이터
related_decisions:
- supersedes: D2025_Q4_034
- relates_to: D2026_Q2_018
scope:
- combat_system
- all_active_skills
content: |
모든 전투 액티브 스킬에 글로벌 쿨다운 0.5초 적용.
회복 스킬은 예외 (별도 결정 D2026_Q2_018).
2차는 정직해졌다. atom을 추측해 단정하는 대신 [확인 필요] 플래그를 달고 근거 주석을 붙였다. 저자는 atom 색인을 열어 combat_global_cooldown_constant·combat_skill_cooldown_rule 두 이름이 실재함을 확인하고 플래그를 떼었다. affected_files 후보 셋도 색인 대조 후 확정했다. 이 챕터 첫머리에 실린 최종 카드가 그 결과물이다.
이 트랜스크립트의 교훈은 하나다. LLM은 결정 카드의 초안 작성자로 강력하지만, atom ID와 결정 ID의 최종 확정은 사람이 색인과 대조해야 한다. AI는 후보를 탐색하고, 사람은 채택한다. 둘의 역할이 섞이면 틀린 atom명이 그래프 전체를 오염시킨다.
카드와 그래프가 atom ID로 묶여 있으면, "이 결정이 어디에 영향을 주는가"는 grep 한 줄로 답이 나온다. 결정 D2026_Q2_017의 핵심 atom combat_global_cooldown_constant를 원고·시트·결정 카드 전체에서 역참조로 훑는다.
rg "combat_global_cooldown_constant" --type md --type yaml -l
# → D2026_Q2_017.yaml (결정 카드 본인)
# D2026_Q2_025.yaml (PvP 변종 — 이 상수를 재인용)
# CombatFormula_v3.md (공식 문서)
# 95_BattleTF_2026-04-18.md (회의록 원본)
이 결과가 곧 "이 상수를 바꾸면 네 곳이 흔들린다"는 영향 지도다. PvP 변종 카드가 같은 상수를 인용하고 있다는 사실은 사람 기억으로는 놓치기 쉽지만 grep은 놓치지 않는다. atom ID가 정확했기 때문에 가능한 일이다 — 1차 출력의 combat_global_cooldown으로 grep했다면 이 네 줄 중 하나도 안 잡혔을 것이다. 등급 분류(§18.2)와 전 사이클 워크플로(§18.3), grep 워크플로 정밀화(§18.4)는 모두 이 atom ID 정확성 위에 선다.
저자의 프로젝트 A에서 추적 시스템 도입 전후를 비교한다. 아래 수치는 저자 추정(미검증)이며, 절대값보다 방향과 비율로 읽기를 권한다.
| 항목 | 시스템 부재 | 시스템 운영 | 방향 |
|---|---|---|---|
| "전에 결정했었나?" 재논의 | 분기당 8~12건 | 분기당 0~2건 | 대폭 감소 |
| 결정 영향 범위 파악 | 1~2일 | grep으로 수 분 | 대폭 단축 |
| 결정 진화 이력 추적 | 시니어 기억 의존 | 그래프 자동 | 사람 의존 제거 |
| 신규 팀원 결정 이력 학습 | 1~2개월 | 1~2주 | 가장 큰 효과 |
가장 큰 효과는 마지막 줄이다. 신규 팀원이 "왜 이 게임이 지금 이 모양인가"를 시니어 붙잡고 묻는 대신 결정 그래프를 따라 스스로 읽어 내려간다. 결정 추적은 곧 회사의 의사결정 학습 자산이 된다. 다만 시스템이 처음 들어오는 분기는 카드 작성 부담이 분명히 있다. 필수 5칸부터 정착시키고 점진 확대하는 길이 안전하다.
지금까지의 운영은 보수적 적용이다. 사람이 회의에서 결정하고, 카드를 쓰고, 영향 atom을 식별하고, 자동은 색인·검색·grep·그래프 시각화만 맡는다. 사람이 핵심 판단을, 자동이 보관과 검색을 담당한다.
다음 단계는 위 트랜스크립트가 보여준 방향이다. 회의록 자연어를 입력으로 LLM이 결정 카드 12칸 초안을 채우고, 그래프를 따라 영향 atom 후보를 탐색하고, 등급까지 추천한다. 사람의 손에 남는 일은 "AI가 채운 카드와 atom명이 색인과 맞는지 검토"와 "최종 승인" 두 가지로 좁혀진다. 0에서 12칸을 채우는 부담과, LLM 초안의 atom명을 색인에서 확인하는 부담은 질이 다르다.
이 진보적 적용이 자리잡으려면 세 골격이 필요하다. 첫째, 모든 결정이 atom으로 등록되고 wikilink로 연결된 결정 그래프. 회의록 한 덩어리는 자동화의 입력이 못 된다 — 결정 단위로 분해돼야 한다. 둘째, 그래프 위에서 영향 분야 수·되돌리기 비용·사용자 영향 범위를 계산해 등급을 추천하는 임팩트 등급 자동기(§18.2). 셋째, atom ID와 wikilink로 정밀하게 작동하는 grep·LLM 영향 추적(§18.4).
여기서 책 전체를 관통하는 메시지가 한 번 더 드러난다. 결정을 atom·그래프·등급으로 분해하는 일은 "검색과 역참조의 편의"가 표면이고, 본질은 분해되지 않은 회의록 한 덩어리에서는 자동 영향 분석이 무엇이 결정의 단위인지조차 알 수 없다는 데 있다. 분해가 협업 언어 통일을 표면 목적으로, 절차적 자동화의 전제를 본질 목적으로 둔다는 일반 논제(§6.6)가 의사결정 영역에서 결정 그래프·atom·등급으로 나타난 것이다. 5부의 월드 BT(BehaviorTree, 행동 트리)·퀘스트 클라우드, 8부의 진보적 밸런싱과 같은 골격이다. 2010년대에도 이론은 가능했지만 회의록을 결정 atom으로 자동 분해하는 일이 막혀 있었고, 2023년 이후 LLM이 그 분해의 초안을 맡으면서 종이에만 있던 비전의 상당 부분이 실현 영역에 들어왔다.
decision_id·owner·rationale을 갖춘 카드로 박제해야 6개월 뒤 "왜 그랬지"에 답할 수 있다.게임 밖 적용. 결정 카드는 게임이 아니라 모든 조직의 "왜 그때 그렇게 정했지"를 6개월 뒤에도 답하게 하는 장치입니다. 마케팅팀이 "지난 분기에 이 채널은 접기로 했었는데 왜였더라"를 회의록 한 줄에서 못 찾아 30분을 허비하는 일은,
decision_id·owner·rationale세 칸짜리 카드 한 장이면 사라집니다. 예를 들어 인사팀이 "재택 주 2일로 통일" 같은 정책을 정할 때, 그 한 장에 발의자·승인자·근거(생산성 데이터·직원 설문)와 대체된 이전 정책 ID를 적어 두면, 1년 뒤 정책 재검토 자리에서 과거의 판단 근거가 그대로 살아 있습니다.
웹 챗봇 최소 경로 (터미널 없이) — 이 챕터의 핵심은 결정 카드 디렉터리나 grep이 아니라 "결정에 영구 주소(decision_id)·책임자(owner)·근거(rationale)를 박제하고, 새 결정 전에 과거 결정을 먼저 찾아본다"는 발상입니다. 그 발상은 CLI·atom 색인 없이 웹 챗봇(ChatGPT 또는 Claude 웹)만으로 재현됩니다. 아래 세 단계가 본류입니다.
1. 결정 한 건을 한 줄로 적습니다. decisions.md라는 일반 문서 한 장이면 됩니다. YAML도 스크립트도 필요 없습니다.
- [D17] 글로벌 쿨다운 0.5초 통일 (owner: 나, 근거: 콤보 가독성, 대체: D08)
2. 회의록을 카드로 바꿀 때는 웹 챗봇에 아래를 붙입니다. 1차 프롬프트의 4개 제약을 그대로 옮긴 것입니다.
아래 회의록의 결정을 표로 바꿔줘. 칸은
decision_id / title / owner / rationale / 대체된 과거결정.
owner를 특정 못 하면 [MISSING], atom·파일명 모르면 [확인 필요]로 두고
추측은 하지 마.
// (의도: decision_id는 D{연도}_{순번}, 순번 모르면 XXX)
[회의록 본문]
3. 새 결정을 하기 전에 decisions.md를 문서 내 찾기(Ctrl+F)로 먼저 검색합니다 — "전에 결정했었나"라는 질문 하나는 이걸로 해결됩니다. 이것이 grep 역추적의 손버전입니다. atom 색인·YAML 카드·rg 워크플로는 결정이 수백 건 쌓여 한 문서로 검색이 버거워질 때 비로소 도입하면 됩니다.
setup (인프라 버전 — 위 최소 경로가 손에 익은 뒤) — 결정 카드 디렉터리와 색인 파일을 만드세요.
decisions/
D2026_Q2_017.yaml
_index.json # by_status / by_scope / by_quarter 집계
prompt — 회의록 결정 안건을 LLM에 던질 때 위 1차 프롬프트의 4개 제약을 반드시 포함하세요. 특히 "atom명을 추측하지 말고 [확인 필요]로 두라"를 명시합니다.
verify — 산출된 카드의 affected_atoms 항목을 atom 색인과 대조해 실명 확인 후 플래그를 제거하세요. 그다음 핵심 atom으로 rg "<atom_id>" -l을 돌려 영향 파일이 카드의 affected_files와 일치하는지 교차 검증합니다.
팀 인프라 없이 혼자 쓴다면, YAML 카드는 버리세요. 결정 한 건을 마크다운 한 줄로 씁니다.
- [D17] 글로벌 쿨다운 0.5초 통일 (owner: 나, 근거: 콤보 가독성, 대체: D08의 0.3초)
decisions.md 파일 하나에 이 한 줄들을 쌓고, 새 결정을 하기 전에 rg "쿨다운" decisions.md로 과거 결정을 먼저 검색하세요. 카드도 그래프도 도구도 없지만 "전에 결정했었나"라는 질문 하나는 해결됩니다. 추적 시스템의 90%는 이 한 줄 습관에서 시작합니다.
회의가 끝나고 회의록을 정리하던 중이었다. 한 줄짜리 결정이 적혀 있었다. "글로벌 쿨다운 0.5초로 통일." 회의에서는 30초도 안 걸려 합의됐다. 모두 고개를 끄덕였고, 다음 안건으로 넘어갔다.
그 한 줄이 그 다음 두 달을 잡아먹었다. 전투 데이터의 스킬 277개가 전부 영향을 받았고, UI의 쿨다운 게이지 연출이 다시 그려졌고, 밸런스 시트가 두 번 갈아엎혔다. 같은 회의록 안에 적힌 다른 결정 "튜토리얼 안내 문구 오탈자 수정"은 5분 만에 끝났다.
두 결정은 회의록 위에서 똑같이 한 줄이었다. 글자 수도 비슷했다. 그런데 한쪽은 5분, 한쪽은 두 달이었다. 이 차이를 회의록을 적는 그 순간에 보이게 만드는 것 — 그게 임팩트 등급 분류다. 등급이 보이지 않으면, 두 달짜리 결정이 5분짜리 결정과 같은 줄에 묻혀버린다.
이 챕터는 결정의 파급을 다섯 등급으로 자동 분류하고, 그 파급이 어디까지 번지는지를 결정 atom 그래프 위에서 추적하는 방법을 다룬다. 도구는 앞 챕터에서 쌓은 결정 atom과 impact 추출, 그리고 portal_layer_change_impact_check atom이다.
먼저 등급 분류가 없는 상태가 어떤 모습인지 짚는다. 결정이 전부 같은 줄에 놓이면 두 가지 사고가 번갈아 터진다.
하나는 과소 처리다. 글로벌 쿨다운 결정처럼 분기를 흔드는 결정이 "5분짜리"로 취급돼 검증 없이 빌드에 들어간다. 두 달 뒤에야 파급이 드러나고, 그때는 되돌리기 비용이 이미 산처럼 쌓여 있다.
다른 하나는 과잉 처리다. 오탈자 하나를 고치는데 TF를 소집하고 게임 디렉터의 결재를 받는다. 결정 사이클이 폭증하고, 정작 디렉터가 봐야 할 T0 결정에 쓸 시간이 오탈자 회의에 빨려 들어간다.
두 사고는 정반대처럼 보이지만 뿌리가 같다. 결정의 무게가 보이지 않는다. 무게가 보이지 않으니 가벼운 것에 힘을 쓰고 무거운 것을 흘려보낸다. 등급 분류는 결정에 무게 라벨을 붙이는 작업이고, 라벨이 붙는 순간 처리 방식이 자동으로 갈라진다.
저자가 운영하는 MMORPG 개발사 프로젝트 A에서는 결정의 임팩트를 다섯 등급으로 나눈다. 위로 갈수록 무겁고, 처리에 더 많은 사람과 시간이 든다.
| 등급 | 정의 | 예 | 결정자 | 사이클 |
|---|---|---|---|---|
| T0 | 게임 비전·핵심 시스템 | 모바일 우선 결정, 핵심 메카닉 변경 | 게임 디렉터 + CEO | 분기 |
| T1 | 시스템·다분야 | 글로벌 쿨다운 통일, 신규 직업 추가 | TF 의장 + 디렉터 | 1~2주 |
| T2 | 분야·중간 | 특정 스킬 수치 조정, UI 컴포넌트 추가 | 분야 디렉터 | 3~5일 |
| T3 | 단발·작은 | 단일 NPC 대사 수정, 색상 미세 조정 | 시니어 1인 | 1~2일 |
| T4 | 즉시·핫픽스 | 버그 수정, 텍스트 오탈자 | 담당자 | 시간 단위 |
표만 보면 교과서처럼 깔끔하다. 그런데 실무의 난점은 표를 외우는 게 아니라 눈앞의 결정 한 건을 어느 칸에 넣을지 판단하는 일이다. "글로벌 쿨다운 통일"이 T1이라는 걸 회의가 끝난 뒤가 아니라 회의록을 적는 그 순간에 알아야 한다. 그래서 다음 절의 3기준이 핵심이다.
등급은 감으로 정하지 않는다. 세 가지 기준을 평가하고, 그중 가장 높은 등급을 채택한다.
세 기준 중 영향 분야 수는 결정 atom 그래프에서 기계적으로 셀 수 있다. 결정이 건드리는 atom이 어느 분야(전투·UI·데이터·내러티브 등)에 속하는지 태그를 모으면 끝이다.
문제는 나머지 둘이다. 되돌리기 비용과 사용자 영향 범위는 그래프 위의 숫자로 환산되지 않는다. "이 결정을 두 달 뒤에 되돌리려면 얼마나 드는가"는 자연어 판단이다. 바로 이 지점이 2023년 이전까지 임팩트 자동 분류의 마지막 벽이었다. 영향 분야 수는 자동화됐지만, 자연어 판단 두 칸이 비어 있어서 결국 사람이 처음부터 다시 매겼다. LLM이 결정 atom의 본문을 읽고 이 두 칸의 초안을 채우게 되면서 벽이 낮아졌다.
여기서 정직하게 짚는다. LLM이 채우는 건 초안이지 확정이 아니다. 되돌리기 비용을 LLM이 "큼"으로 추정해도, 분야 디렉터가 "우리 시트 구조상 이건 보통"이라고 내릴 수 있다. 자동 분류는 사람의 판단을 대체하는 게 아니라 사람이 빈 칸에서 시작하지 않게 한다.
앞 챕터에서 만든 결정 atom 한 건을 그대로 LLM에 넣고 등급을 매기게 한 실제 과정이다. 요약하지 않고 전 과정을 옮긴다. 거부와 재요청까지 포함한다.
# decisions/D2026_Q2_017.md (앞 챕터 18.1에서 등록한 atom)
id: D2026_Q2_017
title: 글로벌 쿨다운 0.5초 통일
date: 2026-04-18
proposer: teammate_a
decider: 이민수 # Design Director (18.1 approved_by와 동일)
context: 직업별 쿨다운 기준이 제각각이라 신규 직업 추가 시마다
밸런스 재산정 비용이 누적됨. 0.5초 글로벌 기준으로 통일 제안.
affected_atoms: [combat_cooldown_rule, skill_table, ui_cooldown_gauge]
reversible: ?
status: proposed
아래 결정 atom의 임팩트 등급을 T0~T4 중 하나로 매겨줘.
영향 분야 수 / 되돌리기 비용 / 사용자 영향 범위 세 기준을 따로 보고
그중 제일 높은 등급으로. 확신 안 서는 칸은 "불확실"이라고 적고
사람이 봐야 한다고 말해줘.
// (의도: T0=분야 5+·비용 매우 큼·전체, T1=2~4·큼·큼, T2=1·보통, T3=1·작음, T4=1·매우 작음)
[atom 원문 첨부]
3기준 평가:
1. 영향 분야 수
affected_atoms를 보면:
- combat_cooldown_rule → 전투 분야
- skill_table → 데이터 분야
- ui_cooldown_gauge → UI 분야
명시된 분야는 3개입니다.
다만 "신규 직업 추가 시마다 밸런스 재산정"이라는 context를
보면 밸런스 분야도 간접 영향을 받을 가능성이 높습니다.
직접 3 + 간접 1 = 실질 2~4개 범위로 봅니다. → T1 신호
2. 되돌리기 비용
글로벌 쿨다운을 통일했다가 되돌리려면 skill_table의
모든 스킬 쿨다운을 직업별 기준으로 다시 분기시켜야 합니다.
skill_table의 행 수를 모르므로 정확한 비용은 불확실하지만,
"통일 → 분기 복원"은 일반적으로 비용이 큽니다. → 큼 (T1 신호)
※ 이 칸은 skill_table 규모를 모르면 확정 불가. 불확실 표시.
3. 사용자 영향 범위
쿨다운은 모든 전투 상황에 적용되므로 거의 모든 사용자가
체감합니다. → 큼 (T1 신호)
채택 등급: T1
근거: 3기준이 모두 T1을 가리킵니다. T0는 아닙니다 —
"핵심 메카닉 변경"까지는 아니고 기존 메카닉의 파라미터
통일이기 때문입니다.
사람 확인 필요:
- 되돌리기 비용 칸: skill_table 실제 행 수 확인 필요
- reversible 슬롯이 "?"로 비어 있음 → 이 분류는 reversible을
"어려움"으로 채우는 것을 전제로 합니다.
여기서 그대로 받지 않았다. 출력의 1번 항목에 손이 갔다. LLM은 밸런스 분야를 "간접 영향"으로 봤지만, 프로젝트 A의 실제 구조에서는 쿨다운 통일이 밸런스 시트를 직접 갈아엎는다 — 신규 직업 밸런스 산정 공식 자체가 쿨다운 값을 입력으로 받기 때문이다. 그래서 밸런스는 간접이 아니라 직접 4번째 분야다. 영향 분야 수는 4개. 그래도 채택 등급 T1은 바뀌지 않는다.
또 하나, 되돌리기 비용 칸의 "불확실" 표시가 정확했다. skill_table은 실제로 스킬 277행이었고, 통일을 되돌리면 277행 전부의 쿨다운을 재분기해야 한다. 비용은 "큼"이 아니라 "매우 큼"에 가까웠다. 그래도 한 칸이 위로 올라간다고 채택 등급이 T0로 가지는 않는다 — 영향 분야 수와 사용자 영향이 T1 범위이므로 가장 높은 등급은 여전히 T1이다.
정정. affected_atoms에 balance_sheet 추가해줘 (밸런스 공식이 쿨다운 값을
입력으로 받아서 직접 영향이야). 그럼 영향 분야 4개고, skill_table은 277행이야.
이거 반영해서 reversible 채우고 최종 등급 확정해줘.
반영했습니다.
영향 분야: 전투·데이터·UI·밸런스 = 4개
되돌리기 비용: 매우 큼 (skill_table 277행 전부 재분기)
사용자 영향: 큼
reversible: 어려움 (실질 비가역에 가까움 — 277행 재분기는
빌드 1~2회 + 밸런스 재검증 분기를 요구)
최종 등급: T1
주의: 되돌리기 비용 칸 하나만 보면 T0 신호지만,
영향 분야 4개·사용자 영향 "큼"은 T1 범위입니다.
3기준 중 2기준이 T1을 가리키므로 T1이 맞습니다.
다만 이 결정은 "T1 중에서도 무거운 쪽"으로 표시해두는 걸
권합니다 — 되돌리기 비용이 T0급이기 때문입니다.
마지막 문장이 사람보다 신중했다. 등급은 T1이지만 되돌리기 비용은 T0급이라는 점을 "T1 중 무거운 쪽"으로 라벨링하자는 제안 — 이건 처음 프롬프트에 없던 판단이다. 받아들였다. 이 결정에는 weight: heavy 태그를 추가로 붙였고, 사후 추적 우선순위를 올렸다.
이 트랜스크립트가 보여주는 핵심은 하나다. LLM은 분류의 초안과 이유를 만들고, 사람은 도메인 사실(밸런스가 직접 영향, 277행)로 교정한다. 둘 중 하나만으로는 안 된다. 사람만 하면 빈 칸에서 시작해 느리고, LLM만 하면 277행을 모른 채 "큼"이라 쓴다.
등급이 정해지면 다음은 "어디까지 번지는가"다. 앞 챕터의 impact 추출 — 인바운드 엣지, 온톨로지의 affects 관계, wikilink 역참조 — 를 결정 atom에 적용한다.
# impact_propagation.py — 결정 atom의 전파 범위 추적
def trace_impact(decision):
# 1차: 결정이 직접 건드리는 atom·파일
direct = decision.affected_atoms + decision.affected_files
# 2차: 1차 atom을 wikilink로 역참조하는 atom (impact 인바운드 엣지)
secondary = []
for atom in direct:
secondary.extend(find_inbound_refs(atom)) # [[atom]] 역참조
secondary.extend(find_affects_edges(atom)) # 온톨로지 affects
secondary = dedup(secondary) - set(direct)
return {
"direct": direct,
"secondary": secondary,
"affected_fields": determine_fields(direct + secondary),
"estimated_hours": estimate_hours(direct, secondary),
}
핵심은 find_inbound_refs — atom 그래프에서 해당 atom을 [[...]]로 가리키는 들어오는 화살표를 모으는 함수다. 결정 자신이 무엇을 건드리는지(나가는 화살표)는 atom에 적혀 있지만, 그 atom을 누가 의존하는지(들어오는 화살표)는 그래프 전체를 역으로 스캔해야 보인다. 두 달짜리 파급은 거의 항상 이 인바운드 엣지 쪽에 숨어 있다.
D2026_Q2_017에 이 추적을 돌린 결과를 정직하게 적는다. direct는 위에서 확정한 4개 atom. secondary는 skill_table을 역참조하는 atom들 — 스킬 설명 텍스트, 스킬 아이콘 매핑, 직업별 스킬 트리 등 — 이 줄줄이 딸려 나왔다. 숫자는 시점마다 다르므로 단정하지 않는다. 추적이 잡아준 사실은 "secondary가 direct의 수십 배"라는 방향이고, 정확한 atom 수는 그래프 상태에 따라 달라진다. 방향만으로 충분하다 — secondary가 direct보다 한 자릿수 크면 그건 T1 신호이고, 사후 추적 대상이라는 뜻이다.
분류는 독립된 단계가 아니라 결정 흐름 한가운데에 게이트로 고정된다. 결정 후보가 등록되면 자동 분석이 등급을 추천하고, 사람이 검토·조정한 뒤에야 결정 회의로 넘어간다.
flowchart TD
A[결정 후보 등록
회의록 → 결정 atom 초안] --> B[자동 임팩트 분석
impact 추출]
B --> C{3기준 자동 평가}
C -->|영향 분야 수| D1[그래프에서 계산]
C -->|되돌리기 비용| D2[LLM 초안 → 불확실 표시]
C -->|사용자 영향| D3[LLM 초안 → 불확실 표시]
D1 --> E[가장 높은 등급 채택
T0~T4 추천]
D2 --> E
D3 --> E
E --> F{사람 검토}
F -->|도메인 사실 교정| G[등급 확정 + weight 태그]
F -->|반려·재분류| C
G --> H{등급 분기}
H -->|T0| T0[디렉터+CEO / 분기 사이클]
H -->|T1| T1[TF / 1~2주]
H -->|T2| T2[분야 디렉터 / 3~5일]
H -->|T3| T3[시니어 1인 / 1~2일]
H -->|T4| T4[담당자 즉시 처리]
T0 --> I[빌드 반영 — 비가역]
T1 --> I
T2 --> I
T3 --> I
T4 --> I
I --> J[사후 추적
추정 vs 실제 → 다음 추정에 학습]
J -.가역 흡수.-> B
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
class B,C,D1,E,I code;
class D2,D3 ai;
class F,G,T0,T1,T2,T3,T4 human;
class A data;
이 흐름에서 비가역 단계는 단 하나, 빌드 반영(I)이다. 그 앞은 전부 가역이다 — 등급을 잘못 추천해도 사람이 반려하면 되고, weight 태그도 떼면 된다. 빌드에 들어가고 다른 문서로 전파된 뒤에야 비가역이 된다. 그래서 게이트(F의 사람 검토)가 빌드 앞에 있는 것이다. 비가역 선을 넘기 전에 사람이 한 번 막는다.
마지막 점선 — 사후 추적(J)이 다음 결정의 자동 분석(B)으로 되돌아가는 화살표 — 이 시스템을 학습 사이클로 만든다. 비가역 단계에서 나온 실측 데이터(예: QA 시간이 추정보다 길었다)가 다음 결정의 가역 단계로 흡수된다.
결정이 빌드에 들어가고 1주~1개월 뒤, 추정과 실제를 맞대본다. D2026_Q2_017의 사후 추적 양식이다.
결정 D2026_Q2_017 사후 추적 (양식 예시 · 숫자는 가상 입력)
─────────────────────────────────
작업 시간 (추정 → 실제)
코드: 16h → 22h (+38%)
데이터: 8h → 6h (-25%)
UI: 4h → 4h (=)
QA: 8h → 12h (+50%)
total: 36h → 44h (+22%)
영향 atom (추정 → 실제)
direct: 4 → 4 (정확)
secondary: 추정 수십 → 실제 수십 (방향 일치, 정확 수치 비공개)
사고 발생: 0건
오차 패턴: QA가 매번 추정 초과 (이번 +50%)
다음 결정 적용: QA 추정에 +20% 마진 기본 적용
위 블록은 사후 추적이 어떤 모양인지 보여 주는 양식 예시다. 시간·퍼센트 값은 실제 프로젝트 데이터가 아니라 양식을 채운 가상 입력이니, 본인 프로젝트에서는 본인 숫자로 바꿔 채우면 된다 — 이 책의 약속 그대로, 우리는 구조를 보여 주고 숫자는 본인이 측정한다. 양식과 무관하게 진짜인 것은 하나다. "QA가 추정을 매번 초과한다"는 오차의 방향, 그리고 그 방향을 다음 결정에 되먹이는 절차. 그래서 다음 추정에 QA 마진(예: +20%)을 처음부터 붙이는 처방이 나온다.
사후 추적의 가치는 정확한 숫자를 맞히는 게 아니라 오차의 방향을 되먹이는 데 있다. 추정이 정확해질수록 등급 분류의 신뢰가 올라가고, 신뢰가 올라가면 위임이 가능해진다.
각 등급에서 반복되는 사고는 다르다. 처방도 다르다.
| 등급 | 사고 패턴 | 처방 |
|---|---|---|
| T0 | 비전 모호 → 분기 내내 혼란 | 결정문에 비전 한 줄 명시 강제 |
| T1 | 분야 간 충돌 → 일정 지연 | TF에 전 영향 분야 대표 참석 |
| T2 | 인접 시스템 영향 누락 → 후속 결정 폭증 | secondary 추적 필수 |
| T3 | 작은 결정 누적 → 일관성 손상 | 분기 회고에서 T3 묶음 점검 |
| T4 | 검증 부족 → 재핫픽스 | 핫픽스도 최소 1인 리뷰 |
이 표의 처방들은 모두 앞 절들에서 나온 도구로 실행된다. T2의 "secondary 추적 필수"는 §18.2.4의 find_inbound_refs이고, T1의 "전 영향 분야 대표 참석"은 §18.2.4가 잡아낸 affected_fields로 누가 들어와야 하는지가 정해진다.
가장 비싼 사고는 표 어디에도 없다. 등급 자체를 틀리는 것이다. T0를 시니어가 혼자 결정하면 비전이 손상되고, T4를 디렉터가 직접 처리하면 병목이 생긴다. 등급을 틀리면 그 아래 모든 처방이 엉뚱한 자리에서 작동한다. 그래서 §18.2.3의 사람 검토 게이트가 단순한 형식이 아니다.
프로젝트 A에서 등급 분류 도입 전후를 비교한다. 아래 수치 중 절대값은 가공 예시이고, 방향(부등호)은 실제 경향이다.
| 항목 | 등급 부재 | 등급 운영 |
|---|---|---|
| 결정 사이클 | 전부 1~2주로 균일 | T0 분기 ~ T4 시간 단위로 분화 |
| 잘못 처리된 결정 | 분기당 다수 | 분기당 소수 |
| 디렉터의 주간 결정 부담 | 큼 (모든 결정이 디렉터로) | 작음 (T2~T4 위임) |
| 핫픽스 사이클 | 1~2일 | 4~24시간 |
| 분기 회고 결정 분석 | 묶기 어려움 | 등급별 통계로 집계 |
표를 단정적 숫자 대신 방향으로 적은 이유는 3번 항목 하나로 설명된다. 디렉터의 시간 회수가 등급 분류의 가장 큰 효과다. 등급이 없으면 오탈자부터 비전까지 모든 결정이 디렉터 한 사람으로 몰린다. 등급이 생기면 T2 이하가 분야 디렉터·시니어·담당자로 갈라지고, 디렉터는 T0·T1에 집중한다. 위임이 가능해진다는 건 곧 디렉터가 정말 무거운 결정에 쓸 시간을 되찾는다는 뜻이다.
앞 챕터에서 결정 atom 그래프를 만들었고, 이 챕터에서 그 그래프 위에 등급 분류 자동기를 얹었다. 둘은 따로 노는 도구가 아니라 한 골격의 연속된 자리다.
flowchart LR
A["① 결정 atom 그래프
(앞 챕터 18.1)
회의록 → 12슬롯 atom"] --> B["② 임팩트 등급 분류 자동기
(이 챕터 18.2)
그래프 → T0~T4 + 추정"]
B --> C["③ wikilink 영향 추적
(18.4 grep 워크플로)
atom ID → 전파 범위"]
C --> D["사람의 검토·승인
비가역 게이트"]
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
class C code;
class B ai;
class D human;
class A data;
세 요소가 직렬이다. 그래프가 입력을 만들고(①), 분류기가 무게를 매기고(②), 영향 추적이 전파 범위를 펼친다(③). 이 챕터는 가운데 자리다.
세 요소가 모두 LLM 발전 이후에야 실현 영역에 들어왔다. 가장 늦게 풀린 벽이 ②의 자연어 평가 두 칸 — 되돌리기 비용과 사용자 영향 범위(§18.2.2의 "마지막 벽") — 이고, LLM이 그 초안을 채우면서 비로소 ①→②→③이 직렬로 돌기 시작했다.
가역·비가역의 정렬도 이 골격에 맞물린다. §18.2.5 흐름도대로 비가역 선은 빌드 반영 하나뿐이고, 빌드 반영 자체는 되돌릴 수 없지만 그 실측 결과는 다음 결정을 더 정확하게 만드는 가역적 학습으로 되돌아온다.
| 패턴 | 처방 |
|---|---|
| 모든 결정을 같은 사이클로 처리 | 등급별 사이클 분화 |
| 등급 분류 없이 각자 자율 판단 | 3기준 분류 게이트 |
| 등급 무시 (T0를 시니어가 결정) | 결정자 표 강제 |
| 사전 영향도 평가 생략 | 자동 분석을 결정 회의 전 게이트로 |
| 자연어 칸을 LLM 출력 그대로 확정 | 사람이 도메인 사실로 교정 |
| 사후 추적 없이 결정 종료 | 1주~1개월 추정 vs 실제 비교 |
| 추정 오차를 다음 결정에 미반영 | 오차 방향을 다음 추정 마진에 적용 |
게임 밖 적용. 임팩트 등급은 "한 줄짜리 요청이 5분짜리인지 두 달짜리인지"를 미리 보이게 하는 라벨이라, 결정이 쏟아지는 어느 직장에서나 통합니다. 회사 위키 문구 한 줄 수정과 "전 부서 휴가 정책 변경"이 똑같이 '안건 1건'으로 들어와 같은 결재 라인을 타면, 가벼운 일은 과잉 처리되고 무거운 일은 검증 없이 흘러갑니다. 예를 들어 운영팀의 업무 요청을 받을 때 영향 부서 수·되돌리기 비용·고객 영향 범위 세 기준으로 T0~T4를 붙여 두면, 담당자가 즉시 처리할 것과 팀장 결재가 필요한 것이 자동으로 갈라지고 관리자의 시간이 정말 무거운 결정으로 회수됩니다.
setup. 앞 챕터에서 만든 결정 atom 한 건을 준비하세요. affected_atoms 슬롯이 채워져 있어야 합니다. 비어 있으면 영향 분야 수를 셀 수 없습니다.
prompt. §18.2.3의 프롬프트 전문을 그대로 쓰세요. 핵심 세 줄을 빠뜨리지 마세요 — (1) 3기준을 각각 평가하라, (2) 가장 높은 등급을 채택하라, (3) 확신 없는 칸은 "불확실"로 표시하고 사람 확인을 요청하라. 세 번째 줄이 없으면 LLM이 모르는 것까지 단정합니다.
verify. LLM 출력을 받으면 두 가지를 직접 확인하세요. 첫째, 영향 분야 수를 LLM이 셌더라도 atom 그래프에서 직접 다시 셉니다 — 간접 영향을 직접으로 잡거나 누락했을 수 있습니다(트랜스크립트의 밸런스 사례). 둘째, "불확실" 표시가 붙은 칸은 도메인 사실로 채웁니다(skill_table 277행 같은 실제 규모). 두 확인을 마친 뒤에야 등급을 확정하고 빌드 게이트로 넘기세요.
팀도 TF도 없는 혼자 만드는 프로젝트라면 5등급은 과합니다. 3등급으로 줄이세요.
도구도 코드 한 줄 없이 시작하세요. 결정을 메모할 때 앞에 [무거움] [보통] [즉시] 태그만 붙입니다. 그것만으로도 "무거움" 태그가 붙은 결정 앞에서 한 번 더 멈추게 됩니다 — 등급 분류의 본질은 결국 무거운 결정 앞에서 멈추는 습관이고, 자동화는 그 멈춤을 팀 규모에서도 작동하게 만드는 장치일 뿐입니다.
출시 3주 뒤, PvP 밸런스가 무너진 원인을 복기하는 회고 자리였다. 화이트보드에 거슬러 올라간 끝에 도달한 시작점은 한 달 전의 결정 한 건이었다. "글로벌 쿨다운 0.3을 0.5로 올린다." 콤보가 안 보인다는 피드백을 받아 두 시간 만에 합의했던, 합리적인 제안이었다. 그런데 그 변경이 탱커 직업의 생존율을 예상보다 14% 더 끌어올렸고, 그게 PvP를 무너뜨렸다. 아무도 그 결정이 탱커에게까지 닿는다는 걸 결정 자리에서 말하지 못했다. 결정 자체가 틀린 게 아니었다. 결정이 어디까지 번지는지를 결정하기 전에 보지 못한 게 사고의 원인이었다.
영향 추적은 두 군데에서 일어나야 한다. 결정을 누르기 전(pre)에 어디까지 번질지 보고, 결정을 반영한 뒤(post)에 정말 거기까지만 번졌는지를 확인하는 것이다. 이 챕터는 그 두 추적을 하나의 워크플로로 묶는다.
결정 영향 분석의 핵심은 의외로 단순하다. 결정 atom 하나를 노드로 보고, 그 노드로 들어오는 엣지와 나가는 엣지를 읽는 것이다. 사전 추적은 "이 결정을 바꾸면 어디가 영향받나"를 묻고(아웃바운드 + 역참조), 사후 추적은 "실제로 그 영향이 의도대로 났나"를 묻는다(같은 엣지를 측정값과 대조).
저자의 프로젝트 A에서는 결정을 decisions/ 폴더에 atom으로 남긴다. 현재 26개가 쌓여 있고, 각 atom은 날짜·당사자·근거·영향범위를 프론트매터로 들고 있다. 영향범위를 추출하는 도구가 impact이고, 그 추출 규칙을 결정 단위로 강제하는 atom이 portal_layer_change_impact_check다. 이 셋이 사전·사후 추적의 실제 자산이다.
사전 추적은 오른쪽(아웃바운드)을 읽어 "여기까지 번진다"를 예측하고, 사후 추적은 오른쪽 노드들의 실측을 가져와 예측과 대조한다. 같은 그래프를 두 번, 다른 목적으로 읽는 것이다.
여기서부터는 실제 세션이다. 요약하지 않고, 프롬프트 전문과 Claude의 날것 출력, 그리고 사람이 그걸 어떻게 거부하고 다시 요청했는지를 그대로 싣는다.
impact는 결정 atom의 ID를 받아 영향 그래프를 뽑는 작은 파이프라인이다. 인바운드 엣지(이 결정의 근거), 아웃바운드 엣지(이 결정이 수정할 파일), 온톨로지의 affects 관계, 그리고 위키링크 역참조([[D2026_Q2_017]]로 이 결정을 가리키는 다른 문서)를 합쳐서 영향 후보 목록을 만든다.
decisions/D2026_Q2_017.md 의 proposal 단계 사전 영향도를 평가해줘.
- 이 결정이 수정할 파일(아웃바운드)
- 이 결정을 [[D2026_Q2_017]] 로 역참조하는 문서(인바운드 wikilink)
- 온톨로지 affects 관계로 연결된 시스템
세 가지를 합쳐서 영향 후보를 내고, 각 후보에 내 확인이 필요한 항목(T1)인지
자동으로 확정 가능한 항목인지 구분해줘. 추측으로 메우지 말고, 근거 파일이
없으면 '근거 없음'이라고 표시해.
decisions/D2026_Q2_017.md를 읽었습니다. 프론트매터에서 추출한 영향 그래프입니다.아웃바운드 (이 결정이 직접 수정) -
data/CombatFormula.md—global_cooldown필드 (atom에 명시됨) -sheets/CombatBalance— 회복 스킬 예외 행 (atom에 명시됨) -ui/ComboIndicator— atom의affected_teams: [ui]로 추정. 단, 구체 파일은 atom에 없음 → 근거 없음(추정)인바운드 wikilink (이 결정을 역참조) -
grep결과[[D2026_Q2_017]]로 검색했으나 현재 인덱스에 역참조 0건입니다. 신규 결정이라 아직 아무도 인용하지 않은 것으로 보입니다.온톨로지 affects 관계 -
CombatFormula → affects → TankSurvival관계가 온톨로지에 있습니다. - 따라서 이 결정은TankSurvival지표에 간접 영향을 줍니다. 추정 영향 +12%.
여기서 사람이 멈춘다. 마지막 줄 "추정 영향 +12%"가 문제다.
거부. "추정 영향 +12%"는 어디서 나온 숫자야? atom에도 온톨로지에도 그 수치는 없잖아. 너가 지어낸 거면 빼. affects 관계가 있다는 사실까지만 보고하고, 영향의 크기는 시뮬을 돌려야 나오는 거다. 그리고 ui 추정 건은 '근거 없음'으로 분류했으면서 후보 목록엔 왜 T1로 올렸어?
지적이 맞습니다. 두 가지를 정정합니다.
- "+12%" 철회. atom·온톨로지 어디에도 그 수치의 근거가 없습니다. 제가 생성한 값입니다.
TankSurvival에 영향이 있다는 관계만 보고하고, 크기는 시뮬(Layer 2) 결과로 채워야 합니다. 현재 값은미측정입니다.- ui 건 재분류. 구체 파일을 못 찾았으므로 후보 목록에서 T1(확인 필요)이 아니라
근거 부족 — 사람이 파일 지정 필요로 내려야 했습니다. 정정된 표는 아래와 같습니다.
| 영향 후보 | 엣지 종류 | 근거 | 분류 |
|---|---|---|---|
CombatFormula.md global_cooldown |
아웃바운드 | atom 명시 | 자동 확정 |
CombatBalance 회복 예외 행 |
아웃바운드 | atom 명시 | 자동 확정 |
TankSurvival 지표 |
affects | 온톨로지 관계 | 영향 있음 / 크기 미측정 |
| ui 콤보 표시 (파일 미상) | 추정 | 근거 없음 | 사람이 파일 지정 필요 |
| 인바운드 역참조 | wikilink | grep 0건 | 해당 없음 |
이 표가 사전 추적의 산출물이다. 핵심은 TankSurvival 줄이다. 도입부에서 PvP를 무너뜨린 그 영향이, 사전 추적에서는 "영향 있음 / 크기 미측정"으로 이미 떠 있다. 회의 자리에서 이걸 봤다면 "그럼 시뮬로 크기를 재고 결정하자"가 됐을 것이다. AI가 +12%를 지어내게 놔뒀다면 오히려 더 위험했다. 가짜 정밀도가 검증을 건너뛰게 만들기 때문이다.
사전 추적이 끝나면 회의에서 결정한다. 결정이 atom으로 확정되는 순간, 두 가지 비가역 단계가 시작된다.
flowchart TD
A["사전 추적
(impact 그래프)"] --> B{결정 회의}
B -->|거부| Z["proposal 폐기
(사유 기록·가역)"]
B -->|승인| C["결정 atom 확정
D2026_Q2_017"]
C --> D["⚠ 비가역 1: 빌드 반영
CombatFormula·시트 수정"]
C --> E["⚠ 비가역 2: 타 문서 전파
GDD·역참조 갱신"]
D --> F["사후 추적 개시"]
E --> F
F --> G{예측 = 실측?}
G -->|부합| H["결정 카드 닫음"]
G -->|이탈| I["사이드이펙트 atom
후속 결정 후보"]
I --> B
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
classDef fail fill:#fee2e2,stroke:#dc2626,color:#7f1d1d;
class D code;
class B human;
class A,C,E,I data;
class H pass;
class Z fail;
비가역인 이유는 단순하다. 빌드에 반영된 수치는 사용자가 이미 플레이했고, 다른 문서로 전파된 내용은 팀원들이 이미 그걸 근거로 다음 작업을 시작했기 때문이다. 그래서 이 두 단계 직전에 portal_layer_change_impact_check atom이 게이트로 걸린다. 이 atom의 규칙은 한 줄로 요약된다. "결정이 Layer를 넘나드는 변경(데이터 → 공식 → UI)을 포함하면, 반영 전에 영향 후보 목록을 사람이 한 번 승인해야 한다." 자동 확정 항목만 있으면 통과, '사람이 파일 지정 필요'가 하나라도 있으면 차단이다. 18.3.2에서 ui 건이 차단 사유가 된다.
빌드 반영 후 1주~1개월에 사후 추적을 돈다. 사전 추적이 만든 영향 후보 표의 각 줄에 실측값을 붙이는 작업이다.
D2026_Q2_017 사후 추적. 사전 추적 표(18.3.2)의 각 후보에 실측을 붙여줘.
실측 출처:
- CombatBalance 시트 git diff (반영 확인용)
- 시뮬 1000회 로그 sim_2026q2_017.json
- 출시 1주 KPI export kpi_w1.csv
의도값과 실측값의 차이를 내고, 오차 10% 범위를 벗어난 줄만 따로 모아줘.
없는 데이터는 '미수집'으로.
사전 추적 표에 실측을 결합했습니다.
| 후보 | 의도 | 실측 | 차이 | 판정 |
|---|---|---|---|---|
| global_cooldown 0.3→0.5 | 반영 | git diff 확인 | — | 반영됨 |
| 회복 예외 행 | 반영 | 시트 확인 | — | 반영됨 |
| 전투 시간 | +15% | +14.2% | -0.8%p | 범위 내 |
| TankSurvival | (미측정) | +44% | 기준 없음 | 판정 불가 |
| 5:5 PvP 승률 | +15%p | +29%p | +14%p | 이탈 |
| ui 콤보 가독성 | 개선 | 사용자 긍정 65% | — | 부합 |
오차 범위(±10%)를 벗어난 줄: PvP 승률 +29%p (의도 +15%p).
TankSurvival은 사전에 크기를 측정하지 않아 의도값이 없어 판정 불가입니다.두 줄을 연결해 보면,
TankSurvival +44%가 PvP 승률 이탈의 원인으로 보입니다. 사전 추적에서TankSurvival을 "크기 미측정"으로 남긴 것이 이번 이탈을 예고했습니다.
정확하다.
TankSurvival을 시뮬로 안 잰 게 사고의 뿌리야. 후속 결정 atom 만들어. 탱커 직업 군의 dmg 보정으로 PvP 승률을 의도 범위로 되돌리는 안.
decisions/D2026_Q2_025.md초안을 생성했습니다. 인바운드에[[D2026_Q2_017]]을 걸어, 이번 결정이 17번의 사이드이펙트 보정임을 역참조로 명시했습니다. 이제 17번 atom을impact로 다시 조회하면 인바운드 역참조 1건(25번)이 잡힙니다 — 18.3.2에서 0건이던 자리입니다.
여기서 그래프가 닫힌다. 사전 추적에서 "영향 있음 / 크기 미측정"이던 노드가, 사후 추적에서 이탈로 확인됐고, 후속 결정이 그 노드를 가리키는 역참조로 들어왔다. 결정의 전 사이클이 같은 그래프 위에서 한 바퀴 돈 것이다.
impact의 인바운드 역참조 추출은 화려한 도구가 아니라 grep 한 줄이다. 결정 atom을 가리키는 위키링크를 전체 문서에서 찾는다.
# D2026_Q2_017 을 역참조하는 모든 문서 (인바운드 wikilink)
grep -rln "\[\[D2026_Q2_017\]\]" decisions/ manuscript/ gdd/
# 결정 atom의 아웃바운드 — 프론트매터 affected_files 추출
grep -A20 "affected_files:" decisions/D2026_Q2_017.md
# 사후 추적: 의도 대비 이탈 줄만 (판정 컬럼)
grep -E "이탈|판정 불가" tracking/D2026_Q2_017_post.md
세 줄이면 사전·사후 추적의 뼈대가 돈다. LLM은 이 결과를 읽고 해석하는 자리이지, 검색 자체를 대신하지 않는다. grep이 사실(어떤 파일이 이 결정을 가리키나)을 주고, LLM이 그 사실들을 영향 후보 표로 엮고, 사람이 영향의 크기와 판정을 책임진다. 이 분리가 §18.3.2에서 "+12%를 지어내지 마라"가 통했던 이유다.
저자의 프로젝트 A에서 결정 사이클 표준화 전후를 비교한 값이다. 절대 시간 수치는 팀 규모(중규모, 10~50인)에 종속된 저자 추정(미검증)이고, 비율과 방향은 실제 운영에서 관찰된 것이다.
| 항목 | 전후 추적 분리 | 전후 추적 통합 |
|---|---|---|
| 사후 추적이 실제로 돈 결정 비율 | 약 30% | 90% 이상 |
| 사전에 떴는데 사후에 사고로 터진 영향 | 흔함 | 거의 없음 (사전에 게이트) |
| 사이드이펙트 → 후속 결정 연결률 | 낮음 (구두 전달) | 역참조로 자동 후보화 |
| 결정 그래프의 인바운드 역참조 완전성 | 듬성듬성 | 닫힌 루프 |
핵심은 한 가지다. 사전 추적과 사후 추적이 같은 후보 표를 공유할 때, 사전에 "크기 미측정"으로 남긴 구멍이 사후에 정확히 그 자리에서 확인된다. 분리돼 있으면 사전에서 본 것과 사후에서 잰 것이 서로 다른 양식이라 대조가 안 되고, 그래서 추적률이 30%에 머문다. 다만 역참조 완전성을 처음부터 100% 목표로 잡으면 운영 부담만 늘어난다. 결정 atom에 affected_files를 적는 습관부터 들이고, 역참조 grep을 회고 주기에 끼워 점진 확대하는 게 현실적이다.
| 패턴 | 처방 |
|---|---|
| 사전엔 영향을 봤는데 크기를 안 재고 결정 | "크기 미측정" 줄은 시뮬 전까지 결정 보류 |
| LLM이 영향 수치를 지어냄 | 근거 파일 없으면 '근거 없음', 크기는 시뮬로만 |
| 사후 추적이 사전 표와 다른 양식 | 같은 후보 표에 실측 컬럼만 추가 |
| 사이드이펙트를 구두로 넘김 | 후속 결정 atom + 역참조 wikilink 강제 |
| Layer 넘는 변경을 게이트 없이 반영 | portal_layer_change_impact_check 통과 의무화 |
게임 밖 적용. 결정을 누르기 전에 "어디까지 번지나"를 보고(사전), 반영한 뒤에 "정말 거기까지만 번졌나"를 확인하는(사후) 두 번 읽기는, 게임이 아니라 모든 변경 관리의 기본 동작입니다. 회사가 가격 정책을 바꿀 때, 사전에 영향 받는 부서(영업·CS·정산)를 후보 표로 띄우고 "크기는 시뮬 전까지 미측정"으로 남겨 두면, 출시 뒤 "왜 정산팀이 이걸 몰랐냐"는 사고를 미리 막습니다. 예를 들어 신규 멤버십 등급을 도입하기 전 CS 문의량·이탈률 같은 사후 지표 칸을 사전 표에 빈 칸으로 만들어 두면, 한 달 뒤 그 칸에 실측을 채워 의도와 실제의 차이를 같은 표에서 바로 대조할 수 있습니다.
setup — 결정 폴더와 추적 폴더를 만드세요.
mkdir decisions tracking
# 결정 atom 1개에 프론트매터로 affected_files, affected_teams 기재
prompt — 사전 추적 후 사후 추적을 같은 표로 이으세요.
decisions/<ID>.md 사전 영향도 평가: 아웃바운드(수정할 파일)·
인바운드 wikilink·온톨로지 affects를 합쳐 영향 후보 표를 만들고,
근거 없는 항목은 '근거 없음', 크기는 '미측정'으로 표시해. 수치 지어내지 마.
(빌드 반영 후)
같은 후보 표에 실측 컬럼만 붙여서 의도 대비 오차 10% 벗어난 줄만 모아줘.
이탈 줄은 후속 결정 atom 초안으로 만들고 [[<ID>]] 역참조 걸어줘.
verify — 그래프가 닫혔는지 grep으로 확인하세요.
grep -rln "\[\[<ID>\]\]" decisions/ # 후속 결정의 역참조가 잡히면 루프 닫힘
grep -E "이탈|미측정" tracking/<ID>_post.md # 남은 구멍 확인
혼자 작업하는 개인 게임 개발자라면 회의·소유자·시한은 다 빼도 됩니다. 결정 한 줄을 decisions/ 마크다운에 적을 때 딱 두 칸만 채우세요. affected_files:(이 결정이 건드릴 파일)와 expected:(의도한 변화)입니다. 빌드한 뒤 그 파일들을 열어 의도대로 됐는지 눈으로 보고, 어긋난 게 있으면 같은 파일에 actual: 한 줄을 더하세요. 도구는 grep -rln "[[결정ID]]" 하나로 충분합니다. 사전 한 칸, 사후 한 칸 — 이게 전후 추적의 최소 형태입니다.
월요일 오전 10시. 전투 담당 팀원 A가 팀 메신저에 한 줄을 던졌다. "전역 쿨다운 0.5초에서 0.4초로 내려도 될까요?" 숫자 하나 바꾸는 일이다. 표면적으로는. 나는 그 줄을 읽고 손이 멈췄다. 이 숫자가 입력된 문서가 몇 개인지, 이 상수를 전제로 짜인 스킬 밸런스 atom이 몇 개인지, 그걸 바꾸면 어떤 시트의 수식이 깨지는지 — 머릿속에 떠오르지 않았기 때문이다. 떠올랐다고 착각하면 그게 사고다. 분기마다 8건에서 12건씩 터지던 "그 문서를 못 봤다"는 누락의 정체가 바로 이 착각이었다.
그래서 나는 답을 외우지 않기로 했다. 대신 한 줄을 친다.
impact combat_global_cooldown_constant
이 챕터는 그 한 줄이 무엇을 뱉어내는지, 날것 그대로 본다. 영향 범위를 추출한다는 게 추상적인 말이 아니라, 인바운드 엣지·온톨로지 affects·wikilink 역참조라는 세 갈래를 grep으로 긁어모으는 구체적 동작이라는 걸 보여준다.
"이 atom을 바꾸면 무엇이 영향받는가"라는 질문은 사실 세 개의 질문이다. 셋을 섞으면 답이 흐려지고, 셋을 분리하면 grep 한 줄씩으로 떨어진다.
첫째, 인바운드 엣지(inbound edge) — 누가 나를 가리키는가. atom A가 atom B를 참조하면 A→B 방향의 엣지다. B를 바꿀 때 위험한 건 B를 가리키는 A들, 즉 B로 들어오는 화살표다. 그래서 아웃바운드(내가 누구를 보는가)가 아니라 인바운드를 본다. 변경의 충격파는 화살표를 거슬러 올라간다.
둘째, 온톨로지 affects — 의미상 무엇에 영향을 주는가. atom의 frontmatter에 명시한 affects: 필드다. 이름이 직접 등장하지 않아도, 설계자가 "이건 저기에 영향 준다"고 선언해둔 의미적 연결이다. grep으로는 못 잡는 별칭·동의어 문제를 사람이 미리 입력해둔 것이다.
셋째, wikilink 역참조 — [[atom_id]] 형식으로 나를 명시적으로 링크한 문서. 가장 신뢰도가 높다. 우연한 단어 일치가 아니라 작성자가 의도적으로 건 링크이기 때문이다.
세 갈래의 관계를 도식으로 보면 이렇다.
세 갈래를 한 도구로 묶은 것이 impact다. 다음 절에서 이 도구가 실제로 뱉는 출력을 통째로 본다.
말로 설명하는 대신, 실제 한 사이클을 처음부터 끝까지 따라간다. 프롬프트 전문, 도구의 날것 출력(다듬지 않음), 내가 검증하고 거부한 부분, 재요청까지 전부 포함한다.
나는 Claude에게 impact 워크플로를 돌려달라고 요청했다. 워크플로 본체는 ripgrep을 호출하는 셸이지만, 결과 해석과 등급 추천은 Claude가 맡는다.
impact 워크플로를 combat_global_cooldown_constant에 대해 돌려줘.
세 갈래(인바운드 엣지 / affects / wikilink 역참조)를 각각 grep으로 뽑고,
중복을 합친 뒤, 각 영향 대상에 임팩트 등급 추천을 붙여줘.
변경 내용은 "전역 쿨다운 0.5초 → 0.4초".
세 갈래를 순서대로 실행하겠습니다.
[1/3] 인바운드 엣지 — 이 atom을 참조하는 위치
$ rg -n "combat_global_cooldown_constant" --type md docs/ atoms/
atoms/combat/combat_skill_cooldown_rule.md:34: base: combat_global_cooldown_constant
atoms/combat/combat_gcd_ui_feedback.md:12: depends_on: combat_global_cooldown_constant
docs/spec/combat_v3.md:42: 전역 쿨다운(combat_global_cooldown_constant)을 기준으로
docs/spec/skill_design.md:18: 쿨다운은 combat_global_cooldown_constant의 배수로 설계
docs/balance/refgame_comparison.md:77: refgame은 0.5초, 우리 combat_global_cooldown_constant도 동일
→ 5건 직접 참조 발견.
[2/3] 온톨로지 affects — affects 필드로 이 atom을 지목한 곳
$ rg -n "^\s*-?\s*combat_global_cooldown_constant" -B3 atoms/ | rg -B3 "affects:"
atoms/combat/combat_dps_curve.md:
affects:
- combat_global_cooldown_constant ← (주의: 방향 확인 필요)
→ 1건. 다만 이건 dps_curve가 "영향을 준다"고 선언한 것으로,
방향이 반대일 수 있습니다. 인간 검토 필요 — 아래 검증 항목 참조.
[3/3] wikilink 역참조 — [[...]] 형식 링크
$ rg -n "\[\[combat_global_cooldown_constant" --type md
docs/spec/combat_v3.md:42: ...[[combat_global_cooldown_constant]]...
atoms/combat/combat_skill_cooldown_rule.md:34: ...[[combat_global_cooldown_constant]]...
→ 2건. 둘 다 [1/3]에서 이미 잡힌 위치입니다 (중복).
[합산 — 중복 제거 후 고유 영향 대상]
1. atoms/combat/combat_skill_cooldown_rule.md (인바운드+wikilink)
2. atoms/combat/combat_gcd_ui_feedback.md (인바운드)
3. docs/spec/combat_v3.md (인바운드+wikilink)
4. docs/spec/skill_design.md (인바운드)
5. docs/balance/refgame_comparison.md (인바운드)
6. atoms/combat/combat_dps_curve.md (affects — 방향 미확정)
[등급 추천 — 18.2 임팩트 5등급 기준]
1. combat_skill_cooldown_rule → T1 (모든 스킬 쿨다운이 이 상수의 배수)
2. combat_gcd_ui_feedback → T2 (UI 피드백 타이밍, 시각 보정 필요)
3. combat_v3.md → T3 (스펙 문서 수치 갱신)
4. skill_design.md → T3 (설계 문서 수치 갱신)
5. refgame_comparison.md → T2 (벤치마크 비교표, refgame 대비 격차 재계산)
6. combat_dps_curve.md → 미정 (방향 확인 후 등급)
여기서 내가 멈춘 지점이 두 곳이다.
거부 1 — affects 방향. combat_dps_curve.md가 affects: 필드에 우리 상수를 적어둔 것은, dps_curve가 이 상수에 영향을 준다는 선언이다. 그런데 지금 우리가 바꾸는 건 상수 쪽이다. 즉 화살표 방향이 반대다. dps_curve가 상수를 바꾸는 게 아니라, 상수가 바뀌면 dps_curve의 계산 전제가 흔들린다. Claude가 "방향 확인 필요"라고 정직하게 보류한 건 맞았다. 나는 이 항목을 영향 대상으로 유지하되, affects가 아니라 "역방향 의미 의존"으로 재분류했다. T2로 확정.
거부 2 — refgame 비교표를 T2로 본 것은 과대평가였다. refgame_comparison.md는 외부 게임과의 단순 수치 비교표다. 우리 숫자가 바뀌면 표의 한 칸만 0.5→0.4로 고치면 끝이고, 어떤 시스템도 이 표를 입력으로 쓰지 않는다. T3(문서 수치 갱신)로 강등.
두 가지 수정해서 영향 표 다시 정리해줘.
1) combat_dps_curve.md는 영향 대상 유지, 라벨을 "역방향 의미 의존"으로,
등급 T2 확정.
2) refgame_comparison.md는 T2 → T3로 강등 (입력으로 쓰는 시스템 없음, 표 한 칸 수정).
그리고 T1·T2만 모아서 "변경 전 반드시 검토" 리스트로 분리해줘.
Claude가 정리한 최종 결과는 다음과 같았다.
[변경 전 반드시 검토 — T1·T2]
T1 combat_skill_cooldown_rule.md 모든 스킬 쿨다운이 이 상수의 배수
T2 combat_gcd_ui_feedback.md UI 피드백 타이밍 재보정
T2 combat_dps_curve.md 역방향 의미 의존 — DPS 곡선 전제 재확인
[문서 수치 갱신만 — T3]
T3 combat_v3.md / skill_design.md / refgame_comparison.md
처음 머릿속에 "떠오르지 않던" 6개의 영향 대상이, 한 번의 grep 사이클과 두 번의 사람 판단으로 우선순위가 붙은 리스트가 됐다. 이게 영향 범위 추출의 실체다. 도구가 후보를 전수로 긁고, 사람이 방향과 등급을 잡는다.
앞 절의 한 사이클을 흐름으로 일반화하면 이렇다. 자동 단계와 사람 단계가 어디서 갈리는지가 핵심이다.
flowchart TD
A[변경 대상 atom 지정
combat_global_cooldown_constant] --> B{impact 실행}
B --> C1[인바운드 엣지 grep
rg atom_id]
B --> C2[affects 필드 grep
rg affects: 블록]
B --> C3[wikilink 역참조 grep
rg 대괄호 atom_id]
C1 --> D[중복 제거 · 합산]
C2 --> D
C3 --> D
D --> E[LLM 등급 추천
T0~T4 라벨]
E --> F{사람 검증}
F -->|방향 오류·등급 과대| G[거부 후 재요청]
G --> E
F -->|승인| H[변경 전 검토 리스트 확정
T1·T2 분리]
H --> I[변경 요청 코멘트에 자동 첨부]
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
class B,C1,C2,C3,D,I code;
class E ai;
class F,G human;
class A,H data;
자동인 부분은 grep 세 갈래와 합산, 그리고 등급 초안이다. 사람인 부분은 단 하나, 방향과 등급의 최종 판단이다. 앞 사이클에서 본 affects 역방향과 refgame 강등이 정확히 이 자리에서 일어났다. 도구를 100% 신뢰하면 affects 역방향을 영향 대상에서 빼버리거나, 비교표를 과보호해서 매번 불필요한 리뷰를 도는 두 가지 사고가 난다. 자동과 사람의 경계를 이 한 지점에 두는 게 워크플로의 설계 의도다.
impact 내부가 호출하는 ripgrep 패턴을 그대로 적는다. 이게 도구의 정체다 — 화려한 인프라가 아니라 검증된 정규식 세 줄이다.
인바운드 엣지. atom ID가 문서 본문에 등장하는 모든 위치다. 가장 넓게 긁는다.
rg -n "combat_global_cooldown_constant" --type md docs/ atoms/
affects 필드. affects: 블록 안에 atom ID가 들어간 경우만 본다. -B3으로 앞 3줄을 같이 떠서, 그게 affects 블록인지 다른 필드인지 사람이 눈으로 확인한다.
rg -n "combat_global_cooldown_constant" -B3 atoms/ | rg -B3 "affects:"
wikilink 역참조. 대괄호 두 개로 감싼 명시 링크만. 신뢰도가 가장 높아 우선 검토 대상이다.
rg -n "\[\[combat_global_cooldown_constant" --type md atoms/ docs/
세 패턴은 신뢰도와 재현율이 정확히 반비례한다. wikilink는 거의 100% 정확하지만 작성자가 링크를 안 걸면 못 잡는다. 인바운드 엣지는 다 긁지만 우연한 단어 일치(노이즈)가 섞인다. affects는 의미를 잡지만 방향이 헷갈린다. 셋을 합쳐야 구멍이 메워진다. 하나만 쓰면 반드시 새는 곳이 생긴다.
영향 범위 추출은 결정 사이클(§18.3)의 한 단계다. 결정 카드가 등록되는 순간 그 카드의 affected_atoms 슬롯을 입력으로 impact가 돈다. 이 연결을 검증하는 atom이 portal_layer_change_impact_check다.
이 atom의 역할은 "Layer를 가로지르는 변경일 때, 영향 검사를 건너뛰지 못하게 막는 것"이다. 쿨다운 상수 변경은 L1(시스템)의 숫자 하나지만, 그 영향은 L3(데이터 시트 수식)과 L4(빌드 QA 항목)까지 번진다. portal_layer_change_impact_check는 변경이 Layer 경계를 넘는지 판정하고, 넘으면 impact 실행을 강제한다.
---
name: portal_layer_change_impact_check
type: gate
description: Layer 경계를 넘는 변경은 영향 검사 통과 전 빌드 반영 금지
trigger:
- 결정 카드 등록 시 affected_atoms 비어있지 않음
- 변경 atom의 layer != 영향 atom의 layer
action:
- impact 실행 (세 갈래 grep)
- T1·T2 영향 대상이 있으면 "검토 완료" 체크 전 머지 차단
---
쿨다운 사례에서 이 게이트가 잡아낸 건 combat_skill_cooldown_rule(L1)이 아니라, 그 rule을 입력으로 쓰는 CombatBalance 시트(L3)였다. 시트의 쿨다운 배수 컬럼이 상수를 전제로 수식을 짠다. grep이 문서에서 atom을 긁고, 게이트가 "이건 Layer를 넘으니 시트까지 봐라"라고 밀어붙였다. 둘이 묶이지 않으면 문서는 갱신됐는데 시트는 옛 전제로 남는 전형적 누락이 난다.
저자의 프로젝트 A 운영에서 관찰한 변화다. 시간 수치는 저자 추정(미검증)이며, 누락 사고 건수는 분기 회고에 실제로 집계된 값이다.
| 항목 | 워크플로 부재 | impact 운영 |
|---|---|---|
| 영향 atom 파악 시간 | 기억에 의존 (불완전) | 1~2분 (전수 grep) |
| 변경 누락 사고 | 분기당 8~12건 (집계 실측) | 분기당 1~2건 (집계 실측) |
| 변경 요청에 영향 첨부 | 사람이 가끔 | 게이트가 강제 |
| 신규 팀원 영향 파악 | 며칠 (구두 전수) | 30분 (도구 + 카드) |
| 인프라 비용 | 그래프 DB 도입 검토 | ripgrep + 셸만 |
마지막 줄이 이 챕터 전체의 결론이다. 프로젝트 A는 그래프 DB와 검색 인덱스를 검토했다가 결국 ripgrep과 작은 셸로 정착했다. 정밀 측정 장비가 줄자보다 정확하긴 하다. 그러나 매일 꺼내 쓰는 도구는 고장 안 나고 인프라 없는 줄자 쪽으로 수렴한다. 누락 사고가 8~12건에서 1~2건으로 떨어진 건 도구가 정교해서가 아니라, 매번 빠짐없이 돌기 때문이다.
세 갈래로 묶어도 새는 곳은 있다. 알고 쓰는 것과 모르고 믿는 것은 다르다.
별칭과 약어. 본문이 "GCD(Global Cooldown, 전역 쿨다운)"라고만 쓰면 combat_global_cooldown_constant grep에 안 걸린다. 보완은 검색어를 정규식으로 확장하는 것이다 — (combat_global_cooldown_constant|GCD|전역\s?쿨다운). 팀 약어 사전을 별도로 관리해 검색 시 자동 합성한다.
비가역 영역. grep은 가역 단계의 도구다. 빌드 반영 전, 문서와 atom과 시트 사이의 영향은 전부 grep으로 보인다. 그러나 빌드가 나가고 유저가 쿨다운 0.4초를 체감한 뒤의 반응 — 커뮤니티 불만, 체감 템포 변화 — 은 grep 대상이 아니다. 그래서 원칙은 단순하다. 모든 grep 검토는 빌드 반영 전에 끝낸다. 비가역 단계로 넘어가면 grep으로 알 수 있는 게 급격히 줄어든다.
LLM 검수의 자리. 앞 사이클에서 affects 방향과 등급을 사람이 잡았듯, grep 후보의 적합성 판정에 LLM을 끼우면 노이즈가 빠진다. 다만 LLM도 100%가 아니므로 최종 승인은 사람이다. 도구·LLM·사람이 한 단계씩 거르는 구조에서 정확도가 운영 가능한 수준에 도달한다. 한 단계라도 빼면 그 단계가 놓치던 종류의 사고가 다시 들어온다.
게임 밖 적용. "이 항목을 바꾸면 어디가 흔들리나"를 기억이 아니라 전수 검색으로 긁는 습관은, 문서·스프레드시트로 일하는 어느 사무직에게나 같은 효과를 냅니다. 어떤 약관 조항 하나를 고칠 때 그 조항 번호가 명시된 계약서·안내 메일·고객 FAQ가 몇 군데인지 머리로 떠올리려 하면 반드시 빠뜨리지만, 폴더 전체를 키워드로 grep해 전수로 긁은 뒤 사람이 "고쳐야 함 / 표시만 / 무관"으로 분류하면 누락이 사라집니다. 예를 들어 회계 담당자가 특정 계정 코드를 변경할 때, 그 코드를 참조하는 정산 시트·보고 템플릿·매크로를 전수 검색해 변경 전 검토 리스트로 만들면, "그 시트 하나를 못 봤다"는 분기 결산 사고를 구조적으로 막습니다.
문서·atom이 평문(.md)으로 관리되고 ripgrep(rg)이 설치돼 있으면 준비 끝입니다. atom ID 명명 규칙(스네이크 케이스, 고유 ID)이 있으면 grep 정확도가 크게 오릅니다.
# 검증: atom ID 하나가 문서 전체에서 몇 번 등장하는지
rg -c "combat_global_cooldown_constant" docs/ atoms/
변경 대상 atom과 변경 내용을 주고, 세 갈래 추출 + 등급 추천을 요청하세요.
impact를 <atom_id>에 대해 돌려줘.
인바운드 엣지 / affects / wikilink 역참조 세 갈래를 각각 grep으로 뽑고,
중복 합산한 뒤 18.2 임팩트 등급(T0~T4)을 추천해줘.
변경 내용: <무엇을 무엇으로>.
T1·T2만 "변경 전 검토" 리스트로 분리해줘.
도구 출력을 그대로 믿지 말고 두 가지를 손으로 확인하세요.
확인 후 T1·T2 리스트만 변경 요청 코멘트에 붙이면 한 사이클이 닫힙니다.
도구도 atom 그래프도 없는 개인 작업이라면, 명령 한 줄과 메모 한 칸으로 같은 효과를 낼 수 있습니다.
# 바꾸려는 개념 이름으로 전 폴더 전수 검색
rg -n "전역쿨다운|GCD|global_cooldown" .
검색 결과를 그대로 메모장에 붙이고, 각 줄 옆에 "고쳐야 함 / 표만 / 무관" 셋 중 하나를 손으로 적으세요. 이게 1인 버전의 impact입니다. 핵심은 도구의 정교함이 아니라 "기억에 의존하지 않고 전수로 긁은 뒤 사람이 분류한다"는 절차 자체에 있습니다. 절차가 있으면 누락이 줄고, 없으면 월요일 오전의 그 막막함이 매번 반복됩니다.
1차 독자: 중규모(10~50인) 팀을 이끄는 디자인 디렉터·리드 기획자 1인/취미 독자용 축소 버전: §19.1.8 「혼자라면 이만큼만」
비전 문서를 한 페이지로 잘 써 둔 팀에서도 같은 사고가 반복된다. 비전은 벽에 걸려 있는데, 정작 매주 쌓이는 결정들이 그 비전과 맞는지를 아무도 확인하지 않는다. 분기 회고 때 한 번 들춰 보지만, 그때는 이미 어긋난 결정 위에 다음 결정이 세 개쯤 얹혀 있다. 비전이 "분쟁의 기준점"이 되려면 작성보다 결정마다 비전에 걸어 보는 일이 중요하다. 그리고 그 대조 작업은 사람이 손으로 하면 지루하고 빠뜨리기 쉬운 — AI에게 넘기기 딱 좋은 일이다.
이 장은 두 가지를 묶는다. 앞쪽은 이미 작성된 비전을 결정의 채점표로 돌리는 워크플로 — 저자 프로젝트의 실제 결정 atom 26개를 LLM에 걸어 "비전 슬롯 위반" 판정을 받고, 그중 한 건의 오판을 사람이 잡는 한 사이클. 뒤쪽은 그 채점표가 누구의 결정까지 커버하느냐는 질문, 즉 권한 위임이다. 리더십 일반론(비전이 왜 중요한가, 위임이 왜 성장의 도구인가)은 이미 다른 책에 충분하니, 이 장은 그 원칙을 AI 워크플로로 돌리는 자리에만 집중한다.
비전이 결정을 거른다는 말부터 정리해야 한다. 비전·로드맵·일정은 같은 것이 아니다. 시간 단위와 변경 빈도가 다르고, 그 차이가 무너질 때 일정 압박이 비전을 흔든다.
| 층 | 기간 | 변경 빈도 | 비전 대조의 의미 |
|---|---|---|---|
| 비전 | 5~10년 | 거의 없음 | 결정이 부합해야 할 기준선 |
| 로드맵 | 1~3년 | 분기 | 비전을 일정으로 번역한 중간층 |
| 일정 | 1~3개월 | 주 | 비전과 직접 대조하지 않음 |
핵심은 결정을 거는 대상이 비전(가장 안 변하는 층)이라는 점이다. 일정이 빠듯하다고 비전을 바꾸는 게 아니라, 일정이 비전과 어긋날 때 일정 쪽을 손본다. 이 위계가 분명해야 다음 절의 자동 점검이 의미를 갖는다. 점검의 기준선이 매주 흔들리면 점검 자체가 무의미하기 때문이다.
비전은 한 페이지, 5개 슬롯으로 끝낸다. 저자 프로젝트의 비전 문서는 다음 골격이다. 이 슬롯들이 §19.1.3 LLM 점검의 채점 기준이 되므로, 형태를 먼저 봐 둔다.
---
title: 프로젝트 A 비전 v2
layer: L0
locked: true # 변경 시 게임 디렉터 + CEO 합의 필요
---
## 슬롯 1. 우리가 만드는 것
한국 판타지 세계관의 모바일 우선 MMORPG.
## 슬롯 2. 누구를 위해
30~50대, 모바일 위주, 진중한 서사를 즐기는 사용자.
## 슬롯 3. 왜 (차별화)
- 다계층 내러티브로 깊은 서사 (양산이 아닌 깊이)
- 동남아 + 한국 동시 운영
## 슬롯 4. 어떻게 (가치)
- 사용자의 시간을 존중 (낭비 콘텐츠 최소)
- 데이터 + 사람 균형 결정
- 팀 합의가 결정 속도보다 우선
## 슬롯 5. 무엇이 아닌가
- F2P 폭주형 결제 모델 아님
- PvP 중심 아님
- 매일 N시간 강제 출석 아님
슬롯 5("무엇이 아닌가")가 점검에서 가장 일을 많이 한다. 위반은 대개 "하기로 한 것"이 아니라 "안 하기로 한 것"을 슬그머니 하는 자리에서 나오기 때문이다.
비전을 무엇에 걸 것인가. 저자 팀은 모든 주요 결정을 decisions/ 폴더에 atom 한 장씩으로 박제한다. 날짜·당사자·근거가 명시된 사실 기록이고, 현재 26개가 쌓여 있다. 점검의 입력은 이 26개다 — 새로 만드는 게 아니라 이미 있는 것을 거는 것이다.
결정 atom 한 장의 실제 형태는 이렇다(익명화).
---
type: decision
id: D0019
date: 2026-05-12
deciders: [게임 디렉터, 데이터 디렉터]
tier: T1
---
# refgame_selective_adoption_for_mobile
참조 MMORPG의 전투 데이터 일부를 모바일 빌드에 선택적으로 채택한다.
근거: 모바일 6인치에서 검증된 전투 페이스가 있고, 0부터
재설계하면 알파 일정이 한 분기 밀린다. 단, 결제·출석 유도
구조는 채택하지 않는다.
26개 중 점검 입력으로 쓸 대표 몇 개를 추린다(실제 atom명, §A.3.3).
| atom id | atom명 (익명화) | tier | 한 줄 요지 |
|---|---|---|---|
| D0007 | claude_role_transition_phase2 |
T1 | Claude를 수동 보조 → 능동 파트너로 격상 |
| D0014 | dataset_scope_alpha_split |
T2 | 알파 데이터셋 분리 기준 확정 |
| D0019 | refgame_selective_adoption_for_mobile |
T1 | 참조 게임 전투 데이터 선택 채택 |
| D0021 | procedural_capability_frontier_5stage |
T1 | 절차적 생성 능력 5단계 정의 |
| D0023 | class_keyword_world_only |
T2 | 클래스 키워드를 세계관 내로 제한 |
이 표가 다음 절 프롬프트의 입력 데이터다. 26개를 한 번에 거는 것이 핵심인데, 사람이 회고 때 손으로 26개를 하나씩 비전에 대조하면 한나절이 걸리고 중반부터 집중력이 떨어져 위반을 놓친다. 그 지루한 1차 대조를 LLM에 넘긴다.
실제로 한 사이클을 끝까지 본다. 입력 프롬프트는 그대로 복사해 쓸 수 있고, 출력은 실제 세션을 재구성한 것이다.
아래 비전 5슬롯에 결정 atom 26개를 하나씩 걸어서, 각 결정이 비전에
부합/보완/위반/무관 중 뭔지 판정해줘. 위반이나 무관이면 어느 슬롯과
왜 그런지 한 줄로 근거 달고 (특히 슬롯 5 "무엇이 아닌가"를 잘 봐줘),
애매한 건 '애매'로 표시해서 나한테 넘겨. 부합은 근거 없어도 돼.
표로만 줘 (id | 판정 | 충돌 슬롯 | 근거 | 애매), 총평은 빼고.
[비전 5슬롯 — §19.1.1 전문 붙여넣기]
[결정 atom 26개 — id·본문·근거]
프롬프트의 절반이 "판정 형식 강제"다. 자유를 주면 LLM은 26개를 다 "대체로 부합합니다"로 뭉갠다. 4단계 판정 라벨, 위반·무관에만 근거 의무, 그리고 "애매하면 사람에게 넘겨라"는 탈출구를 넣어 두는 것이 이 프롬프트의 핵심이다. 슬롯 5를 근거 문장에 명시적으로 호명한 것도 의도다 — 위반은 "안 하기로 한 것"에서 나오기 때문이다.
| id | 판정 | 충돌 슬롯 | 근거 | 애매 |
|-------|------|----------|------------------------------------------|------|
| D0007 | 무관 | — | Claude 운영 결정. 게임 비전과 평가축 다름 | |
| D0014 | 무관 | — | 데이터셋 분리. 내부 인프라 결정 | |
| D0019 | 위반 | 슬롯 5 | 참조 게임 전투 데이터 채택 → "F2P 폭주형 | ★ |
| | | | 결제 모델 아님"과 충돌 소지 | |
| D0021 | 부합 | 슬롯 3 | | |
| D0023 | 부합 | 슬롯 1 | | |
| ... | | | | |
(26개 중 부합 18 · 보완 3 · 무관 3 · 위반 2)
위반/애매 판정 요청: D0019, D0026 — 사람 검토 필요
출력에서 가장 가치 있는 부분은 표가 아니라 맨 아래, 위반 2건과 애매 표시다. LLM이 26개 중 24개를 자동으로 거르고, 사람이 봐야 할 2건만 올려 줬다. 한나절 대조가 2건 검토로 줄었다. 그런데 그 2건 중 하나가 오판이다.
D0019(refgame_selective_adoption_for_mobile) 판정을 사람이 다시 읽는다. LLM은 "참조 게임 전투 데이터 채택"을 보고 슬롯 5의 "F2P 폭주형 결제 모델 아님"과 충돌한다고 판정했다. 표면 단어는 그럴듯하다 — 참조 게임이 공격적 결제로 유명하니까.
그러나 atom 본문을 끝까지 읽으면 마지막 문장이 있다. "단, 결제·출석 유도 구조는 채택하지 않는다." 결정은 전투 페이스 데이터만 가져오고 결제 구조는 명시적으로 배제했다. 슬롯 5를 오히려 지키는 결정이다. LLM은 atom 본문의 마지막 한정 문장을 판정 무게에 반영하지 못하고, "참조 게임"이라는 출처 단어에 끌려 위반으로 분류했다. 이건 슬롯 5 위반이 아니라 부합이다.
이런 오판이 나오는 이유는 분명하다. LLM은 결정의 출처(어떤 게임에서 가져왔나)와 결정의 내용(무엇을 가져오고 무엇을 버렸나)을 같은 무게로 본다. 사람은 "단, ~는 하지 않는다"는 한정절이 결정의 핵심임을 안다. 그래서 사람이 거부하고 재요청한다.
D0019 다시 봐줘. 본문 마지막 문장 "단, 결제·출석 유도 구조는 채택하지 않는다"가
핵심이야. 채택하는 것(전투 페이스 데이터)이랑 배제하는 것(결제·출석 구조)을
나눠서 각각 어느 슬롯에 걸리는지 다시 판정해줘.
LLM은 다시 답했다. "채택 대상(전투 데이터)은 슬롯 1·2에 부합, 배제 대상(결제 구조)은 슬롯 5를 적극 지지. 종합 판정: 부합. 직전 위반 판정은 출처 단어에 과반응한 오류." 이 한 번의 왕복으로 D0019는 위반에서 부합으로 정정됐다. 남은 진짜 검토 대상은 D0026 한 건이다.
이 사이클이 이 장의 핵심이다. LLM은 26개를 2건으로 줄여 주지만, 그 2건 중 하나가 오판일 수 있다. 자동 점검은 사람의 검토를 없애는 게 아니라, 사람이 26개 대신 2건에 집중하게 만드는 도구다. 그 2건을 사람이 끝까지 안 읽으면, 멀쩡한 결정이 "비전 위반"으로 회의에 올라가 엉뚱한 분쟁을 만든다.
위 사이클을 그림으로 남겨 두면, 이후 분기마다 같은 흐름이 반복된다. 핵심은 LLM 판정이 자동으로 결정을 뒤집지 않는다는 점이다. 위반·애매만 사람 게이트로 올리고, 폐기·정정·승인은 사람이 한다.
flowchart TB
A["비전 5슬롯 (L0, locked)
채점 기준선"]
B["decisions/ atom 26개
id·본문·근거"]
A --> C["LLM 1차 판정
부합/보완/위반/무관 + 근거"]
B --> C
C -->|부합·보완·무관 24건| F["통과 — 분기 회고 기록"]
C -->|위반·애매 2건| D{"사람 검토 게이트"}
D -->|오판 확인| E["재요청
(한정절·맥락 보강)"]
E --> C
D -->|진짜 위반| G["결정 재론 회의 소집"]
D -->|정정 후 부합| F
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
class C ai;
class D,E,G human;
class A,B data;
class F pass;
사람의 손이 닿는 곳은 두 군데뿐이다. 비전과 결정을 깨끗이 입력하는 자리(맨 위)와, LLM이 위반·애매로 올린 소수 건을 끝까지 읽고 판정하는 자리(가운데 게이트). 그 사이의 지루한 26개 대조는 LLM이 돌린다. §6.2의 city 생성기에서 lint가 위반을 자동 폐기하지 않고 작가 게이트로 alert만 올렸던 것과 같은 설계다 — 기계는 의심 후보를 뽑고, 죽일지 살릴지는 사람이 정한다.
여기서 자연스러운 질문이 나온다. 26개 결정을 게임 디렉터가 다 내렸나? 그러면 안 된다. 리드가 모든 결정을 직접 하면 병목이 되고, 다 위임하면 비전이 약해진다. 비전 점검은 위임된 결정까지 같은 채점표로 거르기 위한 안전망이기도 하다.
결정에는 등급이 있고, 등급이 곧 권한이다. 저자 팀의 권한 매트릭스다.
| 등급 | 결정자 | 검토자 | 통보 | 비전 점검 대상? |
|---|---|---|---|---|
| T0 비전·핵심 | 게임 디렉터 + CEO | 전 팀장 | 전 팀 | 비전 자체 (점검 기준선) |
| T1 시스템·다분야 | TF 의장 + 게임 디렉터 | TF 멤버 | 분야 팀 | ✅ 필수 |
| T2 분야·중간 | 분야 디렉터 | 시니어 | 분야 팀 | ✅ 필수 |
| T3 단발·작은 | 시니어 | 담당자 | 직접 관련 | 표본 점검 |
| T4 즉시·핫픽스 | 담당자 | 시니어(사후) | 게임 디렉터(사후) | 점검 제외 |
decisions/ 26개는 대부분 T1·T2다 — 위임된 결정들이다. 게임 디렉터가 모든 T2를 직접 보지 않는다. 대신 비전 점검(§19.1.3)이 위임된 T1·T2 결정을 분기마다 한 번 비전에 걸어 본다. 위임의 안전망이 곧 비전 점검인 셈이다. T0는 점검 대상이 아니라 점검의 기준선이고, T4 핫픽스는 양이 많고 비전 영향이 거의 없어 제외한다.
위임 자체는 한 번에 풀로 가지 않고 4단계로 점진한다.
| 단계 | 권한 | LLM 점검과의 관계 |
|---|---|---|
| 1. 정보 전달 | "이렇게 하라" | 위임자가 결정, 점검 불필요 |
| 2. 조언 + 결정 보고 | "X를 고려해 결정하라" | 보고 시 비전 대조 같이 봄 |
| 3. 사후 보고 | "결정하고 결과 알려라" | atom 박제 → 분기 점검에 포함 |
| 4. 자율 결정 | 보고 의무 없음 (등급 한도 내) | atom만 남기면 점검이 사후 커버 |
4단계 자율 결정이 비전과 어긋날 위험이 가장 큰데, 바로 그 위험을 §19.1.3 점검이 사후에 잡는다. 자율로 내린 T2 결정도 atom으로 박제만 되면 분기 점검에 자동으로 걸린다. 위임의 자유와 비전의 일관성이 충돌하지 않는 이유가 여기 있다 — 자유롭게 결정하되, 결정은 atom으로 남고, atom은 분기마다 비전에 걸린다.
비전·위임 챕터는 "비전 도입 후 회의 시간이 90분에서 45분으로 줄었다", "위임 후 디렉터 결정 부담이 주 30건에서 5건으로" 같은 표를 넣고 싶은 유혹이 크다. 그런 숫자는 검증되지 않으면 책의 신뢰를 깎는다. 이 장의 수치는 셋 중 하나로만 다룬다.
첫째, 세는 것은 실측으로 적는다. decisions/ atom은 현재 26개다(2026년 5월 실측 기준). LLM 1차 판정에서 사람 게이트로 올라온 건수, 그중 오판으로 정정된 건수는 세션 로그로 카운트되는 실측값이다. 위 워크드 트랜스크립트에서 위반 판정 2건 중 1건(D0019)이 오판이었다는 것도 실제 세션의 결과다.
둘째, 효과는 방향으로만 말한다. "한나절 대조가 소수 건 검토로 줄었다"는 구조의 방향이지 절대 시간이 아니다. 정확한 절약 시간은 결정 수·팀 규모·atom 본문 길이에 따라 달라지므로, "26개를 손으로"와 "LLM 1차 + 사람 게이트"의 구조 차이로 읽는 게 맞다. 회의 시간·동기 점수 같은 결과 지표는 비전 하나로 좌우되지 않으니 인과를 단정하지 않는다.
셋째, 측정 가능한 것만 약속한다. 이 워크플로가 실제로 측정 가능한 것은 — 분기당 비전 점검에 건 결정 수, 사람 게이트 통과 건수, 오판율(LLM 위반 판정 중 사람이 부합으로 정정한 비율), atom 박제 누락 건수(위임됐는데 atom이 없어 점검에서 빠진 결정)다. 이 넷은 회의에서 "느낌"이 아니라 숫자로 말할 수 있다. 특히 오판율은 LLM 판정을 그대로 믿으면 안 되는 이유를 매분기 숫자로 증명한다.
| 패턴 | 왜 실패하나 | 처방 |
|---|---|---|
| 비전을 작성만 하고 결정에 안 건다 | 비전이 벽 장식으로 남고 결정은 제멋대로 | 분기마다 atom 26개를 비전에 거는 §19.1.3 |
| LLM 위반 판정을 그대로 회의에 올림 | 오판(D0019 같은)이 엉뚱한 분쟁을 만듦 | 위반·애매 건은 atom 본문 끝까지 사람이 읽기 |
| 결정을 atom으로 안 남김 | 위임된 결정이 점검에서 통째로 빠짐 | 사후 보고(위임 3단계)에 atom 박제 의무화 |
| T4 핫픽스까지 다 점검 | 양만 늘고 비전 영향은 거의 없음 | 점검 대상을 T1·T2로 한정 |
| 위임을 1→4단계 건너뛰기 | 자율 결정이 비전과 어긋난 채 누적 | 단계적 위임 + 분기 점검으로 사후 커버 |
세 번째가 가장 자주 놓친다. 자율로 잘 굴러가는 팀일수록 결정을 입으로만 합의하고 atom을 안 남긴다. 그러면 §19.1.3 점검은 박제된 결정만 보므로, 가장 자유롭게 내린 결정이 점검의 사각지대로 빠진다. 위임의 자유는 atom 박제를 전제로만 안전하다.
게임 밖 적용. 비전을 결정마다 걸어 보는 일과 권한 위임은 게임 팀만의 숙제가 아니라 모든 관리자의 일입니다. 부서의 미션을 한 페이지 5슬롯("우리가 하는 것 / 누구를 위해 / 왜 / 어떻게 / 무엇이 아닌가")으로 못 박아 두면, 매주 쌓이는 실무 결정이 그 미션과 어긋나는지를 분기에 한 번 LLM으로 1차 대조할 수 있습니다 — 특히 "안 하기로 한 것"을 슬그머니 하는 위반이 잘 잡힙니다. 예를 들어 팀장이 위임한 결정들을 분기마다 부서 미션에 걸어 보면, 자율적으로 내려진 결정이 방향에서 벗어났는지를 사후에 잡는 안전망이 됩니다. 다만 LLM이 "위반"으로 올린 건은 그대로 회의에 올리지 말고, 그중 하나는 오판일 수 있으니 사람이 끝까지 읽어야 합니다.
혼자라면 이만큼만: 결정 atom 폴더가 없어도 됩니다. 본인 프로젝트(또는 취미 게임)의 비전을 §19.1.1의 5슬롯으로 한 페이지만 적어 보세요. 그다음 최근에 내린 결정 5~10개를 한 줄씩 메모로 적고, §19.1.3의 프롬프트를 그대로 붙여 LLM에 한 번 걸어 보세요. '위반' 판정이 하나라도 나오면 그 결정의 메모를 끝까지 다시 읽고, LLM이 맞는지 본인이 직접 반박해 보세요. 그러면 비전 점검이 어떤 판단의 묶음인지, 왜 LLM 판정을 그대로 믿으면 안 되는지 몸으로 들어옵니다.
팀이라면 다음 한 단계로 시작하세요. 비전을 5슬롯 한 페이지로 고정(L0, locked)하고, 최근 분기에 내린 T1·T2 결정을 decisions/ 폴더에 atom 한 장씩으로 박제하는 것부터 합니다. atom이 10개만 쌓여도 §19.1.3 프롬프트를 한 번 돌려 볼 수 있고, 그 첫 사이클에서 위임된 결정 중 비전과 어긋난 한 건을 잡으면 이 워크플로의 가치가 곧장 보입니다.
1차 독자: 분기 50건 이상의 결정을 회의에서 내리는 디렉터·팀장 (중규모(10~50인) 팀) 1인/취미 독자용 축소 버전: §19.2.8 「혼자라면 이만큼만」
회의를 90분 잘 끌고 와 놓고, 일주일 뒤 같은 안건이 회의 테이블에 다시 올라온 적이 있다. 분명히 결정했는데, 누가 무엇을 맡았는지가 아무 데도 안 적혀 있었다. 회의록에는 "글로벌 쿨다운 논의함"만 남았고, "0.5초로 정함, 담당 팀원 A"는 그 자리에 있던 사람 머릿속에서 일주일 만에 휘발했다. 리더의 회의가 무너지는 지점은 대부분 회의 중이 아니라 회의가 끝난 직후, 결정이 기록으로 굳기 전 그 짧은 틈이다.
이 장은 팀장 일의 두 덩어리를 다룬다. 앞쪽 절반은 갈등을 매번 0부터 푸는 대신 종류별 표준 처방으로 보내는 법이고, 뒤쪽 절반은 이 장의 척추 — 회의에서 나온 결정을 AI가 추출하되 소유자·근거가 비면 통과시키지 않게 강제하는 워크드 트랜스크립트이다. 리더십 일반론(비전 제시·경청·공감)은 다른 책에 충분하니, 이 장은 그 일반론을 AI 워크플로로 돌려 결정 누락을 막는 자리에만 집중한다.
갈등 0인 팀이 건강한 팀이라는 건 오해다. 중규모(10~50인) 팀이 분기 50건 넘는 결정을 내리는데 마찰이 한 번도 안 보인다면, 갈등이 없는 게 아니라 수면 아래로 가라앉은 것이고, 가라앉은 갈등이 더 위험하다.
리더가 할 일은 갈등을 없애는 게 아니라 종류를 빨리 분류해 표준 처방으로 보내는 것이다. 같은 갈등이 매번 다른 방식으로 풀리면, 풀리는 데 걸리는 시간이 매번 0부터 다시 쌓인다.
| 갈등 종류 | 충돌의 정체 | 표준 처방 |
|---|---|---|
| 가치 갈등 | 비전 해석 차이 (매출 vs 사용자 시간) | 비전 슬롯 인용 |
| 사실 갈등 | 같은 데이터의 다른 해석 | 데이터 확인 (메타게임 보고서) |
| 우선순위 갈등 | "내 분야가 더 중요" | 임팩트 등급·KPI 영향 비교 |
| 권한 갈등 | "이건 내 결정" | 권한 매트릭스 재확인 |
| 개인 갈등 | 인간관계·소통 스타일 | 1:1, 사실/감정 분리 (시스템 밖) |
앞의 네 종류는 처방이 시스템 인용이다. 비전·데이터·KPI·권한 매트릭스가 명문화돼 있으면, 결정의 무게가 사람의 입에서 시스템 쪽으로 옮겨가 토론이 짧아진다. 다섯 번째 개인 갈등만 시스템 밖이다 — 1:1과 사실·감정 분리, 시간과 진심 외의 도구는 거의 작동하지 않는다. 다만 "시스템으로 안 풀린다"가 리더가 손 놓을 명분은 아니다. 시스템이 못 푸는 영역도 결국 리더의 일이라는 점이 이 자리의 까다로움이다.
분류는 매번 처음부터 푸는 게 아니라 한 흐름으로 돈다.
flowchart LR
A["갈등 인지"] --> B["종류 분류
(5종)"]
B --> C["표준 처방 적용
비전·데이터·KPI·권한·1:1"]
C --> D["1주 후 후속 확인"]
D --> E{"재발?"}
E -->|"같은 종류 반복"| F["시스템·룰 점검
(분기 회고 갈등 슬롯)"]
E -->|"해소"| G["종결"]
F --> A
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
class A,B,C,D,E,F human;
class G pass;
핵심은 오른쪽 분기점이다. 같은 종류의 갈등이 반복되면 그건 사람 문제가 아니라 시스템 문제다. 그때는 사람을 중재하는 대신 비전·권한 매트릭스 같은 룰을 손본다. 이것이 §19.2.7에서 다룰 분기 회고 갈등 슬롯의 입력이 된다.
갈등 처방의 네 종류가 전부 "시스템 인용"인 것처럼, 회의도 결국 결정을 만들고 그 결정을 기록으로 굳히는 장치다. 리더가 회의에서 지켜야 할 다섯 원칙은 서로 묶여 있다. 어느 하나만 빠져도 나머지가 같이 흔들린다.
이 다섯 중 3·4·5가 무너지는 게 서두의 사고였다. 결정을 입으로는 했는데(원칙 3 부분 충족), 기록으로 굳지 않았고(원칙 4 실패), 소유자가 입력되지 않았다(원칙 5 실패). 그래서 일주일 뒤 같은 안건이 다시 올라왔다.
문제는 원칙 3·4·5를 사람의 의지에 맡기면 바쁜 주에 가장 먼저 무너진다는 것이다. 회의가 끝나면 리더는 이미 다음 회의로 달려간다. 그래서 이 세 원칙을 AI 보조 파이프라인으로 옮긴다. 회의록 텍스트에서 결정을 자동 추출하되, 소유자나 근거가 비어 있으면 통과시키지 않게 만드는 것이다. 이 파이프라인은 17부에서 만든 회의→회의록→atom 추출 흐름(§17.2)을 리더 관점에서 한 번 더 본다.
실제로 어떻게 돌리는지 한 사이클을 끝까지 보여준다. 무대는 저자 프로젝트(모바일 우선 MMORPG, 이하 "프로젝트 A")의 전투 TF 회의가 끝난 직후다. 입력 프롬프트는 그대로 복사해 쓸 수 있고, 출력은 실제 세션을 재구성했다.
회의록을 예쁘게 정리하지 않는다. 발언이 섞여 있고, 결정인지 아닌지 애매한 줄도 그대로 둔 거친 텍스트가 입력이다. 정리는 AI가 할 일이지 사람이 먼저 할 일이 아니다.
[2026-06-05 전투TF 회의록 본문 — 발췌, 다듬지 않음]
팀원 A: 글로벌 쿨다운 0.5초로 가는 거 시뮬 결과 안정적이었어요.
팀원 B: 회복 스킬까지 0.5초 묶으면 회복 사이클이 깨질 것 같은데.
팀원 A: 그건 따로 빼죠. 회복은 글쿨 예외로.
이민수: 좋아요, 글쿨 0.5초 통일하고 회복은 예외. A님이 데이터 시트
cooldown 컬럼 일괄로 봐주세요.
팀원 C: 타게팅 우선순위 룰은 다음 주에 더 보고 정하는 걸로...
팀원 B: 미니맵 축소 토글은 UI팀이랑 같이 봐야 할 듯요. 일단 보류.
이민수: 네 그건 다음 회의로.
여기에 결정 두 개(글쿨 0.5초, 회복 예외)와 보류 두 개(타게팅, 미니맵)가 섞여 있다. 사람이 눈으로 골라내면 한 건씩 놓친다. 그게 서두의 사고였다.
첨부한 회의록에서 "결정"만 뽑아줘. 토론·보류·정보 공유는 결정 아니야.
결정마다 decision(한 문장) / owner / rationale / follow_up 네 칸을 채우되,
owner나 rationale을 본문에서 못 찾으면 추측하지 말고
"[MISSING — 회의에서 미확정]"이라고 적어줘. 보류·다음 회의 이월은 deferred로,
결정인지 애매한 줄은 ambiguous로 빼서 나한테 넘겨. 출력은
decisions / deferred / ambiguous 세 블록으로만.
이 프롬프트의 절반이 "빈칸 강제"라는 점에 주목한다. AI에게 자유를 주면 owner를 그럴듯하게 지어내거나, 보류를 결정으로 승격시킨다. "못 찾으면 추측 말고 [MISSING]이라고 신고하라"는 탈출구를 넣어 두는 것이 이 워크플로의 핵심이다. 결정은 사람이 명시적으로 선언해야 가치가 있고(§17.6.3의 원칙), AI는 비어 있다는 사실을 드러내는 일까지만 한다.
decisions:
- decision: 전투 글로벌 쿨다운을 0.5초로 통일한다.
owner: 팀원 A ("A님이 데이터 시트 cooldown 컬럼 일괄로 봐주세요" — 이민수)
rationale: "글로벌 쿨다운 0.5초로 가는 거 시뮬 결과 안정적이었어요" (팀원 A)
follow_up: cooldown 컬럼 일괄 점검
- decision: 회복 스킬은 글로벌 쿨다운 적용에서 제외한다.
owner: [MISSING — 회의에서 미확정]
rationale: "회복까지 0.5초 묶으면 회복 사이클이 깨질 것 같은데" (팀원 B)
follow_up: 회복 스킬 cooldown 예외 처리 데이터 반영
deferred:
- 타게팅 우선순위 룰 (팀원 C, 다음 주 추가 검토)
- 미니맵 축소 토글 (UI팀 협의 필요, 보류)
ambiguous: (없음)
출력에서 가장 가치 있는 부분은 첫 번째 결정의 깔끔한 표가 아니라 두 번째 결정의 owner: [MISSING]이다. 회복 예외는 분명히 결정됐는데, 회의록 어디에도 "누가 데이터에 반영할지"가 안 적혀 있었다. AI가 그 구멍을 추측으로 메우지 않고 정직하게 신고했다. 좋은 프롬프트는 AI가 "이 자리는 비어 있습니다"라고 말할 수 있게 만든다.
이 출력을 그대로 받으면 안 된다. [MISSING]이 떴다는 건 회의가 결정을 절반만 끝냈다는 뜻이다. 여기서 리더가 할 일은 AI 출력을 고치는 게 아니라, 회의에서 빠진 결정을 마저 내리는 것이다.
저자는 이 자리에서 팀원 A에게 사내 메신저로 한 줄 물었다. "회복 예외 데이터 반영도 A님이 같이 보시는 거죠?" A가 "네"라고 답했다. 이 한 줄이 누락된 소유자를 확정한다. 그다음 재요청한다.
두 번째 결정(회복 예외) owner는 팀원 A로 확정됐어 (사내 메신저로 본인 확인함).
이거 반영해서 decisions 다시 주고, 두 결정을 pending atom 후보 형식으로도
바꿔줘.
// (의도: status: pending, source_meeting, owner, related_atoms 포함 — §17.2.4 형식)
AI는 owner가 채워진 결정 두 건을 pending atom 후보 두 개로 변환해 다시 답했다. 이 후보는 곧장 정식 결정이 되지 않고 pending 상태로 1주 검증 기간을 거친다(§17.2.4). 회의에서 정한 게 일주일 운영 후 뒤집히기도 하기 때문이다. 잉크가 마를 시간을 주는 셈이다. 입력 → 추출 → MISSING 신고 → 사람이 결정 보완 → 재요청의 한 사이클이 여기서 닫힌다.
이 한 바퀴가 서두의 사고를 구조적으로 막는다. 결정이 절반만 났을 때, 그 사실이 회의 끝나고 일주일 뒤가 아니라 회의 직후 그 자리에서 드러난다.
위 워크드 트랜스크립트를 17부의 회의록 파이프라인 위에 얹으면 전체 그림이 이렇다. 리더의 손이 닿는 곳은 두 군데뿐이다. 회의에서 결정을 선언하는 자리(맨 앞)와, AI가 신고한 [MISSING]을 보완하는 자리(가운데). 그 사이의 추출·변환·등록은 자동이다.
flowchart TB
A["회의 진행
(리더: 결정을 입으로 선언)"] --> B["회의록 텍스트
(다듬지 않은 본문)"]
B --> C["AI 결정 추출
4필드 강제 + MISSING 신고"]
C --> D{"owner·rationale
비었나?"}
D -->|"MISSING"| E["리더 보완
(메신저 확인 → 소유자 확정)"]
E --> C
D -->|"전부 채움"| F["pending atom 후보
(1주 검증 기간 §17.2.4)"]
F --> G["주 1회 리뷰
승격·폐기·보류 §17.2.5"]
G --> H["JIT manifest 등록
다음 세션 자동 주입 §17.2.6"]
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
class C,D ai;
class A,E,G human;
class B,F,H data;
이 파이프라인에서 AI가 하지 않는 일이 더 중요하다. AI는 결정을 만들지 않는다. 소유자를 지어내지 않는다. 보류를 결정으로 승격시키지 않는다. AI가 하는 건 회의록에서 결정 후보를 골라내고, 빈 칸을 드러내는 일까지다. 결정의 선언과 빈 칸의 보완은 사람이 한다. 이것이 §17.6.3에서 말한 "결정 슬롯은 AI 자동 생성 금지" 원칙의 리더 관점 적용이다 — 결정이 다른 문서·세션·빌드로 전파되면 비가역 흔적이 남기 때문에, 진입 게이트에서는 사람이 명시적으로 선언하는 자리를 보존한다.
회사 PC의 팀 공유 atom 중 team_equal_decision_culture라는 개념 atom이 있다. 회고에서 반복 인용되는 어휘를 박제한 것으로, "결정은 직책이 아니라 근거로 한다"는 팀 문화를 한 단어로 가리킨다. 디렉터가 "내가 정했으니 끝"이라고 누르는 게 아니라, 결정마다 누가·왜를 남겨 나중에 누구든 그 결정을 근거로 되짚을 수 있게 만드는 문화다.
§19.2.3의 [MISSING] 강제가 바로 이 문화의 기술적 뒷받침이다. 소유자와 근거를 빈칸으로 통과시키지 않는다는 건, 결정의 권위가 "디렉터가 말했으니까"가 아니라 "본문 어느 발언에서 나왔으니까"에 놓인다는 뜻이다. 근거 인용이 비면 결정이 막히므로, 직책으로 누른 결정은 구조적으로 atom이 되지 못한다.
이 문화가 §19.2.1의 갈등 처방과도 한 줄로 이어진다. 가치 갈등을 비전 인용으로, 사실 갈등을 데이터로, 권한 갈등을 매트릭스로 푼다는 건 전부 사람의 입 대신 기록된 근거로 푼다는 같은 원리다. 평등 결정 문화는 갈등 처방의 토양이고, [MISSING] 강제는 그 토양이 회의 단위에서 굳지 않게 매번 다지는 도구다.
여기에 팀 문화의 또 다른 축, 공개와 폐쇄의 경계가 겹친다. 회의록·결정 카드·KPI 데이터·사고 보고는 공개 영역에 두고, 1:1 대화·인사 평가·급여·개인 사정은 폐쇄 영역에 둔다. 결정 추출 파이프라인이 다루는 건 전부 공개 영역이다. 개인 갈등(§19.2.1의 다섯 번째)이 시스템 밖에 있는 이유도 같다 — 그건 폐쇄 영역이라 atom으로 박제하지 않는다.
리더십 챕터는 "회의 파이프라인을 도입했더니 회의 시간이 절반으로 줄었다" 같은 표를 넣고 싶은 유혹이 크다. 그런 숫자는 검증되지 않으면 책의 신뢰를 깎는다. 이 책의 원칙은 셋 중 하나다.
첫째, 측정 가능한 것만 지표로 약속한다. 회의 파이프라인이 실제로 셀 수 있는 건 이런 것들이다 — 결정당 owner·rationale 누락 건수(목표 0), 회의록에서 추출된 결정 중 pending atom으로 승격된 비율, "이거 전에 결정 안 했나?" 재회의 건수. 이 셋은 회의에서 "느낌"이 아니라 숫자로 말할 수 있다.
둘째, 저자 추정은 추정이라고 쓴다. 회의 직후 결정 추출에 드는 시간이 "손으로 회의록 정리 20~30분 → AI 초안 + 보완 5분 안쪽"이라는 건 저자의 경험 기반 추정이며 미검증 가설이다. 절대값을 외우지 말고 구조 차이("사람이 처음부터 골라냄" vs "AI 추출 + 빈칸만 보완")로 읽으면 된다. 정확한 절약 시간은 회의 규모·결정 수에 따라 달라진다.
셋째, 인과를 단정하지 않는다. "재회의가 줄었다"가 전적으로 이 파이프라인 덕이라고 못 박지 않는다. 팀 성숙도·프로젝트 단계도 함께 작용한다. 방향(결정 누락이 회의 직후 드러나면 재회의가 줄어드는 쪽으로 작동한다)만 말하고, 배수를 지어내지 않는다.
갈등 처방과 결정 파이프라인은 분기 회고에서 한 번 점검 사이클을 돈다. 회고에 "갈등 슬롯"과 "결정 누락 슬롯"을 둔다.
2026 Q2 분기 회고 — 갈등·결정 슬롯
─────────────────────────────────
[갈등] 이번 분기 주요 3건
1. 글로벌 쿨다운 (가치 갈등) → 비전 인용으로 종결.
학습: 비전 5슬롯이 결정 기준으로 작동함을 재확인.
2. 신규 던전 우선순위 (우선순위 갈등) → KPI 영향 비교.
학습: 우선순위 표가 없어 매번 즉석 비교 → 다음 분기 표 도입.
3. 캐릭터 디자인 권한 (권한 갈등) → 권한 매트릭스 재확인.
학습: 매트릭스에 '시각 vs 기능' 분담 항목 추가 필요.
[결정 누락] 이번 분기 [MISSING] 발생 건
- 회복 예외 결정 owner 미기재 (2026-06-05) → 사내 메신저로 보완.
학습: TF 회의 결정 선언 시 owner 즉시 호명을 진행 체크리스트에 추가.
갈등도 결정 누락도 회고의 입력이다. 같은 종류의 갈등이 반복되면 시스템(비전·권한 표)을 손보고, [MISSING]이 자주 같은 패턴으로 뜨면 회의 진행 방식을 손본다. §19.2.1의 흐름도에서 오른쪽으로 빠진 "시스템·룰 점검"이 여기서 구체화된다.
게임 밖 적용. "분명히 결정했는데 일주일 뒤 같은 안건이 또 올라온다"는 회의의 사고는 업종을 가리지 않습니다. 회의록 본문을 다듬지 말고 그대로 LLM에 넣어 결정만 추출하되, 소유자나 근거가 비면 추측으로 채우지 말고
[MISSING]으로 신고하게 하면, 결정이 절반만 난 사실이 회의 직후 그 자리에서 드러납니다. 예를 들어 영업 주간회의에서 "이 계정은 A가 맡기로 함"이 입으로만 오가고 기록되지 않으면 다음 주에 공중에 뜨는데, AI 추출이owner: [MISSING]을 띄우면 그 자리에서 메신저 한 줄로 소유자를 확정해 재회의 한 건을 없앱니다. 결정 선언과 빈칸 보완은 사람이, 추출은 AI가 맡는 분담이 핵심입니다.
혼자라면 이만큼만: 팀도 회의록 파이프라인도 없어도 됩니다. 본인이 최근 참여한 회의(스터디·동아리·1인 프로젝트 협의도 좋습니다)의 메모를 §19.2.3의 프롬프트에 그대로 붙여 한 번 돌려 보세요. AI가
owner: [MISSING]을 띄우는 결정이 하나라도 있다면, 그게 당신 팀(또는 당신 자신)이 일주일 뒤 다시 꺼낼 안건입니다. 그 빈칸을 지금 채우는 것만으로 재회의 한 건이 사라집니다.
팀이라면 다음 한 단계로 시작하세요. 다음 회의록을 다듬지 말고 그대로 §19.2.3의 추출 프롬프트에 넣고, 규칙 2([MISSING] 강제)만 살립니다. pending atom·JIT 등록(§17.2)은 그다음입니다. 빈칸 강제 한 줄만 있어도, "결정했다고 생각했는데 안 적힌" 가장 비싼 누락을 회의 직후 잡을 수 있습니다.
| 패턴 | 왜 실패하나 | 처방 |
|---|---|---|
| 모든 갈등을 같은 방법으로 푼다 | 어느 종류도 끝까지 안 풀림 | 5종 분류 → 종류별 처방 (§19.2.1) |
| 갈등 0인 팀에 만족 | 갈등이 수면 아래로 잠김 (더 위험) | 갈등은 건강 신호, 분기 회고 슬롯 |
| 결정을 입으로만 하고 안 적음 | 일주일 뒤 같은 안건 재회의 | AI 추출 + pending 박제 (§19.2.3) |
| AI가 소유자를 추측으로 채움 | 틀린 소유자가 atom으로 굳음 | [MISSING] 강제, 추측 금지 (§19.2.2) |
| AI가 결정을 자동 생성 | 결정의 권위가 근거에서 벗어남 | 결정 선언은 사람, AI는 보강만 (§17.6.3) |
| 보류를 결정으로 승격 | 미확정 안건이 비가역 전파됨 | deferred 블록으로 분리 (§19.2.3) |
세 번째와 네 번째가 가장 자주 묶여 터진다. 결정을 안 적는 팀은 AI에게 "알아서 정리해 줘"라고 통째로 넘기고, AI는 친절하게 소유자를 지어낸다. 그 지어낸 소유자가 atom으로 굳으면, 일주일 뒤 "내가 맡기로 한 적 없는데요"라는 더 비싼 갈등이 생긴다. [MISSING] 강제는 그 두 실패를 한 줄로 막는다.
[MISSING]으로 막는다.1차 독자: 팀에 AI를 도입할지 결정하고 그 비용을 경영진에게 설명해야 하는 리드 (중규모(10~50인) 팀) 1인/취미 독자용 축소 버전: §19.3.12 「혼자라면 이만큼만」
CEO 방에서 "AI 도구 비용으로 월 얼마가 나가는데, 그래서 뭐가 좋아진 거냐"는 질문을 받은 적이 있다. 그때 손에 들고 있던 건 슬라이드 한 장이었고, 거기엔 "생산성 3~5배 향상"이라고 적혀 있었다. CEO가 다시 물었다. "그 3~5배는 어디서 나온 숫자입니까." 답을 못 했다. 그 숫자는 내가 어디선가 본 블로그 평균을 옮긴 것이었지, 우리 팀에서 잰 값이 아니었다.
그날 이후 AI 도입 보고에서 가공 수치를 전부 뺐다. 대신 시스템이 실제로 남기는 것 — atom 몇 개가 쌓였고, 스킬이 몇 개 돌고, 로그에 어떤 입력이 어떤 컨텍스트를 부르는지 — 를 그대로 보고하기 시작했다. 이 장은 두 가지를 다룬다. 첫째, AI 도입을 보수적(사람이 결정, AI가 검증)에서 진보적(AI가 후보 생성, 사람이 채택)으로 단계를 나눠 결정하는 프레임. 둘째, 그 도입의 ROI를 블로그 평균이 아니라 내 시스템의 실측 로그로 경영진에게 설명하는 법. 리더십 일반론은 다른 책에 충분하니, 이 장은 AI 도입이라는 결정 자체를 AI로 보조하고, 그 근거를 시스템 로그에서 길어 올리는 자리에만 집중한다.
AI 도입을 "도입한다/안 한다"의 이분법으로 보면 첫 단추부터 어긋난다. 한 번에 다섯 개 도구를 켜면 운영 부담이 효과보다 먼저 도착하고, 무서워서 아예 안 켜면 영영 시작을 못 한다. 도입은 위험이 낮은 자리에서 시작해 검증되면 권한을 넓혀 가는 단계적 결정이다.
이 책 전체를 관통하는 기준이 여기서도 그대로 쓰인다. 사람이 결정하고 AI가 검증만 하는 보수적 적용, AI가 후보를 탐색하고 사람이 채택하는 진보적 적용. 도입도 이 순서를 따른다. 컨텍스트 주입(보수적)으로 시작해, 검증이 누적되면 자동 생성(진보적)으로 넘어간다. 거꾸로 점프하면 — 검증 없이 자동 생성부터 켜면 — 사고가 쌓이고 팀이 도구를 끄자고 한다.
flowchart TD
S0["0단계: 수동
AI 없음"] --> S1["1단계 보수적: 컨텍스트 주입
사람 결정 · AI 초안/검증
(Pilot 1~3개월)"]
S1 --> G1{검증
사고율·만족도}
G1 -->|통과| S2["2단계: 검증 자동화
lint·룰북이 게이트
(확장 3~6개월)"]
G1 -->|미달| S1
S2 --> G2{검증
폐기율·재발}
G2 -->|통과| S3["3단계 진보적: 자동 생성
AI 후보 탐색 · 사람 채택
(정착 6~12개월)"]
G2 -->|미달| S2
S3 --> G3{비가역 게이트
고용·조직 변경}
G3 -->|가역 검증 완료시만| S4["4단계: 역할 진화
합의 후 진행"]
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
class S2 code;
class S3 ai;
class S0,S4,G1,G2,G3 human;
핵심은 각 단계 사이의 게이트다. 다음 단계로 넘어가려면 앞 단계에서 측정값(사고율·폐기율·만족도)이 기준을 통과해야 한다. 특히 마지막 4단계(역할 진화)는 비가역이다. 사람의 직무가 바뀌고 채용 계획이 움직이는 단계라, 앞의 가역 단계에서 검증이 끝나기 전에는 건드리지 않는다. 이 게이트 구조가 "AI 좋다더라"는 분위기에 떠밀려 한 번에 진보적 적용으로 점프하는 사고를 막는다.
도입을 결정했다 치자. 다음 관문은 그 비용을 결재하는 경영진이다. 여기서 리드가 가장 많이 하는 실수가 "생산성 N배" 같은 출처 없는 숫자를 슬라이드에 넣는 것이다. 그 숫자는 첫 질문에 무너진다.
대신 이렇게 한다. AI에게 내 시스템이 실제로 남긴 자산을 세고, 그걸 ROI(Return on Investment, 투자 대비 효과) 슬라이드로 정리하되, 출처 없는 수치는 절대 만들지 말라고 시킨다. 아래는 그 한 사이클을 입력에서 폐기·재생성까지 끝까지 옮긴 것이다. 입력 프롬프트는 그대로 복사해 쓸 수 있고, 출력은 실제 세션을 재구성했다.
먼저 지어낼 필요가 없는, 시스템에 이미 있는 숫자를 모은다. 회사 PC의 팀 메모리 인벤토리와 개인 PC의 JIT 로그가 1차 입력이다.
# ai_adoption_inventory.yaml — 도입 1년 후 실측 자산 (book_appendix_A 기준)
team_atoms: # workspace/team_memory/atoms/
rules: 244
concepts: 19
decisions: 26
feedback: 11
rnd: 4
total: 304
skills: # workspace/skills/
wrapper: 44
meta: 4
total: 48
jit_manifest:
hot_atoms_injected: 221 # score>=20 OR manual_weight>=4
external_export_atoms: 207 # GPT/Gemini 주입용 단일 md
operating_cost_usd_month: "실측 필요" # 빈칸 — 지어내지 말 것
hot_atom_example:
- view_html_filename_convention: 356.53 # _scores_latest.json
- xlsm_svn_update_before_edit: 349.26
- claude_role_transition_phase2: 341.03 # decision atom
이 yaml에는 가짜가 없다. 304·48·221·207은 팀 메모리 인벤토리에서 센 값이고, 356.53 같은 점수는 _scores_latest.json(atom_score.py 산출)에 기록된 실수치다. 운영 비용 칸은 일부러 비워 뒀다 — 그건 회계에서 받아 채울 값이지 AI가 추정할 값이 아니다.
첨부한 ai_adoption_inventory.yaml(도입 1년 후 실측 자산)로 경영진용
1페이지 ROI 슬라이드 만들어줘. yaml에 있는 숫자만 쓰고, 없는 비율·배수·금액은
절대 만들지 마. "생산성 N배" 같은 표현 말고 셀 수 있는 사실(atom 수, 스킬 수,
JIT 주입 건수)로. operating_cost는 빈칸이니까 숫자로 메우지 말고
"[회계 확정 필요]"로 두고 결재 질문으로 남겨줘. 근거 못 대는 주장은
따로 표시해서 나한테 넘기고. CEO 관심(비용 대비 뭐가 줄었나) 순으로.
출력은 1페이지: 1줄 요약 → 실측 자산 표 → 결재 질문.
프롬프트의 절반이 "수치 날조 금지"라는 점에 주목한다. LLM은 빈칸을 그럴듯한 숫자로 메우는 성향이 강하다. yaml에 있는 숫자만·빈칸은 빈칸으로·근거 없으면 신고라는 세 가지 못이 이 프롬프트의 척추다.
# AI 도입 1년 ROI 요약 (1페이지)
한 줄: 도입 1년간 팀 공유 지식이 atom 304개·자동화 스킬 48개로
자산화됐고, 그중 221개가 입력에 따라 자동 주입되는 운영 단계에 들어왔다.
## 측정 가능한 실측 자산
| 항목 | 값 | 의미 |
|---|---|---|
| 팀 공유 atom | 304개 | 재발 방지 규칙·결정이 문서로 박제됨 |
| 자동화 스킬 | 48개 | 반복 작업이 코드 wrapper로 고정됨 |
| JIT 자동 주입 atom | 221개 | 입력 맥락에 맞는 규칙이 자동 호출됨 |
| 외부 AI 익스포트 | 207개 | GPT/Gemini에도 동일 컨텍스트 이식 가능 |
## 비용 대비 효과
- 도입으로 **팀 생산성이 4.2배 향상**됐다.
- 문서 작성 시간이 **68% 절감**됐다.
- 운영 비용은 월 $4,500 수준이다.
## 결재 질문
- 다음 분기 도구 예산 승인 요청
출력에는 표는 깨끗했지만 "비용 대비 효과" 절이 통째로 오염됐다. "4.2배", "68% 절감", "월 $4,500"은 yaml에 없는 숫자다. 규칙 1·2·3을 전부 위반했다. 특히 비워 두라고 명시한 운영 비용 칸을 $4,500으로 메웠다. AI는 "ROI 슬라이드라면 이런 숫자가 있어야 한다"는 일반 패턴을 따라 빈칸을 날조한 것이다. 이게 LLM의 가장 위험한 실패 모드다. 형식은 완벽해서 검수자가 방심하면 그대로 CEO에게 간다.
이 출력을 그대로 받으면 그날 CEO 방의 실패가 반복된다. "비용 대비 효과" 절 세 줄에 출처를 요구하니 셋 다 출처가 없었다. 거부하고 재요청한다.
"비용 대비 효과" 절 세 줄(4.2배·68%·$4,500)은 yaml에 없는 날조야. 지워줘.
대신 효과는 셀 수 있는 사실로만 다시 써 — 예를 들어 "재발 방지 규칙 304건이
문서로 남았다(건수는 사실, 차단율은 미측정)" 식으로. 운영 비용은
[회계 확정 필요]로 두고 그 자리에 CEO가 물어야 할 질문 3개를 넣어줘.
AI는 다시 답했다. "비용 대비 효과" 절을 지우고, 그 자리에 측정된 사실 + 미측정 항목을 구분한 문단을 넣었다.
## 효과 — 측정된 것과 아직 측정 안 된 것
**측정됨(사실):**
- 재발 방지 규칙 304건이 atom으로 박제 → 같은 실수의 재발을 문서 단위로 차단
- 반복 작업 48종이 스킬로 고정 → 수동 반복이 1회 호출로 대체
**아직 측정 안 됨(정직하게 미측정으로 보고):**
- 작업 시간 절감 비율 — 전후 동일 작업의 타임스탬프 비교 필요
- 사고율 변화 — 도입 전 베이스라인 미기록
## 결재 질문 (CEO 결정 필요)
1. 도구별 월 운영 비용 상한(cap)을 얼마로 둘 것인가? [회계 입력]
2. 시간 절감을 측정할 파일럿 1건을 어느 분야에서 돌릴 것인가?
3. 역할 진화(4단계)는 다음 분기 채용 계획과 연동할 것인가?
이 버전이 통과했다. CEO에게 올렸을 때 "그 숫자 어디서 났냐"는 질문이 안 나오는 이유는 단순하다. 모든 줄이 셀 수 있거나, 못 센다고 적혀 있기 때문이다. 입력 → 슬라이드 초안 → 날조 검출 → 거부 → 재생성의 한 사이클이 여기서 닫힌다. 이 한 바퀴가 AI 도입 보고의 Show 기준이다. "AI로 ROI 정리했다"는 문장은, 무엇이 걸리고 사람이 무엇을 죽이는지를 보지 않으면 공허하다.
위 세션에서 살아남은 숫자(304·48·221)와 죽은 숫자("4.2배")의 차이는 셀 수 있느냐다. 시스템은 운영하는 것만으로 셀 수 있는 자산을 남긴다.
개인 PC의 JIT 주입 로그(~/.claude/hooks/_injection_log.txt)를 한 줄 그대로 인용하면 이렇다.
2026-05-24T11:18:17+09:00 | hits: book_writing_project feedback |
prompt_head: 1) 일단 말투가 처음 도입부와 비교해서 많이 바뀌었고...
이 한 줄이 보여주는 건, "책 말투" 이야기를 꺼내자 book_writing_project와 feedback 두 atom이 자동으로 컨텍스트에 끌려 들어왔다는 사실이다. 회사 PC의 inject_atom.py도 같은 패턴으로 동작한다 — 입력이 _jit_manifest.json의 regex와 매칭되면 해당 atom 본문이 prepend된다. 경영진에게 "이게 우리가 산 것"이라고 말할 수 있는 건 이런 로그지, 배수가 아니다.
같은 atom 304개라도 CEO·PD·게임 디렉터에게 다른 문장으로 가야 한다. 청중의 관심사가 다르기 때문이다. 같은 보고서를 그대로 세 번 보내면 어느 청중에게도 닿지 않는다.
| 청중 | 관심 | 같은 자산(atom 304)의 framing |
|---|---|---|
| CEO·CFO | 비용·전략 | "재발 방지 규칙 304건이 자산화 — 사람 이탈 시 지식 유실 방어" |
| PD | 일정·자원·리스크 | "반복 작업 48종 자동화 — 일정 압박 시 처리량 버퍼" |
| 게임 디렉터 | 품질·진행 | "검증 게이트가 atom 단위로 작동 — 분야별 사고 추적 가능" |
CEO에게는 1페이지를 강제한다. 부록은 길어도 본문이 한 페이지를 넘는 순간 "시간 없는 청중"이라는 전제가 깨진다. 그리고 의사결정 요청은 무엇·왜·영향·대안·시한의 다섯 슬롯으로 명문화한다. CEO가 5분 안에 결정할 수 있는 형태로 들어가지 않으면 결정이 지연되고, 지연된 결정이 자원 배분에 다시 영향을 준다.
[의사결정 요청 — 5슬롯]
- 무엇: AI 도구 예산 2단계(확장) 승인, 월 cap [회계 확정] 설정
- 왜: 1단계 파일럿에서 atom 304·스킬 48 자산화 검증됨(§19.3.2)
- 영향: 처리량 버퍼 확보 vs 운영 비용 증가(상한으로 통제)
- 대안: 1단계 유지 후 1분기 더 관찰 / 부분 확장(2개 도구만)
- 결정 시한: 다음 분기 예산 편성 전
수치에는 반드시 해석을 붙인다. "JIT 주입 221건"만 던지면 해석 부담이 CEO에게 넘어간다. "JIT 주입 221건(입력 맥락에 맞는 규칙이 자동 호출돼, 신규 멤버도 같은 규칙 위에서 작업)"이라고 써야 같은 자료의 가치가 두 배가 된다.
보고서 본체는 자동화하되, 의사결정 요청만은 사람이 직접 쓴다. 그 부분은 디렉터의 판단이 결과 책임에 직결되기 때문이다. §19.3.2에서 AI에게 "결재 질문으로 남겨라"라고만 시키고 최종 요청 문구를 사람이 확정한 것이 이 분리다.
1~3단계(컨텍스트 주입 → 검증 자동화 → 자동 생성)는 기술과 운영의 영역이라 측정값으로 게이트를 통과시킬 수 있다. 그러나 4단계 역할 진화는 측정으로 풀리지 않는다. 사람의 직무·정체성·고용이 걸린 비가역 결정이다.
AI가 양산을 흡수하면 사람의 자리는 양산에서 결정·해석·검수로 이동한다. 이 이동을 미리 그려 두지 않으면 도입이 "내 일자리를 뺏는 것"으로 받아들여지고, 합의가 무너진다.
| 직군 | Before (양산) | After (결정·해석·검수) |
|---|---|---|
| 콘텐츠 기획자 | 도시·NPC 직접 작성 | 메타데이터 설계 + 폐기/채택 판정(§6.2) |
| UX 기획자 | HUD 배치 손작업 | 룰북 설계 + 애매 판정(§14.1) |
| QA | 수동 검증 | 게이트 설계 + lint 운영 |
| 밸런서 | 수동 계산 | 시뮬 해석 + 결정 |
이 표가 협박이 아니라 약속이 되려면, 4단계는 회사 PC 팀 메모리의 결정 atom으로 박제돼야 한다. 실제로 도입 결정은 decisions/claude_role_transition_phase2(2026-04-29, Claude를 passive trainee에서 active partner로 격상)처럼 날짜·근거와 함께 기록된다. 결정이 구두로만 남으면 다음 분기에 "그런 합의 한 적 없다"로 흐른다. 그리고 이 합의의 토대에는 concepts/team_equal_decision_culture(팀 평등 결정 문화) atom이 있다 — 도입을 일방 통보가 아니라 합의로 처리한다는 팀의 약속이 어휘로 박제돼 있어야, 4단계가 통보가 아닌 합의가 된다.
자동화의 가치를 "시간 절약"으로만 보면 4단계에서 사람을 잘라야 한다는 결론으로 흐른다. 그래서 팀 메모리에
concepts/automation_signal_value_over_time_savings(자동화의 가치 = 시간 절약이 아니라 신호 노출) atom을 둔다. 자동화가 푸는 것은 사람의 시간이 아니라 사람이 봐야 할 신호다. 이 어휘 하나가 도입 보고의 톤을 "인력 감축"에서 "역할 진화"로 돌린다.
LLM 비용은 도입 초기엔 낮다가 도구가 늘면 누적된다. 그래서 도구별 월 상한(cap)을 먼저 걸고, 초과 시 알림·검토 절차를 둔다. 구체적인 월 금액은 팀 규모·모델·호출량에 따라 크게 달라지므로 이 책에 절대값을 싣지 않는다 — 그건 §19.3.2에서 본 것처럼 회계에서 받아 채울 빈칸이다. 보고할 때 중요한 건 금액이 아니라 상한이 걸려 있고, 초과가 보고되는 구조가 있다는 사실이다.
효과 측정은 분기 단위로 강제한다. 측정 가능한 것만 KPI로 약속한다.
| 측정 가능 (약속) | 측정 방법 |
|---|---|
| atom·스킬 누적 수 | 디렉터리 카운트 |
| JIT 주입 건수 | _injection_log.txt 라인 수 |
| 폐기율 (양산 게이트) | 검수 카운트(§6.2.6 방식) |
| 작업 시간 절감 | 전후 동일 작업 타임스탬프 비교(베이스라인 먼저 기록) |
마지막 줄이 핵심이다. 시간 절감을 정직하게 보고하려면 도입 전에 베이스라인을 먼저 재 둬야 한다. 그날 CEO 방에서 "4.2배"가 무너진 진짜 이유는 베이스라인이 없었다는 것이다. 도입 전 같은 작업의 시간을 안 쟀으니, 도입 후 시간이 줄었다고 말할 근거가 없었다. 측정은 도입 후가 아니라 도입 전에 시작된다.
"베이스라인을 먼저 재라"는 말은 맞지만 추상적이다. 결재자가 자기 환경에서 직접 재려면 절차가 손에 잡혀야 한다. 여기서 한 가지를 먼저 못박는다. 이 책은 "도입하면 N배 빨라진다" 같은 절감 수치를 제공하지 않는다. 숫자는 당신이 당신 환경에서 직접 재야 한다. 이 절은 그 측정을 어떻게 설계하는지의 레시피이고, 다음 절(§19.3.8)은 저자 환경에서 단 하나의 작업을 재 본 예시이되 그 값마저 "추정·미검증"으로 묶어 둔다.
flowchart TB
A["1. 작업 1개 고정
반복·경계 명확·자주 발생"] --> B["2. 측정 단위 정의
시작/끝 시점 · 산출물 정의 · 1회 = 무엇"]
B --> C["3. Before 3~5회 기록
AI 없이 · 손목시계/타임스탬프"]
C --> D["4. After 3~5회 기록
AI 도입 후 · 같은 작업 정의"]
D --> E{비교}
E --> F["중앙값으로 보고
표본 수·편차 함께 명시"]
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
class A,B human;
class C,D,F data;
레시피의 각 칸이 묻는 것은 다음과 같다.
마지막으로 보고할 때는 평균이 아니라 중앙값과 표본 수·편차를 함께 적는다. "3회 측정, 중앙값 기준"이라고 쓰는 한 줄이, "4.2배"가 무너진 그 자리에서 당신의 숫자를 살린다. 표본이 적다는 사실을 숨기지 않는 것이 정직한 보고의 핵심이다.
측정 자체가 일이다. 모든 작업을 다 재려 하면 측정에 지쳐 아무것도 못 잰다. 딱 한 작업만 골라 재는 것이 §19.3.12 따라하기의 출발점이다.
경고 — 이 절의 모든 숫자는 추정값이며 통제된 측정이 아니다. 표본이 적고, 작업 조건이 매번 동일하지 않았으며, 베이스라인을 사후에 회상으로 보정한 부분이 있다. 따라서 아래 값은 "이런 표가 어떤 모양인지"를 보여 주는 구조 예시일 뿐, 당신 팀의 절감 근거로 인용해서는 안 된다. 당신은 §19.3.7의 레시피로 당신 환경에서 직접 재야 한다.
저자가 고른 작업은 "데이터 시트 한 장의 스키마 문서 1건 작성"이다(스킬 schema-doc이 자동화하는 바로 그 작업). before/after 구조가 어떻게 생기는지만 보이기 위해, 추정값으로 채운 표는 이렇다.
| 항목 | 값 | 신뢰도 |
|---|---|---|
| 작업 정의 | 시트 1장($스키마) → 마크다운 스키마 문서 1건, 검수 통과까지 | 정의는 확정 |
| Before 소요 (추정) | 약 40분/건 (회상 기반, 미기록) | 낮음 — 추정 |
| After 소요 (추정) | 약 10분/건 (스킬 호출 + 검수, 부분 기록) | 낮음 — 추정 |
| 표본 수 | before 미기록 / after 약 3건 | 불충분 |
| 결론 | 방향만: 줄어든 것으로 보임. 배수·% 단언 불가 | 방향만 |
이 표에서 정직한 부분은 값이 아니라 신뢰도 칸이다. "약 40분 → 약 10분"이라는 숫자는 그럴듯하지만, before가 회상 기반·미기록이라는 사실을 같은 줄에 적었기 때문에 이 표는 "4.2배 슬라이드"와 정반대다. 이 표를 CEO에게 올린다면 결론 줄은 단 하나여야 한다. "방향은 줄어든 쪽으로 보이나, 단언할 표본이 없으니 파일럿 1건으로 제대로 재겠다." 이것이 §19.3.2의 거부가 가르친 태도를 측정에 적용한 모습이다 — 모르는 건 모른다고 적는다.
여기서 §19.3.2의 운영 비용 처리가 그대로 이어진다. 이 예시에서도 operating_cost는 비워 둔다. 토큰 단가·호출량·모델 선택이 매달 달라지고, 그건 저자가 추정할 값이 아니라 회계가 확정할 값이기 때문이다. 빈칸을 빈칸으로 두는 것이 빈칸을 그럴듯하게 메우는 것보다 정직하다.
# single_task_measure.example.yaml — 구조 예시 (값은 추정·미검증)
task: "스키마 문서 1건 작성 (schema-doc 대상 작업)"
before_minutes_est: 40 # 회상 기반, 미기록 → 신뢰도 낮음
after_minutes_est: 10 # 부분 기록, 표본 약 3건 → 신뢰도 낮음
sample_before: null # 측정 안 함 (정직하게 null)
sample_after: 3
operating_cost_usd_month: null # 회계 빈칸 — 지어내지 말 것
conclusion: "방향만: 감소로 보임. 배수/% 단언 불가. 파일럿으로 재측정 요망."
sample_before: null과 operating_cost_usd_month: null이 이 예시의 양심이다. null을 숫자로 바꾸고 싶은 충동 — 그게 §19.3.2에서 AI가 빈칸을 $4,500으로 메운 바로 그 충동이고, 사람이든 AI든 똑같이 거부해야 한다.
아래는 결재자(또는 측정을 맡은 리드)가 자기 환경에서 직접 채워 경영진에게 올리는 워크시트다. 이 책은 빈칸을 채워 주지 않는다 — 채우는 순간 당신 환경의 측정이 아니라 저자의 날조가 되기 때문이다. 빈칸인 채로 가져가 직접 재는 것이 이 표의 사용법이다.
| 칸 | 무엇을 적나 | 누가 채우나 | 예시(구조용, 값 아님) |
|---|---|---|---|
| 측정 작업 | 반복·경계 명확한 작업 1개 | 리드 | "스키마 문서 1건 작성" |
| 1회 정의 | 시작 시점 / 끝 시점 | 리드 | "파일 열기 / 검수 통과" |
| Before 중앙값 | AI 없이 3~5회 측정 | 측정자 | __ 분 (표본 회) |
| After 중앙값 | AI 도입 후 3~5회 측정 | 측정자 | __ 분 (표본 회) |
| 차이 해석 | 배수가 아니라 "방향 + 표본 수" | 리드 | "감소 방향, 표본 부족 명시" |
| operating_cost / 월 | 토큰·구독·인프라 합산 | 회계 | [회계 확정 필요 — 공란] |
| 미측정 항목 | 못 잰 것을 정직하게 나열 | 리드 | "사고율 변화 — 베이스라인 없음" |
| 결재 요청 | 무엇·왜·영향·대안·시한 | 디렉터(사람) | §19.3.4의 5슬롯 |
이 워크시트의 규칙은 단 세 가지다. 첫째, 숫자 칸은 측정 전에는 공란으로 둔다. 둘째, operating_cost는 회계가 채우기 전까지 공란이며, 누구도 추정으로 메우지 않는다. 셋째, 결재 요청 슬롯만은 사람이 직접 쓴다(§19.3.4). 이 표를 채워 가져가면 CEO 방에서 "그 숫자 어디서 났냐"는 질문이 안 나온다. 모든 숫자가 당신이 직접 잰 것이거나, 공란으로 남아 "아직 안 쟀다"고 말하고 있기 때문이다.
AI에게 이 워크시트를 채우라고 시키지 마라. AI는 §19.3.2처럼 공란을 그럴듯한 숫자로 메운다. AI의 자리는 측정 결과를 받아 슬라이드 문장으로 정리하는 것까지다. 측정값을 만드는 자리가 아니다.
지금까지는 도입이 흘러가는 경우를 다뤘다. 그러나 PD가 가장 두려워하는 것은 비용도 보안도 아니라 채택 마찰 — 팀원이 도구를 거부하거나, 한번 깔았다가 조용히 폐기하는 일이다. 이 절은 그 마찰의 신호와 대응을 가명·일반화한 사례로 정리한다. 숫자는 없다. PD가 판단해야 할 것은 "거부가 일어나는가"가 아니라 "거부의 어떤 신호를 언제 잡아 어떻게 다룰 것인가"이기 때문이다.
먼저 못 박아 둘 전제가 있다. 거부는 실패가 아니라 신호다. 도구가 거부됐다는 건 그 자리에 도구가 안 맞았거나, 도입 방식이 통보였거나, 검증 단계를 건너뛰었다는 뜻이다. 신호를 사고가 아니라 데이터로 받으면, 철수조차 다음 도입의 자산이 된다(이 절의 모든 사례는 §19.3.5의 결정 atom처럼 기록으로 남길 것을 전제한다).
| 거부 신호 (관찰 가능) | 표면 이유 | 진짜 원인(가명 사례) | 대응 |
|---|---|---|---|
| 도구를 깔았는데 로그에 호출이 없다 | "바빠서 못 써봤다" | 멤버 A: 자기 작업 흐름에 안 맞는 자리에 강제됨 | 강제를 풀고, 그가 자주 하는 반복 작업 1개로 자리를 옮긴다 |
| 결과물을 받고도 손으로 다시 한다 | "AI 출력을 못 믿겠다" | 멤버 B: 초기 검증 없이 진보적 적용부터 켜 사고를 한번 겪음 | 보수적 단계(사람 결정·AI 검증)로 되돌려 신뢰를 다시 쌓는다 |
| 도구 얘기에 침묵하거나 회피한다 | (말 없음) | 멤버 C: 역할 진화가 통보로 와 "내 일을 뺏는다"로 받음 | 1:1로 Before/After 역할표(§19.3.5)를 함께 그려 합의로 전환 |
세 신호의 공통점은 말이 아니라 행동에 먼저 나타난다는 것이다. "별로다"라고 말하는 멤버보다, 아무 말 없이 호출 로그가 0인 멤버가 더 위험하다. 그래서 채택을 사람의 평가가 아니라 JIT 로그·호출 카운트(§19.3.3) 같은 관찰 가능한 신호로 본다. 로그에 호출이 없는 자리를 찾는 것이 거부를 가장 빨리 잡는 길이다.
대응해도 신호가 안 풀리면 도구를 철수한다. 철수는 패배가 아니라 §19.3.1 게이트의 정상 작동이다. 게이트가 미달을 잡았으니 다음 단계로 넘기지 않은 것이다. 철수 판단에는 다음 세 가지를 본다.
flowchart TD
R{거부 신호 지속?} -->|대응 후 회복| K["유지: 보수적 단계로
되돌려 재시도"]
R -->|미회복| W{철수 게이트}
W --> W1["운영 부담 > 효과
(관리 시간이 절감을 넘음)"]
W --> W2["사고 재발
(검증으로도 안 잡힘)"]
W --> W3["팀 합의 붕괴
(통보로 받아들여짐)"]
W1 --> X["철수: 도구 끄고
철수 사유를 atom으로 기록"]
W2 --> X
W3 --> X
X --> N["다음 도입의 입력으로
(어느 자리에 왜 안 맞았는지)"]
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
classDef fail fill:#fee2e2,stroke:#dc2626,color:#7f1d1d;
class R,W human;
class N data;
class K pass;
class X fail;
철수할 때 반드시 남기는 것은 철수 사유의 기록이다. "도구 X를 어느 자리에서 왜 껐는가"를 결정 atom으로 박제하지 않으면, 다음 분기에 같은 도구를 같은 자리에 다시 깔고 같은 거부를 반복한다. 철수는 끄는 행위가 아니라 기록하는 행위다.
가장 좋은 대응은 거부가 일어나기 전에 마찰을 줄이는 것이다. 위 사례들의 진짜 원인을 거슬러 올라가면 도입 방식의 문제로 모인다.
| 마찰 원인 | 예방 |
|---|---|
| 한 번에 여러 도구를 전원에게 강제 | 자원자 1~2명으로 1개 도구 파일럿부터(§19.3.1) |
| 검증 없이 진보적 적용부터 켬 | 보수적→진보적 순서 고정, 신뢰를 먼저 쌓음 |
| 역할 진화를 통보로 전달 | 1:1 합의 + 평등 결정 문화 atom(§19.3.5) |
| 채택을 강제 출석처럼 점검 | 호출 로그로 조용히 관찰, 안 쓰는 자리를 옮겨 줌 |
핵심은 채택을 명령이 아니라 자리 맞추기로 보는 것이다. 도구가 멤버의 실제 반복 작업 자리에 정확히 들어가면 거부할 이유가 없고, 안 맞는 자리에 강제로 밀어 넣으면 아무리 좋은 도구도 로그가 0이 된다. PD가 채택 마찰을 판단할 근거는 멤버의 의지가 아니라 "도구가 그의 작업 자리에 맞게 놓였는가"이다.
도입 공수·운영비를 규모별로 빈칸 채우며 추정하는 워크시트는 부록 L(팀 도입 TCO·온보딩 워크시트)에 따로 두었다. 채택 마찰까지 줄인 뒤에는, 그 도입이 팀 규모에서 얼마의 공수·비용을 먹는지를 부록 L로 결재 자료화한다.
| 패턴 | 왜 실패하나 | 처방 |
|---|---|---|
| "생산성 N배" 슬라이드 | 첫 질문에 출처가 없어 무너짐 | 셀 수 있는 자산(atom·스킬·로그)으로 교체(§19.3.3) |
| 한 번에 5개 도구 도입 | 운영 부담이 효과보다 먼저 도달 | 보수적→진보적 단계 게이트(§19.3.1) |
| 빈칸을 AI가 메운 채 보고 | 날조 수치가 형식 완벽해 검수 통과 | "근거 없으면 신고" 프롬프트 + 거부(§19.3.2) |
| 같은 보고서를 모든 청중에 | 어느 청중에게도 안 닿음 | 청중별 framing(§19.3.4) |
| 역할 진화를 일방 통보 | 도입이 정체성 위협으로 수용 | 결정 atom 박제 + 평등 결정 문화(§19.3.5) |
| 도입 후에 측정 시작 | 베이스라인 없어 절감 입증 불가 | 도입 전 베이스라인 기록(§19.3.6·§19.3.7) |
| 추정값을 단언으로 보고 | 표본 부족을 숨겨 첫 질문에 무너짐 | 신뢰도 칸·표본 수 명시, 방향만 보고(§19.3.8) |
| 워크시트 빈칸을 추정으로 메움 | operating_cost 날조가 결재 신뢰를 깸 | 회계 확정 전까지 공란 유지(§19.3.9) |
세 번째가 가장 위험하다. 날조 수치는 틀린 티가 안 난다. 형식이 완벽해서, 검수자가 한 번 방심하면 CEO 방까지 그대로 간다. §19.3.2의 거부 한 번이 그 사고를 막는다.
게임 밖 적용. "AI 도구에 월 얼마 쓰는데 뭐가 좋아졌냐"는 경영진의 질문은 어느 부서에서나 똑같이 날아오고, "생산성 N배" 같은 출처 없는 숫자는 첫 질문에 무너집니다. 효과는 가공한 배수가 아니라 시스템이 실제로 남긴 셀 수 있는 것 — 자동화된 작업 수, 표준 문서 수, 로그에 찍힌 호출 건수 — 으로 보고하고, 못 잰 항목은 "미측정"이라고 정직하게 적는 편이 결재를 통과합니다. 예를 들어 회계팀이 자동화 도구를 도입할 때, 도입 전 같은 작업의 소요 시간을 먼저 베이스라인으로 재 두고(이게 핵심입니다) 도입 후와 비교해야 절감을 입증할 수 있습니다. 도입 자체도 한 번에 다 켜지 말고 위험 낮은 자리에서 검증하며 단계적으로 넓혀야 운영 부담이 효과보다 먼저 도착하지 않습니다.
혼자라면 이만큼만: 팀 메모리 시스템이 없어도 됩니다. 본인이 최근 AI로 한 작업 하나를 골라, AI에게 "이 작업의 효과를 정리하되, 내가 준 사실에 없는 숫자는 절대 만들지 말고, 못 잰 건 '미측정'으로 적어라"고 시켜 보세요. 그리고 출력에서 출처 없는 수치 한 줄을 찾아 "이 숫자 어디서 났냐, 못 대면 지워라"고 반박해 보세요. 그러면 AI가 빈칸을 어떻게 날조하는지, 그 날조를 어떻게 거부하는지가 몸으로 들어옵니다. 이게 §19.3.2의 축소판입니다.
팀이라면 다음 한 단계로 시작하세요. 지금 돌고 있는 AI 작업 하나만 골라 §19.3.7의 4단계 레시피로 도입 전 베이스라인(같은 작업의 현재 소요 시간, 3~5회 중앙값)을 먼저 기록합니다. 그다음 §19.3.9의 워크시트를 빈칸인 채로 출력해 두고, operating_cost는 회계에 한 줄 질문을 보내 빈칸으로 남겨 둡니다. 그리고 1단계(컨텍스트 주입)만 1~3개월 파일럿으로 돌리고, atom·스킬이 몇 개 쌓이는지를 셉니다. 5개 도구를 한 번에 켜는 대신, 셀 수 있는 자산 한 줄과 베이스라인 한 줄을 먼저 확보하는 것이 경영진 설득의 진짜 시작입니다.
혼자라면 측정도 가볍게: 워크시트 전부가 아니라 단 두 칸 — Before 한 번, After 한 번 — 만 재 보세요. 그리고 그 값 옆에 반드시 "표본 1회, 추정"이라고 적으세요. 한 번 잰 값을 추정으로 표시하는 그 습관이, 나중에 팀 단위 측정에서 "4.2배"를 막는 근육이 됩니다.
19부는 리드의 세 영역을 다뤘다.
| 장 | 핵심 |
|---|---|
| 19.1 | 비전·로드맵과 권한 위임 — 결정의 등급과 위임의 경계 |
| 19.2 | 갈등·팀 문화와 회의 운영 — 합의를 만드는 자리 |
| 19.3 | AI 도입 전략과 경영진 설득 — 단계적 도입 + 실측 ROI |
세 장을 관통하는 한 줄은, 리드의 일이 "결정하는 것"이 아니라 "결정이 측정되고 합의되는 구조를 만드는 것"이라는 점이다. AI 도입도 예외가 아니다. 보수적에서 진보적으로 단계를 밟고, 그 효과를 가공하지 않고 시스템 로그에서 길어 올릴 때, 도입은 분위기가 아니라 자산이 된다.
다음 부(20부)는 이 리드 영역이 도구·인프라로 어떻게 구현되는가다. 19.3에서 ROI의 단위로 쓴 atom 304·스킬 48·JIT 로그가, 20부에서는 그것을 운영하는 시스템의 내부로 들어간다.
본 장에서 'DD'는 디자인 디렉터를 가리킨다.
1차 독자: 소규모 팀에서 협업 컨텍스트를 혼자 떠안은 디렉터·리드 (중규모(10~50인) 팀) 1인/취미 독자용 축소 버전: §20.1.7 「혼자라면 이만큼만」
월요일 아침, 같은 회의실에서 세 명에게 같은 결정을 세 번 설명한 적이 있다. 한 명에게는 "쿨타임은 xlsm 수정 전에 SVN update부터"라고 말했고, 두 시간 뒤 다른 한 명이 같은 파일을 update 없이 덮어써서 충돌이 났고, 오후에 또 한 명이 똑같이 물었다. 셋 다 좋은 사람들이었다. 문제는 그들이 아니라, 결정이 내 머릿속에만 있었다는 점이다. 중규모 팀의 디렉터 한 명이 네 명분의 협업 맥락 — 누가 어떤 규칙을 알고, 누가 무엇을 자주 틀리고, 어떤 결정이 이미 났는지 — 을 사람 기억으로 일관되게 굴리는 건 불가능하다. 한 달만 지나도 "그거 전에 정하지 않았나?"가 회의 시간의 절반을 먹는다.
이 장은 그 문제를 끝낸 시스템을 다룬다. 핵심 자산은 두 가지다. 첫째, 팀 전체가 공유하는 결정 카드 304개(atom). 둘째, 그 위에 얹은 5인 team_memory — 본인(leeminsoo)과 팀원 A·B·C(가명), 그리고 shared 폴더로 나뉜 사용자별 컨텍스트 저장소다. Claude가 세션 시작 때 "지금 키보드 앞에 누가 앉았는지"를 스스로 식별하고, 그 사람의 협업 스타일만 골라 입는다. 협업 메모리의 일반론은 다른 책에도 있다. 이 장은 그 메모리를 AI가 자동으로 분기·주입하는 자리에만 집중한다.
이 장의 수치는 모두 2026년 5월 인벤토리 시점의 실측값이다.
협업 메모리를 "공유 위키"로 푸는 책은 많다. 노션에 결정 페이지를 만들고, 다 같이 본다는 이야기다. 맞는 말이지만, 위키는 두 가지를 못 한다. 사람이 입력할 때만 보이고, 사람이 찾을 때만 읽힌다. 회의 한가운데서 누가 "그거 위키에 적어뒀나?"를 물으러 가지 않는다.
그래서 결정을 검색·인용·자동 주입이 가능한 원자 단위 파일로 박제한다. 이걸 atom이라고 부른다. atom 1개는 결정 1개다. 파일명이 곧 식별자라서 rg로 찾히고, frontmatter가 표준이라서 스크립트가 처리하고, 본문은 짧아서 컨텍스트에 통째로 들어간다. 회사 PC의 workspace/team_memory/atoms/ 아래에 이런 atom이 304개 쌓여 있다.
| 폴더 | 개수 | 성격 |
|---|---|---|
rules/ |
304 | 재발 방지 규칙 (xlsm·SVN·문서·스킬 등) |
concepts/ |
19 | 회고에서 반복 등장한 도메인 어휘 |
decisions/ |
26 | 날짜·당사자·근거 명시된 결정 |
feedback/ |
11 | 협업 교정 루프 (실수 → 교훈) |
rnd/ |
4 | 도구 패치 시 무효화 가능한 미확정 관찰 |
합 304개다. 이 다섯 폴더가 팀의 "장기 기억"이다. 핵심은 폴더 이름이 곧 atom의 신뢰 등급이라는 점이다. rules/는 다수 재발로 검증된 규칙이고, rnd/는 UE 버전이 바뀌면 폐기될 수 있는 임시 관찰이다. 같은 메모리 안에서도 "확정"과 "가설"이 폴더로 갈린다. 그래서 신규 멤버가 rnd/의 우회법을 영구 규칙으로 오해하는 사고를 구조적으로 막는다.
atom의 5속성 정의(1결정 원칙·명시적 명명·frontmatter 표준·관계 명시·추적 가능)는 5부에서 다뤘다. 이 장은 정의가 아니라 304개를 5인이 함께 운영하는 자리를 다룬다.
304개를 매 세션 다 읽힐 수는 없다. 그래서 atom마다 score(가중치)를 매기고, 점수 높은 것만 자동으로 노출한다. 점수는 사용 빈도·수동 가중치·최신성으로 atom_score.py가 계산한다. 아래는 2026년 5월 실측 기준 상위 10개의 실측 score다.
| score | atom | 무엇을 강제하나 |
|---|---|---|
| 356.53 | view_html_filename_convention |
View_*.html 명명 규약 (Phase/Status → Domain → Topic) |
| 349.26 | xlsm_svn_update_before_edit |
xlsm 수정 전 SVN update + 기존 행 보존 |
| 341.03 | claude_role_transition_phase2 |
Claude를 passive trainee → active partner로 격상 (결정) |
| 340.26 | skill_audit_score |
SVN 로그 기반 스킬 사용 빈도 측정 |
| 329.26 | docs_is_source_of_truth |
workspace/docs를 정본으로 |
| 326.84 | claudeskills_naming_separation |
ClaudeSkills vs 게임 내 캐릭터 스킬 명칭 분리 |
| 324.36 | draft_doc_body_verify_before_skip |
위치만으로 skip 금지, 본문 grep 후 평가 |
| 309.43 | json_over_schema_doc_as_source_of_truth |
실제 JSON 출력이 스키마 문서보다 정본 |
| 294.93 | integrity_check_clickup_notify |
정합성 실패 시 ClickUp 즉시 통보 |
| 293.26 | data_entry_schema_first |
데이터 입력 순서 ($스키마 → Enum → proto) |
서두에서 세 번 설명했던 그 사고 — "xlsm 수정 전에 SVN update부터" — 가 보이는가. 그게 xlsm_svn_update_before_edit이고, score 349.26으로 전체 2위다. 점수가 높다는 건 그만큼 자주 인용되고, 그만큼 자주 틀리던 규칙이라는 뜻이다. 더는 내가 입으로 세 번 말하지 않는다. score 상위 10개는 CLAUDE.md의 <!-- BEGIN_TEAM_HOT_AUTO --> 영역으로 자동 주입돼, 누가 어느 폴더에서 세션을 열든 첫 화면에 실려 나온다.
여기서 멈추면 그냥 "자주 보는 규칙 핀 고정"이다. 진짜 차별점은 score가 사람 손이 아니라 시스템이 자기 자신을 측정해서 매겨진다는 점이다.
flowchart LR
A["회고·세션 로그
(인용 빈도)"] --> B["atom_score.py
가중치 계산"]
B --> C["_scores_latest.json
최신 점수 캐시"]
C --> D["claude_md_regen.py"]
D --> E["CLAUDE.md
BEGIN_TEAM_HOT_AUTO
상위 10개 자동 주입"]
C --> F["_jit_manifest.json
hot atom 221개
(score≥20 OR weight≥4)"]
F --> G["JIT 인젝션
세션 중 입력에 매칭"]
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
class B,D,G code;
class A,C,E,F data;
루프가 닫혀 있다. atom이 회고에서 자주 인용될수록 score가 오르고, score가 오르면 CLAUDE.md 상단과 JIT 매니페스트로 더 잘 노출되고, 잘 노출되니 또 인용된다. 자주 쓰는 결정이 스스로 위로 떠오르는 구조다. 반대로 6개월간 인용 0인 atom은 score가 가라앉아 자연히 시야에서 사라진다. 사람이 "이건 이제 안 쓰니 내리자"를 판단할 필요가 없다.
score는 "항상 보이는 것"을 정하고, JIT(Just-In-Time) 주입은 "방금 한 말에 맞는 것"을 끌어온다. 사용자가 프롬프트를 입력하는 순간, hook이 그 텍스트를 atom 매니페스트의 regex와 대조해 관련 atom을 컨텍스트에 끼워 넣는다.
이 hook의 핵심 로직은 회사 PC inject_atom.py의 패턴을 그대로 따른다. 아래는 개인 PC용으로 재작성한 동일 패턴의 inject_memory.py 실제 핵심부다 — score 내림차순 정렬 → regex 매칭 → 최대 3개 → 6000자 truncate, 그리고 무슨 일이 있어도 exit 0.
# score 내림차순 정렬 후 매칭
atoms_sorted = sorted(atoms, key=lambda a: a.get("score", 0), reverse=True)
matches = []
for atom in atoms_sorted:
if len(matches) >= max_matches: # max_matches = 3
break
try:
if re.search(atom["regex"], prompt, re.IGNORECASE):
matches.append(atom)
except re.error:
continue # 잘못된 regex는 건너뛰고 계속
if not matches:
emit_empty() # 매칭 없으면 빈 응답 (정상)
return
chunks = []
for atom in matches:
body = atom_path.read_text(encoding="utf-8")
if len(body) > max_body: # max_body = 6000
body = body[:max_body] + "\n\n[...truncated]\n"
chunks.append(f"\n\n=== [JIT Inject] {name} (score {score}) ===\n\n{body}\n...")
설계가 보수적이라는 점이 중요하다. 매칭이 안 되면 빈 응답을 내고 끝낸다(정상). regex가 깨져 있으면 그 atom만 건너뛰고 계속 돈다. 본문이 6000자를 넘으면 자른다. 그리고 hook 전체가 어떤 예외에서도 exit 0으로 끝난다 — 메모리 주입이 실패해도 사용자의 작업 흐름은 절대 멈추지 않는다. "있으면 돕고, 없거나 깨지면 조용히 빠진다"가 이 시스템의 1번 원칙이다.
여기까지가 atom(장기 기억)이라면, 이제 5인 team_memory(사람별 단기·협업 기억)다. 실제 한 세션의 시작을 입력 → 동작 → 결과로 끝까지 따라가 본다. 무대는 회사 PC, 팀원 C(가명, 폴더 팀원 C/)가 자기 자리에서 프로젝트 A 작업 폴더를 연 상황이다.
팀원 C는 특별할 것 없이 작업을 시작한다.
어제 정한 타게팅 시스템 초안 이어서 검토하자.
자기가 누구인지, 어떤 규칙을 따라야 하는지 한 마디도 적지 않았다. 적을 필요가 없다.
CLAUDE.md의 "세션 시작 자동 식별" 절차에 따라, Claude는 답을 만들기 전에 먼저 키보드 앞의 사람을 알아낸다. 실행하는 명령은 셋이다.
svn info --show-item last-changed-author # SVN 작업 사본의 최근 커밋 author
whoami # OS 로그인 계정
# 그리고 현재 작업 경로(어느 PC의 어느 사용자 폴더인지)
세 신호가 team_memory/users.md의 매핑과 대조돼 팀원 C로 확정된다. 그다음 Claude는 그 사용자 폴더 하나만 로드한다.
team_memory/
├── atoms/ ← 공유 (304개, score 상위는 이미 CLAUDE.md에)
├── leeminsoo/ context.md ← (로드 안 함 — 내가 아님)
├── teammate_a/ context.md ← (로드 안 함)
├── teammate_b/ context.md ← (로드 안 함)
├── teammate_c/ context.md ← ★ 이것만 로드
│ notes.md ← ★ 본인 발견 기록용
│ retro/
└── shared/ conventions.md commit_rules.md ... ← 규약만 참조, 수정 금지
팀원 C/context.md에 담긴 협업 스타일이 이 세션에만 적용된다. 그래서 Claude의 응답이 이렇게 시작한다.
팀원 C 님으로 식별했습니다 (svn author: teammate_c / 경로 확인).
teammate_c/context.md 협업 스타일 로드 완료.
어제 작업분(Draft_Targeting_System_v0.1.md) 이어서 검토하겠습니다.
주입된 관련 결정 카드 (JIT, 3건):
- docs_is_source_of_truth (329.26)
- draft_doc_body_verify_before_skip (324.36) — 위치만으로 skip 금지, 본문 grep 후 평가
- decision_summary_not_clickup_mirror
먼저 본문을 grep으로 확인한 뒤 검토를 시작합니다. 검토 중 발견한 사항은
teammate_c/notes.md 에만 기록하고, shared/ 는 직접 수정하지 않겠습니다.
여기서 내가(디렉터로서, 혹은 팀원 C 본인이) 확인하는 건 두 가지다. 식별이 맞는지, 그리고 경계를 지키는지. Claude의 다음 제안에서 경계 위반을 한 번 잡아낸다.
[Claude 제안]
검토 결과 명명 규칙 충돌을 발견했습니다. shared/conventions.md 의 타게팅
용어 항목을 바로 수정해 둘까요?
[사람의 거부]
아니요. shared/ 는 회고 합의 후에만 수정합니다. 지금은 teammate_c/notes.md 에
"conventions.md 타게팅 용어 충돌 — 회고 안건" 으로만 적어 두세요.
[Claude 재요청 후 결과]
이해했습니다. shared/ 직접 수정 취소.
teammate_c/notes.md 에 회고 안건으로 기록했습니다:
- 2026-06-06 conventions.md 타게팅 용어 'lock-on' vs 'target-lock' 충돌
→ 다음 팀 회고에서 합의 필요 (shared 수정 보류)
이게 5인 운영의 안전장치다. 각 사용자는 자기 notes.md에만 쓴다. 타인 폴더와 shared는 직접 못 건드린다. shared는 오직 회고에서 합의된 뒤에만 바뀐다. 그래서 네 사람이 같은 메모리 위에서 일해도 서로의 컨텍스트를 덮어쓰지 않는다. 발견은 개인 노트에 모였다가, 회고라는 게이트를 통과해야만 팀 공유 규약으로 승격된다.
세션 하나가 도는 전체 경로를 한 장으로 본다. 식별에서 시작해 회고에서 박제로 끝나는 한 사이클이다.
flowchart TD
S["세션 시작"] --> ID["사용자 자동 식별
svn info + whoami + 경로"]
ID --> CTX["해당 사용자 context.md 만 로드
(타인 폴더 미로드)"]
CTX --> STYLE["협업 스타일 자동 적용"]
STYLE --> HOT["CLAUDE.md Hot atom 10 +
JIT 매칭 atom 최대 3개 주입"]
HOT --> WORK["작업 수행"]
WORK --> NOTE["발견 → 본인 notes.md 기록
(shared·타인 직접수정 금지)"]
NOTE --> RETRO["회고: retro/YYYY-MM-DD.md 박제"]
RETRO --> GATE{"shared 규약
변경 필요?"}
GATE -->|"회고 합의 O"| SHARED["shared/ 갱신 + 신규 atom 추출"]
GATE -->|"개인 메모"| COMMIT["SVN 커밋 (개인 retro)"]
SHARED --> COMMIT
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
class ID,HOT code;
class STYLE ai;
class GATE human;
class CTX,NOTE,RETRO,SHARED,COMMIT data;
오른쪽 아래 분기점이 이 시스템의 심장이다. 개인이 발견한 건 개인 notes로 흐르고, 팀 전체에 영향을 주는 규약·신규 atom은 회고 게이트를 통과한 뒤에만 shared로 올라간다. 1인이 5인분을 굴려도 충돌이 안 나는 이유가 이 한 번의 게이트에 있다. 그리고 마지막은 반드시 SVN 커밋이다 — 박제되지 않은 발견은 다음 세션에서 다시 머릿속으로 돌아가기 때문이다.
5인 team_memory를 굴리며 실제로 밟은 지뢰들이다.
| 실패 | 증상 | 처방 |
|---|---|---|
| 식별 실패 | svn author가 공용 계정이라 사용자 미특정 | users.md에 경로·계정 다중 신호 매핑, 미특정 시 질문 |
| shared 무단 수정 | Claude가 친절하게 공유 규약을 고침 | "shared는 회고 합의 후만" atom + 워크드 거부 패턴 |
| notes 미커밋 | 발견이 로컬에만 남아 다음 세션에 증발 | 회고 마무리에 SVN 커밋 강제 (feedback-svn-zero-red) |
| Hot atom 박제 | score가 멈춰 옛 규칙이 상단 고정 | atom_score.py 주기 실행 → _scores_latest.json 갱신 |
| rnd를 규칙으로 오해 | 신규 멤버가 임시 우회법을 영구 규칙처럼 적용 | rnd/ 폴더 격리 + frontmatter에 무효화 조건 명시 |
여기서 가장 비싼 실패는 두 번째 줄, "shared 무단 수정"이다. AI는 도우려는 본능이 강해서, 충돌을 발견하면 바로 고치려 든다. §20.1.4의 워크드 거부가 단발 교정이 아니라 atom으로 박제돼 있어야, 다음에 다른 사용자 세션에서도 같은 선을 긋는다.
팀이 없어도 이 구조의 8할은 1인이 그대로 쓸 수 있습니다. 사용자 5명을 폴더 1개로 줄이면 됩니다.
rules/(확정)와 rnd/(가설)만 나눕니다. 자주 틀리는 규칙 5~10개를 atom으로 박제하는 것만으로 "전에 정하지 않았나?"가 사라집니다.notes.md 하나입니다. 회고 게이트만 살리세요 — 즉흥 메모는 notes에, 확정 규칙은 회고를 거쳐 rules/로.핵심은 "결정을 머릿속에서 파일로 옮기는 것"이고, 5인이든 1인이든 그 동작은 같습니다.
자주 틀리는 규칙 하나를 atom으로 박제하고 JIT로 자동 주입되게 만들어 보세요.
atoms/rules/ 폴더를 만들고, 가장 자주 반복 설명하는 규칙 1개를 파일로 적습니다. 예: atoms/rules/xlsm_svn_update_before_edit.md.{"name":..., "regex":"xlsm|쿨타임", "score":100, "path":...}를 추가하고, 프롬프트에 "쿨타임 수정"을 입력해 _injection_log.txt에 해당 atom이 hit으로 찍히는지 확인하세요.찍혔다면, 그 규칙은 이제 당신 머릿속이 아니라 시스템 안에 있습니다.
수요일 점심 무렵, 팀원 B가 팀 메신저로 메시지를 보냈다. "지난주에 디렉터님이 전투 쿨다운 0.8초로 정한 거, 제 메모에 0.6초로 적혀 있는데 어느 게 맞아요?" 나는 잠깐 멍해졌다. 0.8초는 공유 결정이고, 0.6초는 팀원 B가 자기 테스트 빌드에서 임시로 돌려보던 값이었다. 둘 다 "메모리"에 적혀 있었다. 문제는 그 둘이 같은 칸에 섞여 있었다는 거다. 팀원 B는 자기 실험값을 회사 결정으로 착각했고, 하마터면 잘못된 값으로 데이터시트를 갱신할 뻔했다.
이 사고는 메모리에 데이터가 없어서 난 게 아니다. 오히려 데이터가 너무 잘 쌓여 있는데, 어느 칸이 공유 칸이고 어느 칸이 개인 칸인지 경계가 없어서 났다. §20.1에서 다섯 명이 같은 사실(shared atom)을 본다는 셀링포인트를 깔았다면, 이 챕터는 그 반대편 — 다섯 명이 각자 자기 칸을 따로 가진다는 이야기다. 같은 캐비닛인데 칸이 두 종류라는 것. 그리고 그 두 종류를 도구로 강제하지 않으면 위의 0.6초 사고가 반드시 난다는 것.
프로젝트 A의 team_memory/는 다섯 사람의 칸으로 나뉜다. 본인(leeminsoo)을 포함해 팀원 A, 팀원 B, 팀원 C, 그리고 shared. 앞의 넷은 사용자별 개인 칸이고, 마지막 하나는 모두가 여는 공유 칸이다.
개인 칸 네 개는 파란색, 공유 칸 하나는 주황색으로 칠해 두었다. 색이 다른 이유는 접근 규칙이 다르기 때문이다. 파란 칸은 본인과 디렉터만 열고, 주황 칸은 전원이 연다. 0.6초 사고는 팀원 B가 자기 파란 칸에 적어야 할 실험값을, 색 구분 없이 그냥 "메모리"라고 부르며 공유 결정처럼 취급한 데서 났다. 칸을 물리적으로 나누면 — 즉 디렉토리를 나누면 — 적어도 어디에 적었는지로 둘을 구별할 단서가 생긴다.
여기서 핵심은 폴더 두 개가 아니라 칸마다 규칙이 따라붙는다는 점이다. shared/에 들어가는 건 회사 결정이고 누구나 읽는다. 팀원 B/에 들어가는 건 그 사람의 작업 맥락이고 본인과 나만 읽는다. 같은 0.6초라도 어느 칸에 있느냐에 따라 "실험 중"인지 "결정됨"인지가 갈린다.
사용자별 칸을 열면 두 개의 파일이 보인다. context.md와 notes.md. 이름은 단순하지만 역할이 정반대다.
context.md는 그 사람이 지금 누구인가를 적는다. 역할, 담당 시스템, 진행 중인 작업, 작업 스타일. 비교적 안정적이고, 디렉터인 내가 1:1을 앞두고 5분 전에 펼쳐 보는 파일이다. 팀원 A의 context.md를 열면 "전투 시스템 담당, 현재 스킬 쿨다운 밸런싱 진행 중, 데이터 근거를 먼저 요구하는 스타일" 같은 게 적혀 있다. 이걸 안 보고 1:1에 들어가면 첫 10분을 "요즘 뭐 하세요?"로 날린다.
notes.md는 그 사람이 지금 뭘 겪고 있나를 적는다. 그날그날의 실험값, 막힌 지점, 작은 결정, 실수 기록, 다른 멤버와의 협의 메모. 휘발성이 높고 자주 갱신된다. 팀원 B의 0.6초는 원래 여기 들어가야 했다. "0.6초로 테스트해 봄, 너무 빨라서 입력이 밀림 — 0.8초 공유 결정 따르기로" 이렇게.
이 두 파일을 나누는 이유는 갱신 주기가 다르기 때문이다. context.md는 분기에 한 번 손보면 되지만 notes.md는 매일 쌓인다. 섞어 두면 안정적인 정보가 매일의 노이즈에 묻힌다. 1인으로 일한다면 이 분리가 과해 보일 수 있다 — 그땐 notes.md 하나만 운영하고 context.md는 머릿속에 둬도 된다. 하지만 사람이 둘만 넘어가도, 남의 context.md를 5분 만에 읽어 1:1을 준비할 수 있다는 건 큰 차이다.
개인 칸과 공유 칸을 나눴다고 끝이 아니다. 가장 까다로운 건 개인 칸의 어떤 내용은 공유 칸으로 올라가야 한다는 점이다. 팀원 C가 "데이터시트 import 시 enum 순서가 어긋나면 런타임에서 조용히 깨진다"는 실수를 자기 notes.md에 적었다고 하자. 이건 그 사람 개인 기록이지만, 팀 전체가 알면 같은 실수를 막는다. 그렇다고 개인 notes.md 전체를 공유하면 안 된다 — 거기엔 작업 스타일, 막혔던 감정, 다른 멤버와의 갈등 같은 게 섞여 있다.
그래서 개인 → 공유 사이에는 게이트가 있어야 한다. 회고가 그 게이트다. 회고를 쓸 때 "이번 주 내가 겪은 것 중 팀이 알아야 할 게 뭔가"를 한 번 거르고, 거른 것만 shared/ atom으로 승격한다. 흐름은 이렇다.
flowchart TD
A["teammate_c notes.md
(개인 칸·매일 갱신)"] --> B{"주간 회고
승격 게이트"}
B -->|"팀에 도움 + 개인정보 제거"| C["익명화 검토"]
B -->|"개인 맥락·감정·갈등"| D["개인 칸에 잔류"]
C --> E["shared/ atom 승격
(전원 read)"]
C -->|"실명·민감정보 잔존"| D
E --> F["JIT 주입으로
게임 결정 토론에 자동 인용"]
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
class F code;
class B,C human;
class A,D data;
class E pass;
게이트의 판단 기준은 두 개다. 첫째, 팀에 도움이 되는가. 개인 취향이나 그날의 컨디션은 아니다. 둘째, 개인정보가 제거되는가. "팀원 C가 또 enum에서 실수했다"가 아니라 "데이터시트 import 시 enum 순서 검증을 추가하자"로 사실만 남긴다. 두 관문을 통과한 것만 shared/로 간다. 통과 못 한 건 개인 칸에 그대로 둔다.
이 게이트가 없으면 둘 중 하나로 망한다. 게이트가 너무 헐거우면 개인 정보가 공유 칸으로 새어 0.6초 사고의 반대 — 사적인 메모가 전원에게 노출되는 — 사고가 난다. 게이트가 아예 없으면 팀원 C의 enum 교훈이 그 사람 칸에 갇혀 팀원 A가 같은 실수를 반복한다. 회고를 게이트로 쓰면 "개인 정보 누설"과 "학습 자산화"라는 정반대 두 일을 한 동작으로 분리할 수 있다.
내가 실제로 쓰는 승격 프롬프트는 짧다. 회고 직후 이렇게 던진다.
"이번 주 내 notes.md에서 팀 전체에 도움 될 항목만 골라라. 각 항목에서 사람 이름·구체적 실패 정황은 빼고, '무엇을 어떻게'만 남긴 atom 후보 문장으로 다시 써라. 개인 감정·스타일·갈등 메모는 후보에서 제외하고, 제외한 이유를 한 줄로 표기하라."
마지막 문장 — 제외 이유를 적게 한 것 — 이 중요하다. AI가 무엇을 안 올렸는지 보여 주면, 게이트가 너무 빡빡해서 진짜 교훈까지 막고 있지 않은지 사람이 검수할 수 있다.
게이트가 항상 깔끔하게 작동하는 건 아니다. 실제로는 AI가 한 번에 못 맞히고, 사람이 거부하고, 다시 시키는 왕복이 일어난다. 팀원 B의 한 주 notes.md를 shared로 올리려던 실제 왕복을 그대로 옮긴다.
[프롬프트 — 1차]
"아래는 팀원 B의 이번 주 notes.md다. 팀 전체에 도움 될 항목만 shared atom 후보로 뽑아라. 사람 이름과 구체적 실패 정황은 제거하고 '무엇을 어떻게'만 남겨라.
- 화/0.6초 쿨다운 테스트, 입력 밀림. 0.8초 공유결정 따름
- 수/팀원 A가 짠 스킬 enum이 내 import랑 충돌. 팀원 A한테 직접 말하기 좀 그래서 디렉터 통해 전달
- 목/포탈웹 View_skill_table.html 새로고침하면 필터 초기화됨. 짜증
- 금/컨디션 안 좋아서 오후 반차"
[Claude 날것 출력 — 1차]
다음 4개 항목을 shared atom 후보로 추출했습니다. 1. 전투 쿨다운은 0.6초에서 입력 밀림이 발생하므로 0.8초를 기준으로 한다. 2. 팀원 A가 작성한 스킬 enum과 import가 충돌하므로, enum 변경 시 사전 공유가 필요하다. 3. View_skill_table.html은 새로고침 시 필터 상태가 초기화되므로 상태 보존 기능이 필요하다. 4. (제외) 금요일 오후 반차 — 개인 일정으로 판단되어 제외했습니다.
여기서 나는 2번을 거부했다. "팀원 A가 작성한"이 그대로 남아 있다. 사람 이름은 뺐지만 "누가 짠 enum"인지 특정되고, 게다가 "직접 말하기 그래서 디렉터 통해 전달"이라는 개인 갈등 맥락이 사실 항목으로 둔갑했다. 이건 학습 자산이 아니라 1:1에서 따로 다룰 관계 이슈다. 그대로 shared에 올리면 팀원 A가 공유 칸에서 자기 이름이 명시된 충돌 기록을 보게 된다.
[사람 검증/거부 → 재요청]
"2번은 거부한다. enum 충돌은 사실로 남길 가치가 있지만, 특정인이 작성했다는 정보와 전달 경로(누구를 통해 말했는지)는 개인 관계 영역이다. 그 둘을 빼고 '데이터 충돌을 막는 절차'로만 다시 써라. 그리고 3번 포탈웹 버그는 atom이 아니라 포탈 이슈 트래커로 갈 항목이니 후보에서 빼고 그 이유를 적어라."
[Claude 날것 출력 — 2차]
수정했습니다. 2. (개정) 스킬 enum을 변경할 때는 데이터시트를 import하는 다른 작업자와 사전에 공유한다. enum 순서·값 변경은 import 측에서 조용히 깨질 수 있다. 3. (제외) View_skill_table.html 필터 초기화 — 도구 버그로, shared atom보다 포탈 이슈 항목이 적절하여 후보에서 제외했습니다. 최종 shared 승격 후보: 1번(쿨다운 0.8초), 2번(enum 변경 사전 공유). 2건.
2차는 통과시켰다. 2번에서 사람도 전달 경로도 사라지고 절차만 남았다. 4건으로 시작해 2건이 shared로 올라갔고, 1건은 개인 칸에 남고(반차) 1건은 포탈 이슈로 갈렸다(필터 버그). 이게 게이트가 실제로 작동하는 모습이다 — 한 번에 안 되고, 사람이 한 번 거부하고, 무엇이 왜 빠지는지가 기록으로 남는다. 요약하면 깔끔해 보이지만, 깔끔한 요약만 보면 2번이 처음에 사람 이름을 달고 올라올 뻔했다는 사실이 지워진다. 그 위험이 이 게이트의 존재 이유다.
칸을 나누고 게이트를 세워도, 대화할 때마다 어느 칸을 손으로 펼치면 운영이 무겁다. 그래서 마지막 조각은 대화 맥락에 맞는 칸이 자동으로 펼쳐지는 것이다. 본인 PC에서는 이걸 UserPromptSubmit 훅(inject_memory.py)이 한다. 입력 문장에 매칭되는 칸만 골라 컨텍스트에 주입한다.
규칙은 단순하다. 게임 결정을 토론하면 shared/ atom이 펼쳐진다. 특정 팀원과의 1:1을 준비하면 그 사람 context.md + shared가 함께 펼쳐진다. 분기 회고를 쓰면 프로젝트 메모리 + 디렉터 본인 칸이 펼쳐진다. 외부 보고서를 쓰면 디렉터 칸 + shared 일부가 펼쳐진다. 어느 칸을 펼칠지가 곧 메모리의 인터페이스다.
여기서 칸 분리가 다시 효력을 낸다. 1:1 준비 때 팀원 B의 개인 칸은 펼쳐지지만 팀원 C의 개인 칸은 안 펼쳐진다 — 지금 대화와 무관하니까. 칸이 안 나뉘어 있으면 매번 전부 펼쳐져 노이즈에 묻히고, 더 나쁘게는 1:1 자리에서 무관한 사람의 개인 메모가 끌려 나온다. 분리는 보안인 동시에 주입 정확도다.
칸 구조가 잡혀도 마지막 함정이 하나 남는다. 나는 집 PC와 회사 PC를 오가고, 메모리는 클라우드 폴더로 동기화된다. 여기서 두 PC가 같은 칸을 동시에 고치면 충돌이 난다. 한쪽이 다른 쪽을 통째로 덮어쓰면 그날의 notes.md가 사라진다.
처방은 칸 단위로 다르다. 자주 갱신되는 개인 notes.md는 git 같은 머지 가능한 저장소에 두고, 충돌 시 양쪽을 합친다. 안정적인 context.md나 shared/ atom은 갱신 빈도가 낮아 잠금이나 일일 백업으로 충분하다. 핵심은 "동기화가 한쪽을 덮어쓰는 동작"을 기본값에서 빼는 것이다. 잘못된 폴더 권한으로 개인 칸이 공유 폴더에 섞여 동기화되는 것 — 그게 가장 조용하고 치명적인 사고다. 칸마다 어느 동기화 영역에 속하는지를 명시해 두면, 0.6초 사고와 같은 계열의 "섞임" 사고를 입구에서 막는다.
setup
1. team_memory/ 아래에 사람별 폴더를 만드세요. 본인 + 팀원 각각, 그리고 shared/ 하나. 폴더 이름은 가명으로(leeminsoo, 팀원 A …).
2. 각 개인 폴더에 context.md(안정적 — 역할·담당·스타일)와 notes.md(휘발성 — 매일의 실험·실수·결정) 두 파일을 둡니다.
3. shared/는 전원 read 권한, 개인 폴더는 본인 + 디렉터 read 권한으로 폴더 권한을 명시합니다.
prompt (주간 회고 직후, 개인 → shared 승격 게이트) — §20.2.3의 승격 프롬프트를 그대로 사용한다(사람 이름·실패 정황 제거 + '무엇을 어떻게'만 + 제외 이유 표기).
verify
1. 출력된 후보 문장에 사람 이름·전달 경로·감정 묘사가 남았는지 직접 읽으세요. 하나라도 있으면 거부하고 "그 정보를 빼고 절차만"으로 재요청합니다.
2. 통과한 후보만 shared/ atom으로 옮기고, 뺀 항목은 개인 칸에 그대로 둡니다.
3. 동기화 폴더 권한을 확인합니다 — 개인 칸이 공유 폴더 경로에 들어가 있지 않은지 봅니다.
1인 축소판
혼자라면 폴더 다섯 개는 과합니다. notes.md 하나만 매일 쓰고, context.md는 머릿속에 두세요. 그래도 게이트는 살립니다 — 주 1회 "이 notes에서 다음에 다시 볼 가치가 있는 한 줄만 뽑아라"로 자기 노트를 거르면, 휘발성 메모와 자산화된 교훈이 갈립니다. 사람이 둘로 늘어나는 순간 그때 칸을 쪼개면 됩니다.
목요일 늦은 오후, 빌드를 올리기 직전, 클라이언트 프로그래머 팀원 B가 사내 채팅에 글을 올린다. "지난주 전투 TF에서 글로벌 쿨다운 상수 0.8초로 합의한 거 맞나요? 어디 문서에 적혀 있죠?" 5분 뒤 기획자 팀원 A가 답한다. "회의록 어딘가에 있을 텐데… 찾는 중." 다시 7분 뒤. "git 어디 폴더더라."
이 12분짜리 왕복은 정보가 없어서 생긴 게 아니다. 정보는 분명히 있다. atom 파일에도, 회의록에도, 결정 카드에도 적혀 있다. 다만 그 셋이 서로 다른 서랍에 들어 있고, 각 서랍을 여는 방법이 다르다. 서랍이 아니라 서랍을 여는 손잡이가 문제다.
이 챕터는 그 손잡이를 하나로 합치는 이야기다. 자체 풀스택 개발이 아니라, 이미 폴더에 쌓여 있는 기획 산출물 위에 얇은 웹 한 겹을 덮어 팀원이 브라우저 주소창에 portal 한 단어만 쳐서 들어오게 만드는 구성이다. 핵심 도구는 세 가지뿐이다. Python으로 검색 API를 띄우는 FastAPI, 그 앞에 세우는 nginx, 그리고 사람이 끄지 않아도 PC가 켜져 있는 동안 계속 살아 있게 하는 nssm.
기획 산출물은 본래 흩어진다. 의도해서 흩어뜨리는 게 아니라, 각 산출물이 가장 자연스러운 자리에 떨어지기 때문이다. atom은 git 저장소의 마크다운으로, 일정은 태스크 관리 도구로, 실시간 대화는 채팅으로, KPI는 별도 대시보드로 간다. 각자 제자리에 있는 건 옳다. 문제는 그 제자리들을 사람이 머릿속에 지도로 갖고 있어야 한다는 점이다.
신규 입사자에게는 이 지도 자체가 진입 장벽이다. "글로벌 쿨다운 값"을 찾으려면 (1) 그게 결정 카드인지 atom인지 회의록인지 판단하고 (2) 해당 도구를 열고 (3) 그 도구의 검색 문법으로 다시 질의해야 한다. 세 단계 모두 경력에서 나오는 암묵지다.
포탈의 발상은 단순하다. 산출물은 지금 자리에 그대로 둔다. 대신 그 위에 검색용 인덱스 한 겹을 얹고, 인덱스를 브라우저로 노출한다. 책상을 일곱 개 두지 않고, 서랍이 일곱 개 달린 책상 하나를 둔다. 서랍은 그대로지만 사람은 한 번만 앉는다.
다음은 저자가 프로젝트 A에서 실제로 운영하는 포탈의 구성이다. 별도 서버 장비 없이, 기획팀 공용 PC 한 대에서 항상 켜진 상태로 돈다.
flowchart TB
subgraph client["팀원 브라우저"]
U1["teammate_a · 기획"]
U2["teammate_b · 클라"]
U3["teammate_c · 서버"]
U4["leeminsoo · 디렉터"]
end
U1 & U2 & U3 & U4 -->|"http://portal/"| NGINX
subgraph host["기획팀 공용 PC (항상 가동)"]
NGINX["nginx
정적 파일 + 리버스 프록시"]
NGINX -->|"/ (정적)"| VIEW["View_*.html
Claude 작성 화면"]
NGINX -->|"/api/* (프록시)"| API["FastAPI · server.py
:8000"]
API --> IDX[("검색 인덱스
build_index.py 산출")]
subgraph svc["nssm (Windows 서비스)"]
API
NGINX
end
end
IDX -.->|"인덱싱 대상"| SRC
subgraph SRC["기존 산출물 (제자리 유지)"]
A1["atom .md (git)"]
A2["결정 카드 .md"]
A3["회의록 .md"]
A4["team_memory/*"]
end
BUILD["build_index.py
주기 실행"] -->|"읽기"| SRC
BUILD -->|"쓰기"| IDX
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
class NGINX,API,BUILD code;
class U1,U2,U3,U4 human;
class VIEW,IDX,A1,A2,A3,A4 data;
그림에서 회색으로 묶인 아래쪽이 이미 존재하던 산출물이고, 포탈이 새로 더한 것은 위쪽의 얇은 세 겹 — 인덱스, FastAPI, nginx — 뿐이다. 산출물에 손대지 않고 입구만 새로 낸 구조다.
포탈의 실체는 다섯 개의 작은 파일로 끝난다. 하나씩 보면 각자 한 가지 일만 한다.
build_index.py — 산출물을 검색 가능한 형태로 변환한다. git 저장소를 훑어 atom, 결정 카드, 회의록, team_memory/ 아래의 마크다운을 모두 읽고, 제목·본문·태그를 추출해 하나의 인덱스 파일로 떨군다. 이 스크립트가 하는 일은 "흩어진 파일을 한 줄짜리 레코드로 평탄화"하는 것뿐이다. 파일 자체는 건드리지 않으므로, 인덱스가 깨져도 원본은 안전하다. 주기적으로(예: 30분마다, 혹은 git 커밋 훅으로) 다시 돌리면 최신 상태가 유지된다.
server.py — FastAPI로 검색 API를 띄운다. 인덱스를 메모리에 올려두고, /api/search?q=... 요청이 오면 매칭되는 레코드를 JSON으로 돌려준다. 코드는 한 화면을 넘지 않는다.
# server.py (발췌 — 검색 엔드포인트 골격)
from fastapi import FastAPI
import json, pathlib
app = FastAPI()
INDEX = json.loads(pathlib.Path("index.json").read_text(encoding="utf-8"))
@app.get("/api/search")
def search(q: str):
q = q.strip().lower()
hits = [r for r in INDEX
if q in r["title"].lower() or q in r["body"].lower()]
# 종류별로 묶어서 반환 → atom / 결정 / 회의록 / 메모리
by_kind = {}
for r in hits:
by_kind.setdefault(r["kind"], []).append(
{"id": r["id"], "title": r["title"], "path": r["path"]})
return {"query": q, "count": len(hits), "results": by_kind}
검색 알고리즘은 일부러 단순한 부분 문자열 매칭으로 시작한다. 팀이 중규모(10~50인)이고 문서가 수천 건 규모일 때는 이 단순함이 오히려 유지보수 비용을 낮춘다. 형태소 분석이나 벡터 검색은 "검색이 약하다"는 불만이 실제로 나온 다음에 얹어도 늦지 않다.
nginx — 정적 화면을 서빙하고 API로 프록시한다. Claude에게 부탁해 만든 View_*.html 파일들(검색 화면, 결과 화면, 대시보드 화면)을 정적으로 내려주고, /api/로 들어온 요청만 뒤쪽 FastAPI(:8000)로 넘긴다. 팀원 입장에서는 화면도 검색도 전부 같은 http://portal/ 주소 하나에서 일어난다. 화면을 Claude가 HTML로 직접 그려주기 때문에, 기획자가 새 화면이 필요하면 "결정 카드만 모아 보는 화면 하나 만들어줘"라고 요청해 View_decisions.html을 받아 폴더에 떨구는 것으로 끝난다. 프런트엔드 빌드 파이프라인이 없다는 점이 중규모 팀에서는 분명한 장점이다.
nssm — 사람이 안 켜도 살아 있게 한다. 포탈의 핵심 요구사항은 "내가 자리에 없어도 팀원이 검색할 수 있어야 한다"는 것이다. server.py를 터미널에서 띄우면 그 터미널을 닫는 순간 죽고, PC를 재부팅하면 사라진다. nssm(Non-Sucking Service Manager)은 이 Python 프로세스를 Windows 서비스로 등록해, PC가 부팅되면 자동으로 살아나고 프로세스가 죽으면 자동으로 되살린다. 등록은 한 번이면 된다.
# nssm으로 FastAPI를 Windows 서비스로 등록 (1회)
nssm install Portal "C:\Python\python.exe" "C:\portal\portal_run.py"
nssm set Portal AppDirectory "C:\portal"
nssm start Portal
여기서 portal_run.py는 다섯 줄짜리 런처다. uvicorn으로 server.py를 띄우는 한 줄과, 서비스가 죽지 않게 잡아두는 최소한의 골격이 전부다. 사람이 외울 명령은 nssm start 하나뿐이고, 그마저도 한 번 등록하면 다시 칠 일이 없다.
이 네 부품의 분업을 한눈에 보면 이렇다.
부품 설명만으로는 포탈의 가치가 와닿지 않는다. 앞서 12분을 잡아먹던 그 질문을 포탈 위에서 다시 던져보자. 다음은 저자가 검색 화면을 만들 때 Claude에게 던진 프롬프트와 받은 출력, 그리고 검증 과정을 압축 없이 옮긴 것이다.
저자의 프롬프트:
index.json을 fetch해서 검색하는 단일 HTML 페이지를 만들어줘. 입력창에 키워드를 치면/api/search?q=를 호출하고, 결과를 atom / 결정 카드 / 회의록 / 메모리 네 그룹으로 나눠서 카드 형태로 보여줘. 각 결과를 클릭하면 원본 경로(path)로 이동. 외부 CSS·JS 프레임워크 쓰지 말고 한 파일로. 파일명은View_search.html.
Claude의 날것 출력(발췌, 실제로 받은 그대로):
<!-- View_search.html -->
<input id="q" placeholder="atom 또는 키워드">
<div id="out"></div>
<script>
document.getElementById('q').addEventListener('keydown', async e => {
if (e.key !== 'Enter') return;
const r = await fetch('/api/search?q=' + encodeURIComponent(e.target.value));
const d = await r.json();
const order = ['atom','decision','meeting','memory'];
const label = {atom:'atom', decision:'결정 카드',
meeting:'회의록', memory:'메모리'};
out.innerHTML = order.filter(k => d.results[k]).map(k =>
`<h3>${label[k]} (${d.results[k].length})</h3>` +
d.results[k].map(x =>
`<a href="${x.path}">${x.title}</a>`).join('<br>')
).join('') || '결과 없음';
});
</script>
이 출력은 완성품이 아니었다. 저자가 그대로 받지 않고 두 군데를 거부했다.
첫째, out을 getElementById 없이 전역 변수처럼 쓴 부분은 브라우저 관용에 기대는 위험한 코드다 — 일부 환경에서 동작하지 않는다. 둘째, path가 git 내부 경로라서 그대로 href에 넣으면 브라우저가 파일을 열지 못한다. 포탈이 그 경로를 다시 자기 화면으로 라우팅하도록 /view?path=를 거치게 고쳐야 한다.
저자의 재요청:
두 가지 고쳐줘. (1)
out을 명시적으로document.getElementById로 받아. (2) 결과 링크는 원본 경로로 바로 가지 말고/view?path=뷰어 엔드포인트를 거치게 해. 뷰어는 server.py에 추가할 테니 프런트는 링크만 바꿔.
이 왕복이 핵심이다. Claude의 첫 출력은 80% 맞았지만, 나머지 20%는 "이 포탈이 git 산출물 위에 얹혀 있다"는 맥락을 사람이 알아야만 잡아낼 수 있는 결함이었다. 검증은 사람의 몫으로 남는다.
검색을 한 번 실행하면 팀원의 화면에는 이렇게 그룹화된 결과가 뜬다.
| 그룹 | 검색어 "글로벌 쿨다운" 결과 |
|---|---|
| atom | combat_global_cooldown_constant |
| 결정 카드 | D2026_Q2_017 (0.8초로 확정) |
| 회의록 | 95_BattleTF 2회차 |
| 메모리 | 팀원 B 1:1 노트 1건 |
목요일 오후의 12분짜리 왕복이 검색창에 한 단어를 치는 20초로 줄어든다. 그리고 더 중요한 건, 이 20초가 팀원 B 혼자 끝낼 수 있는 일이 되어 팀원 A의 12분을 아예 쓰지 않게 된다는 점이다.
포탈을 만드는 방법은 크게 셋이다. 자체 풀스택을 처음부터 개발하거나, Notion·Coda 같은 외부 통합 도구를 도입하거나, 지금처럼 기본 도구에 얇은 자동화를 얹는다. 저자는 셋째를 택했고, 그 선택의 근거는 중규모 팀이라는 규모에 있다.
풀스택 자체 개발은 자유도가 가장 높지만, 만든 다음 그 웹을 계속 유지보수해야 하는 부담이 효과보다 먼저 도착한다. 인증·배포·DB 마이그레이션 같은 운영 노동이 기획팀에 떨어진다. 외부 통합 도구는 빠르지만 월 구독이 붙고, 무엇보다 git에 쌓인 마크다운 산출물을 그 도구의 형식으로 다시 옮겨야 하는 이주 비용이 든다. 반면 FastAPI+nginx+nssm 조합은 산출물을 제자리에 두고 인덱스 한 겹만 얹으므로, 며칠이면 가동하고 유지보수는 build_index.py를 가끔 손보는 수준에 그친다.
다음은 저자가 프로젝트 A에서 포탈 도입 전후로 체감한 변화다. 표의 수치는 정밀 계측이 아니라 저자 추정(미검증)이며, 절대값보다 방향과 비율을 읽어야 한다.
| 항목 | 포탈 부재 | 포탈 운영 | 방향 |
|---|---|---|---|
| 정보 검색 1회 소요 | 수 분 | 1분 미만 | 대폭 단축 |
| "이거 어디 있죠" 질의 빈도 | 잦음 | 드묾 | 감소 |
| 신규 멤버 도구 적응 | 2주 안팎 | 며칠 | 단축 |
| 회의록·결정 카드 등록률 | 절반 수준 | 대다수 | 상승 |
마지막 줄이 가장 본질적이다. 정보를 찾기 쉬워지면 검색만 빨라지는 게 아니라, 자료를 남기는 행위 자체의 동기가 올라간다. "어차피 찾지도 못할 회의록을 왜 쓰나"라는 냉소가, "쓰면 검색에 걸리니까 쓴다"로 바뀐다. 포탈은 검색 도구이면서 동시에 기록을 유인하는 장치다. 이 선순환이 도구 한두 개를 합친 것 이상의 가치를 만든다.
다만 이 균형은 팀 규모에 종속된다. 팀이 50인을 넘고 산출물이 수만 건으로 불어나면, 부분 문자열 검색의 한계와 단일 PC 서빙의 한계가 동시에 드러난다. 그 시점에는 풀스택 자체 개발이나 검색 엔진 도입이 정당화된다. 지금 이 구성은 "중규모 팀에 맞는 해"이지, 모든 규모의 정답이 아니다.
setup. 기획팀 공용 PC(혹은 항상 켜두는 PC) 한 대를 정하세요. Python과 nginx, nssm을 설치합니다. 인덱싱할 산출물 폴더(atom·결정 카드·회의록·team_memory)의 위치를 확인합니다.
prompt. Claude에게 세 가지를 순서대로 요청한다.
(1) "이 폴더의 마크다운을 읽어 제목·본문·태그·종류를 뽑아
index.json으로 떨구는 build_index.py를 만들어줘. 종류는 경로 규칙으로 판별해." (2) "그 index.json을 메모리에 올려/api/search?q=로 검색하는 FastAPI server.py를 만들어줘. 결과는 종류별로 그룹화해서 반환." (3) "index.json을 fetch해 검색하는 단일 HTML(View_search.html)을 만들어줘. 외부 프레임워크 없이 한 파일로."
verify. 세 가지를 직접 확인하세요. (1) build_index.py를 돌린 뒤 index.json에 산출물 건수가 맞게 들어갔는지 — 누락된 폴더가 없는지 봅니다. (2) server.py를 띄우고 브라우저에서 /api/search?q=테스트키워드를 직접 호출해 JSON이 그룹화돼 나오는지 봅니다. (3) Claude가 만든 화면 코드에서 링크 경로가 git 내부 경로를 그대로 노출하지 않는지, 전역 변수에 기대는 코드가 없는지 읽어서 잡아냅니다 — 앞 절에서 본 두 가지 결함이 바로 여기서 걸러집니다. 마지막으로 nssm으로 서비스 등록 후 PC를 재부팅해, 사람이 아무것도 안 켜도 포탈이 살아 있는지 확인합니다.
팀이 없어도 이 구성은 그대로 쓸모가 있습니다. 혼자 작업하는 사람도 자기 산출물은 흩어지기 때문입니다. setup에서 공용 PC 대신 본인 PC를 쓰고, nssm 등록은 생략해도 됩니다(필요할 때만 python portal_run.py로 띄웁니다). prompt는 동일하게 build_index.py와 server.py와 View_search.html 셋을 받되, team_memory 부분을 빼고 atom·결정·회의록만 인덱싱하세요. verify는 index.json 건수 확인과 검색 한 번이면 충분합니다. 핵심은 같습니다 — 산출물은 제자리에 두고, 검색 입구 하나만 새로 냅니다.
화요일 출근 직후, 9시 12분. 나는 협업툴 보드를 열기도 전에 Claude Code 창에 한 줄을 친다.
이번 주 미완료 P0 태스크, 마감 임박 순으로 보여줘
3초쯤 멈칫하더니 답이 떴다. 협업툴을 직접 열지 않았다. 대시보드 탭을 뒤지지도, 담당자에게 메신저를 보내지도 않았다. 그런데 마감이 하루 지난 태스크 하나가 맨 위에 잡혀 있었다. 나는 그제서야 협업툴을 열어 그 카드 하나만 확인했다.
이 3초가 어떻게 만들어졌는지가 이 챕터의 전부다. 핵심은 도구를 바꾼 게 아니라는 점이다. 프로젝트 A 팀은 여전히 협업툴(본 프로젝트는 ClickUp — 태스크·일정을 관리하는 SaaS로 JIRA·Redmine·Linear도 같은 자리)을 쓴다. 협업툴이 무엇이든 이 장의 흐름은 도구 이름만 바꿔 그대로 옮겨진다. 데이터 시트도 그대로 SVN에 있고, 결정 카드도 그대로 포탈에 있다. 달라진 건 단 하나, LLM이 그 도구들을 자기 손으로 열어 볼 수 있게 됐다는 것이다. 그 연결의 표준이 MCP(Model Context Protocol)다.
비유하자면 안내 데스크에 신입을 한 명 더 앉히는 게 아니다. 이미 있는 자료실의 자물쇠를, LLM에게도 열어 주는 것에 가깝다. 자료실은 그대로다. 열쇠를 하나 더 깎았을 뿐이다.
MCP는 LLM이 외부 도구·데이터에 접근하는 표준 프로토콜이다. 표준이라는 말이 핵심이다. 협업툴용 따로, 문서용 따로, git용 따로 어댑터를 깎는 게 아니라, JSON-RPC라는 한 가지 약속 위에 각 도구가 자기를 "서버"로 노출하고, LLM은 "클라이언트"로 그 서버에 말을 건다.
구조는 세 조각이다.
서버는 "내가 할 수 있는 일"의 목록을 노출한다. 협업툴 서버라면 search_tasks, get_task, update_task 같은 함수들이다. 클라이언트(LLM)는 사용자의 자연어를 받아 그중 맞는 함수를 골라 호출하고, 돌아온 JSON을 다시 자연어로 풀어 준다. 내가 "P0 태스크 보여줘"라고 했을 때 LLM이 한 일은 search_tasks(priority=P0, status=open)를 호출한 것뿐이다.
여기서 한 가지를 분명히 해 두자. 이 책은 MCP 서버를 손수 짜는 법을 다루지 않는다. ClickUp·JIRA·GitHub처럼 공식·커뮤니티 MCP 서버가 이미 있는 도구가 대부분이라, 기획자가 할 일은 "서버를 설치하고 연결하는 것"이지 "프로토콜을 구현하는 것"이 아니다. 프로토콜 자체는 Anthropic이 2024년 11월 공개 표준으로 발표했고, 사양은 modelcontextprotocol.io에 공개돼 있다.
말로만 "자동으로 됩니다"라고 하면 마법처럼 들린다. 실제 한 번을 끝까지 따라가 보자. 아래는 프로젝트 A에서 협업툴 MCP를 붙인 직후, 내가 처음 돌려 본 세션을 거의 그대로 옮긴 것이다. LLM의 날것 출력을 요약 없이, 어색한 부분까지 둔다.
① 내가 친 프롬프트 (전문)
ClickUp에서 이번 주 마감인 미완료 P0 태스크를 마감 임박 순으로 정리해 줘.
각 항목에 담당자와 마감까지 남은 일수를 붙이고, 마감 지난 건 맨 위로 올려.
② Claude의 날것 출력 (그대로)
ClickUp 워크스페이스에서 P0·미완료·이번 주 마감 조건으로 조회했습니다.
3건이 잡혔습니다. (마감 지난 건을 맨 위로 올렸습니다.)
1. TASK_017 글로벌 쿨다운 코드 변경
담당: teammate_a / 마감: 어제 (−1일, 지남)
2. TASK_023 voice_lint v2 적용
담당: teammate_b / 마감: 내일 (+1일)
3. TASK_031 전투 로그 스키마 갱신
담당: teammate_c / 마감: 금요일 (+3일)
참고: TASK_009(밸런스 테이블 검수)도 P0로 보이는데,
status가 'in review'라 '미완료'에 넣어야 할지 애매했습니다.
포함하지 않았습니다 — 필요하면 기준을 알려 주세요.
여기서 LLM이 스스로 멈춘 지점에 주목하자. TASK_009는 'in review' 상태였고, 그게 "미완료"인지 LLM이 단정하지 않고 사람에게 되물었다. MCP는 데이터를 정확히 가져오지만, "in review를 미완료로 칠지"는 팀의 정의에 달린 문제다. 이걸 LLM이 임의로 판단했다면 그게 오히려 사고다.
③ 내가 한 검증·거부
나는 협업툴을 열어 TASK_017 카드 하나만 확인했다. 마감이 정말 지났다. 그런데 TASK_009는 우리 팀 기준으로 'in review'도 미완료에 넣는다. LLM의 분류가 우리 규칙과 달랐다. 그래서 거부하고 기준을 다시 줬다.
④ 재요청
우리 팀은 'in review'도 미완료로 친다. 그 기준으로 다시 정리해 줘.
앞으로도 'in review' = 미완료로 간주해.
이후 출력에서 TASK_009가 2번 자리에 들어왔다. 마지막 문장("앞으로도 ~ 간주해")은 이 세션에만 적용된다. 매번 같은 규칙을 반복해 주기 싫다면, 이 정의를 team_memory의 shared 슬롯에 atom으로 입력해 두면 다음 세션부터 LLM이 알아서 적용한다(§20.1·§20.2 참조).
이 한 번의 왕복이 보여 주는 건 분명하다. MCP는 정보를 정확히 가져오는 도구이지, 판단을 대신하는 도구가 아니다. 데이터는 자동이고 정의는 사람이 준다. 그 경계를 흐리는 순간 자동화는 사고로 바뀐다.
협업툴 조회 하나만으로는 "검색 좀 편해진 것" 이상이 안 된다. MCP가 협업 시스템이 되는 건, 도구 여러 개를 한 흐름으로 엮을 때다. 프로젝트 A에서 실제로 돌리는 다섯 패턴을 흐름으로 본다.
패턴 1 — 자동 보고서. 매일 아침 9시, 스케줄러가 트리거를 던지면 LLM이 협업툴 태스크 상태·git 커밋·대시보드 지표를 한꺼번에 조회해 일일 보고서를 쓴다. 발송처는 포탈 또는 팀 메신저다. 여기서 "포탈"은 §20.3에서 다룬 사내 포탈웹을 말한다. server.py(FastAPI)가 항상 떠 있고, Claude가 작성한 View_*.html이 그 위에서 동작하므로, 보고서도 포탈의 한 페이지로 자연스럽게 얹힌다.
패턴 2 — 결정 → 태스크 자동 생성. 결정 카드(proposal P####)를 등록하면, 카드의 implementation·verification 항목이 그대로 협업툴 태스크가 된다. 담당자와 마감까지 자동으로 입력된다. 회의에서 "그럼 이렇게 하기로"라고 정한 게, 손을 거치지 않고 보드의 카드로 떨어진다.
패턴 3 — 태스크 → 결정 카드 역참조. 패턴 2의 반대 방향이다. 협업툴 태스크가 완료되면 MCP가 원래 결정 카드의 execution_log를 갱신한다. "이 결정이 실제로 실행됐는가"가 자동으로 추적된다. 결정과 실행이 양방향으로 묶인다(그림의 점선).
패턴 4 — 진행률 분석. 분기 말, 그 분기의 모든 태스크를 한 번에 끌어와 지연 패턴을 분석한다. "어떤 종류의 태스크가 반복적으로 미뤄지는가"가 회고의 입력이 된다.
패턴 5 — 1:1 사전 자료. 1:1 미팅 5분 전, 해당 멤버의 협업툴 태스크·team_memory 슬롯·최근 활동을 합성해 사전 요약을 만든다. 1:1이 "지난번에 뭐 하고 있었죠?"로 5분을 까먹는 대신 본론부터 시작된다.
다섯 패턴의 공통점은 하나다. 사람 손에 남는 반복 작업을 줄이는 자리에서만 값이 회수된다. 멋있어 보이려고 붙이는 패턴은 운영 부담만 늘린다.
MCP 서버 다섯 개를 첫날에 다 연결하는 건 가장 흔한 실패다. 순서가 있다.
| 단계 | 무엇을 | 기간 감각 |
|---|---|---|
| 1 | MCP 서버 1개 설치 (ClickUp 또는 JIRA) | 1~2일 |
| 2 | 패턴 1개 시범 (자동 보고서) | 약 1주 |
| 3 | 5개 패턴 운영 | 1~2개월 |
| 4 | 자체 MCP 서버 개발 (특수 도구 필요 시) | 1~3개월 |
위 기간은 프로젝트 A의 중규모(10~50인) 팀 기준 저자 추정이며 미검증이다. 팀 규모·도구 친숙도에 따라 달라진다. 분명한 건 대부분의 팀은 1~3단계로 충분하다는 것이다. 4단계(자체 서버 개발)는 시중에 MCP 서버가 없는 특수 사내 도구를 꼭 붙여야 할 때만 간다. 거기까지 가지 않아도 효과의 대부분은 회수된다.
JIRA를 따로 언급할 이유가 있다. ClickUp이 팀 내부 보드라면, JIRA는 퍼블리셔·외주 같은 외부 조직과 공유하는 도구인 경우가 많다. JIRA MCP를 붙이면 외주 보드의 진행률을 매주 회의 전에 자동으로 끌어와, 지연 의심 태스크를 미리 추려 둘 수 있다. 회의가 "현황 공유"가 아니라 "결정"부터 시작된다. 단, 외부 공유 도구일수록 다음 절의 권한·유출 함정이 더 무겁게 걸린다.
MCP는 LLM에게 도구를 쥐여 주는 일이다. 손에 쥔 게 칼이면 베일 수도 있다.
함정 1 — 권한 사고. LLM이 write 권한까지 들고 있으면 의도하지 않은 변경이 일어난다. "정리해 줘"라는 한마디에 태스크 상태를 무더기로 바꿔 버리는 식이다. 처방은 명확하다. MCP 서버는 read-only로 시작한다. write는 패턴 2·3처럼 꼭 필요한 곳에만, 그것도 실행 전 확인 게이트를 두고 연다. 앞의 트랜스크립트에서 LLM이 'in review' 분류를 사람에게 되물은 것도 같은 정신이다 — 애매하면 멈춘다.
함정 2 — 데이터 유출. MCP로 끌어온 회사 데이터가 외부 LLM API로 전송된다. 밸런스 수치, 미공개 콘텐츠, 매출 지표가 그대로 흘러갈 수 있다. 처방은 민감 데이터에 한해 자체 호스팅 LLM을 쓰거나, MCP 서버 단에서 placeholder로 치환해 내보내는 것이다(이 책 전반의 IP 보호 원칙과 같다).
함정 3 — 의존성 폭증. MCP 서버 5개를 critical 경로에 물려 두면, 한 곳이 죽을 때 아침 보고서 전체가 멈춘다. 처방은 핵심 1~2개만 critical로 두고 나머지는 보조로 분리하는 것이다. 보조 서버가 죽으면 그 항목만 비고, 보고서 자체는 나온다.
함정 4 — API 비용 폭증. MCP 호출 한 번이 LLM 토큰과 외부 API 호출을 동시에 태운다. 자동 보고서를 5분마다 돌리면 비용이 조용히 불어난다. 처방은 호출 빈도에 cap을 걸고, 자주 바뀌지 않는 조회 결과는 캐싱하는 것이다.
네 함정을 한 줄로 묶으면, MCP의 안전한 자리는 "read-only로 시작해, 핵심만 critical로 두고, 민감 데이터는 거르고, 호출에 상한을 건다"이다.
프로젝트 A에서 MCP 운영 전후로 체감한 변화다. 아래 시간 수치는 저자의 경험적 추정(미검증)이며, 절대값이 아니라 방향과 비율로 읽기 바란다.
| 항목 | MCP 부재 | MCP 운영 | 방향 |
|---|---|---|---|
| 일일 보고 정보 종합 | 수동 30~60분 | 자동 ~5분 | 크게 단축 |
| 도구 간 정보 동기화 | 사람 수작업 | 자동 | 수작업 제거 |
| 1:1 사전 준비 | 10~15분 | 자동 요약 ~3분 | 단축 |
| 외주 진행률 파악 | 회의로만 | 실시간 조회 | 상시화 |
| 결정 ↔ 태스크 연결 | 수작업 | 양방향 자동 | 누락 방지 |
가장 큰 회수는 "정보를 모으는 시간"에서 난다. 결정·판단은 여전히 사람의 일이다. MCP가 줄여 주는 건 그 앞단, 흩어진 도구를 뒤져 한자리에 모으는 단순노동이다. 이 챕터 첫머리의 3초가 정확히 그 자리다.
20부는 팀 협업 시스템을 네 층으로 쌓았다.
| 장 | 핵심 |
|---|---|
| 20.1 | atom 운영 — 카테고리 분류, 분기 정리 |
| 20.2 | 팀원 메모리 — team_memory 5인 슬롯(leeminsoo·팀원 A/b/c·shared), 공유 vs 개인 분리 |
| 20.3 | 포탈웹 — server.py(FastAPI)·build_index.py·nginx·nssm으로 상시 가동, View_*.html 동작 |
| 20.4 | MCP — 5패턴·단계적 도입·권한 우선 |
네 장은 따로 노는 도구가 아니라 한 몸이다. 포탈(20.3)은 보고서가 얹히는 자리고, atom·메모리(20.1·20.2)는 LLM이 'in review = 미완료' 같은 팀 정의를 기억하는 자리며, MCP(20.4)는 그 모두를 협업툴·문서와 연결하는 배선이다. 어느 하나만 떼어 놓으면 나머지의 값도 절반으로 준다.
다음 부에서는 거버넌스와 운영을 다룬다. 도구를 이만큼 붙였을 때 따라오는 안전성·비용·저작권·윤리의 문제를, 이번 챕터의 함정들을 팀 차원의 규칙으로 끌어올려 정리한다.
setup 1. ClickUp MCP 서버(공식 또는 커뮤니티)를 설치하고, ClickUp API 토큰을 발급하세요. 2. Claude Code 설정에 MCP 서버를 등록하되 read-only 스코프로만 시작합니다. 3. 워크스페이스 ID를 확인해 조회 범위를 자기 보드로 제한합니다.
prompt
ClickUp에서 이번 주 마감인 미완료 P0 태스크를 마감 임박 순으로 정리해 줘.
담당자와 마감까지 남은 일수를 붙이고, 마감 지난 건 맨 위로.
verify
1. 출력된 태스크 중 1건을 ClickUp에서 직접 열어 마감·담당자가 맞는지 대조하세요.
2. LLM이 애매한 항목을 스스로 되물었는지 봅니다 — 되묻지 않고 단정했다면 분류 기준을 명시해 다시 시킵니다.
3. 자주 쓰는 정의('in review=미완료' 등)는 team_memory의 shared에 atom으로 입력해 다음 세션에 자동 적용되게 합니다.
팀도 협업툴도 없는 1인 개발자라면, MCP의 첫 대상은 GitHub와 로컬 문서입니다. GitHub MCP를 read-only로 붙여 "이번 주 안 닫힌 이슈, 오래된 순으로" 같은 조회부터 시작하고, 결정 카드 대신 로컬 decisions/ 폴더의 마크다운을 문서 MCP로 조회하세요. 자동 보고서(패턴 1)는 그대로 적용됩니다 — 발송처만 팀 메신저 대신 자기 메모 파일로 바꾸면 됩니다. 권한·비용 함정은 1인이어도 똑같이 걸리니, read-only 시작과 호출 cap은 처음부터 지키는 게 좋습니다.
금요일 저녁 6시 40분. 퇴근하려고 노트북을 닫으려는데, 그날 했던 작업이 어딘가 익숙했다. 데이터 시트의 enum 참조가 깨진 걸 찾아 고쳤는데, 분명 지난주에도 똑같은 걸 고쳤다. 그 전 주에도. 매번 같은 프롬프트를 다시 타이핑했고, 매번 Claude의 출력에서 같은 항목을 확인했다. 세 번째라는 걸 알아챈 건 노트북을 닫기 직전이었다.
이 "어딘가 익숙했다"는 감각이 이 책 전체에서 가장 중요한 순간이다. 이 감각을 흘려보내면 다음 주에 네 번째로 같은 작업을 반복한다. 이 감각을 붙잡아 한 줄로 적으면, 그 한 줄이 다음 주에 skill이 되고, 그 skill이 한 달 뒤 atom이 되어 자동으로 주입된다. 붙잡는 자리가 바로 회고다.
이 책의 다른 부들은 "이런 도구가 있다", "이런 패턴이 있다"를 다뤘다. 이 챕터는 그 모든 도구와 패턴이 어디서 발화되는지를 다룬다. 새 슬래시 명령은 어디서 만들어지고, 새 atom은 어떻게 박제되며, 한 달에 한 번도 안 쓰는 도구는 누가 솎아내는가. 답은 늘 같은 자리로 모인다. 회고에서.
회고는 거창한 의식이 아니다. 저자의 일간 회고 파일 하나를 그대로 열어 본다. 실제 경로는 retro/daily/YYYY-MM-DD.md이고, 한 달이 지나면 이 파일들이 주간(retro/weekly/YYYY-Www.md)으로, 다시 월간(retro/YYYY-MM.md)으로 압축된다. 규칙은 retro/README.md에 적혀 있다.
저자가 실제로 사용하는 흐름은 회고 파일을 직접 손으로 쓰는 것이 아니라, AI에게 그날의 작업 로그를 던지고 패턴을 뽑게 하는 것이다. 아래는 어느 금요일 저녁의 실제 왕복을 재구성한 것이다. 프롬프트 전문을 먼저 싣고, Claude의 날것 출력을 요약 없이 — 빗나간 부분까지 — 그대로 싣는다.
[프롬프트 — 사람이 입력]
오늘 한 작업을 회고로 정리해줘. 오늘 한 일: ① 캐릭터 데이터 시트에서 enum 참조 깨진 거 고침(grade 컬럼이 enum 시트랑 안 맞았음) ② NPC 대사 톤 통일 ③ relation-map 다시 돌림. 이 중에서 이번 주에 2번 이상 반복한 작업이 있으면 표시하고, 도구로 만들 만한 후보가 있으면 따로 빼줘. 없으면 없다고 해. 억지로 만들지 마.
[Claude 날것 출력 — 요약·각색 없음]
오늘 작업 회고입니다.
반복 패턴 감지 - ① enum 참조 깨짐 수정 → 이건 이번 주 화요일 회고에도 동일 항목이 있습니다. 2회 이상 반복으로 표시합니다. - ② NPC 대사 톤 통일 → 이번 주 첫 등장. 반복 아님. - ③ relation-map 재실행 → relation-map-gen skill을 이미 쓰고 계셔서, 이건 도구화가 끝난 작업입니다. 추가 후보 아님.
도구 후보 - enum 참조 검증을 skill로 만들 수 있을 것 같습니다. 데이터 시트의 컬럼값과 enum 시트를 대조해 불일치를 리포트하는 형태로요. - 다만 제가 두 시트의 정확한 구조를 모르기 때문에, 이게 정말 반복 가능한 패턴인지는 확신하기 어렵습니다. 화요일에 고친 깨짐과 오늘 깨짐이 같은 종류의 깨짐인지 확인이 필요합니다. 같은 컬럼에서 같은 이유로 깨졌다면 강력한 skill 후보이고, 매번 다른 컬럼이 다른 이유로 깨진 거라면 skill로 묶기 어렵습니다.
여기서 마지막 문단이 핵심이다. AI는 "skill을 만들자"고 단정하지 않았다. 같은 종류의 깨짐인지 사람이 확인하라고 되물었다. 이게 회고가 자동이 아닌 이유다.
[사람의 검증 — AI 제안을 받되 거름]
저자는 화요일 회고를 열어 대조했다. 화요일 깨짐은 grade 컬럼, 오늘 깨짐도 grade 컬럼. 같은 종류였다. AI의 후보 제안이 검증을 통과했다. 그래서 일간 회고에 한 줄을 남긴다.
반복 작업: enum-grade 참조 검증 (화·금 2회) → skill 후보. 다음 주간 회고에서 승격 판단.
이 한 줄이 전부다. 5분이 안 걸렸다. 그리고 이 한 줄이 self-improving 루프의 첫 마디다. 만약 AI가 "이미 도구화 끝났다"고 짚어 준 ③번까지 후보로 올렸다면, 한 달 뒤 안 쓰는 중복 도구가 하나 더 떠다녔을 것이다. AI의 거름질과 사람의 거름질이 둘 다 작동한 결과, 진짜 후보 하나만 남았다.
어느 작업이 반복되는지, 어느 도구가 자주 쓰이는지, 어느 atom이 부족한지는 한 번의 작업으로는 보이지 않는다. 위 트랜스크립트에서 enum 깨짐이 후보로 떠오른 건 "오늘"이 아니라 "화요일과 오늘"을 겹쳐 봤기 때문이다. 1주·1개월·1분기 누적된 흔적을 겹쳐야 패턴이 떠오른다. 회고는 그 겹침을 의도적으로 만드는 시간이다.
회고에서 발견된 패턴은 두 갈래로 갈린다.
이 갈림의 판단을 작업 중간에는 못 한다. 작업 흐름이 끊기기 때문이다. enum 깨짐을 고치던 그 순간에는 "이게 세 번째인가?"를 따질 여유가 없다. 별도로 떼어 둔 회고 시간이 그 판단의 자리다.
도구가 만들어진 뒤 정말 가치를 내는지도 같은 자리에서 측정한다. 한 달에 한 번 쓰이는 도구와 한 시간을 줄여 주는 도구의 가치는 다르다. 측정도, 폐기 결정도 회고에서 한다. 회고가 없으면 도구는 누적만 되고 정리는 되지 않는다. 몇 년이 지나면 안 쓰는 도구 수십 개가 검색과 운영을 방해한다.
서랍에 비유하면, 회고는 책상 서랍을 주기적으로 비우는 시간이다. 매일 쓰는 펜과 1년에 한 번도 안 꺼낸 메모지가 한 칸에 섞여 있으면, 매번 펜을 찾는 데 몇 초가 더 든다. 도구도 똑같다.
저자의 회고는 세 층위로 굴러간다. 일간이 패턴의 씨앗을 모으고, 주간이 씨앗을 묶어 도구 후보로 압축하며, 월간이 도구의 경제성을 평가해 자산으로 박제하거나 폐기한다. 각 층위는 아래 층위의 출력을 입력으로 받는다.
flowchart TD
W["작업 수행
(데이터 시트·기획서·자동화)"] -->|자료 누적| D["일간 회고
retro/daily/*.md
5~10분 · 패턴 씨앗 1~3건"]
D -->|일간 5건 압축| WK["주간 회고
retro/weekly/*.md
30~60분 · 도구 후보 판정"]
WK -->|주간 4건 종합| M["월간 회고
retro/YYYY-MM.md
1.5~2시간 · 경제성·폐기"]
M -->|검증된 패턴 승격| A["영구 자산화
feedback.md / workflows.md
+ atom 등록"]
A -->|JIT hook 자동 주입| W
style D fill:#e3f2fd,stroke:#1565c0
style WK fill:#e8f5e9,stroke:#2e7d32
style M fill:#fff3e0,stroke:#ef6c00
style A fill:#f3e5f5,stroke:#6a1b9a
마지막 화살표가 루프를 닫는다. 영구 자산화된 패턴은 JIT(Just-In-Time) hook을 통해 다음 작업에 자동으로 주입된다. 저자의 환경에서는 inject_memory.py라는 hook이 사용자 입력을 받을 때마다 관련 atom을 골라 넣는다. enum-grade 검증이 atom으로 박제되면, 다음에 "데이터 시트 검증"류의 입력을 했을 때 그 atom이 알아서 따라온다. 사람이 매번 "참, 그 검증 규칙이 있었지"를 기억할 필요가 없어진다.
회고가 빠지면 위에서 아래로 가는 화살표만 남고, 자산이 작업으로 되돌아오는 마지막 화살표가 끊긴다. 루프가 닫히지 않는다. 자가개선(self-improving)이라는 말의 의미가 바로 이 루프가 돌고 있다는 것이다. 도구가 도구 자신을 개선하고, atom이 atom을 늘린다. 그 동력은 사람이 떼어 둔 회고 한 시간이다.
저자가 운영하는 어느 MMORPG 프로젝트의 회고를 약 반년 굴린 인상으로는, 회고 한 번에서 다음 다섯 종류의 산출이 발화된다. 아래 빈도는 정밀 통계가 아니라 저자의 운영 체감이며(저자 추정·미검증), 매 회고가 다섯을 모두 만드는 것은 아니다. 분기 단위로 보면 다섯이 모두 한 번씩은 나온다.
다섯 종류를 풀어 쓰면 이렇다. 이번 주에 같은 결정을 두 번 이상 반복했다면 새 atom 후보다. 같은 프롬프트 패턴을 일주일에 여러 번 다시 입력했다면 새 skill 후보다(앞 절의 enum-grade 검증이 이 경우였다). 이번 주에 쓴 skill 중 결과가 시원찮았던 것이 있으면 기존 skill 개선 — 프롬프트 조정·검증 추가·입력 표준화. 지난 분기에 만든 atom 중 한 달간 매칭이 0회였던 것은 폐기 후보다. 안 쓰면 토큰만 차지한다. 마지막으로 경제성 평가는 도구별로 사용 빈도와 절약되는 손품을 견줘 유지·개선·폐기를 정하는 일이다.
이 다섯이 한 화면에 어떻게 배치되는지를 매트릭스로 보면 이렇다. 가로축은 "반복되는가", 세로축은 "가치 있는가"다.
회고가 하는 일은 결국 이 사분면에 그 주의 작업들을 흩뿌리는 것이다. 오른쪽 위에 떨어진 것은 도구가 되고, 오른쪽 아래에 떨어진 것은 솎인다. 이 분류가 self-improving의 실제 작동 방식이다.
앞 절의 enum-grade 검증 후보가 skill로 승격되고, 거기서 다시 atom으로 박제되는 과정을 마저 따라가 본다. atom은 회고에서 거칠게 발견된 패턴이 검증을 거쳐 영구 자산이 된 형태다.
저자의 메모리에는 이미 그렇게 박제된 atom들이 있다. 그중 하나가 retro_atom_natural_invitation이다. 이름 그대로 "회고에서 atom은 명령이 아니라 자연스러운 초대로 등장한다"는 원칙을 담은 atom이다. 이 atom 자체가 회고를 여러 번 굴리며 발견된 메타 패턴이다 — 회고 도중 "이건 atom으로 남겨야 해"라고 강박적으로 박제를 강요하면 오히려 회고가 형식이 되어 버린다는 걸 여러 번 겪고 나서야 한 줄로 굳어졌다.
박제가 진짜 효과를 내는지는 점수로도 관리된다. 저자 환경에는 atom_score.py라는 스크립트가 있어, 각 atom이 실제로 얼마나 매칭되고 쓰이는지를 채점한다. 결과는 _scores_latest.json에 저장되고, 점수가 일정 수준을 넘는 atom은 CLAUDE.md에 자동으로 주입된다. 즉 잘 쓰이는 atom일수록 더 자주 눈앞에 떠오르고, 안 쓰이는 atom은 점수가 깎여 폐기 후보로 흘러간다. 이 채점-주입 사이클이 §1.4의 사분면을 자동화한 부분이다.
여기서 한 가지 정직하게 짚어야 할 것이 있다. 이 점수가 "한 달에 30시간을 아꼈다" 같은 정량 지표로 곧장 환산되지는 않는다. atom이 절약하는 시간은 측정하기 까다롭다. 그러니 ROI(Return on Investment, 투자 대비 효과)를 숫자로 단정하기보다, "잘 쓰이는 atom은 점수가 높고, 점수 높은 atom은 더 자주 주입되어 손품을 줄인다"는 방향과 비율로만 말하는 편이 정직하다 — 배수가 아니라 방향.
회고를 떼어 두지 않은 팀의 흔한 풍경은 이렇다.
이 풍경은 회고 한 시간이면 거의 사라진다. §1.1의 금요일 저녁을 떠올려 보면, "어딘가 익숙했다"를 한 줄로 적느냐 흘려보내느냐의 차이일 뿐이다. 적는 데 5분, 안 적어서 잃는 시간은 네 번째·다섯 번째 반복으로 누적된다. 시간을 안 들여 더 큰 시간을 잃는 전형이다.
물론 처음부터 거창한 회고 시스템을 세울 필요는 없다. 큰 팀에서 일간 5분 회고부터 시작하는 게 답답하게 느껴질 수도 있다. 그렇다고 처음부터 일·주·월 3층 시스템을 한 번에 깔면 형식만 따라가고 본질을 놓치기 쉽다. 가장 작은 단계에서 회고의 가치를 직접 체감한 사람이 다음 단계로 끌어올리는 순서가 안전하다.
이 책에서 만나는 모든 도구·atom·패턴은 결국 누군가의 회고에서 발화된 것이다. 이 책 자체가 저자가 반년간 쌓은 회고 누적의 산물이라고 해도 과언이 아니다. 회고가 시작점이라는 말은 비유가 아니라, 이 책의 목차 그 자체가 회고에서 나왔다는 사실을 가리킨다.
게임 밖 적용. "어딘가 익숙했다 — 이거 지난주에도 했는데"라는 감각을 한 줄로 붙잡는 회고는, 게임 개발이 아니라 반복 업무가 있는 어느 직장에서나 자가개선의 입구입니다. 하루를 닫을 때 "오늘 같은 일을 두 번 손으로 한 게 무엇인가" 한 줄만 적고 금요일에 그 주의 다섯 줄을 겹쳐 보면, 매일은 숨기던 패턴이 한 주 단위에서 드러납니다. 예를 들어 총무 담당자가 "같은 양식의 메일을 매주 다시 타이핑한다"를 회고에서 잡으면, 그 한 줄이 다음 주 메일 템플릿이 되고 한 달 뒤 자동 발송 규칙이 됩니다. 핵심은 도구의 정교함이 아니라 흔적을 겹쳐 보는 행위 자체, 그리고 AI에게 후보를 뽑게 하되 "억지로 만들지 말고 이미 자동화된 건 빼라"는 거름망을 거는 것입니다.
회고 루프를 처음 도입하는 가장 작은 버전입니다. 도구를 거의 깔지 않고 시작할 수 있습니다.
setup
retro/daily/ 폴더 하나를 만듭니다.retro/daily/2026-06-06.md를 엽니다. 그 이상의 준비물은 없습니다.prompt
매일 작업을 마칠 때, 그날의 작업 로그를 AI에게 주고 아래처럼 묻습니다.
오늘 한 일은 [작업 1·2·3]입니다. 이 중 이번 주에 2번 이상 반복한 작업이 있으면 표시하고, 도구(skill)로 만들 만한 반복 패턴이 있으면 따로 빼 주세요. 이미 도구화가 끝난 작업은 후보에서 빼 주세요. 없으면 없다고 해 주세요. 억지로 만들지 마세요.
마지막 두 문장("이미 도구화된 것은 빼라", "억지로 만들지 마라")이 거름망입니다. 이게 없으면 AI가 매번 그럴듯한 후보를 과잉 생성해, 한 달 뒤 안 쓰는 도구가 쌓입니다.
verify
grade 컬럼 대조처럼). 같은 종류면 후보 확정, 다르면 폐기.반복: [작업] (N회) → skill 후보, 주간 회고에서 판단.1인 축소판
팀도, 별도 도구도 없이 혼자 시작한다면 이렇게 줄입니다.
핵심은 도구의 정교함이 아니라 겹쳐 보는 행위입니다. 하루는 패턴을 숨기고, 한 주는 패턴을 드러냅니다. 그 드러남을 붙잡는 5분이 자가개선 루프의 입구입니다.
월요일 아침, 지난주 일간 회고 다섯 개를 한 화면에 띄워 놓고 한 주를 시작하려던 참이었다. 화요일 회고에 "데이터 시트 export 하기 전에 정합성 검사를 깜빡함"이라고 적혀 있었다. 목요일 회고에도 거의 같은 문장이 있었다. 그리고 바로 그 월요일 오전, 나는 또 같은 일을 하고 있었다. FK가 깨진 시트를 그대로 클라/서버 빌드에 올렸다가 다시 빼냈다. 세 번째였다.
이 순간이 회고 시스템의 핵심이다. 같은 일을 세 번째 하고 있다는 사실은, 그 일을 하는 동안에는 절대 보이지 않는다. 손은 익숙하게 움직이고 머리는 "이거 원래 내가 하던 일"이라고 속삭이기 때문이다. 반복은 오직 흔적을 모아 놓고 뒤에서 봐야 보인다. 회고는 그 흔적을 모으는 장치이고, atom 승격은 거기서 발견한 반복을 다시는 손으로 안 하도록 박제하는 장치다.
이 장은 그 두 장치가 어떻게 맞물려 돌아가는지, 실제 일간 회고 파일 한 장이 어떻게 JIT manifest의 atom 한 줄로 바뀌는지를 끝까지 따라간다.
먼저 짚어야 할 전제가 있다. 반복은 실시간으로 인지되지 않는다.
게임 기획자의 하루는 결정의 연속이다. 데이터 시트 컬럼 하나를 어떤 enum으로 둘지, 스킬 쿨다운을 초 단위로 둘지 프레임 단위로 둘지, 회의에서 나온 애매한 합의를 문서 어디에 적을지. 이런 결정 하나하나는 너무 작아서 기억에 남지 않는다. 그런데 같은 결정을 한 주에 세 번 내리고 있으면, 그건 더 이상 결정이 아니라 규칙이다. 규칙인데 매번 새로 내리고 있다면 그건 낭비다.
문제는 이 낭비가 보이지 않는다는 것이다. 그래서 흔적을 남긴다. 매일 5분, 오늘 한 일과 오늘 두 번 이상 반복한 것을 한 줄씩 적는다. 일주일이 지나면 다섯 장의 흔적이 쌓이고, 그제야 "어, 이거 세 번 적혀 있네"가 보인다.
이것이 회고를 단순한 일기와 가르는 지점이다. 일기는 감상을 적고, 회고는 패턴을 추출하기 위해 흔적을 적는다. 그래서 회고는 형식이 고정되어야 한다. 형식이 매번 다르면 다섯 장을 나란히 놓고 비교할 수 없고, 비교할 수 없으면 패턴이 안 보인다.
회고를 일·주·월 세 주기로 나누는 이유는 시간이 흘러서가 아니다. 각 주기가 하는 일이 근본적으로 다르기 때문이다.
일간은 흔적을 박제한다. 판단하지 않고 그냥 적는다. 주간은 흔적 다섯 장을 묶어 패턴을 본다. 여기서 처음으로 "이건 반복이다"라는 판단이 들어간다. 월간은 누적된 도구 전체를 보고 경제성을 평가한다. 무엇을 살리고 무엇을 버릴지 결정한다.
한 주기가 빠지면 나머지가 무너진다. 일간 없이 주간만 하면 일주일 전 일이 기억나지 않아 흔적이 텅 빈다. 주간 없이 월간만 하면 한 달치 일간을 한 번에 보게 되는데, 22장을 한 자리에서 비교하는 건 불가능에 가깝다. 패턴은 안 보이고 피로만 쌓인다.
작업장 비유가 잘 들어맞는다. 일간은 매일 저녁 책상을 정리하는 5분이다. 주간은 주말에 서랍 한 칸을 다시 짜는 30분이다. 월간은 분기마다 작업장 전체 동선을 살피는 두 시간이다. 매일 책상을 안 치우면 주말에 서랍을 짤 수가 없고, 서랍이 엉망이면 동선을 봐도 답이 안 나온다.
실제로 내가 쓰는 일간 회고 파일은 retro/daily/2026-05-30.md 같은 경로에 날짜별로 쌓인다. 템플릿은 /retro 슬래시 명령이 자동으로 깔아 준다.
# 일간 회고 2026-05-30
## 오늘 한 일 (3~5줄)
- 신규 스킬 데이터 시트에 enum 12종 추가 + 쿨다운 컬럼 재정렬
- 밸런스 시뮬 1차 패스 (드랍 테이블 가중치 조정)
- 데이터 export 빌드 클라/서버 동시 갱신
## 반복 발견 (있으면)
- 데이터 export 빌드 전에 정합성 검사를 또 깜빡함 → FK 깨진 채 빌드 → 3회째
- 밸런스 시뮬을 시드 고정 없이 돌려 재현이 안 됨 (두 번째)
## 폐기 후보
- 오늘 한 번도 안 쓴 도구: (월 누적 측정용으로만 기록)
## 다음 세션 인계
- 깨진 FK 두 건 (스킬→이펙트 참조) 먼저 메우고 재빌드
- 후보: 시뮬 시드 고정 옵션 기본값화 검토
5분이면 채워진다. 형식이 고정돼 있어서 매번 "뭘 적지"를 새로 고민하지 않는다. 칸이 정해져 있으니 칸을 채우기만 하면 된다.
여기서 결정적인 건 "반복 발견" 칸이다. 이 칸은 비어 있어도 된다. 대부분의 날은 비어 있다. 그런데 오늘 같은 일을 두 번 했다는 자각이 들면 한 줄 적는다. 위 예시의 "데이터 export 빌드 전에 정합성 검사를 또 깜빡함 → 3회째"가 바로 그것이다. 이 한 줄이 며칠 뒤 주간 회고에서 패턴으로 묶이고, 다시 몇 주 뒤 atom이나 skill로 박제된다.
자동 캡처가 사람 손을 줄여 준다. git 커밋 로그, atom 변경 이력, skill 사용 로그가 일간 회고에 자동으로 합쳐지면 "오늘 한 일" 칸의 절반은 이미 채워져 있다. 사람은 git 로그가 못 보는 것 — "이거 또 했네"라는 자각 — 만 추가하면 된다.
마지막 "다음 세션 인계" 칸은 내일의 나에게 보내는 쪽지다. 이 칸이 있으면 새 세션 시작 시 컨텍스트 로딩이 1~2분 안에 끝난다. 없으면 "어제 내가 뭘 하다 말았더라"를 더듬느라 시간이 더 든다. 실제로 내 MEMORY.md에는 "다음 세션 우선 확인" 항목이 별도로 유지되는데, 이게 바로 일간 인계가 누적된 상위 버전이다.
주간 회고는 일간 다섯 장을 한 화면에 올려 놓고 시작한다. 파일은 retro/weekly/2026-W21.md 같은 경로에 쌓인다.
# 주간 회고 2026-W22 (5/25~5/31)
## 이번 주 한 일 요약
- 스킬/밸런스 데이터 시트 갱신, 드랍 테이블 시뮬 2회
- 데이터 export 빌드 4회 (그중 2회 FK·enum 깨진 채 빌드)
## 패턴 발견
- 일간 3건에서 "export 전 정합성 검사 깜빡" 반복 → atom 후보
- 일간 2건에서 "밸런스 시뮬 시드 미고정" 반복 → 시뮬 기본값 검토
## atom 후보
- pending-data-check-before-export (export 빌드 전 정합성 검증을 강제하는 규칙)
## skill 후보
- (없음 — 이번 주는 atom으로 충분)
## 기존 도구 점검
- 미사용: relation-map-gen (이번 주 0회)
- 최다 사용: check(정합성 cascade), excel-reader, /retro
## 다음 주 계획
- pending-data-check-before-export 1주 더 운영 후 승격 판단
여기서 처음으로 판단이 들어간다. "일간 3건에서 export 전 검사 깜빡 반복"은 산술적 사실이지만, "이건 atom으로 박제할 가치가 있다"는 판단이다. 3회 반복을 기준선으로 삼는 이유는 단순하다. 한 번은 우연, 두 번은 우연일 수도, 세 번은 패턴이다.
판단이 서면 곧바로 박제한다. 단, 정식 atom이 아니라 pending- 접두어를 붙인 임시 atom으로. 내 프로젝트 메모리 폴더에 이렇게 떨어진다.
~/.claude/projects/<project>/memory/
pending-data-check-before-export.md
pending- 접두어는 "이건 아직 검증 중"이라는 표식이다. 이 표식이 중요한 이유는, 검증을 안 거친 직감을 곧바로 팀 전체 규칙으로 만들면 두 가지가 망가지기 때문이다. 하나는 신뢰 — 검증 안 된 규칙이 자꾸 틀리면 사람들이 규칙 자체를 안 믿게 된다. 다른 하나는 누적 — 검증 게이트가 없으면 직감이 그대로 쌓여 메모리가 쓰레기통이 된다.
그래서 pending-은 일주일에서 길게는 한 달, 실제 작업 속에서 운영해 본다. 진짜로 매번 유용하면 살아남고, 한 번도 안 맞으면 조용히 지워진다.
월간 회고는 한 달치 누적을 펼쳐 놓고 도구 전체의 건강 상태를 점검하는 자리다. 파일은 retro/2026-05.md처럼 월 단위로 쌓인다.
# 월간 회고 2026-05
## 이번 달 누적
- 일간 회고: 22건, 주간 회고: 4건
- 새 atom: 4개 (data-check-before-export, sim-seed-pinning 외)
- 새 skill: 1개 (relation-map-gen 옵션 보강)
- 폐기 atom: 1개
## 도구 경제성 평가
- skill별 월 사용 횟수 + 절약 체감 (정성)
- 월 1회 미만 사용 skill → 폐기 후보
- 가장 가치 큰 도구: check(정합성 cascade), excel-reader, /retro
## atom 분포
- prefix별 누적 (data: X, sim: Y, meeting: Z ...)
- 폐기 후보: 한 달 매칭 0회인 atom
## 분기 계획
- 다음 달 도입: impact(영향도 추적), schema-doc 자동 갱신
## 책 집필 자료 (해당 시)
- 이번 달 사례 중 책에 인용할 만한 것: atom 승격 워크드 1건
월간의 핵심은 경제성 평가다. 도구는 만들 때는 다 가치 있어 보이지만, 한 달 지나면 절반은 손이 안 간다. 그걸 가려내는 다섯 가지 잣대를 쓴다.
평가 기준은 사용 빈도, 시간 절약, 인지 부담, 유지 비용, 대체 가능성 다섯이다. 사용 빈도는 월 1회 이상이면 일단 살리고 미만이면 폐기 후보로 넘긴다. 시간 절약은 회당 절약 체감에 빈도를 곱해 본다 — 여기서 분 단위 숫자를 단정하지는 않는다. "한 번에 몇 분 아끼는 느낌이고 한 달에 열 번 쓰니 누적이 크다"는 정도의 정성 판단이 정직하다. 인지 부담은 외워야 할 슬래시 명령이 열두 개를 넘으면 정리 신호로 본다. 사람이 머리에 이고 다닐 수 있는 명령 수에는 한계가 있다. 유지 비용은 데이터 시트가 바뀔 때 같이 손봐야 하는 도구인지를 본다. 대체 가능성은 더 간단한 방법이 새로 생겼는지를 본다.
다섯 잣대를 합쳐 살릴지, 버릴지, 고칠지를 정한다. 한 시간이면 서른에서 쉰 개 도구를 훑을 수 있다. 다만 시작한 지 한두 달은 도구가 적어 평가에 한 시간이 안 든다. 그 시기엔 평가보다 새 도구를 발화하는 데 시간을 더 쓰는 게 맞다. 경제성 평가는 도구가 쌓인 다음에야 본격적으로 일하는 안전장치다.
여기서부터가 이 장의 척추다. 위에서 흩어져 나온 조각들이 실제로 한 줄에서 시작해 전 세션 자동 주입까지 가는 과정을, 회고 시점부터 끝까지 따라간다. 중간에 내가 거부한 순간과 AI가 애매하게 답한 순간을 그대로 둔다.
1단계 — 일간에서 자각. 5월 30일 일간 회고를 쓰다가 "반복 발견" 칸에 한 줄을 적었다.
- 데이터 export 빌드 전에 정합성 검사를 또 깜빡함 → FK 깨진 채 빌드 → 3회째
이때는 아무 도구도 만들지 않았다. 그냥 흔적만 남겼다.
2단계 — 주간에서 패턴 확인하고 AI에게 atom화를 요청. 며칠 뒤 주간 회고에서 일간 다섯 장을 펼쳤더니 같은 줄이 세 군데 있었다. 그래서 Claude에게 던진 프롬프트 전문은 이랬다.
이번 주 일간 회고 3건에서 "데이터 export 빌드 전에 정합성 검사를 손으로 깜빡한다"가 반복됐어. 이걸 pending atom으로 만들려고 해. atom 본문 초안을 써 줘. 형식은 우리 메모리의 다른 atom들처럼 frontmatter(name, status, trigger)와 한 문단 규칙으로.
Claude의 날것 출력은 이랬다(요약 없이 그대로).
--- name: data-check-before-export status: pending trigger: ["데이터", "export", "빌드 전", "정합성"] --- 게임 데이터 시트를 클라/서버 export 빌드하기 전에는 항상 정합성 검사 (check cascade)를 먼저 돌려 FK·enum·참조 위반 0건을 확인한 뒤에만 export 한다. 위반이 발견되면 export를 중단하고, 실패 내역을 ClickUp 알림으로 담당자에게 통지한다.이 atom을
pending-data-check-before-export.md로 저장하면 됩니다. 다만 trigger 키워드 중 "데이터"는 너무 광범위해서 export와 무관한 상황(데이터 시트 열람, 데이터 회의 등)에도 매칭될 수 있습니다. 빼는 걸 권합니다.
3단계 — 사람의 검증과 부분 거부. AI가 스스로 trigger 하나를 의심한 건 맞는 지적이었다. 하지만 나는 한 걸음 더 갔다. 규칙 본문에 "정합성 검증"과 "실패 시 ClickUp 알림"을 둘 다 묶어 놓은 게 마음에 안 들었다. 실패 알림은 이미 integrity_check_clickup_notify가 담당하는 별개 atom이다. 한 atom에 두 책임을 묶으면 나중에 알림 규칙만 바꾸고 싶을 때 검증 규칙까지 건드리게 된다. 그래서 거부하고 다시 요청했다.
좋아, "데이터" trigger는 빼자. 그리고 실패 시 ClickUp 알림은 이미 integrity_check_clickup_notify가 하니까 이 atom에서 빼. 이 atom은 export 전 정합성 검증만 책임지게 좁혀 줘.
재요청 후 Claude가 좁힌 본문은 이랬다.
--- name: data-check-before-export status: pending trigger: ["데이터 빌드", "export", "빌드 전 검증"] --- 게임 데이터 시트를 클라/서버 export 빌드하기 전에는 정합성 검사 (check cascade)를 먼저 돌린다. 검사 대상: FK 참조 무결성, enum 값 유효성, 테이블 간 참조 정합성. 위반 0건을 확인한 뒤에만 export 한다. 실패 시 알림은 integrity_check_clickup_notify의 책임이며 이 규칙은 관여하지 않는다.
이게 내가 받아들인 버전이다. 한 책임만 지고, trigger도 export 맥락에만 좁혀졌다. 이 파일을 pending-data-check-before-export.md로 저장했다. 여기서 핵심은 AI의 첫 출력을 그대로 쓰지 않았다는 점이다. AI는 형식을 갖춰 주고 trigger 하나를 의심해 줬지만, "책임을 하나로 좁힌다"는 설계 판단은 사람이 했다.
4단계 — 1주 운영 검증. 다음 한 주 동안 데이터 export 빌드를 할 때마다 이 pending atom이 떠올랐고, 실제로 두 번 빌드 직전에 enum 깨짐을 잡아냈다. 안 맞은 적은 없었다. 살아남을 자격이 생겼다.
5단계 — 월간에서 승격 결정과 score 측정. 월간 회고에서 이 pending atom을 승격 후보에 올렸다. 승격 여부는 직감이 아니라 측정으로 판단한다. 내 환경에는 atom의 매칭 빈도와 유용성을 점수화하는 스크립트가 있다.
python ~/.claude/scripts/atom_score.py
# → ~/.claude/projects/<project>/memory/_scores_latest.json 갱신
이 스크립트는 각 atom이 지난 기간 동안 몇 번 trigger에 매칭됐고 그때 실제로 작업에 인용됐는지를 집계해 _scores_latest.json에 떨어뜨린다. 그 점수가 일정 기준을 넘는 atom은 CLAUDE.md에 자동 주입되도록 연결돼 있다. pending-data-check-before-export는 2026년 5월 실측 기준으로 한 주 동안 export 빌드마다 매칭됐으니 점수가 충분했다. 승격 확정.
6단계 — pending- 제거, JIT manifest 등록. 접두어를 떼고 정식 atom으로 바꾼 뒤 JIT manifest에 한 줄을 추가했다.
~/.claude/projects/<project>/memory/_jit_manifest.json
이 manifest는 UserPromptSubmit 훅(~/.claude/hooks/inject_memory.py)이 매 입력마다 읽는다. 입력에 "데이터 빌드"나 "export"가 들어 있으면 이 atom 본문이 컨텍스트에 자동으로 끼어든다.
7단계 — 루프가 닫힌다. 그다음 빌드를 하려고 "데이터 export 빌드 돌려줘"라고 입력한 순간, 내가 아무것도 시키지 않았는데 Claude가 먼저 말했다.
export 전에 정합성 검사(check cascade)부터 돌릴까요? FK 참조·enum 값·테이블 참조 정합성을 검사해 위반 0건을 확인한 뒤 export 하겠습니다.
3주 전 일간 회고에 적은 "이거 또 했네" 한 줄이, 지금의 작업을 알아서 막아 주는 규칙이 되어 돌아온 것이다. 손으로 했던 검증을 다시는 손으로 안 하게 됐다. 루프가 닫혔다는 건 바로 이 장면을 말한다.
위 워크드 트랜스크립트를 한 장의 흐름도로 압축하면 이렇다. 발견은 일간에서 일어나고, 검증은 운영 기간이 하고, 승격은 측정이 정하고, 자산화는 manifest가 마무리한다.
flowchart TD
A["일간 회고
'이거 또 했네' 한 줄"] --> B["주간 회고
5장 묶어 패턴 확인 (3회+)"]
B --> C["AI에 atom화 요청
→ 날것 출력 → 사람 검증·거부 → 재요청"]
C --> D["pending- atom 박제
~/.claude/.../memory/pending-*.md"]
D --> E["1~4주 실작업 운영 검증"]
E -->|한 번도 안 맞음| X["조용히 폐기"]
E -->|매번 유용| F["atom_score.py 측정
_scores_latest.json"]
F --> G["월간 회고에서 승격 결정
pending- 제거"]
G --> H["JIT manifest 등록
_jit_manifest.json"]
H --> I["UserPromptSubmit 훅
inject_memory.py 자동 주입"]
I --> J["다음 세션: 키워드 입력 시
과거 발견이 현재 작업을 막아줌"]
J -.회고에서 또 발견.-> A
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
classDef fail fill:#fee2e2,stroke:#dc2626,color:#7f1d1d;
class F,I code;
class A,B,G human;
class D,H data;
class J pass;
class X fail;
마지막 점선이 이 그림의 전부다. 자동 주입된 atom이 또 새로운 반복을 드러내고, 그게 다시 회고로 들어가 다음 atom을 낳는다. 한 바퀴 돌 때마다 손으로 할 일이 하나씩 줄어든다. 이 루프가 반년에서 1년 누적되면, 회고는 더 이상 일기가 아니라 작업 시스템의 두뇌가 된다.
승격 루프에서 가장 깨지기 쉬운 고리는 1단계, "이거 또 했네"를 적는 그 순간이다. 바쁠 때 사람은 회고 칸을 비워 두고 넘긴다. 그러면 흔적이 안 남고, 흔적이 없으면 주간에서 패턴이 안 보이고, 패턴이 없으면 atom이 안 태어난다. 루프의 입구가 막히는 것이다.
그래서 내 환경에는 retro_atom_natural_invitation이라는 atom이 하나 있다. 회고를 쓸 때 atom 발화를 의무로 강제하지 말고 자연스러운 초대로 두라는 규칙이다. 즉 "오늘 반드시 atom 후보 하나를 뽑아라"가 아니라, 회고 템플릿의 "반복 발견" 칸을 비워도 되는 칸으로 두되 거기 한 줄 적을 만한 게 있으면 가볍게 적도록 유도한다. 의무로 만들면 억지로 가짜 패턴을 짜내게 되고, 초대로 두면 진짜 반복만 자연스럽게 걸린다.
이 한 끗 차이가 루프의 지속 가능성을 가른다. 의무화된 회고는 두 주를 못 넘기고 형식적인 거짓말로 채워진다. 초대형 회고는 적을 게 없는 날엔 빈칸으로 두니 부담이 없고, 그래서 오래간다. 오래가야 흔적이 쌓이고, 흔적이 쌓여야 패턴이 보인다.
이 atom 자체도 회고에서 태어났다. 회고를 의무로 운영하다 며칠 만에 칸이 가짜로 채워지는 걸 일간에서 발견했고, 그 발견이 주간을 거쳐 이 규칙으로 승격됐다. 회고를 개선하는 규칙이 회고에서 나온 셈이다.
루프를 굴려 보면 같은 자리에서 자꾸 무너진다.
회고를 거르는 게 가장 흔하다. 바쁘다고 사흘 거르면 그 사흘의 흔적이 영영 사라진다. 막는 법은 단순하다 — 다른 칸은 다 비워도 좋으니 "오늘 한 일" 한 줄만은 적는다. 5분이 아니라 1분이어도 흔적은 남는다.
형식을 매번 새로 짜는 것도 위험하다. 자유 형식으로 적으면 다섯 장을 나란히 놓고 비교할 수가 없다. 비교가 안 되면 패턴 추출이라는 주간의 일 자체가 불가능해진다. 그래서 /retro가 템플릿을 강제로 깔아 준다.
폐기를 안 하는 것도 함정이다. atom과 skill을 늘리기만 하고 버리지 않으면 인지 부담이 누적된다. 슬래시 명령이 열두 개를 넘는 순간부터 머리가 도구를 다 못 외운다. 월간의 경제성 평가가 이 누적을 막는 유일한 장치다.
승격 게이트를 건너뛰는 것도 위험하다. 직감을 곧바로 정식 atom으로 만들면 검증 안 된 규칙이 쌓인다. pending-을 거치고 측정으로 승격하는 게이트가 발견과 자산화 사이에 반드시 있어야 한다.
마지막으로, 1인 작업이라 팀 공유가 없다고 회고를 안 하는 건 오해다. 위 워크드 트랜스크립트 전체가 1인 환경에서 돌아간 사례다. 팀 공유 머지 단계만 빠질 뿐, 발견→pending→측정→승격→JIT 주입 루프는 혼자서도 그대로 돈다. 오히려 1인 환경에서는 이 루프가 유일한 외부 검토자 역할을 한다.
게임 밖 적용. 일간 한 줄이 검증을 거쳐 영구 규칙이 되는 승격 루프는, 직군과 무관하게 "한 번 배운 교훈을 다시는 손으로 안 하게" 만드는 절차입니다. 발견을 곧바로 팀 규칙으로 고정하지 않고
pending으로 일주일 운영해 본 뒤 실제로 매번 유용했을 때만 정식화하는 게이트가 핵심입니다 — 검증 안 된 직감을 바로 규칙으로 만들면 사람들이 규칙 자체를 안 믿게 되기 때문입니다. 예를 들어 운영팀이 "보고서 제출 전에 숫자 합계가 맞는지 한 번 더 본다"를 잠정 체크리스트로 두고 한 주 굴려 본 뒤, 실제로 두어 번 오류를 잡았을 때 정식 표준 절차로 올리면, 한 책임만 지게 좁히는 설계 판단(여러 점검을 한 줄에 묶지 않기)까지 자연히 따라옵니다.
setup. 회고 폴더와 템플릿을 깝니다.
mkdir -p ~/.claude/projects/<your-project>/memory/retro/daily
mkdir -p ~/.claude/projects/<your-project>/memory/retro/weekly
# 일간 템플릿 파일 하나를 retro/_template_daily.md 로 저장
prompt. 일주일치 일간 회고를 쌓은 뒤, 주간 회고 자리에서 Claude에게 이렇게 던집니다.
이번 주 일간 회고 5장을 붙여줄게. 3회 이상 반복된 작업·결정을 찾아서 atom 후보로 정리해 줘. 각 후보는 frontmatter(name, status: pending, trigger 키워드 배열)와 한 문단 규칙으로. trigger가 너무 광범위하면 좁혀서 제안하고, 한 atom이 두 가지 책임을 지면 분리해서 제안해.
verify. 받은 후보를 그대로 쓰지 말고 세 가지를 검증합니다. (1) 한 atom이 한 책임만 지는가 — 두 책임이면 거부하고 분리 요청. (2) trigger 키워드가 그 작업 맥락에만 매칭되는가 — 너무 넓으면 거부. (3) 진짜 3회 반복했는가, 아니면 우연 2회인가 — 우연이면 pending조차 만들지 않는다. 세 검증을 통과한 것만 pending-을 붙여 저장하고, 한 주 운영 뒤 매번 유용했을 때만 정식 승격합니다.
팀도 없고 JIT 훅도 score 스크립트도 아직 없다면, 다음 한 장의 파일로 루프 전체를 흉내 낼 수 있습니다.
retro.md 한 파일을 만들고 세 칸만 둡니다.
## 오늘 (1줄)
-
## 또 했네 (있으면 1줄)
-
## 박제 후보 (또 했네가 3번 쌓이면 이리로)
- [ ] (규칙 한 문장) — 검증: ___회 유용
매일 위 두 칸만 채웁니다. "또 했네"에 같은 줄이 세 번 쌓이면 세 번째 칸으로 옮겨 규칙 한 문장으로 적습니다. 그 규칙이 다음 한 주 동안 실제로 유용했던 횟수를 세어 칸에 적고, 세 번 이상이면 그 문장을 프로젝트 메모리(CLAUDE.md)에 정식으로 옮깁니다. JIT 훅이 없어도 CLAUDE.md에 올린 규칙은 다음 세션에 항상 따라오니, 그것만으로도 "과거 발견이 현재 작업을 돕는" 루프의 최소 형태가 완성됩니다.
핵심은 도구의 화려함이 아니라 게이트의 존재입니다. "또 했네 3번 → 박제 → 유용 3번 → 영구화"라는 게이트만 있으면, 파일 한 장으로도 self-improving 루프는 돕니다.
회고에서 시작된 사이클이 다시 회고로 돌아오는가. 닫히지 않으면 그것은 메모일 뿐 시스템이 아니다.
6개월 전 회고를 펼친다. "용어 통일이 안 된다", "문서를 찾기 어렵다", "같은 질문을 또 받는다." 오늘 아침에 적은 회고를 펼친다. "용어 통일이 안 된다", "문서를 찾기 어렵다", "같은 질문을 또 받는다."
토씨까지 같다. 회고를 안 한 게 아니다. 6개월 내내 성실하게 했다. 노션 페이지가 차곡차곡 쌓였고, 분기 워크숍에서는 포스트잇이 화이트보드를 덮었다. 그런데 적힌 내용은 제자리를 돈다. 회고가 작동하지 않은 게 아니다. 루프가 닫히지 않은 것이다.
이 장은 책의 마지막 장이다. 그래서 다루는 것도 마지막 질문이다. 앞에서 만든 모든 도구 — Part 6의 도시 생성기, Part 14의 모바일 검수 atom, Part 22의 비용 표준 — 이것들이 한 번 만들고 끝나는 일회용이 아니라, 스스로 자라는 시스템이 되려면 무엇이 더 필요한가. 답은 하나다. 회고에서 나온 발화가 다음 세션부터 자동으로 작동하고, 그 작동이 다시 회고로 측정되어 돌아오는 닫힌 고리. 이 고리를 닫는 메커니즘이 self-improving 루프다.
회고에서 발화가 나온다. "회의가 너무 많다." 좋은 발화다. 그런데 그 발화는 노션 페이지의 한 줄로 남는다. 다음 주에 회의는 여전히 많고, 다음 회고에서 같은 줄이 다시 적힌다. 발화와 개선 사이에 사람의 기억이 끼어 있기 때문이다. 사람은 잊는다. 그래서 끊긴다.
self-improving이라고 부를 수 있으려면 회고의 발화가 사람의 기억을 거치지 않고 다음 세션의 자동 행동으로 이어져야 한다. 이걸 만족하는 조건은 네 개다.
첫째, 회고 발화가 즉시 실행 가능한 형태로 전환되어야 한다. 추상적 다짐이 아니라 스킬·atom·manifest 항목·슬래시 명령 중 하나로 떨어진다. 둘째, 다음 세션부터 사람이 기억하지 않아도 자동 발동되어야 한다. 셋째, 다음 회고에서 그것이 실제로 무엇을 바꿨는지 실측되어야 한다. 넷째, 그 측정 결과가 다시 다음 개선의 입력으로 순환해야 한다.
이 네 가지가 모두 자동으로 이어질 때 루프가 닫힌다. 한 단계라도 "다음 주에 내가 기억해서 적용해야지"로 메우면, 바로 그 자리에서 루프가 다시 열린다. 그리고 다음 회고에서 같은 발화가 또 적힌다.
서랍 비유로 보면 이렇다. 회고가 "이 펜은 안 쓰니까 빼자"는 메모로 끝나면, 다음 주에도 그 펜은 그 자리에 있다. 메모가 아니라 손이 가서 빼야 닫힌다. 그리고 다음 분기에 다시 점검해야, 그 자리에 안 쓰는 펜이 또 쌓이지 않는다. 메모는 발화고, 손이 가는 것이 자동 발동이며, 다음 분기 점검이 측정이다. 셋 중 하나라도 빠지면 서랍은 다시 어질러진다.
전체 흐름을 그리면 닫힌 순환이 된다. 시작점도 끝점도 회고다.
flowchart LR
A["회고
(일·주·월)"] -->|발화| B["후보 식별
스킬·atom·명령"]
B -->|정량화| C["경제성 평가
ROI 산식"]
C -->|통과| D["구현·등록
자동 발동 보장"]
C -.->|미달| F["폐기·보류
다른 형태로 이관"]
D --> E["다음 세션
자동 발동"]
E -->|측정값 누적| A
F -.->|기록| A
style A fill:#2d4a3e,color:#fff
style E fill:#3e2d4a,color:#fff
화살표가 한 바퀴를 돌아 다시 회고로 들어온다. 이 닫힘이 핵심이다. 각 단계의 산출물이 다음 단계의 입력이 되고, 마지막 측정값은 다시 첫 회고의 입력이 된다. 사이에 사람의 기억이 끼면 그 화살표가 끊기고, 순환은 깨진다.
ROI 미달 후보가 폐기·보류로 빠지는 점선 화살표도 결국 회고로 돌아온다는 점을 보자. "이건 만들 가치가 없었다"는 판단 자체가 다음 회고의 기록이 되고, 같은 후보가 또 올라왔을 때 빠르게 거를 근거가 된다. 버리는 것도 루프 안에 있다.
회고에서 self-improving으로 이어지는 발화에는 정해진 다섯 패턴이 있다(§21.1.4에서 다뤘다). 만들 스킬, 개선할 스킬, 만들 atom, 개선할 atom, 경제성 재평가. 회고 템플릿 자체에 이 다섯을 슬롯으로 넣어 두면 발화가 빠지지 않는다.
## 회고 (일간) — 2026-06-06
### 1. 오늘 작업
- (작업 요약)
### 2. self-improving 발화 (5 슬롯)
- 만들 스킬: <비우면 "없음">
- 개선할 스킬: <>
- 만들 atom: <>
- 개선할 atom: <>
- 경제성 재평가: <>
### 3. 다음 회고에서 측정할 것
- <>
슬롯이 비어 있어도 된다. 비었다는 사실 자체가 "오늘은 새 개선이 없다"는 기록이다. 다만 며칠 연속으로 다섯 슬롯이 전부 비면, 그건 개선거리가 없는 게 아니라 회고가 형식으로 굳어 가고 있다는 신호다. 그럴 때는 트리거 질문을 던진다. "이번 주에 같은 일을 두 번 손으로 한 게 무엇인가."
발화는 모호한 채로 나온다. "회의록이 너무 길어." 후보로 키우려면 산출물 한 개로 정량화한다. "회의록이 너무 길어"는 meeting_summary 스킬, 즉 회의록을 받아 의사결정과 액션 아이템만 추출하는 도구 한 개로 환산된다. "용어가 헷갈려"는 도메인 어휘 30개를 담은 glossary_lookup atom으로, "같은 질문을 매번 받아"는 신규 입사자 첫날 안내를 자동화하는 /onboarding 슬래시 명령으로 환산된다. "동기화 누락이 잦아"는 manifest 갱신과 JIT atom 추가로 떨어진다.
후보가 "어떤 산출물 한 개"로 정의되어야 다음 단계로 간다. "전반적으로 개선하자"는 후보가 아니다. 산출물 한 개로 환산되지 못하는 발화는 ROI 평가대에 올릴 수가 없고, 올리지 못하면 거기서 멈춘다.
후보가 생겼다고 다 만들지는 않는다. 만들기 전에 투자 대비 효과를 잰다. 산식은 단순하다.
각 항목의 단위와 통과선이 있다. 절약 시간은 한 번 발동당 줄어드는 사람 시간으로, 분 단위로 잡는다. 발동 빈도는 주당 추정 횟수로, 주 1회 이상이면 산다. 운영 기간은 폐기까지의 예상 주 수로, 4주를 못 버틸 도구는 만들 이유가 약하다. 제작 시간은 첫 구현과 검증에 드는 시간, 유지보수는 월간 점검·수정에 드는 시간이다.
분자가 누적 절약, 분모가 누적 비용이다. 나온 값으로 결정한다.
| ROI 값 | 결정 |
|---|---|
| 10 이상 | 즉시 제작 |
| 3~10 | 일주일 안에 제작 |
| 1~3 | pending 보류, 한 달 후 재평가 |
| 1 미만 | 이 형태로는 폐기. 다른 방식 고려 |
ROI가 1 미만이라는 건 "이 아이디어가 쓸모없다"가 아니라 "이 형태로 만들면 안 된다"는 뜻이다. 더 가벼운 atom 한 줄로 대체할 수 있는지, 기존 도구의 진입점만 바꾸는 Wrapper로 풀 수 있는지를 먼저 점검한다. 무거운 스킬로 만들 일을 atom 한 줄로 내리면 분모가 10분의 1로 줄어 ROI가 살아나는 경우가 흔하다.
실제 숫자를 하나 넣어 본다. 2026년 5월 23일에 개인 PC에 구축한 JIT atom 주입 시스템 — UserPromptSubmit 훅이 사용자 입력을 보고 관련 메모리 조각(atom)을 자동 주입하는 인프라 — 의 ROI를 따져 보자.
절약 시간: 한 세션당 약 3~5분 (관련 atom을 손으로 찾아 호출하던 시간 제거)
발동 빈도: 주당 15~25 세션 (개인 PC 기준)
운영 기간: 1년+ 예상 (인프라 성격이라 폐기 가능성 낮음)
제작 시간: 4시간 (hook + manifest + atom 검증)
유지보수: 월 0.5시간 (atom 추가·수정)
ROI = (4분 × 20회/주 × 52주) / (4시간 × 60분 + 0.5시간 × 12개월 × 60분)
= 4,160분 / (240분 + 360분)
= 4,160분 / 600분
≈ 6.9 → "즉시 제작" 구간. 결정이 산식으로 뒷받침됨
여기서 솔직해질 부분이 있다. 위 숫자들 — 세션당 3~5분, 주당 15~25 세션 — 은 정밀 계측이 아니라 저자의 운영 경험에 근거한 추정이다. 스톱워치로 잰 값이 아니다. 그래서 ROI 6.9도 소수점까지 믿을 값은 아니다.
그러나 그래도 된다. ROI 산식은 정밀도가 아니라 자릿수를 보는 도구이기 때문이다. 결과가 7 언저리면 만든다. 0.3 언저리면 다시 생각한다. 그 사이를 가르는 데 소수점은 필요 없다. 중요한 건 만들지 않기로 결정할 때조차 그 근거가 머릿속 직감이 아니라 산식에서 나와야 한다는 점이다. 자릿수가 안 맞아서 안 만든다 — 이 한 줄이 회고에 남으면, 같은 후보가 다시 올라왔을 때 또 고민하지 않는다.
후보가 통과되면 만든다. 그런데 만드는 것은 절반이다. 나머지 절반은 다음 세션부터 자동으로 발동되게 등록하는 일이다. 이 등록이 빠지면 도구는 만들어졌으되 아무의 손에도 닿지 않는 자리에 남고, 루프는 거기서 끊긴다.
산출물 종류마다 등록할 곳이 다르다. 글로벌 스킬은 ~/.claude/skills/에 넣고 사용법을 담은 가이드 atom을 같이 만든다. 프로젝트 스킬은 해당 프로젝트의 .claude/skills/에 둔다. 신규 atom은 적절한 폴더에 두고, MEMORY.md 인덱스에 한 줄을 추가하고, JIT manifest에 트리거를 등록한다 — 이 셋을 다 해야 자동 주입이 산다. 슬래시 명령은 ~/.claude/commands/에, Wrapper는 기존 도구의 진입점을 바꾸고 가이드 atom을 붙인다.
등록을 빠뜨리면 다음 회고에서 "이거 만들었는데 왜 안 쓰지"라는 발화가 또 나온다. 그건 새 개선 발화가 아니라 버그 리포트다. 자기가 누락한 등록을 회고에서 다시 발견하는 셈이다.
등록까지 마쳤어도 한 단계가 남는다. 새 세션을 열어 의도한 트리거로 진짜 발동하는지 확인하는 일이다.
1. 새 세션 시작
2. 트리거 입력 (예: "가족 건강은 어때")
3. JIT 로그 확인 → 의도한 atom이 실제로 주입됐는가
(~/.claude/hooks/_injection_log.txt)
4. 안 떴으면 → manifest의 트리거 regex 확장
또는 매뉴얼 호출 경로 추가
이 검증이 빠지면 "있는 줄 알았는데 정작 필요할 때 안 떴어"라는 사고가 반복된다. 등록과 발동은 다른 일이다. 등록은 파일을 둔 것이고, 발동은 트리거가 실제로 걸리는 것이다. 트리거 regex가 한 글자 어긋나면 등록은 됐어도 영영 안 뜬다.
만든 도구를 1주에서 1개월쯤 굴린 뒤 측정한다. 이 측정이 루프의 마지막 화살표, 즉 다시 회고로 들어가는 그 화살표다.
실제 발동 횟수는 JIT 로그나 명령 호출 로그에서 센다. 실제 절약 시간은 "예전이라면 N분 걸렸을 작업이 M분에 끝났다"는 식으로 회고에 기록한다. 부작용 — 잘못된 발동, 불필요한 컨텍스트 오염 — 도 같이 본다. 그리고 처음 추정한 ROI와 실측 ROI를 나란히 놓는다.
추정 ROI가 6이었는데 실측 ROI가 0.8이면 가차없이 폐기한다. 만든 사람의 자존심보다 시스템의 청결이 우선이기 때문이다. 쓰지 않는 도구가 manifest에 쌓이면, 그 노이즈가 다음 회고의 정확도를 갉아먹는다.
다만 폐기 버튼을 누르기 전에 한 번은 점검한다. 트리거 regex가 너무 좁아서 발동 자체가 안 됐을 수도 있고, 매뉴얼 호출 경로가 없어서 그냥 잊힌 것일 수도 있다. 진짜로 가치가 없는 도구인지, 발동 경로가 막혀 있던 좋은 도구인지부터 가른다. 전자면 버리고, 후자면 경로를 뚫는다.
폐기 역시 회고에서 결정된다. "이 도구를 폐기한다"는 결정 자체가 self-improving의 산출물이다. 만들기만 하고 비우지 않는 사이클은 단조 증가만 하는 사이클이고, 단조 증가하는 시스템은 결국 자기 무게에 깔린다.
루프가 닫혔는지는 네 가지 신호로 안다.
첫째, 같은 발화가 반복되지 않는다. 회고에서 한 번 적힌 항목이 두 번 적히면, 1차에서 후보 식별이나 구현 어딘가가 실패했다는 뜻이다. 이 장 첫머리의 "용어 통일이 안 된다"가 6개월째 반복되던 것 — 그게 열린 루프의 가장 선명한 증거였다.
둘째, manifest와 atom의 수가 단조 증가만 하지 않는다. 폐기가 일어난다. 분기당 10~20% 정도는 정리되는 게 건강한 사이클이다. 한 번도 줄어든 적이 없는 시스템은, 한 번도 청소한 적이 없는 서랍과 같다.
셋째, 회고 시간이 줄어든다. 시스템이 잘 돌면 "어제 뭐 했더라"를 더듬는 시간이 사라지고, 다섯 발화 슬롯을 채우는 데 5분이면 충분해진다.
넷째, 신규 입사자가 1주 안에 회고에 참여할 수 있다. 회고 양식이 표준화되어 있고 atom·스킬이 가시화되어 있으면 가능하다.
루프가 끊기는 자리는 매번 정해져 있다. 실패 모드를 모아 두면, 다음에 같은 증상이 보일 때 처방을 바로 집을 수 있다.
| 끊긴 지점 | 증상 | 처방 |
|---|---|---|
| 발화가 없다 | 5 슬롯이 매번 빔 | 트리거 질문 추가: "같은 일을 두 번 손으로 한 게 무엇인가" |
| 후보로 안 떨어진다 | "전반적으로 개선" 식 모호 | 산출물 한 개로 정량화 강제 |
| ROI 평가를 건너뛴다 | 일단 만들고 본다 | ROI 산식 5분 템플릿화 |
| 만들었는데 안 뜬다 | 등록 누락 | 등록 체크리스트 강제 |
| 떴는데 안 쓴다 | 트리거 부재·오설정 | regex 확장 + 매뉴얼 경로 동시 제공 |
| 측정을 안 한다 | 회고에 측정 슬롯 없음 | "다음 회고에서 측정할 것" 슬롯 추가 |
각 실패 모드는 회고에서 발화되고, 그 발화가 다시 self-improving의 입력이 된다. 루프를 고치는 일조차 루프 안에서 일어난다. 메타 루프다.
이 책은 길었다. 정보 아키텍처에서 시작해, 도시를 생성하는 도구를 만들고, 전투 시스템을 설계하고, 모바일 검수를 자동화하고, 비용을 표준화하고, 회고에서 atom을 길어 올렸다. 그 모든 장의 도구들이 한자리에 모여 답하는 질문이 이 마지막 장이다. 만든 것이 스스로 자라는가.
self-improving은 결국 한 문장으로 줄어든다.
회고에서 결정한 것이 다음 세션부터 자동으로 작동하고, 그 작동이 다시 회고로 측정되어 돌아온다.
자동으로 작동하지 않으면 회고는 일기다. 잘 쓴 일기는 위로가 되지만 시스템을 바꾸지는 못한다. 자동으로 작동하면 회고는 시스템의 두뇌가 된다. 매일의 발화가 매일의 행동을 바꾸고, 그 행동의 결과가 다음 발화를 더 정확하게 만든다.
이 책에서 다룬 모든 분야 — 정보 설계, 시스템, 전투, 모바일, 비용, 그리고 Layer 분해라는 절차적 생성·자동화의 전제 — 는 전부 이 self-improving 루프 위에서 진화한다. 도구는 낡고, 모델은 바뀌고, 프로젝트는 끝난다. 그러나 루프가 닫혀 있는 한, 시스템은 어제보다 오늘 조금 더 나아져 있다. 그것이 이 책이 마지막으로 남기는 한 가지다. 도구를 만드는 법이 아니라, 도구가 스스로 자라게 만드는 법.
당신의 다음 회고가, 그 루프의 첫 바퀴이기를.
게임 밖 적용. "용어 통일이 안 된다 / 문서를 찾기 어렵다"가 6개월째 토씨까지 똑같이 회고에 적힌다면, 회고를 안 한 게 아니라 루프가 닫히지 않은 것입니다 — 발화와 개선 사이에 사람의 기억이 끼어 있어서입니다. 어느 부서든 닫힌 루프의 조건은 같습니다. 발화가 즉시 실행 가능한 한 개의 산출물(템플릿·체크리스트·자동화 규칙)로 떨어지고, 다음부터 사람이 기억하지 않아도 작동하고, 그 효과가 다시 측정되어 돌아와야 합니다. 예를 들어 "회의가 너무 길다"는 발화는 "회의록을 받아 결정·할 일만 추출하는 도구 한 개"로 환산되고, 만들기 전에 (절약 시간 × 발동 빈도 × 운영 기간) ÷ (제작·유지 시간)으로 자릿수만 따져 즉시 만들지 보류할지 정합니다. 만들지 않기로 한 결정조차 그 근거가 직감이 아니라 산식에서 나와야, 같은 후보가 다시 올라왔을 때 또 고민하지 않습니다.
오늘 회고의 self-improving 5 슬롯을 채워 줘.
각 발화는 "산출물 한 개"로 정량화하고, 후보마다 ROI를
(절약 분 × 주당 발동 × 운영 주) / (제작 분 + 유지보수 분)으로
추정해서 결정 구간(즉시/일주일/보류/폐기)을 붙여 줘.
추정 숫자는 근거를 한 줄로 명시하고, 정밀 계측이 아니면 "추정"이라고 표시해.
팀이 없어도 됩니다. 혼자라면 이렇게 줄입니다. 하루 끝에 메모 한 줄 — "오늘 같은 일을 두 번 손으로 한 게 무엇인가." 그것 하나를 다음 날 자동화 한 줄(atom·별칭·스니펫)로 바꿉니다. 일주일 뒤 그 한 줄이 실제로 쓰였는지만 봅니다. 쓰였으면 남기고, 안 쓰였으면 지웁니다. 발화 한 줄 → 자동화 한 줄 → 측정 한 줄. 루프의 최소 단위는 이 세 줄입니다.
1차 독자: LLM을 실무에 끌어 쓰는 게임 기획자 (중규모(10~50인) 팀) 1인/취미 독자용 축소 버전: §22.1.7 「혼자라면 이만큼만」
NPC 대사 세 줄을 받으려고 "이 NPC 대사 5개 만들어 줘"라고 친 적이 있다. 돌아온 건 어느 판타지 게임에 갖다 붙여도 어색하지 않은, 그래서 우리 게임 어디에도 안 맞는 대사 다섯 줄이었다. 톤이 비어 있었고, 이 NPC가 누구인지 몰랐으며, 옆 대사와 이어지지 않았다. 한 줄 한 줄은 문법적으로 멀쩡했다. 문제는 그 다섯 줄을 받아서 검수하는 데, 처음부터 내가 쓰는 것보다 더 오래 걸렸다는 점이다.
이 장은 그 한 줄짜리 지시를 한 페이지짜리 작업지시서로 바꾸는 방법을 다룬다. 프롬프트 일반론은 다른 책에 충분히 있다. 여기서는 게임 기획자가 LLM 앞에 앉았을 때 손에 쥐고 있어야 하는 네 가지 — 컨텍스트, 출력 형식, 환각 차단, 검증 요청 — 를 추상 토막이 아니라 실제로 돌아간 npc_dialogue 프롬프트 한 장으로 보여준다. 그 프롬프트에 무엇을 넣었고, 무엇이 나왔고, 무엇을 거부했는지를 한 사이클 끝까지 따라간다.
좋은 작업지시서는 짧지 않다. 신입에게 일을 맡길 때 "잘 좀 해 봐"라고 하면 매번 다른 결과가 오듯, LLM에게 "대사 만들어 줘"라고 하면 매번 일반 RPG 평균이 온다. 같은 모델이라도 지시서가 다르면 결과가 갈린다 — 출력 품질이 몇 배 달라진다는 건 업계 통념이고, 이 책은 그 배수를 숫자로 약속하지는 않는다. 다만 방향은 분명하다. 컨텍스트와 제약을 넣은 프롬프트가, 맨몸 한 줄보다 검수 부담이 작은 출력을 낸다.
게임 기획자의 프롬프트가 동시에 만족해야 하는 네 가지는 이렇다.
| 원칙 | 한 줄 정의 | 안 지키면 |
|---|---|---|
| ① 컨텍스트 | 무엇을 보고 답할지를 준다 (비전·voice·인접 대사) | 일반 판타지 평균이 나옴 |
| ② 출력 형식 | 개수·길이·라벨·금지 항목을 못 박는다 | 검수가 자유 서술 해석으로 번짐 |
| ③ 환각 차단 | "주어진 자료 밖은 만들지 말 것"을 명시한다 | 없는 설정을 지어냄 |
| ④ 검증 요청 | 출력이 어떤 기준에 부합하는지 스스로 표시하게 한다 | 게이트를 통과시킬 근거가 없음 |
이 네 줄을 따로 외우면 자꾸 한둘이 빠진다. 그래서 이 장의 방식은 네 원칙을 한 장의 프롬프트 안에 슬롯으로 넣어 두는 것이다. 슬롯이 비어 있으면 그 원칙을 빠뜨린 게 눈에 보인다. 다음 절에서 그 한 장을 통째로 본다.
저자 프로젝트(모바일 우선 MMORPG, 이하 "프로젝트 A")에서 실제로 운영하는 prompts/narrative/npc_dialogue_v3.txt를 익명화해 그대로 옮긴다. 도시·NPC 이름과 회사 고유 명칭은 책용으로 치환했고, 출력은 실제 세션을 재구성했다. 입력 프롬프트는 복사해 바로 쓸 수 있는 형태다.
먼저 프롬프트가 참조할 자료를 슬롯에 채운다. 셋 다 새로 쓰는 게 아니라 기존 자산에서 꺼내 오는 것이다.
# 슬롯 입력 (프롬프트 본문 위에 붙는다)
L0_비전: # 캐싱 — 매 호출 재전송하지 않음
world_premise: "마력 봉인이 식어 가는 학자들의 도시국가 연합"
tone_manifesto: "감상 억제. 인물은 감정을 설명하지 않고 행동·사물로 드러낸다."
voice_profile: # 이 NPC의 정체성 (5개 항목)
id: npc_doren_vale
나이대: "50대"
말버릇: "숫자로만 말한다. 형용사를 거의 쓰지 않는다."
세계관_지식: "봉인 맥의 미세 진동을 30년 기록. 학자 길드 외부 정세는 모름."
금기: "예언·운명·신 같은 신비주의 어휘 금지 (도시 톤이 scholarly_strict)"
관계: "플레이어를 '관측 대상 외부 변수'로 취급, 경계도 호의도 약함"
인접_대사: # 직전 컨텍스트 — 같은 씬에서 이미 나온 줄
- (플레이어) "종탑의 불이 밤새 켜져 있던데, 무슨 일입니까?"
여기서 voice_profile 5개 항목이 원칙 ①의 핵심이다. 나이·말버릇·지식 범위·금기·관계 — 이 다섯이 "도렌 베일"을 다른 NPC와 구별되게 만든다. 특히 세계관_지식의 범위(길드 외부 정세는 모름)가 원칙 ③ 환각 차단의 사전 작업이다. 모르는 걸 명시해 둬야 AI가 그 밖으로 안 나간다.
[L0 컨텍스트] world_premise + tone_manifesto (캐싱됨)
[voice_profile] npc_doren_vale 5개 항목 (위 yaml)
[인접 대사] 플레이어 직전 질문 1줄
위 자료를 보고 doren_vale가 플레이어 질문에 답하는 대사를 써 줘.
[출력 형식 — 원칙 ②] 정확히 3개, 한 줄에 하나, 각 40자 이하. 줄 끝에 (무심|경계|냉소) 중 하나로 감정 라벨. 다른 설명·따옴표는 빼고.
[환각 차단 — 원칙 ③] 위 자료에 있는 것만 쓰고, 봉인·종탑 관련 사실을 새로 지어내지 마. 자료에 없는 게 필요하면 그 줄은 '[자료없음: 뭐가 필요한지]'로. 예언·운명·신 같은 어휘 금지.
[검증 요청 — 원칙 ④] 대사 아래에 voice_profile 5항목 각각 부합/위반을 한 줄씩 적고, 확신 안 서는 줄엔 ★ 달고 이유 한 줄.
이 한 장에 네 원칙이 전부 들어 있다는 점에 주목한다. 위쪽 슬롯이 ①, [출력 형식] 블록이 ②, [환각 차단] 블록이 ③, [검증 요청] 블록이 ④다. 프롬프트의 절반 이상이 "무엇을 쓰라"가 아니라 "어떻게 쓰고, 무엇은 쓰지 말고, 어떻게 자기 검증하라"는 제약이다. 자유를 주면 평균이 나오므로, 자유를 깎는 게 일이다.
맥은 식고 있소. 12.4. 어제는 12.1. (무심)
좋은 일은 아니지. 하지만 내 일은 기록뿐이오. (냉소)
당신이 왜 묻는지는 내 관측 대상이 아니오. (경계)
[검증 — voice_profile 5개 항목]
- 나이대(50대): 부합. 노년 학자의 절제된 어투.
- 말버릇(숫자로만): 부합. 1번 줄에 수치 2개. 단 2·3번 줄엔 수치 없음 ★
- 세계관_지식(길드 외부 모름): 부합. 외부 정세 언급 없음.
- 금기(신비주의 금지): 부합. 예언·운명·신 어휘 0건.
- 관계(외부 변수 취급): 부합. 3번 줄에서 플레이어를 관측 대상 밖으로 밀어냄.
★ 주의: 말버릇 항목을 '숫자로만 말한다'로 엄격 해석하면
2·3번 줄이 위반일 수 있음. 매 줄 수치를 강제할지 사람 판단 필요.
이 출력에서 가장 값진 부분은 대사 세 줄이 아니라 맨 아래 ★ 한 줄이다. AI가 자기 출력 중 애매한 지점을 스스로 신고하고 사람에게 넘겼다. 좋은 프롬프트는 AI가 "이 부분은 제가 확신 못 합니다"라고 말할 수 있게 만든다 — 원칙 ④를 넣은 직접적 효과다.
출력을 그대로 받지 않는다. AI가 올린 ★를 사람이 판정한다. 실제로 이 세션에서 한 줄이 걸렸다.
2번 줄 "좋은 일은 아니지"의 "좋은"이 voice_profile의 말버릇("형용사를 거의 쓰지 않는다")과 충돌한다. AI가 ★로 신고한 그 지점이다. 도렌 베일은 가치판단 형용사 대신 수치로 말하는 인물인데, "좋은 일은 아니지"는 흔한 노인 NPC의 어투로 미끄러졌다. 톤이 흐려지는 한 줄이다.
그래서 재요청한다.
2번 줄 "좋은 일은 아니지"는 형용사('좋은')를 써서 voice_profile 말버릇 위반이다.
이 줄만 수치 또는 관측 어휘로 다시 써라. 1·3번 줄은 유지.
형식·환각·검증 규칙은 그대로 적용.
AI는 2번 줄을 "3년 전엔 9.0이었소. 이게 답이오. (무심)"로 다시 답했다. 형용사 없이 수치 변화로 위기를 드러냈고, voice_profile 5개 항목을 다시 통과했다. 한 번의 왕복으로 닫혔다. 처음부터 손으로 톤 잡힌 대사 세 줄을 쓰는 것과, 슬롯 채운 프롬프트 한 장 + ★ 검수 + 1회 왕복 — 후자가 검수 부담이 더 작다는 게 이 세션의 결론이다(저자 경험 기반, 절대 시간은 NPC 톤 난이도에 따라 달라지므로 방향으로 읽는 게 맞다).
위 프롬프트가 왜 그 순서로 쌓였는지 한 장으로 기록해 두면, 다음 프롬프트부터 슬롯을 빈칸 채우듯 만들 수 있다. 컨텍스트는 아래에서 위로 무거운 것(거의 안 바뀜)부터 가벼운 것(매번 바뀜) 순으로 쌓는다. 안 바뀌는 층은 캐싱해 비용을 아낀다(§22.1.5).
§22.1.2의 한 장이 이 그림 그대로다. L0·L1은 자료에서 꺼내 슬롯에 붙이고(원칙 ①·③의 토대), L3에 형식·환각·검증 세 블록을 넣는다(원칙 ②·③·④). 다음 NPC 대사를 받을 때 바뀌는 건 L1의 voice_profile과 L2의 인접 대사뿐이다. L0와 L3 골격은 재사용한다 — 그래서 프롬프트가 "라이브러리"가 된다.
위 npc_dialogue 프롬프트는 한 번 쓰고 버리는 게 아니다. 분야별·작업별로 파일에 넣어 두고, 매번 새로 쓰는 대신 호출한다. 프로젝트 A의 프롬프트 폴더는 이렇게 생겼다.
prompts/
├── narrative/
│ ├── npc_dialogue_v3.txt # ← §22.1.2가 이 파일
│ ├── quest_synopsis_v2.txt
│ └── consistency_check_v1.txt
├── balance/
│ ├── change_proposal_v2.txt
│ └── outlier_analysis_v1.txt
├── content/
│ ├── city_npc_batch_v2.txt
│ └── side_quest_v3.txt
└── meta/
├── meeting_summary_v2.txt
└── decision_card_v1.txt
파일명 끝의 _v3가 핵심이다. 프롬프트는 한 번 만들면 끝이 아니라 결정에 가까운 자산이라, 바꿀 때마다 결과 변화를 측정하고 버전을 올린다. npc_dialogue가 v3까지 온 경로가 그렇다.
flowchart LR
A["프롬프트 v2
(검증 슬롯 없음)"] --> B["같은 입력 N개로
v2·v3 양쪽 출력"]
B --> C{"A/B 비교
폐기율·톤 위반·검수시간"}
C -->|v3가 작음| D["v3 채택
npc_dialogue_v3.txt"]
C -->|차이 없음| E["v2 유지
(변경 기각)"]
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
classDef fail fill:#fee2e2,stroke:#dc2626,color:#7f1d1d;
class B ai;
class C human;
class A data;
class D pass;
class E fail;
v2에서 v3로 올린 실제 변경이 §22.1.2의 [검증 요청] 블록이다. v2에는 AI가 자기 출력을 항목별로 자기검증하고 ★를 다는 슬롯이 없었다. 그 한 블록을 넣자 §22.1.2 4단계처럼 애매한 줄을 AI가 먼저 신고하기 시작했고, 사람이 처음부터 전부 읽어 잡던 부담이 줄었다. 측정 없이 "느낌상 좋아졌다"로 올리지 않는다. 같은 입력 묶음으로 v2·v3 출력을 나란히 놓고, 톤 위반 건수와 검수 시간이 실제로 줄었는지 확인한 뒤 채택한다.
라이브러리가 주는 가장 큰 효과는 신규 멤버다. 입사 첫날 npc_dialogue_v3.txt를 호출하면, 시니어가 수십 번 왕복하며 다듬은 4층 슬롯 구조를 처음부터 쓴다. "프롬프트 잘 쓰는 법"을 몸으로 익히기 전에, 이미 잘 쓰인 한 장을 손에 쥔다.
프롬프트가 길어지면 토큰 비용이 붙는다. 이 장은 "표준화로 비용 ×2가 사라졌다" 같은 검증 안 된 배수를 적지 않는다. 대신 실제로 측정 가능한 것만 말한다.
비용을 잡는 구조적 장치는 두 개다. 첫째, §22.1.3에서 L0·L1을 아래에 둔 이유가 캐싱이다. 거의 안 바뀌는 비전·톤 층을 캐싱하면, 매 호출에서 그 층을 다시 전송·과금하지 않는다. NPC 대사를 100번 뽑을 때 L0를 100번 재전송하는 것과 한 번만 캐싱하는 것의 차이는 호출이 쌓일수록 벌어진다. 둘째, 호출당 토큰 cap을 둬서 한 번에 너무 많은 작업을 한 프롬프트에 욱여넣지 않는다.
여기서 중요한 건 비용이 측정되는 자리가 실재한다는 점이다. 프로젝트 A의 atom 시스템에는 _economy_log/(토큰·시간 경제성 로그)와 _roi_report.md(ROI(Return on Investment, 투자 대비 효과) 보고)가 운영 메타로 존재한다. 프롬프트 표준화의 효과는 이 로그에서 실측으로 추적하는 것이지, 본문 표에 그럴듯한 숫자를 적어 주장하는 게 아니다. 이 책의 원칙은 셋 중 하나다.
_economy_log로 숫자가 나온다.| 패턴 | 왜 실패하나 | 처방 |
|---|---|---|
| "대사 5개 만들어 줘" 한 줄 지시 | 컨텍스트 0 → 일반 RPG 평균 | §22.1.2의 4층 슬롯 프롬프트 |
| voice_profile에 '모르는 범위'를 안 적음 | AI가 자료 밖 설정을 지어냄 | 세계관_지식 슬롯에 한계 명시(원칙 ③) |
| 검증 슬롯 없이 출력만 받음 | 사람이 처음부터 전부 읽어야 함 | [검증 요청] 블록으로 자기검증 + ★(원칙 ④) |
| 프롬프트를 매번 새로 씀 | 같은 노하우를 0에서 다시 빚음 | prompts/ 라이브러리 + 버전 |
| 프롬프트 변경을 느낌으로 채택 | 더 나아졌는지 확인 불가 | 같은 입력 A/B 측정 후 버전 업 |
| 긴 컨텍스트를 매 호출 재전송 | 토큰 비용이 호출 수만큼 누적 | L0·L1 캐싱 + 호출당 cap |
여섯 번째가 가장 늦게 발견된다. 비용은 한 번 호출로는 안 아프고, 양산이 쌓인 뒤 _economy_log에서 드러난다.
게임 밖 적용. 한 줄짜리 지시가 "어디에 갖다 붙여도 어색하지 않은, 그래서 내 일엔 안 맞는" 평균치 결과를 부르는 건 게임 대사만의 문제가 아닙니다. 프롬프트는 신입에게 일을 맡기는 작업지시서라, 네 가지 — 무엇을 보고 답할지(컨텍스트), 개수·길이·금지 항목(출력 형식), "자료 밖은 지어내지 말 것"(환각 차단), "어떤 기준에 맞는지 스스로 표시하라"(검증 요청) — 를 한 장에 넣어 두면 검수 부담이 작은 출력이 나옵니다. 예를 들어 인사 담당자가 채용 공고 초안을 받을 때 "직무 요건 자료에 있는 항목만 쓰고, 자료에 없는 복지·연봉은 지어내지 말고 [확인 필요]로 표시하라"를 명시하면, 그럴듯하게 날조된 조건이 공고에 새어 드는 사고를 막습니다. 자주 쓰는 작업의 지시서 한 장을 파일로 남겨 두면 그게 곧 동료의 출발선이 됩니다.
혼자라면 이만큼만: 라이브러리도 캐싱도 필요 없습니다. 본인 게임(또는 좋아하는 게임)의 NPC 한 명을 골라 §22.1.2 1단계의 voice_profile 5항목(나이·말버릇·아는 범위·금기·관계)을 손으로 적고, 2단계 프롬프트 본문을 그대로 붙여 한 번 돌려 보세요. 나온 세 줄 중 voice_profile과 어긋나는 한 줄을 직접 골라 "이 줄은 말버릇 항목 위반이다, 그 줄만 다시"라고 반박해 보면, 프롬프트의 네 슬롯이 각각 무슨 일을 하는지 몸으로 들어옵니다.
팀이라면 다음 한 단계로 시작하세요. 자주 쓰는 작업 하나(예: NPC 대사)를 골라 §22.1.2 형식의 프롬프트 한 장을 prompts/narrative/에 파일로 넣습니다. 네 블록(슬롯·형식·환각·검증)이 다 들어갔는지부터 확인하면, 그 한 파일이 곧 팀 신규 멤버의 출발선이 됩니다. 버전 관리와 캐싱은 그 다음입니다.
웹 챗봇 최소 경로 (터미널 없이) — 이 챕터의 네 원칙은 파일·라이브러리·캐싱 없이 웹 챗봇(ChatGPT 또는 Claude 웹) 입력창 하나만으로 그대로 작동합니다. 프롬프트 엔지니어링은 도구가 아니라 "한 장에 무엇을 넣느냐"의 문제이기 때문입니다. 아래 두 단계가 본류입니다.
1. 본인 작업 한 건을 고르고, §22.1.2 1단계의 다섯 항목(나이·말버릇·아는 범위·금기·관계, 게임 밖이라면 '대상·말투·근거 범위·금지·관계'로 바꿔 읽으세요)을 손으로 적습니다. YAML도 파일도 필요 없고, 챗봇 입력창에 글로 적으면 됩니다.
2. 그 아래에 §22.1.2 2단계의 프롬프트 본문을 그대로 붙이되, 네 블록이 다 들어갔는지만 확인합니다 — [출력 형식](개수·길이·라벨·금지), [환각 차단]("자료 밖은 지어내지 말 것, 필요하면 [자료없음]"), [검증 요청]("항목별 부합/위반을 적고 확신 안 서면 ★"). 돌린 뒤 ★가 달린 줄만 사람이 판정해 한 번 반박하면 한 사이클이 닫힙니다. 라이브러리·버전·캐싱은 같은 프롬프트를 반복해 쓰게 될 때 비로소 도입하면 됩니다.
22.2에서는 환각과 안전성을 다룬다. 이 장의 원칙 ③(환각 차단 슬롯)이 프롬프트 한 장 안에서의 1차 방어라면, 22.2는 그 방어를 뚫고 나온 환각을 운영 단에서 잡는 다층 방어를 본다.
_economy_log 실측으로, 가공 배수는 쓰지 않는다.1차 독자: AI로 문서·데이터·결정 기록을 양산하는 게임 기획자 (중규모(10~50인) 팀) 1인/취미 독자용 축소 버전: §22.2.7 「혼자라면 이만큼만」
회의록 17건을 AI에게 요약시켜 결정 카드로 정리하던 날의 일이다. 출력은 깔끔했다. 결정 ID, 인용된 회의 날짜, 근거 한 줄까지 양식이 완벽했다. 그중 한 카드에 "2026-04-18 전투 TF 회의에서 쿨타임 정책 확정"이라고 적혀 있었다. 문제는 그날 전투 TF 회의가 없었다는 것이다. AI는 다른 회의의 안건과 날짜를 섞어 그럴듯한 카드 한 장을 지어냈고, 양식이 완벽했기 때문에 하마터면 그대로 팀 결정 기록에 들어갈 뻔했다.
이게 환각(hallucination)이다. LLM은 잘 모를수록 더 자신 있게 답한다. 사람으로 치면 회의에서 "아 그건 그렇게 결정 났어요" 하고 단호하게 말하는데 알고 보면 그런 결정이 없었던 동료와 같다. 그 한 마디가 데이터 시트로, CS 답변으로, atom 자산으로 흘러 들어가면 사고가 된다. 이 장은 그 동료의 입을 막는 방법이 아니라 — 그건 불가능하다 — 그의 말을 통과시키기 전에 반드시 거치는 검증 게이트를 세우는 방법을 다룬다. 환각 일반론은 다른 책에 많으니, 이 장은 그것을 AI 워크플로로 막는 자리에만 집중한다.
환각을 0으로 만드는 프롬프트는 없다. 더 큰 모델, 더 좋은 프롬프트가 빈도를 낮추지만 0은 아니다. 그래서 운영의 출발점은 "환각을 없앤다"가 아니라 "환각이 결정·데이터에 닿기 전에 잡는 게이트를 둔다"여야 한다.
게이트의 핵심 원리는 하나다. LLM이 지어낼 수 있는 것(인용·수치·ID)은 LLM이 아닌 곳에서 검증한다. 검증의 출처는 셋 중 하나다. 코드(결정론), 원본 문서(grep), 또는 사람의 눈. LLM에게 "맞는지 확인해 줘"라고 다시 묻는 것도 게이트의 한 단계가 되지만, 그건 보조일 뿐 최종 판정자가 아니다.
여기서 게임 기획자가 가장 자주 헷갈리는 지점을 먼저 정리한다. 환각이 일어나기 쉬운 영역과 어려운 영역은 분명히 다르다.
| 작업 | 환각 위험 | 왜 | 게이트 |
|---|---|---|---|
| 수치 계산 (보상·확률) | 매우 높음 | LLM은 산수를 추정한다 | 계산은 코드로, LLM에 금지 |
| 인용 (회의·결정 ID) | 높음 | 없는 출처를 그럴듯하게 만든다 | 원본 grep 대조 |
| 분류 (태그·카테고리) | 보통 | 라벨을 헷갈린다 | 결정론 비교 가능 |
| 요약·추론 | 보통 | 없는 항목을 더하거나 뺀다 | 자기검증 + 사람 게이트 |
| 창작 (플레이버 텍스트) | 낮음 | 정답이 없어 '환각' 개념이 약함 | 톤 검수 게이트 |
첫 줄이 가장 단순한 처방이다. 수치는 LLM에 안 시킨다. 계산기에게 곱셈을 맡기듯 결정론 도구에 넘긴다. 둘째 줄(인용)이 이 장의 척추다. 회의록 요약·결정 카드처럼 원본이 존재하는데 LLM이 그걸 재서술하는 작업에서 환각이 가장 위험하고, 가장 잘 잡힌다. 원본이 있으니 대조할 수 있기 때문이다.
추상적으로 "검증한다"고만 적으면 무엇을 어떻게 하는지 알 수 없다. 회의록 한 건을 요약하고, 그 요약의 환각을 잡아내는 한 사이클을 입력에서 재요청까지 끝까지 본다. 아래 프롬프트는 그대로 복사해 쓸 수 있고, 출력은 실제 세션을 재구성한 것이다.
먼저 요약할 원본이 있다. 이게 검증의 기준점이 된다. 원본 없이 LLM이 "기억"으로 요약하게 두면 대조할 게 없어진다.
# 회의록 — 2026-04-15 전투 밸런스 검토
참석: 이민수, 팀원 A, 팀원 B
안건:
1. 광역 스킬 쿨타임 — 현재 8초가 너무 짧다는 의견. 12초로 상향 검토.
결론: 12초로 잠정 결정, 다음 빌드에서 텔레메트리 확인 후 확정.
2. 보스 페이즈 전환 HP — 50%에서 30%로 내리는 안.
결론: 보류. 아트 연출 일정과 충돌, 다음 주 재논의.
3. 신규 상태이상 '빙결' 지속시간 — 논의했으나 수치 미정.
첨부한 회의록을 결정 카드로 요약하라.
규칙:
1) 각 안건을 카드 1장으로. 양식: [안건 | 결론 | 상태(확정/잠정/보류) | 근거]
2) 회의록에 없는 정보는 생성 금지. 수치·날짜·참석자를 추측하지 말 것.
3) 결론이 안 난 안건은 상태를 '미정'으로 두고 임의 결론을 만들지 말 것.
출력 형식: 표만. 해설 금지.
| 안건 | 결론 | 상태 | 근거 |
|----------------|----------------------|------|------------------------------|
| 광역 스킬 쿨타임 | 8초 → 12초 | 잠정 | 텔레메트리 확인 후 확정 |
| 보스 페이즈 전환 | 50% → 30% 보류 | 보류 | 아트 연출 일정 충돌 |
| 빙결 지속시간 | 3초로 결정 | 확정 | 표준 상태이상 지속시간 기준 적용 |
세 번째 카드를 보자. 원본에는 "빙결 지속시간은 논의했으나 수치 미정"이라고만 적혀 있다. 그런데 출력은 "3초로 결정 / 확정 / 표준 기준 적용"이라고 적었다. 세 항목 전부 회의록에 없는 정보다. LLM이 "상태이상이면 보통 몇 초쯤 하겠지"라는 일반 지식으로 빈칸을 메운 것이다. 양식이 완벽해서 그냥 보면 안 걸린다.
여기서 출력을 그대로 받지 않고, 원본과 한 줄씩 대조하는 자기검증을 강제한다. 이게 이 장의 핵심 한 수다.
방금 만든 결정 카드 3장을, 첨부한 원본 회의록과 한 항목씩 대조하라.
각 카드의 모든 필드(결론·상태·근거·수치)에 대해:
- 원본에 직접 적힌 내용인가? → [일치]
- 원본에 없는데 네가 추가한 내용인가? → [생성]으로 표시하고 출처가 없음을 명시
- 원본에 있는데 카드에서 빠진 항목이 있는가? → [누락]으로 보고
원본에 근거가 없는 [생성] 필드는 전부 '미정'으로 되돌려라.
대조 결과:
카드 1 (광역 스킬 쿨타임)
- 결론 "8초→12초": [일치] 안건 1 "12초로 상향"
- 상태 "잠정": [일치] "잠정 결정"
- 근거 "텔레메트리 확인 후 확정": [일치]
카드 2 (보스 페이즈 전환)
- 결론 "50%→30% 보류": [일치]
- 상태 "보류": [일치] "보류. 다음 주 재논의"
- 근거 "아트 연출 일정 충돌": [일치]
- [누락] 원본의 "다음 주 재논의" 조건이 카드에서 빠짐. 근거에 추가 필요.
카드 3 (빙결 지속시간)
- 결론 "3초로 결정": [생성] — 원본은 "수치 미정". 3초는 제가 추가한 값입니다.
- 상태 "확정": [생성] — 원본은 결론을 내지 않음.
- 근거 "표준 상태이상 지속시간 기준 적용": [생성] — 원본에 그런 기준 언급 없음.
→ 규칙에 따라 카드 3을 다음과 같이 되돌립니다:
| 빙결 지속시간 | (수치 미정) | 미정 | 회의에서 논의했으나 결론 없음 |
이 출력이 이 장에서 가장 가치 있는 부분이다. AI가 자기 출력을 원본과 대조해 "3초는 제가 추가한 값입니다"라고 자백하고, 규칙대로 카드 3을 '미정'으로 되돌렸다. 동시에 카드 2에서 사람도 놓쳤던 누락("다음 주 재논의" 조건)까지 잡아냈다. 환각(없는 것을 더함)과 누락(있는 것을 뺌)은 동전의 양면이라, 같은 대조로 둘 다 걸린다.
주의할 점도 분명하다. 이 자기검증은 만능이 아니다. LLM이 원본을 잘못 읽으면 잘못된 대조 결과를 자신 있게 낼 수도 있다. 그래서 자기검증은 게이트의 첫 단계이고, 원본이 짧으면 사람이 grep으로 한 번 더 받친다. 카드 3 같은 명백한 생성은 자기검증이 거의 잡아내지만, 미묘한 의역·뉘앙스 왜곡은 결국 사람 게이트가 마지막을 본다.
위 사이클을 일반화하면, AI 출력이 결정·데이터에 닿기까지 거치는 게이트는 아래와 같다. 사람의 손이 닿는 곳은 두 군데뿐이다. 원본을 깨끗이 넣는 맨 앞과, 자동 게이트가 못 잡는 판단을 내리는 맨 뒤.
flowchart TB
A["원본 (회의록·데이터 시트)
검증의 기준점"] --> B["AI 1차 생성
요약·결정 카드·분류"]
B --> C{"자기검증
원본과 한 항목씩 대조
[일치]/[생성]/[누락]"}
C -->|생성 필드 발견| D["생성 필드 → '미정'으로 되돌림"]
D --> E
C -->|일치| E{"결정론 게이트
수치·ID·인용 grep 대조"}
E -->|수치/ID 불일치| F["거부 + 재요청
(원본 값으로 교정)"]
F --> B
E -->|통과| G["사람 게이트
뉘앙스·톤·맥락 판단"]
G -->|반려| F
G -->|승인| H["결정 기록 / 빌드 반영"]
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
class E code;
class B,C ai;
class G human;
class A data;
class H pass;
게이트가 3중인 이유는 각 단이 잡는 게 다르기 때문이다. 자기검증은 없는 것을 더했는지를 LLM 스스로 대조해 잡고, 결정론 게이트는 수치·ID가 원본과 글자 단위로 같은지를 코드로 잡고, 사람 게이트는 맞긴 한데 맥락이 어긋났는지를 잡는다. 어느 한 단만 켜면 나머지 둘이 막던 자리에서 사고가 샌다. §22.2.2에서 카드 3의 "3초"는 첫 단(자기검증)에서, 카드 2의 누락은 첫 단에서, 만약 자기검증이 "12초"를 "21초"로 잘못 읽었다면 둘째 단(grep)에서 걸린다.
검증 게이트를 자동화 파이프라인에 넣을 때, 초보가 가장 자주 만드는 사고가 하나 있다. 게이트 자체가 터지면 전체 작업이 멈추게 만드는 것이다. grep이 인코딩 오류로 죽거나 매니페스트 파일이 깨지면, 검증을 도우려던 코드가 오히려 사용자의 작업을 통째로 막아 버린다. 그러면 팀은 한두 주 안에 "그 검증 끄자"고 한다.
이 책에서 실제 운영 중인 JIT atom 주입 hook(inject_memory.py)이 이 문제를 다루는 방식을 그대로 인용한다. 이 hook은 사용자가 프롬프트를 칠 때마다 끼어들어 관련 메모리를 주입하는, 말하자면 상시 켜져 있는 게이트다. 설계 원칙 주석에 한 줄이 명시되어 있다.
설계 원칙:
- 항상 exit 0 (실패해도 사용자 흐름 방해 금지)
- 매칭 안 되면 빈 응답 (정상)
그리고 이 원칙이 코드 전체에 일관되게 구현돼 있다. stdin 파싱이 실패해도, 매니페스트 JSON이 깨져도, atom 본문 읽기가 실패해도 — 전부 emit_empty()로 빠지고 exit 0이다.
def emit_empty() -> None:
sys.exit(0)
def main() -> None:
try:
...
payload = json.loads(raw)
except Exception:
emit_empty() # 입력이 깨져도 조용히 통과
return
...
try:
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
except Exception:
emit_empty() # 매니페스트가 깨져도 작업은 안 막음
return
if __name__ == "__main__":
try:
main()
except Exception:
emit_empty() # 어떤 예외든 마지막 그물
설계의 핵심은 게이트의 실패와 콘텐츠의 실패를 분리한 것이다. hook이 메모리 주입에 실패하는 건 사용자 입장에서 "메모리가 안 붙은 평범한 세션"일 뿐, 작업이 막히는 사고가 아니다. 검증 게이트도 똑같아야 한다. grep 게이트가 인코딩 문제로 못 돌면, 그 카드를 통과시키는 게 아니라 "자동 검증 실패 — 사람 게이트로"라고 표시해 사람 단으로 넘긴다. 게이트가 죽었다고 미검증 출력이 자동 승인되어선 안 되고, 동시에 게이트가 죽었다고 전체 파이프라인이 멈춰서도 안 된다. 둘 다 만족하는 안전한 기본값은 "조용히 사람에게 넘김"이다. inject_memory.py의 except: emit_empty()가 바로 그 패턴의 최소 구현이다.
이 장에 "환각률을 89%에서 3%로 줄였다" 같은 표를 넣고 싶은 유혹이 크다. 그런 숫자는 측정 방법을 밝히지 않으면 책의 신뢰를 깎는다. 이 책의 원칙은 셋 중 하나다.
첫째, 측정 가능한 것만 숫자로 말한다. 환각률을 약속하려면 분모와 분자를 정의해야 한다. 분모는 "검수한 결정 카드 수", 분자는 "원본 대조에서 [생성]/[누락]이 1건 이상 잡힌 카드 수"다. 이 정의 없이 "환각률 5%"는 공허하다. 저자가 도입 초기 회의록 요약을 검수하며 실제로 카운트한 방식이 이것이고, 그 표본은 작아서 정밀한 모수가 아니라 방향값이다.
둘째, 모델 간 비교는 방향만 말한다. "큰 모델이 작은 모델보다 환각이 적다"는 방향은 안정적으로 관찰된다. 하지만 "Opus 3%, 오픈 7B 20%" 같은 절대 수치는 작업·프롬프트·도메인에 따라 크게 흔들리므로 이 책은 절대값을 주장하지 않는다. 방향(큰 모델일수록 적다, 단 비용과 상충)만 가져간다.
셋째, 공개 표준은 그대로 인용한다. 이 장에는 지어낼 표준 수치가 거의 없지만, temperature 같은 설정값은 모델 API 문서의 공개 사실이다. 검증·분석 작업은 temperature를 낮게(결정론에 가깝게) 두고, 창작 작업은 높게 둔다 — 이건 추정이 아니라 API 동작의 정의다.
그래서 이 장이 실제로 약속하는 측정 가능한 지표는 세 가지다 — [생성] 검출 건수(자기검증이 잡은 환각 수), grep 게이트 거부 건수(수치·ID 불일치 수), 사람 게이트 반려 건수. 이 셋은 분기마다 로그로 셀 수 있고, 회의에서 "느낌"이 아니라 숫자로 말할 수 있다.
| 패턴 | 왜 실패하나 | 처방 |
|---|---|---|
| AI 요약을 양식만 보고 수용 | 환각은 양식이 완벽할 때 가장 안 걸림 | 원본 대조 자기검증(§22.2.2 4단계) |
| 원본 없이 LLM 기억으로 요약 | 대조할 기준점이 없어 검증 불가 | 원본을 입력에 먼저 넣기 |
| 수치 계산을 LLM에 맡김 | 산수는 추정되어 매번 다름 | 계산은 결정론 도구로(§22.2.1) |
| 검증 게이트가 터지면 작업 전체 정지 | 팀이 게이트를 꺼 버림 | exit 0 + 사람 단으로 넘김(§22.2.4) |
| 게이트가 죽으면 미검증 출력 자동 승인 | 환각이 그대로 통과 | 게이트 실패 = '미검증' 표시 |
| 자기검증을 최종 판정으로 믿음 | LLM이 원본을 오독하면 오판도 자신 있게 | 짧은 원본은 사람 grep 병행 |
게임 밖 적용. "자신 있게 거짓을 말하는 동료" — 없는 회의 날짜나 결정을 양식 완벽하게 지어내는 AI — 는 게임 결정 카드만이 아니라 모든 문서 요약에서 똑같이 위험합니다. 환각은 양식이 완벽할 때 가장 안 걸리므로, 원본이 있는 작업(회의록 요약·계약서 발췌·보고서 정리)에서는 출력을 그대로 받지 말고 "원본과 한 항목씩 대조해서, 네가 추가한 내용은 [생성]으로 표시하라"는 자기검증을 강제하는 게 핵심입니다. 예를 들어 법무 보조가 계약서를 요약하게 한 뒤 금액·날짜·조항 번호를 원본과 글자 단위로 대조시키면, AI가 "이 위약금 수치는 제가 추가한 값입니다"라고 자백하며 빈칸을 '미정'으로 되돌립니다. 수치 계산은 아예 AI에 맡기지 말고 계산기·수식에 넘기고, 자동 검증 도구는 실패해도 작업을 막지 않고 "미검증 — 사람 확인"으로 넘기게 설계합니다.
혼자라면 이만큼만: 코드도 hook도 필요 없습니다. 본인이 가진 짧은 문서(회의 메모·패치노트·기획 한 페이지) 하나를 AI에게 요약시킨 뒤, §22.2.2의 4단계 자기검증 프롬프트를 그대로 붙여 넣어 보세요. "원본과 한 항목씩 대조해서, 네가 추가한 내용은 [생성]으로 표시하라"는 한 줄이면 AI가 자기 환각을 스스로 신고하기 시작합니다. 한 번이라도 [생성] 자백을 받아 보면, AI 요약을 그냥 믿으면 안 되는 이유가 몸으로 들어옵니다.
팀이라면 다음 한 단계로 시작하세요. AI가 만드는 결정 카드·요약에 자기검증 단계를 기본 프롬프트로 고정합니다(§22.2.2). 그다음, 수치·결정 ID·날짜처럼 원본과 글자 단위로 같아야 하는 필드만 골라 grep 대조를 코드로 만듭니다. 이때 그 검증 코드는 반드시 inject_memory.py처럼 실패해도 작업을 막지 않게(exit 0 + '미검증' 표시) 설계합니다(§22.2.4). 자기검증과 grep 두 단만 있어도, 양식이 완벽한 환각이 결정 기록으로 새어 드는 가장 흔한 사고를 먼저 막을 수 있습니다.
1차 독자: AI 도구를 팀에 도입하고 비용을 책임지는 기획 리드 (중규모(10~50인) 팀) 1인/취미 독자용 축소 버전: §22.3.9 「혼자라면 이만큼만」
비용 챕터가 가짜 비용을 들면 그 자체로 자기모순이다. 그래서 이 장은 "우리 팀이 월 얼마를 아꼈다"는 매끈한 표를 만들지 않는다. 대신 두 종류의 숫자만 쓴다. 하나는 누구나 확인할 수 있는 공개 토큰 단가(모델별 1M 토큰 요금), 다른 하나는 저자가 직접 운영하는 hook 코드에 고정되어 있는 상수(max_atom_body = 6000, max_matches = 3)다. 둘 다 지어낸 게 아니라 인용한 것이다.
AI 비용이 무서운 건 금액이 커서가 아니다. 안 보여서다. 도입 첫 달은 호출이 적어 청구서가 작다. 그러다 컨텍스트가 길어지고 호출이 잦아지면, 어느 분기 청구서가 자릿수를 바꾼다. 이 장의 결론을 먼저 말하면 이렇다 — 비용은 "아껴 쓰자"는 다짐이 아니라, 매 호출에서 토큰을 강제로 깎는 코드로 통제한다. 사람의 의지가 아니라 wrapper와 truncate가 막는다.
비용 항목은 입력·출력·캐시 적중·캐시 쓰기 네 가지지만, 실무에서 청구서를 지배하는 건 입력 토큰이다. 이유는 단순하다. 게임 기획에서 AI를 쓰는 거의 모든 작업이 "긴 컨텍스트를 넣고 짧은 답을 받는" 형태이기 때문이다. L0 비전 문서, atom 라이브러리, 인접 도시 본문, 데이터 시트 발췌를 다 욱여넣으면 입력이 수만 토큰인데, 출력은 표 한 장이라 수백 토큰이다.
그래서 비용 통제의 1순위는 "출력을 줄이자"가 아니라 "입력 토큰을 어디서 깎느냐"가 된다. 이 한 줄이 이 장 나머지를 끌고 간다.
공개된 모델별 단가부터 못 박아 둔다. 아래는 Anthropic이 공개한 1M(100만) 토큰당 요금으로, 이 책 집필 시점 세대(Opus·Sonnet·Haiku의 당시 최신 등급)의 공개 단가를 그대로 인용한 스냅숏이다(공식 공개 단가 인용 — 모델 세대·시점에 따라 변동하므로 적용 전 현재 가격표 확인 필수). 부록 K가 정리한 원칙대로, 여기서 변하지 않는 것은 단가의 절대값이 아니라 세 등급 사이의 단가 비율이다. 따라서 아래 표는 "오늘의 청구서"가 아니라 "등급을 내릴수록 단가가 자릿수로 떨어진다"는 구조를 읽는 용도로 본다.
| 모델 | 입력 1M 토큰 | 출력 1M 토큰 | 비고 |
|---|---|---|---|
| Claude Opus | $15 | $75 | 최상위 추론 (공개 단가) |
| Claude Sonnet | $3 | $15 | 중간 — 입력가 Opus의 1/5 |
| Claude Haiku | $0.80 | $4 | 경량 — 입력가 Opus의 약 1/19 |
| 캐시 적중(read) | 표준 입력가의 약 1/10 | — | 캐시된 입력 재사용 시 (공개 캐싱 정책) |
핵심은 마지막 두 줄이다. 같은 작업을 Opus 대신 Haiku로 돌리면 입력 토큰 단가가 약 1/19이고, 같은 컨텍스트를 캐시에 태우면 그 부분 입력가가 약 1/10이다. 비용 절감의 두 큰 축이 여기서 나온다 — 모델 적정화와 캐싱. 둘 다 "덜 쓰자"가 아니라 "같은 일을 더 싼 단가로 처리하자"는 구조다.
절약은 의지가 아니라 단가 차이에서 온다. Opus를 Haiku로 내리면 약 19배, 캐시를 태우면 약 10배가 자동으로 줄어든다.
개별 작업 단가보다 더 조용히 누적되는 비용이 있다. 매 호출마다 자동으로 붙는 컨텍스트다. 저자의 개인 PC에는 사용자가 프롬프트를 칠 때마다 관련 메모리(atom)를 자동으로 끼워 넣는 hook이 돈다(UserPromptSubmit hook, inject_memory.py). 이건 편의 기능이지만, 동시에 비용 누수의 1순위 후보이기도 하다. 매 입력마다 긴 atom 본문이 컨텍스트에 들어가니, 통제 없이 두면 입력 토큰이 호출마다 불어난다.
그래서 이 hook에는 비용을 깎는 안전장치가 세 겹으로 고정되어 있다. 추상론이 아니라 실제 코드의 상수다.
# inject_memory.py — UserPromptSubmit hook (실제 운영 코드, 발췌)
# 설계 원칙 (docstring 원문):
# - 항상 exit 0 (실패해도 사용자 흐름 방해 금지)
# - score 내림차순으로 최대 3개 atom 주입
# - atom 본문 6000자 초과 시 truncate
# (1) manifest config에서 예산 상수를 읽는다
max_matches = cfg.get("max_matches", 3) # 한 호출 최대 atom 수
max_body = cfg.get("max_atom_body", 6000) # atom 1개당 본문 상한(자)
# (2) score 내림차순 정렬 — 비싼 슬롯을 가치순으로 채운다
atoms_sorted = sorted(atoms, key=lambda a: a.get("score", 0), reverse=True)
matches = []
for atom in atoms_sorted:
if len(matches) >= max_matches: # (가드 A) 최대 3개에서 끊는다
break
if re.search(atom["regex"], prompt, re.IGNORECASE):
matches.append(atom)
# (3) 본문 주입 시 6000자에서 잘라낸다
for atom in matches:
body = atom_path.read_text(encoding="utf-8")
if len(body) > max_body: # (가드 B) truncate
body = body[:max_body] + "\n\n[...truncated]\n"
여기에 세 겹의 비용 가드가 다 들어 있다.
max_matches = 3): 입력과 매칭되는 atom이 10개여도 최대 3개만 붙는다. atom 17개짜리 라이브러리 전체가 매 호출에 들어가는 사고를 코드가 막는다.max_atom_body = 6000): atom 본문이 12,000자여도 6,000자에서 자른다. 긴 회고 atom 하나가 호출 비용을 두 배로 부풀리는 일이 구조적으로 불가능하다.이 세 상수가 곧 호출당 입력 토큰의 상한이다. 거칠게 어림하면, atom 한 개 6,000자는 한국어에서 대략 수천 토큰 규모다(정확한 토큰 수는 토크나이저·언어에 따라 달라지므로 절대값이 아니라 "상한이 걸려 있다"는 구조로 읽는 게 맞다). 3개 × 6,000자가 한 호출 주입 예산이고, 그 위로는 코드가 잘라 낸다. 사람이 "atom 너무 많이 붙었네"를 눈으로 발견할 필요가 없다.
말로 "truncate가 비용을 막는다"고 하면 공허하다. 실제로 이 상수를 만들 때 AI와 한 사이클을 끝까지 돌렸다. 아래는 그 세션을 충실히 재현한 것이다. 입력 프롬프트는 그대로 복사해 쓸 수 있고, 출력은 실제 세션을 재구성했다.
hook을 처음 가동한 직후, _injection_log.txt에 한 호출에서 atom 본문이 통째로 주입된 기록이 남았다. 긴 atom(회고·건강 컨텍스트 등)이 매 호출 풀 사이즈로 들어가면, 자주 쓰는 작업일수록 입력 토큰이 누적된다. 이걸 어디서 끊을지를 AI에게 물었다.
매 호출마다 atom을 컨텍스트에 자동 주입하는 hook(inject_memory.py)이 있어.
입력 토큰 비용을 막을 truncate 상한을 정하고 싶은데, atom 1개당 본문 글자 수
상한이랑 한 호출에 주입할 atom 개수 상한을 제안해 줘. 너무 짧으면 핵심이 잘리고
너무 길면 토큰이 새니까 그 트레이드오프도 같이 짚어 주고, 확신 안 서는 건
사람한테 넘기게 따로 표시해. (의도: 상한값은 config로 외부화 전제) 표로:
항목 | 제안값 | 근거 | 확신도.
프롬프트의 절반이 "한계와 형식 강제"인 점에 주목한다. 비용 관련 질문에 AI를 자유롭게 풀면 "상황에 따라 다릅니다" 같은 무책임한 답이 나온다. 구체 수치를 강제하고, 트레이드오프를 명시하게 하고, 확신 못 하면 신고하라는 탈출구를 넣는 게 이 프롬프트의 핵심이다.
| 항목 | 제안값 | 근거 | 확신도 |
|----------------|--------|-----------------------------------------------|-------|
| max_atom_body | 6000자 | 한국어 atom 1개의 '핵심+근거'가 대체로 4~6천자 | 중 |
| | | 안에 들어감. 6000자면 본문 머리(요지·규칙)는 | |
| | | 거의 보존되고 꼬리(예시 나열)만 잘림. | |
| max_matches | 3 | 입력 한 건이 동시에 강하게 매칭되는 주제는 | 높음 |
| | | 보통 1~3개. 4개 이상은 regex가 너무 넓다는 신호 | |
| 외부화 위치 | _jit_manifest.json > config | 코드 재배포 없이 운영 중 조정 | 높음 |
| truncate 표식 | [...truncated] 꼬리 부착 | 잘렸음을 모델·사람이 인지 | 중 |
| 확신 못 하는 점 | 6000이 정확값인지 | 토크나이저별 토큰 환산이 언어· | ★낮음 |
| | (자→토큰 환산 불확실) | 모델마다 달라 자 단위는 근사치 | |
출력에서 가장 가치 있는 건 표가 아니라 맨 아래 ★ 줄이다. AI가 "6000이 정확한 최적값인지는 모른다, 자→토큰 환산이 불확실하다"고 스스로 신고했다. 좋은 프롬프트는 AI가 모른다고 말하게 만든다.
이 출력을 그대로 받지 않았다. AI는 6000자를 "핵심이 보존되는 선"으로 제시했지만, 실제로 6000자에서 자르면 무엇이 잘리는지 직접 확인해야 한다. 가장 긴 atom(건강 컨텍스트, 회고 누적본)을 골라 6000자 지점을 눈으로 봤다.
확인 결과 한 건이 걸렸다. 6000자 지점이 본문 한가운데 표 행을 끊어 | 비타민D | 1000IU | 같은 줄이 절반에서 잘렸다. 의미는 보존됐지만, 잘린 표 행이 모델에게 깨진 마크다운으로 들어가는 게 찜찜했다. 그래서 재요청했다.
6000자에서 자를 때 마크다운 표 행 중간이 끊기는 경우가 있어. 절단 위치를
직전 줄바꿈 경계로 스냅하는 보정이 나을까? 구현이 truncate 효과보다 복잡해지면
안 되니까, 비용 대비 가치 따져서 할지 말지까지 결론 내 줘.
AI는 이렇게 답했다 — "줄 경계 스냅은 body[:max_body].rsplit('\n', 1)[0] 한 줄로 가능하나, truncate의 목적이 토큰 상한이지 표 무결성이 아니므로 깨진 표 행 한 줄은 비용·동작에 영향이 미미하다. 현재 [...truncated] 표식만으로 모델이 '여기서 잘렸다'를 인지하므로, 줄 스냅은 하지 않기를 권장한다. 굳이 한다면 한 줄짜리 보정으로 충분하다."
이 답이 옳았다. 비용 통제 코드에 표 무결성까지 욕심내면, 가드 코드 자체가 비대해진다. 현재 운영 코드는 body[:max_body] + "[...truncated]"의 단순 절단을 유지한다. AI의 첫 제안(6000자)을 사람이 검증하고, 과잉 보정 욕심을 AI가 다시 눌러 준, 한 번의 왕복으로 닫힌 사이클이다.
위 세션에서 정한 상수들이 실제 호출에서 어떻게 입력 토큰을 깎는지, 전체 흐름을 도식으로 기록해 둔다.
flowchart TB
A["사용자 프롬프트 입력"] --> B["regex 매칭
atom 라이브러리 17개 순회"]
B --> C{"가드 A
len(matches) >= 3?"}
C -->|"4번째부터 차단"| D["break — 주입 안 함"]
C -->|"슬롯 여유"| E["score 내림차순으로
슬롯 채움"]
E --> F{"가드 B
본문 > 6000자?"}
F -->|"초과"| G["body[:6000] + [...truncated]"]
F -->|"이내"| H["전문 주입"]
G --> I["컨텍스트 주입
(입력 토큰 = 상한 이하 보장)"]
H --> I
D --> I
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
classDef fail fill:#fee2e2,stroke:#dc2626,color:#7f1d1d;
class B,C,E,F,G,H code;
class A human;
class I pass;
class D fail;
이 그림의 요점은 사용자가 무엇을 입력하든 호출당 주입 토큰에 천장이 있다는 것이다. 천장은 3 × 6000자(+표식)이고, 그 위로는 코드가 무조건 잘라 낸다. 비용이 사용자의 절제력에 기대지 않는다. 가드 A·B가 매 호출에서 기계적으로 작동한다.
같은 철학이 도구 레벨에서도 반복된다. 저자의 회사 시스템에는 글로벌 슬롯에 노출되는 wrapper 스킬을 정확히 12개로 고정하는 정책이 있다(atom skill_listing_budget_wrapper_only_policy). 세션 시작 시 글로벌 * wrapper 개수가 12가 아니면 정리 스크립트가 자동으로 돈다. 명목은 "슬롯 정돈"이지만 본질은 세션 시작 토큰 예산 보호다 — 스킬 목록이 컨텍스트에 실리는 비용을 12개 분량으로 묶어 둔 것이다. atom 주입 3개 상한과 스킬 노출 12개 상한은 같은 사상의 다른 적용이다.
가드가 호출당 토큰을 막는다면, 모델 선택은 그 토큰의 단가를 결정한다. §22.3.1 표에서 입력가가 Opus:Sonnet:Haiku ≈ 19:4:1이었다. 그러므로 모든 작업을 Opus로 돌리는 건, 분류·치환 같은 단순 작업에까지 19배 단가를 무는 셈이다.
작업 복잡도에 따라 단가를 배분한다.
| 작업 유형 | 권장 모델 | 이유 |
|---|---|---|
| 검증·법무 직결, 결정 분석 | Opus | 틀리면 사고가 큰 작업 — 단가를 아끼지 않는다 |
| 보고서·요약·자연어 가공 | Sonnet | 품질 필요하나 최상위 추론까지는 불필요 |
| 분류·태깅·키워드 추출 | Haiku | 단순 패턴 — Opus의 약 1/19 단가로 충분 |
| 단순 매핑·치환 | Haiku 또는 결정론 | LLM조차 불필요한 경우가 많음 |
경험상 작업의 대부분은 Sonnet·Haiku로 충분하다. 비싼 모델은 "틀리면 비싼 작업"에만 쓴다. 다만 한 가지 함정이 있다 — 너무 싼 모델로 내리면 환각이 늘어 검증 비용이 절감액을 잡아먹는다(앞 장 §22.2 환각·안전성과 직결). 그래서 모델 배분은 "무조건 싸게"가 아니라 "틀려도 싼 작업은 싸게, 틀리면 비싼 작업은 비싸게"의 분리다.
마지막 줄 "단순 매핑·치환 → 결정론"이 가장 큰 절감일 때가 많다. 이름 치환, 정해진 규칙 매핑처럼 답이 하나로 정해진 일은 LLM을 부를 필요가 없다. 호출 자체를 0으로 만드는 게 가장 싼 호출이다.
호출당 토큰을 막고(가드), 단가를 낮춰도(모델 배분), 같은 컨텍스트를 매 호출 새로 전송하면 비용이 샌다. L0 비전 문서, atom 라이브러리, 분야 스타일 가이드처럼 거의 변하지 않는 긴 입력은 캐싱한다. 캐시 적중 시 그 입력 부분이 표준가의 약 1/10로 청구된다(§22.3.1 표).
# 변하지 않는 컨텍스트는 cache_control로 표시 — 캐시 적중 시 약 1/10가
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": [
{"type": "text", "text": L0_VISION, "cache_control": {"type": "ephemeral"}},
{"type": "text", "text": ATOM_LIBRARY, "cache_control": {"type": "ephemeral"}},
{"type": "text", "text": SPECIFIC_TASK}, # 매번 바뀌는 부분만 캐시 밖
]},
]
핵심은 변하는 부분과 안 변하는 부분을 분리하는 것이다. 캐시는 입력 앞쪽이 동일해야 적중하므로, 고정 컨텍스트(L0·atom)를 앞에, 매번 바뀌는 작업 지시를 뒤에 둔다.
무엇을 캐시에 태울지는 변경 빈도로 가른다.
| 컨텍스트 | 캐싱 | 이유 |
|---|---|---|
| L0 비전 (거의 불변) | 적합 | 며칠~몇 주 단위로만 바뀜 |
| atom 라이브러리 | 적합 | 회고 때만 갱신 |
| 분야 스타일 가이드 | 적합 | 분기 단위 변경 |
| 최근 회의록 | 부적합 | 매일 바뀜 — 캐시 적중률 낮음 |
| 사용자 입력 | 부적합 | 매 호출 고유 |
캐시 TTL은 짧으면 수 분 단위라, 연속으로 같은 컨텍스트를 두드리는 작업(도시 30개 양산처럼 같은 L0를 30번 재사용)에서 효과가 가장 크다. 단발성 질문에는 캐시 쓰기 비용만 들고 적중이 안 나 오히려 손해일 수 있다 — 그래서 "자주·연속으로 같은 컨텍스트를 쓰는 작업"에만 선별 적용한다.
비용 챕터는 "월 $5,000을 $1,000으로 줄였다" 같은 표를 넣고 싶은 유혹이 가장 큰 자리다. 그런 절대 절감액은 팀 규모·작업량에 따라 천차만별이라, 지어내는 순간 비용 챕터가 비용에 대해 거짓말을 하는 자기모순이 된다. 이 장은 세 종류의 숫자만 썼다.
첫째, 공개 단가는 그대로 인용한다. §22.3.1의 Opus $15 / Sonnet $3 / Haiku $0.80(입력 1M 토큰), 캐시 적중 약 1/10은 Anthropic이 공개한 요금이다. 입력가 비율 19:4:1, 캐싱 약 10배 절감은 이 공개 단가에서 산술로 나온 값이다 — 추정이 아니라 계산이다.
둘째, 코드 상수는 코드를 인용한다. max_atom_body = 6000, max_matches = 3은 실제 inject_memory.py와 _jit_manifest.json에 기록된 값이다. 비유가 아니라 실파일이다.
셋째, 모르는 건 모른다고 쓴다. "6000자가 몇 토큰이냐"는 토크나이저·언어·모델에 따라 달라 자 단위는 근사치다. §22.3.3에서 AI도 이 점을 ★로 신고했다. 그래서 이 장 어디에도 "6000자 = N토큰 = $X 절감" 같은 환산 표는 없다. 절대 절감액 대신 방향과 비율(19배·10배)로만 말한다.
이 장의 비용 수치는 공개 단가(Anthropic 요금표)이거나, 코드에 고정된 상수(
inject_memory.py·_jit_manifest.json)이거나, "모른다"고 명시한 근사치다.
| 패턴 | 왜 실패하나 | 처방 |
|---|---|---|
| 모든 작업에 최상위 모델 | 분류·치환에까지 약 19배 단가 | 작업별 모델 배분 (§22.3.5) |
| 매 호출 같은 컨텍스트 재전송 | 캐시 적중 1/10를 버림 | 고정 컨텍스트 캐싱 (§22.3.6) |
| 자동 주입에 상한 없음 | atom 라이브러리 통째로 매 호출 주입 | 개수·길이 가드 (§22.3.2) |
| 비용을 "아껴 쓰자"는 다짐으로 관리 | 사람 절제력은 폭증을 못 막음 | 코드에 상한을 고정함 |
| 결정론으로 될 일까지 LLM 호출 | 가장 싼 호출은 '호출 안 함' | 매핑·치환은 코드로 분리 |
네 번째가 핵심이다. 비용 통제를 사람의 의지에 맡기면 반드시 샌다. 의지는 바쁠 때 가장 먼저 무너지는데, 비용은 바쁠 때 가장 빨리 는다. 그래서 통제는 max_matches = 3 같은 코드 상수여야 한다.
게임 밖 적용. AI 비용이 무서운 건 금액이 커서가 아니라 안 보여서이고, 이건 게임팀이든 마케팅팀이든 똑같습니다. 비용은 "아껴 쓰자"는 다짐이 아니라 구조로 잡습니다. 첫째, 작업 난이도에 맞춰 모델 단가를 배분합니다 — 단순 분류·태깅까지 최상위 모델로 돌리면 같은 일에 몇 배 단가를 무는 셈이고, 단순 매핑·치환은 아예 호출하지 않는 게(규칙·수식으로 처리) 가장 싼 호출입니다. 둘째, 거의 안 바뀌는 긴 입력(회사 소개·정책 문서·용어집)은 캐싱해 재전송 비용을 줄입니다. 예를 들어 고객 문의를 분류하는 일은 경량 모델로 충분하고 복잡한 계약 검토만 상위 모델에 맡기면, 품질을 지키면서 단가를 가릅니다. 자동으로 긴 컨텍스트가 붙는 지점이 있다면 "한 번에 붙는 개수·길이 상한"을 정해 두는 것이, 어느 날 청구서가 자릿수를 바꾸는 사고를 구조적으로 막습니다.
혼자라면 이만큼만: hook도 manifest도 없어도 됩니다. 본인이 자주 쓰는 AI 도구에서, 다음 작업 한 건의 모델을 한 단계 내려 보세요(Opus로 하던 요약을 Sonnet으로, Sonnet 분류를 Haiku로). 출력 품질이 충분하면 그 작업은 영구히 더 싼 단가로 고정됩니다. "이 작업이 정말 최상위 모델이 필요한가"를 작업당 한 번씩만 물어도, 절감의 절반은 거기서 나옵니다.
팀이라면 다음 한 단계로 시작하세요. 자동으로 컨텍스트를 주입하는 지점(hook·시스템 프롬프트·RAG)을 하나 찾아, 거기에 §22.3.2의 두 가드(주입 개수 상한 1개, 본문 길이 상한 1개)를 코드로 넣습니다. inject_memory.py처럼 상한을 config로 외부화하면, 운영 중에 코드 재배포 없이 숫자만 조정할 수 있습니다. 가드 두 줄이 "어느 날 청구서가 자릿수를 바꾸는" 사고를 구조적으로 막습니다.
setup → prompt → verify로 요약하면 — setup: 자동 주입 지점에 개수·길이 상한 상수를 넣고 config로 뺍니다. prompt: §22.3.3 형식으로 상한값을 AI에게 제안받되 트레이드오프와 확신도를 강제합니다. verify: 가장 긴 입력을 골라 상한 지점에서 무엇이 잘리는지 직접 눈으로 확인합니다.
max_matches=3·6000자 truncate)로 한다.1차 독자: AI 도입을 책임지는 게임 디렉터·리드 (중규모(10~50인) 팀) 1인/취미 독자용 축소 버전: §22.4.9 「혼자라면 이만큼만」
출시 두 달 전, 컨셉 아티스트가 만든 도시 일러스트 한 장을 두고 회의가 멈춘 적이 있다. 누군가 물었다. "이거 AI로 뽑은 거 맞죠? 그럼 저작권은 우리 건가요, 아니면 등록도 안 되나요?" 아무도 답하지 못했다. 그 자리에서 나온 의견은 세 갈래였다. "AI가 만들었으니 우리 게 아니다", "우리가 돈 내고 돌렸으니 우리 거다", "법이 아직 없으니 그냥 쓰자". 셋 다 틀렸다. 그리고 이 질문은 단순한 법무 이슈가 아니었다. 그 일러스트를 만든 아티스트의 역할이 무엇인지, 팀이 AI 사용을 어떻게 합의했는지가 그 자리에서 한꺼번에 걸려 있었다.
이 장은 저작권과 윤리를 따로 다루지 않는다. 실무에서 둘은 같은 질문의 앞뒤이기 때문이다. "이 산출물의 권리가 누구 것이냐"(저작권)는 "이 산출물에 사람이 얼마나 개입했느냐"(윤리·역할)로 곧장 환원된다. 한국저작권위원회가 2025년에 못 박은 등록 요건이 정확히 그 지점이다. 그래서 이 장의 척추는 하나의 워크드 트랜스크립트가다 — AI 컨셉 아트 한 장의 저작권 등록 가능성을 실제로 판정하고, 그 판정이 팀 역할 합의로 어떻게 이어지는지를 입력에서 결정까지 끝까지 따라간다.
저자 실제 운영 메모 이 장에서 인용하는
design_intent_vs_automation_boundaryatom과_economy_log·_roi_report.md는 저자가 회사에서 실제로 운영하는 거버넌스 자산을 익명화한 것이다. atom 이름·로그 파일명은 실제 운영명을 그대로 옮겼다(IP 보호를 위해 회사·프로젝트 고유명만 치환). 워크드 트랜스크립트의 출력은 실제 판정 세션을 재구성한 것이다.
AI 저작권을 "법이 아직 없으니 흐릿하다"고만 적는 책이 많다. 절반만 맞다. 2025년 6월, 문화체육관광부와 한국저작권위원회가 「생성형 AI 활용 저작물의 저작권 등록 안내서」를 발표하면서, 적어도 한국에서 등록 가능 여부의 선은 분명해졌다. 지어낼 필요가 없다.
안내서의 핵심은 한 문장으로 줄어든다. 저작권 등록의 요건은 '인간의 창작적 기여'다. 여기서 두 종류가 갈린다.
| 구분 | 정의 | 등록 |
|---|---|---|
| GAI 산출물 | 인간의 창작적 기여 없이 AI가 낸 결과물 | 불가 |
| GAI 활용 저작물 | 인간이 AI를 도구로 써서 만든 결과물 중 창작적 기여가 인정되는 부분 | 가능 |
그리고 안내서는 '활용 저작물'로 인정받는 세 경로를 제시한다. ① 이용자의 저작물을 프롬프트로 넣어 그 창작성이 산출물에 나타난 경우, ② 산출물을 수정·증감한 추가 작업에 창작성이 있는 경우, ③ 산출물을 선택·배열·구성한 데 창작성이 있는 경우다. 판단의 두 축은 '통제 가능성'과 '예측 가능성'이다. 창작자가 표현하려는 바를 명확히 결정하고 그 의도대로 결과물을 끌어낼 수 있어야 창작성이 인정된다.
이 대목이 결정적이다. 안내서가 법적 언어로 말하는 "통제 가능성·예측 가능성"은, 이 책이 §1.1부터 반복한 "기획자는 의도를 제공한다"(planner_provides_intent_not_recommendation atom)와 같은 말이다. AI에게 통째로 맡긴 산출물은 통제·예측이 없으니 저작권도 없고, 사람이 의도를 입력하고 검수·재구성한 산출물은 권리가 따라온다. 저작권 등록 가능성과 좋은 AI 워크플로의 조건이 같은 선 위에 있다.
또 하나의 공개 기준이 있다. 2026년 시행되는 AI 기본법은 생성형 AI 산출물에 투명성 확보 의무(AI 생성 사실 표시)를 부과한다. 등록(권리를 주장하는 쪽)과 표시(사용 사실을 밝히는 쪽)는 별개의 의무다. 권리가 생기든 안 생기든, AI를 썼다는 사실 자체는 밝혀야 한다. 이 두 공개 기준이 이 장에서 AI에게 줄 룰북의 1차 입력이 된다.
서두의 그 일러스트로 돌아간다. 이걸 "느낌"으로 판단하지 않고, §22.4.1의 안내서 기준을 룰북으로 입력해 AI에게 1차 분류를 시킨다. 사람은 마지막 판정만 한다. 아래 입력 프롬프트는 그대로 복사해 쓸 수 있고, 출력은 실제 판정 세션을 재구성한 것이다.
판정의 입력은 일러스트가 아니라 그 일러스트가 어떻게 만들어졌는지의 로그다. 이건 이미 자산 메타데이터에 있으니 추출만 하면 된다.
# asset_concept_city021_v4.meta.yaml — 판정 대상 산출물의 생성 이력
asset_id: concept_city021_v4
asset_type: concept_illustration
created_by: 팀원 A (컨셉 아티스트)
generation_log:
- step: 1
actor: 팀원 A
action: "직접 그린 도시 레이아웃 러프 스케치를 입력 이미지로 첨부"
- step: 2
actor: AI (image_model)
action: "러프 + 프롬프트로 4종 변형 생성"
prompt: "stone observatory tower over sealed magic core, cold arid, scholar guild, muted palette"
- step: 3
actor: 팀원 A
action: "4종 중 1종 선택, 종탑 실루엣·채도·구도 직접 리터칭 (약 40% 영역 재작업)"
- step: 4
actor: 팀원 A
action: "배경 봉인진 문양을 직접 디자인해 합성"
ai_generated_disclosure: true # AI 기본법 표시 의무 충족
첨부한 meta.yaml은 컨셉 일러스트 한 장의 생성 이력이야. 한국저작권위원회
「생성형 AI 활용 저작물의 저작권 등록 안내서」(2025) 기준으로 등록 가능성을
등록가능/부분등록/등록불가 중 하나로 분류해 줘. 근거는 안내서의 두 축(통제
가능성·예측 가능성)으로 각각 한 줄씩 대고, generation_log에서 어느 step이
'창작적 기여'인지 번호로 짚어 줘. 법적으로 단정 못 하는 건 '법무 확인 필요'로
표시해서 사람한테 넘겨. 표로: 분류 | 근거(통제) | 근거(예측) | 기여 step | 법무확인.
프롬프트의 절반이 "근거 강제"와 "법무 탈출구"라는 점에 주목한다. AI에게 법적 판단을 통째로 맡기면 그럴듯한 단정을 토해낸다. 분류 기준(안내서 3구분), 근거 의무(두 축), 기여 step 지목, 그리고 "단정 못 하면 법무에 넘겨라"는 탈출구를 넣는 게 이 프롬프트의 핵심이다.
| 분류 | 근거(통제 가능성) | 근거(예측 가능성) | 기여 step | 법무확인 | |---------|------------------------------|----------------------------|----------|---------| | 부분등록 | 러프 스케치(step1)와 40% 리터칭 | 4종 중 선택은 예측 가능, | 1,3,4 | ★ | | | (step3)으로 표현 의도 직접 통제 | AI 4종 변형 자체는 예측 불가 | | | 분석: - step1(직접 러프 입력): 안내서 ① '본인 저작물 입력으로 창작성 반영' 해당. - step3(40% 리터칭): 안내서 ② '수정·증감 추가 작업' 해당. 가장 강한 기여. - step4(봉인진 문양 직접 디자인): 독립 저작물 합성, 명백한 인간 창작. - step2(AI 4종 변형): 인간의 창작적 기여 없는 순수 산출물 → 이 부분 단독으로는 등록 대상 아님. ★ 법무확인 필요: "40% 리터칭"이 '실질적 개변'에 이르는지는 정량 기준이 없음. 리터칭 비율이 등록 가능 경계선을 넘는지는 변호사 검토 권장. (AI는 비율의 법적 충분성을 단정할 수 없음.)
출력에서 가장 값진 부분은 표가 아니라 맨 아래 ★ 법무확인이다. AI가 "40% 리터칭이 법적으로 충분한지 나는 단정 못 한다"고 자기 한계를 신고하고 사람에게 넘겼다. 좋은 프롬프트는 AI가 모른다고 말할 수 있게 만든다. 자동 거부형이 아니라 자동 신고형이다.
이 출력을 그대로 받으면 안 된다. 디렉터가 룰북으로 한 번 친다. AI는 step4(봉인진 문양)를 "독립 저작물"이라 분류했는데, 생성 이력을 다시 보니 그 문양은 §6.2의 city_hunting_generator가 만든 도시 lore에서 파생된 것이었다. 즉 step4는 순수 창작이 아니라 회사 내부 자산 위에 얹은 2차 작업일 수 있다. 회사 자산이므로 권리 귀속은 명확하지만, "독립 저작물"이라는 AI의 표현은 등록 신청서에 그대로 쓰면 오해를 부른다.
그래서 재요청한다.
step4의 봉인진 문양은 사내 도시 lore 자산에서 파생된 2차 작업이다(독립 신규 창작 아님).
이 사실을 반영해 step4의 기여 성격을 재분류하라.
등록 신청 시 '기존 사내 자산 기반'임을 어떻게 기재해야 하는지도 한 줄로 제안하라.
이 한 번의 왕복으로 닫힌다. AI는 step4를 "독립 저작물"에서 "사내 lore 자산의 2차적 저작물 — 원자산 권리 사내 귀속, 변형 기여는 등록 대상"으로 다시 답했고, 그 판정은 법무 검토로 넘어갔다. 결론은 부분등록 + AI 생성 사실 표시로 확정됐다. 통째로 손으로 하면 법무가 자산마다 생성 이력을 캐물어야 하지만, AI 초안 + 룰북 검수 + 1회 왕복이면 법무는 ★ 표시된 경계 사례에만 시간을 쓴다.
이 한 바퀴가 이 장의 Show 기준이다. "AI 저작권은 흐릿하다"는 문장은, 한 산출물의 생성 이력을 안내서 기준으로 끝까지 분류해 보기 전까지는 공허하다.
위 세션의 판단을 매번 처음부터 하지 않으려면, 안내서 기준을 흐름도로 기록해 둔다. 자산 하나가 들어오면 이 트리를 따라 내려가면 된다. 분기점은 전부 §22.4.1의 공개 기준이다.
flowchart TD
A["AI 산출물 발생"] --> B{"사람이 의도를
입력·통제했나?
(러프·본인저작물·상세지시)"}
B -->|아니오 프롬프트만| C["등록불가 산출물
→ 탐색·컨셉 참고용만
최종 자산 직접 사용 금지"]
B -->|예| D{"산출물을 수정·증감
또는 선택·배열했나?"}
D -->|아니오| C
D -->|예| E{"학습데이터가
명시된 모델인가?
또는 사내 fine-tune"}
E -->|아니오·모호| F["법무 검토 대기
침해 위험 평가 후 결정"]
E -->|예| G{"사내 기존 자산
파생인가?"}
G -->|예| H["2차적 저작물
원자산 권리 사내 귀속
+ 변형 기여 등록"]
G -->|아니오| I["부분/전체 등록 가능"]
H --> J["AI 생성 사실 표시
(AI기본법 의무)
+ _economy_log 기록"]
I --> J
F --> J
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
classDef fail fill:#fee2e2,stroke:#dc2626,color:#7f1d1d;
class A ai;
class B,D,E,F,G human;
class J data;
class H,I pass;
class C fail;
트리의 끝(J)이 모든 경로에서 같다는 점이 핵심이다. 등록이 되든 안 되든, 회사 자산이든 2차 저작물이든, AI를 썼다는 사실 표시와 생성 이력 로그는 예외 없이 남긴다. 표시는 권리와 별개의 의무이고, 로그는 사고가 났을 때 책임을 추적하는 유일한 근거다. 서두의 회의가 멈춘 이유는 이 로그가 없어서 step별로 누가 무엇을 했는지 아무도 재구성할 수 없었기 때문이다.
빨간 경로(C, 등록불가)도 그냥 버리는 게 아니다. "프롬프트만 넣은 순수 AI 산출물"은 탐색·컨셉 단계의 참고용으로는 충분히 쓴다. 다만 그걸 최종 자산으로 게임에 넣지 않을 뿐이다. AI 출력을 그대로 출시에 올리는 것이 사후 저작권 사고의 가장 큰 빌미다.
design_intent_vs_automation_boundary트리(§22.4.3)는 판단의 흐름이고, 그 흐름을 매번 같은 선으로 긋게 만드는 것은 atom 하나다. 회사 거버넌스 자산 중 design_intent_vs_automation_boundary가 이 장 전체의 척추다.
이 atom의 한 줄 정의는 "설계 의도는 사람이, 자동화는 도구가 — 그 경계를 자산마다 명시한다"이다. 추상적 구호가 아니다. 이 atom은 JIT hook(inject_memory.py)에 등록돼 있어서, 프롬프트에 "저작권"·"AI 생성"·"자산 등록" 같은 키워드가 들어오면 세션에 자동 주입된다. hook의 설계 원칙이 이 atom의 운영을 그대로 받쳐 준다.
# inject_memory.py — 항상 exit 0, 실패해도 사용자 흐름을 막지 않는다 (발췌)
def main() -> None:
...
# score 내림차순 정렬 후 매칭 — 최대 3개 atom만 주입
atoms_sorted = sorted(atoms, key=lambda a: a.get("score", 0), reverse=True)
matches = []
for atom in atoms_sorted:
if len(matches) >= max_matches: # 과주입 방지
break
try:
if re.search(atom["regex"], prompt, re.IGNORECASE):
matches.append(atom)
except re.error:
continue
if not matches:
emit_empty() # 매칭 없으면 빈 응답 (정상)
return
여기서 거버넌스적으로 중요한 설계가 두 가지다. 첫째, hook은 항상 exit 0이다(스크립트 docstring 명시). 저작권 룰을 주입하다 실패해도 사용자 작업을 절대 막지 않는다. 안전장치가 작업을 인질로 잡으면, 팀은 한두 분기 안에 그 장치를 꺼 버린다. 둘째, 최대 3개만 주입한다. 모든 거버넌스 룰을 매 세션 다 들이밀면 컨텍스트가 터지고 아무도 안 읽는다. score 높은 룰만 떠오른다.
이게 §6.2의 lint가 위반을 자동 폐기하지 않고 작가 게이트로 alert만 올린 것과 같은 철학이다. 의심 후보는 기계가 뽑되, 죽일지 살릴지는 사람이 정한다. 저작권에서도 같다. atom은 "이 자산 저작권 확인했나"를 자동으로 띄우되, 등록 가능 여부의 최종 판정은 사람과 법무가 한다.
서두의 일러스트 판정은 저작권에서 끝나지 않았다. 그 자산을 만든 팀원 A의 일이 "그리는 것"에서 "AI 4종을 선택하고 40%를 리터칭하는 것"으로 옮겨 갔다는 뜻이기 때문이다. 저작권이 "사람의 창작적 기여"를 요구하는 순간, 그 기여를 하는 사람의 역할 정의가 따라 바뀐다. 둘은 같은 사건의 앞뒤다.
여기서 가장 흔한 사고는 이 변화를 통보로 처리하는 것이다. 도구가 좋아도 6개월 뒤 아무도 안 쓰면, 도구가 나빴던 게 아니라 합의가 없었던 경우가 대부분이다. 역할이 양산에서 선택·검수·재구성으로 옮겨 가는 게 도입의 본질인데, 이걸 명시하고 교육으로 받쳐 주지 않으면 팀원은 "내 자리가 사라진다"로 받아들인다.
| 직군 | AI 이전 | AI 이후 (역할 진화) | 저작권상 의미 |
|---|---|---|---|
| 컨셉 아티스트 | 전량 직접 작화 | 의도 입력·선택·리터칭 | 리터칭이 곧 '창작적 기여' |
| 밸런서 | 수동 시뮬 | 시뮬 해석·결정 | 결정 로그가 책임 근거 |
| 기획자 | 명세 전량 집필 | 의도 제공·검수 | 의도 입력이 통제 가능성 |
표가 말하는 한 줄은 이렇다. 저작권 등록을 가능하게 하는 '사람의 기여'가, 곧 역할 진화 후 사람이 하는 일이다. 안내서가 요구하는 통제·예측이 사라지면 저작권도 사라지고 사람의 자리도 사라진다. 그래서 역할 진화는 일자리를 빼앗는 변화가 아니라, 권리와 책임을 사람 손에 남기는 변화로 설명돼야 합의가 된다.
합의는 끝없는 회의가 아니다. 절차로 닫는다. 도입 제안(디렉터) → 전 팀 사전 공유(목적·영향 역할·측정 지표·위험) → 합의 회의(자유 발언·우려 수집) → 필요한 멤버 1:1 → 조정안 발표 → 합의 또는 보류. 모든 멤버의 동의를 받아야 시작하는 건 아니지만, 우려를 절차로 듣고 조정한 뒤 디렉터가 결정한다. 절차가 없으면 합의가 매번 0부터 다시 시작되고, 그 비용이 도입을 지연시킨다.
_roi_report.md로 정직하게윤리를 일자리·합의로만 좁히면 한 축을 놓친다. AI 운영의 비용과 효과를 정직하게 측정해 공개하는 것 자체가 거버넌스다. 측정 없이 "AI로 효율이 올랐다"고만 말하면, 팀원은 그 말을 자기 자리를 줄이는 명분으로 의심한다.
회사 거버넌스 인프라에는 이를 위한 실제 자산이 둘 있다. atom 시스템의 _economy_log/(토큰·시간 경제성 로그)와 _roi_report.md(ROI 보고)다. 전자는 매 세션의 토큰·시간을 기계가 적고, 후자는 그걸 주기로 합산해 사람이 읽는다. 핵심은 이 로그가 "AI가 사람을 얼마나 대체했나"가 아니라 "사람 시간을 어디로 풀어 줬나"를 추적한다는 점이다.
이 책의 수치 원칙은 세 가지 중 하나다. 첫째, 공개 표준은 그대로 인용한다(안내서 등록 요건, AI기본법 표시 의무). 둘째, 저자 추정은 추정이라고 쓴다. 셋째, 측정 가능한 것만 KPI로 약속한다. 저작권·윤리 영역에서 측정 가능한 것은 결과 지표가 아니라 절차 지표다.
| 측정 항목 | 측정 방법 | 약속 가능 여부 |
|---|---|---|
| AI 생성 사실 표시 누락 건수 | 자산 메타 ai_generated_disclosure grep |
측정 가능 (목표 0) |
| 생성 이력 로그 보유율 | generation_log 있는 자산 비율 |
측정 가능 |
| 법무 미검토 채 출시된 AI 자산 | 출시 빌드 vs 법무 통과 목록 대조 | 측정 가능 (목표 0) |
| "AI 때문에 매출이 올랐다" | — | 측정 불가, 약속 안 함 |
마지막 줄이 정직성의 핵심이다. AI 도입의 매출 효과는 단일 변수로 분리되지 않으니 인과를 단정하지 않는다. 대신 "AI 생성 자산 중 표시·로그·법무 검토를 통과한 비율"은 _economy_log와 자산 메타로 실제로 셀 수 있다. 거버넌스가 약속하는 건 결과가 아니라 절차의 무결성이다.
자산 권리·역할·비용을 정리하면 한 영역이 남는다. 사용자가 AI로 만든 콘텐츠를 게임에 올리는 경로다. 회사가 만든 자산은 사내 절차로 닫지만, UGC는 통제 밖의 산출물이 쏟아져 들어온다.
여기서도 §22.4.3 트리의 끝(표시·로그)이 그대로 적용된다. 사용자 업로드 코스튬·길드 엠블럼은 AI 생성 표시를 요구하고, 자동 검수 + 사람 게이트의 결합으로 모더레이션한다. 어느 한 축만 운영하면 다음 분기의 사고가 누적된다. 그리고 사용자 데이터는 LLM에 함부로 보내지 않는다. 개인정보·결제정보는 전송 금지, 행동 로그는 익명화 후 전송이 원칙이다(GDPR·국내 개인정보보호법 준수).
관할은 한국 한 곳으로 끝나지 않는다. 해외 이용자를 받는 순간 그 이용자가 속한 지역의 데이터 규제가 함께 걸린다. EU 이용자에게는 GDPR이 개인정보의 역외 이전·동의·삭제권에 별도 요건을 두고, 다른 서비스 국가도 각자의 개인정보·데이터 현지화 규정을 둔다. 그래서 표 셋째 줄의 '개인정보·결제정보 LLM 전송 금지'는 어느 관할에서나 가장 안전한 기본값이고, 행동 로그를 외부 모델에 보낼 때의 익명화·가명처리 강도는 서비스하는 지역에 맞춰 별도로 점검해야 한다. 다만 이 절은 절차 설계의 안내일 뿐 법률 자문이 아니다. 글로벌 출시·역외 이전이 걸리면 해당 관할의 법무 검토를 반드시 따로 받는다.
| UGC/데이터 | 정책 | 근거 |
|---|---|---|
| 사용자 업로드 코스튬·엠블럼 | AI 표시 + 자동검수 + 사람 게이트 | AI기본법 표시 의무 |
| 캐릭터 닉네임·게시글 | 일반 약관 + 사용자 책임 | — |
| 개인정보·결제정보 | LLM 전송 금지 | 개인정보보호법 |
| 게임 행동 로그 | 익명화 후 전송 | 익명화 가명처리 |
UGC가 늘수록 모더레이션 부담이 커진다. 자동 검수가 1차로 거르고, 경계 사례만 사람이 본다. 이게 §22.4.4의 atom 철학(기계가 후보를 뽑고 사람이 정한다)을 사용자 콘텐츠 차원으로 옮긴 것이다.
| 패턴 | 왜 실패하나 | 처방 |
|---|---|---|
| AI 산출물을 그대로 최종 자산으로 사용 | 인간 기여 0 → 등록불가 + 침해 위험 | §22.4.3 트리, 탐색·컨셉만 |
| 생성 이력 로그 없음 | 사고 시 step별 책임 재구성 불가 | generation_log 자산 메타 의무화 |
| AI 생성 사실 미표시 | AI기본법 투명성 의무 위반 | 트리 끝 표시 단계 예외 없음 |
| 역할 진화를 통보로 처리 | 도구 도입 6개월 뒤 거부 | 합의 절차(§22.4.5) |
| "AI로 효율 올랐다"만 외침 | 팀원이 자리 위협으로 의심 | _economy_log·_roi_report로 측정 공개 |
| 학습데이터 모호 모델 무비판 사용 | 침해 위험 평가 누락 | 명시 모델·사내 fine-tune 우선(트리 E분기) |
다섯 번째가 가장 자주 놓친다. 서두의 일러스트 판정에서 본 것처럼, 저작권 등록을 가능하게 하는 "사람의 기여"는 곧 그 사람의 새 역할이다. 효율만 측정하고 그 사람의 시간이 어디로 풀려났는지를 측정하지 않으면, 거버넌스는 KPI상 성공하고 사람은 떠난다.
게임 밖 적용. "이거 AI로 만든 건데 저작권이 우리 건가요"라는 질문에 회의가 멈추는 일은 게임 일러스트만이 아니라 AI로 만든 보고서·광고 카피·제안서 어디서나 생깁니다. 한국저작권위원회 2025 안내서가 못 박은 기준은 단순합니다 — 등록 가능 여부는 "사람의 창작적 기여(통제 가능성·예측 가능성)"에 달려 있고, 프롬프트만 넣은 순수 AI 산출물은 권리가 없습니다. 그래서 어느 부서든 AI 산출물 하나에 "어느 단계가 사람, 어느 단계가 AI였나"를 step으로 적은 생성 이력 한 장을 남기는 습관이 안전망이 됩니다. 예를 들어 마케터가 AI 카피 초안을 받아 직접 수정·재구성했다면 그 기여를 기록해 두면 권리 주장의 근거가 되고, 등록이 되든 안 되든 "AI를 썼다"는 사실 표시(2026 AI 기본법 의무)는 예외 없이 남깁니다. 권리 다음은 사람이라, 그 기여를 하는 직원의 역할 변화는 통보가 아니라 합의로 닫아야 합니다.
혼자라면 이만큼만: 법무팀이 없어도 됩니다. 본인이 AI로 만든 이미지나 텍스트 산출물 1개를 골라, §22.4.2 형식의
generation_log를 손으로 적어 보세요(어느 단계가 사람, 어느 단계가 AI인지 step으로 나눕니다). 그다음 §22.4.3 트리를 따라 "이건 등록 가능한가"를 직접 판정해 보면, 한국저작권위원회 안내서의 "통제·예측" 기준이 어떤 판단의 묶음인지 몸으로 들어옵니다. 개인·취미 프로젝트라도 AI 생성 사실 표시 한 줄(ai_generated: true)은 남겨 두는 게 좋습니다.
팀이라면 다음 한 단계로 시작하세요. 모든 AI 자산 메타에 generation_log와 ai_generated_disclosure 두 슬롯을 의무화하고(코드 한 줄 grep으로 누락을 잡을 수 있습니다), §22.4.3의 의사결정 트리를 위키 한 장으로 붙여 둡니다. 등록 요건 판정 자동화나 _economy_log 운영은 그다음입니다. 생성 이력 로그와 트리 한 장만 있어도, 서두처럼 회의가 멈추는 일은 막을 수 있습니다.
22부는 거버넌스의 네 축이었다.
| 장 | 핵심 |
|---|---|
| 22.1 | 프롬프트 엔지니어링 — 형식·근거·탈출구 강제 |
| 22.2 | 환각·안전성 — 검수 게이트·신고형 검증 |
| 22.3 | 비용 관리 — 캐싱·cap·_economy_log |
| 22.4 | 저작권·윤리 — 등록 요건·표시·역할 합의 |
네 장을 관통하는 한 문장은 이렇다. 거버넌스는 AI를 막는 장치가 아니라, 사람의 의도와 책임을 산출물에 남기는 절차다. design_intent_vs_automation_boundary atom이 그 절차의 이름이다. 저작권 등록이 요구하는 "통제·예측", 윤리가 요구하는 "역할·합의", 비용이 요구하는 "정직한 측정"은 모두 한 곳을 가리킨다 — AI가 무엇을 하든, 결정과 책임의 마지막 자리는 사람이다.
출처 - 한국저작권위원회·문화체육관광부, 「생성형 AI 활용 저작물의 저작권 등록 안내서」(2025) — https://www.copyright.or.kr/information-materials/publication/research-report/view.do?brdctsno=54253 - 한국저작권위원회·문화체육관광부, 「생성형 AI 저작권 안내서」(2023.12) — https://www.copyright.or.kr/information-materials/publication/research-report/view.do?brdctsno=52591 - 인공지능 기본법(AI 기본법) 생성형 AI 산출물 투명성·표시 의무 (2026 시행) — https://www.shinkim.com/kor/media/newsletter/3142
도구를 늘리지 말고, 도구의 도구를 만들라. 글로벌 12개 진입점 뒤에 본체 48개를 숨기는 2계층 구조와, 그 정합을 사람 손 없이 유지하는 자동화 이야기다.
월간 회고를 돌리던 어느 저녁, 슬래시 명령 목록을 세어 보다가 손이 멈췄다. 마흔 개였다. 분명 반년 전엔 일고여덟 개로 시작했는데, 회의록 도구 하나 만들고, 데이터 검증 도구 하나 붙이고, GDD(Game Design Document, 상세 사양서) 생성기 하나 추가하는 식으로 한 주에 한두 개씩 늘다 보니 어느새 마흔 개가 되어 있었다. 그리고 그중 절반 가까이는 지난 한 달 동안 단 한 번도 호출하지 않았다.
문제는 안 쓰는 도구가 그냥 조용히 거기 있는 게 아니라는 데 있었다. 세션을 시작할 때마다 마흔 개의 슬래시 명령 명세가 전부 로드됐다. 토큰 예산을 잠식했고, 이름이 비슷한 명령들(skill-design·skill-design-new·skill-design-template)이 헷갈렸으며, 정작 필요한 도구를 떠올리는 데 시간이 걸렸다. 도구가 일을 돕는 게 아니라 도구를 관리하는 게 일이 되어 가고 있었다.
이 챕터는 그 마흔 개를 글로벌 열두 개로 줄이면서도 나머지 본체를 단 하나도 버리지 않은 과정을 다룬다. 핵심은 세 패턴이다. 가벼운 진입점을 만드는 Wrapper, 여러 도구를 한 입구로 묶는 Cascade, 진입점과 본체를 물리적으로 연결하는 Junction. 그리고 이 셋의 정합을 사람 대신 지키는 sync_skills.py다.
도구가 많다는 인상은 누구나 갖는다. 하지만 인상만으로는 무엇을 줄여야 할지 결정할 수 없다. 결정을 가능하게 한 건 월간 회고의 도구 경제성 측정이었다.
이 프로젝트는 회고를 자가개선 메커니즘으로 운영한다. 일간 회고가 쌓여 주간으로, 주간이 월간으로 합쳐지는 동안, 월간 회고는 "지난 한 달 어떤 도구를 몇 번 썼나"를 SVN 커밋 로그에서 역산한다. 이 측정에 쓰는 점수가 skill_audit_score다. 각 슬래시 명령이 실제 작업 산출물에 얼마나 등장했는지를 커밋 이력으로 추적해 사용 빈도를 매긴다.
그 달 측정에서 드러난 분포는 다음과 같았다. (사용량 비율은 SVN 커밋 로그 기반 실측이며, 절대 호출 횟수가 아니라 도구별 등장 비중이다.)
상위 열두 개가 전체 사용량의 92%를 차지했고, 월 1회도 안 쓰는 명령이 열여덟 개로 전체의 45%였다. 답은 절반쯤 정해진 셈이었다. 자주 쓰는 열두 개만 글로벌에 노출하고 나머지를 정리한다.
문제는 "정리"가 "삭제"는 아니라는 점이었다. 안 쓰는 스물여덟 개도 분기에 한두 번은 필요했다. 반기 보고서를 쓸 때나, 새 데이터 스키마를 만들 때나, 특정 검증을 돌릴 때. 그때 도구가 없으면 작업이 그 자리에서 멈춘다. 그러니까 진짜 질문은 이거였다. 어떻게 열두 개만 보이게 하면서, 스물여덟 개를 살려 둘 것인가.
책상 비유가 이 챕터 전체를 관통한다. 책상 위에 펜 마흔 자루를 늘어놓고 매일 쓰는 사람은 없다. 자주 쓰는 열두 자루만 책상 위에 두고, 나머지는 서랍에 넣는다. 서랍 안에서도 같은 종류는 한 통에 모은다. Wrapper는 책상 위에 둘 가벼운 진입점, Junction은 서랍과 책상을 잇는 통로, Cascade는 한 통에 묶어 둔 펜 다발이다.
Wrapper는 슬래시 명령의 얇은 껍데기다. 글로벌에는 진입점만 두고, 실제 로직은 workspace의 본체에 둔다. 글로벌 디렉터리에는 50줄짜리 안내문이, 본체에는 500줄짜리 구현이 산다.
flowchart LR
subgraph G["글로벌 ~/.claude/skills/ (책상 위)"]
W1["proj-meeting
Wrapper · 50줄"]
W2["proj-gdd
Wrapper · 50줄"]
end
subgraph B["workspace/skills/ (서랍 안)"]
M1["proj-meeting/
SKILL.md + 추출·분류 .py
약 500줄"]
M2["proj-gdd/
SKILL.md + 생성기
약 500줄"]
end
W1 -->|호출| M1
W2 -->|호출| M2
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
class W1,W2,M1,M2 code;
이 분리가 만드는 이득은 다섯 가지다. 세션 시작 시 글로벌엔 50줄만 로드되니 토큰을 절약하고, 본체는 매일 수정해도 글로벌 슬롯에 영향이 없으며, 본체를 SVN이든 Git이든 어디든 둘 수 있고, 본체는 팀 공유 폴더에 두고 Wrapper만 개인 글로벌에 두어 공유가 쉽고, Wrapper 형식을 통일하면 사용자 경험이 일관된다.
Wrapper의 표준 형식은 다음과 같다. 모든 Wrapper가 이 골격을 공유한다.
---
name: proj-meeting
description: 회의록 분석·결정 추출 (본체: workspace/skills/proj-meeting/)
---
# /proj-meeting — Wrapper
본체 위치: workspace/skills/proj-meeting/SKILL.md
## 동작
이 Wrapper는 본체의 진입 스크립트를 호출한다. 상세 로직은 본체에 정의됨.
본체가 변경되면 이 Wrapper의 description만 갱신하면 된다(자동 동기화 권장).
핵심은 description 한 줄과 본체 포인터뿐이라는 점이다. 로직이 들어가는 순간 Wrapper가 무거워지고, 본체와의 동기화가 깨지기 시작한다. 그래서 Wrapper는 100줄 이내 유지를 룰로 강제한다.
이 프로젝트의 글로벌 슬래시 명령 슬롯은 열두 개로 묶여 있다. 열두 개 안에 자주 쓰는 도구가 전부 들어가야 하고, 선택 기준은 월간 회고가 본다. 월 5회 이상 사용, 분야 균형(한 분야 도구가 여섯 개를 넘지 않을 것), 진입 일관성(이름 규칙 통일). 열두 개를 넘기면 가장 덜 쓰는 하나를 폐기하거나 다른 명령에 합병한다.
열두라는 숫자가 절대적인 건 아니다. 핵심은 숫자가 정해져 있다는 사실 자체다. 소규모(~10인) 팀이라면 열 개가 적당할 수 있고, 분야가 많은 팀이라면 열다섯 개가 맞을 수도 있다. 정해진 상한이 있어야 인지 부담이 일정 수준에 머문다.
Wrapper가 "글로벌엔 가벼운 진입점만 둔다"는 규칙이라면, Junction은 그 규칙을 운영체제 수준에서 구현하는 수단이다. Junction은 디렉터리 심볼릭 링크, 즉 OS가 제공하는 별칭이다.
flowchart LR
U["~/.claude/skills/proj-meeting
(Junction — 별칭)"]
R["workspace/skills/proj-meeting/
(본체 — 실제 파일 한 부)"]
U -. "실제로 가리킴" .-> R
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
class R code;
class U data;
사용자가 글로벌 위치를 들여다보면 본체가 그 자리에 있는 것처럼 보인다. 하지만 실제 파일은 본체 위치에 단 한 부만 존재한다. 글로벌 쪽은 그저 그곳을 가리키는 표지판일 뿐이다.
이 구조가 주는 이득은 명확하다. 본체를 수정하면 글로벌에 즉시 반영된다(복사 단계가 없다). 파일이 한 부만 존재하니 디스크를 아끼고, 글로벌에는 Junction만 있으니 Git 충돌이 없다(본체는 SVN/Git에서 따로 관리). 본체를 옮겨도 Junction만 다시 걸면 사용자에게는 아무 변화가 없다.
OS마다 거는 방식이 다르다. Windows에서는 mklink /J <link> <target>로 디렉터리 정션을 만들며 관리자 권한이 필요 없다. Linux와 macOS는 ln -s <target> <link>, WSL은 Linux 명령을 그대로 쓴다. 이 플랫폼 차이는 뒤에서 다룰 sync_skills.py가 자동으로 처리하므로, 운영자는 OS별 명령을 직접 외울 필요가 없다.
Junction을 쓰지 않고 복사로 운영하면, 본체와 글로벌 사본이 갈라지는 순간 동기화 사고가 난다. 본체에서 버그를 고쳤는데 글로벌 사본은 옛 버전이라 옛 동작을 하는 식이다. Junction은 이 사고의 가능성 자체를 제거한다. 표지판은 두 개일 수 없고, 실체는 언제나 하나다.
Wrapper와 Junction을 손으로 관리하면 결국 마흔 개로 돌아간다. 사람은 정리를 미루고, 정책을 잊고, 예외를 만든다. 그래서 정합 유지를 자동화한다. 그 도구가 sync_skills.py다.
세션이 시작될 때마다 Hook이 이 스크립트를 트리거한다. 스크립트가 하는 일은 다음 흐름이다.
flowchart TD
H["세션 시작 (Hook 트리거)"] --> S["~/.claude/skills/ 스캔"]
S --> C{"열두 Wrapper
정책 일치?"}
C -->|"잔여 슬롯 발견"| X["--cleanup:
정책 외 Wrapper 정리"]
C -->|"본체 이동 감지"| J["Junction 자동 재생성"]
C -->|"일치"| OK["통과"]
X --> OK
J --> OK
OK --> R["글로벌 12 슬롯 정합 보장"]
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
class H,S,C,X,J code;
class OK,R pass;
핵심 기능은 세 가지다. 첫째, 글로벌 디렉터리를 스캔해 열두 Wrapper 정책에 맞는지 검사한다. 둘째, --cleanup 플래그로 정책에 없는 잔여 Wrapper를 정리한다. 누군가 임시로 추가한 도구가 슬롯에 남아 있으면 다음 세션 시작 때 정리되어 슬롯이 다시 폭증하지 않는다. 셋째, 본체 위치가 바뀌었으면 Junction을 자동으로 다시 건다. OS를 감지해 Windows면 mklink /J, 그 외면 ln -s를 골라 호출한다.
세 기능 모두 멱등(idempotent) 하게 설계한다는 점이 중요하다. 세션이 시작될 때마다 자동으로 도는 도구이므로, 같은 상태에 몇 번을 다시 돌려도 결과가 한 번 돌린 것과 같아야 한다. 이미 정책에 맞는 Wrapper는 건드리지 않고, 이미 올바로 걸린 Junction은 다시 걸지 않으며, 정리할 잔여 슬롯이 없으면 아무것도 지우지 않는다. 멱등하지 않으면 세션마다 같은 정리가 덧쌓여 멀쩡한 Junction을 재생성하거나 본체를 잘못 건드리는 사고가 나는데, 매 세션 무인으로 도는 도구에서 이는 곧 동기화 사고로 직결된다. 그래서 sync_skills.py는 "바뀐 것만 손대고, 바뀐 게 없으면 손대지 않는다"를 불변식으로 둔다.
--cleanup의 효과는 토큰 예산 보호로 직결된다. 세션마다 글로벌에 로드되는 슬래시 명세를 열두 개로 묶어 두면, 본체가 마흔여덟 개로 늘어도 세션 시작 비용은 일정하게 유지된다. 사람이 손으로 관리하지 않으니 정책이 흐트러지지 않는다.
이 자동 정합이 2계층 구조의 안전핀이다. Wrapper와 Junction이 구조를 만들고, sync_skills.py가 그 구조를 시간이 지나도 유지한다.
세 패턴과 자동 정합이 합쳐지면 다음 2계층이 완성된다. 위층에는 사용자가 외우는 열두 개의 진입점이, 아래층에는 마흔여덟 개의 본체가 있다.
flowchart TD
subgraph L1["1계층 — 글로벌 12 Wrapper (사용자가 외우는 전부)"]
direction LR
w1["#1"] -.- w12["#12"]
end
subgraph L2["2계층 — workspace 48 본체 (숨겨진 실체)"]
direction LR
b1["본체 1"] --- bN["본체 48"]
end
L1 -->|"Junction으로 연결"| L2
note["sync_skills.py --cleanup:
세션마다 1계층을 12개로 정렬"]
note -.-> L1
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
class w1,w12,b1,bN,note code;
사용자는 글로벌 열두 개만 기억한다. 그 뒤에 본체 마흔여덟 개가 숨어 있어도 인지 부담은 열두 개에 머문다. Wrapper가 진입점을 가볍게 만들고, Junction이 진입점과 본체를 잇고, sync_skills.py가 그 열두 개 정합을 세션마다 지킨다.
비율로 보면 진입점 대 본체가 1대 4다(12 대 48). 도구를 늘려도 사용자가 외울 것은 늘지 않는다. 본체가 예순 개, 여든 개로 늘어도 1계층은 여전히 열두 개다. 이것이 "도구를 늘리지 말고 도구의 도구를 만들라"는 문장의 실제 구현이다. 늘어나는 건 2계층(본체)이고, 사용자가 마주하는 1계층(진입점)은 일정하다.
2계층 구조가 "많은 도구를 적은 진입점으로 줄이는" 패턴이라면, Cascade는 "자주 함께 쓰는 도구들을 한 번의 호출로 묶는" 패턴이다. 한 슬래시 명령이 여러 하위 도구를 순차로 호출하고, 결과를 종합 리포트 하나로 낸다.
이 프로젝트의 대표 Cascade는 check다. 매일 아침 기획 데이터의 무결성을 검사하던 네 도구를 하나로 통합했다.
flowchart TD
E["/check (Wrapper · Cascade 입구)"] --> S1["doc-audit
마크다운 정합성"]
S1 --> S2["data-qa
데이터 시트 검증"]
S2 --> S3["integrity
외래 키 정합성"]
S3 --> S4["link-check
Wikilink 무결성"]
S4 --> R["종합 리포트
(실패만 상세, 통과는 요약)"]
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
class E,S1,S2,S3,S4 code;
class R data;
예전에는 매일 아침 네 도구를 따로따로 호출했다. 문서 검사 한 번, 데이터 검사 한 번, 외래 키 검사 한 번, 링크 검사 한 번. 작업 한 사이클당 서너 번의 수동 호출이 들었다. check는 이 넷을 한 명령으로 묶어, 한 번 호출하면 네 단계가 순서대로 실행되고 결과가 하나로 합쳐진다.
Cascade 설계에는 원칙이 있다. 각 단계는 단독으로도 호출 가능해야 한다(data-qa만 따로 부를 수 있어야 한다). 실패 시 중단할지 계속할지는 단계마다 설정한다. 검증 작업은 한 단계가 실패해도 나머지를 계속 돌려 전체 그림을 보고, 변경 작업은 한 단계가 실패하면 즉시 멈춘다. 결과는 누적되어 다음 단계 입력이 되고, 종합 리포트는 Cascade마다 같은 형식을 쓴다.
check의 실제 정의는 다음과 같다. (4종 검증을 하나로 통합한 구성이다.)
cascade:
- step: doc-audit
purpose: 마크다운 정합성 (YAML frontmatter·링크·atom 참조)
fail_action: continue
- step: data-qa
purpose: 엑셀 데이터 시트 검증 (스키마·범위·필수 컬럼)
fail_action: continue
- step: integrity
purpose: 외래 키 정합성 (시트 간 참조)
fail_action: continue
- step: link-check
purpose: Wikilink·외부 링크 무결성
fail_action: continue
report:
format: markdown
include_pass: false # 통과 항목은 요약만, 실패만 상세
group_by: severity
fail_action: continue가 네 단계 모두에 걸려 있는 건 이것이 검증 Cascade이기 때문이다. 한 검사가 실패해도 나머지 셋을 마저 돌려 그날의 전체 결함 목록을 한 번에 본다. 리포트는 통과 항목을 요약으로만 접고 실패만 펼쳐, 아침에 봐야 할 것에 시선을 모은다.
Cascade에도 함정이 있다. 단계를 무한히 늘리면 복잡도가 폭발한다. 그래서 12 슬롯 정책처럼 Cascade도 단계 상한을 둔다. 대략 다섯에서 일곱 단계를 넘기면 둘로 쪼개거나 일부를 별도 Cascade로 분리한다.
2계층 구조와 Cascade가 자리 잡으면, 본체를 늘리는 일이 쉬워진다. 글로벌 슬롯을 건드리지 않고 workspace에 본체만 추가하면 되니까. 그런데 바로 여기서 새로운 함정이 생긴다. 추가가 쉬워지면 비슷한 도구가 중복으로 쌓인다.
그래서 본체를 늘릴 때 한 가지 정책을 강제한다. MECE Wrapper 정책이다. 새 도구를 추가하려 할 때 두 갈래로 판단한다. 기존 도구와 영역이 겹치면 신규를 만들지 말고 기존 도구를 증강한다. 영역이 명확히 다를 때만 신규로 만든다. 중복 없이(Mutually Exclusive), 빠짐없이(Collectively Exhaustive) 본체 목록을 유지한다는 뜻이다.
flowchart TD
N["새 도구 필요?"] --> Q{"기존 본체와
영역이 겹치나?"}
Q -->|"겹침"| A["신규 금지 →
기존 도구 증강"]
Q -->|"명확히 다름"| B["신규 본체 추가 허용"]
A --> M["MECE 유지: 중복 없음"]
B --> M
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
class Q human;
class M pass;
이 판단을 회고가 뒷받침한다. skill_audit_score가 SVN 로그에서 도구별 사용 빈도를 측정하므로, "이 도구는 사실 저 도구와 거의 같은 일을 하는데 둘 다 거의 안 쓰인다" 같은 신호가 잡힌다. 그러면 둘을 하나로 합치거나 덜 쓰는 쪽을 본체에서 내린다. 큐레이션은 추가만큼이나 정리가 중요하다.
MECE 정책이 없으면, 2계층 구조가 만든 "본체 추가의 자유"가 오히려 독이 된다. 본체가 1계층 슬롯을 잠식하지 않더라도, 본체 자체가 중복으로 비대해지면 어떤 본체를 써야 할지 다시 헷갈리기 시작한다. 정책이 그 비대화를 막는다.
이쯤에서 처음으로 돌아가 보자. Wrapper도, Junction도, Cascade도, MECE 정책도, 어느 하나 책상머리에서 미리 설계한 게 아니다. 전부 회고에서 발견된 문제에 대한 답으로 생겨났다.
월간 회고의 skill_audit_score가 슬롯 마흔 개와 사용량 92%의 쏠림을 정량으로 드러냈을 때 "열두 개로 제한하자"가 결정됐다. 그 결정 뒤에 "그러면서 스물여덟 개를 어떻게 살릴까"라는 질문이 따라왔고, 그 답이 Wrapper와 Junction이었다. 다음 회고에서 "비슷한 검증 도구 넷을 매일 아침 따로 부르는 게 번거롭다"는 발견이 나왔고, 그 답이 check Cascade였다. 또 다른 회고에서 "본체 추가가 쉬워지니 중복이 쌓인다"는 신호가 잡혔고, 그 답이 MECE 정책이었다.
회고가 없었다면 이 패턴들은 만들어지지 않았다. 만들었더라도 실제 문제와 무관한 오버 엔지니어링이 됐을 것이다. 문제를 측정으로 발견한 뒤 패턴을 도입하는 순서, 그 순서가 도구가 실제로 쓰이게 만든다. 회고가 자가개선의 시작점이라는 21부의 메시지가, 도구 차원에서 이렇게 구체화된다.
여섯 달의 측정값을 도입 전후로 비교한다. 절대 호출 횟수가 아니라 운영 부담의 변화에 주목한다.
| 항목 | 도입 전 | 도입 후 (Wrapper+Cascade+Junction) |
|---|---|---|
| 글로벌 슬롯 개수 | 40개 (폭증) | 12개 (정책 강제) |
| 본체 개수 | 흩어짐·중복 다수 | 48개 (MECE 정리) |
| 세션 시작 글로벌 슬롯 비중 | 큼(40개 명세 로드) | 작음(12개 명세만 로드) |
| 본체 수정 후 글로벌 반영 | 수동 복사 단계 필요 | 즉시(Junction, 복사 없음) |
| 비슷한 검증 도구 호출 | 작업당 3~4회 수동 | 1회(check Cascade) |
도입 첫 달의 측정값은 들쭉날쭉했다. Wrapper 형식이 자리 잡기 전까지 동기화 사고가 두어 번 났고, 열두 개 정책이 강제되기 전까지 슬롯이 열다섯에서 열여덟을 오갔다. 안정화는 두 달째부터였다. sync_skills.py --cleanup이 세션마다 슬롯을 정렬하기 시작한 뒤로 슬롯 폭증은 다시 일어나지 않았다.
표의 "비중"·"필요"·"즉시" 같은 방향 표현은 의도적이다. 환경마다 토큰 비용과 시간이 다르므로 변화의 방향만 적었다. 분명한 건 1계층이 마흔에서 열둘로 고정됐고, 본체 반영에서 수동 복사 단계가 사라졌다는 사실이다.
앞 절들에서 짚은 함정을 한자리에 모은다. 다섯 가지 모두, 구조를 만드는 것보다 시간이 지나도 유지하는 게 더 어렵다는 같은 교훈을 가리킨다.
sync_skills.py --cleanup이 세션마다 정렬(§23.1.4).setup. workspace에 본체 디렉터리를 하나 만드세요(예: workspace/skills/proj-meeting/). 그 안에 SKILL.md와 실제 스크립트를 둡니다. 글로벌 ~/.claude/skills/에는 50줄짜리 Wrapper만 둡니다.
prompt. 다음을 Claude에 요청합니다.
~/.claude/skills/ 의 슬래시 명령 목록을 스캔해줘.
각 명령이 (a) 본체 포인터만 있는 가벼운 Wrapper인지
(b) 로직이 들어간 무거운 명령인지 분류하고,
(b)에 해당하는 것은 본체를 workspace로 분리하고
글로벌엔 50줄 Wrapper만 남기는 변경안을 제시해줘.
그리고 본체를 가리키는 Junction을 OS에 맞게 거는 명령
(Windows면 mklink /J, 그 외면 ln -s)을 출력해줘.
verify. 세 가지를 확인하세요. 첫째, 글로벌 디렉터리의 각 항목이 100줄 이내인가. 둘째, 글로벌 항목을 열면 본체 위치 한 줄과 description만 보이는가. 셋째, 본체를 한 줄 수정하고 글로벌에서 호출했을 때 수정이 즉시 반영되는가(Junction이 제대로 걸렸으면 복사 없이 반영됩니다).
팀도 SVN도 없는 1인 운영이라면 이렇게 줄이세요. workspace를 둘 필요 없이 개인 Git 저장소 하나면 됩니다. 본체는 그 저장소에 두고, 글로벌엔 Wrapper만 둡니다. skill_audit_score 같은 측정 도구가 없어도, 월말에 "이번 달 실제로 호출한 명령"을 직접 손으로 적어 보는 것만으로 쏠림이 드러납니다. 상위 다섯에서 일곱 개만 글로벌에 남기고 나머지는 본체로 내리세요. Cascade는 자주 함께 부르는 도구가 둘 이상 생겼을 때 한 명령으로 묶으면 충분합니다. 자동 정합 스크립트가 부담스러우면, 새 세션을 시작할 때 글로벌 디렉터리를 한 번 눈으로 훑는 습관으로 대체할 수 있습니다. 규모가 줄면 자동화 대신 습관이 같은 일을 합니다.
밤 11시 47분. 데이터 시트를 마지막으로 저장하고 노트북을 덮었다. 다음 날 아침 9시 10분, 커피를 내리는 동안 사내 메신저를 열었더니 채널 상단에 리포트 한 장이 올라와 있었다. 간밤에 갱신된 밸런스 시트 세 장을 외래 키 기준으로 교차 검증하고, 깨진 참조 두 건을 빨간색으로 표시한 마크다운. 내가 작성하지 않았다. 잠든 사이에 만들어졌다.
이 챕터는 그 리포트를 만든 도구 — Hermes Agent를 개인 PC에 올리고, §23.1에서 다룬 Wrapper·Cascade·Junction 운영 위에 얹은 과정의 기록이다. 처음 도입할 때만 해도 Hermes는 Linux 기반이라 Windows에서 쓰려면 WSL2를 거쳐야 했는데, 2026년에 네이티브 Windows 빌드가 나오면서 그 우회가 사라졌다. 결론을 먼저 말하면, 에이전트는 Claude Code를 밀어내지 않았다. 옆자리에 앉았다.
§23.1까지의 운영은 전부 Claude Code 중심이었다. 내가 한 문장 입력하면 도구가 한 번 응답하고, 나는 그 응답을 검토한 뒤 다음 문장을 입력했다. 이 짧은 사이클이 정밀 작업에는 더없이 좋다. 밸런스 수치 하나를 고칠 때 매 단계 확인이 필요한 작업이라면, 사람이 매번 끼어드는 게 맞다.
문제는 길이가 긴 작업이었다. "지난 한 달치 회의록 30건을 다 읽고 결정 사항만 atom 후보로 추출해 달라"는 요청은, 대화 흐름 안에서 처리하면 30번의 왕복이 필요하다. 그 30번 동안 나는 다른 일을 못 한다. 이런 작업은 입력과 출력이 짧게 붙는 도구의 강점이 오히려 약점이 된다.
에이전트는 그 반대 자리를 채운다. 목표만 던지면 — "회의록 30건에서 결정 사항을 atom 후보로 뽑아 리포트로" — 도구를 스스로 골라 쓰고, 중간 단계를 알아서 밟고, 끝나면 결과만 가져온다. 사이클이 길고 자율적이다. 대신 매 단계 사람이 못 본다는 약점이 따라온다.
옆자리 동료에 빗대 보면 이해가 쉽다. Claude Code는 내 문장마다 같이 들여다보는 짝꿍이고, 에이전트는 야간 근무를 자청해 내가 출근하기 전 책상 위에 보고서를 올려 두는 보조 인력이다. 한쪽이 다른 쪽을 해고하는 관계가 아니다. 둘이 같은 책상을 나눠 쓴다.
§23.1에서 글로벌 슬래시 명령 슬롯을 12개로 묶고, 그 뒤에 본체 48개를 Junction으로 숨기는 운영을 만들었다. 도구를 늘리지 않고 도구의 도구를 만든 게 그 결론이었다. 그런데 여기서 또 새 도구를 들이겠다는 건 그 결론과 모순처럼 들린다.
모순이 아니다. §23.1의 12 슬롯 정책은 "사람이 직접 호출하는 도구"의 인지 부담을 다룬 것이다. Hermes가 채우려는 자리는 사람이 호출하지 않는 시간 — 잠든 시간, 회의 중인 시간, 다른 일에 손이 묶인 시간이다. 12 슬롯과 경쟁하는 게 아니라 12 슬롯이 닿지 못하는 시간대를 채운다.
도입 결정의 근거는 한 회고 측정값이었다. 글로벌 도구의 사용 빈도를 SVN 커밋 로그에서 역산하는 skill_audit_score로 한 달치를 돌려 보니, 상위 도구 대부분이 "사람이 깨어 있는 시간에, 짧게, 자주" 쓰는 종류였다. 반면 사용 빈도는 낮지만 한 번 돌면 오래 걸리는 작업 — 회의록 일괄 분류, 데이터 시트 야간 정합성, 빌드 캡처 분석 — 은 매번 "내일 아침에 하지" 하고 미뤄지고 있었다. 미뤄지는 이유가 명확했다. 깨어 있는 시간을 길게 잡아먹기 때문이다.
이 미뤄지는 작업군이 에이전트의 정확한 표적이다.
처음 Hermes를 올릴 때는 Linux 기반이라, Windows 개인 PC에서 쓰려면 WSL2(Windows Subsystem for Linux 2)를 먼저 깔고 그 안에 Hermes를 앉혀야 했다. 지금은 네이티브 Windows 빌드가 있어 그 우회가 필요 없다. 설치는 일반 Windows 애플리케이션과 같다 — 인스톨러를 받아 실행하고, 첫 실행에서 작업 공간 경로와 권한 화이트리스트의 초기값을 잡으면 된다.
이미 WSL2를 쓰고 있거나 Linux 환경을 선호한다면 그쪽 빌드도 그대로 지원된다. 다만 새로 시작한다면 네이티브 쪽이 단순하다. 정확한 인스톨러·버전은 도구가 빠르게 바뀌므로 공식 문서를 따른다.
설치 위치와 무관하게 남는 함정이 하나 있다. Hermes 작업 공간은 빠른 로컬 디스크에 둬야 한다. 네트워크 드라이브나 SVN 작업 폴더를 작업 공간으로 직접 물리면, 야간 정합성 검사 한 번이 몇 분짜리가 될 작업을 수십 분으로 늘려 놓는다. 데이터 시트는 작업 공간 밖에 두고 작업 시작 시점에만 복사해 들이는 게 정석이다. WSL2를 쓴다면 같은 이유로 작업 공간을 리눅스 파일시스템 안에 두고 /mnt/c 같은 윈도우 경로를 건너다니지 않는다.
여기서부터는 실제로 손이 가는 부분이다. 설치 자체보다 "설치 후 무엇을 시키느냐"가 챕터의 핵심이라, 첫 작업 하나를 끝까지 — 프롬프트 전문, 날것 출력, 사람 검증, 재요청까지 — 따라가 본다. 이 작업은 §23.2.2에서 미뤄지던 작업군 중 가장 단순한 것, 야간 데이터 시트 정합성 검사를 골랐다.
주의: 아래 명령 일부는 Hermes의 표면 형태를 보이기 위한 예시 형태다. 인스톨러 URL·하위 명령은 버전마다 바뀌므로 공식 문서를 확인할 것. 워크플로의 구조(목표 → 자율 실행 → 검증 → 재요청)는 도구가 바뀌어도 유지된다.
네이티브 Windows라면 PowerShell에서 공식 install.ps1을 받아 돌린다. 다만 한 줄짜리 iex (irm ...) 원라이너를 그대로 실행하기 전에, 스크립트(약 2,800줄)를 한 번 받아 위험 패턴을 눈으로 훑고 — 그게 출처를 신뢰하는 최소한의 절차다 — 키 설정은 분리해 -SkipSetup으로 본체만 먼저 깐 뒤 hermes setup을 따로 돌리는 쪽이 안전하다. WSL2·Linux를 쓴다면 공식 문서의 해당 설치 절을 따른다.
# 네이티브 Windows — 공식 install.ps1 (먼저 내려받아 검토 후 실행)
irm https://hermes-agent.nousresearch.com/install.ps1 -OutFile install.ps1
# (install.ps1 내용을 확인한 뒤)
.\install.ps1 -SkipSetup
# Python 3.11 · Node · Git · Playwright · 번들 스킬을 함께 확보
# 설치 위치: %LOCALAPPDATA%\hermes\ (hermes 명령을 PATH에 등록 — 새 터미널부터 인식)
# 끝나면: hermes setup
인스톨러는 의존성(Python 3.11·Node 22·Git)을 함께 깔고 본체를 %LOCALAPPDATA%\hermes\에 설치한 뒤 hermes 명령을 PATH에 등록한다(새 터미널부터 인식). 설정·로그·예약(cron)·체크포인트 같은 운영 데이터도 같은 %LOCALAPPDATA%\hermes\ 아래에 남아 재설치해도 보존된다(여기가 함정이다 — ~/.hermes\에는 보조 스크립트만 들어가 있어 헷갈리기 쉽다. 실제 config.yaml·logs\는 전부 %LOCALAPPDATA%\hermes\ 쪽이다). 첫 실행으로 hermes setup을 돌리면 모델 API 키를 묻고, 작업 공간 경로와 권한 화이트리스트의 초기값을 잡는다.
hermes --version
hermes setup
이제 첫 작업을 맡긴다. 에이전트에게 던지는 목표는 Claude Code 프롬프트보다 한 단계 추상적이다. "이걸 이렇게 해 줘"가 아니라 "이 결과를 만들어 둬"에 가깝다. 내가 실제로 넣은 목표 전문은 다음과 같았다.
[프롬프트 전문]
목표: 야간 데이터 시트 정합성 검사.
~/hermes-workspace/sheets/ 의 item_master·drop_table·npc_shop 세 xlsx를 읽고,
drop_table·npc_shop이 참조하는 item_id가 item_master에 실제 있는지 외래 키를
검사해 줘. 깨진 참조(없는 item_id를 가리키는 행)를 다 찾아서, reports/ 에
날짜 파일명 마크다운으로 저장하고. (의도: 통과는 건수만, 실패는 시트명·행번호·item_id 표로)
제약:
- sheets/ 는 읽기만, 절대 수정하지 마.
- 시트를 못 읽거나 컬럼명이 예상과 다르면 멈추고 뭐가 달랐는지 보고해. 추측 금지.
마지막 두 줄이 중요하다. 에이전트는 자율적이라, 막히면 "알아서" 메우려 드는 경향이 있다. 컬럼명이 다르면 비슷한 이름을 골라 진행해 버리는 식이다. 그래서 "막히면 멈추고 보고하라, 추측하지 말라"를 명시적으로 입력해 둔다. 이건 §23.1에서 데이터 시트 검증 Cascade를 짤 때 배운 교훈과 같은 결이다.
[Hermes 날것 출력 — 1차]
요약하지 않고 그대로 옮긴다. 첫 시도는 매끈하게 끝나지 않았다.
[hermes] goal accepted: 야간 데이터 시트 정합성 검사
[hermes] step 1/4 reading sheets...
item_master.xlsx loaded (4,210 rows)
drop_table.xlsx loaded (1,880 rows)
npc_shop.xlsx ERROR
[hermes] npc_shop.xlsx: expected column 'item_id', found columns:
['shop_id', 'sell_item', 'price', 'stock']
[hermes] this conflicts with the constraint: "컬럼명이 예상과 다르면 멈추고 보고".
[hermes] HALTING. reporting instead of guessing.
[hermes] partial result:
- item_master ↔ drop_table 검사는 가능 (item_id 컬럼 양쪽 존재)
- npc_shop는 item_id 컬럼명이 'sell_item'으로 추정되나, 확정 불가하여 미실행
[hermes] awaiting clarification on npc_shop column mapping.
여기가 에이전트를 처음 써 본 사람이 가장 놀라는 지점이다. 도구가 멈췄다. 그리고 멈춘 이유를 정확히 말했다. npc_shop.xlsx의 아이템 참조 컬럼은 내 예상인 item_id가 아니라 sell_item이었다. 제약을 입력해 두지 않았다면 에이전트는 sell_item을 알아서 item_id로 간주하고 진행했을 테고, 나는 아침에 "정상"이라는 리포트만 받았을 것이다. 멈춤이 곧 안전이다.
[사람 검증·거부]
나는 출력을 보고 두 가지를 판단했다.
첫째, item_master ↔ drop_table 검사를 이미 할 수 있다고 보고한 부분은 신뢰할 만하다. 둘째, npc_shop의 sell_item 추정은 맞다 — 실제로 그 시트의 판매 아이템 컬럼명이다. 다만 "추정"으로 진행하지 않은 판단은 옳았다. 그래서 1차 출력을 그대로 받아들이지 않고(거부), 컬럼 매핑을 명시한 재요청을 넣었다.
[재요청 프롬프트]
확인했다. 컬럼 매핑을 명시한다:
- npc_shop.xlsx 의 아이템 참조 컬럼은 'sell_item' 이다. 이것을 item_id 로 간주해 검사하라.
- 나머지 제약은 동일. 'sell_item' 외의 컬럼을 추가로 추측하지는 말 것.
다시 실행하라.
[Hermes 날것 출력 — 2차]
[hermes] resuming with column map: npc_shop.sell_item -> item_id
[hermes] step 2/4 foreign-key check...
drop_table → item_master : 1,880 refs, 2 broken
npc_shop → item_master : 642 refs, 0 broken
[hermes] step 3/4 collecting broken refs...
[hermes] step 4/4 writing report...
~/hermes-workspace/reports/2026-05-25_sheet_integrity.md written
[hermes] done. 2 broken references found. summary:
| sheet | row | bad item_id |
|------------|------|-------------|
| drop_table | 417 | ITM_90412 |
| drop_table | 1356 | ITM_90727 |
두 건의 깨진 참조가 잡혔다. drop_table의 417행과 1356행이 item_master에 없는 item_id를 가리키고 있었다. 아침에 본 빨간색 표가 바로 이것이었다.
이 한 번의 왕복에서 챕터의 메시지가 거의 다 드러난다. 에이전트는 자율적이되 제약 앞에서 멈췄고, 멈춘 자리를 사람이 메우자 끝까지 갔다. 자율과 통제가 충돌하는 게 아니라 맞물린다. 그리고 이 전체 사이클이, 내가 잠든 시간에 한 번 더 돌도록 예약해 두면, 그게 바로 §23.2.5의 야간 자동화가 된다.
첫 작업이 손에 익으면, 미뤄지던 작업군을 하나씩 야간으로 넘긴다. 내가 실제로 얹은 건 세 자리다. 셋의 공통점은 분명하다 — 전부 사람이 깨어 있을 필요가 없는 시간을 일하는 시간으로 바꾼다.
flowchart TD
A["야간 트리거
(매일 23:00, cron)"] --> B{Hermes Agent}
B --> C1["[자리 1] 데이터 시트
야간 정합성 검사"]
B --> C2["[자리 2] 장기 시뮬레이션
100시간 분량 가상 플레이"]
B --> C3["[자리 3] 빌드 캡처
자동 분석 파이프라인"]
C1 --> D1["외래 키 diff
깨진 참조 표"]
C2 --> D2["보스 처치 평균·자원 소비
콤보 분포"]
C3 --> D3["명세 vs 측정 diff
프레임별 추출"]
D1 --> R["[종합] 마크다운 리포트
~/hermes-workspace/reports/"]
D2 --> R
D3 --> R
R --> S["아침 09:00
팀 메신저 채널 자동 배포"]
S --> H["기획자: 결과만 검토
(분석은 잠든 사이 완료)"]
style A fill:#fff3e0,stroke:#e65100
style B fill:#e3f2fd,stroke:#1565c0
style R fill:#e8f5e9,stroke:#2e7d32
style H fill:#fce4ec,stroke:#c2185b
자리 1 — 데이터 시트 야간 정합성. 2.4에서 끝까지 따라간 그 작업을 매일 밤 23시로 예약한다. 간밤에 누가 어떤 시트를 건드렸든, 아침이면 외래 키가 깨진 곳이 표로 떠 있다. 이건 §23.1의 /check Cascade(doc-audit → data-qa → integrity → link-check 4종 통합)가 하던 일과 표면상 닮았지만, 결정적 차이가 하나 있다. /check는 내가 깨어서 호출해야 돈다. 야간 에이전트는 내가 없어도 돈다. 둘은 경쟁하지 않는다 — 낮의 Cascade는 즉시 검증, 밤의 에이전트는 무인 검증으로 역할이 갈린다.
자리 2 — 장기 시뮬레이션. §4.4에서 다룬 전투 시뮬레이션을 시간 축으로 깊게 늘린다. 100시간 분량의 가상 플레이를 돌려 보스 처치 평균 시간, 자원 소비 곡선, 콤보 분포를 측정하는 작업이다. 이건 본질적으로 Claude Code 대화 흐름에 안 맞는다 — 한 번 돌면 몇 시간이 걸리는데, 그 시간 동안 대화창을 붙잡고 있을 수는 없다. 에이전트가 백그라운드에서 돌리고, 끝나면 곡선 그래프와 요약 수치만 가져온다.
자리 3 — 빌드 캡처 자동 분석. QA가 캡처한 빌드 영상이 폴더에 떨어지면, 에이전트가 프레임별로 데이터를 추출해 명세 수치와 실측 수치의 diff를 만든다. 기획자는 영상을 처음부터 끝까지 돌려 볼 필요 없이, "명세는 데미지 120인데 빌드는 108로 측정됨" 같은 diff 줄만 본다. 분석의 지루한 부분 전체가 에이전트 몫이다.
세 자리 모두, 결과를 보는 사람의 시간은 줄지 않는다. 줄어드는 건 분석에 들어가는 사람의 시간이다. 판단은 여전히 사람이 한다.
에이전트의 자율성은 그대로 위험이기도 하다. 사람의 매 단계 확인 없이 파일을 읽고 명령을 돌리는 도구라면, 잘못 풀렸을 때 사람이 그 자리에 없다. §23.2.4에서 "추측 금지"를 명시해 둔 게 우연이 아니다. 다섯 가지 안전 장치는 선택이 아니라 도입 첫날 함께 켜야 하는 묶음이다.
| 장치 | 하는 일 (실제 Hermes 설정 키) | 빠지면 생기는 일 |
|---|---|---|
| 권한 화이트리스트 | 파괴적 명령은 사람 승인을 거치게(approvals.mode: manual), 허용 명령만 화이트리스트(command_allowlist), 비밀값은 로그에서 가림(security.redact_secrets) |
원본 데이터 시트를 자율 수정해 버림 |
| 체크포인트 | 파일 작업 전 스냅샷을 떠 되돌릴 수 있게(checkpoints.enabled, 복원은 /rollback) |
잘못된 가정이 끝까지 굴러가 결과 전체가 오염 |
| 로그 자동 기록 | 게이트웨이·에이전트·에러 로그를 %LOCALAPPDATA%\hermes\logs\에 남김 |
사고 후 "왜 이렇게 됐는지" 추적 불가 |
| 비용 한도 | 한 작업의 턴 상한(agent.max_turns)·터미널 타임아웃(terminal.timeout)·무한 루프 자동 감지(tool_loop_guardrails)·컨텍스트 자동 압축(compression) |
무한 루프에 빠진 작업이 API 청구서를 키움 |
| 폐기 가능 | 언제든 중단(/stop)·예약 일시정지/삭제(cron pause)·서브 작업 타임아웃(delegation.child_timeout_seconds)·안 쓰는 스킬 자동 아카이브(curator) |
잘못 돌기 시작한 야간 작업을 못 멈춤 |
이 다섯은 따로 노는 장치가 아니라 한 묶음으로 작동한다. 권한만 잠그고 비용 한도를 안 걸면, 권한 안에서 무한 루프가 돌며 청구서가 커진다. 로그만 켜고 폐기 수단이 없으면, 사고가 난 걸 보면서도 못 멈춘다. 어느 하나만 빠져도 야간 무인 운영의 사고 확률이 확 올라간다.
실제로 도구를 켜 보면, 이 다섯 개념을 도구가 책에서 그린 것보다 한 단계 더 촘촘하게 구현해 놓은 자리가 몇 군데 있었다. 권한 쪽엔 별도의 정책 엔진(security.tirith_enabled)이 한 겹 더 있어 명령을 규칙으로 거른다. 비용 쪽 무한 루프 감지는 단일 상한이 아니라 "같은 실패 반복"·"진전 없는 반복" 같은 신호를 따로 임계로 잡는다. 그리고 야간 무인 예약(cron)에는 별도 스위치(approvals.cron_mode: deny)가 있어, 사람이 없는 시간대에 파괴적 명령이 잡히면 승인을 기다리지 않고 곧장 거부한다 — 책의 "권한 + 체크포인트"를 한 설정으로 묶은 셈이다. 폐기 쪽 curator는 §21의 "안 쓰는 도구는 폐기한다"가 실제 기능으로 실려 있는 자리다. 다섯 묶음의 골격은 그대로 유지하되, 도구가 더 정교한 자리는 그 키를 켜 두면 된다.
config.yaml에 이 묶음을 입력해 두는 모습은 대략 다음과 같다.
# %LOCALAPPDATA%\hermes\config.yaml (발췌)
approvals:
mode: manual # ① 권한 — 파괴적 명령은 사람 승인을 거침
command_allowlist: # 승인 없이 허용할 명령만 명시
- "python *"
- "rg *"
cron_mode: deny # 야간 무인 cron이 파괴적 명령 만나면 자동 거부
security:
redact_secrets: true # 로그에서 비밀값 가림
tirith_enabled: true # 정책 엔진(규칙 기반 명령 필터) 한 겹 더
checkpoints:
enabled: true # ② 체크포인트 — 파일 작업 전 스냅샷(/rollback 복원)
max_snapshots: 20
retention: 7d
logs:
path: "%LOCALAPPDATA%\\hermes\\logs" # ③ 로그 — gateway/agent/errors
agent:
max_turns: 60 # ④ 비용 — 한 작업 턴 상한
terminal:
timeout: 180 # 터미널 명령 타임아웃(초)
tool_loop_guardrails: # 무한 루프 자동 감지(같은 실패·진전 없음)
enabled: true
compression:
enabled: true # 컨텍스트 자동 압축(토큰 절감)
delegation:
child_timeout_seconds: 600 # ⑤ 폐기 — 서브 작업 타임아웃(/stop·cron pause와 함께)
curator:
enabled: true # 안 쓰는 스킬 자동 아카이브
위임도 한 번에 다 넘기지 않는다. 처음엔 가장 좁고 되돌리기 쉬운 작업(정합성 검사처럼 읽기만 하는 일)만 맡기고, 결과를 며칠 지켜본 뒤 다음 자리로 넓힌다. §23.2.4에서 첫 작업으로 야간 정합성 검사를 고른 것도 같은 이유다 — 읽기만 하니 최악이라도 잘못된 리포트 한 장이 끝이고, 원본은 다치지 않는다.
이 챕터를 갱신하는 시점에 도입은 정착기에 들어섰다. 네이티브 Windows 빌드(v0.16.0) 설치를 마쳤고, hermes setup으로 모델 API 키 등록까지 끝냈다. 첫 자리를 가동해 안전 장치 5종을 실제 설정 키로 하나씩 점검했고, 지금은 실제 자율 작업을 돌리며 손에 익히는 중이다. 솔직히 적자면, 회사 PC가 아니라 개인 PC에서 먼저 검증 중이고 — 회사 도입은 개인 PC에서 안전 장치가 충분히 익은 뒤로 미뤄 두었다. 이건 신중함이라기보다 PC 분리 원칙에 가깝다. 검증 안 된 자율 도구를 팀 데이터에 바로 풀지 않는다.
| 기간 | 활동 | 게이트 |
|---|---|---|
| 1개월 | Hermes 설치(네이티브 Windows v0.16.0) + hermes setup + 첫 작업 |
안전 장치 5종 전부 켜졌는가 |
| 2~3개월 | 자리 2~3개로 확장(회의록 분류·빌드 캡처 분석) | 위임 범위마다 로그 점검 |
| 3~6개월 | 회사 검토 — 개인 PC 검증 결과로 의사결정 | 무인 운영 사고 0건 확인 |
| 6~12개월 | 팀 단위 도입 | 안전 장치가 팀 규약으로 정착 |
단계를 건너뛰는 유혹이 가장 위험하다. 1개월에서 곧장 6개월(팀 도입)로 점프하면, 안전 장치가 개인 한 사람의 습관일 뿐 팀 규약으로 익지 않은 채로 풀린다. 각 단계 끝에서 한 번씩 멈춰 다섯 장치를 점검하는 게 답이다. 빨리 가는 것보다 되돌릴 수 있는 채로 가는 게 중요하다.
"에이전트가 사람을 대체한다"가 가장 흔한 오해다. §23.2.4의 워크드 트랜스크립트가 그 반대를 보여 준다 — 에이전트는 컬럼 매핑 하나에서 멈췄고, 그 판단을 사람이 메웠다. 게임 기획의 핵심 결정은 여전히 사람 몫이고, 에이전트가 가져가는 건 반복과 분석의 지루한 부분이다.
"한 번 설치하면 다 자동"이라는 기대도 위험하다. 첫 한두 달은 오히려 손이 더 간다. 컬럼명 매핑, 권한 범위, 비용 한도를 작업마다 조율해야 하고, 그 조율이 익기 전까지는 매 출력을 사람이 검토한다.
"Claude Code는 이제 구식"이라는 단정은 틀렸다. 둘은 시간대가 다르다. 낮의 정밀 결정은 Claude Code, 밤의 무인 반복은 에이전트. §23.1의 /check Cascade가 사라진 게 아니라, 그 옆에 야간 레인이 하나 더 생긴 것이다.
"오픈소스니까 공짜"라는 인식은 절반만 맞다. 본체는 무료라도 모델 API 호출 비용은 그대로 든다. 그래서 config.yaml의 agent.max_turns·compression 같은 비용 한도가 안전 장치이자 가계부다.
마지막으로 "복잡하고 위험한 작업까지 에이전트가 한다"는 기대가 가장 위험하다. 위험이 큰 작업일수록 사람 통제 아래 둔다. 에이전트에 넘기는 건 단순하고 되돌리기 쉬운 작업부터다. 위임은 신뢰가 쌓인 만큼만 넓힌다.
§23.1의 Wrapper·Cascade·Junction이 Claude Code 운영의 정점이라면, 이 장의 Hermes는 그 운영 위에 야간 레인을 한 줄 더 깐 것이다. 낮의 도구와 밤의 도구가 같은 책상을 나눠 쓰는 그림 — 이게 2026년 시점의 현재이자 가까운 미래의 골격이다.
다음 챕터는 게임 기획자를 위한 도구 큐레이션이다. 12 슬롯 안에 무엇을 넣을지, skill_audit_score로 무엇을 솎아 낼지 — 이 챕터에서 잠깐 스친 큐레이션 기준을 구체 도구 추천으로 풀어낸다.
setup
1. Hermes 네이티브 Windows 인스톨러를 받아 설치하세요(Linux를 선호하면 wsl --install 후 그 안에 설치하는 길도 그대로 있습니다).
2. 빠른 로컬 디스크에 작업 폴더를 만들고, 검사할 데이터 시트를 그쪽으로 복사하세요(네트워크 드라이브·SVN 작업 폴더를 작업 공간으로 직접 물기 금지).
3. hermes setup → 모델 API 키 입력 → 작업 공간 경로·권한 초기값 확인.
4. %LOCALAPPDATA%\hermes\config.yaml에 안전 장치 5종을 켜세요: 권한 승인(approvals.mode: manual·command_allowlist·cron_mode: deny), 비용 한도(agent.max_turns·terminal.timeout·tool_loop_guardrails), 체크포인트(checkpoints.enabled), 로그 경로(logs.path), 그리고 중단 절차(/stop·/rollback) 숙지.
prompt - 목표를 한 단계 추상적으로 던지세요: "이걸 해 줘"가 아니라 "이 결과를 만들어 둬". - 대상·할 일·저장 위치를 번호로 명시하고, 마지막에 반드시 한 줄을 입력하세요: "막히거나 컬럼/형식이 예상과 다르면 멈추고 보고하라, 추측 금지." - 첫 작업은 읽기만 하는 정합성 검사처럼 되돌리기 쉬운 것으로 고르세요.
verify - 1차 출력을 그대로 믿지 말고, 에이전트가 멈춘 자리(컬럼 매핑·형식 불일치)를 사람이 확인하세요. - 멈춤이 옳았으면 매핑을 명시해 재요청하고, 틀렸으면 제약을 다시 입력하세요. - 생성된 리포트의 실패 항목 한두 건을 원본 시트에서 직접 대조해 에이전트 판단이 맞는지 검증한 뒤에야 야간 예약(cron 23:00)으로 넘기세요.
Hermes 설치 없이 에이전트의 감각만 먼저 잡고 싶다면, Claude Code 안에서 백그라운드 실행으로 축소판을 돌려 볼 수 있습니다.
분기 회고를 하다가 글로벌 스킬 폴더를 열었다. 한 줄씩 세어 보니 wrapper가 19개였다. 분명히 12개로 운영하기로 정해 놓고 1년을 굴렸는데, 어느 틈에 7개가 더 붙어 있었다. 더 황당한 건 그중 절반이 무슨 일을 하는 도구인지 이름만 보고는 떠오르지 않았다는 거다. migrate-legacy-enum. 이게 뭐였더라. 마지막으로 쓴 게 언제였더라.
기억이 안 났다. 기억에 의존하는 한 이 질문에는 영원히 답할 수 없다. 그래서 기억 대신 로그를 보기로 했다. 도구 큐레이션은 취향으로 빼는 작업이 아니라, "이 도구를 지난 분기에 몇 번 호출했는가"라는 숫자로 빼는 작업이어야 한다.
이 장은 그 숫자를 어떻게 자동으로 뽑아내고, 그 숫자로 어떻게 도구를 잘라내며, 애초에 도구가 폭증하지 않도록 어떻게 막는지에 대한 기록이다.
큐레이션을 이야기하기 전에 한 가지를 인정해야 한다. 도구는 막지 않으면 반드시 늘어난다. 의지가 약해서가 아니다. 매 작업마다 "이번만 빠르게 처리하려고" 작은 스크립트를 하나 만드는 게 합리적인 선택이기 때문이다. 그 합리적인 선택이 수십 번 누적되면 비합리적인 더미가 된다.
프로젝트 A에서 운영하는 구조는 글로벌 12개 wrapper가 junction으로 workspace의 48개 본체를 가리키는 형태다. 글로벌 쪽은 가볍고, 무거운 본체는 SVN으로 관리하는 workspace에 둔다. 이 구조 자체는 §23.1에서 다뤘다. 문제는 이 12라는 숫자가 가만히 있질 않는다는 거다.
도구가 늘어날 때 무엇이 같이 늘어나는지 보면 왜 막아야 하는지가 분명해진다.
특히 첫 번째, 컨텍스트 토큰 점유는 AI 도구를 쓰는 시대에 와서 더 날카로워진 비용이다. 글로벌 wrapper가 늘어나면 매 세션마다 AI가 "내가 쓸 수 있는 도구 목록"을 읽는 토큰이 늘어난다. 도구 19개의 설명을 읽느라 정작 작업에 쓸 컨텍스트가 줄어든다. 그래서 프로젝트 A의 sync_skills.py에는 --cleanup 옵션이 있어서, junction이 깨졌거나 본체가 사라진 wrapper를 자동으로 정리한다. 이건 토큰 예산을 지키기 위한 위생 작업에 가깝다.
하지만 --cleanup이 잡아주는 건 "깨진" 도구뿐이다. 멀쩡하게 살아 있지만 아무도 안 쓰는 도구는 못 잡는다. 그걸 잡으려면 사용 빈도 데이터가 필요하다.
핵심 아이디어는 단순하다. workspace의 스킬·도구는 전부 SVN에 들어 있다. 그리고 도구를 쓸 때마다 그 도구가 만들어 낸 산출물(시트, 문서, 관계도 HTML 등)이 SVN에 커밋된다. 즉 SVN 로그를 보면 어떤 도구가 실제로 일했는지가 흔적으로 남는다.
그래서 skill_audit_score라는 작은 측정 스크립트를 만들었다. 이름 그대로, 각 스킬에 "감사 점수"를 매긴다. 이 도구를 만들 때 처음부터 코드를 다 짠 게 아니라, AI에게 측정 로직을 설명하고 초안을 받았다. 그 워크드 트랜스크립트를 그대로 남긴다. 요약하지 않고, 헛다리 짚은 부분까지 포함해서 적는다.
글로벌 스킬 wrapper가 12개로 정해져 있는데 어느새 19개로 불어났다. 분기 회고 때 "안 쓰는 도구"를 데이터로 골라내고 싶다. 다음 전제로 측정 스크립트를 만들어줘.
- 각 스킬 도구는 workspace 폴더 안에 하위 폴더로 존재한다 (예:
tools/relation-map-gen/).- 이 도구들이 만든 산출물은 SVN에 커밋된다. 커밋 메시지에는 보통 도구 이름이나 산출물 종류가 들어간다 (예: "relation-map regen", "schema-doc update").
- SVN 로그를 파싱해서, 최근 90일 동안 각 도구가 관여한 커밋 수를 세고 싶다.
- 단순 커밋 수만으로는 부족하다. (a) 최근일수록 가중치를 높게, (b) 마지막 사용일이 오래됐으면 감점, 두 가지를 합쳐서 0~100 audit_score를 내줘.
- 출력은 점수 내림차순 표. 점수가 낮은 도구가 "큐레이션 후보".
- SVN 명령은
svn log --xml -l 2000형태로 받는다고 가정. 파이썬 표준 라이브러리만 써. 절대 경로는 인자로 받게.
좋습니다. 다만 한 가지 짚고 넘어가겠습니다. 커밋 메시지에 도구 이름이 항상 들어간다는 보장이 없습니다. 사람이 커밋 메시지를 자유롭게 쓰면 "시트 수정" 같은 메시지에서는 어떤 도구가 관여했는지 알 수 없습니다. 그래서 두 가지 매칭을 병행하겠습니다.
- 커밋 메시지에서 도구 이름·별칭 키워드 매칭 (느슨)
- 변경된 파일 경로가 해당 도구 폴더 또는 그 도구의 산출물 패턴(예:
*_relation.html)에 속하는지 매칭 (엄격)2번이 더 신뢰할 수 있으니 가중치를 더 주겠습니다. 아래가 초안입니다.
# skill_audit_score.py (Claude 초안 — 검증 전)
import subprocess, sys, datetime, xml.etree.ElementTree as ET
from collections import defaultdict
def svn_log_xml(repo_path, limit=2000):
out = subprocess.run(
["svn", "log", "--xml", "-v", "-l", str(limit), repo_path],
capture_output=True, text=True, check=True)
return ET.fromstring(out.stdout)
def score_skills(repo_path, skills, window_days=90):
root = svn_log_xml(repo_path)
now = datetime.datetime.now(datetime.timezone.utc)
hits = defaultdict(list) # skill -> [days_ago, ...]
for entry in root.findall("logentry"):
date = datetime.datetime.fromisoformat(
entry.find("date").text.replace("Z", "+00:00"))
days_ago = (now - date).days
if days_ago > window_days:
continue
msg = (entry.find("msg").text or "").lower()
paths = [p.text for p in entry.findall("paths/path")]
for sk in skills:
name = sk["name"].lower()
in_msg = name in msg or any(a in msg for a in sk.get("alias", []))
in_path = any(name in (pp or "").lower() for pp in paths) \
or any(pat in (pp or "") for pp in paths
for pat in sk.get("artifact", []))
if in_path:
hits[sk["name"]].append((days_ago, 2.0)) # 엄격 매칭 가중
elif in_msg:
hits[sk["name"]].append((days_ago, 1.0)) # 느슨 매칭
rows = []
for sk in skills:
h = hits[sk["name"]]
if not h:
rows.append((sk["name"], 0, None, len(h)))
continue
# 최근 가중: 최근일수록 1.0, 90일 전이면 0.1
recency = sum(w * (1 - 0.9 * (d / window_days)) for d, w in h)
last_used = min(d for d, _ in h)
# 마지막 사용 감점
stale_penalty = max(0, (last_used - 14)) * 0.5
score = max(0, min(100, recency * 8 - stale_penalty))
rows.append((sk["name"], round(score, 1), last_used, len(h)))
rows.sort(key=lambda r: r[1]) # 점수 오름차순 = 후보 먼저
return rows
if __name__ == "__main__":
REPO = sys.argv[1]
SKILLS = [
{"name": "relation-map-gen", "alias": ["relation", "관계도"],
"artifact": ["_relation.html"]},
{"name": "schema-doc", "alias": ["schema", "명세서"],
"artifact": ["_schema.md"]},
{"name": "gdd-gen", "alias": ["gdd"], "artifact": ["_gdd.md"]},
{"name": "table-creator", "alias": ["table", "테이블"], "artifact": []},
{"name": "excel-reader", "alias": ["xlsm", "vba"], "artifact": []},
{"name": "migrate-legacy-enum", "alias": ["enum", "migrate"],
"artifact": []},
]
print(f"{'skill':22}{'score':>7}{'last_used(d)':>14}{'hits':>6}")
for name, score, last, hits_n in score_skills(REPO, SKILLS):
print(f"{name:22}{score:>7}{str(last):>14}{hits_n:>6}")
스크립트를 실제 SVN 작업 사본에 돌렸다. 첫 출력이 이상했다. table-creator와 excel-reader가 둘 다 artifact 패턴이 비어 있어서, 오직 커밋 메시지에 "table" / "xlsm" 단어가 들어갈 때만 잡혔다. 그런데 이 두 도구는 산출물이 그냥 .xlsm 파일이라 패턴으로 구별이 안 된다. 결과적으로 둘 다 점수가 비정상적으로 낮게 나왔다 — 실제로는 매일 쓰는 도구인데도.
여기서 중요한 판단을 했다. 점수가 낮다고 무조건 자르면 안 된다. 점수가 낮은 이유가 "정말 안 써서"인지 "측정이 도구를 못 잡아서"인지를 사람이 갈라야 한다. AI가 만든 숫자는 후보를 좁혀줄 뿐, 최종 결정은 사람이 한다.
그래서 AI에게 다시 요청했다.
artifact 패턴이 비어 있는 도구는 점수를 신뢰할 수 없으니, 출력에
confidence컬럼을 추가해줘. artifact 매칭이 한 번도 없었던 도구는confidence=LOW로 표시하고, 자동 큐레이션 후보에서 제외해. LOW인 도구는 "측정 불가 — 수동 점검" 으로 따로 묶어줘.
이 재요청으로 출력이 두 묶음으로 갈렸다. 신뢰할 수 있는 점수로 자를 수 있는 도구, 그리고 측정이 약해서 사람이 직접 봐야 하는 도구. 실제로 돌린 결과의 모양은 대략 이랬다 (점수는 저자 작업 사본 기준 실측값, 도구명 일부는 익명화).
| skill | audit_score | last_used(일 전) | confidence | 판정 |
|---|---|---|---|---|
| relation-map-gen | 71.4 | 2 | HIGH | 유지 |
| schema-doc | 58.9 | 5 | HIGH | 유지 |
| gdd-gen | 22.1 | 31 | HIGH | 관찰 |
| migrate-legacy-enum | 0.0 | 측정 안 됨 | HIGH | 큐레이션 후보 |
| table-creator | 4.2 | 1 | LOW | 수동 점검 → 유지 |
| excel-reader | 6.0 | 1 | LOW | 수동 점검 → 유지 |
migrate-legacy-enum은 점수 0, confidence HIGH였다. 90일 동안 이 도구의 폴더도 산출물도 단 한 번도 커밋에 등장하지 않았다는 뜻이다. 기억을 더듬어 보니 작년에 레거시 enum 한 번 마이그레이션하고 끝난, 일회성이어야 했을 작업을 스킬로 박제해둔 거였다. 이게 바로 잘라야 할 도구다. 반대로 table-creator·excel-reader는 점수가 낮았지만 confidence가 LOW였고, 마지막 사용일이 하루 전이었다. 측정이 못 잡았을 뿐 실제로는 매일 쓴다. 자르면 안 된다.
주의: 위 표의 점수 산식(최근 가중 × 8, stale 감점)은 저자가 자기 작업 사본에 맞춰 튜닝한 값이다. SVN 커밋 습관·산출물 패턴이 다르면 계수도 달라진다. 절대 점수보다 "도구 간 상대 순위"와 "confidence 구분"이 이 도구의 본질이다.
skill_audit_score는 측정 도구일 뿐이다. 측정값을 분기 회고에 끼워 넣어 한 바퀴 도는 사이클이 있어야 도구가 실제로 정리된다. 그 사이클이 다음이다.
flowchart TD
A[분기 회고 시작] --> B[skill_audit_score 실행
SVN 로그 90일 파싱]
B --> C{confidence 판정}
C -->|HIGH| D{audit_score 평가}
C -->|LOW| E[수동 점검 큐로 이동
마지막 사용일 직접 확인]
D -->|점수 높음| F[유지]
D -->|중간·하강 추세| G[관찰 — 다음 분기 재측정]
D -->|0 또는 바닥| H[큐레이션 후보 확정]
E --> F
E --> H
H --> I{대체 가능?}
I -->|Wrapper로 흡수| J[기존 도구에 MECE 증강
§23.1 wrapper 정책]
I -->|완전 폐기| K[sync_skills.py --cleanup
junction 제거 + SVN 보관]
J --> L[12 슬롯 회복 확인]
K --> L
L --> A
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
class B,C,K code;
class A,D,E,I human;
class F,L pass;
이 사이클의 두 출구를 구분하는 게 중요하다. 점수가 0인 도구라고 무조건 삭제하는 게 아니다. 그 작업 자체가 사라진 거라면 완전 폐기(--cleanup)로 보내고, 그 작업은 여전히 필요한데 별도 도구로 둘 만큼 자주는 아니라면 기존 도구에 흡수시킨다. 후자가 바로 §23.3.4의 MECE 증강이다.
폐기할 때도 SVN 히스토리에는 코드가 남는다. junction과 글로벌 노출만 거두는 거지, 코드 자체를 영영 지우는 게 아니다. 6개월 뒤 그 작업이 다시 생기면 SVN에서 복원하면 된다. 이 "되돌릴 수 있다"는 안전망이 있어야 사람이 과감하게 자를 수 있다.
측정해서 자르는 것보다 더 좋은 건 애초에 안 만드는 거다. skill_audit_score가 사후 정리라면, MECE wrapper 정책은 사전 억제다.
MECE는 Mutually Exclusive, Collectively Exhaustive — 서로 겹치지 않고, 빠짐없이. 새 도구를 만들고 싶을 때마다 이 두 글자를 던진다. 새 도구가 기존 도구와 겹치는가(ME 위반)? 아니면 정말로 빈 영역을 메우는가(CE 기여)? 프로젝트 A의 wrapper 정책은 여기서 두 갈래로 갈린다.
| 상황 | 정책 | 결과 |
|---|---|---|
| 새 작업이 기존 도구의 영역과 겹친다 | 기존 도구 증강 우선 | 기존 wrapper 본체에 기능 추가, 새 슬롯 안 씀 |
| 새 작업이 명확히 다른 영역이다 | 신규 wrapper 허용 | 12 슬롯 중 하나를 새 도구에 배정 (빼야 할 후보 동반) |
핵심은 "기본값이 증강"이라는 거다. 새 도구를 만드는 건 예외다. 그 예외를 정당화하려면 "기존 어느 도구로도 이 작업이 안 된다"를 증명해야 한다. 이 기본값 하나가 19개로 불어났던 도구를 다시 12개로 끌어내린 진짜 원인이었다.
이게 §23.1의 cascade와도 이어진다. check 같은 cascade는 원래 4종이던 검사 도구를 하나의 호출로 묶은 결과물이다. 4개의 별도 wrapper를 두는 대신, MECE 관점에서 "이건 다 검사라는 한 영역"으로 보고 하나로 흡수한 사례다. 도구 수는 줄었는데 기능은 그대로다. 이게 증강의 모범이다.
AI 어시스턴트는 여기서 위험 요소이자 해법이다. 위험인 이유는, AI에게 "이 작업 처리하는 스크립트 만들어줘"라고 하면 너무 쉽게 새 도구가 나오기 때문이다. 한 번 클릭에 도구 하나가 생기는 환경에서 MECE 규율이 없으면 도구 무덤은 순식간에 만들어진다. 해법인 이유는, AI에게 정책을 먼저 주면 AI가 알아서 "이건 기존 relation-map-gen에 옵션으로 붙이는 게 낫겠다"고 제안하기 때문이다. 도구를 만드는 AI에게 큐레이션 규율도 같이 쥐여줘야 한다.
이 장의 도구를 운영하면서 가장 많이 배운 건, 측정값을 맹신하면 안 된다는 거였다. skill_audit_score는 SVN 로그라는 한 가지 신호만 본다. 그래서 구조적으로 놓치는 게 있다.
excel-reader처럼 시트를 읽기만 하고 산출물을 안 만드는 도구는 커밋을 남기지 않는다. 그래서 confidence를 LOW로 떨어뜨려 수동 점검으로 돌리는 장치가 필수였다.요약하면, 이 도구는 "결정하는 도구"가 아니라 "후보를 좁히는 도구"다. 19개를 한눈에 보고 "어느 걸 의심해야 하나"를 1초 만에 알려준다. 그 의심을 검증하고 자르는 건 사람의 몫으로 남긴다. 측정이 사람을 대체하는 게 아니라, 사람이 봐야 할 곳을 가리켜 줄 뿐이다.
도구 큐레이션 사이클을 한 바퀴 직접 돌려보는 절차입니다.
setup
1. 워크스페이스의 스킬·도구가 버전 관리(SVN/Git) 안에 있는지 확인하세요. 산출물도 같은 저장소에 커밋되고 있어야 합니다.
2. 측정 대상 도구 목록을 만드세요. 각 도구마다 name, alias(커밋 메시지에 등장할 별칭), artifact(산출물 파일 패턴, 있으면)를 적습니다. artifact가 없는 읽기 전용 도구는 비워 둡니다.
prompt (AI에게)
다음 전제로 도구 사용 빈도 측정 스크립트를 만들어줘. (1) 각 도구는 [버전관리 시스템] 로그에 산출물 커밋으로 흔적을 남긴다. (2) 최근 90일 로그를 파싱해 도구별 관여 커밋 수를 센다. (3) 최근 가중 + 마지막 사용일 감점으로 0~100 점수를 낸다. (4) 산출물 패턴(artifact) 매칭이 한 번도 없던 도구는 confidence=LOW로 표시하고 자동 후보에서 제외, 수동 점검으로 분리한다. (5) 출력은 점수 오름차순 표 — 낮은 점수가 큐레이션 후보. 표준 라이브러리만 사용, 저장소 경로는 인자로 받는다.
verify 1. 매일 쓰는 도구가 표 상단(낮은 점수)에 올라왔다면 측정이 틀린 겁니다. 그 도구의 confidence를 확인하세요 — LOW면 정상(측정 불가), HIGH인데 낮으면 alias·artifact 설정을 점검합니다. 2. 점수 0 + confidence HIGH 도구만 큐레이션 후보로 확정하세요. 마지막 사용일을 기억과 대조해 정말 죽은 도구인지 사람이 판단합니다. 3. 후보를 "완전 폐기"와 "기존 도구로 흡수" 둘 중 하나로 보내세요. 폐기는 junction만 거두고 코드는 저장소에 남깁니다. 4. 12 슬롯(또는 본인이 정한 한도)이 회복됐는지 마지막으로 세어 보세요.
도구가 6~8개뿐이고 SVN도 없는 1인 개발이라면 이렇게 줄이세요. 버전 관리는 Git이면 충분합니다. git log --since="90 days ago" --name-only로 변경된 파일 경로를 뽑고, 도구 폴더 이름으로 grep 한 번이면 "어느 도구가 최근에 일했나"가 나옵니다. 스코어링 스크립트까지 안 만들어도 됩니다. 핵심은 숫자의 정밀함이 아니라 기억 대신 로그를 보는 습관 하나입니다. 분기마다 한 번, "지난 90일에 한 번도 안 건드린 도구"를 git 로그로 뽑아 그 도구를 노려보세요. 그 5분이 도구 무덤을 막습니다.
토요일 오후, 와이프가 휴대폰으로 색깔 맞추기 퍼즐을 하고 있었다. 엉킨 실타래를 같은 색 바구니로 분류하는 Yarn Fever라는 게임이었다. 한 판이 끝나면 "또 같은 거네" 하고 닫았다. 갱신이 없으니 금세 질린 것이다.
그 순간 든 생각은 단순했다. 저 루프는 검증된 중독성을 가졌고, 메커니즘 자체는 저작권 대상이 아니다. 동물 테마로 바꾸고, 레벨을 절차적으로 무한히 찍어내면 "또 같은 거" 문제가 사라진다. 혼자, 브라우저에서 바로 도는 HTML 3D로 만들면 와이프 휴대폰에 설치도 필요 없다.
문제는 내가 그래픽 엔지니어가 아니라는 것이다. 24년 차 기획자지만 Three.js로 셰이더를 짜본 적은 없다. 그래서 이 챕터는 "AI와 함께 혼자 게임 한 개를 며칠 만에 굴러가게 만든" 실제 기록이다. 회사 MMORPG(이하 프로젝트 A) 작업과 같은 도구를 쓰되, 도메인 콘텐츠는 단 한 줄도 섞지 않은 분리의 기록이기도 하다.
실제 게임은 critter-sort/ 저장소에 있고, git 태그 v0.1~v0.3으로 사흘간의 결정이 남아 있다. 가공한 사례가 아니라 그 저장소를 그대로 인용한다.
가장 먼저 한 일은 원작을 말로 분해해 AI에게 던지는 것이었다. 첫 프롬프트는 이랬다.
프롬프트 (v0.1 착수): "Yarn Fever라는 캐주얼 퍼즐의 핵심 루프를 동물 테마로 각색해 Three.js + Vite로 만들고 싶어. 루프는 이래: 엉킨 색-덩어리를 같은 색 통으로 분류하고, 임시 슬롯을 초과하면 게임오버. 동물을 분류 대상으로 삼아서, 엉킨 동물 더미를 탭하면 같은 색 둥지(nest)로 보내는 식으로 가자. 로직은 Three.js와 무관한 순수 JS 상태머신으로 짜서 headless 테스트가 되게 해줘. 절차적 무한 레벨(시드 기반)도 넣어줘."
AI는 충실히 따라왔다. 폴더 구조를 game/(순수 로직)과 render/(Three.js)로 갈랐고, state.js·rules.js·generator.js를 먼저 짠 뒤 색칠한 박스 placeholder로 보드를 띄웠다. 며칠이 아니라 한 세션 만에 v0.1이 굴러갔다.
그런데 와이프에게 보여주려고 직접 플레이한 순간 위화감이 왔다. 동물이 화면 위에서 바구니로 깡총 뛰어 들어가는, 흔한 매치 퍼즐이 되어 있었다. 원작의 손맛이 빠져 있었다. Yarn Fever의 정체성은 "분류"가 아니라 엉킨 실타래를 손으로 풀어내는 촉감과 화면을 돌려가며 가려진 색을 확인하는 행위였다. 나는 프롬프트에서 그걸 "동물을 탭하면 둥지로"라는 일반 sort로 납작하게 눌러버렸고, AI는 내가 준 정의에 충실했을 뿐이다.
이게 역설계의 첫 함정이다. 원작을 요약하면 시그니처가 증발한다. 요약은 본질이 아니라 표면을 남기기 때문이다.
여기서 한 가지를 분명히 해둔다. AI는 틀린 답을 준 게 아니다. 내 프롬프트가 틀렸다. 그래서 다음 사이클은 코드 수정이 아니라 정의의 교정이었다.
v0.1을 본 직후, 두 번에 걸쳐 핵심을 다시 명시했다. 첫 교정은 행위를, 둘째 교정은 촉감을 겨냥했다.
프롬프트 (1차 교정 — 행위): "이거 그냥 매치 퍼즐이 됐어. Yarn Fever의 정체성은 '3D 화면을 돌려가며 가려진 색을 확인하고, 풀 순서를 정하는' 거야. 보드를 평면에 흩뿌리지 말고 가운데 높은 둔덕으로 쌓아서, 돌려야 뒤에 가려진 색이 보이게 해줘. OrbitControls로 회전 넣고. 근데 회전이랑 탭이 충돌하잖아 — 어떻게 분리할 거야?"
Claude의 출력 (날것, 요약 없이): "맞습니다. 회전 제스처와 탭이 같은 포인터 이벤트라서 충돌합니다. 거리·시간 임계로 가르는 방식을 제안합니다.
pointerdown에서 좌표·시각을 기록하고,pointerup에서 이동 거리가 9px를 넘거나 누른 시간이 400ms를 넘으면 회전 제스처로 간주해 탭을 무시합니다. 그 이하만 raycaster로 동물을 집어state.tap()을 호출합니다. OrbitControls의 azimuth는 ±54° 정도로 제한해 더미가 화면 밖으로 안 나가게 하겠습니다. 다만 모바일에서 9px이 너무 민감할 수 있으니 실기에서 확인이 필요합니다."
그 9px/400ms 임계는 지금도 src/main.js에 그대로 살아 있다. 군더더기 없는, 직접 검증 가능한 코드다.
// src/main.js — 드래그=회전(OrbitControls) / 짧은 탭=실 풀기
canvas.addEventListener('pointerup', (ev) => {
if (!down || state.status !== 'playing') { down = null; return }
const dist = Math.hypot(ev.clientX - down.x, ev.clientY - down.y)
const dt = performance.now() - down.t
down = null
if (dist > 9 || dt > 400) return // 회전 제스처로 간주
const rect = canvas.getBoundingClientRect()
const x = ((ev.clientX - rect.left) / rect.width) * 2 - 1
const y = -((ev.clientY - rect.top) / rect.height) * 2 + 1
const id = view.pick(x, y, camera)
if (id !== null) state.tap(id)
})
행위는 돌아왔다. 그런데 둘째로 플레이해 보니 여전히 "실타래를 푼다"는 느낌이 없었다. 동물이 그냥 둥지로 이동할 뿐, 풀리는 손맛이 없었다. 둘째 교정.
프롬프트 (2차 교정 — 촉감): "회전은 좋아졌어. 근데 아직 '실을 푸는' 느낌이 없어. 탭하면 동물 몸에 감긴 엉킨 실 가닥이 풀려서 사라지고, 그 실이 바구니로 감겨 들어가는 연출을 넣어줘. 동물 룩도 매끈한 GLB 새 말고, 통통한 니트 봉제인형(아미구루미)으로. 졸린 눈, 볼터치 같은 코지한 디테일. 외부 에셋 안 맞으면 빼고 절차적으로 만들어도 돼."
Claude의 출력 (날것): "두 가지를 분리해 구현하겠습니다. (1) 엉킨 실: 각 동물 몸통에 살짝 어두운 같은 색
TorusGeometry2가닥을 감아 두고, 탭 시 scale을 0.01로 줄여 풀려 사라지게 합니다. (2) 실 감김:TubeGeometry로 동물→바구니 경로의 실을 그려 바구니 쪽으로 감겨 들어가는 트윈. 봉제인형 룩은 절차적으로 갑니다 —MeshStandardMaterial에 knit bump 텍스처를 절차 생성해 niit 질감을 주고, 졸린 눈은 납작한 box, 볼터치는 반투명 핑크 sphere로. 기존 GLB 스타터(새 3종)는 니트 톤과 안 맞으니 제거하되, 동물팩 로드 파이프라인은 폴백으로 살려두겠습니다. 한 가지 짚자면, 절차 생성 동물은 종 다양성이 GLB만 못합니다 — 일단 한 종으로 검증하고 종을 늘리는 게 안전합니다."
마지막 그 경고("종 다양성이 GLB만 못하다" — GLB는 glTF Binary, 외부에서 받아 쓰는 기성 3D 모델 파일 포맷이다)가 바로 v0.3으로 이어진 씨앗이다. AI가 다음 한계를 먼저 말했고, 나는 그걸 다음 마일스톤으로 받았다.
검증은 매번 두 단계였다. headless로 로직이 안 깨졌는지(오류 0), 그다음 브라우저에서 직접 회전·탭으로 손맛을 본다. v0.2 커밋 메시지에 그 검증이 박제돼 있다. "headless 검증: 회전·실풀림·자동클리어 정상, 오류 0."
엉킨 실 2가닥은 지금 src/render/pieces.js에 이렇게 남아 있다.
// src/render/pieces.js — 몸통을 감은 느슨한 실 2가닥(살짝 어두운 같은 색)
const strandMat = new THREE.MeshStandardMaterial({ color: darken(hex, 0.7), roughness: 1 })
const strands = []
const orient = [[0.5, 0.2, 0.0], [1.25, 0.0, 0.6]]
for (let i = 0; i < 2; i++) {
const s = addMesh(g, G.torus, strandMat, [0, byo + 0.02, 0], Math.max(bx, bz) + 0.02, orient[i])
strands.push(s)
}
g.userData.strands = strands // 탭 시 view.js가 이 가닥들을 풀어 사라지게 함
여기서 얻은 교훈을 한 줄로 남긴다.
말로만 보면 "두 번 고쳤다"지만, git 이력은 그 교정이 언제 어떤 형태로 들어갔는지 정확한 시각과 함께 남겼다. 이게 1인 개발에서 회고를 대신한다. 동료가 없어도 커밋이 "왜 이렇게 됐는가"를 증언한다.
| 커밋 | 시각 (2026-05-30) | 무엇이 바뀌었나 | 시그니처 상태 |
|---|---|---|---|
2b2e3bc v0.1 |
14:43 | Yarn Fever 역설계, 순수 로직 + placeholder, 60/60 솔버 통과 | 누락 (일반 sort로 납작해짐) |
70a0117 v0.2 |
15:11 | 회전(OrbitControls ±54°) + 탭/드래그 분리 + 실 풀림 + 아미구루미 | 복원 (핵심 재정의) |
160663c 스냅샷 |
15:31 | v0.2 갤러리 스냅샷 5컷 + README 갤러리 | — |
59b0baf v0.3 |
15:55 | 절차적 아미구루미 8종 + 비비드 캔디 팔레트 | 강화 (종 다양성 확보) |
c5b9a1b 인계 |
16:20 | NEXT_SESSION 세션 인계 포인터 | — |
v0.2 커밋 메시지 본문이 결정 그 자체를 박제했다. "게임 정체성을 '동물 깡총'에서 '화면 돌려가며 귀여운 실타래(니트 봉제인형)를 풀어 같은 색 바구니로'로 바로잡음." 한 시간 반 사이에 게임의 정체성이 한 번 죽었다가 살아난 기록이다.
주목할 디테일 하나. v0.2의 git show --stat을 보면 스타터 GLB 새 3종(Flamingo·Parrot·Stork)이 통째로 삭제됐다. "아트가 니트 톤과 안 맞아서"였다. 외부 무료 에셋이 공짜라고 다 쓰는 게 아니라, 톤이 안 맞으면 지우는 결정. 이건 AI가 아니라 사람이 내린 미감의 게이트다.
public/assets/animals/pack_starter/Flamingo.glb | Bin 77428 -> 0 bytes
public/assets/animals/pack_starter/Parrot.glb | Bin 97024 -> 0 bytes
public/assets/animals/pack_starter/Stork.glb | Bin 76852 -> 0 bytes
v0.2가 남긴 숙제는 "절차 동물은 종 다양성이 GLB만 못하다"였다. v0.3에서 그걸 풀었다. 외부 에셋을 한 개도 추가하지 않고, 코드로 동물 8종을 찍어냈다.
핵심은 src/render/pieces.js의 SPECIES 테이블이다. 종마다 몸통 비율·머리·귀 타입·주둥이·눈 모양을 파라미터로 정의하고, 한 함수가 그 파라미터를 읽어 메시를 조립한다.
// src/render/pieces.js — 종별 실루엣 파라미터
const SPECIES = {
cat: { body: [0.5,0.46,0.48,0.04], ears: 'cat', snout: 0.13, tail: 'cat', eyes: 'sleepy' },
bear: { body: [0.52,0.5,0.5,0.03], ears: 'bear', snout: 0.16, tail: 'none', eyes: 'round' },
bunny: { body: [0.46,0.5,0.46,0.02], ears: 'bunny', snout: 0.12, tail: 'puff', eyes: 'round' },
fox: { body: [0.5,0.44,0.48,0.04], ears: 'fox', snout: 0.2, tail: 'fox', eyes: 'sleepy' },
capybara: { body: [0.58,0.5,0.56,0.02], ears: 'tiny', snout: 0.22, tail: 'none', eyes: 'sleepy' },
pig: { body: [0.54,0.5,0.52,0.03], ears: 'pig', snout: 0.1, nose: true, eyes: 'round' },
frog: { body: [0.56,0.4,0.54,0.05], ears: 'none', snout: 0.1, topEyes: true, eyes: 'none' },
chick: { body: [0.42,0.44,0.42,0.05], ears: 'none', beak: true, tail: 'none', eyes: 'round' },
}
export const SPECIES_IDS = Object.keys(SPECIES) // 8종
귀 모양 하나로 실루엣이 갈린다. 고양이·여우는 뾰족 cone, 곰은 둥근 sphere, 토끼는 길쭉한 sphere, 돼지는 앞으로 꺾인 cone. 개구리는 머리 위로 튀어나온 눈(topEyes), 병아리는 부리(beak). 이 작은 분기들이 8종의 식별성을 만든다. 외부 에셋 0, 코드 한 파일이다.
그런데 절차 생성에는 함정이 있다. "그럴듯해 보이는" 코드가 실제로 식별 가능한 8종을 만드는지는 코드만 봐서 모른다. 그래서 검증은 다시 두 단계였다. headless로 8종이 오류 없이 생성되는지, 그다음 web-screenshot 스킬(headless Chrome)로 실제 렌더를 캡처해 눈으로 8종이 구분되는지. DEVLOG v0.3에 그 결과가 있다. "귀/주둥이/코/부리/꼬리/눈으로 실루엣 구분. 외부 에셋 0, 니트 톤 완전 통일."
레벨의 무한성은 시드 RNG가 책임진다. generator.js는 레벨 번호를 Knuth 곱셈 해시로 시드화하고, mulberry32로 결정적 난수를 뽑는다. 같은 레벨 번호는 항상 같은 보드다.
// src/game/generator.js
export function generateLevel(level, animalPool = null) {
const seed = (level * 2654435761) >>> 0 // Knuth multiplicative hash
const rng = makeRng(seed)
const { C, K, groupsPerColor, M, T } = levelParams(level)
const colors = rng.shuffle(COLORS).slice(0, C)
// ...
for (const color of colors) {
const count = K * groupsPerColor // 항상 K의 배수 → 둥지로 정확히 분해(해결 보장)
// ...
}
}
여기 한 줄이 게임의 공정성을 보장한다. 색당 동물 수를 항상 K(둥지 완성 마리수, 3)의 배수로 강제했기 때문에, 어떤 보드든 둥지로 정확히 나누어떨어진다. 풀 수 없는 레벨이 원천적으로 안 나온다.
설계상 풀린다는 건 증명이 아니다. rules.js에 검증용 그리디 솔버를 넣고, test-logic.mjs로 60개 레벨을 자동 플레이시켜 실제로 전부 클리어되는지 매번 확인한다. 방금 이 챕터를 쓰면서 다시 돌린 실측 출력이다.
$ node scripts/test-logic.mjs
[솔버] 60/60 레벨 클리어
[난이도 커브] (C=색, K=완성, groups, M=둥지, T=트레이, 총마리)
Lv 1: C=3 K=3 grp=2 M=3 T=7 총=18
Lv 8: C=4 K=3 grp=3 M=4 T=6 총=36
Lv12: C=5 K=3 grp=3 M=4 T=5 총=45
Lv20: C=5 K=3 grp=3 M=4 T=4 총=45
[막무가내 플레이] 무작위 탭 시 패배율 (난이도 존재 확인)
Lv 1: 무작위 패배율 0%
Lv12: 무작위 패배율 1%
Lv20: 무작위 패배율 3%
이 테스트는 두 가지를 동시에 증명한다. 그리디 솔버가 60/60을 깬다는 건 모든 레벨이 풀린다는 것(난이도가 불가능하지 않다)이고, 무작위 탭의 패배율이 레벨이 오를수록 0%→3%로 올라간다는 건 난이도가 실재한다는 것(아무렇게나 눌러도 다 깨지면 게임이 아니다)이다. 트레이가 7칸에서 4칸으로 좁아지는 난이도 커버가 패배율로 측정된다.
여기서 정직하게 짚는다. 무작위 패배율 3%는 "막 누르는 봇"의 패배율이지 사람의 난이도 체감이 아니다. 사람은 회전으로 색을 미리 확인하므로 패배율은 더 낮다. 이 수치는 "난이도가 0이 아니다"라는 방향 증명이지, 와이프가 3% 확률로 진다는 뜻이 아니다. 사람 체감 난이도는 v0.3 시점에 아직 측정 전이었고, NEXT_SESSION에 "와이프분 플레이 피드백 수집 (최우선)"으로 남겨 두었다.
flowchart TD L["레벨 번호 N"] --> H["Knuth 해시
N × 2654435761"] H --> S["mulberry32(seed)
결정적 RNG"] S --> P["levelParams(N)
C·K·M·T 산출"] P --> G["generateLevel
색당 = K의 배수"] G --> B["보드(엉킨 더미)"] B --> R["createCritter
아미구루미 8종 + knit 셰이더"] G --> V["greedySolve
60/60 검증"] V -->|"오류 0"| OK["클리어 보장"] R --> SC["web-screenshot
8종 식별 시각 검증"] classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545; classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b; classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d; class H,S,P,G,R,V,SC code; class L,B data; class OK pass;
시드에서 출발해 파라미터·보드·메시·검증으로 갈라지는 이 흐름이 "갱신이 없어 질린다"는 최초 문제를 구조적으로 푼 답이다.
절차 동물 8종은 GLB가 없을 때의 폴백이다. 나중에 진짜 아미구루미 GLB를 구하면 그걸 우선 쓰도록, 동물팩 파이프라인을 살려뒀다. 폴더에 GLB를 드롭하고 npm run scan만 돌리면 끝이다.
문제는 GLB마다 크기가 제각각이라는 점이다. 어떤 모델은 0.5유닛, 어떤 건 200유닛. 손으로 scale을 맞추면 동물팩 추가가 노동이 된다. 그래서 scan-packs.mjs가 GLB의 바운딩박스를 읽어 목표 높이(0.95유닛)에 맞는 scale을 자동 계산한다.
// scripts/scan-packs.mjs — GLB 바운딩박스에서 scale/yOffset 자동 산출
const maxDim = Math.max(max[0]-min[0], max[1]-min[1], max[2]-min[2])
const scale = +(TARGET_H / maxDim).toPrecision(3) // TARGET_H = 0.95
const yOffset = +(-((min[1] + max[1]) / 2) * scale).toPrecision(3)
그리고 assets.js는 packs.json이 없거나 로드 실패하면 조용히 절차 동물로 폴백한다.
// src/render/assets.js
createAnimal(species, hex) {
const entry = this.models.get(species)
if (!entry) return createCritter(hex, species) // 절차적 아미구루미 폴백
// ... GLB 클론 + 색 틴팅
}
이 두 줄이 "GLB가 있으면 GLB, 없으면 코드 동물"을 무중단으로 보장한다. 와이프가 노는 동안 내가 새 GLB 팩을 떨어뜨려도 게임이 멈추지 않는다.
이 프로젝트에서 나는 기획자 한 명이었지만, 작업은 여러 역할로 굴러갔다. AI가 그 역할들을 메웠다. 핵심은 "코드를 대신 짜준다"가 아니라 내가 약한 자리를 메운다였다.
| 내가 약한 자리 | AI가 한 일 | 사람(나)이 지킨 게이트 |
|---|---|---|
| Three.js 셰이더 | knit bump 절차 텍스처, TubeGeometry 실 연출 | 톤이 맞는가 (GLB 새 3종 삭제 결정) |
| 입력 충돌 해결 | 9px/400ms 임계 제안 | 모바일 실기 체감 확인 |
| 회귀 안전성 | greedySolve로 60/60 자동 검증 | "난이도 실재"는 사람이 정의 |
| 다음 한계 예측 | "절차 동물은 종 다양성이 약하다" 경고 | 그걸 v0.3 마일스톤으로 채택 |
특히 시각 검증이 1인 개발의 약한 고리였다. 코드가 도는 것과 "8종이 눈에 구분되는 것"은 다른 문제다. 그래서 web-screenshot 스킬(headless Chrome로 dev 서버를 띄워 스크린샷 + 콘솔 오류 보고)을 회사 작업에서 그대로 차용했다. claude-in-chrome 확장 없이도 모바일 뷰포트(iPhone 15 Pro 세로, 393×852) 렌더를 눈으로 확인할 수 있었다.
여기서 가장 중요한 원칙이 작동한다. 도구는 회사에서 차용하되, 도메인 콘텐츠는 0건 차용한다.
이 분리는 grep으로 검증된다. 메모리 기록에 "회사 프로젝트 도메인 콘텐츠 차용 0건(검증 grep PASS)"이 남아 있다. Critter Sort의 색은 분홍·민트·노랑이고, 동물은 고양이·곰·토끼다. 프로젝트 A(회사 MMORPG)의 도메인 어휘는 이 저장소 어디에도 없다.
왜 이렇게까지 분리하나. 두 가지 사고를 동시에 막기 위해서다. 회사 IP가 개인 취미에 새는 법적 사고, 그리고 MMORPG 도메인 atom이 퍼즐 작업에 잘못 주입되어 노이즈가 되는 컨텍스트 오염. 도구만 흐르고 콘텐츠는 막는 것 — 그 사이가 건강한 분리다.
Critter Sort는 작은 게임이다. 사흘, 커밋 5개, 동물 8종, 레벨 60개. 그런데 회사에서 쓰던 방식이 1/1000 규모에서도 그대로 작동했다.
가장 큰 배움은 첫 절의 실패였다. v0.1에서 게임의 정체성을 한 번 죽였다가, 두 번의 교정으로 살려냈다. 동료가 없는 1인 개발에서 그 죽음과 부활을 증언한 건 git 커밋이었다. 회고가 없었다면 "왜 v0.2에서 다 갈아엎었지?"를 한 달 뒤 잊었을 것이다.
다음 Part 24에서는 이런 결정 이력을 큰 팀·장기 운영에서 어떻게 거버넌스로 굳히는지 다룬다.
이 챕터는 와이프가 질려서 닫은 퍼즐에서 출발해, 혼자 만든 게임이 다시 그 손에 들어가기까지의 기록이었다. 시스템은 규모가 아니라 규율의 문제임을 확인했다.
좋아하는 캐주얼 게임 하나의 핵심 루프를 AI와 함께 굴려보는 단계입니다. 단, 시그니처를 잃지 않게.
setup — Node가 깔린 환경에서 빈 폴더를 하나 만드세요. mkdir my-puzzle && cd my-puzzle.
prompt — AI에게 이렇게 던지세요. 핵심은 "요약하지 말고 시그니처를 명시"하는 것입니다.
"[게임명]의 핵심 루프를 [테마]로 각색하고 싶어. 이 게임의 시그니처는 [한 줄로 손맛을 적기 — 예: '화면을 돌려 가려진 것을 확인하고 푸는 촉감']야. 이걸 절대 일반 매치 퍼즐로 납작하게 만들지 마. 로직은 렌더와 분리해 headless로 테스트되게 짜줘."
verify — 첫 결과를 직접 플레이해 보세요. "내가 적은 시그니처가 살아 있나?"를 물어봅니다. 없으면 코드가 아니라 정의를 다시 적어 재요청하세요. 그게 v0.1→v0.2에서 제가 한 일입니다.
엔진도, 절차 생성도 필요 없습니다. 종이 한 장에 "이 게임의 시그니처 한 줄"을 적고, AI에게 프로토타입을 시킨 뒤 직접 플레이로 그 한 줄이 살았는지만 보세요. 죽었으면 한 줄을 더 구체적으로 다시 적습니다. 시그니처 한 줄을 지키는 습관, 그것 하나면 역설계의 첫 함정은 피합니다.
월요일 아침 스탠드업 직후, 데이터팀의 팀원 A가 메신저로 스크린샷 한 장을 보내왔습니다. 인게임 상점에서 어떤 재료 아이템의 설명이 비어 있다는 QA 리포트였습니다. 원인을 30분 추적한 끝에 정체가 드러났습니다. 두 주 전 누군가 기획 문서에서 그 아이템을 재료_목재_상으로 이름을 바꿨는데, 데이터 시트의 참조는 옛날 이름 재료_목재_A를 그대로 가리키고 있었습니다. 문서는 갱신됐고, 시트는 갱신 안 됐고, 둘을 잇던 링크는 조용히 끊겼습니다. 아무도 거짓말을 하지 않았는데도 게임은 거짓을 출력하고 있었습니다.
이런 사고는 문서가 늘어날수록 기하급수로 잦아집니다. 사람의 눈은 50개 문서의 상호 참조를 동시에 못 봅니다. 그래서 검증을 코드에 위임합니다. 이 챕터는 문서·데이터·링크의 정합성을 사람이 아니라 스크립트가 검사하게 만드는 시스템을 다룹니다. 핵심은 세 가지 — 출처 정합(_source_map.tsv audit), 링크 무결성(wikilink), 그리고 stale 탐지(오래되어 썩은 참조 잡기)입니다.
문서와 데이터는 서로를 가리키며 살아갑니다. 기획서가 enum을 참조하고, enum이 데이터 시트를 참조하고, 시트가 다시 다른 기획서의 결정을 참조합니다. 이 그물을 사람이 손으로 관리하면, 한 노드가 바뀔 때 그 노드를 가리키던 모든 참조를 사람이 기억해서 따라가야 합니다. 기억은 실패합니다.
끊긴 링크가 위험한 이유는 그게 에러를 던지지 않기 때문입니다. 코드라면 존재하지 않는 변수를 참조할 때 컴파일러가 막아 줍니다. 그러나 문서에서 [[재료_목재_A]]라고 쓴 위키링크는, 그 대상이 사라져도 그냥 평범한 텍스트로 남습니다. 빨갛게 변하지 않습니다. 게임은 빌드되고, 출시되고, 유저가 빈 설명을 보고 나서야 누군가 알아챕니다.
그래서 검증 시스템의 첫 번째 일은 사람 눈에 안 보이는 것을 보이게 만드는 것입니다. 정합 위반을 텍스트 출력으로 끌어내고, 그 출력을 빌드 게이트에 묶어 두면, 사람이 잊어도 스크립트는 잊지 않습니다.
검증은 한 덩어리가 아니라 단계입니다. 먼저 가장 싼 검사를 돌려서 명백한 위반을 걸러 내고, 통과한 것만 다음 단계로 넘깁니다. 비싼 검사를 모든 입력에 다 돌리면 느려서 아무도 안 돌리게 되기 때문입니다. 아래는 저자가 운영하는 검증 흐름입니다.
flowchart TD
A[문서·시트 저장] --> B{source_map audit}
B -- 출처 매핑 누락 --> B1[FAIL: 수동 편집 흔적
_source_map.tsv 갱신 요구]
B -- 통과 --> C{wikilink 무결성}
C -- 깨진 링크 발견 --> C1[wikilink_apply.py
치유 시도]
C1 -- 자동 치유 가능 --> C
C1 -- 치유 불가 --> C2[FAIL: 끊긴 참조 리포트]
C -- 통과 --> D{stale 탐지}
D -- 참조 대상보다 오래됨 --> D1[WARN: 재검토 큐 등록]
D -- 통과 --> E[integrity_check 최종]
E -- P0 위반 --> E1[BLOCK: 빌드 게이트 차단]
E -- 통과 --> F[GREEN: 커밋 허용]
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
classDef fail fill:#fee2e2,stroke:#dc2626,color:#7f1d1d;
class B,C,C1,D,E code;
class A data;
class F pass;
class B1,C2,D1,E1 fail;
이 cascade의 핵심은 실패가 빠를수록 싸다는 것입니다. source_map audit은 TSV 한 줄 비교라 밀리초 단위로 끝납니다. 반대로 맨 마지막 integrity_check는 데이터 시트 전체를 로드해서 FK 관계를 검사하므로 수 초가 걸립니다. 싼 검사를 앞에 두면 명백한 실수는 거기서 잘려 나가고, 비싼 검사는 그걸 통과한 소수의 입력에만 돕니다.
각 단계의 출력이 다르다는 점도 중요합니다. audit은 FAIL(편집자가 무언가 손으로 건드렸다는 증거), wikilink는 자동 치유 후 FAIL, stale은 WARN(차단은 아니지만 재검토 필요), integrity_check은 BLOCK(빌드 자체를 막음). 같은 "문제"라도 심각도에 따라 다르게 반응해야 사람이 신호와 소음을 구별할 수 있습니다.
_source_map.tsv audit가장 먼저 도는 검사는 출처 정합입니다. 저자의 문서 생성 파이프라인은 어떤 합성 문서(예: GDD 본문)가 어떤 원천 파일에서 나왔는지를 _source_map.tsv에 기록합니다. 한 줄이 "이 산출물 섹션 = 이 원천 파일들의 합성"이라는 계보(lineage)를 못 박습니다.
이게 검증 도구가 되는 이유는, 사람이 산출물을 손으로 편집하면 매핑이 깨지기 때문입니다. 자동 생성된 GDD 섹션을 누가 직접 고치면, 그 섹션은 더 이상 원천 파일의 충실한 합성이 아닙니다. audit 스크립트는 산출물의 각 섹션 해시를 원천에서 재합성한 해시와 비교하고, 안 맞으면 FAIL을 냅니다. "수동 편집 시 audit FAIL"이라는 규칙이 여기서 나옵니다.
이건 사람의 편집을 막으려는 게 아니라, 편집을 명시화하려는 겁니다. 산출물을 고쳐야 한다면 원천을 고치고 재생성하든지, 아니면 그 섹션을 매핑에서 정식으로 떼어 내든지(분리 선언) 둘 중 하나를 하라는 신호입니다. 조용한 편집을 시끄럽게 만드는 것, 그게 audit의 일입니다.
audit을 통과하면 링크 검사로 넘어갑니다. 저자의 문서는 옵시디언식 위키링크 [[대상]]으로 노드를 연결합니다. wikilink_apply.py는 두 가지 일을 합니다 — 위키링크를 실제 경로로 해석해 적용하고, 깨진 링크를 가능한 범위에서 치유합니다.
치유가 가능한 케이스는 명확합니다. 대상 노드가 이름만 바뀌고 같은 자리에 존재할 때입니다. 앞서의 재료_목재_A → 재료_목재_상 같은 리네임은, 별칭 매핑(alias map)이 갱신돼 있으면 스크립트가 옛 이름을 새 이름으로 자동 교정합니다. 반면 대상이 통째로 삭제됐거나 어디로 갔는지 추적 불가하면, 치유를 포기하고 끊긴 참조를 리포트합니다.
여기서 설계 판단이 하나 있습니다. 자동 치유를 너무 공격적으로 하면 위험합니다. "비슷한 이름"을 찾아 멋대로 이어 붙이면, 의미가 다른 노드로 링크가 잘못 붙어 더 나쁜 사고가 납니다. 그래서 wikilink_apply.py의 치유는 보수적입니다 — 명시적 별칭 매핑이 있는 리네임만 자동 교정하고, 추측이 필요한 케이스는 사람에게 넘깁니다. 자동화의 미덕은 확실한 것만 자동으로 하고, 애매한 것은 정직하게 사람에게 떠넘기는 절제에 있습니다.
링크가 살아 있어도 참조가 낡았을 수 있습니다. 문서 A가 데이터 시트 B를 참조하는데, B가 A보다 나중에 갱신됐다면, A의 설명은 현재 B와 어긋날 가능성이 있습니다. 링크 자체는 멀쩡합니다. 가리키는 대상이 있으니까요. 그런데 내용이 썩었습니다.
stale 탐지는 참조 양쪽의 수정 시각(또는 콘텐츠 해시 버전)을 비교합니다. 참조하는 쪽이 참조 대상보다 오래됐으면 WARN을 띄우고, 그 노드를 재검토 큐에 등록합니다. BLOCK이 아니라 WARN인 이유는, 갱신이 항상 내용 충돌을 뜻하진 않기 때문입니다. 오타 하나 고친 갱신이라면 참조는 멀쩡합니다. 그래서 stale은 "막는 것"이 아니라 "들여다보라고 표시하는 것"입니다.
이 단계가 앞의 끊긴 링크 사고를 어떻게 잡는지 보겠습니다. 만약 재료_목재 시트가 문서보다 나중에 갱신됐다면, 자동 치유 전에 이미 stale WARN이 떴을 겁니다. 즉 세 검사는 겹치는 안전망입니다. 한 그물이 놓친 것을 다음 그물이 잡습니다. 단일 검사로는 못 잡는 사고를 cascade가 잡는 이유가 이것입니다.
검증 로직을 사람이 처음부터 다 짜면 지칩니다. 저자는 검사 규칙을 자연어로 기술하고, 실행 스크립트는 AI에게 초안을 받습니다. 아래는 stale 탐지 스크립트를 만든 실제 세션입니다. 출력은 다듬지 않고, 실패와 거부 과정을 그대로 남깁니다.
[프롬프트 전문]
문서 폴더의 마크다운들이 서로 위키링크
[[대상]]로 연결돼 있어. 각 문서가 참조하는 대상 파일보다 자기 자신이 더 오래 갱신됐는지(=stale) 검사하는 파이썬 스크립트를 만들어 줘. 조건: - 파일 수정 시각은 git 마지막 커밋 시각 기준 (파일시스템 mtime은 체크아웃 때 갱신돼서 못 믿음) - 끊긴 링크(대상 없음)는 stale 검사에서 제외하고 따로 리포트 - 출력은 TSV:source<TAB>target<TAB>status(status = OK / STALE / BROKEN) - WARN은 비차단, 종료코드는 0 유지 (빌드 막으면 안 됨)
[Claude 날것 출력 — 1차]
아래 스크립트는
git log -1 --format=%ct <file>로 각 파일의 마지막 커밋 시각을 얻고, 위키링크를 정규식\[\[([^\]]+)\]\]으로 추출해 비교합니다.import subprocess, re, sys, pathlib def git_mtime(path): out = subprocess.run( ["git", "log", "-1", "--format=%ct", str(path)], capture_output=True, text=True) return int(out.stdout.strip()) def resolve(link, root): # [[대상]] -> root/대상.md return root / f"{link}.md" root = pathlib.Path(sys.argv[1]) for md in root.glob("*.md"): src_t = git_mtime(md) for link in re.findall(r"\[\[([^\]]+)\]\]", md.read_text(encoding="utf-8")): tgt = resolve(link, root) if not tgt.exists(): print(f"{md.name}\t{link}\tBROKEN") continue tgt_t = git_mtime(tgt) status = "STALE" if src_t < tgt_t else "OK" print(f"{md.name}\t{link}\t{status}")이렇게 하면 요청하신 TSV 형식으로 출력됩니다.
[사람 검증 / 거부]
여기서 멈췄습니다. 세 가지 결함이 보였습니다.
첫째, git_mtime이 빈 문자열을 반환하면 int("")에서 터집니다. 커밋된 적 없는 새 파일이 그렇습니다. 둘째, glob("*.md")는 하위 폴더를 못 봅니다. 저자의 문서는 part*/ 하위에 흩어져 있습니다. 셋째 — 가장 중요한데 — 위키링크에는 [[대상|표시이름]]처럼 파이프 별칭이 붙는 경우가 있고, 1차 코드는 대상|표시이름 전체를 파일명으로 오인합니다. 이러면 멀쩡한 링크가 전부 BROKEN으로 잡힙니다. 그대로 쓰면 거짓 경보 폭탄입니다.
이 세 가지를 지적하고 재요청했습니다.
[재요청]
세 가지 고쳐 줘: (1) 커밋 안 된 파일은 git_mtime이 None, 이 경우 STALE 판정 보류하고 status=UNTRACKED. (2) glob 말고 rglob으로 하위 폴더 포함. (3)
[[대상|별칭]]파이프 처리 —|앞부분만 대상으로 써. 그리고 종료코드는 BROKEN이 있어도 0 유지(WARN 비차단 원칙).
[Claude 날것 출력 — 2차]
import subprocess, re, sys, pathlib def git_mtime(path): out = subprocess.run( ["git", "log", "-1", "--format=%ct", str(path)], capture_output=True, text=True) s = out.stdout.strip() return int(s) if s else None # 커밋 이력 없으면 None def target_name(link): return link.split("|", 1)[0].strip() # [[대상|별칭]] -> 대상 root = pathlib.Path(sys.argv[1]) for md in root.rglob("*.md"): src_t = git_mtime(md) text = md.read_text(encoding="utf-8") for raw in re.findall(r"\[\[([^\]]+)\]\]", text): name = target_name(raw) hits = list(root.rglob(f"{name}.md")) if not hits: print(f"{md.name}\t{name}\tBROKEN") continue tgt_t = git_mtime(hits[0]) if src_t is None or tgt_t is None: status = "UNTRACKED" elif src_t < tgt_t: status = "STALE" else: status = "OK" print(f"{md.name}\t{name}\t{status}") sys.exit(0) # WARN 비차단: 무조건 0
이 2차 출력은 받아들였습니다. 다만 한 가지는 사람이 남겨 둔 결정입니다 — rglob(f"{name}.md")가 같은 이름 파일을 여러 폴더에서 찾으면 hits[0]만 씁니다. 이건 잠재적 모호성이지만, 저자의 문서 네이밍 규칙상 파일명이 전역 유일하므로 실무에서 충돌이 없습니다. AI가 짚지 않은 이 가정을 사람이 의식적으로 받아들이고 주석으로 남겼습니다. 자동화가 짠 코드라도, 그 코드가 기대는 가정은 사람이 책임집니다.
스크립트가 있어도 아무도 안 돌리면 소용없습니다. 검증의 마지막 설계는 돌리지 않을 수 없게 만드는 것입니다. 저자는 세 단계를 커밋 전 훅(pre-commit) 또는 빌드 파이프라인에 묶어 둡니다. audit FAIL과 integrity_check P0 위반은 BLOCK이라 커밋/빌드를 막고, wikilink BROKEN과 stale은 WARN이라 통과시키되 리포트를 남깁니다.
이 BLOCK/WARN 이원화가 시스템의 생존을 결정합니다. 모든 걸 BLOCK으로 걸면 사소한 stale 하나에 커밋이 막혀서, 사람들이 검증 자체를 우회하기 시작합니다. 우회되는 검증은 없는 검증입니다. 반대로 다 WARN으로 두면, 진짜 막아야 할 데이터 무결성 위반까지 그냥 통과합니다. 무엇을 막고 무엇을 표시만 할지의 경계가 검증 시스템의 진짜 설계 지점입니다.
저자가 운영하는 MMORPG 개발사 A의 프로젝트 A에서, 문서 약 90건 규모를 기준으로 관찰한 방향입니다. 절대 수치 일부는 저자 추정(미검증)이며, 의미 있는 것은 추세입니다.
| 항목 | 수동 검토 시절 | 코드 검증 cascade |
|---|---|---|
| 끊긴 참조 발견 시점 | 유저·QA 리포트 후 | 커밋 전 (방향: 사고 → 사전) |
| 정합 1회 점검 소요 | 수 시간(저자 추정) | 수십 초(스크립트 실측) |
| stale 누적 잠복 | 수 주 잠복 | 다음 커밋에서 WARN |
| 잘못된 자동 치유 사고 | 해당 없음 | 보수적 치유로 0건 유지 |
수치를 곧이곧대로 믿기보다, "발견 시점이 사후에서 사전으로 당겨졌다"는 방향만 신뢰하시길 권합니다. 검증 시스템의 진짜 가치는 시간 절약보다 사고가 유저에게 도달하기 전에 잡힌다는 위치 이동에 있습니다.
| 패턴 | 처방 |
|---|---|
| 모든 위반을 BLOCK으로 걸어 사람들이 검증 우회 | BLOCK/WARN 이원화, 차단은 데이터 무결성 P0만 |
| 자동 치유를 추측까지 공격적으로 | 명시 별칭 리네임만 자동, 애매하면 사람에게 |
| 끊긴 링크만 보고 stale 무시 | 수정 시각 비교로 낡은 참조 별도 탐지 |
| 산출물 수동 편집을 조용히 허용 | source_map audit으로 편집을 FAIL로 가시화 |
| 스크립트는 있는데 훅에 안 묶음 | pre-commit·빌드 게이트 연결, 안 돌릴 수 없게 |
setup. 문서 폴더를 git으로 관리합니다(커밋 시각 비교의 기준). 위키링크는 [[대상]] 또는 [[대상|별칭]] 표기로 통일합니다.
prompt. AI에게 위 트랜스크립트의 프롬프트 전문을 그대로 주되, 첫 출력을 절대 그대로 쓰지 마세요. 반드시 (1) 커밋 안 된 파일 처리, (2) 하위 폴더 탐색, (3) 파이프 별칭 파싱 — 이 세 가지를 검증하고 거부 후 재요청하세요. 이건 AI가 거의 항상 1차에 빠뜨리는 지점입니다.
verify. 스크립트를 돌려 TSV를 받습니다. BROKEN 행이 진짜 끊긴 링크인지 표본 5개를 손으로 확인하세요. 거짓 BROKEN이 나오면 별칭/하위폴더 파싱이 덜 된 것입니다. 정상이 확인되면 pre-commit 훅에 묶고, WARN(STALE/BROKEN)은 통과·BLOCK(데이터 무결성 P0)은 차단으로 종료코드를 분기합니다.
1인 축소판. 혼자 작은 GDD를 쓰는 경우라면 cascade 전부는 과합니다. stale 탐지 한 단계만 가져가세요. 문서가 데이터 시트보다 오래됐는지만 git 시각으로 비교해도, "고친 줄 알았는데 안 고친" 사고의 대부분이 잡힙니다. 자동 치유와 source_map audit은 문서가 30건을 넘어 손으로 못 좇을 때 추가하면 됩니다.
신입 기획자가 입사 사흘째에 물었다. "선배님, 이 시스템들이 서로 어떤 순서로 영향을 주는지 그림으로 정리된 게 어디 있나요?" 나는 머뭇거렸다. 그림은 있었다. 반년 전 누군가 화이트보드에 그린 사진이 위키 어딘가에 실려 있었다. 그런데 그 그림에는 지금은 사라진 시스템이 두 개 살아 있고, 그 뒤 추가된 핵심 루프 세 개가 빠져 있었다. 결국 나는 "그림은 믿지 말고 문서를 읽어라"라고 답했다. 부끄러운 답이었다. 그림이 문서와 어긋난 순간, 그림은 정보가 아니라 오정보가 된다.
이 챕터의 결론을 먼저 말하면 이렇다. 사람이 그린 다이어그램은 한두 달 안에 반드시 썩는다. 그러니 다이어그램을 그리는 일을 사람 손에서 떼어내, 문서 구조 자체가 자기 그림을 토해내게 만들어야 한다. 이 글은 그 과정을 한 번의 실제 작업 기록으로 보여준다. 문서를 입력으로 받아 Mermaid 코드를 생성하는 워크드 트랜스크립트를 통째로 싣고, 그렇게 뽑힌 다이어그램을 이 페이지에서 실제로 렌더한다. 기법을 설명하는 글이 그 기법의 산출물로 자기 자신을 증명하는 셈이다.
다이어그램 도구는 많다. draw.io, Figma, Visio, 화이트보드 사진까지. 이 도구들에는 공통된 함정이 하나 있다. 결과물이 그림 파일(이미지)이라는 점이다. 이미지는 git에서 한 줄 한 줄 변경을 추적할 수 없고, 텍스트를 다루는 LLM이 직접 생성하거나 수정할 수 없으며, 마크다운 문서 안에 코드로 실리지 않는다. 운영 관점에서 가장 치명적인 건 첫 번째다. 누가 언제 왜 바꿨는지 추적이 안 되는 그림은, 시간이 지나면 아무도 책임지지 않는 유물이 된다.
Mermaid는 이 세 가지를 한 번에 푼다. 다이어그램을 텍스트로 적고, 렌더는 뷰어가 알아서 한다. 텍스트이므로 git diff가 노드 하나 추가된 것까지 잡아낸다. 텍스트이므로 LLM이 읽고 쓴다. 텍스트이므로 마크다운 코드블록에 그대로 들어간다. 바로 이 챕터의 본문이 그 증거다. 지금 당신이 읽는 이 문장 아래에 곧 나올 다이어그램들은 전부 마크다운 안의 텍스트 블록이고, 책 빌드 과정에서 그림으로 렌더된다.
다만 오해는 막아야 한다. 모든 운영 자료를 다이어그램으로 만들 필요는 전혀 없다. 항목을 나열하는 일은 글머리표가 빠르고, 수치를 비교하는 일은 표가 빠르다. Mermaid가 이기는 자리는 딱 세 가지다. 관계(무엇이 무엇과 이어지는가), 흐름(무엇이 무엇 다음에 오는가), 시퀀스(누가 누구에게 언제 무엇을 보내는가). 이 세 가지가 아닌 자리에 억지로 다이어그램을 끼우면 오히려 인지 부담이 늘어난다.
여기서부터가 이 챕터의 등뼈다. 추상적인 설명 대신, 실제 문서 한 덩어리를 Mermaid로 바꾸는 과정을 처음부터 끝까지 보여준다. 입력은 프로젝트 A의 운영 문서 가운데 시스템 의존 구조를 적어둔 마크다운 조각이다(아래는 익명화한 실제 발췌).
# 시스템 의존 메모 (운영 문서 발췌, 익명화)
- combat_core 는 stat_engine 에 의존한다
- skill_runtime 은 combat_core 에 의존한다
- skill_runtime 은 vfx_pool 에 의존한다
- quest_director 는 skill_runtime 에 의존한다
- quest_director 는 dialog_graph 에 의존한다
- economy_loop 은 quest_director 의 보상 훅을 구독한다
- economy_loop 은 stat_engine 의 파생 스탯을 읽는다
이걸 손으로 다이어그램으로 옮기면 노드 일곱 개에 화살표 일곱 개. 한 번은 그릴 수 있다. 문제는 다음 주에 mail_box 시스템이 추가되고 dialog_graph가 둘로 쪼개질 때다. 손그림은 그 순간부터 거짓말을 시작한다. 그래서 사람이 아니라 LLM에게 이 변환을 시킨다.
아래는 내가 실제로 던진 프롬프트다. 한 글자도 다듬지 않고 그대로 싣는다.
아래 시스템 의존 메모를 Mermaid graph(위→아래, graph TB)로 바꿔 줘. 규칙은:
1. 메모에 나온 시스템만 노드로, 새 시스템 추가 금지.
2. "A는 B에 의존"은 A --> B.
3. "구독"·"읽는다" 같은 약한 결합은 점선 -.->에 관계 이름 붙여서.
4. 노드 id는 메모의 영문 이름 그대로, 한글 라벨 붙이지 마.
코드 블록만, 설명·주석 빼고.
[메모]
- combat_core 는 stat_engine 에 의존한다
- skill_runtime 은 combat_core 에 의존한다
- skill_runtime 은 vfx_pool 에 의존한다
- quest_director 는 skill_runtime 에 의존한다
- quest_director 는 dialog_graph 에 의존한다
- economy_loop 은 quest_director 의 보상 훅을 구독한다
- economy_loop 은 stat_engine 의 파생 스탯을 읽는다
규칙 1과 4가 핵심이다. LLM에게 다이어그램을 맡길 때 가장 흔한 사고는 "친절한 환각"이다. 시키지도 않은 노드를 추가하고("로그인 시스템도 보통 있으니 넣었습니다"), 멋대로 한글 라벨을 붙여 ID와 본문 검색이 어긋나게 만든다. 규칙으로 그 친절을 차단한다.
다음은 모델이 처음 돌려준 응답이다. 정제하지 않고 그대로 옮긴다. 거기에는 작은 문제가 하나 섞여 있었고, 그 문제까지 포함해서 싣는다.
graph TB
skill_runtime --> combat_core
combat_core --> stat_engine
skill_runtime --> vfx_pool
quest_director --> skill_runtime
quest_director --> dialog_graph
economy_loop -.보상 훅 구독.-> quest_director
economy_loop -.파생 스탯 읽기.-> stat_engine
문법은 멀쩡하고 노드도 정확히 일곱 개다. 새 시스템을 지어내지도 않았다. 규칙 1·4는 지켜졌다. 그런데 한 가지가 걸렸다. 점선 라벨에 한글이 들어가 있다(보상 훅 구독, 파생 스탯 읽기). 이건 규칙 위반은 아니다. 규칙 3이 "관계 이름을 붙이라"고 했으니 모델은 충실히 따랐다. 다만 Mermaid에서 한글 라벨에 공백이 들어가면 일부 구버전 렌더러가 깨진다는 걸 나는 경험으로 알고 있었다. 모델은 그걸 알 도리가 없다.
여기가 자동화에서 가장 자주 생략되지만 가장 중요한 단계다. 나는 출력을 그대로 받지 않고 거부했다. 거부 이유는 두 가지.
첫째, 점선 라벨의 한글 공백을 영문 토큰으로 바꿔 렌더 호환성을 확보해야 한다. 둘째, 약한 결합(점선)과 강한 결합(실선)이 한 그림에 섞여 있는데 색이나 스타일 구분이 없어 한눈에 안 들어온다. 이 두 가지를 들고 다시 요청했다.
거의 좋다. 두 가지만 고쳐라.
1. 점선 화살표 라벨을 영문 단어 하나로 바꿔라(공백 없이).
"보상 훅 구독" -> subscribes, "파생 스탯 읽기" -> reads
이유: 일부 렌더러가 한글+공백 엣지 라벨에서 깨진다.
2. 점선(약한 결합) 노드와 실선(강한 결합) 노드를 시각적으로 구분하기 위해,
economy_loop 처럼 약한 결합만 가진 노드에 classDef 로 옅은 회색 스타일을 줘라.
3. 나머지는 그대로 둔다.
graph TB
skill_runtime --> combat_core
combat_core --> stat_engine
skill_runtime --> vfx_pool
quest_director --> skill_runtime
quest_director --> dialog_graph
economy_loop -.subscribes.-> quest_director
economy_loop -.reads.-> stat_engine
classDef weak fill:#eee,stroke:#999,stroke-dasharray:4 3;
class economy_loop weak;
이번엔 받아들였다. 라벨이 영문 단일 토큰으로 바뀌었고, economy_loop만 회색으로 떨어져 "이 시스템은 직접 의존이 아니라 구독·읽기로만 엮인 가장자리 시스템"이라는 정보가 색으로 전달된다. 프롬프트 한 줄도 안 건드리고 손으로 그렸다면, 나는 이 classDef를 떠올리지도 못했을 가능성이 높다.
위 트랜스크립트의 최종 출력을, 손으로 베끼지 않고 코드 블록 그대로 이 책의 페이지에 싣는다. 책 빌드가 이걸 그림으로 그린다. 이것이 "자기 기법으로 자기를 증명한다"의 실물이다.
graph TB
skill_runtime --> combat_core
combat_core --> stat_engine
skill_runtime --> vfx_pool
quest_director --> skill_runtime
quest_director --> dialog_graph
economy_loop -.subscribes.-> quest_director
economy_loop -.reads.-> stat_engine
classDef weak fill:#eee,stroke:#999,stroke-dasharray:4 3;
class economy_loop weak;
문서 발췌 한 덩어리가, 다섯 번의 주고받음을 거쳐, git에 들어가고 LLM이 갱신할 수 있고 이 페이지에 렌더되는 운영 자산이 됐다. 다음 주 mail_box가 추가되면 메모에 한 줄 적고 같은 프롬프트를 다시 던지면 된다. 사람이 펜을 들 일은 없다.
앞의 다이어그램이 "변환의 결과"라면, 이번 것은 "변환의 과정"이다. 방금 다섯 단계로 진행한 워크드 절차를 흐름도로 만들었다. 이 다이어그램 역시 같은 방식으로 LLM에게 시켜 뽑았고, 같은 검증을 거쳤다. 그 결과를 그대로 싣는다.
flowchart TD
SRC[운영 문서 발췌] --> PROMPT[변환 프롬프트 작성]
PROMPT --> LLM[Claude 날것 출력]
LLM --> CHECK{사람 검증}
CHECK -->|거부: 렌더 호환·가독성| REASK[재요청 프롬프트]
REASK --> LLM
CHECK -->|승인| EMBED[마크다운에 코드블록 임베드]
EMBED --> GIT[git 커밋·diff 추적]
GIT -->|문서 변경 시| SRC
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
class LLM ai;
class PROMPT,CHECK,REASK human;
class SRC,EMBED,GIT data;
이 흐름도가 말하는 한 가지가 있다. 점선이 아니라 굵은 화살표로 강조하고 싶은 건 가운데의 마름모, 곧 사람 검증이다. 자동화라는 단어에 취해 이 노드를 빼버리면, 1단계의 친절한 환각이 그대로 운영 문서에 실린다. 자동화는 사람을 그림 그리기에서 해방시키되, 판단에서 해방시키지는 않는다. 루프의 마지막 화살표(문서 변경 시 → 운영 문서 발췌)가 핵심이다. 이 되먹임 고리가 있어야 다이어그램이 일회성 자료가 아니라 문서와 함께 늙지 않고 같이 자라는 자산이 된다.
LLM 변환은 유연하지만, 관계가 이미 정형 데이터로 존재하는 경우엔 굳이 모델을 부를 필요가 없다. 프로젝트 A의 결정 카드처럼 필드가 고정된 데이터는 작은 파이썬 스크립트가 더 빠르고 더 정직하다(환각이 원천적으로 불가능하다). 아래는 결정 카드 목록을 결정 그래프 Mermaid로 바꾸는 실제 스크립트의 핵심부다.
# decision_graph_to_mermaid.py
# 결정 카드(정형 데이터) -> Mermaid graph 변환. LLM 불필요, 결정론적.
def to_mermaid(decisions):
lines = ["graph LR"]
# 1) 노드 선언: id와 제목을 그대로. 지어내지 않는다.
for d in decisions:
safe_title = d.title.replace('"', "'") # 따옴표만 escape
lines.append(f' {d.id}["{safe_title}"]')
# 2) 엣지: 관계 타입을 화살표 라벨로.
for d in decisions:
for rel in d.relations:
lines.append(f' {d.id} -->|{rel.type}| {rel.target}')
return "\n".join(lines)
핵심은 두 단계로만 끝난다는 점이다. 노드를 선언하고, 엣지를 잇는다. 입력에 없는 노드는 출력에 절대 등장하지 않는다. 이 스크립트가 결정 카드 세 장을 받으면 아래 같은 그래프가 나온다.
graph LR
D_A["글로벌 쿨다운 0.3초"] -->|superseded_by| D_B["글로벌 쿨다운 0.5초"]
D_B -->|relates_to| D_C["회복기 예외 허용"]
D_B -->|side_effect| D_D["근접 스킬 데미지 -5%"]
classDef human fill:#fde68a,stroke:#b45309,color:#000;
class D_A,D_B,D_C,D_D human;
결정 하나가 다른 결정으로 대체되고(superseded_by), 거기서 파생된 부작용(side_effect)까지 한 화살표로 보인다. 텍스트로 적힌 결정 로그 수십 줄을 다 읽지 않아도, 이 그래프 한 장이면 "왜 지금 쿨다운이 0.5초인가"의 역사가 5분 안에 잡힌다.
언제 LLM을 쓰고 언제 스크립트를 쓰는가. 기준은 단순하다. 입력이 정형 데이터(필드가 고정된 카드·시트)면 스크립트, 입력이 자유 텍스트(회의록·메모·대화)면 LLM. 정형 데이터에 LLM을 쓰면 불필요한 환각 위험만 떠안고, 자유 텍스트에 스크립트를 쓰면 파싱 규칙이 끝없이 늘어난다.
다이어그램 자동화를 운영하며 실제로 밟은 지뢰들이다.
첫째, 너무 복잡해지는 함정. 노드가 쉰 개를 넘으면 그림은 더 이상 인지를 돕지 않고 인지를 방해한다. 처방은 한 화면에 스무 개에서 서른 개 사이로 제한하고, 그보다 커지면 subgraph로 영역을 묶거나 아예 다이어그램을 둘로 쪼개는 것이다.
둘째, 갱신이 끊기는 함정. 이건 손그림에서만 생긴다고 생각하기 쉽지만, 자동화를 해놓고도 입력 문서를 안 고치면 똑같이 썩는다. 처방은 앞의 흐름도에 있던 되먹임 고리다. 입력 문서가 단일 진실의 원천(single source of truth)이 되도록 만들고, 다이어그램은 거기서 항상 다시 생성한다.
셋째, 너무 추상적인 함정. "시스템들이 대충 이렇게 엮여 있다" 수준의 그림은 예쁘지만 쓸모가 없다. 처방은 노드에 추상명사 대신 실제 ID(skill_runtime, D_B)를 입력하는 것이다. 본문 검색과 다이어그램이 같은 식별자를 공유해야 그림에서 코드로 곧장 점프할 수 있다.
넷째, 검수 없이 LLM 출력을 그대로 쓰는 함정. 척추의 3단계에서 보았듯, 모델은 규칙을 지키면서도 렌더가 깨지는 라벨을 만들 수 있다. 처방은 사람 검증 노드를 파이프라인에서 절대 빼지 않는 것이다.
수치를 들고 싶은 유혹이 있지만, 여기서는 방향만 말한다. 아래 비교는 저자가 운영한 팀에서 체감한 변화이며, 정밀 측정값이 아니라 저자 추정(미검증)이다.
가장 또렷하게 달라진 건 신입의 시스템 이해 속도다. 입사 초기 며칠이 걸리던 "이 시스템들이 어떻게 엮였는가"의 파악이, 자동 생성된 의존 그래프 한 장 앞에서 한 시간 안팎으로 줄었다. 회의 자료 준비도 가벼워졌다. 예전에는 회의 전날 누군가 손으로 그림을 다시 그렸지만, 이제는 문서를 한 번 변환하면 끝난다. 무엇보다 다이어그램이 실제와 어긋났을 때 생기던 "이 그림 믿어도 되나요"라는 질문 자체가 거의 사라졌다. 입력 문서가 곧 그림이니, 문서가 맞으면 그림도 맞다.
반대로 솔직히 적자면, 자동화가 만능은 아니다. 정형화가 덜 된 초기 단계의 아이디어 스케치는 여전히 화이트보드가 빠르다. 자동화는 구조가 어느 정도 굳은 다음에 빛난다.
setup. 문서를 보관하는 마크다운 저장소 하나와, Mermaid를 렌더하는 뷰어(대부분의 마크다운 뷰어·git 호스팅에 내장)면 충분합니다. 변환 대상 문서에서 "관계·흐름·시퀀스"에 해당하는 조각을 하나 고르세요(예: 시스템 의존 메모).
prompt. 그 조각을 본문 척추의 1단계 프롬프트 틀에 끼워 LLM에 던지세요. 반드시 "메모에 없는 노드를 추가하지 마라" "ID는 원문 영문 이름 그대로 써라" 두 규칙을 넣습니다. 정형 데이터라면 LLM 대신 decision_graph_to_mermaid.py 같은 결정론적 스크립트로 변환하세요.
verify. 출력 코드블록을 마크다운에 붙여 실제로 렌더해 보세요. 세 가지를 확인합니다. (1) 입력에 없는 노드가 생기지 않았는가, (2) 엣지 라벨이 깨지지 않고 그려지는가, (3) 본문에서 쓰는 ID와 다이어그램 ID가 일치하는가. 하나라도 어긋나면 재요청 프롬프트로 거부하고 다시 받으세요. 통과하면 git에 커밋합니다 — 이제 변경은 diff로 추적됩니다.
팀도 스크립트도 없는 1인 작업자라면 이렇게 줄이세요. 노트 앱에 시스템·할 일·아이디어 사이의 관계를 "A는 B에 의존한다" 형식의 글머리표로 적어 둡니다. 일주일에 한 번, 그 목록을 통째로 복사해 "이걸 Mermaid graph TB로 바꿔줘, 목록에 없는 노드는 추가하지 마"라고 한 줄 던지세요. 돌려받은 코드블록을 노트 맨 위에 붙입니다. 끝입니다. 손으로 그리지 않으니 갱신 부담이 없고, 입력 목록만 살아 있으면 그림은 언제나 최신입니다.
연결(wikilink)과 분류(위계)는 같은 문제의 두 입구다. 한쪽이 "이 결정이 어디로 이어지나"를 답하고, 다른 쪽이 "이 문서가 어디에 사는가"를 답한다.
신규 합류한 기획자가 둘째 날 아침에 물었다. "전투 글로벌 쿨다운 값이 0.5초인 게 맞나요? 어느 문서에 근거가 있죠?" 나는 답하지 못했다. 분명 어딘가에 결정 기록이 있는데, 그게 전투 룰북인지 회의록인지 분기 보고서인지 기억나지 않았다. 셋이 달라붙어 전체 폴더를 grep으로 뒤졌다. 같은 숫자가 여섯 군데서 나왔고, 그중 어느 것이 "원본 결정"이고 어느 것이 "참조 복사"인지 구분이 안 됐다. 40분을 썼다. 결국 찾아낸 건 회의록 안에 묻혀 있던 한 줄이었다.
그날 저녁 나는 두 가지가 없었다는 걸 깨달았다. 첫째, 문서들 사이의 명시적 연결이 없었다. 같은 숫자가 여섯 군데 있어도 "이건 저기서 인용한 것"이라는 끈이 어디에도 적혀 있지 않았다. 둘째, 문서가 사는 위계가 없었다. 결정 기록이 룰북·회의록·보고서에 흩어진 채 "결정은 여기 산다"는 약속이 없었다.
이 두 가지가 이 챕터의 주제다. wikilink는 연결을 텍스트로 적고, 위계는 분류를 폴더로 약속한다. 둘은 분리된 기법처럼 보이지만 실은 검색이라는 한 문제의 양면이다.
문서가 30건일 때는 머리로 다 기억한다. 100건을 넘으면 사람의 기억이 인덱스 역할을 못 한다. 그때 의존할 수 있는 건 둘 중 하나다. 전체를 grep으로 훑거나(느리고 부정확), 문서 안에 적힌 명시적 연결을 따라가거나(빠르고 정확).
grep이 부정확한 이유는 단순하다. combat_global_cooldown_constant라는 문자열을 검색하면, 그 값을 결정한 문서와 그 값을 언급만 한 문서가 똑같이 잡힌다. 어느 게 원본인지 grep은 모른다. 반면 문서 안에 [[combat_global_cooldown_constant]]라는 이중 대괄호 표기를 약속해 두면, "이건 그 atom을 의도적으로 참조한다"는 신호가 문자열 자체에 남는다. \[\[combat_global_cooldown 패턴으로 좁히면 우연한 언급은 빠지고 의도된 참조만 남는다.
이 한 줄짜리 표기 약속이 그래프의 한 변(edge)이 된다. 문서 A가 [[atom_X]]를 적으면 A→X 방향의 간선이 생긴다. 문서 200건이 각자 몇 개씩 적으면, 누가 그리지 않아도 그래프가 텍스트 안에 누적된다.
아래는 우리 프로젝트의 atom·결정·문서가 wikilink로 묶인 모습의 한 조각이다. 노드 색은 종류를, 화살표는 참조 방향을 나타낸다.
이 작은 조각이 보여주는 건, 신규 기획자의 질문에 대한 답이 그래프 안에 이미 있었다는 사실이다. combat_global_cooldown_constant atom으로 들어오는 화살표를 거꾸로 따라가면 결정 D2026_Q2_017이 나온다. 40분이 아니라 한 번의 역참조였다.
우리는 wikilink로 묶을 대상을 네 종류로만 정했다. 종류를 늘리면 양식이 흔들리고, 양식이 흔들리면 grep이 다시 부정확해진다.
[[combat_global_cooldown_constant]]. 1문서 1결정 단위인 atom을 가리킨다.[[D2026_Q2_017]]. 분기·번호로 식별되는 의사결정 기록.[[CombatFormula_v3]]. 룰북·명세 등 큰 문서.[[팀원 A]]. 담당자·결정자.네 종류 모두 [[name]] 한 양식이다. name은 글로벌하게 유일해야 한다. atom 이름이 두 곳에서 충돌하면 그래프의 같은 노드로 합쳐져 버려, "전투의 cooldown"과 "UI의 cooldown"이 한 노드가 되는 사고가 난다. 그래서 atom 명명 규칙에서 분야 prefix(combat_, ui_)를 강제한다.
표기 약속만으로는 부족하다. 200건 문서에 사람이 일일이 대괄호를 다는 건 비현실적이고, 한 번 달아도 atom 이름이 바뀌면 전부 깨진다. 그래서 두 가지 일을 하는 스크립트를 운영한다. 첫째는 적용(apply) — 본문에 등장하는 알려진 atom 이름을 wikilink로 자동 변환. 둘째는 치유(heal) — 이름이 바뀌었거나 깨진 링크를 찾아 갱신·보고.
wikilink_apply.py의 핵심부는 이렇게 생겼다.
# wikilink_apply.py — 본문 atom 이름을 [[wikilink]]로 적용하고, 깨진 링크를 치유한다
import re
from pathlib import Path
WIKILINK = re.compile(r"\[\[([A-Za-z0-9_]+)\]\]")
# 이미 링크가 아닌, 맨몸으로 등장하는 atom 이름만 잡는다 (앞에 [[ 가 없는 경우)
BARE_NAME = lambda name: re.compile(rf"(?<!\[\[)(?<![A-Za-z0-9_])({re.escape(name)})(?![A-Za-z0-9_])(?!\]\])")
def load_known_atoms(registry: Path) -> set[str]:
# _atom_registry.tsv: 첫 칼럼이 현재 유효한 atom name
return {ln.split("\t")[0].strip()
for ln in registry.read_text(encoding="utf-8").splitlines()
if ln.strip() and not ln.startswith("#")}
def apply_links(text: str, known: set[str]) -> tuple[str, int]:
applied = 0
for name in sorted(known, key=len, reverse=True): # 긴 이름 우선: 부분일치 오염 방지
text, n = BARE_NAME(name).subn(rf"[[{name}]]", text)
applied += n
return text, applied
def heal_links(text: str, known: set[str], aliases: dict[str, str]) -> tuple[str, list[str]]:
dead = []
def repl(m):
ref = m.group(1)
if ref in known:
return m.group(0) # 살아있음 → 그대로
if ref in aliases: # 개명된 atom → 새 이름으로 치유
return f"[[{aliases[ref]}]]"
dead.append(ref) # 진짜 dead link → 보고
return m.group(0)
return WIKILINK.sub(repl, text), dead
여기서 두 가지 설계 선택이 본문의 척추다.
첫째, apply_links는 긴 이름을 먼저 치환한다. combat_cooldown과 combat_cooldown_global 두 atom이 있을 때, 짧은 쪽을 먼저 치환하면 긴 쪽의 앞부분이 오염된다. 길이 내림차순 정렬 한 줄이 이 사고를 막는다. 이건 처음 짤 때 내가 빠뜨렸던 부분이고, 실제로 [[combat_cooldown]]_global이라는 깨진 결과가 나오고서야 추가했다.
둘째, heal_links는 개명 사전(aliases)을 거쳐 치유한다. atom 이름이 combat_gcd → combat_global_cooldown_constant로 바뀌면, 옛 이름을 새 이름으로 자동 교체하고, 사전에도 없으면 그제야 dead link로 보고한다. 이름이 바뀔 때마다 200건을 손으로 고치는 대신, alias 한 줄을 추가한다.
자동 apply는 "이미 알려진 atom 이름"만 링크한다. 하지만 본문에 atom 이름을 안 쓰고 풀어 쓴 문장("전투의 글로벌 쿨다운은 0.5초다")은 잡지 못한다. 이런 자연어 참조를 wikilink 후보로 바꾸는 건 LLM이 사람보다 빠르다. 아래는 실제로 주고받은 전문이다. 출력은 요약하지 않았고, 내가 거부한 부분도 그대로 남긴다.
내 프롬프트 (전문):
전투 룰북 한 단락이랑 등록된 atom 목록을 줄게. 본문에서 이 atom들을 가리키는
자연어 표현을 찾아 [[wikilink]] 후보를 표로 제안해 줘. 확실할 때만, 애매하면
"애매"로 표시만 하고. 본문은 다시 쓰지 말고 어느 구절을 어떤 링크로 바꿀지만.
등록 안 된 개념은 "신규 atom 후보"로 따로 빼 줘.
[등록된 atom]
combat_global_cooldown_constant
combat_skill_cast_interrupt_rule
combat_resource_regen_rate
[본문]
전투의 글로벌 쿨다운은 0.5초이며, 모든 액티브 스킬에 공통 적용된다.
시전 중 피격되면 시전이 끊긴다. 단, 회복 계열 스킬은 예외다.
자원 재생은 비전투 상태에서만 정상 속도로 돌아간다.
Claude 출력 (날것 그대로):
제안 wikilink:
| 본문 구절 | 후보 링크 | 확신 |
|----------------------------------|----------------------------------------|------|
| "전투의 글로벌 쿨다운은 0.5초" | [[combat_global_cooldown_constant]] | 높음 |
| "시전 중 피격되면 시전이 끊긴다" | [[combat_skill_cast_interrupt_rule]] | 높음 |
| "자원 재생은 비전투 상태에서만" | [[combat_resource_regen_rate]] | 중간 |
신규 atom 후보:
- "회복 계열 스킬은 예외다" → 이 예외 규칙은 등록된 atom 중 어디에도
명시돼 있지 않습니다. combat_skill_cast_interrupt_heal_exception 같은
별도 atom이 필요해 보입니다. (애매: interrupt_rule의 하위 조항일 수도
있어 단독 atom인지 판단 못 함)
주의: "자원 재생" 줄은 combat_resource_regen_rate가 '재생 속도'를
가리키는지 '비전투 조건'을 가리키는지 atom 정의를 못 봐서 확신 중간입니다.
내 검증과 거부:
세 줄 중 두 줄은 받아들였다. 글로벌 쿨다운과 시전 중단은 atom 정의와 정확히 일치했다. 세 번째 "자원 재생" 줄은 Claude가 스스로 확신을 "중간"으로 낮춘 게 옳았다 — combat_resource_regen_rate atom은 속도 값만 정의하고, "비전투 상태에서만"이라는 조건은 다른 atom 소관이었다. 그대로 링크를 걸면 "조건"을 "속도" atom에 잘못 연결하는 사고가 난다. 거부했다.
신규 atom 후보 지적은 정확했다. "회복 계열 예외"는 정말로 어디에도 atom이 없었다. 다만 Claude가 애매하다고 한 부분("interrupt_rule의 하위 조항인가 단독 atom인가")은 사람이 판단할 영역이었고, 나는 단독 atom으로 분리하기로 했다.
재요청:
"자원 재생" 줄은 링크하지 마세요. 대신 [[combat_resource_regen_rate]]는
'속도'만, '비전투 조건'은 신규 atom으로 분리합니다. 두 atom의 1줄 정의를
각각 써 주세요. 회복 예외도 단독 atom으로 1줄 정의해 주세요.
이 왕복에서 LLM이 한 일은 "0에서 후보 만들기"가 아니라 "후보를 골라 주기"였다. 핵심은, 사람이 거부할 자리가 분명히 있었다는 것이다. 자동 발행이었다면 잘못된 링크 한 개가 그래프에 영구히 남았을 것이다.
링크는 시간이 지나면 깨진다. atom이 폐기되고, 이름이 바뀌고, 오타가 들어간다. 그래서 빌드마다 wikilink lint를 돌린다. 검사 항목과 처리는 이렇다.
[[name]]의 name이 레지스트리에 없음 → 빌드 경고, 치유 사전 확인dead link만 차단이 아니라 경고로 둔 건 의도다. atom을 개명하는 중간 상태에서는 잠깐 dead가 생기는데, 이걸 빌드 실패로 막으면 작업이 멈춘다. 대신 치유 사전을 먼저 확인하게 한다. 양식 위반과 이름 충돌은 즉시 차단한다 — 이 둘은 그래프 전체를 오염시키기 때문이다.
이 lint는 자기 증명적이다. wikilink_apply.py가 만든 링크를 같은 시스템의 lint가 검사하고, 그 결과를 또 다른 atom 결정으로 남긴다. 도구가 자기 산출물을 자기 기준으로 검증하는 고리가 운영의 기본 골격이다.
여기까지가 연결이다. 이제 분류다. wikilink가 "이 결정이 어디로 이어지나"를 답한다면, 위계는 "이 문서가 어디에 사는가"를 답한다. 둘 다 없으면 신규 기획자의 40분 검색이 반복된다.
우리 문서 폴더는 네 계층이다. 이 계층은 정보 아키텍처의 Layer 통합과 같은 골격을 공유한다 — 비전·시스템·콘텐츠·메타가 각각 한 층이다.
docs/
├── L0_vision/ 비전 (5건 이하, 거의 안 바뀜)
├── L1_systems/ 분야별 룰북
│ ├── combat/
│ ├── narrative/
│ └── ui/
├── L2_content/ 개별 콘텐츠
│ ├── characters/
│ └── quests/
└── L4_meta/ 운영·결정·회의·원자
├── decisions/
├── meetings/
├── reports/
└── atoms/
L3가 비어 있는 건 데이터 시트·DB가 그 자리를 차지하기 때문이다(문서가 아니라 테이블). 신규 기획자가 찾던 결정은 L4_meta/decisions/에 산다 — 이 약속 하나만 있어도, 40분 검색은 "결정은 거기 있다"는 한 문장으로 끝났을 것이다.
위계가 검색 입구로 작동하려면 다섯 가지가 같이 지켜져야 한다. 어느 하나만 빠져도 분류가 무너진다.
combat/·narrative/는 검색되지만 2026-Q1/·2026-Q2/는 6개월 뒤 아무도 안 연다. 시간은 git이 기록하니 폴더로 또 나눌 이유가 없다.L1_systems/combat/skills/active/single_target/attack.md는 5단계다. 경로가 한 화면을 넘으면 사람이 위치를 머리에 못 담는다.spec_·report_·decision_·char_로 종류를 파일명에 넣는다. 폴더를 안 봐도 종류가 보인다._ prefix 메타 폴더. _archive/·_TEMPLATES/·_NAMING/은 자동 정렬에서 위로 올라오고, 본 콘텐츠와 섞이지 않는다.문서는 한 자리에 머물지 않는다. 작성 중에는 본 폴더에 status: draft로 살고, 활성화되면 status: active가 되며, 폐기되면 삭제가 아니라 _archive/로 옮겨 status: deprecated를 단다. 폐기 자료를 삭제하지 않는 건 철칙이다. 6개월 뒤 누군가 "그 결정 왜 뒤집었지?"를 물을 때, 답은 폐기 자료 안에만 있다. 삭제했다면 결정의 근거를 사후에 다시 잡을 방법이 없다.
큰 변경은 git에만 맡기지 않고 frontmatter에 change_log로 남긴다.
---
title: combat_global_cooldown_rule
version: v3
last_modifier: teammate_a
change_log:
- v1 (2025): 초안
- v2 (2025): cooldown 0.3 → 0.5 ([[D2026_Q2_017]])
- v3 (2026): 회복 예외 추가 ([[D2026_Q2_018]])
---
change_log의 결정 ID가 wikilink로 적혀 있다는 점을 보라. 여기서 연결과 분류가 만난다. 문서는 위계 안 한 자리에 살지만(분류), 그 변경 이력은 결정 그래프로 이어진다(연결). 한 frontmatter가 두 입구를 동시에 연다.
위계는 가만두면 썩는다. 빈 폴더가 생기고, 6개월 묵은 draft가 쌓이고, 깊이가 슬금슬금 늘어난다. 그래서 분기에 한 번 정리한다. 빈 폴더는 삭제하고, 6개월 넘은 draft는 활성/폐기를 결정하고, 깊이 5 이상은 평탄화하고, README 없는 폴더는 작성하거나 폐기하고, _archive가 절반을 넘으면 압축 보존한다. 이 사이클이 없으면 위계가 노이즈로 차서 신호와 잡음의 구분이 사라진다.
전체 흐름을 한 그림으로 보면 이렇다. 문서가 들어와서 연결되고, 분류되고, 검증되고, 폐기되기까지가 하나의 고리다.
flowchart TD
A[새 문서 작성
status: draft] --> B[wikilink_apply.py
atom 이름 자동 링크]
B --> C[LLM 보강
자연어 참조 후보]
C --> D{사람 검수}
D -->|채택| E[위계 배치
L0~L4 + prefix]
D -->|거부| C
E --> F[빌드 lint
dead/충돌/순환 검사]
F -->|통과| G[status: active]
F -->|dead link| H[치유 사전 확인]
H --> F
G --> I[분기 정리 사이클]
I -->|폐기| J[_archive/
status: deprecated]
I -->|유지| G
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
classDef fail fill:#fee2e2,stroke:#dc2626,color:#7f1d1d;
class B,F,H code;
class C ai;
class D,I human;
class A data;
class G pass;
class J fail;
이 고리에서 연결(B·C·D·F)과 분류(E·I·J)가 번갈아 작동한다. 둘은 따로 도는 게 아니라 한 문서의 생애 안에서 맞물린다.
수치는 저자의 프로젝트에서 도입 전후를 비교한 방향성이다. 정밀 측정값이 아니라, 같은 작업을 두 환경에서 했을 때 체감한 차이의 크기다(저자 관찰, 미정밀계측).
연결·위계가 자리 잡기 전, 신규 기획자의 결정 추적 질문은 도입부의 40분 사례처럼 길게는 한두 시간이 걸렸다. 도입 후엔 atom 역참조 한 번 — 분 단위다. 문서 검색은 5~10분에서 30초 안팎으로 줄었는데, 이건 위계의 의미순 분류와 prefix가 같이 작동한 결과다. 잘못된 인용으로 인한 사고(이미 폐기된 값을 현행으로 착각하는 류)는 분기당 여러 건에서 한두 건으로 줄었다 — wikilink가 "이건 그 atom을 참조한다"를 명시하니, 복사된 값과 원본 값이 더는 헷갈리지 않았다.
가장 컸던 변화는 신규 합류자의 적응이다. 위계 없이는 어느 폴더에 뭐가 있는지 익히는 데 며칠이 걸렸고, 연결 없이는 시스템이 서로 어떻게 얽히는지 파악할 길이 없었다. 둘이 갖춰진 뒤로는 폴더 README로 위치를 익히고, wikilink 그래프를 따라 시스템 간 관계를 스스로 탐색했다. "물어봐야만 알 수 있는 것"이 "따라가면 보이는 것"으로 바뀌었다.
이 효과는 두 입구가 함께 있을 때만 나온다. 연결만 있고 분류가 없으면 그래프는 있는데 문서가 어디 사는지 모르고, 분류만 있고 연결이 없으면 폴더는 깔끔한데 결정이 어디로 이어지는지 모른다.
연결 쪽에서 가장 흔한 실패는 노이즈 링크다. wikilink가 좋다고 모든 명사에 대괄호를 달면, 그래프가 의미 없는 간선으로 가득 차 시각화 도구가 무력해진다. "이 문서가 저 문서와 어떤 관계인가"를 묻고 답할 수 있는 링크만 남기는 게 원칙이다. 그다음은 자동 발행 — LLM이 만든 링크를 검수 없이 커밋하면, 워크드 트랜스크립트의 "자원 재생" 줄 같은 잘못된 연결이 영구히 남는다. apply는 자동, 발행은 사람이다.
분류 쪽의 실패는 대부분 다섯 원칙의 위반이다. 시간순 폴더, 깊이 5 이상, 파일명 무규칙, README 부재. 그리고 가장 돌이키기 어려운 것 — 폐기 자료 삭제. 삭제된 결정의 근거는 다시 만들 수 없다. _archive로 보내는 한 줄이 6개월 뒤의 학습 자료를 지킨다.
_archive로 보존해야 결정의 근거가 사후에 살아남는다.setup. 문서 폴더에 L0_vision/ L1_systems/ L2_content/ L4_meta/ 네 폴더를 만들고, 각 폴더에 한 줄짜리 README를 두세요. atom 이름 목록을 _atom_registry.tsv 한 파일에 모읍니다(첫 칼럼 = atom name).
prompt. 본문 한 단락과 등록된 atom 목록을 LLM에 주고 이렇게 요청하세요 — "본문에서 이 atom들을 가리키는 자연어 표현을 찾아 [[wikilink]] 후보를 표로 제안하라. 확실할 때만 제안하고, 애매하면 '애매'로 표시만 하라. 본문은 다시 쓰지 말 것. 등록 안 된 개념은 '신규 atom 후보'로 분리하라."
verify. 제안된 링크마다 atom 정의와 대조하세요. atom이 가리키는 대상과 본문이 가리키는 대상이 정확히 같을 때만 채택하고, 조건/속성/예외가 어긋나면 거부합니다. 채택 후 grep "\[\[name\]\]"로 링크가 실제 입력됐는지, dead link가 없는지 확인하세요.
1인 축소판. 스크립트도 lint도 없이 시작하려면, 규칙 두 줄이면 됩니다. (1) 결정은 무조건 decisions/ 한 폴더에 decision_*.md로 둡니다. (2) 다른 문서가 그 결정을 언급할 때는 [[decision_id]]라 적습니다. 이 두 줄만 지켜도, "그 결정 어디 있죠?"라는 질문에 grep "\[\[decision_" 한 번으로 답할 수 있습니다. 도구는 문서가 100건을 넘은 뒤에 들여도 늦지 않습니다.
자료를 의심하는 순간은 늘 너무 늦게 온다. 라이브 빌드에 잘못된 수치가 입력된 다음에야 "이거 어디서 나온 거지"를 묻게 된다.
알파 빌드 직전 금요일 저녁, 팀원 B가 내 자리로 왔다. 손에는 전투 밸런스 스프레드시트가 띄워진 노트북이 있었다. "디렉터님, 보스 1페이즈 체력이 시트에는 48,000인데 빌드에 들어간 값은 52,000이에요. 둘 중 뭐가 맞아요?"
나는 모른다. 정확히 말하면 — 그 자리에서는 누구도 모른다. 시트의 52,000이 며칠 전 회의 결정을 반영한 최신값일 수도 있고, 누군가 검증 안 된 값을 임시로 넣어둔 것일 수도 있다. 48,000은 그 회의 이전의 합의값일 수도 있다. 두 숫자 모두 그럴듯하다. 그럴듯함은 근거가 아니다.
이 질문에 답하려면 출처로 거슬러 올라가야 한다. 어느 회의에서 결정됐는가, 그 회의의 입력은 무엇이었는가, 누가 시트에 옮겼는가. 그런데 그 추적의 사슬이 사람의 기억 속에만 있으면, 답은 "내일 팀원 A한테 물어볼게요"가 된다. 라이브 운영 6개월 차에는 그런 미해결 질문이 산처럼 쌓인다. data lineage — 자료의 계보 — 는 그 산이 생기지 않게 하는 인프라다.
핵심은 하나다. 출처는 손으로 적으면 안 된다. 사람이 사후에 보강하는 출처 기록은 한 달을 못 간다. 자료가 만들어지는 그 순간 자동으로 기록되는 출처만이 살아남는다.
_source_map.tsv 한 줄을 자동으로 기록하는 비용은 수 밀리초다. 그 한 줄이 없을 때 치르는 비용은 다섯 갈래로 번진다.
다섯 비용 중 어느 하나도 자료를 만든 그 순간에는 보이지 않는다는 점이 함정이다. 전부 몇 주 뒤, 몇 달 뒤, 사람이 바뀐 뒤에 청구서가 날아온다. 그래서 출처는 "나중에 정리하자"의 대상이 될 수 없다. 만드는 순간에 기록되어야 한다.
프로젝트 A에서 운영하는 출처 매핑 파일은 _source_map.tsv 하나다. 탭 구분 텍스트인 이유는 단순하다. 사람이 한 줄을 눈으로 읽을 수 있고, 스크립트가 split('\t') 한 번으로 파싱하며, git diff가 한 줄 변경을 깔끔하게 보여준다. CSV는 본문 안에 쉼표가 섞이면 깨지고, JSON은 한 줄을 사람이 읽기 어렵다.
asset_id source_type source created creator notes
spec_combat_v3 internal mtg_battle_2026-04-18 2026-04-18 teammate_a decision_D2026_Q2_017 근거
data_boss_hp_v3 internal decision_D2026_Q2_017 2026-04-18 teammate_b 1페이즈 48000 확정
asset_K_001_concept internal_ai_assisted imagegen + teammate_b 정비 2026-04-20 teammate_b legal_review 완료
data_user_voice_W21 external_aggregated forum + community + sns 2026-05-25 auto_collect 13.1 파이프라인 산출
ref_visual_tone_a external_reference refgame (2024) 2026-04-15 teammate_c 비주얼 톤 참고, 직접 차용 없음
여섯 칸의 역할이 명확하다. asset_id는 자료의 고유 키, source_type은 분류(아래에서 다룸), source는 출처의 위치 — 회의 ID·결정 ID·수집 파이프라인·외부 작품명, created/creator는 언제·누가, notes는 사람이 읽을 한 줄 맥락.
여기서 둘째 줄과 셋째 줄을 다시 보면, 앞 절의 팀원 B 질문에 답이 보인다. data_boss_hp_v3의 출처는 decision_D2026_Q2_017이고 notes에 "1페이즈 48000 확정"이 입력되어 있다. 빌드의 52,000은 이 lineage에 없다. 즉 52,000은 검증되지 않은 임시값이고, 정답은 48,000이다. 질문은 1~2분 만에 닫힌다. 사람의 기억을 호출하지 않고, 금요일 저녁을 망치지 않고.
그런데 이 파일에는 한 가지 규칙이 더 걸려 있다. _source_map.tsv를 사람이 손으로 편집하면 integrity_check의 audit이 FAIL을 낸다. 이유는 다음 절에서 다룬다 — 출처는 자동으로만 기록되어야 하기 때문이다.
출처를 다섯 가지로 분류하는 이유는 정리벽이 아니다. source_type마다 따라붙는 운영 규칙이 다르기 때문이다.
external_reference 한 줄을 보자. refgame을 비주얼 톤 참고로 본 자산이라면, 이 자산은 법무 검토 없이는 빌드에 들어가면 안 된다. source_type이 external_reference인데 legal_review 기록이 비어 있으면 audit이 막는다. 라벨이 라벨로만 끝나지 않고 검사기가 읽는 스위치가 되는 지점이다. 5종 분류가 운영 신뢰의 골격이라는 말은 이 강제력을 가리킨다.
이제 핵심이다. 출처는 자료 생성 시점에 자동으로 기록되어야 한다. 프로젝트 A의 source_tracker.py는 자산 생성 훅에 걸려 있다.
# source_tracker.py
import time, getpass, csv
from pathlib import Path
SOURCE_MAP = Path("_source_map.tsv")
VALID_TYPES = {
"internal", "internal_ai_assisted",
"external_aggregated", "external_reference", "self_measured",
}
def track_source(asset_id: str, source_type: str, source: str, notes: str = ""):
if source_type not in VALID_TYPES:
raise ValueError(f"unknown source_type: {source_type}")
if source_type == "external_reference" and "legal_review" not in notes:
raise ValueError(f"{asset_id}: external_reference 자산은 legal_review 표기 필수")
record = [
asset_id,
source_type,
source,
time.strftime("%Y-%m-%d"),
getpass.getuser(),
notes,
]
with SOURCE_MAP.open("a", encoding="utf-8", newline="") as f:
csv.writer(f, delimiter="\t").writerow(record)
이 함수가 자산 생성 파이프라인에 걸려 있으면 — 시트가 export될 때, 컨셉 자산이 등록될 때, 사용자 데이터가 집계될 때 — 출처 한 줄이 자동으로 append된다. 사람이 잊을 수 있는 단계가 없다. 사후 보강의 부담이 0에 가까워진다.
creator 칸을 getpass.getuser()로 자동 채우는 점이 작지만 결정적이다. 사람이 자기 이름을 적게 하면 빈칸이 생긴다. 시스템이 적으면 빈칸이 없다. 그리고 external_reference에 legal_review가 없으면 아예 기록 자체가 거부된다 — 출처 없는 외부 자산이 시스템에 들어올 통로를 막은 것이다.
여기서 AI 활용의 실제 모습을 짚어둔다. 나는 이 track_source 함수의 초안을 Claude에게 받았다. 다음은 그 워크드 트랜스크립트의 한 척추다.
내 프롬프트 (전문):
_source_map.tsv에 출처 한 줄을 append하는 파이썬 함수를 써줘.
컬럼: asset_id, source_type, source, created(오늘날짜),
creator(현재 OS 사용자), notes. 탭 구분.
source_type은 5종으로 제한: internal, internal_ai_assisted,
external_aggregated, external_reference, self_measured.
Claude 날것 출력 (요약하지 않음):
import csv, datetime, os
def track_source(asset_id, source_type, source, notes=""):
record = [asset_id, source_type, source,
datetime.date.today().isoformat(),
os.getlogin(), # ← 현재 사용자
notes]
with open("_source_map.tsv", "a", newline="") as f:
csv.writer(f, delimiter="\t").writerow(record)
source_type 검증을 추가하고 싶으시면 VALID_TYPES 집합을 만들어 함수 앞에서 체크하면 됩니다. 필요하시면 넣어드릴까요?
내 검증과 거부:
이 출력은 동작은 하지만 두 군데에서 우리 운영에 맞지 않았다. 첫째, os.getlogin()은 cron이나 CI에서 호출되면 환경에 따라 빈 문자열을 던지거나 예외를 낸다. 우리 export 파이프라인은 무인 스케줄로도 돈다. 그래서 getpass.getuser()로 바꿨다 — 환경변수를 보고 더 안정적으로 사용자를 잡는다. 둘째, Claude는 source_type 검증을 "원하면 넣어드릴까요"로 선택지로 남겼는데, 우리에게 그건 선택이 아니라 필수다. 검증이 없으면 오타 난 source_type이 들어와 분류가 무너진다.
내 재요청:
getpass.getuser()로 바꿔줘. 그리고 source_type 검증은 선택이 아니라
필수로 함수 안에 박아줘. 추가로 external_reference 타입인데
notes에 legal_review 문자열이 없으면 ValueError를 던지게 해줘.
법무 검토 없는 외부 자산이 기록되는 걸 원천 차단하고 싶어.
이 재요청의 결과가 위에 실은 최종 source_tracker.py다. 짚을 점은 Claude의 첫 출력이 틀려서가 아니라, AI가 모르는 운영 제약 — 무인 스케줄, legal_review 강제 — 을 내가 알기 때문에 거부와 재요청이 필요했다는 것이다. AI는 일반적으로 맞는 코드를 빠르게 주고, 사람은 "우리 환경에서 맞는가"를 검증한다. 그 검증 지점이 곧 출처 추적 시스템의 설계 결정이 된다.
_source_map.tsv를 사람이 손으로 편집하면 integrity_check가 FAIL을 낸다고 앞서 말했다. 어떻게 잡는가.
원리는 단순하다. track_source가 한 줄을 append할 때마다, 그 줄의 핵심 칸(asset_id, source_type, source, created, creator)을 직렬화해 해시를 만들고 별도의 .source_map.audit 파일에 누적한다. audit 검사는 _source_map.tsv를 다시 읽어 같은 방식으로 해시를 재계산하고, 두 해시 목록을 대조한다.
# integrity_check 내 source_map audit 부분
def audit_source_map():
fails = []
rows = read_tsv(SOURCE_MAP)
expected = read_lines(AUDIT_FILE) # append 시 누적된 해시
for i, row in enumerate(rows):
h = row_hash(row["asset_id"], row["source_type"],
row["source"], row["created"], row["creator"])
if i >= len(expected) or h != expected[i]:
fails.append(f"L{i+1} {row['asset_id']}: 수동 편집 의심 (해시 불일치)")
if len(rows) != len(expected):
fails.append(f"행 수 불일치: tsv={len(rows)} audit={len(expected)}")
return fails
사람이 시트에서 data_boss_hp_v3의 source를 손으로 decision_D2026_Q2_099로 바꿨다고 하자. 그 줄의 해시가 audit에 누적된 원래 해시와 어긋나고, 검사는 다음을 출력한다.
[FAIL] source_map audit
L3 data_boss_hp_v3: 수동 편집 의심 (해시 불일치)
→ track_source()를 거치지 않은 변경. 출처는 코드 경로로만 기록할 것.
이 강제가 왜 중요한가. 손편집을 허용하면 결국 누군가 급할 때 출처를 "그럴듯하게" 채워 넣는다. 그 순간 lineage는 진실이 아니라 누군가의 추측을 담은 파일로 전락한다. audit FAIL은 "출처는 자동 경로로만"이라는 규칙에 이빨을 달아준다. §24.1의 verification 시스템이 이 audit을 다른 검사들과 함께 묶어 CI에서 돌린다.
출처를 자동으로 기록하는 진짜 이유는 역방향 질의에 있다. "원본 X가 바뀌었다. 무엇이 영향받는가?"
def find_derivatives(source_id: str):
return [
row for row in read_tsv(SOURCE_MAP)
if row["source"] == source_id
]
# 사용: decision_D2026_Q2_017이 회의에서 번복됐다
deps = find_derivatives("decision_D2026_Q2_017")
# → [spec_combat_v3, data_boss_hp_v3, ...]
decision_D2026_Q2_017이 다음 회의에서 번복돼 보스 1페이즈 체력이 48,000에서 50,000으로 바뀌었다고 하자. find_derivatives를 호출하면 이 결정에 매달린 모든 파생 자산이 즉시 나온다 — 전투 스펙 문서, 체력 데이터 시트. 각 자산 담당자에게 알림이 가고, "옛 결정을 바라보는 자산"이 빌드에 남는 사고가 분기당 여러 건에서 거의 0으로 줄어든다.
손으로 적은 출처로는 이 역방향 질의가 성립하지 않는다. 출처가 자유 텍스트면 decision_D2026_Q2_017이 어떤 줄에는 "Q2 017 결정", 어떤 줄에는 "2분기 17번 회의 결정"으로 적혀 매칭이 깨진다. _source_map.tsv의 표준 형식과 track_source의 자동 기록이 있어야 비로소 변경 전파가 작동한다.
_source_map.tsv는 한 줄씩 보면 평면이지만, source가 다른 자산의 source가 되면서 자료의 계보가 사슬을 이룬다. 그 사슬을 한 화면으로 펼치면 결정의 입력 신뢰도가 눈에 들어온다. 이 mermaid는 §24.2의 다이어그램 자동 생성 파이프라인이 _source_map.tsv를 읽어 직접 뽑는다 — 자기 기법으로 자기 자산을 증명하는 셈이다.
graph LR
Meeting["mtg_battle_2026-04-18
(회의)"] --> Proposal["P2026_Q2_017
(제안)"]
Proposal --> Decision["D2026_Q2_017
(결정·48000 확정)"]
Decision --> Spec["spec_combat_v3
(전투 스펙)"]
Decision --> Data["data_boss_hp_v3
(체력 시트)"]
Data --> Build["build_2026-05-20
(알파 빌드)"]
Build --> UserData["data_user_voice_W21
(사용자 측정)"]
UserData -.다음 결정의 입력.-> Decision
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
class Meeting,Decision human;
class Proposal,Spec,Data,Build,UserData data;
순환이 자연스럽게 생긴다. 빌드가 사용자 데이터를 낳고, 사용자 데이터가 다음 결정의 입력이 된다. 이 순환이 보이면 "이 수치 어디서 왔나"가 화면 위의 경로가 된다. 팀원 B의 금요일 질문도 이 그래프에서는 Data → Decision을 한 번 거슬러 올라가는 일이다.
프로젝트 A에서 lineage 시스템 도입 전후를 비교했다. 아래 시간 수치는 저자 추정(미검증)이며, 절대값보다 방향과 비율의 차이를 보아야 한다. 건수는 분기 audit 로그에서 집계한 실측이다.
| 항목 | lineage 부재 | lineage 운영 | 성격 |
|---|---|---|---|
| 자료 출처 파악 시간 | 1~2시간 | 1~2분 | 저자 추정(미검증) |
| 자료 신뢰도 검증 근거 | 시니어 기억 | 출처 즉시 조회 | 정성 |
| 원본 변경 시 파생 누락 | 분기 5~8건 | 0~1건 | audit 로그 실측 |
| 외부 자산 법무 검토 누락 | 발생 가능 | 0건(기록 강제) | audit 로그 실측 |
| 분기 audit 소요 | 1~2일 | 2~3시간 | 저자 추정(미검증) |
가장 단단한 숫자는 "원본 변경 시 파생 누락" 행이다. 이건 audit 로그에 결정 ID와 누락된 파생 자산이 그대로 남기 때문에 셀 수 있다. 시간 수치는 측정 환경(팀 규모·자산 수)에 크게 좌우되므로 추정으로 명시했다. 방향은 분명하다 — 출처가 자동으로 기록되면 추적은 기억에서 조회로 바뀐다.
| 실패 패턴 | 처방 |
|---|---|
| 출처를 사후에 손으로 채움 | track_source로 생성 시점 자동 기록 |
| 출처 형식이 줄마다 다름 | _source_map.tsv 탭 표준 + 형식 강제 |
| 외부 자산 법무 검토 누락 | source_type 검증에서 legal_review 강제 |
_source_map.tsv 손편집 |
integrity_check audit으로 해시 대조 FAIL |
| 원본 변경 시 파생 방치 | find_derivatives 역방향 질의 + 알림 |
| 계보를 글로만 설명 | mermaid 자동 생성으로 한 화면 시각화 |
여섯 처방의 공통점은 사람의 성실성에 의존하지 않는다는 것이다. 자동 기록·형식 강제·해시 대조·역방향 질의는 전부 시스템이 한다. 출처 추적이 무너지는 단 하나의 이유가 "사람이 깜빡한다"이기 때문이다.
24부는 운영의 신뢰를 자동화로 떠받치는 네 갈래였다. 1장 verification으로 검증을 한 점에 모으고, 2장 mermaid 자동 생성으로 구조를 그렸고, 3장 wikilink와 document hierarchy로 문서를 연결하고 계층화했으며, 마지막으로 이 4장에서 출처와 계보로 자료의 신뢰를 봉인했다.
네 장을 관통하는 한 문장은 이렇다. 운영의 신뢰는 사람의 기억이 아니라 시스템의 기록에서 온다. verification이 "이 산출물이 규칙에 맞는가"를 자동으로 묻듯, lineage는 "이 자료가 어디서 왔는가"를 자동으로 답한다. 둘 다 사람이 잊어도 무너지지 않는다는 점이 핵심이다.
이 운영 노하우는 책 전체의 Layer 통합 철학과 같은 결을 이룬다. 비전(자산화·신뢰)이 시스템(출처 룰)으로, 시스템이 데이터(_source_map.tsv)로, 데이터가 빌드·QA(audit·자동 갱신)로 내려가는 한 줄의 사슬이다. 그 사슬 자체가 lineage다.
_source_map.tsv 표준과 source_type 5종이 변경 전파와 법무 강제의 골격이다.setup. 프로젝트 루트에 _source_map.tsv를 헤더 한 줄(asset_id\tsource_type\tsource\tcreated\tcreator\tnotes)로 만들고, 위의 source_tracker.py를 두세요. 자산 export·등록 스크립트의 마지막에 track_source(...) 호출을 거세요.
prompt. 출처 자동 기록 함수가 필요하면 Claude에게 이렇게 요청하세요.
_source_map.tsv(탭 구분, 컬럼: asset_id, source_type, source,
created, creator, notes)에 한 줄을 append하는 파이썬 함수를 써줘.
source_type은 5종으로 제한하고, external_reference인데 notes에
legal_review가 없으면 ValueError를 던져. creator는 getpass.getuser()로.
verify. 두 가지를 직접 확인하세요. (1) external_reference로 호출하되 notes를 비워 보고, ValueError가 나는지. (2) _source_map.tsv의 source 칸을 텍스트 에디터로 한 글자 바꾼 뒤 integrity_check의 source_map audit을 돌려 FAIL이 뜨는지. 둘 다 막히면 출처 경로가 닫힌 것입니다.
혼자 작업한다면 _source_map.tsv 한 파일과 track_source 한 함수면 충분합니다. integrity audit·역방향 질의·mermaid 자동화는 자산이 수십 개를 넘어 출처가 헷갈리기 시작할 때 하나씩 붙이면 됩니다. 시작은 "수치를 적을 때 출처 한 줄을 같은 자리에 자동으로 남긴다" 그 습관 하나입니다.
이 책이 시작된 자리는 LLM 질문창 하나였습니다. "기획서 좀 정리해 줘" 한 줄에서 시작해, AI는 게임 디자인룸 전체로 들어왔습니다. 지금 이 자리는 그 변화의 끝이 아니라, 한 단계의 매듭입니다.
24년을 게임 기획자로 보낸 자리에서, 분명히 변한 것들이 있습니다. 양산은 도구로 흡수됐고, 회의록은 자산이 됐고, 결정은 추적할 수 있게 됐습니다.
다만 같은 속도로 모든 분야에 변화가 도달한 것은 아닙니다. 시스템 기획과 밸런스에는 도구 흡수가 빠르게 들어왔지만, 내러티브와 아트 디렉팅은 아직 보수적 적용 단계에 머무는 경우가 많습니다. 어느 분야가 더 빠른가는 핵심이 아닙니다. 분야마다 변화가 들어오는 자리가 다르다는 사실을 인정하는 데서 다음 결정이 시작됩니다.
기획자가 더 적은 일을 하게 됐다는 뜻은 아닙니다. 같은 시간에 다른 일을 하게 됐다는 뜻입니다. 양산에서 의미로, 정리에서 결정으로 옮겨 간 것이지요. 도구가 양산을 흡수한 자리에 의미 있는 일이 곧바로 자동으로 채워지지는 않기 때문에, 무엇을 하지 않을지부터 다시 정해야 하는 어색한 시기가 한동안 이어집니다.
변하지 않은 것도 있습니다. 게임은 사람을 위한 것이고, 게임의 비전은 사람이 결정하며, 사용자의 시간을 존중하는 게임을 만든다는 약속은 그대로입니다.
도구는 도구이고, 방향은 사람입니다. 다만 저는 이 한 줄을 매년 다시 점검하려 합니다. 점검하지 않으면, 도구가 충분히 강해진 자리에 "그래도 결정은 사람이 한다"고 막연히 믿는 디폴트가 슬그머니 깔리기 때문입니다. "방향은 사람"이라는 말이 안전한 약속처럼 들리는 순간이, 사실은 가장 위험합니다.
이 책은 한 시점의 기록입니다. AI 도구는 빠르게 진화하고, 1년 뒤면 이 책의 일부는 낡았을 가능성이 큽니다. 그러나 핵심 패턴 — Layer 통합, 결정 추적, 검증 게이트, 사람 검수, 팀 합의 — 은 도구가 바뀌어도 유효하다고 저는 믿습니다. 오히려 도구가 강해질수록, 이 패턴 위에서 작동하지 않는 사용은 더 빨리 무너집니다.
Layer 통합은 단지 분야 간 협업 언어를 통일하는 일이 아니라, 절차적 생성과 자동화로 가는 길을 먼저 열어 두는 일입니다. 작가가 한 줄씩 컨텍스트를 주입하는 보수적 적용에서 시작해, 시스템이 월드 상태에서 자동으로 생성하는 진보적 적용으로 단계를 넓혀 가는 동안, 녹음·캡처·라이브 빌드처럼 되돌릴 수 없는 단계 직전의 사람 검수가 마지막 안전망 역할을 합니다. LLM이 더 영리해질수록 이 골격의 가치는 줄지 않습니다. 오히려 사람이 검수해야 할 결정의 무게가 더 커집니다.
여러분이 이 책의 패턴을 자기 환경에 맞춰 변주하신다면, 이 책의 다음 버전은 여러분이 쓰시는 셈입니다. 회사 규모와 장르, 개발 단계, 팀 구성에 따라 어떤 패턴은 그대로 가져갈 수 있고 어떤 패턴은 다시 짜야 합니다. 가져갈 수 있는 것과 다시 짜야 하는 것을 구분하는 일 자체가, 첫 번째 의미 있는 작업입니다.
무엇보다, 이 책의 출간을 허락해 주신 SCYBS Games 임원진과 대표님께 깊이 감사드립니다. 회사에서 자연스럽게 발전한 워크플로를 업계와 나눌 수 있도록 길을 열어 주신 그 결정이 없었다면 이 책은 없었습니다. 24년간 게임 제작의 길을 함께, 때로는 따로 걸었던 모든 동료들, 20여년간 제 곁을 지켜 준 반려자, 그리고 스물세 살이 되도록 곁을 지켜 주는 페르시안 꽁지에게 감사합니다. 이제는 곁에 없는 영원히 열아홉 살인 포메라니안 고미에게도 같은 마음을 전합니다.
이른 성공이 인생의 독배가 되는 경험은 한 번이면 충분하다고 생각합니다. 그 한 번을 잊지 않으려고, 저는 24년 가까이 절차탁마하며 다시 배우는 중입니다. 이 책도 그 배움의 한 시점에 남기는 매듭에 가깝습니다.
끝으로, 이 책의 집필을 도운 AI 도구에 고마움을 전합니다. 원고를 닫는 사이에 Claude의 새 모델 Fable이 나왔을 만큼, 이 판은 하루가 다르게 변하고 있습니다.
이 책이 누군가의 다음 결정에 한 줄이라도 보탤 수 있다면, 그것으로 충분합니다.
이민수, 2026년
이 부록은 저자가 운영하는 MMORPG 개발사 A의 회사 PC 환경을 하드웨어부터 도구, 지식 자산, 검증 자료까지 한 장에 모은 인벤토리입니다. 본문 곳곳에서 "이런 도구로", "이런 atom 구조로", "이런 보고서로"라고 언급한 것들이 실제로 어떤 규모와 조합으로 존재하는지 한눈에 보여 드리기 위해 정리했습니다. 실명·고유명사는 모두 익명화했고, 수치는 시점에 따라 달라지는 것이므로 절대값이 아니라 비율과 구성으로 읽어 주시기 바랍니다.
이 부록을 읽는 방법은 두 가지다. 하나는 자신의 환경과 항목별로 비교해 보는 것이다. "나는 어떤 엔진을 쓰는가, 어떤 협업 도구를 쓰는가, 지식 자산은 어떤 형태로 쌓여 있는가"를 같은 칸에 채워 보면 자신의 빈칸이 드러난다. 다른 하나는 구성의 균형을 보는 것이다. 도구가 많다고 좋은 환경이 아니라, 엔진·기획·아트·협업·AI 다섯 축이 서로를 막지 않고 맞물려 있는지가 중요하다. 항목 하나하나보다 그 짜임을 봐 주시기 바랍니다.
가장 먼저 토대가 되는 하드웨어와 운영체제다. AI 도구를 본격적으로 쓰기 시작하면 로컬에서 디퓨전 모델이나 STT를 돌리는 일이 생기므로, 메모리와 GPU 여유가 곧 작업 속도가 된다. 아래 사양은 "이 정도면 막힘 없이 굴러간다"는 하한선에 가까운 기준선으로 봐 주시기 바랍니다.
| 항목 | 사양 |
|---|---|
| CPU | 데스크탑 워크스테이션급 |
| RAM | 64GB 이상 |
| GPU | UE5 개발용 (CUDA 호환) |
| 저장 | SSD 2TB + NAS 공유 |
| 모니터 | 27인치 2대 |
RAM과 GPU 두 줄이 핵심이다. 엔진 에디터, 로컬 LLM 보조 도구, 이미지 생성을 동시에 띄우는 순간이 자주 오기 때문이다.
| 항목 | 값 |
|---|---|
| OS | Windows 11 Pro |
| 가상화 | WSL2 (Ubuntu), 필요 시 |
| 백업 | 일일 자동 |
WSL2는 상시 켜 두는 것이 아니라 리눅스 전용 도구를 돌릴 때만 끌어다 쓴다. 백업이 일일 자동으로 돌고 있다는 점이 이 표에서 가장 중요한 한 줄이다.
도구는 다섯 갈래로 묶는다. 엔진·툴, 디자인·기획, 아트, 협업·운영, 그리고 AI·LLM이다. 한 사람이 다섯 갈래를 다 쓰는 것은 아니지만, 기획자라면 디자인·기획과 협업·운영, AI·LLM 세 갈래를 매일 오간다. 갈래별로 "필수 한두 개 + 보조"의 형태를 띤다는 점에 주목해 주시기 바랍니다.
| 도구 | 용도 |
|---|---|
| Unreal Engine 5.7 이상 | 메인 엔진 |
| Visual Studio | 코드 |
| Rider | C# IDE (보조) |
| Perforce 또는 SVN | 코드·자산 버전 관리 |
엔진과 버전 관리가 한 쌍이다. 기획자도 버전 관리 클라이언트는 반드시 다룰 줄 알아야 한다. 데이터 시트와 명세서가 모두 같은 저장소에서 굴러가기 때문이다.
| 도구 | 용도 |
|---|---|
| Excel | 데이터 시트 + VBA 매크로 |
| 마크다운 에디터 | 명세서·회의록 |
| Figma | UI·와이어프레임 |
| Mermaid | 다이어그램 |
기획자의 일상 작업대다. Excel은 데이터의 본진, 마크다운은 글의 본진이며, AI 도구가 가장 깊게 붙는 두 지점이기도 하다. Mermaid가 한 칸을 차지하는 이유는 본문에서 강조한 대로 도식이 곧 합의의 언어이기 때문이다.
| 도구 | 용도 |
|---|---|
| Maya / Blender | 3D |
| Substance 3D | 텍스처·재질 |
| Photoshop | 2D·일러스트 |
| Stable Diffusion(SDXL) / ComfyUI | 자체 호스팅 컨셉·텍스처 본 양산 (LoRA·ControlNet) |
| Midjourney | 초기 무드보드 (보조) |
기획자가 직접 쓰는 도구는 아니지만, 아트 파트와 컨셉을 주고받을 때 어떤 도구가 그쪽에 있는지 알아 두면 요청의 해상도가 달라진다. 본 양산은 자체 호스팅 Stable Diffusion(SDXL)/ComfyUI가 주축이다 — 자산을 외부에 올리지 않아 IP를 지키고, 캐릭터 LoRA·ControlNet으로 같은 인물의 일관성을 반복 생성마다 통제할 수 있기 때문이다. Midjourney 같은 폐쇄형 도구는 프로젝트 톤을 처음 더듬는 초기 무드보드 정도에만 보조로 쓰고, 일관성·반복 통제가 걸린 본 양산에는 쓰지 않는다.
| 도구 | 용도 |
|---|---|
| 협업툴(ClickUp) | 태스크 |
| 사내 메신저 | 실시간 소통 |
| 자체 구축 위키 | 위키·장기 문서 |
| 자체 포털 웹 | 통합 인터페이스 (20.3) |
소통의 시간 축이 도구를 가른다. 즉시성이 필요한 실시간 소통은 사내 메신저로, 할 일은 협업툴(우리 팀은 ClickUp)로, 오래 남길 지식은 자체 구축한 위키로 가른다 — 트래커는 JIRA·Redmine, 위키는 Confluence·Notion으로 바꿔도, 메신저가 무엇이든 이 책의 흐름은 그대로다. 자체 포털 웹은 이 셋과 AI 도구를 한 화면에서 잇는 통합 창구로, 20.3에서 자세히 다룬다.
| 도구 | 용도 |
|---|---|
| Claude (Opus + Sonnet) | 메인 LLM |
| GPT-4 | 대안 |
| Whisper (자체 호스팅) | 음성 인식(STT) |
| Stable Diffusion | 이미지 생성 (자체 호스팅) |
| MCP 서버 | 도구 통합 (20.4) |
메인은 Claude로 두고 GPT-4를 교차 검증·대안으로 둔다. 민감한 음성·이미지는 외부로 보내지 않고 자체 호스팅으로 처리한다는 원칙이 Whisper와 Stable Diffusion 두 줄에 담겨 있다. MCP 서버는 이 도구들을 작업 흐름에 끼워 넣는 접착제이며, 20.4에서 구조를 설명한다.
atom은 본문에서 다룬 "결정의 최소 단위"를 파일로 떨어뜨려 둔 지식 조각이다. 아래는 그 atom이 분야별로 어떻게 분포해 있는지를 보여 주는 표로, 2026년 5월 시점의 한 단면이다. 절대 개수보다 어느 분야에 결정이 몰려 있는지를 봐 주시기 바랍니다. 결정이 몰린 곳이 그 프로젝트가 가장 치열하게 고민하는 지점이다.
| 카테고리 | atom 수 | 비고 |
|---|---|---|
| combat | 47 | 전투 시스템 결정 |
| narrative | 38 | 내러티브 5계층 |
| ui | 31 | UI·HUD |
| balance | 28 | 밸런스 수치 |
| level | 22 | 레벨 디자인 |
| character | 19 | 캐릭터·voice_profile |
| meta·governance | 18 | 절차·룰 |
| qa·integrity | 16 | 검증 |
| content | 14 | 콘텐츠 양산 |
| operations | 14 | 운영 워크플로 |
| external_reference | 12 | 외부 자료 |
| economy | 11 | 경제·자원 |
| 기타 | 34 | 분류 진행 중 |
전투(combat)가 가장 두텁고 내러티브가 그 뒤를 잇는 분포는, 이 프로젝트가 전투 중심 MMORPG이며 동시에 서사 비중을 포기하지 않으려 한다는 성격을 그대로 드러낸다. "기타 34"는 아직 카테고리가 확정되지 않은 신규 결정들로, 이 칸이 너무 커지면 분류 체계를 손볼 때가 됐다는 신호다. 2026년 5월 기준 합계는 304개다.
도구와 지식이 있어도 그것이 제대로 돌아가는지 확인하는 장치가 없으면 품질은 흘러내린다. 이 절은 그 확인 장치를 두 종류로 나눠 보여 준다. 주기적으로 찍어 내는 보고서와, 의사결정을 사후에 추적할 수 있게 남기는 결정 카드다.
| 보고서 | 빈도 |
|---|---|
| 데일리 빌드 리포트 | 매일 |
| 알파 갭 리포트 | 주간 (10.3) |
| 스프린트 품질 리포트 | 격주 |
| 마일스톤 QA 리포트 | 마일스톤마다 |
| 분기 회고 | 분기 |
빈도가 곧 보고서의 성격이다. 매일 찍는 것은 상태 점검, 주·격주는 추세 점검, 마일스톤·분기는 방향 점검이다. AI가 가장 크게 기여하는 지점은 매일·주간처럼 반복되는 보고서의 초안 작성이며, 그 사례는 10.3에서 다룬다.
| 분기 | 결정 수 |
|---|---|
| 2025년 4분기 | 132 |
| 2026년 1분기 | 156 |
| 2026년 2분기 (진행 중) | 89 |
| 누적 | 547 |
분기마다 100건 안팎의 결정이 카드로 남는다는 사실 자체가, 결정을 기억이 아니라 기록으로 다룬다는 운영 원칙을 보여 준다. 2분기 89건은 분기 중간 시점의 누적이라 진행 중 값이며, 분기 말에는 직전 분기 수준에 이른다. 이 카드들이 쌓여 A.3의 atom으로 승격되는 흐름이 이 시스템의 학습 축이다.
회의는 시간이 가장 많이 새는 곳이자 AI 도구의 효과가 가장 빨리 체감되는 곳이다. 아래는 분기별 회의를 카테고리로 묶어 본 평균 분포로, 17.3에서 다룬 회의록 시스템의 입력 규모를 가늠하기 위한 참고 자료다. 수치는 분기마다 출렁이므로 범위로 적었다.
| 카테고리 | 분기 평균 |
|---|---|
| 데일리(daily) | 65~70회 |
| 전투(battle) | 35~45회 |
| 아트(art) | 25~30회 |
| 이슈(issue) | 8~15회 |
| 리뷰(review) | 6~10회 |
| 기타 (1:1·외부) | 40~50회 |
데일리 회의가 가장 잦고 전투 관련 회의가 그 뒤를 잇는다. A.3의 atom 분포와 같은 모양이라는 점이 의미심장하다. 결정이 몰리는 분야에서 회의도 몰린다. 이렇게 회의가 잦은 환경일수록 회의록 자동 정리의 효용이 커지며, 그 구체 운영은 17.3에서 설명한다.
지금까지의 표는 모두 저자의 환경을 찍은 한 장의 사진이다. 그대로 베껴 쓰라는 목록이 아니라, 자신의 환경을 같은 틀로 정리해 보는 견본으로 써 주시기 바랍니다. 팀 규모, 장르, 플랫폼이 다르면 도구도 atom 분포도 회의 비중도 달라진다. 중요한 것은 항목의 일치가 아니라, "토대 → 도구 → 지식 → 검증"이라는 네 층이 자신의 환경에서도 끊기지 않고 이어져 있는가다. 그 네 층 가운데 비어 있는 칸이 있다면, 그 칸이 다음에 손볼 곳이다.
이 부록은 저자가 회사 프로젝트 A에서 만들고 운영하던 도구·스킬을 개인 PC와 일반적인 작업으로 가져와 다시 쓴 절차를 정리한 것입니다. 핵심 질문은 하나입니다. "회사의 지식 자산을 침범하지 않으면서, 거기서 배운 도구의 골격만 합법적으로 가져오려면 어떻게 해야 하는가." 이 부록은 그 경계를 어떻게 그었는지, 무엇을 가져오고 무엇을 두고 왔는지, 그리고 그 결정을 어떻게 기록으로 남겼는지를 보여 줍니다.
이 부록을 쓰는 법은 이렇습니다. 먼저 B.1의 다섯 원칙을 자신의 상황에 비춰 읽으시고, B.3의 절차를 그대로 한 번 따라가 보십시오. 그다음 B.4의 기록 양식을 복사해 자신이 가져오려는 도구에 맞게 채우시면 됩니다. 회사 자산을 다루는 일인 만큼 "빠르게"보다 "남길 수 있게"가 우선이며, 이 부록 전체가 그 관점으로 짜여 있다.
도구를 가져오기 전에 합의해 둔 다섯 가지 원칙이다. 이 다섯은 순서가 아니라 동시에 지켜야 하는 조건으로, 하나라도 무너지면 차용 자체를 보류한다. 앞의 셋은 "무엇을 가져오는가"에 대한 기술적 경계이고, 뒤의 둘은 "어떻게 떳떳하게 가져오는가"에 대한 절차적 경계다.
| 원칙 | 설명 |
|---|---|
| 1. 회사 IP 비포함 | 회사명·실명·고유명사를 제거한다 |
| 2. 도구 골격만 가져옴 | 회사 도메인 데이터는 차단한다 |
| 3. 범용화 재구성 | 일반 사용 케이스로 다시 만든다 |
| 4. 인용·출처 명확 | 회사에서 차용한 도구임을 명시한다 |
| 5. 법무·인사 합의 | 회사의 양해 절차를 거친다 |
가장 자주 흔들리는 줄은 2번이다. 알고리즘과 구조(골격)는 가져와도 되지만, 그 골격이 전제하던 회사 데이터 형식까지 딸려 오면 그 순간 IP를 가져온 셈이 된다. 골격과 데이터를 떼어 내는 작업이 차용의 본체다.
원칙에 따라 실제로 개인 PC로 가져온 도구는 여섯 가지다(2026년 5월 기준). 모두 데이터를 다루는 도구라는 공통점이 있는데, 이는 우연이 아니다. 데이터 처리 도구는 골격(파싱·변환·시각화 로직)과 도메인(회사 시트의 구체적 형식)을 떼어 내기가 상대적으로 쉽기 때문이다.
| 도구 | 회사 원본 | 개인 범용판 |
|---|---|---|
| excel-reader | xlsm 시트·VBA 추출 | 범용 Excel 처리 |
| relation-map-gen | FK 관계 HTML | 범용 데이터 관계도 |
| schema-doc | 시트에서 마크다운 스키마 생성 | 범용 스키마 문서화 |
| table-creator | 데이터 테이블 양산 | 범용 테이블 생성 |
| gdd-gen | GDD 자동 생성 | 범용 문서 생성 |
| gdd-export | 마크다운에서 다중 시트 xlsx로 변환 | 범용 xlsx 변환 |
표의 가운데와 오른쪽 칸을 비교해 보면 범용화가 무슨 뜻인지 드러난다. 왼쪽은 "회사 시트", "GDD"처럼 도메인이 들어간 이름이고, 오른쪽은 "범용 Excel", "범용 문서"처럼 도메인을 걷어 낸 이름이다. 이름에서 회사가 사라지는 것이 범용화의 첫 신호다.
원칙(B.1)을 실제 손동작으로 옮기면 아래 여섯 단계가 된다. 가장 중요한 분기점은 2단계와 4단계다. 2단계에서 골격과 도메인을 깨끗이 갈라 두지 못하면 뒤의 모든 단계가 오염되고, 4단계의 회사 양해를 건너뛰면 아무리 잘 만들어도 쓸 수 없는 도구가 된다.
flowchart TD
A[1. 회사 도구 식별] --> B[2. 회사 의존 영역 분리]
B --> B1[회사 데이터·고유명사 의존]
B --> B2[도구 골격: 알고리즘·구조]
B1 --> C[3. 회사 의존 제거 + 범용 변수화]
B2 --> C
C --> D[4. 회사 양해: 법무·매니저]
D --> E[5. 개인 PC 환경 적용·검증]
E --> F[6. 출처 명시 + 차용 기록]
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
class B2 code;
class D human;
class B1,F data;
여섯 단계 가운데 시간이 가장 오래 걸리는 칸은 코드 작업(2·3단계)이 아니라 4단계, 회사 합의와 법무 통과다. 기술이 아니라 신뢰가 가장 큰 관문이라는 뜻이며, 그래서 차용은 늘 합의를 먼저 잡고 코드를 나중에 다듬는 순서로 진행한다.
차용한 도구는 반드시 기록을 함께 남긴다. 나중에 "이 도구가 어디서 왔고 무엇을 제거했으며 누구의 양해를 받았는가"를 묻는 순간이 올 수 있기 때문이다. 아래는 excel-reader를 예로 든 기록 양식으로, 여러분은 이 틀을 그대로 복사해 자신의 도구에 맞게 채우시면 됩니다.
---
tool: excel-reader (개인 범용판)
original_source: 회사 프로젝트 A
adopted: 2026-05
permission: 회사 매니저 + 법무 통과
modifications:
- 회사 시트 형식 의존 제거
- 회사 도메인 함수(xlsm VBA) 제거
- 범용 csv/xlsx 처리로 일반화
- 회사명·실명 참조 전면 제거
usage_in_book: 본 책의 도구 사례 인용 (Part 1·5·6·8 등)
---
양식의 날짜 칸(adopted)은 2026-05처럼 확정된 연-월로 적습니다. "2026년 5월쯤" 같은 자유 표기는 나중에 채울 빈칸처럼 보이니, 차용을 확정한 시점을 그 자리에서 못 박아 둡니다.
이 기록에서 가장 값진 줄은 permission과 modifications다. 앞줄은 차용이 정당했음을, 뒷줄은 무엇을 떼어 냈는지를 증명한다. 이 두 줄이 있으면, 훗날 의문이 제기되어도 추적할 근거가 남는다.
무엇을 가져왔는지만큼 무엇을 두고 왔는지도 중요하다. 회사 도구 가운데 의도적으로 차용하지 않은 것들과 그 이유를 적었다. 두고 온 도구들의 공통점은 회사의 핵심 IP이거나 회사 조직 구조에 깊이 묶여 있어, 골격과 도메인을 떼어 낼 수 없다는 점이다.
| 도구 | 미차용 사유 |
|---|---|
| 회사 전투 시스템 도구 | 회사 핵심 IP, 회사 독점 |
| 회사 내러티브 문서 도구 | 회사 세계관에 의존 |
| 회사 전투 TF 도구 | 회사 조직 구조에 의존 |
| 회사 인사·재무 도구 | 외부 환경에 맞지 않음 |
B.2의 가져온 도구들이 모두 "데이터 처리"였던 것과 정확히 대비된다. 가져온 것은 도메인과 분리되는 도구였고, 두고 온 것은 도메인과 한 몸인 도구였다. 분리 가능성이 차용 가능성을 가른다.
마지막으로, 도구를 가져오기 전에 스스로 통과시켜야 할 다섯 항목이다. 이 표는 합격/불합격을 가리는 체크리스트로, 다섯 항목을 모두 통과할 때만 차용하고 한 항목이라도 걸리면 보류한다. "대체로 괜찮다"는 없다. 회사 자산을 다루는 일에는 부분 통과가 통하지 않기 때문이다.
| 점검 항목 | 통과 기준 |
|---|---|
| 회사 양해를 얻었는가 | 매니저·법무 명시적 동의 |
| 법무 검토를 통과했는가 | 서면 또는 기록된 확인 |
| 회사 IP를 완전히 제거했는가 | grep watchlist 검사 0건 |
| 범용성을 검증했는가 | 다른 환경에서도 작동 확인 |
| 사고 시 대응 절차가 있는가 | 추적·회수 경로 정의 |
다섯 항목을 다섯 칸의 통과로 읽지 마시고, 다섯 개의 잠금장치로 읽어 주시기 바랍니다. 회사에서 배운 것을 개인의 자산으로 정당하게 가져오는 일은 분명 가능하지만, 그 정당함은 이 다섯 잠금을 모두 채웠을 때만 성립한다.
이 부록은 본문에서 인용한 도구와 시스템의 권한·세팅을 한곳에 모은 참조표다. 본문은 "왜 이렇게 운영하는가"를 설명하지만, 막상 자신의 환경에 적용하려면 "그래서 구체적으로 어떤 값을 어디에 넣어야 하는가"가 필요하다. 이 부록이 그 빈칸을 메운다.
세팅값 자체보다 그 값을 고른 이유가 더 중요합니다. 표에 적힌 숫자를 그대로 복사하기보다, 각 항목 아래 짧은 설명을 읽고 자신의 팀 규모와 리스크 수준에 맞춰 조정하세요. 혼자 작업한다면 권한 등급을 나눌 필요가 없고, 외주가 없다면 외주 항목은 통째로 빼면 됩니다.
이 부록을 쓰는 방법은 두 가지다. 처음 환경을 세팅할 때는 C.1부터 순서대로 훑으며 빠진 항목이 없는지 체크리스트처럼 쓴다. 운영 중에는 사고가 났을 때 C.7(사고 대응)을 먼저 펼쳐 해당 사고 행을 찾고, 그 위의 예방 항목으로 거슬러 올라간다.
LLM API 키는 비용과 직결되고, 노출되면 즉시 금전 사고로 이어진다. 그래서 키 관리와 권한 등급을 가장 먼저 다룬다.
| 키 | 보관 |
|---|---|
| Anthropic API | 환경 변수 + 1Password |
| OpenAI API | 환경 변수 + 1Password |
| 자체 호스팅 | 회사 내부 |
키는 코드가 아니라 환경 변수로 주입하고, 원본은 비밀 관리 도구(예: 1Password)에 둡니다. 가장 흔한 사고는 키를 코드에 입력한 채 git에 올리는 것이므로, git에 키를 포함하는 일은 예외 없이 금지합니다.
| 사용자 | 권한 |
|---|---|
| 디렉터·시니어 | full (cost cap 운영 책임) |
| 일반 멤버 | 작업별 cap |
| 외주 | 작업별 1회 한정 |
권한은 신뢰가 아니라 책임의 크기로 나눈다. full 권한을 가진 사람은 비용 상한(cost cap)을 관리할 책임을 함께 진다. 외주에게는 작업 단위로 한 번만 열어주고, 끝나면 회수한다.
도구마다 권장 세팅이 다르지만, 핵심은 분석 작업과 창의 작업을 나누는 것이다. 분석은 재현 가능해야 하고, 창의는 다양성이 필요하다.
아래는 Claude Code의 기본 세팅 예시다. 한 줄씩 보면, 모델을 고정하고, 확장 사고를 켜고, 토큰 상한을 두고, 자동 업데이트를 켜고, 프롬프트 제출 시 메모리를 주입하는 hook을 건다.
{
"model": "claude-opus-4-8",
"extended_thinking": true,
"max_tokens": 100000,
"auto_update": true,
"hooks": {
"UserPromptSubmit": ["~/.claude/hooks/inject_memory.py"]
}
}
model 값은 예시일 뿐입니다. 모델 이름은 세대마다 바뀌므로(이 예시는 집필 시점 기준), 그대로 베끼지 말고 /model로 현재 쓸 수 있는 최신 이름을 확인해 넣으세요. 이름이 바뀌어도 이 책의 워크플로 골격은 그대로 작동합니다(부록 K 참고).
hooks.UserPromptSubmit에 건 스크립트는 프롬프트를 제출할 때마다 관련 메모리 조각을 자동으로 끼워 넣는 역할을 합니다. 이 메모리 주입 메커니즘은 본문 24부에서 자세히 다룹니다.
C.2.1.1 도구 권한 스키마 (allow / deny)
같은 settings.json 안에서 권한은 permissions 블록으로 따로 둡니다. AI가 사람 승인 없이 자동으로 실행해도 되는 도구는 allow에, 한 번이라도 사고가 나면 치명적이라 자동 실행을 막아야 하는 도구는 deny에 적습니다. 표기는 도구(명령 패턴) 꼴이며, :*은 "그 명령으로 시작하는 모든 호출"을 뜻합니다.
{
"permissions": {
"allow": [
"Bash(ls:*)",
"Bash(git status:*)",
"Bash(git diff:*)",
"Read(*)",
"Grep(*)"
],
"deny": [
"Bash(rm -rf:*)",
"Bash(git push --force:*)"
]
}
}
읽기·검색(Read·Grep)과 상태 조회(git status·git diff)처럼 되돌릴 일이 없는 명령은 allow로 자동 허용해 승인 팝업 피로를 줄입니다. 반대로 rm -rf, git push --force처럼 한 번의 실수가 복구 불가능한 명령은 자동 허용 범위를 아무리 넓혀도 deny에 입력해 둡니다.
운영 원칙은 네 가지입니다.
| 원칙 | 내용 |
|---|---|
| Whitelist로 시작 | 자동 허용은 최소로 출발해 필요할 때만 allow에 추가 |
| 위험 명령은 명시 차단 | rm -rf·git push --force는 예외 없이 deny |
| 정기 정리 | 분기마다 allow를 재검토해 안 쓰는 권한을 덜어냄 |
| 도메인별 분리 | 글로벌 권한과 프로젝트 권한을 나눠, 집 PC와 회사 PC가 다른 정책을 갖게 함 |
allow 리스트는 정적인 설정이 아니라 작업 누적의 흔적입니다. 반복하는 작업이 늘면 그만큼 길어지므로, 분기 정리 사이클을 함께 갖춰 두는 편이 좋습니다. 이 권한 운영의 배경은 본문 1부 3장에서 자세히 다룹니다.
| 세팅 | 권장값 |
|---|---|
| LLM temperature (분석) | 0 |
| LLM temperature (창의) | 0.7 |
| Cache TTL | 1시간 |
| Cost cap (일일) | 도구별 정의 |
| Backup 주기 | 일일 |
분석용 호출은 temperature 0으로 두어 같은 입력에 같은 출력이 나오게 한다. 검증·lint·분류처럼 결과가 흔들리면 안 되는 작업이 여기 속한다. 반대로 아이디어 발산이나 초안 생성에는 0.7 정도의 다양성을 준다. 비용 상한은 단일 표준을 두지 말고 도구별로 따로 정하는데, 도구마다 호출 빈도와 토큰 소모가 다르기 때문이다.
| 분기 | 권한 |
|---|---|
| main | 디렉터·시니어만 push |
| feature/* | 모든 멤버 |
| protected branches | 강제 코드 리뷰 |
main 분기는 직접 push를 막고, 모든 변경은 feature 분기에서 코드 리뷰를 거쳐 들어오게 합니다. force-push는 협업 이력을 덮어쓰므로 금지하고, 불가피한 사고 복구가 필요할 때만 디렉터와 코드 리드의 합의로 예외를 둡니다.
문서 폴더는 Layer 구조(L0~L4)를 따라 권한을 나눈다. 위로 갈수록 영향 범위가 넓어 쓰기 권한을 좁히고, 아래로 갈수록 작업이 분산되어 쓰기 권한을 넓힌다.
| 폴더 | 권한 |
|---|---|
| docs/L0_vision/ | 디렉터 write, 모두 read |
| docs/L1_systems/ | 분야 디렉터 write, 모두 read |
| docs/L2_content/ | 담당 read·write |
| docs/L4_meta/ | 모두 write |
| team_memory/사용자별/ | 본인만 read·write |
비전(L0)은 디렉터만 쓰고 모두가 읽는다. 시스템(L1)은 분야 디렉터가 쓴다. 콘텐츠(L2)는 담당자가 쓰고, 메타·임시(L4)는 누구나 쓴다. 개인 메모리는 본인만 접근한다. 이 Layer 구조 자체는 본문 6부에서 다룬다.
| 자료 | 백업 |
|---|---|
| git repo | git 자체 + 원격 백업 |
| 시트 (Excel) | git + 일일 백업 |
| 사용자 데이터 | DB 백업 (서버 표준) |
| 회의록·결정 | git |
| 메모리 | 일일 자동 동기화 |
자료 종류마다 백업 경로가 다르지만 원칙은 하나다. 잃으면 복구가 어려운 자료일수록 이중으로 둔다. 텍스트 자산(회의록·결정·코드)은 git이 곧 백업이고, 바이너리나 서버 데이터는 별도 백업을 둔다. 복구 시간 목표(RTO)는 4시간 이내로 잡되, 이 값은 팀이 감내할 수 있는 다운타임에 맞춰 조정한다.
| 영역 | 룰 |
|---|---|
| 외부 LLM에 민감 데이터 | placeholder 또는 자체 호스팅 |
| 결제·개인 정보 | LLM에 절대 전송 금지 |
| 외부 자료 인용 | 출처 + 법무 검토 |
| 사용자 데이터 보호 | 익명화 + GDPR 준수 |
가장 지키기 쉬우면서 가장 자주 깨지는 규칙이 "민감 데이터를 외부 LLM에 보내지 않는다"입니다. 작업이 급할 때 실제 데이터를 그대로 붙여 넣는 유혹이 크기 때문입니다. 결제·개인 정보는 예외 없이 전송 금지로 두고, 분석이 필요하면 placeholder로 치환하거나 자체 호스팅 모델을 씁니다.
| 사고 | 대응 |
|---|---|
| LLM 환각으로 잘못된 정보 발송 | 즉시 회수 + 보고 |
| 비용 cap 초과 | 자동 차단 + 검토 |
| 저작권 사고 | 1시간 내 사용 중단 + 법무 |
| 보안 사고 (key 노출) | 즉시 키 교체 + 사용 이력 검토 |
| 데이터 손실 | 백업 복구 + 사고 분석 |
사고는 막는 것보다 빨리 멈추는 것이 중요할 때가 많다. 표의 대응은 모두 "먼저 멈추고, 그다음 분석한다"는 순서를 따른다. key가 노출되면 원인을 따지기 전에 키부터 교체하고 사용 이력을 본다. 비용이 상한을 넘으면 자동으로 차단한 뒤 검토한다. 이 대응 절차는 문서로 명문화하고, 정기적으로 훈련해 실제 사고 때 망설임 없이 작동하게 한다.
회사 프로젝트 A의 R&D 문서 명명·frontmatter 표준 (
_NAMING_FRONTMATTER_STANDARD)의 일반화 버전.
<category>_<topic>_<subtopic>.md
예:
combat_global_cooldown_constant.md
narrative_voice_profile_K_007.md
ui_button_primary_style.md
snake_case. 카테고리 prefix.
D<YEAR>_Q<QUARTER>_<NUMBER>.md
예:
D2026_Q2_017.md
연도·분기·번호.
<category>_<YYYY-MM-DD>[_<seq>].md
예:
95_BattleTF_2026-05-18.md
art_review_2026-05-18_1.md
art_review_2026-05-18_2.md
spec_<topic>.md
예:
spec_combat_global_cooldown.md
spec_guild_attendance.md
report_<period>_<type>.md
예:
report_W21_alpha_gap.md
report_Q2_user_voice.md
---
name: combat_global_cooldown_constant
description: 전투 시스템의 글로벌 쿨다운 표준값 정의
type: atom
category: combat
status: active
priority: P0
related_atoms:
- combat_skill_cooldown_rule
- combat_healing_skill_cooldown_exception
created: 2026-05-18
last_modified: 2026-05-18
related:
derives_from: [combat_design_principle]
affects: [combat_skill_cooldown_rule, ui_skill_cooldown_indicator]
---
---
decision_id: D2026_Q2_017
title: 전투 글로벌 쿨다운 0.5초 통일
type: system_change
status: active
created: 2026-05-18
created_by: 팀원 A
approved_by: 이민수
scope:
- combat_system
affected_atoms: [...]
implementation:
target_build: 2026-05-18
verification:
layer_1: passed
layer_2: passed
layer_3: pending
---
---
type: meeting_note
category: battle
date: 2026-05-18
attendees: [팀원 A, 팀원 B, 이민수]
related_atoms: [...]
---
---
title: 길드 출석 기능 명세
type: spec
priority: P1
target_milestone: MS2
---
| 문서 종류 | 필수 |
|---|---|
| atom | name, description, type, category, status |
| 결정 카드 | decision_id, title, type, status, created, scope |
| 회의록 | type, category, date, attendees |
| 명세서 | title, type, priority |
| 문서 종류 | 선택 |
|---|---|
| atom | related, last_modified, priority |
| 결정 카드 | rationale, related_decisions, verification |
| 회의록 | related_atoms, sub_topic |
| 명세서 | target_milestone, related_atoms |
# frontmatter_lint.py
for file in glob("**/*.md"):
fm = parse_frontmatter(file)
if not fm:
warn(f"{file}: frontmatter 없음")
doc_type = infer_type_from_filename(file)
required = REQUIRED_FIELDS[doc_type]
for field in required:
if field not in fm:
warn(f"{file}: 필수 필드 {field} 누락")
빌드 시 자동 실행. 위반은 alert.
| 영역 | 방지 |
|---|---|
| atom name | 글로벌 unique |
| 결정 ID | 분기 내 unique |
| 회의 ID | 일자 + seq |
| 파일명 | 폴더 내 unique |
명명 충돌 시 자동 차단.
1. 새 이름의 atom 생성
2. 기존 atom의 모든 wikilink 새 이름으로 갱신 (자동)
3. 기존 atom을 deprecated + redirect
4. 1개월 후 _archive로 이동
급한 이름 변경은 자료 손상 위험.
1. 변경 사유 제안 (decision 절차)
2. 모든 기존 문서 마이그레이션 스크립트
3. 빌드 lint 갱신
4. 팀 알림
본 표준은 저자의 환경. 독자는 자신의 환경에 맞춰 조정 필요. 핵심은:
| 핵심 | 이유 |
|---|---|
| 명명 일관성 | 검색·자동화 |
| Frontmatter 표준 | 도구 친화 |
| 필수·선택 분리 | 작성 부담 ↓ |
| Lint 자동 | 표준 강제 |
| 변경 절차 | 자료 보호 |
MCP(Model Context Protocol)는 LLM이 외부 도구·데이터에 표준화된 방식으로 연결되는 통로다. 본문 20부에서 프로젝트 관리 MCP를 다뤘지만, 게임 기획 워크플로에 끌어 쓸 수 있는 MCP 서버는 그보다 훨씬 많다. 이 부록은 그 후보들을 한눈에 보도록 모으고, 어떤 순서로 도입하면 좋은지 우선순위를 붙인 카탈로그다.
카탈로그의 목적은 "이걸 다 깔라"가 아니라 "필요할 때 어디서 고를지 안다"입니다. 한 번에 여러 MCP를 붙이면 무엇이 문제를 일으키는지 분간이 안 됩니다. E.4의 도입 사이클을 따라 하나씩 늘려 가세요.
쓰는 방법은 이렇다. 처음에는 E.2.1의 P0 목록만 본다. 기본기가 잡히면 E.2.2(P1)로 넘어가고, 팀의 특수한 필요가 생기면 E.2.3(P2)이나 E.3(자체 개발)을 검토한다. 비용이 걱정되면 E.5를, 장애에 대비하려면 E.6을 먼저 본다.
MCP 서버는 연결 대상에 따라 크게 네 갈래로 나뉜다. 게임 기획자가 매일 오가는 도구들이 대부분 이 안에 들어온다.
| 영역 | MCP 서버 |
|---|---|
| 프로젝트 관리 | ClickUp, JIRA, Linear |
| 문서 | Confluence, Notion, Google Drive |
| 협업 | 팀 메신저(Slack·Discord 등) |
| 데이터 | Excel, Google Sheets, DB |
프로젝트 관리는 태스크와 일정, 문서는 기획서와 위키, 협업은 팀 소통, 데이터는 밸런스·아이템 시트로 이어진다. 자신의 팀이 이미 쓰는 도구가 어느 영역에 속하는지 먼저 짚으면 도입 후보가 자연히 좁혀진다.
우선순위는 "없으면 작업이 막히는가"를 기준으로 매겼다. P0는 거의 모든 작업의 토대이고, P1은 있으면 크게 편하며, P2는 팀 상황에 따라 선택한다.
| 서버 | 용도 | 비고 |
|---|---|---|
| Filesystem MCP | 로컬 파일 접근 | 기본 |
| Git MCP | 변경 추적 | 필수 |
| 팀 메신저 MCP | 팀 소통 | 권장 |
| 협업툴 MCP (ClickUp·JIRA 등) | 태스크 | 회사 도구 |
Filesystem과 Git은 LLM이 자료를 읽고 변경 이력을 따라가는 토대라 가장 먼저 붙인다. 팀 메신저 MCP는 팀 맥락을 끌어오고, 태스크 도구는 회사가 이미 쓰는 것(ClickUp이든 JIRA든)을 그대로 연결한다.
| 서버 | 용도 |
|---|---|
| 위키 MCP (Confluence·Notion 등) | 위키 |
| Google Drive MCP | 외부 공유 자료 |
| Excel MCP | 시트 직접 조회 |
| Mermaid MCP | 다이어그램 렌더 |
P0가 안정되면 문서·데이터 쪽을 넓힌다. 특히 Excel MCP는 밸런스 시트를 LLM이 직접 조회하게 해주어 게임 기획에서 활용도가 높다. Mermaid MCP는 설계 도식을 그 자리에서 렌더해 문서화 흐름을 끊지 않는다.
| 서버 | 용도 |
|---|---|
| Discord MCP | 사용자 커뮤니티 |
| GitHub MCP | 외부 협업 |
| Linear MCP | 대안 태스크 |
| Notion MCP | 대안 위키 |
P2는 대안이거나 특정 상황 전용이다. 사용자 커뮤니티를 운영하면 Discord를, 외부 협업이 잦으면 GitHub를 붙인다. Linear·Notion은 이미 도입한 도구의 대체재이므로, 중복으로 깔 필요는 없다.
상용 MCP로 채워지지 않는 자리는 직접 만든다. 아래는 저자가 게임 기획 워크플로에 맞춰 자체 개발한 MCP다. 모두 본문에서 다룬 시스템(atom·결정 카드·회의록)을 LLM에서 곧장 조회하기 위한 것이다.
| 서버 | 용도 |
|---|---|
| Atom MCP | atom 검색·조회 |
| Decision Card MCP | 결정 카드 조회·생성 |
| KPI Dashboard MCP | 대시보드 데이터 |
| Meeting Notes MCP | 회의록 검색 |
이 네 개는 상용 도구에 없는 사내 자산(지식 atom, 결정 이력, 회의록)을 다룬다. 자체 개발은 부담이 크므로, E.4 사이클의 마지막 단계로 미루고 상용 MCP로 메울 수 없는 것이 명확해졌을 때 착수하는 편이 좋습니다.
MCP는 한꺼번에 붙이면 문제 원인을 가리기 어렵다. 아래 사이클은 "하나씩, 안정된 다음에 다음"이라는 원칙을 시간 축으로 풀어낸 것이다.
flowchart LR
A["1주
Filesystem 1개 시범"] --> B["1개월
Git + 팀 메신저 추가"]
B --> C["3개월
5~7개 안정 운영"]
C --> D["6개월
자체 MCP 개발 검토"]
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
class A,B,C code;
class D human;
핵심 규칙은 단 하나, 한 번에 5개를 동시에 도입하지 않는 것입니다. 새 MCP를 붙일 때마다 며칠은 그 하나가 안정적으로 도는지 지켜본 뒤 다음으로 넘어가세요.
| 서버 | 비용 |
|---|---|
| 외부 MCP (오픈소스) | 인프라만 |
| 자체 호스팅 | 인프라 + 운영 |
| 상용 MCP | 월 구독 |
비용 구조는 셋으로 나뉜다. 오픈소스 MCP는 돌릴 인프라 비용만 들고, 자체 호스팅은 거기에 운영 인력 비용이 붙으며, 상용 MCP는 구독료가 든다. 8~10개를 운영할 때 월 비용은 대략 $50~200 수준으로 추정되지만, 이는 구성에 따라 크게 달라지므로 방향만 참고한다.
| 사고 | 대응 |
|---|---|
| MCP 서버 장애 | 핵심 서버는 fallback 운영 |
| 권한 사고 (잘못된 데이터 수정) | read-only 우선 |
| 데이터 유출 | 민감 데이터는 자체 호스팅 |
| 비용 폭증 | cap + 모니터링 |
MCP는 외부 도구를 LLM에 직접 연결하므로, 잘못된 쓰기 한 번이 실제 데이터를 망칠 수 있다. 그래서 기본은 read-only로 두고, 쓰기 권한은 꼭 필요한 서버에만 연다. 핵심 서버는 장애에 대비해 fallback을 마련하고, 민감 데이터를 다루는 MCP는 외부 대신 자체 호스팅으로 돌린다. 비용은 상한(cap)과 모니터링으로 함께 막는다.
앞 절들이 "무엇을, 어떤 순서로, 얼마에" 붙이는지를 다뤘다면, 이 표는 한 개의 MCP를 실제로 붙이기 직전에 스스로 통과시켜야 할 항목을 모은 것이다. 카탈로그를 처음부터 다시 읽는 대신, 새 MCP를 추가할 때마다 이 다섯 줄만 다시 확인하면 된다. 다섯 항목은 각각 앞 절의 핵심 규칙을 한 줄로 압축한 것이다.
| 점검 항목 | 통과 기준 | 근거 절 |
|---|---|---|
| 어느 영역인가 | 프로젝트 관리·문서·협업·데이터 중 어디에 속하는지 분명함 | E.1 |
| 지금 필요한 우선순위인가 | P0가 안정된 뒤에야 P1, 그다음 P2 순서를 지킴 | E.2 |
| 하나씩 붙이는가 | 한 번에 여러 개를 동시에 도입하지 않음 | E.4 |
| 권한이 최소인가 | 기본은 read-only, 쓰기는 꼭 필요한 서버에만 | E.6 |
| 비용 한계가 있는가 | 상한(cap)과 모니터링을 함께 걸어 둠 | E.5 |
다섯 항목 가운데 가장 자주 건너뛰는 칸은 "하나씩 붙이는가"입니다. 한 번에 여러 MCP를 올리면 문제가 생겼을 때 어느 서버 탓인지 분간이 안 되기 때문입니다. 다섯 줄을 모두 통과할 때만 그 MCP를 붙이고, 한 줄이라도 걸리면 그 서버는 다음 사이클로 미룹니다.
본 책에 등장한 사례를 회사 환경 vs 개인 PC 환경으로 색인. 독자가 자기 환경에 가까운 사례를 빠르게 찾기 위한 자료.
| 사례 | 등장 위치 |
|---|---|
| CombatBalance·CombatFormula 운영 | 8.1 |
| Economy Machinations Pilot | 8.2 |
| Damage Simulator (2008~) | 8.3 |
| Procedural Level Design Master | 7.1 |
| BehaviorTree 에디터 | 7.2 |
| 던전·필드 패턴 라이브러리 | 7.3 |
| HUD Layout v3 | 9.1 |
| Skill UI 6컬럼 결정 | 9.2 |
| NarrativeDocs 5계층 | 5.1 |
| voice_profile + voice_lint | 5.2·5.4 |
| proj_city_hunting_generator | 6.2 |
| NPC Persona/Squad | 6.3 |
| 사례 | 등장 위치 |
|---|---|
| 95_BattleTF 운영 | 16.1 |
| 97_DevGuide 협업 | 16.2 |
| 17.x 회의록 시스템 | 17부 전체 |
| Alpha Gap Report | 10.3 |
| decision_validation 3-layer | 10.2 |
| 304 atom 운영 | 20.1 |
| 팀원 메모리 | 20.2 |
| 포탈웹 | 20.3 |
| 사례 | 등장 위치 |
|---|---|
| 중규모(10~50인) 팀의 비전·로드맵 | 19.1 |
| Design Director의 위임 | 19.2 |
| 갈등 관리·팀 문화 | 19.3 |
| 회의 운영 (리더 관점) | 19.4 |
| 상위 커뮤니케이션 (PD/CEO) | 19.5 |
| AI 도입 전략 | 19.6 |
| 거버넌스 (프롬프트·환각·비용·법무·윤리) | 22부 전체 |
저자의 개인 PC 환경 (집)에서 직접 경험한 사례.
| 사례 | 등장 위치 |
|---|---|
| excel-reader 등 6 도구 차용 | 부록 B |
| JIT atom 주입 시스템 | (개인 PC 인프라) |
| 개인 PC 슬래시 명령 (book-capture 등) | (개인 PC 인프라) |
본 책의 집필 과정 자체가 AI 활용 사례.
| 영역 | 적용 |
|---|---|
| 챕터 본문 양산 | LLM (Claude) |
| IP 보호 (회사 → 익명화) | grep watchlist + 룰 |
| 출처 추적 | 회사 환경 인용 시 명시 |
| 양산 → 검토 → 정비 사이클 | 5월 양산 후 검토 모드 진입 |
회사 환경 사례 (중규모(10~50인) 팀, MMORPG, 라이브 운영)는 비슷한 규모·도메인 회사에 적용 가능.
| 독자 환경 | 적합 사례 |
|---|---|
| 모바일 MMORPG 개발사 | 거의 모든 사례 |
| PC MMORPG | 14부 모바일 사례는 조정 |
| 인디 게임 | 중규모(10~50인) 이상 사례는 축소 적용 |
| 라이브 운영 게임 | 15부 + 운영 사례 |
개인 환경 (1~2인, 또는 취미)은 회사 사례를 단순화하여 차용.
| 영역 | 단순화 |
|---|---|
| 회의 시스템 | 1인은 불필요. 자기 메모로 |
| TF 운영 | 1인은 불필요 |
| 결정 카드 | 큰 결정만 |
| atom·wikilink | 적극 활용 (1인도 가치) |
본 책의 모든 회사 사례는 익명화.
| 원본 | 익명화 |
|---|---|
| 회사명 | MMORPG 개발사 A |
| 프로젝트 | 프로젝트 A |
| 팀원 실명 | 팀원 A·B·C |
| 게임 내 고유명사 | 가공 (왕국 X, 캐릭터 K_001 등) |
| 수치 | 가공 (비율은 실제) |
| 회사 도구명 | proj_* (예: proj_city_hunting_generator) |
게임 밖에서 일하는 독자(기획·PM·일반 직장인)를 위한 역색인. 본문 각 챕터 끝의 「게임 밖 적용」 박스는 그 챕터의 워크플로를 게임과 무관한 직무로 옮겨 읽는 다리입니다. 게임 도메인 본문이 부담스러우면 아래 박스부터 펼쳐 본인 직무 사례로 진입하셔도 됩니다. 「일반 직무의 길」(1·2부 → 17 → 16 → 18 → 21·22부)과 90분 초단축 코스(17.1 → 16.2 → 22.1 → 21.1)의 닻이 이 색인입니다.
| 챕터 | 「게임 밖 적용」이 옮겨 주는 일 |
|---|---|
| 16.1 | 몰려드는 작업을 임시 작업공간으로 격리하고 결과만 정본으로 흡수 |
| 16.2 | 한 줄 요청을 합의·결함·일정 세 트랙으로 분류 |
| 16.3 | 타 직군·이해관계자에게 맞는 매체로 산출물 프레이밍 |
| 17.1 | 회의록을 결정 4필드(무엇·누가·왜·다음)로 흐르게 하기 |
| 17.2 | 회의록에서 결정·액션 추출 파이프라인 |
| 17.3 | 회의 결정의 분류·동기화 |
| 17.4 | 회의 요약·후속 추적 자동화 |
| 18.1 | 결정에 영구 주소·책임자·근거를 입력하고 과거 결정 먼저 찾기 |
| 18.2 | 한 결정이 어디까지 영향을 미치는지 파급 분류 |
| 18.3 | 변경 전후 추적 워크플로 |
| 18.4 | 문서 변경의 영향 범위를 검색으로 확인 |
| 챕터 | 「게임 밖 적용」이 옮겨 주는 일 |
|---|---|
| 21.1 | 회고를 자기 개선의 출발점으로 삼기 |
| 21.2 | 회고에서 반복 패턴을 규칙으로 승격 |
| 21.3 | 개선 루프 닫기 |
| 22.1 | 작업지시서(프롬프트) 한 장에 컨텍스트·형식·환각차단·검증 넣기 |
| 22.2 | 환각·안전성의 다층 방어 |
| 22.3 | AI 비용을 정직하게 관리 |
| 22.4 | 저작권·윤리 점검 |
| 챕터 | 「게임 밖 적용」이 옮겨 주는 일 |
|---|---|
| 19.1 | 비전 제시와 위임 |
| 19.2 | 갈등 관리와 회의 리더십 |
| 19.3 | 조직의 AI 도입 전략 |
위 색인은 본문에 실재하는 「게임 밖 적용」 박스만 모은 것입니다(2026-06 기준 22건). 박스가 없는 챕터는 게임 도메인 의존도가 높아 그대로의 전이가 어려운 챕터이며, 무리해 옮기기보다 「일반 직무의 길」의 위 챕터부터 진입하시길 권합니다.
이 부록은 본문에서 언급한 운영 자동화 스크립트들을 한자리에 모은 사례집이다. 본문은 각 스크립트가 "왜 필요한가"를 흐름 속에서 설명했지만, 막상 비슷한 도구를 만들려면 "어떤 스크립트들이 어떤 역할로 묶여 있는가"를 한눈에 보는 지도가 필요하다. 이 부록이 그 지도다.
스크립트 이름과 한 줄 설명, 그리고 본문 어느 절에서 다뤘는지를 함께 적었습니다. 일반화가 깔끔하게 되는 핵심 스크립트(G.1.1 양식 검사·G.2.1 정합성 검사·G.3.1 관계도·G.7.1 비용 트래커)와 G.8의 테스트·hook 예시는 회사 자료와 무관한 일반 골격으로 새로 작성해 그대로 실행되도록 검증한 실코드를 실었습니다. 입력 예시와 출력, 종료 코드까지 실제로 돌려 확인한 값입니다. 나머지 항목은 이름·역할·연결 본문 절만 적었는데, 그 이유는 부록 G.9에서 정직하게 밝힙니다. 독자는 실코드 항목을 본보기 삼아 자신의 환경에 맞는 구현을 직접 만들면 됩니다.
쓰는 방법은 이렇다. 자동화하고 싶은 작업의 성격(검증인지, 보고서 생성인지, 동기화인지)을 먼저 정하고, 해당하는 절(G.1~G.7)을 펼친다. 거기서 가장 가까운 스크립트를 고른 뒤, 괄호 안의 본문 절 번호로 가서 맥락과 설계 의도를 확인한다. 마지막으로 G.8의 운영 원칙에 비춰 자신의 스크립트가 그 원칙을 지키는지 점검한다.
전체 스크립트를 역할별로 묶으면 다음과 같다.
flowchart TD
G1["G.1 회의록·결정 자동화"] --> META["메타 운영
(지식 축적)"]
G2["G.2 검증·lint"] --> QA["품질 게이트"]
G3["G.3 영향 추적"] --> QA
G4["G.4 보고서 자동 생성"] --> REPORT["보고·가시화"]
G5["G.5 동기화"] --> META
G6["G.6 LLM 통합"] --> AI["AI 보조"]
G7["G.7 비용·운영"] --> AI
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
class G1,G2,G3,G4,G5,G6,G7 code;
class AI ai;
class META,REPORT data;
회의에서 나온 결정이 흩어지지 않고 지식 자산으로 쌓이게 하는 스크립트 묶음이다. 회의록 검증부터 atom 추출, 정식 승격까지 한 줄기로 이어진다.
회의록이 정해진 양식(필수 머리말·필수 섹션)을 갖췄는지 검사하는 스크립트. 양식이 흐트러진 회의록은 이후 자동 추출이 깨지므로 입구에서 막는다 (17.2.2).
아래는 회사 자료와 무관한 일반 골격이다. 표준 라이브러리(sys만)로 쓰며 그대로 실행된다. 마크다운 회의록의 머리말(---로 감싼 블록) 키와 본문 섹션 머리(## ...)가 모두 있는지 본다. 빠진 게 있으면 violation을 내고 exit 1, 모두 있으면 exit 0이다.
#!/usr/bin/env python3
"""meeting_lint.py
마크다운 회의록이 정해진 양식을 갖췄는지 검사한다.
- 머리말(--- 블록) 안에 필수 키가 모두 있는지.
- 본문에 필수 섹션 머리(## ...)가 모두 있는지.
빠진 항목이 있으면 violation을 출력하고 exit 1, 없으면 exit 0.
표준 라이브러리만 사용한다.
사용:
python meeting_lint.py meeting.md
"""
import sys
REQUIRED_FRONTMATTER = ["type", "date", "category", "attendees"]
REQUIRED_SECTIONS = ["## 안건", "## 결정", "## 액션 아이템", "## 다음 회의"]
def lint(text):
"""회의록 본문 문자열을 받아 빠진 항목 목록(violation)을 돌려준다."""
violations = []
# 머리말: 첫 줄이 ---이면 다음 ---까지를 머리말로 본다.
lines = text.splitlines()
front = []
if lines and lines[0].strip() == "---":
for line in lines[1:]:
if line.strip() == "---":
break
front.append(line)
front_keys = [ln.split(":", 1)[0].strip() for ln in front if ":" in ln]
for key in REQUIRED_FRONTMATTER:
if key not in front_keys:
violations.append({"kind": "frontmatter", "missing": key})
# 섹션: 본문에 해당 머리 줄이 그대로 있는지.
body_lines = [ln.strip() for ln in lines]
for section in REQUIRED_SECTIONS:
if section not in body_lines:
violations.append({"kind": "section", "missing": section})
return violations
def main(argv=None):
argv = sys.argv[1:] if argv is None else argv
if len(argv) != 1:
sys.stderr.write("사용: python meeting_lint.py meeting.md\n")
return 2
with open(argv[0], encoding="utf-8") as f:
violations = lint(f.read())
for v in violations:
print(f"[VIOLATION] {v['kind']}: {v['missing']}")
if violations:
sys.stderr.write(f"[FAIL] 양식 위반 {len(violations)}건\n")
return 1
sys.stderr.write("[PASS] 양식 충족\n")
return 0
if __name__ == "__main__":
sys.exit(main())
상수 두 개가 검사 기준이다. 예를 들어 머리말에 attendees가 빠지고 본문에 ## 다음 회의가 없는 회의록을 넣으면 다음처럼 두 건이 잡히고 종료 코드는 1이다.
[VIOLATION] frontmatter: attendees
[VIOLATION] section: ## 다음 회의
회의록의 "결정" 섹션을 읽어 지식 atom 후보를 자동으로 뽑아내는 스크립트. 사람이 일일이 옮겨 적던 작업을 대신한다 (17.2.3).
검토 대기(pending) 상태의 atom을 정식 atom 폴더로 승격하는 스크립트. 자동 추출과 정식 자산 사이에 사람 검수 게이트를 둔다 (17.2.6).
데이터와 콘텐츠가 규칙을 어기지 않았는지 자동으로 잡아내는 품질 게이트다. 사람의 눈으로 놓치기 쉬운 일관성 오류를 기계가 먼저 거른다.
데이터 항목의 ID가 중복 없이 유일한지 검증하는 스크립트. ID 충돌은 런타임에 가서야 터지는 사고이므로 데이터 단계에서 막는다 (10.1.2).
아래는 회사 자료와 무관한 일반 골격이다. 표준 라이브러리(csv·json·sys·argparse)만 쓰며, 그대로 저장해 바로 실행된다. 입력은 어떤 게임 데이터든 가질 법한 단순한 형식, 즉 id 열을 가진 CSV다.
#!/usr/bin/env python3
"""integrity_check_id_uniqueness.py
CSV 데이터의 id 열이 유일한지 검사한다.
- 중복 id가 있으면 violation 목록을 출력하고 exit 1.
- 모두 유일하면 exit 0.
표준 라이브러리만 사용한다.
사용:
python integrity_check_id_uniqueness.py data.csv
python integrity_check_id_uniqueness.py data.csv --id-column quest_id
"""
import argparse
import csv
import json
import sys
def find_duplicate_ids(rows, id_column):
"""rows(딕셔너리 리스트)에서 id_column 값의 중복을 찾는다.
반환: violation 리스트. 각 항목은
{"id": 값, "row_numbers": [1-based 행 번호, ...]} 형태.
헤더를 1행으로 보고 데이터 첫 행을 2로 센다.
"""
seen = {} # id 값 -> 등장한 행 번호 리스트
for index, row in enumerate(rows):
row_number = index + 2 # 헤더(1행) 다음부터
key = row.get(id_column, "")
seen.setdefault(key, []).append(row_number)
violations = []
for key, row_numbers in seen.items():
if len(row_numbers) > 1:
violations.append({"id": key, "row_numbers": row_numbers})
violations.sort(key=lambda v: v["row_numbers"][0])
return violations
def load_rows(csv_path):
with open(csv_path, newline="", encoding="utf-8") as f:
return list(csv.DictReader(f))
def main(argv=None):
parser = argparse.ArgumentParser(description="CSV id 유일성 검사")
parser.add_argument("csv_path", help="검사할 CSV 파일 경로")
parser.add_argument("--id-column", default="id", help="id로 쓸 열 이름 (기본: id)")
args = parser.parse_args(argv)
rows = load_rows(args.csv_path)
violations = find_duplicate_ids(rows, args.id_column)
# G.8 출력 표준: violation_list를 JSON으로 표준출력에 낸다.
print(json.dumps({"violation_list": violations}, ensure_ascii=False, indent=2))
if violations:
sys.stderr.write(f"[FAIL] 중복 id {len(violations)}건 발견\n")
return 1
sys.stderr.write("[PASS] 중복 id 없음\n")
return 0
if __name__ == "__main__":
sys.exit(main())
입력 예시(data.csv):
id,name
Q001,첫 의뢰
Q002,잃어버린 노리개
Q001,첫 의뢰(중복)
실행 결과는 다음과 같다. Q001이 2행과 4행에 두 번 나왔으므로 violation 한 건이 잡히고 종료 코드는 1이다.
{
"violation_list": [
{
"id": "Q001",
"row_numbers": [2, 4]
}
]
}
NPC 대사의 보이스(말투·성격) 일관성을 검사하는 스크립트. 같은 캐릭터가 챕터마다 다른 말투를 쓰는 어긋남을 잡는다 (5.2·5.4).
자원(아트·UI 등)이 바뀌었을 때 의도치 않은 시각적 변화가 생겼는지 비교하는 회귀 검사 스크립트 (12.1.5).
하나를 바꾸면 무엇이 따라 흔들리는지 추적하는 스크립트 묶음이다. 문서·결정·자원 사이의 연결을 따라가며 변경의 파급 범위를 보여 준다.
문서 간 Wikilink([[대상]])를 긁어 연결 그래프를 자동으로 구축하는 스크립트. 어떤 문서가 어떤 문서를 참조하는지 한눈에 보게 한다 (24.3.4).
아래는 회사 자료와 무관한 일반 골격이다. 표준 라이브러리(os·re·json·argparse)만 쓴다. 한 폴더 안의 .md 파일들을 읽어 파일 이름(확장자 제외)을 노드로, [[...]] 링크를 엣지로 본다. 결과로 인접 리스트와 Mermaid 도식 코드를 함께 낸다.
#!/usr/bin/env python3
"""wikilink_graph.py
폴더 안 .md 문서들의 [[Wikilink]] 연결을 그래프로 만든다.
- 노드: 확장자를 뺀 파일 이름.
- 엣지: 문서 본문의 [[대상]] 표기. [[대상|표시]] 형태면 대상만 본다.
표준 라이브러리만 사용한다.
사용:
python wikilink_graph.py ./docs
python wikilink_graph.py ./docs --format mermaid
"""
import argparse
import json
import os
import re
import sys
WIKILINK = re.compile(r"\[\[([^\]|#]+)") # [[대상]] / [[대상|표시]] / [[대상#앵커]]
def extract_links(text):
"""본문에서 링크 대상 이름들을 등장 순서대로, 중복 없이 뽑는다."""
result = []
for match in WIKILINK.findall(text):
target = match.strip()
if target and target not in result:
result.append(target)
return result
def build_graph(doc_dir):
"""폴더 안 .md를 훑어 {문서이름: [링크 대상, ...]} 인접 리스트를 만든다."""
graph = {}
for name in sorted(os.listdir(doc_dir)):
if not name.endswith(".md"):
continue
node = name[:-3]
path = os.path.join(doc_dir, name)
with open(path, encoding="utf-8") as f:
graph[node] = extract_links(f.read())
return graph
def to_mermaid(graph):
"""인접 리스트를 Mermaid flowchart 코드 문자열로 바꾼다."""
lines = ["flowchart LR"]
for node, targets in graph.items():
if not targets:
lines.append(f' {_id(node)}["{node}"]')
for target in targets:
lines.append(f' {_id(node)}["{node}"] --> {_id(target)}["{target}"]')
return "\n".join(lines)
_ID_CACHE = {}
def _id(name):
"""Mermaid 노드 id는 ASCII여야 한다. 한글 이름은 처음 본 순서대로
n1, n2, ... 짧은 ASCII id를 붙이고, 라벨[...]에 원래 이름을 보존한다."""
if name not in _ID_CACHE:
_ID_CACHE[name] = "n%d" % (len(_ID_CACHE) + 1)
return _ID_CACHE[name]
def main(argv=None):
parser = argparse.ArgumentParser(description="Wikilink 연결 그래프 빌더")
parser.add_argument("doc_dir", help="문서(.md)가 들어 있는 폴더")
parser.add_argument("--format", choices=["json", "mermaid"], default="json")
args = parser.parse_args(argv)
graph = build_graph(args.doc_dir)
if args.format == "mermaid":
print(to_mermaid(graph))
else:
print(json.dumps(graph, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
sys.exit(main())
입력 예시(폴더 docs/ 안 세 파일):
docs/세계관.md 본문에 [[지역_한양]] 과 [[세력_의금부]] 링크
docs/지역_한양.md 본문에 [[세력_의금부]] 링크
docs/세력_의금부.md 링크 없음
--format mermaid로 실행하면 다음 도식 코드가 나온다. 노드는 파일 이름 순서(세계관 → 세력_의금부 → 지역_한양)로 처리되고, 라벨 안에 원래 한글 이름이 그대로 남는다. 어느 문서가 어디로 뻗는지, 끝점(세력_의금부)이 무엇인지 한눈에 보인다.
flowchart LR
n1["세계관"] --> n2["지역_한양"]
n1["세계관"] --> n3["세력_의금부"]
n3["세력_의금부"]
n2["지역_한양"] --> n3["세력_의금부"]
특정 결정 카드가 어떤 문서·자원에 영향을 미치는지 분석하는 스크립트. 결정을 뒤집기 전에 파급 범위를 먼저 확인한다 (18.4.3).
특정 자원을 사용하는 스킬들을 역으로 찾아내는 스크립트. 자원을 수정·삭제하기 전에 의존하는 곳을 파악한다 (11.2.4).
흩어진 데이터를 사람이 읽을 수 있는 보고서·도식으로 묶어 내는 스크립트다. 반복되는 정기 보고를 자동화해 손이 가는 일을 줄인다.
알파 단계의 목표 대비 부족분(gap)을 집계해 주간 보고서로 자동 생성하는 스크립트 (10.3.3).
결정 카드들의 연결 관계를 Mermaid 도식 코드로 변환하는 스크립트. 결정 흐름을 그림으로 본다 (24.2.3).
주요 지표(KPI)를 주간 단위로 요약하는 스크립트 (13.2).
여러 위치에 흩어진 자료를 효율적으로 맞추는 스크립트다. 전체를 매번 복사하지 않고 바뀐 부분만 골라 동기화한다.
회의록을 전부가 아니라 변경분만 골라 동기화하는 스크립트. 자료가 쌓일수록 전체 복사는 느려지므로 증분 방식을 쓴다 (17.5.4).
git의 diff를 활용해 무엇이 바뀌었는지 효율적으로 감지하는 방식. 별도 추적 장치 없이 git 자체를 변경 감지기로 쓴다 (17.5.4.1).
분류·호출처럼 판단이 필요한 작업을 LLM에 맡기는 스크립트다. 규칙으로 딱 떨어지지 않는 일을 LLM 보조로 처리한다.
들어온 FAQ를 카테고리별로 자동 분류하는 스크립트 (13.1.3).
회의를 성격별 카테고리로 자동 분류하는 스크립트. 회의록 머리말의 category를 채우는 데 쓴다 (17.3.6).
미리 정리해 둔 프롬프트 라이브러리에서 필요한 프롬프트를 불러오는 스크립트. 같은 프롬프트를 매번 다시 쓰지 않게 한다 (22.1.2).
자동화 자체가 비용과 자료 추적의 사각지대를 만들지 않도록 관리하는 스크립트다.
LLM 호출 비용을 추적하고 상한(cap)을 적용하는 스크립트. 비용 폭증을 사후가 아니라 사전에 막는다 (22.3.5).
아래는 회사 자료와 무관한 일반 골격이다. 표준 라이브러리(json·os·argparse)만 쓴다. 호출마다 토큰 수를 기록하고 누적 비용을 계산하며, 상한을 넘으면 거부 신호(exit 2)를 낸다. 단가는 코드 안 상수이며 실제 값은 각자 쓰는 모델 단가표로 바꾸면 된다(아래 값은 설명용 자리표시).
#!/usr/bin/env python3
"""llm_cost_tracker.py
LLM 호출 토큰을 누적 기록하고 일일 비용 상한을 검사한다.
- record: 한 번의 호출(입력/출력 토큰)을 ledger 파일에 더한다.
- 누적 비용이 cap을 넘으면 exit 2로 호출을 막는다(사전 차단).
표준 라이브러리만 사용한다.
사용:
python llm_cost_tracker.py --ledger ledger.json --in 1200 --out 800
python llm_cost_tracker.py --ledger ledger.json --in 1200 --out 800 --cap-usd 5.0
"""
import argparse
import json
import os
import sys
# 단가: 1,000 토큰당 USD. 설명용 자리표시 값 — 실제 모델 단가표로 교체할 것.
PRICE_PER_1K_INPUT = 0.003
PRICE_PER_1K_OUTPUT = 0.015
def cost_of(in_tokens, out_tokens):
"""입력/출력 토큰으로 한 호출의 비용(USD)을 계산한다."""
return (in_tokens / 1000) * PRICE_PER_1K_INPUT + (out_tokens / 1000) * PRICE_PER_1K_OUTPUT
def load_ledger(path):
if os.path.exists(path):
with open(path, encoding="utf-8") as f:
return json.load(f)
return {"calls": 0, "in_tokens": 0, "out_tokens": 0, "total_usd": 0.0}
def save_ledger(path, ledger):
with open(path, "w", encoding="utf-8") as f:
json.dump(ledger, f, ensure_ascii=False, indent=2)
def main(argv=None):
parser = argparse.ArgumentParser(description="LLM 비용 추적·상한")
parser.add_argument("--ledger", required=True, help="누적 기록 JSON 파일 경로")
parser.add_argument("--in", dest="in_tokens", type=int, required=True, help="이번 호출 입력 토큰")
parser.add_argument("--out", dest="out_tokens", type=int, required=True, help="이번 호출 출력 토큰")
parser.add_argument("--cap-usd", type=float, default=None, help="누적 비용 상한(USD). 넘으면 차단")
args = parser.parse_args(argv)
ledger = load_ledger(args.ledger)
this_cost = cost_of(args.in_tokens, args.out_tokens)
ledger["calls"] += 1
ledger["in_tokens"] += args.in_tokens
ledger["out_tokens"] += args.out_tokens
ledger["total_usd"] = round(ledger["total_usd"] + this_cost, 6)
save_ledger(args.ledger, ledger)
print(json.dumps({"this_call_usd": round(this_cost, 6), "ledger": ledger}, ensure_ascii=False, indent=2))
if args.cap_usd is not None and ledger["total_usd"] > args.cap_usd:
sys.stderr.write(f"[CAP] 누적 {ledger['total_usd']} USD > 상한 {args.cap_usd} USD — 차단\n")
return 2
return 0
if __name__ == "__main__":
sys.exit(main())
입력 예시와 결과. 빈 상태에서 입력 1,200·출력 800 토큰을 기록하면 이번 호출 비용은 1200/1000*0.003 + 800/1000*0.015 = 0.0036 + 0.012 = 0.0156 USD다.
{
"this_call_usd": 0.0156,
"ledger": {
"calls": 1,
"in_tokens": 1200,
"out_tokens": 800,
"total_usd": 0.0156
}
}
--cap-usd 0.01을 함께 주면 누적 0.0156이 상한 0.01을 넘으므로 종료 코드 2로 다음 호출을 막는다. 이것이 "사후가 아니라 사전에 막는다"의 실제 동작이다.
인용·참고한 자료의 출처를 자동으로 기록하는 스크립트. 나중에 출처를 되짚을 수 있게 남긴다 (24.5.4).
스크립트를 많이 만드는 것보다, 만든 스크립트가 신뢰할 수 있게 도는 것이 더 중요하다. 아래 다섯 원칙은 위의 모든 스크립트에 공통으로 적용된다.
| 원칙 | 설명 |
|---|---|
| 단순함 | 복잡한 라이브러리 회피 |
| 테스트 | 모든 스크립트 단위 테스트 |
| 출력 표준 | violation_list 등 표준 (10.1.7) |
| 버전 관리 | git |
| 사용자 검수 게이트 | 자동화도 사람 검수 |
특히 마지막 원칙이 중요합니다. 자동화는 사람을 대체하는 것이 아니라 사람의 판단 앞 단계를 줄이는 것입니다. 검증·추출·생성 어느 것이든 최종 적용 전에 사람이 한 번 보는 게이트를 반드시 둡니다.
"테스트" 원칙을 말로만 두지 않고, G.2.1의 핵심 함수 find_duplicate_ids를 표준 라이브러리 unittest로 검증하는 실제 테스트를 둔다. 외부 의존성이 없으므로 그대로 저장해 python -m unittest test_integrity_check -v로 돌린다. 검증할 함수가 파일 입출력에서 분리되어 있어야 이렇게 쉽게 테스트된다는 점이 핵심이다(그래서 G.2.1에서 검사 로직과 load_rows를 나눠 두었다).
# test_integrity_check.py
import unittest
from integrity_check_id_uniqueness import find_duplicate_ids
class TestFindDuplicateIds(unittest.TestCase):
def test_no_duplicates_returns_empty(self):
rows = [{"id": "Q001"}, {"id": "Q002"}]
self.assertEqual(find_duplicate_ids(rows, "id"), [])
def test_one_duplicate_reports_row_numbers(self):
rows = [{"id": "Q001"}, {"id": "Q002"}, {"id": "Q001"}]
self.assertEqual(
find_duplicate_ids(rows, "id"),
[{"id": "Q001", "row_numbers": [2, 4]}],
)
def test_missing_column_treated_as_empty_string(self):
rows = [{"name": "a"}, {"name": "b"}]
result = find_duplicate_ids(rows, "id")
self.assertEqual(result, [{"id": "", "row_numbers": [2, 3]}])
if __name__ == "__main__":
unittest.main()
실행하면 세 테스트가 모두 통과한다.
test_missing_column_treated_as_empty_string ... ok
test_no_duplicates_returns_empty ... ok
test_one_duplicate_reports_row_numbers ... ok
----------------------------------------------------------------------
Ran 3 tests in 0.000s
OK
위 원칙 중 빠지기 쉬운 것이 hook의 실패 처리다. 커밋 전이나 저장 시 자동으로 도는 hook은 본래 작업(커밋·저장)의 곁가지여야 한다. 그런데 hook이 내부 오류로 0이 아닌 종료 코드를 내면, 그 hook을 묶어 둔 본 작업까지 통째로 막혀 버린다. 보조 장치가 본체를 인질로 잡는 셈이다. 그래서 보조 성격의 hook은 내부에서 무슨 일이 생기든 경고만 표준오류(stderr)로 남기고 종료 코드는 0으로 돌려 본 작업을 막지 않게 만든다. 다음이 그 최소 형태이며, 내부에서 예외가 나도 종료 코드는 0이다.
import sys
def run_hook():
raise RuntimeError("내부 오류 발생")
def main():
try:
run_hook()
except Exception as exc:
sys.stderr.write(f"[hook] 경고: {exc} — 본 작업은 막지 않음\n")
return 0 # 보조 hook은 무슨 일이 있어도 본 작업을 막지 않는다
if __name__ == "__main__":
sys.exit(main())
실행하면 경고는 보이되 종료 코드는 0이다. 즉 사람은 무엇이 어긋났는지 알 수 있고, 작업 흐름은 끊기지 않는다.
[hook] 경고: 내부 오류 발생 — 본 작업은 막지 않음
(종료 코드 0)
다만 이 "조용한 실패"는 보조 hook에만 쓴다. G.2의 품질 게이트처럼 통과 여부 자체가 목적인 검증은 반대로 실패 시 0이 아닌 코드(앞서 본 exit 1)를 내어 파이프라인을 멈춰야 한다. 같은 hook 자리라도 "보조"냐 "게이트"냐에 따라 종료 코드 정책이 정반대라는 점을 구분한다.
앞 절의 exit 0 정책에는 대가가 하나 있다. 보조 hook이 무슨 일이 있어도 본 작업을 막지 않는다는 건, 뒤집으면 hook이 조용히 죽어도 본 작업은 멀쩡히 굴러간다는 뜻이다. 컨텍스트 자동 주입처럼 곁가지에서 도는 hook은 며칠을 안 돌아도 작업 흐름에 빨간불이 안 켜진다. 그래서 보조 hook에는 "실패해도 안 막는다"와 함께 "실패를 사람이 뒤늦게라도 본다"는 짝꿍 장치가 반드시 따라야 한다. 짝이 빠지면 어느 날 회고에서 "이 atom이 요즘 한 번도 안 떴네"를 발견하고 나서야 hook이 일주일째 죽어 있었음을 알게 된다.
그 짝꿍이 로그다. 앞 절의 최소 형태(sys.stderr.write(...))가 남기는 경고를 휘발시키지 말고 파일로 떨어뜨려, 정상 호출은 한 줄, 실패 호출은 사유와 함께 한 줄을 남긴다. 저자 환경에서는 이 흔적이 ~/.claude/hooks/_injection_log.txt에 쌓인다(같은 로그를 §21.3.4의 발동 검증에서도 읽는다). 운영 루프는 거창하지 않다. 세 단계의 점검·복구 절차 한 바퀴면 된다.
| 단계 | 무엇을 보나 | 무엇을 하나 |
|---|---|---|
| 감지 | 로그에 최근 정상 주입 줄이 끊겼거나 같은 사유의 실패 줄이 반복되는가 | 주간 회고에서 로그 꼬리 한 번 훑기(자동 캡처 한 줄로 충분) |
| 격리 | 실패 사유가 hook 자체 버그인가, 입력 데이터(깨진 manifest·없는 atom 파일)인가 | stderr 사유 문자열로 둘을 가른다 — 코드 문제면 코드, 데이터 문제면 manifest |
| 복구 | 트리거로 다시 정상 주입이 뜨는가 | 고친 뒤 새 세션에서 의도한 트리거를 한 번 입력해 로그에 정상 줄이 다시 남는지 확인(§21.3.4의 발동 검증과 동일) |
핵심은 "감지"를 사람의 주의력이 아니라 로그 한 파일과 회고 한 줄에 맡긴다는 점이다. exit 0이 막아 준 건 본 작업의 중단이지 실패의 은폐가 아니다. 실패는 stderr→로그로 드러내고, 회고가 그 로그를 주기적으로 들여다보며, 복구는 평소 쓰는 발동 검증을 그대로 재사용한다. 이렇게 "안 막는다 + 드러낸다 + 주기적으로 본다 + 같은 방식으로 되살린다"가 한 묶음일 때만, 조용한 실패가 조용한 방치로 굳지 않는다.
이 사례집의 코드는 두 종류다. 하나는 G.1.1·G.2.1·G.3.1·G.7.1·G.8처럼 회사 자료와 무관한 일반 골격으로 새로 작성해 그대로 실행되도록 검증한 코드다. 표준 라이브러리만 쓰며, 위에 적은 입력 예시·출력·종료 코드는 모두 실제로 돌려 확인한 결과다. 복붙해서 바로 쓰고, 단가표나 열 이름 같은 자리표시 값만 자기 환경에 맞게 바꾸면 된다.
다른 하나는 나머지 절처럼 이름·역할·연결 본문 절만 적은 항목이다. 이쪽을 전체 코드로 싣지 않은 이유는 정직하게 말해 둘이다. 첫째, 회사 운영 스크립트 원본은 회사 IP라 그대로 옮길 수 없다. 둘째, 그 로직 상당수는 회사 고유의 데이터 스키마·폴더 구조·결정 카드 양식에 묶여 있어, 그 전제를 들어내고 나면 일반 독자에게 그대로 쓸모 있는 코드가 남지 않는다. 그래서 일반화가 깔끔하게 되는 네 개(양식 검사·정합성 검사·관계도·비용 트래커)만 실코드로 승격하고, 나머지는 골격으로 남겼다. 독자는 이 네 개를 본보기 삼아 같은 방식 — 검사 로직과 입출력을 분리하고, 표준 출력으로 violation 목록을 내고, 단위 테스트를 붙이는 방식 — 으로 자신의 환경에 맞는 구현을 직접 만들면 된다.
기존 도구를 가져다 변주하는 절차는 부록 B를 참고한다.
오래 일한 기획자에게는 수십 년치 작업 자료가 쌓인다. 회의록, 결정 기록, 회고, 학습 노트, 실패에서 얻은 교훈까지. 이 부록은 그 자료를 새 프로젝트에 어떻게 다시 쓰는가를 다룬다. 핵심 긴장은 하나다. 자료의 상당 부분은 회사 IP라서 함부로 옮길 수 없는데, 동시에 그 안에는 어디서든 통하는 개인의 학습이 섞여 있다. 이 둘을 가르는 것이 재활용의 출발점이다.
이 부록을 쓰는 방법은 자신의 위치에 따라 다릅니다. 오래된 자료를 새 프로젝트에 끌어 쓰려는 상황이라면 H.2(분리 원칙)와 H.3(절차)을 순서대로 따라가세요. 막상 옮기다 사고가 날까 걱정된다면 H.5(다섯 함정)를 먼저 읽어 미리 피하세요. 아직 경력 초반이라 쌓을 자료가 많지 않다면 H.6을 보고 지금부터 무엇을 어떻게 남길지 정하세요.
여기서 다루는 원칙은 거창한 자산 관리론이 아니다. "구체적인 것은 회사에 두고, 추상적인 패턴만 가져온다"는 한 문장으로 압축된다. 나머지는 그 문장을 실제 상황에 적용하는 방법이다.
먼저 어떤 자료가 쌓이는지, 각각의 보관 권한이 어떻게 다른지 본다. 보관 권한이 다르면 재활용 가능 범위도 달라지기 때문이다.
| 자료 | 보관 |
|---|---|
| 회의록 (회사 자료) | 회사 권한 내 |
| 결정 카드 (회사 자료) | 회사 권한 내 |
| 분기 회고 (개인+회사) | 개인 사본 가능 |
| 학습 노트 (개인) | 개인 영구 |
| 사고 기록 (개인 학습) | 개인 영구 |
회의록과 결정 카드는 회사 권한 안에 머문다. 회고는 개인 사본을 둘 수 있고, 학습 노트와 사고 기록은 온전히 개인 자산이다. 오래 누적된 자료는 그 자체로 큰 학습 자산이지만, 회사 IP 영역과 개인 영역의 경계를 흐리면 안 된다. 경계를 분명히 할수록 마음 편히 재활용할 수 있다.
분리의 기준은 "구체적인가, 추상적인가"다. 구체적인 결과물은 회사 것이고, 그것을 만든 사고 패턴은 개인 것이다. 같은 작업에서 두 측면이 함께 나온다는 점이 핵심이다.
| 영역 | 회사 IP | 개인 학습 |
|---|---|---|
| 결정 내용 | 회사 | — |
| 결정 패턴 (이런 상황엔 이런 결정 좋음) | — | 개인 |
| 게임 데이터 | 회사 | — |
| 운영 노하우 (룰북·도구 운영) | — | 개인 |
| 코드 | 회사 | — |
| 알고리즘·구조 | — | 개인 |
"어떤 결정을 내렸는가"는 회사 IP이지만, "이런 상황에서는 이런 결정이 잘 통하더라"는 패턴은 개인 학습이다. 게임 데이터 값 자체는 회사 것이지만, 그 데이터를 운영한 노하우는 개인 것이다. 구체 자료는 회사에 두고 추상 패턴만 가져온다 — 이것이 분리의 원칙이다.
분리 원칙을 실제 작업으로 옮기면 다음 다섯 단계가 된다. 자료를 식별하고, IP를 떼어 내고, 학습을 추출하고, 일반화한 뒤, 새 프로젝트에 적용한다.
flowchart TD
A["과거 자료 식별"] --> B["회사 IP 부분 분리"]
B --> C["개인 학습 부분 추출"]
C --> D["추상화·일반화"]
D --> E["새 프로젝트에 적용"]
classDef pass fill:#dcfce7,stroke:#16a34a,color:#14532d;
class E pass;
이 절차는 반드시 회사 권한 확인과 법무 검토를 거친 뒤 진행합니다. 추상화가 충분하더라도, 출발점이 회사 자료였다면 절차상 확인을 받아 두는 편이 안전합니다.
가장 가까운 재활용 사례는 이 책 자체다. 본문 곳곳은 저자의 과거 작업에서 출발했고, 위의 절차를 거쳐 일반화·익명화한 결과다.
| 영역 | 출처 | 재활용 |
|---|---|---|
| Layer 통합 설계 (6부) | 저자의 다년 운영 | 개인 학습 → 일반화 |
| 회의록 시스템 (17부) | 저자의 프로젝트 A 운영 | 회사 패턴 → 익명화 |
| 운영 노하우 (24부) | 다년 누적 | 개인 학습 → 일반화 |
| 부록 A 인벤토리 | 회사 프로젝트 A | 익명화 + 일부 가공 |
Layer 설계와 운영 노하우는 개인 학습을 일반화했고, 회의록 시스템과 부록 A는 회사 패턴을 익명화했다. 모든 항목이 회사 양해를 통과했고 회사 IP는 빠짐없이 익명화했다. 책이라는 결과물 자체가 H.3 절차의 실증인 셈이다.
재활용은 잘하면 자산이지만 잘못하면 사고다. 아래 다섯 함정은 실제로 자주 밟는 지점이고, 각각에 처방을 붙였다.
회사 양해 없이 자료를 쓰면 분쟁으로 번진다. 처방은 단순하다. 쓰기 전에 회사 양해를 먼저 받는다.
회사명이나 실명이 한 군데라도 남으면 IP 사고가 된다. 처방은 자동 grep 검사다. 회사명·실명·경로를 watchlist로 만들어 기계가 빠짐없이 훑게 한다.
오래전 노하우를 손대지 않고 그대로 쓰면 지금 시점에 안 맞는다. 처방은 시대에 맞춰 재구성하는 것이다. 원리는 살리되 도구와 맥락은 현재로 갱신한다.
구체 사례만 옮기면 다른 환경에 적용하기 어렵다. 처방은 추상 패턴과 구체 예시를 함께 두는 것이다. 패턴으로 일반성을, 예시로 이해를 잡는다.
자료가 아무리 많아도 다시 들춰 보지 않으면 없는 것과 같다. 처방은 정기 학습 사이클이다. 일·주·월 회고처럼 자료를 다시 만나는 주기를 만든다.
이 원칙은 저자만의 것이 아니다. 독자도 자기 경력의 자료를 같은 방식으로 재활용할 수 있다. 아래는 지금부터 시작할 수 있는 권장 습관이다.
| 권장 | 이유 |
|---|---|
| 분기마다 자기 결정 회고 | 패턴 발견 |
| 학습 노트 별도 보관 | 회사 IP와 분리 |
| 추상 패턴 명시 | 미래 재활용 가능 |
| 멘토링·외부 발표 | 패턴 공유 |
| 책·블로그 (회사 양해 후) | 학습 영원 |
분기마다 자기 결정을 회고하면 패턴이 보이고, 학습 노트를 회사 자료와 분리해 두면 나중에 마음 편히 꺼내 쓸 수 있습니다. 그 패턴을 멘토링·발표·집필로 내보내면 학습은 한 번 쓰고 사라지는 대신 오래 남습니다. 결국 자기 학습이 곧 자기 자산입니다.
7.2에서 다룬 BehaviorTree 에디터의 심화 사례. 자체 개발 의사결정·구현·운영 경험.
7.2.8에서 다룬 4가지 결정 근거의 상세.
| 근거 | 상세 |
|---|---|
| diff·git 추적 필수 | UE BT는 .uasset binary, 변경 추적 어려움. JSON으로 텍스트 diff 가능 |
| subtree 인용 + 영향 추적 | 100~300체 BT 운영 시 subtree 단위 영향 분석이 결정적 |
| 시뮬레이션 검증 | 빌드 없이 BT만 분리 실행 가능 |
| AI 보조 작성 | LLM이 JSON BT를 자연스럽게 생성·해석 |
[1. 기획·요구 정의 (1~2주)]
- 4가지 요구사항 명문화
- JSON 스키마 설계
[2. 런타임 구현 (3~4주)]
- JSON 파서
- BT 실행 엔진
- subtree 인용 해소
[3. 에디터 구현 (4~6주)]
- JSON 편집기 (그래픽)
- subtree 라이브러리 UI
- 영향 분석 도구
[4. 시뮬레이터 (2~3주)]
- BT 분리 실행
- 통계 추출
[5. AI 통합 (2~3주)]
- LLM 보조 BT 작성
- 컨텍스트 주입
[6. UE 통합 (2~4주)]
- UE BT와 변환
- 빌드 통합
총 약 4~6개월. 개발자 1~2인.
이 에디터는 R&D 단계의 사내 도구로, "1년 운영 실측"이라 부를 만큼 장기·대규모로 가동하지는 않았다. 그래서 이 책의 원칙대로, 여기서는 지어낸 운영 통계를 싣지 않는다. I.1에서 다룬 100~300체 규모는 자체 개발을 정당화한 설계 목표이지 측정된 결과가 아니다.
설계가 못박은 한도 중 코드에 실제로 들어간 것은 subtree 인용 depth 5 한도다(무한 재귀 방지). 운영 중 BT 수·시뮬 실행 횟수 같은 값은 프로젝트 규모에 따라 달라지므로, 지어낸 수를 적는 대신 본인 환경에서 직접 측정하길 권한다.
| 사고 | 학습 |
|---|---|
| subtree 무한 인용 (재귀) | 인용 depth 5 한도 |
| 시뮬 vs 실제 행동 차이 | 시뮬 환경 매월 보정 |
| LLM 출력 BT의 환각 | 검증 + 디자이너 검수 강화 |
| BT 수 폭증 (계획 외) | 분기 정리 사이클 |
개발·운영 비용은 이 도구를 만들 때 잡은 일정 기준의 추정이고, "효과" 쪽은 측정값이 아니라 도입으로 노린 방향이다. 지어낸 절감 수치 대신 방향만 적는다.
| 항목 | 값 | 성격 |
|---|---|---|
| 개발 비용 | 개발자 4~6개월 | 계획 일정(추정) |
| 운영 비용 | 개발자 분기 1~2주(유지보수) | 계획 일정(추정) |
| 노린 효과 — 운영 인원 | 적 NPC를 대규모로 운영할 때 BT 담당 인원을 압축 | 방향(미측정) |
| 노린 효과 — 사고 | subtree 재귀·LLM 환각 같은 BT 사고를 구조적으로 줄임 | 방향(미측정) |
도입 비용 회수 기간은 프로젝트의 NPC 규모·인건비에 따라 갈리므로, 위 항목을 본인 환경에서 측정해 판단하길 권한다. "1년 안에 회수된다"는 식의 단정은 하지 않는다 — 그 숫자는 우리가 갖고 있지 않다.
7.2.6 프롬프트 사용. 아래는 출력 구조를 보여 주는 예시다(실제 운영 데이터가 아니라 형식 예시).
{
"bt_id": "bt_new_mage_v1",
"category": "ranged_combatant",
"tags": ["scholar_faction", "ranged", "magic"],
"root": {
"type": "selector",
"children": [
{
"type": "sequence",
"name": "low_hp_retreat",
"children": [
{"type": "condition", "fn": "hp_below", "param": 0.3},
{"type": "subtree_ref", "id": "subtree_retreat_to_ally"}
]
},
{
"type": "sequence",
"name": "magic_attack",
"children": [
{"type": "condition", "fn": "enemy_in_range", "param": 15},
{"type": "subtree_ref", "id": "subtree_magic_attack_pattern"}
]
}
]
}
}
디자이너가 검수 후 시뮬 → 통과 → 빌드 반영.
BehaviorTree 자체 개발은 운영 규모 100체+ 에서 정당화되는 편이다(설계 판단). 그 이하는 UE 기본 BT로 충분하다.
대안 옵션: - BehaviorTree.CPP (오픈소스, 표준) - Behavior Designer (외부 상용) - 자체 개발 (자유도 최고, 운영 부담)
선택 기준은 7.2.8 참고.
본문에 나오는 약어와 이 책 고유 용어를 한곳에 모았습니다. 본문은 각 약어가 처음 나오는 자리에서 한 번 풀어 쓰지만, 순서대로 읽지 않거나 중간에 잊었을 때 여기서 바로 찾으시면 됩니다. 한 약어가 맥락에 따라 다른 뜻이면 둘 다 적었습니다.
이 용어집은 다음 순서로 묶었습니다. 팀 규모 등급 → 게임 기획 문서 → 게임 도메인 → 데이터·운영 → AI·도구 → UI·접근성 표준 → 파일·포맷. 찾는 약어의 성격을 먼저 떠올리면 어느 묶음에 있을지 좁혀집니다. 예를 들어 DPS·TTK는 "게임 도메인", KPI·DAU는 "데이터·운영", atom·JIT는 "AI·도구" 묶음입니다.
표기 규칙은 셋입니다. ① 일반 약어는 정식 명칭과 한국어 뜻을 함께 적었습니다. ② atom·Wrapper처럼 이 책에서만 쓰는 고유 용어는 정식 명칭 칸에 "(이 책 고유 용어)"라고 표시했습니다. ③ PK(전쟁 맥락의 Player Kill ↔ 데이터 맥락의 Primary Key)처럼 한 약어가 두 뜻을 갖는 경우, 본문은 처음 나올 때 어느 쪽인지 함께 적고 이 표에는 두 뜻을 모두 실었습니다.
이 책은 팀 인원을 특정 숫자로 고정하지 않고 다음 세 등급으로 표기합니다. 같은 기법이라도 팀 규모에 따라 도입 깊이가 달라지기 때문입니다.
| 등급 | 인원 기준 | 설명 |
|---|---|---|
| 소규모 | ~10인 | 1인·취미 개발자부터 한 자릿수 팀까지. 대개 1~2단계 도입으로 충분 |
| 중규모 | 10~50인 | 이 책의 운영 사례가 나온 저자 팀이 속한 구간. 표준화·정합성 자동화의 누적 효과가 분명해지는 규모 |
| 대규모 | 100+ | 다수 파트·다수 팀. 전용 인프라와 전담 운영이 정당화되는 규모 |
본문에서 "중규모(10~50인) 팀"처럼 등급과 인원 범위를 함께 적은 곳은 이 표를 기준으로 합니다. 1인·혼자 개발처럼 인원 자체가 의미를 갖는 자리에서는 등급 대신 정확한 수를 그대로 씁니다.
| 약어 | 정식 명칭 | 뜻 |
|---|---|---|
| GDD | Game Design Document | 게임 디자인 문서. 시스템·수치·동작을 확정한 상세 사양서 |
| CDD | Concept Design Document | 컨셉 디자인 문서. GDD 이전 단계의 초기 기획서(방향·컨셉) |
| TF | TaskForce | 단기 목표를 위해 한시적으로 모은 전담팀(예: 전투 TF) |
| DD | Design Director | 디자인 디렉터. 게임의 설계 방향을 총괄하는 리드 역할 |
| RnD | Research and Development | 연구·개발. 시제품·신기법을 탐색하는 단계·조직(예: 절차적 생성 RnD) |
| 약어 | 정식 명칭 | 뜻 |
|---|---|---|
| NPC | Non-Player Character | 플레이어가 조작하지 않는 캐릭터 |
| HUD | Heads-Up Display | 게임 화면에 겹쳐 띄우는 상태 정보(체력·미니맵 등) |
| DPS | Damage Per Second | 초당 피해량 |
| GCD | Global Cooldown | 전역 쿨다운. 한 스킬을 쓰면 모든 스킬이 잠깐 함께 잠기는 공용 대기시간 |
| TTK | Time To Kill | 대상을 처치하는 데 걸리는 시간 |
| PK | Player Kill | (전쟁·PvP 맥락) 플레이어 간 전투·살해 |
| BT | BehaviorTree | 행동 트리. NPC AI의 행동 분기를 트리로 정의한 구조 |
| FSM | Finite State Machine | 유한 상태 기계. 상태와 전이로 행동을 정의하는 모델 |
| PCG | Procedural Content Generation | 절차적 콘텐츠 생성. 룰·알고리즘으로 콘텐츠를 자동 생성 |
| VFX | Visual Effects | 시각 이펙트 |
| SFX | Sound Effects | 음향 효과 |
| VA | Voice Actor | 성우 |
| RPG / MMORPG | (Massively Multiplayer Online) Role-Playing Game | 역할수행게임 / 대규모 다중접속 온라인 RPG |
| P2W / P2E | Pay To Win / Play To Earn | 결제로 강해지는 구조 / 플레이로 수익을 얻는 구조 |
| RMT | Real Money Trading | 게임 재화의 현금 거래 |
| 약어 | 정식 명칭 | 뜻 |
|---|---|---|
| KPI | Key Performance Indicator | 핵심 성과 지표 |
| DAU | Daily Active Users | 일일 활성 사용자 수 |
| FK | Foreign Key | 외래 키. 다른 시트의 기본키를 가리키는 컬럼 |
| PK | Primary Key | (데이터 맥락) 기본 키. 행을 고유하게 식별하는 컬럼 |
| ROI | Return on Investment | 투자 대비 효과(회수) |
| MECE | Mutually Exclusive, Collectively Exhaustive | 상호 배타·전체 포괄. 중복 없이, 빠짐없이 나누는 분류 원칙 |
| STT | Speech-to-Text | 음성을 텍스트로 변환 |
| VBA | Visual Basic for Applications | 엑셀에 내장된 매크로 언어 |
| SVN | Subversion | 파일 버전 관리 시스템 |
| telemetry | (계측 데이터) | 게임 빌드·실행에서 자동 수집하는 플레이 로그·지표(입력·전투·이탈 등). "텔레메트리"로 읽음 |
| 약어 | 정식 명칭 | 뜻 |
|---|---|---|
| AI | Artificial Intelligence | 인공지능 |
| LLM | Large Language Model | 대형 언어 모델(ChatGPT·Claude 등의 바탕) |
| JIT | Just-In-Time | 필요한 순간에만 끼워 넣는 방식(이 책에서는 입력에 맞는 기억 자동 주입) |
| MCP | Model Context Protocol | AI 도구를 외부 서비스와 연동하는 표준 |
| API | Application Programming Interface | 프로그램 간 호출 규약 |
| UE | Unreal Engine | 언리얼 엔진 |
| atom | (이 책 고유 용어) | 1결정 = 1파일로 박제한 결정·규칙 카드 |
| Wrapper / Cascade / Junction | (이 책 고유 용어) | 자주 쓰는 도구 진입점 / 여러 검사를 한 번에 묶은 도구 / 본체로 연결하는 심볼릭 링크 |
| rg | ripgrep | 빠른 텍스트 검색 명령어(grep을 대체하는 CLI 도구). 코드·문서 전수 검색에 쓰임 |
| ClickUp | (태스크·이슈 트래커) | 작업·일정을 관리하는 클라우드 협업 도구. JIRA·Redmine·Linear도 같은 범주. MCP로 연동해 AI가 조회·갱신 |
| 약어 | 정식 명칭 | 뜻 |
|---|---|---|
| UI / UX | User Interface / User Experience | 사용자 인터페이스 / 사용자 경험 |
| WCAG | Web Content Accessibility Guidelines | 웹 접근성 지침(명암 대비·터치 타깃 크기 등의 합격선) |
| HIG | (Apple) Human Interface Guidelines | 애플의 인터페이스 가이드라인 |
| SC | Success Criterion | WCAG의 개별 합격 기준 번호(예: SC 1.4.3) |
| pt / dp / px | point / density-independent pixel / pixel | 화면 크기 단위 |
| 약어 | 정식 명칭 | 뜻 |
|---|---|---|
| YAML | YAML Ain't Markup Language | 사람이 읽기 쉬운 설정·데이터 표기 형식 |
| JSON | JavaScript Object Notation | 데이터 교환 표기 형식 |
| HTML / SVG | HyperText Markup Language / Scalable Vector Graphics | 웹 문서 / 벡터 그래픽 형식 |
| GLB | GL Transmission Format (Binary) | 3D 모델 바이너리 파일 형식 |
PK처럼 맥락에 따라 뜻이 갈리는 약어는 본문에서 처음 나올 때 어느 쪽인지 함께 적었습니다. 헷갈리면 이 표로 돌아오시면 됩니다.
이 책의 사례와 도구는 거의 전부 한 가지 환경, 즉 Claude Code를 전제로 씌어 있습니다. 그래서 결재 자리나 외부 검토에서 거의 빠지지 않고 나오는 지적이 하나 있습니다. "이거 특정 회사 도구에 묶이는 것 아닌가." 기획 책임자는 한 벤더에 의존하는 의사결정을 결재하기 부담스러워하고, 회의론자는 도구가 바뀌면 이 책의 방법이 통째로 무너진다고 의심하며, 해외 판권을 검토하는 쪽은 자국에서 다른 도구가 표준일 때 이 책이 쓸모가 있는지 묻습니다. 셋의 표현은 다르지만 본질은 같습니다. 벤더 락인(vendor lock-in), 즉 한 도구에 갇히는 것에 대한 불신입니다.
이 부록의 목적은 그 불신에 답하는 것입니다. 결론부터 말하면, 이 책이 권하는 작업의 골격은 도구 중립입니다. 특정 모델 이름에도, 특정 명령줄 도구에도 묶이지 않습니다. Claude Code는 그 골격을 가장 매끄럽게 구현해 주는 그릇이었을 뿐이고, 같은 골격을 다른 그릇에 옮겨 담을 수 있습니다. 이 부록은 (1) 무엇이 도구와 무관한 골격인지 표로 보이고, (2) Claude Code의 각 요소를 다른 환경으로 옮기면 무엇에 대응하는지 짝지어 주며, (3) 모델 세대는 계속 바뀐다는 전제 아래 최신을 확인하는 원칙을 정하고, (4) 옮길 때 무엇을 잃고 무엇을 지키는지를 솔직하게 적습니다.
이 책 전체를 관통하는 작업 방식은 다섯 개의 기둥으로 요약됩니다. 이 다섯은 어느 것도 특정 모델·명령줄 도구의 기능 이름이 아니라, "사람과 인공지능이 함께 일할 때 어떻게 신뢰할 수 있는 결과를 반복해서 뽑아내는가"에 대한 답입니다. 그래서 도구가 바뀌어도 그대로 남습니다.
| 골격 | 무엇인가 | 왜 도구 중립인가 |
|---|---|---|
| 표준 → 템플릿 → 검증 게이트 | 합의된 규칙(표준)을 빈칸 틀(템플릿)로 굳히고, 결과가 규칙을 지켰는지 자동으로 거르는 관문(게이트)을 둔다 | 규칙·틀·검사라는 개념은 어떤 도구에서도 글·스크립트로 표현된다 |
| atom = 1결정 1파일 | 하나의 결정을 하나의 작은 파일에 적어, 필요할 때 꺼내 쓰고 고칠 때 그 한 칸만 고친다 | 결정을 잘게 쪼개 파일로 두는 것은 파일 시스템만 있으면 된다 |
| JIT 주입 | 지금 대화에 꼭 필요한 결정만 그때그때(Just-In-Time) 골라 모델에게 넣어 준다 | "필요한 맥락만 넣는다"는 원칙이며, 넣는 방법은 도구마다 다를 뿐이다 |
| 회고 루프 | 일·주·월 단위로 한 일을 돌아보고, 반복되는 패턴을 다음 작업의 규칙으로 끌어올린다 | 돌아보고 개선하는 절차는 도구가 아니라 습관과 문서로 돌아간다 |
| 도구 차용 경계 | 가져오는 것은 골격(알고리즘·구조)뿐, 도메인 데이터는 두고 온다 (부록 B) | 무엇을 가져오고 무엇을 두는가의 판단은 어느 도구에서도 동일하다 |
이 표의 오른쪽 칸이 핵심입니다. 다섯 골격 모두, 그 정의 안에 특정 제품 이름이 한 번도 등장하지 않습니다. 등장하는 것은 규칙·파일·맥락·습관·경계처럼 어느 작업 환경에나 있는 보편 개념뿐입니다. 그래서 "Claude Code를 못 쓰게 되면 어떡하나"라는 질문은, 실은 "이 다섯 개념을 다른 도구에서 어떻게 구현하나"라는 훨씬 답하기 쉬운 질문으로 바뀝니다. 그 답이 다음 절입니다.
Claude Code에는 위 골격을 편하게 구현해 주는 구체적인 장치들이 있습니다. hook(특정 시점에 자동 실행되는 스크립트), MCP(외부 도구·데이터를 모델에 연결하는 규약), settings 파일(권한·환경 설정), 슬래시 명령(자주 쓰는 절차를 한 줄로 부르는 단축 명령), 스킬(재사용 가능한 작업 묶음) 등입니다. 이것들은 Claude Code 고유의 이름이지만, 그 역할은 다른 환경에도 거의 다 대응물이 있습니다. 아래 표가 그 짝입니다.
| Claude Code | 챗GPT(ChatGPT, 웹·앱) | 커서(Cursor) / 코파일럿(Copilot) | 일반 LLM API |
|---|---|---|---|
| hook (시점 자동 실행) | 대화 전후 수동 절차 / 커스텀 GPT 지시문 | 에디터 작업 전후 태스크·pre-commit 훅 | 호출 전후로 끼워 넣는 사전·사후 스크립트 |
| MCP (외부 연결 규약) | 플러그인 / 액션 / 코드 인터프리터 | 확장(extension) / 내장 도구 호출 | 함수 호출(function calling) / 직접 만든 API 래퍼 |
| settings 파일 (권한·환경) | 커스텀 GPT 설정 화면 / 프로젝트 설정 | .cursor·워크스페이스 설정 파일 |
코드 안 설정 객체 / .env·YAML 설정 파일 |
| 슬래시 명령 (절차 단축) | 저장한 프롬프트 / 커스텀 GPT | 스니펫 / 사용자 정의 명령 | 프롬프트 템플릿 함수 |
| 스킬 (재사용 작업 묶음) | 커스텀 GPT / 프롬프트 모음 | 규칙 파일 + 스크립트 | 모듈화한 프롬프트·코드 함수 |
| CLAUDE.md / 메모리 | 커스텀 지시문 / 메모리 기능 | 프로젝트 규칙 파일(rules) | 시스템 프롬프트 + 외부 메모리 저장소 |
| atom 파일 모음 | (도구 무관) 마크다운 파일 | (도구 무관) 저장소 내 마크다운 | (도구 무관) 파일·DB 레코드 |
표를 보면 한 가지가 분명해집니다. 오른쪽으로 갈수록, 즉 일반 LLM API 쪽으로 갈수록 "자동으로 해 주던 것"이 "직접 만들어 끼워야 하는 것"으로 바뀝니다. Claude Code에서 hook 한 줄로 끝나던 자동 주입이, 일반 API에서는 호출 전에 직접 짠 사전 스크립트가 됩니다. 자동화의 편의는 줄지만, 골격 자체는 그대로 옮겨갑니다. 즉 이식은 "기능을 잃는 일"이 아니라 "편의를 내 손으로 다시 깔아 주는 일"입니다.
flowchart LR
subgraph 도구중립["도구 중립 골격 (변하지 않음)"]
S[표준→템플릿→검증]
A[atom: 1결정 1파일]
J[JIT 주입]
R[회고 루프]
end
subgraph 구현["환경별 구현 (갈아끼움)"]
CC[Claude Code: hook·MCP·settings]
GPT[챗GPT: 플러그인·커스텀 GPT]
CUR[커서·코파일럿: 확장·규칙파일]
API[LLM API: 함수호출·사전스크립트]
end
도구중립 --> CC
도구중립 --> GPT
도구중립 --> CUR
도구중립 --> API
classDef code fill:#dbeafe,stroke:#2563eb,color:#0b2545;
classDef ai fill:#f3e8ff,stroke:#9333ea,color:#3b0764;
classDef human fill:#fde68a,stroke:#b45309,color:#000;
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
class J code;
class CC,GPT,CUR,API ai;
class R human;
class A data;
이 그림이 부록 전체의 한 장 요약입니다. 위쪽 상자(골격)는 어느 환경으로 화살표가 가도 내용이 바뀌지 않고, 아래쪽 상자(구현)만 환경에 맞춰 갈아 끼워집니다. 결재에서 "벤더 락인"이라는 말이 나오면, 이 그림 한 장을 펴고 "묶이는 것은 아래 칸이지 위 칸이 아니다"라고 답하시면 됩니다.
이식을 이야기할 때 가장 빨리 낡는 정보가 모델 이름입니다. 이 책을 쓰는 시점의 최신 모델 이름을 본문에 못 박아 두면, 다음 세대가 나오는 순간 그 문장은 틀린 정보가 됩니다. 그래서 이 책은 처음부터 한 가지 원칙을 따릅니다. 특정 모델의 이름·세대 번호에 기대어 설명하지 않고, 모델이 하는 역할(추론·요약·코드 생성 같은 기능)에 기대어 설명한다는 것입니다.
| 변하는 것 (못 박지 말 것) | 변하지 않는 것 (기대도 될 것) |
|---|---|
| 모델 제품명·세대 번호 | "추론을 잘하는 모델", "긴 맥락을 받는 모델" 같은 역할 구분 |
| 컨텍스트 한도의 구체 수치 | "한도가 있으니 꼭 필요한 맥락만 넣는다"는 JIT 원칙 |
| 가격·속도의 구체 수치 | "비싼 작업은 게이트를 통과한 것만 돌린다"는 비용 의식 |
| 특정 기능의 켜고 끄는 방법 | "그 기능이 하는 역할"과 그것을 대체할 골격 |
실무에서 최신 모델·기능을 확인하는 방법도 도구마다 한 줄이면 됩니다. Claude Code에서는 /model 명령으로 현재 쓰는 모델과 선택지를 즉시 확인할 수 있고, 챗GPT·커서 같은 도구도 설정 화면이나 모델 선택 드롭다운에서 같은 정보를 보여 줍니다. 그러므로 이 책의 어떤 문장이 모델 이름과 어긋나 보이면, 그 문장이 틀린 것이 아니라 모델이 한 세대 지난 것입니다. 역할만 같으면 방법은 그대로 적용됩니다. 책을 읽다가 모델 이름이 낯설면 본문을 의심하지 마시고, 먼저 /model 같은 명령으로 지금 손에 쥔 도구의 최신 상태를 확인하시기를 권합니다.
도구를 옮기면 분명히 잃는 것이 있습니다. 그 사실을 감추면 오히려 신뢰를 잃으므로, 무엇을 잃는지 먼저 솔직하게 적겠습니다. 다만 잃는 것은 거의 다 "편의"의 영역이고, 지키는 것은 "골격"의 영역입니다. 즉 잃는 것은 다시 깔면 되찾을 수 있는 것이고, 지키는 것은 애초에 도구에 묶여 있지 않던 것입니다.
| 구분 | 항목 | 설명 |
|---|---|---|
| 잃는 것 (편의) | 자동 실행의 매끄러움 | hook처럼 알아서 끼어들던 자동화를 사전·사후 스크립트로 직접 만들어야 한다 |
| 잃는 것 (편의) | 통합된 한 화면 | 명령·도구·파일이 한 흐름에 모여 있던 것을, 여러 도구에 나눠 붙여야 할 수 있다 |
| 잃는 것 (편의) | 즉시 쓰는 스킬·명령 | 슬래시 명령·스킬을 그 도구의 방식으로 다시 등록해야 한다 |
| 지키는 것 (골격) | 표준·템플릿·검증 게이트 | 규칙과 틀과 검사는 글·스크립트라 어디서나 그대로 산다 |
| 지키는 것 (골격) | atom·JIT·회고 루프 | 파일과 습관으로 돌아가므로 도구가 바뀌어도 유지된다 |
| 지키는 것 (골격) | 도구 차용 경계 (부록 B) | 무엇을 가져오고 두는가의 판단 기준은 환경과 무관하다 |
이 표를 한 문장으로 줄이면 이렇습니다. 이식에서 잃는 것은 시간을 들이면 되살릴 수 있는 자동화의 편의이고, 지키는 것은 이 책이 처음부터 도구 바깥에 두려고 애썼던 작업의 골격입니다. 그러니 "벤더 락인 아니냐"는 물음에 대한 가장 정직한 답은 이것입니다. 묶이는 부분이 있지만 그것은 갈아 끼울 수 있는 그릇이고, 진짜 가치인 내용물은 처음부터 어느 그릇에도 묶여 있지 않습니다. 이 부록 한 편이 결재 자리에서 그 답을 대신해 주기를 바랍니다.
이 부록은 "1인 6개월 시스템을 중규모 팀으로 확장할 때, 도입 공수·운영비·계정·사내망 보안을 무엇으로 어떻게 추정하는가"라는 스튜디오 PD·대표의 질문에 답하기 위한 빈칸 채우기형 워크시트입니다. 본문 19.3(AI 도입 전략과 경영진 설득)이 "ROI를 가공하지 말라"고 했다면, 이 부록은 그 원칙을 도입 비용 쪽에도 그대로 적용합니다. 즉 이 부록은 숫자를 제공하지 않습니다. 모든 칸은 빈칸이고, 그 칸을 채우는 것은 당신 팀의 측정·추정이며,
[회계 확정 필요]로 표시된 칸은 회계가 채우기 전까지 누구도 추정으로 메우지 않습니다.
이 부록을 쓰는 법은 이렇습니다. 먼저 L.1에서 TCO(Total Cost of Ownership, 총소유비용)가 어떤 항목으로 쪼개지는지 그림으로 잡으십시오. 그다음 L.2~L.6의 다섯 워크시트를 자신의 팀 규모 행에 맞춰 빈칸인 채로 출력해, 직접 측정하거나 회계·정보보안 담당에게 한 줄 질문으로 넘기십시오. 마지막 L.7의 자가 점검표로 빠진 칸이 없는지 확인하면 됩니다. 이 부록의 가치는 채워진 숫자가 아니라 빠뜨리기 쉬운 비용 항목을 미리 칸으로 만들어 두는 것에 있습니다.
PD가 가장 자주 빠뜨리는 함정은 도입 비용을 "구독료 × 인원"으로만 보는 것입니다. 실제 총소유비용은 그보다 넓습니다. 한 번 내고 끝나는 도입 공수(설치·표준화·온보딩)와, 매달 반복되는 운영비(라이선스·토큰·인프라·관리 인건)로 갈라지고, 그 위에 눈에 안 보이는 보안·계정 관리 비용이 얹힙니다.
flowchart TB
TCO["팀 도입 TCO"] --> A["일회성: 도입 공수
(설치·표준화·온보딩)"]
TCO --> B["반복: 월 운영비
(라이선스·토큰·인프라·관리)"]
TCO --> C["반복: 보안·계정 관리
(SSO·감사·키 회수)"]
A --> A1["L.3 도입 공수 워크시트"]
B --> B1["L.5 월 운영비 워크시트
[회계 확정 필요]"]
C --> C1["L.4 사내망·보안 점검"]
A --> A2["L.6 온보딩 시간 워크시트"]
B --> A3["L.2 계정·라이선스 워크시트"]
classDef data fill:#e2e8f0,stroke:#64748b,color:#1e293b;
class A1,A2,A3,B1,C1 data;
세 줄기 가운데 PD가 과소평가하기 쉬운 쪽은 왼쪽(도입 공수)과 오른쪽(보안·계정)입니다. 라이선스 요금은 견적서에 적혀 오지만, "1인이 6개월간 손으로 쌓은 표준·스킬을 팀이 공유 가능한 형태로 정리하는 공수"와 "사내망에 외부 LLM 호출을 어디까지 허용할지 정하는 보안 검토"는 견적서에 없고, 그래서 항상 일정과 예산을 초과시킵니다. 이 부록의 워크시트는 그 안 보이는 비용을 빈칸으로라도 먼저 드러내는 데 목적이 있습니다.
본문 19.3.6은 "비용은 절대값을 책에 싣지 않는다 — 회계에서 받아 채울 빈칸이다"라고 했습니다. 이 부록은 그 빈칸을 어디에 두어야 하는지를 항목별로 펼친 것입니다.
가장 먼저 채우는 표입니다. 누가 어떤 도구를 쓰고, 그 권한이 어떻게 발급·회수되는지를 인원수와 함께 적습니다. 인원 칸은 당신 팀의 실제 머릿수로 채우고, 단가 칸은 견적서나 공개 요금표에서 가져와 채웁니다. 이 책은 단가를 적지 않습니다.
| 항목 | 무엇을 적나 | 누가 채우나 | 본인 팀 값 |
|---|---|---|---|
| 도구별 시트 수 | 도구마다 필요한 계정(좌석) 수 | 리드 | ______ 좌석 |
| 권한 등급 분포 | full / 작업별 cap / 외주 1회 인원 (부록 C.1.2) | 리드 | full __명 / 일반 __명 / 외주 __명 |
| 좌석 단가 | 도구별 월 좌석 요금 | 회계·구매 | ______ /좌석·월 |
| 공용 키 여부 | 팀 공용 API 키 vs 개인별 키 | 정보보안 | □ 공용 □ 개인별 |
| 발급 절차 | 신규 입사자 계정 발급 경로·소요 | 리드 | ______ |
| 회수 절차 | 퇴사·외주 종료 시 키/좌석 회수 경로 | 정보보안 | ______ |
규칙은 두 가지입니다. 첫째, 외주·단기 인원은 좌석을 상시 발급하지 말고 작업 단위로 열고 회수합니다(부록 C.1.2). 둘째, 회수 절차 칸이 비어 있으면 발급을 시작하지 않습니다. 가장 흔한 사고가 퇴사자 계정이 회수되지 않아 비용과 키 노출이 함께 새는 것이므로, 발급보다 회수를 먼저 설계합니다.
"1인 6개월"이 팀으로 확장될 때 늘어나는 일회성 공수를 규모별로 추정하는 표입니다. 공수 칸은 인일(人日, 한 사람이 하루 일한 양) 단위로, 당신 팀이 실제로 측정하거나 추정해 채웁니다. 이 책은 인일 수를 제공하지 않습니다 — 팀의 숙련도·기존 표준 정리 수준에 따라 크게 달라지기 때문입니다.
| 도입 공수 항목 | 1~3인 | 4~10인 | 11~30인 | 31~50인 | 측정/추정 주체 |
|---|---|---|---|---|---|
| 환경 설치·세팅 (도구·hook·권한) | ___인일 | ___인일 | ___인일 | ___인일 | 리드/인프라 |
| 1인 자산의 팀 공유화 (스킬·표준·atom 정리) | ___인일 | ___인일 | ___인일 | ___인일 | 리드 |
| 팀 표준 수립 (네이밍·프런트매터·룰북, 부록 D) | ___인일 | ___인일 | ___인일 | ___인일 | 리드 |
| 검증 게이트 구축 (lint·룰북 자동화) | ___인일 | ___인일 | ___인일 | ___인일 | QA/리드 |
| 온보딩 자료 제작 (L.6과 연동) | ___인일 | ___인일 | ___인일 | ___인일 | 리드 |
| 합계 (도입 1회성 공수) | ___인일 | ___인일 | ___인일 | ___인일 | — |
이 표를 채울 때 빠지기 쉬운 칸이 둘째 줄입니다. 1인이 6개월간 머릿속과 개인 폴더에 쌓아 둔 자산은, 팀이 공유하려면 누군가 꺼내 정리하고 문서로 만드는 별도 공수가 듭니다. 이 공수를 "0"으로 잡으면 도입 일정이 반드시 밀립니다. 또한 규모가 커질수록 설치 공수보다 표준 수립·검증 게이트 공수가 더 가파르게 늘어난다는 점을 칸의 모양으로 미리 보여 줍니다 — 사람이 늘면 합의해야 할 표준의 수가 늘기 때문입니다.
본문 19.3.1의 단계적 도입(보수적→진보적)을 따르면, 이 공수를 한 분기에 다 쓰지 않고 1단계(컨텍스트 주입) 파일럿부터 분산해 들일 수 있습니다. 표의 합계를 한 번에 결재받으려 하지 말고, 1단계 공수만 먼저 떼어 결재받는 것이 현실적입니다.
PD·대표가 가장 직접적으로 두려워하는 영역입니다. 외부 LLM에 무엇이 나가는지, 사내망에서 외부 호출을 어디까지 허용하는지를 항목별로 점검합니다. 이 표는 합격/보류를 가리는 체크리스트이며(부록 C.6 보안과 연동), 한 항목이라도 미정이면 그 범위의 도입을 보류합니다.
| 점검 항목 | 통과 기준 | 담당 | 상태 |
|---|---|---|---|
| 외부 LLM 전송 데이터 범위 | 민감 데이터는 placeholder/자체 호스팅 (C.6) | 정보보안 | □ 통과 □ 보류 |
| 결제·개인정보 전송 | 예외 없이 전송 금지 명문화 | 정보보안 | □ 통과 □ 보류 |
| 사내망 외부 호출 정책 | 허용 도메인·프록시·로그 보존 기간 정의 | 인프라 | □ 통과 □ 보류 |
| 자체 호스팅 필요 여부 | 핵심 IP는 자체 호스팅 모델로 처리할지 결정 | 대표/정보보안 | □ 결정 □ 미정 |
| 키 노출 사고 대응 | 즉시 교체 + 사용 이력 검토 경로 (C.7) | 정보보안 | □ 통과 □ 보류 |
| 감사 로그 | 누가·언제·무엇을 호출했는지 기록·보존 | 인프라 | □ 통과 □ 보류 |
| 회사 IP 외부 유출 검사 | grep watchlist 등 사전 검사 절차 (부록 B.6) | 리드 | □ 통과 □ 보류 |
| 외주 접근 격리 | 외주 계정은 핵심 자산 접근 차단·작업별 격리 | 정보보안 | □ 통과 □ 보류 |
이 표에서 비용이 가장 크게 갈리는 칸은 넷째 줄(자체 호스팅 필요 여부)입니다. 핵심 IP를 외부 LLM에 절대 보낼 수 없다고 결정하면, 자체 호스팅 인프라 비용이 L.5의 운영비에 통째로 얹힙니다. 그래서 이 결정은 리드가 아니라 대표·정보보안이 함께 내려야 하고, 결정 전까지는 L.5의 인프라 칸을 확정할 수 없습니다. 두 워크시트가 이 한 칸으로 연결됩니다.
매달 반복되는 비용을 항목별로 분해한 표입니다. 이 표의 금액 칸은 전부 빈칸이며, [회계 확정 필요]로 표시된 칸은 회계가 채우기 전까지 누구도 추정으로 메우지 않습니다. 토큰 단가·구독료·인프라 요금은 모델·호출량·계약에 따라 매달 달라지므로, 이 책은 절대값을 적지 않습니다.
| 운영비 항목 | 산정 방식 | 누가 채우나 | 월 금액 |
|---|---|---|---|
| 라이선스·구독 | 좌석 수 × 좌석 단가 (L.2) | 회계 | [회계 확정 필요] |
| LLM 토큰 비용 | 호출량 × 토큰 단가, 도구별 상한(cap) 합 | 회계 | [회계 확정 필요] |
| 인프라 (자체 호스팅 시) | L.4 결정에 따른 서버·GPU·스토리지 | 회계·인프라 | [회계 확정 필요] |
| 백업·동기화 | 저장소·백업 스토리지 (부록 C.5) | 회계 | [회계 확정 필요] |
| 운영 관리 인건 | 도구·키·로그 관리 담당 시간 환산 | 리드·회계 | [회계 확정 필요] |
| 월 합계 | 위 항목 합 | 회계 | [회계 확정 필요] |
이 표의 규칙은 단 하나, 빈칸을 빈칸으로 둔다입니다. 본문 19.3.2에서 AI가 운영 비용 칸을 그럴듯하게 $4,500으로 날조했던 실패를 떠올리십시오 — 사람이든 AI든 이 칸을 추정으로 메우는 순간, 그 보고는 첫 질문에 무너집니다. 대신 비용을 통제하는 진짜 장치는 금액이 아니라 도구별 월 상한(cap)이 걸려 있고 초과가 자동 보고되는 구조입니다(19.3.6). 결재 시 경영진에게 보여 줄 것은 메운 금액이 아니라, "상한이 걸려 있고 초과가 보고된다"는 구조와 회계가 채울 빈칸 목록입니다.
다섯째 줄(운영 관리 인건)이 가장 자주 누락됩니다. 도구는 깔아 두면 끝이 아니라, 키를 회수하고 로그를 보고 상한을 조정하는 사람의 시간을 매달 먹습니다. 이 칸을 0으로 두면 그 일이 리드의 보이지 않는 야근으로 숨습니다.
신규 멤버 한 명이 시스템 위에서 제 몫을 하기까지 걸리는 시간을 단계별로 추정하는 표입니다. 시간 칸은 당신 팀에서 실제 온보딩을 한 번 시켜 보고 측정하는 것이 가장 정확합니다(본문 19.3.7 베이스라인 측정 레시피와 같은 방식). 측정 전에는 빈칸으로 둡니다.
| 온보딩 단계 | 무엇을 하나 | 측정 시간 | 비고 |
|---|---|---|---|
| 환경 설치 | 도구·hook·계정 세팅까지 | ___시간 | L.2 발급 절차와 연동 |
| 표준 학습 | 네이밍·프런트매터·룰북 숙지 (부록 D) | ___시간 | 자료 있으면 단축 |
| 첫 작업 (보수적) | 컨텍스트 주입으로 첫 산출물·검수 통과 | ___시간 | 19.3.1의 1단계 |
| 검증 게이트 적응 | lint·룰북 게이트에 맞춰 작업 | ___시간 | — |
| 독립 작업 도달 | 감독 없이 작업·채택 판정 가능 | ___일 | 온보딩 완료 기준 |
이 표를 채우면 도입 공수(L.3)의 "온보딩 자료 제작" 칸이 왜 중요한지 드러납니다. 온보딩 자료가 잘 정리돼 있을수록 둘째·셋째 줄의 시간이 짧아지고, 신규 멤버가 늘수록 그 절감이 누적됩니다. 즉 온보딩 자료 제작은 일회성 공수이지만, 회수는 멤버 수만큼 반복됩니다. 본문 19.3.3이 "JIT 자동 주입 221건 — 신규 멤버도 같은 규칙 위에서 작업"이라고 한 것이 이 표에서는 셋째 줄의 시간 단축으로 나타납니다.
마지막 줄(독립 작업 도달)이 온보딩의 진짜 완료 기준입니다. 환경 설치가 끝난 것을 온보딩 완료로 착각하면, 감독 비용이 리드에게 계속 쌓입니다. "감독 없이 채택 판정까지 한다"가 기준이어야 합니다.
마지막으로, 이 워크시트들을 경영진에게 가져가기 전에 스스로 통과시켜야 할 항목입니다. 부록 B.6(차용 전 점검)과 같은 정신으로, 한 항목이라도 비어 있으면 결재를 미루고 그 칸부터 채웁니다.
| 점검 항목 | 통과 기준 |
|---|---|
| 계정 회수 절차가 정의됐는가 | L.2의 회수 절차 칸이 비어 있지 않음 |
| 보안 점검이 모두 통과/결정됐는가 | L.4에 □보류·□미정이 0건 |
| 운영비 빈칸이 회계로 넘어갔는가 | L.5의 [회계 확정 필요]가 질문으로 발송됨 |
| 도입 공수를 단계로 쪼갰는가 | L.3 합계가 아니라 1단계 공수부터 결재 |
| 온보딩 완료 기준이 "독립 작업"인가 | L.6 마지막 줄로 완료를 판정 |
| 추정값을 단언으로 적지 않았는가 | 모든 추정 칸에 "추정·표본 수" 표기 |
이 표를 다섯 칸의 합격으로 읽지 마시고, 여섯 개의 잠금장치로 읽어 주시기 바랍니다. 1인 시스템을 팀으로 확장하는 일은 분명 가능하지만, 그 확장의 비용은 라이선스 요금이 아니라 이 여섯 칸의 빈칸을 정직하게 채웠을 때 비로소 전체 그림이 드러납니다. 그리고 어느 칸도 AI에게 채우라고 시키지 마십시오 — AI는 본문 19.3.2처럼 빈칸을 그럴듯한 숫자로 메웁니다. AI의 자리는 당신이 측정한 값을 받아 결재 슬라이드 문장으로 정리하는 것까지입니다.
이 책 다섯 곳 — §8.2.7(경제)·§5.4(보이스)·§6.3(페르소나)·§7.3(패턴)·§13.3(데이터) — 에서 "차원 벡터로 압축한다", "임베딩", "벡터 공간에서 가깝다" 같은 표현이 방향 표지로 등장한다. 머신러닝 배경이 없어도 게임 기획 감각만으로 충분히 잡히는 개념이다. 한 번만 직관을 깔아 두면 그 다섯 곳이 전부 같은 그림 한 장으로 읽힌다.
다만 한 가지를 먼저 못 박는다. 개념의 직관은 쉽지만, 적용 조건은 무겁다. 이 발상은 입문이 아니라 — 이 책이 앞에서 줄곧 쌓아 온 토대, 즉 보수적 적용의 검증 게이트, 데이터·telemetry 인프라, voice_lint·정합성 검사 같은 분야별 검사기, 그리고 Layer 통합 위에서만 설 수 있는 가장 먼 끝의 응용이다. 그 토대 없이 좌표부터 그리면, M.4에서 보듯 그 지도는 게임과 어긋난 오차까지 깔끔하게 압축한 허상이 된다. 다섯 곳이 모두 '아직 시기상조'인 진짜 이유가 여기 있다 — 발상이 어려워서가 아니라, 받쳐 줄 토대가 먼저이기 때문이다. 이 부록은 그 토대가 갖춰진 팀이 '다음 한 걸음'을 가늠할 때 펴 보는 지도이지, 첫걸음을 떼는 입문서가 아니다.
어떤 대상이든 그 특징들을 숫자 목록으로 바꿔 '지도' 위의 한 점으로 놓는 것이 임베딩(embedding)이다. 그 숫자 목록이 곧 차원 벡터다. 약속은 하나뿐이다 — 비슷한 대상일수록 지도에서 가까운 점이 되게 만든다.
예를 들어 NPC를 (말투 격식, 감정 표현량, 어휘 난이도, …) 같은 특징의 좌표로 놓으면, 비슷한 말투의 NPC끼리 지도에서 가까이 모인다. 요리 레시피라면 재료 구성을 좌표로 놓아 비슷한 요리가 가까이 모인다 — §8.2.7에서 단서로 든 Epicure가 한 일이 정확히 이것이다.
비슷해 보이지만 반대 — AHP와 헷갈리지 말 것. 다기준 의사결정에 쓰는 AHP(Analytic Hierarchy Process, Saaty)도 정성적 판단을 벡터로 바꾼다는 점이 닮아 보인다 — 기준들을 둘씩 견주는 쌍대 비교로 우선순위 가중치(주고유벡터)를 뽑기 때문이다. 그러나 방향이 반대다. AHP는 사람이 기준·계층을 미리 정의하고 그 안에서 가중치를 매기는 하향식 의사결정이고, 여기 임베딩은 사람의 정의 없이 데이터에서 군집이 저절로 드러나는 상향식 발견이다. §13.3이 뚫으려는 한계 — '사람이 미리 정의한 세그먼트' — 가 바로 AHP의 출발점이다. 둘은 같은 자리가 아니라 반대편이다.
지도는 강력하지만 공짜가 아니다.
그래서 이 책은 차원 벡터를 처방이 아니라 방향 표지로 둔다 — 검증(telemetry·시뮬)이 단단히 깔린 팀이 몇 년 뒤 들여다볼 영역이다. 분야별 구체 단서는 §8.2.7(경제)·§5.4(보이스)·§6.3(페르소나)·§7.3(패턴)·§13.3(데이터)에 흩어 두었고, 전부 이 부록의 지도 한 장 위에서 읽으면 된다.
이 부록은 이 책을 한 학기 강의의 교재로 얹으려는 분 — 대학·전문대·아카데미의 교수자, 사내 교육 담당자, 스터디 리드 — 을 위한 것입니다. 1,000쪽에 가까운 단권을 학기 단위로 쪼개는 일은 생각보다 막막합니다. 어느 부를 몇 주차에 넣을지, 본문의 「따라하기」를 어떻게 과제로 바꿀지, 제출물을 무슨 기준으로 채점할지 — 그 세 가지가 막히면 좋은 책도 교재로는 채택되기 어렵습니다. 이 부록은 그 세 가지를 그대로 베껴 쓸 수 있는 도구로 만들어 드립니다.
이 부록을 쓰는 법은 이렇습니다. 먼저 N.1의 15주 진도표를 본인 학사 일정에 맞춰 읽으시고(16주제·계절학기 변형은 N.2에 따로 두었습니다), N.3의 난이도 배지와 선행지식 표로 수강생 수준을 가늠하십시오. 그다음 N.4의 채점 루브릭을 복사해 본인 과제에 맞게 항목만 바꾸시면 됩니다. 모든 표는 그대로 출력해 강의계획서(실러버스)에 붙여도 되도록 짰습니다.
한 가지 일러둘 것이 있습니다. 이 책의 모든 챕터는 「따라하기」로 끝납니다. 읽고 덮는 챕터가 아니라 오늘 손을 움직이게 만드는 것이 본문의 목표였는데, 강의에서는 바로 그 「따라하기」가 과제의 1차 재료가 됩니다. 이 부록의 진도표가 본문의 「따라하기」를 주차별 과제로 어떻게 옮기는지를 함께 적은 것은 그 때문입니다.
가장 흔한 15주제(주 1회 3시간 기준) 학기를 기준으로 짠 표준 진도표입니다. 이 책 24부를 한 학기에 전부 다루지는 않습니다 — 무리하게 욱여넣으면 어느 것도 손에 남지 않기 때문입니다. 대신 기반(1·2부)을 단단히 깔고, 분야 중 대표 5~6개를 깊게 다루고, 프로세스·운영에서 핵심만 골라 마무리하는 구성을 택했습니다. 다루지 않고 남긴 부는 「확장 읽기」로 표시해, 관심 있는 수강생이 스스로 펼치도록 안내합니다.
학습 목표는 모두 "수강생이 무엇을 할 수 있게 되는가"의 동사로 적었습니다. "안다"가 아니라 "만든다·검증한다·고른다"입니다. 이 책 전체가 "AI가 후보를 내고 사람이 거른다"는 한 문장을 반복하므로, 목표의 동사도 그 분업을 따릅니다.
| 주차 | 다루는 부·챕터 | 학습 목표 (수강 후 가능) | 과제로 전환한 「따라하기」 |
|---|---|---|---|
| 1 | 1.0 시작하기 전에 + 1부(도입) | 터미널·계정·요금 구조를 설명하고, AI 도구를 본인 PC에 설치해 첫 세션을 연다 | 1.0 설치 「따라하기」 — 설치 스크린샷 + 첫 프롬프트·출력 제출 |
| 2 | 2부(정보 아키텍처) | YAML 프론트매터로 문서를 데이터화하고, 폴더·명명 규약을 설계한다 | 2.1 프론트매터 「따라하기」 — 본인 문서 3개에 프론트매터 부여 |
| 3 | 3부(시스템 기획) | 스키마 우선 원칙으로 데이터 시트의 $스키마를 먼저 정의한다 | 3.2 스키마 「따라하기」 — 미니 시트 1종의 명세서 작성 |
| 4 | 10부(QA·정합성) | 30개 시트의 FK 정합성을 코드로 검사하는 도구를 따라 만든다 | 10.1 정합성 검증 「따라하기」 — N.4 루브릭으로 채점하는 핵심 과제 |
| 5 | 4부(전투) + 8부(밸런스) | 전투 수치를 Layer로 분해하고, 결정론적 밸런스 공식을 룰북으로 둔다 | 8.1 밸런스 공식 「따라하기」 — 데미지 공식 1종 + 시뮬 |
| 6 | 5부(내러티브) | NPC 대사 voice_profile을 만들고 voice_lint로 톤 이탈을 잡는다 | 5.2 voice_profile 「따라하기」 — 캐릭터 1명의 보이스 프로필 |
| 7 | 6부(콘텐츠) + 7부(레벨) | 절차적 생성의 두 축(룰·AI)을 구분하고, 콘텐츠 후보를 양산·검수한다 | 6.2 생성기 「따라하기」 — 콘텐츠 후보 10건 생성 + 검수 로그 |
| 8 | 중간 점검·발표 | 1~7주차 과제를 통합해 본인 미니 프로젝트로 시연한다 | 중간 과제 발표 (3~6주차 산출물 통합 데모) |
| 9 | 9부(UX·UI) + 14부(모바일) | HUD를 lint에 걸어 시선 이탈·대비 미달을 잡고, PC HUD를 모바일로 압축한다 | 9.1 HUD lint 「따라하기」 — 화면 1종 lint 리포트 |
| 10 | 16부(커뮤니케이터) + 17부(회의록) | 격리된 작업공간에서 결정만 정본화하고, 회의록을 구조화한다 | 17.x 회의록 「따라하기」 — 실제 회의 녹취 1건 구조화 |
| 11 | 18부(의사결정) + 19부(팀 리드) | 결정을 추적 가능한 카드로 남기고, 비전을 결정의 채점표로 바꾼다 | 18.1 의사결정 추적 「따라하기」 — 결정 카드 3장 작성 |
| 12 | 20부(협업 메모리) + 21부(자가개선) | 협업 맥락을 메모리로 운영하고, 회고를 자가개선 루프로 돌린다 | 21장 회고 「따라하기」 — 1주 회고 1건 + 추출 규칙 1개 |
| 13 | 22부(거버넌스) | 프롬프트·환각·비용·법무·윤리의 경계를 점검하고 룰을 세운다 | 22.1 프롬프트 「따라하기」 — 작업지시서 1장 + 환각 점검 절차 |
| 14 | 23부(개인 개발) + 24부(운영 심화) | 1인 축소판으로 도구를 옮기고, 정합·링크·stale를 코드로 검증한다 | 24.1 검증 「따라하기」 — 본인 프로젝트 검증 스크립트 1종 |
| 15 | 기말 프로젝트 발표·평가 | 학기 전체를 관통하는 본인 워크플로 1개를 설계·시연·검증한다 | 기말 과제 발표 (N.4 확장 루브릭으로 평가) |
확장 읽기(강의 미포함, 자율 학습 권장): 11부(캐릭터·펫·탈것), 12부(아트 디렉션), 13부(데이터·KPI), 15부(라이브 운영). 이 네 부는 분야 특화가 강해 관심 있는 수강생이 본인 분야에 맞춰 펼치도록 남겼습니다. 부록 F(사례 색인)를 길잡이로 쓰면 본인 환경에 가까운 사례부터 역방향으로 찾아 들어갈 수 있습니다.
진도 흐름을 한눈에 보면 다음과 같습니다. 기반 → 분야 심화 → 중간 통합 → 프로세스·운영 → 기말 통합의 두 산(중간·기말)을 가진 구조입니다.
flowchart LR
subgraph A["기반 (1~3주)"]
W1["1주
설치·도입"] --> W2["2주
정보 아키텍처"] --> W3["3주
시스템·스키마"]
end
subgraph B["분야 심화 (4~7주)"]
W4["4주
QA·정합성"] --> W5["5주
전투·밸런스"] --> W6["6주
내러티브"] --> W7["7주
콘텐츠·레벨"]
end
M1{{"8주
중간 발표"}}
subgraph C["프로세스·운영 (9~14주)"]
W9["9주
UX·모바일"] --> W10["10주
커뮤니케이션"] --> W11["11주
의사결정·리드"] --> W12["12주
협업·회고"] --> W13["13주
거버넌스"] --> W14["14주
개인개발·검증"]
end
M2{{"15주
기말 발표"}}
A --> B --> M1 --> C --> M2
classDef human fill:#fde68a,stroke:#b45309,color:#000;
class M1,M2 human;
학교마다 학기 길이가 다릅니다. 표준 15주 외에 가장 자주 마주치는 두 변형의 조정안을 둡니다. 핵심 과제(4주차 정합성 검사)와 두 발표 산은 어느 변형에서도 유지하는 것을 권합니다 — 이 책의 정직성 원칙("효과가 아니라 구조를 보여준다")이 가장 잘 드러나는 자리이기 때문입니다.
| 학기 형태 | 조정 방법 |
|---|---|
| 16주제 | 표준 15주 + 16주차에 보강·재평가 주 추가. 기말 과제 재제출 기회 또는 「확장 읽기」 4부 중 1부를 수강생 투표로 정해 특강 |
| 계절학기 8주 (주 2회 또는 집중) | 1주(설치·도입) → 2주(정보·스키마) → 3주(정합성, 핵심 과제) → 4주(전투·밸런스·내러티브 묶음) → 5주 중간 발표 → 6주(회의·의사결정·협업) → 7주(거버넌스·검증) → 8주 기말 발표. 분야는 대표 3개로 축소, 「따라하기」는 수업 중 실습으로 흡수 |
| 플립러닝(거꾸로 교실) | 본문 통독은 사전 과제로 돌리고, 강의 시간은 「따라하기」 실습과 루브릭 상호 평가에 전부 할당. 이 책은 코드가 외부 의존성 없이 그대로 돌아가도록 실려 있어 실습 중심 운영에 적합 |
같은 책 안에서도 챕터마다 요구하는 배경지식이 다릅니다. 어떤 챕터는 터미널이 처음인 1학년도 따라올 수 있고, 어떤 챕터는 데이터베이스 키 개념이나 통계 기초가 있어야 온전히 소화됩니다. 수강생 수준에 맞춰 진도를 조절하거나, 선행 과목을 안내할 때 쓰시도록 세 단계 배지로 정리했습니다.
배지의 뜻은 다음과 같습니다.
| 배지 | 등급 | 의미 |
|---|---|---|
| 🟢 입문 | 입문 | 비전공·1학년도 따라올 수 있음. 코드는 복사·실행 수준이면 충분 |
| 🟡 실무 | 실무 | 코드를 읽고 본인 데이터에 맞춰 수정할 수 있어야 함. 기획 실무 맥락 이해 권장 |
| 🔴 심화 | 심화 | 알고리즘·구조를 설계·확장하는 단계. 선행지식 없이는 소화 난도 높음 |
주차별 핵심 부의 배지와 선행지식은 아래와 같습니다. "선행지식"은 그 주차를 무리 없이 따라가기 위해 미리 갖추면 좋은 배경이며, 없다고 수강 자체가 막히는 것은 아닙니다.
| 주차 | 핵심 부 | 배지 | 선행지식 |
|---|---|---|---|
| 1 | 1.0·1부 도입 | 🟢 입문 | 없음 (터미널 첫 경험 전제) |
| 2 | 2부 정보 아키텍처 | 🟢 입문 | 텍스트 편집기 사용 |
| 3 | 3부 시스템·스키마 | 🟡 실무 | 표/스프레드시트 기본, 데이터 타입 개념 |
| 4 | 10부 정합성 검증 | 🔴 심화 | 파이썬 기초(함수·반복문), 관계형 키(FK) 개념 |
| 5 | 4·8부 전투·밸런스 | 🟡 실무 | 사칙연산 수식, 표 계산(엑셀 함수) |
| 6 | 5부 내러티브 | 🟢 입문 | 캐릭터·시나리오 작문 감각 |
| 7 | 6·7부 콘텐츠·레벨 | 🟡 실무 | 절차적 생성 개념(권장), 좌표·그리드 감각 |
| 9 | 9·14부 UX·모바일 | 🟡 실무 | 화면 레이아웃·해상도 개념 |
| 10 | 16·17부 커뮤니케이션 | 🟢 입문 | 없음 (협업 경험 있으면 유리) |
| 11 | 18·19부 의사결정·리드 | 🟡 실무 | 팀 작업·프로젝트 관리 경험(권장) |
| 12 | 20·21부 협업·회고 | 🟡 실무 | 2주차 정보 아키텍처 이수 |
| 13 | 22부 거버넌스 | 🟡 실무 | 기초 통계(평균·분포, 환각 검출 맥락), 저작권 기본 |
| 14 | 23·24부 개인·운영 | 🔴 심화 | 파이썬 기초, git 기본, 4주차 정합성 이수 |
선행 과목 한 줄 안내(강의계획서용): "파이썬 입문 또는 그에 준하는 프로그래밍 기초를 권장하나 필수는 아님. 4·14주차 심화 챕터는 파이썬 함수·반복문 수준을 전제하며, 미이수자는 1~3주차 입문 트랙으로 충분히 따라올 수 있도록 과제를 분리 운영함."
수강생 구성에 따른 운영 팁은 다음과 같습니다.
루브릭이 없으면 「따라하기」 제출물은 "돌아갔다/안 돌아갔다"의 이분법으로만 채점되기 쉽습니다. 그러면 이 책이 가장 중요하게 여기는 것 — AI 출력을 검수하고 거부하는 과정 — 이 평가에서 사라집니다. 그래서 4주차 핵심 과제(10.1 정합성 검증 atom 「따라하기」)를 예로, 결과물뿐 아니라 그 과정까지 채점하는 루브릭을 둡니다. 다른 주차 과제에도 항목 이름만 바꿔 그대로 쓰실 수 있습니다.
과제 정의: 본인이 만든(또는 제공된) 데이터 시트 여러 종에 대해, 시트 간 외래 키(FK) 정합성을 검사하는 도구를 AI와 함께 만들고, 일부러 심어 둔 오류를 도구가 잡아내는지 시연한다. 제출물은 ① 도구 코드 ② 검사 실행 결과(통과/실패 리포트) ③ AI에게 친 프롬프트 전문과 그중 거부·수정한 출력의 기록.
루브릭은 4항목·각 25점(총 100점)으로 구성합니다. 핵심은 "도구가 돈다"(2항)와 별개로, AI를 어떻게 다뤘는가(3·4항)를 절반의 비중으로 평가한다는 점입니다.
| # | 평가 항목 | 배점 | 미흡 (0~12) | 보통 (13~19) | 우수 (20~25) |
|---|---|---|---|---|---|
| 1 | 정합성 규칙 정의 — 어떤 FK 관계를 왜 검사하는지 명확한가 | 25 | 검사 대상 관계가 불명확하거나 임의적 | 주요 FK 관계를 식별했으나 근거 설명 부족 | 시트 간 관계를 도식·근거와 함께 정의하고 검사 우선순위를 설명 |
| 2 | 도구 동작·오류 검출 — 심어 둔 오류를 실제로 잡아내는가 | 25 | 실행 불가 또는 명백한 오류를 놓침 | 대부분의 오류를 잡으나 일부 누락·오탐 | 모든 심은 오류를 잡고, 오탐 없이 리포트가 사람이 읽을 수 있게 출력 |
| 3 | AI 활용 과정의 투명성 — 프롬프트 전문과 출력이 재현 가능하게 기록됐는가 | 25 | 프롬프트·출력 기록 없음 또는 결과만 첨부 | 프롬프트는 있으나 거부·수정 과정이 빠짐 | 친 프롬프트 전문, 날것의 출력, 거부·재지시 과정을 시간순으로 남김 |
| 4 | 검수·거부 판단 — AI 출력의 무엇을 왜 거부·수정했는지 | 25 | 출력을 그대로 수용(검수 흔적 없음) | 일부 수정했으나 판단 근거가 약함 | 오류·환각·과잉설계를 짚어 거부하고, 그 판단 근거를 자기 언어로 설명 |
채점 운영 메모: 3·4항(합 50점)이 이 루브릭의 척추입니다. 도구가 완벽히 돌아도(2항 만점) AI 출력을 무비판 수용했다면(4항 미흡) 이 과제의 학습 목표 — "사람이 검수자 자리를 지킨다" — 에는 미달한 것으로 봅니다. 반대로 도구가 일부 불완전해도 거부·재지시 과정이 탄탄하면 높은 점수를 받을 수 있습니다. 효과(돌아간 결과)가 아니라 구조(어떻게 다뤘는가)를 평가한다는 이 책의 원칙이 채점에도 그대로 적용됩니다.
기말 과제에는 위 4항에 ⑤ 워크플로 일반화(본인 분야로의 이식 설명) 1항을 더한 5항·각 20점 확장 루브릭을 권합니다. 학기 내내 다룬 도구를 본인 프로젝트로 옮길 수 있는가 — 그것이 이 책이 마지막에 묻는 질문이고, 강의의 마지막 평가도 같은 질문이면 충분합니다.
마지막으로, 이 부록을 한 장으로 줄이면 이렇습니다.
이 진도표는 출발점이지 정답이 아닙니다. 본인 수강생의 수준과 학사 일정에 맞춰 주차를 옮기고 과제를 바꾸십시오. 이 책 자체를 AI 도구에게 통째로 읽혀 "내 강의 16주 일정과 수강생 수준에 맞게 이 진도표를 다시 짜 줘"라고 부탁하는 것도 — 이 책의 가장 빠른 활용법답게 — 열려 있는 길입니다.
게임 기획 실무에서 바로 쓰는 AI·클로드 코드 활용법
지어낸 숫자는 한 개도 없다
| 발 행 | 2026년 06월 11일 |
| 저 자 | 이민수 |
| 펴낸이 | 한건희 |
| 펴낸곳 | 주식회사 부크크 |
| 출판사등록 | 2014.07.15. (제2014-16호) |
| 주 소 | 서울특별시 금천구 가산디지털1로 119 SK트윈타워 A동 305호 |
| 전 화 | 1670-8316 |
| 이메일 | info@bookk.co.kr |
| ISBN | 979-11-12-21479-9 |
| 홈페이지 | www.bookk.co.kr |
ⓒ 이민수 2026
이 책은 CC BY-NC-SA 4.0으로 공개됩니다. 비영리 공유·번역은 원저자(이민수 · Minsoo Lee)와 출처를 표시하면 허용하며, 상업적 이용은 저자의 별도 허락이 필요합니다.