구글에게 '이 페이지 좀 봐주세요'라고 API로 직접 요청하기

5 min readJeongwoo Ahn

구글에게 '이 페이지 좀 봐주세요’라고 API로 직접 요청하기

thumbnail

들어가며

제가 만든 문장수집가(sentence)는 SPA입니다. 사용자가 책 구절을 저장하면 sentence.jeongwoo.in/sentence/{id} 같은 상세 페이지가 생깁니다.

문제는 이 페이지들이 검색에 잘 안 잡힌다는 거였어요. 사이트맵을 만들어 올려두긴 했지만, 그건 어디까지나 "여기 페이지 목록이 있으니 언제 시간 되시면 와서 보세요"라는 수동적인 안내입니다. 구글 봇이 실제로 크롤링하러 오기까지는 며칠, 길면 몇 주가 걸리죠.

그래서 생각했습니다. “기다리지 말고, 페이지가 생기거나 바뀔 때마다 구글에게 직접 알려주면 어떨까?” 그게 바로 Google Indexing API입니다.

💡 먼저, 솔직한 전제 하나

시작 전에 짚고 넘어갈 게 있습니다. 구글은 공식적으로 Indexing API를 JobPosting(채용 공고)이나 라이브 스트리밍 BroadcastEvent 페이지에만 쓰라고 안내합니다.

그러니 일반 콘텐츠 페이지에 쓰는 건 엄밀히 말하면 "권장 용도"는 아닙니다. 다만 엔드포인트 자체는 URL을 받아주고, 사이트맵 크롤링을 기다리는 것보다 발견이 빨라지는 경향이 있어서 실험 삼아 붙여봤습니다. 이 부분은 "무조건 효과 보장"이 아니라 “해볼 만한 시도” 정도로 봐주시면 좋겠습니다.

전체 흐름은 세 단계입니다.

  1. GCP에서 Web Search Indexing API 활성화
  2. 서비스 계정을 Search Console 속성의 소유자로 등록
  3. 서비스 계정으로 인증해서 색인 요청 API 호출

🔧 1단계: Indexing API 활성화

이미 이미지 OCR용으로 쓰던 GCP 프로젝트와 서비스 계정이 있었기 때문에, 저는 여기에 API 하나만 더 켜주면 됐습니다.

GCP 콘솔에서 프로젝트를 선택하고, API 및 서비스 → 라이브러리로 들어가 "Web Search Indexing API"를 검색해 **사용 설정(Enable)**을 누릅니다.

이걸 빼먹으면 나중에 403 SERVICE_DISABLED 같은 다른 에러를 만나게 됩니다. 저는 이 단계는 무사히 넘겼는데, 다음 단계에서 발목이 잡혔습니다.

🔑 2단계: 서비스 계정을 Search Console 소유자로 등록

Indexing API는 아무 페이지나 색인 요청을 받아주지 않습니다. "네가 정말 이 사이트 주인이 맞아?"를 먼저 확인합니다. 그 확인의 근거가 바로 Google Search Console의 소유권입니다.

핵심은 요청하는 주체가 서비스 계정이라는 점입니다. 그러니 사람인 내 계정이 소유자인 것과는 별개로, 서비스 계정 이메일 자체를 소유자로 추가해야 합니다.

xxxxxx@*********-project.iam.gserviceaccount.com

서비스 계정 이메일은 보통 이렇게 생겼습니다. 이걸 Search Console의 설정 → 사용자 및 권한 → 사용자 추가에서 등록하는데, 여기서 반드시 지켜야 할 두 가지가 있습니다.

첫째, 권한은 '전체(Full)'가 아니라 '소유자(Owner)'여야 합니다. Indexing API는 소유자 레벨을 요구합니다. Full로 추가하면 다른 건 다 되는데 색인 요청만 거부당하는, 꽤 헷갈리는 상황이 생깁니다.

둘째, 속성이 대상 URL을 실제로 덮는지 확인해야 합니다. 저는 서브도메인(sentence.jeongwoo.in) 페이지를 색인하려던 거였는데, 만약 Search Console에 https://jeongwoo.in/ 같은 URL 접두어 속성만 등록되어 있다면 이건 서브도메인을 포함하지 않습니다. 그래서 jeongwoo.in 도메인 속성을 만들고 거기에 서비스 계정을 소유자로 추가하는 게 가장 확실합니다. 도메인 속성은 모든 서브도메인을 한 번에 덮으니까요.

⚡ 3단계: 워커에서 색인 요청 보내기

인증과 요청은 Cloudflare Worker에서 처리했습니다. 서비스 계정으로 JWT를 만들어 access token으로 교환하고, 그 토큰으로 색인 API를 호출하는 구조입니다.

Cloudflare Worker는 Node 런타임이 아니라서 crypto 모듈 대신 Web Crypto API를 씁니다. 서비스 계정 개인키(PEM)를 가져와 RS256으로 JWT에 서명하는 부분이 조금 낯설 수 있습니다.

