홍보 첫날 사이드프로젝트가 죽었다: Cloudflare 1102, 10ms의 벽
· cloudflare, workers, nextjs
목차
사이드 프로젝트 Indieblog를 GeekNews에 올린 날 이야기예요. 트래픽이 처음으로 몰려온 날, 사이트가 간헐적으로 죽기 시작했어요. 사용자에게 보인 건 제 사이트가 아니라 Cloudflare의 에러 페이지였어요. Error 1102, Worker exceeded resource limits. 이 글은 그날 무슨 일이 있었는지를 Cloudflare 분석 데이터로 추적한 기록이에요. 결론부터 말하면 범인은 무료 플랜의 CPU 10ms 한도였고, 해결은 $5짜리 결제 버튼이었는데, 데이터를 파보니 "왜 전부가 아니라 일부만 죽었는지"가 생각보다 흥미로웠어요.
홍보 날 점심 즈음
Indieblog는 한국 개발자 개인 블로그를 RSS로 모아 보여주고 트래픽으로 환원하는 사이드 프로젝트예요. Next.js를 OpenNext로 감싼 프로젝트를 Cloudflare Workers에 올렸고, 홍보 전 트래픽은 전무했어요.
사이드 프로젝트 홍보 글을 올리고 생각보다 반응이 괜찮았어요. 시간당 요청이 1,700건을 넘더니 오후 1시에는 5,500건까지 올라갔어요. 하루 전체로는 3만 6천 건이었어요. 트래픽이 없던 사이트 기준으로는 처음 겪는 규모였어요.
그리고 그 시간대에 모바일에서 어드민 승인 요청을 처리하려는데 1102 에러 페이지가 보였어요. 새로고침을 해도 계속 1102만 떠서 매우 당황했어요. 급하게 데스크탑으로 메인 페이지를 열어보니 이쪽은 정상인 줄 알았는데, 몇 번 새로고침하다 보니 메인도 간헐적으로 1102가 떴어요. 전면 장애가 아니라, 페이지마다 죽는 정도가 다른 간헐적 장애였어요.
1102 에러 코드가 뭐지
Cloudflare 문서에서 1102는 이렇게 설명돼요: Worker가 리소스 한도를 초과해서 요청이 중단됐다는 뜻이에요. 무료와 유료 플랜의 주요 한도를 비교하면 이래요.
| 항목 | Workers Free | Workers Paid ($5/월) |
|---|---|---|
| 요청 수 | 100,000건/일 | 무제한 (월 1,000만 건 포함, 초과 종량) |
| 요청당 CPU 시간 | 10ms | 기본 30초 (설정 시 최대 5분) |
| 메모리 | 128MB | 128MB |
| 서브리퀘스트 | 50건/요청 | 10,000건/요청 |
참고로 공식 문서 기준, 평균적인 Worker의 CPU 사용량은 요청당 약 2.2ms예요. 10ms가 그렇게 야박한 한도는 아니라는 뜻이에요. 다만 같은 문서는 인증 처리나 서버사이드 렌더링, 큰 페이로드 파싱 같은 무거운 워크로드가 보통 10~20ms를 쓴다고 말해요 — SSR은 애초에 무료 플랜 한도와 정면충돌하는 워크로드인 거예요.
10ms라는 숫자만 보면 "그럼 10ms 넘는 요청은 다 죽어야 하는 거 아닌가?"라는 의문이 들어요. 그런데 실제로는 그렇게 동작하지 않았어요. 이 의문이 아래 데이터 추적에서 풀려요.
범인 추적
Cloudflare는 Workers 요청 지표를 GraphQL Analytics API로 제공해요. 대시보드 그래프보다 훨씬 정밀하게 볼 수 있어서, 상태별 CPU 시간 분위수를 직접 조회했어요. wrangler로 로그인돼 있다면 그 OAuth 토큰으로 바로 호출할 수 있어요.
query ($accountTag: String!) {
viewer {
accounts(filter: { accountTag: $accountTag }) {
workersInvocationsAdaptive(
limit: 100
filter: {
scriptName: "indieblog"
date_geq: "2026-08-09"
date_leq: "2026-08-10"
}
orderBy: [date_ASC]
) {
dimensions { date status }
sum { requests errors }
quantiles { cpuTimeP50 cpuTimeP90 cpuTimeP99 }
}
}
}
}
핵심은 dimensions.status예요. 요청이 success, exceededResources,
clientDisconnected 같은 상태별로 나뉘어 집계되기 때문에, "죽은 요청"만 따로
들여다볼 수 있어요. 그날 죽은 요청의 집계는 이랬어요.
{
"dimensions": { "date": "2026-08-10", "status": "exceededResources" },
"sum": { "errors": 69, "requests": 69 },
"quantiles": { "cpuTimeP50": 10000, "cpuTimeP90": 21449, "cpuTimeP99": 36715 }
}
여기서 세 가지가 보였어요.
첫째, 킬된 요청의 CPU P50이 정확히 10,000µs예요. 10ms. 소수점 하나 없이 한도값에 핀처럼 고정돼 있어요. "리소스 한도 초과"라는 에러 이름에서 메모리인지 CPU인지 애매했는데, 이 숫자 하나로 CPU 한도에서 잘렸다는 게 증명돼요.
둘째, 성공한 요청의 CPU 중앙값이 이미 한도에 걸쳐 있었어요. 그날 성공 요청의 P50이 9~15ms였어요. 요청의 절반이 항상 10ms 경계선 위에서 줄타기를 하고 있었다는 뜻이에요. 트래픽이 적을 때는 티가 안 나지만, 구조적으로는 언제 터져도 이상하지 않은 상태였어요.
셋째, 290ms짜리 성공 요청도 있었어요. 성공 요청의 P99는 270~300ms였어요. 한도의 30배 가까이 쓰고도 살아남은 요청이 흔했어요. 즉 무료 플랜의 10ms는 모든 요청을 매번 칼같이 자르는 한도가 아니었어요. 넘겨도 살아남는 요청이 있고, 정확히 10ms에서 잘리는 요청이 있는 거예요. 문서를 다시 찾아보니 "간헐적으로 한도를 넘는 건 허용하되, 지속적으로 넘기 시작하면 종료한다"는 설명이 있었어요. 실제로 부하가 몰린 시간대에 잘린 요청이 늘었고, 무거운 페이지일수록 더 자주 잘렸어요. "간헐적" 1102의 정체가 이거였어요. 어드민처럼 CPU를 많이 쓰는 페이지는 거의 항상 죽고, 메인처럼 가벼운 페이지는 가끔씩 죽었던 거예요.
시간대별로 보면 장애의 전개가 그대로 드러나요.
| 시간 (KST) | 요청 수 | 1102 건수 |
|---|---|---|
| 09~12시 | 817 | 3 |
| 12시 | 1,748 | 2 |
| 13시 | 5,562 | 5 |
| 14시 | 4,178 | 8 |
| 15시 | 3,270 | 18 |
| 16시 | 4,637 | 33 |
| 17시 이후 | 10,000+ | 0 |
에러율로 치면 0.3% 정도라 숫자만 보면 작아 보여요. 하지만 사용자 입장에선 0.3% 확률로 사이트 대신 Cloudflare 에러 화면을 만나는 거고, 하필 그날은 첫인상이 전부인 홍보 날이었어요.
왜 이런 구조였나
원인은 한도 자체보다 제 구현에 있었어요.
Indieblog의 데이터는 2시간 주기 수집 크론에서만 바뀌어요. 그래서 설계할 때는
홈과 피드를 ISR로 캐싱해서 Worker CPU를 아끼는 게 원안이었어요. 그런데 구현
과정에서 로그인 상태 표시 같은 것들을 처리하다가 주요 페이지가 force-dynamic
전면 SSR로 굳어졌고, 결과적으로 2시간에 한 번 바뀌는 데이터를 매 요청마다
서버 렌더링하는 구조가 됐어요.
평소에는 문제가 없었어요. 무료 플랜의 느슨한 집행 덕에 P50이 한도에 걸쳐 있어도 다 살아남았으니까요. 설계서에 적어둔 방어막을 구현에서 빼먹었는데, 트래픽이 없어서 그 사실조차 모르고 있었던 거예요. 청구서는 트래픽이 처음 몰린 날, 가장 아픈 타이밍에 날아왔어요.
우료 플랜 결제
장애 당일에 캐싱 구조를 고칠 수는 없었어요. 그날 할 수 있는 가장 빠른 해결책은 Workers 유료 플랜 전환이었어요. 유료 플랜은 요청당 CPU 한도가 10ms에서 기본 30초로 늘어나요.
오후 5시쯤 결제했고, 효과는 즉시였어요. 이후로도 시간당 2천~4천 건씩 트래픽이 계속 들어왔지만 1102는 0건. 위 표의 마지막 줄이 그 결과예요.
"서버비 0원" 운영을 하고 싶었는데 홍보 첫날 만에 그 바람은 끝이 났어요. 유료 플랜에는 월 요청 1,000만 건과 CPU 3,000만 ms가 포함돼 있어서 지금 규모에서는 종량 초과가 사실상 불가능하고, 오히려 과대 스펙이에요.
남은 숙제와 교훈
유료 플랜 전환은 급한 불을 끈 거지 근본 해결이 아니에요. 2시간에 한 번 바뀌는 데이터를 매 요청 렌더링하는 구조는 그대로니까요. 숙제로 남긴 것들이에요.
- 캐싱 복원: 홈·피드·디렉토리를 revalidate 캐싱이나 Cloudflare Cache API로 돌려놓기. 이게 설계 원안이었어요.
- CPU 안전장치: wrangler 설정의
limits.cpu_ms로 상한 걸기(예: 1,000ms). 유료 플랜의 30초 한도는 버그로 CPU가 폭주할 때 종량 요금 폭탄이 될 수 있어서, 거꾸로 상한을 낮춰두는 안전장치예요. - 빌링 알림: Cloudflare Notifications에서 사용량 알림 설정.
이번 일로 배운 것들이에요.
설계서에 쓴 방어막은 구현에서 지키지 않으면 없는 것과 같아요. "ISR로 CPU 아끼기"는 설계 문서에만 존재했고, 프로덕션에는 존재하지 않았어요. 설계와 구현이 어긋난 지점은 문제가 터지기 전까지 눈에 안 보여요.
무료 티어의 한도는 트래픽이 와야 드러나요. P50이 한도에 걸쳐 있는 아슬아슬한 구조였는데, 느슨한 집행 덕에 몇 주 동안 아무 신호가 없었어요. 홍보처럼 트래픽이 예정된 이벤트 전에는 한도 대비 현재 사용량을 실제 수치로 확인해볼 필요가 있어요. GraphQL Analytics 쿼리 한 번이면 돼요.
돈으로 시간을 사는 것도 엔지니어링 판단이에요. 트래픽이 실시간으로 들어오는 홍보 날에 필요했던 건 우아한 캐싱 아키텍처가 아니라 지금 당장 에러를 멈추는 것이었고, 그 값이 월 $5였어요. 근본 해결은 트래픽이 지나간 뒤에 해도 늦지 않아요.
그 뒤로
사이트는 그 뒤로 멀쩡히 돌아가고 있어요(물론 트래픽이 무료 플랜에도 돌아갈 정도로 줄었어요). 홍보 사흘 만에 22명의 블로거분들이 등록해주셨고, 저는 이제 2시간에 한 번 바뀌는 데이터를 매 요청 렌더링하는 구조를 고치러 갑니다. 개인 개발 블로그를 운영하고 있다면 Indieblog에 등록해보세요! 이런 삽질 기록도 누군가에게 발견되는 곳이에요.
관련 글
- Next.js 16 Cache Components - Vercel이 공개한 PPR·use cache 가이드 (전문 번역)Vercel Labs가 공개한 Next.js 16 Cache Components 스킬 전문 번역입니다. Partial Prerendering(PPR), use cache 지시어, cacheLife, cacheTag, updateTag까지 캐시 컴포넌트의 전체 그림을 한글로 옮겼어요.
- Next.js Best Practices - Vercel이 공개한 Next.js 개발 가이드 (전문 번역)Vercel이 공개한 Next.js 베스트 프랙티스 스킬 전문 번역입니다. 파일 컨벤션, RSC 경계, 데이터 패턴, 에러 처리, 이미지·폰트 최적화 등 19개 문서를 한글로 번역했어요.
- React Best Practices - Vercel 엔지니어링 팀의 성능 최적화 가이드 (전문 번역)Vercel 엔지니어링 팀이 공개한 React/Next.js 성능 최적화 가이드 전문 번역입니다. 8개 카테고리, 45개 이상의 규칙을 영향도 기준으로 정리했습니다.