전체 글

gatsby-plugin-sitemap 기본값, 사이트맵 lastmod 0개 — 구글 색인 0편

xXfacebook페이스북

블로그 여섯 곳에 글이 95편 올라가 있었는데 구글에 색인된 글은 0편이었습니다. 사이트맵은 200 으로 응답했고 money 사이트맵에는 URL 이 222개 들어 있었고 robots.txt 도 정상이었습니다. 2026년 9월 8일에 그 사이트맵 XML 을 열어 보니 구글이 읽는다고 밝힌 lastmod 가 한 개도 들어 있지 않았습니다. gatsby-plugin-sitemap 을 기본값 그대로 쓰면 그렇게 나갑니다.

먼저 경계선을 하나 그어 두겠습니다. 이 글은 "lastmod 가 없어서 색인이 0편이었다"는 이야기가 아닙니다. 구글은 사이트맵 제출이 힌트일 뿐이며 색인을 보장하지 않는다고 문서에 명시해 뒀습니다. lastmod 가 없으면 재수집이 줄어든다고 적어 둔 공식 문서는 저희가 찾지 못했습니다. 여기 있는 것은 두 가지입니다. 구글이 읽는다고 스스로 밝힌 날짜 필드가 우리 사이트맵에 0개였다는 사실, 그리고 같은 기간에 재수집도 색인도 없었다는 관측입니다. 둘을 인과로 잇는 것은 저희 추론입니다.

고친 것은 커밋 5e215e2 하나입니다. 재수집과 색인이 실제로 돌아왔는지는 이 글을 쓰는 시점에 아직 모릅니다.

구글 색인 상태를 사이트별로 정리한 비교표. 구글이 주소를 아는가, 크롤했는가, 색인했는가 세 층으로 나눠 보면 money 홈만 세 층을 다 통과해 색인됐고(마지막 크롤 8월 24일), travel 홈은 크롤까지 갔지만 색인되지 않았고, money 글 3편과 dev 홈은 구글이 주소 자체를 몰라 크롤이 시작조차 안 된 상태로 갈린다

URL 검사 판정은 네 갈래로 갈렸다. 색인된 것은 money 홈 하나였다.

글 95편 중 색인 0편 — 무엇을 봤나

시작은 "왜 검색에 안 걸리나" 였습니다. Search Console 의 URL 검사로 사이트별 홈과 글 몇 편을 찍어 봤더니 판정이 이렇게 갈렸습니다.

확인한 주소 URL 검사 판정
money 홈 색인됨 — 마지막 크롤 8월 24일
money 글 3편 "Google 에는 아직 알려지지 않은 URL 입니다"
travel 홈 크롤은 됐지만 색인되지 않음
dev 홈 구글이 주소의 존재조차 모름

검색 노출도 같은 방향이었습니다. 직전 45일 동안 검색 결과에 나타난 것은 money 홈 3회가 전부였고 나머지 다섯 사이트는 0회였습니다. 글은 매일 늘어나는데 색인된 글은 한 편도 없는 상태가 몇 주째 이어졌습니다.

여기 적은 수치들은 저희 저장소의 커밋 5e215e2 메시지에 그대로 남겨 둔 관측값입니다. 이 글을 쓰면서 Search Console 을 다시 조회하지는 않았으니 출처는 "그날의 우리 기록"이라고 읽어 주시는 게 정확합니다. 이 시점까지 보고 있던 것은 증상뿐이었습니다.

사이트맵은 멀쩡했는데 왜 다시 안 읽어갔나

가장 먼저 의심한 것은 사이트맵이 깨졌거나 아예 없는 경우였습니다. 그런데 그쪽은 멀쩡했습니다. 사이트맵 주소는 200 으로 응답했고 money 사이트맵에는 URL 이 222개 들어 있었고 robots.txt 에도 Sitemap: 줄이 정상적으로 나가고 있었습니다.

문제로 보인 것은 내용이 아니라 날짜였습니다. Search Console 사이트맵 보고서의 마지막 읽은 시각이 2026년 8월 18일 한 번에 멈춰 있었고 9월 8일까지 갱신되지 않았습니다. 3주 공백입니다. 같은 Search Console 속성에 들어 있는 util 사이트는 9월 5일에도 다시 읽혀 갔습니다. 속성 설정이 잘못됐을 가능성은 이걸로 낮아졌지만 두 사이트는 연식도 콘텐츠도 유입도 달라서 통제된 비교는 아닙니다.

