← 전체 글

88%에서 LLM 계정을 갈아탄 기록 — 수동으로 먼저, 그다음 임계값 80% 자동 전환

· 10 분 분량
대시보드 전체 화면. 오른쪽 위 Grok 주간 소진율 패널이 89%에 닿아 있다
오른쪽 위가 주간 한도입니다. 초기화까지 33시간, 남은 건 11%였습니다.

NOTE

제가 담당한 블로그 자동화 서비스는 예약된 시간에 글 본문을 grok으로 자동 생성합니다. 이때 종량 API 키가 아니라 구독 계정을 쓰는데, 구독제는 주간 한도가 정해져 있습니다. 한도가 차면 다음 초기화까지 글이 아예 안 나옵니다. 사용자들이 예약해 둔 글이 시간대마다 나가고 있어서, 한도의 88%를 쓴 시점에 알아채고 생성이 비는 새벽에 서브 계정으로 갈아탔습니다. 지금은 80%를 넘기면 알아서 grok 서브계정으로 넘어갑니다.

스케줄은 사용자가 겁니다. 블로그마다 cron이 걸려 있고, 워커가 그 시각에 맞춰 글을 만들어 내보냅니다.

계정을 전환할때 인증을 대신 해 주는 프록시를 재기동해야 하는데, 그 순간 만들고 있던 글이 있으면 그 글이 실패합니다.

그래서 한도가 차기 전에, 그리고 생성이 비어 있는 시각에 갈아타야 했습니다.

새벽에 대시보드를 확인하니 주 계정 이용량이 **88%**였습니다.

1. 88%를 보고 갈아탈 계획을 세웠다

주간 한도는 매주 같은 시각에 초기화됩니다. 그때가 88%였고, 초기화까지 33시간이 남아 있었습니다. 물어볼 건 하나였습니다. 남은 12%로 33시간을 버틸까?

발행 시각이 아니라 생성 시각으로 센다

블로그마다 cron이 걸려 있으니 앞으로 몇 건이 나갈지는 스케줄에서 셀 수 있습니다. 다만 함정이 하나 있었습니다.

worker/src/scheduler.ts
// pre-generation lookahead(기본 6시간): 이 범위 내 예정 스케줄을 미리 생성한다.
const PREGEN_LOOKAHEAD_MS = Math.max(
0,
Number(process.env.PREGEN_LOOKAHEAD_MS ?? 6 * 3600_000),
);

글은 발행 6시간 전에 미리 만들어집니다. 토큰은 만들 때 나가고, 발행은 이미 만들어 둔 걸 내보내기만 합니다. 그러니 지금부터 정오까지 얼마나 쓸지 알려면 정오까지 발행될 글이 아니라 정오까지 만들어질 글을 세야 합니다. 06:52부터 18:00 사이에 나갈 글들이죠.

DB의 last_run_at이 cron 시각보다 정확히 6시간 이른 걸 보고 알았습니다. 발행 기준으로 셌다면 창이 통째로 어긋났을 겁니다.

상한이 아니라 글감 재고를 센다

스케줄에는 posts_per_run이라는 상한이 있습니다. 그걸 다 더하면 그 창에 57건이 나옵니다. 그런데 실제로는 그만큼 안 나옵니다.

-- 스케줄이 실제로 집어갈 수 있는 글감 재고
SELECT count(*) FROM topics t
WHERE t.site_id = $1
AND t.status = 'verified'
AND t.content_plan_status IN ('ready', 'not_requested')
AND NOT EXISTS (SELECT 1 FROM posts p WHERE p.topic_id = t.id)

스케줄러는 verified 상태의 글감을 집어갑니다. 재고가 없는 블로그는 스케줄이 돌아도 0건을 만들고 끝납니다. 재고를 반영해 다시 세니 15건이었습니다.

건수
스케줄 실행 횟수39회
posts_per_run 합계57건
글감 재고 반영 실제15건

