Use Cases (업데이트: 2026. 6. 3.)

Claude Code로 XML 사이트맵 자동 생성하기

Astro와 Node로 사이트맵을 만들고 hreflang, lastmod, robots.txt, Search Console까지 점검합니다.

Claude Code로 XML 사이트맵 자동 생성하기

사이트맵은 검색엔진에 전달하는 공개 URL 목록이다

Claude Code로 글, 문서, 상품 페이지를 빠르게 늘리면 페이지 템플릿보다 먼저 흔들리는 부분이 있습니다. 검색엔진이 새 URL을 안정적으로 찾을 수 있는지입니다. XML 사이트맵은 어떤 URL이 정규 URL인지, 언제 의미 있게 수정되었는지, 번역된 페이지가 서로 어떤 관계인지 알려주는 목록입니다.

하지만 사이트맵은 색인을 보장하지 않습니다. Google의 현재 문서에 따르면 prioritychangefreq는 Google에서 사용하지 않으며, lastmod도 실제 변경일과 꾸준히 맞을 때만 참고됩니다. 예전 코드에 남아 있는 https://www.google.com/ping?sitemap=... 방식도 폐기되었으므로, 지금은 robots.txt, Google Search Console 제출, 배포 후 검증을 기준으로 잡는 편이 맞습니다.

이 글에서는 Astro 공식 통합을 쓰는 방법과, Node.js 내장 모듈만으로 다국어 MDX 콘텐츠의 사이트맵을 만드는 방법을 함께 다룹니다. 전체 SEO 흐름을 점검하려면 Claude Code SEO 최적화Claude Code CI/CD 설정도 같이 확인해 보세요.

먼저 고정해야 할 공식 규칙

항목실무 기준
URLhttps://example.com/blog/post/처럼 절대 URL을 사용한다
파일 제한사이트맵 하나는 50,000 URL 또는 비압축 50 MB까지다
인코딩UTF-8로 저장하고 XML 값은 이스케이프한다
lastmod본문, 구조화 데이터, 중요한 링크가 실제로 바뀐 날만 넣는다
priority / changefreqGoogle은 사용하지 않으므로 생략해도 된다
다국어 페이지각 URL이 자기 자신과 모든 대체 언어 URL을 함께 나열한다
제출 방식robots.txt와 Search Console을 사용하고 ping 스크립트는 제거한다

공식 근거는 Google 사이트맵 가이드, Google 사이트맵 ping 폐기 안내, Google 현지화 페이지 가이드, sitemaps.org 프로토콜입니다.

활용 사례 1: Astro 페이지와 블로그 라우트

일반적인 Astro 정적 사이트라면 공식 @astrojs/sitemap 통합부터 시작하는 것이 가장 단순합니다. astro build 과정에서 사이트맵을 생성하고, 라우트 구조가 일정하면 다국어 URL 관계도 함께 넣을 수 있습니다.

npx astro add sitemap
// astro.config.mjs
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';

export default defineConfig({
  site: 'https://claudecodelab.com',
  integrations: [
    sitemap({
      filter: (page) => !page.includes('/draft/') && !page.includes('/preview/'),
      i18n: {
        defaultLocale: 'ja',
        locales: {
          ja: 'ja',
          en: 'en',
          zh: 'zh-CN',
          ko: 'ko',
          es: 'es',
          fr: 'fr',
          de: 'de',
          pt: 'pt-BR',
          hi: 'hi',
          id: 'id',
        },
      },
    }),
  ],
});

여기서 가장 흔한 실수는 site 값이 틀린 상태로 배포하는 것입니다. localhost, 미리보기 도메인, httphttps가 섞인 URL이 들어가면 Google은 그 URL을 그대로 크롤링하려고 합니다. 사이트맵의 URL은 페이지의 canonical URL과 일치해야 합니다.

활용 사례 2: Node.js로 다국어 MDX 사이트맵 만들기

콘텐츠가 blog, blog-en, blog-zh 같은 컬렉션에 나뉘어 있거나, frontmatter의 updatedDatelastmod로 정확히 쓰고 싶다면 직접 생성하는 편이 낫습니다. 아래 스크립트는 Node.js 내장 모듈만 사용해 public/sitemap.xml을 만듭니다.

// scripts/generate-sitemap.mjs
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';

const SITE_URL = (process.env.SITE_URL ?? 'https://example.com').replace(/\/$/, '');
const OUT_DIR = 'public';
const OUT_FILE = path.join(OUT_DIR, 'sitemap.xml');

