배포할 때 새 글만 IndexNow로 자동 전송하는 방법 (네이버·Bing)

정적 블로그를 배포할 때마다 운영 중인 사이트맵과 새로 빌드한 사이트맵의 lastmod를 비교해, 새로 추가되거나 수정된 글 주소만 IndexNow로 자동 전송하는 방법을 정리합니다. 한 번 보내면 네이버·Bing 등에 함께 전달됩니다.

새 글이 검색에 늦게 반영되는 문제와 IndexNow

블로그에 새 글을 올려도 검색엔진이 그 글을 찾아오기까지는 시간이 걸립니다. 검색엔진의 수집 프로그램(크롤러)이 언제 다시 방문할지는 검색엔진이 정하기 때문입니다. 그동안 네이버 서치어드바이저나 Bing 웹마스터 도구에 들어가 글 주소를 하나씩 직접 제출하곤 했는데, 글을 올릴 때마다 하기에는 번거롭고 자주 잊어버렸습니다.

IndexNow는 이 과정을 자동으로 할 수 있게 해 주는 공개 규약입니다. 사이트 주인이 "이 주소가 새로 생겼거나 바뀌었다"고 검색엔진에 직접 알리는 방식입니다. 한 곳에 보내면 IndexNow에 참여한 다른 검색엔진에도 자동으로 공유됩니다1. 현재 참여 검색엔진에는 Bing, 네이버(Naver), Yandex, Seznam 등이 있고2, 구글은 참여하지 않습니다.

그래서 이 블로그의 배포 명령에 IndexNow 전송을 붙였습니다. 목표는 두 가지였습니다.

  • 배포할 때 자동으로 보낸다.
  • 전체 주소가 아니라 새로 추가되거나 수정된 글만 보낸다. 바뀌지 않은 주소를 매번 보내는 건 검색엔진 입장에서 의미 없는 요청입니다.

IndexNow 키 파일 준비

IndexNow로 주소를 보내려면, 그 사이트의 주인이라는 걸 증명할 키가 필요합니다. 키는 직접 만드는 문자열이고, 영문자·숫자·-로 8~128자여야 합니다1. 저는 32자리 16진수 문자열을 썼습니다. 터미널에서 이렇게 만들 수 있습니다.

openssl rand -hex 16

만든 키는 두 곳에 둡니다.

  1. 사이트 루트의 키 파일: public/<IndexNow키>.txt 파일에 키 문자열 한 줄만 적습니다. 배포되면 https://sncstudio.net/<IndexNow키>.txt로 열립니다. 검색엔진은 이 파일을 열어 키가 맞는지 확인합니다.
  2. 전송 스크립트에서 쓸 상수: src/lib/env.ts에 같은 값을 둡니다.
export const INDEXNOW_KEY = '<IndexNow키>'; // public/<키>.txt 와 같아야 함

키 파일은 누구나 볼 수 있는 공개 파일이라 비밀번호처럼 숨길 필요는 없습니다. 다만 두 곳의 값이 다르면 전송이 거절(403)됩니다.

사이트맵 lastmod로 바뀐 글만 고르기

"새로 추가되거나 수정된 글"을 어떻게 알아낼지가 핵심이었습니다. 따로 기록을 남기지 않고 사이트맵을 이용하기로 했습니다.

이 블로그의 글 사이트맵(post-sitemap.xml)에는 글마다 주소(loc)와 마지막 수정 시각(lastmod)이 들어 있습니다. lastmod는 글 frontmatter의 updatedAt 값입니다.

<url><loc>https://sncstudio.net/korea-covered-call-etf-comparison</loc><lastmod>2026-10-20T11:10:00.000Z</lastmod><changefreq>monthly</changefreq><priority>0.8</priority></url>

배포 직전에는 사이트맵이 두 개 있습니다.

  • 운영 중인 사이트맵: 지금 사이트에 올라가 있는, 지난번 배포 때의 상태
  • 새로 빌드한 사이트맵: out/post-sitemap.xml, 이번에 배포할 상태

둘을 비교해서 운영 쪽에 없는 주소는 새 글이고, lastmod가 달라진 주소는 수정된 글입니다. 그래서 기존 글을 고친 뒤 updatedAt을 바꾸면 그 글도 자동으로 다시 전송됩니다.

scripts/deploy.ts 전체 코드

package.json의 배포 명령은 빌드한 뒤 이 스크립트를 실행합니다.

"deploy": "bun run build && bun scripts/deploy.ts",

scripts/deploy.ts 전체 코드입니다.

// out/을 Cloudflare Pages에 배포하고, 새로 추가·수정된 포스트 URL을 IndexNow로 알린다.
// 배포 전 운영 중인 post-sitemap.xml과 새로 빌드한 out/post-sitemap.xml의 lastmod를 비교해 바뀐 URL만 보낸다.
// IndexNow는 네이버·Bing 등 참여 검색엔진에 공유된다(구글은 미지원).
import {readFileSync} from "node:fs";
import {spawnSync} from "node:child_process";
import {INDEXNOW_KEY, SITE_URL} from "../src/lib/env";

const SITEMAP_PATH = '/post-sitemap.xml';

function parseSitemap(xml: string): Map<string, string> {
  const entries = new Map<string, string>();
  for (const [, body] of xml.matchAll(/<url>([\s\S]*?)<\/url>/g)) {
    const loc = body.match(/<loc>([^<]+)<\/loc>/)?.[1];
    const lastmod = body.match(/<lastmod>([^<]+)<\/lastmod>/)?.[1] ?? '';
    if (loc) entries.set(loc, lastmod);
  }
  return entries;
}

