나는 오랫동안 Ctrl+Shift+R이 브라우저에 저장된 캐시를 삭제하는 줄 알았다.
그런데 아니었다.


발단 — iframe 내부가 바뀌지 않는다

AWS CloudFront CDN을 사용하는 프로젝트에서 iframe을 사용하고 있었다.

배포 후 CDN 캐시 초기화(invalidation)까지 진행했는데, iframe 내부의 페이지는 여전히 이전 버전을 보여주고 있었다. 강력 새로고침을 해봐도 마찬가지였다.

결국 브라우저의 저장 데이터를 전부 삭제하고 다시 켜야만 변경사항이 반영됐다. 이 현상을 파고들다 보니, 브라우저 캐시에 대해 오해하고 있던 부분들이 꽤 있었다.


브라우저 캐시의 구조

캐시는 한 곳에만 있지 않다. 요청이 사용자에게 도달하기까지 여러 레이어를 거친다.

브라우저 → CloudFront (CDN) → S3 (Origin)
   ↑              ↑
브라우저 캐시   CDN 캐시

CDN 캐시를 초기화해도 브라우저 캐시는 별개다. 두 캐시는 독립적으로 동작하기 때문에, CDN invalidation이 브라우저 캐시에 영향을 주지 않는다.


강력 새로고침의 실제 동작

Ctrl+Shift+R (강력 새로고침)은 캐시를 삭제하는 것이 아니다. 실제로 하는 일은 이렇다.

강력 새로고침 실행
        ↓
현재 페이지 요청의 Request Header에 Cache-Control: no-cache 추가
        ↓
"캐시가 있어도 무시하고, 서버에서 새로 받아와라"
        ↓
응답을 받은 뒤 캐시를 덮어씀 (기존 캐시는 그대로 남아있음)

즉, 강력 새로고침 이후에도 캐시 스토리지에는 이전 데이터가 그대로 남아있다. 단지 이번 요청에서만 그것을 무시한 것이다. 반면 브라우저 저장 데이터를 전체 삭제(Ctrl+Shift+Del)하면 캐시 스토리지 자체가 비워진다.

동작 캐시 삭제 여부 서버 재요청
일반 새로고침 (F5) 조건부 (ETag 검증)
강력 새로고침 (Ctrl+Shift+R) ✅ (부모 페이지만)
저장 데이터 전체 삭제 ✅ (모든 리소스)

Request Header vs Response Header의 no-cache

개발자 도구를 보면 Cache-Control: no-cache가 Request Header에 있을 때와 Response Header에 있을 때 의미가 완전히 다르다.

Request Header의 no-cache
  → 브라우저가 서버에게 "캐시 말고 새로 줘"라고 요청하는 것
  → 강력 새로고침, fetch({ cache: 'no-store' }) 등에서 브라우저가 자동으로 붙임

Response Header의 no-cache
  → 서버가 브라우저에게 "이거 저장은 해도 되지만 매번 재검증해라"라고 지시하는 것
  → 실질적인 캐시 정책을 결정하는 쪽

Request에 no-cache가 붙어있어도, Response Header가 캐시를 허용하고 있으면 브라우저는 캐시한다.


Cache-Control: public, max-age=0 이 의미하는 것

Response Header에서 이 조합을 자주 볼 수 있다.

public      → 브라우저, CDN, 프록시 등 모든 캐시 주체가 저장 가능
max-age=0   → 캐시 유효 기간이 0초, 즉 받는 즉시 만료

no-store(저장 금지)와 다르다. 캐시를 저장은 하되, 사용 전 서버에 유효성 검증(ETag)을 요청하는 방식이다.

브라우저 재요청
  → If-None-Match: "abc123" 헤더와 함께 요청
  → 파일 변경 없음 → 304 Not Modified (빠름, 바디 없음)
  → 파일 변경됨   → 200 OK + 새 콘텐츠

의도는 좋은 설정이지만, 배포 후 ETag가 실제로 달라졌는지가 핵심이다. 파일 내용이 동일하면 304가 돌아오고, 브라우저는 기존 캐시를 그대로 사용한다.


왜 강력 새로고침이 iframe에는 적용되지 않는가

강력 새로고침의 no-cache사용자가 직접 요청한 페이지(top-level navigation)에만 적용된다.