const collections = [
  { dir: 'site/src/content/blog', prefix: '/blog', hreflang: 'ja' },
  { dir: 'site/src/content/blog-en', prefix: '/en/blog', hreflang: 'en' },
  { dir: 'site/src/content/blog-zh', prefix: '/zh/blog', hreflang: 'zh-CN' },
  { dir: 'site/src/content/blog-ko', prefix: '/ko/blog', hreflang: 'ko' },
  { dir: 'site/src/content/blog-es', prefix: '/es/blog', hreflang: 'es' },
  { dir: 'site/src/content/blog-fr', prefix: '/fr/blog', hreflang: 'fr' },
  { dir: 'site/src/content/blog-de', prefix: '/de/blog', hreflang: 'de' },
  { dir: 'site/src/content/blog-pt', prefix: '/pt/blog', hreflang: 'pt-BR' },
  { dir: 'site/src/content/blog-hi', prefix: '/hi/blog', hreflang: 'hi' },
  { dir: 'site/src/content/blog-id', prefix: '/id/blog', hreflang: 'id' },
];

function escapeXml(value) {
  return String(value).replace(/[<>&'"]/g, (char) => ({
    '<': '&lt;',
    '>': '&gt;',
    '&': '&amp;',
    "'": '&apos;',
    '"': '&quot;',
  })[char]);
}

async function* walk(dir) {
  let items;
  try {
    items = await readdir(dir, { withFileTypes: true });
  } catch (error) {
    if (error.code === 'ENOENT') return;
    throw error;
  }

  for (const item of items) {
    const fullPath = path.join(dir, item.name);
    if (item.isDirectory()) {
      yield* walk(fullPath);
    } else if (/\.(md|mdx)$/.test(item.name)) {
      yield fullPath;
    }
  }
}

function frontmatterOf(source) {
  return source.match(/^---\n([\s\S]*?)\n---/)?.[1] ?? '';
}

function dateField(frontmatter, key) {
  return frontmatter.match(new RegExp(`^${key}:\\s*["']?(\\d{4}-\\d{2}-\\d{2})`, 'm'))?.[1];
}

function routeSlug(collectionDir, filePath) {
  return path
    .relative(collectionDir, filePath)
    .replace(/\\/g, '/')
    .replace(/\.(md|mdx)$/, '')
    .replace(/\/index$/, '');
}

function encodeRoute(slug) {
  return slug.split('/').map(encodeURIComponent).join('/');
}

async function collectEntries() {
  const bySlug = new Map();

  for (const collection of collections) {
    for await (const filePath of walk(collection.dir)) {
      const source = await readFile(filePath, 'utf8');
      const frontmatter = frontmatterOf(source);
      if (/^draft:\s*true\s*$/m.test(frontmatter)) continue;

      const info = await stat(filePath);
      const slug = routeSlug(collection.dir, filePath);
      const lastmod =
        dateField(frontmatter, 'updatedDate') ??
        dateField(frontmatter, 'pubDate') ??
        info.mtime.toISOString().slice(0, 10);

      const route = `${collection.prefix}/${encodeRoute(slug)}/`;
      const variant = {
        loc: `${SITE_URL}${route}`,
        hreflang: collection.hreflang,
        lastmod,
      };

      const variants = bySlug.get(slug) ?? [];
      variants.push(variant);
      bySlug.set(slug, variants);
    }
  }

  return [...bySlug.values()].flatMap((variants) =>
    variants.map((variant) => ({
      ...variant,
      alternates: variants.map(({ hreflang, loc }) => ({ hreflang, loc })),
    })),
  );
}

