성경 서사 게임을 3개월 만들며: 코드보다 '문서를 기계로 재는 층'을 먼저 만든 이유
2026년 5월 20일에 시작한 개인 프로젝트 lemuel-xr 이 오늘(8월 11일) 204번째 커밋을 지났다. 성경 인물 서사를 게임과 묵상 콘텐츠로 제공하는 앱인데, 3개월 동안 실제로 시간을 가장 많이 쓴 곳은 게임 로직도 LLM 프롬프트도 아니었다. 문서가 스스로에 대해 하는 주장을 기계가 다시 재게 만드는 층이었다.
이 글은 그 구조와 작동 원리, 그리고 왜 그렇게 됐는지를 정리한 것이다. 수치는 전부 리포에서 직접 실행·측정해 옮겼고, 확인하지 못한 것은 확인하지 못했다고 적었다.
한 줄 요약 — LLM 이 문서와 코드를 함께 쓰는 프로젝트에서 가장 비싼 실패는 버그가 아니라 “검사했다”는 거짓 초록이다. 그래서 통과/실패 두 값 대신 “잴 수 없음(BLOCKED)” 을 일급 상태로 만들고, 문서의 숫자를 실행으로 대조하고, 그 판정기를 다시 돌연변이로 검사하는 층을 만들었다.
0. 규모 — 실측
| 영역 | 파일 | 줄 수 |
|---|---|---|
docs/ (설계 문서) |
58 | 26,584 |
content/ (저작 YAML) |
136 | 21,680 |
scripts/ (검증 도구·설정) |
144 | 12,688 |
backend/src/main/kotlin/ |
282 | 12,220 |
backend/src/test/kotlin/ |
101 | 11,628 |
frontend/src/ |
35 | 7,160 |
ai/ · tts/ (사이드카) |
11 | 982 |
(git ls-files 기준 추적 파일. 첫 커밋 2026-05-20, 최신 커밋 2026-08-11, 총 204 커밋, 추적 파일 946개.)
이 표에서 눈여겨볼 건 마지막 줄이 아니라 위 세 줄이다. 설계 문서(26,584)가 애플리케이션 코드(백엔드 12,220)보다 크고, 검증 도구 트리(12,688)가 백엔드 소스보다 크다. 그런데 그 검증 도구 중 CI 에서 도는 건 하나도 없다. 왜 그런 이상한 비율이 됐는지가 이 글의 내용이다.
1. 무엇을 만들고 있나 — 듀얼 트랙
docs/PLAN.md 가 정의하는 구조는 두 트랙이다.
사용자 진입
│
▼
감정/의도 입력 ("우울해" / "재밌는 거" / "묵상")
│
├──[감정/위로]──► 트랙 A : 정적 회복 (Theme 1~7)
│ 일기·잠언·전도서·시편·고통·불안·대인공포
│
└──[탐색/몰입]──► 트랙 B : 서사 게임 (Theme 8~23)
인물 서사를 선택지 기반으로 체험
트랙 B 는 인물마다 구원 카테고리를 하나씩 맡는다. 요셉=경제, 모세=정치, 다윗=외세, 예수=영적, 엘리야=번아웃 회복, 솔로몬=성공 속 허무, 베드로=실패 이후 정체성 재구성, 다니엘=동화 압력 속 경계 설정, 에스더=정체 개시의 위험 부담, 아브라함=지연된 약속, 야곱=내가 가해자인 관계의 회복.
Theme 19~23 은 마태복음 계보의 다섯 여인(룻·라합·다말·우리야의 아내·마리아)을 하나의 축의 다섯 각도로 보는 시리즈다.
2. 런타임 구조 — 컨텍스트마다 육각형
backend/src/main/kotlin/github/lms/lemuel/xr/ 아래는 기술 레이어가 아니라 도메인 컨텍스트로 먼저 쪼개져 있다. 16개다.
xr/
├── ai/ LLM 호출·캐시·grounding
├── analytics/ asset/ auth/ common/ config/ content/
├── emotion/ 감정 분류·기록
├── game/ 세션·씬·선택
├── journey/ outbox/ recovery/
├── safety/ 금칙 토큰·위기 키워드
├── scripture/ 성경 본문
└── tts/ values/
각 컨텍스트 안에서 다시 adapter / application / domain 으로 나뉘고, 아웃바운드 의존은 전부 application/port/out 인터페이스 뒤에 있다. Alistair Cockburn 이 2005년 원문에서 이 패턴의 목적을 이렇게 적었다 — “UI 나 데이터베이스 없이도 동작하도록 애플리케이션을 만들어라. 그래야 자동 회귀 테스트를 돌릴 수 있고, DB 가 없을 때도 동작하고, 사용자 개입 없이 애플리케이션끼리 연결할 수 있다.”
도메인 클래스는 실제로 프레임워크를 모른다. game/domain/GameSession.kt 가 그 계약을 주석으로 못 박아 둔다.
/**
* 게임 세션 도메인.
*
* 한 세션 = 한 캐릭터 1회 플레이. decisions 에 Scene 별 사용자 선택 기록.
* Scene 4 에서 *Scene 3 분배 패턴* 을 참조해 실시간 LLM 프롬프트를 구성한다.
*
* 프레임워크 무관(framework-free) — jakarta/spring 임포트 없음. 영속화는
* `GameSessionPersistenceAdapter` 가 `GameSessionJpaEntity` 와 매핑한다.
*
* 팩토리([start]/[reconstitute])로만 생성한다 — 기본 생성자는 private.
*/
class GameSession private constructor() {
게임 도메인의 전부는 다섯 파일이다: Character.kt · GameDecision.kt · GameSession.kt · Scenario.kt · SceneView.kt. 세션은 사용자의 축("emotional" | "rational" | "spiritual") 선택과 씬별 결정을 들고 있고, 종료 상태가 둘이다 — complete() 와 exit(). 후자는 finalOutcome 에 "safe_exit:{사유}" 를 쓴다. 중도 이탈이 예외 처리가 아니라 애그리거트의 1급 종료 경로라는 뜻이고, 이 설계 판단이 뒤(§10)에서 다시 문제가 된다.
그런데 같은 육각형이라도 속은 다르다. analytics 는 컨트롤러 파일 하나가 전부고, journey 는 컨트롤러와 유즈케이스 둘뿐이다. 포트도 도메인도 없다 — 헥사고날 디렉터리 모양만 갖춘 얇은 컨트롤러다. 그리고 진입점 KDoc 은 아직도 “13 bounded contexts” 라고 적고 있다 — 실제로는 16개다. 아키텍처 문서가 코드보다 늦는 건 이 리포에서도 예외가 아니었다.
2.1 LLM 은 포트 뒤에 있고, 안전 필터는 그 앞뒤에 있다
ai/application/GenerateLlmResponseUseCase.kt 가 이 프로젝트에서 가장 정직한 파일이라고 생각한다. 유즈케이스는 아웃바운드 포트만 안다.
@Service
class GenerateLlmResponseUseCase(
private val cache: LlmCachePort,
private val sidecar: LlmGenerationPort,
private val keyer: CacheKeyComputer,
private val metrics: LlmMetricsPort,
...
)
- 캐시 우선 — hit 이면 사이드카를 부르지 않는다.
llm.cache.hit{purpose}/llm.cache.miss{purpose,provider}를 낸다. - Micrometer 를 모른다 — 메트릭 발행도 포트 뒤로 격리(DIP)돼 있다.
- HTTP 를 모른다 — 실제 호출은
LlmGenerationSidecarAdapter안에만 있다. 프로바이더 API 키는 파이썬 사이드카에만 있고 백엔드는 모른다. - 트랜잭션 격리 — 사이드카 호출은
REQUIRES_NEW. 사이드카 5xx 가 바깥 트랜잭션(DecideSceneUseCase)의 롤백 신호를 오염시키지 않게 한다.
안전 필터(ForbiddenTokenScanner)는 세 자리에 걸린다. 생성 결과를 검사하고(걸리면 한 번 재생성, 두 번째도 걸리면 정적 폴백), 오염된 출력은 캐시에 쓰지 않고, 그리고 캐시 히트도 검사한다. 마지막 항목이 핵심이다 — 필터가 생기기 전에 저장된 항목이 캐시에 남아 있으면, 필터를 나중에 붙여 봐야 그 항목은 계속 나간다.
그런데 이 파일에서 제일 많이 배운 건 아키텍처가 아니라 설정 기본값에 대한 주석이었다.
// 기본값을 두지 않는다 (2026-07-30). 이전에는 여기가 false, application.yml 이 true 라
// 두 곳이 서로 다른 말을 했다. 어느 한쪽으로 맞추면 *조용히* 동작이 바뀌는데,
// AI 생성 on/off 는 조용히 정해질 사안이 아니다. 설정이 없으면 기동을 실패시켜
// 운영자가 명시적으로 정하게 한다. application.yml 이 유일한 출처다.
@Value("\${ai.generation.enabled}") private val generationEnabled: Boolean,
기본값을 지우면 설정이 빠졌을 때 앱이 기동을 실패한다. 편의를 하나 버리고 “두 곳이 다른 말을 하는데 아무도 모르는 상태”를 없앤 것이다. 이 글 전체의 주제가 사실 이 주석 하나에 다 들어 있다.
2.2 “구조화됐다”는 게 “자연어가 없다”는 뜻은 아니다
사이드카(ai/app.py)의 출력 필터에는 사고 기록이 통째로 주석으로 남아 있다.
def apply_output_safety(text: str, json_mode: bool) -> tuple[str, Optional[str]]:
"""
출력측 안전 필터를 *모드와 무관하게* 적용한다.
이전에는 `jsonMode=true` 면 필터를 통째로 건너뛰었다. 주석은 "구조화된 데이터라
skip" 이라고 했지만, 구조화됐다는 건 *문자열 값 안에 자연어가 들어 있다* 는 뜻이지
자연어가 없다는 뜻이 아니다. JSON 값으로 실려 온 자해 묘사는 그대로 사용자에게 갔다.
...
"""
지금은 JSON 모드에서도 필터가 돌고, 차단되면 유효한 JSON 형태의 안전 응답(safetyBlocked: true)을 돌려준다. 호출자가 json.loads 에서 깨지지 않게 하기 위해서다.
이런 종류의 버그는 테스트로 잡히지 않는다. 필터를 건너뛰는 분기에는 애초에 무엇을 검사하겠다는 의도가 있었고, 그 의도가 틀렸기 때문이다. 이걸 잡는 유일한 방법은 “이 초록이 무엇을 말하지 않는가”를 계속 묻는 것뿐이다.
문장 근거 검사(백엔드 ai/grounding/ 컨텍스트)도 같은 규율로 다룬다. 생성된 묵상문의 각 문장을 임베딩해 성경 본문과 코사인 비교하는 게이트인데, KDoc 이 스스로 “섀도우 프로토타입 — 스프링 빈 아님(2026-07-19 결정), 라이브 배선은 별도 phase” 라고 적어 두었다. 임계값(similarity_threshold 0.62)은 골든셋 매니페스트가 “signed_off 픽스처 5개(n=5)에 맞춰 튜닝한 값이다. 임베딩 모델이 바뀌면 재튜닝 대상” 이라고 스스로 밝힌다. 게이트가 있다는 것과 게이트가 막고 있다는 것은 다르다.
2.3 배포와 CI
.github/workflows/ci.yml 은 네 이미지를 GHCR 로 민다.
| 서비스 | 스택 | CI 가 실제로 하는 것 |
|---|---|---|
backend |
Kotlin 2.2.20 · Spring Boot 4.0.4 · JVM 25 | gradle test → 커버리지 게이트 → bootJar → 이미지 |
ai |
Python (FastAPI) | pytest -q → 이미지 (테스트를 빌드 앞에 둠) |
tts |
Python | 이미지만. 테스트 없음 |
frontend |
Next.js 16 · React 19 | npm ci → type-check → 이미지 |
여기도 정직한 주석이 두 개 있다.
- 백엔드 Gradle 은 리포 루트의
../content를 테스트 태스크 입력으로 등록한다. 안 하면 저작 YAML 만 고쳤을 때:test가UP-TO-DATE로 건너뛰고 “안전 게이트가 조용히 낡는다”고 적혀 있다. - 프런트는 타입 체크만 게이트한다.
npm run lint는 Next 16 에서 제거된next lint를 호출해 현재 깨져 있고, Playwright e2e 는 백엔드가 떠 있어야 해서 CI 에서 안 돈다 — 둘 다 워크플로 주석에 그대로 적혀 있다.
그리고 앞서 말한 것: scripts/ 의 12,688줄짜리 검증 스위트는 CI 에서 한 줄도 돌지 않는다. 전부 수동·에이전트 호출이다. 이건 자랑이 아니라 이 글에 적어야 할 한계다.
그런데 글을 올린 뒤 12개 게이트를 한자리에서 전부 돌려 보고 나서, 안 붙어 있는 이유를 알게 됐다.
| 인물 | PASS | FAIL | BLOCKED | rc |
|---|---|---|---|---|
| peter · ruth | 26 | 1 | 0 | 1 |
| abraham · daniel · elijah | 23 | 0 | 4 | 1 |
| esther · jacob · solomon | 23 | 0 | 4 | 1 |
| rahab | 10 | 0 | 17 | 1 |
| david · joseph · moses | 3 | 1 | 23 | 1 |
초록인 게이트가 하나도 없다. rc 규약상 BLOCKED 가 하나만 있어도 1 이니, 지금 이 스위트를 CI 에 그대로 붙이면 첫날부터 빨강이고 사흘 뒤엔 아무도 안 본다.
그래서 “CI 에 안 붙였다”는 게으름이 아니라 초록이 아닌 것을 초록으로 만들 방법이 없어서 미뤄 둔 것에 가깝다. 다만 미뤄 둔 대가는 분명하다 — 이 인프라는 리포 인프라가 아니라 에이전트를 부를 때만 도는 인프라이고, 안 부르면 그날부터 조용히 낡는다.
3. 계획표에 행이 있다는 것과 앱에서 돌아간다는 것은 다르다
여기서부터가 이 프로젝트의 진짜 주제다.
docs/PLAN.md트랙 B 표: 인물 16명content/저작 디렉터리: 11명 (abraham, daniel, david, elijah, esther, jacob, joseph, moses, peter, ruth, solomon)scripts/gates/: 설정 12개- 런타임
Characterenum: 7명
// backend/.../game/domain/Character.kt
JOB("job"), ELIJAH("elijah"), MOSES("moses"), DAVID("david"),
JOSEPH("joseph"), JESUS("jesus"), SOLOMON("solomon");
재밌는 건 두 집합이 포함관계가 아니라는 것이다. 겹치는 건 다섯뿐(david·elijah·joseph·moses·solomon)이다. job 과 jesus 는 런타임에만 있고 저작 디렉터리가 없다. 반대로 abraham·daniel·esther·jacob·peter·ruth 는 문서·콘텐츠·게이트 설정까지 다 있는데 enum 에 없어서 플레이할 수 없다.
기술 스택도 마찬가지다. PLAN.md §6 은 Unity 6 · React Native · LangChain · Pinecone 을 “추천”으로 적어 뒀는데, unity/ 에는 README.md 한 개뿐이고(실제 C# 은 unity-stub/ 5파일), 프런트는 React Native 가 아니라 Next.js 다. 그리고 백엔드 어댑터 KDoc 은 사이드카를 “FastAPI + LangChain” 이라고 소개하는데, ai/ 와 backend/ 전체에서 LangChain 은 0회 등장한다. 사이드카는 프로바이더 SDK 를 직접 부른다.
그래서 PLAN.md 에는 이런 경고가 붙어 있다.
⚠️ 런타임 탑재 현황은 이 표와 다르다. (…) 표에 행이 있다는 것과 앱에서 돌아간다는 것은 다른 사실이다 — 섞어 보고하지 않는다.
혼자 하는 프로젝트에서 자기한테 경고를 적는 게 우스워 보이는데, 실제로 이 경고가 붙기 전까지 문서 두 판이 라합의 현황을 낡은 상태로 들고 있었다.
4. 왜 문서를 기계로 재나
이 프로젝트의 콘텐츠는 성경 본문의 자구를 다룬다. 인물 미션은 실제 절을 자막으로 싣고, 어떤 절을 쓰고 어떤 절을 배제할지를 문서가 선언한다. 그러면 문서에 이런 문장이 잔뜩 생긴다.
- “자막 24행”
- “배제 선언 29항”
- “토큰 31종 ↔ 예문 31개가 1:1”
- “1군 인용 33건”
- “
docs/MVP-RAHAB.md955줄” - “게이트 PASS 10 · FAIL 0 · BLOCKED 17”
공통점은 전부 틀릴 수 있고, 틀려도 아무도 모른다는 것이다. 문서를 고칠 때마다 숫자가 어긋나는데 사람이 읽으면 그냥 읽힌다. 게다가 이 문서들은 상당 부분 LLM 이 함께 쓴다. LLM 은 “24행”이라고 적는 데는 아주 능하고, 실제로 24행인지 세는 데는 아주 서툴다.
그래서 주장의 종류별로 재는 스크립트를 하나씩 만들었다. 최상위 scripts/*.py 기준 19개다.
| 스크립트 | 무엇을 재나 |
|---|---|
newchar_gates.py |
인물 산출물 게이트 27종(G0cfg~G11). 설정은 gates/{인물}.yml |
quote_sweep.py |
따옴표 안 문자열이 성경 자구인지 — 미매칭은 사람 검토 목록으로 |
quote_sweep_delta.py |
그 검토 목록이 직전 판보다 늘었는지 |
verse_lines_check.py |
따옴표 없이 실린 절 자구의 무훼손 |
occurrence_check.py |
정본을 세어서 하는 주장(서수·개수·전수성) |
code_claims_check.py |
문서가 코드에 대해 하는 주장 (backend/·content/ 를 읽음) |
list_count_check.py |
문서가 자기 나열을 세는 주장(“N갈래”·”N키”·합계) |
self_count_check.py |
문서가 자기 안의 문자열을 세는 주장 (자기수정적 주장) |
ac_table_check.py |
수용기준 표의 명령을 실제로 돌려 「실측」 열과 대조 |
review_log_check.py |
검토 대상 전건에 사람 판정 기록이 있는지 |
check_rahab_captions.py |
자막 24행 표기·동의 카드 부착·내레이션 잠금 |
check_rahab_staging.py |
27개 게이트가 아예 안 보는 층 (연출 제약·노출 등급·트리거) |
check_ruth_*.py (2) |
룻 전용 자막 검사 / 생체·시선 필드 부재 검사 |
mutate_*.py (4) |
위 판정기들 자신을 돌연변이로 검사 |
generate_scenes.py |
유일한 비검사 도구 (씬 배경 이미지 생성) |
list_count_check 와 self_count_check 를 굳이 나눈 게 처음엔 과해 보였는데, 둘 다 실제로 빨강을 냈다. 어제만 해도 self_count 가 “「히 11:31」 18회”라고 적힌 문장을 잡았다 — 내가 바로 그 문단에 히 11:31 을 한 번 더 인용해서 19회가 돼 있었다. 자기를 세는 주장은 그 주장을 쓰는 행위 자체가 값을 바꾼다.
5. BLOCKED — 통과도 실패도 아닌 세 번째 값
이 층에서 가장 중요한 설계 결정은 스크립트가 아니라 종료 코드 규약이다.
0 PASS
1 FAIL
2 BLOCKED (판정 불가 — 대상 부재, 판정기 부재, 순회 0회)
≥126 실행 실패
newchar_gates.py 는 결과 뒤에 이 줄을 찍는다.
--- PASS 10 / FAIL 0 / BLOCKED 17 ---
⚠️ BLOCKED 는 PASS 가 아니다 — 판정 불가 상태다. 통과로 보고하지 말 것.
종료 코드는 return 0 if (n_fail == 0 and n_block == 0) else 1 이다. BLOCKED 가 하나라도 있으면 초록이 아니다.
이게 없으면 미착수 항목이 공란으로 남고, 공란은 읽는 사람에게 “문제 없음”으로 보인다. 설계 문서의 표현을 그대로 옮기면 이렇다.
🚨 판정기가 없는 AC 는 초록이 아니라
2다. 미착수를 공란으로 두면 그 줄은 읽는 사람에게 “문제 없음”으로 보인다 — “누락이 통과가 되는 구조”가 수용기준 층위에 나타난 꼴이다.
self_count_check.py 는 한 걸음 더 간다. 주장이 하나도 없으면 PASS 가 아니라 BLOCKED 다. 검사할 게 없다는 건 통과가 아니기 때문이다. ac_table_check.py 는 여러 축을 접을 때 BLOCKED 가 FAIL 보다 우선하도록 폴딩한다. 그리고 check_ruth_captions.py 는 정본 문서를 못 읽으면 판정을 내지 않고 rc 126 으로 죽는다 — “조용한 초록으로 새지 않는다”고 주석에 적혀 있다.
PASS 의 주장 범위도 러너가 직접 좁혀서 출력한다.
⚠️ PASS 의 주장 범위: '선언된 토큰의 정확한 표층형이 대상에 없다' 까지다.
'R2/R3 위반이 없다' 가 아니다.
초록 옆에 그 초록이 말하지 않는 것을 적는 것. 이게 이 프로젝트에서 반복적으로 옳았던 규칙이다.
6. 수용기준 표를 실행으로 대조하기
가장 최근에 만든 도구는 ac_table_check.py 다. 대상은 문서 안의 표 하나 — 수용기준(AC) 표다.
표의 각 행은 셋을 1:1 로 붙인다. (a) 그대로 붙여 돌릴 수 있는 명령 한 줄 · (b) 기대 rc · (c) 이 판 시점의 실측 rc.
| AC | 단언 | 판정기 | 기대 | 실측 |
| AC-3 | 자막 정본의 절 자구 무훼손 | python3 scripts/verse_lines_check.py ... | 1 | 1 |
| AC-6 | 자막 24행의 표기 | python3 scripts/check_rahab_captions.py | 0 | 2 |
ac_table_check.py 는 이 표를 파싱해 명령을 실제로 실행하고 「실측」 열과 대조한다. 열두 축으로 나눠 재는데 몇 개가 특히 유용했다.
t-rows/t-tally— 행 수와 합계 줄이 맞는가, 합계의 이름 목록까지 맞는가.t-none— “판정기 없음” 행이0으로 적혀 있으면 빨강. 미착수를 초록으로 두는 형태를 직접 겨냥한다.t-baseline— 델타 검사의--baseline커밋이 정말 직전 판의 커밋인지. 문서 머리의rev.N을 읽고 git 로그를 걸어가rev.N-1을 선언한 커밋을 찾아 대조한다.
마지막 축은 실제 사고에서 나왔다. “인용이 직전 판보다 늘지 않았다”를 재는 줄이 세 판 동안 세 판 전 커밋을 기준으로 재고 있었다. 명령은 초록이었고 사람은 그걸 읽고 안심했다. t-baseline 은 판 번호를 올리면 기준선 갱신이 기계적으로 강제되도록 만든다.
이 도구는 첫 실행에서 열 자리가 어긋나 있는 것을 찾았다.
7. 판정기를 다시 재기 — 돌연변이
한 겹이 더 필요하다. 판정기가 초록을 냈다고 그 판정기가 뭔가를 재고 있다는 보장은 없다. 아무것도 안 하는 스크립트도 rc=0 을 낸다.
그래서 판정기마다 돌연변이 러너를 붙였다. 원리는 뮤테이션 테스팅과 같다 — Jia 와 Harman 의 서베이가 정리하듯, 결함을 일부러 심고 테스트가 그것을 죽이는지로 테스트의 질을 잰다. 다만 대상이 프로그램이 아니라 문서다.
✅ t-rows FAIL — 합계의 「16건 중」을 15 로 — 표 행 수와 어긋난다
✅ t-cmd(재귀) BLOCKED — AC-15 의 판정기를 이 표의 판정기 자신으로 — 무한 재귀
✅ t-none FAIL — AC-11(판정기 없음)의 실측을 2 → 0 으로
✅ t-baseline FAIL — baseline 을 옛 커밋으로 되돌린다 — 실제로 저지른 결함이다
✅ t-lines FAIL — 파생 문서의 줄 수를 955 → 956 으로
--- 검출 15 / 15 ---
변이 목록의 대부분이 실제로 저질렀던 결함의 재현이다. 새로 상상한 결함이 아니라, 사람이 한 번 잡은 결함을 다시는 사람이 잡지 않아도 되게 만든 것이다.
규율 셋이 붙는다.
① rc 만으로 검증하지 않는다. 어느 축이 깨져도 rc 는 똑같이 1 이다. 그래서 돌연변이 러너는 지목한 축 이름이 FAIL 목록에 실제로 떴는지를 확인한다.
② 기준선이 이미 빨강인 축은 역방향으로 잰다. 루브릭 점수 축은 원래부터 문턱 미달이라 정당하게 빨강이다. 여기에 결함을 심고 “빨강이 났다”로 재면 아무것도 잰 게 아니다. 그래서 이 축만 점수를 문턱 위로 올리는 역변이를 넣고 빨강이 사라지는지로 잰다.
③ 문서를 고쳐서 변이를 통과시키지 않는다. 변이가 안 잡히면 고칠 것은 판정기이거나 기대값이지 문서가 아니다.
자기 참조 하나 — 이 돌연변이 러너는 자기가 검사하는 판정기를 겨냥한 변이는 돌리지 않는다. 무한 재귀가 되기 때문이다. 대신 그 사실 자체를 미완화 항목으로 문서에 적어 뒀다.
8. 그래도 도구가 못 잡는 것
ac_table_check.py 를 만든 판의 결론은 “이제 이 표를 잰다”가 아니라 “이 도구가 못 재는 자리가 넷” 이었다.
- 자기 자신은 못 잰다. 자기를 재는 표에 자기를 적으면 무한 재귀다.
- 「단언」 열은 재지 않는다. 명령이 초록이라는 사실과, 그 명령이 그 단언을 재고 있다는 것은 다르다. 뒤엣것은 사람이 읽어야 한다.
- 정정 기록은 판정 대상 밖이다. 정정 표의 칸에는 “직전 판이 무엇을 틀리게 적고 있었나”가 들어가야 하므로 틀린 옛 값을 인용으로 담는다. 그걸 오늘 값과 대조하면 정직한 기록이 영구히 빨강이 된다. 그래서 뺐고, 그래서 그 칸의 낡은 수치는 도구가 못 본다.
PASS n · FAIL m · BLOCKED k꼴이 아닌 산문 수치는 못 잰다.
넷째가 실제로 물었다. 최근 판에서 사람이 잡은 결함 넷이 전부 이 형태였다.
- “17종” ↔ 실제 18종
- 하류 문서가 선언한 상류 문서의 판 번호가 낡음
- 표를 산문으로 다시 세면서 틀림 — 바로 위 스무 줄에서 기계가 정확히 센 그 표를
docs/PLAN.md가 두 판 동안 라합 현황을 낡은 상태로 들고 있음 (“MVP 문서는 아직 없다” ← 실제로는 955줄로 존재)
마지막 둘은 원인이 같다. ac_table_check.py 는 문서 한 파일만 읽는다. 하류 문서가 상류에 대해 하는 선언은 구조적으로 시야 밖이다.
그래서 도구를 하나 만든 판에서 사람이 잡은 몫이 오히려 늘었다. 열넷 중 열은 도구가, 넷은 사람이 잡았다. 자동화가 늘면 사람 몫이 준다는 직관이 여기서는 맞지 않았다 — 도구는 자기가 보는 창 안쪽을 청소하고, 결함은 창 바깥으로 이동한다.
존재한 적 없는 정본을 인용하던 검사기
가장 좋은(그리고 가장 부끄러운) 사례가 룻 쪽에 있다. 룻 자막 검사기 check_ruth_captions.py 의 헤더 주석은 이렇게 적혀 있다.
배경: 원래 이 파일은 값을 하드코딩해 두고 상류가
SEED-RUTH.md라고 적었는데, 그 파일은 저장소에 존재한 적이 없었다(작업 트리·git 전체 이력 모두). 이 파일이 스스로 경고하던 자기참조 검사가 실제 상태였던 것이다.
검사기가 “나는 상류 정본을 따른다”고 선언했는데 그 정본이 처음부터 없었다. 지금은 실재하는 docs/RUTH-LOCKED-STRINGS.md 를 읽고, 못 읽으면 rc 126 으로 죽는다.
같은 계열로, 룻 게이트 설정 scripts/gates/ruth.yml 은 파일 맨 위에 이렇게 적어 둔다.
⚠️ 이 파일 하나로 “안전하다”가 증명되지 않는다. §8-1 AC 14건의 실행기는 아직 없다. 그 14건은 구조상 만족하도록 저작했을 뿐 기계로 재지 않았다 — 초록을 그 14건의 통과로 읽지 마라.
게이트 결과와 그 결과가 덮지 못하는 14건이 같은 파일에 나란히 있다. 이게 이 리포에서 초록을 다루는 기본 자세다.
정정(2026-08-11). 이 글의 초판은 이 자리에 “게이트를 27/0 으로 통과시키면서” 라고 적었다. 글을 올린 뒤 12개 게이트를 전부 다시 돌려 보니 사실이 아니었다 — 룻은 PASS 26 / FAIL 1 / BLOCKED 0 이고 rc 는 1 이다(FAIL 은 G0e 배제 문자열 과차단). 근거 없이 옮긴 초록이었고, 이 글이 하지 말라고 쓴 바로 그 짓이다. 실측은 §2.3 의 표에 실었다.
9. 완화하지 못한 것을 등재한다
설계 문서에는 DP-R1 부터 DP-R19 까지 열아홉 항목이 있다. 해결한 목록이 아니라 해결하지 못한 목록이다. 대체로 이런 꼴로 끝난다.
DP-R17. 화면에서 정본과 비정본을 가르는 수단이 표지 문자열 하나뿐이고 (…) ⚠️ 현재 완화 없음.
여기에 규칙이 하나 붙는다. 거절 델타가 공집합인 안전 통제는 삭제한다. “이 통제가 실제로 무엇을 거절하는가”를 물었을 때 답이 없으면, 그 통제를 남기지 않고 미완화 노출로 등재한다. 아무것도 거절하지 않는 동의 카드는 보호를 준 게 아니라 보호를 얻었다는 착각만 준다.
10. 그래서 인물의 종류를 바꿨다
이 규율이 실제로 설계를 바꾼 사례가 어제 있었다.
라합(Theme 20)은 선택 분기가 있는 미션으로 설계 중이었고, 착수 게이트인 루브릭 점수가 0.37 이었다(문턱 0.8). 두 채점자가 독립으로 같은 자리를 찍었다 — 거절 델타를 실행할 것이 런타임에 없다. 사용자가 중간에 빠져나갈 손잡이를 문서는 약속하는데 그걸 집행할 씬 산출물이 없었다(content/rahab/ 은 지금도 없다. 그 인물의 seed 문서는 3,322줄이다).
선택지는 둘이었다. 그 자리를 코드로 메우거나, 인물의 종류를 바꾸거나. 결정은 후자였다 — 라합은 미션이 아니라 낭독 트랙이 된다. 축은 신의 섭리와 은혜이고, 야고보서 2:25 의 “행함”은 강조점에서 뺀다.
여기서 문서에 같이 적은 네 가지가 이 글의 결론에 가깝다.
① 줄어든 것은 델타이지 노출이 아니다. 미션이 아니게 되면 거절 델타는 진입 시점의 동의 하나로 줄어든다. 하지만 여호수아 2:15 · 6:25 의 자구와 전멸 배경은 그대로 나온다. 위험이 준 게 아니라 통제 지점이 준 것이다.
② 강조를 빼는 것과 본문을 부인하는 것은 다르다. 야고보서 2:25 를 부정하는 문장은 쓰지 않는다. 부정하면 정경 한 권을 부정하는 것이다. 금지 방향은 부정이지 침묵이 아니다.
③ 이 변경은 안전을 개선하지 않는다. 오히려 §2 에서 애그리거트의 1급 종료 경로로까지 만들어 둔 중도 이탈 손잡이를 없앤다. 그래서 진입 동의와 전체 건너뛰기는 지금보다 약해질 수 없다.
④ 게이트는 초록이 아니라 BLOCKED 가 는다. 기존 게이트는 전부 “미션”을 전제로 만들어졌다. 씬 yml 이 사라지면 판정기는 2 를 낸다. 그래서 규칙을 하나 더 적었다.
🚨 그래서 이 결정 직후의 상태는 초록이 아니라 잴 수 없음이다. 대상이 사라지면 판정기는
2를 내고,2는 통과가 아니다. ⚠️ 현재 완화 없음 — 낭독 트랙용 게이트와 축을 만들기 전까지 이 인물의 어떤 초록도 미션 기준으로 매긴 옛 값이다.
BLOCKED 가 줄어든 것처럼 보이는 표를 만들지 않는다. 설계를 바꿔서 잴 것이 사라졌을 때, 그건 문제가 해결된 게 아니다.
초록이 났는데 나아진 게 아닌 경우
같은 판에서 하나 더 있었다. “인용이 직전 판보다 늘지 않음”을 재는 줄이 세 판 만에 초록이 됐다. 원인은 개선이 아니라 기준선이 옮겨간 것이었다. 직전 판 커밋으로 baseline 을 갱신하니 문제의 인용 하나가 기준선 안쪽으로 들어갔을 뿐이다(33 → 33).
그래서 같은 자리에 이렇게 적었다.
🚨 자를 옮겨서 얻은 초록임을 같은 자리에 적어 둔다 — 다음 판이 이 초록을 “정리됐다”로 읽지 않도록.
11. 현재 상태 — 정직하게
- 라합 수용기준 16건: PASS 9 · FAIL 2 · BLOCKED 5
- 그 표를 재는 도구: PASS 35 / FAIL 1 / BLOCKED 6 (유일한 FAIL 은 정당한 루브릭 미달)
- 그 도구를 재는 돌연변이: 검출 15 / 15
- 낭독 트랙용 게이트: 없음 (다음 판의 일)
- 플레이 가능한 인물 7명 / 저작된 인물 11명 / 설계표 16명
- 검증 스위트 12,688줄 중 CI 에서 도는 것 0줄
- 문장 근거 게이트는 섀도 모드, 임계값은 n=5 로 튜닝
- k8s/Helm 매니페스트는 이 리포에 없다 — 별도 배포 리포에 있다고 문서가 말할 뿐, 이 리포 안에서는 검증 불가
- 앱은 아직 사용자에게 나가지 않았다
결론
3개월간 이 프로젝트가 만든 것 중 재사용 가치가 있는 건 게임 로직이 아니라 다음 다섯 습관이라고 생각한다.
① 통과/실패 두 값을 쓰지 않는다
→ "잴 수 없음(BLOCKED)" 을 일급 상태로. 그리고 BLOCKED 는 초록이 아니다
② 초록 옆에 그 초록이 말하지 않는 것을 적는다
→ PASS 의 주장 범위를 러너가 직접 좁혀서 출력한다
③ 문서가 자기에 대해 하는 수치 주장을 기계가 다시 잰다
→ 세는 주장 / 자기를 세는 주장 / 자구 주장 / 코드에 대한 주장을 각각
④ 판정기를 다시 잰다
→ 결함을 심어 잡히는지. 안 잡히면 고칠 것은 판정기지 문서가 아니다
⑤ 완화하지 못한 것은 완화한 척하지 않고 등재한다
→ 아무것도 거절하지 않는 통제는 삭제하고 노출로 적는다
성경 앱에만 해당하는 이야기가 아니다. LLM 이 코드와 문서를 함께 쓰는 프로젝트라면 어디서든 같은 실패 모드가 생긴다. 모델은 “24행”이라고 쓰는 데 능하고 24행인지 세는 데 서툴다. 사람은 그걸 읽고 넘어간다. 그 사이를 메운 건 더 좋은 프롬프트가 아니라 틀리면 빨강이 나는 명령 한 줄이었다.
가장 짧게 줄이면 이렇다.
검사하지 않은 것을 초록으로 적지 않는다. 그리고 “잴 수 없다”도 하나의 값이다.
참고 자료
- Alistair Cockburn — Hexagonal Architecture (Ports & Adapters), 원문 (2005)
- Alistair Cockburn — Component-plus-Strategy generalizes Ports-and-Adapters (HaT Technical Report 2022.01)
- Yue Jia, Mark Harman — An Analysis and Survey of the Development of Mutation Testing, IEEE TSE 37(5), 2011, DOI 10.1109/TSE.2010.62
- Spring Boot Reference Documentation
- Kotlin Documentation
본문의 모든 수치(커밋 수·줄 수·PASS/FAIL/BLOCKED 집계)는 lemuel-xr 리포에서 직접 실행·측정해 옮긴 값이다. 인용한 코드·주석은 해당 파일의 실제 내용이다. 리포는 비공개라 독자가 직접 재현할 수는 없으며, 그 한계를 밝혀 둔다. 배포(k8s/Helm) 관련 서술은 이 리포의 문서가 주장하는 바일 뿐 코드로 확인한 것이 아니라고 §11 에 따로 적었다.
공개 글에는 credential, token, private IP, 내부 endpoint 를 포함하지 않았다.