전월 대비 +2,217% 짜리 청구서를 받았다. 이 글은 그 원인을 추적해서 막기까지의 기록이다. 결론부터 적으면, 범인은 비싼 모델큰 입력도 아니었다. 1분마다 도는 크론 7대와, 그 한 번의 쓰기가 뒤에서 조용히 부르던 LLM 호출 8회였다.

1. 청구서

Google Cloud 결제 화면의 숫자는 이랬다.

항목
월간 누적 ₩183,711
월말 예상 ₩565,510
전월 대비 +2,217.24%
남은 크레딧 ₩0 (소진)

전월까지는 사실상 크레딧 안에서 놀던 금액이었다. 즉 새로 생긴 무언가가 있다는 뜻이고, 그러면 찾아야 할 것은 “무엇이 비싼가”가 아니라 “언제부터인가” 다.

2. 후보를 세우고, 측정으로 기각한다

제미나이 키를 쓰는 워크로드는 넷이었다. 추측하지 않고 하나씩 실제 호출량을 봤다.

후보 판정 근거
자체 에이전트 오케스트레이터 기각 해당 기간 호출량 변화 없음
LiteLLM 게이트웨이 기각 라우팅 로그상 제미나이 경유 트래픽 미미
정산 서비스 기각 제미나이 호출 경로 자체가 없음
XR 프로젝트(묵상 생성) 기각 호출은 있으나 8월과 동일 수준
메모리 서버(OpenViking) 잔류 아래

여기서 중요한 건 “기각”의 품질이다. 「안 썼을 것 같다」가 아니라 그 기간의 호출 카운트를 직접 세서 8월과 다르지 않음을 확인한 뒤에야 뺐다. 후보를 감으로 빼면 남은 하나도 감이 된다.

3. 로그 시계열이 변곡점을 찍어준다

메모리 서버는 디렉터리 요약을 만들 때마다 로그에 Generated abstract.md 한 줄을 남긴다. 이 문자열의 일별 발생 횟수를 Elasticsearch 에서 세면 그대로 LLM 호출 시계열이 된다.

기간 Generated abstract.md 건수
8월 한 달 46
9/1 – 9/3 24
9/4 – 9/14 2,833 (일평균 258)

9월 4일에 계단이 있다. 그리고 그날 아침에 내가 설치한 게 있었다 — 에이전트 7대(맥 1 + 노드 6)가 서로의 상태를 공유하려고 메모리 서버에 하트비트를 쓰는 동기화 잡. 맥은 launchd StartInterval 60, 노드 6대는 crontab * * * * *. 7대 × 분당 1회. 잡 스크립트의 mtime 도 9/4 오전으로 청구서 시작점과 맞았다.

여기까지가 “상관관계”다. 인과로 올리려면 쓰기 한 번이 정말 LLM 을 부르는지, 부른다면 몇 번 부르는지를 봐야 한다.

4. 내가 처음 세운 대책은 틀렸다

첫 가설은 이랬다. “이 서버는 memories/ 경로에 쓸 때만 의미 요약을 돌리니까, 하트비트를 resources/ 로 옮기면 공짜다.”

그럴듯했지만 틀렸다. 실행 중인 이미지에서 패키지 소스를 직접 읽어보니:

  • 쓰기 엔드포인트는 POST /api/v1/content/write 하나뿐이다. 파일시스템 라우터에는 ls / tree / stat / mkdir / DELETE / mv 만 있고 원시 쓰기 API 가 없다.
  • 경로에 따라 갈리는 건 처리기뿐이다. /memories/ → 메모리 처리기, 그 외 → DAG 실행기.
  • 그런데 두 갈래 모두 _generate_overview() 를 호출한다. 메모리 처리기의 디렉터리 처리 경로에서 한 번, DAG 실행기의 overview 태스크에서 한 번 — 이름만 다르지 같은 LLM 호출이다.

어느 경로로 쓰든 LLM 을 탄다. 경로를 옮겨 요금을 피하는 길은 없다. 남는 손잡이는 하나, 쓰는 횟수뿐이다.

