배포 후 CI 단계에 JSON‑LD 구조화 데이터 감사를 도입하면서 배운 점
출처: Dev.to
결론부터 말하자면: JSON‑LD 구조화 데이터는 눈에 보이는 어떤 것도 깨뜨리지 않고 사이트에서 사라질 수 있는 요소 중 하나입니다. Astro 빌드는 성공하고, Cloudflare Pages 배포도 완료됩니다. 페이지는 브라우저에서 정상적으로 렌더링됩니다. 하지만 Googlebot이 풍부한 결과에 해당하는지 판단하기 위해 읽는 “ 블록 안에서는 뭔가 잘못됐으며, Search Console이 몇 주 뒤에 경고를 띄우기 전까지는 알 수 없습니다.
저는 CI 파이프라인에 배포 후 감시 단계를 추가했으며, 이 단계는 60초 이내에 문제를 찾아냅니다. 스크립트가 어떻게 동작하는지, 첫 실행에서 무엇을 발견했는지, 그리고 이 접근 방식이 어떤 점에서 한계가 있는지를 아래에 정리했습니다.
Astro SSG에서 구조화 데이터가 조용히 깨지는 이유
제가 운영하는 세 개의 디렉터리 사이트—aiappdex.com, findindiegame.com, ossfind.com—는 모두 Cloudflare Pages에 배포된 Astro 5 정적 SSG 빌드1입니다. 구조화 데이터는 각 페이지의 “ 안에 존재하며, 레이아웃 컴포넌트가 주입합니다. 서버‑사이드 렌더링도 없고, 동적 주입도 없습니다.
사용 중인 스키마 타입:
SoftwareApplication+BreadcrumbList→aiappdex.com모델 페이지VideoGame+BreadcrumbList→findindiegame.com게임 페이지ItemList+BreadcrumbList→ossfind.com대안 페이지WebSite→ 모든 홈 페이지
이 모든 스키마는 Astro 레이아웃 컴포넌트에서 생성됩니다. 새 슬롯을 추가하거나 “ 를 재구성하거나 공유 레이아웃 로직을 추출하면 JSON‑LD 블록이 사라질 수 있습니다. Astro 컴파일러는 구조화 데이터를 검증하지 않으며, 빌드 단계에서도 확인하지 않습니다. 배포는 성공하고, 오류도 발생하지 않죠.
특히 정적 SSG 사이트에서는 빌드 시점이 유일한 검증 기회이기 때문에(런타임에 검증할 서버가 없습니다)2, 템플릿 변경으로 2,000개의 게임 페이지에서 VideoGame 스키마가 사라진다면 배포가 끝날 때쯤 이미 큰 손해가 발생합니다.
지난 주간 회고3에서 일부 페이지의 FAQ JSON‑LD가 형식이 잘못됐을 가능성을 언급했었습니다. 이것이 바로 검사를 실제로 구현하게 만든 계기였습니다.
감시 스크립트가 확인하는 내용
scripts/audit-jsonld.mjs 파일에는 사이트별 기대 스키마를 정의한 테이블이 있습니다:
const SITES = [
{
host: "aiappdex.com",
homepage: { path: "/", expectedTypes: ["WebSite"] },
detail: {
pathRegex: /\/models\//,
expectedTypes: ["SoftwareApplication", "BreadcrumbList"],
},
},
{
host: "findindiegame.com",
homepage: { path: "/", expectedTypes: ["WebSite"] },
detail: {
pathRegex: /\/games\//,
expectedTypes: ["VideoGame", "BreadcrumbList"],
},
},
{
host: "ossfind.com",
homepage: { path: "/", expectedTypes: ["WebSite"] },
detail: {
pathRegex: /\/alternatives\//,
expectedTypes: ["ItemList", "BreadcrumbList"],
},
},
];
각 사이트에 대해 스크립트는 홈페이지와 두 개의 샘플 상세 페이지를 가져와 모든 JSON‑LD 블록을 추출하고, 존재하는 @type 값을 수집한 뒤, 기대되는 타입이 누락됐는지 보고합니다.
이 검사는 실제 배포된 페이지를 대상으로 수행됩니다. Cloudflare가 오래된 캐시를 반환하더라도 감지할 수 있고, CDN 엣지가 원본과 다른 HTML을 제공하더라도 잡아냅니다. 빌드 산출물을 테스트하면 템플릿 오류를 더 일찍 발견할 수 있지만, 배포·캐시 문제는 놓치게 됩니다—저는 이런 상황을 Cloudflare Pages에서 직접 겪은 적이 있습니다4.
사이트맵을 통해 실시간 페이지를 찾아내는 방법
상세 페이지 경로를 하드코딩하는 대신, 스크립트는 현재 사이트맵을 읽어 실제 페이지 URL을 찾아냅니다:
async function discoverDetailPaths(host, regex, count = 2) {
try {
const sitemap = await fetch(`https://${host}/sitemap-0.xml`).then(r => r.text());
const urls = [...sitemap.matchAll(/([^/g)].map(m => m[1]);
return urls.filter(u => regex.test(u)).slice(0, count).map(u => new URL(u).pathname);
} catch {
return [];
}
}
sitemap-0.xml 파일명을 고의적으로 사용했습니다. 앞서 언급했듯이(‘astrojssitemap’ 시리즈)5 작은 사이트(≈1,000 페이지 이하)에서는 @astrojs/sitemap이 /sitemap-0.xml을 생성하고, /sitemap-index.xml은 만들지 않습니다. /sitemap-index.xml을 고정하면 탐색이 조용히 실패해 상세 페이지를 전혀 검사하지 못하게 됩니다.
pathRegex 로 필터링하면 현재 프로덕션에 실제로 존재하는 모델/게임/대안 페이지만 골라냅니다. 한 번에 사이트당 2개의 샘플을 검사하므로 빠르지만 전면적인 검사는 아닙니다.
JSON‑LD 추출 및 @graph 처리
추출 로직은 HTML에 대한 정규식이며, @graph 를 풀어내는 비직관적인 부분이 있습니다:
function extractJsonLd(html) {
const matches = [
...html.matchAll(
/]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)/gi,
),
];
const items = [];
for (const m of matches) {
try {
const parsed = JSON.parse(m[1].trim());
const arr = Array.isArray(parsed) ? parsed : [parsed];
for (const node of arr) {
if (!node) continue;
if (node["@graph"]) {
for (const sub of node["@graph"]) {
if (sub && sub["@type"]) items.push(sub);
}
} else if (node["@type"]) {
items.push(node);
}
}
} catch (e) {
items.push({ "@type": "_PARSE_ERROR", error: String(e).slice(0, 80) });
}
}
return items;
}
일부 생성기는 여러 스키마 객체를 최상위 @graph 배열에 묶어 내보냅니다. Google은 @graph 안의 각 항목을 별개의 엔터티로 취급하므로, 감시 스크립트도 동일하게 처리합니다. 예를 들어 @graph: [{ "@type": "VideoGame" }, { "@type": "BreadcrumbList" }] 와 같이 구성된 경우, 두 타입 모두 추출돼 개별적으로 검증됩니다.
파싱 오류는 _PARSE_ERROR 항목으로 표시됩니다. 이는 템플릿에서 JSON 블록에 이스케이프되지 않은 따옴표가 삽입되는 등, @type 검사를 수행하기 전에 형식이 깨진 경우를 잡아내는 데 유용합니다.
CI 파이프라인에 비치명적 단계로 추가하기
이 스크립트를 publish-articles.yml에 연결했습니다—Dev.to, Hashnode, Bluesky에 글을 배포하는 동일 파이프라인6:
- name: Audit JSON-LD (non-fatal)
run: node scripts/audit-jsonld.mjs || echo "JSON-LD audit reported issues (non-fatal)"
|| 뒤의 fallback이 핵심 설계 포인트입니다. 이 덕분에 단계가 언제나 0(exit code)으로 종료되어, 감시가 실패해도 글 게시가 차단되지 않습니다. 문제는 액션 로그에 표시되지만 배포 자체는 중단되지 않죠.
이는 제가 Bluesky 이미지 업로드 타이밍 문제7를 해결한 방식과 동일합니다. 먼저 검사를 도입해 실제 상황에서 어떤 결과가 나오는지 관찰하고, 근본 원인을 고친 뒤