// 서비스 계정 JWT → access token 교환 (핵심만 발췌)
const jwtClaim = {
  iss: env.GOOGLE_CLOUD_CLIENT_EMAIL,
  scope: 'https://www.googleapis.com/auth/indexing',
  aud: 'https://oauth2.googleapis.com/token',
  exp: now + 3600,
  iat: now,
};

// PEM 개인키를 Web Crypto의 CryptoKey로 변환 후 RS256 서명
const cryptoKey = await crypto.subtle.importKey(
  'pkcs8',
  binaryDer,
  { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
  false,
  ['sign']
);
const signature = await crypto.subtle.sign(
  'RSASSA-PKCS1-v1_5',
  cryptoKey,
  encoder.encode(`${headerBase64}.${claimBase64}`)
);

토큰을 받았다면 색인 요청은 오히려 단순합니다. urlNotifications:publish 엔드포인트에 URL과 타입(URL_UPDATED 또는 URL_DELETED)만 넘기면 됩니다.

const response = await fetch(
  'https://indexing.googleapis.com/v3/urlNotifications:publish',
  {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${accessToken}`,
    },
    body: JSON.stringify({
      url: 'https://sentence.jeongwoo.in/sentence/{id}',
      type: 'URL_UPDATED',
    }),
  }
);

한 가지 안전장치를 더 뒀습니다. 워커가 아무 URL이나 색인 요청하지 못하도록, 허용된 경로 접두어(https://sentence.jeongwoo.in/sentence/)로 시작하는 URL만 통과시키는 검증을 넣었어요. API 키가 유출되더라도 엉뚱한 도메인을 대신 색인 요청하는 오남용을 막기 위해서입니다.

🎯 그리고 403과의 싸움

여기까지 만들고 문장을 하나 저장했더니, 이런 에러가 돌아왔습니다.

{
  "error": {
    "code": 403,
    "message": "Permission denied. Failed to verify the URL ownership.",
    "status": "PERMISSION_DENIED"
  }
}

이 에러 메시지가 은근히 사람을 헷갈리게 합니다. "권한 거부"라고 하니 코드나 토큰 문제인가 싶었거든요. 하지만 차분히 뜯어보면 답이 보입니다.

토큰 발급 단계에서 실패했다면 401이나 다른 에러가 났을 겁니다. 그런데 403이 왔다는 건, 인증은 통과했고 API가 요청을 받긴 했는데 소유권 검증에서 걸렸다는 뜻입니다. 즉 코드가 아니라 2단계의 Search Console 설정 문제였던 거죠.

원인은 앞에서 말한 그 두 함정 중 하나였습니다. 서비스 계정이 소유자로 등록되지 않았거나, Full로만 등록됐거나, 속성이 서브도메인을 안 덮거나. 저는 도메인 속성에 서비스 계정을 소유자로 다시 등록하는 것으로 해결했습니다.

🔍 코드를 배포하지 않고 검증하기

고쳤다고 바로 앱에서 문장을 저장해 확인할 수도 있지만, 그 경로는 Supabase 인증까지 타야 해서 번거로웠습니다. 대신 워커가 쓰는 것과 똑같은 서비스 계정 자격증명으로 로컬에서 Indexing API를 직접 한 번 때려봤습니다.

굳이 워커를 재배포하고 앱 UI를 거치지 않아도, "소유권 설정이 정말 통과하는가"라는 딱 하나의 질문에 답할 수 있으니까요. 결과는 이랬습니다.

✅ access token 발급 성공
[indexing] status=200
{
  "urlNotificationMetadata": {
    "url": "https://sentence.jeongwoo.in/sentence/..."
  }
}

403이 200으로 바뀌는 순간, 원인이 코드가 아니라 설정이었다는 게 명확해졌습니다. 문제를 좁혀서 “가장 작은 단위로 재현하고 검증하는” 이 습관이, 저는 삽질 시간을 가장 많이 줄여준다고 느낍니다.

마치며

정리하면 이렇습니다.

  • API 활성화는 GCP에서 스위치 하나 켜는 일 — 빼먹으면 SERVICE_DISABLED.
  • 소유권 등록이 진짜 관문 — 서비스 계정을 **소유자(Owner)**로, 속성은 대상 URL을 덮는 것으로.
  • 403 “Failed to verify the URL ownership” 은 코드가 아니라 Search Console 설정을 보라는 신호.

에러 메시지 하나를 어떻게 읽느냐에 따라, 코드를 헤집으며 몇 시간을 보낼 수도 있고 콘솔 설정 한 줄로 끝낼 수도 있습니다. 이번엔 "인증은 됐는데 왜 403이지?"라는 질문 하나가 방향을 제대로 잡아줬네요.