정적 사이트에는 런타임이 없습니다. 잘못된 상태가 있어도 예외를 던질 코드가 없고, 그냥 조금 이상한 HTML이 배포될 뿐입니다. 브라우저는 아무 불평도 하지 않습니다.

그래서 어긋난 상태가 오래 남습니다. 이 사이트에서 세 번 반복해서 깨졌던 규칙이 있습니다.

1. 글은 ko/en 두 파일이 항상 한 쌍이어야 한다 2. 두 파일의 발행일이 같아야 한다 3. 글끼리 거는 링크는 항상 과거 방향이어야 한다

셋 다 문서에 적혀 있었습니다. 셋 다 깨졌습니다. 지금은 셋 다 빌드가 실패시킵니다. 그 뒤로는 한 번도 안 깨졌습니다.

1. 세 규칙이 깨지면 무슨 일이 생기나

중요한 건 셋 다 즉시 눈에 띄지 않는다는 점입니다. 이게 이 규칙들을 빌드로 옮겨야 하는 이유입니다.

로케일 한쪽만 있을 때

레이아웃은 모든 글에 hreflang 두 개를 찍습니다. 한국어 글이 영어판 URL을 가리키고, 영어 글이 한국어판을 가리킵니다. 한쪽만 올라가면 살아 있는 쪽이 존재하지 않는 URL을 가리키게 됩니다.

브라우저로 보면 완벽하게 정상입니다. <link> 태그는 눈에 안 보이니까요. 검색엔진만 이상하게 봅니다.

발행일이 어긋날 때

이건 더 고약합니다. 예약 게시는 각 파일의 date로 판정하니, 한국어 8월 30일 · 영어 9월 1일이면 이틀 동안 위와 똑같은 상태가 됩니다. 파일은 두 개 다 있는데도요.

커밋 시점에는 아무 문제가 없어 보이고, 이틀 뒤 저절로 나으며, 그 사이에는 아무도 모릅니다. 사람이 잡을 수 있는 종류의 오류가 아닙니다.

링크가 미래를 향할 때

8월 30일 글에서 9월 4일 글로 링크를 걸면 닷새 동안 404입니다. 링크를 쓸 때는 대상 파일이 저장소에 멀쩡히 있어서 검증할 것도 없어 보입니다.

공통점: 저장소는 정상인데 배포된 사이트만 어긋납니다.

코드 리뷰로 잡히지 않습니다. 리뷰어가 보는 건 파일이고, 문제는 "그 파일이 언제 산출물에 들어가는가"에서 생기기 때문입니다.

2. 빌드 시작 시점에 한 번에 검사한다

Eleventy에는 eleventy.before 이벤트가 있습니다. 템플릿을 처리하기 전에 한 번 돕니다. 여기서 소스 파일을 직접 읽어 검사합니다.

글마다 검사하지 않고 빌드당 한 번 도는 게 중요합니다. 대칭이나 링크 방향 같은 규칙은 개별 글이 아니라 전체 집합에 대한 성질이라, 글 하나만 봐서는 판정할 수 없습니다.

