배포할 때 새 글만 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
만든 키는 두 곳에 둡니다.
- 사이트 루트의 키 파일:
public/<IndexNow키>.txt파일에 키 문자열 한 줄만 적습니다. 배포되면https://sncstudio.net/<IndexNow키>.txt로 열립니다. 검색엔진은 이 파일을 열어 키가 맞는지 확인합니다. - 전송 스크립트에서 쓸 상수:
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에 들어 있어서, 번역본을 추가하거나 고쳐도 같은 방식으로 함께 전송됩니다.