모든 응답은 속도 제한(Rate Limit)을 약속했지만, 아무것도 이를 강제하지 않았다
요약
웹 서비스에서 선언된 Rate Limit 헤더가 실제 코드에서 강제되지 않을 때 발생하는 보안 및 운영상의 허점을 분석합니다. Cloudflare Workers를 사용하여 실제 속도 제한을 구현하는 방법과 'fail open' 방식의 설계 원칙을 다룹니다.
핵심 포인트
- 선언된 Rate Limit 헤더가 실제 로직과 일치하지 않으면 에이전트가 오작동할 수 있음
- Cloudflare Workers의 rate limiting binding을 통한 실제 강제 구현 방법 제시
- 시스템 안정성을 위해 오류 발생 시 요청을 허용하는 'fail open' 전략의 중요성
- IP 기반 속도 제한의 한계와 API 키 등 안정적인 식별자 사용 권장
이 사이트의 Worker에 있는 한 함수는 렌더링되는 모든 응답에 보안 헤더(security headers)를 부착합니다. 어제까지 그중 두 개는 RateLimit-Limit: 100과 RateLimit-Policy: "default";q=100;w=60이었습니다. 이 헤더들은 홈페이지, 24개의 모든 가이드, 모든 마크다운(markdown) 쌍, 그리고 모든 404 페이지와 함께 제공되었습니다. 또한 이 사이트는 에이전트(agents)를 위한 응답 헤더(response headers)에 관한 가이드를 발행하고 있는데, 해당 가이드는 잘 작동하는 에이전트라면 선언된 예산을 읽고 누군가 자신을 제한하기 전에 스스로 속도를 조절(throttle)해야 한다는 논거를 바탕으로 RateLimit 제품군을 가르칩니다.
하지만 어떤 코드도 이 두 숫자를 강제하지 않았습니다. Worker에는 속도 제한(rate limiting) 로직이 없었고, 429 상태 코드를 반환하는 경로도 없었으며, 설정(config)에도 제한기(limiter)가 선언되어 있지 않았습니다. '100'이라는 숫자는 카운터(counter)에 연결된 적이 없었기에, 모든 응답은 서버가 소비할 방법이 없는 예산을 광고하고 있었던 셈입니다. 이 사실은 제가 이 사이트에 던져질 수 있는 가장 어려운 질문들에 대한 답변을 준비하던 중에 드러났으며, 그것이 이 문제가 발견된 유일한 이유였습니다.
이것이 바로 이 비즈니스가 찾아내어 판매하는 바로 그 결함입니다. 코드가 해결하지 못하는 선언된 표면(declared surface)은 에이전트 준비성 감사(agent-readiness audit)에서 가장 먼저 찾는 항목이며, 헤더를 신뢰할 만큼 예의 바른 에이전트라면 텍스트로만 존재하는 제한에 맞춰 스스로의 할당량을 조절했을 것입니다.
강제(Enforcement)가 구현되는 방식
이제 Cloudflare의 Workers 속도 제한 바인딩(rate limiting binding)이 그 역할을 수행합니다. 설정은 wrangler.jsonc 내의 블록으로, 60초당 100개의 요청이라는 단순한 제한을 가진 제한기(limiter)를 지정합니다. 따라서 설정은 헤더가 스스로 약속해 왔던 것과 동일한 숫자를 담게 됩니다. fetch 핸들러의 상단에서 Worker는 클라이언트 IP를 키(key)로 하여 limit()를 호출하며, 제한을 초과하면 Retry-After: 60과 함께 429를 반환합니다. 이는 다른 모든 응답과 마찬가지로 동일한 보안 헤더 함수에 의해 구축되므로, 속도 제한(rate limit) 헤더가 429 응답 자체에 함께 실려 나가게 됩니다.
이는 의도적으로 '페일 오픈 (fail open)' 방식으로 작동합니다. 만약 바인딩(binding)이 누락되거나 limit() 함수에서 오류가 발생하면, 요청은 정상적으로 처리됩니다. 왜냐하면 자체적인 배관(plumbing)이 고장 났을 때 사이트를 다운시켜 버리는 제한기(limiter)는, 일시적인 트래픽 급증을 허용하는 것보다 더 나쁜 선택이기 때문입니다. 이러한 선택에는 비용이 따르며, 이 글에서 다시 다루게 될 내용입니다. 외부에서 볼 때, 페일 오픈(fail open) 상태가 된 감시자는 제 역할을 수행 중인 감시자와 구별할 수 없으며, 오직 로그만이 그 차이를 말해줄 수 있습니다.
핵심은 클라이언트 IP이며, Cloudflare는 이를 권장하지 않습니다. 그들의 베스트 프랙티스(best practices)는 다음과 같이 명확하게 명시하고 있습니다: "많은 유효한 사례에서 여러 사용자가 공유할 수 있으므로, IP 주소나 위치(지역 또는 국가)를 사용하는 것은 권장되지 않습니다." 그들의 말이 맞습니다. 그들이 권장하는 식별자들은 호출자의 안정적인 속성, 즉 Authorization 헤더의 API 키나 사용자(user) 또는 테넌트(tenant) ID 등인데, 여기에는 그 중 어느 것도 존재하지 않습니다. 이 사이트는 계정도 로그인도 없는 공개 문서이므로 남은 것은 IP뿐이며, 그 대가는 실질적입니다. 하나의 기업 프록시(proxy)나 하나의 모바일 NAT 뒤에 있는 여러 에이전트(agents)가 100이라는 단일 예산을 공유하게 되며, 거절당하는 에이전트는 정중하게 요청을 보낸 에이전트일 수도 있습니다.
아무것도 찾아내지 못한 테스트
코드를 읽는 과정에서 이 사실을 포착했습니다. 이 발견에는 별도의 도구가 필요하지 않았습니다. 어디에도 제한기(limiter) 로직이 없었으며, 429 응답을 보낼 수 있는 그 어떤 것도 없었기 때문입니다. 같은 날 아침, 약 6초 동안 130개의 요청을 보낸 조사(probe)가 이를 뒷받침하는 듯 보였으나, 돌아온 429 응답은 0개였습니다. 저는 코드 분석보다는 조사 결과를 즉시 인용했을 것입니다. 왜냐하면 조사 결과에는 숫자가 포함되어 있고, 숫자는 더 잘 전달되기 때문입니다.
이 조사는 아무것도 증명하지 못하며, 이제 이는 추측이 아닌 측정된 사실입니다. 저는 해당 사이트에 한 번도 접속한 적이 없는 네트워크를 통해, 제한(enforcement)이 활성화된 고정된 사이트를 대상으로 동일한 버스트(burst)를 실행했습니다. 130개의 요청을 10개씩 병렬로 보냈으나, 429(Too Many Requests) 응답은 단 하나도 없이 130개 모두 200(OK) 응답을 받았습니다. 이는 문제가 발생한 사이트가 보여준 것과 동일한 결과였습니다. 아래의 더 큰 규모의 버스트에서도 처음 130개의 요청 중 거부된 것은 단 하나도 없었습니다. 130개의 요청은 이 플랫폼이 거부를 시작하는 지점에 도달하지 못하므로, 정상 작동하는 사이트와 제한 장치가 없는 사이트는 해당 조사에 정확히 똑같은 목소리로 답하게 됩니다.
대상 시스템의 고장 여부와 상관없이 동일한 답변을 내놓는 테스트는 약한 증거가 아닙니다. 그것은 증거가 아닙니다. 그것은 아예 숫자가 없는 것보다 더 나쁩니다. 왜냐하면 숫자는 인용되기 마련인데, 이 숫자는 저에 의해 인용되었을 것이기 때문입니다. 코드 판독이 결함을 찾아낸 것이며, curl은 우연히 그 결과에 동의했을 뿐입니다.
Cloudflare는 이러한 느슨함을 문서화하고 있다
해당 바인딩(binding)의 문서 '정확도(Accuracy)' 섹션에는 API가 "허용적(permissive)이며, 최종적 일관성(eventually consistent)을 가지며, 정확한 회계 시스템(accounting system)으로 사용되지 않도록 의도적으로 설계되었다"라고 명시되어 있습니다. 그 위의 '성능(Performance)' 섹션은 그 이유를 설명합니다. 카운터(counters)가 Worker가 실행되는 머신에 캐싱되어 있으며, 동일한 Cloudflare 위치 내의 백킹 스토어(backing store)를 대상으로 비동기적으로 업데이트되기 때문에 limit() 함수가 유의미한 지연 시간(latency)을 발생시키지 않는 것입니다. 그리고 이 카운터들은 로컬(local)입니다. 각 키(key)에 대해 Cloudflare 위치별로 별도의 제한이 적용되므로, 60초당 100회라는 예산은 IP당, 위치당 할당되는 것이지 결코 전역(global)적인 것이 아닙니다.
여기서부터 측정했을 때, 허용적인(permissive) 모습은 다음과 같습니다. 한 번에 10개씩, 300개의 요청을 병렬로 폭발적으로(burst) 보냈을 때, 279개의 200 응답과 21개의 429 응답이 반환되었습니다. 해당 실행에서는 각 요청에 인덱스를 태깅하여 거부된 요청들이 어디에 위치하는지 확인할 수 있었습니다. 거부된 요청들은 모두 마지막 부분인 인덱스 240부터 299 사이에 모여 있었습니다. 다른 네트워크에서 수행한 두 번째 실행에서는 281 대 19로 나뉘었습니다. 폭발적인 요청 직후에 보낸 단일 요청은 한 네트워크에서는 거부되었고 다른 네트워크에서는 처리되었습니다. 이는 광고된 대로 작동하는 최종 일관성 (eventual consistency)의 모습이며, 429 응답의 Retry-After: 60이 측정된 대기 시간이 아니라 선언된 상한선(ceiling)인 이유이기도 합니다.
뼈아픈 대조는 로컬(local) 환경에서 나타납니다. wrangler dev 실행은 제한을 정확하게 강제하여, 100개의 요청은 통과하고 나머지는 거부됩니다. 즉, 자신의 코드를 자연스럽게 검증하게 되는 환경이 실제 운영(production) 환경과는 전혀 다르게 동작하는 유일한 환경인 것입니다.
제한에 도달하는 더 느린 경로도 존재하며, 우리의 첫 번째 테스트는 이를 인지하지 못한 채 그 경로를 따라갔습니다. 예산은 60초당 100회, 즉 초당 2회에 약간 못 미치는 속도로 충전됩니다. 초당 3회의 순차적 루프(sequential loop)는 충전 속도보다 빠르게 소진되므로, 버킷 모델 (bucket model)을 따른다면 요청 220회 이후 어딘가에서 첫 번째 거부를 만날 것입니다. 우리의 테스트는 115회의 요청과 38초 지점에서 멈췄고, 200 응답 외에는 아무것도 보지 못했습니다. 저는 이를 배포(deploy) 오류로 오해하여 두 번째 배포를 요청했습니다. 배포 자체는 문제가 없었습니다. 측정값이 너무 작아 어느 쪽으로도 결론을 내릴 수 없었으며, 아마도 낭비되었을 배포 비용은 이 이야기의 대가 중 일부입니다.
테스트하고 싶다면
여기서 느린 루프는 아무것도 알려주지 않으며, 100개의 요청 또한 아무것도 알려주지 않습니다. 제한이 강제되든 존재하지 않든, 두 경우 모두 전부 200 응답으로 돌아오기 때문입니다. 제한에 도달하게 만드는 것은 카운터를 앞지를 만큼 충분히 큰 병렬 폭발 (parallel burst)입니다:
seq 300 | xargs -P 10 -I{} curl -s -o /dev/null -w "%{http_code}\n" https://turva.dev/ | sort | uniq -c
대부분의 버스트(burst)는 통과하고 그 끝부분 일부만 거부될 것으로 예상됩니다. 제가 수행한 두 번의 실행 결과는 각각 279 대 21, 그리고 281 대 19로 나뉘었습니다. 여러분의 결과는 세 번째 숫자 쌍이 될 것입니다. 왜냐하면 여러분은 저의 위치가 아닌 여러분 자신의 Cloudflare 위치에서 카운터를 채우게 될 것이기 때문입니다. 형태는 반복되지만, 산술적 수치는 그렇지 않습니다. 만약 300개의 버스트가 발생했는데도 429 (Too Many Requests) 오류가 전혀 발생하지 않는다면, 꼭 알려주시기 바랍니다. 그것은 고장 난 바인딩(binding)을 숨기고 있는 페일 오픈(fail-open) 경로이거나, 제가 현재로서는 설명할 수 없는 동작이기 때문입니다. 이메일 주소는 info@turva.dev입니다.
하나의 헤더는 폐기된 개정판(revision)의 것이었습니다
이 사이트의 가이드에는 현재 사용되는 두 필드인 RateLimit와 RateLimit-Policy가 명시되어 있습니다. 하지만 코드는 RateLimit-Limit와 RateLimit-Policy를 보내고 있었습니다. 무엇인가를 수정하기 전에 저는 어떤 인터페이스가 맞는지 확인하기 위해 IETF 아카이브를 찾아갔고, 답은 명확했습니다. 2026년 5월부터 활성화된 개정판인 draft-ietf-httpapi-ratelimit-headers의 Revision 11은 정확히 RateLimit-Policy와 RateLimit라는 두 개의 필드만을 정의합니다. RateLimit-Limit는 초기 개정판에 속하며, Revision 11에서 이 용어가 언급되는 곳은 해당 초안이 교체하려는 레거시(legacy) 헤더 이름들을 조사하는 섹션 내부뿐이며, 해당 섹션의 제목 자체에도 RFC로 발행되기 전에 삭제될 것이라고 명시되어 있습니다. 해당 사이트는 동일한 응답 내에서 현재 초안의 필드 하나와 폐기된 계보의 필드 하나를 함께 보내고 있었습니다. 가이드는 내내 옳았습니다. 오직 코드만이 틀렸을 뿐입니다.
어제 수정 사항이 배포되었습니다: RateLimit-Limit은 사라졌고 RateLimit-Policy는 유지됩니다. 현재 초안(draft)이 정의하는 필드인 RateLimit은 의도적으로 추가되지 않았습니다. Revision 11은 그 r 파라미터, 즉 남은 할당량(remaining quota)을 필수 항목으로 만들며, Cloudflare의 limit() 함수는 성공 여부를 나타내는 불리언(boolean) 값만을 반환하고 그 외의 남은 양(remaining)이나 재설정(reset) 값은 반환하지 않습니다. 따라서 RateLimit을 보내는 것은 해당 필드가 전달하기 위해 존재하는 바로 그 숫자를 임의로 만들어내는 것을 의미하게 됩니다. 또한 초안에 따르면 policy 필드만으로도 클라이언트가 자신의 요청 흐름을 제어할 수 있으며, RateLimit 필드는 매우 동적인 제한(limits)을 위해 배치되었습니다. 현재 이 제한은 60초당 100회라는 정적(static)인 값입니다. 정적 제한의 경우, RateLimit-Policy만 사용하는 것이 올바른 형식입니다.
점수판에서 치른 대가
RateLimit이라는 이름이 포함된 헤더를 삭제하는 것은 스캐너(scanner) 측면에서 결과를 초래하며, 이는 전후로 측정되었습니다. startuphub.ai의 이 사이트에 대한 스캔 결과는 100점에서 99점으로, 품질 카테고리는 100점에서 96점으로 떨어졌는데, 이는 해당 사이트의 속도 제한(rate limit) 체크를 더 이상 통과하지 못하기 때문입니다. 모든 응답이 RateLimit-Policy를 포함하고 있음에도 불구하고, 체크 결과는 이제 "No RateLimit-* headers"라고 보고합니다. 스캐너가 제안하는 수정 사항은 RateLimit: limit=100, remaining=47, reset=42를 요구하는데, 이는 이미 대체된 2023년 6월의 Revision 07 문법입니다. 또한 체크의 제목은 RFC 9331을 인용하고 있는데, 이는 속도 제한 초안과 전혀 무관합니다. RFC 9331은 L4S 네트워크를 위한 명시적 혼잡 통지(Explicit Congestion Notification) 프로토콜입니다. 속도 제한 필드들은 아직 RFC 번호가 없으며, 아마도 이 때문에 잘못된 번호가 붙은 것으로 추정됩니다.
저는 현재의 필드를 유지하고 논점을 수용했습니다. 홈페이지의 증거(evidence) 섹션은 사이트의 의도적으로 붉게 표시된 두 가지 상거래 체크 항목 옆에 누락된 점을 설명하고 있으며, 스캐너 지원팀에 노트를 전달했습니다. 또 다른 스캐너인 isitagentready.com은 이러한 헤더를 확인하지 않으며 여전히 Level 5로 읽습니다. 만약 스캐너가 초안의 내용을 따라잡는다면, 점수는 저절로 돌아올 것입니다. 만약 그렇지 않더라도, 주장과 코드가 반드시 일치해야 한다는 것이 사이트의 핵심 논거인 상황에서, 폐기된 필드로 얻은 100점보다는 현재 필드를 사용한 99점이 더 낫습니다.
시사점 (What to take from it)
선언된 제한(limit)은 동작에 대한 주장이며, 이러한 동작에 대한 주장은 조용히 부패합니다. 왜냐하면 주장이 지켜지지 않더라도 시스템이 깨지지는 않기 때문입니다. 이러한 종류의 결함을 잡아내는 점검 방법은 코드를 읽는 것입니다. 엔드포인트(endpoint)를 조사하는 것만으로는 이를 수행할 수 없습니다. 왜냐하면 결과적 일관성 (eventual consistency) 모델을 따르는 플랫폼에서는, 보호 장치가 정상 작동할 때나, 누락되었을 때나, 혹은 실패하여 개방(failed open)된 상태일 때나 조사 결과가 똑같이 안심할 수 있는 200 OK 응답을 반환하기 때문입니다. 만약 귀하의 사이트 헤더가 무언가를 약속한다면, 흥미로운 질문은 그 값이 합리적으로 보이는지 여부가 아닙니다. 바로 어떤 코드 라인이 그 약속을 실제로 구현하고 있는가 하는 점입니다.
귀하의 에이전트 대상 주장(agent-facing claims)이 회의론자의 관점에서 읽히기를 원한다면, 제가 수행하는 감사를 이용하십시오. 이메일: info@turva.dev.
관련 항목 (Related)
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기