async function fetchLiveSitemap(): Promise<Map<string, string> | null> {
  try {
    const res = await fetch(`${SITE_URL}${SITEMAP_PATH}`);
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return parseSitemap(await res.text());
  } catch (e) {
    console.warn(`[indexnow] 운영 사이트맵을 가져오지 못해 IndexNow 전송을 건너뜁니다: ${e}`);
    return null;
  }
}

async function submitIndexNow(urls: string[]) {
  const res = await fetch('https://api.indexnow.org/indexnow', {
    method: 'POST',
    headers: {'Content-Type': 'application/json; charset=utf-8'},
    body: JSON.stringify({
      host: new URL(SITE_URL).host,
      key: INDEXNOW_KEY,
      keyLocation: `${SITE_URL}/${INDEXNOW_KEY}.txt`,
      urlList: urls,
    }),
  });
  // 200: 접수, 202: 접수(키 검증 대기), 403: 키 불일치, 422: host와 URL 불일치
  console.log(`[indexnow] 응답 ${res.status} ${res.statusText}`);
  if (!res.ok) console.log(await res.text());
}

const live = await fetchLiveSitemap();
const built = parseSitemap(readFileSync(`out${SITEMAP_PATH}`, 'utf8'));
const changed = live ? [...built].filter(([loc, lastmod]) => live.get(loc) !== lastmod).map(([loc]) => loc) : [];

const deploy = spawnSync(
  'wrangler',
  ['pages', 'deploy', 'out', '--project-name=sncstudio-blog', '--branch=main', '--commit-dirty=true'],
  {stdio: 'inherit'},
);
if (deploy.status !== 0) process.exit(deploy.status ?? 1);

if (changed.length === 0) {
  console.log('[indexnow] 새로 추가·수정된 포스트가 없어 전송하지 않습니다.');
} else {
  console.log(`[indexnow] ${changed.length}개 URL 전송:\n  ${changed.join('\n  ')}`);
  await submitIndexNow(changed);
}

강조한 부분을 차례로 보겠습니다.

배포 전에 운영 사이트맵부터 받아 두기

순서가 중요합니다. 배포가 끝나면 운영 사이트맵이 새 사이트맵으로 바뀌어서 비교할 대상이 사라집니다. 그래서 배포하기 전에 운영 사이트맵을 먼저 받아 두고, 바뀐 주소 목록(changed)을 계산해 둡니다.

운영 사이트맵을 받지 못하면(네트워크 오류 등) 전송을 건너뜁니다. 비교할 기준이 없을 때 전체를 보내는 것보다는, 이번 한 번 건너뛰는 편이 안전하다고 봤습니다.

배포가 성공했을 때만 전송하기

배포는 Cloudflare의 wrangler pages deploy 명령으로 합니다. 배포가 실패하면 그 자리에서 종료합니다. 배포되지 않은 주소를 검색엔진에 알리면, 검색엔진이 찾아왔을 때 아직 없는 페이지(404)를 보게 되기 때문입니다.

api.indexnow.org로 한 번에 보내기

https://api.indexnow.org/indexnow는 IndexNow 공용 주소입니다3. 여기에 JSON으로 다음 값을 POST로 보냅니다.

  • host: 사이트 주소(sncstudio.net)
  • key: IndexNow 키
  • keyLocation: 키 파일 주소
  • urlList: 바뀐 글 주소 목록(한 번에 최대 10,000개)

응답 코드는 다음과 같은 뜻입니다1.

코드의미
200정상 접수
202접수됨, 키 확인은 아직 진행 중
400요청 형식이 잘못됨
403키가 올바르지 않음(키 파일을 못 찾았거나 값이 다름)
422주소가 host와 맞지 않음
429요청이 너무 많음

배포하면서 실제로 전송된 결과

bun run deploy 한 번으로 빌드, 배포, IndexNow 전송이 차례로 진행됩니다. 바뀐 글이 없으면 [indexnow] 새로 추가·수정된 포스트가 없어 전송하지 않습니다.만 출력하고 끝납니다.

주의사항

  • 구글은 IndexNow를 쓰지 않습니다. 구글은 Search Console에 사이트맵을 등록해 두고 크롤러가 알아서 가져가도록 두면 됩니다.
  • updatedAt을 바꿔야 수정 글이 전송됩니다. 글 내용을 고쳐도 updatedAt을 그대로 두면 lastmod가 같아서 바뀐 글로 잡히지 않습니다. 반대로 오타 하나 고칠 때마다 updatedAt을 바꾸면 그때마다 전송되니, 의미 있는 수정일 때만 바꾸는 편이 좋습니다.
  • 사이트맵 주소와 실제 주소가 같아야 합니다. 사이트맵에 끝 슬래시가 붙은 주소가 있고 실제로는 리다이렉트된다면, 검색엔진에 리다이렉트되는 주소를 알리게 됩니다. 주소 형태를 맞추는 방법은 Cloudflare Pages 끝 슬래시 리다이렉트 없애는 방법에 정리했습니다.
  • 이 블로그는 한국어·영어·일본어 글이 모두 하나의 post-sitemap.xml에 들어 있어서, 번역본을 추가하거나 고쳐도 같은 방식으로 함께 전송됩니다.

참조

Footnotes

  1. https://www.indexnow.org/documentation ↩ ↩2 ↩3

  2. https://www.indexnow.org/searchengines.json ↩

  3. https://www.indexnow.org/faq ↩

2026 © snc studio·개인정보처리방침