제약이 돈에서 쿼터로 바뀌자 큐가 필요해졌다

자체 GPU 서빙을 접고 구독 CLI로 갈아탔다. 4월에 "만들지 마라"고 못 박았던 공용 계층을, 이번에는 만들었다.

자체 GPU 서빙을 접고, 공시 요약 서비스와 의정활동 트래커와 자동매매 봇 셋이 구독 CLI 하나를 나눠 쓰기로 했다. 돈이면 일단 쓰고 나중에 청구서를 보면 되지만, 쿼터는 한 프로젝트가 태우는 순간 나머지가 조용히 멈춘다. 4월에 똑같은 성격의 공용 계층을 스스로 끊어 놓았던 터라, 넉 달 만에 그 판단을 다시 꺼내 봐야 했다.

하려던 것

지난주에 장비 가동률을 쟀더니 3%였다. 넉 달을 튜닝한 결과가 그거였다. 병목은 성능이 아니라 일감이었다.

그래서 방향을 뒤집기로 했다. 자체 GPU 위의 vLLM을 세 프로젝트가 나눠 쓰는 대신, 이미 매달 내고 있는 구독으로 claude -p를 부른다. 공시 요약 서비스가 하루 400건, 의정활동 트래커가 17건, 자동매매 봇이 열댓 건. 셋을 합쳐도 한 대가 놀고 있는 것보다는 낫다.

여기서 성질이 하나 바뀐다. 제약이 돈에서 쿼터로 바뀐다. 구독이라 호출 한 번의 한계비용은 0인데, 대신 쓸 수 있는 양에 상한이 있고 그 상한은 대화 세션과 공유된다. 돈이면 그냥 쓰고 나중에 청구서를 보면 되지만, 쿼터는 다 쓰는 순간 내가 지금 켜놓은 창까지 같이 막힌다.

그게 큐가 필요해진 이유다.

어떻게 시켰나

4월에 정확히 반대 결론을 낸 적이 있다. 여러 도메인이 LLM을 쓴다고 공용 진입점을 만들려던 걸 끊으면서, 판단 기준 세 줄을 박아뒀었다. 표준 인터페이스로 직접 되면 만들지 마라, 도메인 로직이 진짜 복잡한 게 아니면 만들지 마라, 여러 도메인이 공유할 공통 로직일 때만 고려하라.

넉 달 만에 세 번째 조건이 처음으로 참이 됐다. 쿼터 계량과 페이싱은 어느 한 프로젝트의 일이 아니다. 공시 서비스가 백필을 돌리다 쿼터를 다 태우면 의정 트래커가 조용히 멈춘다. 셋 중 누구도 혼자서는 알 수 없는 상태다.

그래서 이번엔 만들되, 4월에 문제였던 게 재발하지 않도록 범위를 먼저 못 박고 시작했다.

큐가 하는 일은 기록·페이싱·실행 셋뿐이다. 공시가 무엇인지, 법안이 무엇인지는 몰라도 된다.

이 한 줄이 프로젝트 문서 맨 앞에 들어갔다. 4월의 래퍼가 나빴던 건 “공용”이라서가 아니라 응답 스키마를 자기 것으로 고정해서 도메인마다 그걸 우회하고 싶어졌기 때문이다. 큐는 스키마를 안 정한다. 무엇을 보낼지는 각 프로듀서가 정하고, 큐는 그걸 그대로 실어 나른다.

덕분에 나중에 나온 최적화 아이디어들 — 정기보고서 본문 클램프, 저가치 공시 스킵 — 을 큐에 넣자는 제안이 올라왔을 때 자를 근거가 이미 있었다. 저건 프로듀서의 자기 최적화지 큐의 일이 아니다.

어디서 막혔나

호출당 입력이 33,090 토큰이었다. 기본값으로 claude -p를 부르면 그렇다. 뜯어보니 툴 정의가 20,661, 기본 시스템 프롬프트가 6,706, 환경 정보와 git 상태가 3,652, 프로젝트 설정과 스킬 목록이 1,898, MCP 정의가 173이었다.

