Cloudflare 무료 플랜의 rate limit으로는 하루 1,000건을 못 지킨다
rate limit 바인딩은 10초와 60초만 받고 무료 존 규칙은 10초 고정이다. 10초에 1건이면 하루 8,640건이라 KV 쓰기 예산 1,000건보다 여덟 배 느슨하다. 하루라는 창을 적을 수 있는 수단은 Durable Object 하나였다.
egg5분 읽기
지난달 URL 단축기 글에서 제한을 두 층으로 나눴다고 한 문장으로 넘겼는데, 왜 한 층으로는 안 되는지가 빠졌다. 숫자를 정리해 남겨둔다.
Snip은 Cloudflare Workers 무료 플랜에 올라가 있고 설계 대부분이 숫자 하나와의 협상이다. 무료 플랜의 KV 쓰기는 하루 1,000건이고 링크 하나를 만드는 게 정확히 쓰기 1회다.
그러니까 하루 새 링크 1,000개가 서비스 전체의 상한인데, 그 1,000건은 방문자 전원이 같이 쓰는 한 통이다.
넘으면 청구서가 오는 게 아니라 기능이 선다. 무료 플랜은 저 혼자 유료로 올라가지 않으니 쓰기가 거부되고 화면 앞에 있는 사람은 아무것도 못 받는다. 한도는 00:00 UTC에 풀린다.
아래 한도와 표는 2026년 8월 24일에 읽은 문서 기준이다.
무료 플랜이 주는 창은 10초와 60초뿐이다
Workers의 rate limiting 바인딩은 period로 10초와 60초만 받는다. 그 사이도 그 밖도 없다.
존 단위 rate limiting rule은 무료 존에서 더 빡빡하다. availability 표가 주는 건 규칙 1개, IP 기준 카운팅, 10초 고정 집계 창, 10초 고정 차단, 그리고 표현식에 쓸 수 있는 필드 두 개(Path와 Verified Bot)다.
그래서 어디에 적든 제일 촘촘하게 쓸 수 있는 규칙이 주소당 10초에 1건이다.
10초에 1건은 하루 8,640건이다
8,640과 1,000. 제일 조인 규칙이 예산보다 여덟 배 넘게 느슨하고, 그 느린 속도로도 두 시간 40분이면 그날이 빈다.
거꾸로 계산하면 하루 1,000건이 되는 지점은 86초에 1건이다. 링크 하나 만들고 1분 반을 기다리는 도구는 아무도 안 쓴다. 건드릴 일도 없는 예산을 지키려고 정상 사용을 죽이는 셈.
이걸 이틀 걸려 알았다. rate와 하루 예산은 단위가 다르다. 창을 조여서 두 번째가 나오는 게 아니고, 나오게 조이면 도구가 죽는다. 내 바인딩을 원하는 것의 약한 버전이라고 생각했는데 애초에 다른 물건이었던 것 같다.
바인딩이 정확히 세지도 않는다. 한 주소에서 40연발을 보내니 첫 429가 27번째에 나오고 7건만 막혔다. 82% 통과면 1,200번쯤에서 그날의 1,000건이 끝난다. 실측 과정은 지난 글에 적어뒀다.
무료 존에서 쓸 수 있는 필드는 하나로 충분했다
문서를 다시 읽다가 기각해놓고 다시 안 본 결정을 하나 찾았다. 존 rate limiting rule을 초기에 보고 버렸는데, 사유가 “무료 존은 Method를 매치할 수 없어서 POST만 겨냥하지 못한다”였다.
사유 자체는 맞다. 그런데 Snip은 생성을 POST /api/links에서만 하고 그 경로는 다른 일을 하지 않는다. POST가 아니면 405다. Path는 무료 존이 받는 두 필드 중 하나니까, 못 쓴다고 적어둔 규칙이 한 줄이었다.
http.request.uri.path eq "/api/links"
제약은 맞게 읽고 결론도 적어뒀는데 그 결론을 내 서비스 모양에 다시 대보지 않았다. 문서가 바뀐 게 아니라 내가 다시 안 본 것 같다.
하루를 셀 수 있는 건 Durable Object 하나였다
Durable Objects는 2025년 4월 7일부터 무료 플랜에서 된다. SQLite 백엔드만 되고 하루 요청 10만 건, duration 13,000 GB-초, 행 쓰기 10만 건이다.
애초에 세는 걸 기각한 이유가 KV 카운터였다. 주소별 카운터를 KV에 두면 생성마다 쓰기가 1회 붙어서 아끼려던 예산을 카운터가 먹는다. DO에서는 그 반대 사유가 사라지고, 생성이 이미 KV 때문에 하루 1,000건인 만큼 객체 자체의 사용량은 한도에 닿지도 않는다.
Cloudflare 문서가 전역 rate limiter를 안티패턴으로 꼽고 있는데, 그건 모든 트래픽을 global이라는 하나짜리 인스턴스로 모으는 경우다. 주소당 객체 하나씩 두는 게 같은 문서가 권하는 샤딩이다.
클래스는 여섯 줄이다.
export class IpQuota extends DurableObject {
async take(day: string, limit: number): Promise<boolean> {
const next = nextCount(await this.ctx.storage.get<Counter>('c'), day, limit);
if (!next) return false;
await this.ctx.storage.put('c', next);
return true;
}
}
락도 blockConcurrencyWhile도 없다. storage 작업이 떠 있는 동안 input gate가 다음 이벤트를 붙잡아준다.
날짜는 UTC 문자열로 밖에서 넣어준다. KV 예산이 00:00 UTC에 풀리니까 다른 경계로 세면 이름과 다른 걸 지키게 된다. 카운터를 비우는 alarm도 안 뒀는데, 날이 바뀌고 들어온 첫 요청이 공짜로 초기화하고 setAlarm()은 그 아무것도 아닌 일에 행 쓰기를 물린다.
두 층이 각각 다른 예산을 지킨다
고치고 나니 집행은 늘고 코드는 줄었다. 바인딩은 통째로 빠졌다.
| 층 | 규칙 | 지키는 예산 | 실행 위치 |
|---|---|---|---|
| 존 rate limiting rule | 주소당 10초에 생성 1건 | 요청 수, 하루 100,000건 | 워커 앞 |
IpQuota Durable Object | 주소당 하루 생성 50건 | KV 쓰기, 하루 1,000건 | 워커 안, 쓰기 직전 |
존 규칙은 워커보다 앞인 http_ratelimit 단계에 있어서 막힌 연발은 워커 호출을 한 건도 쓰지 않는다. 차단 응답은 내 워커가 주는 것과 같은 429, 같은 JSON 본문으로 맞춰뒀다. 브라우저 코드는 둘을 구분하지 못한다.
하루치는 요청 앞이 아니라 KV 쓰기 바로 앞에서 센다. 앞에 두면 주소를 잘못 넣은 사람이 한 칸을 버리고, 세는 대상이 쓰기가 아니라 요청으로 슬쩍 바뀐다.
5건/60초 바인딩은 지웠다. 위가 10초를 잡고 아래가 하루를 잡으면 그 사이에서 할 말이 없다.
정밀도가 달라서 화면 문구도 달라졌다
로컬에서 1번부터 50번까지 201이고 51번째가 429였다. 정확히 51번째. DO는 강한 일관성이라 이 숫자는 근사가 아니다.
프로덕션의 존 규칙은 문서 그대로 움직인다. /api/links에 연속 세 번.
#1 -> 400 blocked_destination
#2 -> 400 blocked_destination
#3 -> 429 rate_limited
10초 1건인데 두 건이 통과했다. 카운터가 데이터센터별이고 최종적 일관성이라, 집행이 시작되기 전에 초과 요청이 들어올 수 있다고 문서에도 적혀 있다. 3회 한 번으로 정상 상태를 측정했다고 할 수는 없어도 모양은 안다.
그래서 화면에는 하루 50건만 숫자로 박고 10초 쪽은 “10초에 한 번쯤”으로 얼버무렸다. 처음 쓴 문구가 “10초에 한 번”이었는데, 100% 틀리는 숫자를 적어두면 언젠가 모르는 사람의 버그 신고로 돌아올 것 같았다.
못 막는 것
50이라는 숫자는 방어와 상관없이 골랐다. Cloudflare 안내가 IP를 키로 쓰지 말라고 하는 이유가 주소 공유인데, 통신사 NAT 뒤에는 남남이 여럿 붙어 있다. 조이면 예산은 잘 지켜지고 진짜 사용자가 막히는 시점도 같이 빨라진다. 지금은 하루 생성이 몇 건이라 예산이 타는 것보다 사람 한 명 막히는 쪽이 더 아프다.
주소를 돌려 쓰는 호출은 두 층 다 못 본다. 둘 다 주소를 키로 쓰니 당연하다. 정직한 답은 Turnstile인데 브라우저 쪽을 닫으면서 문서에 적어둔 스크립트 사용까지 같이 깨진다. 아직 오지도 않은 남용에 그 값을 치를 생각은 없다.
여튼 오후 하나를 여기 썼는데 뭐가 공격해와서가 아니었다. 오류 페이지도 알림도 없이 링크 만드는 칸만 자정까지 조용히 서 있는 모양이 싫었던 것 같다. 3개월 뒤에 하루 생성 숫자를 보고 다시 남겨야겠다.
Filed under 개발