eleventyConfig.on("eleventy.before", () => { const slugs = (lang) => readdirSync(`src/${lang}/posts`) .filter((f) => f.endsWith(".njk") && f !== "index.njk") .map((f) => f.replace(/\.njk$/, "")) .sort(); const ko = slugs("ko"); const en = slugs("en"); const missingEn = ko.filter((s) => !en.includes(s)); const missingKo = en.filter((s) => !ko.includes(s)); if (missingEn.length || missingKo.length) { throw new Error([ "ko/en 포스트가 대칭이 아닙니다.", ...missingEn.map((s) => ` en 누락: ${s}`), ...missingKo.map((s) => ` ko 누락: ${s}`), ].join("\n")); } // ... 아래 검사가 이어진다 });

파일 목록만 비교하면 끝입니다. 10줄짜리 검사가 문서 한 페이지보다 강력합니다.

3. 검사를 위해 파일을 한 번만 읽는다

나머지 두 검사는 파일 내용이 필요합니다. 각 검사가 따로 읽으면 같은 파일을 두 번 읽게 되니, 앞에서 한 번에 읽어 재사용합니다.

const posts = new Map(); for (const lang of ["ko", "en"]) { for (const slug of ko) { // 대칭은 이미 확인됨 const raw = readFileSync(`src/${lang}/posts/${slug}.njk`, "utf8"); posts.set(`${lang}/${slug}`, { lang, raw, date: raw.match(/^date:\s*(\S+)\s*$/m)?.[1] ?? null, }); } }

여기서 date정규식으로 긁어 문자열로 둡니다. YAML 파서를 붙이지 않았고, Date 객체로도 바꾸지 않았습니다.

날짜가 YYYY-MM-DD 형식이라 사전순 비교가 곧 날짜 비교이기 때문입니다. 시간대도, 파싱 실패도, 의존성도 없습니다. 문자열이 다르면 날짜가 다르고, 문자열이 크면 날짜가 늦습니다.

검증 코드는 검증 대상보다 단순해야 합니다.

가드가 복잡해지면 가드 자체에 버그가 생깁니다. 그리고 가드의 버그는 아무도 검사해주지 않습니다.

4. 날짜 일치와 링크 방향

읽어둔 posts 맵으로 나머지 두 검사를 합니다.

// 발행일 일치 const mismatched = ko .map((slug) => ({ slug, ko: posts.get(`ko/${slug}`).date, en: posts.get(`en/${slug}`).date, })) .filter(({ ko: k, en: e }) => k !== e); // 링크 방향 — 본문의 글 링크를 전부 긁는다 const dead = []; for (const [key, { lang, raw, date }] of posts) { const pattern = new RegExp(`href="/${lang}/posts/([a-z0-9-]+)\\.html"`, "g"); for (const [, target] of raw.matchAll(pattern)) { const linked = posts.get(`${lang}/${target}`); if (!linked) { dead.push(` ${key} → ${target}: 그런 글이 없습니다`); } else if (linked.date > date) { dead.push(` ${key}(${date}) → ${target}(${linked.date}): 링크 대상이 더 늦게 게시됩니다`); } } }

링크 검사는 두 가지를 한 번에 봅니다. 대상이 존재하는가(오타·삭제된 글)와 대상이 나보다 먼저 게시되는가입니다. 뒤쪽이 예약 게시 때문에 필요한 검사입니다.

정규식으로 링크를 긁는 게 조잡해 보일 수 있는데, 여기서는 충분합니다. 검사 범위가 같은 로케일의 글 링크로 한정되어 있고 그 형식은 고정이기 때문입니다. HTML 파서를 붙일 만한 이유가 없습니다.

5. 실패 메시지가 곧 수정 지시가 되게

가드를 만들 때 코드보다 오래 고민한 게 메시지였습니다.

나쁨 — 무엇을 하라는 건지 알 수 없다 Error: Invalid post configuration 좋음 — 읽는 즉시 다음 행동이 정해진다 Error: ko/en 글의 date 가 다릅니다. 예약 게시 시점이 어긋납니다. zone2-cardio-heart-rate-guide: ko=2026-08-27 en=2026-08-29

좋은 쪽은 무엇이 · 어디서 · 왜 문제인지를 한 번에 줍니다. 특히 "예약 게시 시점이 어긋납니다"라는 한 문장이 중요합니다. 날짜가 왜 같아야 하는지 모르는 사람도 이 메시지만 보면 이해합니다.

그리고 위반을 모아서 한 번에 보고합니다. 첫 번째에서 멈추면 고치고 다시 돌리고를 반복하게 됩니다. 특히 AI 에이전트가 작업할 때 차이가 큽니다 — 실패한 빌드 한 번에서 남은 일을 전부 파악할 수 있습니다.

6. 무엇을 가드로 만들지 판단하는 기준

모든 규칙을 빌드에 넣을 수는 없습니다. 셋을 고른 기준은 이랬습니다.

  • 조용히 깨지는가. 배포 후에도 브라우저에서 정상으로 보이는 오류 — 최우선입니다.
  • 기계적으로 판정 가능한가. 파일 목록과 날짜 문자열 비교면 끝나는 것들입니다.
  • 실제로 깨진 적이 있는가. 가상의 위험이 아니라 이미 밟은 것만 넣습니다.

반대로 넣지 않은 것도 분명합니다. "영문판이 기계 번역처럼 읽히지 않아야 한다"는 지켜야 할 규칙이지만 검사할 방법이 없습니다. 이런 건 문서에 남기고 사람이 봅니다.

애매한 경우엔 깨졌을 때 얼마나 늦게 알게 되는가로 판단합니다. 즉시 알게 되는 오류는 가드가 없어도 됩니다. 몇 주 뒤 검색 콘솔에서나 알게 되는 오류는 가드가 필요합니다.

7. 남은 구멍은 남아 있다고 적는다

가드를 넣으면 "빌드가 통과했으니 괜찮다"는 착각이 생깁니다. 이게 가드의 부작용입니다.

그래서 규칙 문서에 빌드가 못 잡는 것을 따로 나열했습니다.

빌드가 잡아주는 것: 로케일 대칭, 날짜 일치, 글 사이 링크, 템플릿 문법 오류 빌드가 잡아주지 않는 것 — 직접 확인한다: 브라우저에서 레이아웃과 링크가 깨지지 않는지 staticPages.js 가 실제 페이지 목록과 일치하는지 통과 복사되는 .html 의 상대 경로가 디렉터리 깊이에 맞는지 딥링크 폴백 동작 (실기기 필요)

두 번째 항목이 실제로 자주 걸립니다. sitemap.xml은 글은 컬렉션에서 자동으로 채우지만 글이 아닌 페이지는 손으로 관리하는 목록에서 가져옵니다. 페이지를 옮기면 그 목록을 같이 고쳐야 하는데, 빌드는 모릅니다. 이것도 언젠가 가드로 옮길 후보입니다.

8. 정리

전부 합쳐 60줄 남짓입니다. 그 60줄이 규칙 문서 한 페이지보다 확실하게 동작했습니다.

  • 조용히 깨지는 규칙부터 옮긴다. 시끄럽게 깨지는 건 이미 잡힙니다.
  • 전체 집합에 대한 성질은 빌드 시작 시점에 한 번에 본다.
  • 검증 코드는 최대한 단순하게. 날짜를 문자열로 두면 파서도 시간대도 필요 없습니다.
  • 메시지가 수정 지시가 되게, 위반은 모아서 한 번에.
  • 가드가 못 잡는 것을 명시한다. 녹색 빌드가 전부 확인했다는 뜻이 되면 안 됩니다.

규칙 문서에서 같은 문장을 세 번째 강조하고 있다면, 그건 그 규칙이 문서에 있으면 안 된다는 신호입니다. 지켜야 할 것은 지키게 만들고, 지킬 수 없는 것만 적으세요.