이 절을 굳이 남기는 이유는, 이게 이번 조사에서 가장 값싼 교훈이었기 때문이다. 그럴듯한 대책을 구현하기 전에 소스를 읽는 데 든 비용은 20분이었다. 구현했다면 요금은 그대로인 채 “조치 완료” 로 닫혔을 것이다.

5. 쓰기 1회의 원가를 직접 잰다

추정 대신 재기로 했다. 하트비트를 딱 한 번 수동으로 밀고 그 순간의 로그를 봤다.

13:01:19 Processing semantic generation for: .../memories/bus (recursive=True)
13:01:19 Parsed 0 existing summaries from overview.md
13:01:19 Processing memory directory ... changes: added=0, modified=1, deleted=0
13:01:19 Generating summaries for 7 changed files (reused 0 cached)
13:01:55 Generated abstract.md and overview.md for .../memories/bus
13:01:55 Registered embedding tracker ...: 2 tasks

한 줄씩 읽으면 이렇다.

  • modified=1 — 실제로 바뀐 파일은 하나다.
  • Generating summaries for 7 changed files — 그런데 7개를 다시 요약한다.
  • reused 0 cached — 요약 캐시가 하나도 맞지 않았다. 바뀐 건 하나인데 7개를 전부 다시 태웠다. 그러면 한 번의 쓰기가 최대 8배로 부푼다.

나중에 정정한 부분. 이 글을 올린 뒤 같은 경로를 여러 번 더 돌려보니, 캐시는 항상 빗나가는 게 아니라 직전에 쓰인 overview.md 를 파서가 읽어낼 수 있을 때만 맞았다(Parsed 7 existing summariesreused 6 cached). 어떤 이유로든 overview.md 의 형식이 깨지면 그 다음 쓰기는 Parsed 0reused 0 이 되고 전량 재요약으로 간다. 처음 관측한 reused 0 은 “캐시가 원래 안 맞는다”가 아니라 그 직전 상태가 깨져 있었다는 뜻이었다. 한 번의 관측으로 상시 결함이라고 단정한 것이 성급했다. 다만 한 번의 쓰기가 LLM 8회까지 부풀 수 있다는 결론과 아래 산수는 그대로다 — 청구 총액을 실측 쓰기 횟수로 나눈 값이기 때문이다.

  • 그리고 디렉터리 overview 생성 1회 + 임베딩 2건.
  • 13:01:19 → 13:01:55, 36초.

쓰기 1회 = LLM 8회. 그러면 산수가 맞는다.

2,833회 × 8 = 22,664 LLM 호출
₩183,711 ÷ 2,833회 ≈ 쓰기 1회당 ₩65
₩183,711 ÷ 22,664회 ≈ 호출 1회당 ₩8.1

6. 1,116바이트가 남긴 질문

여기서 한 번 더 막혔다. 호출 1건에 ₩8.1 이면 입력이 커서일 것 같은데, 하트비트 파일 7개의 총 크기는 1,116바이트다. API 로 디렉터리를 조회해 실측한 값이다. 대략 300토큰. 이 입력으로 ₩8.1 이 나올 수가 없다.

그러면 돈은 출력 쪽이다. 그리고 Gemini API 공식 가격표를 보면 과금 항목명 자체가 답을 갖고 있다 — 모든 모델의 행 제목이 이렇게 적혀 있다:

Output price (including thinking tokens)Gemini Developer API pricing

공식 문서는 또 “The Gemini 3 and 2.5 series models use a ‘thinking process’” 라고 명시한다(Gemini thinking). 사고 토큰은 출력 토큰으로 과금된다. 그리고 Flash 계열 가격표에서 출력 단가는 입력 단가의 5~6배다.

정리하면, 300토큰짜리 파일 목록을 요약시키는 호출이 비싼 이유는 요약문이 길어서가 아니라 모델이 요약하기 전에 생각한 분량이 과금되기 때문이다. 온도 0, 짧은 입력, 기계적인 작업 — 어느 것도 이걸 막아주지 않는다.

