3일 동안 GET /v1/tickets에 대한 8,822건과 GET /v1/events에 대한 8,815건의 호출이 발생했습니다. 이 두 요청은 단 한 명의 사용자 및 두 명의 에이전트가 사용하는 트래커의 API에서 처리된 모든 요청 중 88%를 차지했습니다.
제약 조건은 다음과 같습니다: 에이전트는 몇 초 안에 새로운 작업을 감지해야 하며, 유휴 상태인 에이전트는 비용이 들지 않아야 합니다.
저는 Oizom에서 소프트웨어 팀을 이끌고 있는 Panth입니다. 이 내용은 제가 사용하는 오픈 소스 보드인 ticket-tracker에서 가져온 것입니다. 이 보드는 제 에이전트들이 작업하는 곳입니다. 해당 프로젝트의 계획에는 앱 비용에 대한 섹션이 있으며, 본 게시물은 에이전트에 관한 부분입니다: 이전에는 폴링(polling) 방식을 사용했지만, 이제는 '깨어나는' 방식으로 변경했습니다.
폴링 방식이 초래한 비용
데이터는 2026년 9월 23일부터 26일까지의 것입니다.
| 항목 | 측정값 |
|---|
| API 함수 요청 수 | 3일 동안 41,515건 |
| ... | |
두 개의 오케스트레이터 루프가 약 30초마다 실행되었습니다. 각 루프는 매번 전체 목록을 요청했습니다: limit=200과 updated_since가 없는 티켓, 커서(cursor)가 없는 이벤트였습니다. 답변은 거의 항상 동일했고, 그럴 때마다 비용이 발생했습니다. | |
API는 Cloud Run에서 실행되며, I/O 대기 시간에 소요되는 시간까지 포함하여 전체 요청에 대해 CPU를 과금합니다. 각 호출은 실제 작업 전에 API 키 조회(API-key lookup), 보드 읽기(board read), 에이전트 읽기(agent read) 및 두 번의 속도 제한 거래(rate-limit transactions)를 수행했습니다. 유휴 상태인 에이전트가 청구서에서 가장 큰 비중을 차지하는 항목이었습니다.
유휴 상태일 때 비용이 들지 않는 구조
API 데이터는 Firestore에 저장되어 있으며, 이는 작업당(per operation)으로 과금됩니다. Realtime Database는 대역폭(bandwidth)을 기준으로 과금합니다. 따라서 신호는 Realtime Database에 존재하고, 데이터는 그곳에 머무릅니다.
신호를 전달하는 두 개의 작은 노드가 있습니다:
rev/{boardId} '이 보드에서 무언가 변경됨'
agents/{agentId}/wake '당신의 받은 편지함에 무언가가 있음'
모든 쓰기 작업(write)은 하나의 커맨드 레이어(command layer)를 거칩니다. 명령이 성공적으로 완료된 후, 보드의 rev 노드는 약 40바이트로 덮어쓰여집니다:
export const bumpBoardRev = (boardId: string, by: string | null, at: number): Promise<void> =>
liveSafe(`rev ${boardId}`, () =>
rtdbAdmin()
...
변경 사항은 이 경로를 통해 전달되지 않습니다. 노드는 단지 "다시 물어보라"고만 말합니다. 두 가지 명령어는 이를 움직이지 못하게 합니다: 아무것도 변경하지 않는 ping과 자체 노드를 가진 에이전트 하트비트입니다. 하트비트를 펌핑하는 것은 매분마다 보드의 모든 워처를 깨우게 되며, 이는 다시 폴링(polling) 그 자체입니다.
에이전트 측은 해당 노드에서 장기 지속되는 스트림 하나와, 변경된 내용만 요청하는 단 하나의 REST 호출로 구성됩니다: 티켓의 경우 updated_since를, 이벤트의 경우 cursor를 사용합니다. 명령어당 한 번의 Realtime Database 쓰기 작업, 실제 변경 사항마다 작은 쿼리 실행이 이루어지며, 보드가 조용할 때는 아무것도 일어나지 않습니다.
Firebase SDK 없이 스트리밍하기
Realtime Database의 REST API는 노드를 순수한 Server-Sent Events로 스트리밍합니다. accept: text/event-stream을 사용하여 {database_url}/{path}.json을 요청하면, 노드가 변경될 때마다 put 및 patch 프레임을 받게 됩니다. SDK 내부에 Firebase SDK가 없기 때문에 동일한 코드를 Node, Deno, Bun에서 실행할 수 있습니다. 유휴 스트림은 열린 소켓이며 바이트는 거의 0에 가깝습니다.
이 스트림에는 자격 증명이 필요하며, 에이전트는 Firebase 로그인 정보가 아닌 API 토큰을 보유합니다. GET /v1/live가 이 역할을 수행합니다. 이는 토큰의 주체(principal)를 위해 Firebase 사용자 정의 토큰을 발행하고, 에이전트 보드에 대한 board 클레임을 추가한 후, 이를 서버 측에서 ID 토큰으로 교환하여 데이터베이스 URL, 토큰, 수명 주기, 그리고 스트리밍할 수 있는 정확한 경로들을 반환합니다. 이후 데이터베이스 규칙은 에이전트가 자신의 웨이크 노드와 보드의 rev만 읽을 수 있도록 허용하며, 그 외의 것은 아무것도 할 수 없게 합니다. 자격 증명이 만료되면 워처는 토큰을 재발행합니다.
다시 폴링이 되는 것을 막는 두 가지 세부 사항
재연결은 변경 사항이 아닙니다. Realtime Database는 스트림이 연결될 때마다 노드의 현재 값을 put으로 재생(replay)합니다. 만약 모든 프레임이 에이전트를 깨운다면, 불안정한 연결(flapping connection)은 추가 단계가 붙은 폴링 루프가 될 것입니다. 따라서 프레임은 그 at 타임스탬프가 마지막으로 본 것보다 지나갔을 때만 카운트되며, 연결 후 처음 받는 프레임은 단지 현재 상태를 기록할 뿐입니다.
10개의 변경 사항은 한 번의 호출로 처리됩니다. 워처(watcher)는 최대 하나의 깨어남(wake)을 대기열에 유지합니다. 에이전트가 바쁜 동안 10개의 명령이 도착하더라도, 델타(delta)가 모두를 포함하기 때문에 단 한 번만 깨어나서 한 번의 델타 호출을 수행합니다. 실제 변경 사항은 기다리고 있는 백스톱 깨어남을 대체할 뿐, 그 반대는 절대 아닙니다.
스트림 열기 실패 시
일부 설정에서는 SSE(Server-Sent Events) 스트림을 유지할 수 없습니다. 예를 들어, 스트림을 소비하는 프록시나 스트리밍 본문이 없는 런타임 환경, 또는 /v1/live 엔드포인트가 없는 구형 서버 등이 있습니다. 이런 경우 워처는 폴링(polling)을 수행하며 그 이유를 알려줍니다. 작업 데이터가 도착할 때는 2초 간격으로 시작하여, 아무것도 발견되지 않으면 60초까지 백오프(backoff)합니다. 호출자는 마지막 델타 응답이 반환한 행의 개수와 함께 워처에게 어떤 데이터를 가져올지 지시합니다:
const w = tm.watch();
for await (const signal of w) {
const page = await tm.events.list({ cursor, unacked: true });
...
}
스트리밍 중일 때조차도, 혹시 변경 사항이 누락되었을 경우를 대비하여 SDK는 15분마다 한 번 호출합니다. 이 백스톱(backstop)은 유휴 상태의 스트리밍 워처가 수행하는 유일한 REST 트래픽입니다.
이것이 필요한 이유
계획상의 목표는 각각 10개의 라이브 티켓을 가진 50개의 보드를, 월 50루피 또는 그 이하로 항상 유지하는 것입니다. 이는 스테디 스테이트(steady state)가 Firestore의 무료 일일 할당량 내에 있고 API 함수들이 읽기 경로(read path)를 사용하지 않을 때만 가능합니다. 30초마다 폴링하는 에이전트는 이 범위 안에 들어갈 수 없습니다. 하지만 40바이트 쓰기 작업에 의해 깨어나는 에이전트는 가능합니다.
만약 워커들이 타이머로 API를 폴링한다면, 요청 중 얼마나 많은 부분이 '혹시 변경된 것이 있는지' 확인하는 질문일까요?