먼저 바로잡을 점: Google Indexing API는
JobPosting페이지 또는VideoObject안에BroadcastEvent가 포함된 라이브 스트리밍 페이지에만 사용할 수 있다. 일반 블로그 글을 이 API로 제출하면 안 된다. 일반 블로그 운영자는 사이트맵과 크롤링 가능한 내부 링크로 URL 발견을 돕고, Search Console의 URL Inspection API로 상태를 조회해야 한다. 이 방법도 크롤링이나 색인을 보장하지 않는다.
이 글은 일반 블로그의 색인 상태를 반복 점검하려는 운영자·개발자를 위한 가이드다. 기존 주소 https://joshua12.com/entry/41은 그대로 유지한다.
발행 본문
한눈에 보는 올바른 흐름
일반 블로그에서는 다음 순서로 운영한다.
- 색인시키려는 정규 URL을 XML 사이트맵에 넣는다.
- 관련 글 본문에 설명형 앵커 텍스트로 내부 링크를 만든다.
- 새 URL, 최근 수정 URL, 이전 검사에서 문제가 있었던 URL만 큐에 넣는다.
- URL Inspection API로 Google 색인의 현재 보유 정보를 조회한다.
PASS,미색인,다른 canonical 선택,차단,권한/요청 오류로 분류한다.- 원인을 수정한 뒤 변경 URL만 다시 큐에 넣는다.
URL Inspection API는 색인 요청 API가 아니다. API 응답은 Search Console의 URL 검사 도구에 표시되는 색인 버전 정보를 제공하지만, 라이브 URL 테스트 기능은 제공하지 않는다. 사이트맵 역시 URL 발견을 돕는 힌트이며 모든 URL의 크롤링·색인을 보장하지 않는다. URL Inspection API 공식 문서, 사이트맵 개요에서 이 범위를 확인할 수 있다. (확인일: 2026-07-20)
준비 사항
- Google Cloud 프로젝트
- 해당 프로젝트에서 활성화한 Search Console API
- Search Console 속성에 접근할 수 있는 서비스 계정 또는 Google 사용자
- Python 3.10 이상
- 사이트맵 URL(예:
https://example.com/sitemap.xml) - Search Console 속성 문자열
- URL-prefix 속성:
https://example.com/ - Domain 속성:
sc-domain:example.com
- URL-prefix 속성:
siteUrl은 Search Console에 등록된 속성 문자열과 정확히 같아야 하며, inspectionUrl은 그 속성 아래의 완전한 URL이어야 한다. 조회 전용 자동화에는 https://www.googleapis.com/auth/webmasters.readonly 범위를 사용한다. URL Inspection API 요청 본문·권한 범위 (확인일: 2026-07-20)
service account와 OAuth 사용자 인증은 어떻게 다른가
둘 다 최종적으로 OAuth 2.0 액세스 토큰을 사용하지만, 누가 권한의 주체인지와 토큰을 얻는 방식이 다르다.
| 구분 | service account | OAuth 사용자 인증 |
|---|---|---|
| 주체 | 자동화용 비인간 계정 | 동의 화면에서 로그인한 Google 사용자 |
| 적합한 환경 | 서버, 스케줄러, CI 작업 | 개인용 도구, 데스크톱 앱, 사용자를 대신하는 앱 |
| Search Console 권한 | 서비스 계정 이메일을 해당 속성의 사용자로 추가 | 로그인 사용자가 해당 속성 권한을 보유해야 함 |
| 초기 상호작용 | 일반적으로 없음 | 브라우저 로그인과 동의 필요 |
| 보관 대상 | 가능하면 키 없는 실행 환경을 우선하고, JSON 키를 쓴다면 비밀 저장소에 보관 | OAuth 클라이언트 구성과 발급된 사용자 토큰을 비밀로 보관 |
서비스 계정 JSON 키를 API 키라고 부르면 안 된다. 서비스 계정 키는 서명 가능한 자격증명이며, Search Console 데이터 접근은 단순 API 키가 아니라 OAuth 2.0 권한으로 이루어진다. 개인용 OAuth 흐름에서는 oauth-client.json으로 동의를 시작하고, 이후 생성되는 token.json도 외부에 공개하지 않는다. 공식 인증 개요는 Search Console API 요청 승인 문서를 참고한다. (확인일: 2026-07-20)
설치
python3 -m venv .venv
. .venv/bin/activate
python -m pip install google-api-python-client google-auth google-auth-oauthlib requests
이 글에는 실제 자격증명을 넣지 않는다. 아래 경로는 모두 눈에 띄는 예시다.
사이트맵 변경분을 큐로 관리하는 Python 예제
다음 예제는 SQLite를 작업 큐로 사용한다.
- 사이트맵에 처음 나타난 URL은
PENDING으로 등록한다. - 같은 URL의
lastmod가 바뀌면 다시PENDING으로 바꾼다. - 검사에 성공한 URL은 매일 재검사하지 않는다.
429또는5xx만 재시도 대상으로 두고 지수형 대기 시간을 적용한다.403은 권한 문제일 수 있으므로 자동 반복 대신 설정을 확인한다.- 일반 블로그 URL을 Indexing API로 보내는 코드는 포함하지 않는다.
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import sqlite3
import time
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse
import requests
from google.oauth2 import service_account
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
SCOPE = "https://www.googleapis.com/auth/webmasters.readonly"
RETRYABLE_HTTP = {429, 500, 502, 503, 504}
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def normalize_url(url: str) -> str:
parsed = urlparse(url)
path = parsed.path or "/"
return parsed._replace(path=path, fragment="").geturl().rstrip("/")
def classify(inspection_url: str, response: dict) -> tuple[str, dict]:
result = response.get("inspectionResult", {}).get("indexStatusResult", {})
details = {
"verdict": result.get("verdict"),
"coverageState": result.get("coverageState"),
"robotsTxtState": result.get("robotsTxtState"),
"indexingState": result.get("indexingState"),
"pageFetchState": result.get("pageFetchState"),
"googleCanonical": result.get("googleCanonical"),
"userCanonical": result.get("userCanonical"),
"lastCrawlTime": result.get("lastCrawlTime"),
}
google_canonical = details["googleCanonical"]
if google_canonical and normalize_url(google_canonical) != normalize_url(inspection_url):
return "CANONICAL_OTHER", details
fetch = details["pageFetchState"] or ""
coverage = details["coverageState"] or ""
if fetch in {"NOT_FOUND", "SOFT_404"} or "404" in coverage.upper():
return "NOT_FOUND", details
indexing = details["indexingState"] or ""
robots = details["robotsTxtState"] or ""
if indexing.startswith("BLOCKED_") or robots == "DISALLOWED":
return "BLOCKED", details
if details["verdict"] == "PASS":
return "PASS", details
return "NOT_INDEXED", details
class InspectionQueue:
def __init__(self, db_path: str):
self.db = sqlite3.connect(db_path)
self.db.execute(
"""
CREATE TABLE IF NOT EXISTS inspection_queue (
url TEXT PRIMARY KEY,
lastmod TEXT,
state TEXT NOT NULL,
priority INTEGER NOT NULL DEFAULT 0,
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at INTEGER NOT NULL DEFAULT 0,
checked_at TEXT,
result_json TEXT,
error TEXT
)
"""
)
self.db.commit()
def enqueue_sitemap_urls(self, entries: list[tuple[str, str | None]]) -> None:
for url, lastmod in entries:
row = self.db.execute(
"SELECT lastmod FROM inspection_queue WHERE url = ?", (url,)
).fetchone()
if row is None:
self.db.execute(
"INSERT INTO inspection_queue(url,lastmod,state,priority) VALUES(?,?,?,?)",
(url, lastmod, "PENDING", 50),
)
elif row[0] != lastmod and lastmod is not None:
self.db.execute(
"""UPDATE inspection_queue
SET lastmod=?, state='PENDING', priority=100,
attempts=0, next_attempt_at=0, error=NULL
WHERE url=?""",
(lastmod, url),
)
self.db.commit()
def due(self, limit: int) -> list[tuple[str, int]]:
return self.db.execute(
"""SELECT url, attempts FROM inspection_queue
WHERE state IN ('PENDING','RETRY') AND next_attempt_at <= ?
ORDER BY priority DESC, next_attempt_at ASC, url ASC LIMIT ?""",
(int(time.time()), limit),
).fetchall()
def finish(self, url: str, state: str, details: dict) -> None:
self.db.execute(
"""UPDATE inspection_queue
SET state=?, checked_at=?, result_json=?, error=NULL
WHERE url=?""",
(state, now_iso(), json.dumps(details, ensure_ascii=False), url),
)
self.db.commit()
def fail(self, url: str, attempts: int, status: int, message: str) -> None:
retryable = status in RETRYABLE_HTTP
next_attempt = int(time.time()) + min(3600, 60 * (2 ** attempts))
self.db.execute(
"""UPDATE inspection_queue
SET state=?, attempts=?, next_attempt_at=?, checked_at=?, error=?
WHERE url=?""",
(
"RETRY" if retryable else "ERROR",
attempts + 1,
next_attempt if retryable else 0,
now_iso(),
f"HTTP {status}: {message[:300]}",
url,
),
)
self.db.commit()
def read_sitemap(sitemap_url: str) -> list[tuple[str, str | None]]:
response = requests.get(sitemap_url, timeout=30)
response.raise_for_status()
root = ET.fromstring(response.content)
namespace = "{http://www.sitemaps.org/schemas/sitemap/0.9}"
if root.tag != f"{namespace}urlset":
raise ValueError("이 예제는 urlset 사이트맵을 받습니다. sitemapindex라면 하위 사이트맵별로 실행하세요.")
return [
(
node.findtext(f"{namespace}loc").strip(),
node.findtext(f"{namespace}lastmod"),
)
for node in root.findall(f"{namespace}url")
if node.findtext(f"{namespace}loc")
]
def credentials_from_args(args: argparse.Namespace):
if args.auth == "service-account":
return service_account.Credentials.from_service_account_file(
args.credentials_file, scopes=[SCOPE]
)
token_path = Path(args.token_file)
if token_path.exists():
from google.oauth2.credentials import Credentials
credentials = Credentials.from_authorized_user_file(token_path, [SCOPE])
else:
credentials = None
if not credentials or not credentials.valid:
if credentials and credentials.expired and credentials.refresh_token:
credentials.refresh(Request())
else:
flow = InstalledAppFlow.from_client_secrets_file(
args.credentials_file, [SCOPE]
)
credentials = flow.run_local_server(port=0)
token_path.write_text(credentials.to_json(), encoding="utf-8")
return credentials
def inspect_due(service, queue: InspectionQueue, site_url: str, limit: int) -> None:
for url, attempts in queue.due(limit):
try:
response = service.urlInspection().index().inspect(
body={
"inspectionUrl": url,
"siteUrl": site_url,
"languageCode": "ko-KR",
}
).execute()
state, details = classify(url, response)
queue.finish(url, state, details)
print(json.dumps({"url": url, "state": state, **details}, ensure_ascii=False))
except HttpError as error:
status = int(getattr(error.resp, "status", 0))
queue.fail(url, attempts, status, str(error))
print(json.dumps({"url": url, "state": "HTTP_ERROR", "status": status}))
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--sitemap", required=True)
parser.add_argument("--site-url", required=True)
parser.add_argument("--db", default="inspection-queue.sqlite3")
parser.add_argument("--limit", type=int, default=20)
parser.add_argument("--auth", choices=["service-account", "user"], required=True)
parser.add_argument("--credentials-file", required=True)
parser.add_argument("--token-file", default="token.json")
args = parser.parse_args()
queue = InspectionQueue(args.db)
queue.enqueue_sitemap_urls(read_sitemap(args.sitemap))
credentials = credentials_from_args(args)
service = build("searchconsole", "v1", credentials=credentials, cache_discovery=False)
inspect_due(service, queue, args.site_url, args.limit)
if __name__ == "__main__":
main()
실행 예시
서버 작업에서 서비스 계정을 사용하는 경우:
python inspect_queue.py \
--sitemap 'https://example.com/sitemap.xml' \
--site-url 'https://example.com/' \
--auth service-account \
--credentials-file '/secure/path/search-console-service-account.json' \
--limit 20
개인용 도구에서 OAuth 사용자 동의를 사용하는 경우:
python inspect_queue.py \
--sitemap 'https://example.com/sitemap.xml' \
--site-url 'sc-domain:example.com' \
--auth user \
--credentials-file '/secure/path/oauth-client.json' \
--token-file '/secure/path/oauth-token.json' \
--limit 20
--limit 20은 이 운영 예제의 배치 크기일 뿐 Google이 권장하는 값이나 색인 성과 기준이 아니다. 실제 배치 크기는 공식 할당량 화면, 다른 작업의 사용량, 처리 시간에 맞춰 정한다. 공식 문서는 URL Inspection에 속성별·프로젝트별 제한이 있다고 설명하므로 전체 URL을 매일 훑는 구조보다 변경·오류 큐가 적합하다. Search Console API 사용 제한 (확인일: 2026-07-20)
결과는 어떻게 판정하고 조치하는가
API의 verdict만 저장하지 말고 canonical, 크롤링, robots, 마지막 크롤링 시각을 함께 보관한다.
| 분류 | 관찰할 값 | 우선 확인할 조치 |
|---|---|---|
PASS |
verdict=PASS이고 Google canonical이 검사 URL과 같음 |
조치 없음. 매일 재검사하지 않음 |
NOT_INDEXED |
verdict가 PASS가 아니며 다른 명확한 차단 원인이 없음 |
URL이 200인지, noindex가 없는지, 사이트맵·내부 링크·콘텐츠 중복 여부 확인 |
CANONICAL_OTHER |
googleCanonical이 검사 URL과 다름 |
의도한 정규 URL인지 확인하고 canonical, 리디렉션, 내부 링크, 사이트맵을 일치시킴 |
BLOCKED |
robots 또는 indexing 상태가 차단을 나타냄 | 의도적 차단인지 확인. 의도하지 않았다면 robots.txt, meta robots, HTTP 헤더 수정 |
NOT_FOUND |
가져오기 상태나 coverage가 404/soft 404를 나타냄 | 삭제가 의도라면 사이트맵·내부 링크에서 제거. 대체 문서가 있으면 관련성 있는 리디렉션 검토 |
HTTP_ERROR 403 |
API 호출 자체가 403 | 서비스 계정/사용자의 속성 권한, OAuth 범위, siteUrl 정확성 확인 후 수동 재등록 |
HTTP_ERROR 404 |
API 호출 자체가 404 | 엔드포인트, 속성·검사 URL 관계, 요청 값 확인. 페이지의 HTTP 404와 혼동하지 않음 |
HTTP_ERROR 429/5xx |
호출 제한 또는 일시 장애 | 백오프 후 제한된 횟수로 재시도하고 지속되면 작업 중단·로그 검토 |
URL Inspection 응답은 Google이 보유한 인덱스 정보다. 운영 서버의 현재 HTTP 응답, 렌더링 결과, robots.txt, canonical을 별도의 HTTP 검사로 확인해야 원인 판단이 완성된다.
실제 검사 기록표
자격증명 없이 실제 Search Console 응답을 꾸며낼 수는 없다. 운영 실행 후 아래 표를 API 출력과 함께 채우고, 날짜는 UTC인지 KST인지 명시한다.
| 검사 URL | 검사 시각 | 상태 | Google canonical | 마지막 크롤링 | 확인한 조치 |
|---|---|---|---|---|---|
https://example.com/post/changed |
YYYY-MM-DD HH:MM TZ |
PASS/NOT_INDEXED/... |
https://example.com/... |
API의 lastCrawlTime |
실제 조치 기록 |
직접 실행 증거로는 다음 세 가지를 함께 보관하는 것이 좋다.
- 비밀 값을 제거한 JSON Lines 출력
- 같은 시점의
inspection_queue상태 내보내기 - 수정 전후의 HTTP 상태·canonical·robots 검사 결과
공개 글에는 서비스 계정 이메일, 사용자 이메일, 속성의 비공개 URL, 토큰, JSON 키, 고객 검색 데이터를 마스킹한다.
사이트맵과 내부 링크를 함께 점검하는 이유
사이트맵은 중요한 정규 URL을 알려 주는 수단이고, 내부 링크는 사용자와 Google이 사이트 안의 다른 페이지를 찾고 문맥을 이해하도록 돕는다. 중요한 페이지에는 적어도 하나의 크롤링 가능한 내부 링크를 두라는 Google의 링크 권장사항도 확인한다. Google의 크롤링 가능한 링크 권장사항 (확인일: 2026-07-20)
이 사이트에서는 다음처럼 문맥이 드러나는 앵커를 사용할 수 있다.
- 검색 성과를 바탕으로 콘텐츠를 고칠 때는 Search Console 데이터로 롱테일 검색어를 발굴하는 워크플로을 함께 본다.
- 색인 대상 페이지의 제목과 본문 구조는 문서 구조 점검 가이드로 확인한다.
- 자동 HTTP 점검기를 만들 때는 robots.txt와 재시도 정책을 지키는 웹 수집기 설계를 참고한다.
자동 생성된 ‘관련 글’ 블록에만 의존하지 말고, 실제 관련 문단 안에 이 링크를 둔다.
실패 조건·권한·할당량·개인정보·비용 한계
실패 조건
siteUrl이 Search Console 속성 문자열과 다름- 검사 URL이 속성 범위 밖에 있음
- 서비스 계정 또는 OAuth 사용자가 속성 권한을 갖지 않음
- 사이트맵이
urlset이 아니라sitemapindex인데 하위 사이트맵을 처리하지 않음 - robots 차단,
noindex, 오류 응답, 잘못된 canonical을 API 호출만으로 고치려 함 - URL Inspection의 인덱스 정보를 라이브 URL 테스트로 오해함
권한과 자격증명
조회 작업에는 읽기 전용 scope를 우선한다. 키와 토큰은 Git 저장소, 이미지, 로그, 공개 환경 변수에 넣지 않는다. 서비스 계정 키 파일을 사용해야 한다면 비밀 저장소에서 런타임에 제공하고 접근 주체를 제한한다. OAuth 사용자 토큰도 사용자 데이터 접근 권한을 가지므로 같은 수준으로 보호한다.
할당량과 재시도
공식 한도는 변경될 수 있으므로 배포 전에 Search Console API 사용 제한과 Google Cloud Console의 현재 할당량을 확인한다. 403을 무조건 재시도하지 말고 권한과 속성 값을 먼저 고친다. 429·일시적 5xx만 백오프 대상으로 제한한다. 매일 전체 URL을 재검사하는 대신 다음을 큐 입력으로 사용한다.
- 사이트맵에 새로 등장한 URL
lastmod가 실제 콘텐츠 수정과 함께 바뀐 URL- 배포 직후 canonical·robots·상태 코드가 바뀐 URL
- 이전에
NOT_INDEXED,BLOCKED,CANONICAL_OTHER였고 원인을 수정한 URL
개인정보와 약관
검사 URL 자체가 비공개 경로나 개인 식별 정보를 포함할 수 있다. 로그·대시보드의 접근 권한과 보존 기간을 정하고, 외부 알림에는 필요한 상태와 마스킹된 URL만 보낸다. API 사용은 Google API 서비스 약관과 조직 정책을 함께 검토한다.
비용과 보장 한계
Google 문서의 API 사용 제한과 별개로, 실행 환경·데이터베이스·로그·비밀 저장소에는 자체 비용이 생길 수 있다. 이 글은 트래픽 증가, 검색 순위 상승, 발견 시간 단축을 수치로 약속하지 않는다. 사이트맵 제출, 내부 링크 추가, URL Inspection 조회, Search Console UI의 색인 요청 모두 색인을 보장하지 않는다. Google은 페이지를 크롤링하거나 색인하지 않을 수 있다. URL 검사 도구 도움말, 페이지 색인 생성 보고서 도움말 (확인일: 2026-07-20)
크롤링 예산을 과장하지 말아야 하는 이유
404나 미색인 URL 몇 개를 발견했다고 곧바로 ‘사이트 신뢰 급락’, ‘저품질 낙인’, ‘크롤링 예산 삭감’으로 단정할 근거는 없다. Google의 크롤링 예산 가이드는 주로 매우 큰 사이트나 빠르게 변하는 사이트가 참고할 주제다. 일반 블로그에서는 먼저 사이트맵, 내부 링크, 서버 응답, robots, canonical, 콘텐츠 중복을 확인한다. Google 대형 사이트 크롤링 예산 가이드 (확인일: 2026-07-20)
운영 체크리스트
- 기존 canonical과 숫자 URL
/entry/41을 유지했다. - Indexing API를 일반 블로그 URL에 사용하지 않았다.
- 사이트맵에는 200 응답의 정규 URL만 넣었다.
- 중요한 페이지에 크롤링 가능한 내부 링크가 있다.
- 서비스 계정과 OAuth 사용자의 권한 주체를 구분했다.
- 조회 전용 scope를 사용했다.
- 신규·수정·오류 URL만 큐에 넣었다.
-
403과 페이지 자체의404를 구분했다. - 검사 결과에 시각, canonical, 마지막 크롤링 시각을 함께 기록했다.
- 키·토큰·이메일·비공개 URL을 공개 로그에서 제거했다.
- API가 색인을 요청하거나 보장하지 않는다고 문서화했다.
- 실제 검사 결과는 사람의 사실 검토를 거쳤다.
자주 묻는 질문
URL Inspection API를 호출하면 색인 요청이 되나?
아니다. URL Inspection API는 Google 색인에 있는 URL 상태를 조회한다. 색인 요청과 색인 보장은 별개다.
일반 블로그에 Indexing API를 조금만 써도 되나?
안 된다. 공식 지원 범위는 JobPosting 또는 VideoObject 안에 BroadcastEvent가 포함된 페이지다. 일반 블로그는 사이트맵·내부 링크·URL Inspection을 사용한다.
모든 URL을 매일 검사하면 더 안전한가?
그렇지 않다. 변경되지 않은 PASS URL을 계속 조회하면 할당량과 운영 비용만 사용한다. 신규·최근 수정·원인 수정이 끝난 오류 URL을 큐에 넣는다.
PASS면 검색 결과 노출이 보장되나?
아니다. 색인 상태와 특정 검색어의 노출·순위는 같은 의미가 아니다. Search Analytics 데이터는 별도로 확인한다.
공식 출처
아래 문서는 모두 2026-07-20에 확인했다.
'🤖 1인 에이전트 구축기' 카테고리의 다른 글
| AI 에이전트 비즈니스 상용화 전략, 지금 반드시 확인해야 할 5가지 (0) | 2026.06.15 |
|---|---|
| 1인 기업 대시보드 구축할 때 이것만은 절대 하지 마세요 (데이터 오류 방지) (0) | 2026.06.15 |
| AI 에이전트 성능 유지, 회귀 테스트 구축 시 절대 하지 말아야 할 실수 3 (1) | 2026.06.15 |
| 정책을 지키는 웹 수집기: robots.txt·429·재시도·증분 수집 (0) | 2026.06.15 |
| API 키 유출 방지: 시크릿 저장·로테이션·사고 대응 체크리스트 (0) | 2026.06.15 |