×10짜리 스케줄 둘이 재고가 0이라 빈 채로 돌고 있었습니다. 어제 같은 시간대의 schedule_runs를 보니 39회 중 실제로 만든 건 15건, 계산과 맞았습니다.

갈아탈 계정부터 확인한다

바로 갈아타기 전에 서브 계정을 먼저 봤습니다.

  • 같은 구독 모델인가. 주 계정과 같은 SuperGrok Heavy여야 한도 크기가 같습니다. 플랜이 다르면 계산이 전부 틀립니다.
  • 지금 쓰고 있나. 소진율이 0%가 아니면 갈아타는 의미가 줄어듭니다.
  • 초기화가 언제인가. 두 계정을 번갈아 쓰려면 주기가 어긋나 있는지 겹치는지 알아야 합니다.
공급자 콘솔의 주간 한도 화면. 소진율 89%, 초기화 시각, Grok 빌드 86% · Imagine 3% 항목별 내역이 보인다
공급자 콘솔의 주간 한도 화면. 소진율 89%, 초기화 시각, Grok 빌드 86% · Imagine 3% 항목별 내역이 보인다

플랜 이름도 소진율도 초기화 시각도 이 화면에만 있습니다. 개인 구독에는 공식 API가 없어서, 확인하려면 결국 콘솔을 봐야 합니다. 나중에 이 화면이 쓰는 요청을 그대로 흉내 내게 됩니다.

확인해 보니 같은 플랜에 0%였고, 초기화는 주 계정보다 3시간 뒤였습니다. 주기가 거의 겹쳐서 엇갈리게 쓰며 틈을 메우는 건 안 되지만, 대신 주간 총량이 두 배가 됩니다.

정오는 버틴다, 그러니 서두르지 않는다

시간대별 소비를 실측해 쌓아 보니 정오에 94% 안팎이었습니다. 급하지 않다는 뜻입니다. 그러면 아무 때나 갈아탈 이유가 없습니다. 만들고 있는 글이 0건인 시각을 골라서 하면 됩니다.

2. 계정 하나로는 애초에 모자랐다

계산하다 보니 더 중요한 게 나왔습니다.

주기가 시작되고 5.57일 동안 88%를 썼습니다. 하루 평균 16%p쯤 됩니다.

16%p/일 × 7일 = 112%p/주 ← 필요량
계정 하나의 한도 100%p

주간 수요가 한도를 넘습니다. 88%에서 아슬아슬했던 게 우연이 아니었습니다. 이번 주만 그런 게 아니라 매주 그럴 참이었습니다.

여기서 작업의 성격이 바뀌었습니다. 오늘 밤을 넘기려고 한 번 갈아타는 일이 아니라, 계정에 순서를 줘서 주 계정이 차면 자동으로 서브 계정으로 넘어가는 구조를 만드는 일이 됐습니다. 계정 둘이면 200%p니까 수요 112%p에 여유가 1.8배 남습니다.

IMPORTANT

서브 계정의 이번 주 한도 100%는 33시간 뒤 초기화와 함께 그냥 사라질 참이었습니다. 한 주 내내 안 쓴 채로 흘러왔으니까요. 안 쓰면 버려지는 몫이라, 주 계정의 남은 12%를 아낀다는 계산은 성립하지 않았습니다.

이걸 알고 나니 판단이 정리됐습니다. 글 발행을 미룬다고 해결되는 문제가 아니라 앞으로 발생할 위험을 줄이는 문제였고, 그러면 0%인 계정으로 지금 옮기는 쪽이 낫다고 판단했습니다.

3. 자동화를 미룬 이유, 그리고 미루길 잘한 이유

여기서 바로 자동 전환을 만들 수도 있었습니다. 안 만들었습니다.

트리거를 검증할 수가 없었다