"3주면 너무 긴 것 아닌가"라는 판단도 실은 근거가 약합니다. 구글은 사이트맵을 며칠에 한 번 다시 읽는지 숫자로 공표하지 않습니다. 저희가 확인한 문서 네 종류(사이트맵 만들기, 사이트맵 개요, 크롤링 예산 관리, Search Console 사이트맵 보고서) 어디에도 일 단위 주기가 없었습니다. "제출하면 곧 크롤을 시도하고 이후에는 사이트 크롤 일정과 무관한 속도로 재수집한다"는 서술까지가 전부였습니다. 그러니 3주는 일반적인 기준이 아니라 저희 관측치입니다.

8월 18일이 왜 하필 그 날짜인지도 짚어 둘 만합니다. 저장소의 on-post-build.ts 주석에는 그전까지 robots.txt 에 Sitemap: 줄이 없어서 크롤러가 사이트맵을 스스로 찾을 방법이 없었다고 적혀 있습니다. 그 줄을 넣은 것은 저희 저장소의 커밋 034d03e 이고 커밋 시각은 2026년 8월 18일 오전 9시 53분(한국 시각)입니다. 이 커밋이 on-post-build.ts 에 Sitemap: 줄을 추가했고 그 날짜는 사이트맵 보고서의 마지막 읽은 시각과 같은 날입니다. 다만 구글이 그날 배포된 그 파일을 읽어 갔는지는 알 수 없습니다 — 마지막 읽은 시각이 8월 18일이라는 것까지가 관측입니다. 즉 8월 18일은 "마지막으로 읽어간 날"이자 사실상 "처음 읽어간 날"이었습니다. 참고로 같은 주석에 남아 있는 8월 18일 수치는 색인 67 / 미색인 333 이고 그중 111개가 "발견됨 - 현재 색인이 생성되지 않음" 이었습니다. 다만 이 값은 sc-domain:seongjun.kr 도메인 속성 전체라서 util 과 옛 블로그까지 포함합니다. 앞에서 말한 "글 95편 중 0편"과는 세는 범위가 다르니 나란히 놓고 비교하면 안 됩니다.

세로 타임라인. 8월 18일 사이트맵 첫 수집 이후 9월 8일까지 다시 읽어간 기록이 없는 3주 공백이 붉은 점선으로 이어지고, 그 사이 9월 5일에 같은 Search Console 속성의 util 은 재수집됐다. 9월 8일 원인 진단과 수정 커밋 뒤의 마지막 구간은 재수집·재색인이 확인되지 않아 물음표와 점선으로 표시된 관측 중 상태다

같은 속성 안에서 한쪽은 9월 5일에 다시 읽혀 갔다. 통제된 비교는 아니다.

사이트맵 항목을 열어보니 — changefreq·priority만 있고 lastmod가 0개

그래서 사이트맵 XML 을 직접 열어 봤습니다. 항목 하나가 이렇게 생겨 있었습니다.

<url>
  <loc>https://money.seongjun.kr/posts/글-주소/</loc>
  <changefreq>daily</changefreq>
  <priority>0.7</priority>
</url>

222개 항목 전부 같은 모양이었습니다. 필드를 세어 보면 changefreq 222개, priority 222개, lastmod 0개입니다. 그리고 이 세 필드에 대한 구글의 입장은 공식 문서에 그대로 적혀 있습니다.

Google ignores <priority> and <changefreq> values.

Google uses the <lastmod> value if it's consistently and verifiably (for example by comparing to the last modification of the page) accurate.

앞의 두 필드는 무시하고 lastmod 는 일관되고 (페이지의 실제 마지막 수정과 대조해) 검증 가능하게 정확할 때 쓴다는 뜻입니다. 저희 사이트맵은 정확히 그 반대였습니다 — 무시한다고 밝힌 두 필드는 모든 항목에 있었습니다. 쓴다고 밝힌 한 필드는 한 항목에도 없었습니다.

한 가지는 정확하게 말해야 합니다. "구글이 읽는 신호가 하나도 없었다"고 쓰면 과장입니다. loc 는 구글이 읽습니다 — 문서에도 나열된 URL 을 그대로 크롤해 보려 한다고 적혀 있습니다. 정확한 서술은 URL 목록 말고는, 구글이 읽는다고 밝힌 값이 하나도 없었다는 것입니다.

