Google OAuth 토큰 다시 발급하기 — YouTube·Drive invalid_grant 해결
잘 돌아가던 자동화가 갑자기 죽었다
처음에는 YouTube 업로드도, Drive 파일 업로드도 잘 됐다. 그런데 어느 날 두 작업이 동시에 실패했다. 로그에는 다음과 같은 문장이 남았다.
invalid_grant: Token has been expired or revoked.
처음에는 client_secret.json을 다시 받아야 하나 생각했다. 하지만 이 오류에서 먼저 봐야 할 것은 클라이언트 시크릿이 아니라 refresh token이었다.
client_secret.json은 어떤 애플리케이션이 OAuth를 요청하는지 설명하는 클라이언트 자격 증명이다. 반면 토큰은 특정 Google 계정이 특정 scope를 승인한 결과다. 둘은 서로 교체할 수 있는 파일이 아니다.
처음 Google Cloud 프로젝트와 OAuth 클라이언트를 설정하는 방법은 기존 글에서 다뤘다. 이 글에서는 그다음 운영 단계에서 토큰이 죽었을 때를 다룬다.
client secret, access token, refresh token은 다르다
- client secret: OAuth 클라이언트의 신원을 설명한다.
- access token: Google API 요청에 사용하는 단기 토큰이다.
- refresh token: access token을 새로 받는 데 사용하는 장기 토큰이다.
access token이 만료되면 클라이언트 라이브러리는 유효한 refresh token으로 새 access token을 요청할 수 있다. 하지만 refresh token 자체가 무효화되면 같은 파일을 계속 재사용해도 복구되지 않는다. 사용자의 동의가 다시 필요하다.[1]
따라서 invalid_grant가 발생하면 먼저 구분해야 한다.
access token만 만료됨 → refresh token으로 자동 갱신 가능성
refresh token 무효화됨 → OAuth 재인증 필요
scope가 부족함 → 필요한 scope 전체로 다시 동의 필요
refresh token은 왜 무효화되는가
Google 공식 문서가 안내하는 대표적인 원인은 다음과 같다.
- 사용자가 앱의 계정 접근 권한을 취소한 경우
- refresh token을 6개월 동안 사용하지 않은 경우
- Gmail scope가 포함된 상태에서 사용자가 비밀번호를 변경한 경우
- Google Account와 OAuth client ID 조합에서 허용된 live refresh token 수를 넘은 경우
- 사용자가 time-based access를 허용했고 그 기간이 끝난 경우
- Google Workspace 관리자가 요청한 서비스나 scope를 제한한 경우
- Google Cloud 조직의 session control 정책이 만료된 경우
- External 사용자 유형이면서 게시 상태가 Testing인 프로젝트에서 발급한 경우
특히 마지막 조건은 자동화에서 자주 놓치는 함정이다. OAuth 앱이 External 사용자 유형이고 게시 상태가 Testing이면, YouTube·Drive처럼 사용자 데이터에 접근하는 scope를 요청해 발급한 refresh token은 일반적으로 동의 시점부터 7일 후 만료된다. 단, openid, userinfo.email, userinfo.profile 계열처럼 이름·이메일·프로필 정보만 요청한 경우에는 이 7일 만료 예외가 적용되지 않는다. YouTube나 Drive scope를 함께 요청하면 이 예외에 해당하지 않는다.[2]
또 하나 놓치기 쉬운 제한이 있다. Google은 한 Google Account와 하나의 OAuth client ID 조합에 대해 최대 100개의 refresh token을 허용한다. 한도를 넘으면 새 토큰을 만들 때 가장 오래된 토큰이 경고 없이 무효화될 수 있다. 테스트 과정에서 여러 컴퓨터와 스크립트로 반복해서 인증했다면 이 제한도 의심해야 한다.[1]
그래서 invalid_grant를 단순히 “사용자가 취소했다”로 해석하면 안 된다. 발생 시점, OAuth 앱의 게시 상태, 요청 scope, 최근 재인증 횟수, 조직 정책을 함께 봐야 한다.
재인증 전에 할 일
기존 토큰 파일을 곧바로 삭제하지 않았다. 먼저 안전한 로컬 위치에 백업했다.
기존 토큰 백업
→ 파일 권한 확인
→ Git·공개 폴더·로그에 포함되지 않는지 확인
→ 새 OAuth 인증 시작
백업 파일도 access token과 refresh token을 포함할 수 있으므로 비밀정보로 취급해야 한다. 토큰 값이나 authorization code를 터미널 출력, 이슈, 블로그 본문에 남기지 않는다.
우리 자동화에서 YouTube 토큰을 다시 발급한 방법
1. 실제 기능에 필요한 scope를 확정한다
Google Cloud Console에 YouTube Data API를 활성화한 것과, 저장된 토큰이 해당 권한을 갖는 것은 별개의 문제다. OAuth 동의 화면에 등록한 scope와 코드의 SCOPES가 현재 기능과 맞는지 먼저 확인했다.
SCOPES = [
"https://www.googleapis.com/auth/youtube.upload",
"https://www.googleapis.com/auth/youtube.force-ssl",
]
사용하지 않는 권한까지 넣기보다 실제 기능에 필요한 최소 scope를 요청하는 편이 안전하다. scope를 추가했다면 기존 refresh token이 자동으로 권한을 확장해주지 않는다. 필요한 scope 전체를 포함한 OAuth 동의 흐름을 다시 실행해야 한다.[3]
2. 채널 이름을 인자로 인증 URL을 생성한다
우리 자동화에서는 채널명을 인자로 받아 PKCE 상태와 인증 URL을 생성한다.
python3 scripts/yt_gen_auth_url.py <channel>
출력된 URL을 브라우저에서 열고 Google 계정으로 로그인한 뒤 권한을 승인한다. access_type=offline은 사용자가 없는 동안에도 access token을 갱신할 수 있도록 refresh token을 요청하는 설정이다. 재인증 과정에서는 prompt=consent를 사용해 Google 동의 화면을 다시 거치도록 한다. 실제로 어떤 scope가 승인됐고 refresh token이 반환됐는지는 교환 응답과 저장 결과로 확인해야 한다.[1]
PKCE에서는 인증 요청마다 code verifier가 만들어지고, 교환 단계에서 같은 verifier가 검증된다. S256 방식이 권장된다.[4]
3. 수동 redirect 전달은 우리 구현에 한정된 흐름이다
현재 우리 자동화는 redirect URL을 수동으로 복사해 교환 스크립트에 전달한다. 이 방식에서는 승인 뒤 브라우저가 localhost로 이동하면서 연결 실패 화면을 보여줄 수 있다.
이 경우 인증이 반드시 실패했다는 뜻은 아니다. 주소창의 전체 redirect URL을 복사해 다음 단계에 전달한다.
python3 scripts/yt_exchange_token.py <channel> "<전체 redirect URL>"
Google이 폐기한 것은 과거의 OOB(manual copy/paste) 인증 방식이다. Desktop 앱에서는 현재도 loopback redirect가 권장되지만, 정상적인 구현이라면 로컬 HTTP listener가 callback을 직접 받아야 한다. 우리 자동화는 이 callback URL을 사용자가 수동으로 전달하는 자체 구현이다.[4]
authorization code는 한 번만 사용할 수 있고 유효 시간이 짧다. 승인 직후 바로 교환하는 편이 안전하다.
4. 채널별 파일 분리는 Google의 파일 규칙이 아니다
OAuth는 기본적으로 Google 사용자가 애플리케이션에 권한을 부여하는 구조다. Google이 YouTube 채널별 토큰 파일을 요구하는 것은 아니다.
우리 자동화에서는 여러 채널을 운영하면서 잘못된 채널에 업로드하는 사고를 막고, 인증 상태를 독립적으로 점검하기 위해 토큰 파일을 채널별로 분리한다.
config/youtube/channel-a/oauth2_token.json
config/youtube/channel-b/oauth2_token.json
이것은 Google OAuth의 기본 단위가 아니라 우리 운영 규칙이다. 특히 하나의 Google 계정이 여러 YouTube 채널이나 Brand Account를 관리하면, 토큰 파일을 나누는 것만으로 채널 선택이 보장되지 않는다. 인증 뒤 API가 실제로 어떤 채널을 반환하는지 확인해야 한다.
5. channels.list(mine=true)로 실제 채널을 확인한다
토큰 파일이 생성됐다는 사실만으로 완료하지 않는다. 인증된 사용자가 소유한 채널을 API로 조회하고 예상한 channel ID와 비교한다.
channels.list(
part=snippet,
mine=true
)
검증 결과에는 채널 이름과 channel ID만 표시한다. access token, refresh token, client secret, authorization code는 출력하지 않는다.[5]
우리 자동화에서 Drive 토큰을 다시 발급한 방법
1. Drive scope를 좁게 시작한다
Drive API는 Google Cloud Console과 애플리케이션 코드 양쪽에서 scope를 선언한다. 가능한 경우 실제 작업에 필요한 가장 좁은 scope부터 검토한다.
drive.file은 앱이 만든 파일이나 사용자가 앱과 공유한 파일처럼 앱과 연결된 파일 중심으로 접근하는 권한이다. 사용자의 전체 Drive를 읽는 권한이 아니므로, 이 scope에서 파일 목록이 비어 있다고 곧바로 OAuth 실패로 판단하면 안 된다.[6]
2. Drive 전용 토큰을 별도로 발급한다
cd <blog-automation-project>
python3 gen_auth_url.py
Google 계정으로 Drive 권한을 승인한 뒤 authorization code를 교환하고 결과가 drive_token.json에 저장되는지 확인한다.
Blogger와 Drive를 함께 사용하는 프로젝트라면 다음 파일을 서로 복사하지 않는다.
token.json → Blogger 용도
drive_token.json → Drive 용도
같은 Google 계정으로 발급했더라도 저장된 scope가 다르면 API 요청이 거부될 수 있다. scope를 바꿨다면 필요한 권한 전체로 다시 동의한다.
3. 작은 실제 API 요청으로 검증한다
파일이 존재하는지만 확인하지 말고 Drive API를 실제로 호출한다.
files.list(
q="trashed = false",
pageSize=1,
fields="files(id,name)"
)
drive.file을 사용한다면 앱이 만든 파일이거나 앱과 공유된 파일인지도 확인해야 한다. 검증 로그에는 파일 이름 정도만 남기고 토큰 값은 기록하지 않는다.
재발급했다고 끝난 것이 아니다
이번에 가장 중요했던 것은 새 토큰 파일의 생성 여부가 아니었다. 새 토큰으로 실제 대상과 실제 API를 확인하는 것이었다.
YouTube는 다음 순서로 확인한다.
refresh 호출 성공
→ channels.list(mine=true)
→ 예상 channel ID와 비교
→ 비공개 테스트 작업 1회 실행
Drive는 다음 순서로 확인한다.
refresh 호출 성공
→ files.list 또는 대상 파일 조회
→ 파일 권한·scope 범위 확인
→ 실패했던 업로드 작업 1회 재실행
invalid_grant가 사라졌다는 것만으로 올바른 채널에 업로드된다는 보장은 없다. 인증 성공과 대상 확인은 별도의 검증 단계다.
운영하면서 남긴 규칙
- YouTube 채널별 토큰 파일 분리는 우리 운영 규칙이다.
- Drive 토큰과 Blogger 토큰은 용도별로 분리한다.
- scope를 추가하거나 바꾸면 기존 토큰이 자동으로 확장된다고 가정하지 않는다.
- External + Testing 프로젝트에서 YouTube·Drive scope로 발급한 refresh token은 7일 만료를 전제로 관리한다.
- 반복 인증으로 OAuth client ID별 refresh token 100개 제한에 가까워지지 않게 한다.
- 토큰·client secret·authorization code는 Git, 로그, 공개 문서에 남기지 않는다.
- 새 토큰 발급 후에는 실제 YouTube channel ID와 Drive API 호출을 확인한다.
- 일반 설치형 앱을 새로 만든다면 수동 복사 방식보다 loopback callback listener 방식을 우선 검토한다.
체크리스트
- [ ] 오류가 access token, refresh token, scope 중 무엇인지 구분했는가
- [ ] 기존 토큰을 안전하게 백업했는가
- [ ] OAuth 앱의 User Type과 Publishing status를 확인했는가
- [ ] Testing 상태에서 7일 만료 조건과 scope 예외를 확인했는가
- [ ] 최근 여러 환경에서 반복 발급해 100개 제한에 가까워지지 않았는가
- [ ] YouTube scope가 실제 기능과 일치하는가
- [ ] YouTube 인증 후
channels.list(mine=true)의 channel ID가 예상과 일치하는가 - [ ] Drive scope가 실제 작업에 필요한 최소 범위인가
- [ ] Drive API 실제 호출이 성공하는가
- [ ] scope 변경 시 전체 OAuth 동의를 다시 진행했는가
- [ ] 토큰과 authorization code가 Git·로그·공개 폴더에 남지 않았는가
- [ ] 실패했던 YouTube·Drive 작업을 테스트로 한 번 재실행했는가
마무리
Google OAuth 자동화가 멈췄을 때 client_secret.json부터 다시 받는 것은 빠른 해결책이 아닐 수 있다. 먼저 어떤 값이 죽었는지 나눠야 한다.
access token만 만료됐다면 자동 갱신으로 끝날 수 있다. refresh token이 무효화됐다면 다시 동의해야 한다. scope가 바뀌었다면 필요한 권한 전체로 재인증해야 한다. 그리고 새 토큰이 생겼다는 사실보다 중요한 것은 올바른 YouTube 채널과 Drive API를 실제로 확인하는 일이다.
처음 설정은 기존 글에서, 토큰 복구는 이 글에서 다룬다. OAuth는 인증 URL을 한 번 여는 작업으로 끝나는 기능이 아니라, 만료·폐기·scope·대상 리소스를 계속 확인해야 하는 운영 요소였다.
Sources
[1] Google OAuth 2.0 개요 및 refresh token 만료 조건
[2] Google Cloud 앱 사용자 유형 및 게시 상태
[4] Google 설치형 앱 OAuth와 PKCE·loopback