자동 전환은 결국 언제 넘어갈지를 기계가 판단하는 일입니다. 가장 자연스러운 신호는 한도 초과 응답이겠죠. 그런데 그걸 조건으로 쓰려면 그 응답이 어떻게 생겼는지 알아야 합니다.

sum by (code) (increase(llm_http_responses_total{lane="grok"}[7d]))
→ code="200" 전부

7일 동안 200 말고 다른 코드가 하나도 없었습니다. 한도가 찼을 때 뭐가 오는지 실측 기록이 아예 없다는 뜻입니다. 조건을 코드로 쓸 수야 있지만 맞는지 확인할 방법이 없습니다. 틀리면 두 방향으로 틀립니다.

틀리는 방향결과
너무 둔감전환이 안 일어나고 글이 실패한다. 코드만 늘어난 셈이다
너무 예민순간 속도 제한을 한도 소진으로 오인해 멀쩡한 계정을 버린다

자동화해도 사람 손은 줄지 않는다

서브 계정의 토큰을 얻으려면 어떤 설계를 하든 대화형 로그인을 해야 합니다. 브라우저 OIDC라 자동화가 안 됩니다. 자동 전환을 먼저 완성해도 그 단계는 그대로 남습니다. 실제로 줄어드는 건 명령 두 줄뿐입니다.

Terminal window
rm ~/.grok-proxy/token.json
sudo systemctl restart xai-proxy

이 두 줄을 없애자고 검증 안 된 코드를 새벽에, 그것도 글 생성이 지나가는 유일한 경로에 밀어 넣을 이유가 없습니다.

밟은 순서

그래서 손으로 했습니다.

① 만들고 있는 글이 0건인 시각을 고른다. llm_inflight과 큐를 보고 생성이 비어 있는 창을 잡았습니다. 다음 생성 블록까지 1시간 20분이 남아 있었습니다.

② 살아 있는 토큰을 백업한다. 여기에 함정이 있었습니다. OIDC refresh token은 회전 방식이라 한 번 갱신하면 이전 것이 무효가 됩니다. 그래서 grok CLI가 만든 auth.json의 토큰은 이미 죽어 있었습니다. 우리 것은 6월자였습니다. 살아 있는 건 프록시가 들고 있는 상태 파일 쪽이었습니다.

③ 상태 파일이 기준이다. 프록시는 이렇게 동작합니다.

xai-proxy.py
def _load_state():
if os.path.exists(STATE_FILE):
return json.load(open(STATE_FILE)) # ← 있으면 이것만 본다
d = json.load(open(GROK_AUTH)) # ← 없을 때만 여기서 씨딩

auth.json에 새 계정으로 로그인해도 아무 일도 일어나지 않습니다. 상태 파일을 지워야 그때 다시 읽습니다. 뒤집어 말하면 로그인만 해두고 상태 파일을 안 지우면 운영은 그대로 굴러갑니다. 로그인 단계에서는 프로덕션이 전혀 흔들리지 않고, 되돌릴 것도 파일 하나뿐입니다.

④ 동의 화면의 Personal과 Team은 지갑이 다르다. 기기 인증 승인 화면에 Personal / Team 토글이 있습니다. Team을 고르면 주체가 팀이 되어서, 개인 구독의 주간 한도가 아니라 팀의 종량 크레딧을 씁니다. 갈아타는 의미가 사라지고 과금까지 붙습니다.

기존 계정이 어느 쪽인지 토큰을 열어 확인하고 진행했습니다.

principal_type = User ← Team 이 아니다
principal_id = <user_id> ← team_id 가 아니다
team_id = <있음> ← 필드는 있지만 주체는 User

team_id에 값이 들어 있어서 헷갈리는데, 주체를 정하는 건 principal_type입니다.

갈아탄 순간 화면이 거짓말을 했다

교체는 잘 끝났습니다. 프록시가 새 계정 토큰으로 붙었고 실제 호출도 200으로 돌아왔습니다. 그런데 소진율을 보니 새 계정은 0%인데 화면에는 **옛 계정의 90%**가 그대로 떠 있었습니다.