근거의 한계를 밝혀둔다. 현재 공식 가격 페이지는 Gemini 3.x 계열을 싣고 있어 이 서버가 쓰던 특정 모델의 1M 토큰당 단가를 그 페이지에서 확인하지 못했다. 그래서 위 문단은 단가 숫자가 아니라 과금 항목의 정의(출력에 사고 토큰 포함)와 입력 실측치 1,116바이트에 기대고 있다. 호출당 ₩8.1 은 청구 총액을 실측 호출 수로 나눈 값이지, 벤더가 제공한 내역이 아니다.

7. 막은 방법

원인이 “쓰기 횟수” 하나로 좁혀졌으므로 대책도 하나다.

① 즉시 정지. 7대의 동기화 잡을 전부 껐다. 맥은 launchd 에서 언로드, 노드 6대는 crontab 라인을 지우지 않고 주석 처리했다 — 왜 껐는지가 파일에 남아야 6개월 뒤의 내가 되살리지 않는다.

#[2026-09-14 gemini burn] * * * * * python3 .../bus_remote.py sync >/dev/null 2>&1

② 레이트 리밋을 코드에 상수로 박았다. 되살릴 때를 대비해서.

LIVENESS = 24 * 3600    # 내용이 그대로면 이 간격으로만 다시 쓴다 (30분 → 24시간)
MIN_PUSH = 6 * 3600     # 내용이 바뀌어도 이 간격 안에는 다시 안 쓴다

원래 코드에는 ①(내용 동일 시 스킵)만 있었고, 그 간격이 30분이었다. 그래서 아무 일도 안 일어나도 7대 × 48회/일 = 336회가 꼬박꼬박 나갔다. 이번에 ②를 새로 넣었다. 내용이 바뀌어도 6시간 안에는 안 쓴다. 이건 버스의 해상도를 깎아 요금을 사는 거래이고, 의도적으로 그렇게 정했다.

이 두 상수가 곧 예산이다.

7대 × (24h ÷ 6h) = 하루 최대 28회 × ₩65 ≈ 월 ₩55,000
(이전 설정은 월 ₩550,000 궤도였다)

③ 감시를 붙였다. 같은 로그 문자열의 최근 1시간 건수를 세서 임계치를 넘으면 텔레그램으로 알린다. 청구서는 한 달 뒤에 오지만 로그는 지금 온다. 이번 사고에서 가장 아팠던 건 금액이 아니라 10일 동안 아무도 몰랐다는 것이다.

④ 요약 모델을 로컬로 내렸다. 디렉터리 요약은 품질 요구가 낮은 기계적 작업이라 상용 모델을 쓸 이유가 없다고 봤다. 같은 노드에 OpenAI 호환 엔드포인트(Ollama)를 띄우고 요약 모델만 3B 로컬 모델로 돌렸다. 임베딩은 건드리지 않았다 — 벡터 DB 컬렉션이 3072차원으로 이미 만들어져 있어서 임베딩 모델을 바꾸면 저장된 벡터가 전부 무효가 되고, 임베딩은 전체 비용의 0.3% 수준이라 바꿀 이유도 없다.

여기서 두 가지가 걸렸고, 둘 다 적어둘 값어치가 있다.

(1) 기본 타임아웃 60초에 걸려 두 번 다 실패했다. CPU 추론 실측이 5.1 토큰/초라 overview 생성이 60초를 넘겼고, 로그에는 정확히 1m0s 짜리 500 이 두 번(= max_retries: 2) 찍혔다. 클라이언트 쪽 timeout 값을 올려서 풀었다. 값이 정확히 60.000초면 그건 모델이 아니라 누군가의 기본값이다.

