Cloudflare 뒤에 WordPress를 두었는데 cf-cache-status: DYNAMIC이 떠서 HTML 캐시 적중률이 0%라면, WordPress가 보내는 no-cache 헤더를 Cloudflare가 그대로 존중하고 있기 때문입니다. 캐시 규칙으로 원본 헤더를 무시하고 강제 캐시하면 페이지 응답이 실측 기준 348ms → 154ms(약 2.3배)로 빨라집니다. 무료 플랜에서 가능합니다.
증상: 모든 페이지가 DYNAMIC, 캐시 적중률 0%
응답 헤더를 까보면 이렇게 나옵니다.
curl -sI https://www.example.com/ | grep -i cf-cache-status
cf-cache-status: DYNAMIC
Cloudflare가 분명히 앞단에 붙어 있는데도, HTML 요청이 매번 한국에 있는 원본 서버까지 왕복하고 있다는 뜻입니다(이 상태의 페이지 응답이 약 348ms). 정적 파일(CSS·JS·이미지)은 캐시되는데 정작 가장 무거운 HTML 본문이 캐시를 타지 않으니, 체감 속도 개선이 거의 없었습니다.
cf-cache-status 값의 의미부터 정리하고 가겠습니다.
| 값 | 의미 |
|---|---|
HIT |
엣지 캐시에서 바로 응답 (성공 상태) |
MISS |
원본에서 가져왔고 이제 캐시함 (첫 방문 정상) |
DYNAMIC |
캐시 대상 자체가 아님 (지금 우리 문제) |
BYPASS |
규칙이 명시적으로 캐시 제외 처리 (HEAD 등) |
환경
- CDN/프록시: Cloudflare 무료 플랜 (오렌지 클라우드 프록시 ON)
- 원본: WordPress (PHP) + 한국 리전 서버
- 측정 위치: 해외 엣지에서 GET 요청으로 응답 시간 측정
원인 분석: WordPress의 no-cache 헤더
WordPress는 기본적으로 페이지 응답에 다음과 비슷한 헤더를 실어 보냅니다.
Cache-Control: no-cache, must-revalidate, max-age=0
Cloudflare는 기본 동작상 원본이 보낸 Cache-Control을 존중합니다. 원본이 "캐시하지 마"라고 말하니 Cloudflare도 순순히 캐시하지 않고, 그 결과가 바로 DYNAMIC입니다. 즉 버그가 아니라 설계된 동작입니다. 해결의 핵심은 Cloudflare가 이 헤더를 무시하도록(Override) 강제하는 것입니다.
예전 가이드들이 쓰던 Page Rules의 "Cache Everything"은 2024년 7월부터 신규 무료 플랜 계정에서 사용이 막혔습니다. 지금은 Cache Rules가 표준이고, 무료 플랜에서도 정상 동작합니다. 검색하다 Page Rules 얘기가 나오면 구버전 글이라고 보면 됩니다.
해결: 캐시 규칙 두 개로 강제 캐시
핵심 전략은 "먼저 캐시하면 안 되는 요청을 걸러내고, 나머지는 전부 강제 캐시"입니다. 캐시 규칙(Cache Rules)을 두 개 순서대로 만듭니다.

