goodchuck 블로그

IndexNow 로 Bing · 네이버에 바뀐 페이지 바로 알리기 — 키 파일 하나와 POST 한 번

11분 읽기

오시레이더는 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 는 "이 사이트의 주인이 보낸 알림"인지를 키 파일로 확인합니다.

  1. 키를 하나 정합니다 (영문 · 숫자로 된 긴 문자열, 보통 32자 16진수)
  2. 사이트 루트에 /<키>.txt 파일을 두고, 내용에 키를 그대로 적습니다
  3. 알릴 때 같은 키를 보냅니다 → 검색엔진이 그 주소의 파일을 열어 맞는지 봅니다

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 웹마스터 도구와 네이버 서치어드바이저에서 따로 확인해 볼 생각입니다.