원인은 수집기의 파서였습니다. 소진율 응답은 protobuf인데, proto3는 기본값을 직렬화하지 않습니다. 소진율이 정확히 0인 계정에는 그 필드가 아예 없습니다.

# 총 소진율은 f1(0x0d) 뒤 4바이트
if (ratio < 0 && B[i] == 13) { ratio = f32(B[i+1], B[i+2], B[i+3], B[i+4]) }
...
if (ratio < 0 || ratio > 100) exit 1 # ← 못 찾으면 실패

파서는 필드를 못 찾으면 실패로 처리합니다. 실패하면 수집기가 아무것도 쓰지 않고, 그러면 직전에 성공한 값이 파일에 그대로 남습니다. 화면의 90%는 갱신이 멈춘 옛 계정 값이었습니다.

WARNING

이 실패가 고약한 건 화면이 비지 않아서입니다. 값이 사라지면 눈치채는데, 옛 값이 그대로 있으면 현재값으로 읽습니다. 비슷한 사고가 전에도 있었습니다. 4시간 낡은 잔액을 지금 잔액으로 읽고 판단한 적이 있습니다.

유효한 응답인지는 초기화 시각으로 가릅니다. 소진율이 0인 응답에도 초기화 시각은 실려 옵니다. gRPC 프레임 길이가 맞고 초기화 시각이 파싱되면 제대로 읽은 응답이고, 비어 있는 소진율은 실패가 아니라 0입니다.

valid = 0
if (n > 5 && B[0] == 0 && reset > 0) {
mlen = B[1]*16777216 + B[2]*65536 + B[3]*256 + B[4]
if (mlen > 0 && n >= mlen + 5) valid = 1
}
if (ratio < 0 && valid) ratio = 0 # 없으면 0 이다
if (ratio < 0 || ratio > 100) exit 1 # 프레임이 깨졌으면 예전처럼 실패

프레임이 깨졌거나 초기화 시각이 없으면 예전처럼 실패합니다. 로그인 HTML이나 잡음에서 0을 지어내지는 않습니다. 실제 0% 응답을 픽스처로 떠서 테스트에 박아 뒀습니다.

4. 자동 전환 구현방법

관측을 고치고 나서 자동 전환을 올렸습니다.

키에 순서를 준다

임계값보다 순서가 먼저입니다. 계정을 우선순위 목록으로 두고, 요청이 올 때마다 첫 번째부터 다시 봅니다.

xai-proxy.py
def order(self, now):
"""매번 0번부터 보므로 리셋된 계정이 저절로 복귀한다."""
usable = [a for a in self.accounts if not a.blocked(now)]
if usable:
return usable + [a for a in self.accounts if a.blocked(now)]
# 전부 막혀 있어도 포기하지 않는다 — 가장 덜 찬 계정으로 최선을 다한다
return sorted(self.accounts, key=lambda a: a.ratio())
def blocked(self, now):
if self.cooldown_until > now: return "cooldown"
if self.quota["ratio"] >= QUOTA_THRESHOLD: return "quota"

이렇게 두면 세 가지가 따라옵니다.

  • 서브는 메인이 비거나 마를 때만 받습니다. 메인이 멀쩡하면 서브는 순서상 뒤라 아예 안 쓰입니다.
  • 복귀에 코드가 필요 없습니다. 메인이 초기화되면 blocked()None을 돌려주고, 다음 요청부터 저절로 메인으로 돌아옵니다.
  • 전부 막혀도 멈추지 않습니다. 마지막 줄이 그 역할을 합니다. 여기서 예외를 던지면 글 생성이 통째로 죽습니다.

