서비스 계정으로 Search Console에 sitemap 쓰기 — 403이 두 겹이었던 이유
사이트맵이 여러 달째 "대기(pending)" 상태로 멈춰 있었습니다. 새로 쓴 글이 조금이라도 빨리 발견되길 바라는 마음에, Search Console에 sitemap을 API로 다시 제출하기로 했습니다. 그런데 요청을 보내자마자 돌아온 것은 403 Forbidden이었습니다.
처음엔 단순한 권한 문제라고 생각했습니다. 자동화에 쓰는 서비스 계정이 속성에서 소유자가 아니라 그런 것이겠거니 하고, Search Console에서 그 계정을 소유자로 승격했습니다. 그리고 다시 제출했습니다. 여전히 403이었습니다. 이 글은 그 두 번째 403에서 시작합니다.
권한을 올렸는데 왜 여전히 403이었나
돌이켜 생각해보면, 저는 인가(authorization)를 한 겹으로 착각하고 있었습니다. "이 계정이 이 속성의 소유자라면 무엇이든 할 수 있다"는 가정입니다. 소유자로 올렸으니 쓰기도 당연히 될 것이다 — 이 예측이 빗나간 순간, 문제가 한 겹이 아니라는 것을 인정할 수밖에 없었습니다.
실제로 403은 서로 다른 두 개의 층에서 날 수 있었습니다. 하나는 이 계정이 이 속성에서 무엇을 할 수 있는가(리소스 권한)이고, 다른 하나는 내가 들고 있는 토큰이 애초에 무엇을 할 수 있도록 발급되었는가(OAuth 스코프)입니다. 저는 앞엣것만 올렸고, 뒤엣것은 손도 대지 못한 채였습니다.
OAuth 스코프와 리소스 권한은 다른 층입니다
두 층을 나란히 두면 이렇습니다.
| 층 | 무엇을 정하나 | 언제 정해지나 | 바꾸는 주체 |
|---|---|---|---|
| OAuth 스코프 | 토큰이 들고 다니는 권한의 종류·범위 | 토큰 발급 시점 (이후 불변) | 토큰을 발급하는 코드 |
| 리소스 권한(역할) | 이 속성에서 이 계정이 할 수 있는 일 | 언제든 조정 가능 | 속성 소유자 (Search Console UI) |
핵심은 가운데 열입니다. 스코프는 토큰을 발급하는 그 시점에 정해지고, 한 번 발급된 토큰의 스코프는 이후에 바뀌지 않습니다. webmasters.readonly 스코프로 발급된 토큰은, 그 계정이 속성에서 소유자든 무엇이든, 쓰기 API를 부르면 403입니다. 리소스 권한을 아무리 올려도 이 토큰으로는 영원히 쓸 수 없습니다. 쓰기가 통과하려면 두 조건이 동시에 참이어야 합니다 — 스코프에 쓰기가 포함돼 있고, 속성에서의 역할도 충분해야 합니다. 둘은 AND로 묶여 있습니다.
문제의 절반은 여기 있었습니다. 제가 쓰던 GSC 연동 도구는 서드파티 래퍼(MCP 서버)였는데, 이 래퍼가 OAuth 스코프를 소스 코드에 webmasters.readonly로 못박아 두고 있었습니다. 스코프를 바꿀 설정 옵션도 없었습니다. 그 도구가 발급하는 토큰은 태생부터 읽기 전용이었던 셈입니다. 그래서 제가 속성 권한을 소유자로 올리든 말든, 쓰기는 통과할 수 없었습니다.
흥미로운 것은, 그 래퍼로 데이터를 읽는 일은 멀쩡히 잘 되고 있었다는 점입니다. 당연합니다 — readonly 스코프는 읽기에는 충분하니까요. 그래서 저는 오랫동안 "이 도구는 잘 동작한다"고 믿고 있었습니다. 쓰기를 처음 시도하기 전까지는, 스코프가 반쪽짜리라는 사실을 마주칠 일이 없었던 것입니다.
두 층을 다 뚫기
원인을 두 층으로 나누고 나니, 해법도 자연스럽게 두 갈래가 되었습니다.
층 1 — 리소스 권한: 서비스 계정도 결국 한 명의 사용자입니다
자동화 계정이라고 해서 특별한 취급은 없습니다. 서비스 계정 역시 속성의 입장에서는 그저 하나의 "사용자"입니다. 그 계정의 client_email(예: ...@project.iam.gserviceaccount.com)을 Search Console의 설정 → 사용자 및 권한에서 속성에 추가하고, 역할을 소유자로 올리면 됩니다. 읽기가 이미 되고 있었다면 그 계정은 사용자로는 이미 등록돼 있을 테니, 새로 초대할 것 없이 역할만 올리면 됩니다.
층 2 — 스코프: 래퍼를 우회하고 full 스코프로 직접 발급합니다
래퍼가 스코프를 못박아 두었으니, 래퍼를 거치지 않고 서비스 계정 키로 토큰을 직접 발급하기로 했습니다. 발급하는 그 순간에 webmasters(readonly가 아닌) 스코프를 지정하면, 그 토큰은 쓰기까지 포함합니다. googleapis(Node)로는 이렇게 됩니다.
const { google } = require('googleapis');
const auth = new google.auth.JWT({
keyFile: '/path/to/service-account.json',
scopes: ['https://www.googleapis.com/auth/webmasters'], // readonly 아님 = 쓰기 포함
});
const sc = google.searchconsole({ version: 'v1', auth });
await sc.sitemaps.submit({
siteUrl: 'sc-domain:example.com',
feedpath: 'https://example.com/sitemap.xml',
});
// → 204 No Content = 제출 성공
돌아온 응답은 204 No Content였습니다. 본문 없는 204는, 이 API에서는 제출이 정상 처리되었다는 신호입니다. 몇 달을 대기 상태로 멈춰 있던 사이트맵이, 스코프 문자열 한 줄을 바꾸자 그제서야 접수되었습니다. 바꾼 것은 권한이 아니라, 토큰을 발급하는 방식이었습니다.
인증이 문제인지 스코프가 문제인지 빠르게 가르기
같은 403을 다시 만나지 않으려면, 어느 층이 막혔는지부터 가려야 합니다. 저는 쓰기를 시도하기 전에, 읽기 엔드포인트로 상태코드만 한 번 찍어 보는 방법을 씁니다. 상태코드 하나로 인증과 권한을 이분할 수 있습니다.
| 요청 | 상태코드 | 해석 |
|---|---|---|
읽기 (예: GET .../webmasters/v3/sites) |
200 | 인증·자격증명 정상 |
| 읽기 | 401 | 토큰 또는 키 파일 문제 (스코프 이전의 문제) |
쓰기 (sitemaps.submit) |
403 | 스코프 또는 역할 층에서 막힘 |
| 쓰기 | 204 | 제출 성공 |
읽기가 200이면 자격증명과 인증 자체는 멀쩡한 것입니다. 그 상태에서 쓰기만 403이라면, 남은 용의자는 스코프 아니면 역할, 두 층뿐입니다. 반대로 읽기부터 401이 뜬다면 그건 권한 이전에 토큰이나 키 파일이 잘못된 것이니, 스코프를 논하기 전에 자격증명부터 봐야 합니다.
한 가지 함정을 더 적어 둡니다. sc-domain: 접두사가 붙은 도메인 속성과 https://로 시작하는 URL 접두어 속성은 서로 별개의 속성입니다. 제출 대상의 속성 형식을 코드에서 정확히 맞추지 않으면, 스코프와 역할이 다 맞더라도 엉뚱한 속성에 제출하거나 실패하게 됩니다. 저도 이 둘을 한 번 혼동한 적이 있어, 함께 적어 둡니다.
한 번이냐, 상시냐
이번처럼 스크립트로 토큰을 직접 발급하는 방식은 도구를 건드리지 않는 일회성 우회입니다. 사이트맵 재제출처럼 어쩌다 한 번 하는 쓰기라면 이걸로 충분하고, 저는 이 짧은 스크립트를 그대로 보관해 두기로 했습니다. 다만 매번, 상시로 쓰기가 필요하다면 선택지는 두 가지입니다.
- 스크립트를 상시 보관해 필요할 때마다 실행합니다. 쓰기가 드물다면 이쪽이 가장 단순합니다.
- 래퍼(MCP 서버)를 로컬로 포크해, 스코프 한 줄만
webmasters로 바꿔 도구 안에서 쓰기까지 상시 쓰는 방법입니다. 다만 이 경우 업스트림 업데이트를 자동으로 따라가는 것은 포기해야 합니다.
어느 쪽을 고르든, 막고 있던 것이 도구가 아니라 스코프였다는 사실은 바뀌지 않습니다. 도구는 그 스코프를 편하게 감싸 두었을 뿐, 넘을 수 없는 벽은 아니었습니다.
정리하며
이번 일에서 남은 것을 몇 가지로 적어 둡니다.
가장 먼저, OAuth 스코프와 리소스 권한은 다른 층입니다. "권한을 줬는데 왜 403인가"라는 질문의 절반은 스코프에 답이 있습니다. 토큰이 읽기 전용으로 발급됐다면, 그 계정이 소유자여도 쓰지 못합니다. 403을 만나면 "역할"과 "스코프"라는 두 축을 따로 점검하는 습관이 필요하다는 생각이 들었습니다.
다음으로, 스코프는 발급 시점에 굳습니다. 이미 readonly로 만들어진 클라이언트나 도구는, 리소스 권한을 상향한다고 해서 고쳐지지 않습니다. 더 넓은 스코프로 토큰을 다시 발급하는 것 외에는 방법이 없습니다.
그리고, 서드파티 래퍼가 스코프를 하드코딩해 두었다면, 우회의 실마리는 결국 원천 자격증명입니다. 서비스 계정 키만 손에 있으면 어떤 스코프로든 토큰을 직접 만들 수 있습니다. 래퍼는 편의이지, 내가 할 수 있는 일의 한계선은 아니었습니다.
마지막으로, 서비스 계정도 속성의 한 사용자일 뿐입니다. client_email을 속성에 초대하고 역할을 주는 것에서부터 시작해야 합니다. 자동화 계정이라고 해서 다른 문으로 들어가는 일은 없었습니다. 결국 두 겹이라는 것을 알고 나면, 뚫는 순서는 오히려 단순했습니다.
관련 글
Claude Code에 Google Search Console MCP를 추가하고, OAuth 토큰 만료를 해결한 경험
GA4 MCP를 운영하다 OAuth 토큰이 만료되고, GSC MCP를 추가하면서 gcloud CLI 설치 오류까지 겪었습니다. ADC 토큰 갱신부터 Python 호환성 문제까지, 실전에서 마주친 트러블슈팅 과정을 정리했습니다.
급락 경보가 정작 급락에서 침묵했습니다 — GSC API가 0인 날짜를 생략하는 함정
검색 성과가 급락하면 Slack으로 알려주는 경보를 만들었는데, 정작 트래픽이 완전히 소멸하는 최악의 순간에 조용히 침묵했습니다. GSC Search Analytics API가 0인 날짜의 행을 아예 생략하는 성질과 위치 기반 슬라이스가 맞물린 역설을 한 줄씩 추적하고, 날짜 경계로 나눠 고친 버그 회고입니다.
NestJS 일일 리포트에 GA4, GSC 데이터 통합하기
기존 Grafana Prometheus 기반 서버 리포트에 Google Analytics 4와 Search Console 데이터를 추가하여, 서버 상태부터 사용자 행동, 검색 성과까지 한눈에 파악할 수 있는 통합 일일 리포트를 구축한 과정을 정리했습니다.