요약 잡은 툴을 하나도 안 쓴다. 전부 순손실이다. 넷을 다 끄니 248 토큰이 됐다. 99.3% 감소. 이 플래그 네 개는 이제 절대 규칙으로 문서 맨 위에 박혀 있다. CLI 버전을 올릴 때 반드시 이 값을 다시 재고 올리라는 조건까지 같이.

그다음은 배포였다. Railway에 올렸는데 크래시 루프를 돌았다. Dockerfile을 안 붙였더니 빌더가 cmd/ 아래 바이너리 중 하나를 임의로 골랐고, 하필 정리 배치를 집어 마이그레이션 없이 부팅한 것이다. 시작 커맨드를 명시해 고정했다.

이어서 두 번째. Dockerfile을 붙였는데 API로 확인한 빌더 필드가 여전히 다른 값으로 보였다. 한참 붙잡았는데, 그 enum에는 DOCKERFILE이라는 값 자체가 없었다. Dockerfile이 있으면 알아서 그걸 쓰고, 필드 표시는 안 바뀐다. 실제 사용 여부는 빌드 로그에서만 확인된다. 세 번째는 더 허무했다 — API로 만든 서비스에는 깃 자동배포 훅이 안 붙어 있어서, 푸시해도 아무 일이 안 일어나고 있었다.

인증에서도 하나 걸렸다. 대시보드 호스트의 인증서가 소유권 검증 단계에서 한 시간 넘게 멈췄다. DNS는 요구값과 일치하고 플랫폼 자체 판정도 정상인데 발급으로 안 넘어간다. 다른 프로젝트의 정상 도메인과 설정을 대조해도 차이가 없었다. 결국 CDN 프록시가 자기 인증서로 TLS를 끝내고 뒤로 넘기므로 그 경로는 멀쩡히 동작한다는 걸 확인하고, 문제의 인증서는 미해결로 문서에 적어둔 채 넘어갔다. 우리가 안 쓰는 경로였다.

어떻게 풀었나

인증을 표면별로 나눴다. 통계 대시보드는 사람이 보니까 이메일 로그인, 잡 제출과 결과 조회는 기계가 부르니까 서비스 토큰. 하나로 합치려다 알았는데, API 호스트에 이메일 로그인을 걸면 쿠키가 없는 기계 호출이 전부 막힌다.

나누고 나니 문제가 하나 생겼다. 서비스 토큰으로 들어온 요청에는 이메일이 없다. “누가 봤나”를 기록하려면 사람의 신원을 뒤로 넘겨야 하는데, 그냥 헤더에 붙이면 바깥에서도 붙일 수 있다. 그래서 이렇게 했다.

오리진이 그 헤더를 검증 전에 지우고, 토큰이 유효할 때만 다시 채운다. 바깥에서 붙인 신원 주장은 구조적으로 통과하지 못한다.

마지막은 그래프였다. 일별 처리량 막대에서, 처리가 0건인 날에도 2px짜리 막대가 남아 있었다. 높이를 0으로 줬는데 최소 높이 설정이 그걸 덮어쓰고 있었다. 사소해 보이지만 “없음”이 “아주 조금”으로 보이는 건 그래프가 거짓말을 하는 것이다. 큐를 만든 목적이 소비량을 정확히 세는 거였는데 첫 화면부터 어긋나 있으면 곤란하다.

여담으로, 여러 건을 한 호출에 묶는 안도 검토했고 실측까지 했다. 짧은 공시 5건 기준으로 입력이 72% 줄었지만 실제 평균 크기에서는 12% 안팎이었고, 그보다 묶음 응답의 토큰은 한 덩어리로 나와서 건별 값이 안분 근사가 된다. 사용량을 정확히 기록하겠다는 요구 자체가 흐려진다. 안 쓰기로 했다.

배운 점