Cache-Control 없는 정적 페이지: iframe 안에서만 옛 화면이 뜬 이유
백업 현황 페이지에 칸을 하나 더 그려 올렸는데, 새 탭에서는 새 화면이 뜨고 랜딩 페이지의 데모 모달 안에서는 전날 화면이 떴습니다. 같은 주소, 같은 서버, 같은 파일인데 화면이 둘이었습니다. 원인은 응답 헤더 하나가 처음부터 빠져 있던 데 있었고, 그 헤더를 찾다가 같은 자리에서 빠져 있던 보안 헤더들도 같이 찾았습니다.
프레임워크 없이 굳힌 HTML 한 장
ark.duolabs.co.kr은 앱이 아닙니다. 매일 새벽 백업이 끝나면 스크립트가 결과를 읽어 HTML 한 장을 다시 만들고, 서울 리전의 작은 VPS로 보냅니다. 거기서 Caddy가 그 파일을 그대로 내보냅니다. 요청마다 달라질 것이 없는 화면이라 런타임을 두지 않았고, 이 결정은 지금도 맞다고 생각합니다.
그날은 이 페이지 위쪽에 최근 30일 백업을 하루 한 칸으로 그리는 작업을 올렸습니다. 새 탭으로 열어 보니 칸이 잘 보였습니다. 그런데 랜딩 페이지 푸터에서 같은 페이지를 모달로 띄우면 칸이 없었고, 상단의 기준 시각이 2026-09-11 04:11이었습니다. 새 탭은 2026-09-12 00:10이었습니다.
서버는 이미 새 파일을 주던 상황
처음에는 모달이 다른 주소를 여는지부터 봤습니다. 모달은 랜딩의 다크·라이트 톤을 넘기려고 주소 뒤에 테마를 붙입니다.
url + (url.includes("?") ? "&" : "?") + "theme=" + tone그 주소 그대로 서버에 물어보면 새 파일이 옵니다. 문제는 오는 파일이 아니라 같이 오는 헤더에 있었습니다.
$ curl -sI 'https://ark.duolabs.co.kr?theme=light'
etag: "dlcl0047kinpnmx"
last-modified: Fri, 11 Sep 2026 15:10:40 GMT
strict-transport-security: max-age=31536000; includeSubDomainsCache-Control이 없습니다. Expires도 없습니다.
헤더가 없으면 브라우저가 정하는 유효 기간
캐시 지시가 없는 응답을 브라우저는 "저장하지 말라"로 읽지 않습니다. 반대입니다. HTTP 캐시 명세 RFC 9111 §4.2.2는 이런 응답에 대해 캐시가 유효 기간을 스스로 추정해도 된다고 적고, Last-Modified 이후 흐른 시간의 10%를 흔한 값으로 예로 듭니다. 크롬(Chromium)도 이 비율을 씁니다.
이 페이지에 대입해 보면 이렇습니다. 옛 파일은 9월 11일 04:11에 만들어졌습니다. 브라우저가 그 파일을 스무 시간쯤 뒤에 받았다면, 그때부터 두 시간 동안은 서버에 묻지 않고 저장해 둔 사본을 씁니다. 모달이 04:11 사본을 보여 줬다는 것까지는 확인했지만, 브라우저가 그 사본을 언제 받았는지는 모릅니다. 두 시간은 그 가정으로 계산한 값입니다.
로고나 폰트 파일에는 합리적인 기본값입니다. 같은 주소의 내용이 매일 바뀌는 상태 페이지에는 틀린 값입니다.
?theme= 가 만든 별도의 캐시 항목
그러면 새 탭은 왜 멀쩡했을까요. 브라우저 캐시는 주소 단위로 저장합니다. 새 탭에서 연 https://ark.duolabs.co.kr/와 모달이 연 https://ark.duolabs.co.kr/?theme=light는 서버에게는 같은 파일이지만 브라우저에게는 다른 항목입니다. 두 항목은 따로 받고 따로 늙습니다. 새 탭 쪽은 새 파일을 받은 뒤였고, 모달 쪽은 그 전에 받아 둔 사본이 추정 기한 안에 있었습니다.
테마가 둘이니 모달 쪽 항목만 둘이고, 새 탭까지 치면 셋입니다. 셋이 서로 다른 시각의 화면을 들고 있을 수 있습니다.
no-cache를 붙인 뒤에도 남아 있던 옛 화면
고치는 것은 한 줄이었습니다.
handle {
header Cache-Control "no-cache"
file_server
}no-cache는 이름과 달리 "저장하지 말라"가 아닙니다. 저장은 하되 쓰기 전에 매번 서버에 확인하라는 뜻입니다. 이 페이지에는 ETag가 있어서, 파일이 안 바뀌었으면 서버가 본문 없이 304로 답하고 끝납니다. 저장 자체를 막는 no-store까지 갈 이유는 없었습니다. Vercel에 올린 상태 페이지는 public, max-age=0, must-revalidate를 보내는데, 이 페이지에서 둘이 다르게 동작할 일은 없습니다.
배포하고 응답에 cache-control: no-cache가 찍히는 것까지 확인했습니다. 그런데 모달은 여전히 전날 화면이었습니다.
여기서 한 번 더 헷갈렸습니다. 새 헤더는 새로 받는 응답에만 붙습니다. 브라우저가 이미 저장해 둔 사본은 헤더 없이 받은 것이고, 그 사본의 추정 기한은 받는 순간 정해졌습니다. 기한이 끝나기 전에는 브라우저가 서버에 묻지 않으니, 서버를 고친 것을 알 방법이 없습니다.
그 사본을 지금 비우려면 모달이 여는 주소를 직접 열고 새로고침하면 됩니다. https://ark.duolabs.co.kr/?theme=light를 새 탭에서 열어 새로고침하면 같은 캐시 항목이 새것으로 바뀌고, 모달도 그 항목을 씁니다. 개발자도구의 "캐시 비우기 및 강력 새로고침"도 됩니다. 랜딩에서 그냥 강력 새로고침만 하는 것으로 풀리는지는 확인하지 못했습니다. 모달의 iframe은 페이지가 다 뜬 뒤에 만들어지는데, 그 요청까지 강력 새로고침이 미치는지는 제가 확신이 없습니다. 기다려도 됩니다. 추정 기한이 끝나면 저절로 풀리고, 그 뒤로는 매번 서버에 묻습니다.
같은 자리에서 빠져 있던 보안 헤더
헤더를 보다가 하나 더 알게 됐습니다. 이 페이지가 보내는 헤더는 HSTS 하나뿐이었습니다. 같은 VPS 뒤에 있는 다른 앱들은 Next.js 설정에서 보안 헤더를 붙이고 있었고, 이 페이지는 Caddy가 파일을 직접 내서 아무도 붙이지 않고 있었습니다. 파비콘 요청도 404였습니다.
| 헤더 | Next.js로 만든 다른 앱 | 이 페이지 (전) | 이 페이지 (후) |
|---|---|---|---|
Cache-Control |
앱마다 다름 | 없음 | no-cache |
Content-Security-Policy |
있음 | 없음 | default-src 'none'에서 필요한 것만 |
X-Content-Type-Options |
nosniff |
없음 | nosniff |
Referrer-Policy |
strict-origin-when-cross-origin |
없음 | 같음 |
Permissions-Policy |
카메라·마이크·위치 등 차단 | 없음 | 같음 |
Strict-Transport-Security |
있음 | 있음 | 있음 |
CSP는 다른 앱들보다 좁게 잡았습니다. 이 페이지는 밖에서 불러오는 것이 하나도 없습니다. 스크립트와 스타일은 전부 HTML 안에 있고, 아이콘은 인라인 SVG입니다. 그래서 전부 막아 두고 필요한 것만 엽니다.
default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:;
base-uri 'none'; form-action 'none';
frame-ancestors 'self' https://duolabs.co.kr https://*.duolabs.co.kr인라인 스크립트는 해시로 허용하는 편이 더 좁습니다. 그러지 않은 이유는 해시는 웹 서버 설정에 적히고 스크립트는 HTML을 만드는 쪽에 있기 때문입니다. 스크립트를 한 줄 고칠 때마다 두 곳을 같이 고쳐야 하고, 한 곳을 빠뜨리면 에러 화면 없이 스크립트만 조용히 멈춥니다. 다른 앱들도 'unsafe-inline'을 쓰고 있어서 거기에 맞췄습니다.
frame-ancestors는 처음 문제의 그 모달 때문에 필요합니다. 클릭재킹을 막겠다고 X-Frame-Options: DENY를 붙이면 랜딩의 모달이 빈 화면이 됩니다. 품어도 되는 출처를 frame-ancestors에 적으면 나머지는 막히고 모달은 삽니다. 헤더를 붙인 채 로컬에서 띄워 봤을 때 CSP 위반은 0건이었고 스크립트도 정상으로 돌았습니다.
파비콘은 파일로 두지 않고 data: URI로 HTML 머리에 넣었습니다. 이 서버는 /와 /index.html 말고는 전부 404를 내게 해 두었는데, 아이콘 하나 때문에 그 문을 넓히고 싶지 않았습니다.
파일을 직접 내면 아무도 대신 붙여 주지 않는 헤더
요청마다 달라지지 않는 것은 정적으로 내는 게 맞다는 생각은 그대로입니다. 이번에 그 선택의 값을 하나 알았습니다. 프레임워크를 쓰면 설정 파일 한 곳에 적어 둔 헤더가 모든 응답에 붙습니다. 파일을 웹 서버가 직접 내면 그 한 곳이 사라지고, 캐시 정책도 보안 헤더도 제가 적지 않는 한 아무것도 붙지 않습니다.
아쉬운 것은 이틀 전에 이미 한 번 이 자리를 지나갔다는 점입니다. 여러 앱 중 이 페이지만 HSTS가 없다는 것을 보고 웹 서버에서 일괄로 넣었는데, 그때 나머지 헤더는 보지 않았습니다. 그날 헤더 목록을 다른 앱과 한 줄씩 대 봤다면 모달의 전날 화면은 없었을 겁니다.
함께 읽기
- 웹서비스 외부 백업: S3에 올린 사본이 같이 지워지지 않게 하는 방법매일 밤 서버에서 이런 명령이 돈다고 해 보겠습니다.
- 모니터링 서비스 비교: Better Stack부터 Datadog까지 7곳Better Stack 첫 화면에는 표가 하나 있습니다. 한 달에 트레이스·로그·지표를 각각 1TB씩 보낼 때 Datadog은 약 $55,574, Better Stack은 $687이라고 적혀 있습니다. 표 아래 각주는 이 숫자가 추정치이며 연 결제, 유럽 데이터 위치, 응답자 1명, Tera 번들, 평균 이벤트 크기 1…
- 사이트맵이 6일째 멈춘 이유: revalidateTag 가 닿지 않는 자리글을 두 편 발행하고 사이트맵을 열어 봤습니다. 둘 다 없었습니다.
- Cloudflare 존 이전과 R2: 커스텀 도메인이 따라오지 않는 이유이미지가 하나도 안 나온다는 말을 듣고 열어 봤습니다. 이미지 원본을 서빙하는 호스트 세 개가 DNS 에서 통째로 사라져 있었습니다. dig 가 아무것도 돌려주지 않았습니다.
- Vercel DB 통합: 비밀번호를 몰라도 되는 이유하루에 관리형 Postgres 두 개를 배포에 붙였습니다. 하나는 비밀번호를 재설정하고 연결 문자열을 손으로 옮겨 적었고, 다른 하나는 비밀번호를 끝내 보지 않았습니다. 후자가 더 빨랐고, 무엇보다 틀릴 자리가 없었습니다.