CDN·Cloudflare 심화 — 엣지 캐시·Workers·애니캐스트·DDoS·프로덕션 패턴
이 글의 핵심
CDN이 단순 ‘복제 저장소’가 아니라 어떤 알고리즘·네트워크·보안으로 동작하는지 정리합니다. 엣지 캐시의 키·TTL·무효화, Workers의 V8 Isolate 실행 모델, 애니캐스트 BGP 라우팅, DDoS 완화 계층, 그리고 실서비스에서 쓰는 캐시·버전·오리진 보호 패턴까지 다룹니다.
들어가며
CDN은 정적 파일을 사용자 가까운 곳에 복제해 두는 기술로 소개되는 경우가 많지만, 실제로 운영해 보면 캐시 일관성, 라우팅, 코드 실행 환경, 보안이 한 플랫폼에 묶여 있는 시스템입니다. Cloudflare처럼 DNS, 리버스 프록시, Workers, DDoS 방어를 함께 제공하는 서비스에서는 이 요소들이 서로 영향을 주기 때문에, 어느 하나만 이해해서는 “왜 캐시가 안 붙지?” 같은 질문에 답하기 어렵습니다.
이 글에서는 엣지 캐시가 무엇을 기준으로 저장하고 언제 버리는지, Workers가 어떤 실행 모델 위에서 도는지, 애니캐스트가 트래픽을 어떻게 모으는지, DDoS 공격을 어느 계층에서 걸러 내는지, 그리고 운영에서 쓰는 캐시 헤더와 배포 패턴을 차례로 살펴봅니다. 공급자의 비공개 구현은 단정하지 않고, HTTP 캐싱 표준(RFC 9111)과 BGP의 공개된 동작, 그리고 Cloudflare 공식 문서에 적힌 동작을 기준으로 설명합니다.
엣지 캐시가 저장하고 버리는 기준
캐시 키와 정규화
엣지 서버의 저장 공간은 유한하므로, 어떤 요청들을 “같은 객체”로 볼지가 히트율과 비용을 좌우합니다. 이 기준이 캐시 키입니다. 대부분의 CDN은 스킴, 호스트, 경로, 쿼리 문자열을 기본 키로 쓰고, 설정에 따라 일부 헤더나 쿠키, 기기 종류를 추가합니다.
HTTP 표준은 응답의 Vary 헤더로 “이 헤더 값이 다르면 다른 응답”임을 알리도록 하지만, CDN이 이를 그대로 따르지는 않습니다. Cloudflare는 Vary: Accept-Encoding만 기본적으로 처리하고, 다른 Vary 값은 캐시 키에 반영하지 않습니다(이미지 최적화 기능의 Accept 등 일부 예외가 있습니다). 따라서 Accept-Language에 따라 다른 HTML을 돌려주면서 Vary에만 의존하면, 한국어 사용자가 먼저 채운 캐시를 영어 사용자가 받는 일이 생깁니다. 이런 경우에는 /ko/, /en/처럼 URL을 나누거나 Cache Rules의 사용자 정의 캐시 키로 헤더를 명시적으로 포함시켜야 합니다.
쿼리 문자열을 정렬하거나 utm_* 같은 추적용 파라미터를 키에서 빼는 정규화는 히트율을 크게 올릴 수 있습니다. 반대로 API처럼 쿼리 파라미터가 응답 내용을 바꾸는 경우에 잘못 정규화하면, 다른 조건의 응답을 돌려주는 사고가 됩니다. 정규화 규칙은 “이 파라미터가 응답을 바꾸는가”를 경로별로 확인한 뒤에 적용해야 합니다.
교체 정책
엣지 저장소가 차면 무엇을 내보낼지 정해야 합니다. 기본 이론은 일반적인 캐시와 같습니다.
| 방식 | 요지 | 엣지에서의 함의 |
|---|---|---|
| LRU (Least Recently Used) | 가장 오래 참조되지 않은 것부터 제거 | 시간 지역성이 강한 트래픽에 유리하지만, 한 번만 요청되는 대량 객체가 인기 객체를 밀어낼 수 있음 |
| LFU (Least Frequently Used) | 참조 빈도가 낮은 것부터 제거 | 인기 객체가 오래 남지만, 인기가 식은 객체도 과거 빈도 때문에 남기 쉬움 |
| FIFO | 먼저 들어온 것부터 제거 | 구현은 단순하나 워크로드에 따라 히트율이 낮음 |
실제 CDN은 메모리와 SSD를 계층으로 나누고, 객체 크기와 남은 TTL을 함께 고려하는 혼합 방식을 쓰는 것으로 알려져 있지만 세부 구현은 공개되지 않는 경우가 많습니다. 서비스를 운영하는 입장에서 통제할 수 있는 부분은 교체 정책이 아니라 캐시 키, TTL, 원본이 보내는 헤더입니다. 히트율 문제의 대부분은 여기서 해결됩니다. 참고로 트래픽이 적은 객체는 TTL이 남아 있어도 엣지에서 먼저 밀려날 수 있으므로, “TTL을 1년으로 줬는데 왜 원본 요청이 오지?”라는 질문의 답이 되기도 합니다.
TTL, 재검증, 만료된 사본 제공
원본이 보내는 Cache-Control 헤더가 엣지의 동작을 정합니다.
max-age는 브라우저와 공유 캐시 모두에 적용되는 신선도 수명이고,s-maxage는 CDN 같은 공유 캐시에만 적용되어max-age보다 우선합니다. 브라우저에는 짧게, CDN에는 길게 캐시하게 하고 싶을 때 둘을 다르게 둡니다.- 신선도 수명이 지나면 캐시는
ETag나Last-Modified를 이용해If-None-Match,If-Modified-Since조건부 요청을 원본에 보냅니다. 원본이 304 Not Modified를 돌려주면 본문을 다시 받지 않고 수명을 연장합니다. stale-while-revalidate=N은 만료 후 N초 동안은 만료된 사본을 즉시 돌려주면서 백그라운드에서 갱신하라는 뜻이고,stale-if-error=N은 원본이 에러를 낼 때 N초 동안 만료된 사본으로 대신 응답하라는 뜻입니다(RFC 5861). 이 두 지시어의 지원 범위와 방식은 공급자마다 다르므로, 쓰기 전에 해당 CDN 문서에서 비동기 재검증을 하는지 확인해야 합니다.
Cloudflare는 기본 설정에서 모든 응답을 캐시하지 않는다는 점도 알아 둘 필요가 있습니다. 기본적으로 이미지, CSS, JS 같은 정적 파일 확장자만 캐시하고 HTML은 캐시하지 않으며, Set-Cookie가 붙은 응답도 캐시하지 않습니다. HTML을 엣지에 캐시하려면 Cache Rules에서 해당 경로를 캐시 대상으로 지정해야 합니다.
운영에서는 “긴 TTL과 명시적 퍼지”와 “짧은 TTL과 SWR” 중에서 고르게 되는데, 전자는 원본 부하가 적은 대신 퍼지가 실패하거나 누락되면 오래된 내용이 오래 남고, 후자는 일관성 문제가 짧게 끝나는 대신 원본 요청이 꾸준히 발생합니다.
무효화와 버전 전략
캐시를 비우는 방법은 URL 단위 퍼지, 태그·호스트·접두사 단위 퍼지, 전체 퍼지가 있습니다. Cloudflare는 URL 단위와 전체 퍼지를 모든 요금제에서 제공하고, 태그·접두사 퍼지는 오랫동안 Enterprise 전용이었다가 제공 범위가 넓어졌습니다. 요금제별 제공 여부와 API 호출 한도는 자주 바뀌므로 현재 문서를 확인해야 합니다. 퍼지는 전 세계 엣지에 전파되는 데 시간이 걸리고, 전체 퍼지 직후에는 원본으로 요청이 몰립니다.
그래서 널리 쓰이는 방식은 파일명에 콘텐츠 해시를 넣는 것입니다(app.a1b2c3.js). 내용이 바뀌면 이름이 바뀌므로 정적 자산은 퍼지할 필요가 없고, HTML만 짧은 TTL로 두거나 배포 때 퍼지하면 됩니다. 이전 해시 파일이 그대로 남아 있으므로, 배포를 롤백해도 예전 HTML이 참조하는 파일을 그대로 받을 수 있습니다.
Cloudflare Workers의 실행 모델
컨테이너가 아니라 V8 Isolate
Workers는 요청마다 컨테이너나 VM을 띄우지 않고, V8 JavaScript 엔진의 Isolate에 사용자 코드를 로드합니다. Isolate는 독립된 힙과 전역 환경을 가진 실행 단위로, 하나의 프로세스 안에 수많은 Isolate가 공존할 수 있습니다. 새 프로세스를 띄우는 것보다 시작 비용이 훨씬 작아서 콜드 스타트가 수 밀리초 수준이고, 이 덕분에 전 세계 엣지에서 수많은 고객의 코드를 동시에 돌리는 멀티테넌시가 가능합니다.
이 모델에서 나오는 제약이 몇 가지 있습니다.
첫째, 런타임은 Node.js가 아니라 Fetch, Streams, Web Crypto 같은 웹 표준 API를 중심으로 합니다. nodejs_compat 호환 플래그를 켜면 Buffer, crypto, AsyncLocalStorage 등 상당수 Node.js API를 쓸 수 있지만, 파일 시스템 접근이나 네이티브 애드온에 의존하는 패키지는 동작하지 않습니다.
둘째, 제한은 실제 경과 시간이 아니라 CPU 시간 기준입니다. 외부 API나 스토리지 응답을 await하며 기다리는 시간은 CPU 시간에 포함되지 않지만, 이미지 처리나 큰 JSON 파싱처럼 CPU를 계속 쓰는 작업은 요금제별 CPU 한도에 걸립니다. 무거운 계산은 Queues로 넘겨 비동기로 처리하거나 원본 서버에 맡기는 편이 안전합니다.
셋째, 이미 로드된 Isolate가 여러 요청을 연속으로 처리할 수 있습니다. 전역 변수에 요청별 데이터를 담으면 다음 요청이 그 값을 보게 되어 사용자 간 정보 누수가 생길 수 있습니다. 반대로 같은 Isolate가 계속 재사용된다는 보장도 없으므로 전역 변수를 캐시로 믿어서도 안 됩니다. 요청별 상태는 핸들러 안의 지역 변수로만 다루는 것이 원칙입니다.
데이터는 엣지에 없을 수도 있다
Workers는 사용자와 가까운 PoP에서 실행되지만, 코드가 읽는 데이터까지 가까이 있다는 뜻은 아닙니다. 바인딩하는 스토리지마다 지연과 일관성 모델이 다릅니다. KV는 읽기가 많은 데이터를 엣지에 캐시하는 대신 쓰기가 전 세계에 전파되기까지 시간이 걸리는 최종 일관성 저장소이고(Cloudflare 문서는 최대 60초 이상 걸릴 수 있다고 안내합니다), D1은 SQLite 기반 데이터베이스로 기본 인스턴스가 특정 위치에 있으며, Durable Objects는 객체 하나가 한 위치에서 실행되며 강한 일관성을 제공합니다. 쓰기 직후 읽기가 반드시 최신이어야 하는 로직을 KV로 만들면, 가끔 이전 값이 읽히는 재현하기 어려운 버그가 됩니다.
Worker가 한 요청에서 멀리 있는 데이터베이스에 여러 번 쿼리를 보내면, 사용자 가까이에서 실행한 이점보다 왕복 지연이 더 커질 수 있습니다. Cloudflare가 Smart Placement 같은 기능으로 Worker를 데이터 가까이 옮겨 실행하는 옵션을 제공하는 것도 이 때문입니다.
애니캐스트 라우팅
같은 IP, 여러 위치
유니캐스트에서는 IP 주소 하나가 한 위치를 가리키지만, 애니캐스트에서는 같은 IP 접두사를 여러 위치에서 BGP로 광고합니다. 인터넷의 라우터들은 각자 가장 좋다고 판단한 경로를 선택하므로, 사용자마다 서로 다른 위치의 서버에 도달합니다. Cloudflare는 전 세계 PoP에서 같은 IP 대역을 광고해, DNS가 어떤 IP를 돌려주든 사용자가 근처 PoP에 붙도록 합니다.
BGP가 고르는 “가까운” 경로
BGP의 경로 선택은 지리적 거리를 보지 않습니다. 각 네트워크(AS)의 로컬 선호도 설정, AS 경로 길이, 피어링과 트랜짓 계약 조건이 먼저 반영됩니다. 그래서 서울 사용자가 쓰는 ISP가 특정 PoP과 직접 피어링하지 않으면, 트래픽이 도쿄나 홍콩의 PoP으로 가는 일이 실제로 생깁니다. 지연 문제를 조사할 때는 추측하지 말고 실제 사용자 경로를 확인해야 합니다. Cloudflare 뒤의 사이트라면 /cdn-cgi/trace 경로를 열었을 때 나오는 colo= 값이 요청을 처리한 PoP의 공항 코드이고, 응답의 cf-ray 헤더 끝에도 같은 코드가 붙습니다.
장애와 공격 트래픽의 분산
특정 PoP이 장애로 광고를 철회하면 BGP가 수렴하면서 그 지역 트래픽이 다른 PoP으로 넘어갑니다. 이때 기존 TCP 연결은 새 PoP에 상태가 없으므로 끊기고, 클라이언트는 재연결해야 합니다. HTTP처럼 짧은 요청 위주의 트래픽에서는 문제가 작지만, 장시간 유지되는 WebSocket 같은 연결은 재연결 처리를 클라이언트에 반드시 넣어야 하는 이유이기도 합니다.
공격 측면에서는 애니캐스트가 자연스러운 분산 장치가 됩니다. 봇넷의 트래픽이 전 세계에서 출발하면 각 봇이 자기 근처 PoP으로 가므로, 공격 트래픽이 한 지점에 모이지 않고 여러 PoP에 나뉘어 흡수됩니다.
DNS 기반 지역 라우팅과의 차이
지역별로 다른 IP를 돌려주는 DNS 기반 라우팅은 사용자의 위치가 아니라 사용자가 쓰는 DNS 리졸버의 위치를 기준으로 판단합니다. 공용 리졸버를 쓰는 사용자는 엉뚱한 지역으로 안내될 수 있고, EDNS Client Subnet이 이를 보완하지만 모든 리졸버가 보내지는 않습니다. 애니캐스트는 실제 패킷 경로를 따르므로 이 문제가 없는 대신, 앞에서 본 것처럼 BGP 정책에 좌우됩니다. 특정 지역 지연을 디버깅할 때 두 방식 중 어느 쪽이 쓰이는지부터 확인해야 하는 이유입니다.
DDoS 완화
공격 유형별 계층
| 계층 | 예시 | 완화 방향 |
|---|---|---|
| 대용량(Volumetric) | UDP 플러드, DNS·NTP 반사 증폭 | 애니캐스트로 분산 흡수, 엣지에서 불필요한 프로토콜 차단 |
| 프로토콜(L3/L4) | SYN 플러드, 비정상 패킷 | SYN 쿠키, 상태 비저장 필터링, 연결 추적 테이블 보호 |
| 애플리케이션(L7) | HTTP 요청 플러드, Slowloris | 레이트 리밋, 봇 탐지, 챌린지, WAF 규칙 |
HTTP만 받는 프록시 뒤에 있는 사이트는 UDP 반사 공격이 원본까지 도달하지 않습니다. 엣지가 HTTP/HTTPS 포트 외의 트래픽을 받지 않기 때문입니다. 반면 L7 공격은 정상 요청과 형태가 같아서 가장 구별하기 어렵고, 동적 페이지 요청이 캐시를 우회해 원본까지 가면 적은 요청 수로도 원본 데이터베이스를 쓰러뜨릴 수 있습니다.
엣지에서 쓰는 수단
레이트 리밋은 IP, ASN, URI, 헤더 조합별로 요청 수를 제한합니다. IP만 기준으로 하면 회사나 모바일 통신사처럼 한 IP 뒤에 많은 사용자가 있는 경우 정상 사용자가 차단되므로, 로그인 세션이나 API 키 같은 식별자와 함께 쓰는 편이 오탐이 적습니다. 챌린지는 브라우저가 아닌 자동화 도구에 비용을 부과하는 방식인데, API 엔드포인트에 걸면 정상 클라이언트까지 막히므로 페이지와 API의 정책을 나눠야 합니다. WAF 규칙은 알려진 공격 패턴을 차단하지만, 직접 작성한 규칙은 오탐 시 정상 요청을 대량으로 403 처리할 수 있어 로그 전용(Log) 모드로 먼저 돌려 보고 차단으로 바꾸는 순서가 안전합니다.
오리진 보호
CDN 뒤에 숨겨 둔 원본 IP가 알려지면 공격자는 CDN을 거치지 않고 원본을 직접 공격합니다. 원본 IP는 CDN을 붙이기 전의 과거 DNS 기록, CDN을 거치지 않는 서브도메인(메일 서버 등), 원본이 외부로 보내는 요청의 출발 IP, 인증서 투명성 로그 등을 통해 드러나곤 합니다. 원본 방화벽에서 CDN의 IP 대역만 허용하고, 가능하면 Cloudflare의 Authenticated Origin Pulls(mTLS)나 Cloudflare Tunnel처럼 원본이 공인 IP로 직접 요청을 받지 않는 구성을 쓰면 이 경로를 막을 수 있습니다.
운영에서 쓰는 패턴
정적 자산: 해시 파일명과 긴 캐시
파일명에 콘텐츠 해시가 들어 있으면 같은 URL의 내용은 절대 바뀌지 않으므로 Cache-Control: public, max-age=31536000, immutable을 줄 수 있습니다. immutable은 브라우저가 새로고침할 때도 재검증 요청을 보내지 않게 하는 지시어로, 내용이 바뀌지 않는다는 전제가 지켜질 때만 써야 합니다.
HTML과 API: 짧게 캐시하거나 캐시하지 않기
HTML은 배포 직후 새 자산을 참조해야 하므로 짧은 TTL이나 배포 시 퍼지를 씁니다. API 응답은 더 조심해야 합니다. 사용자별 데이터를 담은 응답에는 private 또는 no-store를 붙이고, 공유 캐시에 저장되어도 되는지 확신이 없으면 캐시하지 않는 것이 맞습니다. CDN 사고 중 가장 치명적인 것은 느린 응답이 아니라 한 사용자의 개인 정보가 담긴 응답이 다른 사용자에게 전달되는 경우입니다. 이 일이 생기면 먼저 캐시 키에 인증 정보가 빠졌는지, 사용자별 응답에 캐시 가능한 헤더가 붙었는지를 확인해야 합니다.
원본 앞의 중간 캐시
엣지 PoP이 수백 곳이면, 같은 객체에 대한 첫 미스가 PoP마다 한 번씩 원본에 도달합니다. 원본 앞에 중간 캐시 계층을 두면 엣지 미스를 중간 계층이 모아 원본 요청을 크게 줄일 수 있습니다. AWS CloudFront는 이를 Origin Shield라고 부르고, Cloudflare는 Tiered Cache로 제공합니다. 대신 중간 계층이 한 번 더 경유하는 만큼 미스 시 지연이 늘 수 있고, 중간 계층 설정 오류의 영향 범위가 넓어집니다.
Workers로 할 일과 하지 않을 일
Workers는 A/B 테스트 분기, JWT 같은 토큰 검증, URL 리라이트와 리다이렉트, 응답 헤더 조작처럼 요청마다 짧게 끝나는 로직에 잘 맞습니다. 반면 CPU를 오래 쓰는 처리나 강한 트랜잭션이 필요한 로직은 CPU 한도와 데이터 위치 문제 때문에 원본이나 전용 서비스가 더 적합한 경우가 많습니다. 서버 사이드 렌더링은 프레임워크와 데이터 접근 패턴에 따라 엣지에서 잘 동작하기도 하므로, 페이지당 데이터베이스 왕복이 몇 번인지를 기준으로 판단하는 것이 현실적입니다.
캐시가 기대대로 동작하지 않을 때
캐시가 붙지 않으면 응답 헤더부터 봅니다. Cloudflare는 cf-cache-status 헤더로 결과를 알려 주는데, HIT, MISS, EXPIRED, REVALIDATED 외에 DYNAMIC은 캐시 대상으로 판단되지 않았다는 뜻이고, BYPASS는 규칙이나 원본 헤더 때문에 캐시를 건너뛰었다는 뜻입니다. DYNAMIC이면 해당 경로가 캐시 대상 확장자나 Cache Rules에 포함되는지, BYPASS나 반복되는 MISS라면 원본의 Cache-Control에 private, no-store, no-cache가 있는지, Set-Cookie가 붙는지를 확인합니다.
특정 지역에서만 느리다면 DNS 응답, 사용자가 붙는 PoP(colo), 원본까지의 거리, TLS 핸드셰이크 왕복 시간을 나눠서 측정해야 원인이 보입니다. curl -w의 time_connect, time_appconnect, time_starttransfer 값을 비교하면 연결, TLS, 서버 처리 중 어느 구간이 느린지 구분할 수 있습니다.
자주 묻는 질문 (FAQ)
배포한 뒤에도 사용자에게 예전 JS·CSS가 보이면 퍼지로 해결해야 하나요?
매번 퍼지에 기대기보다 정적 자산 파일명에 콘텐츠 해시(app.a1b2c3.js)를 넣고 긴 max-age를 주는 방식이 더 안정적입니다. 내용이 바뀌면 파일 이름도 바뀌므로 캐시를 지우지 않아도 새 파일을 받게 되고, HTML만 짧은 TTL이나 퍼지로 관리하면 됩니다. 이렇게 하면 대규모 퍼지가 필요한 경우가 줄고, 이전 해시 파일이 남아 있어 롤백도 단순해집니다.