그러면 구글이 프로토콜을 무시하는 건가 싶지만 그것도 아닙니다. sitemaps.org 프로토콜은 lastmod·changefreq·priority 세 필드를 모두 선택 사항(Optional) 으로 정의하고 changefreq 설명에는 프로토콜 자신이 단서를 달아 뒀습니다.

Please note that the value of this tag is considered a hint and not a command.

priority 쪽에도 우선순위가 검색 결과 순위에 영향을 줄 가능성은 낮다고 적혀 있습니다. 구글이 두 필드를 안 읽는 것은 프로토콜을 배신한 게 아니라 프로토콜이 처음부터 남겨 둔 여지를 쓴 것입니다. 표준에 있는 필드를 다 채웠다고 해서 검색엔진이 그걸 본다는 뜻은 아닙니다.

수정 전과 후의 사이트맵 항목 비교. 수정 전에는 loc 과 changefreq daily, priority 0.7 이 있고 lastmod 는 필드 자체가 없었다. 각 줄 옆에는 구글이 읽는 값이라는 뜻의 체크 표시, 무시하는 값이라는 뜻의 곱셈 표시, 필드 자체가 빠졌다는 뜻의 (없음) 표시가 범례와 함께 붙어 있다. 수정 후에는 loc 과 lastmod 만 남고 두 필드는 아예 내보내지 않는다

구글이 무시한다고 밝힌 두 필드만 있었고, 쓴다고 밝힌 한 필드가 없었다.

원인은 플러그인 기본값이었다 — gatsby-plugin-sitemap 6.14.0

gatsby-config.ts 에는 changefreq 나 priority 를 넣으라고 적어 둔 곳이 없었습니다. 그래서 node_modules 안에 설치된 플러그인 소스를 직접 열어 봤습니다. gatsby-plugin-sitemap/internals.js 의 130~137줄입니다.

function serialize(page, _ref3) {
  var resolvePagePath = _ref3.resolvePagePath;
  return {
    url: "" + resolvePagePath(page),
    changefreq: "daily",
    priority: 0.7
  };
}

반환하는 객체에 lastmod 키가 아예 없습니다. 저희가 잘못 설정한 것이 아니라 플러그인을 손대지 않고 그대로 쓰면 이렇게 나갑니다. 값 자체도 사실과 무관합니다 — 모든 페이지가 매일 바뀐다고 알리고 모든 페이지의 중요도가 똑같이 0.7 이라고 알리는 셈입니다. 다만 이건 이 저장소에 설치된 6.14.0 기준입니다. package.json 에는 ^6.14.0 으로 적혀 있으니 다른 환경에서는 설치본이 다를 수 있고 확인은 각자의 node_modules 에서 해야 합니다.

재밌는 건 플러그인 저자도 이걸 알고 README 에 경고를 적어 뒀다는 점입니다. 두 필드가 페이지마다 똑같이 나가고 대부분 틀린 값일 것이라고 쓴 뒤, 정확한 lastmod 가 들어가도록 설정을 직접 손보라고 권합니다. 그 README 가 인용해 둔 구글 문구는 so don't bother adding them, Google will stop reading it 같은 표현인데, 이건 당시 구글 문서를 인용한 것이고 현재 공식 문서의 문구는 아닙니다. 지금 문서에는 위에 옮긴 두 문장만 있습니다. 경고 자체는 유효하지만 인용문을 현재 문구로 옮겨 쓰면 안 됩니다.

resolvePages로 날짜를 잇는다 — 경로 정규화가 진짜 함정이었다

고치는 방향은 단순합니다. 사이트맵에 들어갈 페이지 목록(allSitePage)과 글의 날짜(allMarkdownRemark)를 각각 가져와서 같은 주소끼리 이어 붙이면 됩니다. 쿼리에 글 목록을 추가했습니다.

allMarkdownRemark(
  filter: {
    frontmatter: { template: { eq: "post" }, draft: { ne: true } }
  }
) {
  nodes {
    fields { slug }
    frontmatter { slug date }
  }
}

