배포하면 CSS가 404: stale-while-revalidate 함정
"모바일에서 사이트가 이상해요."
스크린샷을 받아보니 스타일이 하나도 없었습니다. 글자만 있고 배경도, 여백도, 레이아웃도 없는 상태였습니다.
제 브라우저로 같은 주소를 열었습니다. 멀쩡했습니다.
먼저 제 잘못을 의심했습니다
한 시간 전에 CSS를 고쳐 배포한 참이었습니다. 체크박스 스타일 스무 줄쯤을 globals.css에 넣었는데, 중괄호를 하나 빠뜨렸다면 그 뒤 규칙이 통째로 날아갈 수 있습니다.
세어봤습니다.
전체 { = 2574 } = 2574 차이 0균형은 맞았습니다. 넣은 블록이 미디어 쿼리 안으로 들어갔는지도 확인했는데 최상위였습니다.
제 변경은 아닙니다. 여기서 방향을 틀었습니다.
캐시를 의심했습니다
배포하면 Next.js가 CSS 파일 이름을 내용 해시로 새로 짓습니다. e667f55fe06737fe.css 같은 식입니다. 내용이 바뀌면 이름이 바뀌고, 옛 이름의 파일은 컨테이너에서 사라집니다.
그런데 HTML은 엣지에 캐시돼 있습니다. 헤더를 봤습니다.
cache-control: s-maxage=3600, stale-while-revalidate=31532400뒤 숫자가 31,532,400초 — 365일입니다.
stale-while-revalidate는 "캐시가 만료돼도 일단 옛 사본을 내보내고, 뒤에서 조용히 새것을 받아와라"는 지시입니다. 응답 속도를 위한 좋은 장치입니다. 그런데 이 값이 1년이면 1시간 지난 HTML을 최대 1년까지 그대로 내보낼 수 있다는 뜻이 됩니다.
배포 스크립트를 봤습니다. 캐시를 지우는 단계가 없었습니다.
그림이 그려졌습니다.
1. 배포 → CSS 이름이 바뀌고 옛 파일은 사라짐
2. 어느 POP → 옛 HTML 을 아직 들고 있음
3. 방문자 → 옛 HTML 수신 → 사라진 CSS 요청 → 404
4. 렌더 → 스타일 없음
5. 같은 요청이 → 백그라운드로 새 HTML 을 받아옴
6. 다음 방문자 → 정상5번과 6번이 이 버그를 잡기 어렵게 만듭니다. 깨진 화면을 본 첫 사람이 수리 방아쇠를 당깁니다. 제보를 받고 확인하러 들어가면 이미 고쳐져 있습니다.
"제 화면에서는 되는데요"가 구조적으로 나옵니다.
없는 해시를 직접 요청해봤습니다.
$ curl -o /dev/null -w "%{http_code}" https://.../css/0000000000000000.css
404폴백이 없습니다. 그대로 무스타일입니다.
그런데 한 페이지는 안 고쳐졌습니다
여기서 예상이 빗나갔습니다.
캐시 문제라면 캐시를 우회하면 정상이어야 합니다. 쿼리스트링을 붙여 열었습니다.
/privacy?cachebust=1여전히 깨져 있었습니다.
네트워크 탭을 열었습니다. 요청 32개, 전부 200입니다. CSS도 200이고 크기도 맞습니다. 그런데 화면은 무스타일입니다.
캐시로는 설명이 안 되는 상태였습니다. 문제가 하나가 아니었던 겁니다.
두 번째 문제: 아무도 그 CSS를 부르지 않았다
개인정보 처리방침 페이지는 .legal 클래스를 씁니다. 그 규칙이 어디 있는지 찾았습니다.
$ grep -rn "\.legal" src --include="*.css"
components/landing/landing-overlays.css:979: .legal { max-width: 760px; ... }landing-overlays.css에 있었습니다. 누가 이 파일을 가져오는지 봤습니다.
PrivacyModal.tsx import "./landing/landing-overlays.css";
TermsModal.tsx import "./landing/landing-overlays.css";
ContactModal.tsx import "./landing-overlays.css";
AppStores.tsx import "./landing-overlays.css";전부 모달과 런처 컴포넌트입니다. 독립 페이지인 /privacy는 아무도 이 파일을 가져오지 않습니다.
실제로 그 페이지가 받는 CSS를 뒤져보니 .legal 규칙이 0건이었습니다.
왜 오래 안 보였나
이 부분이 이 버그의 핵심입니다.
랜딩에서 "개인정보 처리방침" 링크를 누르면 팝업으로 열립니다. 그때는 PrivacyModal이 CSS를 함께 가져오니 멀쩡합니다.
깨지는 건 주소로 직접 들어가거나 검색으로 유입될 때입니다.
개발하면서 확인하는 경로가 전자입니다. 링크를 눌러 확인하면 늘 정상이었습니다. 정작 검색으로 처음 들어온 사람은 계속 그 화면을 보고 있었고요.
git 이력에서 원인이 나왔습니다.
bb637dc perf: 초기 렌더 차단 CSS 분리렌더를 막는 CSS를 줄이려고 파일을 쪼갠 커밋입니다. 그전에는 랜딩이 이 CSS를 항상 불러왔기 때문에 독립 페이지에서도 덤으로 스타일이 걸렸습니다. 쪼개면서 그 우연한 연결이 끊겼습니다.
성능을 개선하면서 다른 걸 깨뜨린 건데, 두 페이지가 서로 상관없어 보여서 이어지지 않았습니다.
고친 방법
페이지 쪽
본문 컴포넌트가 자기 스타일을 직접 가져오게 했습니다.
// PrivacyContent.tsx
import "./landing/landing-overlays.css";페이지가 아니라 본문 컴포넌트에 넣은 이유가 있습니다. 이 컴포넌트는 페이지와 모달 양쪽에서 렌더됩니다. 여기 두면 어느 경로로 들어와도 붙고, 나중에 누가 import를 또 정리해도 같은 일이 안 생깁니다.
고친 뒤 확인했습니다.
6f610baa24217ccf.css ← 새로 붙은 것
.legal .legal-back .legal-footer .legal-meta .legal-modal .legal-note
e667f55fe06737fe.css ← 원래 있던 것
.legal 규칙 없음배포 쪽
배포 마지막에 캐시를 비우는 단계를 넣었습니다.
- name: Purge Cloudflare cache
continue-on-error: true
env:
CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
if [ -z "${CF_TOKEN:-}" ]; then
echo "::warning::토큰 미설정 — 캐시를 비우지 않았다"
exit 0
fi
ZONE=$(curl -s -H "Authorization: Bearer $CF_TOKEN" \
"https://api.cloudflare.com/client/v4/zones?name=example.com" \
| sed -n 's/.*"result":\[{"id":"\([a-f0-9]*\)".*/\1/p')
curl -s -X POST -H "Authorization: Bearer $CF_TOKEN" \
-H "Content-Type: application/json" \
--data '{"purge_everything":true}' \
"https://api.cloudflare.com/client/v4/zones/$ZONE/purge_cache"URL을 골라 지우지 않고 통째로 비웁니다. 배포는 어차피 모든 자산의 해시를 바꾸므로 고를 이유가 없습니다.
토큰이 없으면 경고만 남기고 넘어갑니다. 퍼지는 신선도를 앞당기는 장치일 뿐이고, 없어도 시간이 지나면 자연 복구되므로 배포를 막을 이유가 없습니다.
그리고 검증
이쪽이 더 중요합니다.
- name: Verify assets referenced by HTML
run: |
HTML=$(curl -s "https://example.com/?deploycheck=$GITHUB_SHA")
FAIL=0
for A in $(printf '%s' "$HTML" | grep -oE '/_next/static/(css|chunks)/[^"]+\.(css|js)' | sort -u | head -12); do
CODE=$(curl -s -o /dev/null -w '%{http_code}' "https://example.com$A")
[ "$CODE" != "200" ] && { echo "::error::$A → HTTP $CODE"; FAIL=1; }
done
exit $FAILHTML이 가리키는 파일이 실제로 열리는지 봅니다. 이 문제의 유일한 직접 검증입니다.
퍼지 API가 성공을 돌려주는 것과, 방문자가 받는 HTML이 살아 있는 파일을 가리키는 것은 다른 이야기입니다. 앞엣것만 확인하면 다음에 또 어긋났을 때 똑같이 모릅니다.
파싱을 sed로 한 이유
위 스크립트에서 JSON을 sed로 긁는 게 거슬리실 수 있습니다. 처음에는 Python으로 썼습니다.
run: |
echo "$RESP" | python3 -c "
import json,sys
...
"바꾼 이유는 며칠 전 같은 패턴으로 워크플로를 통째로 깨뜨렸기 때문입니다. run: | 블록 안의 Python을 들여쓰기 없이 붙였더니 YAML이 파싱되지 않았고, 워크플로가 실행 자체가 안 됐습니다. 실패한 것도 아니고 아예 안 도는 상태라 한참 몰랐습니다.
몇 줄 아끼자고 셸 스크립트에 다른 언어를 섞을 이유가 없습니다.
남는 이야기
"제 화면에서는 되는데요"가 단서였습니다. 사람마다 다르게 보인다는 건 요청이 서로 다른 경로를 탄다는 뜻이고, 대개 캐시입니다. 재현이 안 된다고 넘겼으면 못 찾았을 겁니다.
증상이 같다고 원인이 하나는 아닙니다. 저는 "스타일이 없다"는 증상 하나를 캐시로 다 설명하려다 시간을 썼습니다. 캐시를 우회했는데도 한 페이지만 안 고쳐진 걸 보고서야 갈렸습니다. 그 테스트를 안 했으면 퍼지만 붙이고 끝냈을 거고, 개인정보 페이지는 계속 깨진 채였을 겁니다.
성능 최적화는 조용히 무언가를 끊습니다. CSS를 쪼갠 커밋 자체는 옳았습니다. 다만 "랜딩이 불러오니까 다른 페이지도 덤으로 스타일이 걸리던" 우연한 의존을 함께 끊었고, 그런 우연에 기대고 있었다는 걸 아무도 몰랐습니다.
stale-while-revalidate를 길게 걸어두셨다면 배포 절차에 퍼지가 있는지 한 번 보시길 권합니다. 없으면 매 배포마다 몇몇 방문자가 무스타일 페이지를 봅니다. 그리고 그 사람들은 아무 말도 안 하고 나갑니다.
함께 읽기
- 캐시 퍼지 순서: 오리진을 먼저 비워야 하는 이유엣지 캐시를 켜고 나니 대가가 하나 생겼습니다. 글을 고쳐 발행해도 최대 5분 동안 옛 내용이 나갔습니다. 캐시가 없던 때는 즉시 반영되던 일이라 후퇴처럼 느껴졌습니다.
- Cloudflare가 캐시하지 않는 3가지 이유: Vary, RSC, 언어 감지사이트를 Cloudflare 뒤에 두고 캐시 헤더를 제대로 붙였는데도 CF-Cache-Status가 계속 DYNAMIC으로 나오는 일을 겪었습니다. 한 번이 아니라 세 번이었고, 매번 다른 문제처럼 보였습니다.
- 한국에서 Cloudflare 엣지가 서울이 아닐 때: 무료 플랜의 POP 배정이미지 엣지 캐시를 손보고 나서 얼마나 빨라졌는지 재고 있었습니다. 캐시에서 나오면 156ms, 오리진까지 가면 622ms. 4배 차이니까 좋아하고 있었는데, 156ms라는 숫자가 자꾸 걸렸습니다.
- 엣지 캐시를 켜기 전 확인할 것: 비공개 응답의 Cache-Control전에 CDN의 파일 경로에 확장자가 없어서 Cloudflare가 캐시하지 않던 문제를 고쳤습니다. 헤더를 아무리 강하게 줘도 CF-Cache-Status가 DYNAMIC으로 나오던 그 이야기입니다.
- 블로그를 서브도메인에서 하위 경로로 옮긴 이유, 그리고 CSP가 애드센스를 막고 있었습니다회사 사이트와 기술 블로그를 따로 운영하는 구성은 흔합니다. 회사는 duolabs.co.kr, 블로그는 blog.duolabs.co.kr.