배포와 인프라8 분 읽기

내 유튜브 토큰이 매일 하나씩 죽었다 — OAuth '테스트' 모드의 7일 시한폭탄

채널 토큰이 하루에 하나씩 순차로 죽었습니다. 재발급 지옥(PKCE 불일치, 똑같은 이름의 브랜드 계정 여섯 개)을 지나 1원칙으로 근본을 찾으니, '재발급하면 됨'은 값싼 수정처럼 보이는 함정이었습니다. 진짜 원인은 OAuth 동의 화면의 게시 상태였습니다.

#oauth#youtube-api#tokens#gotchas#google-cloud
개념 도식: 왼쪽은 테스트 모드에서 refresh token이 7일마다 만료돼 채널이 순차로 죽는 그림, 오른쪽은 프로덕션 게시 후 무기한이지만 소급되지 않는다는 단서
테스트 모드는 refresh token을 7일 뒤 폐기한다. 프로덕션 게시는 그걸 멈추지만, 소급하지는 않는다.

대시보드 첫 줄이 빨갰습니다. 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일을 넘겨 살아남으면, 가설이 맞은 겁니다. 틀렸다면 다시 추적하면 됩니다. 지표를 라벨로 믿지 않는 태도는 여기에도 그대로 적용됩니다.

당신의 프로젝트를 점검할 세 가지:

  1. 당신의 OAuth 앱 게시 상태는 테스트인가 프로덕션인가? 테스트라면 모든 refresh token이 7일짜리 시한폭탄입니다.
  2. invalid_grant를 볼 때, 재발급으로 끝낼지 아니면 왜 만료됐는지 근본부터 볼지 — 값싼 수정의 유혹을 의심하고 있는가?
  3. 여러 자격 증명이 순차로 실패한다면, 그건 개별 사고가 아니라 공통 만료 정책의 신호일 수 있다. 개별로 대응하지 말고 패턴을 보라.

헤드리스 환경의 다른 API 자격 증명 함정은 토스 인베스트 API 기록에, 이 봇들을 띄워 둔 인프라 이야기는 여기 적어 뒀습니다.

지금 딱 하나만 해보세요. Cloud Console에서 OAuth 동의 화면의 게시 상태를 확인하세요. "테스트"라고 적혀 있다면, 당신의 토큰들도 조용히 카운트다운 중입니다.

관련 글