Cloudflare Workers에서 LLM 응답을 안정적으로 스트리밍하기
vooy는 에이전트와 랜딩, API를 전부 Cloudflare 위에서 굴립니다. 엣지 런타임에서 긴 LLM 스트림을 끊김 없이 흘려보내기 위해 다룬 워커 수명, 백프레셔, 그리고 OpenNext 이야기.
vooy의 거의 모든 것 — 에이전트 런타임, API, 그리고 지금 읽고 계신 이 블로그 — 는 Cloudflare 위에서 돕니다. 엣지는 빠르고 싸지만, "수십 초간 토큰을 흘려보내는 LLM 스트림"이라는 워크로드와는 처음부터 궁합이 좋지 않았습니다. 이 글은 그 간극을 메운 기록입니다.
왜 엣지인가
이유는 단순합니다. 사용자는 전 세계에 흩어져 있고, 메신저 웹훅은 지연에 민감합니다. 엣지에서 받으면 첫 토큰까지의 시간(TTFT)이 눈에 띄게 줄고, 콜드 스타트도 컨테이너보다 한 자릿수 빠릅니다.
대신 엣지 런타임은 제약이 많습니다. Node API가 일부만 있고, CPU 시간에 한도가 있으며, 무엇보다 워커의 수명이 요청에 묶여 있습니다.
스트리밍과 워커의 수명
순진하게 짜면 이렇게 됩니다.
export default {
async fetch(req: Request) {
const llm = await model.stream(prompt); // 30초가 걸릴 수도 있다
return new Response(llm.toReadableStream());
},
};문제는, 응답 본문 스트림이 닫히기 전에 워커가 살아 있어야 한다는 것입니다. 중간에 다른 비동기 작업(로그 적재, 메모리 업데이트)을 띄워 놓고 응답만 먼저 끝내면, 그 작업은 워커와 함께 증발합니다. 해법은 waitUntil로 백그라운드 작업의 수명을 명시적으로 연장하는 것입니다.
export default {
async fetch(req: Request, env: Env, ctx: ExecutionContext) {
const { readable, writable } = new TransformStream();
const writer = writable.getWriter();
ctx.waitUntil(
(async () => {
for await (const chunk of model.stream(prompt)) {
await writer.write(encoder.encode(chunk));
}
await persistTurn(); // 응답이 끝난 뒤에도 안전하게 마무리
await writer.close();
})()
);
return new Response(readable, {
headers: { "content-type": "text/event-stream" },
});
},
};TransformStream으로 읽기/쓰기를 분리하면, 응답은 즉시 반환하면서 생성은 백그라운드에서 계속됩니다.
백프레셔와 끊김
엣지에서 가장 자주 만난 버그는 느린 소비자였습니다. 모바일 네트워크의 사용자가 우리가 토큰을 쓰는 속도보다 느리게 읽으면, 버퍼가 부풀고 메모리 한도에 부딪힙니다.
writer.write()가 돌려주는 프라미스를 반드시 await 하는 것이 핵심입니다. 이게 백프레셔 신호입니다. await를 빼먹으면 빠른 생산자가 느린 소비자를 압도합니다.
스트림에서
await writer.write(...)의await를 지우고 싶은 유혹이 들면, 그건 거의 항상 버그를 심는 중입니다.
OpenNext로 Next를 워커에 올리기
블로그와 랜딩은 Next.js입니다. 이걸 Cloudflare Workers에서 굴리기 위해 OpenNext의 Cloudflare 어댑터를 씁니다. 빌드 결과를 워커가 실행할 수 있는 형태로 변환해 주고, 정적 자산은 Cloudflare 캐시에 얹힙니다.
핵심은 무엇을 정적으로 만들 수 있는가입니다. 이 블로그의 글과 체인지로그는 빌드 시점에 마크다운을 HTML로 굳혀, 런타임에 파일 시스템을 건드리지 않습니다. 덕분에 글 페이지는 순수 정적 자산으로 엣지 캐시에서 즉시 떨어집니다.
관측성
엣지에서 디버깅은 어렵습니다. 워커는 금방 죽고, 스택 트레이스는 짧습니다. 우리는 모든 턴에 trace ID를 붙이고, waitUntil 안에서 구조화 로그를 비동기로 흘려보냅니다. 스트리밍 응답 자체를 막지 않으면서도, 무슨 일이 있었는지는 남깁니다.
엣지에서 LLM을 스트리밍하는 건 "되긴 되는데 조심해야 하는" 영역입니다. 워커 수명, 백프레셔, 정적/동적 경계 — 이 셋만 정확히 다루면, 컨테이너보다 빠르고 싼 인프라 위에서 부드러운 응답을 줄 수 있습니다. 우리에겐 그 트레이드오프가 옳았습니다.