function buildSitemap(entries) {
  const urls = entries.map((entry) => `  <url>
    <loc>${escapeXml(entry.loc)}</loc>
    <lastmod>${entry.lastmod}</lastmod>
${entry.alternates.map((alt) => `    <xhtml:link rel="alternate" hreflang="${escapeXml(alt.hreflang)}" href="${escapeXml(alt.loc)}" />`).join('\n')}
  </url>`).join('\n');

  return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
        xmlns:xhtml="http://www.w3.org/1999/xhtml">
${urls}
</urlset>
`;
}

const entries = await collectEntries();
if (entries.length === 0) {
  throw new Error('사이트맵에 넣을 공개 URL을 찾지 못했습니다.');
}

await mkdir(OUT_DIR, { recursive: true });
await writeFile(OUT_FILE, buildSitemap(entries), 'utf8');
console.log(`${OUT_FILE}에 ${entries.length}개 URL을 썼습니다.`);

실행은 다음처럼 합니다.

SITE_URL=https://claudecodelab.com node scripts/generate-sitemap.mjs

활용 사례 3: 글, 상품, 문서를 나누어 관리하기

작은 블로그는 하나의 sitemap.xml로 충분합니다. 하지만 글, 태그, 상품, 도움말 문서가 늘어나면 종류별로 나누는 편이 좋습니다. 공식 제한을 피할 수 있고, Search Console에서 어느 영역에 문제가 있는지 빠르게 볼 수 있습니다.

<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <sitemap>
    <loc>https://example.com/sitemap-pages.xml</loc>
    <lastmod>2026-06-03</lastmod>
  </sitemap>
  <sitemap>
    <loc>https://example.com/sitemap-blog.xml</loc>
    <lastmod>2026-06-03</lastmod>
  </sitemap>
  <sitemap>
    <loc>https://example.com/sitemap-products.xml</loc>
    <lastmod>2026-06-03</lastmod>
  </sitemap>
</sitemapindex>

Claude Code에 구현을 맡길 때는 파일별 URL 수를 로그로 남기고, 50,000개에 가까워지면 자동으로 분할하며, 사이트맵 인덱스에는 같은 사이트의 파일만 넣도록 요구하세요.

robots.txt, Search Console, 검증

공개된 사이트의 robots.txt에는 사이트맵 주소를 넣습니다.

User-agent: *
Allow: /

Sitemap: https://claudecodelab.com/sitemap.xml

그 다음 Google Search Console의 사이트맵 화면에서 한 번 제출합니다. 배포 확인에는 공개 URL이 HTTP 200을 반환하고 XML 루트가 urlset 또는 sitemapindex인지 검사하는 스크립트를 넣으면 됩니다.

// scripts/verify-sitemap.mjs
const sitemapUrl = process.env.SITEMAP_URL ?? 'https://example.com/sitemap.xml';
const response = await fetch(sitemapUrl);

if (!response.ok) {
  throw new Error(`사이트맵 요청 실패: HTTP ${response.status}`);
}

const xml = await response.text();
if (!xml.includes('<urlset') && !xml.includes('<sitemapindex')) {
  throw new Error('응답이 사이트맵 XML처럼 보이지 않습니다.');
}

console.log(`${sitemapUrl} 확인 완료. 크기: ${xml.length} bytes`);

공개 전에 잡아야 할 실패 사례

가장 큰 실수는 모든 lastmod를 빌드 날짜로 바꾸는 것입니다. 실제로 내용이 바뀌지 않았는데 매일 최신 날짜가 되면 업데이트 신호의 신뢰도가 떨어집니다.

두 번째는 초안, noindex 페이지, 리다이렉트 원본, 중복 URL을 넣는 것입니다. 사이트맵에는 검색 결과에 노출하고 싶은 정규 URL만 들어가야 합니다.

세 번째는 다국어 링크가 한쪽 방향만 있는 경우입니다. 일본어가 영어를 가리키면 영어도 일본어를 다시 가리켜야 하며, 각 언어는 자기 자신도 포함해야 합니다.

네 번째는 XML 이스케이프 누락입니다. 쿼리 문자열의 &는 XML 안에서 &amp;가 되어야 합니다.

수익화와 실제 확인 결과

사이트맵 자체가 광고 수익을 직접 올리지는 않습니다. 하지만 튜토리얼, 비교 글, 무료 자료, 상담 페이지처럼 수익과 이어지는 페이지가 검색엔진에 발견되는 길을 정리합니다. 사이트맵을 고친 뒤에는 내부 링크와 CTA도 점검해 독자가 Claude Code 교육 같은 다음 행동으로 자연스럽게 이동하도록 만드세요.

Masa가 ClaudeCodeLab 흐름에서 확인했을 때 가장 효과가 컸던 변경은 오래된 ping 코드 제거, lastmodupdatedDate의 일치, 10개 언어 버전의 상호 hreflang 연결이었습니다. 편집자는 MDX frontmatter만 보고 어떤 언어가 업데이트되었는지 확인할 수 있었고, Search Console에서 문제 영역을 좁히기도 쉬워졌습니다.

#Claude Code #사이트맵 #SEO #XML #자동화
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.