(2) 3B 모델이 형식을 못 지켰다. 내용은 멀쩡했지만(실제 호스트 이름들을 정확히 읽었다) 출력이 지시문 골격(1. **Title** (H1): …)을 그대로 뱉었다. 그러면 다음 쓰기에서 Parsed 0 existing summaries 가 되고, 위에서 말한 캐시가 영구히 빗나간다. 돈은 0원이 됐지만 쓰기 1회가 36초에서 4분 37초로 늘었다. 작은 모델을 붙일 때 먼저 깨지는 건 지식이 아니라 형식 준수다.

8. 남는 것

  • 요약 캐시는 overview.md 의 형식에 매달려 있다. 파서가 직전 결과를 못 읽으면 다음 쓰기가 전량 재요약으로 간다. 요약 모델을 바꾸면 이 지점이 먼저 깨진다(실제로 깨뜨려 봤다 — 아래).
  • 예산 상한(하드 캡)은 아직 없다. 레이트 리밋은 내 코드 안의 약속이지 벤더 쪽 차단선이 아니다. 내 코드 밖의 무언가가 같은 키를 쓰면 그대로 뚫린다.
  • 키를 워크로드별로 분리하지 않았었다. 이번 조사에서 제일 오래 걸린 단계가 “누가 썼는지” 였는데, 키가 하나면 청구서는 절대 그걸 알려주지 않는다.

이번 건에서 배운 것

  1. 금액이 아니라 시각을 먼저 본다. “무엇이 비싼가”로 시작하면 모델 단가표를 들여다보게 되고, 그 길엔 답이 없다. “언제부터인가”로 시작하면 로그 한 줄의 일별 카운트가 범인을 찍어준다.
  2. 후보는 감으로 빼지 않는다. 네 개를 실측으로 기각했기 때문에 남은 하나를 믿을 수 있었다.
  3. 대책을 구현하기 전에 소스를 읽는다. “경로를 옮기면 된다”는 20분짜리 확인으로 무너졌다. 확인하지 않았다면 요금은 그대로인 채 티켓만 닫혔다.
  4. 원가는 재는 것이지 추정하는 게 아니다. 한 번 밀고 로그를 읽으니 “8회 / 36초 / 캐시 0회” 가 그냥 적혀 있었다.
  5. 작은 입력이 싼 호출이라는 보장은 없다. 과금 항목에 사고 토큰이 들어 있는 한, 300토큰 입력도 얼마든지 비쌀 수 있다.
  6. 주기적 잡은 곱셈이다. 1분 × 7대는 “자주”가 아니라 하루 10,080회다. 호출 1회가 ₩65 이면 그건 잡이 아니라 정기 결제다.

References

  • Google, Gemini Developer API pricinghttps://ai.google.dev/gemini-api/docs/pricing (과금 항목명 “Output price (including thinking tokens)”, Flash 계열 입·출력 단가 비율)
  • Google, Gemini thinkinghttps://ai.google.dev/gemini-api/docs/thinking (“The Gemini 3 and 2.5 series models use a ‘thinking process’”)
  • OpenViking (메모리 서버) 패키지 소스 — 실행 중인 이미지에서 직접 확인. server/routers/content.py(쓰기 엔드포인트 단일성), server/routers/filesystem.py(원시 쓰기 API 부재), storage/queuefs/semantic_processor.py · storage/queuefs/semantic_dag.py (두 경로 모두 _generate_overview 호출)
  • 호출 시계열 — 자체 클러스터 Elasticsearch 인덱스에서 Generated abstract.md 일별 집계 (8월 46 / 9-1~9-3 24 / 9-4~9-14 2,833)
  • 청구 금액 — Google Cloud 결제 콘솔 (₩183,711, 월말 예상 ₩565,510, 전월 대비 +2,217.24%)

금액 대비 호출 수 환산(₩65/쓰기, ₩8.1/호출)은 청구 총액을 실측 호출 수로 나눈 자체 계산이며 벤더가 제공한 내역이 아니다. 같은 프로젝트의 다른 워크로드 호출이 섞여 있을 수 있고, 그만큼 실제 단가는 이보다 낮다.