sitemap.xml 은 검색엔진에게 "이 사이트에 이런 URL 들이 있다"고 알려주는 파일입니다. 형식은 단순하고, 그래서 처음에는 손으로 써도 되는 파일처럼 보입니다.

실제로 그렇게 시작했습니다. 그리고 곧 이렇게 됐습니다.

글 하나 추가 → ko 항목 추가 en 항목 추가 각각 hreflang 두 줄씩 lastmod 날짜 총 20 줄, 손으로

문제는 분량이 아니라 어긋나도 아무 일이 안 일어난다는 것입니다. 항목을 빠뜨리면 빌드는 성공하고, 사이트는 정상이고, 그 URL 만 검색엔진에게 안 보입니다. 몇 주 뒤 검색 콘솔을 볼 때까지 아무도 모릅니다.

1. sitemap 은 문서가 아니라 산출물이다

지금 이 저장소에서 sitemap.xml저장소에 존재하지 않습니다. 빌드가 만듭니다.

src/sitemap.njk ← 템플릿 (사람이 읽는 것) _site/sitemap.xml ← 산출물 (빌드가 만드는 것, gitignore)

이 구분이 핵심입니다. sitemap 은 사이트 상태를 표현한 결과물이지 사이트와 나란히 존재하는 별도의 사실이 아닙니다. 두 개의 사실이 있으면 둘은 반드시 어긋납니다.

2. 유도할 수 있는 것은 유도한다

글의 URL 은 이미 저장소 어딘가에 다 있습니다. 파일이 있고, front matter 에 slugdate 가 있습니다. sitemap 에 그걸 다시 적는 것은 같은 사실을 두 번 적는 것입니다.

그래서 글은 컬렉션에서 그대로 꺼냅니다.

{%- for post in collections.sorted_ko %} {%- for lang in ["ko", "en"] %} <url> <loc>{{ site.url }}/{{ lang }}/posts/{{ post.data.slug }}.html</loc> <lastmod>{{ (post.data.sitemapLastmod or post.data.date) | isoDate }}</lastmod> ...

여기서 로케일 루프가 안쪽에 있는 게 의도적입니다. 글 목록은 sorted_ko 하나만 돌고, ko/en URL 은 그 슬러그에서 만들어냅니다. 두 로케일의 목록을 각각 돌면 한쪽에만 있는 글이 조용히 sitemap 에 절반만 들어갑니다. 한 목록에서 양쪽을 만들면 그런 상태 자체가 불가능합니다.

이게 가능한 이유는 빌드 가드가 ko/en 대칭을 이미 강제하기 때문입니다. 가드가 있으니 템플릿은 대칭을 가정해도 안전합니다. 검증을 앞단에서 한 번 하면 뒷단이 단순해집니다.

3. 예약 게시가 자동으로 따라온다

이 구조에서 공짜로 얻은 게 있습니다. 예약 게시된 글은 sitemap 에도 자동으로 안 들어갑니다.

미래 날짜의 글은 컬렉션에서 빠지도록 되어 있고, sitemap 은 그 컬렉션을 돌기 때문입니다. 따로 조건을 쓴 곳이 없습니다.

글이 sitemap 에 들어가는 조건 = 컬렉션에 들어가는 조건 = 페이지가 생성되는 조건

손으로 관리했다면 이건 매번 신경 써야 하는 규칙이 됩니다. 아직 안 나온 글의 URL 을 sitemap 에 올리면 검색엔진이 와서 404 를 받습니다. 조건을 한 곳에 두면 그런 규칙 자체가 사라집니다.

같은 판단이 두 곳에 있으면 언젠가 갈라집니다.

"게시됐는가"를 페이지 생성과 sitemap 이 각자 판단하면, 둘 중 하나만 고쳤을 때 조용히 어긋납니다.

4. 유도할 수 없는 것은 선언한다

글이 아닌 페이지는 사정이 다릅니다. 랜딩, 정책, lab 페이지는 통과 복사되는 .html 파일이라 front matter 도 컬렉션도 없습니다. 파일 목록만 봐서는 sitemap 에 넣을 페이지인지 아닌지 판단할 수 없습니다.

실제로 src/ 아래에는 넣으면 안 되는 .html 도 있습니다.

넣어야 함: ko/index.html, ko/lab/index.html, ko/privacy-policy.html 넣으면 안 됨: src/index.html 로케일 라우팅용 noindex fallback google4027544127224681.html 검색엔진 소유권 확인 파일 naverb8f2761613db3a60ee4cb25e02a3499f.html