임계값은 0.80으로 뒀습니다. 100%까지 미는 건 위험합니다. 본문 생성에는 이 경로 말고 폴백이 없어서, 한도가 차면 재시도 세 번 뒤에 실패로 떨어집니다. 이미지는 다른 엔진으로 넘어가는 폴백이 있어 살아남습니다.

TIP

임계값은 하드 캡이 아닙니다. 계정이 전부 임계값을 넘으면 가장 덜 찬 계정으로 계속 씁니다. 웬만하면 여기서 다음 계정으로 넘어가라는 우선순위 신호일 뿐, 여기서 멈추라는 뜻이 아닙니다.

신호는 둘, 주와 안전망

전환 판단에 신호를 둘 씁니다.

신호역할
소진율 (주)요청 전에 미리 읽어서 임계값을 넘은 계정을 아예 안 고른다
응답 코드 (안전망)그래도 한도성 응답이 오면 쿨다운시키고 같은 요청 안에서 다음 계정으로 간다

소진율을 주 신호로 둔 이유는 앞에서 말한 그대로입니다. 한도 초과 응답의 실측 기록이 없어서 그것만으로는 검증할 수가 없습니다. 그렇다고 소진율만 믿지도 않습니다. 조회 경로가 비공식이라 언제 막힐지 모릅니다.

조회가 실패해도 계정을 막지는 않습니다. 못 읽었으면 못 읽은 대로 두고 넘어갑니다(fail-open). 소진율을 못 읽었다고 글 생성을 세우는 쪽이 손해가 훨씬 큽니다.

회전 토큰이 구조를 정했다

대시보드에 두 계정을 나란히 띄우려니 제약이 하나 걸렸습니다.

소진율을 조회하려면 그 계정의 액세스 토큰이 있어야 하고, 액세스 토큰은 리프레시 토큰으로 갱신합니다. 그런데 이 리프레시 토큰이 쓸 때마다 회전합니다. 그래서 한 계정의 토큰을 갱신하는 주체는 하나여야 합니다. 둘이 갱신하면 서로 무효화합니다.

수집기가 비활성 계정의 소진율을 못 읽는 것도 그 때문입니다. 읽으려면 갱신해야 하고, 갱신하면 프록시와 부딪힙니다.

프록시 ── 계정별 토큰 소유 · 갱신 · 소진율 조회 ──▶ 계정별 지표 파일
수집기 ────────────── 옮기기만 한다 ──────────────────┘

프록시가 읽어서 Prometheus 텍스트까지 만들어 두고, 수집기는 그걸 텍스트파일 디렉터리로 옮기기만 합니다. 셸에서 중첩 JSON을 파싱할 일도 없고, 스크레이프 대상을 새로 만들 일도 없습니다.

처음에는 수집기에도 파서를 그대로 뒀습니다. 두 벌이 서로를 검증해 준다고 봤는데, 그게 아니었습니다. 전환은 프록시가 자기 파싱 결과로 하고, 화면은 수집기 파싱 결과로 그립니다. 둘이 어긋나면 화면은 멀쩡한데 전환이 안 되는 상태가 되고, 그건 눈으로 못 잡습니다. 확인해 보니 수집기가 내던 지표는 판넬도 경보도 안 보고 있었습니다. 파서와 거기 딸린 코드 386줄을 걷어내고 프록시 한 곳으로 합쳤습니다.

CAUTION

코드만 지우면 안 됩니다. 지표 파일이 놓인 볼륨은 그대로 남아서, 파일을 지우지 않으면 수집기가 낡은 값을 영원히 내보냅니다. 값이 사라지면 눈치채지만 옛 값이 남으면 현재값으로 읽습니다. 이 글 앞에서 겪은 그 실패와 똑같습니다. 기동할 때 한 번 치우게 해 뒀습니다.

하나가 더 필요해졌습니다. 계정이 바뀌면 소진율 곡선이 90%에서 0%로 뚝 떨어집니다. 그 계단을 설명해 두지 않으면 나중에 그래프를 보고 수집이 고장 났다고 오해합니다. 그래서 지금 어느 계정이 도는지를 같은 화면에 띄웠습니다.

