대시보드 첫 줄이 빨갰습니다. invalid_grant: Token has been expired or revoked. ko 운세 채널의 유튜브 토큰이 죽었습니다. 재발급하면 되는 일이라 여겼습니다. 그런데 다음 날 en이, 그 다음 ja가 같은 방식으로 죽었습니다. 하루에 하나씩, 순서대로.
질문 하나 먼저 드립니다. 당신의 OAuth 앱은 지금 "테스트"입니까, "프로덕션"입니까? 저는 이 값을 한 번도 확인한 적이 없었습니다.
먼저 재발급 지옥부터
근본을 찾기 전에, 표면의 고통이 둘 있었습니다. 둘 다 남에게 쓸모 있어 먼저 적습니다.
함정 1 — PKCE verifier 불일치. 헤드리스 서버라 브라우저는 다른 기기에서 엽니다. 인증 URL을 만들면 PKCE code_verifier가 파일에 저장되고, 코드 교환 때 그 verifier를 다시 써야 합니다. 그런데 브라우저 주소창 자동완성이 예전 URL(예전 code_challenge) 을 계속 붙잡았습니다. URL을 새로 만들어도 verifier 파일은 최신 것만 담으니, 옛 URL로 받은 코드와는 영원히 불일치 — 교환할 때마다 Invalid code verifier. 탈출법은 PKCE를 아예 빼는 것이었습니다.
# 손수 만든 URL: code_challenge 없이
params = {"response_type":"code","client_id":cid,
"redirect_uri":"http://localhost:8765/","scope":scope,
"access_type":"offline","prompt":"consent"}
# 교환: verifier 없이 client_secret만으로
flow.code_verifier = None
flow.fetch_token(code=code)데스크톱 클라이언트는 client_secret만으로 교환됩니다. PKCE-free URL은 state가 없어 재사용 가능 — 옛 탭이 물려도 무해합니다.
함정 2 — 똑같은 이름의 계정 여섯 개. 이 브랜드 계정 여섯 개가 계정 선택 화면에 전부 "OOTSSU"로만 뜹니다. 핸들이 안 보여서 어느 게 목표인지 화면만으론 못 고릅니다. 방법은 하나였습니다 — 일단 교환한 뒤 채널을 물어보는 것.
h = build("youtube","v3",creds).channels().list(
part="snippet", mine=True).execute()["items"][0]["snippet"]["customUrl"]
# 맞는 핸들일 때만 진짜 토큰 파일에 저장, 아니면 다음 계정 재시도최악의 경우 여섯 번. 실제로 여섯 번 가까이 돌렸습니다.
1원칙: 값싼 수정이 있는 척하는가
재발급은 됐습니다. 하지만 지표를 볼 때 거는 질문 — "이 라벨이 값싼 수정이 있는 척 하는가?" — 을 여기 걸었습니다. invalid_grant는 "재발급하면 끝"처럼 보입니다. 그게 함정이었습니다. 근본을 안 고치면 매주 반복될 테니까요.
가장 먼저 의심한 건 "재발급/재동의가 기존 토큰을 무효화한다"였습니다. ko를 찾느라 여섯 계정을 다 재동의했으니, 그게 en·ja를 죽였나? 반증이 나왔습니다. 같은 세션에서 재동의한 앱 다운로드 채널 세 개(krapp·enapp·jaapp)는 멀쩡히 살아 있었습니다. 재동의가 범인이면 걔네도 죽었어야 합니다. 재동의는 무해했습니다.
그럼 남는 건 순차 사망 패턴 하나. ko → en → ja, 하루 간격. 채널마다 마지막 인증 시점이 다르면, 일정 수명 뒤 그 순서대로 만료된다는 뜻입니다. Google OAuth의 문서화된 동작이 정확히 이겁니다. 동의 화면이 "테스트(Testing)" 게시 상태면 refresh token은 7일 뒤 만료됩니다.
여기서 멈추고 여쭙겠습니다. 당신이라면? 죽을 때마다 재발급하는 운영 루틴을 짜겠습니까 — 매주 이 삽질을 자동화하면서? 아니면 게시 상태 한 번 바꿔서 만료 자체를 없애겠습니까?
반전: 프로덕션 게시는 소급되지 않는다
해결은 콘솔 한 번입니다. Google Cloud Console → Auth Platform → Audience → 게시 상태를 "테스트"에서 "프로덕션"으로 게시. 민감 스코프라도 사용자 100명 미만이면 심사 없이 즉시 됩니다.
그런데 여기 반전이 있습니다. 프로덕션 게시는 소급 적용되지 않습니다. 테스트 모드일 때 발급된 토큰 — 방금 재발급한 ko·en·ja를 포함해 — 은 각자 7일 뒤 한 번 더 죽습니다. 무기한이 되는 건 프로덕션에서 재발급된 토큰부터입니다. 그러니 다음 주에 마지막 한 라운드가 남았습니다. 그때 재발급하면, 그 분부터 안정입니다.
솔직한 부분
원인을 100% 단정하진 못합니다. 테스트 모드 만료를 프로그램으로 직접 읽어 증명한 게 아니라, (1) 재동의 범인설의 반증과 (2) 순차 사망 패턴으로 가장 유력한 가설을 세운 겁니다. 진짜 검증은 다음 주입니다 — 프로덕션에서 재발급한 토큰이 7일을 넘겨 살아남으면, 가설이 맞은 겁니다. 틀렸다면 다시 추적하면 됩니다. 지표를 라벨로 믿지 않는 태도는 여기에도 그대로 적용됩니다.
당신의 프로젝트를 점검할 세 가지:
- 당신의 OAuth 앱 게시 상태는 테스트인가 프로덕션인가? 테스트라면 모든 refresh token이 7일짜리 시한폭탄입니다.
invalid_grant를 볼 때, 재발급으로 끝낼지 아니면 왜 만료됐는지 근본부터 볼지 — 값싼 수정의 유혹을 의심하고 있는가?- 여러 자격 증명이 순차로 실패한다면, 그건 개별 사고가 아니라 공통 만료 정책의 신호일 수 있다. 개별로 대응하지 말고 패턴을 보라.
헤드리스 환경의 다른 API 자격 증명 함정은 토스 인베스트 API 기록에, 이 봇들을 띄워 둔 인프라 이야기는 여기 적어 뒀습니다.
지금 딱 하나만 해보세요. Cloud Console에서 OAuth 동의 화면의 게시 상태를 확인하세요. "테스트"라고 적혀 있다면, 당신의 토큰들도 조용히 카운트다운 중입니다.