안녕하세요, 자바파커입니다.
"블로그에 공들여 글 올렸는데 구글에 안 뜨고 있는 걸 몇 주 뒤에 알게 된 적, 있으신가요?"
솔직히 저도 그랬습니다. 포스팅만 올리고 Search Console은 한 달에 한 번 열어볼까 말까 했고, "알아서 크롤링되겠지" 하고 방치했습니다. 그러다 최근에 모니터를 만들어 첫 실행한 결과가 충격적이었습니다.
57개 URL 중 인덱싱된 건 0개. 전부 "Google에는 아직 알려지지 않은 URL입니다" 상태였습니다. 몇 달 동안 제가 올린 글이 검색에 뜨지 않고 있었던 겁니다.
결론부터 말씀드리면 — GSC URL Inspection API + GitHub Actions cron + Telegram 알림을 엮으면, 이런 상황을 주간 리포트로 자동 포착할 수 있습니다. Claude Code로 30분 안에 완성됐고, 덕분에 "구글이 내 블로그를 모른다"는 사실을 즉시 알아챘습니다. 오늘은 이 시스템의 전체 구조·셋업·실제 결과 분석·Claude Code 활용 회고까지 정리하겠습니다.
Claude Code 관련 내용이 궁금하시면 Claude Code Subagent 완벽 가이드도 같이 보시면 흐름이 이어집니다.
왜 "자동 모니터링"인가 — 수동 GSC의 한계
Google Search Console을 수동으로 여는 방식은 몇 가지 치명적 한계가 있습니다.
| 수동 체크의 한계 | 영향 |
|---|---|
| 자발적 로그인이 필요 | 바쁘면 2~3주씩 안 들어감 |
| 전체 URL 일괄 조회가 불편 | 보통 문제 있는 URL 하나 찍어서 확인, 나머지는 모른 채 |
| 알림 기능이 제한적 | 이메일 알림은 큰 이슈만, "천천히 밀린 인덱싱"은 놓침 |
| 추이 추적이 어려움 | 지난달 대비 인덱싱 비율 변화 같은 건 수동 기록해야 함 |
| 여러 속성 관리가 번거로움 | 블로그·쇼핑몰·랜딩 각각 따로 체크 |
자동화의 핵심: "없어도 되는 작업"은 자동화하고, 사람은 결과만 확인하는 흐름으로 전환.
전체 아키텍처 — 주간 5단계 파이프라인
시스템의 흐름은 단순합니다.
[GitHub Actions cron]
↓
1. sitemap-index.xml fetch → URL 전체 추출
↓
2. URL마다 GSC URL Inspection API 호출 (서비스 계정 인증)
↓
3. 이전 상태(data/indexing-status.json)와 diff 계산
↓
4. 변화가 있으면 Telegram HTML 메시지 전송
↓
5. 새 상태를 JSON에 저장하고 자동 커밋각 단계는 독립된 모듈로 분리해서, 하나만 바꿔도 나머지에 영향 없게 설계합니다.
사전 준비 3종 — 총 20분
1) GCP 서비스 계정 (15분)
- Google Cloud Console에서 프로젝트 생성 (예:
gsc-monitor) - APIs & Services → Library → Google Search Console API 활성화
- IAM & Admin → Service Accounts → 서비스 계정 생성 (역할 없이도 OK)
- 서비스 계정 → Keys 탭 → Add Key → JSON → 다운로드
- 다운로드한 JSON을 GitHub Secret
GSC_SERVICE_ACCOUNT_KEY에 전체 내용 붙여넣기
2) Search Console 권한 부여 (2분)
- Search Console → 대상 속성 선택
- Settings → Users and permissions → Add User
- 서비스 계정 이메일(
xxx@yyy.iam.gserviceaccount.com) 추가, Restricted 권한이면 충분
3) Telegram Bot (3분)
- Telegram @BotFather에게
/newbot→ HTTP API Token 수신 →TELEGRAM_BOT_TOKEN - 생성한 봇에게
/start아무 메시지 (봇이 먼저 말 못 걸기 때문) - @userinfobot에
/start→ 본인 chat_id →TELEGRAM_CHAT_ID
코드 구조 — 4개 모듈로 분리
scripts/gsc-monitor/
├── package.json # googleapis + fast-xml-parser
├── run.js # 오케스트레이션 (40줄)
└── lib/
├── sitemap.js # sitemap-index → URL 배열
├── gsc.js # URL Inspection API 호출
├── diff.js # 이전 결과와 비교, 요약 계산
└── telegram.js # HTML 메시지 빌더 + 전송
.github/workflows/gsc-monitor.yml # cron + workflow_dispatch
data/indexing-status.json # 상태 스냅샷 (봇이 자동 커밋)lib/sitemap.js — sitemap-index 파싱
import { XMLParser } from "fast-xml-parser"
const parser = new XMLParser({ ignoreAttributes: false })
async function fetchXml(url) {
const res = await fetch(url, { headers: { "User-Agent": "gsc-monitor/1.0" } })
if (!res.ok) throw new Error(`${url} → HTTP ${res.status}`)
return parser.parse(await res.text())
}
export async function extractAllUrls(indexUrl) {
const index = await fetchXml(indexUrl)
const children = [].concat(index.sitemapindex?.sitemap ?? [])
const urlSets =
children.length > 0
? await Promise.all(children.map(s => fetchXml(s.loc)))
: [index]
const urls = new Set()
for (const doc of urlSets) {
for (const entry of [].concat(doc.urlset?.url ?? [])) {
if (entry.loc) urls.add(entry.loc)
}
}
return Array.from(urls)
}lib/gsc.js — URL Inspection API
핵심은 urlInspection.index.inspect 호출.
import { google } from "googleapis"
export function createClient(serviceAccountKey) {
const auth = new google.auth.GoogleAuth({
credentials: JSON.parse(serviceAccountKey),
scopes: ["https://www.googleapis.com/auth/webmasters.readonly"],
})
return google.searchconsole({ version: "v1", auth })
}
export async function inspectUrl(client, { siteUrl, inspectionUrl }) {
const res = await client.urlInspection.index.inspect({
requestBody: { siteUrl, inspectionUrl, languageCode: "ko" },
})
const idx = res.data.inspectionResult?.indexStatusResult ?? {}
return {
url: inspectionUrl,
verdict: idx.verdict,
coverageState: idx.coverageState,
lastCrawlTime: idx.lastCrawlTime,
googleCanonical: idx.googleCanonical,
userCanonical: idx.userCanonical,
}
}중요:
siteUrl형식이 속성 유형에 따라 다릅니다.
- Domain property:
sc-domain:blog.javapark.kr(권장, 하위 모든 경로 자동 커버)- URL prefix property:
https://blog.javapark.kr/(프로토콜·www 구분됨)
lib/diff.js — 변화 감지
단순하지만 핵심 로직. 이전 실행의 URL별 상태와 비교해서 새로 인덱싱된 것, 인덱스에서 제거된 것, canonical 불일치를 찾습니다.
const INDEXED_STATES = new Set([
"Submitted and indexed",
"Indexed, not submitted in sitemap",
])
export function diffResults(previous = [], current = []) {
const prev = new Map(previous.map(r => [r.url, r]))
const newlyIndexed = []
const newlyDeindexed = []
const canonicalMismatch = []
for (const curr of current) {
const prevEntry = prev.get(curr.url)
const currIndexed = INDEXED_STATES.has(curr.coverageState ?? "")
const prevIndexed = prevEntry
? INDEXED_STATES.has(prevEntry.coverageState ?? "")
: false
if (currIndexed && !prevIndexed) newlyIndexed.push(curr)
if (!currIndexed && prevIndexed) newlyDeindexed.push(curr)
if (
curr.googleCanonical &&
curr.userCanonical &&
curr.googleCanonical !== curr.userCanonical
)
canonicalMismatch.push(curr)
}
const indexed = current.filter(c =>
INDEXED_STATES.has(c.coverageState ?? "")
).length
return {
newlyIndexed,
newlyDeindexed,
canonicalMismatch,
total: current.length,
indexed,
rate:
current.length === 0
? 0
: Math.round((indexed / current.length) * 1000) / 10,
}
}lib/telegram.js — HTML 메시지
Telegram은 제한적 HTML을 지원합니다. <b>, <i>, <code>, <a> 정도.
export async function sendTelegram(token, chatId, text) {
const url = `https://api.telegram.org/bot${token}/sendMessage`
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: chatId,
text,
parse_mode: "HTML",
disable_web_page_preview: true,
}),
})
if (!res.ok) throw new Error(`Telegram ${res.status}: ${await res.text()}`)
}GitHub Actions 워크플로우
cron으로 주간 실행 + 수동 트리거(workflow_dispatch) 지원.
name: GSC Indexing Monitor
on:
schedule:
- cron: "0 0 * * 1" # 매주 월요일 00:00 UTC (09:00 KST)
workflow_dispatch:
inputs:
force_notify:
description: "변경 없어도 Telegram 알림 전송"
default: "false"
type: choice
options: ["false", "true"]
permissions:
contents: write
jobs:
monitor:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20" }
- name: Install
working-directory: scripts/gsc-monitor
run: npm install --no-audit --no-fund
- name: Run
working-directory: scripts/gsc-monitor
env:
GSC_SERVICE_ACCOUNT_KEY: ${{ secrets.GSC_SERVICE_ACCOUNT_KEY }}
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
GSC_SITE_URL: sc-domain:blog.javapark.kr
SITE_ORIGIN: https://blog.javapark.kr
FORCE_NOTIFY: ${{ inputs.force_notify }}
run: node run.js
- name: Commit status
run: |
git config user.name "gsc-monitor-bot"
git config user.email "bot@users.noreply.github.com"
git add data/indexing-status.json
if git diff --staged --quiet; then
echo "No changes"
else
git commit -m "chore(gsc): update $(date -u +%Y-%m-%d)"
git push
fi첫 실행 결과 — 57/0 사태
준비가 끝나고 수동 트리거로 첫 실행을 돌렸습니다. data/indexing-status.json에 저장된 결과:
{
"generatedAt": "2026-04-20T16:06:00.795Z",
"siteUrl": "sc-domain:blog.javapark.kr",
"summary": {
"total": 57,
"indexed": 0,
"rate": 0,
"stillPending": 57
}
}57개 URL 전부 다음과 같은 상태:
{
"verdict": "NEUTRAL",
"coverageState": "Google에는 아직 알려지지 않은 URL입니다.",
"indexingState": "INDEXING_STATE_UNSPECIFIED",
"lastCrawlTime": null,
"googleCanonical": null
}충격이었습니다. 모니터링을 만들지 않았으면 이 상태를 몇 달 더 모른 채 글을 계속 올리고 있었을 겁니다.
이 상태의 의미
"Google에는 아직 알려지지 않은 URL입니다"는 구글 봇이 한 번도 이 URL을 방문하지 않았다는 뜻입니다. 이게 광범위하게 발생하면 보통 다음 4가지 중 하나입니다.
| 원인 후보 | 진단 방법 |
|---|---|
| ① sitemap이 GSC에 제출되지 않음 | GSC → Sitemaps 메뉴에서 sitemap-index.xml 수동 등록 여부 확인 |
| ② 속성이 최근 추가되었고 아직 크롤링 미실행 | GSC 속성 "추가 일자" 확인. 2주 이내면 정상 — 기다림 |
| **③ 사이트가 최근 마이그레이션됨 | 도메인 변경, 플랫폼 이관(GitHub Pages → Netlify 등) 후 새 도메인의 크롤링 히스토리 부족** |
| ④ robots.txt가 광범위 차단 | https://blog.javapark.kr/robots.txt 확인. Disallow: /가 있으면 전면 차단 |
제 경우는 ①과 ③의 조합이었습니다.
- 최근
javapark.github.io→blog.javapark.kr도메인으로 Netlify 이관 - 새 도메인 속성을 GSC에 추가는 했지만 sitemap 제출은 안 함
해결 순서
robots.txt로Disallow: /없는지 확인 → 통과 (문제 없음)- GSC에서
Sitemaps메뉴 →https://blog.javapark.kr/sitemap-index.xml수동 제출 - 주요 페이지 5~10개 선택해서
URL 검사→ 색인 생성 요청 - 1~2주 기다리면서 이 모니터의 주간 리포트로 인덱싱 진행률 추적
모니터가 없었다면 이 진단 자체가 시작되지 않았을 겁니다. 자동화의 진짜 가치는 여기 있습니다.
Telegram 메시지 실제 모습
변화 발생 시 오는 메시지 예시:
🔔 GSC Indexing Monitor
2026-04-28 · https://blog.javapark.kr
✅ 신규 인덱싱: 3건
• /devops/k8s-understanding/
• /devops/kubectl-essential-commands/
• /devops/k9s-essential-guide/
⚠️ 인덱싱 대기: 54건
📈 전체: 57개 중 3개 인덱싱 (5.3%)한 화면에 현재 상태와 지난 주 대비 변화가 같이 잡힙니다. 주 1회 1분만 확인하면 블로그 SEO 건강 상태 파악 완료.
Claude Code 활용 회고 — 30분 구축의 비결
이 시스템을 Claude Code로 30분 만에 완성했습니다. 핵심 요인 3가지.
1) Subagent로 API 문서 조사
googleapis 라이브러리의 Search Console API 스펙을 Subagent에게 조사시키고, 주요 메서드·응답 구조만 요약받았습니다. 메인 세션은 그 결과를 받아 코드만 작성하면 됐습니다. Subagent 편의 "컨텍스트 격리" 가치가 여기서 직접 드러납니다.
2) 모듈 단위 병렬 생성
sitemap.js, gsc.js, diff.js, telegram.js를 각각 독립 모듈로 지정해 한 번에 4개 파일 생성. Claude Code가 파일 간 인터페이스만 맞춰주면 됩니다. 만약 하나의 긴 파일로 작성하다 에러 나면 전체 디버깅이 필요한데, 분리돼 있으면 해당 파일만 재작성하면 됩니다.
3) pre-commit 훅까지 자동 대응
레포의 husky + prettier + eslint가 자동 실행되는 환경이었는데, eslint의 Set/Map is not defined 같은 에러를 Claude Code가 즉시 파악하고 로컬 .eslintrc.json으로 Node ESM 환경 선언으로 해결했습니다. 사람이 직접 ESLint 설정을 뜯어보는 시간이 절약됐습니다.
"도구가 도구를 만든다"는 느낌이 강하게 드는 작업이었습니다.
확장 아이디어 5가지
기본 시스템이 안정되면 붙일 수 있는 것들.
| 확장 아이디어 | 가치 |
|---|---|
| 일 단위 cron으로 변경 | 이슈 감지 속도 7배 빨라짐 (API quota 여유 확인 필요) |
블로그 내부 /insights 대시보드 |
indexing-status.json을 Gatsby 페이지로 시각화 (독자에게도 공개 가능) |
| Canonical 불일치 즉시 알림 | 중복 콘텐츠 판정으로 SEO 누수되기 전에 차단 |
| **IndexNow 프로토콜 병행 | Bing·Yandex에는 즉시 푸시** 가능 (Google은 미지원) |
| **배포 훅 연동 | Netlify 배포 성공 시 30분 뒤 해당 URL만 타겟팅 체크** |
가장 투자 대비 효과가 큰 건 "블로그 내부 대시보드"입니다. 본인 블로그의 SEO 현황을 공개하는 것 자체가 기술 블로그의 차별화 포인트가 됩니다.
한계와 현실
| 한계 | 대응 방법 |
|---|---|
| API quota: 일당 2000, 분당 600 | 개인 블로그는 여유. URL 1000+ 넘어가면 배치 분할 고려 |
| 인덱싱 상태는 실시간 아님 | 구글 내부 캐시 — 몇 시간~며칠 지연 있을 수 있음 |
| API는 읽기 전용 | "지금 인덱싱해줘" 요청은 별도(Indexing API, 제한적) 필요 |
| Telegram 메시지 4096자 제한 | 큰 이슈가 많으면 메시지 분할 필요 (현재는 미구현) |
FAQ
Q. 이거 돌리는 데 돈이 드나요?
무료입니다. Google Search Console API는 무료 할당량(일 2000회) 내에서 사용, GitHub Actions는 public 레포는 무제한, Telegram Bot도 무료. 개인 블로그 규모에서는 0원으로 운영 가능합니다.
Q. sitemap을 여러 개 쓰거나 분할한 경우는?
코드에서 sitemap-index.xml을 먼저 fetch하고, 그 안의 <sitemap> 자식들을 모두 순회합니다. 대부분의 Gatsby/Hugo/Jekyll 기본 설정이 이 구조를 쓰므로 별도 작업 불필요.
Q. Google Indexing API로 "지금 인덱싱" 요청도 자동화할 수 있나요?
가능은 한데 공식적으로 JobPosting·BroadcastEvent 타입만 지원합니다. 일반 블로그 글은 ToS 위반이 됩니다. 많은 사이트가 편법으로 쓰지만 계정 제재 리스크가 있어 비권장. 대신 Bing의 IndexNow는 공식 지원하므로 그쪽으로 자동화하는 게 안전.
Q. Canonical 불일치가 뭔가요?
내가 선언한 canonical URL(<link rel="canonical">)과 Google이 실제로 선택한 canonical이 다른 경우입니다. 예: /post/ vs /post/index.html, http:// vs https://. 불일치가 있으면 내 URL 대신 Google 선택 URL이 노출되며, SEO 가치가 분산됩니다. 모니터가 이걸 자동 감지해 알려주는 게 실용적 가치 중 하나.
Q. Domain property와 URL prefix property 차이는요?
- Domain property:
sc-domain:blog.javapark.kr— DNS TXT 인증, 하위 모든 경로·프로토콜·www 자동 커버. 권장. - URL prefix property:
https://blog.javapark.kr/— HTML 파일 인증, 프로토콜·www 구분됨. 더 제약적.
신규 시작하면 Domain property로 가세요. 이미 URL prefix로 되어있으면 병행 등록 후 Domain으로 이관.
마무리 — 블로그 SEO 건강검진을 자동화하라
"구글에 내 글이 어떻게 보이는지"를 사람이 수동으로 확인하는 일은 이제 그만해도 됩니다. API와 Cron, 그리고 Telegram 봇 하나면 주간 자동 건강검진이 가능합니다.
오늘 정리 핵심:
- GSC URL Inspection API는 무료 + 강력, 개인 블로그는 quota 여유
- GitHub Actions cron + JSON 상태 저장으로 추이 추적 가능
- Claude Code로 30분 구축 — 모듈 분리 + Subagent 활용이 핵심
- 첫 실행에서 "57/0" 같은 예상치 못한 사태를 발견하는 게 자동화의 진짜 가치
- 확장: 일 단위 cron, 블로그 내부 대시보드, IndexNow 연동까지
다음에 써볼 만한 주제
- Gatsby 페이지로 SEO 대시보드 노출하기 (indexing-status.json 시각화)
- IndexNow로 Bing·Yandex 즉시 인덱싱 요청
- Netlify Deploy Hook과 연동해 "배포 후 30분 뒤 해당 URL만 체크"
- Slack·Discord 다중 채널 동시 알림
관련 포스팅
여러분의 블로그는 구글에 얼마나 잘 잡히고 있으신가요? GSC를 "문제 생긴 후 확인하는 곳"에서 "주간 리포트로 받아보는 건강검진"으로 바꿔보세요. 구축은 30분, 효과는 몇 달치입니다.