오시레이더는 11월 1일까지 검색으로 들어온 방문이 0이었습니다. 페이지는 사이트맵에 다 들어 있었지만, 검색엔진이 언제 와서 읽어 갈지는 검색엔진 마음입니다.
구글은 Search Console 에서 주소를 하나씩 색인 요청할 수 있지만, 하루에 할 수 있는 수가 많지 않습니다. 그래서 검색엔진을 나눠서 대응하기로 했습니다.
- 구글 — 사이트맵 + Search Console 수동 요청 (그대로)
- Bing · 네이버 — IndexNow 로 바뀐 주소를 자동으로 알림
이 글은 IndexNow 를 붙인 과정과, 언제 보내고 언제 안 보내게 했는지의 기록입니다. 서버는 Python(FastAPI)이지만 요청은 HTTP 하나라 어떤 언어로도 똑같습니다.
IndexNow 는 무엇인가 — 한 번 알리면 참여 검색엔진이 나눠 갖는다
IndexNow 는 "이 주소가 바뀌었다"를 사이트 쪽에서 검색엔진에 먼저 알리는 약속입니다. 검색 로봇이 찾아올 때까지 기다리지 않아도 됩니다.
좋은 점은 한 곳에만 보내면 된다는 것입니다. api.indexnow.org 로 보내면 IndexNow 에 참여한 검색엔진(Bing · 네이버 · Yandex 등)이 그 알림을 나눠 갖습니다. 네이버도 2023년부터 IndexNow 를 받습니다.
다만 구글은 IndexNow 를 쓰지 않습니다. 구글은 여전히 사이트맵과 Search Console 로 챙겨야 합니다.
준비물 — 키 파일 하나, 키는 비밀이 아니다
IndexNow 는 "이 사이트의 주인이 보낸 알림"인지를 키 파일로 확인합니다.
- 키를 하나 정합니다 (영문 · 숫자로 된 긴 문자열, 보통 32자 16진수)
- 사이트 루트에
/<키>.txt파일을 두고, 내용에 키를 그대로 적습니다 - 알릴 때 같은 키를 보냅니다 → 검색엔진이 그 주소의 파일을 열어 맞는지 봅니다
Next.js 라면 public/<키>.txt 에 두기만 하면 됩니다.
public/95e1c96517ace2e34c51f9421025bbcd.txt ← 내용: 95e1c96517ace2e34c51f9421025bbcd
키는 비밀이 아닙니다. 그래서 .env 에 숨기지 않았습니다. 어차피 누구나 열어 볼 수 있는 주소에 공개해야 동작하는 값입니다. 그래서 서버 코드에도 기본값으로 그대로 넣었습니다. 남이 이 키로 알림을 보내도 제 사이트 주소만 알릴 수 있으니 문제가 없습니다.
키 파일이 실제로 보이는지는 이렇게 확인합니다.
curl https://vtuber.finisxh.com/95e1c96517ace2e34c51f9421025bbcd.txt
보내는 요청 — JSON 으로 POST 한 번
요청은 하나입니다. 주소 목록을 JSON 으로 POST 합니다.
ENDPOINT = "https://api.indexnow.org/indexnow"
MAX_URLS = 10_000 # 한 번에 보낼 수 있는 주소 수
async def submit(http: httpx.AsyncClient, urls: list[str]) -> bool:
"""IndexNow 로 보낸다. 성공 여부만 — 던지지 않는다."""
host = urlparse(config.site_url).netloc
ok = True
for i in range(0, len(urls), MAX_URLS):
body = {"host": host, "key": config.indexnow_key,
"keyLocation": f"{config.site_url}/{config.indexnow_key}.txt",
"urlList": urls[i:i + MAX_URLS]}
try:
r = await http.post(ENDPOINT, json=body, timeout=15)
except httpx.HTTPError as e:
logger.warning("IndexNow 실패: 연결 실패(%s)", type(e).__name__)
return False
if r.status_code not in (200, 202):
logger.warning("IndexNow 거절: HTTP %s", r.status_code)
ok = False
return ok
| 필드 | 값 |
|---|---|
host | 사이트 도메인 (vtuber.finisxh.com) |
key | 키 |
keyLocation | 키 파일 주소 |
urlList | 알릴 주소 목록 (모두 host 안의 주소) |
응답 코드는 이렇게 봅니다.
| 코드 | 뜻 |
|---|---|
| 200 | 받았음 |
| 202 | 받았고, 키는 아직 확인 중 |
| 403 | 키가 맞지 않음 — 대개 키 파일이 안 보이는 경우 |
| 422 | 목록에 host 밖의 주소가 섞임 |
| 429 | 너무 자주 보냄 |
403 이 나오면 먼저 위의 curl 로 키 파일부터 확인합니다. 배포가 덜 됐거나, 경로가 루트가 아닌 경우가 대부분입니다.
언제 보내나 — 바뀔 때만, 하루 한 번
요청 자체는 간단하고, 고민은 언제 보내느냐였습니다. 네 가지를 정했습니다.
1. 페이지가 실제로 바뀔 때
오시레이더는 페이지를 미리 그려 두고(ISR), 내용이 바뀌면 웹에 "이 페이지를 새로 그려라"는 신호를 보냅니다. 그 신호와 같은 때에 IndexNow 도 보내게 했습니다. 새로 그려야 할 만큼 바뀐 페이지가 곧 검색엔진에 알릴 페이지라서, 따로 "무엇이 바뀌었나"를 계산할 필요가 없었습니다.
# 웹 새로 그리기 신호를 보낸 직후 — 실패해도 로그만
await indexnow.notify_tags(http, s, tags)
2. 사이트맵과 같은 범위만
사이트맵에서 일부러 뺀 페이지(정보가 적어 검색에 내지 않기로 한 페이지)를 IndexNow 로 알리면 앞뒤가 맞지 않습니다. 그래서 알리기 전에 사이트맵에 들어가는 페이지인지 한 번 거릅니다. 사이트맵 기준을 바꾸면 이쪽도 같이 바꿔야 해서, 코드 주석에 "웹 sitemap 과 같게"를 남겨 두었습니다.
3. 같은 주소는 하루에 한 번
버튜버 상세 페이지는 방송이 켜지고 꺼질 때마다 새로 그려집니다. 그때마다 알리면 같은 주소를 하루에도 여러 번 보내게 됩니다. 검색엔진이 하루에 몇 번씩 다시 읽을 이유는 없으니, 오늘(한국 시간) 이미 보낸 주소는 건너뜁니다.
_sent: dict[str, date] = {}
def fresh(urls: list[str], now: datetime | None = None) -> list[str]:
"""오늘(KST) 아직 안 보낸 주소만 — 보낸 것으로 적는다."""
today = ((now or datetime.now(UTC)) + KST).date()
out = [u for u in dict.fromkeys(urls) if _sent.get(u) != today]
for u in out:
_sent[u] = today
return out
기록은 DB 가 아니라 프로세스 메모리에 둡니다. 서버가 다시 켜지면 잊어버려서 같은 날 한 번 더 보낼 수는 있지만, 그 정도는 문제가 되지 않아 단순한 쪽을 골랐습니다.
4. 운영에서만, 실패해도 조용히
- 로컬이나 테스트에서 운영 주소를 알리면 안 되니 운영 환경에서만 켭니다.
- IndexNow 가 실패해도 로그만 남기고 넘어갑니다. 검색엔진 알림은 있으면 좋은 것이지, 페이지를 새로 그리는 본래 일을 막을 이유가 없습니다. 그래서
submit은 예외를 던지지 않고 성공 여부만 돌려줍니다.
처음 한 번은 사이트맵 전부
평소엔 바뀐 페이지만 알리니, 붙인 날엔 이미 있던 페이지를 한 번에 알려야 합니다. 사이트맵을 읽어 주소를 전부 보내는 작은 스크립트를 만들었습니다.
LOC = re.compile(r"<loc>\s*([^<\s]+)\s*</loc>")
async def sitemap_urls(http: httpx.AsyncClient, url: str, depth: int = 0) -> list[str]:
r = await http.get(url, timeout=20)
r.raise_for_status()
locs = LOC.findall(r.text)
if "<sitemapindex" not in r.text or depth > 1:
return locs
out: list[str] = []
for child in locs: # 사이트맵 색인이면 안의 사이트맵까지 따라간다
out += await sitemap_urls(http, child, depth + 1)
return out
- 사이트맵이 여러 개로 나뉘어 있으면(
<sitemapindex>) 안의 사이트맵까지 따라갑니다. - 기본은 미리보기입니다. 몇 개를 보낼지와 예시 주소만 찍고,
--apply를 붙여야 실제로 보냅니다. 사이트맵 정리를 크게 한 뒤에도 한 번 더 돌리면 됩니다.
진짜로 보내지 않고 어떻게 확인했나 — 테스트 세 개
검색엔진 알림은 잘못 보내도 화면에 아무것도 안 나타납니다. 엉뚱한 주소를 보내거나, 로컬에서 운영 주소를 알리거나, 같은 주소를 수십 번 보내도 눈치채기 어렵습니다. 그래서 위에서 정한 규칙을 그대로 테스트 세 개로 옮겼습니다.
| 테스트 | 확인하는 것 |
|---|---|
| 사이트맵 범위 | 사이트맵에 들어가는 사람만 남고, 빠지는 사람 · 없는 주소는 걸러지는가 |
| 하루 한 번 | 같은 날 두 번째는 빠지고, 한국 시간으로 날짜가 바뀌면 다시 보내는가 |
| 보내는 모양 · 운영만 | JSON 이 규격대로인가, 운영이 아니면 아예 안 보내는가 |
"하루 한 번" 테스트는 시각을 직접 넣어서 확인합니다. 함수가 지금 시각을 인자로 받기 때문에 가능합니다.
def test_once_a_day() -> None:
now = datetime(2026, 11, 1, 3, 0, tzinfo=UTC)
assert indexnow.fresh(["a", "b", "a"], now) == ["a", "b"]
assert indexnow.fresh(["a", "c"], now) == ["c"]
# 다음 날(KST)이면 다시
assert indexnow.fresh(["a"], datetime(2026, 11, 1, 16, 0, tzinfo=UTC)) == ["a"]
UTC 16시는 한국 시간으로 다음 날 새벽 1시입니다. 날짜를 UTC 로 자르면 한국 시간 오전 9시에 하루가 바뀌어 버리니, 한국 시간 자정을 넘는 경우를 테스트에 넣었습니다.
"보내는 모양" 테스트는 실제 네트워크 대신 httpx 의 MockTransport 로 요청을 가로챕니다. 가로챈 JSON 을 그대로 비교하니, 한글 주소가 %EC%95%84… 처럼 인코딩된 채로 들어가는지도 함께 확인됩니다. 같은 테스트에서 환경을 개발로 바꾼 뒤 한 번 더 보내 보고, 그때는 요청이 하나도 나가지 않는 것까지 봅니다.
def handler(req: httpx.Request) -> httpx.Response:
seen.append(json.loads(req.content)) # 보낸 JSON 을 모아 둔다
return httpx.Response(202)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
ok = await indexnow.submit(http, [indexnow.streamer_url("아카네-리제")])
구글은 따로 — 중요한 페이지만 손으로
IndexNow 를 붙여도 구글 쪽 일은 그대로 남습니다. Search Console 의 수동 색인 요청은 하루에 10개 남짓이라, 모든 페이지를 요청할 수는 없습니다. 그래서 페이지에 순위를 매겨 두었습니다.
- 1순위 — 홈, 전체 목록, 순위 · 지도 같은 대표 페이지, 큰 소속사 페이지
- 2순위 — 나머지 소속사, 덜 찾는 순위, 많이 찾는 버튜버 상세, 소개 페이지
- 나머지 — 버튜버 상세 수백 개는 사이트맵에 맡김
검색엔진마다 할 일은 이렇게 나눴습니다.
| 곳 | 할 일 |
|---|---|
| Google Search Console | 사이트맵 상태 확인 → 'URL 검사'에 1순위부터 하나씩 색인 요청 |
| 네이버 서치어드바이저 | 사이트맵 제출 → 웹 페이지 수집 요청에 1 · 2순위 (IndexNow 와 별개로) |
| Bing 웹마스터 도구 | 사이트맵 확인 → 평소엔 IndexNow 가 자동으로 알림 |
요청하기 전에는 그 페이지들의 제목 · 설명 · canonical 이 제대로 나오는지 점검 스크립트로 먼저 확인합니다.
이 블로그에는 아직 안 붙였습니다
이 블로그는 빌드 때 정적 페이지로 굽고 서버가 없어서, "바뀐 순간"에 알릴 곳이 없습니다. 붙인다면 배포가 끝난 뒤 새 글 주소만 보내는 단계를 배포 작업에 넣는 방식이 될 것 같습니다. 글이 하루 한 편 정도라 지금은 네이버 서치어드바이저에서 직접 수집 요청을 하고 있습니다.
정리
| 항목 | 정한 것 |
|---|---|
| 대상 검색엔진 | Bing · 네이버 등 IndexNow 참여 엔진 (구글은 사이트맵 · Search Console) |
| 키 | 비밀 아님 — public/<키>.txt 로 공개, 서버 코드에 기본값 |
| 보내는 때 | 페이지 새로 그리기 신호와 같은 때 |
| 범위 | 사이트맵에 들어가는 페이지만 |
| 중복 | 같은 주소는 하루 한 번 (메모리 기록) |
| 환경 | 운영에서만 |
| 실패 | 로그만, 본래 작업은 그대로 |
| 처음 | 사이트맵 전부 한 번 (기본 미리보기) |
IndexNow 는 붙이는 데 키 파일 하나와 POST 한 번이면 되지만, 아무 때나 보내지 않게 정하는 쪽이 일의 대부분이었습니다. 색인과 검색 유입이 실제로 얼마나 달라졌는지는 숫자가 쌓인 뒤 Bing 웹마스터 도구와 네이버 서치어드바이저에서 따로 확인해 볼 생각입니다.