문제는 "같은 주소끼리"가 생각보다 안 맞는다는 것이었습니다. 이 저장소에서 글의 실제 주소는 create-pages 가 frontmatter.slug || fields.slug 로 정합니다. 글 대부분은 frontmatter 에 slug 를 직접 적어 두고 안 적은 글은 파일 경로에서 나온 fields.slug 를 씁니다. 그러니 사이트맵도 같은 규칙으로 골라야 합니다. 한쪽만 보면 나머지 절반이 안 맞습니다.

여기에 함정이 하나 더 있었습니다. allSitePage 의 path 는 끝에 / 가 붙어 나오는데 frontmatter.slug 에는 안 붙어 있습니다. 문자열 그대로 비교하면 /posts/foo 와 /posts/foo/ 가 다른 키가 되어 버립니다. 그래서 양쪽을 같은 모양으로 정규화한 뒤 Map 을 만들었습니다.

/** 사이트맵 경로 비교용. allSitePage 는 끝에 `/` 가 붙고 frontmatter.slug 는 안 붙는다. */
const withSlash = (p: string): string => (p.endsWith("/") ? p : `${p}/`);

resolvePages: ({ allSitePage, allMarkdownRemark }) => {
  const dates = new Map<string, string>();
  for (const node of allMarkdownRemark?.nodes ?? []) {
    const raw = node.frontmatter?.slug || node.fields?.slug;
    if (!raw || !node.frontmatter?.date) continue;
    dates.set(withSlash(raw), node.frontmatter.date);
  }
  return (allSitePage?.nodes ?? []).map((page) => ({
    path: page.path,
    lastmod: dates.get(withSlash(page.path)),
  }));
},

이 대목이 실무적으로 제일 위험합니다. 경로가 안 맞아도 아무 오류가 나지 않습니다. Map.get 이 undefined 를 돌려주고 lastmod 없는 사이트맵이 조용히 다시 나갑니다. 빌드는 성공합니다. 재수집이 멈춘 3주 동안 이 문제를 못 알아챈 이유와 똑같은 방식으로 재발할 수 있습니다.

경로 정규화 다이어그램. allSitePage 의 path 는 끝에 슬래시가 붙은 /posts/foo/ 이고 frontmatter.slug 는 슬래시가 없는 /posts/foo 다. 그대로 문자열 비교하면 다른 키가 되어 Map 조회가 undefined 를 돌려주고 lastmod 가 안 붙는 반면, withSlash 를 양쪽에 적용하면 같은 키가 되어 lastmod 가 붙는다. 어긋나도 오류 없이 빌드는 성공한다는 경고가 아래에 붙어 있다

끝 슬래시 하나 때문에 절반이 안 맞았다. 안 맞아도 빌드는 성공한다.

내보내는 쪽도 바꿨습니다. 날짜가 있을 때만 lastmod 를 붙이고 changefreq 와 priority 는 아예 내보내지 않습니다.

serialize: ({ path, lastmod }) => (lastmod ? { url: path, lastmod } : { url: path }),

날짜가 없을 때 빈 lastmod 를 넣지 않는 것이 포인트입니다. 빈 값은 파서에 따라 오류가 됩니다. 구글이 안 읽는 두 필드를 굳이 남겨 둘 이유도 없습니다. 다만 다른 검색엔진이 이 둘을 쓰는지는 확인하지 않았습니다.

이렇게 고친 뒤 9월 8일에 로컬 빌드를 돌려 세어 보니 loc 216개에 lastmod 39개, changefreq 와 priority 는 0개였습니다. 39는 초안이 아닌 글에만 붙은 수이고 나머지 177개는 홈·태그·카테고리·페이지네이션처럼 글이 아닌 페이지입니다. 어느 사이트를 빌드한 것이었는지는 기록에 남기지 않아서 지금은 확인할 수 없습니다. 앞에서 말한 222 와 이 216 을 나란히 놓고 "6개 줄었다"로 읽으면 안 됩니다. 222 는 8월 18일 시점의 라이브 사이트맵이고 216 은 9월 8일 로컬 빌드라 같은 조건의 전후 비교가 아닙니다.

날짜 형식은 2026-09-04T02:16:03.299Z 처럼 나갑니다. sitemaps.org 가 요구하는 W3C Datetime 형식 여섯 가지 중 하나에 타임존 Z 를 붙인 모양이라 규격에는 맞습니다. 다만 구글 문서의 예시는 2022-06-04 같은 날짜형 하나뿐이어서 소수점 초까지 붙은 전체 타임스탬프를 구글이 그대로 받아들이는지는 문서에서 확인하지 못했습니다.