강력 새로고침 적용 범위
  ✅ 부모 페이지 document
  ✅ 부모 페이지의 JS, CSS, 이미지 등 서브리소스
  ❌ iframe 내부 document
  ❌ iframe 내부의 서브리소스

그 이유는 브라우저가 iframe을 별도의 Browsing Context로 관리하기 때문이다.

window (Top-level Browsing Context)
  ├── document
  ├── history stack
  └── frames
        └── iframe (Nested Browsing Context)
              ├── document  ← 독립적
              └── history stack  ← 독립적

부모 페이지를 새로고침해도 iframe은 독립된 컨텍스트로 이전 상태를 유지한다. 이것은 버그가 아니라 의도된 성능 최적화다. 매번 새로고침마다 iframe 내부까지 전부 재요청하면 페이지 로딩이 불필요하게 느려지기 때문이다.


iframe 캐시를 무력화하는 방법

브라우저는 캐시를 URL 전체 문자열을 키로 저장한다. 쿼리 파라미터가 서버 동작에 실제로 영향을 주는지는 관계없다.

https://cdn.example.com/page.html?v=1  ← 캐시 키 1
https://cdn.example.com/page.html?v=2  ← 캐시 키 2 (완전히 다른 리소스로 인식)

따라서 iframe의 src URL에 배포 버전을 파라미터로 붙이면, 브라우저는 새 URL로 인식해 서버에 새로 요청한다.

iframe.src = `https://cdn.example.com/page.html?v=${deployVersion}`;

여기서 주의할 점은 값의 종류에 따라 동작이 달라진다.

// 매번 달라지는 값 → 캐시 100% 무력화, 항상 새로 요청 (비효율)
iframe.src = `page.html?v=${Date.now()}`;

// 배포 버전 값 → 배포 전까지는 캐시 사용, 배포 후에만 새로 요청 (효율적)
iframe.src = `page.html?v=${deployVersion}`;

실무 적용 — 버전 기반 캐시 무력화

배포 파이프라인에서 version.json을 생성하고, 페이지 로드 시 서버 버전과 로컬 버전을 비교하는 방식이 가장 균형잡힌 접근이다.

# 빌드 스크립트
echo "{\"version\": \"$(date +%Y%m%d%H%M%S)-$(git rev-parse --short HEAD)\"}" > ./dist/version.json
const VERSION_URL = 'https://cdn.example.com/version.json';
const STORAGE_KEY = 'app_deployed_version';

async function checkAndClearCacheOnDeploy() {
  try {
    const res = await fetch(VERSION_URL, { cache: 'no-store' });
    const { version: remoteVersion } = await res.json();
    const localVersion = localStorage.getItem(STORAGE_KEY);

    if (localVersion !== remoteVersion) {
      localStorage.setItem(STORAGE_KEY, remoteVersion);

      // iframe src에 새 버전 파라미터 주입
      document.querySelectorAll('iframe').forEach(iframe => {
        const baseUrl = iframe.src.split('?')[0];
        iframe.src = `${baseUrl}?v=${remoteVersion}`;
      });
    }
  } catch (e) {
    console.warn('[버전 체크 실패]', e);
  }
}

checkAndClearCacheOnDeploy();

이 방식의 장점은 배포 전까지는 캐시를 최대한 활용하고, 배포 이후 첫 접속 시 딱 한 번만 캐시를 무력화한다는 것이다. version.json은 항상 최신 값을 반환해야 하므로 CloudFront에서 별도로 no-store 설정이 필요하다.


정리

개념 실제 동작
강력 새로고침 캐시 삭제 ❌, 이번 요청만 캐시 무시 ✅
CDN invalidation CDN 캐시만 초기화, 브라우저 캐시는 무관
iframe 새로고침 부모 새로고침과 독립적, 별도 컨텍스트
URL 변경 브라우저가 새 리소스로 인식, 캐시 무력화

캐시는 성능을 위한 도구지만, 동작 원리를 정확히 알지 못하면 디버깅에 많은 시간을 쓰게 된다. 강력 새로고침이 "캐시를 지운다"는 오해 하나가 꽤 오랫동안 잘못된 디버깅 방향으로 이끌었다.

앞으로는 캐시 문제를 만나면 "어느 레이어의 캐시인가"를 먼저 구분하는 것부터 시작하려 한다.