전환 후 대시보드. 주 계정 90%와 서브 계정 0%가 나란히 뜨고, 지금 트래픽을 받는 계정과 계정별 초기화 시각이 함께 보인다. 계정 이름은 가렸다
전환 후 대시보드. 주 계정 90%와 서브 계정 0%가 나란히 뜨고, 지금 트래픽을 받는 계정과 계정별 초기화 시각이 함께 보인다. 계정 이름은 가렸다

알림은 실패할 때만

전환 자체는 알리지 않습니다. 주간 한도 때문에 매주 몇 번씩 일어나는 정상 동작이라, 그걸 메일로 보내면 진짜 사건이 그 사이에 묻힙니다.

전환이 실패하는 길은 셋이고 조치가 각각 달라서, 규칙도 셋으로 나눴습니다.

규칙조건등급조치
no-target모든 계정이 막힘긴급갈 곳이 없다발행을 줄이거나 계정 추가
account-down계정 하나가 토큰을 못 얻음확인그 계정으로 갈 수 없다다시 로그인
blind소진율 수집이 멎음확인갈 때를 모른다프록시 상태 확인

조심한 게 둘 있습니다.

min이지 max가 아닙니다. 계정 하나가 한도에 닿는 건 정상입니다. 그러라고 둘을 뒀습니다. max로 쓰면 평소에도 울리고, 늘 켜져 있는 경보는 진짜 문제를 가립니다.

지표가 사라져도 울어야 합니다. 프록시가 죽으면 지표가 아예 없어지는데, 빈 결과는 조건이 평가되지 않아서 조용히 지나갑니다. 그게 가장 위험한 침묵입니다.

expr: (time() - max(grok_account_quota_updated_seconds)) or vector(999999)
noDataState: Alerting

5. 결과

갈아탄 날 밤의 첫 생성 블록은 오류 없이 지나갔습니다.

발행 9/9 성공
Grok 호출 text 45 · image 3 — 전부 200
실패율 16% (평소 7~21% 범위)
프록시 오류 없음

자동 전환도 양쪽으로 확인했습니다. 계정을 지정한 적은 없고 임계값만 바꿨습니다.

임계값요청을 받은 계정
0.80서브 (메인 90% > 80% 라 건너뜀)
0.95메인 ← 자격을 회복하자 스스로 복귀
0.80서브

프록시가 남긴 전환 이력입니다. reorder가 프록시 스스로 판단한 겁니다.

17:01 메인(0.90) → 서브(0.00) mode=manual ← 손으로
17:30 메인(0.90) → 서브(0.00) mode=reorder reason=quota
17:42 서브(0.00) → 메인(0.90) mode=reorder ← 자격 회복하자 복귀
17:43 메인(0.90) → 서브(0.00) mode=reorder reason=quota

note

비공식 조회 경로에 기대고 있습니다. 개인 구독에는 공식 API가 없어서 콘솔이 쓰는 gRPC-Web 엔드포인트를 그대로 부릅니다. 응답 구조가 바뀌면 조회가 깨집니다. 그래서 fail-open으로 뒀습니다. 깨져도 서비스는 안 죽고 전환 판단만 늦어지며, 그 사실은 blind 경보가 알려 줍니다.

감지가 최대 5분 늦습니다. 소진율을 캐시하기 때문입니다. 지금 속도로는 5분에 0.04%p라 없는 거나 마찬가지지만, 발행량이 크게 늘면 다시 볼 값입니다.

반응형 경로는 실전에서 발동한 적이 없습니다. 한도 초과 응답을 받아 본 적이 없으니 테스트로만 덮여 있는 셈입니다. 능동 신호가 주역인 한 문제는 없지만, 검증되지 않았다는 건 알고 있어야 합니다.