Cloudflare 대시보드 → 해당 도메인 선택 → 좌측 캐싱(Caching) > 캐시 규칙(Cache Rules) → 규칙 만들기(Create rule). Cloudflare 화면은 한글이라 아래는 한글 메뉴명을 기준으로 적고, 괄호 안에 영문을 병기했습니다.
1단계 — 바이패스 규칙 (캐시 제외 대상 먼저 차단)
규칙 이름은 Bypass cache for admin/logged-in 정도로 정합니다.
① 수신 요청이 일치하는 경우 — 사용자 설정 필터 식(Custom filter expression)을 선택하고, 식 편집(Edit expression)을 눌러 아래 식을 그대로 붙여넣습니다.
(http.request.uri.path wildcard "/wp-admin/*") or
(http.request.uri.path wildcard "/wp-login*") or
(http.request.uri.path wildcard "/wp-json/*") or
(http.request.uri.path wildcard "/xmlrpc.php") or
(http.request.uri.path wildcard "/wp-cron.php") or
(http.request.uri.query contains "preview=true") or
(http.cookie contains "wordpress_logged_in_") or
(http.cookie contains "wp-postpass_") or
(http.cookie contains "comment_author_") or
(http.request.method ne "GET")
② 이 경우... 영역에서:
- 캐시 적합성(Cache eligibility) → 캐시 바이패스(Bypass cache) 선택
이 규칙의 역할은 "관리자·로그인 사용자·미리보기·비밀글·댓글 작성자·POST 요청은 절대 캐시하지 마"입니다. 로그인한 본인이 캐시된 옛 페이지를 보거나, 로그인 쿠키가 다른 사람에게 새어 나가는 사고를 막아줍니다. 배포(Deploy/Save)를 눌러 저장합니다.
2단계 — 강제 캐시 규칙 (공개 페이지)
다시 규칙 만들기 → 이름은 Force cache HTML on public pages.
직관적으로는 2단계를 모든 수신 요청으로 두면 1단계 바이패스가 위에서 먼저 걸러줄 것 같습니다. 하지만 그러면 로그인해도 캐시된 페이지가 보이는 버그가 생깁니다. Cloudflare 캐시 규칙은 예전 페이지 규칙과 달리 "첫 매칭에서 멈춤"이 아니라, 매칭되는 모든 규칙이 겹쳐 적용되고 충돌 시 마지막(아래) 규칙이 이기는(last match wins) 방식이기 때문입니다. 로그인 요청은 1번(바이패스)과 2번(모든 요청) 둘 다 매칭되는데, 아래에 있는 2번 "캐시에 적합"이 1번 바이패스를 덮어써 버립니다. 그래서 2단계는 "모든 요청"이 아니라 로그인 대상을 식에서 빼야(not) 합니다.
① 수신 요청이 일치하는 경우 — 사용자 설정 필터 식(Custom filter expression)을 선택하고, 식 편집을 눌러 아래 식을 붙여넣습니다. 1단계 식 전체를 not ( ... )으로 감싼 형태입니다.
not (
(http.request.uri.path wildcard "/wp-admin/*") or
(http.request.uri.path wildcard "/wp-login*") or
(http.request.uri.path wildcard "/wp-json/*") or
(http.request.uri.path wildcard "/xmlrpc.php") or
(http.request.uri.path wildcard "/wp-cron.php") or
(http.request.uri.query contains "preview=true") or
(http.cookie contains "wordpress_logged_in_") or
(http.cookie contains "wp-postpass_") or
(http.cookie contains "comment_author_") or
(http.request.method ne "GET")
)
이 식의 뜻은 "괄호 안 조건(관리자·로그인·미리보기·비밀글·댓글·비-GET)에 해당하지 않는 요청에만 이 규칙을 적용해라"입니다. 즉 로그인·관리자 요청은 2번 규칙 자체가 매칭되지 않아, "캐시에 적합"으로 덮어쓸 일이 사라집니다.
② 이 경우... 영역에서 아래대로 설정합니다.
| 한글 메뉴 | 선택할 값 |
|---|---|
| 캐시 적합성 (Cache eligibility) | 캐시에 적합 (Eligible for cache) |
| 에지 TTL (Edge TTL) ★핵심 | 캐시 제어 헤더를 무시 및 이 TTL 사용 → TTL 7200(2시간) |
| 브라우저 TTL (Browser TTL) | 원본을 재정의 및 이 TTL 사용 → TTL 14400(4시간) |
| 캐시 속임수 방어 (Cache deception armor) | 켜기(ON) |
| 장치 유형별 캐시 (Cache by device type) | 끄기(OFF, PC/모바일 동일 HTML이면) |
예전 가이드의 "Origin Cache Control: OFF"가 최신 UI에는 별도 토글로 없습니다. 대신 에지 TTL에서 세 번째 옵션인 "캐시 제어 헤더를 무시 및 이 TTL 사용"을 고르는 것이 바로 그 역할입니다. 첫 번째·두 번째 옵션("…캐시 제어 헤더가 있는 경우 사용")을 고르면 WordPress의 no-cache를 그대로 존중해서 DYNAMIC이 계속 뜹니다. 반드시 세 번째를 선택하세요.
나머지 항목(캐시 키, 부실 콘텐츠 제공, 강한 ETag, 원본 오류 페이지 패스스루 등)은 기본값 그대로 두면 됩니다. 배포를 눌러 저장합니다.
TTL 입력칸은 초 단위입니다. 2시간 = 7200초, 4시간 = 14400초. 드롭다운에 "2 hours" 같은 프리셋이 보이면 그걸 골라도 됩니다.
3단계 — 규칙 순서 확인 (위치)
규칙 생성 시 하단 위치(Placement)에서 순서 선택이 가능하고, 목록에서 드래그로도 조정됩니다. 바이패스가 위, 강제 캐시가 아래로 둡니다.
1. Bypass cache for admin/logged-in ← 위 (먼저 평가)
2. Force cache HTML on public pages ← 아래
참고로 2단계에 not(...) 제외식을 제대로 넣었다면 1단계 바이패스 규칙은 사실 없어도 동작합니다(로그인 요청이 2번에 아예 안 걸리므로). 다만 의도를 명시적으로 드러내고 이중 안전장치로 삼기 위해 1번을 그대로 두는 편이 좋습니다.
검증: 반드시 GET으로 테스트할 것 (HEAD 함정 주의)
설정을 다 마쳤는데도 curl -I로 찍어보니 계속 DYNAMIC이 떠서 "룰이 안 먹나?" 하고 한참을 의심했습니다. 원인은 황당하게도 테스트 방식이었습니다. curl -I는 HEAD 요청을 보내는데, 우리 제외식에 http.request.method ne "GET"(GET이 아니면 제외)이 들어 있어서 HEAD 요청은 강제 캐시 규칙에서 빠집니다. 그래서 DYNAMIC이 떴던 것이고, 실제 브라우저가 보내는 GET 요청은 처음부터 정상 작동하고 있었습니다. 검증은 반드시 GET으로 하세요.
적용까지 1~2분 걸립니다. 시크릿 창에서, 또는 아래처럼 GET 요청으로 헤더를 확인합니다.
# -I(HEAD) 말고 이렇게 GET으로! 헤더만 출력
curl -sSL -o /dev/null -D - https://www.example.com/some-page/ | grep -i cf-cache-status
# 첫 방문 → MISS, 같은 URL 재방문 → HIT
# cf-cache-status: MISS
# cf-cache-status: HIT ← 성공
전체 시나리오 점검 결과
공개 페이지는 캐시되고(HIT), 보호 대상은 캐시를 타지 않아야(DYNAMIC) 합니다. GET으로 10가지 시나리오를 점검한 실제 결과입니다.
| # | 시나리오 | 기대값 | 결과 |
|---|---|---|---|
| 1 | 로그아웃 홈페이지 (1차/2차) | MISS → HIT | ✅ |
| 2 | 포스트 페이지 (1차/2차) | MISS → HIT | ✅ |
| 3 | 로그인 쿠키 | DYNAMIC | ✅ |
| 4 | 댓글 작성자 쿠키 | DYNAMIC | ✅ |
| 5 | wp-postpass 쿠키 (비밀글) | DYNAMIC | ✅ |
| 6 | /wp-admin/ | DYNAMIC | ✅ |
| 7 | /wp-login.php | DYNAMIC | ✅ |
| 8 | /wp-json/ | DYNAMIC | ✅ |
| 9 | ?preview=true | DYNAMIC | ✅ |
| 10 | POST 메서드 | DYNAMIC | ✅ |
제외식(not) 방식에서는 로그인·관리자 요청이 강제 캐시 규칙에 아예 매칭되지 않습니다. 그래서 "캐시 규칙이 적용되지 않은 일반 동적 응답" = DYNAMIC으로 표시됩니다. BYPASS는 규칙이 명시적으로 바이패스 처리를 했을 때 뜨는 값이라 표현만 다를 뿐, 둘 다 "캐시 안 탐"으로 결과는 동일하고 보안상 문제 없습니다. 로그인 사용자에게 캐시된 페이지가 노출되지 않으면 성공입니다.
발행하면 옛 글이 보인다? 자동 Purge
Edge TTL 2시간 = 새 글을 올려도 최대 2시간 동안 옛 버전이 보일 수 있습니다. 해결책은 두 가지입니다.
- 옵션 A — Cloudflare 공식 WP 플러그인 (추천): WP 관리자에서 Cloudflare 플러그인 설치 후 API 토큰을 연결하면, 글 발행·수정 시 해당 URL을 자동으로 purge합니다. 추가 비용 0원.
- 옵션 B — 수동 Purge: 발행 후 Cloudflare 대시보드 → Caching → Configuration → Purge by URL. 5초면 되지만 깜빡하기 쉽습니다.
"긴 TTL로 성능은 챙기고, 발행 순간만 콕 집어 비우기" 조합이 정석이라 옵션 A를 권합니다.
적용 전후 비교 (실측)
실제로 페이지 응답 시간을 측정한 결과입니다. 캐시가 적중(HIT)되면 한국 원본까지 왕복하지 않으니 체감이 확 달라집니다.
| 상태 | 응답 시간 | 개선 |
|---|---|---|
| Cold (캐시 미적용) | 348ms | 기준 |
| Warm 홈페이지 (HIT) | 154ms | 약 2.3배 |
| Warm 포스트 (HIT) | 227ms | 약 1.5배 |
그 외 부수 효과도 큽니다. 캐시 HIT 비율이 올라가면 원본 서버로 가는 요청이 줄어(대략 10~20% 수준), 서버 부하와 트래픽 비용이 함께 내려갑니다. GA 측정값에는 영향을 주지 않습니다(Cloudflare 엣지 캐시는 GA 태그 실행과 무관).
보너스로, 갑자기 트래픽이 몰려도(예: 외부 커뮤니티 유입) 원본 서버가 버티기 쉬워집니다. 대부분의 요청을 Cloudflare 엣지가 흡수해주기 때문입니다.
주의사항 두 가지
"오늘의 방문자 수", 실시간 댓글 카운터, 카운트다운 타이머처럼 비로그인 사용자에게 노출되는 동적 위젯이 있으면, 캐시 때문에 옛날 값이 박제될 수 있습니다. 그런 요소는 클라이언트 측 JS로 따로 불러오거나, 해당 경로를 Bypass 식에 추가하세요.
세션 쿠키가 담긴 응답이 캐시되면, 그 페이지를 받은 다른 방문자가 같은 세션을 공유하게 되는 보안 사고로 이어집니다. 위 Bypass 식의 로그인·비밀글·댓글 쿠키 조건이 이 위험을 막는 1차 방어선입니다. WooCommerce 같은 장바구니/결제 페이지를 쓴다면 해당 경로(/cart, /checkout, /my-account 등)도 반드시 Bypass에 추가하세요.
유료로 더 깔끔하게 — APO ($5/월, 선택)
월 5달러 여유가 있다면 Speed → Optimization → Cloudflare APO for WordPress 옵션도 있습니다. APO를 켜면 위 Cache Rules 수동 작업이 대부분 불필요해지고(자동), 발행 시 자동 purge, 그리고 Cloudflare 전 POP에 사전 배포되어 전 세계 캐시 적중률이 올라갑니다. 다만 APO와 수동 Cache-Everything 룰을 동시에 켜면 충돌이 나니, 둘 중 하나만 씁니다. 트래픽이 적을 땐 무료 Cache Rules로 충분하고, 월 1만 PV를 넘기면 그때 APO를 고려하면 됩니다.
DYNAMIC은 버그가 아니라 "원본이 no-cache라고 했으니 Cloudflare가 따른 것"입니다. 해결의 본질은 단 하나 — 에지 TTL에서 "캐시 제어 헤더를 무시 및 이 TTL 사용"을 골라 원본 헤더를 무시하는 것. 단, 강제 캐시 전에 관리자·로그인·쿠키·POST 요청을 바이패스로 먼저 걸러내는 안전장치가 반드시 선행돼야 합니다. "넓게 캐시하고, 위험한 것만 정밀하게 제외한다"가 핵심 원칙입니다.
'Tech > Dev Notes' 카테고리의 다른 글
| 폐쇄망 한국어 RAG 모델 라인업 총정리! V100 vs Mac Mini M4 Pro + 라이선스 함정 (0) | 2026.05.26 |
|---|---|
| oh-my-zsh + starship 터미널 꾸미기 완벽 가이드 (macOS · Linux) (0) | 2026.05.14 |
| 구글 애드센스 세금정보 제출 완벽 가이드 | W-8BEN 작성법과 거주자증명서 발급 한 번에 (1) | 2026.04.18 |
| Python Streamlit - 데이터부터 AI 웹앱까지 한 번에 (0) | 2025.11.11 |
| [Mac] ReactNative 설치하기 (0) | 2025.02.23 |