콘텐츠 발행도 릴리스처럼: 정확한 PR HEAD에서만 통과시키는 법
한·영 MDX 글은 텍스트 파일 두 개 이상의 문제다. 로케일 계약, 로컬 에셋, CI 검증, 변경 불가능한 PR HEAD를 발행 전에 명시하는 릴리스형 운영 흐름을 정리한다.
기술 블로그 글을 발행하는 일은 글쓰기 작업처럼 보입니다. 하지만 정적으로 생성되는 다국어 사이트에서는 하나의 릴리스입니다.
글은 로케일별 파일 한 쌍으로 존재합니다. 메타데이터는 목록과 페이지 메타데이터를 만듭니다. MDX는 애플리케이션의 일부로 컴파일됩니다. 테스트와 콘텐츠 검증은 특정 커밋에서 실행됩니다. 그 뒤 배포 시스템이 커밋을 공개 URL로 바꿉니다. 한·영 파일이 같은 릴리스를 설명하지 않거나, 이미 바뀐 이전 커밋의 체크가 초록색이라는 이유만으로는 충분하지 않습니다.
이 저장소는 이 계약을 점점 더 명시적인 정책으로 바꿔왔습니다. 이 글은 특정 블로그가 발행을 영원히 해결했다는 주장이나 트래픽·성능에 대한 보고서가 아닙니다. 증거가 부족할 때 자동 콘텐츠 릴리스를 fail closed, 즉 통과시키지 않도록 만드는 실무 패턴을 정리한 글입니다.
다국어 글에는 실패 지점이 하나보다 많다
독자가 보는 본문은 마지막 계층일 뿐입니다. 그 전에 여러 계약이 동시에 성립해야 합니다.
핵심 설계는 각 계약을 실행 가능한 형태로 만드는 것입니다. 체크리스트는 사람이 무엇을 볼지 알려주지만, validator는 아무도 지켜보지 않는 순간에도 릴리스를 멈출 수 있습니다.
표의 실패 지점은 저장소 정책이 예방하도록 설계된 경우들입니다. 이를 실제 프로덕션 장애 목록처럼 표현하려면 별도의 장애 기록이 필요합니다.
먼저 콘텐츠 계약을 실행 가능하게 만든다
이 저장소의 새 자동 발행 글은 공통 slug와 두 개의 로케일 접미사로 식별됩니다.
src/content/posts/<slug>.ko.mdx
src/content/posts/<slug>.en.mdx
두 메타데이터 export의 제목과 excerpt가 같을 필요는 없습니다. 대신 같은 릴리스 정체성을 가져야 합니다. 최소한의 쌍은 다음과 같은 모양입니다.
export const metadata = {
title: "이 언어에 맞게 자연스럽게 쓴 제목",
excerpt: "이 언어로 작성한 짧은 설명입니다.",
category: "Engineering",
date: "2026-08-10",
readTime: "9 min",
lang: "ko",
keywords: ["mdx", "릴리스 엔지니어링"],
hidden: false,
};
영어 파일은 lang: "en"과 자연스러운 영어 문장을 사용하되 date, category, hidden은 맞춥니다. keywords는 양쪽 모두 배열입니다. 형식을 일부러 단순하게 유지하는 이유는 임의의 MDX나 JavaScript를 실행하지 않아도 parser가 판단할 수 있게 하기 위해서입니다.
자동화 정책은 slug별로 콘텐츠를 묶고, 새로운 자동 발행 대상에 대해 다음 조건을 거부합니다.
ko또는en파일이 없음- 같은 로케일 파일이 중복됨
- 날짜·카테고리·공개 상태가 서로 다름
- keywords가 없거나 비어 있거나 배열이 아님
정책에는 명시적인 시작일과 명시적인 레거시 예외 목록이 있습니다. 모든 예전 파일에 조용히 예외를 적용하는 것보다 안전한 방식입니다. 기존 콘텐츠는 별도로 마이그레이션하고, 새 콘텐츠에는 예측 가능한 계약을 적용할 수 있습니다. 구현은 저장소의 블로그 자동화 정책에서 확인할 수 있습니다.
다국어 글쓰기에는 사람의 기준도 필요합니다. validator는 두 파일이 존재하고 메타데이터가 일치하는지는 확인할 수 있습니다. 하지만 한국어 문장이 자연스러운지, 영어 버전이 근거 없는 주장을 추가하지 않았는지, 두 글이 같은 논지를 유지하는지는 판단하지 못합니다. 이 부분은 여전히 리뷰의 몫입니다.
로컬 에셋을 약속이 아니라 경로로 다룬다
메타데이터만 검사하는 콘텐츠 validator에는 흔한 구멍이 남습니다. 글은 컴파일되지만 배포 산출물에 없는 이미지를 가리킬 수 있습니다.
이 저장소의 로컬 에셋 정책은 MDX에 렌더링되는 /images/... 경로를 검사합니다. 먼저 fenced code와 inline code 예시를 마스킹합니다. 문서 안에서 예시로 보여준 경로가 실제 런타임 의존성이 되지 않게 하기 위해서입니다. 그 다음 경로를 public/ 아래로 해석하고 실제 파일인지 확인합니다.
핵심은 경로가 디렉터리 안에 있는지 확인하는 부분입니다.
const resolvedPath = path.resolve(normalizedPublicDir, `.${assetPath}`);
const isInsidePublicDir =
resolvedPath === normalizedPublicDir ||
resolvedPath.startsWith(`${normalizedPublicDir}${path.sep}`);
const exists =
isInsidePublicDir && existsSync(resolvedPath) && statSync(resolvedPath).isFile();
이 검사는 두 가지를 동시에 수행합니다. 없는 이미지를 발견하고, /images/../../private-file 같은 경로가 public 디렉터리 바깥으로 빠져나가 유효한 public 에셋으로 취급되지 않게 합니다. 전체 구현은 로컬 에셋 정책에 있습니다.
이런 사이트에서는 기본값을 단순하게 두는 편이 안전합니다. 실제 로컬 에셋이거나 명시적으로 검토한 외부 리소스가 아니라면 이미지를 추가하지 않습니다. 아무도 검증할 수 없는 장식용 의존성보다는 이미지 없는 글이 낫습니다.
로컬 검증과 CI의 계약을 같게 만든다
로컬 명령은 Pull Request 게이트의 약한 근사치가 되어서는 안 됩니다. 이 저장소의 package.json은 여러 검사를 하나의 경로로 합칩니다.
{
"check": "npm test && npm run content:verify && npm run lint && npm run typecheck",
"build": "npm run check && next build && npm run verify:static-home",
"verify": "npm run build && npm run security:audit"
}
따라서 릴리스 순서가 명확합니다.
- 단위 테스트와 정책 테스트를 실행합니다.
- 메타데이터, 다국어 자동화 규칙, 로컬 에셋을 검증합니다.
- ESLint와 파일을 생성하지 않는 TypeScript 검사를 실행합니다.
- 정적 라우트를 빌드합니다.
- static-home assertion을 실행합니다.
- 설정된 심각도 기준으로 프로덕션 의존성을 audit합니다.
워크플로는 lockfile을 기준으로 npm ci를 실행한 뒤 npm run verify를 실행합니다. 자세한 내용은 저장소의 quality workflow에서 확인할 수 있습니다.
명령 이름보다 중요한 것은 이 조합입니다. npm run build에서 콘텐츠 validator가 빠지면 로컬 빌드가 성공해도 콘텐츠 계약을 어긴 릴리스가 됩니다. CI가 개발자가 실행하는 명령과 다른 명령을 실행하면 팀에는 "준비 완료"의 정의가 두 개 생깁니다.
GitHub의 워크플로 문서는 이벤트 기반 실행과 concurrency 제어를 설명합니다. 이 기본 기능들은 유용하지만, 보고 있는 체크가 현재 PR HEAD에 붙어 있다는 사실까지 보장하지는 않습니다. 정체성은 별도로 확인해야 합니다.
초록색 체크도 오래된 것일 수 있다
체크가 실행되는 동안 Pull Request는 바뀔 수 있습니다. 빌드가 시작된 뒤 다른 커밋이 push될 수 있기 때문입니다. 이때 "PR이 통과했다"는 일반적인 관찰은 실제 merge하려는 트리와 다른 커밋을 가리킬 수 있습니다.
이 저장소의 준비성 가드는 PR HEAD를 최종 판단의 변경 불가능한 입력처럼 다룹니다. 흐름은 다음과 같습니다.
HEAD_SHA="$(gh pr view "$PR_NUMBER" --json headRefOid --jq '.headRefOid')"
gh pr checks "$PR_NUMBER" --watch --fail-fast >/dev/null
SUCCESSFUL_GATE_COUNT="$(
gh api "repos/$REPOSITORY/commits/$HEAD_SHA/check-runs" \
--jq '[.check_runs[] | select(.name == "Verify production build" and .status == "completed" and .conclusion == "success")] | length'
)"
if [[ "$SUCCESSFUL_GATE_COUNT" -lt 1 ]]; then
printf 'Required check Verify production build is not successful for %s.\n' "$HEAD_SHA" >&2
exit 1
fi
CURRENT_HEAD_SHA="$(gh pr view "$PR_NUMBER" --json headRefOid --jq '.headRefOid')"
[[ "$CURRENT_HEAD_SHA" == "$HEAD_SHA" ]]
여기에는 의도적인 세부 사항이 네 가지 있습니다.
- 기다리기 전에 HEAD SHA를 읽습니다.
- PR에 현재 연결된 체크가 끝날 때까지 기다립니다.
- Checks API에 정확한 커밋을 질의하고 이름이 정해진 프로덕션 게이트를 요구합니다.
- 검증 중 HEAD가 바뀌었는지 다시 읽고, 바뀌었으면 실패합니다.
이 가드는 PR이 열려 있고, draft가 아니며, develop을 대상으로 하고, merge 가능한 상태인지도 확인합니다. 전체 스크립트는 PR readiness guard입니다.
정확한 check run을 조회하는 방법은 GitHub Checks API 문서를 기준으로 삼았습니다. 실무적인 차이는 이렇습니다. Pull Request에 붙은 상태는 사람이 전체 상황을 보는 데 유용하지만, 최종 merge 조건에는 커밋 정체성이 함께 들어가야 합니다.
리뷰한 HEAD만 merge한다
최종 merge 명령도 브랜치가 그대로였다고 가정하지 말고 정체성 조건을 반복해야 합니다.
HEAD_SHA="$(scripts/assert-pr-ready.sh "$PR_NUMBER")"
gh pr merge "$PR_NUMBER" \
--squash \
--delete-branch \
--match-head-commit "$HEAD_SHA"
--match-head-commit은 테스트나 리뷰를 대신하지 않습니다. readiness guard를 통과한 SHA와 원격 PR HEAD가 여전히 같을 때만 merge하게 하는 마지막 경합 조건입니다. 이 옵션의 동작은 공식 gh pr merge 매뉴얼에 설명되어 있습니다.
이 지점이 "체크 하나가 통과했다"와 "리뷰한 산출물이 merge됐다"를 나누는 경계입니다. 특히 스케줄 기반 발행에서는 검증이 끝난 뒤 나타난 새 push를 작업이 조용히 따라가지 않게 하는 데 유용합니다.
Merge 검증과 프로덕션 검증을 분리한다
성공한 merge는 저장소가 커밋을 받아들였다는 뜻입니다. 프로덕션 도메인이 의도한 글을 제공한다는 뜻은 아닙니다. merge 후에는 두 로케일 라우트와 렌더링된 HTML을 모두 확인해야 합니다.
이 글의 사이트에서 최소한 확인할 URL은 다음과 같습니다.
https://hyunjoong.kim/ko/blog/fail-closed-bilingual-publishing
https://hyunjoong.kim/en/blog/fail-closed-bilingual-publishing
각 언어에 대해 다음을 확인합니다.
- 응답이
200인지 - 화면에 보이는 제목과 본문이 해당 언어와 일치하는지
- canonical URL이 해당 로케일 경로를 가리키는지
hreflangalternate에 한국어·영어 경로가 모두 들어 있는지- title, description, Open Graph 메타데이터,
BlogPostingJSON-LD가 존재하는지 robots.txt와sitemap.xml이 유효한 응답을 반환하고 예상 공개 경로를 포함하는지
GitHub에서 merge가 성공했다고 해서 배포까지 확인했다고 말하면 안 됩니다. 중복 Vercel 프로젝트 하나가 실패했지만 프로덕션 도메인이 정상인 경우에는 그 차이를 따로 기록해야 합니다. 독자가 소비하는 것은 공개 도메인이지만, 배포 신호의 불일치도 조사 대상입니다.
자동 콘텐츠 릴리스 체크리스트
체크리스트를 사용하되 모든 항목이 실행 가능하거나 직접 확인 가능한 산출물을 가리키게 하세요.
편집 전
- worktree가 깨끗하다.
-
develop이origin/develop과 최신 상태다. - slug와 중심 논지가 기존 글과 겹치지 않는다.
- 후보 주제에 직접 수행한 엔지니어링 근거가 있고, 현재 주장에는 1차 출처가 있다.
PR 전
-
*.ko.mdx와*.en.mdx가 모두 있다. -
date,category,hidden이 일치한다. -
keywords가 비어 있지 않은 배열이다. - 글의 섹션은
##부터 시작하고, 보이는 H1은 route가 소유한다. - 로컬 이미지 참조가 모두
public/아래 실제 파일로 해석된다. -
npm ci와npm run verify가 통과한다. -
git diff --check와 최종 diff 리뷰가 통과한다.
Merge 전
- PR 대상이
develop이고 draft가 아니다. - GitHub Actions, GitGuardian, Vercel 체크가 모두 끝났고 성공했다.
-
scripts/assert-pr-ready.sh가 현재 immutable head SHA를 반환한다. - 그 정확한 SHA를
--match-head-commit에 넣어 merge한다.
Merge 후
- merge commit을 식별한다.
- 두 프로덕션 글 URL이
200을 반환한다. - 라이브 응답에서 canonical,
hreflang, title, description, OG 메타데이터, JSON-LD, robots, sitemap을 확인한다. - 배포 실패를 프로젝트별로 보고하고 모호한 하나의 상태로 뭉개지 않는다.
트레이드오프는 의도적인 마찰이다
이 흐름은 한 파일짜리 블로그에는 필요하지 않을 단계를 추가합니다. 번역 파일 쌍, 메타데이터 파싱, 경로 검사, 전체 빌드, immutable HEAD 가드, 배포 후 HTML 검증이 그것입니다. 실제로 마찰이 생깁니다.
대안은 보이지 않는 모호함입니다. 언어 전환을 눌렀을 때까지 번역 누락을 모를 수 있습니다. 오래된 체크가 초록색인 상태에서 다른 커밋이 merge될 수 있습니다. 없는 에셋은 배포 뒤에야 드러날 수 있습니다. 계약을 실행 가능한 코드로 만들면 이런 문제를 발행 전에 싸게 막을 수 있고, 발행 뒤 진단하는 비용을 줄일 수 있습니다.
모든 글을 엔터프라이즈 릴리스 트레인에 태우자는 뜻은 아닙니다. 실패 영향 범위에 맞는 경계를 선택하자는 뜻입니다. 공개 다국어 사이트에서 유용한 최소 경계는 파일 쌍 계약, 재현 가능한 빌드, 정확한 커밋 정체성, 라이브 경로 확인입니다.
결론: 콘텐츠도 더 작은 산출물을 가진 릴리스다
기술 글에는 비즈니스 로직 대신 문장이 들어 있지만, 여전히 프로덕션 경계를 넘습니다. 따라서 신뢰할 수 있는 흐름은 "쓰고, 커밋하고, 잘 되길 기다리기"가 아닙니다.
다음 순서에 가깝습니다.
- 실제 근거로 독립적인 아이디어를 점수화합니다.
- 두 로케일을 하나의 일관된 쌍으로 씁니다.
- 메타데이터와 에셋을 실행 가능한 계약으로 바꿉니다.
- 로컬과 CI에서 같은 검증 경로를 실행합니다.
- 결정을 정확한 PR HEAD SHA에 묶습니다.
- 그 SHA만 merge합니다.
- 배포 후 공개 경로와 렌더링된 메타데이터를 확인합니다.
Fail-closed 발행이 글의 품질까지 보장해주지는 않습니다. 대신 더 좁고 유용한 것을 보장합니다. "준비 완료"라고 말할 때 그 근거가 실제로 리뷰하고 merge하는 산출물에 붙어 있다는 사실입니다.