태그·목록 페이지엔 일부러 안 붙였다 — 그리고 아직 결과는 모른다

글이 아닌 페이지에는 lastmod 를 붙이지 않았습니다. 태그·카테고리·목록 페이지가 마지막으로 "의미 있게" 바뀐 시각을 저희가 모르기 때문입니다. 빌드 시각을 넣으면 편하지만 그건 매번 빌드할 때마다 전 페이지가 새로 고쳐졌다고 구글에 주장하는 것이 됩니다. 이 판단에는 문서 근거가 있습니다.

The <lastmod> value should reflect the date and time of the last significant update to the page. For example, an update to the main content, the structured data, or links on the page is generally considered significant, however an update to the copyright date is not.

저작권 연도를 갱신하는 정도는 유의미한 수정이 아니라고 못 박아 뒀습니다. 그리고 앞에서 본 대로 구글은 lastmod 를 "일관되고 검증 가능하게 정확할 때" 쓴다고 했으니 모르는 날짜를 지어내는 것은 아껴 두는 편이 낫다고 봤습니다.

이 규칙은 internal/gatsby/sitemap.test.ts 에 테스트 네 개로 못 박아 뒀습니다. 글에는 발행일이 붙는지(frontmatter 로 slug 를 적은 글과 파일 경로에서 나온 글 양쪽), 글이 아닌 페이지에는 안 붙는지, serialize 가 lastmod 만 내보내고 두 필드는 안 내보내는지, siteUrl 을 설정에서 읽는지입니다. 테스트를 붙인 이유는 앞 절과 같습니다. 경로 규칙이 어긋나도 빌드는 성공하고 사이트맵만 조용히 비어 나가서 사람이 다시 발견할 때까지 몇 주가 걸립니다.

알려진 한계도 적어 둡니다. 날짜의 출처는 frontmatter.date, 즉 발행일입니다. 이 저장소에는 수정일 필드가 없어서 나중에 본문을 고쳐도 lastmod 는 발행일에 머물러 있습니다. 한 번도 고치지 않은 글에서는 발행일이 곧 마지막 유의미한 수정일이라고 봤습니다. 오차는 "실제보다 오래된 날짜를 유지하는" 방향이라 실제보다 새로 고쳐진 것처럼 주장하는 쪽은 아니지만, 정확한 값은 아닙니다. 수정일을 따로 기록하기 시작하면 그때 바꿀 자리입니다.

마지막으로, "고쳤다"와 "회복됐다"는 다른 말입니다. 지금 확인된 것은 사이트맵에 lastmod 가 붙어 나가기 시작했다는 것까지입니다. 구글이 사이트맵을 다시 읽어갔는지, 마지막 읽은 시각이 갱신됐는지, 글이 색인됐는지는 이 글을 쓰는 시점에 전부 관측되지 않았습니다. 구글은 사이트맵 제출이 힌트일 뿐이라고 했고 사이트맵이 URL 발견을 돕지만 그 안의 항목이 모두 크롤되고 색인된다고 보장하지는 않는다고도 했습니다. 색인이 0편이었던 이유도 사이트맵 하나로 환원할 수 없습니다 — 신규 도메인, 사이트 평가, 콘텐츠 판단 같은 후보가 여럿 남아 있습니다. 이번에 한 일은 구글이 읽는다고 밝힌 값을 이제야 보내기 시작한 것이고 그 이상은 아직 저희도 모릅니다.

근거와 참고

저장소 안의 근거는 링크가 아니라 경로로 적습니다. 수정 커밋은 5e215e2, 설정은 gatsby-config.ts 의 1112줄과 152219줄, 테스트는 internal/gatsby/sitemap.test.ts, robots.txt 배경은 internal/gatsby/on-post-build.ts 의 2045줄입니다. 플러그인 쪽은 node_modules/gatsby-plugin-sitemap/internals.js 의 130137줄과 같은 패키지의 README.md 49~54줄, 버전 표기는 package.json 64줄입니다. 참고로 이 사이트맵 설정은 Gatsby 앱 하나를 SITE 환경변수로 여섯 번 빌드하는 구조 위에 얹혀 있어서, 사이트마다 따로 나가고 사이트마다 따로 틀릴 수 있습니다.

이 글 공유하기xXfacebook페이스북