인보이스, 배송 라벨, 영수증 서비스를 Cloudflare Workers로 옮긴 이유는 분명합니다. 나머지 스택도 이미 그곳에서 실행되고 있고, 지연 시간 계산도 아름답습니다. 가장 가까운 colo까지 5 ms, CPU 1 ms, 요청 종료.
그런데 PDF 생성이 들어오자 갑자기 p99 800 ms, Worker 번들 50 MB 경고, 그리고 도구를 잘못 고른 것 같은 느낌을 마주합니다. 왜 이런 일이 생기는지, 그리고 오후 안에 고칠 수 있는 실제 병목이 무엇인지 보겠습니다.
Workers는 Lambda가 아닙니다
진단하기 전에 실행 모델부터 정확히 잡아야 합니다. Cloudflare Workers는 서버리스 컨테이너가 아닙니다. 다음 제약을 가진 V8 isolates입니다.
- CPU 시간 제한: 무료 플랜은 요청당 50 ms, Workers Paid (Bundled)는 30초, Unbound는 5분입니다. 실제 경과 시간에는 제한이 없지만 과금 대상입니다.
- 메모리 상한: isolate당 128 MB.
- 번들 크기: 무료 플랜 1 MB, 유료 플랜 10 MB.
- 파일시스템 없음.
fs.readFileSync는 없습니다. 모든 것은 메모리에 있거나 fetch로 가져와야 합니다. - 네이티브 바이너리 없음. JavaScript / WebAssembly만 가능합니다.
node-canvas, 네이티브 zlib 호출, Ghostscript shell-out은 없습니다. - 콜드 스타트 약 5 ms. 놀랄 만큼 빠르지만, 부팅할 큰 덩어리가 없기 때문에 가능한 일입니다.
“Workers에서 PDF가 느리다“는 문제 대부분은 이 제약 중 하나, 대개 CPU 제한이나 번들 크기를 어기고 조용히 throttling을 맞는 데서 나옵니다.
실제로 느린 다섯 가지
팀들이 자주 맞닥뜨리는 순서로 보면 다음과 같습니다.
1. Chromium 기반 렌더러를 Workers 안으로 끌어들이기
이건 안 됩니다. Puppeteer는 약 250 MB의 Chromium과 실제 OS가 필요합니다. Cloudflare의 Browser Rendering API나 Browserless 같은 브라우저 렌더링 서비스는 동작하지만 Workers가 아닙니다. Worker에서 별도 서비스를 호출하는 구조이고, 왕복 약 500 ms + 렌더링 시간이 붙습니다.
“Worker 기반 PDF“가 실제로는 “원격 브라우저 렌더링 API를 호출하는 Worker“라면 지연 시간 하한은 약 500 ms입니다. Workers의 문제가 아니라 브라우저 비용이 Edge까지 따라온 것입니다.
진단: 코드에 fetch("https://browser-rendering.cloudflare.com/...") 또는 비슷한 호출이 있는지 확인하세요. 있다면 측정 중인 지연 시간은 Worker가 아니라 상위 서비스의 지연 시간입니다.
2. JavaScript에서 레이아웃 계산하기
직접 JS 기반 레이아웃 엔진을 작성했다면(“박스 위치를 그냥 수동 계산하면 되겠지”), CPU 제한에 부딪히고 있을 가능성이 큽니다. JS는 빠르지만, 텍스트 줄바꿈이 있는 30개 이상의 요소 레이아웃은 Workers Free에서 쉽게 50 ms를 넘고 Bundled에서도 100-300 ms가 될 수 있습니다.
이런 렌더링 처리 흐름은:
JSON → JS layout pass → SVG generation → SVG-to-PDF library → emit
같은 데이터를 네 번 CPU 중심으로 훑습니다. 모두 JS에서 실행되고, 모두 garbage collection 비용이 있으며, 모두 중간 트리를 다시 할당합니다.
진단: 한 번의 렌더링에 대한 wrangler tail 로그를 찾으세요. I/O 전에 CPU 시간이 50 ms를 넘는다면 컴퓨트 문제입니다.
3. 요청마다 글꼴 불러오기
글꼴은 각각 50-250 KB입니다. 렌더러가 매번 KV / R2에서 글꼴을 읽는다면, 요청마다 글꼴 하나당 네트워크 왕복이 생깁니다. 글꼴 5개면 렌더링을 시작하기 전에 50-150 ms가 듭니다.
진단: 글꼴 로딩 코드에 시간을 찍어 보세요. p50 대부분을 차지한다면 여기가 문제입니다.
수정: 글꼴은 요청 handler 안이 아니라 module init 시점, 즉 Worker 파일의 top level에서 한 번만 불러오세요. isolate가 살아 있는 동안(몇 분에서 몇 시간) 해당 bytes가 캐시됩니다.
// Module-init: runs ONCE per isolate
import notoSans from "./fonts/noto-sans.woff2";
const FONT_BYTES = new Uint8Array(notoSans);
export default {
async fetch(request) {
// Per-request: zero font I/O
return renderPdf({ fontBytes: FONT_BYTES, ...data });
}
};
bundler가 글꼴을 bytes로 inline한다면 더 좋습니다. I/O가 아예 없습니다.
4. Workers용으로 만들어지지 않은 JS PDF 라이브러리 사용하기
pdfkit, pdf-lib, jsPDF는 모두 Workers에서 실행됩니다. 하지만 성능을 해치는 특성이 있습니다.
pdfkit은 NodeBuffershim이 필요합니다. 가능은 하지만 약 500 KB가 늘고 컴퓨트가 약 30% 느려집니다.pdf-lib은 기존 PDF 편집에는 훌륭하지만 처음부터 생성하는 데는 덜 적합합니다. 추상화 계층이 페이지당 약 10 ms의 오버헤드를 더합니다.jsPDF는 브라우저 우선 설계입니다. 같은 Buffer 문제가 있고, API 표면이 커서 불필요한 코드를 제거하기 어렵습니다.
대부분 “JSON을 읽고 PDF bytes를 쓴다“에 가까운 렌더링 처리 흐름이라면, 범용 PDF 추상화를 거치지 않고 PDF를 직접 내보내는 목적별 엔진이 5-20배 빠릅니다. Rust나 C++에서 컴파일한 WebAssembly 엔진은 JIT에 잘 맞는 tight loop 덕분에 더 유리합니다.
5. 사실은 4 MB인 번들
Workers Free는 번들 크기를 1 MB로 제한합니다. Workers Bundled는 10 MB입니다. 많은 팀은 wrangler deploy가 “Script exceeds size limit“으로 실패할 때 이 한도를 처음 알게 됩니다. 어떤 팀은 그보다 먼저 거대한 import 때문에 콜드 스타트가 느려지는 것을 봅니다. V8이 그 전체를 컴파일해야 하기 때문입니다.
wrangler는 배포 출력에 번들 크기를 보여 줍니다. 500 KB를 넘으면 조사할 만합니다. 흔한 원인은 다음과 같습니다.
- 번들에 포함된 글꼴. Workers Assets로 옮기고 module init에서 한 번 fetch하세요.
node:shim 계층. source map에__cf_KV나polyfills:가 보이면 bundler가 실제로 필요 없는 Node API를 shim하고 있을 수 있습니다.- 사용하지 않는 의존성. Wrangler 4+에서는
npm run build -- --analyze로 treemap을 볼 수 있습니다.
Workers에서 빠른 PDF는 실제로 어떤 모습인가
구조화 문서용으로 목적에 맞게 만든 Edge 렌더러(gPdf가 한 예이며, 잘 만든 엔진이라면 같은 아키텍처가 적용됩니다)는 보통 다음 범위에 있습니다.
| 지표 | 일반적인 범위 | 이유 |
|---|---|---|
| 콜드 스타트 | 5-20 ms | V8 isolate boot + WASM module first-load |
| 렌더링당 CPU | 1-4 ms | WASM tight loop, GC pressure 없음 |
| 렌더링당 실제 경과 시간 | 3-8 ms | CPU + PDF object ID용 crypto 몇 마이크로초 |
| 번들 크기 | 4-6 MB | 렌더 엔진 + 내장 글꼴 (Latin + CJK NotoSans) |
| 메모리 피크 | 8-20 MB | 문서 트리 + 생성된 PDF 버퍼 |
전형적인 “Worker에서 원격 브라우저 렌더링을 통해 Puppeteer를 호출“하는 경로와 비교해 보세요. p50은 500-1000 ms, 브라우저 메모리는 다른 곳에서 1-2 GB, 상위 서비스 비용은 렌더링당 약 0.001달러입니다.
빠른 triage
지금 Workers에서 PDF가 느리다면 다른 것보다 먼저 이 체크리스트를 실행하세요.
- 시간이 어디로 가는가?
wrangler tail에 timestamp를 추가하세요. 병목이 어디인지 분리해야 합니다.- CPU(프로세스 안 작업)
- Egress fetch(상위 서비스 호출)
- 콜드 스타트(조용한 기간 뒤 첫 요청만)
- JS 레이아웃을 실행하고 있는가? 그렇다면 CPU 대부분이 거기에 쓰입니다. 레이아웃을 미리 계산하는 렌더러로 바꾸세요.
- 요청마다 글꼴을 불러오는가? 글꼴 로딩을 module init으로 옮기세요.
- 외부 브라우저를 호출하는가? 그러면 지연 시간 하한은 그 서비스의 응답 시간입니다. 같은 isolate 안에서 동작하는 렌더러로 옮기세요(fetch 없음).
- 번들이 1 MB를 넘는가? 콜드 스타트는 번들 크기에 따라 커집니다. 사용하지 않는 의존성을 줄이세요.
Workers에서 가능한 가장 빠른 PDF는 문서 데이터가 단일 fetch handler 호출 안에서 PDF bytes로 바뀌는 형태입니다. 자체 fetch() 호출이 없고 CPU가 무거운 JS 레이아웃도 없습니다. 우리가 듣는 “Workers는 PDF에 느리다“는 불만 대부분은 실제로 “Workers 모양이 아닌 PDF 스택을 Workers에 올려 두 세계의 단점만 얻었다“에 가깝습니다.
짧게 말하면
Cloudflare Workers는 한 자릿수 밀리초 안에 PDF를 렌더링할 수 있습니다. 단, 렌더러가 isolate 형태의 실행 환경에 맞게 설계되어 있어야 합니다. Node용 JS PDF 라이브러리, Worker에서 호출하는 원격 브라우저 렌더링 서비스, 요청마다 불러오는 글꼴, JavaScript 레이아웃 pass는 p50을 “오리진보다 느림“에서 “지역 Lambda와 구분하기 어려움” 사이 어딘가로 밀어 올립니다.
직접 이 문제를 해결하고 싶지 않다면 gPdf Playground가 이와 같은 실행 환경에서 완전히 Edge 배포로 동작하는 렌더러입니다. Render PDF를 누르고 네트워크 탭을 보세요. 그 p50이 처리 흐름이 실행 환경과 싸우지 않을 때 Worker가 낼 수 있는 속도입니다.