learncave는 각주 번호가 출처 목록을 벗어나면 빌드를 멈춥니다: 출처 등급과 각주를 배포 전에 검사하는 방법
학습 코스 아카이브 learncave는 출처마다 A·B·C 등급을 매기고 본문 각주를 번호로 겁니다. 출처를 지우고 각주만 남겨도 Astro 빌드는 그대로 통과했습니다. 검사 스크립트를 빌드 앞에 세워 그런 각주가 배포되지 않게 막은 과정과 실제 실패 출력을 적었습니다.
목차
learncave는 제가 주제 하나를 리서치해서 출처 신뢰도 등급, 쉬운 설명, 퀴즈로 묶어 두는 한국어 학습 코스 아카이브입니다. Astro 7과 MDX로 만든 정적 사이트이고, 챕터 하나가 MDX 파일 하나입니다. 같은 아카이브에 붙인 계산 도구는 learncave 적금 계산기 글에 따로 적었습니다.
코스 초안은 Claude Code의 learncave 스킬이 쓰고, 공개 여부는 제가 검수한 뒤에 정합니다. 에이전트가 한 코스에 각주를 수십 개씩 다는 구조라서, 틀린 각주는 사람보다 기계가 먼저 잡아야 한다고 봤습니다.
챕터의 사실 문장 끝에는 <Cite n={2} /> 같은 각주가 붙습니다. 숫자는 그 챕터 프론트매터 sources 목록의 순번입니다. 그런데 이 번호가 틀려도 Astro 빌드는 아무 말 없이 통과합니다.
그래서 learncave의 bun run build는 Astro보다 검사 스크립트 scripts/verify.mjs를 먼저 돌립니다.
이 글은 리서치에서 매긴 등급이 각주로 이어지고, 그 각주가 빌드에서 검사받는 흐름을 따라갑니다. 실패 출력은 오늘(2026년 10월 17일) 레포 사본에서 각주를 일부러 틀리게 바꾸고 돌려서 얻었습니다.
출처는 A·B·C 세 등급으로 적습니다#
리서치는 주제를 4~6갈래로 나눠 따로 조사하는 데서 시작합니다. 갈래는 정의와 원리, 역사와 맥락, 현재 상태, 반대 의견, 흔한 오해, 실전 감각 가운데서 고릅니다.
반대 의견과 흔한 오해는 빼지 않습니다. 이 둘이 빠지면 학습 자료가 홍보 문구처럼 읽힙니다.
모은 자료에는 그 자리에서 등급을 매깁니다. 나중에 매기면 어느 자료에서 무엇을 읽었는지 기억이 섞입니다.
- A, 1차 자료: 논문, 표준 문서, 정부·규제기관 발행물, 프로젝트 공식 문서
- B, 신뢰할 만한 2차 자료: 출처를 다는 정리 문서(위키백과 등), 편집 책임이 있는 매체의 심층 기사
- C, 참고 수준: 개인 블로그, 커뮤니티 글, 파는 사람이 쓴 비교 글
등급을 쓰는 규칙도 정해 두었습니다. 핵심 주장은 A나 B로 받치고, C만으로 받쳐지는 주장은 본문에서 뺍니다. C 자료는 사람들이 무엇을 틀리게 아는지 보여 주는 증거로만 씁니다.
등급은 챕터 프론트매터에 출처마다 적습니다. 복리 코스 첫 챕터의 둘째 출처는 이렇습니다.
- grade: B
title: Rule of 72
publisher: Wikipedia
url: https://en.wikipedia.org/wiki/Rule_of_72
note: 공식의 유도 과정과 오차 범위를 정리한 문서. 수식 자체는 검증이 쉬우나 2차 자료이므로 B로 둡니다.오늘 기준으로 챕터 15개에 출처가 68개 있습니다. A가 49개, B가 18개, C가 1개이고, 본문 각주는 150개입니다.
C 하나는 인덱스 투자 코스에 있습니다. 한 해 성과만으로 액티브 ETF가 시장을 이긴 것처럼 읽히는 기사 제목을 예로 들 때만 인용했습니다. 규칙대로라면 C가 설 수 있는 곳은 거기뿐입니다.
출처를 지워도 빌드는 통과했습니다#
본문 각주는 컴포넌트 하나가 그립니다.
interface Props { n: number }
const { n } = Astro.props;
---
<a class="cave-cite" href={`#src-${n}`} data-cite={n} aria-label={`출처 ${n}번`}>[{n}]</a>이 컴포넌트는 n이 출처 목록 안에 있는지 모릅니다. 받은 숫자로 [n]을 그리고 #src-n으로 링크를 걸 뿐입니다. 출처 목록은 항목마다 id="src-1", id="src-2"처럼 순번대로 앵커를 답니다.
learncave 스킬 문서는 흔한 실수를 이렇게 적어 두었습니다. "출처를 지우고 각주를 그대로 두는 실수가 흔하다." 그 실수를 사본에서 그대로 재현했습니다.
복리 코스 첫 챕터에서 둘째 출처(위의 Rule of 72)를 지우고, 본문의 <Cite n={2} />는 남겼습니다. 그리고 검사 없이 npx astro build만 돌렸습니다.
결과는 exit 0이었습니다. 만들어진 페이지에는 href="#src-2"로 가는 [2]가 있었고, id="src-2"는 한 번도 나오지 않았습니다. 독자가 각주를 누르면 아무 데도 가지 않고, 그 문장을 받치던 출처는 화면에서 사라집니다.
첫 커밋(045643b, 2026년 10월 11일 22시 13분)의 빌드 명령이 바로 이 상태였습니다.
"build": "astro build",
"verify": "node scripts/verify.mjs",검사 스크립트는 처음부터 있었지만 따로 불러야 돌았습니다. 22분 뒤 커밋 9b4b4fe가 빌드 명령 앞에 검사를 붙였습니다.
- "build": "astro build",
+ "build": "node scripts/verify.mjs && astro build",지금은 검색 색인 단계가 끝에 붙어 node scripts/verify.mjs && astro build && pagefind --site dist입니다. 레포의 CLAUDE.md 불변 조건에도 이 순서를 적어 두었습니다. "build는 scripts/verify.mjs를 먼저 돈다. 이 순서를 바꾸면 잘못된 각주가 배포된다."
검사를 앞에 세우면 빌드가 시작되지 않습니다#
같은 사본, 같은 상태에서 bun run build를 돌리면 이렇게 나옵니다.
$ node scripts/verify.mjs && astro build && pagefind --site dist
출처 링크 56개는 --links 로 확인합니다.
실패 src/content/courses/compounding/01-time-beats-money.mdx: <Cite n={2} /> 가 sources 1개의 범위를 벗어납니다.
1건 실패, 0건 경고.
error: script "build" exited with code 1verify.mjs가 exit 1로 끝나면 && 뒤의 astro build는 시작되지 않습니다. dist/ 폴더도 생기지 않았습니다.
배포 이미지를 만드는 Dockerfile도 bun run check && bun run build를 돌린 뒤 dist만 nginx 이미지에 담습니다. 그래서 잘못된 각주가 든 챕터는 배포 이미지까지 가지 못합니다.
검사의 중심은 출처 개수를 세고 각주 번호를 그 범위와 비교하는 몇 줄입니다.
const sourceCount = (frontmatter.match(/^\s*-\s+grade:/gm) ?? []).length;
// ...
if (n < 1 || n > sourceCount) {
errors.push(`${rel}: ${tag} 가 sources ${sourceCount}개의 범위를 벗어납니다.`);
}YAML 파서를 쓰지 않고 - grade:로 시작하는 줄을 셉니다. 출처 항목은 반드시 grade가 첫 키라는 약속에 기대는 셈입니다. 그래서 그 약속도 따로 검사합니다.
둘째 출처의 키 순서를 title, grade로 바꿨더니 이렇게 나왔습니다.
실패 src/content/courses/compounding/01-time-beats-money.mdx: sources 항목 2개 중 '- grade:'로 시작하는 항목이 1개입니다. 항목마다 grade를 첫 키로 둡니다.
실패 src/content/courses/compounding/01-time-beats-money.mdx: <Cite n={2} /> 가 sources 1개의 범위를 벗어납니다.YAML로는 멀쩡한 파일이고 출처도 실제로 2개입니다. 그런데 줄을 세는 방식은 1개로 봅니다.
키 순서 검사가 없던 옛 버전으로 같은 파일을 돌리면 둘째 줄만 나옵니다. 출처가 2개 있는데 1개의 범위를 벗어난다고 하니, 원인을 찾기 어려운 메시지입니다. 지금은 진짜 원인이 첫 줄에 먼저 나옵니다.
따옴표 하나로 검사를 빠져나갈 수 있었습니다#
처음 검사는 각주를 정규식 하나로 읽었습니다.
for (const m of body.matchAll(/<Cite\s+n=\{(\d+)\}/g)) {이 정규식은 n={3} 표기만 읽습니다. MDX에서는 <Cite n="3" />도 같은 컴포넌트를 부르는데, 이 표기는 검사에 아예 보이지 않습니다.
출처가 2개인 챕터에서 둘째 각주를 <Cite n="3" />로 바꾸고, 옛 버전(a32f2c9 직전)을 돌렸습니다.
경고 src/content/courses/compounding/01-time-beats-money.mdx: 2번 출처를 본문에서 인용하지 않았습니다.
통과. 챕터 15개, 경고 1건.없는 3번 출처를 가리키는 각주가 경고 한 줄만 남기고 통과했습니다. 같은 파일로 astro build만 돌려도 exit 0이었고, 페이지에는 #src-3으로 가는 [3]이 그려졌습니다.
경고 내용도 엉뚱합니다. 범위를 벗어난 각주가 아니라 2번 출처를 아무도 인용하지 않는다고만 말합니다.
2026년 10월 13일 커밋 a32f2c9가 각주를 읽는 방식을 바꿨습니다. <Cite로 시작하는 태그를 먼저 모두 모으고, 그 안에서 n을 네 가지 표기로 읽습니다. 숫자를 못 읽으면 그것도 실패입니다.
// n={9}, n={ 9 }, n="9", n='9' 를 모두 읽는다. 숫자를 읽지 못한 <Cite>도 실패로 본다.
for (const [tag] of body.matchAll(/<Cite(?=[\s/>])[^>]*>/g)) {
const m = tag.match(/\bn\s*=\s*(?:\{\s*(\d+)\s*\}|"(\d+)"|'(\d+)')/);
if (!m) {
errors.push(`${rel}: ${tag} 에서 출처 번호 n을 읽지 못했습니다.`);
continue;
}같은 파일에서 지금 버전은 이렇게 멈춥니다.
실패 src/content/courses/compounding/01-time-beats-money.mdx: <Cite n="3" /> 가 sources 2개의 범위를 벗어납니다.n 대신 id={2}라고 잘못 쓴 각주는 <Cite id={2} /> 에서 출처 번호 n을 읽지 못했습니다.로 막힙니다. 키 순서 검사도 이 커밋에서 같이 들어왔습니다.
다만 이 커밋 메시지에는 따옴표 각주가 실제로 들어와서 고쳤다는 기록이 없습니다. git log 전체에서 콘텐츠의 각주 번호를 고친 커밋도 찾지 못했습니다. 이 검사가 실제 사고를 잡은 기록은 아직 없고, 지금까지 한 일은 구멍을 미리 메운 것입니다.
등급은 스키마가 막고, 판단은 사람이 봅니다#
verify.mjs는 grade: 줄을 셀 뿐 값은 보지 않습니다. 값은 Astro 콘텐츠 컬렉션의 zod 스키마가 봅니다. 이 스키마는 필수 항목과 퀴즈 정답 범위도 함께 봅니다.
const source = z.object({
grade: z.enum(['A', 'B', 'C']),
// ...
});등급을 S로 바꾸면 verify.mjs는 통과하고, 바로 다음 단계인 astro build에서 멈춥니다.
[InvalidContentEntryDataError] chapters → compounding/01-time-beats-money data does not match collection schema.
sources.1.grade: Invalid option: expected one of "A"|"B"|"C"여기까지가 코드가 막는 범위입니다. 등급이 맞게 매겨졌는지는 코드가 모릅니다.
인덱스 투자 코스에 그런 예가 하나 있습니다. 미국 대형주 액티브 펀드의 79%가 2025년에 S&P 500보다 낮은 수익을 냈다는 수치는 원래 S&P 다우존스 지수의 SPIVA 보고서에서 나옵니다.
그런데 출처 메모에는 "원문 PDF는 자동 접속을 막아 이 기사를 출처로 두었습니다"라고 적혀 있습니다. 실제로 인용한 것은 금융 매체 InvestmentNews의 기사이고 등급은 B입니다.
리서치 규칙은 2차 자료가 옮긴 수치를 원문과 대조하라고 합니다. 메모대로라면 이 수치는 원문과 대조하지 못한 채 들어왔습니다. 검사 스크립트 입장에서는 grade: B가 적힌 정상 출처입니다.
그래서 마지막 문은 사람이 지킵니다. AI가 만든 코스는 _course.yaml에 draft: true를 달고 들어오고, 운영 빌드에서 통째로 빠집니다. 지금 코스 다섯 개 가운데 공개된 것은 복리 하나이고, 인덱스 투자를 포함한 넷은 제 검수를 기다립니다.
아직 빌드에 넣지 않은 것#
출처 URL이 살아 있는지는 빌드에서 보지 않습니다. 네트워크에 기대는 검사라서 bun run verify:links로 따로 돌립니다. 오늘 사본에서 돌렸더니 출처 68개 가운데 겹치는 주소를 뺀 56개가 모두 응답했고, 21초쯤 걸렸습니다.
인용하지 않은 출처는 실패가 아니라 경고라서 빌드는 통과합니다. CLAUDE.md에는 경고 0건을 목표로 한다고만 적혀 있고, 오늘 기준 경고는 0건입니다.
각주 번호가 범위 안에 있어도, 그 출처가 정말 그 문장을 받치는지는 검사하지 않습니다. <Cite n={1} />을 <Cite n={2} />로 잘못 달면 둘 다 범위 안이라 통과합니다. 이건 미결입니다. 지금은 draft 검수에서 제가 읽고 잡는 수밖에 없습니다.
문서 하나는 코드를 못 따라왔습니다. learncave 스킬의 "자주 걸리는 함정"에는 아직 "빌드는 통과하지만 bun run verify가 잡는다"고 적혀 있습니다. 9b4b4fe 이후로는 빌드 자체가 멈추니 이제 틀린 문장입니다. 스킬 문서를 고치는 일이 남았습니다.