파일 시스템에는 이 둘을 가르는 신호가 없습니다. 확장자도 위치도 같습니다. 차이는 의도에만 있습니다.

그래서 이 부분은 목록으로 선언합니다.

export default [ { path: "/", lastmod: "2026-07-22", changefreq: "weekly", priority: "0.9" }, { path: "/posts/", ... }, { path: "/lab/", ... }, { path: "/lab/fitness-type/", ... }, { path: "/privacy-policy.html", ... }, { path: "/account-deletion.html", ... }, ];

여기서도 로케일 접두사는 적지 않습니다. path/lab/ 이고 템플릿이 /ko/lab//en/lab/ 을 만듭니다. 손으로 적는 줄 수가 절반이 되고, 한쪽 로케일만 적는 실수가 구조적으로 불가능해집니다.

어떤 페이지가 어느 쪽인가

"유도할 수 있는가"는 페이지 종류마다 답이 정해져 있습니다. 이 사이트 기준으로 정리하면 이렇습니다.

페이지 종류 sitemap 에 출처 판정 근거 글 (posts/*.njk) 들어감 컬렉션 front matter 에 사실이 있음 글 목록 (index.njk) 들어감 staticPages.js 템플릿이라 front matter 가 없음 카테고리 목록 들어감 staticPages.js 위와 같음 랜딩 (ko/index.html) 들어감 staticPages.js 통과 복사라 메타가 없음 lab 목록·상세 들어감 staticPages.js 통과 복사 정책·계정 삭제 들어감 staticPages.js 통과 복사 루트 fallback (src/) 안 들어감 - noindex 가 의도 소유권 확인 파일 안 들어감 - 검색엔진 전용 robots.txt · app-ads.txt 안 들어감 - HTML 이 아님

가운데 다섯 줄이 전부 staticPages.js 로 몰립니다. 목록이 처음 여섯 줄이었는데 지금은 여덟 줄입니다 — 카테고리별 글 목록(/posts/athlentic/, /posts/dev/)이 나중에 생기면서 두 줄이 늘었습니다. 이 여덟 줄이 로케일 두 벌로 펼쳐져 sitemap 의 16 개 URL 이 됩니다.

주목할 건 위 표에서 "안 들어감" 세 줄의 판정 근거가 전부 파일 밖에 있다는 점입니다. 확장자도 위치도 들어가는 것들과 다르지 않습니다. 소유권 확인 파일은 이름이 특이해서 눈으로는 구분되지만, 그건 사람의 지식이지 코드가 읽을 수 있는 신호가 아닙니다.

5. 남은 수기 목록은 남아 있다고 적는다

staticPages.js 가 이 저장소에서 sitemap 관련으로 빌드가 잡아주지 않는 유일한 부분입니다. 페이지를 옮기거나 지우면 이 목록을 같이 고쳐야 하는데, 빌드는 모릅니다.

여기서 두 가지 선택지가 있었습니다.

  • 가드를 만든다 — 목록의 path 가 실제 파일과 대응하는지 검사
  • 수기임을 명시한다 — 파일 맨 위에 주석으로, 규칙 문서에 항목으로

지금은 두 번째입니다. 목록에 있는데 파일이 없는 경우는 검사할 수 있지만, 파일이 있는데 목록에 없는 경우는 그게 실수인지 의도인지 빌드가 판단할 수 없기 때문입니다. 위의 google*.html 이 정확히 그 경우입니다.

절반만 검사하는 가드는 "빌드가 통과했으니 sitemap 은 맞다"는 착각을 만듭니다. 그 착각이 검사 없는 상태보다 위험합니다. 그래서 검사를 넣지 않고, 대신 여기가 수기라는 사실을 눈에 띄게 적어뒀습니다.

// 페이지를 추가/삭제/이동하면 이 목록도 같이 고친다 — // sitemap 에서 빌드가 대신 잡아주지 않는 유일한 부분이다.

이 목록이 어긋나는 네 가지 경로

"어긋난다"를 뭉뚱그리면 대비가 안 됩니다. 실제로 벌어지는 형태는 네 가지고, 각각 증상이 다릅니다.

어긋나는 방식 sitemap 이 가리키는 것 Search Console 증상 페이지 추가 + 목록에 안 넣음 아무것도 (누락) 해당 URL 이 아예 없음 페이지 이동 + path 를 안 고침 리다이렉트되는 URL "페이지에 리다이렉트가 있음" 페이지 삭제 + 목록에 남김 404 "찾을 수 없음(404)" 한 로케일에만 페이지 생성 한쪽만 404 en 쪽만 404

첫 줄이 가장 조용합니다. 없는 것은 오류로 보고되지 않습니다. 나머지 셋은 검색 콘솔이 언젠가 빨간 줄로 알려주지만, 누락은 아무 데도 안 뜹니다. 그 URL 이 색인되지 않았다는 사실을 알아차리려면 있어야 할 목록을 따로 알고 있어야 합니다.

두 번째와 세 번째는 경로를 옮기거나 지울 때 세트로 따라옵니다. 리다이렉트는 기억나는데 이 목록은 기억나지 않습니다. sitemap 이 리다이렉트되는 URL 을 계속 올리는 건 "이 주소를 색인하라"고 말한 뒤 크롤러가 오면 "저쪽이다"라고 답하는 셈입니다.

네 번째가 이 설계의 대가입니다. 로케일 접두사를 뺀 덕분에 "한쪽만 적는 실수"는 사라졌지만, 대신 "한쪽 파일만 만드는 실수"가 그 자리에 들어왔습니다. path 를 한 줄 적으면 sitemap 은 ko·en 두 URL 을 무조건 만듭니다. 그런데 실제 파일을 ko 에만 만들었다면 en 쪽 URL 은 404 입니다. 글은 빌드 가드가 대칭을 강제하지만 통과 복사되는 .html 은 검사 대상이 아닙니다.

수기 목록을 없애지 못한 대신 목록이 짧아서 눈으로 훑을 수 있다는 게 유일한 방어입니다. 여덟 줄은 읽힙니다. 여든 줄이었으면 이 방어는 성립하지 않았을 겁니다.

6. lastmod 는 쓰이고 priority 는 안 쓰인다

목록의 각 줄에는 lastmod · changefreq · priority 세 값이 붙어 있습니다. 셋을 똑같이 정성껏 관리하고 싶어지는데, 검색엔진이 실제로 읽는 정도는 셋이 전혀 다릅니다.

필드 Google 이 쓰는가 조건 lastmod 쓴다 값이 일관되게 정확할 때만 changefreq 무시한다 - priority 무시한다 -

Google 은 changefreqpriority 를 보지 않는다고 공식적으로 밝혀 뒀습니다. 사이트 안에서 어느 페이지가 더 중요한지는 sitemap 에 적은 숫자가 아니라 내부 링크 구조로 판단합니다. priority: "0.9" 를 전 페이지에 적어도 아무 일이 일어나지 않습니다.

lastmod 는 다릅니다. 다만 조건이 붙습니다 — 믿을 수 있을 때만 쓴다는 것입니다. 빌드할 때마다 전 페이지의 lastmod 를 오늘로 갱신하면 크롤러는 곧 그 값을 무시하기 시작합니다. "매일 전부 바뀐다"는 신호는 정보가 없는 신호입니다.

lastmod 를 빌드 시각으로 채우지 마세요.

글의 lastmodsitemapLastmod or date 에서 나옵니다. 내용을 실제로 고쳤을 때만 sitemapLastmod 를 갱신하고, 오타 하나 고친 배포로는 건드리지 않습니다. 정확한 날짜만이 재크롤링을 앞당깁니다.

그럼 changefreqpriority 는 왜 아직 적혀 있느냐 — 규격에 있는 필드고, Google 외의 크롤러가 참고할 여지가 남아 있고, 지우는 이득보다 목록 형태를 유지하는 이득이 크기 때문입니다. 다만 이 값을 조정해서 순위를 움직이려는 시도는 하지 않습니다. 그런 지렛대가 아닙니다.

7. sitemap 하나로는 색인이 결정되지 않는다

sitemap 을 정확하게 만들어두면 색인이 보장될 것 같지만, sitemap 은 여러 신호 중 하나일 뿐입니다. 그리고 다른 신호와 모순되면 sitemap 쪽이 집니다.

sitemap canonical robots/meta 결과 포함 자기 자신 허용 정상 — 의도대로 색인 요청 포함 다른 URL 허용 모순 — 대개 canonical 쪽을 따른다 포함 - noindex 모순 — 색인 안 됨, 신호만 낭비 포함 - robots.txt 차단 크롤 자체를 못 함 제외 자기 자신 허용 색인은 되지만 발견이 늦다 포함 - 301 리다이렉트 모순 — 색인은 목적지로 간다

세 번째 줄이 이 사이트에서 실제로 조심하는 조합입니다. 루트 src/index.htmlnoindex 이고, 그래서 staticPages.js없습니다. 둘 중 하나만 해두면 신호가 갈라집니다.

네 번째 줄은 더 사납습니다. robots.txt 로 막힌 URL 은 크롤러가 페이지를 열지 못하므로 안에 적힌 noindex 를 읽을 방법이 없습니다. 색인에서 빼려고 막았는데 오히려 색인에 남는 상태가 됩니다. 색인에서 빼고 싶으면 크롤은 허용하고 noindex 를 읽히는 게 맞습니다.

다섯 번째 줄은 오류가 아니라 손해입니다. sitemap 에 없어도 링크만 있으면 결국 색인됩니다. sitemap 이 하는 일은 "색인시켜라"가 아니라 "여기 있으니 빨리 와라"에 가깝습니다.

8. 경계를 어디에 긋는가

이 작업에서 얻은 판단 기준은 이렇습니다.

유도한다 — 다른 곳에 이미 사실이 있을 때 글 URL ← 파일 이름 발행일 ← front matter 게시 여부 ← 컬렉션 포함 여부 로케일 쌍 ← 대칭이 보장됨 선언한다 — 사실이 코드 어디에도 없을 때 이 페이지를 색인시킬 것인가 ← 의도 우선순위·변경 주기 ← 판단

핵심 질문은 "이 정보가 이미 다른 데 있는가" 입니다. 있으면 유도하고, 없으면 선언합니다. 있는데도 다시 적는 게 가장 나쁩니다 — 그 순간 두 곳이 갈라질 수 있는 상태가 만들어집니다.

반대로 없는 걸 억지로 유도하려는 것도 문제입니다. "파일이 있으면 다 sitemap 에 넣는다"는 규칙은 간단해 보이지만, 소유권 확인 파일까지 색인 요청을 보내게 됩니다. 의도는 코드에서 유도되지 않습니다.

9. 빌드 뒤에 확인하는 것

sitemap 이 산출물이 되면 사람이 고칠 자리가 없어지는 대신 눈으로 볼 자리도 없어집니다. 저장소에 파일이 없으니 diff 에도 안 나옵니다. 그래서 빌드 뒤에 산출물을 직접 봅니다.

npm run build # 1. URL 개수가 예상과 맞는가 grep -c '<url>' _site/sitemap.xml 48 # 2. 글이 아닌 URL 만 뽑아 목록과 대조 grep -oP '<loc>\K[^<]+' _site/sitemap.xml \ | grep -v '/posts/[a-z0-9-]*\.html' 16 개 — staticPages.js 8 줄 × 로케일 2 # 3. sitemap 의 모든 URL 이 실제 파일과 대응하는가 # (_site 아래에 해당 .html / index.html 이 있는지)

1번의 숫자는 암산으로 검산됩니다. 이 글을 쓰는 시점에 게시된 글이 16 편이고 로케일 두 벌이라 32, 여기에 비-포스트 16 을 더해 48 입니다. 숫자가 어긋나면 대개 예약 게시 경계에 걸린 글이 있다는 뜻이고, 그건 오류가 아니라 정상 동작입니다 — 그래서 개수만 보고 놀라지 않으려면 계산이 되어야 합니다.

3번이 앞서 말한 네 가지 어긋남을 실제로 잡는 유일한 검사입니다. 빌드는 이걸 하지 않습니다. 페이지를 추가·이동·삭제한 배포에서만 돌리면 충분합니다 — 글만 추가한 배포에서는 어긋날 수 있는 부분이 없습니다.

여기까지가 빌드 산출물을 보는 검사고, 색인이 실제로 어떻게 됐는지는 며칠 뒤 검색 콘솔에서만 보입니다. 그 간격 때문에 sitemap 오류는 늘 고친 사람이 잊어버린 뒤에 보고됩니다. 배포 직후에 한 번 보는 게 훨씬 쌉니다.

10. 자주 묻는 것

sitemap 을 저장소에 커밋해두면 안 되나요?
됩니다. 다만 그 순간 같은 사실이 두 곳에 존재하게 됩니다 — 실제 페이지와 커밋된 sitemap. 둘이 갈라지는 건 시간 문제고, 갈라져도 빌드는 성공합니다. 커밋할 거라면 최소한 빌드가 생성한 파일을 커밋하고, 손으로 편집하지는 않는 규칙을 세워야 합니다.

URL 이 많아지면 sitemap 을 나눠야 하나요?
한 파일에 URL 50,000 개, 압축 전 50MB 까지입니다. 넘으면 sitemap 을 여러 개로 쪼개고 sitemap 인덱스로 묶습니다. 이 사이트는 48 개라 한참 멉니다. 미리 쪼개는 건 이득 없이 구조만 늘리는 일입니다.

검색엔진에 sitemap 을 매번 알려야 하나요?
아닙니다. robots.txtSitemap: 한 줄을 적어두면 크롤러가 알아서 찾아갑니다. 배포마다 핑을 보내는 건 대부분 불필요하고, Google 은 사이트맵 핑 엔드포인트를 이미 없앴습니다.

예약 게시된 글의 URL 을 미리 넣어두면 발행일에 빨리 색인되지 않나요?
반대입니다. 아직 페이지가 없는 URL 을 sitemap 에 올리면 크롤러가 와서 404 를 받고, 그 URL 에 대한 신뢰가 떨어집니다. 예약 게시에서 페이지 생성과 sitemap 포함을 같은 조건으로 묶은 이유가 이것입니다.

hreflang 은 sitemap 에 넣어야 하나요, <head> 에 넣어야 하나요?
둘 중 하나면 됩니다. 이 사이트는 양쪽 다 넣습니다 — sitemap 쪽은 템플릿이 로케일 루프로 만들어내니 공짜고, <head> 쪽은 레이아웃이 만들어내니 역시 공짜이기 때문입니다. 손으로 적어야 했다면 하나만 골랐을 겁니다. 공짜일 때만 중복이 무해합니다.

목록이 실제 파일과 맞는지 검사하는 가드를 결국 넣어야 하지 않나요?
넣고 싶어지는 지점이 어디인지는 위의 네 가지 표로 분명해집니다. "목록에 있는데 파일이 없다"는 언제나 오류라서 검사가 가능하고, 두 번째·세 번째·네 번째가 여기에 걸립니다. 그래도 지금 넣지 않는 이유는 바뀌지 않았습니다 — 잡히지 않는 첫 번째(추가하고 목록에 안 넣음)가 가장 조용하고 가장 흔한 실수인데, 통과한 빌드는 그걸 통과했다고 말해버립니다. 절반을 검사하면서 전부를 검사한 것처럼 보이는 게 문제고, 그 착각의 비용이 목록 여덟 줄을 눈으로 확인하는 비용보다 큽니다. 목록이 눈으로 훑기 어려울 만큼 길어지면 그때 계산이 뒤집힙니다.

11. 정리

  • sitemap 은 문서가 아니라 산출물이다. 저장소에 두지 않고 빌드가 만든다.
  • 다른 곳에 이미 있는 사실은 유도한다. 두 번 적는 순간 갈라질 수 있게 된다.
  • 로케일은 한 목록에서 양쪽을 만든다. 한쪽만 들어가는 상태를 구조적으로 없앤다.
  • 게시 판정은 한 곳에서만. 페이지 생성과 sitemap 이 같은 조건을 쓰면 예약 게시가 공짜로 따라온다.
  • 의도는 선언한다. 색인시킬지 말지는 파일 시스템에서 나오지 않는다.
  • 절반짜리 가드는 넣지 않는다. 대신 수기라는 사실을 눈에 띄게 적고, 목록을 눈으로 훑을 수 있는 길이로 유지한다.
  • lastmod 만 관리한다. changefreqpriority 는 Google 이 무시한다. 빌드 시각으로 lastmod 를 채우면 그것마저 무시된다.
  • sitemap 은 다른 신호와 모순되면 진다. noindex 인 페이지는 목록에서도 빼야 신호가 갈라지지 않는다.
  • 산출물은 diff 에 안 나오므로 빌드 뒤에 직접 본다. URL 개수를 암산으로 검산하는 게 가장 싼 검사다.

손으로 쓴 목록이 실제와 어긋나는 건 부주의의 문제가 아니라 시간의 문제입니다. 충분히 오래 유지되는 수기 목록은 반드시 어긋납니다. 유도할 수 있는 것을 유도해두면, 남은 수기 목록이 짧아져서 그것만은 계속 지킬 수 있게 됩니다.